SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
6.4 KB · 94 lines python
Raw Blame History
1"""One-off repair: remove snapshots/entities/events produced from mis-decoded bodies (2026-09-12 Brotli incident).23Before the fix, the fetcher advertised `Accept-Encoding: br` without the `brotli` codec installed, so Brotli-compressed responses4were stored and "parsed" as text: garbage titles (U+FFFD runs), 100 %-changed pages, meaningless events. This script:56  1. scans every snapshot's stored text (and title) and flags the corrupt ones (text quality < 0.99 or U+FFFD in the title),7  2. retracts events derived from them (or carrying U+FFFD) — status 'retracted' + reason, never deleted,8  3. deletes the corrupt snapshots, their change rows and the corrupt extracted entities (labels with U+FFFD),9  4. resets the affected sensors (hashes, last_snapshot_id, snapshot_count) and schedules them to run now, so the next observation10     becomes a clean baseline (entities flagged baseline, no bogus "change").1112Raw observation objects are kept (content-addressed archive). Usage: `.venv/bin/python scripts/purge_corrupt.py [--apply]`.13"""14from __future__ import annotations1516import asyncio17import sys18from datetime import UTC, datetime1920from companyatlas import archive21from companyatlas.db import dispose, execute, fetch_all, fetch_val, transaction22from companyatlas.fetch import text_quality2324APPLY = "--apply" in sys.argv25REASON = "corrupt extraction: response body was not decoded (Brotli without codec); repaired 2026-09-13"262728async def main() -> None:29    async with transaction() as conn:30        rows = await fetch_all(conn, "select id, sensor_id, title, text_key from snapshots order by sensor_id, fetched_at")31    corrupt: list[str] = []32    sensors: set[str] = set()33    for r in rows:34        bad = bool(r["title"] and "�" in r["title"])35        if not bad and r["text_key"]:36            try:37                bad = text_quality(archive.get_text(r["text_key"])) < 0.9938            except FileNotFoundError:39                bad = False40        if bad:41            corrupt.append(r["id"])42            sensors.add(r["sensor_id"])43    print(f"snapshots scanned={len(rows)} corrupt={len(corrupt)} sensors_affected={len(sensors)}")44    async with transaction() as conn:45        n_ev_text = await fetch_val(conn, "select count(*) from events where status = 'active' and (title ~ E'\\uFFFD' or coalesce(summary, '') ~ E'\\uFFFD')")46        n_ev_snap = await fetch_val(conn, "select count(*) from events where status = 'active' and (snapshot_before = any(cast(:s as text[])) or snapshot_after = any(cast(:s as text[])))",47                                    s=corrupt or ["-"])48        counts = {}49        for table, col in (("news_items", "title"), ("jobs", "title"), ("people", "name"), ("products", "name"), ("pricing_plans", "plan_name"), ("locations", "name")):50            counts[table] = await fetch_val(conn, f"select count(*) from {table} where {col} ~ E'\\uFFFD'")51        n_changes = await fetch_val(conn, "select count(*) from changes where snapshot_before = any(cast(:s as text[])) or snapshot_after = any(cast(:s as text[]))", s=corrupt or ["-"])52    print(f"events to retract: text={n_ev_text} snapshot-linked={n_ev_snap} · changes to delete={n_changes} · corrupt entities={counts}")53    if not APPLY:54        print("dry-run (pass --apply to repair)")55        return56    now = datetime.now(UTC)57    async with transaction() as conn:58        await execute(conn, """update events set status = 'retracted', retracted_reason = :r59                               where status = 'active' and (title ~ E'\\uFFFD' or coalesce(summary, '') ~ E'\\uFFFD'60                                     or snapshot_before = any(cast(:s as text[])) or snapshot_after = any(cast(:s as text[]))61                                     or change_id in (select id from changes where snapshot_before = any(cast(:s as text[])) or snapshot_after = any(cast(:s as text[]))))""",62                      r=REASON, s=corrupt or ["-"])63        await execute(conn, "delete from changes where snapshot_before = any(cast(:s as text[])) or snapshot_after = any(cast(:s as text[]))", s=corrupt or ["-"])64        for table, col in (("news_items", "title"), ("jobs", "title"), ("people", "name"), ("products", "name"), ("pricing_plans", "plan_name"), ("locations", "name")):65            await execute(conn, f"delete from {table} where {col} ~ E'\\uFFFD'")66        await execute(conn, "update snapshots set previous_snapshot_id = null where previous_snapshot_id = any(cast(:s as text[]))", s=corrupt or ["-"])67        await execute(conn, "delete from snapshots where id = any(cast(:s as text[]))", s=corrupt or ["-"])68        for sid in sensors:69            last = await fetch_all(conn, "select id, content_hash, normalized_hash, structural_hash from snapshots where sensor_id = :sid order by fetched_at desc limit 1", sid=sid)70            n = await fetch_val(conn, "select count(*) from snapshots where sensor_id = :sid", sid=sid)71            if last:72                await execute(conn, """update sensors set last_snapshot_id = :snap, last_content_hash = :ch, last_normalized_hash = :nh, last_structural_hash = :sh,73                                       snapshot_count = :n, consecutive_unchanged = 0, next_run_at = :now, etag = null, last_modified = null,74                                       config = config - 'last_structured_hash', updated_at = :now where id = :sid""",75                              snap=last[0]["id"], ch=last[0]["content_hash"], nh=last[0]["normalized_hash"], sh=last[0]["structural_hash"], n=n, now=now, sid=sid)76            else:77                await execute(conn, """update sensors set last_snapshot_id = null, last_content_hash = null, last_normalized_hash = null, last_structural_hash = null,78                                       snapshot_count = 0, change_count = 0, meaningful_change_count = 0, event_count = 0, consecutive_unchanged = 0,79                                       next_run_at = :now, etag = null, last_modified = null, config = config - 'last_structured_hash', updated_at = :now80                                       where id = :sid""", now=now, sid=sid)81        await execute(conn, "update companies c set last_event_at = (select max(detected_at) from events e where e.company_id = c.id and e.status = 'active')")82    print(f"repaired: {len(corrupt)} snapshots removed, {len(sensors)} sensors reset and due now, events retracted, corrupt entities deleted")838485async def _run() -> None:86    try:87        await main()88    finally:89        await dispose()909192if __name__ == "__main__":93    asyncio.run(_run())94