"""Cache important external sources as local Markdown snapshots.""" from __future__ import annotations import hashlib import json import re from dataclasses import dataclass from pathlib import Path from urllib.parse import urlparse import httpx from lxml import html IMPORTANT_DOMAINS = ( "fda.gov", "ema.europa.eu", "nmpa.gov.cn", "cde.org.cn", "ich.org", "who.int", "edqm.eu", "pmda.go.jp", "ec.europa.eu", "health.ec.europa.eu", ) @dataclass(frozen=True) class CacheResult: source_id: str url: str cached_text_path: str raw_path: str status: str chars: int def _safe_stem(source: dict) -> str: source_id = str(source.get("id") or "source") digest = hashlib.sha1(str(source.get("url") or source_id).encode("utf-8")).hexdigest()[:10] safe_id = re.sub(r"[^A-Za-z0-9_-]+", "_", source_id).strip("_") or "source" return f"{safe_id}-{digest}" def _domain(url: str) -> str: return urlparse(url).netloc.lower() def is_important_source(source: dict) -> bool: url = str(source.get("url") or "") if not url.startswith(("http://", "https://")): return False domain = _domain(url) if any(domain.endswith(item) for item in IMPORTANT_DOMAINS): return True tier = str(source.get("tier") or "").lower() if "tier 1" in tier or tier in {"1", "1.0"}: return True title = str(source.get("title") or "").lower() return any(term in title for term in ("ich q9", "ich q10", "annex 1", "fda guidance", "who guideline")) def load_sources(path: Path) -> list[dict]: if not path.exists(): return [] rows: list[dict] = [] for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue rows.append(json.loads(line)) return rows def write_sources(path: Path, rows: list[dict]) -> None: path.write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), encoding="utf-8") def _response_ext(url: str, content_type: str) -> str: lowered = url.lower() if "pdf" in content_type or lowered.endswith(".pdf"): return ".pdf" if "html" in content_type or lowered.endswith((".html", ".htm", "/")): return ".html" return ".bin" def _html_to_text(content: bytes) -> str: doc = html.fromstring(content) for bad in doc.xpath("//script|//style|//noscript"): bad.drop_tree() return "\n".join(line.strip() for line in doc.text_content().splitlines() if line.strip()) def _pdf_to_text(path: Path) -> str: try: import fitz except Exception: return "" doc = fitz.open(path) parts: list[str] = [] for index, page in enumerate(doc, start=1): text = page.get_text("text").strip() if text: parts.append(f"## Page {index}\n\n{text}") return "\n\n".join(parts) def _bytes_to_text(*, raw_path: Path, content: bytes, content_type: str, url: str) -> str: if raw_path.suffix == ".pdf" or "pdf" in content_type or url.lower().endswith(".pdf"): return _pdf_to_text(raw_path) if raw_path.suffix in {".html", ".htm"} or "html" in content_type: return _html_to_text(content) try: return content.decode("utf-8") except UnicodeDecodeError: return content.decode("utf-8", errors="ignore") def cache_source( project_root: Path, source: dict, *, client: httpx.Client | None = None, force: bool = False, timeout: float = 45.0, ) -> CacheResult: url = str(source.get("url") or "") if not url.startswith(("http://", "https://")): raise ValueError(f"source URL is not remote: {url}") cache_dir = project_root / "phase2" / "source_cache" raw_dir = cache_dir / "raw" text_dir = cache_dir / "md" raw_dir.mkdir(parents=True, exist_ok=True) text_dir.mkdir(parents=True, exist_ok=True) stem = _safe_stem(source) md_path = text_dir / f"{stem}.md" if md_path.exists() and not force: return CacheResult( source_id=str(source.get("id") or ""), url=url, cached_text_path=str(md_path.relative_to(project_root)), raw_path=str(source.get("cached_raw_path") or ""), status="cached", chars=len(md_path.read_text(encoding="utf-8")), ) owns_client = client is None http = client or httpx.Client(trust_env=False, follow_redirects=True, timeout=timeout) try: response = http.get(url) response.raise_for_status() content_type = response.headers.get("content-type", "").lower() ext = _response_ext(str(response.url), content_type) raw_path = raw_dir / f"{stem}{ext}" raw_path.write_bytes(response.content) text = _bytes_to_text(raw_path=raw_path, content=response.content, content_type=content_type, url=str(response.url)) lines = [ f"# Source Snapshot: {source.get('title') or source.get('id') or url}", "", f"- source_id: {source.get('id', '')}", f"- original_url: {url}", f"- fetched_url: {response.url}", f"- content_type: {content_type}", f"- raw_path: {raw_path.relative_to(project_root)}", "", "## Extracted Text", "", text.strip() or "[No extractable text. Keep raw file for manual review.]", "", ] md_path.write_text("\n".join(lines), encoding="utf-8") return CacheResult( source_id=str(source.get("id") or ""), url=url, cached_text_path=str(md_path.relative_to(project_root)), raw_path=str(raw_path.relative_to(project_root)), status="fetched", chars=len(text), ) finally: if owns_client: http.close() def cache_sources( project_root: Path, *, sources_rel: str = "phase2/sources.jsonl", important_only: bool = True, limit: int | None = None, force: bool = False, ) -> list[CacheResult]: sources_path = project_root / sources_rel rows = load_sources(sources_path) results: list[CacheResult] = [] selected_indexes = [ index for index, row in enumerate(rows) if row.get("url") and (not row.get("cached_text_path") or force) and (not important_only or is_important_source(row)) ] if limit is not None: selected_indexes = selected_indexes[:limit] with httpx.Client(trust_env=False, follow_redirects=True, timeout=45.0) as client: for index in selected_indexes: row = rows[index] try: result = cache_source(project_root, row, client=client, force=force) except Exception as exc: row["cache_status"] = "failed" row["cache_error"] = str(exc)[:300] continue row["cached_text_path"] = result.cached_text_path row["cached_raw_path"] = result.raw_path row["cache_status"] = result.status row["cached_text_chars"] = result.chars results.append(result) write_sources(sources_path, rows) manifest = project_root / "phase2" / "source_cache" / "manifest.json" manifest.parent.mkdir(parents=True, exist_ok=True) manifest.write_text( json.dumps([result.__dict__ for result in results], ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) return results