Files
DB-cleanup/merge-output/fetch_sources.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

236 lines
9.3 KiB
Python

"""
Stage source text for one or more cases.
Raw HTTP first, because that path is deterministic: the bytes come off the wire and
the extracted text is hashed, so it can be re-verified later. Sites that refuse a raw
request are recorded as BLOCKED and left for the WebFetch path, which is a model
transcription and must be labelled as such.
Nothing here writes to case files. Output goes to merge-output/staging/.
"""
import argparse
import gzip
import hashlib
import json
import re
import urllib.error
import urllib.request
import zlib
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
BASE = Path(__file__).resolve().parent
JSONF = BASE.parent / "attacks-export-Gart-website.json"
STAGING = BASE / "staging"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
NOT_ARTICLE = re.compile(r"(youtube\.com|youtu\.be|x\.com|twitter\.com|instagram\.com|"
r"facebook\.com|tiktok\.com|t\.me)", re.I)
# Furniture that survives tag-stripping and has to go by content, not markup.
JUNK = re.compile(r"^(facebook|instagram|linkedin|rss|twitter|x|whatsapp|telegram|share|"
r"compartilhar|publicidade|advertisement|newsletter|cookies?|assine|"
r"leia (também|mais)|read more|related( articles)?|tags?|"
r"siga o|follow us|subscribe)\b", re.I)
class Extract(HTMLParser):
"""Pull block-level text.
Deliberately simple. Suppression is tracked with a stack of the tag names that
opened it, so a container that never closes cleanly cannot swallow the rest of the
document. Furniture is removed by content afterwards, not by guessing at CSS class
names, because class conventions differ on every site and a wrong guess here fails
silently by returning an empty page.
"""
DROP = {"script", "style", "noscript", "nav", "header", "footer", "aside",
"form", "svg", "button", "figure", "iframe", "select"}
BLOCK = {"p", "h1", "h2", "h3", "h4", "li", "blockquote", "div", "section", "br"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.stack = []
self.cur = []
self.blocks = []
def handle_starttag(self, tag, attrs):
if tag in self.DROP:
self.stack.append(tag)
return
if tag in self.BLOCK:
self._flush()
def handle_endtag(self, tag):
if self.stack and self.stack[-1] == tag:
self.stack.pop()
return
if not self.stack and tag in self.BLOCK:
self._flush()
def handle_data(self, data):
if not self.stack:
self.cur.append(data)
def _flush(self):
if self.cur:
t = re.sub(r"[ \t\xa0]+", " ", "".join(self.cur)).strip()
if t:
self.blocks.append(t)
self.cur = []
def text(self):
self._flush()
out, seen = [], set()
for t in self.blocks:
if len(t) < 40 and not re.search(r"[.!?]$", t):
continue # nav labels, buttons, breadcrumbs
if JUNK.match(t):
continue
if t in seen:
continue
seen.add(t)
out.append(t)
return "\n\n".join(out)
def fetch_raw(url, timeout=30):
req = urllib.request.Request(url, headers={
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en,pt;q=0.9,fr;q=0.8,id;q=0.7,es;q=0.6",
})
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
enc = (r.headers.get("Content-Encoding") or "").lower()
if enc == "gzip":
raw = gzip.decompress(raw)
elif enc == "deflate":
raw = zlib.decompress(raw)
charset = r.headers.get_content_charset() or "utf-8"
return r.status, raw.decode(charset, errors="replace"), r.geturl()
WAYBACK_AVAIL = "https://archive.org/wayback/available?url="
def fetch_wayback(url, timeout=40):
"""Fetch the closest Wayback Machine snapshot of a URL.
Returns (timestamp, snapshot_url, html) or (None, None, None) when nothing is
archived. The `id_` flag asks for the original bytes without the Wayback toolbar,
so extraction runs on the same markup a live fetch would have seen. A snapshot is
a third party's copy taken on a given date, so anything staged from it must say so.
"""
import urllib.parse
q = WAYBACK_AVAIL + urllib.parse.quote(url, safe="")
with urllib.request.urlopen(urllib.request.Request(q, headers={"User-Agent": UA}),
timeout=timeout) as r:
closest = json.loads(r.read().decode("utf-8")).get("archived_snapshots", {}).get("closest")
if not closest or not closest.get("available"):
return None, None, None
ts = closest["timestamp"]
raw_url = f"https://web.archive.org/web/{ts}id_/{url}"
status, html, final = fetch_raw(raw_url, timeout=timeout)
return ts, closest["url"], html
def stage_case(rec, extra_urls=(), new_only=False):
cid = rec["id"]
out = STAGING / f"{cid:03d}"
out.mkdir(parents=True, exist_ok=True)
entries = []
# Re-fetching a source that is already staged is pure cost, and worse: live pages
# add and drop teasers, so the paragraph indices in an approved cut silently shift.
# With --new-only, anything already fetched is carried over untouched.
prior = {}
idx_path = out / "index.json"
if new_only and idx_path.exists():
prior = {e["field"]: e for e in json.loads(idx_path.read_text(encoding="utf-8"))}
urls = [(f, rec[f]) for f in ("url", "url_2", "url_3", "url_4", "url_5") if rec.get(f)]
urls += [(f"extra_{i+1}", u) for i, u in enumerate(extra_urls)]
for n, (field, url) in enumerate(urls, 1):
host = re.sub(r"^https?://(www\.)?([^/]+).*", r"\2", url)
if field in prior and prior[field].get("verdict") in ("FETCHED", "NOT AN ARTICLE"):
carried = dict(prior[field])
carried["carried_over"] = True
entries.append(carried)
continue
e = {"case": cid, "n": n, "field": field, "url": url, "host": host,
"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
if NOT_ARTICLE.search(url):
e.update(verdict="NOT AN ARTICLE", method="none",
note="video or social post; recorded as a source reference, not transcribed")
entries.append(e)
continue
try:
status, html, final = fetch_raw(url)
text = Extract()
text.feed(html)
body = text.text()
if len(body) < 400:
e.update(verdict="THIN", method="raw", http=status, chars=len(body),
note="extractor recovered very little; needs the WebFetch path or a "
"site-specific rule")
else:
e.update(verdict="FETCHED", method="raw", http=status, chars=len(body),
sha256=hashlib.sha256(body.encode()).hexdigest(),
final_url=final if final != url else None)
(out / f"{n:02d}_{re.sub(r'[^a-z0-9]+', '-', host.lower())}.txt").write_text(
body, encoding="utf-8")
except urllib.error.HTTPError as ex:
e.update(verdict="BLOCKED" if ex.code in (401, 403, 429) else "DEAD",
method="raw", http=ex.code,
note="raw request refused; try the WebFetch path, which is a model "
"transcription and must be labelled as such")
except Exception as ex:
e.update(verdict="ERROR", method="raw", note=f"{type(ex).__name__}: {str(ex)[:120]}")
entries.append(e)
(out / "index.json").write_text(json.dumps(entries, ensure_ascii=False, indent=2),
encoding="utf-8")
return entries
def main():
ap = argparse.ArgumentParser()
ap.add_argument("cases", nargs="+", type=int)
ap.add_argument("--extra", action="append", default=[],
help="CASEID=URL for a source not on the record")
ap.add_argument("--new-only", action="store_true",
help="skip sources already staged; use this whenever adding a source to "
"a case that has approved cuts, or their paragraph indices will drift")
args = ap.parse_args()
extra = {}
for spec in args.extra:
cid, _, url = spec.partition("=")
extra.setdefault(int(cid), []).append(url)
records = {r["id"]: r for r in json.loads(JSONF.read_text(encoding="utf-8"))}
STAGING.mkdir(exist_ok=True)
for cid in args.cases:
entries = stage_case(records[cid], extra.get(cid, ()), new_only=args.new_only)
print(f"\ncase {cid}")
for e in entries:
bits = "carried" if e.get("carried_over") else (f"HTTP {e.get('http')}" if e.get("http") else "")
size = f"{e['chars']:,} chars" if e.get("chars") else ""
print(f" [{e['verdict']:<15}] {e['field']:<8} {e['host']:<26} {bits:<9} {size}")
if e.get("note"):
print(f" {e['note']}")
if __name__ == "__main__":
main()