Files
learningcircuit--local-deep…/tests/performance/content_fetcher/test_extraction_benchmark.py
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

612 lines
21 KiB
Python

"""Benchmark tests for HTML content extraction pipeline.
Tests extraction quality across 200+ real-world pages from diverse domains.
Skipped in CI (requires network). Run manually with:
pytest tests/research_library/downloaders/test_extraction_benchmark.py -v -s
Results are printed as a comparison table with content length, boilerplate
count, and timing for each downloader mode.
"""
import time
from dataclasses import dataclass
from typing import Dict, List, Tuple
import pytest
from local_deep_research.content_fetcher import ContentFetcher
from local_deep_research.research_library.downloaders.html import HTMLDownloader
from local_deep_research.research_library.downloaders.playwright_html import (
AutoHTMLDownloader,
)
# Skip entire module in CI — these hit the network and need a browser.
# `integration` is the marker CI excludes via `-m 'not integration'`; `slow` is
# kept so the tests can be selected/deselected independently when run locally.
pytestmark = [pytest.mark.slow, pytest.mark.integration]
BOILERPLATE_KEYWORDS = [
"cookie",
"sign up",
"newsletter",
"skip to content",
"subscribe",
"accept all",
"privacy policy",
"terms of service",
"log in",
"sign in",
"add to cart",
"checkout",
"wishlist",
]
# ---------------------------------------------------------------------------
# URL corpus — 200+ pages across 17 categories
# Each entry: (label, url)
# Grouped by expected fetch behaviour: static-friendly vs JS-heavy
# ---------------------------------------------------------------------------
NEWS = [
("BBC front", "https://www.bbc.com/news"),
("Reuters", "https://www.reuters.com/"),
("NYTimes", "https://www.nytimes.com/"),
("Guardian", "https://www.theguardian.com/international"),
("CNN", "https://www.cnn.com/"),
("AP News", "https://apnews.com/"),
("Al Jazeera", "https://www.aljazeera.com/"),
("DW", "https://www.dw.com/en/"),
("France24", "https://www.france24.com/en/"),
("NBC News", "https://www.nbcnews.com/"),
("USA Today", "https://www.usatoday.com/"),
("Forbes", "https://www.forbes.com/"),
("Bloomberg", "https://www.bloomberg.com/"),
("Politico", "https://www.politico.com/"),
("HuffPost", "https://www.huffpost.com/"),
("ABC News", "https://abcnews.go.com/"),
("Time", "https://time.com/"),
("NPR", "https://www.npr.org/"),
("BBC Tech", "https://www.bbc.com/news/technology"),
("Ars Technica", "https://arstechnica.com/"),
]
TECH = [
("GitHub README", "https://github.com/AmiGandhi/WordPredict"),
("GH LDR", "https://github.com/LearningCircuit/local-deep-research"),
(
"GH Issue",
"https://github.com/LearningCircuit/local-deep-research/issues/1",
),
(
"StackOverflow",
"https://stackoverflow.com/questions/231767/what-does-the-yield-keyword-do-in-python",
),
("SO tagged", "https://stackoverflow.com/questions/tagged/python"),
("HackerNews", "https://news.ycombinator.com/item?id=40956541"),
("HN front", "https://news.ycombinator.com/"),
("TechCrunch", "https://techcrunch.com/"),
("Wired", "https://www.wired.com/"),
("The Verge", "https://www.theverge.com/"),
("ZDNet", "https://www.zdnet.com/"),
("Slashdot", "https://slashdot.org/"),
("Dev.to", "https://dev.to/"),
("Lobsters", "https://lobste.rs/"),
("InfoQ", "https://www.infoq.com/"),
]
REFERENCE = [
("Wiki EN", "https://en.wikipedia.org/wiki/Python_(programming_language)"),
("Wiki JA", "https://ja.wikipedia.org/wiki/Python"),
("Wiki DE", "https://de.wikipedia.org/wiki/Python_(Programmiersprache)"),
("Wiki FR", "https://fr.wikipedia.org/wiki/Intelligence_artificielle"),
("Wiki ZH", "https://zh.wikipedia.org/wiki/Python"),
(
"Wiki AR",
"https://ar.wikipedia.org/wiki/%D8%A8%D8%A7%D9%8A%D8%AB%D9%88%D9%86",
),
("Wiki ES", "https://es.wikipedia.org/wiki/Python"),
(
"Britannica",
"https://www.britannica.com/technology/artificial-intelligence",
),
("M-W Dict", "https://www.merriam-webster.com/dictionary/algorithm"),
("W3Schools", "https://www.w3schools.com/python/python_lists.asp"),
]
DOCS = [
("Python docs", "https://docs.python.org/3/library/asyncio.html"),
(
"MDN Docs",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions",
),
(
"ReadTheDocs",
"https://requests.readthedocs.io/en/latest/user/quickstart/",
),
(
"Rust Book",
"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html",
),
("Go Docs", "https://go.dev/doc/effective_go"),
("Django Docs", "https://docs.djangoproject.com/en/5.0/topics/http/views/"),
("React Docs", "https://react.dev/learn"),
("Vue Docs", "https://vuejs.org/guide/introduction.html"),
(
"MS Learn",
"https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/",
),
("Kubernetes", "https://kubernetes.io/docs/concepts/overview/"),
]
ACADEMIC = [
("ArXiv", "https://arxiv.org/abs/2301.07507"),
("ArXiv ICL", "https://arxiv.org/abs/2301.00234"),
("ArXiv LLM", "https://arxiv.org/abs/2303.08774"),
("ArXiv HTML", "https://arxiv.org/html/2301.07507"),
("PubMed", "https://pubmed.ncbi.nlm.nih.gov/37828879/"),
("PubMed CRISPR", "https://pubmed.ncbi.nlm.nih.gov/26553966/"),
("PubMed COVID", "https://pubmed.ncbi.nlm.nih.gov/32015507/"),
("PMC", "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7095418/"),
(
"SemScholar",
"https://www.semanticscholar.org/paper/Attention-Is-All-You-Need-Vaswani-Shazeer/204e3073870fae3d05bcbc2f6a8e263d9b72e776",
),
(
"SemScholar2",
"https://www.semanticscholar.org/paper/BERT%3A-Pre-training-of-Deep-Bidirectional-Devlin-Chang/df2b0e26d0599ce3e70df8a9da02e51594e0e992",
),
("OpenAlex", "https://openalex.org/works/W2741809807"),
("OpenAlex2", "https://openalex.org/works/W2963403868"),
("Nature", "https://www.nature.com/articles/s41586-024-07487-w"),
("Science", "https://www.science.org/"),
("ResearchGate", "https://www.researchgate.net/"),
("Springer", "https://link.springer.com/"),
("IEEE", "https://www.ieee.org/"),
("JSTOR", "https://www.jstor.org/"),
("ACM DL", "https://dl.acm.org/"),
("bioRxiv", "https://www.biorxiv.org/content/10.1101/2024.01.01.573841v1"),
]
GOVERNMENT = [
("WhiteHouse", "https://www.whitehouse.gov/"),
("NASA", "https://www.nasa.gov/"),
("NIH", "https://www.nih.gov/"),
("CDC", "https://www.cdc.gov/"),
("Europa EU", "https://europa.eu/"),
("WHO", "https://www.who.int/"),
("UN", "https://www.un.org/en/"),
("GOV UK", "https://www.gov.uk/"),
("USA.gov", "https://www.usa.gov/"),
("Data.gov", "https://data.gov/"),
]
EDUCATION = [
("MIT", "https://www.mit.edu/"),
("Stanford", "https://www.stanford.edu/"),
("Harvard", "https://www.harvard.edu/"),
("Berkeley", "https://www.berkeley.edu/"),
("Cornell", "https://www.cornell.edu/"),
("Oxford", "https://www.ox.ac.uk/"),
("Cambridge", "https://www.cam.ac.uk/"),
("Coursera", "https://www.coursera.org/"),
("Khan Academy", "https://www.khanacademy.org/"),
("edX", "https://www.edx.org/"),
]
SHOPPING = [
("Amazon", "https://www.amazon.com/"),
("eBay", "https://www.ebay.com/"),
("Walmart", "https://www.walmart.com/"),
("Target", "https://www.target.com/"),
("Etsy", "https://www.etsy.com/"),
("BestBuy", "https://www.bestbuy.com/"),
("Newegg", "https://www.newegg.com/"),
("IKEA", "https://www.ikea.com/"),
("HomeDepot", "https://www.homedepot.com/"),
("AliExpress", "https://www.aliexpress.com/"),
("Temu", "https://www.temu.com/"),
("Idealo", "https://www.idealo.de/"),
]
SOCIAL = [
("Reddit", "https://www.reddit.com/r/Python/"),
("Reddit old", "https://old.reddit.com/r/Python/"),
("LinkedIn", "https://www.linkedin.com/"),
("Pinterest", "https://www.pinterest.com/"),
("Quora", "https://www.quora.com/"),
("Tumblr", "https://www.tumblr.com/"),
]
ENTERTAINMENT = [
("YouTube", "https://www.youtube.com/"),
("IMDb", "https://www.imdb.com/title/tt0111161/"),
("Rotten Tom", "https://www.rottentomatoes.com/"),
("Spotify", "https://open.spotify.com/"),
("Goodreads", "https://www.goodreads.com/"),
("Letterboxd", "https://letterboxd.com/"),
]
FINANCE = [
("Yahoo Fin", "https://finance.yahoo.com/"),
("Investing", "https://www.investing.com/"),
("MarketWatch", "https://www.marketwatch.com/"),
("SeekingAlpha", "https://seekingalpha.com/"),
("CoinGecko", "https://www.coingecko.com/"),
("Investopedia", "https://www.investopedia.com/"),
]
PACKAGES = [
("PyPI justext", "https://pypi.org/project/justext/"),
("PyPI trafila", "https://pypi.org/project/trafilatura/"),
("npm readab", "https://www.npmjs.com/package/readability"),
("npm express", "https://www.npmjs.com/package/express"),
("crates serde", "https://crates.io/crates/serde"),
("RubyGems rails", "https://rubygems.org/gems/rails"),
("Docker Hub", "https://hub.docker.com/"),
]
INTERNATIONAL = [
("Baidu", "https://www.baidu.com/"),
("Yandex", "https://yandex.ru/"),
("Naver", "https://www.naver.com/"),
("Rakuten", "https://www.rakuten.co.jp/"),
("MercadoLibre", "https://www.mercadolibre.com/"),
("Allegro PL", "https://allegro.pl/"),
("Bol NL", "https://www.bol.com/"),
("Lemonde FR", "https://www.lemonde.fr/"),
("Spiegel DE", "https://www.spiegel.de/"),
("Corriere IT", "https://www.corriere.it/"),
]
BLOGS = [
(
"Medium",
"https://medium.com/@natassha6789/if-i-had-to-start-learning-data-science-again-how-would-i-do-it-78a02b1b56d2",
),
("Substack", "https://substack.com/"),
("S. Willison", "https://simonwillison.net/2024/Mar/8/gpt-4-barrier/"),
("WordPress", "https://wordpress.com/"),
("Ghost", "https://ghost.org/"),
("Hashnode", "https://hashnode.com/"),
]
HEALTH = [
("WebMD", "https://www.webmd.com/"),
("Mayo Clinic", "https://www.mayoclinic.org/"),
("Healthline", "https://www.healthline.com/"),
("MedNews", "https://www.medicalnewstoday.com/"),
("Cleveland", "https://my.clevelandclinic.org/"),
]
TRAVEL = [
("Booking", "https://www.booking.com/"),
("TripAdvisor", "https://www.tripadvisor.com/"),
("Airbnb", "https://www.airbnb.com/"),
("Expedia", "https://www.expedia.com/"),
("Lonely Planet", "https://www.lonelyplanet.com/"),
]
FOOD = [
("AllRecipes", "https://www.allrecipes.com/"),
("Epicurious", "https://www.epicurious.com/"),
("SeriousEats", "https://www.seriouseats.com/"),
("Bon Appetit", "https://www.bonappetit.com/"),
("Food Network", "https://www.foodnetwork.com/"),
]
MISC = [
("Craigslist", "https://www.craigslist.org/"),
("Archive.org", "https://archive.org/"),
("Wayback", "https://web.archive.org/"),
("Pastebin", "https://pastebin.com/"),
("Regex101", "https://regex101.com/"),
]
# Combine all categories
ALL_CATEGORIES: Dict[str, List[Tuple[str, str]]] = {
"news": NEWS,
"tech": TECH,
"reference": REFERENCE,
"docs": DOCS,
"academic": ACADEMIC,
"government": GOVERNMENT,
"education": EDUCATION,
"shopping": SHOPPING,
"social": SOCIAL,
"entertainment": ENTERTAINMENT,
"finance": FINANCE,
"packages": PACKAGES,
"international": INTERNATIONAL,
"blogs": BLOGS,
"health": HEALTH,
"travel": TRAVEL,
"food": FOOD,
"misc": MISC,
}
# Flat list of all pages
ALL_PAGES = []
for cat, pages in ALL_CATEGORIES.items():
for label, url in pages:
ALL_PAGES.append((cat, label, url))
# Pages expected to work with static fetch (no JS rendering needed)
STATIC_PAGES = []
for cat, label, url in ALL_PAGES:
if cat not in ("shopping", "social", "entertainment", "travel"):
STATIC_PAGES.append((label, url))
@dataclass
class FetchResult:
"""Result of a single fetch attempt."""
category: str = ""
page: str = ""
mode: str = ""
length: int = 0
boilerplate: int = 0
time_s: float = 0.0
sample: str = ""
def _count_boilerplate(data: bytes | None) -> int:
if not data:
return 0
text = data.decode("utf-8", errors="replace").lower()
return sum(1 for kw in BOILERPLATE_KEYWORDS if kw in text)
def _get_sample(data: bytes | None, n: int = 80) -> str:
if not data:
return "(no content)"
text = data.decode("utf-8", errors="replace")
idx = text.find("\n\n")
start = text[idx + 2 :] if idx > 0 else text
return start[:n].replace("\n", " ").strip()
def _run_downloader(
dl, category: str, name: str, url: str, mode: str
) -> FetchResult:
t0 = time.time()
try:
data = dl.download(url)
except Exception:
data = None
elapsed = time.time() - t0
return FetchResult(
category=category,
page=name,
mode=mode,
length=len(data) if data else 0,
boilerplate=_count_boilerplate(data),
time_s=round(elapsed, 2),
sample=_get_sample(data),
)
def _print_category_summary(results: list[FetchResult], mode: str):
"""Print per-category success rates."""
from collections import defaultdict
by_cat: Dict[str, list[FetchResult]] = defaultdict(list)
for r in results:
if r.mode == mode:
by_cat[r.category].append(r)
print(f"\n Per-category breakdown ({mode}):")
for cat in ALL_CATEGORIES:
cat_results = by_cat.get(cat, [])
if not cat_results:
continue
success = sum(1 for r in cat_results if r.length > 100)
total = len(cat_results)
avg_len = sum(r.length for r in cat_results if r.length > 100) // max(
success, 1
)
avg_bp = sum(r.boilerplate for r in cat_results) / max(total, 1)
bar = "█" * success + "░" * (total - success)
print(
f" {cat:<15} {bar} {success}/{total} "
f"avg_len={avg_len:>6} avg_bp={avg_bp:.1f}"
)
class TestExtractionBenchmark:
"""Benchmark extraction quality across downloader modes.
Tests 200+ real-world pages from 17 categories.
Not purely assertions-based — prints a comparison table for human review.
"""
@pytest.fixture(autouse=True)
def _setup(self):
self.static_dl = HTMLDownloader(timeout=20)
# Benchmark exercises the JS-rendering fallback path explicitly,
# so opt in regardless of the disable-by-default ctor.
self.auto_dl = AutoHTMLDownloader(timeout=20, enable_js_rendering=True)
yield
self.static_dl.close()
self.auto_dl.close()
def test_static_pages_return_content(self):
"""Static downloader should extract content from most static pages."""
failures = []
for name, url in STATIC_PAGES[:30]: # Test first 30 for speed
result = _run_downloader(self.static_dl, "", name, url, "static")
if result.length < 100:
failures.append(f"{name}: {result.length} chars")
# Allow up to 20% failure rate (bot protection, geo-blocks, etc.)
max_failures = len(STATIC_PAGES[:30]) * 0.2
assert len(failures) <= max_failures, (
f"Static downloader failed on too many pages "
f"({len(failures)}/{len(STATIC_PAGES[:30])}): "
f"{', '.join(failures[:10])}"
)
def test_full_benchmark(self):
"""Full benchmark across all pages with Auto downloader.
Prints detailed results table grouped by category.
"""
results: list[FetchResult] = []
total = len(ALL_PAGES)
print(f"\n{'=' * 90}")
print(
f" EXTRACTION BENCHMARK: {total} pages across "
f"{len(ALL_CATEGORIES)} categories"
)
print(f"{'=' * 90}")
for i, (cat, name, url) in enumerate(ALL_PAGES):
r = _run_downloader(self.auto_dl, cat, name, url, "Auto")
results.append(r)
# Progress indicator
status = "✓" if r.length > 100 else "✗"
print(
f" [{i + 1:>3}/{total}] {status} {cat:<14} "
f"{name:<16} {r.length:>7} chars "
f"{r.time_s:>5.1f}s bp={r.boilerplate}"
)
# Summary table by category
print(f"\n{'=' * 90}")
print(" SUMMARY")
print(f"{'=' * 90}")
total_success = sum(1 for r in results if r.length > 100)
total_chars = sum(r.length for r in results)
total_bp = sum(r.boilerplate for r in results)
avg_time = sum(r.time_s for r in results) / len(results)
print(
f"\n Overall: {total_success}/{total} pages extracted "
f"({total_success / total * 100:.0f}%)"
)
print(f" Total chars: {total_chars:,}")
print(f" Total boilerplate hits: {total_bp}")
print(f" Avg time per page: {avg_time:.1f}s")
_print_category_summary(results, "Auto")
# Show failures
failures = [r for r in results if r.length <= 100]
if failures:
print(f"\n Failed pages ({len(failures)}):")
for r in failures:
print(
f" {r.category:<14} {r.page:<16} "
f"{r.length} chars {r.sample[:60]}"
)
# Show high-boilerplate pages (potential quality issues)
high_bp = [r for r in results if r.boilerplate >= 3 and r.length > 100]
if high_bp:
print(f"\n High boilerplate pages ({len(high_bp)}):")
for r in sorted(high_bp, key=lambda x: -x.boilerplate):
print(
f" {r.category:<14} {r.page:<16} "
f"bp={r.boilerplate} {r.length} chars"
)
# Soft assertion: at least 60% of pages should extract content
# (many sites have bot protection, geo-blocks, paywalls)
assert total_success >= total * 0.6, (
f"Too many failures: {total_success}/{total} "
f"({total_success / total * 100:.0f}%) — expected at least 60%"
)
# URLs that ContentFetcher should route to specialized downloaders
CONTENT_FETCHER_URLS = [
("ArXiv abs", "https://arxiv.org/abs/2301.07507", "arXiv"),
("ArXiv GPT-4", "https://arxiv.org/abs/2303.08774", "arXiv"),
("PubMed", "https://pubmed.ncbi.nlm.nih.gov/37828879/", "PubMed"),
("PubMed CRISPR", "https://pubmed.ncbi.nlm.nih.gov/26553966/", "PubMed"),
("PubMed COVID", "https://pubmed.ncbi.nlm.nih.gov/32015507/", "PubMed"),
("PMC", "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7095418/", "PMC"),
(
"SemScholar",
"https://www.semanticscholar.org/paper/Attention-Is-All-You-Need-Vaswani-Shazeer/204e3073870fae3d05bcbc2f6a8e263d9b72e776",
"Semantic Scholar",
),
("OpenAlex", "https://openalex.org/works/W2741809807", "OpenAlex"),
(
"bioRxiv",
"https://www.biorxiv.org/content/10.1101/2024.01.01.573841v1",
"bioRxiv",
),
(
"HTML page",
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"Web Page",
),
]
class TestContentFetcherRouting:
"""Test that ContentFetcher routes academic URLs to specialized downloaders.
Verifies the new fetch_batch path used by FullSearchResults actually
works end-to-end with real URLs.
"""
def test_academic_urls_via_content_fetcher(self):
"""ContentFetcher.fetch_batch returns content for academic URLs."""
urls = [url for _, url, _ in CONTENT_FETCHER_URLS]
with ContentFetcher(timeout=30) as fetcher:
results = fetcher.fetch_batch(urls)
print(f"\n{'=' * 90}")
print(" CONTENT FETCHER ROUTING BENCHMARK")
print(f"{'=' * 90}")
failures = []
for label, url, expected_source in CONTENT_FETCHER_URLS:
content = results.get(url)
length = len(content) if content else 0
sample = (
content[:80].replace("\n", " ") if content else "(no content)"
)
# Check URL classification
info = fetcher.get_url_info(url)
detected = info["source_name"]
status = "✓" if length > 50 else "✗"
match = "✓" if detected == expected_source else "✗"
print(
f" {status} {label:<16} "
f"route={match} {detected:<18} "
f"{length:>7} chars {sample[:50]}"
)
if length <= 50:
failures.append(f"{label} ({detected}): {length} chars")
print(
f"\n Results: {len(CONTENT_FETCHER_URLS) - len(failures)}"
f"/{len(CONTENT_FETCHER_URLS)} URLs returned content"
)
if failures:
print(f" Failures: {', '.join(failures)}")
# At least 60% should work (some may be rate-limited or paywalled)
max_failures = len(CONTENT_FETCHER_URLS) * 0.4
assert len(failures) <= max_failures, (
f"ContentFetcher failed on too many URLs: "
f"{len(failures)}/{len(CONTENT_FETCHER_URLS)}: "
f"{', '.join(failures)}"
)