Until now only cases 357+ (plus a few older ones) had their linked URLs fetched; the other 252 dossiers held just the text pasted into the spreadsheet, often only the lede. This pass fetches every linked URL for those cases so the full text is on file before link rot takes it. - stage_all.py: new --include-sheet-cases flag that lifts the skip on cases holding spreadsheet articles. Resumable as before. - staging/: raw extracts and index.json for the 252 cases. - sourced-articles.json, case-files-manifest.json: 188 new UNTRIMMED blocks. 179 cases gained at least one fetched article; 73 got none (49 BLOCKED, 18 THIN, 9 DEAD, 4 video/social, 3 ERROR). - sources/*.txt: rebuilt with build_cases.py --all. Sheet pastes are kept; fetched blocks sit beside them under the NOT YET TRIMMED banner. Files previously committed with CRLF are now LF. - wayback-availability.json: Wayback snapshots exist for 73 of the 98 failed URL slots; input for an archive-fetch pass. - README-START-HERE.md: progress note and next step. verify_all.py PASSES (1836 checks). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZZENdTLzNGsbNy4DyF1yt
110 lines
4.9 KiB
Python
110 lines
4.9 KiB
Python
"""
|
|
Stage every remaining case's sources, untrimmed.
|
|
|
|
The point is to race link rot. Retrieval is permanent; trimming and fact-checking can
|
|
happen any time afterwards against saved text. So this fetches broadly, files the full
|
|
extracted page, and marks every block UNTRIMMED so nobody mistakes it for curated text.
|
|
|
|
Resumable and non-destructive:
|
|
- cases already present in sourced-articles.json are skipped entirely, so the curated
|
|
entries for 456-462 are never overwritten
|
|
- sources already staged are carried over rather than re-fetched, because re-fetching
|
|
a live page shifts paragraph indices under any approved cut
|
|
|
|
Politeness: a delay between requests. We are reading other people's servers.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fetch_sources import STAGING, stage_case
|
|
|
|
BASE = Path(__file__).resolve().parent
|
|
JSONF = BASE.parent / "attacks-export-Gart-website.json"
|
|
STORE = BASE / "sourced-articles.json"
|
|
LOG = BASE / "staging-progress.json"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--limit", type=int, default=0, help="stop after N cases (0 = all)")
|
|
ap.add_argument("--delay", type=float, default=1.5, help="seconds between cases")
|
|
ap.add_argument("--include-sheet-cases", action="store_true",
|
|
help="also stage cases that already hold a spreadsheet article. The "
|
|
"sheet paste is often just the lede; this fetches the linked URLs "
|
|
"so the full text is on file before link rot takes it.")
|
|
args = ap.parse_args()
|
|
|
|
records = json.loads(JSONF.read_text(encoding="utf-8"))
|
|
store = json.loads(STORE.read_text(encoding="utf-8")) if STORE.exists() else {}
|
|
progress = json.loads(LOG.read_text(encoding="utf-8")) if LOG.exists() else {}
|
|
|
|
# Cases that already carry article text from the spreadsheet are not worth fetching
|
|
# for: they have evidence on file, and the point of this pass is the ones with none.
|
|
manifest = BASE / "case-files-manifest.json"
|
|
have_articles = set()
|
|
if manifest.exists() and not args.include_sheet_cases:
|
|
have_articles = {e["case"] for e in
|
|
json.loads(manifest.read_text(encoding="utf-8"))["entries"]
|
|
if "reported_K&R sheet" in e.get("origin", "")}
|
|
|
|
todo = [r for r in sorted(records, key=lambda r: -r["id"])
|
|
if str(r["id"]) not in store and r["id"] not in have_articles]
|
|
print(f"skipping {len(have_articles)} cases that already hold spreadsheet articles",
|
|
flush=True)
|
|
if args.limit:
|
|
todo = todo[:args.limit]
|
|
print(f"{len(todo)} cases to stage (skipping {len(store)} already in the store)\n", flush=True)
|
|
|
|
for n, rec in enumerate(todo, 1):
|
|
cid = rec["id"]
|
|
try:
|
|
entries = stage_case(rec, new_only=True)
|
|
except Exception as ex: # one bad case must not stop the run
|
|
progress[str(cid)] = {"error": f"{type(ex).__name__}: {str(ex)[:150]}"}
|
|
print(f"[{n}/{len(todo)}] case {cid} ERROR {type(ex).__name__}", flush=True)
|
|
continue
|
|
|
|
blocks, tally = [], {}
|
|
for e in entries:
|
|
tally[e["verdict"]] = tally.get(e["verdict"], 0) + 1
|
|
if e["verdict"] != "FETCHED":
|
|
blocks.append({k: e.get(k) for k in ("field", "verdict", "url", "source", "note")})
|
|
continue
|
|
path = STAGING / f"{cid:03d}" / f"{e['n']:02d}_" \
|
|
f"{e['host'].lower().replace('.', '-')}.txt"
|
|
if not path.exists():
|
|
cands = list((STAGING / f"{cid:03d}").glob(f"{e['n']:02d}_*.txt"))
|
|
if not cands:
|
|
continue
|
|
path = cands[0]
|
|
text = path.read_text(encoding="utf-8")
|
|
blocks.append({
|
|
"field": e["field"], "verdict": "UNTRIMMED", "url": e["url"],
|
|
"source": e["host"], "language": "unknown",
|
|
"text": text, "chars": len(text),
|
|
"sha256": hashlib.sha256(text.encode()).hexdigest(),
|
|
"staged_file": path.name,
|
|
"paragraphs_kept": f"ALL {len(text.split(chr(10) * 2))} paragraphs, untrimmed",
|
|
"method": "raw HTTP retrieval, deterministic extraction",
|
|
"fetched_at": e.get("fetched_at"), "http": e.get("http"),
|
|
"cut_note": "NOT TRIMMED. Full page extract; trim on read-through.",
|
|
})
|
|
|
|
store[str(cid)] = blocks
|
|
progress[str(cid)] = tally
|
|
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 {cid:>4} " +
|
|
" ".join(f"{k}={v}" for k, v in sorted(tally.items())), flush=True)
|
|
time.sleep(args.delay)
|
|
|
|
print("\ndone", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|