Files
DB-cleanup/merge-output/wayback_pass.py
StellarCrowandClaude Fable 5.1 439e83a4cb feat(sources): Wayback pass, automatic trimming, detention rule, ten cases re-sourced
Second and third rounds on the dossier pipeline, reviewed locally before commit.

Retrieval
- wayback_pass.py: retries every BLOCKED/DEAD/THIN/ERROR linked source through
  the Wayback Machine; blocks carry the snapshot date, archive URL and the live
  verdict. 143 slots retried: 93 fetched, 37 no snapshot, 12 script shells, 1 PDF.
- fetch_sources.py: fetch_wayback() helper.
- add_extra_sources.py: files staged extra_* finds (web search, not on the DB
  record) into the store with a provenance note.
- Cases re-sourced by web search: 248 (Oslo: Document.no, Avisa Oslo, NRK; the
  linked Le Parisien piece is case 242 and is marked OFF-CASE), 258 Kharkiv,
  260 Singapore, 358 Bangkok, 365 Las Vegas, 388 Verneuil-sur-Seine, 390 Zoersel,
  430 Homestead. 341 of 364 cases now hold a fetched article; 11, 66 and 399
  have no public text source (podcast, police video, direct victim report).

Trimming
- auto_trim.py: case-anchored furniture cut for UNTRIMMED blocks. Finds the body
  run that mentions the case, merges across subheadings and short furniture
  gaps, drops teasers, share bars, date/URL/caption lines and subscription
  pitches; refuses pages with no record term or almost no body. 302 blocks
  trimmed; 16 left on auto-trim-review.md (7 OFF-CASE suspects). Every cut is
  labelled AUTO-TRIMMED, UNREVIEWED in the dossier; the full extract stays in
  staging/. Decisions with anchors in trim-decisions-auto.json.
- build_cases.py / verify_all.py: banners for auto-trimmed and archived blocks,
  staged-file check extended to Wayback blocks, flag legend under the record.

Detention rule
- README "Definitions": the DB field `kidnappings` is the DETENTION violence
  type (victim, guard, staff or relative held to force submission or execute
  the theft), distinct from the Kidnapping scenario (taken away and held).
- detention-flag-review.md / detention-flag-corrections.json: 40 records
  reviewed with evidence; 30 set-to-1 proposals accepted by the owner on
  2026-09-06 (listed in corrections-approved.md), 10 still open.
- apply_detention_wording.py: "Violence Used" in the 70 reviewed summaries now
  names detention explicitly (62 of 70 labelled), supported by the summary's
  own text; idempotent; supersedes the batch scripts' wording.

Worklist and docs
- sofia-worklist.md: 248 decisions, detention rule item replacing the old
  "no abduction" item, fresh-search section for the textless cases.
- README-START-HERE.md: progress notes, run commands, next steps.
- Bug fixed in passing: Wayback blocks stored in-memory text with carriage
  returns; now stored as read back from disk.

verify_all.py PASSES (2321 checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZZENdTLzNGsbNy4DyF1yt
2026-09-06 19:36:45 +02:00

125 lines
6.2 KiB
Python

"""
Second retrieval pass through the Wayback Machine for every linked source the live
fetch could not read (BLOCKED, DEAD, THIN, ERROR).
A snapshot is an archived copy taken by a third party on a recorded date, not the live
page, so every block staged here is labelled with the snapshot timestamp and the archive
URL, and the original live verdict is kept in the note. Extraction is the same
deterministic HTML pass as the live path, so the text is hashed and re-verifiable.
Resumable: a store block that already carries `archive_url` or `archive_tried` is not
retried. Nothing here touches KEEP or PARTIAL blocks. Run build_cases.py --all and
verify_all.py afterwards.
"""
import argparse
import hashlib
import json
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from fetch_sources import STAGING, Extract, fetch_wayback
BASE = Path(__file__).resolve().parent
STORE = BASE / "sourced-articles.json"
LOG = BASE / "wayback-progress.json"
RETRY = ("BLOCKED", "DEAD", "THIN", "ERROR")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("cases", nargs="*", type=int, help="limit to these case ids")
ap.add_argument("--delay", type=float, default=1.0)
args = ap.parse_args()
store = json.loads(STORE.read_text(encoding="utf-8"))
progress = json.loads(LOG.read_text(encoding="utf-8")) if LOG.exists() else {}
todo = [(cid, b) for cid, blocks in store.items() for b in blocks
if b.get("verdict") in RETRY and not b.get("archive_url") and not b.get("archive_tried")
and b.get("url") and (not args.cases or int(cid) in args.cases)]
print(f"{len(todo)} failed source slots to retry through the Wayback Machine", flush=True)
tally = {}
for n, (cid, b) in enumerate(todo, 1):
url = b["url"]
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
b["archive_tried"] = stamp
try:
ts, snap, html = fetch_wayback(url)
except Exception as ex:
outcome = f"ERROR {type(ex).__name__}"
b["archive_note"] = f"Wayback lookup failed: {type(ex).__name__}: {str(ex)[:100]}"
else:
if not ts:
outcome = "NO SNAPSHOT"
b["archive_note"] = "no Wayback Machine snapshot exists for this URL"
elif html.lstrip().startswith("%PDF"):
outcome = "PDF"
b["archive_note"] = f"Wayback snapshot {ts} is a PDF; not extracted"
b["archive_url"] = snap
else:
x = Extract()
x.feed(html)
body = x.text()
if len(body) < 400:
outcome = "THIN"
b["archive_note"] = (f"Wayback snapshot {ts} extracted to only {len(body)} chars "
"(page needs a browser to render)")
b["archive_url"] = snap
else:
outcome = "FETCHED"
cdir = STAGING / f"{int(cid):03d}"
cdir.mkdir(parents=True, exist_ok=True)
idx_path = cdir / "index.json"
index = json.loads(idx_path.read_text(encoding="utf-8")) if idx_path.exists() else []
entry = next((e for e in index if e.get("field") == b["field"]), None)
num = entry["n"] if entry else len(index) + 1
host = re.sub(r"^https?://(www\.)?([^/]+).*", r"\2", url)
fname = f"{num:02d}_{re.sub(r'[^a-z0-9]+', '-', host.lower())}-wayback.txt"
(cdir / fname).write_text(body, encoding="utf-8")
# Store what the file reads back as, not the in-memory body: a page
# with carriage returns inside its text would otherwise never match
# the staged file on re-verification.
body = (cdir / fname).read_text(encoding="utf-8")
live_verdict = b["verdict"]
method = f"Wayback Machine snapshot {ts}, deterministic extraction"
new_entry = {"case": int(cid), "n": num, "field": b["field"], "url": url,
"host": host, "fetched_at": stamp, "verdict": "FETCHED",
"method": "wayback", "snapshot": ts, "archive_url": snap,
"chars": len(body),
"sha256": hashlib.sha256(body.encode()).hexdigest(),
"live_verdict": live_verdict}
if entry:
index[index.index(entry)] = new_entry
else:
index.append(new_entry)
idx_path.write_text(json.dumps(index, ensure_ascii=False, indent=2),
encoding="utf-8")
b.clear()
b.update({
"field": new_entry["field"], "verdict": "UNTRIMMED", "url": url,
"source": host, "language": "unknown",
"text": body, "chars": len(body),
"sha256": new_entry["sha256"], "staged_file": fname,
"paragraphs_kept": f"ALL {len(body.split(chr(10) * 2))} paragraphs, untrimmed",
"method": method, "fetched_at": stamp, "http": None,
"archive_url": snap, "snapshot": ts,
"note": f"live URL was {live_verdict} on the first pass; text comes from "
f"an archived copy dated {ts[:4]}-{ts[4:6]}-{ts[6:8]}",
"cut_note": "NOT TRIMMED. Full page extract; trim on read-through.",
})
tally[outcome] = tally.get(outcome, 0) + 1
progress[f"{cid}:{b['field']}"] = outcome
STORE.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8")
LOG.write_text(json.dumps(progress, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[{n}/{len(todo)}] case {int(cid):>3} {b['field']:<7} {outcome}", flush=True)
time.sleep(args.delay)
print("\ndone " + " ".join(f"{k}={v}" for k, v in sorted(tally.items())), flush=True)
if __name__ == "__main__":
main()