"""Sensor auto-repair (spec §60, §189): failing / stale / redirected / PAGE_REMOVED sensors. retry ─▶ (redirect target) ─▶ re-fetch sitemap ─▶ rediscover navigation ─▶ candidate URL for the same surface ─▶ content-identity check (simhash / fuzzy ratio against the last snapshot text) ─▶ migrate (old retired with config.successor_id, new sensor with config.predecessor_id, history kept) or review_queue(kind='sensor_migration'). Registered as a periodic task (every 30 min, bounded batch). Bounded requests per sensor; never raises out of the batch. """ from __future__ import annotations import logging import re from datetime import UTC, datetime from typing import Any from urllib.parse import urlparse from rapidfuzz import fuzz from companyatlas import archive from companyatlas.config import settings from companyatlas.db import execute, fetch_all, fetch_one, jsonb, transaction from companyatlas.fetch import BlockedError, Fetcher, FetchError, FetchResult, NotModified from companyatlas.ids import new_id from companyatlas.sdk import connector as connectors from companyatlas.sdk import normalize from companyatlas.sdk.normalize import hamming, normalized_text, simhash from companyatlas.services.periodic import periodic from companyatlas.taxonomy import SensorStatus, Surface from companyatlas.urls import canonicalize_url, classify_url, registrable_domain, same_company_host log = logging.getLogger(__name__) REPAIR_VERSION = "repair-v1" MAX_REQUESTS_PER_SENSOR = 6 IDENTITY_HAMMING = 12 IDENTITY_RATIO = 0.6 MIN_CANDIDATE_CONF = 0.7 SOFT_404_RE = re.compile(r"(page not found|404|not be found|doesn'?t exist|no longer available)", re.IGNORECASE) class _Session: def __init__(self, fetcher: Fetcher): self.fetcher = fetcher self.requests = 0 async def get(self, url: str, *, max_bytes: int = 2 * 1024 * 1024, accept: str | None = None) -> FetchResult | None: if self.requests >= MAX_REQUESTS_PER_SENSOR: return None self.requests += 1 try: return await self.fetcher.get(url, max_bytes=max_bytes, retries=0, accept=accept) except NotModified: return None except (FetchError, BlockedError) as exc: log.debug("repair fetch failed", extra={"url": url, "failure": str(exc.failure)}) return None except Exception as exc: # noqa: BLE001 log.debug("repair fetch error", extra={"url": url, "error": str(exc)[:200]}) return None def _soft_404(res: FetchResult) -> bool: m = re.search(r"]*>(.*?)", res.text[:4000], re.IGNORECASE | re.DOTALL) return bool(m and SOFT_404_RE.search(m.group(1))) async def _last_text(sensor: dict[str, Any]) -> str: sid = sensor.get("last_snapshot_id") if not sid: return "" async with transaction() as conn: row = await fetch_one(conn, "select text_key from snapshots where id = :id", id=sid) if row and row.get("text_key") and archive.exists(row["text_key"]): try: return archive.get_text(row["text_key"]) except OSError: return "" return "" def content_identity(previous_text: str, candidate_text: str) -> tuple[bool, float]: """(same_content, score). simhash distance first, fuzzy ratio second — both on noise-normalised text.""" if not previous_text or not candidate_text: return False, 0.0 d = hamming(simhash(previous_text), simhash(candidate_text)) ratio = fuzz.ratio(normalized_text(previous_text)[:20000], normalized_text(candidate_text)[:20000]) / 100.0 return (d <= IDENTITY_HAMMING or ratio >= IDENTITY_RATIO), round(max(ratio, 1 - d / 64), 3) async def _candidates(session: _Session, sensor: dict[str, Any], company: dict[str, Any]) -> list[tuple[str, float, str]]: """(url, confidence, method) for the sensor's surface: redirect target, sitemap entries, navigation links.""" surface = str(sensor["surface"]) canonical = str(company.get("canonical_domain") or sensor["domain"]) out: list[tuple[str, float, str]] = [] cfg = sensor.get("config") or {} if cfg.get("redirect_url"): out.append((str(cfg["redirect_url"]), 0.9, "redirect")) origin = f"https://{urlparse(str(company.get('website') or 'https://' + canonical)).netloc or canonical}" # sitemap async with transaction() as conn: sm = await fetch_one(conn, "select url from sensors where company_id = :c and surface = 'sitemap' and status <> 'retired' order by quality_score desc limit 1", c=company["id"]) sm_url = str(sm["url"]) if sm else f"{origin}/sitemap.xml" res = await session.get(sm_url, max_bytes=4 * 1024 * 1024, accept="application/xml,text/xml,*/*;q=0.5") if res is not None and not res.is_html: from companyatlas.connectors.sitemap import _decode, parse_sitemap pages, children = parse_sitemap(_decode(res.content)) if children and not pages: child = await session.get(children[0][0], max_bytes=4 * 1024 * 1024, accept="application/xml,text/xml,*/*;q=0.5") if child is not None: pages, _ = parse_sitemap(_decode(child.content)) for loc, _lm in pages[: settings.discovery_max_sitemap_urls]: if not same_company_host(loc, canonical): continue s, c = classify_url(loc, canonical_domain=canonical) if s == surface and c >= MIN_CANDIDATE_CONF: out.append((loc, c * 0.9, "sitemap")) # navigation home = await session.get(origin + "/") if home is not None and home.is_html: page = normalize.parse(home.text, url=home.final_url) for ln in page.links: if not same_company_host(ln.url, canonical): continue s, c = classify_url(ln.url, anchor=ln.anchor, canonical_domain=canonical) if s == surface and c >= MIN_CANDIDATE_CONF: out.append((ln.url, c, "nav")) # dedupe, exclude the broken URL itself seen = {canonicalize_url(str(sensor["url"]))} uniq: list[tuple[str, float, str]] = [] for url, conf, method in sorted(out, key=lambda x: -x[1]): cu = canonicalize_url(url) if cu in seen: continue seen.add(cu) uniq.append((url, conf, method)) return uniq[:5] async def migrate_sensor(sensor: dict[str, Any], new_url: str, *, reason: str, confidence: float, identity_score: float) -> str: """Retire the old sensor (successor_id) and create the replacement (predecessor_id). Returns the new sensor id.""" now = datetime.now(UTC) surface = str(sensor["surface"]) connector = connectors.for_surface(surface, new_url) new_id_ = new_id("sensor") old_cfg = dict(sensor.get("config") or {}) new_cfg = {k: v for k, v in old_cfg.items() if k in ("canonical_domain", "vendor", "token")} new_cfg.update({"predecessor_id": sensor["id"], "repair": {"version": REPAIR_VERSION, "reason": reason, "confidence": confidence, "identity": identity_score, "at": now.isoformat(), "from_url": sensor["url"]}}) async with transaction() as conn: existing = await fetch_one(conn, "select id from sensors where company_id = :c and canonical_url = :u", c=sensor["company_id"], u=canonicalize_url(new_url)) if existing is not None: new_id_ = str(existing["id"]) await execute(conn, "update sensors set status = case when status = 'retired' then 'pending' else status end, next_run_at = now(), updated_at = now() where id = :id", id=new_id_) else: await execute(conn, """ insert into sensors (id, company_id, surface, connector_id, url, canonical_url, domain, discovery_confidence, discovery_method, quality_score, status, tier, base_interval_s, current_interval_s, next_run_at, priority, config) values (:id, :c, :surface, :conn, :url, :curl, :domain, :conf, 'repair', :q, 'pending', :tier, :base, :base, now(), :prio, cast(:cfg as jsonb)) """, id=new_id_, c=sensor["company_id"], surface=surface, conn=connector.connector_id, url=new_url, curl=canonicalize_url(new_url), domain=registrable_domain(new_url), conf=round(confidence, 3), q=max(30.0, float(sensor.get("quality_score") or 50) * 0.9), tier=sensor.get("tier") or "D", base=int(sensor.get("base_interval_s") or 86400), prio=float(sensor.get("priority") or 0.5), cfg=jsonb(new_cfg)) await execute(conn, """update sensors set status = 'retired', retired_at = coalesce(retired_at, now()), claimed_by = null, claimed_at = null, config = config || cast(:cfg as jsonb), updated_at = now() where id = :id""", id=sensor["id"], cfg=jsonb({"successor_id": new_id_, "retired_reason": reason})) await execute(conn, "update review_queue set status = 'resolved', resolution = :r, resolved_at = now() where ref_id = :id and status = 'open'", id=sensor["id"], r=f"migrated to {new_id_}") return new_id_ async def repair_sensor(sensor: dict[str, Any], *, fetcher: Fetcher, dry_run: bool = False) -> dict[str, Any]: session = _Session(fetcher) sid = str(sensor["id"]) result: dict[str, Any] = {"sensor_id": sid, "surface": sensor["surface"], "url": sensor["url"], "action": "none", "requests": 0} async with transaction() as conn: company = await fetch_one(conn, "select id, canonical_domain, website from companies where id = :id", id=sensor["company_id"]) if company is None: result["action"] = "orphan" return result # 1. retry the URL itself res = await session.get(str(sensor["url"])) if res is not None and res.status == 200 and registrable_domain(res.final_url) == registrable_domain(str(sensor["url"])) and not (res.is_html and _soft_404(res)): result["action"] = "recovered" if not dry_run: async with transaction() as conn: await execute(conn, """update sensors set status = 'active', consecutive_failures = 0, next_run_at = now(), claimed_by = null, claimed_at = null, config = config - 'redirect_url', updated_at = now() where id = :id""", id=sid) result["requests"] = session.requests return result if res is not None and registrable_domain(res.final_url) != registrable_domain(str(sensor["url"])): sensor = {**sensor, "config": {**(sensor.get("config") or {}), "redirect_url": res.final_url}} # 2–4. candidates + identity check previous = await _last_text(sensor) cands = await _candidates(session, sensor, company) result["candidates"] = [{"url": u, "confidence": c, "method": m} for u, c, m in cands] best: tuple[str, float, str, float] | None = None for url, conf, method in cands[:3]: r = await session.get(url) if r is None or r.status != 200 or (r.is_html and _soft_404(r)): continue text = normalize.parse(r.text, url=r.final_url).text if r.is_html else r.text same, score = content_identity(previous, text) result.setdefault("checked", []).append({"url": url, "identity": score, "same": same}) if same and (best is None or score > best[3]): best = (r.final_url, conf, method, score) elif best is None and method == "redirect" and conf >= 0.9 and not previous: best = (r.final_url, conf, method, 0.0) result["requests"] = session.requests if best is not None: result["action"] = "migrated" if not dry_run else "would_migrate" result["new_url"] = best[0] if not dry_run: result["new_sensor_id"] = await migrate_sensor(sensor, best[0], reason=best[2], confidence=best[1], identity_score=best[3]) return result # uncertain: one strong candidate → human review; none → leave the sensor to its failure policy if cands and not dry_run: async with transaction() as conn: existing = await fetch_one(conn, "select id from review_queue where kind = 'sensor_migration' and ref_id = :r and status = 'open'", r=sid) if existing is None: await execute(conn, "insert into review_queue (id, kind, ref_id, company_id, payload) values (:id, 'sensor_migration', :r, :c, cast(:p as jsonb))", id=new_id("review"), r=sid, c=sensor["company_id"], p=jsonb({"sensor_id": sid, "url": sensor["url"], "surface": sensor["surface"], "candidates": result["candidates"], "checked": result.get("checked", [])})) result["action"] = "review" elif cands: result["action"] = "would_review" async with transaction() as conn: await execute(conn, "update sensors set config = config || cast(:c as jsonb), updated_at = now() where id = :id", id=sid, c=jsonb({"last_repair_at": datetime.now(UTC).isoformat(), "last_repair_action": result["action"]})) return result async def repair_batch(limit: int = 50, *, fetcher: Fetcher | None = None, dry_run: bool = False) -> dict[str, Any]: own = fetcher is None fetcher = fetcher or Fetcher() if own: await fetcher.open() stats: dict[str, Any] = {"examined": 0, "recovered": 0, "migrated": 0, "review": 0, "none": 0, "results": []} try: async with transaction() as conn: rows = await fetch_all(conn, """ select * from sensors where (status in ('failing', 'stale', 'redirected') or (status = 'active' and last_failure_class = 'PAGE_REMOVED' and consecutive_failures >= 2)) and (config->>'last_repair_at' is null or (config->>'last_repair_at')::timestamptz < now() - interval '1 day') and surface <> :sitemap order by quality_score desc, last_run_at nulls first limit :n """, n=limit, sitemap=str(Surface.SITEMAP)) for row in rows: stats["examined"] += 1 try: r = await repair_sensor(row, fetcher=fetcher, dry_run=dry_run) except Exception as exc: log.exception("repair failed", extra={"sensor_id": row.get("id")}) r = {"sensor_id": row.get("id"), "action": "error", "error": str(exc)[:200]} key = r["action"].removeprefix("would_") stats[key] = stats.get(key, 0) + 1 stats["results"].append(r) finally: if own: await fetcher.close() return stats @periodic("sensor-repair", every_s=1800, initial_delay_s=120) async def repair_task() -> None: stats = await repair_batch(limit=50) if stats["examined"]: log.info("repair batch", extra={k: v for k, v in stats.items() if k != "results"}) def sensor_status_values() -> tuple[str, ...]: return tuple(s.value for s in SensorStatus) __all__ = ["REPAIR_VERSION", "content_identity", "migrate_sensor", "repair_batch", "repair_sensor", "repair_task"]