Skip to content

Commit 7992c82

Browse files
committed
Curate scraped HTML for vector-DB ingest: tighten URL filter to stop scope-bleed into sister projects, save an empty stub when a configured selector misses, strip embedded TOC/nav/footer/svg/script/copyright cruft from inside extracted content, and reassign ~20 scrape entries to the right scraper class for their site's theme.
1 parent 3add946 commit 7992c82

2 files changed

Lines changed: 128 additions & 32 deletions

File tree

core/constants.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,11 +1599,13 @@
15991599
},
16001600
"cffi": {
16011601
"URL": "https://cffi.readthedocs.io/en/stable/",
1602-
"folder": "cffi"
1602+
"folder": "cffi",
1603+
"scraper_class": "DivClassDocumentScraper"
16031604
},
16041605
"chardet": {
16051606
"URL": "https://chardet.readthedocs.io/en/stable/",
1606-
"folder": "chardet"
1607+
"folder": "chardet",
1608+
"scraper_class": "FuroThemeScraper"
16071609
},
16081610
"charset-normalizer": {
16091611
"URL": "https://charset-normalizer.readthedocs.io/en/stable/",
@@ -1612,11 +1614,13 @@
16121614
},
16131615
"click": {
16141616
"URL": "https://click.palletsprojects.com/en/stable/",
1615-
"folder": "click"
1617+
"folder": "click",
1618+
"scraper_class": "BodyRoleMainScraper"
16161619
},
16171620
"coloredlogs": {
16181621
"URL": "https://coloredlogs.readthedocs.io/en/latest/",
1619-
"folder": "coloredlogs"
1622+
"folder": "coloredlogs",
1623+
"scraper_class": "BodyRoleMainScraper"
16201624
},
16211625
"contourpy": {
16221626
"URL": "https://contourpy.readthedocs.io/en/stable/",
@@ -1626,16 +1630,17 @@
16261630
"cryptography": {
16271631
"URL": "https://cryptography.io/en/stable/",
16281632
"folder": "cryptography",
1629-
"scraper_class": "ReadthedocsScraper"
1633+
"scraper_class": "DivClassDocumentScraper"
16301634
},
16311635
"CTranslate2": {
16321636
"URL": "https://opennmt.net/CTranslate2/",
16331637
"folder": "ctranslate2",
1634-
"scraper_class": "RstContentScraper"
1638+
"scraper_class": "DivClassDocumentScraper"
16351639
},
16361640
"cycler": {
16371641
"URL": "https://matplotlib.org/cycler/",
1638-
"folder": "cycler"
1642+
"folder": "cycler",
1643+
"scraper_class": "BodyRoleMainScraper"
16391644
},
16401645
"dataclasses-json": {
16411646
"URL": "https://lidatong.github.io/dataclasses-json/",
@@ -1649,11 +1654,13 @@
16491654
},
16501655
"deepdiff 8.6.1": {
16511656
"URL": "https://zepworks.com/deepdiff/8.6.1/",
1652-
"folder": "deepdiff_861"
1657+
"folder": "deepdiff_861",
1658+
"scraper_class": "BodyRoleMainScraper"
16531659
},
16541660
"Deprecated": {
16551661
"URL": "https://deprecated.readthedocs.io/en/latest/",
1656-
"folder": "deprecated"
1662+
"folder": "deprecated",
1663+
"scraper_class": "BodyRoleMainScraper"
16571664
},
16581665
"deprecation": {
16591666
"URL": "https://deprecation.readthedocs.io/en/latest/",
@@ -1672,7 +1679,8 @@
16721679
},
16731680
"distro": {
16741681
"URL": "https://distro.readthedocs.io/en/stable/",
1675-
"folder": "distro"
1682+
"folder": "distro",
1683+
"scraper_class": "BodyRoleMainScraper"
16761684
},
16771685
"einops": {
16781686
"URL": "https://einops.rocks/",
@@ -1697,7 +1705,7 @@
16971705
"fonttools": {
16981706
"URL": "https://fonttools.readthedocs.io/en/stable/",
16991707
"folder": "fonttools",
1700-
"scraper_class": "RstContentScraper"
1708+
"scraper_class": "DivClassDocumentScraper"
17011709
},
17021710
"fsspec": {
17031711
"URL": "https://filesystem-spec.readthedocs.io/en/stable/",
@@ -1764,11 +1772,6 @@
17641772
"folder": "kiwisolver",
17651773
"scraper_class": "ReadthedocsScraper"
17661774
},
1767-
"Langchain": {
1768-
"URL": "https://python.langchain.com/api_reference/",
1769-
"folder": "langchain",
1770-
"scraper_class": "PydataThemeScraper"
1771-
},
17721775
"llvmlite": {
17731776
"URL": "https://llvmlite.readthedocs.io/en/stable/",
17741777
"folder": "llvmlite",
@@ -1782,7 +1785,7 @@
17821785
"markdown-it-py": {
17831786
"URL": "https://markdown-it-py.readthedocs.io/en/stable/",
17841787
"folder": "markdown_it_py",
1785-
"scraper_class": "MainIdMainContentRoleMainScraper"
1788+
"scraper_class": "PydataThemeScraper"
17861789
},
17871790
"markupsafe": {
17881791
"URL": "https://markupsafe.palletsprojects.com/en/stable/",
@@ -1887,7 +1890,7 @@
18871890
"pi-heif": {
18881891
"URL": "https://pillow-heif.readthedocs.io/en/latest/",
18891892
"folder": "piheif",
1890-
"scraper_class": "RstContentScraper"
1893+
"scraper_class": "DivClassDocumentScraper"
18911894
},
18921895
"pikepdf": {
18931896
"URL": "https://pikepdf.readthedocs.io/en/stable/",
@@ -1952,6 +1955,7 @@
19521955
"PyOpenGL": {
19531956
"URL": "https://mcfletch.github.io/pyopengl/documentation/manual/",
19541957
"folder": "pyopengl",
1958+
"scraper_class": "MainScraper"
19551959
},
19561960
"PyPDF": {
19571961
"URL": "https://pypdf.readthedocs.io/en/stable/",
@@ -1966,7 +1970,7 @@
19661970
"python-dateutil": {
19671971
"URL": "https://dateutil.readthedocs.io/en/stable/",
19681972
"folder": "python_dateutil",
1969-
"scraper_class": "RstContentScraper"
1973+
"scraper_class": "DivClassDocumentScraper"
19701974
},
19711975
"python-dotenv": {
19721976
"URL": "https://saurabh-kumar.com/python-dotenv/",
@@ -2016,7 +2020,7 @@
20162020
"Rich": {
20172021
"URL": "https://rich.readthedocs.io/en/stable/",
20182022
"folder": "rich",
2019-
"scraper_class": "RstContentScraper"
2023+
"scraper_class": "DivClassDocumentScraper"
20202024
},
20212025
"rpds-py": {
20222026
"URL": "https://rpds.readthedocs.io/en/stable/",
@@ -2035,7 +2039,8 @@
20352039
},
20362040
"scikit-learn": {
20372041
"URL": "https://scikit-learn.org/stable/",
2038-
"folder": "scikit_learn"
2042+
"folder": "scikit_learn",
2043+
"scraper_class": "PydataThemeScraper"
20392044
},
20402045
"SciPy 1.16.2": {
20412046
"URL": "https://docs.scipy.org/doc/scipy-1.16.2/",
@@ -2050,17 +2055,17 @@
20502055
"Six": {
20512056
"URL": "https://six.readthedocs.io/",
20522057
"folder": "six",
2053-
"scraper_class": "RstContentScraper"
2058+
"scraper_class": "DivClassDocumentScraper"
20542059
},
20552060
"sniffio": {
20562061
"URL": "https://sniffio.readthedocs.io/en/stable/",
20572062
"folder": "sniffio",
2058-
"scraper_class": "RstContentScraper"
2063+
"scraper_class": "DivClassDocumentScraper"
20592064
},
20602065
"SoundFile 0.13.1": {
20612066
"URL": "https://python-soundfile.readthedocs.io/en/0.13.1/",
20622067
"folder": "soundfile_0131",
2063-
"scraper_class": "RstContentScraper"
2068+
"scraper_class": "DivClassDocumentScraper"
20642069
},
20652070
"sounddevice 0.5.3": {
20662071
"URL": "https://python-sounddevice.readthedocs.io/en/0.5.3/",
@@ -2075,11 +2080,12 @@
20752080
"SpeechBrain (latest)": {
20762081
"URL": "https://speechbrain.readthedocs.io/en/latest/",
20772082
"folder": "speechbrain_latest",
2078-
"scraper_class": "RstContentScraper"
2083+
"scraper_class": "DivClassDocumentScraper"
20792084
},
20802085
"SQLAlchemy 20": {
20812086
"URL": "https://docs.sqlalchemy.org/en/20/",
2082-
"folder": "sqlalchemy_20"
2087+
"folder": "sqlalchemy_20",
2088+
"scraper_class": "BodyRoleMainScraper"
20832089
},
20842090
"sympy": {
20852091
"URL": "https://docs.sympy.org/latest/",
@@ -2089,12 +2095,12 @@
20892095
"tenacity": {
20902096
"URL": "https://tenacity.readthedocs.io/en/stable/",
20912097
"folder": "tenacity",
2092-
"scraper_class": "RstContentScraper"
2098+
"scraper_class": "DivClassDocumentScraper"
20932099
},
20942100
"Tile DB": {
20952101
"URL": "https://tiledb-inc-tiledb.readthedocs-hosted.com/projects/tiledb-py/en/stable/",
20962102
"folder": "tiledb",
2097-
"scraper_class": "RstContentScraper"
2103+
"scraper_class": "DivClassDocumentScraper"
20982104
},
20992105
"tiledb-vector-search": {
21002106
"URL": "https://tiledb-inc.github.io/TileDB-Vector-Search/documentation/",
@@ -2104,6 +2110,7 @@
21042110
"tiledb-cloud": {
21052111
"URL": "https://tiledb-inc.github.io/TileDB-Cloud-Py/",
21062112
"folder": "tiledb_cloud",
2113+
"scraper_class": "FastcoreScraper"
21072114
},
21082115
"Timm 1.0.20": {
21092116
"URL": "https://huggingface.co/docs/timm/v1.0.20/en/",
@@ -2172,6 +2179,7 @@
21722179
"webencodings": {
21732180
"URL": "https://pythonhosted.org/webencodings/",
21742181
"folder": "webencodings",
2182+
"scraper_class": "BodyRoleMainScraper"
21752183
},
21762184
"Wrapt": {
21772185
"URL": "https://wrapt.readthedocs.io/en/master/",
@@ -2181,7 +2189,7 @@
21812189
"xlrd": {
21822190
"URL": "https://xlrd.readthedocs.io/en/stable/",
21832191
"folder": "xlrd",
2184-
"scraper_class": "RstContentScraper"
2192+
"scraper_class": "DivClassDocumentScraper"
21852193
},
21862194
"yarl": {
21872195
"URL": "https://yarl.aio-libs.org/en/stable/",

modules/scraper.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import re
23
import asyncio
34
import aiofiles
45
from bs4 import BeautifulSoup
@@ -12,6 +13,85 @@
1213
from core.constants import PROJECT_ROOT
1314

1415

16+
_VERSION_SUFFIX_RE = re.compile(r"^v?\d+(\.\d+)*$")
17+
18+
# Cruft commonly embedded INSIDE the main article element by Sphinx-style themes.
19+
# Stripped post-extraction so the saved HTML is closer to "vector-DB-ready" content.
20+
_CRUFT_TAGS = ("script", "style", "nav", "footer", "svg")
21+
_CRUFT_CLASSES = (
22+
"toctree-wrapper", # Sphinx project TOC tree, often dumped at bottom of index pages
23+
"related", # Sphinx prev/next bar
24+
"sphinxsidebar", # classic Sphinx sidebar, when scoped inside the content
25+
"footer", # Sphinx classic theme div.footer (copyright/build info)
26+
"edit-this-page", # MkDocs Material "Edit this page"
27+
"md-source-file", # MkDocs Material source-file metadata
28+
"prev-next-area", # Pydata-theme prev/next nav
29+
"prev-next-bottom",
30+
"prev-next-top",
31+
"bd-toc", # Pydata "On this page" sidebar
32+
"bd-sidebar-secondary",
33+
"feedback-widget", # Pydantic.dev "Was this page helpful?"
34+
"pagination-links", # Pydantic.dev / generic prev-next pagination
35+
"footer-version", # PyMuPDF "This documentation covers all versions..."
36+
"try_examples_button_container", # SciPy "Try it in your browser! / Open in Tab"
37+
"try_examples_outer_iframe", # SciPy interactive-examples sandbox iframe
38+
)
39+
_CRUFT_IDS = (
40+
"indices-and-tables", # Sphinx auto-generated bottom-of-index "Index/ModIndex/Search" stub
41+
"search",
42+
"footerDisclaimer", # PyMuPDF "This software is provided AS-IS..."
43+
)
44+
45+
46+
def _strip_trailing_version(path: str) -> str:
47+
"""Strip a trailing '-vX.Y.Z' / '-1.2.3' from a URL path.
48+
49+
Used by is_valid_url so that, e.g., a seed of /foo-v1.2/ still matches /foo-v1.3/.
50+
Does NOT strip non-version suffixes (so /array-api-compat does not become /array-api).
51+
"""
52+
parts = path.rsplit("-", 1)
53+
if len(parts) == 2 and _VERSION_SUFFIX_RE.match(parts[1]):
54+
return parts[0]
55+
return path
56+
57+
58+
_CRUFT_HEADINGS = {"copyright", "copyrights", "license", "licenses"}
59+
60+
61+
def _strip_embedded_cruft(content):
62+
"""Remove TOC trees, nav, scripts, etc. that sit INSIDE the extracted main element."""
63+
for tag_name in _CRUFT_TAGS:
64+
for el in content.find_all(tag_name):
65+
el.decompose()
66+
for cls in _CRUFT_CLASSES:
67+
for el in content.find_all(class_=cls):
68+
el.decompose()
69+
for cruft_id in _CRUFT_IDS:
70+
for el in content.find_all(id=cruft_id):
71+
el.decompose()
72+
# Strip <section> whose first heading is a boilerplate heading like "Copyright" or "License".
73+
for section in content.find_all("section"):
74+
h = section.find(["h1", "h2", "h3", "h4", "h5", "h6"])
75+
if h and h.get_text(strip=True).lower() in _CRUFT_HEADINGS:
76+
section.decompose()
77+
# Strip <p> elements whose only meaningful content is Prev/Next pagination anchors
78+
# (e.g. ruamel.yaml's "<p><a>Prev</a> <a>Next</a></p>" with no class/id to target).
79+
_NAV_LABELS = {"prev", "previous", "next"}
80+
for p in content.find_all("p"):
81+
anchors = p.find_all("a")
82+
if not anchors:
83+
continue
84+
anchor_text = " ".join(a.get_text(strip=True).lower() for a in anchors).split()
85+
if anchor_text and all(w in _NAV_LABELS for w in anchor_text):
86+
# Confirm there's no extra non-anchor text in the paragraph.
87+
non_anchor_text = "".join(
88+
str(c) for c in p.contents if getattr(c, "name", None) != "a"
89+
).strip()
90+
if not non_anchor_text:
91+
p.decompose()
92+
return content
93+
94+
1595
class BaseScraper:
1696
def __init__(self, url, folder):
1797
self.url = url
@@ -25,10 +105,18 @@ def __init__(self, url, folder):
25105
def process_html(self, soup):
26106
main_content = self.extract_main_content(soup)
27107
if main_content:
108+
cleaned = _strip_embedded_cruft(deepcopy(main_content))
28109
new_soup = BeautifulSoup("<html><body></body></html>", "lxml")
29-
new_soup.body.append(deepcopy(main_content))
110+
new_soup.body.append(cleaned)
30111
return new_soup
31-
return soup
112+
# Fallback differs based on whether the user configured a scraper_class:
113+
# - BaseScraper directly (no scraper_class set) => preserve full page
114+
# - any subclass (selector configured but missed) => save empty stub
115+
# so a misconfigured selector is visible as a tiny file instead of
116+
# silently saving the full untrimmed page with TOC/nav cruft.
117+
if type(self) is BaseScraper:
118+
return soup
119+
return BeautifulSoup("<html><body></body></html>", "lxml")
32120

33121
def extract_main_content(self, soup):
34122
return None
@@ -383,7 +471,7 @@ def strip_www(netloc: str) -> str:
383471
return False
384472

385473
if acceptable_domain_extension:
386-
base_no_version = acceptable_domain_extension.rsplit('-', 1)[0]
474+
base_no_version = _strip_trailing_version(acceptable_domain_extension)
387475
return (
388476
parsed.path.startswith(acceptable_domain_extension) or
389477
parsed.path.startswith(base_no_version)

0 commit comments

Comments
 (0)