diff --git a/.env.template b/.env.template
index e8084ca7..349aff0f 100644
--- a/.env.template
+++ b/.env.template
@@ -18,33 +18,7 @@ METACULUS_API_BASE_URL=https://www.metaculus.com/api
HUGGINGFACE_API_KEY=
# As of Jun 10 2025, used for browser use agents.
-# Also a fallback capture backend for the source archive (see below).
HYPERBROWSER_API_KEY=
-# --- Source archive (agents_and_tools/source_archive) -----------------------
-# Capture HTML + screenshot + markdown for every URL a bot cites. All optional;
-# blank WEB_ARCHIVE_S3_BUCKET stores locally instead of S3.
-WEB_ARCHIVE_S3_BUCKET=
-WEB_ARCHIVE_S3_PREFIX=source-archive
-WEB_ARCHIVE_AWS_PROFILE=
-# Set to a local capture directory to run/view the archive with no S3 (the
-# viewer reads from here when set). E.g. `capture --local ./archive`.
-WEB_ARCHIVE_LOCAL_DIR=
-WEB_ARCHIVE_TTL_DAYS=14
-# Managed fallback backends for the anti-bot / PDF tail behind self-hosted
-# Playwright. FIRECRAWL also parses PDFs natively (OCR fallback for PdfFetcher).
-FIRECRAWL_API_KEY=
-# Firecrawl proxy mode for hardened anti-bot sites: basic (1 credit) | auto |
-# stealth/enhanced (5 credits). Leave "basic" unless you need Cloudflare bypass.
-WEB_ARCHIVE_FIRECRAWL_PROXY=basic
-# Hyperbrowser session knobs (proxy turns a 1-credit scrape into 10 credits).
-WEB_ARCHIVE_HYPERBROWSER_PROXY=true
-WEB_ARCHIVE_HYPERBROWSER_STEALTH=true
-WEB_ARCHIVE_HYPERBROWSER_CAPTCHA=true
-# CloakBrowser (self-hosted anti-bot Playwright fork) module, if installed
-# (`pip install cloakbrowser`). Exposes cloakbrowser.launch() -> Browser.
-WEB_ARCHIVE_CLOAKBROWSER_IMPORT=cloakbrowser
-WEB_ARCHIVE_PDF_MAX_PAGES=50
-
# Disable if in Streamlit Cloud
FILE_WRITING_ALLOWED=TRUE
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/conftest.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/conftest.py
deleted file mode 100644
index ff07b829..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/conftest.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-
-class FakeFetcher:
- """Returns canned CaptureResults by URL; raises FetchError for missing ones."""
-
- name = "fake"
-
- def __init__(self) -> None:
- self.responses: dict[str, CaptureResult] = {}
- self.calls: list[str] = []
-
- def add(
- self,
- url: str,
- *,
- html: str | None = None,
- markdown: str | None = None,
- status_code: int = 200,
- screenshot: bytes | None = b"\x89PNG fake",
- ) -> None:
- body = (
- html
- if html is not None
- else "
" + "content " * 80 + ""
- )
- self.responses[url] = CaptureResult(
- url=url,
- final_url=url,
- status_code=status_code,
- html=body,
- markdown=markdown if markdown is not None else "content " * 80,
- screenshot=screenshot,
- screenshot_content_type="image/png",
- fetcher=self.name,
- )
-
- def fetch(self, url: str) -> CaptureResult:
- self.calls.append(url)
- if url not in self.responses:
- raise FetchError(f"no canned response for {url}")
- return self.responses[url]
-
-
-@pytest.fixture
-def make_fetcher():
- """Factory so a test can spin up one or several independent fake fetchers."""
-
- def _factory(name: str = "fake") -> FakeFetcher:
- f = FakeFetcher()
- f.name = name
- return f
-
- return _factory
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_backends.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_backends.py
deleted file mode 100644
index 53f0a6ac..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_backends.py
+++ /dev/null
@@ -1,266 +0,0 @@
-"""Unit tests for the backup capture backends and the bake-off pricing model.
-
-These mock the vendor SDKs so they run without API keys, network, browsers, or
-the optional pymupdf/playwright/cloakbrowser packages installed.
-"""
-
-from __future__ import annotations
-
-import base64
-
-import pytest
-
-from forecasting_tools.agents_and_tools.source_archive import benchmark as B
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers import (
- build_default_fetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.fetchers.cloakbrowser_fetcher import (
- CloakBrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.firecrawl_fetcher import (
- FirecrawlFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.hyperbrowser_fetcher import (
- HyperbrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.pdf_fetcher import (
- PdfFetcher,
- looks_like_pdf,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-
-# --- Firecrawl proxy/stealth wiring ------------------------------------------
-def test_firecrawl_basic_sends_no_proxy_key():
- f = FirecrawlFetcher(ArchiveConfig(firecrawl_proxy="basic"))
- assert "proxy" not in f._scrape_kwargs(["markdown"])
-
-
-@pytest.mark.parametrize("mode", ["auto", "stealth", "enhanced"])
-def test_firecrawl_stealth_sends_proxy_key(mode):
- f = FirecrawlFetcher(ArchiveConfig(firecrawl_proxy=mode))
- assert f._scrape_kwargs(["markdown"])["proxy"] == mode
-
-
-def test_firecrawl_fetch_pdf_markdown():
- class FakeClient:
- def scrape(self, url, **kwargs):
- assert kwargs["formats"] == ["markdown"]
- return {"markdown": "# PDF body " + "x " * 200}
-
- f = FirecrawlFetcher(ArchiveConfig(firecrawl_api_key="k"), client=FakeClient())
- assert f.fetch_pdf_markdown("https://x/y.pdf").startswith("# PDF body")
-
-
-# --- Hyperbrowser screenshot coercion + result mapping -----------------------
-def test_hyperbrowser_coerce_screenshot_data_uri():
- raw = b"\x89PNG fake"
- uri = "data:image/png;base64," + base64.b64encode(raw).decode()
- shot, ctype = HyperbrowserFetcher._coerce_screenshot(uri)
- assert shot == raw and ctype == "image/png"
-
-
-def test_hyperbrowser_coerce_screenshot_bare_base64():
- raw = b"\x89PNG fake"
- shot, ctype = HyperbrowserFetcher._coerce_screenshot(base64.b64encode(raw).decode())
- assert shot == raw and ctype == "image/png"
-
-
-def test_hyperbrowser_coerce_screenshot_none():
- assert HyperbrowserFetcher._coerce_screenshot(None) == (None, None)
-
-
-def test_hyperbrowser_fetch_maps_result(monkeypatch):
- class Data:
- metadata = {"statusCode": 200, "title": "T", "sourceURL": "https://final"}
- html = "ok"
- markdown = "ok " * 100
- screenshot = None
-
- class Resp:
- status = "completed"
- error = None
- data = Data()
-
- class FakeClient:
- class scrape:
- @staticmethod
- def start_and_wait(params):
- return Resp()
-
- f = HyperbrowserFetcher(
- ArchiveConfig(hyperbrowser_api_key="k"), client=FakeClient()
- )
- # Avoid constructing real SDK request models in the unit test.
- monkeypatch.setattr(f, "_params", lambda url: None)
- result = f.fetch("https://x")
- assert result.fetcher == "hyperbrowser"
- assert result.final_url == "https://final"
- assert result.status_code == 200
- assert result.metadata["used_proxy"] is True
-
-
-def test_hyperbrowser_failed_job_raises(monkeypatch):
- class Resp:
- status = "failed"
- error = "blocked"
- data = None
-
- class FakeClient:
- class scrape:
- @staticmethod
- def start_and_wait(params):
- return Resp()
-
- f = HyperbrowserFetcher(
- ArchiveConfig(hyperbrowser_api_key="k"), client=FakeClient()
- )
- monkeypatch.setattr(f, "_params", lambda url: None)
- with pytest.raises(FetchError):
- f.fetch("https://x")
-
-
-def test_hyperbrowser_requires_key():
- with pytest.raises(FetchError):
- HyperbrowserFetcher(ArchiveConfig(hyperbrowser_api_key=None)).fetch("https://x")
-
-
-# --- PDF fetcher -------------------------------------------------------------
-def test_looks_like_pdf():
- assert looks_like_pdf("https://x/report.pdf")
- assert looks_like_pdf("https://x/report.PDF?v=2")
- assert not looks_like_pdf("https://x/report.html")
-
-
-def test_pdf_rejects_non_pdf_bytes():
- f = PdfFetcher(
- ArchiveConfig(),
- downloader=lambda url, t: (b"not a pdf", url, 200),
- )
- with pytest.raises(FetchError):
- f.fetch("https://x/fake.pdf")
-
-
-def test_pdf_falls_back_to_firecrawl_when_local_thin(monkeypatch):
- class FakeFirecrawl:
- def fetch_pdf_markdown(self, url):
- return "# Scanned doc recovered by OCR " + "y " * 200
-
- f = PdfFetcher(
- ArchiveConfig(),
- firecrawl=FakeFirecrawl(),
- downloader=lambda url, t: (b"%PDF- minimal", url, 200),
- )
- # Force the local parser to look thin regardless of whether pymupdf is present.
- monkeypatch.setattr(f, "_parse_local", lambda data: (None, None, None, 3, "none"))
- result = f.fetch("https://x/scan.pdf")
- assert result.metadata["pdf_engine"] == "firecrawl"
- assert "OCR" in result.markdown
-
-
-def test_pdf_uses_local_when_text_is_rich(monkeypatch):
- f = PdfFetcher(
- ArchiveConfig(),
- downloader=lambda url, t: (b"%PDF- minimal", url, 200),
- )
- rich = "# Title\n" + "real body text " * 100
- monkeypatch.setattr(
- f, "_parse_local", lambda data: (rich, b"png", "image/png", 5, "pymupdf4llm")
- )
- result = f.fetch("https://x/clean.pdf")
- assert result.metadata["pdf_engine"] == "pymupdf4llm"
- assert result.metadata["pdf_pages"] == 5
- assert result.screenshot == b"png"
-
-
-# --- CloakBrowser ------------------------------------------------------------
-def test_cloakbrowser_missing_package_gives_clear_error(monkeypatch):
- # Force every import to fail so this passes whether or not cloakbrowser is
- # actually installed in the test environment.
- import forecasting_tools.agents_and_tools.source_archive.fetchers.cloakbrowser_fetcher as cb
-
- def _boom(name):
- raise ImportError(name)
-
- monkeypatch.setattr(cb.importlib, "import_module", _boom)
- f = CloakBrowserFetcher(ArchiveConfig())
- with pytest.raises(FetchError) as exc:
- f._launch_browser()
- assert "cloakbrowser" in str(exc.value).lower()
-
-
-# --- Pricing model -----------------------------------------------------------
-def test_pricing_self_host_is_floor():
- r = CaptureResult(url="u", final_url="u")
- assert B.estimate_cost("playwright", r, 1_000_000, B.Pricing()) == 0.00001
- assert B.estimate_cost("cloakbrowser", r, 1_000_000, B.Pricing()) == 0.00001
-
-
-def test_pricing_firecrawl_basic_vs_stealth():
- basic = CaptureResult(url="u", final_url="u", metadata={"firecrawl_proxy": "basic"})
- stealth = CaptureResult(
- url="u", final_url="u", metadata={"firecrawl_proxy": "auto"}
- )
- assert B.estimate_cost("firecrawl", basic, 0, B.Pricing()) == pytest.approx(0.00083)
- assert B.estimate_cost(
- "firecrawl-stealth", stealth, 0, B.Pricing()
- ) == pytest.approx(0.00415)
-
-
-def test_pricing_hyperbrowser_proxy_includes_bandwidth():
- r = CaptureResult(url="u", final_url="u", metadata={"used_proxy": True})
- # 10 credits ($0.01) + 1MB * $10/GB ($0.01) = $0.02
- assert B.estimate_cost("hyperbrowser", r, 1_000_000, B.Pricing()) == pytest.approx(
- 0.02
- )
-
-
-def test_pricing_pdf_local_is_free_firecrawl_is_per_page():
- local = CaptureResult(
- url="u", final_url="u", metadata={"pdf_engine": "pymupdf4llm"}
- )
- ocr = CaptureResult(
- url="u", final_url="u", metadata={"pdf_engine": "firecrawl", "pdf_pages": 10}
- )
- assert B.estimate_cost("pdf", local, 0, B.Pricing()) == 0.0
- assert B.estimate_cost("pdf", ocr, 0, B.Pricing()) == pytest.approx(0.0083)
-
-
-# --- Default tiered chain composition ----------------------------------------
-def _fake_browser():
- from unittest.mock import MagicMock
-
- return None, MagicMock() # (playwright_handle, browser) — browser.close() ok
-
-
-def test_default_chain_cloakbrowser_is_primary(monkeypatch):
- # CloakBrowser available -> it is the single self-hosted browser tier.
- monkeypatch.setattr(
- CloakBrowserFetcher, "_launch_browser", lambda self: _fake_browser()
- )
- config = ArchiveConfig(hyperbrowser_api_key="h", firecrawl_api_key="f")
- with build_default_fetcher(config) as fetcher:
- names = [b.name for b in fetcher._tiered.backends]
- # Note: exactly one browser tier (cloakbrowser), not vanilla + cloak.
- assert names == ["cloakbrowser", "pdf", "firecrawl", "hyperbrowser"]
-
-
-def test_default_chain_falls_back_to_playwright_and_skips_unkeyed(monkeypatch):
- from forecasting_tools.agents_and_tools.source_archive.fetchers.playwright_fetcher import (
- PlaywrightFetcher,
- )
-
- # CloakBrowser not installed -> vanilla Playwright is the browser tier.
- def raise_unavailable(self):
- raise FetchError("cloakbrowser not installed")
-
- monkeypatch.setattr(CloakBrowserFetcher, "_launch_browser", raise_unavailable)
- monkeypatch.setattr(
- PlaywrightFetcher, "_launch_browser", lambda self: _fake_browser()
- )
- config = ArchiveConfig(hyperbrowser_api_key=None, firecrawl_api_key=None)
- with build_default_fetcher(config) as fetcher:
- names = [b.name for b in fetcher._tiered.backends]
- assert names == ["playwright", "pdf"]
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_canonicalize.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_canonicalize.py
deleted file mode 100644
index e45405c0..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_canonicalize.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import url_hash
-
-# (raw, expected canonical) — each pair documents one normalization rule.
-CASES = [
- # fragment dropped
- ("https://a.test/x#section", "https://a.test/x"),
- # trailing slash dropped (non-root)
- ("https://a.test/x/", "https://a.test/x"),
- # root path collapses (with or without slash) to host only
- ("https://a.test/", "https://a.test"),
- ("https://a.test", "https://a.test"),
- # scheme + host lowercased, path case preserved
- ("HTTPS://A.TEST/Path", "https://a.test/Path"),
- # default ports stripped, non-default kept
- ("http://a.test:80/x", "http://a.test/x"),
- ("https://a.test:443/x", "https://a.test/x"),
- ("https://a.test:8443/x", "https://a.test:8443/x"),
- # tracking params removed, meaningful params kept
- ("https://a.test/x?utm_source=z&utm_medium=email", "https://a.test/x"),
- ("https://a.test/x?id=7&fbclid=abc", "https://a.test/x?id=7"),
- ("https://a.test/x?gclid=abc&igshid=q", "https://a.test/x"),
- # remaining params sorted (order-independent)
- ("https://a.test/x?b=2&a=1", "https://a.test/x?a=1&b=2"),
- # bare "ref"/"source" are intentionally preserved
- ("https://a.test/x?ref=home", "https://a.test/x?ref=home"),
- # combination
- (
- "HTTPS://A.TEST:443/Path/?b=2&utm_campaign=spring&a=1#frag",
- "https://a.test/Path?a=1&b=2",
- ),
- # non-http(s) left alone
- ("mailto:someone@a.test", "mailto:someone@a.test"),
-]
-
-
-@pytest.mark.parametrize("raw,expected", CASES)
-def test_canonicalize_cases(raw: str, expected: str):
- assert canonicalize_url(raw) == expected
-
-
-@pytest.mark.parametrize("raw,_expected", CASES)
-def test_canonicalize_is_idempotent(raw: str, _expected: str):
- once = canonicalize_url(raw)
- assert canonicalize_url(once) == once
-
-
-def test_near_duplicates_share_a_url_hash():
- variants = [
- "https://a.test/article",
- "https://a.test/article/",
- "https://a.test/article#intro",
- "https://a.test/article?utm_source=newsletter",
- "HTTPS://A.test/article",
- ]
- hashes = {url_hash(v) for v in variants}
- assert len(hashes) == 1
-
-
-def test_distinct_pages_keep_distinct_hashes():
- assert url_hash("https://a.test/x?id=1") != url_hash("https://a.test/x?id=2")
- assert url_hash("https://a.test/x") != url_hash("https://a.test/y")
-
-
-def test_empty_and_none_safe():
- assert canonicalize_url("") == ""
-
-
-def test_lazy_port_valueerror_returns_raw() -> None:
- # .port raises ValueError lazily when the port section is non-numeric —
- # junk the URL regex can extract from CSS-ish text ("Port could not be
- # cast to integer value as 'root{--novem-render-frac'"). canonicalize
- # must not propagate it.
- junk = "http://a.test:root{--novem-render-frac/x"
- assert canonicalize_url(junk) == junk
- assert canonicalize_url(canonicalize_url(junk)) == canonicalize_url(junk)
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_catalog.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_catalog.py
deleted file mode 100644
index 3387d046..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_catalog.py
+++ /dev/null
@@ -1,139 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive import manifest as manifest_io
-from forecasting_tools.agents_and_tools.source_archive.catalog import (
- build_catalog,
- write_catalog,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CaptureResult,
- CitationRecord,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-def _capture(url: str, html: str) -> CaptureResult:
- return CaptureResult(
- url=url,
- final_url=url,
- status_code=200,
- html=html,
- markdown="md " * 30,
- screenshot=b"img",
- screenshot_content_type="image/png",
- fetcher="fake",
- )
-
-
-def _seed(tmp_path):
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- cstore = ContentStore(store, config)
- cstore.store(_capture("https://a.test/p", "a
"))
- cstore.store(_capture("https://b.test/q", "b
"))
- # uncaptured.test/x is cited but never captured.
- records = [
- CitationRecord(
- url="https://a.test/p?utm_source=news", # canonicalizes to /p
- run_id="r1",
- bot="alpha",
- question_id="100",
- question_url="https://www.metaculus.com/questions/100/",
- tool_name="web_search",
- ),
- CitationRecord(
- url="https://b.test/q",
- run_id="r1",
- bot="beta",
- question_id="100",
- question_url="https://www.metaculus.com/questions/100/",
- tool_name="page_fetch",
- ),
- CitationRecord(
- url="https://uncaptured.test/x",
- run_id="r1",
- bot="alpha",
- question_id="100",
- ),
- # A data/API call made only via run_code -> excluded from the catalog.
- CitationRecord(
- url="https://data.test/api?fmt=csv",
- run_id="r1",
- bot="beta",
- question_id="100",
- tool_name="run_code",
- ),
- ]
- manifest_io.write_blob(store, "r1", records, config)
- return store, config
-
-
-def test_build_catalog_joins_and_canonicalizes(tmp_path):
- store, config = _seed(tmp_path)
- data = build_catalog(store, config)
-
- # The two a.test variants collapse to one source; the run_code API call is
- # excluded (tool/API call, not a page).
- urls = {s.canonical_url for s in data.sources}
- assert urls == {
- "https://a.test/p",
- "https://b.test/q",
- "https://uncaptured.test/x",
- }
- assert data.excluded.get("tool_call") == 1
- assert "https://data.test/api?fmt=csv" not in urls
- captured = {s.canonical_url for s in data.sources if s.captured}
- assert captured == {"https://a.test/p", "https://b.test/q"}
-
- by_q = data.by_question()
- assert set(by_q) == {"100"}
- assert len(by_q["100"]) == 3
- by_bot = data.by_bot()
- assert set(by_bot) == {"alpha", "beta"}
-
-
-def test_build_catalog_excludes_parse_raising_urls(tmp_path):
- store, config = _seed(tmp_path)
- # A bare "http://[" (junk extracted from a bot comment) makes urlsplit raise
- # ValueError ("Invalid IPv6 URL"); it must count as malformed, not crash.
- records = [
- CitationRecord(url="http://[", run_id="r2", bot="alpha", question_id="100"),
- ]
- manifest_io.write_blob(store, "r2", records, config)
-
- data = build_catalog(store, config)
- assert data.excluded.get("malformed") == 1
- assert "http://[" not in {s.canonical_url for s in data.sources}
-
- summary = write_catalog(store, config)
- assert summary.excluded.get("malformed") == 1
-
-
-def test_write_catalog_emits_views(tmp_path):
- store, config = _seed(tmp_path)
- summary = write_catalog(store, config)
-
- assert summary.sources == 3
- assert summary.captured == 2
- assert summary.questions == 1
- assert summary.excluded.get("tool_call") == 1
-
- keys = set(store.list_keys("t/catalog/"))
- assert "t/catalog/index.html" in keys
- assert "t/catalog/READ_ME_FIRST.html" in keys
- assert "t/catalog/by-question/100.html" in keys
- assert "t/catalog/by-question/100.csv" in keys
- assert "t/catalog/by-bot/alpha.html" in keys
- assert "t/catalog/by-domain/a.test.html" in keys
-
- q_html = store.get("t/catalog/by-question/100.html").decode("utf-8")
- assert "https://a.test/p" in q_html
- assert "alpha" in q_html # bot tag present
- # Local links are relative into the content store.
- assert "../../content/" in q_html
-
- q_csv = store.get("t/catalog/by-question/100.csv").decode("utf-8")
- assert "https://uncaptured.test/x" in q_csv
- assert "no" in q_csv # uncaptured row marked
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_content_store.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_content_store.py
deleted file mode 100644
index 27dfdf15..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_content_store.py
+++ /dev/null
@@ -1,194 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime, timedelta, timezone
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CaptureResult,
- url_hash,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-def _store(tmp_path, **cfg) -> ContentStore:
- return ContentStore(LocalBlobStore(tmp_path), ArchiveConfig(s3_prefix="t", **cfg))
-
-
-def _result(url: str, html: str, final_url: str | None = None) -> CaptureResult:
- return CaptureResult(
- url=url,
- final_url=final_url if final_url is not None else url,
- status_code=200,
- html=html,
- markdown="md " * 50,
- screenshot=b"img",
- screenshot_content_type="image/png",
- fetcher="fake",
- )
-
-
-def test_store_writes_blobs_and_index(tmp_path):
- store = _store(tmp_path)
- res = store.store(_result("https://a.test", "one
"))
- assert res.created is True
- cap = res.capture
- assert store.blobs.exists(cap.html_key)
- assert store.blobs.exists(cap.markdown_key)
- assert store.blobs.exists(cap.screenshot_key)
-
-
-def test_lookup_within_ttl_is_cache_hit(tmp_path):
- store = _store(tmp_path, ttl_days=14)
- store.store(_result("https://a.test", "one
"))
- assert store.lookup("https://a.test") is not None
-
-
-def test_lookup_after_ttl_expires_returns_none(tmp_path):
- store = _store(tmp_path, ttl_days=14)
- store.store(_result("https://a.test", "one
"))
-
- uh = url_hash("https://a.test")
- index = store._read_index(uh)
- old = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
- for cap in index["captures"].values():
- cap["last_seen"] = old
- store._write_index(uh, index)
-
- assert store.lookup("https://a.test") is None
-
-
-def test_identical_content_is_deduped(tmp_path):
- store = _store(tmp_path)
- first = store.store(_result("https://a.test", "same
"))
- second = store.store(_result("https://a.test", "same
"))
- assert first.created is True
- assert second.created is False
- assert first.capture.content_hash == second.capture.content_hash
-
-
-def test_changed_content_creates_new_capture(tmp_path):
- store = _store(tmp_path)
- first = store.store(_result("https://a.test", "v1
"))
- second = store.store(_result("https://a.test", "v2 changed
"))
- assert second.created is True
- assert first.capture.content_hash != second.capture.content_hash
-
-
-# --- Phase B: redirect aliasing -------------------------------------------
-def test_redirect_keys_capture_by_final_url(tmp_path):
- store = _store(tmp_path)
- res = store.store(
- _result("https://bit.ly/x", "dest
", final_url="https://dest.test/page")
- )
- # Capture is stored under the FINAL url's hash, not the shortener's.
- assert res.capture.url == "https://dest.test/page"
- assert res.capture.url_hash == url_hash("https://dest.test/page")
- # The canonical index records the cited shortener as an alias.
- canonical = store._read_index(url_hash("https://dest.test/page"))
- assert "https://bit.ly/x" in canonical["aliases"]
-
-
-def test_lookup_via_shortener_and_final_both_hit(tmp_path):
- store = _store(tmp_path)
- store.store(
- _result("https://bit.ly/x", "dest
", final_url="https://dest.test/page")
- )
- via_alias = store.lookup("https://bit.ly/x")
- via_final = store.lookup("https://dest.test/page")
- assert via_alias is not None and via_final is not None
- assert via_alias.content_hash == via_final.content_hash
- assert via_alias.url == "https://dest.test/page"
-
-
-def test_two_shorteners_to_same_page_store_once(tmp_path):
- store = _store(tmp_path)
- first = store.store(
- _result("https://bit.ly/x", "same
", final_url="https://dest.test/page")
- )
- second = store.store(
- _result("https://t.co/y", "same
", final_url="https://dest.test/page")
- )
- assert first.created is True
- assert second.created is False # identical content deduped, not re-stored
- canonical = store._read_index(url_hash("https://dest.test/page"))
- assert set(canonical["aliases"]) == {"https://bit.ly/x", "https://t.co/y"}
- assert len(canonical["captures"]) == 1
-
-
-# --- Phase C: cross-URL content dedup -------------------------------------
-def test_identical_content_across_distinct_urls_reuses_blobs(tmp_path):
- store = _store(tmp_path)
- a = store.store(_result("https://a.test/x", "same
"))
- b = store.store(_result("https://b.test/y", "same
"))
-
- # Both are real captures (each URL has its own index entry)...
- assert a.created is True and b.created is True
- # ...but B reuses A's blobs instead of writing its own.
- assert a.capture.content_alias_of is None
- assert b.capture.content_alias_of == url_hash("https://a.test/x")
- assert b.capture.html_key == a.capture.html_key
-
- # No duplicate blob was written under B's url hash.
- b_own_key = (
- f"t/content/{url_hash('https://b.test/y')}/{b.capture.content_hash}.html"
- )
- assert not store.blobs.exists(b_own_key)
- assert store.blobs.exists(a.capture.html_key)
-
-
-def test_content_reverse_index_tracks_members(tmp_path):
- store = _store(tmp_path)
- store.store(_result("https://a.test/x", "same
"))
- store.store(_result("https://b.test/y", "same
"))
-
- ch = store.store(_result("https://c.test/z", "same
")).capture.content_hash
- reverse = store._read_content_index(ch)
- assert reverse["canonical_url_hash"] == url_hash("https://a.test/x")
- member_hashes = {m["url_hash"] for m in reverse["members"]}
- assert member_hashes == {
- url_hash("https://a.test/x"),
- url_hash("https://b.test/y"),
- url_hash("https://c.test/z"),
- }
-
-
-def test_different_content_not_aliased(tmp_path):
- store = _store(tmp_path)
- a = store.store(_result("https://a.test/x", "one
"))
- b = store.store(_result("https://b.test/y", "two different
"))
- assert b.capture.content_alias_of is None
- assert b.capture.html_key != a.capture.html_key
-
-
-def test_incomplete_capture_is_not_a_cache_hit(tmp_path):
- # A browser capture whose screenshot failed to encode (screenshot_key=None)
- # is not "done" — the next run should retry it to fill the missing format.
- store = _store(tmp_path, ttl_days=14)
- store.store(
- CaptureResult(
- url="https://a.test",
- final_url="https://a.test",
- status_code=200,
- html="one
",
- markdown="md " * 50,
- screenshot=None,
- fetcher="cloakbrowser",
- )
- )
- assert store.lookup("https://a.test") is None
-
-
-def test_pdf_capture_without_screenshot_is_complete(tmp_path):
- # PDFs have no screenshot by nature, so markdown alone counts as complete.
- store = _store(tmp_path, ttl_days=14)
- store.store(
- CaptureResult(
- url="https://a.test/x.pdf",
- final_url="https://a.test/x.pdf",
- status_code=200,
- markdown="md " * 50,
- fetcher="pdf",
- )
- )
- assert store.lookup("https://a.test/x.pdf") is not None
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_cost.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_cost.py
deleted file mode 100644
index fe9dea55..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_cost.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.cost import (
- estimate_run_cost,
- price_per_capture,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import StoredCapture
-from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- CaptureOutcome,
- PipelineSummary,
-)
-
-
-def _cap(url: str, fetcher: str) -> StoredCapture:
- return StoredCapture(url=url, url_hash="h", content_hash="c", fetcher=fetcher)
-
-
-def _stored(url: str, fetcher: str) -> CaptureOutcome:
- return CaptureOutcome(url=url, status="stored", stored=_cap(url, fetcher))
-
-
-def test_free_backends_cost_nothing():
- cfg = ArchiveConfig()
- for f in ("cloakbrowser", "playwright", "pdf", ""):
- assert price_per_capture(f, cfg) == 0.0
-
-
-def test_paid_backends_priced_by_config():
- cfg = ArchiveConfig(hyperbrowser_use_proxy=True, firecrawl_proxy="basic")
- assert price_per_capture("hyperbrowser", cfg) == 10 * 0.001
- assert price_per_capture("firecrawl", cfg) == 1 * 0.00083
-
- cheap = ArchiveConfig(hyperbrowser_use_proxy=False, firecrawl_proxy="auto")
- assert price_per_capture("hyperbrowser", cheap) == 1 * 0.001
- assert price_per_capture("firecrawl", cheap) == 5 * 0.00083
-
-
-def test_estimate_run_cost_breakdown():
- cfg = ArchiveConfig(hyperbrowser_use_proxy=True, firecrawl_proxy="basic")
- summary = PipelineSummary(
- outcomes=[
- _stored("u1", "cloakbrowser"),
- _stored("u2", "cloakbrowser"),
- _stored("u3", "hyperbrowser"),
- _stored("u4", "firecrawl"),
- CaptureOutcome(
- url="u5", status="cache_hit", stored=_cap("u5", "cloakbrowser")
- ),
- CaptureOutcome(url="u6", status="error", reason="boom"),
- ]
- )
- rc = estimate_run_cost(summary, cfg, run_id="r1")
-
- assert rc.archived == 5 # 4 stored + 1 cache_hit; the error doesn't count
- assert rc.paid_captures == 2 # hyperbrowser + firecrawl
- assert rc.total_usd == round(0.01 + 0.00083, 4) # 0.0108 (4-dp rounding)
- by = {b.backend: b for b in rc.by_backend}
- assert by["cloakbrowser"].captures == 2 and by["cloakbrowser"].total_usd == 0.0
- assert by["hyperbrowser"].captures == 1 and by["hyperbrowser"].unit_usd == 0.01
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_coverage.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_coverage.py
deleted file mode 100644
index 155d3772..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_coverage.py
+++ /dev/null
@@ -1,110 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive.catalog import Citation, Source
-from forecasting_tools.agents_and_tools.source_archive.coverage import (
- coverage_from_sources,
-)
-
-
-def _src(url, domain, captured, cits):
- return Source(canonical_url=url, domain=domain, captured=captured, citations=cits)
-
-
-def _trace(bot, q, tool):
- return Citation(bot=bot, question_id=q, tool_name=tool, origin="tool_result")
-
-
-def _comment(bot, q):
- return Citation(bot=bot, question_id=q, origin="metaculus_comment")
-
-
-SOURCES = [
- _src(
- "https://a.test/1",
- "a.test",
- True,
- [_trace("template", "100", "scrape_webpage")],
- ),
- _src(
- "https://b.test/2",
- "b.test",
- False,
- [_trace("template", "100", "scrape_webpage")],
- ),
- _src("https://c.test/3", "c.test", True, [_comment("otherbot", "200")]),
- # run_code-only -> excluded as a tool/API call
- _src(
- "https://data.test/x",
- "data.test",
- False,
- [_trace("template", "100", "run_code")],
- ),
- # search-engine result page -> excluded as a non-source
- _src(
- "https://www.google.com/search?q=x",
- "google.com",
- False,
- [_trace("template", "100", "scrape_webpage")],
- ),
- # malformed (extractor junk) -> excluded
- _src(
- "https://a.test/y%5B1%5D",
- "a.test",
- False,
- [_trace("template", "100", "scrape_webpage")],
- ),
-]
-
-
-def test_trace_report_excludes_non_sources_and_counts_pages():
- r = coverage_from_sources(SOURCES, "trace")
- assert r.cited == 2 # a.test/1 + b.test/2 (data/search/malformed excluded)
- assert r.captured == 1
- assert r.pct == 50.0
- assert r.excluded == {"tool_call": 1, "search": 1, "malformed": 1}
- assert r.missing == 1
- assert r.missing_urls == ["https://b.test/2"]
-
- by_q = {row.label: (row.cited, row.captured) for row in r.by_question}
- assert by_q == {"100": (2, 1)}
- by_tool = {row.label: (row.cited, row.captured) for row in r.by_tool}
- assert by_tool == {"scrape_webpage": (2, 1)}
- missed = {row.label for row in r.missed_by_domain}
- assert missed == {"b.test"}
-
-
-def test_comment_report_is_separate():
- r = coverage_from_sources(SOURCES, "comments")
- assert r.cited == 1 # only the metaculus_comment source
- assert r.captured == 1
- assert r.pct == 100.0
- assert {row.label for row in r.by_bot} == {"otherbot"}
-
-
-def test_modes_do_not_bleed():
- trace = coverage_from_sources(SOURCES, "trace")
- comments = coverage_from_sources(SOURCES, "comments")
- assert "https://c.test/3" not in trace.missing_urls # comment source not in trace
- # the trace bot never appears in the comment report
- assert "template" not in {row.label for row in comments.by_bot}
-
-
-def test_csv_export_has_overall_row():
- csv_text = coverage_from_sources(SOURCES, "trace").to_csv()
- assert "group,label,cited,captured,pct" in csv_text
- assert "overall,trace,2,1,50.0" in csv_text
-
-
-def test_outcomes_split_never_fetched_vs_failed():
- # b.test/2 is the only missing page source. With no outcome for it, it's a
- # pure collection gap (never fetched).
- r = coverage_from_sources(SOURCES, "trace", {"https://a.test/1": "stored"})
- assert r.has_outcomes is True
- assert r.missing_never_fetched == 1
- assert r.missing_fetch_failed == 0
-
- # If a run report shows b.test/2 was fetched and failed, it's a capture
- # problem, not a collection gap.
- r2 = coverage_from_sources(SOURCES, "trace", {"https://b.test/2": "error"})
- assert r2.missing_never_fetched == 0
- assert r2.missing_fetch_failed == 1
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_layout.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_layout.py
deleted file mode 100644
index 36147d38..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_layout.py
+++ /dev/null
@@ -1,123 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive import layout
-from forecasting_tools.agents_and_tools.source_archive import manifest as manifest_io
-from forecasting_tools.agents_and_tools.source_archive.catalog import _load_all_records
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.models import CitationRecord
-from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- CaptureOutcome,
- PipelineSummary,
-)
-from forecasting_tools.agents_and_tools.source_archive.reports import (
- read_outcomes,
- write_run_report,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-# --- key helpers --------------------------------------------------------------
-def test_manifest_key_nests_by_group_daily_or_adhoc():
- assert (
- layout.manifest_key("r1", group="sprints/myrun")
- == "manifests/sprints/myrun/r1.jsonl"
- )
- assert (
- layout.manifest_key("daily-2026-07-01")
- == "manifests/daily/2026-07/daily-2026-07-01.jsonl"
- )
- assert layout.manifest_key("r1") == "manifests/adhoc/r1.jsonl"
-
-
-def test_report_key_nests_and_keeps_suffix():
- assert (
- layout.report_key("daily-2026-07-01", ".json")
- == "reports/daily/2026-07/daily-2026-07-01.json"
- )
- assert layout.report_key("r1", "_cost.json") == "reports/adhoc/r1_cost.json"
- assert (
- layout.report_key("r1", ".json", group="sprints/myrun")
- == "reports/sprints/myrun/r1.json"
- )
-
-
-def test_group_slashes_are_normalized():
- assert layout.manifest_key("r1", group="/sprints/myrun/") == (
- "manifests/sprints/myrun/r1.jsonl"
- )
-
-
-def test_candidates_prefer_nested_then_legacy_flat():
- assert layout.manifest_key_candidates("r1") == [
- "manifests/adhoc/r1.jsonl",
- "manifests/r1.jsonl",
- ]
- assert layout.report_key_candidates("r1", ".json") == [
- "reports/adhoc/r1.json",
- "reports/r1.json",
- ]
-
-
-# --- readers ------------------------------------------------------------------
-def test_read_blob_falls_back_to_legacy_flat_key(tmp_path):
- store = LocalBlobStore(tmp_path)
- cfg = ArchiveConfig(s3_prefix="t")
- legacy = manifest_io.dumps([CitationRecord(url="https://old.test", run_id="r1")])
- store.put("t/manifests/r1.jsonl", legacy.encode("utf-8"))
-
- assert manifest_io.read_blob(store, "r1", cfg)[0].url == "https://old.test"
-
-
-def test_read_blob_prefers_nested_over_legacy(tmp_path):
- store = LocalBlobStore(tmp_path)
- cfg = ArchiveConfig(s3_prefix="t")
- legacy = manifest_io.dumps([CitationRecord(url="https://old.test", run_id="r1")])
- store.put("t/manifests/r1.jsonl", legacy.encode("utf-8"))
- manifest_io.write_blob(
- store, "r1", [CitationRecord(url="https://new.test", run_id="r1")], cfg
- )
-
- assert manifest_io.read_blob(store, "r1", cfg)[0].url == "https://new.test"
-
-
-def test_catalog_loads_nested_and_flat_manifests(tmp_path):
- store = LocalBlobStore(tmp_path)
- cfg = ArchiveConfig(s3_prefix="t")
- store.put(
- "t/manifests/old.jsonl",
- manifest_io.dumps([CitationRecord(url="https://old.test")]).encode("utf-8"),
- )
- manifest_io.write_blob(
- store, "daily-2026-07-01", [CitationRecord(url="https://daily.test")], cfg
- )
- manifest_io.write_blob(
- store,
- "sprint-run",
- [CitationRecord(url="https://sprint.test")],
- cfg,
- group="sprints/myrun",
- )
-
- urls = {r.url for r in _load_all_records(store, "t")}
- assert urls == {"https://old.test", "https://daily.test", "https://sprint.test"}
-
-
-def test_read_outcomes_sees_nested_and_flat_reports(tmp_path):
- store = LocalBlobStore(tmp_path)
- cfg = ArchiveConfig(s3_prefix="t")
- store.put(
- "t/reports/old.json",
- b'[{"url": "https://old.test", "status": "stored", "reason": ""}]',
- )
- write_run_report(
- store,
- "daily-2026-07-01",
- PipelineSummary(
- outcomes=[CaptureOutcome(url="https://daily.test", status="error")]
- ),
- cfg,
- )
-
- out = read_outcomes(store, cfg)
- assert out["https://old.test"] == "stored"
- assert out["https://daily.test"] == "error"
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_pipeline_and_manifest.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_pipeline_and_manifest.py
deleted file mode 100644
index ba895f4d..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_pipeline_and_manifest.py
+++ /dev/null
@@ -1,295 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive import manifest
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.models import CitationRecord
-from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- CapturePipeline,
- capture_urls_concurrent,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-def _pipeline(tmp_path, fetcher) -> CapturePipeline:
- store = ContentStore(
- LocalBlobStore(tmp_path), ArchiveConfig(s3_prefix="t", ttl_days=14)
- )
- return CapturePipeline(fetcher, store)
-
-
-def test_capture_urls_concurrent_captures_all(tmp_path, make_fetcher):
- from contextlib import contextmanager
-
- config = ArchiveConfig(s3_prefix="t", concurrency=4)
- store = ContentStore(LocalBlobStore(tmp_path), config)
- urls = [f"https://s{i}.test/p" for i in range(12)]
-
- @contextmanager
- def factory(_cfg):
- f = make_fetcher()
- for u in urls:
- f.add(u)
- yield f
-
- summary = capture_urls_concurrent(urls, store, config, factory)
-
- assert len(summary.outcomes) == 12
- assert summary.count("stored") == 12
- # every URL is resolvable afterwards (proves the shared store got all writes)
- assert all(store.lookup(u) is not None for u in urls)
-
-
-def test_concurrent_supervisor_recovers_a_stuck_worker(tmp_path, make_fetcher):
- import threading
- from contextlib import contextmanager
-
- config = ArchiveConfig(s3_prefix="t", concurrency=1)
- store = ContentStore(LocalBlobStore(tmp_path), config)
- urls = ["https://stuck.test/x"]
- reaped = threading.Event()
- builds = {"n": 0}
-
- class _Wedges:
- name = "wedge"
-
- def fetch(self, url):
- # Block until the supervisor's reaper "kills the browser", then surface
- # the dead-browser error a killed Chromium would raise.
- reaped.wait(5)
- raise RuntimeError("Target page, context or browser has been closed")
-
- @contextmanager
- def factory(_cfg):
- builds["n"] += 1
- if builds["n"] == 1:
- yield _Wedges() # first browser wedges
- else:
- fetcher = make_fetcher()
- fetcher.add(urls[0])
- yield fetcher # rebuilt browser works
-
- # Inject a fake reaper so the test drives the supervisor without real Chromium.
- summary = capture_urls_concurrent(
- urls, store, config, factory, per_url_timeout=0.3, reaper=reaped.set
- )
-
- assert builds["n"] == 2 # stalled -> reaped -> death -> rebuild -> retry
- assert summary.count("stored") == 1 # recovered and captured on a fresh browser
-
-
-def test_concurrent_restarts_browser_after_death(tmp_path, make_fetcher):
- from contextlib import contextmanager
-
- config = ArchiveConfig(s3_prefix="t", concurrency=1)
- store = ContentStore(LocalBlobStore(tmp_path), config)
- urls = ["https://a.test/x"]
- builds = {"n": 0}
-
- class _DeadBrowser:
- name = "dead"
-
- def fetch(self, url):
- raise RuntimeError("Target page, context or browser has been closed")
-
- @contextmanager
- def factory(_cfg):
- builds["n"] += 1
- if builds["n"] == 1:
- yield _DeadBrowser() # first browser is dead
- else:
- fetcher = make_fetcher()
- fetcher.add(urls[0])
- yield fetcher # rebuilt browser works
-
- summary = capture_urls_concurrent(urls, store, config, factory)
-
- assert builds["n"] == 2 # detected death, rebuilt once
- assert summary.count("stored") == 1 # retry on the fresh browser succeeded
-
-
-def test_concurrent_rebuild_survives_poisoned_thread_loop_state(tmp_path, make_fetcher):
- """Regression: a SIGKILLed sync-Playwright browser leaves its asyncio loop
- registered as *running* on the worker thread (teardown is thread-affine, so
- ``_close_quietly``'s helper thread can't clear it). The rebuild must reset
- that thread-local state — otherwise ``sync_playwright().start()`` raises
- "Sync API inside the asyncio loop" and future.result() kills the whole run.
- """
- import asyncio
- import threading
-
- config = ArchiveConfig(s3_prefix="t", concurrency=1)
- store = ContentStore(LocalBlobStore(tmp_path), config)
- urls = ["https://a.test/x", "https://b.test/y"]
- builds = {"n": 0}
-
- class _SyncPlaywrightAlike:
- """Mimics sync Playwright's thread behavior: __enter__ refuses if the
- thread already reports a running loop, then registers its own; a killed
- browser errors on fetch; teardown from a foreign thread fails the way a
- greenlet does, leaving the loop state poisoned."""
-
- name = "pwalike"
-
- def __init__(self, dead: bool, inner):
- self._dead = dead
- self._inner = inner
- self._thread = None
-
- def __enter__(self):
- try:
- asyncio.get_running_loop()
- except RuntimeError:
- pass
- else: # what playwright's sync context manager raises
- raise RuntimeError(
- "It looks like you are using Playwright Sync API inside "
- "the asyncio loop."
- )
- self._thread = threading.current_thread()
- asyncio.events._set_running_loop(asyncio.new_event_loop())
- return self
-
- def fetch(self, url):
- if self._dead:
- raise RuntimeError("Target page, context or browser has been closed")
- return self._inner.fetch(url)
-
- def __exit__(self, *exc):
- if threading.current_thread() is not self._thread:
- raise RuntimeError("cannot switch to a different thread")
- asyncio.events._set_running_loop(None)
-
- def factory(_cfg):
- builds["n"] += 1
- inner = make_fetcher()
- for u in urls:
- inner.add(u)
- return _SyncPlaywrightAlike(dead=builds["n"] == 1, inner=inner)
-
- try:
- summary = capture_urls_concurrent(urls, store, config, factory)
- finally:
- asyncio.events._set_running_loop(None) # never leak into other tests
-
- assert builds["n"] == 2 # death detected -> loop state reset -> rebuilt
- assert summary.count("stored") == 2 # retried URL and the rest captured
-
-
-def test_concurrent_failed_rebuild_does_not_kill_the_run(tmp_path, make_fetcher):
- """If the rebuild itself fails (e.g. a transient launch error), the run must
- keep going: the URL keeps its error outcome and the next URL retries the
- rebuild."""
- from contextlib import contextmanager
-
- config = ArchiveConfig(s3_prefix="t", concurrency=1)
- store = ContentStore(LocalBlobStore(tmp_path), config)
- urls = ["https://a.test/x", "https://b.test/y"]
- builds = {"n": 0}
-
- class _DeadBrowser:
- name = "dead"
-
- def fetch(self, url):
- raise RuntimeError("Target page, context or browser has been closed")
-
- @contextmanager
- def factory(_cfg):
- builds["n"] += 1
- if builds["n"] == 1:
- yield _DeadBrowser() # first browser is dead
- elif builds["n"] == 2:
- raise RuntimeError("browser launch flaked") # rebuild attempt fails
- else:
- fetcher = make_fetcher()
- for u in urls:
- fetcher.add(u)
- yield fetcher # second rebuild attempt works
-
- summary = capture_urls_concurrent(urls, store, config, factory)
-
- assert builds["n"] == 3 # dead -> failed rebuild -> successful rebuild
- assert summary.count("error") == 1 # first URL kept its error outcome
- assert summary.count("stored") == 1 # second URL captured after recovery
-
-
-class _BoomFetcher:
- """Raises an unexpected (non-FetchError) exception, like a bad screenshot."""
-
- name = "boom"
-
- def fetch(self, url):
- raise ValueError("kaboom")
-
-
-def test_pipeline_isolates_unexpected_fetcher_errors(tmp_path):
- # One pathological URL must not abort the whole run.
- pipe = _pipeline(tmp_path, _BoomFetcher())
- summary = pipe.run(["https://a.test", "https://b.test"])
- assert summary.count("error") == 2
- assert len(summary.outcomes) == 2
- assert all(o.reason.startswith("unexpected:") for o in summary.outcomes)
-
-
-def test_manifest_roundtrip_and_unique_urls():
- records = [
- CitationRecord(url="https://a.test", run_id="r1", bot="b", tool_name="search"),
- CitationRecord(url="https://a.test", run_id="r1", bot="b", tool_name="fetch"),
- CitationRecord(url="https://b.test", run_id="r1", bot="b"),
- ]
- back = manifest.loads(manifest.dumps(records))
- assert [r.url for r in back] == [r.url for r in records]
- assert list(manifest.unique_urls(back)) == ["https://a.test", "https://b.test"]
-
-
-def test_manifest_blob_roundtrip(tmp_path):
- store = LocalBlobStore(tmp_path)
- cfg = ArchiveConfig(s3_prefix="t")
- records = [CitationRecord(url="https://a.test", run_id="r1")]
- manifest.write_blob(store, "r1", records, cfg)
- assert store.exists("t/manifests/adhoc/r1.jsonl")
- assert manifest.read_blob(store, "r1", cfg)[0].url == "https://a.test"
-
-
-def test_pipeline_stores_then_cache_hits(tmp_path, make_fetcher):
- fetcher = make_fetcher()
- fetcher.add("https://a.test")
- pipeline = _pipeline(tmp_path, fetcher)
-
- first = pipeline.run(["https://a.test"])
- assert first.count("stored") == 1
- assert fetcher.calls == ["https://a.test"]
-
- second = pipeline.run(["https://a.test"])
- assert second.count("cache_hit") == 1
- assert fetcher.calls == ["https://a.test"] # not refetched
-
-
-def test_pipeline_quality_failed_not_stored(tmp_path, make_fetcher):
- fetcher = make_fetcher()
- fetcher.add("https://bad.test", status_code=404)
- pipeline = _pipeline(tmp_path, fetcher)
-
- summary = pipeline.run(["https://bad.test"])
- assert summary.count("quality_failed") == 1
- assert summary.captures == {}
-
-
-def test_pipeline_error_when_no_backend_succeeds(tmp_path, make_fetcher):
- fetcher = make_fetcher() # no canned responses -> FetchError
- pipeline = _pipeline(tmp_path, fetcher)
- summary = pipeline.run(["https://missing.test"])
- assert summary.count("error") == 1
-
-
-def test_pipeline_run_manifest_dedups_urls(tmp_path, make_fetcher):
- fetcher = make_fetcher()
- fetcher.add("https://a.test")
- pipeline = _pipeline(tmp_path, fetcher)
- records = [
- CitationRecord(url="https://a.test", tool_name="search"),
- CitationRecord(url="https://a.test", tool_name="fetch"),
- ]
- summary = pipeline.run_manifest(records)
- assert len(summary.outcomes) == 1
- assert fetcher.calls == ["https://a.test"]
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_quality_and_tiered.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_quality_and_tiered.py
deleted file mode 100644
index d4f6b697..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_quality_and_tiered.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive.fetchers.tiered import (
- TieredFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-from forecasting_tools.agents_and_tools.source_archive.quality import evaluate
-
-
-def _cap(**kw) -> CaptureResult:
- base = dict(url="u", final_url="u", status_code=200, html=None, markdown="x " * 200)
- base.update(kw)
- return CaptureResult(**base)
-
-
-def test_quality_passes_real_page():
- assert evaluate(_cap()).passed
-
-
-def test_quality_fails_404():
- assert not evaluate(_cap(status_code=404)).passed
-
-
-def test_quality_fails_thin_content():
- assert not evaluate(_cap(markdown="short")).passed
-
-
-def test_quality_fails_block_page():
- v = evaluate(_cap(markdown="Attention Required! | Cloudflare " * 20))
- assert not v.passed
- assert "block_signature" in v.reason
-
-
-def test_tiered_falls_back_to_secondary_on_quality_fail(make_fetcher):
- primary = make_fetcher("primary")
- primary.add("https://blocked.test", markdown="please enable javascript " * 20)
- secondary = make_fetcher("secondary")
- secondary.add("https://blocked.test")
-
- result = TieredFetcher(primary, secondary).fetch("https://blocked.test")
- assert result.fetcher == "secondary"
- assert result.metadata["quality_passed"] is True
-
-
-def test_tiered_falls_back_on_fetch_error(make_fetcher):
- primary = make_fetcher("primary") # no canned response -> FetchError
- secondary = make_fetcher("secondary")
- secondary.add("https://x.test")
-
- result = TieredFetcher(primary, secondary).fetch("https://x.test")
- assert result.fetcher == "secondary"
-
-
-def test_tiered_returns_failed_capture_when_all_fail(make_fetcher):
- primary = make_fetcher("primary")
- primary.add("https://x.test", status_code=404)
- secondary = make_fetcher("secondary")
- secondary.add("https://x.test", status_code=500)
-
- result = TieredFetcher(primary, secondary).fetch("https://x.test")
- assert result.metadata["quality_passed"] is False
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reindex.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reindex.py
deleted file mode 100644
index 82e5f5b7..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reindex.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-import json
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.reindex import (
- analyze,
- rebuild_content_index,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-def _put_index(store, key: str, body: dict) -> None:
- store.put(f"t/index/{key}.json", json.dumps(body).encode("utf-8"))
-
-
-def _canonical(url: str, content_hash: str) -> dict:
- return {
- "url": url,
- "url_hash": f"hash_of_{url}",
- "latest_content_hash": content_hash,
- "captures": {
- content_hash: {
- "url": url,
- "url_hash": f"hash_of_{url}",
- "content_hash": content_hash,
- "html_key": f"t/content/hash_of_{url}/{content_hash}.html",
- }
- },
- }
-
-
-def _seed(tmp_path) -> tuple[LocalBlobStore, ArchiveConfig]:
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- # Legacy rows stored under raw hashing: two URLs that now canonicalize equal.
- _put_index(store, "h1", _canonical("https://x.test/p?utm_source=news", "c1"))
- _put_index(store, "h2", _canonical("https://x.test/p", "c2"))
- # Two distinct URLs with byte-identical content (same latest hash).
- _put_index(store, "h3", _canonical("https://a.test/1", "cX"))
- _put_index(store, "h4", _canonical("https://b.test/2", "cX"))
- # Same host+path, meaningful query differs -> Phase D candidate.
- _put_index(store, "h5", _canonical("https://q.test/item?id=1", "n1"))
- _put_index(store, "h6", _canonical("https://q.test/item?id=2", "n2"))
- # An alias (redirect) index -> counted but not a capture.
- _put_index(store, "h7", {"url": "https://bit.ly/z", "alias_of": "hash_of_x"})
- return store, config
-
-
-def test_analyze_reports_all_three_lenses(tmp_path):
- store, config = _seed(tmp_path)
- report = analyze(store, config)
-
- assert report.total_url_indexes == 7
- assert report.alias_indexes == 1
- assert report.canonical_captures == 6
-
- canon_keys = {c.key for c in report.canonicalization_clusters}
- assert "https://x.test/p" in canon_keys
-
- content_urls = {tuple(c.urls) for c in report.content_clusters}
- assert ("https://a.test/1", "https://b.test/2") in content_urls
-
- near_keys = {c.key for c in report.near_dup_clusters}
- assert "https://q.test/item" in near_keys
-
-
-def test_analyze_ignores_reverse_content_index(tmp_path):
- store, config = _seed(tmp_path)
- # A by-content reverse index must not be mistaken for a URL index.
- store.put(
- "t/index/by-content/cX.json",
- json.dumps({"content_hash": "cX", "canonical_url_hash": "x"}).encode("utf-8"),
- )
- report = analyze(store, config)
- assert report.total_url_indexes == 7 # unchanged
-
-
-def test_rebuild_content_index_is_dry_by_default(tmp_path):
- store, config = _seed(tmp_path)
- groups = rebuild_content_index(store, config, apply=False)
- assert groups >= 1
- # Dry run wrote nothing under by-content/.
- assert not list(store.list_keys("t/index/by-content/"))
-
- rebuild_content_index(store, config, apply=True)
- assert list(store.list_keys("t/index/by-content/"))
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reports.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reports.py
deleted file mode 100644
index 7a637a64..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_reports.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-import json
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.cost import (
- RunCost,
- write_cost_report,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import (
- StoredCapture,
- url_hash,
-)
-from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- CaptureOutcome,
- PipelineSummary,
-)
-from forecasting_tools.agents_and_tools.source_archive.reports import (
- read_outcomes,
- write_run_report,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
-
-def _stored(url: str, fetcher: str) -> StoredCapture:
- return StoredCapture(
- url=url, url_hash=url_hash(url), content_hash="c1", fetcher=fetcher
- )
-
-
-def test_run_report_roundtrip_canonicalizes(tmp_path):
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- summary = PipelineSummary(
- outcomes=[
- CaptureOutcome(url="https://a.test/p?utm_source=x", status="stored"),
- CaptureOutcome(url="https://b.test/q", status="error", reason="cloudflare"),
- ]
- )
- write_run_report(store, "r1", summary, config)
-
- out = read_outcomes(store, config)
- # keys are canonicalized (tracking param stripped)
- assert out["https://a.test/p"] == "stored"
- assert out["https://b.test/q"] == "error"
-
-
-def test_run_report_records_backend_per_url(tmp_path):
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- summary = PipelineSummary(
- outcomes=[
- CaptureOutcome(
- url="https://a.test/p",
- status="stored",
- stored=_stored("https://a.test/p", "firecrawl"),
- ),
- CaptureOutcome(
- url="https://c.test/r",
- status="cache_hit",
- stored=_stored("https://c.test/r", "playwright"),
- ),
- CaptureOutcome(url="https://b.test/q", status="error", reason="cloudflare"),
- ]
- )
- key = write_run_report(store, "r1", summary, config)
-
- rows = {r["url"]: r for r in json.loads(store.get(key).decode("utf-8"))}
- assert rows["https://a.test/p"]["backend"] == "firecrawl"
- assert rows["https://c.test/r"]["backend"] == "playwright"
- assert rows["https://b.test/q"]["backend"] == "" # nothing fetched
-
-
-def test_captured_status_wins_over_failure(tmp_path):
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- write_run_report(
- store,
- "early",
- PipelineSummary(
- outcomes=[CaptureOutcome(url="https://a.test", status="error")]
- ),
- config,
- )
- write_run_report(
- store,
- "later",
- PipelineSummary(
- outcomes=[CaptureOutcome(url="https://a.test", status="stored")]
- ),
- config,
- )
- assert read_outcomes(store, config)["https://a.test"] == "stored"
-
-
-def test_read_outcomes_ignores_cost_reports(tmp_path):
- """Cost reports live under reports/ too (``_cost.json``, a JSON
- dict, not a list of rows) — read_outcomes must skip them, not crash."""
- store = LocalBlobStore(tmp_path)
- config = ArchiveConfig(s3_prefix="t")
- write_run_report(
- store,
- "r1",
- PipelineSummary(
- outcomes=[CaptureOutcome(url="https://a.test", status="stored")]
- ),
- config,
- )
- write_cost_report(store, "r1", RunCost(run_id="r1", archived=1), config)
-
- out = read_outcomes(store, config)
- assert out == {"https://a.test": "stored"}
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_screenshot_encoding.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_screenshot_encoding.py
deleted file mode 100644
index d357982b..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_screenshot_encoding.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Tests for screenshot encoding + the height cap.
-
-Regression guard for a silent truncation bug: the height cap used to be applied
-via Playwright's ``clip`` *without* ``full_page``, which is bounded by the
-viewport and chopped tall pages down to a single screen. The cap is now enforced
-by cropping the full-page render in Pillow — these tests pin that behavior.
-"""
-
-from __future__ import annotations
-
-import io
-
-import pytest
-
-from forecasting_tools.agents_and_tools.source_archive.fetchers.playwright_fetcher import (
- _encode_screenshot,
-)
-
-Image = pytest.importorskip("PIL.Image")
-
-
-def _png(width: int, height: int) -> bytes:
- out = io.BytesIO()
- Image.new("RGB", (width, height), (255, 0, 0)).save(out, format="PNG")
- return out.getvalue()
-
-
-def test_tall_page_cropped_to_max_height():
- data, ct = _encode_screenshot(_png(1280, 12000), "webp", max_height=4000)
- assert ct == "image/webp"
- img = Image.open(io.BytesIO(data))
- assert img.size == (1280, 4000) # cropped to the cap, full width preserved
-
-
-def test_short_page_not_cropped():
- data, _ = _encode_screenshot(_png(1280, 3000), "webp", max_height=20000)
- assert Image.open(io.BytesIO(data)).size == (1280, 3000) # untouched
-
-
-def test_webp_clamped_to_format_limit_even_without_cap():
- # WebP cannot exceed 16383px; an over-tall page must crop, not crash.
- data, _ = _encode_screenshot(_png(1280, 25000), "webp", max_height=0)
- assert Image.open(io.BytesIO(data)).size == (1280, 16383)
-
-
-def test_webp_cap_above_format_limit_is_clamped():
- # A configured cap above WebP's limit still degrades safely to 16383.
- data, _ = _encode_screenshot(_png(1280, 18000), "webp", max_height=16000)
- assert Image.open(io.BytesIO(data)).height == 16000
-
-
-def test_png_keeps_full_height_uncapped():
- # PNG has no such limit, so max_height=0 preserves the whole render.
- data, _ = _encode_screenshot(_png(1280, 20000), "png", max_height=0)
- assert Image.open(io.BytesIO(data)).size == (1280, 20000)
-
-
-def test_webp_is_real_webp():
- data, ct = _encode_screenshot(_png(800, 600), "webp")
- assert ct == "image/webp"
- assert data[:4] == b"RIFF" and data[8:12] == b"WEBP"
-
-
-def test_jpeg_format():
- data, ct = _encode_screenshot(_png(800, 600), "jpeg")
- assert ct == "image/jpeg"
- assert Image.open(io.BytesIO(data)).format == "JPEG"
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_trace_extraction.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_trace_extraction.py
deleted file mode 100644
index 2637eccd..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_trace_extraction.py
+++ /dev/null
@@ -1,215 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from forecasting_tools.agents_and_tools.source_archive.ingest.trace_extraction import (
- extract_records_from_events,
- extract_records_from_question_dir,
- extract_records_from_trace_file,
- harvest_run,
- trace_label,
-)
-
-
-def test_trace_label_strips_prefix_and_suffix():
- assert trace_label("/x/traces_forecast_1_attempt_1.jsonl") == "forecast_1_attempt_1"
- assert trace_label("traces_summarize.jsonl") == "summarize"
-
-
-def test_tool_call_carries_query_and_tool_args():
- events = [
- {
- "type": "tool_call",
- "call_id": "c1",
- "name": "search_online",
- "args": {"query": "uk election polls", "max_results": 5},
- }
- ]
- records = extract_records_from_events(events, trace="forecast_1")
- # No URL in the args -> nothing emitted from the tool_call itself.
- assert records == []
-
-
-def test_tool_result_attributed_to_originating_call():
- events = [
- {
- "type": "tool_call",
- "call_id": "c1",
- "name": "search_online",
- "args": {"query": "uk election polls"},
- },
- {
- "type": "tool_result",
- "call_id": "c1",
- "content": "Top hit: [poll](https://a.test/poll) and https://b.test/x",
- "timestamp": "2026-05-12T12:00:00+00:00",
- },
- ]
- records = extract_records_from_events(events, trace="forecast_1", bot="template")
- assert [r.url for r in records] == ["https://a.test/poll", "https://b.test/x"]
- rec = records[0]
- assert rec.origin == "tool_result"
- assert rec.tool_name == "search_online"
- assert rec.query == "uk election polls"
- assert rec.tool_args == {"query": "uk election polls"}
- assert rec.trace == "forecast_1"
- assert rec.bot == "template"
- assert rec.first_seen == "2026-05-12T12:00:00+00:00"
-
-
-def test_query_from_list_args():
- events = [
- {
- "type": "tool_call",
- "call_id": "c1",
- "name": "s",
- "args": {"queries": ["a", "b"]},
- },
- {"type": "tool_result", "call_id": "c1", "content": "https://a.test/x"},
- ]
- records = extract_records_from_events(events, trace="t")
- assert records[0].query == "a b"
-
-
-def test_url_directly_in_tool_call_args():
- events = [
- {
- "type": "tool_call",
- "call_id": "c1",
- "name": "fetch_page",
- "args": {"url": "https://a.test/page"},
- }
- ]
- records = extract_records_from_events(events, trace="t")
- assert len(records) == 1
- assert records[0].url == "https://a.test/page"
- assert records[0].origin == "tool_call"
- assert records[0].tool_name == "fetch_page"
- assert records[0].tool_args == {"url": "https://a.test/page"}
-
-
-def test_initial_prompt_only_scanned_when_enabled():
- events = [
- {"type": "initial_prompt", "prompt": "background: https://a.test/bg"},
- ]
- assert extract_records_from_events(events, trace="forecast_1") == []
- records = extract_records_from_events(
- events, trace="summarize", include_initial_prompt=True
- )
- assert [r.url for r in records] == ["https://a.test/bg"]
- assert records[0].origin == "initial_prompt"
- assert records[0].tool_name == ""
-
-
-def test_non_dict_events_skipped():
- events = ["garbage", None, {"type": "tool_result", "content": "https://a.test/x"}]
- records = extract_records_from_events(events, trace="t")
- assert [r.url for r in records] == ["https://a.test/x"]
-
-
-def _write_jsonl(path: Path, events: list[dict]) -> None:
- path.write_text("\n".join(json.dumps(e) for e in events), encoding="utf-8")
-
-
-def test_trace_file_uses_summarize_rule(tmp_path: Path):
- f = tmp_path / "traces_summarize.jsonl"
- _write_jsonl(f, [{"type": "initial_prompt", "prompt": "see https://a.test/r"}])
- records = extract_records_from_trace_file(str(f), run_id="run1", bot="template")
- assert [r.url for r in records] == ["https://a.test/r"]
- assert records[0].trace == "summarize"
- assert records[0].run_id == "run1"
-
-
-def test_trace_file_skips_blank_and_bad_lines(tmp_path: Path):
- f = tmp_path / "traces_forecast_1.jsonl"
- f.write_text(
- '\n{"type": "tool_result", "content": "https://a.test/x"}\nnot json\n',
- encoding="utf-8",
- )
- records = extract_records_from_trace_file(str(f))
- assert [r.url for r in records] == ["https://a.test/x"]
-
-
-def test_question_dir_reads_metadata_and_builds_url(tmp_path: Path):
- qdir = tmp_path / "q_123"
- qdir.mkdir()
- (qdir / "question.json").write_text(
- json.dumps({"question_id": "metac_123", "metaculus_id": 123}),
- encoding="utf-8",
- )
- _write_jsonl(
- qdir / "traces_forecast_1.jsonl",
- [{"type": "tool_result", "content": "https://a.test/x"}],
- )
- records = extract_records_from_question_dir(
- str(qdir), run_id="run1", bot="template"
- )
- assert len(records) == 1
- rec = records[0]
- assert rec.question_id == "metac_123"
- assert rec.metaculus_id == "123"
- assert rec.question_url == "https://www.metaculus.com/questions/123/"
-
-
-def test_question_dir_without_metadata_still_emits(tmp_path: Path):
- qdir = tmp_path / "q_x"
- qdir.mkdir()
- _write_jsonl(
- qdir / "traces_forecast_1.jsonl",
- [{"type": "tool_result", "content": "https://a.test/x"}],
- )
- records = extract_records_from_question_dir(str(qdir))
- assert [r.url for r in records] == ["https://a.test/x"]
- assert records[0].question_id is None
- assert records[0].question_url is None
-
-
-def test_harvest_run_walks_bot_and_question_dirs(tmp_path: Path):
- run = tmp_path / "run_demo"
- qdir = run / "bot_template" / "q_1"
- qdir.mkdir(parents=True)
- (qdir / "question.json").write_text(
- json.dumps({"metaculus_id": 1}), encoding="utf-8"
- )
- _write_jsonl(
- qdir / "traces_forecast_1.jsonl",
- [{"type": "tool_result", "content": "https://a.test/x"}],
- )
- records = harvest_run(str(run))
- assert len(records) == 1
- rec = records[0]
- assert rec.run_id == "run_demo"
- assert rec.bot == "template"
- assert rec.metaculus_id == "1"
-
-
-def test_harvest_run_flat_layout_without_bot_dirs(tmp_path: Path):
- # Flat layout: //traces_*.jsonl with no bot_* grouping.
- run = tmp_path / "s3_backfill"
- qdir = run / "2026-05-20_metac_43538"
- qdir.mkdir(parents=True)
- (qdir / "question.json").write_text(
- json.dumps({"metaculus_id": 43538}), encoding="utf-8"
- )
- _write_jsonl(
- qdir / "traces_forecast_1.jsonl",
- [{"type": "tool_result", "content": "https://a.test/x"}],
- )
- records = harvest_run(str(run), bot="mybot")
- assert len(records) == 1
- rec = records[0]
- assert rec.bot == "mybot" # the flat-layout bot override
- assert rec.metaculus_id == "43538" # still read from question.json
-
-
-def test_harvest_run_flat_layout_defaults_bot_to_run_name(tmp_path: Path):
- run = tmp_path / "myrun"
- qdir = run / "q_only"
- qdir.mkdir(parents=True)
- _write_jsonl(
- qdir / "traces_x.jsonl",
- [{"type": "tool_result", "content": "https://a.test/y"}],
- )
- records = harvest_run(str(run)) # no bot= -> defaults to run dir name
- assert [r.bot for r in records] == ["myrun"]
diff --git a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_url_extraction.py b/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_url_extraction.py
deleted file mode 100644
index 443578bb..00000000
--- a/code_tests/unit_tests/test_agents_and_tools/test_source_archive/test_url_extraction.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive.ingest.url_extraction import (
- dedupe_records,
- extract_citation_records,
- extract_urls,
-)
-
-
-def test_extracts_markdown_autolink_and_bare():
- text = (
- "See [the report](https://a.test/report) and "
- "plus bare https://c.test/x for details."
- )
- assert extract_urls(text) == [
- "https://a.test/report",
- "https://b.test/page",
- "https://c.test/x",
- ]
-
-
-def test_trims_trailing_punctuation():
- assert extract_urls("ends a sentence at https://a.test/path.") == [
- "https://a.test/path"
- ]
- assert extract_urls("(see https://a.test/path)") == ["https://a.test/path"]
-
-
-def test_keeps_balanced_parens_in_url():
- text = "https://en.wikipedia.org/wiki/Forecasting_(disambiguation)"
- assert extract_urls(text) == [
- "https://en.wikipedia.org/wiki/Forecasting_(disambiguation)"
- ]
-
-
-def test_dedupes_preserving_order():
- text = "https://a.test x https://b.test y https://a.test"
- assert extract_urls(text) == ["https://a.test", "https://b.test"]
-
-
-def test_strips_trailing_backslash_escape_residue():
- # Markdown often leaves a trailing backslash, e.g. "Zaporizhzhia\"
- assert extract_urls("see https://a.test/search?q=Zaporizhzhia\\ ok") == [
- "https://a.test/search?q=Zaporizhzhia"
- ]
-
-
-def test_cuts_markdown_reference_tail_and_keeps_both_urls():
- # The bare scan can glue ")[10](other)" onto a real URL; the tail is cut so
- # the first URL is clean, and the genuinely-separate second URL (itself a
- # valid markdown link) is still extracted. Order follows pattern precedence
- # (markdown links before bare URLs), so compare as a set.
- text = "https://a.test/story?id=123)[10](https://b.test/other)"
- assert set(extract_urls(text)) == {
- "https://a.test/story?id=123",
- "https://b.test/other",
- }
-
-
-def test_ignores_non_http_and_empty():
- assert extract_urls("ftp://a.test mailto:x@y.test nope") == []
- assert extract_urls(None) == []
- assert extract_urls("") == []
-
-
-def test_extract_citation_records_attaches_provenance():
- records = extract_citation_records(
- "source: https://a.test/r",
- run_id="r1",
- bot="demo",
- question_id="42",
- origin="metaculus_comment",
- )
- assert len(records) == 1
- rec = records[0]
- assert rec.url == "https://a.test/r"
- assert rec.run_id == "r1"
- assert rec.bot == "demo"
- assert rec.question_id == "42"
- assert rec.origin == "metaculus_comment"
-
-
-def test_dedupe_records_keeps_first():
- records = extract_citation_records("https://a.test https://a.test https://b.test")
- deduped = dedupe_records(records)
- assert [r.url for r in deduped] == ["https://a.test", "https://b.test"]
diff --git a/forecasting_tools/agents_and_tools/source_archive/README.md b/forecasting_tools/agents_and_tools/source_archive/README.md
deleted file mode 100644
index 5c368e32..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/README.md
+++ /dev/null
@@ -1,285 +0,0 @@
-# Source Archive
-
-Capture and preserve the web sources a forecasting bot relied on. For every
-unique URL a bot cited, this captures **HTML + a full-page screenshot +
-markdown** in a single page load and stores it with provenance, so a forecast
-can be audited later even if the original page changes or disappears.
-
-## Why this exists
-
-A bot's forecast is only as trustworthy as the sources behind it, and those
-sources rot: pages get edited, paywalled, or deleted. This package snapshots
-each cited URL at the time it was used.
-
-It is built to be cheap at scale. Two ideas do the heavy lifting:
-
-- **Self-hosted rendering.** A single headless-Chromium page load produces all
- three artifacts (HTML, screenshot, markdown), at a tiny fraction of the cost
- of managed scraping APIs. A hosted fallback (Firecrawl) is used only for sites
- that block headless browsers.
-- **A content store with a TTL cache.** Bots re-forecast the same open question
- every 20–30 minutes for weeks, citing the same pages each time. The store is
- keyed by `url + content-hash`: a URL captured within the TTL is *not* refetched,
- and identical content is *not* re-stored. So the first capture costs real money
- and every re-run is nearly free.
-
-## Install
-
-The backends are optional, so they aren't pulled in by a default install:
-
-```bash
-pip install "forecasting-tools[source-archive]"
-playwright install chromium # one-time browser download
-```
-
-## Configure
-
-Configuration is read from the environment (see the project `.env.template`):
-
-| Variable | Purpose | Default |
-| --- | --- | --- |
-| `WEB_ARCHIVE_S3_BUCKET` | Destination S3 bucket. Blank → store locally. | — |
-| `WEB_ARCHIVE_S3_PREFIX` | Key prefix within the bucket. | `source-archive` |
-| `WEB_ARCHIVE_AWS_PROFILE` | Named AWS profile (e.g. an SSO profile). | default chain |
-| `WEB_ARCHIVE_TTL_DAYS` | Days before a cached capture is refetched. | `14` |
-| `FIRECRAWL_API_KEY` | Enables the Firecrawl fallback. | — (fallback off) |
-| `WEB_ARCHIVE_FIRECRAWL_PROXY` | Firecrawl proxy mode for hardened sites: `basic` (1 credit) / `auto` / `stealth` (5 credits). | `basic` |
-| `HYPERBROWSER_API_KEY` | Enables the Hyperbrowser managed fallback. | — (off) |
-| `WEB_ARCHIVE_CLOAKBROWSER_IMPORT` | Module exposing CloakBrowser's `launch()`. | `cloakbrowser` |
-| `WEB_ARCHIVE_PDF_MAX_PAGES` | Cap on PDF pages parsed per document. | `50` |
-
-AWS credentials use the standard AWS resolution chain — environment variables, a
-shared config file, or an SSO profile. Nothing secret is committed or baked into
-the code.
-
-## Use it from Python
-
-```python
-from forecasting_tools.agents_and_tools.source_archive import (
- ArchiveConfig, CapturePipeline, ContentStore, build_default_fetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage import (
- LocalBlobStore, S3BlobStore,
-)
-
-config = ArchiveConfig.from_env()
-
-# Store locally while experimenting...
-store = ContentStore(LocalBlobStore("./archive"), config)
-# ...or to S3 in production:
-# store = ContentStore(S3BlobStore(config.s3_bucket, config=config), config)
-
-with build_default_fetcher(config) as fetcher:
- summary = CapturePipeline(fetcher, store).run([
- "https://example.com",
- "https://www.federalregister.gov/",
- ])
-
-print(summary)
-# PipelineSummary(total=2, cache_hit=0, stored=2, deduped=0, quality_failed=0, error=0)
-```
-
-## Use it from the command line
-
-```bash
-# Inspect the resolved configuration (secrets are masked)
-source-archive check
-
-# Capture every URL in a manifest, storing locally (no AWS needed)
-source-archive capture run.jsonl --local ./archive
-
-# Capture and upload to S3 (uses WEB_ARCHIVE_S3_BUCKET), plus the manifest itself
-source-archive capture run.jsonl --upload-manifest --run-id 2026-06-01_demo
-
-# Skip the Hyperbrowser fallback this run; failures are written to a retry
-# manifest so you can come back to just those sites later (e.g. with it on).
-source-archive capture run.jsonl --no-hyperbrowser --run-id demo
-source-archive capture demo_needs_retry.jsonl --run-id demo # later, hyperbrowser on
-
-# Build a manifest by harvesting the URLs bots cited on a Metaculus tournament
-source-archive harvest 32506 --out run.jsonl
-```
-
-Because a failed fetch leaves no cache entry while a success does, re-running the
-same manifest only re-attempts the failures — the retry manifest just makes that
-explicit and fast (it skips the already-captured majority).
-
-`source-archive` is installed by the extra; the equivalent module form is
-`python -m forecasting_tools.agents_and_tools.source_archive.cli`.
-
-## Backup backends & the bake-off
-
-A self-hosted browser is the primary backend and gets ~70% of URLs for ~free,
-but two kinds of URL fall through it: **anti-bot/Cloudflare** pages (it detects
-the block but can't get past it) and **PDFs** (Chromium downloads them instead of
-rendering, so nothing is captured). The package ships these backups, ordered by
-marginal cost so the cheap tiers absorb most of the tail:
-
-| Backend | Cost (2026) | Closes | Notes |
-| --- | --- | --- | --- |
-| `CloakBrowserFetcher` | ~$0/page (self-host) | Cloudflare | **The primary browser tier when installed** (`pip install cloakbrowser`): patched Chromium that beat vanilla Playwright on Cloudflare in 2026 benchmarks. Only one browser runs — cloak *replaces* vanilla Playwright (two `sync_playwright` instances conflict in one process), falling back to vanilla when cloak isn't installed. |
-| `PdfFetcher` | $0 local; ~$0.0008/pg OCR | PDFs | PyMuPDF4LLM locally, falls back to Firecrawl OCR on scanned PDFs. |
-| `FirecrawlFetcher` | $0.0008 basic / $0.0042 stealth | Cloudflare + PDFs | Native PDF parser; `WEB_ARCHIVE_FIRECRAWL_PROXY=stealth` for hardened sites. |
-| `HyperbrowserFetcher` | $0.001 basic / $0.01 proxy | Cloudflare | Consolidates spend onto a vendor already used elsewhere. No PDF support. |
-
-Selenium was evaluated and **rejected**: it drives the same Chromium as
-Playwright, so it bypasses nothing Playwright can't, and its stealth ecosystem
-(`undetected-chromedriver`) is now legacy. CloakBrowser/Patchright/nodriver are
-the credible self-hosted upgrades.
-
-To decide which backup(s) to wire in, run the bake-off — it runs each selected
-backend independently over the same URLs (not tiered) and reports reliability,
-latency, and estimated cost per backend, broken down by category:
-
-```bash
-python -m forecasting_tools.agents_and_tools.source_archive.benchmark \
- --manifest forecasting_tools/agents_and_tools/source_archive/benchmarks/sample_urls.jsonl \
- --backends playwright,cloakbrowser,firecrawl,firecrawl-stealth,hyperbrowser,pdf \
- --out bench.csv
-```
-
-Backends whose API key or dependency is missing are skipped cleanly. Cost
-figures are model estimates (see `PRICING` in `benchmark.py`); tune the credit
-rates with `--firecrawl-credit-usd` / `--hyperbrowser-credit-usd` to match your
-plan. Swap the sample manifest for a JSONL of your own cited URLs (one
-`{"url", "category"}` per line; categories `normal`/`cloudflare`/`pdf`) for a
-representative run.
-
-## Browse what you captured
-
-A Streamlit viewer reads the manifests + index back out of the store and shows
-each captured URL's **screenshot, markdown, and HTML** side by side, filterable
-by bot and question:
-
-```bash
-AWS_PROFILE=default WEB_ARCHIVE_S3_BUCKET=my-web-archive \
- streamlit run forecasting_tools/agents_and_tools/source_archive/viewer.py
-```
-
-It uses the same `ArchiveConfig.from_env()` settings as capture, so it points at
-whatever bucket/prefix you captured to (no extra configuration).
-
-To browse a **local** capture (no S3/AWS), set `WEB_ARCHIVE_LOCAL_DIR` to the
-directory you captured into with `--local`:
-
-```bash
-WEB_ARCHIVE_LOCAL_DIR=./archive \
- streamlit run forecasting_tools/agents_and_tools/source_archive/viewer.py
-```
-
-## The catalog: a browsable, coworker-legible view
-
-The viewer is interactive (good for us); the **catalog** is a set of static
-HTML/CSV pages written into the bucket so a non-technical coworker can browse the
-sources without any tooling. It is **question-primary** — the encyclopedia of
-every web source used for a question — plus `by-bot/` and `by-domain/`
-cross-views, built by joining the manifests with the index:
-
-```bash
-# write catalog/ into the bucket (uses WEB_ARCHIVE_S3_BUCKET)
-source-archive catalog
-# or against a local capture dir
-source-archive catalog --local ./archive
-```
-
-Start at `catalog/index.html` (or `catalog/READ_ME_FIRST.html` for the plain
-explainer). Each source shows its screenshot, who used it (bot + tool), and
-whether it was captured; each question also has a CSV. Data/API calls (a bot's
-`run_code` pulling a CSV, etc.) are **excluded** from the catalog — it lists web
-pages a bot read, not data endpoints — though they remain in the raw manifests.
-
-## Coverage: what fraction did we archive?
-
-The catalog shows what we *have*; the **coverage report** shows what we're
-*missing*. It's two separate reports, by ingestion path — different denominators,
-different ground truth:
-
-```bash
-source-archive coverage # both reports
-source-archive coverage --mode trace # just the complex/template bot
-source-archive coverage --csv ./cov # also write cov_.csv (+ _missing.txt)
-```
-
-- **trace** — the complex/template bot's instrumented runs (metac-ai-sdk). Traces
- hold *every* URL the bot touched, so this is a true archival success-rate.
-- **comments** — every bot (Metaculus's own + outsiders) harvested from public
- comments. Comments are truncated, so this denominator under-counts — coverage
- here means "of the links visible in comments, how many we archived."
-
-The report is oriented to one question: **are there sources bots are using that
-we are not yet archiving?** It leads with that gap, then breaks it down by
-question, bot, tool, and the biggest-gap sites, plus the list of sources to
-collect. Non-source URLs — search-engine results, `run_code`-style tool/API
-calls, and malformed extractor junk — are excluded (same as the catalog).
-
-If capture runs have persisted their outcomes (`reports/.json`, written
-automatically by `capture`), the gap is split into **never fetched** (the real
-collection gap) vs **fetched but failed** (a capture problem).
-
-## The manifest: what to feed it
-
-A run produces a **citation manifest** — a JSONL file with one record per cited
-URL. Only `url` is required; the rest is provenance you fill in where you have it:
-
-```json
-{"url": "https://example.com/report", "run_id": "2026-06-01_demo", "bot": "my-bot", "question_id": "1234", "question_url": "https://www.metaculus.com/questions/1234/", "tool_name": "web_search", "origin": "research"}
-```
-
-The pipeline dedupes URLs within the manifest before fetching.
-
-## Where the manifest comes from
-
-You write a manifest yourself, or generate one from a bot's recorded reasoning.
-
-**From text.** `extract_urls(text)` / `extract_citation_records(...)` in
-`ingest.url_extraction` pull URLs out of any markdown/text (markdown links,
-autolinks, and bare URLs) — point them at whatever record of a bot's reasoning
-you have.
-
-**From instrumented traces.** For bots you control, a trace is the fullest
-source. `source-archive ingest-traces ` walks a traced run and emits a
-manifest of every URL the bot touched, with provenance (trace, tool, search
-query).
-
-## How it's organized
-
-| Module | Responsibility |
-| --- | --- |
-| `config.py` | Environment-driven `ArchiveConfig` |
-| `models.py` | `CaptureResult`, `StoredCapture`, `CitationRecord` |
-| `ingest/` | Build a manifest: URL extraction from text + traced bot runs |
-| `fetchers/` | Playwright (primary) + CloakBrowser / PDF / Firecrawl / Hyperbrowser backups, tiered orchestrator |
-| `benchmark.py` | Backend bake-off: reliability + cost per backend over a manifest |
-| `quality.py` | Reject 404s, block pages, and thin content before archiving |
-| `storage/` | `BlobStore` interface with S3 and local backends |
-| `content_store.py` | `url + content-hash` store with the TTL cache and dedup |
-| `manifest.py` | Read/write citation manifests |
-| `pipeline.py` | `lookup → fetch → quality gate → store` |
-| `cli.py` | `source-archive` command |
-
-## Roadmap
-
-Planned and shipped improvements — smarter dedup (URL canonicalization +
-redirect/content aliasing), the coworker-legible catalog, and coverage reports —
-are written up in [ROADMAP.md](ROADMAP.md).
-
-## What lands in storage
-
-```
-/index/.json per-URL capture history (+ aliases)
-/index/by-content/.json reverse index for content dedup
-/content//.html
-/content//.webp (screenshot)
-/content//.md
-/manifests//.jsonl the run's citation manifest
-/reports//.json per-URL capture outcomes (for coverage)
-/reports//_cost.json the run's estimated cost breakdown
-/catalog/index.html browsable catalog (by question/bot/site)
-/catalog/by-question/.{html,csv}
-```
-
-`` keeps manifests/reports from piling into one flat folder: it is
-`daily/` for `daily-YYYY-MM-DD` run ids, `adhoc` otherwise, or
-whatever you pin with `--group` (e.g. `--group sprints/myrun`). Readers list by
-prefix (and fall back to the old flat keys), so archives written before this
-layout keep working unchanged.
diff --git a/forecasting_tools/agents_and_tools/source_archive/__init__.py b/forecasting_tools/agents_and_tools/source_archive/__init__.py
deleted file mode 100644
index 5ede914d..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/__init__.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Source Archive — capture and store the web sources a forecasting bot cited.
-
-For every unique URL a bot used, this captures **HTML + screenshot + markdown**
-in a single page load and stores it with provenance, deduplicated by
-``url + content-hash`` so re-runs of the same question are nearly free.
-
-Quick start (see ``README.md`` in this package for the full guide)::
-
- from forecasting_tools.agents_and_tools.source_archive import (
- ArchiveConfig, CapturePipeline, ContentStore, build_default_fetcher,
- )
- from forecasting_tools.agents_and_tools.source_archive.storage import LocalBlobStore
-
- config = ArchiveConfig.from_env()
- store = ContentStore(LocalBlobStore("./archive"), config)
- with build_default_fetcher(config) as fetcher:
- summary = CapturePipeline(fetcher, store).run(["https://example.com"])
- print(summary)
-
-The heavy backends (Playwright, boto3, Firecrawl, trafilatura) are optional;
-install them with ``pip install forecasting-tools[source-archive]``.
-"""
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import (
- ContentStore,
- StoreResult,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers import (
- build_default_fetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.ingest import extract_urls
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CaptureResult,
- CitationRecord,
- StoredCapture,
-)
-from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- CaptureOutcome,
- CapturePipeline,
- PipelineSummary,
-)
-
-__all__ = [
- "ArchiveConfig",
- "CaptureOutcome",
- "CaptureResult",
- "CapturePipeline",
- "CitationRecord",
- "ContentStore",
- "PipelineSummary",
- "StoreResult",
- "StoredCapture",
- "build_default_fetcher",
- "extract_urls",
-]
diff --git a/forecasting_tools/agents_and_tools/source_archive/benchmark.py b/forecasting_tools/agents_and_tools/source_archive/benchmark.py
deleted file mode 100644
index 76d79083..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/benchmark.py
+++ /dev/null
@@ -1,459 +0,0 @@
-"""Backend bake-off: run each capture backend independently over the same URLs.
-
-This is the harness for deciding *which* backup to put behind Playwright. Unlike
-the production :class:`TieredFetcher` (which stops at the first backend that
-passes the quality gate), the benchmark runs **every** selected backend over
-**every** URL, so you get an apples-to-apples table of reliability, latency, and
-estimated cost per backend — broken down by URL category (normal / cloudflare /
-pdf).
-
-Run it::
-
- python -m forecasting_tools.agents_and_tools.source_archive.benchmark \\
- --manifest sample_urls.jsonl \\
- --backends playwright,cloakbrowser,firecrawl,firecrawl-stealth,hyperbrowser,pdf \\
- --out bench.csv
-
-A backend whose dependency or API key is missing is skipped with a note rather
-than failing the whole run, so you can benchmark whatever you have configured.
-
-Cost figures are ESTIMATES from a documented pricing model (see ``PRICING``,
-sourced 2026-06); they are not billed amounts. Override the credit rates via
-CLI flags to match your plan.
-"""
-
-from __future__ import annotations
-
-import argparse
-import csv
-import io
-import json
-import logging
-import statistics
-import sys
-import time
-from contextlib import nullcontext
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Callable
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import (
- Fetcher,
- FetchError,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.cloakbrowser_fetcher import (
- CloakBrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.firecrawl_fetcher import (
- FirecrawlFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.hyperbrowser_fetcher import (
- HyperbrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.pdf_fetcher import (
- PdfFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.playwright_fetcher import (
- PlaywrightFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-from forecasting_tools.agents_and_tools.source_archive.quality import evaluate
-
-logger = logging.getLogger(__name__)
-
-GB = 1_000_000_000
-
-# --- Pricing model -----------------------------------------------------------
-# $/unit as of 2026-06, from each vendor's public pricing + this repo's prior
-# cost experiment. These are the knobs to adjust for your plan.
-#
-# - Self-hosted compute (Playwright / CloakBrowser): ~$0.00001/page rendered
-# (measured in bot-sources probe). Marginal service fee is effectively $0.
-# - Firecrawl: 1 credit basic, 5 credits stealth/"enhanced" proxy. Standard
-# plan ≈ $0.00083/credit.
-# - Hyperbrowser: 1 credit ($0.001) basic, 10 credits ($0.01) with proxy,
-# plus $10/GB proxy bandwidth. 1 credit = $0.001.
-# - PDF: PyMuPDF4LLM local = $0; Firecrawl OCR fallback = ~1 credit/PDF page.
-
-
-@dataclass
-class Pricing:
- self_host_per_page: float = 0.00001
- firecrawl_credit_usd: float = 0.00083
- firecrawl_basic_credits: int = 1
- firecrawl_stealth_credits: int = 5
- hyperbrowser_credit_usd: float = 0.001
- hyperbrowser_basic_credits: int = 1
- hyperbrowser_proxy_credits: int = 10
- hyperbrowser_bandwidth_usd_per_gb: float = 10.0
-
-
-def estimate_cost(
- backend: str, result: CaptureResult, response_bytes: int, pricing: Pricing
-) -> float:
- """Estimated $ for one successful capture by ``backend``."""
- meta = result.metadata or {}
- if backend in ("playwright", "cloakbrowser"):
- return pricing.self_host_per_page
- if backend.startswith("firecrawl"):
- proxy = str(meta.get("firecrawl_proxy", "basic")).lower()
- credits = (
- pricing.firecrawl_basic_credits
- if proxy in ("", "basic")
- else pricing.firecrawl_stealth_credits
- )
- return credits * pricing.firecrawl_credit_usd
- if backend == "hyperbrowser":
- credits = (
- pricing.hyperbrowser_proxy_credits
- if meta.get("used_proxy")
- else pricing.hyperbrowser_basic_credits
- )
- bandwidth = (response_bytes / GB) * pricing.hyperbrowser_bandwidth_usd_per_gb
- return credits * pricing.hyperbrowser_credit_usd + bandwidth
- if backend == "pdf":
- if meta.get("pdf_engine") == "firecrawl":
- pages = int(meta.get("pdf_pages") or 1)
- return pages * pricing.firecrawl_credit_usd
- return 0.0 # local PyMuPDF4LLM
- return 0.0
-
-
-# --- Backend registry --------------------------------------------------------
-# Factories so a missing dependency / API key only skips that backend. The
-# ``context`` flag marks browser backends that must be entered as a context
-# manager (the browser launches once and is reused across URLs).
-
-
-@dataclass
-class BackendSpec:
- name: str
- factory: Callable[[ArchiveConfig], Fetcher]
- context: bool = False
- # Optional pre-flight: return a reason string if the backend can't run
- # (missing key/dep) so the bake-off reports a clean SKIP instead of N/N
- # fetch_errors. ``None`` means "looks runnable".
- precheck: Callable[[ArchiveConfig], str | None] | None = None
-
-
-def _need_firecrawl_key(config: ArchiveConfig) -> str | None:
- if not config.firecrawl_api_key:
- return "FIRECRAWL_API_KEY not set"
- return None
-
-
-def _need_hyperbrowser_key(config: ArchiveConfig) -> str | None:
- if not config.hyperbrowser_api_key:
- return "HYPERBROWSER_API_KEY not set"
- return None
-
-
-def _firecrawl_stealth(config: ArchiveConfig) -> FirecrawlFetcher:
- # Force the proxy/stealth path so this row measures the Cloudflare-grade
- # (5-credit) cost, even if the operator left the default at "basic".
- proxy = config.firecrawl_proxy
- if proxy in ("", "basic"):
- proxy = "auto"
- f = FirecrawlFetcher(config.model_copy(update={"firecrawl_proxy": proxy}))
- f.name = "firecrawl-stealth"
- return f
-
-
-BACKENDS: dict[str, BackendSpec] = {
- "playwright": BackendSpec("playwright", PlaywrightFetcher, context=True),
- "cloakbrowser": BackendSpec("cloakbrowser", CloakBrowserFetcher, context=True),
- "firecrawl": BackendSpec(
- "firecrawl", FirecrawlFetcher, precheck=_need_firecrawl_key
- ),
- "firecrawl-stealth": BackendSpec(
- "firecrawl-stealth", _firecrawl_stealth, precheck=_need_firecrawl_key
- ),
- "hyperbrowser": BackendSpec(
- "hyperbrowser", HyperbrowserFetcher, precheck=_need_hyperbrowser_key
- ),
- "pdf": BackendSpec("pdf", PdfFetcher),
-}
-
-
-# --- Sample manifest ---------------------------------------------------------
-# A curated starter set spanning the three categories the backup must handle.
-# Replace/extend with your own real cited URLs for a representative run.
-SAMPLE_MANIFEST: list[dict] = [
- {"url": "https://example.com", "category": "normal"},
- {"url": "https://en.wikipedia.org/wiki/Forecasting", "category": "normal"},
- {"url": "https://www.federalregister.gov/", "category": "normal"},
- # Sites commonly fronted by Cloudflare / anti-bot:
- {"url": "https://www.g2.com/", "category": "cloudflare"},
- {"url": "https://www.indeed.com/", "category": "cloudflare"},
- {"url": "https://www.zillow.com/", "category": "cloudflare"},
- # PDFs (the gap Playwright can't render):
- {"url": "https://arxiv.org/pdf/1706.03762", "category": "pdf"},
- {"url": "https://bitcoin.org/bitcoin.pdf", "category": "pdf"},
-]
-
-
-@dataclass
-class Row:
- backend: str
- url: str
- category: str
- passed: bool
- reason: str
- seconds: float
- html_bytes: int
- md_bytes: int
- screenshot_bytes: int
- cost_usd: float
- error: str = ""
-
-
-@dataclass
-class BackendRun:
- name: str
- rows: list[Row] = field(default_factory=list)
- skipped: str = ""
-
-
-def _sizes(result: CaptureResult) -> tuple[int, int, int]:
- html = len(result.html.encode()) if result.html else 0
- md = len(result.markdown.encode()) if result.markdown else 0
- shot = len(result.screenshot) if result.screenshot else 0
- return html, md, shot
-
-
-def run_backend(
- spec: BackendSpec,
- manifest: list[dict],
- config: ArchiveConfig,
- pricing: Pricing,
-) -> BackendRun:
- run = BackendRun(name=spec.name)
- if spec.precheck is not None:
- reason = spec.precheck(config)
- if reason:
- run.skipped = reason
- logger.warning("%s skipped: %s", spec.name, reason)
- return run
- try:
- fetcher = spec.factory(config)
- except Exception as e: # construction (e.g. missing key) — skip cleanly
- run.skipped = f"could not construct {spec.name}: {e}"
- logger.warning(run.skipped)
- return run
-
- cm = fetcher if spec.context else nullcontext(fetcher)
- try:
- with cm as live:
- for record in manifest:
- run.rows.append(_capture_one(spec.name, live, record, pricing))
- except FetchError as e:
- # A browser backend can fail to even start (e.g. cloakbrowser not
- # installed). Record it as a skip rather than crashing the bake-off.
- if not run.rows:
- run.skipped = f"{spec.name} unavailable: {e}"
- logger.warning(run.skipped)
- else:
- raise
- return run
-
-
-def _capture_one(backend: str, fetcher: Fetcher, record: dict, pricing: Pricing) -> Row:
- url = record["url"]
- category = record.get("category", "normal")
- start = time.monotonic()
- try:
- result = fetcher.fetch(url)
- except FetchError as e:
- return Row(
- backend,
- url,
- category,
- False,
- "fetch_error",
- round(time.monotonic() - start, 2),
- 0,
- 0,
- 0,
- 0.0,
- error=str(e)[:300],
- )
- except Exception as e: # backend bug / unexpected SDK error
- return Row(
- backend,
- url,
- category,
- False,
- "exception",
- round(time.monotonic() - start, 2),
- 0,
- 0,
- 0,
- 0.0,
- error=str(e)[:300],
- )
-
- seconds = round(time.monotonic() - start, 2)
- verdict = evaluate(result)
- html_b, md_b, shot_b = _sizes(result)
- response_bytes = html_b + shot_b
- cost = (
- estimate_cost(backend, result, response_bytes, pricing)
- if verdict.passed
- else 0.0
- )
- return Row(
- backend,
- url,
- category,
- verdict.passed,
- verdict.reason or "ok",
- seconds,
- html_b,
- md_b,
- shot_b,
- round(cost, 6),
- )
-
-
-# --- Reporting ---------------------------------------------------------------
-def write_csv(path: str, runs: list[BackendRun]) -> None:
- buf = io.StringIO()
- w = csv.writer(buf)
- w.writerow(
- [
- "backend",
- "url",
- "category",
- "passed",
- "reason",
- "seconds",
- "html_bytes",
- "md_bytes",
- "screenshot_bytes",
- "cost_usd",
- "error",
- ]
- )
- for run in runs:
- for r in run.rows:
- w.writerow(
- [
- r.backend,
- r.url,
- r.category,
- r.passed,
- r.reason,
- r.seconds,
- r.html_bytes,
- r.md_bytes,
- r.screenshot_bytes,
- r.cost_usd,
- r.error,
- ]
- )
- Path(path).write_text(buf.getvalue(), encoding="utf-8")
-
-
-def summarize(runs: list[BackendRun], urls_per_question: int, tail_share: float) -> str:
- cats = ["normal", "cloudflare", "pdf"]
- lines = []
- header = (
- f"{'backend':<18}{'overall':>9}"
- + "".join(f"{c:>11}" for c in cats)
- + f"{'med s':>8}{'$/page':>10}{'proj $/q':>10}"
- )
- lines.append(header)
- lines.append("-" * len(header))
- for run in runs:
- if run.skipped:
- lines.append(f"{run.name:<18} SKIPPED: {run.skipped[:80]}")
- continue
- total = len(run.rows)
- passed = [r for r in run.rows if r.passed]
- overall = f"{len(passed)}/{total}"
-
- def cat_rate(cat: str) -> str:
- rows = [r for r in run.rows if r.category == cat]
- if not rows:
- return "-"
- ok = sum(1 for r in rows if r.passed)
- return f"{ok}/{len(rows)}"
-
- med = statistics.median([r.seconds for r in run.rows]) if run.rows else 0
- cost_per = statistics.mean([r.cost_usd for r in passed]) if passed else 0.0
- # Illustrative: if THIS backend alone handled the whole post-Playwright
- # tail of a question. (tail_share × urls × $/successful page.)
- proj = tail_share * urls_per_question * cost_per
- lines.append(
- f"{run.name:<18}{overall:>9}"
- + "".join(f"{cat_rate(c):>11}" for c in cats)
- + f"{med:>8.1f}{cost_per:>10.5f}{proj:>10.3f}"
- )
- note = (
- f"\nproj $/q assumes one backend covers a {tail_share:.0%} tail of "
- f"{urls_per_question} URLs/question, BEFORE the TTL cache (which makes "
- f"re-runs nearly free). Costs are model estimates, not billed amounts."
- )
- return "\n".join(lines) + "\n" + note
-
-
-def load_manifest(path: str | None) -> list[dict]:
- if not path:
- return SAMPLE_MANIFEST
- records = []
- for line in Path(path).read_text(encoding="utf-8").splitlines():
- line = line.strip()
- if line:
- records.append(json.loads(line))
- return records
-
-
-def main(argv: list[str] | None = None) -> int:
- logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")
- p = argparse.ArgumentParser(description="Capture-backend bake-off.")
- p.add_argument(
- "--manifest", help="JSONL of {url, category}. Omit for the built-in sample."
- )
- p.add_argument(
- "--backends",
- default="playwright,cloakbrowser,firecrawl,firecrawl-stealth,hyperbrowser,pdf",
- help="Comma-separated subset of: " + ", ".join(BACKENDS),
- )
- p.add_argument("--out", default="benchmark.csv", help="CSV output path.")
- p.add_argument("--urls-per-question", type=int, default=450)
- p.add_argument(
- "--tail-share",
- type=float,
- default=0.30,
- help="Fraction of URLs that fall through Playwright.",
- )
- p.add_argument("--firecrawl-credit-usd", type=float, default=0.00083)
- p.add_argument("--hyperbrowser-credit-usd", type=float, default=0.001)
- args = p.parse_args(argv)
-
- config = ArchiveConfig.from_env()
- pricing = Pricing(
- firecrawl_credit_usd=args.firecrawl_credit_usd,
- hyperbrowser_credit_usd=args.hyperbrowser_credit_usd,
- )
- manifest = load_manifest(args.manifest)
-
- selected = [b.strip() for b in args.backends.split(",") if b.strip()]
- unknown = [b for b in selected if b not in BACKENDS]
- if unknown:
- p.error(f"unknown backends: {unknown}. Choose from {list(BACKENDS)}")
-
- runs: list[BackendRun] = []
- for name in selected:
- print(f"running {name} over {len(manifest)} URLs...", file=sys.stderr)
- runs.append(run_backend(BACKENDS[name], manifest, config, pricing))
-
- write_csv(args.out, runs)
- print("\n" + summarize(runs, args.urls_per_question, args.tail_share))
- print(f"\nper-URL detail written to {args.out}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/forecasting_tools/agents_and_tools/source_archive/canonicalize.py b/forecasting_tools/agents_and_tools/source_archive/canonicalize.py
deleted file mode 100644
index 7d722c69..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/canonicalize.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""Canonicalize URLs so trivially-different links collapse to one dedup key.
-
-Every capture of a page is grouped under ``url_hash`` (see :mod:`models`).
-Historically that hashed the *raw* URL string, so ``…/x``, ``…/x/``,
-``…/x?utm_source=…`` and ``…/x#frag`` were four different "sources" — inflating
-both storage and any "how many sources have we covered" count.
-
-This module normalizes away differences that do **not** change *which page* you
-get, so the dedup key is stable across those variants:
-
- - lowercase scheme + host, strip a default port (``:80`` / ``:443``)
- - drop the fragment (``#…``)
- - drop known analytics / click-tracking query params, then sort the rest
- - normalize a trailing slash (``…/x/`` -> ``…/x``; root collapses to no path)
-
-It is deliberately conservative. It does **not** upgrade ``http`` -> ``https`` or
-strip ``www.``: those can resolve to genuinely different pages on some hosts, so
-collapsing them belongs to a later, opt-in phase (see ``ROADMAP.md``).
-"""
-
-from __future__ import annotations
-
-from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
-
-# Query params that are analytics/click tracking and never select the page.
-# Matched case-insensitively; any key starting with a prefix below is also
-# dropped. Bare ``ref`` / ``source`` are intentionally left alone — they are too
-# often load-bearing (API refs, content selectors) to drop blindly.
-_TRACKING_PARAMS = frozenset(
- {
- "gclid",
- "gclsrc",
- "dclid",
- "gbraid",
- "wbraid",
- "fbclid",
- "msclkid",
- "yclid",
- "twclid",
- "mc_eid",
- "mc_cid",
- "_hsenc",
- "_hsmi",
- "igshid",
- "igsh",
- "vero_id",
- "vero_conv",
- "oly_anon_id",
- "oly_enc_id",
- "spm",
- "scm",
- "ref_src",
- "ref_url",
- }
-)
-_TRACKING_PREFIXES = ("utm_",)
-
-_DEFAULT_PORTS = {"http": "80", "https": "443"}
-
-
-def _is_tracking(key: str) -> bool:
- k = key.lower()
- return k in _TRACKING_PARAMS or any(k.startswith(p) for p in _TRACKING_PREFIXES)
-
-
-def canonicalize_url(url: str) -> str:
- """Return a normalized form of ``url`` to use as a dedup key.
-
- Idempotent — ``canonicalize_url(canonicalize_url(u)) == canonicalize_url(u)``.
- Non-http(s) or unparsable input is returned stripped but otherwise as-is
- (e.g. ``mailto:``, relative paths), so callers can pass anything safely.
- """
- if not url:
- return url
- raw = url.strip()
- # urlsplit() itself rarely raises; .hostname/.port are LAZY properties that
- # raise ValueError on junk like "http://root{--x:80/" or bad IPv6 — so the
- # guard must cover the whole netloc normalization, not just the split.
- try:
- parts = urlsplit(raw)
- if parts.scheme not in ("http", "https") or not parts.netloc:
- return raw
-
- scheme = parts.scheme.lower()
-
- # netloc: lowercase host (bracket IPv6), keep userinfo, strip default
- # port.
- host = (parts.hostname or "").lower()
- if ":" in host: # IPv6 literal
- host = f"[{host}]"
- netloc = host
- if parts.username is not None:
- auth = parts.username
- if parts.password is not None:
- auth += f":{parts.password}"
- netloc = f"{auth}@{netloc}"
- if parts.port is not None and str(parts.port) != _DEFAULT_PORTS.get(scheme):
- netloc += f":{parts.port}"
- except ValueError:
- return raw
-
- # path: collapse the bare root to empty; drop a trailing slash otherwise.
- path = parts.path
- if path in ("", "/"):
- path = ""
- elif path.endswith("/"):
- path = path.rstrip("/")
-
- # query: drop tracking params, then sort so order doesn't matter.
- kept = [
- (k, v)
- for k, v in parse_qsl(parts.query, keep_blank_values=True)
- if not _is_tracking(k)
- ]
- kept.sort()
- query = urlencode(kept)
-
- # fragment: always dropped.
- return urlunsplit((scheme, netloc, path, query, ""))
diff --git a/forecasting_tools/agents_and_tools/source_archive/catalog.py b/forecasting_tools/agents_and_tools/source_archive/catalog.py
deleted file mode 100644
index 7712760d..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/catalog.py
+++ /dev/null
@@ -1,571 +0,0 @@
-"""Generate a coworker-legible catalog over the hash-addressed store.
-
-The content store is keyed by URL/content hash — great for dedup, opaque to a
-human browsing the bucket. This builds a browsable ``catalog/`` layer on top by
-joining the citation manifests (who cited what, on which question, with which
-tool) with the per-URL index (what actually got captured). Blobs are never moved
-or duplicated; the catalog only writes small HTML/CSV pointer pages.
-
-Views (question-primary, with two cross-views):
-
- catalog/READ_ME_FIRST.html plain-language explainer for coworkers
- catalog/index.html landing page + headline counts
- catalog/by-question/.html ★ the encyclopedia for one question:
- catalog/by-question/.csv every source, deduped, tagged with the
- bots/tools/queries that used it
- catalog/by-bot/.html one bot's sources across questions
- catalog/by-domain/.html sources grouped by site
-
-The question view is the default because that's how post-mortems think
-("what sources informed question X?"); ``by-bot`` groups a bot's sources
-across questions, and ``by-domain`` groups them by site.
-"""
-
-from __future__ import annotations
-
-import csv
-import html
-import io
-from collections import defaultdict
-from urllib.parse import urlsplit
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive import manifest as manifest_io
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CitationRecord,
- url_hash,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-_UNKNOWN_Q = "unknown-question"
-
-# Tools that fetch data/API endpoints, not human-readable web pages. A URL only
-# ever touched by one of these is a data call (e.g. a bot's run_code pulling a
-# CSV), so it is kept out of the page-oriented catalog (it stays in the raw
-# manifests). A URL also seen via search/page-fetch is treated as a real page.
-_NON_PAGE_TOOLS = {
- "run_code",
- "code",
- "python",
- "run_python",
- "code_interpreter",
- "execute_code",
- "bash",
- "shell",
-}
-
-
-def tool_call_only(citations: list) -> bool:
- """True if a URL was touched *only* by code-execution tools (a data/API call,
- not a page a bot read)."""
- tools = {(c.tool_name or "").lower() for c in citations}
- code_tools = tools & _NON_PAGE_TOOLS
- other_tools = tools - _NON_PAGE_TOOLS - {""}
- return bool(code_tools) and not other_tools
-
-
-def _is_tool_call_only(source: "Source") -> bool:
- return tool_call_only(source.citations)
-
-
-# Search-engine result pages are navigation, not sources — a bot citing a
-# google/duckduckgo search URL hasn't handed us a page worth archiving.
-_SEARCH_HOSTS = {
- "duckduckgo.com",
- "bing.com",
- "search.brave.com",
- "search.yahoo.com",
- "ecosia.org",
- "startpage.com",
- "baidu.com",
- "ask.com",
- "qwant.com",
- "search.marginalia.nu",
- "kagi.com",
-}
-# Percent-encoded junk that means the extractor swallowed markdown / a second URL
-# / control chars into the URL (legacy captures from before extraction hardening).
-_MALFORMED_MARKERS = ("%5b", "%5d", "%5c", "%0a", "%0d", "%28http", "%29%5b")
-
-
-def is_search_url(url: str) -> bool:
- try:
- host = urlsplit(url).netloc.lower()
- except ValueError: # unparsable (see is_malformed_url) — not a search page
- return False
- host = host[4:] if host.startswith("www.") else host
- return host in _SEARCH_HOSTS or host == "google.com" or host.startswith("google.")
-
-
-def is_malformed_url(url: str) -> bool:
- try:
- urlsplit(url)
- except ValueError: # e.g. a bare "http://[" — urlsplit: "Invalid IPv6 URL"
- return True
- low = url.lower()
- return url.count("://") > 1 or any(m in low for m in _MALFORMED_MARKERS)
-
-
-def exclusion_reason(url: str, citations: list) -> str | None:
- """Why a cited URL is kept out of the page catalog / coverage, or ``None`` to
- keep it. ``malformed`` (extractor junk), ``search`` (search-engine results),
- ``tool_call`` (data/API endpoint touched only by code tools)."""
- if is_malformed_url(url):
- return "malformed"
- if is_search_url(url):
- return "search"
- if tool_call_only(citations):
- return "tool_call"
- return None
-
-
-class Citation(BaseModel):
- bot: str | None = None
- question_id: str | None = None
- question_url: str | None = None
- run_id: str | None = None
- tool_name: str | None = None
- origin: str | None = None
- query: str | None = None
- cited_url: str = "" # the original URL as cited (pre-canonicalization)
-
-
-class Source(BaseModel):
- canonical_url: str
- domain: str
- captured: bool = False
- content_hash: str | None = None
- html_key: str | None = None # store-relative (no prefix)
- screenshot_key: str | None = None
- markdown_key: str | None = None
- citations: list[Citation] = []
-
- @property
- def bots(self) -> list[str]:
- return sorted({c.bot for c in self.citations if c.bot})
-
- @property
- def question_ids(self) -> list[str]:
- return sorted({c.question_id for c in self.citations if c.question_id})
-
-
-class CatalogData(BaseModel):
- sources: list[Source] = []
- excluded: dict[str, int] = {} # exclusion reason -> count of URLs dropped
-
- @property
- def hidden_total(self) -> int:
- return sum(self.excluded.values())
-
- def by_question(self) -> dict[str, list[Source]]:
- out: dict[str, list[Source]] = defaultdict(list)
- for s in self.sources:
- qids = s.question_ids or [_UNKNOWN_Q]
- for qid in qids:
- out[qid].append(s)
- return out
-
- def by_bot(self) -> dict[str, list[Source]]:
- out: dict[str, list[Source]] = defaultdict(list)
- for s in self.sources:
- for bot in s.bots or ["(no bot)"]:
- out[bot].append(s)
- return out
-
- def by_domain(self) -> dict[str, list[Source]]:
- out: dict[str, list[Source]] = defaultdict(list)
- for s in self.sources:
- out[s.domain].append(s)
- return out
-
- def question_url(self, qid: str) -> str | None:
- for s in self.sources:
- for c in s.citations:
- if c.question_id == qid and c.question_url:
- return c.question_url
- return None
-
-
-# --------------------------------------------------------------------------- #
-# Build (join manifests + index)
-# --------------------------------------------------------------------------- #
-def _domain(url: str) -> str:
- try:
- host = urlsplit(url).netloc.lower()
- except ValueError: # malformed URL — caller falls back to "(unknown)"
- return ""
- return host[4:] if host.startswith("www.") else host
-
-
-def _strip_prefix(key: str | None, prefix: str) -> str | None:
- if not key:
- return None
- p = prefix.rstrip("/") + "/"
- return key[len(p) :] if key.startswith(p) else key
-
-
-def _latest_capture(store: ContentStore, canonical_url: str) -> dict | None:
- """Return the latest stored capture dict for a URL (ignoring TTL), following
- a redirect alias if present. ``None`` if nothing was ever captured."""
- index = store._read_index(url_hash(canonical_url))
- if not index:
- return None
- if index.get("alias_of"):
- index = store._read_index(index["alias_of"])
- if not index:
- return None
- ch = index.get("latest_content_hash")
- return (index.get("captures") or {}).get(ch)
-
-
-def _load_all_records(store: BlobStore, prefix: str) -> list[CitationRecord]:
- records: list[CitationRecord] = []
- for key in store.list_keys(f"{prefix.rstrip('/')}/manifests/"):
- if not key.endswith(".jsonl"):
- continue
- try:
- records.extend(manifest_io.loads(store.get(key).decode("utf-8")))
- except (UnicodeDecodeError, ValueError):
- continue
- return records
-
-
-def build_sources(store: BlobStore, config: ArchiveConfig) -> list[Source]:
- """Join every manifest with the index into one ``Source`` per canonical URL.
-
- Unfiltered (includes tool/API-call URLs) so other tools — e.g. the coverage
- report — can classify them. The catalog itself filters these out.
- """
- prefix = config.s3_prefix.rstrip("/")
- cstore = ContentStore(store, config)
- records = _load_all_records(store, prefix)
-
- grouped: dict[str, list[CitationRecord]] = defaultdict(list)
- for r in records:
- if r.url:
- grouped[canonicalize_url(r.url)].append(r)
-
- sources: list[Source] = []
- for canonical, recs in sorted(grouped.items()):
- cap = _latest_capture(cstore, canonical)
- source = Source(
- canonical_url=canonical,
- domain=_domain(canonical) or "(unknown)",
- captured=cap is not None,
- content_hash=(cap or {}).get("content_hash"),
- html_key=_strip_prefix((cap or {}).get("html_key"), prefix),
- screenshot_key=_strip_prefix((cap or {}).get("screenshot_key"), prefix),
- markdown_key=_strip_prefix((cap or {}).get("markdown_key"), prefix),
- citations=[
- Citation(
- bot=r.bot,
- question_id=r.question_id or r.metaculus_id,
- question_url=r.question_url,
- run_id=r.run_id,
- tool_name=r.tool_name,
- origin=r.origin,
- query=r.query,
- cited_url=r.url,
- )
- for r in recs
- ],
- )
- sources.append(source)
- return sources
-
-
-def build_catalog(store: BlobStore, config: ArchiveConfig) -> CatalogData:
- sources = build_sources(store, config)
- pages: list[Source] = []
- excluded: dict[str, int] = defaultdict(int)
- for s in sources:
- reason = exclusion_reason(s.canonical_url, s.citations)
- if reason:
- excluded[reason] += 1
- else:
- pages.append(s)
- return CatalogData(sources=pages, excluded=dict(excluded))
-
-
-# --------------------------------------------------------------------------- #
-# Render
-# --------------------------------------------------------------------------- #
-_CSS = """
-body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;margin:0;color:#1a1a1a;background:#fafafa}
-header{background:#1f2937;color:#fff;padding:16px 24px}
-header a{color:#cbd5e1}
-h1{font-size:20px;margin:0 0 4px}
-.wrap{padding:24px;max-width:1100px;margin:0 auto}
-.muted{color:#6b7280}
-.badge{display:inline-block;font-size:11px;padding:1px 7px;border-radius:10px}
-.ok{background:#dcfce7;color:#166534}.no{background:#fee2e2;color:#991b1b}
-.card{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:12px;margin:12px 0;display:flex;gap:12px}
-.card img{width:160px;height:110px;object-fit:cover;object-position:top;border:1px solid #e5e7eb;border-radius:4px;background:#f3f4f6}
-.card .meta{flex:1;min-width:0}
-.card .u{font-weight:600;word-break:break-all}
-.tags{margin-top:6px}
-.tag{display:inline-block;background:#eef2ff;color:#3730a3;font-size:11px;padding:1px 7px;border-radius:10px;margin:2px 4px 2px 0}
-.links a{margin-right:10px;font-size:12px}
-table{border-collapse:collapse;width:100%;background:#fff}
-td,th{border:1px solid #e5e7eb;padding:6px 8px;text-align:left;font-size:13px}
-th{background:#f3f4f6}
-a.grid{display:inline-block;margin:4px 12px 4px 0}
-"""
-
-
-def _esc(s) -> str:
- return html.escape(str(s)) if s is not None else ""
-
-
-def _page(title: str, body: str, rel_root: str) -> str:
- return (
- ""
- f"{_esc(title)}"
- f""
- f"{body}
"
- )
-
-
-class Linker:
- """Turns a store-relative blob key into a link a coworker can open."""
-
- def __init__(self, store: BlobStore, config: ArchiveConfig):
- from forecasting_tools.agents_and_tools.source_archive.storage import (
- S3BlobStore,
- )
-
- self.is_s3 = isinstance(store, S3BlobStore)
- self.bucket = config.s3_bucket
- self.region = config.aws_region
- self.prefix = config.s3_prefix.rstrip("/")
-
- def url(self, rel_key: str | None, rel_root: str) -> str | None:
- if not rel_key:
- return None
- if self.is_s3:
- host = (
- f"{self.bucket}.s3.{self.region}.amazonaws.com"
- if self.region
- else f"{self.bucket}.s3.amazonaws.com"
- )
- return f"https://{host}/{self.prefix}/{rel_key}"
- return f"{rel_root}{rel_key}" # local: relative within the prefix dir
-
-
-def _source_card(s: Source, linker: Linker, rel_root: str) -> str:
- shot = linker.url(s.screenshot_key, rel_root)
- html_link = linker.url(s.html_key, rel_root)
- md_link = linker.url(s.markdown_key, rel_root)
- badge = (
- "captured"
- if s.captured
- else "not captured"
- )
- img = (
- f"
"
- if shot
- else ""
- )
- tools = sorted({c.tool_name for c in s.citations if c.tool_name})
- tags = "".join(f"{_esc(b)}" for b in s.bots)
- tool_tags = "".join(f"{_esc(t)}" for t in tools)
- links = []
- if html_link:
- links.append(f"HTML")
- if md_link:
- links.append(f"markdown")
- if shot:
- links.append(f"screenshot")
- links.append(f"live ↗")
- return (
- f""
- )
-
-
-def _question_csv(sources: list[Source]) -> str:
- buf = io.StringIO()
- w = csv.writer(buf)
- w.writerow(["url", "domain", "captured", "bots", "tools", "screenshot_key"])
- for s in sources:
- tools = sorted({c.tool_name for c in s.citations if c.tool_name})
- w.writerow(
- [
- s.canonical_url,
- s.domain,
- "yes" if s.captured else "no",
- "; ".join(s.bots),
- "; ".join(tools),
- s.screenshot_key or "",
- ]
- )
- return buf.getvalue()
-
-
-# --------------------------------------------------------------------------- #
-# Write
-# --------------------------------------------------------------------------- #
-class CatalogSummary(BaseModel):
- sources: int = 0
- captured: int = 0
- questions: int = 0
- bots: int = 0
- domains: int = 0
- excluded: dict[str, int] = {}
-
- def __str__(self) -> str:
- excl = sum(self.excluded.values())
- breakdown = (
- " (" + ", ".join(f"{k}={v}" for k, v in sorted(self.excluded.items())) + ")"
- if self.excluded
- else ""
- )
- return (
- f"Catalog: {self.sources} page sources ({self.captured} captured) across "
- f"{self.questions} questions, {self.bots} bots, {self.domains} domains "
- f"— {excl} non-page URLs excluded{breakdown}"
- )
-
-
-def _slug(value: str) -> str:
- # Keep dots so domains stay readable (a.test.html); collapse anything else.
- keep = [c if c.isalnum() or c in "-_." else "-" for c in value]
- out = "".join(keep).strip("-.").replace("..", ".")[:80]
- return out or "x"
-
-
-def write_catalog(
- store: BlobStore,
- config: ArchiveConfig,
- out_store: BlobStore | None = None,
-) -> CatalogSummary:
- """Build the catalog from ``store`` and write it to ``out_store`` (default:
- ``store``). Pass a separate ``out_store`` to preview a live bucket's catalog
- into a local directory without mutating the bucket."""
- prefix = config.s3_prefix.rstrip("/")
- data = build_catalog(store, config)
- out = out_store or store
- linker = Linker(out, config)
-
- def put(rel: str, body: str, ctype: str) -> None:
- out.put(f"{prefix}/catalog/{rel}", body.encode("utf-8"), content_type=ctype)
-
- by_q = data.by_question()
- by_b = data.by_bot()
- by_d = data.by_domain()
-
- # Per-question pages (the encyclopedia) + CSVs. rel_root: catalog// -> ../../
- rr2 = "../../"
- for qid, sources in sorted(by_q.items()):
- sources = sorted(sources, key=lambda s: s.canonical_url)
- qurl = data.question_url(qid)
- head = f"Question {_esc(qid)}
"
- if qurl:
- head += f"{_esc(qurl)} ↗
"
- head += (
- f"{len(sources)} source(s); "
- f"{sum(s.captured for s in sources)} captured · "
- f"download CSV
"
- )
- cards = "".join(_source_card(s, linker, rr2) for s in sources)
- put(
- f"by-question/{_slug(qid)}.html",
- _page(f"Question {qid}", head + cards, rr2),
- "text/html",
- )
- put(f"by-question/{_slug(qid)}.csv", _question_csv(sources), "text/csv")
-
- # Per-bot and per-domain cross-views.
- for bot, sources in sorted(by_b.items()):
- sources = sorted(sources, key=lambda s: s.canonical_url)
- body = f"Bot: {_esc(bot)}
{len(sources)} source(s)
"
- body += "".join(_source_card(s, linker, rr2) for s in sources)
- put(f"by-bot/{_slug(bot)}.html", _page(f"Bot {bot}", body, rr2), "text/html")
-
- for domain, sources in sorted(by_d.items()):
- sources = sorted(sources, key=lambda s: s.canonical_url)
- body = f"Site: {_esc(domain)}
{len(sources)} source(s)
"
- body += "".join(_source_card(s, linker, rr2) for s in sources)
- put(
- f"by-domain/{_slug(domain)}.html",
- _page(f"Site {domain}", body, rr2),
- "text/html",
- )
-
- # Landing + readme. rel_root: catalog/ -> ../
- rr1 = "../"
- index_body = _index_body(data, by_q, by_b, by_d)
- put("index.html", _page("Catalog", index_body, rr1), "text/html")
- put("READ_ME_FIRST.html", _page("Read me first", _readme_body(), rr1), "text/html")
-
- return CatalogSummary(
- sources=len(data.sources),
- captured=sum(s.captured for s in data.sources),
- questions=len(by_q),
- bots=len(by_b),
- domains=len(by_d),
- excluded=data.excluded,
- )
-
-
-def _index_body(data, by_q, by_b, by_d) -> str:
- captured = sum(s.captured for s in data.sources)
-
- def links(items: dict, view: str) -> str:
- rows = []
- for key, sources in sorted(items.items(), key=lambda kv: (-len(kv[1]), kv[0])):
- rows.append(
- f""
- f"{_esc(key)} ({len(sources)})"
- )
- return "".join(rows)
-
- hidden_note = (
- f" · {data.hidden_total} non-page URLs hidden "
- f"({', '.join(f'{k} {v}' for k, v in sorted(data.excluded.items()))})"
- if data.hidden_total
- else ""
- )
- return (
- f"What is this? →
"
- f"{len(data.sources)} page sources ({captured} captured) · "
- f"{len(by_q)} questions · {len(by_b)} bots · {len(by_d)} sites{hidden_note}
"
- f"By question
The encyclopedia of sources per "
- f"question — start here.
{links(by_q, 'by-question')}"
- f"By bot
{links(by_b, 'by-bot')}"
- f"By site
{links(by_d, 'by-domain')}"
- )
-
-
-def _readme_body() -> str:
- return (
- "What is this bucket?
"
- "This is a source archive: for every web page a forecasting bot "
- "cited, we save a snapshot — the page's HTML, a full-page "
- "screenshot, and a clean markdown copy — so a forecast can be "
- "audited later even if the original page changes or disappears.
"
- "How to browse it
"
- ""
- "- Open index.html (the catalog home).
"
- "- By question is the main view: pick a question to see every "
- "source used for it, who used it, and a screenshot of each.
"
- "- By bot shows one bot's sources across questions; By site "
- "groups sources by website.
"
- "- Each question also has a CSV you can open in a spreadsheet.
"
- "
"
- "The folders with long hash names (content/, index/) are "
- "the machine-readable store — you don't need to open those.
"
- )
diff --git a/forecasting_tools/agents_and_tools/source_archive/cli.py b/forecasting_tools/agents_and_tools/source_archive/cli.py
deleted file mode 100644
index 0a93a1f3..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/cli.py
+++ /dev/null
@@ -1,353 +0,0 @@
-"""Command-line interface for the source archive.
-
- # See the resolved configuration (secrets masked)
- python -m forecasting_tools.agents_and_tools.source_archive.cli check
-
- # Capture every URL in a manifest and upload to S3 (uses WEB_ARCHIVE_S3_BUCKET)
- python -m forecasting_tools.agents_and_tools.source_archive.cli capture run.jsonl
-
- # Same, but store to a local folder instead of S3 (no AWS needed)
- python -m forecasting_tools.agents_and_tools.source_archive.cli capture run.jsonl --local ./archive
-
-If installed via the ``source-archive`` extra, the ``source-archive`` console
-command is equivalent to ``python -m ...cli``.
-"""
-
-from __future__ import annotations
-
-import argparse
-import sys
-
-from forecasting_tools.agents_and_tools.source_archive import manifest as manifest_io
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.fetchers import (
- build_default_fetcher,
-)
-
-
-def _load_dotenv() -> None:
- try:
- from dotenv import load_dotenv
-
- load_dotenv()
- except ImportError:
- pass
-
-
-def _mask(value: str | None) -> str:
- if not value:
- return "(unset)"
- if len(value) <= 6:
- return "***"
- return f"{value[:3]}…{value[-2:]}"
-
-
-def _make_blob_store(config: ArchiveConfig, local_dir: str | None, bucket: str | None):
- if local_dir:
- from forecasting_tools.agents_and_tools.source_archive.storage import (
- LocalBlobStore,
- )
-
- return LocalBlobStore(local_dir)
- bucket = bucket or config.s3_bucket
- if not bucket:
- sys.exit(
- "No S3 bucket configured. Set WEB_ARCHIVE_S3_BUCKET (or pass --bucket), "
- "or use --local DIR to store to the filesystem."
- )
- from forecasting_tools.agents_and_tools.source_archive.storage import S3BlobStore
-
- return S3BlobStore(bucket, config=config)
-
-
-def _cmd_check(config: ArchiveConfig) -> int:
- print("Source-archive configuration (secrets masked):")
- print(f" S3 bucket : {config.s3_bucket or '(unset)'}")
- print(f" S3 prefix : {config.s3_prefix}")
- print(f" AWS profile : {config.aws_profile or '(default chain)'}")
- print(f" AWS region : {config.aws_region or '(default)'}")
- print(f" Firecrawl API key : {_mask(config.firecrawl_api_key)}")
- print(f" Firecrawl proxy mode : {config.firecrawl_proxy}")
- print(f" Hyperbrowser API key : {_mask(config.hyperbrowser_api_key)}")
- print(f" Hyperbrowser proxy : {config.hyperbrowser_use_proxy}")
- print(f" CloakBrowser module : {config.cloakbrowser_import}")
- print(f" PDF max pages : {config.pdf_max_pages}")
- print(f" TTL (days) : {config.ttl_days}")
- print(f" Screenshot format : {config.screenshot_format}")
- print(f" Screenshot max height: {config.screenshot_max_height}")
- return 0
-
-
-def _cmd_capture(args, config: ArchiveConfig) -> int:
- from forecasting_tools.agents_and_tools.source_archive.manifest import unique_urls
- from forecasting_tools.agents_and_tools.source_archive.pipeline import (
- capture_urls_concurrent,
- )
-
- records = manifest_io.read_file(args.manifest)
-
- overrides = {}
- if getattr(args, "no_hyperbrowser", False):
- overrides["hyperbrowser_api_key"] = None
- if getattr(args, "concurrency", None):
- overrides["concurrency"] = args.concurrency
- if overrides:
- config = config.model_copy(update=overrides)
- if "hyperbrowser_api_key" in overrides:
- print("Hyperbrowser fallback DISABLED for this run.")
-
- store = ContentStore(_make_blob_store(config, args.local, args.bucket), config)
-
- urls = list(unique_urls(records))
- if args.limit:
- urls = urls[: args.limit]
- target = args.local or f"s3://{args.bucket or config.s3_bucket}/{config.s3_prefix}"
- print(
- f"Capturing {len(urls)} URL(s) at concurrency {config.concurrency} -> {target}"
- )
-
- summary = capture_urls_concurrent(urls, store, config, build_default_fetcher)
- print(summary)
-
- from forecasting_tools.agents_and_tools.source_archive import cost as cost_mod
-
- run_cost = cost_mod.estimate_run_cost(summary, config, run_id=args.run_id)
- print(run_cost)
-
- run_id = args.run_id or (records[0].run_id if records else None)
- if run_id:
- from forecasting_tools.agents_and_tools.source_archive import reports
-
- key = reports.write_run_report(
- store.blobs, run_id, summary, config, group=args.group
- )
- print(f"Wrote run outcomes -> {key}")
- key = cost_mod.write_cost_report(
- store.blobs, run_id, run_cost, config, group=args.group
- )
- print(f"Wrote cost report -> {key}")
-
- # Failures leave no cache entry, so re-running retries exactly them. Write a
- # retry manifest (with provenance) so coming back — e.g. with hyperbrowser
- # re-enabled — is one command over only the sites that still need it.
- failed = {
- o.url for o in summary.outcomes if o.status in ("quality_failed", "error")
- }
- if failed:
- from forecasting_tools.agents_and_tools.source_archive.ingest import (
- dedupe_records,
- )
-
- retry_records = dedupe_records(r for r in records if r.url in failed)
- retry_path = args.retry_out or f"{run_id or 'run'}_needs_retry.jsonl"
- manifest_io.write_file(retry_path, retry_records)
- print(
- f"{len(failed)} URL(s) failed -> retry manifest {retry_path}\n"
- f" come back later with: source-archive capture {retry_path} "
- f"--run-id {run_id or ''} (hyperbrowser on by default)"
- )
-
- if args.upload_manifest:
- if not run_id:
- sys.exit("--upload-manifest needs --run-id (no run_id found in records)")
- manifest_io.write_blob(store.blobs, run_id, records, config, group=args.group)
- print(
- f"Uploaded manifest -> {manifest_io.manifest_key(run_id, config, args.group)}"
- )
- return 0
-
-
-def _cmd_ingest_traces(args, config: ArchiveConfig) -> int:
- from forecasting_tools.agents_and_tools.source_archive.ingest import (
- dedupe_records,
- harvest_run,
- )
-
- run_id = args.run_id # None -> derived from the run dir name
- records = harvest_run(args.run_dir, run_id=run_id, bot=args.bot)
- if args.dedupe:
- records = dedupe_records(records)
- run_id = run_id or (records[0].run_id if records else None)
- print(f"Ingested {len(records)} citation record(s) from traces in {args.run_dir}")
-
- out_path = args.out or f"{run_id or 'traces'}.jsonl"
- if not args.upload or args.out:
- manifest_io.write_file(out_path, records)
- print(f"Wrote manifest -> {out_path}")
- if args.upload:
- if not run_id:
- sys.exit("--upload needs a run id (pass --run-id; none found in records)")
- store = _make_blob_store(config, None, args.bucket)
- manifest_io.write_blob(store, run_id, records, config, group=args.group)
- print(
- f"Uploaded manifest -> {manifest_io.manifest_key(run_id, config, args.group)}"
- )
- return 0
-
-
-def _cmd_catalog(args, config: ArchiveConfig) -> int:
- from forecasting_tools.agents_and_tools.source_archive.catalog import write_catalog
-
- store = _make_blob_store(config, args.local, args.bucket)
- target = args.local or f"s3://{args.bucket or config.s3_bucket}/{config.s3_prefix}"
- print(f"Building catalog from manifests + index -> {target}/catalog/")
- summary = write_catalog(store, config)
- print(summary)
- print(f"Open {config.s3_prefix}/catalog/index.html")
- return 0
-
-
-def _cmd_coverage(args, config: ArchiveConfig) -> int:
- from pathlib import Path
-
- from forecasting_tools.agents_and_tools.source_archive import reports
- from forecasting_tools.agents_and_tools.source_archive.catalog import build_sources
- from forecasting_tools.agents_and_tools.source_archive.coverage import (
- MODES,
- coverage_from_sources,
- )
-
- store = _make_blob_store(config, args.local, args.bucket)
- sources = build_sources(store, config) # read manifests + index once
- outcomes = reports.read_outcomes(store, config) or None
- modes = MODES if args.mode == "both" else (args.mode,)
- for mode in modes:
- report = coverage_from_sources(sources, mode, outcomes)
- print(report)
- print()
- if args.csv:
- Path(f"{args.csv}_{mode}.csv").write_text(report.to_csv())
- print(f"Wrote {args.csv}_{mode}.csv")
- if report.missing_urls:
- Path(f"{args.csv}_{mode}_missing.txt").write_text(
- "\n".join(report.missing_urls)
- )
- print(f"Wrote {args.csv}_{mode}_missing.txt")
- return 0
-
-
-def main(argv: list[str] | None = None) -> int:
- _load_dotenv()
- parser = argparse.ArgumentParser(
- prog="source-archive",
- description="Capture HTML + screenshot + markdown for the URLs a "
- "forecasting bot cited, and store them with provenance.",
- )
- sub = parser.add_subparsers(dest="command", required=True)
-
- sub.add_parser("check", help="print the resolved configuration (secrets masked)")
-
- cap = sub.add_parser("capture", help="capture all URLs in a citation manifest")
- cap.add_argument("manifest", help="path to a citation manifest (.jsonl)")
- cap.add_argument(
- "--local", metavar="DIR", help="store to this directory instead of S3"
- )
- cap.add_argument(
- "--bucket", help="override the S3 bucket (default: WEB_ARCHIVE_S3_BUCKET)"
- )
- cap.add_argument(
- "--upload-manifest",
- action="store_true",
- help="also upload the manifest itself to manifests/.jsonl",
- )
- cap.add_argument("--run-id", help="run id for the uploaded manifest")
- cap.add_argument(
- "--group",
- help="nest the uploaded manifest/reports under this folder, e.g. "
- "'sprints/myrun' (default: daily// for daily-YYYY-MM-DD "
- "run ids, else adhoc/)",
- )
- cap.add_argument(
- "--no-hyperbrowser",
- action="store_true",
- help="disable the Hyperbrowser fallback for this run (others still run)",
- )
- cap.add_argument(
- "--retry-out",
- metavar="FILE",
- help="where to write the failed-URL retry manifest "
- "(default: _needs_retry.jsonl)",
- )
- cap.add_argument(
- "--concurrency",
- type=int,
- metavar="N",
- help="parallel browser workers for this run (overrides WEB_ARCHIVE_CONCURRENCY)",
- )
- cap.add_argument(
- "--limit",
- type=int,
- metavar="N",
- help="capture only the first N URLs (chunk a big manifest; resume via cache)",
- )
-
- ing = sub.add_parser(
- "ingest-traces",
- help="build a manifest from a traced bot run directory (bot_*/q_*/traces_*.jsonl)",
- )
- ing.add_argument("run_dir", help="path to a traced run directory")
- ing.add_argument(
- "--out", metavar="FILE", help="write the manifest to this .jsonl file"
- )
- ing.add_argument("--run-id", help="run id (default: the run dir's name)")
- ing.add_argument(
- "--bot",
- help="bot name for a flat (no bot_*/) layout (default: the run dir's name)",
- )
- ing.add_argument(
- "--dedupe", action="store_true", help="keep one record per URL (first seen)"
- )
- ing.add_argument(
- "--upload", action="store_true", help="upload the manifest to S3 manifests/"
- )
- ing.add_argument(
- "--group",
- help="nest the uploaded manifest under this folder, e.g. 'sprints/myrun' "
- "(default: daily// for daily-YYYY-MM-DD run ids, else adhoc/)",
- )
- ing.add_argument("--bucket", help="override the S3 bucket")
-
- cat = sub.add_parser(
- "catalog",
- help="generate a coworker-legible HTML/CSV catalog (by question/bot/site)",
- )
- cat.add_argument(
- "--local", metavar="DIR", help="read/write the catalog in this directory"
- )
- cat.add_argument("--bucket", help="override the S3 bucket")
-
- cov = sub.add_parser(
- "coverage",
- help="report what %% of cited sources were archived (trace vs comments)",
- )
- cov.add_argument(
- "--mode",
- choices=["trace", "comments", "both"],
- default="both",
- help="which report(s) to print (default: both)",
- )
- cov.add_argument(
- "--csv", metavar="PREFIX", help="write PREFIX_.csv (+ _missing.txt)"
- )
- cov.add_argument("--local", metavar="DIR", help="read from this directory")
- cov.add_argument("--bucket", help="override the S3 bucket")
-
- args = parser.parse_args(argv)
- config = ArchiveConfig.from_env()
-
- if args.command == "check":
- return _cmd_check(config)
- if args.command == "capture":
- return _cmd_capture(args, config)
- if args.command == "ingest-traces":
- return _cmd_ingest_traces(args, config)
- if args.command == "catalog":
- return _cmd_catalog(args, config)
- if args.command == "coverage":
- return _cmd_coverage(args, config)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/forecasting_tools/agents_and_tools/source_archive/config.py b/forecasting_tools/agents_and_tools/source_archive/config.py
deleted file mode 100644
index cfb643ef..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/config.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""Configuration for the source archive, read from environment variables.
-
-No bucket names, credentials, or other deployment-specific values are baked in
-here, so this module is safe to publish. Operators set the bucket via
-``WEB_ARCHIVE_S3_BUCKET`` (see ``.env.template``).
-"""
-
-from __future__ import annotations
-
-import os
-
-from pydantic import BaseModel
-
-
-def _get_int(name: str, default: int) -> int:
- raw = os.environ.get(name)
- if raw is None or raw == "":
- return default
- return int(raw)
-
-
-def _get_bool(name: str, default: bool) -> bool:
- raw = os.environ.get(name)
- if raw is None or raw == "":
- return default
- return raw.strip().lower() in ("1", "true", "yes", "on")
-
-
-class ArchiveConfig(BaseModel):
- """Runtime configuration. Construct directly in tests, or ``from_env()``."""
-
- s3_bucket: str | None = None
- s3_prefix: str = "source-archive"
- # Local archive directory. When set, the viewer reads captures from here
- # instead of S3 — handy for inspecting a local capture run with no AWS.
- local_dir: str | None = None
- aws_profile: str | None = None
- aws_region: str | None = None
- firecrawl_api_key: str | None = None
- # Firecrawl proxy mode for the anti-bot path: "basic" (1 credit) | "auto"
- # (1 credit, escalates to 5 on fallback) | "stealth"/"enhanced" (5 credits).
- # Only the fallback Firecrawl tier pays this; basic is the default.
- firecrawl_proxy: str = "basic"
- hyperbrowser_api_key: str | None = None
- # Hyperbrowser session knobs for the anti-bot path. Proxy turns a 1-credit
- # scrape into a 10-credit one, so leave it on only for the Cloudflare tier.
- hyperbrowser_use_proxy: bool = True
- hyperbrowser_use_stealth: bool = True
- hyperbrowser_solve_captchas: bool = True
- # CloakBrowser exposes ``cloakbrowser.launch() -> Browser``; the module name
- # is overridable in case the package is renamed.
- cloakbrowser_import: str = "cloakbrowser"
- pdf_max_pages: int = 50 # cap PDF parsing so a huge report can't blow latency/cost
- ttl_days: int = 14
- screenshot_format: str = "webp" # webp | jpeg | png
- screenshot_max_height: int = 16_000 # px; safety cap (under WebP's 16383 limit)
- nav_timeout_ms: int = 30_000
- concurrency: int = 5
-
- @classmethod
- def from_env(cls) -> "ArchiveConfig":
- return cls(
- s3_bucket=os.environ.get("WEB_ARCHIVE_S3_BUCKET"),
- s3_prefix=os.environ.get("WEB_ARCHIVE_S3_PREFIX", "source-archive"),
- local_dir=os.environ.get("WEB_ARCHIVE_LOCAL_DIR"),
- aws_profile=os.environ.get("WEB_ARCHIVE_AWS_PROFILE"),
- aws_region=os.environ.get("AWS_REGION")
- or os.environ.get("AWS_DEFAULT_REGION"),
- firecrawl_api_key=os.environ.get("FIRECRAWL_API_KEY"),
- firecrawl_proxy=os.environ.get("WEB_ARCHIVE_FIRECRAWL_PROXY", "basic"),
- hyperbrowser_api_key=os.environ.get("HYPERBROWSER_API_KEY"),
- hyperbrowser_use_proxy=_get_bool("WEB_ARCHIVE_HYPERBROWSER_PROXY", True),
- hyperbrowser_use_stealth=_get_bool(
- "WEB_ARCHIVE_HYPERBROWSER_STEALTH", True
- ),
- hyperbrowser_solve_captchas=_get_bool(
- "WEB_ARCHIVE_HYPERBROWSER_CAPTCHA", True
- ),
- cloakbrowser_import=os.environ.get(
- "WEB_ARCHIVE_CLOAKBROWSER_IMPORT", "cloakbrowser"
- ),
- pdf_max_pages=_get_int("WEB_ARCHIVE_PDF_MAX_PAGES", 50),
- ttl_days=_get_int("WEB_ARCHIVE_TTL_DAYS", 14),
- screenshot_format=os.environ.get("WEB_ARCHIVE_SCREENSHOT_FORMAT", "webp"),
- screenshot_max_height=_get_int("WEB_ARCHIVE_SCREENSHOT_MAX_HEIGHT", 16_000),
- nav_timeout_ms=_get_int("WEB_ARCHIVE_NAV_TIMEOUT_MS", 30_000),
- concurrency=_get_int("WEB_ARCHIVE_CONCURRENCY", 5),
- )
diff --git a/forecasting_tools/agents_and_tools/source_archive/content_store.py b/forecasting_tools/agents_and_tools/source_archive/content_store.py
deleted file mode 100644
index 2c0827cb..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/content_store.py
+++ /dev/null
@@ -1,323 +0,0 @@
-"""URL content store, keyed by URL + content hash, with a TTL cache.
-
-The big cost lever is **not re-fetching** a URL captured recently: a bot
-re-forecasts the same open question every 20-30 minutes for weeks, citing the
-same pages over and over, so temporal overlap is near-total.
-
- - :meth:`ContentStore.lookup` — if a URL was captured within the TTL, return
- the pointer and skip the fetch entirely (the cheap path that makes re-runs
- nearly free).
- - :meth:`ContentStore.store` — write blobs under
- ``content//.*``; if that exact content hash is
- already stored, skip the write (dedup identical re-fetches) and just refresh
- timestamps.
-
-**Redirect aliasing.** A capture is keyed by its *final* URL (after redirects),
-so a link shortener (``bit.ly/x``) and the page it resolves to collapse onto one
-capture instead of two. The original cited URL gets a tiny **alias index** that
-points at the final URL's index, and the final URL's index lists its aliases for
-provenance. So ``lookup(bit.ly/x)`` and ``lookup(final)`` both hit the same
-stored page, and we never store the destination twice.
-
-**Cross-URL content dedup.** Different URLs that return byte-identical content
-share the blobs rather than storing them three times each. The first URL to
-store a given content owns the blobs; later URLs get a capture whose blob keys
-point back at them and whose ``content_alias_of`` names the owner. A reverse
-``index/by-content/.json`` tracks the owner and every member URL.
-
-Object layout (under ``config.s3_prefix``)::
-
- index/.json canonical: capture history + "aliases"
- index/.json alias: {"alias_of": }
- index/by-content/.json reverse: owner + member urls
- content//.html
- content//.
- content//.md
-"""
-
-from __future__ import annotations
-
-import json
-import threading
-from datetime import datetime, timedelta, timezone
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CaptureResult,
- StoredCapture,
- url_hash,
- utcnow_iso,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-_IMG_EXT = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}
-
-
-class StoreResult(BaseModel):
- capture: StoredCapture
- created: bool # False when the content hash was already stored (deduped)
-
-
-def _parse_iso(ts: str) -> datetime:
- dt = datetime.fromisoformat(ts)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt
-
-
-def _capture_is_complete(cap: dict) -> bool:
- """Whether a stored capture has every format we expect for its type.
-
- A browser capture is complete only with html + markdown + screenshot; a PDF
- (which has no screenshot) only needs its markdown. Used by :meth:`lookup` so
- an incomplete capture is re-fetched rather than treated as already done.
- """
- if (cap.get("fetcher") or "").lower() == "pdf":
- return bool(cap.get("markdown_key"))
- return bool(
- cap.get("html_key") and cap.get("markdown_key") and cap.get("screenshot_key")
- )
-
-
-class ContentStore:
- def __init__(self, blob_store: BlobStore, config: ArchiveConfig | None = None):
- self.blobs = blob_store
- self.config = config or ArchiveConfig()
- self.prefix = self.config.s3_prefix.rstrip("/")
- # Serializes the shared by-content reverse index across capture threads
- # (concurrent runs). Per-URL index files are written by a single thread
- # each, so they don't need it; the by-content index can be contended when
- # different URLs return identical content.
- self._content_lock = threading.Lock()
-
- # --- key helpers -------------------------------------------------------
- def _index_key(self, uh: str) -> str:
- return f"{self.prefix}/index/{uh}.json"
-
- def _content_key(self, uh: str, ch: str, ext: str) -> str:
- return f"{self.prefix}/content/{uh}/{ch}.{ext}"
-
- def _content_index_key(self, ch: str) -> str:
- return f"{self.prefix}/index/by-content/{ch}.json"
-
- # --- index io ----------------------------------------------------------
- def _read_index(self, uh: str) -> dict | None:
- key = self._index_key(uh)
- if not self.blobs.exists(key):
- return None
- return json.loads(self.blobs.get(key).decode("utf-8"))
-
- def _write_index(self, uh: str, index: dict) -> None:
- data = json.dumps(index, indent=2, sort_keys=True).encode("utf-8")
- self.blobs.put(self._index_key(uh), data, content_type="application/json")
-
- def _read_content_index(self, ch: str) -> dict | None:
- key = self._content_index_key(ch)
- if not self.blobs.exists(key):
- return None
- try:
- return json.loads(self.blobs.get(key).decode("utf-8"))
- except (json.JSONDecodeError, UnicodeDecodeError):
- # A concurrent writer may have left a partial local file mid-write;
- # treat as absent rather than crash. The locked path below is authoritative.
- return None
-
- def _register_content(
- self, ch: str, uh: str, url: str, blob_keys: dict | None
- ) -> None:
- """Record that ``uh`` produced content ``ch`` in the reverse index.
-
- The first URL to store a given content becomes its ``canonical_url_hash``
- and owns the blob keys; later URLs with identical content are added as
- ``members`` and reuse those blobs (see :meth:`store`). Locked so concurrent
- capture threads with identical content don't clobber each other's members.
- """
- with self._content_lock:
- reverse = self._read_content_index(ch)
- if reverse is None:
- reverse = {
- "content_hash": ch,
- "canonical_url_hash": uh,
- "blob_keys": blob_keys or {},
- "members": [],
- }
- members = reverse.setdefault("members", [])
- if not any(m.get("url_hash") == uh for m in members):
- members.append({"url_hash": uh, "url": url})
- data = json.dumps(reverse, indent=2, sort_keys=True).encode("utf-8")
- self.blobs.put(
- self._content_index_key(ch), data, content_type="application/json"
- )
-
- # --- public api --------------------------------------------------------
- def lookup(self, url: str) -> StoredCapture | None:
- """Return the latest stored capture if within the TTL, else ``None``.
-
- A non-``None`` return means callers can skip fetching this URL. If ``url``
- is an alias of a previously-redirected target, the alias is followed to
- the canonical capture.
- """
- uh = url_hash(url)
- index = self._read_index(uh)
- if not index:
- return None
- alias_of = index.get("alias_of")
- if alias_of: # follow the alias to the canonical (final-URL) index
- index = self._read_index(alias_of)
- if not index:
- return None
- latest_ch = index.get("latest_content_hash")
- captures = index.get("captures", {})
- latest = captures.get(latest_ch)
- if not latest:
- return None
-
- last_seen = _parse_iso(latest["last_seen"])
- age = datetime.now(timezone.utc) - last_seen
- if age > timedelta(days=self.config.ttl_days):
- return None
- # Skip only a COMPLETE capture. A partial one (e.g. a failed screenshot
- # encode left screenshot_key=None) is treated as a miss so the next run
- # retries the missing format instead of skipping it forever. PDFs have no
- # screenshot by nature, so they only need their markdown.
- if not _capture_is_complete(latest):
- return None
- return StoredCapture.model_validate(latest)
-
- def store(self, result: CaptureResult) -> StoreResult:
- """Persist a capture, deduping by content hash. Always updates the index.
-
- The capture is keyed by the *final* URL (after redirects). If the cited
- URL differs from the final one, an alias index is written so the cited
- URL still resolves here, and the cited URL is recorded under the
- canonical index's ``aliases``.
- """
- final_url = result.final_url or result.url
- uh = url_hash(final_url)
- ch = result.content_hash
- now = utcnow_iso()
-
- index = self._read_index(uh) or {
- "url": final_url,
- "url_hash": uh,
- "first_seen": now,
- "captures": {},
- }
- captures = index.setdefault("captures", {})
- existing = captures.get(ch)
-
- created = existing is None
- if existing is not None:
- # Identical content already stored for THIS url — skip writes, touch.
- existing["last_seen"] = now
- stored = StoredCapture.model_validate(existing)
- else:
- reverse = self._read_content_index(ch)
- reuse = bool(
- reverse and reverse.get("canonical_url_hash") not in (None, uh)
- )
- if reuse:
- # Byte-identical content already stored under a DIFFERENT url —
- # point at its blobs instead of writing three more (cross-URL
- # content dedup); each url still keeps its own index history.
- bk = reverse.get("blob_keys", {})
- html_key = bk.get("html")
- markdown_key = bk.get("markdown")
- screenshot_key = bk.get("screenshot")
- content_alias_of = reverse["canonical_url_hash"]
- else:
- html_key = screenshot_key = markdown_key = None
- if result.html is not None:
- html_key = self._content_key(uh, ch, "html")
- self.blobs.put(
- html_key, result.html.encode("utf-8"), content_type="text/html"
- )
- if result.markdown is not None:
- markdown_key = self._content_key(uh, ch, "md")
- self.blobs.put(
- markdown_key,
- result.markdown.encode("utf-8"),
- content_type="text/markdown",
- )
- if result.screenshot is not None:
- ext = _IMG_EXT.get(result.screenshot_content_type or "", "png")
- screenshot_key = self._content_key(uh, ch, ext)
- self.blobs.put(
- screenshot_key,
- result.screenshot,
- content_type=result.screenshot_content_type,
- )
- content_alias_of = None
- stored = StoredCapture(
- url=final_url,
- url_hash=uh,
- content_hash=ch,
- status_code=result.status_code,
- fetcher=result.fetcher,
- captured_at=result.fetched_at,
- html_key=html_key,
- screenshot_key=screenshot_key,
- markdown_key=markdown_key,
- content_alias_of=content_alias_of,
- first_seen=now,
- last_seen=now,
- )
- captures[ch] = stored.model_dump()
- self._register_content(
- ch,
- uh,
- final_url,
- blob_keys=(
- None
- if reuse
- else {
- "html": html_key,
- "markdown": markdown_key,
- "screenshot": screenshot_key,
- }
- ),
- )
-
- index["latest_content_hash"] = ch
- index["last_checked"] = now
-
- # If the cited URL redirected to a different final URL, record the alias.
- orig_uh = url_hash(result.url)
- if orig_uh != uh:
- aliases = index.setdefault("aliases", [])
- if result.url not in aliases:
- aliases.append(result.url)
-
- self._write_index(uh, index)
-
- if orig_uh != uh:
- self._write_alias(orig_uh, result.url, uh, now)
-
- return StoreResult(capture=stored, created=created)
-
- def _write_alias(
- self, orig_uh: str, orig_url: str, final_uh: str, now: str
- ) -> None:
- """Write/refresh a pointer from a cited URL's hash to its final capture.
-
- Never clobbers a canonical index (one that already holds captures), so a
- URL fetched directly in the past keeps its own history.
- """
- existing = self._read_index(orig_uh)
- if existing and existing.get("captures"):
- return
- first_seen = existing.get("first_seen", now) if existing else now
- self._write_index(
- orig_uh,
- {
- "url": orig_url,
- "url_hash": orig_uh,
- "alias_of": final_uh,
- "first_seen": first_seen,
- "last_checked": now,
- },
- )
diff --git a/forecasting_tools/agents_and_tools/source_archive/cost.py b/forecasting_tools/agents_and_tools/source_archive/cost.py
deleted file mode 100644
index 33e950eb..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/cost.py
+++ /dev/null
@@ -1,137 +0,0 @@
-"""Estimate what a capture run cost — per backend and per archived site.
-
-Self-hosted browsers (CloakBrowser / Playwright) and local PDF parsing are ~free;
-the managed backends (Hyperbrowser, Firecrawl) bill per page. This turns a run's
-outcomes into a cost breakdown so an operator can see what the paid backends are
-costing per site archived.
-
-Costs are **estimates** from each vendor's public pricing applied to the
-configured proxy mode — we record the backend that produced each capture, not the
-live credit count — so treat them as close approximations, not billed amounts.
-Only *successful* captures are priced; a paid backend call that then failed the
-quality gate isn't attributed to a backend here (so this slightly under-counts).
-"""
-
-from __future__ import annotations
-
-import json
-from collections import Counter
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive import layout
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-
-# $ per vendor credit (public list pricing, 2026-06).
-_FIRECRAWL_CREDIT_USD = 0.00083
-_HYPERBROWSER_CREDIT_USD = 0.001
-
-# Backends that run on our own machine — no per-page charge.
-_FREE_BACKENDS = {"cloakbrowser", "playwright", "pdf", ""}
-
-
-def price_per_capture(fetcher: str, config: ArchiveConfig) -> float:
- """Estimated $ for one successful capture by ``fetcher`` under ``config``."""
- f = (fetcher or "").lower()
- if f in _FREE_BACKENDS:
- return 0.0
- if f == "hyperbrowser":
- credits = 10 if config.hyperbrowser_use_proxy else 1
- return credits * _HYPERBROWSER_CREDIT_USD
- if f.startswith("firecrawl"):
- basic = (config.firecrawl_proxy or "basic").lower() in ("", "basic")
- return (1 if basic else 5) * _FIRECRAWL_CREDIT_USD
- return 0.0 # unknown backend — assume free rather than invent a number
-
-
-class BackendCost(BaseModel):
- backend: str
- captures: int
- unit_usd: float
- total_usd: float
-
-
-class RunCost(BaseModel):
- run_id: str | None = None
- archived: int = 0 # sites we now hold (stored + deduped + cache_hit)
- paid_captures: int = 0 # captures via a paid backend this run
- total_usd: float = 0.0
- usd_per_archived: float = 0.0
- by_backend: list[BackendCost] = []
-
- def __str__(self) -> str:
- lines = [
- f"RunCost(run_id={self.run_id}, archived={self.archived}, "
- f"paid_captures={self.paid_captures}, total=${self.total_usd:.4f}, "
- f"$/archived=${self.usd_per_archived:.5f})",
- f" {'backend':<14}{'captures':>9}{'$/capture':>12}{'$ total':>10}",
- ]
- for b in self.by_backend:
- lines.append(
- f" {b.backend:<14}{b.captures:>9}{b.unit_usd:>12.5f}{b.total_usd:>10.4f}"
- )
- return "\n".join(lines)
-
-
-def estimate_run_cost(
- summary, config: ArchiveConfig, run_id: str | None = None
-) -> RunCost:
- """Estimate a :class:`PipelineSummary`'s cost, broken down by backend.
-
- Newly fetched captures (``stored`` / ``deduped``) are priced by the backend
- that produced them; ``cache_hit`` re-uses cost nothing (no fetch happened).
- """
- counts: Counter[str] = Counter()
- for o in summary.outcomes:
- if o.status in ("stored", "deduped") and o.stored is not None:
- counts[(o.stored.fetcher or "unknown")] += 1
-
- by_backend: list[BackendCost] = []
- total = 0.0
- paid = 0
- for backend, n in sorted(counts.items()):
- unit = price_per_capture(backend, config)
- sub = unit * n
- total += sub
- if unit > 0:
- paid += n
- by_backend.append(
- BackendCost(
- backend=backend,
- captures=n,
- unit_usd=round(unit, 6),
- total_usd=round(sub, 4),
- )
- )
-
- archived = sum(
- 1 for o in summary.outcomes if o.status in ("stored", "deduped", "cache_hit")
- )
- return RunCost(
- run_id=run_id,
- archived=archived,
- paid_captures=paid,
- total_usd=round(total, 4),
- usd_per_archived=round(total / archived, 6) if archived else 0.0,
- by_backend=by_backend,
- )
-
-
-def cost_report_key(
- run_id: str, config: ArchiveConfig, group: str | None = None
-) -> str:
- prefix = config.s3_prefix.rstrip("/")
- return f"{prefix}/{layout.report_key(run_id, '_cost.json', group)}"
-
-
-def write_cost_report(
- store, run_id: str, cost: RunCost, config: ArchiveConfig, group: str | None = None
-) -> str:
- """Persist the cost breakdown next to the run report (``_cost.json``)."""
- key = cost_report_key(run_id, config, group)
- store.put(
- key,
- json.dumps(cost.model_dump(), indent=2).encode("utf-8"),
- content_type="application/json",
- )
- return key
diff --git a/forecasting_tools/agents_and_tools/source_archive/coverage.py b/forecasting_tools/agents_and_tools/source_archive/coverage.py
deleted file mode 100644
index 9b812e9b..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/coverage.py
+++ /dev/null
@@ -1,237 +0,0 @@
-"""Coverage reports: what fraction of cited sources did we actually archive?
-
-Two **separate** reports, split by ingestion path — they have different
-denominators and different notions of ground truth, so they must not be blurred:
-
-- ``trace`` — the complex/template bot's own instrumented runs (metac-ai-sdk).
- Traces record *every* URL the bot touched, so this is a true archival
- success-rate against ground truth.
-- ``comments`` — every bot (Metaculus's own + outside bots) harvested from public
- Metaculus comments. Comments are length-truncated, so the denominator is itself
- incomplete: coverage here means "of the links we could *see* in comments, how
- many we archived" — a weaker claim than the trace report.
-
-For each mode: denominator = distinct canonical **page** sources cited (tool/API
-calls excluded, same rule as the catalog); numerator = those with a successful
-capture in the index. Misses are bucketed by site — the per-URL failure *reason*
-isn't persisted yet (that needs each run's pipeline outcomes saved), so we can
-say *which* sites we miss, not yet *why*.
-"""
-
-from __future__ import annotations
-
-import csv
-import io
-from collections import defaultdict
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive.catalog import (
- Source,
- build_sources,
- exclusion_reason,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-MODES = ("trace", "comments")
-_COMMENT_ORIGINS = {"metaculus_comment"}
-
-
-def citation_mode(citation) -> str:
- return "comments" if (citation.origin or "") in _COMMENT_ORIGINS else "trace"
-
-
-class CoverageRow(BaseModel):
- label: str
- cited: int = 0
- captured: int = 0
-
- @property
- def pct(self) -> float:
- return round(100 * self.captured / self.cited, 1) if self.cited else 0.0
-
-
-class CoverageReport(BaseModel):
- mode: str
- cited: int = 0
- captured: int = 0
- excluded: dict[str, int] = {} # non-source reason -> count
- by_question: list[CoverageRow] = []
- by_bot: list[CoverageRow] = []
- by_tool: list[CoverageRow] = []
- missed_by_domain: list[CoverageRow] = []
- missing_urls: list[str] = []
- # Populated only when per-run outcomes (reports/) exist:
- has_outcomes: bool = False
- missing_never_fetched: int = 0 # the real collection gap
- missing_fetch_failed: int = 0 # tried, failed (Cloudflare/PDF/…)
-
- @property
- def pct(self) -> float:
- return round(100 * self.captured / self.cited, 1) if self.cited else 0.0
-
- @property
- def missing(self) -> int:
- return self.cited - self.captured
-
- def __str__(self) -> str:
- title = {
- "trace": "Trace coverage — complex/template bot (ground truth)",
- "comments": "Comment coverage — all bots (truncated denominator)",
- }.get(self.mode, self.mode)
- excl = (
- " (excluded as non-sources: "
- + ", ".join(f"{k} {v}" for k, v in sorted(self.excluded.items()))
- + ")"
- if self.excluded
- else ""
- )
- lines = [
- title,
- "=" * len(title),
- # Lead with the collection gap: this report exists to tell us whether
- # there are sources bots are using that we are NOT yet archiving.
- f"{self.missing} of {self.cited} cited page sources are NOT yet in the "
- f"archive — candidates to collect ({self.captured} archived, "
- f"{self.pct}%).",
- excl,
- ]
- if self.has_outcomes:
- lines.append(
- f" of those {self.missing}: {self.missing_never_fetched} were "
- f"never fetched (collection gap), {self.missing_fetch_failed} "
- f"were fetched but failed."
- )
- if self.mode == "comments":
- lines.append(
- " note: comments are length-truncated, so even this denominator "
- "under-counts what bots actually read — the true gap is larger."
- )
-
- def table(header: str, rows: list[CoverageRow], n: int = 8) -> None:
- if not rows:
- return
- lines.append("")
- lines.append(f"--- {header} ---")
- for r in rows[:n]:
- lines.append(f" {r.captured:>4}/{r.cited:<4} {r.pct:>5}% {r.label}")
- if len(rows) > n:
- lines.append(f" … +{len(rows) - n} more")
-
- table("by question (most-cited first)", self.by_question)
- table("by bot", self.by_bot)
- if self.mode == "trace":
- table("by tool", self.by_tool)
- table("biggest collection gaps by site (captured/cited)", self.missed_by_domain)
- if self.missing_urls:
- lines.append("")
- lines.append(
- f"--- {len(self.missing_urls)} source(s) to collect (first 10) ---"
- )
- for u in self.missing_urls[:10]:
- lines.append(f" {u}")
- return "\n".join(lines)
-
- def to_csv(self) -> str:
- buf = io.StringIO()
- w = csv.writer(buf)
- w.writerow(["group", "label", "cited", "captured", "pct"])
- w.writerow(["overall", self.mode, self.cited, self.captured, self.pct])
- for group, rows in (
- ("question", self.by_question),
- ("bot", self.by_bot),
- ("tool", self.by_tool),
- ("missed_domain", self.missed_by_domain),
- ):
- for r in rows:
- w.writerow([group, r.label, r.cited, r.captured, r.pct])
- return buf.getvalue()
-
-
-def _grouped(scoped: list[tuple[Source, list]], key_of) -> list[CoverageRow]:
- agg: dict[str, list[int]] = defaultdict(lambda: [0, 0])
- for source, cits in scoped:
- keys = {k for k in (key_of(c) for c in cits) if k} or {"(none)"}
- for k in keys:
- agg[k][0] += 1
- if source.captured:
- agg[k][1] += 1
- rows = [CoverageRow(label=k, cited=v[0], captured=v[1]) for k, v in agg.items()]
- return sorted(rows, key=lambda r: (-r.cited, r.label))
-
-
-def coverage_from_sources(
- sources: list[Source], mode: str, outcomes: dict[str, str] | None = None
-) -> CoverageReport:
- scoped: list[tuple[Source, list]] = []
- excluded: dict[str, int] = defaultdict(int)
- for s in sources:
- cits = [c for c in s.citations if citation_mode(c) == mode]
- if not cits:
- continue
- reason = exclusion_reason(s.canonical_url, cits)
- if reason:
- excluded[reason] += 1
- continue
- scoped.append((s, cits))
-
- captured = sum(1 for s, _ in scoped if s.captured)
-
- never_fetched = failed = 0
- if outcomes is not None:
- from forecasting_tools.agents_and_tools.source_archive.reports import (
- FAILED_STATUSES,
- )
-
- for s, _ in scoped:
- if s.captured:
- continue
- status = outcomes.get(s.canonical_url)
- if status is None:
- never_fetched += 1
- elif status in FAILED_STATUSES:
- failed += 1
- else:
- failed += 1
-
- domain_agg: dict[str, list[int]] = defaultdict(lambda: [0, 0])
- for s, _ in scoped:
- domain_agg[s.domain][0] += 1
- if s.captured:
- domain_agg[s.domain][1] += 1
- missed_by_domain = sorted(
- (
- CoverageRow(label=d, cited=c, captured=cap)
- for d, (c, cap) in domain_agg.items()
- if cap < c
- ),
- key=lambda r: (-(r.cited - r.captured), r.label),
- )
-
- return CoverageReport(
- mode=mode,
- cited=len(scoped),
- captured=captured,
- excluded=dict(excluded),
- by_question=_grouped(scoped, lambda c: c.question_id),
- by_bot=_grouped(scoped, lambda c: c.bot),
- by_tool=_grouped(scoped, lambda c: c.tool_name),
- missed_by_domain=missed_by_domain,
- missing_urls=sorted(s.canonical_url for s, _ in scoped if not s.captured),
- has_outcomes=outcomes is not None,
- missing_never_fetched=never_fetched,
- missing_fetch_failed=failed,
- )
-
-
-def build_coverage(
- store: BlobStore, config: ArchiveConfig, mode: str
-) -> CoverageReport:
- from forecasting_tools.agents_and_tools.source_archive.reports import read_outcomes
-
- sources = build_sources(store, config)
- outcomes = read_outcomes(store, config) or None
- return coverage_from_sources(sources, mode, outcomes)
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/__init__.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/__init__.py
deleted file mode 100644
index e41b581d..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/__init__.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""Fetchers turn a URL into a CaptureResult (HTML + screenshot + markdown).
-
-Most callers want :func:`build_default_fetcher`, which wires the recommended
-cost-ordered tiered setup: self-hosted Playwright primary, then CloakBrowser,
-PDF, Firecrawl, and Hyperbrowser backups.
-"""
-
-from __future__ import annotations
-
-import logging
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import (
- Fetcher,
- FetchError,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.cloakbrowser_fetcher import (
- CloakBrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.firecrawl_fetcher import (
- FirecrawlFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.hyperbrowser_fetcher import (
- HyperbrowserFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.pdf_fetcher import (
- PdfFetcher,
- looks_like_pdf,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.playwright_fetcher import (
- PlaywrightFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.fetchers.tiered import (
- TieredFetcher,
-)
-
-logger = logging.getLogger(__name__)
-
-__all__ = [
- "Fetcher",
- "FetchError",
- "CloakBrowserFetcher",
- "FirecrawlFetcher",
- "HyperbrowserFetcher",
- "PdfFetcher",
- "PlaywrightFetcher",
- "TieredFetcher",
- "looks_like_pdf",
- "build_default_fetcher",
-]
-
-
-def build_default_fetcher(config: ArchiveConfig | None = None) -> PlaywrightFetcher:
- """Return the recommended fetcher as a context manager.
-
- Use it like::
-
- with build_default_fetcher(config) as fetcher:
- fetcher.fetch(url)
-
- Backends are tried in **cost order** — the first capture that passes the
- quality gate wins, so the cheap tiers absorb most of the tail and the paid
- ones only fire on what survives them:
-
- 1. **Self-hosted browser** (~free) — the primary; ~70% of URLs. Uses
- **CloakBrowser** (patched Chromium; matches-or-beats vanilla Playwright on
- anti-bot) when installed, else falls back to vanilla **Playwright**. Only
- one browser is used: two live ``sync_playwright`` instances conflict in a
- single process, so cloak *replaces* vanilla rather than stacking with it.
- 2. **PdfFetcher** (local, free; Firecrawl OCR fallback) — captures PDFs,
- which the browsers can't render.
- 3. **Firecrawl** (managed) — cheapest stealth + native-PDF safety net
- (~$0.0042/page stealth). Added when ``FIRECRAWL_API_KEY`` is set.
- 4. **Hyperbrowser** (managed) — anti-bot fallback of last resort (~$0.01/page
- with proxy, plus bandwidth). Added when ``HYPERBROWSER_API_KEY`` is set.
-
- The returned object is a :class:`PlaywrightFetcher` subclass so the single
- browser's lifecycle is managed by ``with``.
- """
- config = config or ArchiveConfig()
- return _ManagedTieredFetcher(config)
-
-
-class _ManagedTieredFetcher(PlaywrightFetcher):
- """PlaywrightFetcher whose ``fetch`` is delegated to a cost-ordered tiered
- pipeline. The single self-hosted browser is CloakBrowser when available
- (overriding ``_launch_browser``), else vanilla Playwright; the extra backends
- are composed once it is live.
- """
-
- _primary_name = "playwright"
-
- def _launch_browser(self):
- # Prefer CloakBrowser (patched Chromium, beats vanilla on anti-bot) as
- # the one self-hosted browser. Two live sync_playwright instances in a
- # process conflict, so cloak REPLACES vanilla here rather than stacking.
- try:
- browser = CloakBrowserFetcher(self.config)._launch_browser()
- self._primary_name = "cloakbrowser"
- return browser
- except FetchError as e:
- logger.info("cloakbrowser unavailable, using vanilla Playwright: %s", e)
- self._primary_name = "playwright"
- return super()._launch_browser()
-
- def __enter__(self) -> "_ManagedTieredFetcher":
- super().__enter__() # launches the chosen browser via _launch_browser
- backends: list[Fetcher] = [_PrimaryBrowser(self, self._primary_name)]
-
- # PDFs: free local parse (Firecrawl OCR fallback wired internally when a
- # key is present). Cheap to keep in the chain unconditionally.
- backends.append(PdfFetcher(self.config))
-
- if self.config.firecrawl_api_key:
- backends.append(FirecrawlFetcher(self.config))
- if self.config.hyperbrowser_api_key:
- backends.append(HyperbrowserFetcher(self.config))
-
- self._tiered = TieredFetcher(*backends)
- return self
-
- def fetch(self, url: str): # type: ignore[override]
- return self._tiered.fetch(url)
-
-
-class _PrimaryBrowser:
- """Adapts the live primary browser to the Fetcher protocol for tiering,
- calling the un-overridden ``fetch`` so we don't recurse, and labelling the
- capture with the actual browser used (cloakbrowser/playwright)."""
-
- def __init__(self, owner: PlaywrightFetcher, name: str):
- self._owner = owner
- self.name = name
-
- def fetch(self, url: str):
- result = PlaywrightFetcher.fetch(self._owner, url)
- result.fetcher = self.name
- return result
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/base.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/base.py
deleted file mode 100644
index e2432a8a..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/base.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Fetcher interface.
-
-A fetcher turns a URL into a ``CaptureResult`` (HTML + markdown + screenshot in
-one pass). Implementations: self-hosted Playwright (primary) and Firecrawl
-(fallback).
-"""
-
-from __future__ import annotations
-
-from typing import Protocol, runtime_checkable
-
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-
-class FetchError(Exception):
- """Raised when a fetcher cannot produce a capture at all (network/render
- failure). Quality problems with an otherwise-successful fetch are not errors
- — those are handled by the quality gate."""
-
-
-@runtime_checkable
-class Fetcher(Protocol):
- name: str
-
- def fetch(self, url: str) -> CaptureResult: ...
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/cloakbrowser_fetcher.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/cloakbrowser_fetcher.py
deleted file mode 100644
index d4164e70..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/cloakbrowser_fetcher.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""CloakBrowser fetcher — a self-hosted anti-bot upgrade to Playwright.
-
-CloakBrowser (``CloakHQ/CloakBrowser``) is an open-source, patched-Chromium fork
-whose ``cloakbrowser.launch()`` returns a standard Playwright ``Browser`` — so
-this fetcher reuses *all* of ``PlaywrightFetcher``'s capture logic (settle,
-autoscroll, full-page screenshot, trafilatura→markdown) and only overrides how
-the browser is launched. The fork applies source-level fingerprint patches that
-get past Cloudflare Turnstile and similar challenges that plain headless Chromium
-trips; in the one rigorous 2026 anti-detect benchmark it cleared more Cloudflare
-targets than vanilla Playwright.
-
-It runs on your own compute, so the marginal service cost is ~$0/page. The
-patched Chromium binary (~200MB) is downloaded automatically on first launch.
-
-Install separately (it is not in the ``source-archive`` extra because of the
-binary): ``pip install cloakbrowser``. The package module is configurable via
-``config.cloakbrowser_import`` (default ``cloakbrowser``) in case it is renamed.
-"""
-
-from __future__ import annotations
-
-import importlib
-import logging
-
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.fetchers.playwright_fetcher import (
- PlaywrightFetcher,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class CloakBrowserFetcher(PlaywrightFetcher):
- name = "cloakbrowser"
-
- def _launch_browser(self):
- module = self._import_module()
- launch = getattr(module, "launch", None)
- if launch is None:
- raise FetchError(
- f"{module.__name__} has no launch(); the CloakBrowser API may "
- "have changed. Expected `cloakbrowser.launch() -> Browser`."
- )
- # stealth_args=True applies the fingerprint patches; the returned object
- # is a Playwright Browser, so the inherited fetch() drives it unchanged.
- # No separate playwright handle to stop — CloakBrowser owns its driver.
- browser = launch(headless=True, stealth_args=True)
- return None, browser
-
- def _import_module(self):
- candidates = [self.config.cloakbrowser_import, "cloakbrowser"]
- tried: list[str] = []
- for mod_name in dict.fromkeys(c for c in candidates if c):
- try:
- return importlib.import_module(mod_name)
- except ImportError:
- tried.append(mod_name)
- raise FetchError(
- "cloakbrowser is not installed. Install it with "
- "`pip install cloakbrowser` (or set WEB_ARCHIVE_CLOAKBROWSER_IMPORT "
- f"to the right module). Tried: {', '.join(tried)}."
- )
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/firecrawl_fetcher.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/firecrawl_fetcher.py
deleted file mode 100644
index 622d51ff..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/firecrawl_fetcher.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""Firecrawl fetcher — a managed FALLBACK backend.
-
-Reserved for sites that block headless Chromium. A basic scrape costs 1 credit/
-page even with a screenshot, so it only runs when the primary backend fails or
-its capture fails the quality gate.
-
-For hardened anti-bot sites, set ``config.firecrawl_proxy`` to ``"auto"`` or
-``"stealth"`` (a.k.a. "enhanced") — this routes through residential proxies and
-is billed at ~5 credits/page, so it is opt-in and reserved for the Cloudflare
-tier. Firecrawl also natively parses PDFs to markdown (1 credit per PDF page),
-which is why it is the fallback for the tiered ``PdfFetcher``.
-
-The Firecrawl SDK is optional and imported lazily. The screenshot comes back as
-a hosted URL, which we download to bytes.
-"""
-
-from __future__ import annotations
-
-import logging
-import urllib.request
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-logger = logging.getLogger(__name__)
-
-
-def _attr(obj, key, default=None):
- if obj is None:
- return default
- if isinstance(obj, dict):
- return obj.get(key, default)
- return getattr(obj, key, default)
-
-
-class FirecrawlFetcher:
- name = "firecrawl"
-
- def __init__(self, config: ArchiveConfig | None = None, client=None):
- self.config = config or ArchiveConfig()
- self._client = client
-
- def _get_client(self):
- if self._client is not None:
- return self._client
- if not self.config.firecrawl_api_key:
- raise FetchError("FIRECRAWL_API_KEY is not set")
- try:
- from firecrawl import Firecrawl
- except ImportError as e:
- raise FetchError(
- "firecrawl-py is not installed. Install it with "
- "`pip install forecasting-tools[source-archive]`."
- ) from e
- self._client = Firecrawl(api_key=self.config.firecrawl_api_key)
- return self._client
-
- def _scrape_kwargs(self, formats: list[str]) -> dict:
- kwargs: dict = {"formats": formats}
- # Firecrawl 4.x renamed "stealth" to the "enhanced" proxy mode but still
- # accepts the legacy spelling; pass whatever the operator configured and
- # let the SDK normalize. "basic" is the implicit default, so only send
- # the param when something stronger is requested (keeps the call 1-credit
- # unless the operator explicitly opts into the 5-credit proxy).
- proxy = (self.config.firecrawl_proxy or "basic").strip().lower()
- if proxy and proxy != "basic":
- kwargs["proxy"] = proxy
- return kwargs
-
- def fetch(self, url: str) -> CaptureResult:
- client = self._get_client()
- try:
- doc = client.scrape(
- url, **self._scrape_kwargs(["markdown", "html", "screenshot"])
- )
- except Exception as e:
- raise FetchError(f"firecrawl scrape failed for {url}: {e}") from e
-
- metadata = _attr(doc, "metadata", {}) or {}
- status = _attr(metadata, "statusCode") or _attr(metadata, "status_code")
- final_url = _attr(metadata, "sourceURL") or _attr(metadata, "url") or url
-
- screenshot_url = _attr(doc, "screenshot")
- screenshot, content_type = None, None
- if screenshot_url:
- screenshot, content_type = self._download(screenshot_url)
-
- return CaptureResult(
- url=url,
- final_url=final_url,
- status_code=int(status) if status is not None else None,
- html=_attr(doc, "html"),
- markdown=_attr(doc, "markdown"),
- screenshot=screenshot,
- screenshot_content_type=content_type,
- fetcher=self.name,
- metadata={
- "title": _attr(metadata, "title"),
- "firecrawl_proxy": self.config.firecrawl_proxy,
- },
- )
-
- def fetch_pdf_markdown(self, url: str) -> str | None:
- """Scrape just the markdown for a PDF URL via Firecrawl's native PDF
- parser. Used as the fallback inside :class:`PdfFetcher` when local
- extraction yields thin text (e.g. a scanned PDF needing OCR)."""
- client = self._get_client()
- try:
- doc = client.scrape(url, **self._scrape_kwargs(["markdown"]))
- except Exception as e:
- raise FetchError(f"firecrawl pdf scrape failed for {url}: {e}") from e
- return _attr(doc, "markdown")
-
- @staticmethod
- def _download(src_url: str) -> tuple[bytes | None, str | None]:
- try:
- with urllib.request.urlopen(src_url, timeout=30) as resp:
- return resp.read(), resp.headers.get("Content-Type", "image/png")
- except Exception as e:
- logger.warning("failed to download firecrawl screenshot: %s", e)
- return None, None
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/hyperbrowser_fetcher.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/hyperbrowser_fetcher.py
deleted file mode 100644
index ce728abd..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/hyperbrowser_fetcher.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""Hyperbrowser fetcher — a managed FALLBACK backend.
-
-Hyperbrowser exposes a Firecrawl-style ``scrape`` endpoint that returns
-markdown + HTML + a screenshot in one call, with optional stealth, residential
-proxy, and CAPTCHA-solving session options for getting past Cloudflare and other
-anti-bot filters.
-
-Why it's here even though Firecrawl already is: forecasting-tools already uses
-Hyperbrowser elsewhere (``research/computer_use.py``), so routing the anti-bot
-tail through it consolidates spend onto one vendor/bill.
-
-Cost note: a basic scrape is 1 credit ($0.001); enabling ``use_proxy`` makes it
-10 credits ($0.01) plus proxy bandwidth ($10/GB). So the proxy/stealth session
-is opt-in and meant for the small hardened-Cloudflare residual, not every URL.
-Hyperbrowser has no documented PDF→markdown path, so PDFs go to the dedicated
-``PdfFetcher`` instead of here.
-
-The SDK is optional and imported lazily; a screenshot may come back as a hosted
-URL (downloaded to bytes) or inline base64.
-"""
-
-from __future__ import annotations
-
-import base64
-import binascii
-import logging
-import urllib.request
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-logger = logging.getLogger(__name__)
-
-
-def _attr(obj, key, default=None):
- if obj is None:
- return default
- if isinstance(obj, dict):
- return obj.get(key, default)
- return getattr(obj, key, default)
-
-
-class HyperbrowserFetcher:
- name = "hyperbrowser"
-
- def __init__(self, config: ArchiveConfig | None = None, client=None):
- self.config = config or ArchiveConfig()
- self._client = client
-
- def _get_client(self):
- if self._client is not None:
- return self._client
- if not self.config.hyperbrowser_api_key:
- raise FetchError("HYPERBROWSER_API_KEY is not set")
- try:
- from hyperbrowser import Hyperbrowser
- except ImportError as e:
- raise FetchError(
- "hyperbrowser is not installed. Install it with "
- "`pip install forecasting-tools[source-archive]`."
- ) from e
- self._client = Hyperbrowser(api_key=self.config.hyperbrowser_api_key)
- return self._client
-
- def _params(self, url: str):
- """Build the SDK request objects. Imported here (not at module top) so
- importing this module never requires the SDK."""
- from hyperbrowser.models import (
- CreateSessionParams,
- ScrapeOptions,
- StartScrapeJobParams,
- )
-
- return StartScrapeJobParams(
- url=url,
- scrape_options=ScrapeOptions(
- formats=["markdown", "html", "screenshot"],
- only_main_content=False,
- ),
- session_options=CreateSessionParams(
- use_proxy=self.config.hyperbrowser_use_proxy,
- use_stealth=self.config.hyperbrowser_use_stealth,
- solve_captchas=self.config.hyperbrowser_solve_captchas,
- ),
- )
-
- def fetch(self, url: str) -> CaptureResult:
- client = self._get_client()
- try:
- resp = client.scrape.start_and_wait(self._params(url))
- except Exception as e:
- raise FetchError(f"hyperbrowser scrape failed for {url}: {e}") from e
-
- # The job wrapper carries status/error; the payload is on ``.data``.
- if _attr(resp, "status") == "failed":
- raise FetchError(
- f"hyperbrowser scrape failed for {url}: {_attr(resp, 'error')}"
- )
- data = _attr(resp, "data", resp)
-
- metadata = _attr(data, "metadata", {}) or {}
- status = _attr(metadata, "statusCode") or _attr(metadata, "status_code")
- final_url = _attr(metadata, "sourceURL") or _attr(metadata, "url") or url
-
- screenshot, content_type = self._coerce_screenshot(_attr(data, "screenshot"))
-
- return CaptureResult(
- url=url,
- final_url=final_url,
- status_code=int(status) if status is not None else None,
- html=_attr(data, "html"),
- markdown=_attr(data, "markdown"),
- screenshot=screenshot,
- screenshot_content_type=content_type,
- fetcher=self.name,
- metadata={
- "title": _attr(metadata, "title"),
- "used_proxy": self.config.hyperbrowser_use_proxy,
- },
- )
-
- @classmethod
- def _coerce_screenshot(cls, value) -> tuple[bytes | None, str | None]:
- """A screenshot may arrive as a hosted URL, a data: URI, or raw base64."""
- if not value or not isinstance(value, str):
- return None, None
- if value.startswith("http://") or value.startswith("https://"):
- return cls._download(value)
- if value.startswith("data:"):
- try:
- header, b64 = value.split(",", 1)
- ctype = header[5:].split(";", 1)[0] or "image/png"
- return base64.b64decode(b64), ctype
- except (ValueError, binascii.Error):
- return None, None
- try:
- return base64.b64decode(value, validate=True), "image/png"
- except (binascii.Error, ValueError):
- return None, None
-
- @staticmethod
- def _download(src_url: str) -> tuple[bytes | None, str | None]:
- try:
- with urllib.request.urlopen(src_url, timeout=30) as resp:
- return resp.read(), resp.headers.get("Content-Type", "image/png")
- except Exception as e:
- logger.warning("failed to download hyperbrowser screenshot: %s", e)
- return None, None
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/pdf_fetcher.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/pdf_fetcher.py
deleted file mode 100644
index 0977605c..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/pdf_fetcher.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""PDF fetcher — closes the gap Playwright can't.
-
-Headless Chromium *downloads* a PDF instead of rendering it (``page.goto`` raises
-"Download is starting"), and trafilatura doesn't parse PDFs, so a cited ``.pdf``
-URL produces nothing today. This fetcher fills that hole with a two-tier strategy:
-
- 1. Download the PDF bytes and parse locally with **PyMuPDF4LLM** — free, fast,
- CPU-only, and excellent on text-layer PDFs (most government/legal reports).
- The first page is rendered to an image so the viewer still has a screenshot.
- 2. If local extraction yields thin text (a scanned PDF that needs OCR), fall
- back to **Firecrawl's** native PDF parser (~1 credit/page, OCR included).
-
-Both parsers are optional and imported lazily. Use :func:`looks_like_pdf` /
-:meth:`PdfFetcher.is_pdf` to decide whether a URL should be routed here.
-"""
-
-from __future__ import annotations
-
-import logging
-import urllib.request
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.fetchers.firecrawl_fetcher import (
- FirecrawlFetcher,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-from forecasting_tools.agents_and_tools.source_archive.quality import MIN_TEXT_LEN
-
-logger = logging.getLogger(__name__)
-
-_PDF_MAGIC = b"%PDF-"
-
-
-def looks_like_pdf(url: str) -> bool:
- """Cheap URL-shape heuristic: does this look like a PDF link? (The fetcher
- still confirms by sniffing the magic bytes before parsing.)"""
- path = url.split("?", 1)[0].split("#", 1)[0].lower()
- return path.endswith(".pdf")
-
-
-class PdfFetcher:
- name = "pdf"
-
- def __init__(
- self,
- config: ArchiveConfig | None = None,
- *,
- firecrawl: FirecrawlFetcher | None = None,
- downloader=None,
- ):
- self.config = config or ArchiveConfig()
- # Reuse the configured Firecrawl client for the OCR fallback when a key
- # is present; otherwise the fallback is simply skipped.
- if firecrawl is not None:
- self._firecrawl = firecrawl
- elif self.config.firecrawl_api_key:
- self._firecrawl = FirecrawlFetcher(self.config)
- else:
- self._firecrawl = None
- self._download = downloader or _download_bytes
-
- def is_pdf(self, url: str, data: bytes | None = None) -> bool:
- if data is not None:
- return data[:5] == _PDF_MAGIC
- return looks_like_pdf(url)
-
- def fetch(self, url: str) -> CaptureResult:
- data, final_url, status = self._download(url, self.config.nav_timeout_ms)
- if not data or data[:5] != _PDF_MAGIC:
- raise FetchError(f"not a PDF (no %PDF- magic) for {url}")
-
- markdown, screenshot, ctype, pages, engine = self._parse_local(data)
-
- thin = not markdown or len(markdown.strip()) < MIN_TEXT_LEN
- if thin and self._firecrawl is not None:
- logger.info("local PDF parse thin for %s; trying Firecrawl OCR", url)
- try:
- fc_md = self._firecrawl.fetch_pdf_markdown(url)
- except FetchError as e:
- logger.info("firecrawl PDF fallback failed for %s: %s", url, e)
- else:
- if fc_md and len(fc_md.strip()) >= MIN_TEXT_LEN:
- markdown, engine = fc_md, "firecrawl"
-
- return CaptureResult(
- url=url,
- final_url=final_url or url,
- status_code=status,
- html=None,
- markdown=markdown,
- screenshot=screenshot,
- screenshot_content_type=ctype,
- fetcher=self.name,
- metadata={"pdf_engine": engine, "pdf_pages": pages},
- )
-
- def _parse_local(
- self, data: bytes
- ) -> tuple[str | None, bytes | None, str | None, int, str]:
- """Return (markdown, screenshot_png, content_type, pages, engine)."""
- try:
- import pymupdf # PyMuPDF (a.k.a. fitz)
- import pymupdf4llm
- except ImportError:
- logger.warning(
- "pymupdf4llm not installed; local PDF parsing unavailable. "
- "Install with `pip install forecasting-tools[source-archive]`."
- )
- return None, None, None, 0, "none"
-
- try:
- doc = pymupdf.open(stream=data, filetype="pdf")
- except Exception as e:
- raise FetchError(f"could not open PDF: {e}") from e
-
- try:
- total = doc.page_count
- limit = min(total, self.config.pdf_max_pages) or total
- markdown = pymupdf4llm.to_markdown(doc, pages=list(range(limit)))
- screenshot, ctype = self._render_first_page(doc)
- return markdown or None, screenshot, ctype, total, "pymupdf4llm"
- finally:
- doc.close()
-
- @staticmethod
- def _render_first_page(doc) -> tuple[bytes | None, str | None]:
- try:
- pix = doc[0].get_pixmap(dpi=110)
- return pix.tobytes("png"), "image/png"
- except Exception as e:
- logger.info("could not render PDF first page: %s", e)
- return None, None
-
-
-def _download_bytes(
- url: str, timeout_ms: int
-) -> tuple[bytes | None, str | None, int | None]:
- # A browser-ish UA avoids the cheapest 403s; the content store needs the
- # bytes, not a render, so plain HTTP is fine and free.
- req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
- try:
- with urllib.request.urlopen(req, timeout=max(timeout_ms / 1000, 1)) as resp:
- return resp.read(), resp.geturl(), getattr(resp, "status", 200)
- except Exception as e:
- raise FetchError(f"could not download PDF for {url}: {e}") from e
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/playwright_fetcher.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/playwright_fetcher.py
deleted file mode 100644
index d81ea82e..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/playwright_fetcher.py
+++ /dev/null
@@ -1,254 +0,0 @@
-"""Self-hosted Playwright fetcher — the PRIMARY backend.
-
-A single page load yields all three artifacts:
-
- - HTML via ``page.content()``
- - screenshot via a full-page capture (height-capped, then compressed)
- - markdown via trafilatura over the rendered HTML
-
-Self-hosted compute is far cheaper than any managed scraping API, so this is the
-default; Firecrawl is reserved for sites that block headless Chromium (see
-``TieredFetcher``).
-
-Playwright and trafilatura are optional and imported lazily, so importing this
-module never requires a browser. Install everything with
-``pip install forecasting-tools[source-archive]`` and then run
-``playwright install chromium`` once to download the browser.
-"""
-
-from __future__ import annotations
-
-import io
-import logging
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import FetchError
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-logger = logging.getLogger(__name__)
-
-# WebP's hard per-side pixel limit; taller captures must be cropped before encode.
-_WEBP_MAX_DIM = 16383
-# Above this total pixel count, skip the screenshot rather than decode it: a
-# pathological full-page render (very tall × wide) costs minutes of CPU in Pillow
-# for a screenshot that's nice-to-have, not essential.
-_MAX_SCREENSHOT_PIXELS = 200_000_000
-
-
-def _to_markdown(html: str, url: str) -> str | None:
- try:
- import trafilatura
- except ImportError:
- logger.warning("trafilatura not installed; markdown will be omitted")
- return None
- return trafilatura.extract(
- html, url=url, output_format="markdown", include_links=True
- )
-
-
-# Scroll the document top-to-bottom (triggering lazy-loaded content) then back
-# up, so a subsequent full-page screenshot captures the fully-rendered page.
-_AUTOSCROLL_JS = """
-async () => {
- await new Promise((resolve) => {
- let y = 0;
- const step = () => {
- window.scrollTo(0, y);
- y += 1000;
- if (y < document.body.scrollHeight) setTimeout(step, 40);
- else resolve();
- };
- step();
- });
- window.scrollTo(0, 0);
-}
-"""
-
-
-def _encode_screenshot(
- png_bytes: bytes, fmt: str, max_height: int = 0
-) -> tuple[bytes, str]:
- """Crop (to ``max_height``) and re-encode a PNG screenshot using Pillow.
-
- Pillow is already a forecasting-tools dependency, so true WebP is available
- here (Playwright itself only emits PNG/JPEG). The height cap is enforced by
- cropping the *full-page* render to its top ``max_height`` pixels — never via
- Playwright's ``clip`` (which, without ``full_page``, is bounded by the
- viewport and silently truncates tall pages to a single screen).
- """
- fmt = fmt.lower()
- try:
- from PIL import Image
- except ImportError:
- # No Pillow: can't crop or transcode; hand back the raw full-page PNG.
- return png_bytes, "image/png"
-
- image = Image.open(io.BytesIO(png_bytes)) # lazy: reads size, doesn't decode
- if image.width * image.height > _MAX_SCREENSHOT_PIXELS:
- raise ValueError(
- f"screenshot too large to encode ({image.width}x{image.height}px)"
- )
- # WebP cannot encode beyond 16383px on a side. Clamp the effective cap for
- # webp so an over-tall page degrades to a top-crop instead of crashing the
- # encoder mid-run (which would propagate out of fetch() and abort the URL).
- limit = max_height or 0
- if fmt == "webp":
- limit = min(limit or _WEBP_MAX_DIM, _WEBP_MAX_DIM)
- if limit and image.height > limit:
- image = image.crop((0, 0, image.width, limit))
-
- out = io.BytesIO()
- if fmt == "webp":
- image.save(out, format="WEBP", quality=80, method=6)
- return out.getvalue(), "image/webp"
- if fmt in ("jpeg", "jpg"):
- image.convert("RGB").save(out, format="JPEG", quality=80, optimize=True)
- return out.getvalue(), "image/jpeg"
- image.save(out, format="PNG", optimize=True)
- return out.getvalue(), "image/png"
-
-
-class PlaywrightFetcher:
- """Renders pages with a persistent headless Chromium.
-
- Use it as a context manager so the browser launches once and is reused
- across many URLs (throughput is thousands of pages/hour single-process)::
-
- with PlaywrightFetcher(config) as fetcher:
- for url in urls:
- fetcher.fetch(url)
- """
-
- name = "playwright"
-
- def __init__(self, config: ArchiveConfig | None = None):
- self.config = config or ArchiveConfig()
- self._playwright = None
- self._browser = None
-
- def _launch_browser(self):
- """Start the browser. Returns ``(playwright_or_none, browser)`` where
- ``browser`` is a Playwright ``Browser``. Subclasses override this to swap
- in a different stealth browser (see ``CloakBrowserFetcher``) while reusing
- all of the capture logic. A backend that manages its own driver returns
- ``None`` for the first element."""
- try:
- from playwright.sync_api import sync_playwright
- except ImportError as e:
- raise FetchError(
- "playwright is not installed. Install it with "
- "`pip install forecasting-tools[source-archive]` and then run "
- "`playwright install chromium`."
- ) from e
- playwright = sync_playwright().start()
- browser = playwright.chromium.launch(headless=True)
- return playwright, browser
-
- def __enter__(self) -> "PlaywrightFetcher":
- self._playwright, self._browser = self._launch_browser()
- return self
-
- def __exit__(self, *exc) -> None:
- # close() raises when the browser process is already gone (crashed or
- # killed by the pipeline's reaper). Still attempt stop(): it tears down
- # the driver and un-registers the sync API's event loop from this
- # thread — without that, the next sync_playwright().start() here fails
- # with "Sync API inside the asyncio loop".
- try:
- if self._browser is not None:
- self._browser.close()
- except Exception as e:
- logger.info("browser close failed (already dead?): %s", e)
- finally:
- self._browser = None
- try:
- if self._playwright is not None:
- self._playwright.stop()
- except Exception as e:
- logger.info("playwright stop failed: %s", e)
- finally:
- self._playwright = None
-
- def _settle(self, page) -> None:
- """Best-effort: let the page finish rendering before the screenshot.
-
- ``page.goto`` only waits for ``domcontentloaded``, which fires before
- CSS/images/lazy content have laid out — capturing then yields a short,
- half-built page. Wait for the load/network to quiesce and scroll the
- document to force lazy content in, so the full-page capture is complete.
- Each step is bounded and swallows timeouts: rendering aids are
- nice-to-have, never fatal to the capture.
- """
- try:
- page.wait_for_load_state("load", timeout=self.config.nav_timeout_ms)
- except Exception:
- pass
- try:
- page.wait_for_load_state(
- "networkidle", timeout=min(self.config.nav_timeout_ms, 10_000)
- )
- except Exception:
- pass
- try:
- page.evaluate(_AUTOSCROLL_JS)
- page.wait_for_timeout(500)
- except Exception:
- pass
-
- def fetch(self, url: str) -> CaptureResult:
- if self._browser is None:
- raise FetchError("PlaywrightFetcher must be used as a context manager")
-
- context = self._browser.new_context()
- page = context.new_page()
- try:
- try:
- response = page.goto(
- url,
- wait_until="domcontentloaded",
- timeout=self.config.nav_timeout_ms,
- )
- except Exception as e:
- raise FetchError(f"navigation failed for {url}: {e}") from e
-
- self._settle(page)
-
- status = response.status if response is not None else None
- html = page.content()
-
- # Always capture the entire scrollable page in one shot — Playwright
- # stitches it internally. The height cap is applied afterward by
- # cropping in Pillow (see ``_encode_screenshot``). Fall back to a
- # viewport capture only if a full-page shot fails (e.g. a page taller
- # than Chromium's screenshot limit).
- try:
- png = page.screenshot(full_page=True)
- except Exception as e:
- logger.info("full-page screenshot failed for %s: %s", url, e)
- png = page.screenshot()
- # Encoding can fail on pathological pages (e.g. a 400M-pixel full-page
- # render trips Pillow's decompression-bomb guard). A screenshot is
- # nice-to-have — never lose the whole capture over it.
- try:
- screenshot, content_type = _encode_screenshot(
- png,
- self.config.screenshot_format,
- self.config.screenshot_max_height,
- )
- except Exception as e:
- logger.info("screenshot encode failed for %s: %s", url, e)
- screenshot, content_type = None, None
-
- return CaptureResult(
- url=url,
- final_url=page.url,
- status_code=status,
- html=html,
- markdown=_to_markdown(html, page.url),
- screenshot=screenshot,
- screenshot_content_type=content_type,
- fetcher=self.name,
- metadata={"title": page.title()},
- )
- finally:
- context.close()
diff --git a/forecasting_tools/agents_and_tools/source_archive/fetchers/tiered.py b/forecasting_tools/agents_and_tools/source_archive/fetchers/tiered.py
deleted file mode 100644
index bb47640a..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/fetchers/tiered.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Tiered fetcher: self-hosted Playwright first, Firecrawl on failure.
-
-A backend "fails" if it raises ``FetchError`` (couldn't render) OR its capture
-fails the quality gate (404 / block page / thin content). The first capture that
-passes the gate wins. If none pass, the last attempted capture is returned with
-``quality_passed=False`` in its metadata so the pipeline can still record the
-miss.
-"""
-
-from __future__ import annotations
-
-import logging
-
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import (
- Fetcher,
- FetchError,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-from forecasting_tools.agents_and_tools.source_archive.quality import evaluate
-
-logger = logging.getLogger(__name__)
-
-
-class TieredFetcher:
- name = "tiered"
-
- def __init__(self, *backends: Fetcher):
- if not backends:
- raise ValueError("TieredFetcher requires at least one backend")
- self.backends = backends
-
- def fetch(self, url: str) -> CaptureResult:
- last_result: CaptureResult | None = None
- errors: list[str] = []
-
- for backend in self.backends:
- try:
- result = backend.fetch(url)
- except FetchError as e:
- errors.append(f"{backend.name}: {e}")
- continue
-
- verdict = evaluate(result)
- result.metadata["quality_passed"] = verdict.passed
- result.metadata["quality_reason"] = verdict.reason
- if verdict.passed:
- return result
- last_result = result
- errors.append(f"{backend.name}: quality {verdict.reason}")
-
- if last_result is not None:
- logger.info(
- "all backends failed quality for %s: %s", url, "; ".join(errors)
- )
- return last_result
- raise FetchError(f"all backends failed for {url}: {'; '.join(errors)}")
diff --git a/forecasting_tools/agents_and_tools/source_archive/ingest/__init__.py b/forecasting_tools/agents_and_tools/source_archive/ingest/__init__.py
deleted file mode 100644
index 5b9729db..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/ingest/__init__.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Ingestion: discover the URLs a bot cited and turn them into a manifest.
-
-The capture pipeline needs a citation manifest as input. These helpers build one
-from a bot's published reasoning:
-
- - :mod:`url_extraction` — pull URLs out of free text / markdown.
- - :mod:`trace_extraction` — build a manifest from a traced bot run (fullest path).
-"""
-
-from forecasting_tools.agents_and_tools.source_archive.ingest.trace_extraction import (
- extract_records_from_events,
- extract_records_from_question_dir,
- extract_records_from_trace_file,
- harvest_run,
-)
-from forecasting_tools.agents_and_tools.source_archive.ingest.url_extraction import (
- dedupe_records,
- extract_citation_records,
- extract_urls,
-)
-
-__all__ = [
- "dedupe_records",
- "extract_citation_records",
- "extract_records_from_events",
- "extract_records_from_question_dir",
- "extract_records_from_trace_file",
- "extract_urls",
- "harvest_run",
-]
diff --git a/forecasting_tools/agents_and_tools/source_archive/ingest/trace_extraction.py b/forecasting_tools/agents_and_tools/source_archive/ingest/trace_extraction.py
deleted file mode 100644
index 1c63fe08..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/ingest/trace_extraction.py
+++ /dev/null
@@ -1,379 +0,0 @@
-"""Build a citation manifest from a bot's run traces.
-
-When the template bot is run with tracing enabled it writes one JSONL trace per
-forecast attempt, recording the agent loop step by step. Those traces are the
-*fullest* record of what the bot actually looked at — richer than the reasoning
-comment it posts, which is length-truncated.
-
-This module walks those traces and pulls out every external URL the bot touched,
-turning each into a :class:`CitationRecord` with provenance (which trace, which
-tool, the search query that surfaced it). That manifest is the input to the
-capture pipeline, exactly like the comment-harvested one.
-
-Trace layout
-------------
-A traced run is a directory tree::
-
- /
- bot_/
- q_/
- question.json
- traces_forecast_1_attempt_1.jsonl
- traces_summarize.jsonl
- ...
-
-Each ``traces_*.jsonl`` file is a stream of newline-delimited event objects. The
-events that can carry external links are:
-
-- ``tool_call`` — the arguments the bot passed to a tool (e.g. a search query,
- or a ``url`` handed to a page fetcher). Carries ``name`` and ``call_id``.
-- ``tool_result`` — what the tool returned. Search tools inline their citations
- here as ``[n](url)`` or as a list of result URLs. Carries ``call_id`` so the
- result can be attributed back to the originating ``tool_call``.
-- ``initial_prompt`` — the first prompt of a trace. Only scanned for the
- ``summarize`` trace: the template bot runs research *outside* the agent loop
- and pastes the research blob verbatim into the summarizer's first prompt, so
- that is the one place those URLs are recoverable. Other traces' initial
- prompts just echo the question text (background, resolution criteria), whose
- URLs aren't research, so they're skipped.
-
-Search provenance (``query`` / ``tool_args``) only exists in these instrumented
-traces — it is populated here from each ``tool_call`` and carried onto the URLs
-that the matching ``tool_result`` returned.
-"""
-
-from __future__ import annotations
-
-import glob
-import json
-import os
-from pathlib import Path
-from typing import Any
-
-from forecasting_tools.agents_and_tools.source_archive.ingest.url_extraction import (
- extract_urls,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CitationRecord
-
-METACULUS_QUESTION_URL_FMT = "https://www.metaculus.com/questions/{}/"
-
-# Event type -> the field on that event that carries the URL-bearing payload.
-_SCANNABLE_FIELDS: dict[str, str] = {
- "tool_call": "args",
- "tool_result": "content",
- "initial_prompt": "prompt",
-}
-# The trace whose initial prompt holds pasted-in research (see module docstring).
-_SUMMARIZE_TRACE_LABEL = "summarize"
-# Keys a tool's input commonly uses for the search string, best-effort.
-_QUERY_KEYS = ("query", "q", "search_query", "search", "queries", "question")
-
-
-def _urls_in(value: Any) -> list[str]:
- """Return URLs found anywhere in a string / dict / list, in first-seen order.
-
- Tool args are structured (a dict) and tool results may be either a blob of
- text or a structured payload, so we walk the whole value and run the shared
- :func:`extract_urls` over every string we reach — keeping markdown-link and
- trailing-punctuation handling identical to the comment path.
- """
- urls: list[str] = []
-
- def walk(v: Any) -> None:
- if v is None:
- return
- if isinstance(v, str):
- urls.extend(extract_urls(v))
- return
- if isinstance(v, dict):
- for key, val in v.items():
- walk(key)
- walk(val)
- return
- if isinstance(v, (list, tuple, set, frozenset)):
- for item in v:
- walk(item)
- return
- walk(str(v))
-
- walk(value)
- return urls
-
-
-def _query_from_args(args: Any) -> str | None:
- """Pull the search string out of a tool's arguments, if recognisable."""
- if not isinstance(args, dict):
- return None
- for key in _QUERY_KEYS:
- val = args.get(key)
- if isinstance(val, str) and val.strip():
- return val
- if isinstance(val, (list, tuple)) and val:
- joined = " ".join(str(item) for item in val if item)
- if joined.strip():
- return joined
- return None
-
-
-def trace_label(trace_path: str) -> str:
- """``traces_forecast_1_attempt_1.jsonl`` -> ``forecast_1_attempt_1``."""
- name = os.path.basename(trace_path)
- if name.startswith("traces_"):
- name = name[len("traces_") :]
- if name.endswith(".jsonl"):
- name = name[: -len(".jsonl")]
- return name
-
-
-def extract_records_from_events(
- events: Any,
- *,
- trace: str | None = None,
- include_initial_prompt: bool = False,
- run_id: str | None = None,
- bot: str | None = None,
- question_id: str | None = None,
- metaculus_id: str | None = None,
- question_url: str | None = None,
-) -> list[CitationRecord]:
- """Turn one trace's event stream into CitationRecords.
-
- ``events`` is any iterable of event dicts (already parsed from JSONL). The
- given provenance is stamped onto every record; per-event provenance
- (``trace``, ``tool_name``, ``origin``, ``query``, ``tool_args``,
- ``first_seen``) is filled in here.
-
- Set ``include_initial_prompt`` to scan ``initial_prompt`` events — callers
- should only do this for the ``summarize`` trace (see module docstring).
- """
- records: list[CitationRecord] = []
- # Attribute tool_result events (which only carry call_id) back to the
- # originating tool_call's name and arguments.
- tool_name_by_call_id: dict[str, str] = {}
- tool_args_by_call_id: dict[str, Any] = {}
-
- for event in events:
- if not isinstance(event, dict):
- continue
- event_type = event.get("type")
-
- if event_type == "tool_call":
- call_id = str(event.get("call_id") or "").strip()
- name = event.get("name") or ""
- if call_id:
- if name:
- tool_name_by_call_id[call_id] = name
- if "args" in event:
- tool_args_by_call_id[call_id] = event.get("args")
-
- field = _SCANNABLE_FIELDS.get(event_type or "")
- if field is None:
- continue
- if event_type == "initial_prompt" and not include_initial_prompt:
- continue
-
- urls = _urls_in(event.get(field))
- if not urls:
- continue
-
- if event_type == "tool_call":
- tool_name = event.get("name") or ""
- origin = "tool_call"
- tool_args = (
- event.get("args") if isinstance(event.get("args"), dict) else None
- )
- elif event_type == "tool_result":
- call_id = str(event.get("call_id") or "").strip()
- tool_name = tool_name_by_call_id.get(call_id, "")
- origin = "tool_result"
- originating_args = tool_args_by_call_id.get(call_id)
- tool_args = originating_args if isinstance(originating_args, dict) else None
- else: # initial_prompt
- tool_name = ""
- origin = event_type or ""
- tool_args = None
-
- query = _query_from_args(tool_args)
- timestamp = event.get("timestamp")
- for url in urls:
- record = CitationRecord(
- url=url,
- run_id=run_id,
- bot=bot,
- question_id=question_id,
- metaculus_id=metaculus_id,
- question_url=question_url,
- trace=trace,
- tool_name=tool_name,
- origin=origin,
- query=query,
- tool_args=tool_args,
- )
- if timestamp:
- record.first_seen = str(timestamp)
- records.append(record)
-
- return records
-
-
-def _read_jsonl(path: str) -> list[dict]:
- """Read a JSONL file, skipping blank or unparsable lines."""
- events: list[dict] = []
- for raw_line in Path(path).read_text(encoding="utf-8").splitlines():
- line = raw_line.strip()
- if not line:
- continue
- try:
- events.append(json.loads(line))
- except json.JSONDecodeError:
- continue
- return events
-
-
-def extract_records_from_trace_file(
- trace_path: str,
- *,
- run_id: str | None = None,
- bot: str | None = None,
- question_id: str | None = None,
- metaculus_id: str | None = None,
- question_url: str | None = None,
-) -> list[CitationRecord]:
- """Extract CitationRecords from one ``traces_*.jsonl`` file."""
- label = trace_label(trace_path)
- return extract_records_from_events(
- _read_jsonl(trace_path),
- trace=label,
- include_initial_prompt=(label == _SUMMARIZE_TRACE_LABEL),
- run_id=run_id,
- bot=bot,
- question_id=question_id,
- metaculus_id=metaculus_id,
- question_url=question_url,
- )
-
-
-def _read_question_metadata(question_dir: str) -> tuple[str | None, str | None]:
- """Return ``(question_id, metaculus_id)`` from ``question.json`` in the dir.
-
- Read as a plain dict with flexible keys so the ingest stays decoupled from
- any particular question model. Missing/unparsable metadata is non-fatal —
- records are still emitted, just with empty question provenance.
- """
- question_path = os.path.join(question_dir, "question.json")
- if not os.path.exists(question_path):
- return None, None
- try:
- data = json.loads(Path(question_path).read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- return None, None
- if not isinstance(data, dict):
- return None, None
-
- def _str_or_none(*keys: str) -> str | None:
- for key in keys:
- val = data.get(key)
- if val is not None:
- return str(val)
- return None
-
- question_id = _str_or_none("question_id", "id", "post_id")
- metaculus_id = _str_or_none("metaculus_id", "post_id", "id")
- return question_id, metaculus_id
-
-
-def extract_records_from_question_dir(
- question_dir: str,
- *,
- run_id: str | None = None,
- bot: str | None = None,
- question_id: str | None = None,
- metaculus_id: str | None = None,
- question_url: str | None = None,
-) -> list[CitationRecord]:
- """Aggregate CitationRecords across every trace in one ``q_*`` dir.
-
- Question provenance is read from ``question.json`` in the dir; pass any of
- ``question_id`` / ``metaculus_id`` / ``question_url`` to override what's
- found there (or to supply it when the file is absent).
- """
- found_qid, found_mid = _read_question_metadata(question_dir)
- question_id = question_id or found_qid
- metaculus_id = metaculus_id or found_mid
- if question_url is None and metaculus_id is not None:
- question_url = METACULUS_QUESTION_URL_FMT.format(metaculus_id)
-
- records: list[CitationRecord] = []
- for trace_path in sorted(glob.glob(os.path.join(question_dir, "traces_*.jsonl"))):
- records.extend(
- extract_records_from_trace_file(
- trace_path,
- run_id=run_id,
- bot=bot,
- question_id=question_id,
- metaculus_id=metaculus_id,
- question_url=question_url,
- )
- )
- return records
-
-
-def _bot_name_from_dir(bot_dir: str) -> str:
- """``.../bot_complex`` -> ``complex``."""
- name = os.path.basename(bot_dir)
- return name[len("bot_") :] if name.startswith("bot_") else name
-
-
-def _question_dirs_flat(run_dir: str) -> list[str]:
- """Question dirs directly under ``run_dir`` (no ``bot_*`` level).
-
- A "question dir" is any immediate subdirectory that actually contains
- ``traces_*.jsonl``. This handles flatter layouts (e.g. a backfill of one
- bot's runs as ``//traces_*.jsonl``) where the ``bot_*``
- grouping is absent.
- """
- dirs = []
- for entry in sorted(glob.glob(os.path.join(run_dir, "*"))):
- if os.path.isdir(entry) and glob.glob(os.path.join(entry, "traces_*.jsonl")):
- dirs.append(entry)
- return dirs
-
-
-def harvest_run(
- run_dir: str, *, run_id: str | None = None, bot: str | None = None
-) -> list[CitationRecord]:
- """Build a citation manifest from a whole traced run directory.
-
- Primary layout is ``/bot_*/q_*/traces_*.jsonl``, deriving ``run_id``
- from the run dir's name and ``bot`` from each ``bot_*`` subdir. If no
- ``bot_*`` subdirs exist, falls back to a **flat layout** —
- ``//traces_*.jsonl`` — attributing every question to a
- single bot (the ``bot`` argument, else the run dir's name). Question
- provenance still comes from each dir's ``question.json``.
-
- Returns the flat list of CitationRecords (one per URL occurrence); feed it
- through :func:`url_extraction.dedupe_records` before capture for one row per
- URL.
- """
- run_id = run_id or os.path.basename(os.path.normpath(run_dir))
- records: list[CitationRecord] = []
-
- bot_dirs = sorted(glob.glob(os.path.join(run_dir, "bot_*")))
- if bot_dirs:
- for bot_dir in bot_dirs:
- bot_name = _bot_name_from_dir(bot_dir)
- for question_dir in sorted(glob.glob(os.path.join(bot_dir, "q_*"))):
- records.extend(
- extract_records_from_question_dir(
- question_dir, run_id=run_id, bot=bot_name
- )
- )
- return records
-
- # Flat fallback: no bot_* grouping. One bot, question dirs directly below.
- bot_name = bot or os.path.basename(os.path.normpath(run_dir))
- for question_dir in _question_dirs_flat(run_dir):
- records.extend(
- extract_records_from_question_dir(question_dir, run_id=run_id, bot=bot_name)
- )
- return records
diff --git a/forecasting_tools/agents_and_tools/source_archive/ingest/url_extraction.py b/forecasting_tools/agents_and_tools/source_archive/ingest/url_extraction.py
deleted file mode 100644
index b8b06d3b..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/ingest/url_extraction.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""Extract URLs from free text and markdown.
-
-Bots surface their sources as prose with embedded links (e.g. the reasoning
-comment they post on a question). This module pulls those URLs out and turns
-them into :class:`CitationRecord` provenance rows — the manifest that feeds the
-capture pipeline.
-
-It handles markdown links ``[label](url)``, autolinks ````, and bare URLs,
-and trims the trailing punctuation that so often clings to a URL in prose.
-"""
-
-from __future__ import annotations
-
-import re
-from collections.abc import Iterable
-
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import CitationRecord
-
-# Markdown link target: [label](url) or [label](), optionally with a title.
-_MD_LINK = re.compile(r"\[[^\]]*\]\(\s*(https?://[^)\s>]+)>?[^)]*\)", re.IGNORECASE)
-# Autolink:
-_AUTOLINK = re.compile(r"<(https?://[^>\s]+)>", re.IGNORECASE)
-# Bare URL. Parens are allowed in the match and removed by _trim only when
-# unbalanced, so trailing prose parens drop but ``..._(disambiguation)`` survives.
-_BARE = re.compile(r"(https?://[^\s<>\"'\]]+)", re.IGNORECASE)
-
-# Characters commonly stuck to the end of a URL in prose (incl. markdown-escape
-# residue: a trailing backslash or backtick).
-_TRAILING = ".,;:!?'\"\\`"
-
-
-def _cut_markdown_tail(url: str) -> str:
- """Cut a URL at a markdown reference/link tail the bare-URL scan can swallow.
-
- Bots sometimes emit ``…/story?id=123)[10](https://other…)`` where ``)[10](…``
- is a markdown reference glued onto a real URL. The leading ``)`` was never
- part of the URL, so cut at the first ``)[`` or ``](`` boundary.
- """
- cut = len(url)
- for marker in (")[", "]("):
- i = url.find(marker)
- if i > 0:
- cut = min(cut, i)
- return url[:cut]
-
-
-def _trim(url: str) -> str:
- """Strip trailing punctuation, and a closing bracket/paren only when it is
- unbalanced (so Wikipedia-style ``..._(disambiguation)`` URLs survive)."""
- url = _cut_markdown_tail(url)
- while url:
- last = url[-1]
- if last in _TRAILING:
- url = url[:-1]
- elif last == ")" and url.count("(") < url.count(")"):
- url = url[:-1]
- elif last == "]" and url.count("[") < url.count("]"):
- url = url[:-1]
- else:
- break
- return url
-
-
-def extract_urls(text: str | None) -> list[str]:
- """Return the distinct http(s) URLs in ``text``, in first-seen order.
-
- Distinctness is by *canonical* URL (see :func:`canonicalize_url`), so
- ``…/x`` and ``…/x?utm_source=…`` count once; the original first-seen string
- is returned.
- """
- if not text:
- return []
- seen: set[str] = set()
- ordered: list[str] = []
- for pattern in (_MD_LINK, _AUTOLINK, _BARE):
- for match in pattern.finditer(text):
- url = _trim(match.group(1))
- if not url:
- continue
- key = canonicalize_url(url)
- if key not in seen:
- seen.add(key)
- ordered.append(url)
- return ordered
-
-
-def extract_citation_records(
- text: str | None,
- *,
- run_id: str | None = None,
- bot: str | None = None,
- question_id: str | None = None,
- metaculus_id: str | None = None,
- question_url: str | None = None,
- comment_id: str | None = None,
- trace: str | None = None,
- tool_name: str | None = None,
- origin: str | None = None,
-) -> list[CitationRecord]:
- """Extract URLs from ``text`` and wrap each in a CitationRecord with the
- given provenance."""
- return [
- CitationRecord(
- url=url,
- run_id=run_id,
- bot=bot,
- question_id=question_id,
- metaculus_id=metaculus_id,
- question_url=question_url,
- comment_id=comment_id,
- trace=trace,
- tool_name=tool_name,
- origin=origin,
- )
- for url in extract_urls(text)
- ]
-
-
-def dedupe_records(records: Iterable[CitationRecord]) -> list[CitationRecord]:
- """Keep the first record per *canonical* URL, preserving order."""
- seen: set[str] = set()
- out: list[CitationRecord] = []
- for r in records:
- if not r.url:
- continue
- key = canonicalize_url(r.url)
- if key not in seen:
- seen.add(key)
- out.append(r)
- return out
diff --git a/forecasting_tools/agents_and_tools/source_archive/layout.py b/forecasting_tools/agents_and_tools/source_archive/layout.py
deleted file mode 100644
index b63c1435..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/layout.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Key layout for manifests and run reports in the blob store.
-
-Manifests and reports used to be written flat (``manifests/.jsonl``,
-``reports/.json``), which turns into one giant folder as runs
-accumulate. New writes nest by run family instead:
-
-- an explicit ``group`` pins the folder (e.g. ``sprints/myrun``);
-- ``daily-YYYY-MM-DD`` run ids nest under ``daily//``;
-- everything else lands under ``adhoc/``.
-
-Readers that list by prefix (catalog, coverage) pick up both layouts for free;
-exact-key readers should try :func:`manifest_key_candidates` /
-:func:`report_key_candidates` — nested first, then the legacy flat key — so
-archives written before the nesting keep working.
-
-Keys here are store-relative (no ``s3_prefix``); callers prepend the prefix.
-"""
-
-from __future__ import annotations
-
-import re
-
-_DAILY_RUN_ID = re.compile(r"^daily-(\d{4}-\d{2})-\d{2}$")
-
-
-def _folder(run_id: str, group: str | None) -> str:
- if group:
- return group.strip("/")
- match = _DAILY_RUN_ID.match(run_id)
- if match:
- return f"daily/{match.group(1)}"
- return "adhoc"
-
-
-def manifest_key(run_id: str, group: str | None = None) -> str:
- """Nested key for a run's citation manifest."""
- return f"manifests/{_folder(run_id, group)}/{run_id}.jsonl"
-
-
-def report_key(run_id: str, suffix: str, group: str | None = None) -> str:
- """Nested key for a run report artifact (``suffix`` e.g. ``.json``)."""
- return f"reports/{_folder(run_id, group)}/{run_id}{suffix}"
-
-
-def legacy_manifest_key(run_id: str) -> str:
- return f"manifests/{run_id}.jsonl"
-
-
-def legacy_report_key(run_id: str, suffix: str) -> str:
- return f"reports/{run_id}{suffix}"
-
-
-def manifest_key_candidates(run_id: str, group: str | None = None) -> list[str]:
- """Where a run's manifest may live, preferred (nested) first."""
- return [manifest_key(run_id, group), legacy_manifest_key(run_id)]
-
-
-def report_key_candidates(
- run_id: str, suffix: str, group: str | None = None
-) -> list[str]:
- """Where a run's report may live, preferred (nested) first."""
- return [report_key(run_id, suffix, group), legacy_report_key(run_id, suffix)]
diff --git a/forecasting_tools/agents_and_tools/source_archive/manifest.py b/forecasting_tools/agents_and_tools/source_archive/manifest.py
deleted file mode 100644
index 4b629fd1..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/manifest.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Per-run citation manifest: one JSONL record per (URL, citation).
-
-This is the provenance layer a bot emits and the input to the capture pipeline.
-One manifest per run, stored in the blob store under the nested ``manifests/``
-layout (see :mod:`layout`); reads fall back to the legacy flat key.
-"""
-
-from __future__ import annotations
-
-import json
-from collections.abc import Iterable, Iterator
-from pathlib import Path
-
-from forecasting_tools.agents_and_tools.source_archive import layout
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.models import CitationRecord
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-
-def dumps(records: Iterable[CitationRecord]) -> str:
- return "\n".join(json.dumps(r.model_dump(), sort_keys=True) for r in records)
-
-
-def loads(text: str) -> list[CitationRecord]:
- out: list[CitationRecord] = []
- for line in text.splitlines():
- line = line.strip()
- if line:
- out.append(CitationRecord.model_validate(json.loads(line)))
- return out
-
-
-def unique_urls(records: Iterable[CitationRecord]) -> Iterator[str]:
- """Yield each distinct URL once, preserving first-seen order.
-
- Distinctness is by *canonical* URL (see :func:`canonicalize_url`), so
- near-duplicate links collapse to a single fetch; the original first-seen URL
- string is what's yielded, for provenance.
- """
- seen: set[str] = set()
- for r in records:
- if not r.url:
- continue
- key = canonicalize_url(r.url)
- if key not in seen:
- seen.add(key)
- yield r.url
-
-
-# --- file io ---------------------------------------------------------------
-def read_file(path: str | Path) -> list[CitationRecord]:
- return loads(Path(path).read_text(encoding="utf-8"))
-
-
-def write_file(path: str | Path, records: Iterable[CitationRecord]) -> None:
- Path(path).write_text(dumps(records), encoding="utf-8")
-
-
-# --- blob store io ---------------------------------------------------------
-def manifest_key(
- run_id: str, config: ArchiveConfig | None = None, group: str | None = None
-) -> str:
- prefix = (config or ArchiveConfig()).s3_prefix.rstrip("/")
- return f"{prefix}/{layout.manifest_key(run_id, group)}"
-
-
-def read_blob(
- store: BlobStore,
- run_id: str,
- config: ArchiveConfig | None = None,
- group: str | None = None,
-) -> list[CitationRecord]:
- prefix = (config or ArchiveConfig()).s3_prefix.rstrip("/")
- candidates = [
- f"{prefix}/{k}" for k in layout.manifest_key_candidates(run_id, group)
- ]
- # Prefer the nested key; fall back to the legacy flat key for old runs.
- for key in candidates[:-1]:
- if store.exists(key):
- return loads(store.get(key).decode("utf-8"))
- return loads(store.get(candidates[-1]).decode("utf-8"))
-
-
-def write_blob(
- store: BlobStore,
- run_id: str,
- records: Iterable[CitationRecord],
- config: ArchiveConfig | None = None,
- group: str | None = None,
-) -> None:
- store.put(
- manifest_key(run_id, config, group),
- dumps(records).encode("utf-8"),
- content_type="application/x-ndjson",
- )
diff --git a/forecasting_tools/agents_and_tools/source_archive/models.py b/forecasting_tools/agents_and_tools/source_archive/models.py
deleted file mode 100644
index 08c63cd6..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/models.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""Core data structures shared across the source-archive pipeline."""
-
-from __future__ import annotations
-
-import hashlib
-from datetime import datetime, timezone
-from typing import Any
-
-from pydantic import BaseModel, Field
-
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-
-
-def utcnow_iso() -> str:
- return datetime.now(timezone.utc).isoformat()
-
-
-def url_hash(url: str) -> str:
- """Stable key for a URL — groups every capture of that URL together.
-
- The URL is canonicalized first (see :func:`canonicalize_url`) so trivially
- different links — tracking params, a trailing slash, a ``#fragment``,
- query-param order, host case — collapse onto one key instead of being
- stored and counted as separate sources.
- """
- return hashlib.sha256(canonicalize_url(url).encode("utf-8")).hexdigest()
-
-
-def content_hash(html: str | bytes) -> str:
- """Hash of page content — dedups identical re-fetches of the same URL."""
- data = html.encode("utf-8") if isinstance(html, str) else html
- return hashlib.sha256(data).hexdigest()
-
-
-class CaptureResult(BaseModel):
- """What a fetcher returns for a single URL, before it is stored."""
-
- url: str
- final_url: str
- status_code: int | None = None
- html: str | None = None
- markdown: str | None = None
- screenshot: bytes | None = None
- screenshot_content_type: str | None = None
- fetcher: str = ""
- fetched_at: str = Field(default_factory=utcnow_iso)
- metadata: dict[str, Any] = Field(default_factory=dict)
-
- @property
- def content_hash(self) -> str:
- basis = self.html if self.html else (self.markdown or self.final_url)
- return content_hash(basis)
-
-
-class StoredCapture(BaseModel):
- """Pointer to a stored capture in the object store."""
-
- url: str
- url_hash: str
- content_hash: str
- status_code: int | None = None
- fetcher: str = ""
- captured_at: str = Field(default_factory=utcnow_iso)
- html_key: str | None = None
- screenshot_key: str | None = None
- markdown_key: str | None = None
- # Set when this capture reuses another URL's blobs because the fetched
- # content was byte-identical (cross-URL content dedup); holds that URL's hash.
- content_alias_of: str | None = None
- first_seen: str = Field(default_factory=utcnow_iso)
- last_seen: str = Field(default_factory=utcnow_iso)
-
-
-class CitationRecord(BaseModel):
- """One provenance record per (URL, citation) a bot emitted in a run.
-
- This is the manifest schema: a run produces a JSONL file of these, which is
- the input to the capture pipeline. Fields are deliberately generic so any
- bot's trace/comment format can be mapped onto them.
- """
-
- url: str
- run_id: str | None = None
- bot: str | None = None
- question_id: str | None = None
- metaculus_id: str | None = None
- question_url: str | None = None
- comment_id: str | None = None # Metaculus comment the URL was cited in
- trace: str | None = None
- tool_name: str | None = None
- origin: str | None = None
- # Search provenance (populated by instrumented trace ingest, not comments):
- query: str | None = None # the search query the bot ran, if known
- tool_args: dict[str, Any] | None = None # full tool input (query + filters…)
- first_seen: str = Field(default_factory=utcnow_iso)
diff --git a/forecasting_tools/agents_and_tools/source_archive/pipeline.py b/forecasting_tools/agents_and_tools/source_archive/pipeline.py
deleted file mode 100644
index e5564669..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/pipeline.py
+++ /dev/null
@@ -1,317 +0,0 @@
-"""Capture pipeline: turn a list of cited URLs into archived captures.
-
-For each unique URL:
-
- 1. :meth:`ContentStore.lookup` — within the TTL? cache hit, skip the fetch.
- 2. ``fetcher.fetch`` — tiered Playwright -> Firecrawl, quality-gated.
- 3. quality gate — junk (404 / block / thin) is not archived.
- 4. :meth:`ContentStore.store` — write blobs (deduped by content hash).
-"""
-
-from __future__ import annotations
-
-import logging
-import threading
-from collections.abc import Iterable
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.fetchers.base import (
- Fetcher,
- FetchError,
-)
-from forecasting_tools.agents_and_tools.source_archive.manifest import unique_urls
-from forecasting_tools.agents_and_tools.source_archive.models import (
- CitationRecord,
- StoredCapture,
-)
-from forecasting_tools.agents_and_tools.source_archive.quality import evaluate
-
-logger = logging.getLogger(__name__)
-
-# "cache_hit" | "stored" | "deduped" | "quality_failed" | "error"
-Status = str
-_STATUSES = ("cache_hit", "stored", "deduped", "quality_failed", "error")
-
-
-class CaptureOutcome(BaseModel):
- url: str
- status: Status
- stored: StoredCapture | None = None
- reason: str = ""
-
-
-class PipelineSummary(BaseModel):
- outcomes: list[CaptureOutcome] = []
-
- def count(self, status: Status) -> int:
- return sum(1 for o in self.outcomes if o.status == status)
-
- @property
- def captures(self) -> dict[str, StoredCapture]:
- return {o.url: o.stored for o in self.outcomes if o.stored is not None}
-
- def __str__(self) -> str:
- body = ", ".join(f"{s}={self.count(s)}" for s in _STATUSES)
- return f"PipelineSummary(total={len(self.outcomes)}, {body})"
-
-
-class CapturePipeline:
- def __init__(self, fetcher: Fetcher, content_store: ContentStore):
- self.fetcher = fetcher
- self.content_store = content_store
-
- def capture_url(self, url: str) -> CaptureOutcome:
- cached = self.content_store.lookup(url)
- if cached is not None:
- return CaptureOutcome(url=url, status="cache_hit", stored=cached)
-
- try:
- result = self.fetcher.fetch(url)
- except FetchError as e:
- logger.info("fetch error for %s: %s", url, e)
- return CaptureOutcome(url=url, status="error", reason=str(e))
- except Exception as e: # never let one bad URL abort the whole run
- logger.warning("unexpected error capturing %s: %s", url, e)
- return CaptureOutcome(url=url, status="error", reason=f"unexpected: {e}")
-
- # Gate here so any fetcher is covered; the tiered fetcher also gates
- # internally to decide fallback, but this is the authoritative check.
- verdict = evaluate(result)
- if not verdict.passed:
- return CaptureOutcome(
- url=url, status="quality_failed", reason=verdict.reason
- )
-
- store_result = self.content_store.store(result)
- status = "stored" if store_result.created else "deduped"
- return CaptureOutcome(url=url, status=status, stored=store_result.capture)
-
- def run(self, urls: Iterable[str]) -> PipelineSummary:
- summary = PipelineSummary()
- for url in urls:
- summary.outcomes.append(self.capture_url(url))
- return summary
-
- def run_manifest(self, records: Iterable[CitationRecord]) -> PipelineSummary:
- return self.run(unique_urls(records))
-
-
-# An outcome whose error reason contains one of these means the browser itself
-# died (crash, OOM, or the machine slept and severed the CDP pipe) — not a
-# problem with the URL. Without recovery, every later URL in that worker's shard
-# would error against the dead browser, so we rebuild the browser and retry.
-_DEAD_BROWSER_MARKERS = (
- "has been closed",
- "Target page, context or browser",
- "Browser.new_context",
- "Connection closed",
- "browser has been closed",
-)
-
-
-def _browser_died(reason: str | None) -> bool:
- return any(m in (reason or "") for m in _DEAD_BROWSER_MARKERS)
-
-
-def _close_quietly(cm, timeout_s: float = 15.0) -> None:
- """Tear down a fetcher context manager, but never block on it: a wedged
- browser's ``close()`` can itself hang, so run it in a daemon thread and give
- up after ``timeout_s`` (the leftover process is reaped at the end of the run).
- """
- done = threading.Event()
-
- def _close() -> None:
- try:
- cm.__exit__(None, None, None)
- except Exception:
- pass
- finally:
- done.set()
-
- threading.Thread(target=_close, daemon=True).start()
- done.wait(timeout_s)
-
-
-def _running_loop():
- import asyncio
-
- try:
- return asyncio.get_running_loop()
- except RuntimeError:
- return None
-
-
-def _restore_thread_loop_state(baseline) -> None:
- """Un-poison a worker thread's asyncio state after abandoning a dead
- sync-Playwright instance.
-
- Sync Playwright drives its asyncio loop *on the calling thread* (via a
- greenlet), so while an instance is alive the thread is marked as being
- inside a running event loop. A clean ``stop()`` clears that mark — but a
- SIGKILLed browser can't be closed cleanly: ``close()``/``stop()`` fail or
- hang, and both are thread-affine, so :func:`_close_quietly`'s helper thread
- can't reach them either. The abandoned loop then stays registered as
- running, and the next ``sync_playwright().start()`` on this thread refuses
- with "Playwright Sync API inside the asyncio loop", killing the rebuild.
-
- Resetting the thread-local marker to its pre-fetcher ``baseline`` lets the
- rebuilt fetcher start a fresh loop. The old loop object is leaked on
- purpose (its browser processes are swept by the reaper); that is the price
- of recovering without touching thread-affine Playwright internals.
- """
- import asyncio
-
- if _running_loop() is baseline:
- return
- try:
- asyncio.events._set_running_loop(baseline)
- except Exception:
- pass
-
-
-def _reap_browser_descendants() -> None:
- """Best-effort: kill automation Chromium descending from this process. Used
- both to recover a wedged worker (kill its browser so the blocked sync call
- errors out) and to sweep leftovers at end of run. No-op without psutil so it
- never becomes a hard dependency.
- """
- try:
- import os
-
- import psutil
- except Exception:
- return
- try:
- for child in psutil.Process(os.getpid()).children(recursive=True):
- try:
- if "chrom" in (child.name() or "").lower():
- child.kill()
- except Exception:
- pass
- except Exception:
- pass
-
-
-def capture_urls_concurrent(
- urls: Iterable[str],
- store: ContentStore,
- config,
- fetcher_factory,
- per_url_timeout: float | None = None,
- reaper=_reap_browser_descendants,
-) -> PipelineSummary:
- """Capture ``urls`` across ``config.concurrency`` worker threads.
-
- Headless Chromium's sync API is **thread-affine** — a browser must be used on
- the thread that created it — so each worker opens its **own** browser via
- ``fetcher_factory(config)`` and runs all captures inline on its own thread.
- The content store is shared (writes are keyed by URL hash and idempotent, so
- shards never collide). Order of outcomes is not preserved.
-
- Hang protection runs *out of band*: a supervisor thread watches each worker's
- heartbeat and, if one is stuck on a single URL past ``per_url_timeout`` (a
- wedged sync call whose Playwright timeout never fires — e.g. the machine
- slept and severed the CDP pipe), it **kills the browser processes**. That is
- an OS-level action (safe across threads, unlike touching Playwright objects),
- so the blocked call errors out and the worker rebuilds via the same
- dead-browser path — no single stuck worker can freeze the whole run.
- """
- import time
- from concurrent.futures import ThreadPoolExecutor
-
- url_list = list(urls)
- workers = max(1, int(getattr(config, "concurrency", 1) or 1))
- if per_url_timeout is None:
- nav_s = float(getattr(config, "nav_timeout_ms", 30000)) / 1000.0
- per_url_timeout = max(90.0, nav_s * 4)
-
- # worker index -> monotonic start of its current URL (None when between URLs)
- heartbeats: dict[int, float | None] = {}
- hb_lock = threading.Lock()
- stop = threading.Event()
-
- def supervisor() -> None:
- interval = max(0.5, min(per_url_timeout / 2, 30.0))
- while not stop.wait(interval):
- now = time.monotonic()
- with hb_lock:
- stalled = [
- w
- for w, t in heartbeats.items()
- if t is not None and now - t > per_url_timeout
- ]
- if stalled:
- logger.warning(
- "worker(s) %s stuck > %.0fs on one URL; killing browsers to recover",
- stalled,
- per_url_timeout,
- )
- reaper()
- with hb_lock: # grace: don't reap again before workers rebuild
- for w in list(heartbeats):
- if heartbeats[w] is not None:
- heartbeats[w] = now
-
- def work(idx: int, shard: list[str]) -> list[CaptureOutcome]:
- outcomes: list[CaptureOutcome] = []
- baseline_loop = _running_loop() # almost always None; see _restore_...
- cm = fetcher_factory(config)
- pipeline = CapturePipeline(cm.__enter__(), store)
- try:
- for url in shard:
- with hb_lock:
- heartbeats[idx] = time.monotonic()
- outcome = pipeline.capture_url(url)
- if outcome.status == "error" and _browser_died(outcome.reason):
- logger.warning(
- "browser died; rebuilding worker %d, retrying %s", idx, url
- )
- _close_quietly(cm)
- _restore_thread_loop_state(baseline_loop)
- try:
- cm = fetcher_factory(config)
- pipeline = CapturePipeline(cm.__enter__(), store)
- except Exception as e:
- # A failed rebuild must never kill the run: keep this
- # URL's error outcome and move on — the still-dead
- # pipeline makes the next URL error with a dead-browser
- # reason, which retries the rebuild.
- logger.warning(
- "worker %d rebuild failed (%s); keeping error outcome",
- idx,
- e,
- )
- else:
- with hb_lock:
- heartbeats[idx] = time.monotonic()
- # one retry on a fresh browser
- outcome = pipeline.capture_url(url)
- outcomes.append(outcome)
- with hb_lock:
- heartbeats[idx] = None
- finally:
- _close_quietly(cm)
- _restore_thread_loop_state(baseline_loop)
- return outcomes
-
- supervisor_thread = threading.Thread(target=supervisor, daemon=True)
- supervisor_thread.start()
- try:
- if workers == 1:
- heartbeats[0] = None
- return PipelineSummary(outcomes=work(0, url_list))
-
- shards = [url_list[i::workers] for i in range(workers)]
- for i in range(workers):
- heartbeats[i] = None
- summary = PipelineSummary()
- with ThreadPoolExecutor(max_workers=workers) as pool:
- futures = [pool.submit(work, i, shards[i]) for i in range(workers)]
- for future in futures:
- summary.outcomes.extend(future.result())
- return summary
- finally:
- stop.set()
- reaper()
diff --git a/forecasting_tools/agents_and_tools/source_archive/quality.py b/forecasting_tools/agents_and_tools/source_archive/quality.py
deleted file mode 100644
index 0bed3497..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/quality.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Quality gate for captures.
-
-A headless browser will happily "succeed" at screenshotting a 404 or a bot-block
-interstitial. Gate captures on HTTP status, content length, and block-page
-signatures before archiving, so junk is neither stored nor counted as a success
-(and so the tiered fetcher knows when to fall back to another backend).
-"""
-
-from __future__ import annotations
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive.models import CaptureResult
-
-# Substrings that strongly indicate a block / interstitial rather than the real
-# page. Matched case-insensitively against extracted text.
-BLOCK_SIGNATURES = (
- "verify you are a human",
- "are you a human",
- "checking your browser before",
- "enable javascript and cookies to continue",
- "please enable javascript",
- "access to this page has been denied",
- "access denied",
- "request unsuccessful. incapsula",
- "attention required! | cloudflare",
- "ddos protection by cloudflare",
- "ray id:",
- "captcha",
- "unusual traffic from your computer",
-)
-
-MIN_TEXT_LEN = 200
-
-
-class QualityVerdict(BaseModel):
- passed: bool
- reason: str = ""
-
-
-def evaluate(
- result: CaptureResult, *, min_text_len: int = MIN_TEXT_LEN
-) -> QualityVerdict:
- if result.status_code is not None and result.status_code >= 400:
- return QualityVerdict(passed=False, reason=f"http_status={result.status_code}")
-
- text = (result.markdown or result.html or "").strip()
- if len(text) < min_text_len:
- return QualityVerdict(passed=False, reason=f"thin_content len={len(text)}")
-
- lowered = text.lower()
- for sig in BLOCK_SIGNATURES:
- if sig in lowered:
- return QualityVerdict(passed=False, reason=f"block_signature={sig!r}")
-
- return QualityVerdict(passed=True)
diff --git a/forecasting_tools/agents_and_tools/source_archive/reindex.py b/forecasting_tools/agents_and_tools/source_archive/reindex.py
deleted file mode 100644
index 0a1e5a45..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/reindex.py
+++ /dev/null
@@ -1,278 +0,0 @@
-"""One-off reindex / dedup audit for an existing archive.
-
-This walks the canonical per-URL indexes already in a store and reports how much
-the smarter-dedup work (see ``ROADMAP.md`` Plan 1) would collapse, **without
-mutating anything by default**. It answers the practical question: *after exact
-canonicalization and content dedup, are there still many URLs that look like the
-same page?* — i.e. whether the fuzzy near-dup phase (D) is worth building.
-
-Three lenses:
-
- - **Canonicalization (Phase A):** group stored URLs by :func:`canonicalize_url`.
- Any group with >1 distinct raw URL is a set that *now* shares one key.
- - **Content (Phase C):** group distinct canonical URLs by their latest content
- hash. A group with >1 URL is byte-identical pages reachable at different URLs.
- - **Near-dup signal (Phase D candidate):** of the URLs surviving both dedups,
- group by ``scheme://host/path`` ignoring the query string. Big groups mean
- "same path, differing query" pages that exact dedup leaves separate — the
- cases fuzzy matching would target.
-
-Run it::
-
- # against the configured S3 bucket (read-only audit)
- WEB_ARCHIVE_S3_BUCKET=my-web-archive WEB_ARCHIVE_AWS_PROFILE=default \\
- python -m forecasting_tools.agents_and_tools.source_archive.reindex
-
- # against a local capture dir
- python -m forecasting_tools.agents_and_tools.source_archive.reindex --local ./archive
-
- # additionally (re)build the content reverse index for existing captures
- python -m forecasting_tools.agents_and_tools.source_archive.reindex --apply
-
-``--apply`` only writes the additive ``index/by-content/`` reverse index (safe,
-idempotent). It does **not** move blobs or re-key the per-URL indexes; that
-heavier migration is intentionally deferred (the archive is young).
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import sys
-from collections import defaultdict
-from urllib.parse import urlsplit
-
-from pydantic import BaseModel
-
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.content_store import ContentStore
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-
-class Cluster(BaseModel):
- key: str
- urls: list[str]
-
-
-class AnalysisReport(BaseModel):
- total_url_indexes: int = 0
- alias_indexes: int = 0 # already-collapsed redirects (Phase B)
- canonical_captures: int = 0 # distinct stored URLs with content
- distinct_after_canonicalization: int = 0
- distinct_after_content_dedup: int = 0
- canonicalization_clusters: list[Cluster] = [] # raw URLs that now share a key
- content_clusters: list[Cluster] = [] # different URLs, identical content
- near_dup_clusters: list[Cluster] = [] # same host+path, differing query
-
- def __str__(self) -> str:
- merged_a = sum(len(c.urls) - 1 for c in self.canonicalization_clusters)
- merged_c = sum(len(c.urls) - 1 for c in self.content_clusters)
- lines = [
- "Source-archive dedup audit",
- "=" * 40,
- f"URL indexes scanned : {self.total_url_indexes}",
- f" of which alias (redirect) : {self.alias_indexes}",
- f" of which canonical capture : {self.canonical_captures}",
- "",
- f"Distinct URLs (raw) : {self.canonical_captures}",
- f"After canonicalization (A) : {self.distinct_after_canonicalization}"
- f" (−{merged_a} merged)",
- f"After content dedup (C) : {self.distinct_after_content_dedup}"
- f" (−{merged_c} byte-identical)",
- "",
- f"Canonicalization clusters : {len(self.canonicalization_clusters)}",
- f"Identical-content clusters : {len(self.content_clusters)}",
- f"Near-dup candidates (D) : {len(self.near_dup_clusters)}"
- " (same host+path, differing query)",
- ]
-
- def _show(title: str, clusters: list[Cluster], limit: int = 5) -> None:
- if not clusters:
- return
- lines.append("")
- lines.append(f"--- top {title} ---")
- for c in sorted(clusters, key=lambda x: len(x.urls), reverse=True)[:limit]:
- lines.append(f" [{len(c.urls)}] {c.key}")
- for u in c.urls[:4]:
- lines.append(f" {u}")
- if len(c.urls) > 4:
- lines.append(f" … +{len(c.urls) - 4} more")
-
- _show("canonicalization clusters", self.canonicalization_clusters)
- _show("identical-content clusters", self.content_clusters)
- _show("near-dup candidates (Phase D signal)", self.near_dup_clusters)
- return "\n".join(lines)
-
-
-def _host_path(url: str) -> str:
- parts = urlsplit(canonicalize_url(url))
- return f"{parts.scheme}://{parts.netloc}{parts.path}"
-
-
-def iter_url_indexes(store: BlobStore, prefix: str):
- """Yield ``(key, index_dict)`` for each per-URL index, skipping the reverse
- content index under ``index/by-content/``."""
- index_prefix = f"{prefix.rstrip('/')}/index/"
- content_sub = f"{index_prefix}by-content/"
- for key in store.list_keys(index_prefix):
- if not key.endswith(".json") or key.startswith(content_sub):
- continue
- try:
- yield key, json.loads(store.get(key).decode("utf-8"))
- except (json.JSONDecodeError, UnicodeDecodeError):
- continue
-
-
-def analyze(store: BlobStore, config: ArchiveConfig) -> AnalysisReport:
- report = AnalysisReport()
- by_canonical: dict[str, list[str]] = defaultdict(list)
- by_content: dict[str, list[str]] = defaultdict(list)
-
- for _key, index in iter_url_indexes(store, config.s3_prefix):
- report.total_url_indexes += 1
- if index.get("alias_of"):
- report.alias_indexes += 1
- continue
- url = index.get("url")
- if not url or not index.get("captures"):
- continue
- report.canonical_captures += 1
- by_canonical[canonicalize_url(url)].append(url)
- ch = index.get("latest_content_hash")
- if ch:
- by_content[ch].append(url)
-
- report.distinct_after_canonicalization = len(by_canonical)
- report.canonicalization_clusters = [
- Cluster(key=k, urls=sorted(set(v)))
- for k, v in by_canonical.items()
- if len(set(v)) > 1
- ]
-
- # Content dedup operates on the canonicalized URL set.
- content_groups = {k: sorted(set(v)) for k, v in by_content.items()}
- report.content_clusters = [
- Cluster(key=k, urls=v) for k, v in content_groups.items() if len(v) > 1
- ]
- # distinct pages after content dedup = canonical URLs minus those merged away
- merged_by_content = sum(len(v) - 1 for v in content_groups.values() if len(v) > 1)
- report.distinct_after_content_dedup = max(
- 0, report.distinct_after_canonicalization - merged_by_content
- )
-
- # Phase D signal: among canonical URLs, same host+path but differing query.
- survivors = {canonicalize_url(u) for grp in by_canonical.values() for u in grp}
- by_host_path: dict[str, set[str]] = defaultdict(set)
- for u in survivors:
- by_host_path[_host_path(u)].add(u)
- report.near_dup_clusters = [
- Cluster(key=k, urls=sorted(v)) for k, v in by_host_path.items() if len(v) > 1
- ]
- return report
-
-
-def rebuild_content_index(
- store: BlobStore, config: ArchiveConfig, *, apply: bool
-) -> int:
- """(Re)build ``index/by-content/`` from existing captures. Returns the number
- of content groups (that would be) written. Additive and idempotent."""
- cstore = ContentStore(store, config)
- groups: dict[str, list[tuple[str, str]]] = defaultdict(list)
- for _key, index in iter_url_indexes(store, config.s3_prefix):
- if index.get("alias_of") or not index.get("captures"):
- continue
- uh = index.get("url_hash")
- url = index.get("url")
- ch = index.get("latest_content_hash")
- if uh and url and ch:
- groups[ch].append((uh, url))
-
- written = 0
- for ch, members in groups.items():
- written += 1
- if not apply:
- continue
- owner_uh, owner_url = members[0]
- # Re-register every member; the first becomes canonical owner.
- for uh, url in members:
- blob_keys = None
- if uh == owner_uh:
- cap = index_blob_keys(store, config, owner_uh, ch)
- blob_keys = cap
- cstore._register_content(ch, uh, url, blob_keys)
- return written
-
-
-def index_blob_keys(
- store: BlobStore, config: ArchiveConfig, uh: str, ch: str
-) -> dict | None:
- cstore = ContentStore(store, config)
- index = cstore._read_index(uh)
- if not index:
- return None
- cap = (index.get("captures") or {}).get(ch)
- if not cap:
- return None
- return {
- "html": cap.get("html_key"),
- "markdown": cap.get("markdown_key"),
- "screenshot": cap.get("screenshot_key"),
- }
-
-
-def _build_store(local_dir: str | None, bucket: str | None, config: ArchiveConfig):
- if local_dir:
- from forecasting_tools.agents_and_tools.source_archive.storage import (
- LocalBlobStore,
- )
-
- return LocalBlobStore(local_dir)
- bucket = bucket or config.s3_bucket
- if not bucket:
- sys.exit(
- "No S3 bucket configured. Set WEB_ARCHIVE_S3_BUCKET (or pass --bucket), "
- "or use --local DIR."
- )
- from forecasting_tools.agents_and_tools.source_archive.storage import S3BlobStore
-
- return S3BlobStore(bucket, config=config)
-
-
-def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(
- prog="source-archive-reindex",
- description="Audit (and optionally rebuild) dedup structures for an "
- "existing archive.",
- )
- parser.add_argument("--local", metavar="DIR", help="audit a local capture dir")
- parser.add_argument("--bucket", help="override WEB_ARCHIVE_S3_BUCKET")
- parser.add_argument(
- "--apply",
- action="store_true",
- help="rebuild index/by-content/ for existing captures (additive)",
- )
- parser.add_argument("--json", action="store_true", help="emit the report as JSON")
- args = parser.parse_args(argv)
-
- config = ArchiveConfig.from_env()
- store = _build_store(args.local, args.bucket, config)
-
- report = analyze(store, config)
- if args.json:
- print(report.model_dump_json(indent=2))
- else:
- print(report)
-
- if args.apply:
- n = rebuild_content_index(store, config, apply=True)
- print(f"\nRebuilt index/by-content/ for {n} content group(s).")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/forecasting_tools/agents_and_tools/source_archive/reports.py b/forecasting_tools/agents_and_tools/source_archive/reports.py
deleted file mode 100644
index 8ad95945..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/reports.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Persist each capture run's per-URL outcomes under ``reports/`` (nested per
-:mod:`layout`; reads also pick up legacy flat ``reports/.json`` keys).
-
-The coverage report's job is to surface sources we should be collecting. A cited
-source we have not archived falls into two very different buckets:
-
-- **never fetched** — it was harvested into a manifest but no capture run ever
- attempted it. This is the real "we should go collect this" signal.
-- **fetched but failed** — we tried and the fetch/quality gate rejected it
- (Cloudflare, PDF, 404…). A capture problem, not a collection problem.
-
-Without persisted run outcomes the two are indistinguishable. Writing each run's
-outcomes here lets coverage tell them apart.
-"""
-
-from __future__ import annotations
-
-import json
-
-from forecasting_tools.agents_and_tools.source_archive import layout
-from forecasting_tools.agents_and_tools.source_archive.canonicalize import (
- canonicalize_url,
-)
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-
-CAPTURED_STATUSES = {"stored", "deduped", "cache_hit"}
-FAILED_STATUSES = {"quality_failed", "error"}
-
-
-def report_key(run_id: str, config: ArchiveConfig, group: str | None = None) -> str:
- prefix = config.s3_prefix.rstrip("/")
- return f"{prefix}/{layout.report_key(run_id, '.json', group)}"
-
-
-def write_run_report(
- store: BlobStore,
- run_id: str,
- summary,
- config: ArchiveConfig,
- group: str | None = None,
-) -> str:
- """Persist a run's per-URL outcomes; ``summary`` is a ``PipelineSummary``.
-
- ``backend`` is the fetcher that produced the capture (from the stored
- capture, so it is set for stored/deduped/cache_hit outcomes and ``""``
- when unknown, e.g. errors) — it enables per-domain cost attribution.
- """
- rows = [
- {
- "url": o.url,
- "status": o.status,
- "reason": getattr(o, "reason", ""),
- "backend": o.stored.fetcher if o.stored is not None else "",
- }
- for o in summary.outcomes
- ]
- key = report_key(run_id, config, group)
- store.put(
- key, json.dumps(rows, indent=2).encode("utf-8"), content_type="application/json"
- )
- return key
-
-
-def read_outcomes(store: BlobStore, config: ArchiveConfig) -> dict[str, str]:
- """Map canonical URL -> last known capture status across all run reports.
-
- A captured status wins over a failed one (if we ever succeeded, that's the
- truth). Returns ``{}`` if no reports exist yet.
- """
- prefix = config.s3_prefix.rstrip("/")
- out: dict[str, str] = {}
- for key in store.list_keys(f"{prefix}/reports/"):
- # reports/ also holds per-run cost breakdowns (``_cost.json``,
- # a dict) — only run reports (a list of outcome rows) belong here.
- if not key.endswith(".json") or key.endswith("_cost.json"):
- continue
- try:
- rows = json.loads(store.get(key).decode("utf-8"))
- except (UnicodeDecodeError, ValueError):
- continue
- if not isinstance(rows, list):
- continue
- for r in rows:
- url = canonicalize_url(r.get("url", ""))
- status = r.get("status", "")
- if not url:
- continue
- if url not in out or status in CAPTURED_STATUSES:
- out[url] = status
- return out
diff --git a/forecasting_tools/agents_and_tools/source_archive/storage/__init__.py b/forecasting_tools/agents_and_tools/source_archive/storage/__init__.py
deleted file mode 100644
index a7c7755a..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/storage/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""Blob storage backends for the source archive."""
-
-from forecasting_tools.agents_and_tools.source_archive.storage.blob_store import (
- BlobStore,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage.local_store import (
- LocalBlobStore,
-)
-from forecasting_tools.agents_and_tools.source_archive.storage.s3_store import (
- S3BlobStore,
-)
-
-__all__ = ["BlobStore", "LocalBlobStore", "S3BlobStore"]
diff --git a/forecasting_tools/agents_and_tools/source_archive/storage/blob_store.py b/forecasting_tools/agents_and_tools/source_archive/storage/blob_store.py
deleted file mode 100644
index 7553c972..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/storage/blob_store.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Blob store interface.
-
-The content store and manifest layer depend on this abstraction, not on S3
-directly, so they can run offline against :class:`LocalBlobStore`.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Iterable
-from typing import Protocol, runtime_checkable
-
-
-@runtime_checkable
-class BlobStore(Protocol):
- def put(
- self, key: str, data: bytes, *, content_type: str | None = None
- ) -> None: ...
-
- def get(self, key: str) -> bytes: ...
-
- def exists(self, key: str) -> bool: ...
-
- def list_keys(self, prefix: str = "") -> Iterable[str]:
- """Yield every stored key beginning with ``prefix`` (for reindex/audit)."""
- ...
diff --git a/forecasting_tools/agents_and_tools/source_archive/storage/local_store.py b/forecasting_tools/agents_and_tools/source_archive/storage/local_store.py
deleted file mode 100644
index d85b0b0b..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/storage/local_store.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Filesystem-backed blob store for tests, local dev, and dry runs."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-
-class LocalBlobStore:
- def __init__(self, root: str | Path):
- self.root = Path(root)
-
- def _path(self, key: str) -> Path:
- return self.root / key
-
- def put(self, key: str, data: bytes, *, content_type: str | None = None) -> None:
- path = self._path(key)
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(data)
-
- def get(self, key: str) -> bytes:
- return self._path(key).read_bytes()
-
- def exists(self, key: str) -> bool:
- return self._path(key).exists()
-
- def list_keys(self, prefix: str = "") -> list[str]:
- if not self.root.exists():
- return []
- keys = [
- p.relative_to(self.root).as_posix()
- for p in self.root.rglob("*")
- if p.is_file()
- ]
- return sorted(k for k in keys if k.startswith(prefix))
diff --git a/forecasting_tools/agents_and_tools/source_archive/storage/s3_store.py b/forecasting_tools/agents_and_tools/source_archive/storage/s3_store.py
deleted file mode 100644
index 10914b94..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/storage/s3_store.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""S3-backed blob store (boto3).
-
-Bucket and credentials come from :class:`ArchiveConfig` / the environment and are
-never hardcoded, so this is safe to publish. boto3 is optional and imported
-lazily (``pip install forecasting-tools[source-archive]``).
-"""
-
-from __future__ import annotations
-
-from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
-
-
-class S3BlobStore:
- def __init__(
- self, bucket: str, *, config: ArchiveConfig | None = None, client=None
- ):
- if not bucket:
- raise ValueError(
- "S3BlobStore requires a bucket name (set WEB_ARCHIVE_S3_BUCKET)"
- )
- self.bucket = bucket
- self._config = config or ArchiveConfig()
- self._client = client
-
- def _get_client(self):
- if self._client is None:
- try:
- import boto3
- except ImportError as e:
- raise ImportError(
- "boto3 is not installed. Install it with "
- "`pip install forecasting-tools[source-archive]`."
- ) from e
-
- session = boto3.Session(
- profile_name=self._config.aws_profile,
- region_name=self._config.aws_region,
- )
- self._client = session.client("s3")
- return self._client
-
- def put(self, key: str, data: bytes, *, content_type: str | None = None) -> None:
- extra = {"ContentType": content_type} if content_type else {}
- self._get_client().put_object(Bucket=self.bucket, Key=key, Body=data, **extra)
-
- def get(self, key: str) -> bytes:
- resp = self._get_client().get_object(Bucket=self.bucket, Key=key)
- return resp["Body"].read()
-
- def exists(self, key: str) -> bool:
- from botocore.exceptions import ClientError
-
- try:
- self._get_client().head_object(Bucket=self.bucket, Key=key)
- return True
- except ClientError as e:
- code = e.response.get("Error", {}).get("Code")
- if code in ("404", "NoSuchKey", "NotFound"):
- return False
- raise
-
- def list_keys(self, prefix: str = ""):
- paginator = self._get_client().get_paginator("list_objects_v2")
- for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
- for obj in page.get("Contents", []):
- yield obj["Key"]
diff --git a/forecasting_tools/agents_and_tools/source_archive/viewer.py b/forecasting_tools/agents_and_tools/source_archive/viewer.py
deleted file mode 100644
index 022e632c..00000000
--- a/forecasting_tools/agents_and_tools/source_archive/viewer.py
+++ /dev/null
@@ -1,409 +0,0 @@
-"""Streamlit viewer for the source archive.
-
-Browse what the capture pipeline stored in S3: pick a captured URL and see its
-**screenshot, markdown, and HTML** side by side, with the question/bot it came
-from. Reads provenance from the run manifests and resolves each URL's latest
-capture through its per-URL index — no local file wrangling.
-
-Run it::
-
- # uses the same env as the rest of the archive (WEB_ARCHIVE_S3_BUCKET, etc.)
- AWS_PROFILE=default WEB_ARCHIVE_S3_BUCKET=my-web-archive \\
- streamlit run forecasting_tools/agents_and_tools/source_archive/viewer.py
-
-Nothing here is deployment-specific: bucket/prefix/profile come from
-``ArchiveConfig.from_env()``.
-"""
-
-from __future__ import annotations
-
-import json
-import sys
-from pathlib import Path
-
-# `streamlit run ` puts only the script's own directory on sys.path, not
-# the repo root — so make `import forecasting_tools` work whether the package is
-# pip-installed or just checked out. (viewer.py -> source_archive -> agents_and_tools
-# -> forecasting_tools -> .)
-_REPO_ROOT = Path(__file__).resolve().parents[3]
-if str(_REPO_ROOT) not in sys.path:
- sys.path.insert(0, str(_REPO_ROOT))
-
-import pandas as pd # noqa: E402
-import streamlit as st # noqa: E402
-
-from forecasting_tools.agents_and_tools.source_archive.config import ( # noqa: E402
- ArchiveConfig,
-)
-from forecasting_tools.agents_and_tools.source_archive.models import ( # noqa: E402
- url_hash,
-)
-
-# --- S3 access (cached) ----------------------------------------------------
-
-
-@st.cache_resource(show_spinner=False)
-def _client(profile: str | None, region: str | None):
- import boto3
-
- return boto3.Session(
- profile_name=profile or None, region_name=region or None
- ).client("s3")
-
-
-def _cfg() -> ArchiveConfig:
- return ArchiveConfig.from_env()
-
-
-@st.cache_data(show_spinner=False)
-def _list_keys(bucket: str, prefix: str) -> list[str]:
- cfg = _cfg()
- if cfg.local_dir: # filesystem-backed archive — list matching files
- root = Path(cfg.local_dir)
- if not root.exists():
- return []
- return [
- p.relative_to(root).as_posix()
- for p in root.rglob("*")
- if p.is_file() and p.relative_to(root).as_posix().startswith(prefix)
- ]
- s3 = _client(cfg.aws_profile, cfg.aws_region)
- keys: list[str] = []
- token = None
- while True:
- kw = {"Bucket": bucket, "Prefix": prefix}
- if token:
- kw["ContinuationToken"] = token
- resp = s3.list_objects_v2(**kw)
- keys.extend(o["Key"] for o in resp.get("Contents", []))
- if not resp.get("IsTruncated"):
- break
- token = resp.get("NextContinuationToken")
- return keys
-
-
-@st.cache_data(show_spinner=False)
-def _get_bytes(bucket: str, key: str) -> bytes | None:
- cfg = _cfg()
- if cfg.local_dir:
- p = Path(cfg.local_dir) / key
- return p.read_bytes() if p.exists() else None
- s3 = _client(cfg.aws_profile, cfg.aws_region)
- try:
- return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
- except Exception:
- return None
-
-
-# Metaculus question id -> review URL. Derived at display time (not stored) so
-# there's no redundant, drift-prone URL column in S3.
-_METACULUS_QUESTION_BASE = "https://www.metaculus.com/questions/"
-
-
-def _metaculus_url(metaculus_id) -> str:
- if metaculus_id in (None, "", "null"):
- return ""
- return f"{_METACULUS_QUESTION_BASE}{metaculus_id}/"
-
-
-def _comment_url(metaculus_id, comment_id) -> str:
- """Deep-link to the specific comment the URL was cited in."""
- base = _metaculus_url(metaculus_id)
- if not base or comment_id in (None, "", "null"):
- return ""
- return f"{base}#comment-{comment_id}"
-
-
-@st.cache_data(show_spinner="Loading manifests…")
-def _manifest_rows(bucket: str, prefix: str) -> pd.DataFrame:
- """Every (question, bot, url) the bots cited, from the run manifests."""
- rows = []
- for key in _list_keys(bucket, f"{prefix}/manifests/"):
- body = _get_bytes(bucket, key)
- if not body:
- continue
- for line in body.decode("utf-8").splitlines():
- line = line.strip()
- if not line:
- continue
- r = json.loads(line)
- rows.append(
- {
- "question": r.get("question_id") or "(none)",
- "bot": r.get("bot") or "(none)",
- "run_id": r.get("run_id") or "",
- "origin": r.get("origin") or "",
- "query": r.get("query") or "",
- "metaculus": _metaculus_url(r.get("metaculus_id")),
- "comment": _comment_url(r.get("metaculus_id"), r.get("comment_id")),
- "url": r.get("url", ""),
- "question_url": r.get("question_url") or "",
- "tool_args": r.get("tool_args"),
- }
- )
- df = pd.DataFrame(rows)
- if not df.empty:
- # Keep distinct provenance (a URL cited via two origins/runs = two rows).
- df = df.drop_duplicates(
- subset=["question", "bot", "run_id", "origin", "url"]
- ).reset_index(drop=True)
- return df
-
-
-def _scrape_report(bucket: str, prefix: str, view: pd.DataFrame):
- """Per-question scraping cost: which backend captured each URL.
-
- Self-hosted Playwright is free; Firecrawl (the fallback) costs ~1 credit per
- page and is what actually accrues spend once a key is configured. We classify
- each *stored* capture by ``fetcher`` and count Firecrawl pages per question.
-
- Caveat: only successful captures are recorded in the index, so a Firecrawl
- attempt that failed its quality gate isn't counted here — billed attempts
- aren't yet persisted (see the note in the UI).
- """
- per_q: dict[str, dict] = {}
- for _, row in view.iterrows():
- cap = _index(bucket, prefix, row["url"])
- q = row["question"]
- agg = per_q.setdefault(
- q,
- {
- "question": q,
- "urls": 0,
- "captured": 0,
- "playwright": 0,
- "firecrawl": 0,
- "other": 0,
- },
- )
- agg["urls"] += 1
- if not cap:
- continue
- agg["captured"] += 1
- fetcher = (cap.get("fetcher") or "").lower()
- if fetcher in ("playwright", "firecrawl"):
- agg[fetcher] += 1
- else:
- agg["other"] += 1
- return per_q
-
-
-@st.cache_data(show_spinner=False)
-def _index(bucket: str, prefix: str, url: str) -> dict | None:
- """Latest stored capture for a URL (keys + metadata), or None if uncaptured."""
- body = _get_bytes(bucket, f"{prefix}/index/{url_hash(url)}.json")
- if not body:
- return None
- idx = json.loads(body.decode("utf-8"))
- ch = idx.get("latest_content_hash")
- cap = (idx.get("captures") or {}).get(ch)
- return cap
-
-
-# --- UI --------------------------------------------------------------------
-
-
-def main() -> None:
- st.set_page_config(page_title="Source Archive Viewer", layout="wide")
- cfg = _cfg()
- st.title("📚 Source Archive Viewer")
-
- location = cfg.local_dir or cfg.s3_bucket
- if not location:
- st.error(
- "No archive configured. Set WEB_ARCHIVE_LOCAL_DIR (a local capture "
- "directory) or WEB_ARCHIVE_S3_BUCKET (S3), then reload."
- )
- st.stop()
- if cfg.local_dir:
- st.caption(f"📂 local: {cfg.local_dir}/{cfg.s3_prefix}")
- else:
- st.caption(
- f"s3://{cfg.s3_bucket}/{cfg.s3_prefix} · "
- f"profile={cfg.aws_profile or 'default'}"
- )
-
- with st.sidebar:
- st.header("Filters")
- if st.button("🔄 Refresh"):
- st.cache_data.clear()
- st.rerun()
-
- df = _manifest_rows(location, cfg.s3_prefix)
- if df.empty:
- st.warning("No manifests found under this prefix yet. Run a capture first.")
- st.stop()
-
- with st.sidebar:
- bots = sorted(df["bot"].unique())
- qs = sorted(df["question"].unique())
- sel_bots = st.multiselect("Bot", bots, default=bots)
- sel_qs = st.multiselect("Question", qs, default=qs)
- search = st.text_input("URL contains")
-
- view = df[df["bot"].isin(sel_bots) & df["question"].isin(sel_qs)]
- if search:
- view = view[view["url"].str.contains(search, case=False, na=False)]
- view = view.reset_index(drop=True)
-
- st.subheader(f"{len(view)} cited URL(s)")
-
- # Resolve capture status for the filtered rows (cached per-URL).
- if len(view) > 300:
- st.info(
- "Showing 300 of %d — narrow with the filters for capture details."
- % len(view)
- )
- table = []
- for _, row in view.head(300).iterrows():
- cap = _index(location, cfg.s3_prefix, row["url"])
- table.append(
- {
- "question": row["question"],
- "bot": row["bot"],
- "run_id": row["run_id"],
- "origin": row["origin"],
- "captured": "✅" if cap else "—",
- "fetcher": (cap or {}).get("fetcher", ""),
- "captured_at": (cap or {}).get("captured_at", "")[:19],
- "metaculus": row["metaculus"],
- "comment": row["comment"],
- "url": row["url"],
- }
- )
- st.dataframe(
- pd.DataFrame(table),
- use_container_width=True,
- hide_index=True,
- column_config={
- # Show the full link address as the clickable text (not a label).
- "url": st.column_config.LinkColumn("url"),
- "metaculus": st.column_config.LinkColumn(
- "metaculus", display_text="question ↗"
- ),
- "comment": st.column_config.LinkColumn("comment", display_text="comment ↗"),
- },
- )
-
- if st.sidebar.checkbox("💸 Show scraping cost"):
- st.subheader("💸 Scraping cost (filtered set)")
- rate = st.number_input(
- "Firecrawl cost per page ($)",
- min_value=0.0,
- value=0.001,
- step=0.0005,
- format="%.4f",
- help="Self-hosted Playwright is free; this prices the Firecrawl "
- "fallback. Adjust to your plan's credit rate.",
- )
- per_q = _scrape_report(location, cfg.s3_prefix, view.head(300))
- rows, t_fc, t_pw, t_cap, t_url = [], 0, 0, 0, 0
- for agg in sorted(per_q.values(), key=lambda a: a["question"]):
- rows.append(
- {
- "question": agg["question"],
- "urls": agg["urls"],
- "captured": agg["captured"],
- "playwright (free)": agg["playwright"],
- "firecrawl (paid)": agg["firecrawl"],
- "firecrawl $": round(agg["firecrawl"] * rate, 4),
- }
- )
- t_fc += agg["firecrawl"]
- t_pw += agg["playwright"]
- t_cap += agg["captured"]
- t_url += agg["urls"]
- st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
- a, b, c = st.columns(3)
- a.metric("Captured", f"{t_cap}/{t_url}")
- b.metric("Firecrawl pages", t_fc, help="Playwright pages are free")
- c.metric("Est. Firecrawl cost", f"${t_fc * rate:.4f}")
- st.caption(
- f"Playwright (free): {t_pw} · Firecrawl (paid): {t_fc}. "
- "⚠️ Only **successful** captures carry a fetcher in the index, so "
- "Firecrawl attempts that failed the quality gate aren't counted — "
- "billed-attempt tracking needs the pipeline to persist fetch attempts."
- )
-
- st.divider()
- st.subheader("Inspect a capture")
- labels = [f"[{r['question']}] {r['url']}" for _, r in view.iterrows()]
- if not labels:
- st.stop()
- choice = st.selectbox("URL", range(len(labels)), format_func=lambda i: labels[i])
- row = view.iloc[choice]
- url = row["url"]
- cap = _index(location, cfg.s3_prefix, url)
-
- c1, c2 = st.columns([3, 2])
- with c1:
- st.markdown(f"**URL:** [{url}]({url})")
- st.markdown(
- f"**Question:** `{row['question']}` · **Bot:** `{row['bot']}` · "
- f"**Origin:** `{row['origin'] or '—'}`"
- )
- st.markdown(f"**Run:** `{row['run_id'] or '—'}`")
- review = row["metaculus"] or row["question_url"]
- if review:
- st.markdown(f"**Metaculus question:** [{review}]({review})")
- if row["comment"]:
- st.markdown(f"**Cited in comment:** [{row['comment']}]({row['comment']})")
- if row["query"]:
- st.markdown(f"**Search query:** `{row['query']}`")
- if row.get("tool_args"):
- st.markdown(f"**Tool args:** `{row['tool_args']}`")
- with c2:
- if cap:
- st.markdown(
- f"**Captured:** {cap.get('captured_at','')[:19]} · "
- f"**Fetcher:** {cap.get('fetcher','')} · "
- f"**HTTP:** {cap.get('status_code','?')}"
- )
-
- if not cap:
- st.warning(
- "No stored capture for this URL — it failed the quality gate / errored, "
- "or hasn't been captured yet."
- )
- st.stop()
-
- tab_shot, tab_md, tab_html = st.tabs(["🖼 Screenshot", "📝 Markdown", "🌐 HTML"])
-
- with tab_shot:
- key = cap.get("screenshot_key")
- data = _get_bytes(location, key) if key else None
- if data:
- st.download_button("Download .webp", data, file_name="screenshot.webp")
- st.image(data, use_container_width=True)
- else:
- st.info("No screenshot stored.")
-
- with tab_md:
- key = cap.get("markdown_key")
- data = _get_bytes(location, key) if key else None
- if data:
- text = data.decode("utf-8", "replace")
- st.download_button("Download .md", data, file_name="page.md")
- st.caption(f"{len(text):,} chars")
- st.markdown(text)
- else:
- st.info("No markdown stored.")
-
- with tab_html:
- key = cap.get("html_key")
- data = _get_bytes(location, key) if key else None
- if data:
- html = data.decode("utf-8", "replace")
- st.download_button("Download .html", data, file_name="page.html")
- st.caption(
- f"{len(html):,} chars · rendered below (CSS/images load from the "
- "original site and may not all resolve — the screenshot is the "
- "faithful visual record)."
- )
- st.components.v1.html(html, height=800, scrolling=True)
- else:
- st.info("No HTML stored.")
-
-
-if __name__ == "__main__":
- main()
diff --git a/poetry.lock b/poetry.lock
index f741fa95..82b06da8 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -444,12 +444,11 @@ version = "2.18.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"},
{file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"},
]
-markers = {main = "extra == \"source-archive\""}
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
@@ -508,48 +507,6 @@ files = [
{file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"},
]
-[[package]]
-name = "boto3"
-version = "1.43.19"
-description = "The AWS SDK for Python"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "boto3-1.43.19-py3-none-any.whl", hash = "sha256:ec6825193b75fbb6bfbf12181e4960d00ad2f404343586765394ce620e63783c"},
- {file = "boto3-1.43.19.tar.gz", hash = "sha256:8b84704719dd3960ac12a8f37d9ff5adb853715baa9742f84fdbe2de0305c4cb"},
-]
-
-[package.dependencies]
-botocore = ">=1.43.19,<1.44.0"
-jmespath = ">=0.7.1,<2.0.0"
-s3transfer = ">=0.18.0,<0.19.0"
-
-[package.extras]
-crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
-
-[[package]]
-name = "botocore"
-version = "1.43.19"
-description = "Low-level, data-driven core of boto 3."
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "botocore-1.43.19-py3-none-any.whl", hash = "sha256:99dbdccbf748974750601e805cecc9362a85d11fee89d6d58cd3f4ff302e6ff9"},
- {file = "botocore-1.43.19.tar.gz", hash = "sha256:18ac2fdd76c89b940707eb10493ff58678adad337d03215caec2d408ccd43cc0"},
-]
-
-[package.dependencies]
-jmespath = ">=0.7.1,<2.0.0"
-python-dateutil = ">=2.1,<3.0.0"
-urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3"
-
-[package.extras]
-crt = ["awscrt (==0.32.2)"]
-
[[package]]
name = "cachetools"
version = "7.1.3"
@@ -867,29 +824,6 @@ files = [
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
-[[package]]
-name = "cloakbrowser"
-version = "0.3.32"
-description = "Stealth Chromium that passes every bot detection test. Drop-in Playwright replacement with source-level fingerprint patches."
-optional = true
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "cloakbrowser-0.3.32-py3-none-any.whl", hash = "sha256:5a993ee019bfcd00d545d7d6d51837646bcb1e8226545acdf0b543b38a8883df"},
- {file = "cloakbrowser-0.3.32.tar.gz", hash = "sha256:7361e2f5e366f651b5d54aad3ac13e145462110e0956b538ae3686916c36535a"},
-]
-
-[package.dependencies]
-httpx = ">=0.24"
-playwright = ">=1.40"
-
-[package.extras]
-dev = ["pytest (>=7.0)", "pytest-asyncio (>=0.23)"]
-geoip = ["geoip2 (>=4.0)", "socksio (>=1.0)"]
-patchright = ["patchright (>=1.40)"]
-serve = ["aiohttp (>=3.9)", "websockets (>=12.0)"]
-
[[package]]
name = "colorama"
version = "0.4.6"
@@ -1010,27 +944,6 @@ mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "
test = ["Pillow", "contourpy[test-no-images]", "matplotlib"]
test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"]
-[[package]]
-name = "courlan"
-version = "1.4.0"
-description = "Clean, filter and sample URLs to optimize data collection – includes spam, content type and language filters."
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "courlan-1.4.0-py3-none-any.whl", hash = "sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e"},
- {file = "courlan-1.4.0.tar.gz", hash = "sha256:fbbac7b7fcde2195ea08e707609503c81cf39c891e8d26cdb1fed4585782d63d"},
-]
-
-[package.dependencies]
-babel = ">=2.16.0"
-tld = ">=0.13"
-urllib3 = ">=1.26,<3"
-
-[package.extras]
-dev = ["mypy (==2.1.0)", "pytest (==9.0.3)", "pytest-cov (==7.1.0)", "pytest-httpserver (==1.1.5)", "ruff (==0.15.15)"]
-
[[package]]
name = "crontab"
version = "1.0.5"
@@ -1150,30 +1063,6 @@ typepy = {version = ">=1.3.2,<3", extras = ["datetime"]}
logging = ["loguru (>=0.4.1,<1)"]
test = ["pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)", "tcolorpy (>=0.1.2)"]
-[[package]]
-name = "dateparser"
-version = "1.4.0"
-description = "Date parsing library designed to parse dates from HTML pages"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378"},
- {file = "dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4"},
-]
-
-[package.dependencies]
-python-dateutil = ">=2.7.0"
-pytz = ">=2024.2"
-regex = ">=2024.9.11"
-tzlocal = ">=0.2"
-
-[package.extras]
-calendars = ["convertdate (>=2.2.1)", "hijridate"]
-fasttext = ["fasttext (>=0.9.1)", "numpy (>=1.22.0,<2)"]
-langdetect = ["langdetect (>=1.0.0)"]
-
[[package]]
name = "debugpy"
version = "1.8.20"
@@ -1492,28 +1381,6 @@ files = [
{file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"},
]
-[[package]]
-name = "firecrawl-py"
-version = "4.28.2"
-description = "Python SDK for Firecrawl API"
-optional = true
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "firecrawl_py-4.28.2-py3-none-any.whl", hash = "sha256:0689080cb01672370e5a97963e0df479f6102137aa088857eac0fa287a4269b6"},
- {file = "firecrawl_py-4.28.2.tar.gz", hash = "sha256:7e6181e2129b63c8d6aec5728d9b2fcf16ea82cb854372ad824b278efd258696"},
-]
-
-[package.dependencies]
-aiohttp = "*"
-httpx = "*"
-nest-asyncio = "*"
-pydantic = ">=2.0"
-python-dotenv = "*"
-requests = "*"
-websockets = "*"
-
[[package]]
name = "fonttools"
version = "4.63.0"
@@ -1813,100 +1680,6 @@ gitdb = ">=4.0.1,<5"
doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
-[[package]]
-name = "greenlet"
-version = "3.5.1"
-description = "Lightweight in-process concurrent programming"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f"},
- {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f"},
- {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c"},
- {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19"},
- {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5"},
- {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061"},
- {file = "greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97"},
- {file = "greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d"},
- {file = "greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1"},
- {file = "greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f"},
- {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2"},
- {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33"},
- {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360"},
- {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563"},
- {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747"},
- {file = "greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071"},
- {file = "greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c"},
- {file = "greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e"},
- {file = "greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523"},
- {file = "greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2"},
- {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed"},
- {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10"},
- {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249"},
- {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b"},
- {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee"},
- {file = "greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207"},
- {file = "greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823"},
- {file = "greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b"},
- {file = "greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188"},
- {file = "greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b"},
- {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a"},
- {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283"},
- {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce"},
- {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135"},
- {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436"},
- {file = "greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd"},
- {file = "greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1"},
- {file = "greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9"},
- {file = "greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e"},
- {file = "greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07"},
- {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea"},
- {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2"},
- {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c"},
- {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c"},
- {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d"},
- {file = "greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0"},
- {file = "greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc"},
- {file = "greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3"},
- {file = "greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54"},
- {file = "greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad"},
- {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e"},
- {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986"},
- {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f"},
- {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e"},
- {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de"},
- {file = "greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d"},
- {file = "greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78"},
- {file = "greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2"},
- {file = "greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541"},
- {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de"},
- {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64"},
- {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0"},
- {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5"},
- {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc"},
- {file = "greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368"},
- {file = "greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26"},
- {file = "greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab"},
- {file = "greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6"},
- {file = "greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed"},
- {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244"},
- {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c"},
- {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c"},
- {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd"},
- {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62"},
- {file = "greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e"},
- {file = "greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659"},
- {file = "greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e"},
- {file = "greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a"},
- {file = "greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829"},
-]
-
-[package.extras]
-docs = ["Sphinx", "furo"]
-test = ["objgraph", "psutil", "setuptools"]
-
[[package]]
name = "griffelib"
version = "2.0.2"
@@ -1973,31 +1746,6 @@ files = [
[package.extras]
tests = ["pytest"]
-[[package]]
-name = "htmldate"
-version = "1.10.0"
-description = "Fast and robust extraction of original and updated publication dates from URLs and web pages."
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "htmldate-1.10.0-py3-none-any.whl", hash = "sha256:9211dae35ab94147c8ed9e5fc2c9287a5cf31d2394cb7857e7f5dd814eb2aad6"},
- {file = "htmldate-1.10.0.tar.gz", hash = "sha256:a38df10772ab5d7dbb11896e3f6a852a8491fb1b0965465bc174e23fc2baae58"},
-]
-
-[package.dependencies]
-charset_normalizer = ">=3.4.0"
-dateparser = ">=1.1.2"
-lxml = ">=5.3.0"
-python-dateutil = ">=2.9.0.post0"
-urllib3 = ">=1.26,<3"
-
-[package.extras]
-all = ["htmldate[dev]", "htmldate[speed]"]
-dev = ["mypy", "pytest", "pytest-cov", "ruff", "types-dateparser", "types-lxml", "types-python-dateutil", "types-urllib3"]
-speed = ["backports-datetime-fromisoformat ; python_version < \"3.11\"", "faust-cchardet (>=2.1.19)", "urllib3[brotli]"]
-
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -2522,19 +2270,6 @@ files = [
{file = "jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76"},
]
-[[package]]
-name = "jmespath"
-version = "1.1.0"
-description = "JSON Matching Expressions"
-optional = true
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"},
- {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"},
-]
-
[[package]]
name = "joblib"
version = "1.5.3"
@@ -2876,22 +2611,6 @@ docs = ["autodoc-traits", "jinja2 (<3.2.0)", "mistune (<4)", "myst-parser", "pyd
openapi = ["openapi-core (>=0.18.0,<0.19.0)", "ruamel-yaml"]
test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-validator (>=0.6.0,<0.8.0)", "pytest (>=7.0,<8)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter[server] (>=0.6.2)", "pytest-timeout", "requests-mock", "ruamel-yaml", "sphinxcontrib-spelling", "strict-rfc3339", "werkzeug"]
-[[package]]
-name = "justext"
-version = "3.0.2"
-description = "Heuristic based boilerplate removal tool"
-optional = true
-python-versions = "*"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7"},
- {file = "justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05"},
-]
-
-[package.dependencies]
-lxml = {version = ">=4.4.2", extras = ["html-clean"]}
-
[[package]]
name = "kiwisolver"
version = "1.5.0"
@@ -3114,176 +2833,6 @@ semantic-router = ["aurelio-sdk (==0.0.19) ; python_full_version < \"3.14.0\"",
stt-nvidia-riva = ["audioread (>=3.0.1)", "numpy (>=1.26.0)", "nvidia-riva-client (>=2.15.0)", "soundfile (>=0.12.1)"]
utils = ["numpydoc (==1.8.0)"]
-[[package]]
-name = "lxml"
-version = "6.1.1"
-description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
-optional = true
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
- {file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
- {file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
- {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
- {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
- {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
- {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
- {file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
- {file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
- {file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
- {file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
- {file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
- {file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
- {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
- {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
- {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
- {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
- {file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
- {file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
- {file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
- {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
- {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
- {file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
- {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
- {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
- {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
- {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
- {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
- {file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
- {file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
- {file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
- {file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
- {file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
- {file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
- {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
- {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
- {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
- {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
- {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
- {file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
- {file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
- {file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
- {file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
- {file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
- {file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
- {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
- {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
- {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
- {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
- {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
- {file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
- {file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
- {file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
- {file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
- {file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
- {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
- {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
- {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
- {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
- {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
- {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
- {file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
- {file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
- {file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
- {file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
- {file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
- {file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
- {file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
- {file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
- {file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
- {file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
- {file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
- {file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
- {file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
- {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
- {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
- {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
- {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
- {file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
- {file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
- {file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
- {file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
- {file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
-]
-
-[package.dependencies]
-lxml_html_clean = {version = "*", optional = true, markers = "extra == \"html-clean\""}
-
-[package.extras]
-cssselect = ["cssselect (>=0.7)"]
-html-clean = ["lxml_html_clean"]
-html5 = ["html5lib"]
-htmlsoup = ["BeautifulSoup4"]
-
-[[package]]
-name = "lxml-html-clean"
-version = "0.4.5"
-description = "HTML cleaner from lxml project"
-optional = true
-python-versions = "*"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746"},
- {file = "lxml_html_clean-0.4.5.tar.gz", hash = "sha256:e2a4c7d5beedd17cd7b484d848a0571e54baa239a4f9df5546e3acba7f990560"},
-]
-
-[package.dependencies]
-lxml = ">=6.1.1"
-
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -4561,29 +4110,6 @@ files = [
{file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"},
]
-[[package]]
-name = "playwright"
-version = "1.60.0"
-description = "A high-level API to automate web browsers"
-optional = true
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7"},
- {file = "playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5"},
- {file = "playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705"},
- {file = "playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e"},
- {file = "playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353"},
- {file = "playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7"},
- {file = "playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02"},
- {file = "playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537"},
-]
-
-[package.dependencies]
-greenlet = ">=3.1.1,<4.0.0"
-pyee = ">=13,<14"
-
[[package]]
name = "plotly"
version = "6.7.0"
@@ -5339,25 +4865,6 @@ numpy = ">=1.16.4"
carto = ["pydeck-carto"]
jupyter = ["ipykernel (>=5.1.2)", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"]
-[[package]]
-name = "pyee"
-version = "13.0.1"
-description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
-optional = true
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
- {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
-]
-
-[package.dependencies]
-typing-extensions = "*"
-
-[package.extras]
-dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
-
[[package]]
name = "pygments"
version = "2.20.0"
@@ -5394,46 +4901,6 @@ dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pyt
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
-[[package]]
-name = "pymupdf"
-version = "1.27.2.3"
-description = "A high performance Python library for data extraction, analysis, conversion & manipulation of PDF (and other) documents."
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e"},
- {file = "pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e"},
- {file = "pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2"},
- {file = "pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2"},
-]
-
-[[package]]
-name = "pymupdf4llm"
-version = "0.3.4"
-description = "PyMuPDF Utilities for LLM/RAG"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "pymupdf4llm-0.3.4-py3-none-any.whl", hash = "sha256:0517492f82af978541162ade20fc54649cdca52acd478e33b97cb6171d69956f"},
- {file = "pymupdf4llm-0.3.4.tar.gz", hash = "sha256:48d396a5fb3c14351493c7f1dd25b2a843efdbdc4526e489ee100643a2cebec1"},
-]
-
-[package.dependencies]
-pymupdf = ">=1.27.1"
-tabulate = "*"
-
-[package.extras]
-layout = ["pymupdf-layout (>=1.27.1)"]
-
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -5730,12 +5197,11 @@ version = "2026.2"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"},
{file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"},
]
-markers = {main = "extra == \"source-archive\""}
[[package]]
name = "pywin32"
@@ -6336,25 +5802,6 @@ files = [
{file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"},
]
-[[package]]
-name = "s3transfer"
-version = "0.18.0"
-description = "An Amazon S3 Transfer Manager"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6"},
- {file = "s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd"},
-]
-
-[package.dependencies]
-botocore = ">=1.37.4,<2.0a0"
-
-[package.extras]
-crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
-
[[package]]
name = "scikit-learn"
version = "1.8.0"
@@ -6722,22 +6169,6 @@ typepy = ">=1.2.0,<3"
logging = ["loguru (>=0.4.1,<1)"]
test = ["pytablewriter (>=0.46)", "pytest"]
-[[package]]
-name = "tabulate"
-version = "0.10.0"
-description = "Pretty-print tabular data"
-optional = true
-python-versions = ">=3.10"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"},
- {file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"},
-]
-
-[package.extras]
-widechars = ["wcwidth"]
-
[[package]]
name = "tcolorpy"
version = "0.1.7"
@@ -7091,27 +6522,6 @@ webencodings = ">=0.4"
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["pytest", "ruff"]
-[[package]]
-name = "tld"
-version = "0.13.2"
-description = "Extract the top-level domain (TLD) from the URL given."
-optional = true
-python-versions = ">=3.7"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c"},
- {file = "tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345"},
-]
-
-[package.extras]
-all = ["tld[build,dev,docs,lint,test]"]
-build = ["build", "pkginfo", "twine", "wheel"]
-dev = ["detect-secrets", "ipython", "uv"]
-docs = ["sphinx", "sphinx-autobuild", "sphinx-llms-txt-link", "sphinx-no-pragma", "sphinx-rtd-theme (>=1.3.0)", "sphinx-source-tree ; python_version > \"3.9\""]
-lint = ["doc8", "mypy", "pydoclint", "ruff"]
-test = ["coverage", "fake.py", "pytest", "pytest-codeblock", "pytest-cov", "pytest-ordering", "tox"]
-
[[package]]
name = "tokenizers"
version = "0.22.2"
@@ -7285,32 +6695,6 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
-[[package]]
-name = "trafilatura"
-version = "2.0.0"
-description = "Python & Command-line tool to gather text and metadata on the Web: Crawling, scraping, extraction, output as CSV, JSON, HTML, MD, TXT, XML."
-optional = true
-python-versions = ">=3.8"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "trafilatura-2.0.0-py3-none-any.whl", hash = "sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d"},
- {file = "trafilatura-2.0.0.tar.gz", hash = "sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247"},
-]
-
-[package.dependencies]
-certifi = "*"
-charset_normalizer = ">=3.4.0"
-courlan = ">=1.3.2"
-htmldate = ">=1.9.2"
-justext = ">=3.0.1"
-lxml = {version = ">=5.3.0", markers = "platform_system != \"Darwin\" or python_version > \"3.8\""}
-urllib3 = ">=1.26,<3"
-
-[package.extras]
-all = ["brotli", "cchardet (>=2.1.7) ; python_version < \"3.11\"", "faust-cchardet (>=2.1.19) ; python_version >= \"3.11\"", "htmldate[speed] (>=1.9.2)", "py3langid (>=0.3.0)", "pycurl (>=7.45.3)", "urllib3[socks]", "zstandard (>=0.23.0)"]
-dev = ["flake8", "mypy", "pytest", "pytest-cov", "types-lxml", "types-urllib3"]
-
[[package]]
name = "traitlets"
version = "5.15.0"
@@ -7456,25 +6840,6 @@ files = [
{file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
]
-[[package]]
-name = "tzlocal"
-version = "5.3.1"
-description = "tzinfo object for the local timezone"
-optional = true
-python-versions = ">=3.9"
-groups = ["main"]
-markers = "extra == \"source-archive\""
-files = [
- {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"},
- {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"},
-]
-
-[package.dependencies]
-tzdata = {version = "*", markers = "platform_system == \"Windows\""}
-
-[package.extras]
-devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
-
[[package]]
name = "unidecode"
version = "1.4.0"
@@ -7896,10 +7261,7 @@ enabler = ["pytest-enabler (>=3.4)"]
test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
-[extras]
-source-archive = ["boto3", "cloakbrowser", "firecrawl-py", "playwright", "pymupdf4llm", "trafilatura"]
-
[metadata]
lock-version = "2.1"
python-versions = "^3.11"
-content-hash = "d9abd6c9194bdd4769704c8c60f48f438f9d77370b35ee739555d3b9fd3e5e22"
+content-hash = "4cf8a2f0d78535d469e1c0c647146d2f890f94f66c6f37fe7128376b958f6d46"
diff --git a/pyproject.toml b/pyproject.toml
index c8b322f6..705eda4e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,26 +51,6 @@ hyperbrowser = ">=0.53.0,<1.0.0"
pendulum = "^3.1.0"
openai-agents = {extras = ["litellm"], version = ">=0.2.0,<0.20.0"}
-# Optional backends for the source archive (agents_and_tools/source_archive).
-# Install with: pip install forecasting-tools[source-archive]
-boto3 = {version = ">=1.34,<2.0.0", optional = true}
-playwright = {version = ">=1.44,<2.0.0", optional = true}
-firecrawl-py = {version = ">=4.0,<5.0.0", optional = true}
-trafilatura = {version = ">=1.9,<3.0.0", optional = true}
-pymupdf4llm = {version = ">=0.0.17,<1.0.0", optional = true}
-# Self-hosted anti-bot backend (CloakBrowser). Pinned tight to 0.3.x: it's a
-# young, fast-moving 0.x package whose launch() API changed recently, so bump
-# the minor deliberately. The pip wheel is light (httpx + playwright); the
-# ~200MB patched Chromium downloads at first launch, not at install.
-cloakbrowser = {version = ">=0.3.31,<0.4.0", optional = true}
-
-[tool.poetry.extras]
-# hyperbrowser is already a core dep (used elsewhere too).
-source-archive = ["boto3", "playwright", "firecrawl-py", "trafilatura", "pymupdf4llm", "cloakbrowser"]
-
-[tool.poetry.scripts]
-source-archive = "forecasting_tools.agents_and_tools.source_archive.cli:main"
-
[tool.poetry.group.dev.dependencies]
time-machine = ">=2.19.0,<4.0.0"
pre-commit = "^4.0.1"