grant-radar / src /analyzer /net /fetcher.py
Riley Coleman
Initial commit of grant-analyst project
19de729
Raw
History Blame Contribute Delete
3.04 kB
# src/analyzer/net/fetcher.py
from __future__ import annotations
import os, time, hashlib
from typing import Optional, Dict
import httpx
from bs4 import BeautifulSoup
CACHE_DIR = os.getenv("LINK_CACHE_DIR", "data/link_cache")
HEADERS = {"User-Agent": "grant-analyst/1.0 (+polite; contact: engineering@example.com)"}
TIMEOUT = httpx.Timeout(25.0)
def _sha(s: str) -> str:
return hashlib.sha1(s.encode("utf-8", "ignore")).hexdigest()
def _path(stem: str, ext: str) -> str:
os.makedirs(CACHE_DIR, exist_ok=True)
return os.path.join(CACHE_DIR, f"{stem}.{ext}")
def _html2text(html: str) -> str:
soup = BeautifulSoup(html, "lxml")
for sel in ["nav", "header", "footer", ".govuk-footer", ".govuk-phase-banner"]:
for n in soup.select(sel):
n.decompose()
txt = "\n".join(p.get_text(" ", strip=True) for p in soup.find_all(["h1","h2","h3","p","li","dt","dd"]))
return txt.strip()
def fetch_link(url: str, *, force: bool = False) -> Dict:
"""
Fetch a URL (HTML or PDF), parse to text, and cache results.
Returns: {url, ok, kind, text, cached_txt, cached_html, fetched_at}
"""
key = _sha(url)
cached_txt = _path(key, "txt")
cached_html = _path(key, "html")
meta_path = _path(key, "meta")
if not force and os.path.exists(cached_txt):
with open(cached_txt, "r", encoding="utf-8") as f:
text = f.read()
return {"url": url, "ok": True, "kind": "cached", "text": text,
"cached_txt": cached_txt, "cached_html": cached_html,
"fetched_at": os.path.getmtime(cached_txt)}
kind = "html"
try:
with httpx.Client(timeout=TIMEOUT, follow_redirects=True, headers=HEADERS) as cli:
r = cli.get(url)
ctype = r.headers.get("content-type","").lower()
if "application/pdf" in ctype or url.lower().endswith(".pdf") or ".pdf?" in url.lower():
kind = "pdf"
pdf_path = _path(key, "pdf")
with open(pdf_path, "wb") as f:
f.write(r.content)
try:
import fitz # PyMuPDF
with fitz.open(pdf_path) as doc:
pages = [p.get_text() for p in doc]
text = "\n".join(pages).strip()
except Exception:
text = "(PDF saved; text extraction failed)"
else:
html = r.text
text = _html2text(html)
with open(cached_html, "w", encoding="utf-8") as f:
f.write(html)
except Exception as e:
return {"url": url, "ok": False, "error": str(e), "kind": kind}
with open(cached_txt, "w", encoding="utf-8") as f:
f.write(text)
with open(meta_path, "w", encoding="utf-8") as f:
f.write(f"{time.time()}\n{url}\n{kind}\n")
return {"url": url, "ok": True, "kind": kind, "text": text,
"cached_txt": cached_txt, "cached_html": cached_html,
"fetched_at": time.time()}