"""Scrape web tutorial sources and save as plain-text files in data/web/. Uses requests + BeautifulSoup to extract readable text from each URL defined as a tutorial/course source in CANONICAL_TEXT_SOURCES. Skips files that already exist — safe to re-run. Requires: pip install beautifulsoup4 (added to requirements/pyproject.toml separately) Usage: uv run python scripts/fetch_web_sources.py """ from __future__ import annotations import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from rich.console import Console from rich.table import Table from researchpath.corpus import CANONICAL_TEXT_SOURCES console = Console() ROOT = Path(__file__).resolve().parents[1] WEB_DIR = ROOT / "data" / "web" WEB_DIR.mkdir(parents=True, exist_ok=True) HEADERS = { "User-Agent": ( "Mozilla/5.0 (compatible; ResearchPath/1.0; educational corpus builder)" ) } TIMEOUT = 30 def _fetch_text(url: str) -> str: """Fetch URL and return cleaned plain text via BeautifulSoup.""" import requests from bs4 import BeautifulSoup resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) resp.raise_for_status() # Force UTF-8 — prevents ¶ artifacts from latin-1 mis-detection resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") # Remove nav, footer, header, scripts, style blocks for tag in soup.find_all(["nav", "footer", "header", "script", "style", "aside"]): tag.decompose() # Prefer main content areas in priority order for selector in ["article", "main", '[role="main"]', ".content", "#content", "body"]: container = soup.select_one(selector) if container: break if container is None: container = soup lines = [] for element in container.find_all(["h1", "h2", "h3", "h4", "p", "li", "pre", "code"]): text = element.get_text(" ", strip=True) if text: lines.append(text) return "\n\n".join(lines) def main() -> int: try: import requests # noqa: F401 from bs4 import BeautifulSoup # noqa: F401 except ImportError: console.print( "[red]Missing dependencies. Run:[/red]\n" " uv add beautifulsoup4 requests" ) return 1 web_sources = [ s for s in CANONICAL_TEXT_SOURCES if s.filename.endswith(".txt") ] table = Table(title="Web scrape results") table.add_column("Source ID", style="bold") table.add_column("Status") table.add_column("Detail") ok = 0 for source in web_sources: dest = WEB_DIR / source.filename if dest.exists(): chars = dest.stat().st_size table.add_row(source.source_id, "[green]OK[/green]", f"already exists ({chars:,} chars)") ok += 1 continue try: text = _fetch_text(source.url) dest.write_text(text, encoding="utf-8") table.add_row(source.source_id, "[green]OK[/green]", f"scraped {len(text):,} chars") ok += 1 except Exception as e: table.add_row(source.source_id, "[red]FAIL[/red]", str(e)[:80]) time.sleep(2) # polite crawl delay console.print(table) console.print( f"\n[bold]{'[green]' if ok == len(web_sources) else '[yellow]'}" f"{ok}/{len(web_sources)} web sources saved to data/web/[/bold]" ) return 0 if ok > 0 else 1 if __name__ == "__main__": sys.exit(main())