""" Propose where a case's article starts and stops in each staged file, using the case record itself as the anchor. The earlier version took the longest contiguous run of body-like paragraphs. That fails on two shapes seen in real pages: - a page carrying several stories, where the longest run is a DIFFERENT article (A Gazeta on case 456 opened on an unrelated arrest for threatening a relative) - an article whose key paragraph sits after a short sentence, so the run stops early (A Tribuna on case 456 truncated three paragraphs before the only line it was added for) So: score every paragraph by how many case-specific terms it contains, drawn from the database record. Seed on the best-scoring paragraph and grow outward through body-like text, stopping at teaser runs. A page whose paragraphs never mention the case at all is flagged OFF-CASE, which is the deterministic half of the Phuket trap. Emits exact anchors so apply_sources.py can guard against paragraph drift without anyone retyping them. """ import io import json import re import sys import unicodedata from pathlib import Path BASE = Path(__file__).resolve().parent STAGING = BASE / "staging" JSONF = BASE.parent / "attacks-export-Gart-website.json" PAYWALL = re.compile( r"(il vous reste|% à lire|reste \d+% |assinante|already a subscriber|" r"subscribe to (read|continue)|log ?in to (read|continue)|para continuar lendo|" r"conteúdo exclusivo|abonnez-vous|déjà abonné)", re.I) JUNK = re.compile( r"(cookie|adblock|bloqueador de an|javascript n|termos de uso|terms of use|" r"all rights reserved|todos os direitos|aviso legal|disclaimer|©|newsletter|" r"receba as principais|participe da nossa|join the groups|login page|please log in|" r"follow us|leia também|à lire aussi|is a writer|escritora y editora|" r"contact us|about us|sabe de alguma coisa)", re.I) STOP = {"the", "and", "for", "was", "were", "with", "his", "her", "man", "men", "woman", "unidentified", "undisclosed", "unknown", "multiple", "victim", "repeat", "family", "crypto", "cryptocurrency", "bitcoin", "old", "year", "y/o", "de", "da", "do", "la", "le", "el", "en", "un", "una", "and"} MONTHS = { 1: ["january", "janeiro", "janvier", "enero"], 2: ["february", "fevereiro", "février", "febrero"], 3: ["march", "março", "mars", "marzo"], 4: ["april", "abril", "avril"], 5: ["may", "maio", "mai", "mayo"], 6: ["june", "junho", "juin", "junio"], 7: ["july", "julho", "juillet", "julio"], 8: ["august", "agosto", "août"], 9: ["september", "setembro", "septembre", "septiembre"], 10: ["october", "outubro", "octobre", "octubre"], 11: ["november", "novembro", "novembre", "noviembre"], 12: ["december", "dezembro", "décembre", "diciembre"], } def fold(s): s = unicodedata.normalize("NFKD", str(s or "")) return "".join(c for c in s if not unicodedata.combining(c)).lower() def case_terms(rec): """Distinctive strings that should appear in an article about THIS case.""" terms = set() for field in ("victim", "location", "country"): for tok in re.split(r"[^A-Za-z0-9]+", fold(rec.get(field))): if len(tok) > 3 and tok not in STOP: terms.add(tok) if rec.get("month") in MONTHS: terms.update(MONTHS[rec["month"]]) m = re.search(r"\b(\d{1,2})\b", str(rec.get("date") or "")) if m: terms.add(m.group(1)) for field in ("money_wanted", "description"): for num in re.findall(r"\d[\d.,]{2,}", str(rec.get(field) or "")): terms.add(fold(num)) # Distinctive nouns from the description carry most of the signal: a car model, a # street, a company. Without them a bare "11" matches half of any news page. for field in ("description", "notes"): for tok in re.findall(r"\b[A-Z][A-Za-z0-9]{4,}\b", str(rec.get(field) or "")): t = fold(tok) if t not in STOP: terms.add(t) return {t for t in terms if t} def score(para, terms): f = fold(para) return sum(1 for t in terms if t in f) def bodyish(p): p = p.strip() return len(p) > 110 and re.search(r"[.!?”»\"]$", p) and not JUNK.search(p) def propose(paras, terms, bridge=2): """Runs decide the boundaries; case terms decide which run. Boundaries come from contiguous body text, tolerating up to `bridge` short lines so a one-sentence paragraph cannot truncate an article. Selection then picks the run that actually talks about this case, which is what a multi-story page needs. """ scores = [score(p, terms) for p in paras] total_hits = sum(scores) runs, cur, gap = [], [], 0 for i, p in enumerate(paras): if bodyish(p): cur.append(i) gap = 0 elif cur and len(p.strip()) <= 200 and not JUNK.search(p) and gap < bridge: cur.append(i) gap += 1 else: if cur: runs.append(cur) cur, gap = [], 0 if cur: runs.append(cur) if not runs: return None, None, total_hits def run_score(r): # Distinct terms first: a long unrelated run accumulates raw hits just by being # long, which is how A Gazeta's story about a woman threatening her relative beat # the actual kidnapping. Density breaks ties; length only decides after that. text = fold(" ".join(paras[i] for i in r)) distinct = sum(1 for t in terms if t in text) chars = sum(len(paras[i]) for i in r) or 1 return (distinct, sum(scores[i] for i in r) / chars, chars) best = max(runs, key=run_score) start, end = best[0], best[-1] # Trim bridged non-body lines off either end. while start < end and not bodyish(paras[start]): start += 1 while end > start and not bodyish(paras[end]): end -= 1 return start, end, total_hits def main(): sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") records = {r["id"]: r for r in json.loads(JSONF.read_text(encoding="utf-8"))} out = {} for case in sys.argv[1:]: cid = int(case) rec = records[cid] terms = case_terms(rec) print(f"\n### case {cid} terms: {' '.join(sorted(terms))[:110]}") for f in sorted((STAGING / f"{cid:03d}").glob("*.txt")): paras = f.read_text(encoding="utf-8").split("\n\n") start, end, hits = propose(paras, terms) flag = "" if hits == 0: flag = " !! OFF-CASE: no case term appears anywhere on this page" elif hits < 3: flag = f" !! WEAK: only {hits} case-term hits on the whole page" pay = [p.strip()[:60] for p in paras if PAYWALL.search(p)] title = paras[0].strip() if paras else "" use_title = start not in (0, None) and 20 < len(title) < 200 and not JUNK.search(title) key = f"{cid}:{f.name}" out[key] = {"start": start, "end": end, "title": 0 if use_title else None, "paras": len(paras), "case_term_hits": hits, "starts_with": paras[start][:45] if start is not None else None, "ends_with": paras[end][-55:] if end is not None else None, "paywall": pay} kept = len("\n\n".join(paras[start:end + 1])) if start is not None else 0 print(f" {f.name:34} {len(paras):>4} paras -> " f"{'[0] + ' if use_title else ''}{start}..{end} {kept:>6,} chars hits={hits}{flag}") if pay: print(f" PAYWALL: {pay[0]}") if start is not None: print(f" starts: {paras[start][:88]}") print(f" ends : {paras[end][-88:]}") (STAGING / "proposed-cuts.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\nwrote {len(out)} proposals with anchors -> staging/proposed-cuts.json") if __name__ == "__main__": main()