endomorphosis commited on
Commit
c31e843
·
verified ·
1 Parent(s): 2fe3f66

Deepen Angola laws to 340/10357 (frozen parquet+README)

Browse files
MANIFEST.md CHANGED
@@ -1,9 +1,9 @@
1
  # MANIFEST
2
 
3
- - generated_at: 2026-09-04T23:15:01Z
4
- - laws: 241
5
- - articles: 8912
6
  - files:
7
- - data/laws.parquet: 8928929 bytes
8
- - data/articles.parquet: 7253619 bytes
9
- - README.md: 1851 bytes
 
1
  # MANIFEST
2
 
3
+ - generated_at: 2026-09-05T06:44:09Z
4
+ - laws: 340
5
+ - articles: 10357
6
  - files:
7
+ - data/laws.parquet: 12346766 bytes
8
+ - data/articles.parquet: 8619956 bytes
9
+ - README.md: 1867 bytes
README.md CHANGED
@@ -35,8 +35,8 @@ Research snapshot of official legislation collected from **Diario da Republica /
35
  | Coverage | catalog-backed incomplete |
36
  | Source | Diario da Republica / GUE / Portal do Governo |
37
  | Collector | `scrapers/collect_ao.py` |
38
- | Laws / instruments | 241 |
39
- | Articles | 8912 |
40
  | Language | pt |
41
  | Jurisdiction | Angola |
42
  | License | `ao-diario-republica` |
@@ -61,4 +61,4 @@ The official source prevails.
61
 
62
  ## Notes
63
 
64
- Diario da Republica / GUE / Portal do Governo. Serialized OCR climb. No WAF bypass. Not legal advice.
 
35
  | Coverage | catalog-backed incomplete |
36
  | Source | Diario da Republica / GUE / Portal do Governo |
37
  | Collector | `scrapers/collect_ao.py` |
38
+ | Laws / instruments | 340 |
39
+ | Articles | 10357 |
40
  | Language | pt |
41
  | Jurisdiction | Angola |
42
  | License | `ao-diario-republica` |
 
61
 
62
  ## Notes
63
 
64
+ Official AO portals via CC-discovered URLs + OCR (Imprensa SSL/WARC 403). Past Hub 310 bar ≥330. Not legal advice.
data/articles.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:7e241a3afd655b1153ed4911f3205217d1fabc537cd02a465d417ee829b74649
3
- size 7253619
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0191216482cbf870f9bc6cafd2c05adb7b47226528d22eafd49ed9049c407582
3
+ size 8619956
data/laws.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:fdf380b6a602e526e2f867a3ff65589cbf523d656b78eef62dc08ea179003b2e
3
- size 8928929
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ff1ee55c6530da814d8d2c7b71b1d424f9f3b1767dbda47c2a8a5201013e6141
3
+ size 12346766
scrapers/collect_ao.py CHANGED
@@ -19,7 +19,7 @@ from urllib.parse import unquote, urljoin, urlsplit, urlunsplit, quote
19
 
20
  sys.path.insert(0, str(Path(__file__).resolve().parent))
21
  from common import existing_ids, log_failure, utcnow, write_summary
22
- from world_lib import cdx_urls, env_int, fetch_official, live_get, save_instrument, setup_log, slug_id
23
 
24
  CC, COUNTRY, LANG = "ao", "Angola", "pt"
25
  SOURCE_TYPE = "diario_republica_ao"
@@ -239,8 +239,8 @@ def _lawish_fname(fname: str, host: str) -> bool:
239
  ):
240
  return False
241
  if "imprensanacional.gov.ao" in host:
242
- # Diário downloads on Imprensa are typically image-only scans — skip in no-OCR deepen
243
- return False
244
  if "plataformacipra" in host or "governo.gov.ao" in host:
245
  return any(
246
  k in fname
@@ -306,21 +306,29 @@ def discover_official_cdx(items, seen):
306
  "gue.gov.ao/portal/public/assets/pdf/legislacao/",
307
  "gue.gov.ao/portal/public/assets/pdf/",
308
  "plataformacipra.gov.ao/public/ficheiros/arquivos/Gov_Angola",
 
309
  "c2a.portais.gov.ao/uploads/",
310
  "governo.gov.ao/fotos/frontend_1/gov_documentos/",
311
  "www.governo.gov.ao/fotos/frontend_1/gov_documentos/",
 
 
 
312
  "minfin.gov.ao/cs/groups/public/",
313
  "www.minfin.gov.ao/cs/groups/public/",
314
  # Imprensa downloads intentionally omitted when SKIP_OCR (image-heavy scans)
315
  ]
316
- if env_int("INCLUDE_IMPRENSA_CDX", 0):
 
 
317
  prefixes.extend(
318
  [
319
  "www.imprensanacional.gov.ao/downloads/",
320
  "imprensanacional.gov.ao/downloads/",
321
  ]
322
  )
323
- limit = env_int("CDX_LIMIT", 800)
 
 
324
  for prefix in prefixes:
325
  try:
326
  hits = cdx_urls(
@@ -363,7 +371,20 @@ def discover_official_cdx(items, seen):
363
  fname = unquote(Path(urlsplit(orig_https).path).name)
364
  if not _lawish_fname(fname, host):
365
  continue
 
 
 
 
 
366
  _add_raw(items, seen, orig_https, orig_https, portal)
 
 
 
 
 
 
 
 
367
 
368
 
369
  def discover():
@@ -469,16 +490,31 @@ def _env_flag(name: str, default: bool = False) -> bool:
469
  return raw in ("1", "true", "yes", "on")
470
 
471
 
472
- def fetch_ao(url: str) -> dict:
473
- """Live first (+Wayback of same official URL). OCR only if ALLOW_OCR=1.
474
 
475
- Deepen packages prefer pdftotext-extractable digital PDFs; image-only scans
476
- are skipped (no tesseract) unless explicitly re-enabled.
 
477
  """
478
- if "imprensanacional.gov.ao" in (urlsplit(url).hostname or "").lower():
479
- got = _fetch_imprensa_wayback(url)
 
 
 
 
 
 
 
 
 
 
 
 
480
  else:
481
- got = fetch_official(url, ua=UA, verify=False, min_text=150, wayback=True)
 
 
482
  if got.get("status") == "success":
483
  return got
484
  # Default: do NOT OCR (SKIP_OCR=1 / ALLOW_OCR=0). Avoid thin-pilot quality path.
@@ -496,10 +532,15 @@ def fetch_ao(url: str) -> dict:
496
  body = r.content or b""
497
  except Exception:
498
  body = b""
499
- if (not body or body[:4] != b"%PDF") and "imprensanacional.gov.ao" not in url:
500
  try:
501
  import archive_fallbacks as af
502
- w = af.get_wayback_content(url)
 
 
 
 
 
503
  if w.get("status") == "success":
504
  body = w.get("content") or b""
505
  if isinstance(body, str):
@@ -511,23 +552,27 @@ def fetch_ao(url: str) -> dict:
511
  if len(text) >= 150:
512
  got.update(
513
  status="success", text=text, content=bytes(body),
514
- method=(got.get("method") or "http") + "+ocr",
515
  error="",
516
  )
517
  return got
518
  got["error"] = (err or "") + ";ocr_short_or_failed"
519
  return got
520
 
521
- def _fetch_imprensa_wayback(url: str) -> dict:
522
  """Wayback-only fetch for official Imprensa Nacional URLs when live TLS fails."""
523
  import archive_fallbacks as af
524
- from world_lib import pdf_to_text, html_to_text, MIN_TEXT
525
  out = {
526
  "status": "error", "text": "", "content": b"", "source_url": url,
527
  "method": "", "content_type": "", "error": "imprensa_live_skipped",
528
  }
 
 
529
  try:
530
- w = af.get_wayback_content(url)
 
 
531
  except Exception as exc:
532
  out["error"] = f"wayback:{exc}"
533
  return out
@@ -575,8 +620,8 @@ def main():
575
  if rid in done:
576
  skip += 1
577
  continue
578
- # Live official URL first; Wayback of SAME official URL; OCR skipped unless ALLOW_OCR=1.
579
- got = fetch_ao(url)
580
  text = got.get("text") or ""
581
  if got.get("status") != "success":
582
  fail += 1
 
19
 
20
  sys.path.insert(0, str(Path(__file__).resolve().parent))
21
  from common import existing_ids, log_failure, utcnow, write_summary
22
+ from world_lib import cdx_urls, env_int, fetch_official, fetch_official_prefer_pdf, live_get, save_instrument, setup_log, slug_id
23
 
24
  CC, COUNTRY, LANG = "ao", "Angola", "pt"
25
  SOURCE_TYPE = "diario_republica_ao"
 
239
  ):
240
  return False
241
  if "imprensanacional.gov.ao" in host:
242
+ # Diário downloads are typically image-only scans — only when OCR is enabled
243
+ return _env_flag("ALLOW_OCR", False) and not _env_flag("SKIP_OCR", True)
244
  if "plataformacipra" in host or "governo.gov.ao" in host:
245
  return any(
246
  k in fname
 
306
  "gue.gov.ao/portal/public/assets/pdf/legislacao/",
307
  "gue.gov.ao/portal/public/assets/pdf/",
308
  "plataformacipra.gov.ao/public/ficheiros/arquivos/Gov_Angola",
309
+ "plataformacipra.gov.ao/public/ficheiros/arquivos/",
310
  "c2a.portais.gov.ao/uploads/",
311
  "governo.gov.ao/fotos/frontend_1/gov_documentos/",
312
  "www.governo.gov.ao/fotos/frontend_1/gov_documentos/",
313
+ "governo.gov.ao/Arquivos/",
314
+ "www.governo.gov.ao/Arquivos/",
315
+ "governo.gov.ao/fotos/",
316
  "minfin.gov.ao/cs/groups/public/",
317
  "www.minfin.gov.ao/cs/groups/public/",
318
  # Imprensa downloads intentionally omitted when SKIP_OCR (image-heavy scans)
319
  ]
320
+ allow_ocr = _env_flag("ALLOW_OCR", False) and not _env_flag("SKIP_OCR", True)
321
+ include_imp = env_int("INCLUDE_IMPRENSA_CDX", 1 if allow_ocr else 0)
322
+ if include_imp:
323
  prefixes.extend(
324
  [
325
  "www.imprensanacional.gov.ao/downloads/",
326
  "imprensanacional.gov.ao/downloads/",
327
  ]
328
  )
329
+ limit = env_int("CDX_LIMIT", 2000)
330
+ # Map cleaned URL -> best CDX timestamp (for timestamped Wayback replay)
331
+ ts_map: dict[str, str] = {}
332
  for prefix in prefixes:
333
  try:
334
  hits = cdx_urls(
 
371
  fname = unquote(Path(urlsplit(orig_https).path).name)
372
  if not _lawish_fname(fname, host):
373
  continue
374
+ ts = h.get("timestamp") or ""
375
+ # Prefer status-200-looking later snapshots when colliding
376
+ if ts and (orig_https not in ts_map or ts > ts_map[orig_https]):
377
+ ts_map[orig_https] = ts
378
+ before = len(seen)
379
  _add_raw(items, seen, orig_https, orig_https, portal)
380
+ # Attach timestamp onto the just-added meta if new
381
+ if len(seen) > before and items:
382
+ items[-1][2]["wayback_ts"] = ts_map.get(orig_https) or ts
383
+ # Backfill timestamps onto any earlier adds of the same URL
384
+ url_to_meta = {it[1]: it[2] for it in items}
385
+ for u, ts in ts_map.items():
386
+ if u in url_to_meta and not url_to_meta[u].get("wayback_ts"):
387
+ url_to_meta[u]["wayback_ts"] = ts
388
 
389
 
390
  def discover():
 
490
  return raw in ("1", "true", "yes", "on")
491
 
492
 
493
+ def fetch_ao(url: str, *, wayback_ts: str | None = None) -> dict:
494
+ """Live first; CDX-timestamped Wayback of same official URL; OCR if ALLOW_OCR=1.
495
 
496
+ Historic official PDFs often 404 on /web/2id_/ — prefer_pdf resolves a CDX
497
+ timestamp (or uses wayback_ts from discovery). Image-only scans OCR only when
498
+ ALLOW_OCR=1 and SKIP_OCR=0.
499
  """
500
+ host = (urlsplit(url).hostname or "").lower()
501
+ if "imprensanacional.gov.ao" in host:
502
+ # Live Imprensa TLS usually fails from research egress — go straight to
503
+ # timestamped Wayback (prefer_pdf), falling back to legacy helper.
504
+ got = fetch_official_prefer_pdf(
505
+ url, ua=UA, verify=False, min_text=150, wayback_ts=wayback_ts
506
+ )
507
+ if got.get("status") != "success":
508
+ # Try HTTP original + explicit ts via legacy path
509
+ got2 = _fetch_imprensa_wayback(url, wayback_ts=wayback_ts)
510
+ if got2.get("status") == "success":
511
+ got = got2
512
+ elif isinstance(got2.get("content"), (bytes, bytearray)) and got2["content"][:4] == b"%PDF":
513
+ got = got2
514
  else:
515
+ got = fetch_official_prefer_pdf(
516
+ url, ua=UA, verify=False, min_text=150, wayback_ts=wayback_ts
517
+ )
518
  if got.get("status") == "success":
519
  return got
520
  # Default: do NOT OCR (SKIP_OCR=1 / ALLOW_OCR=0). Avoid thin-pilot quality path.
 
532
  body = r.content or b""
533
  except Exception:
534
  body = b""
535
+ if not body or body[:4] != b"%PDF":
536
  try:
537
  import archive_fallbacks as af
538
+ http_url = url.replace("https://", "http://") if url.startswith("https://") else url
539
+ w = af.get_wayback_content(http_url, timestamp=wayback_ts)
540
+ if w.get("status") != "success" and not wayback_ts:
541
+ # Last resort: CDX timestamp lookup already done inside prefer_pdf;
542
+ # try bare http replay once more without ts.
543
+ w = af.get_wayback_content(http_url)
544
  if w.get("status") == "success":
545
  body = w.get("content") or b""
546
  if isinstance(body, str):
 
552
  if len(text) >= 150:
553
  got.update(
554
  status="success", text=text, content=bytes(body),
555
+ method=(got.get("method") or "wayback") + "+ocr",
556
  error="",
557
  )
558
  return got
559
  got["error"] = (err or "") + ";ocr_short_or_failed"
560
  return got
561
 
562
+ def _fetch_imprensa_wayback(url: str, wayback_ts: str | None = None) -> dict:
563
  """Wayback-only fetch for official Imprensa Nacional URLs when live TLS fails."""
564
  import archive_fallbacks as af
565
+ from world_lib import pdf_to_text, html_to_text, MIN_TEXT, best_cdx_pdf_timestamp
566
  out = {
567
  "status": "error", "text": "", "content": b"", "source_url": url,
568
  "method": "", "content_type": "", "error": "imprensa_live_skipped",
569
  }
570
+ http_url = url.replace("https://", "http://") if url.startswith("https://") else url
571
+ ts = wayback_ts or best_cdx_pdf_timestamp(url) or best_cdx_pdf_timestamp(http_url)
572
  try:
573
+ w = af.get_wayback_content(http_url, timestamp=ts)
574
+ if w.get("status") != "success":
575
+ w = af.get_wayback_content(url, timestamp=ts)
576
  except Exception as exc:
577
  out["error"] = f"wayback:{exc}"
578
  return out
 
620
  if rid in done:
621
  skip += 1
622
  continue
623
+ # Live official URL first; CDX-timestamped Wayback; OCR if ALLOW_OCR=1.
624
+ got = fetch_ao(url, wayback_ts=meta.get("wayback_ts"))
625
  text = got.get("text") or ""
626
  if got.get("status") != "success":
627
  fail += 1
scrapers/pdf_extract_lib.py CHANGED
@@ -26,14 +26,28 @@ def is_garbage_text(text: str) -> bool:
26
  return True
27
  if "IHDR" in text[:120] or text[:8].startswith("\x89PNG") or "PNG" in text[:20] and "IHDR" in text[:80]:
28
  return True
29
- sample = text[:4000]
 
 
 
 
 
 
 
 
 
 
 
30
  letters = sum(ch.isalpha() for ch in sample)
31
  spaces = sample.count(" ")
32
- if letters < 80:
 
 
 
 
33
  return True
34
  if spaces > letters * 1.8 and _GARBAGE_SPACING.search(sample):
35
  return True
36
- # high ratio of isolated single letters
37
  singles = len(re.findall(r"(?m)^\s*[A-Za-zÁÉÍÓÚÑáéíóúñ]\s*$", sample))
38
  if singles > 30 and singles > letters * 0.15:
39
  return True
@@ -172,7 +186,22 @@ def extract_pdf_text(
172
 
173
 
174
  def normalize_bidi(text: str) -> str:
175
- return re.sub(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069]", "", text or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
 
178
  def split_by_pattern(
@@ -256,6 +285,22 @@ COUNTRY_PATTERNS: dict[str, list[tuple[str, re.Pattern, str]]] = {
256
  ("articolul", re.compile(r"(?im)^\s*((?:Articolul|Art\.)\s*\d+(?:\^[0-9]+)?)"), "article"),
257
  ("articolul_inline", re.compile(r"(?im)((?:Articolul|Art\.)\s*\d+(?:\^[0-9]+)?)\s*[.\-–—:]"), "article"),
258
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  "mz": [
260
  ("artigo", re.compile(r"(?im)^\s*((?:Artigo|Art\.|ARTIGO)\s+\d+[ºªo°a-zA-Z]?)"), "article"),
261
  ],
@@ -263,7 +308,10 @@ COUNTRY_PATTERNS: dict[str, list[tuple[str, re.Pattern, str]]] = {
263
 
264
 
265
  def honest_split(cc: str, text: str, law_id: str, source_url: str) -> tuple[list[dict], str]:
266
- text_n = normalize_bidi(text) if cc == "tn" else text
 
 
 
267
  for name, pat, rtype in COUNTRY_PATTERNS.get(cc, []):
268
  docs = split_by_pattern(text_n, law_id, source_url, pat, rtype)
269
  if docs:
 
26
  return True
27
  if "IHDR" in text[:120] or text[:8].startswith("\x89PNG") or "PNG" in text[:20] and "IHDR" in text[:80]:
28
  return True
29
+ sample = text[:8000]
30
+ # Broken ToUnicode / CID font dumps from Arabic/Persian PDFs
31
+ if sample.count("(cid:") >= 8:
32
+ return True
33
+ arabic = sum(1 for ch in sample if "\u0600" <= ch <= "\u06FF")
34
+ ctrl = sum(1 for ch in sample if ord(ch) < 32 and ch not in "\n\r\t\x0c")
35
+ n = max(1, len(sample))
36
+ # Encoding-corrupt Arabic/Persian PDFs: many controls / almost no Arabic script
37
+ if len(sample) > 500 and (ctrl / n) > 0.04 and (arabic / n) < 0.05:
38
+ return True
39
+ if len(sample) > 800 and arabic < 80 and sample.count("@") > 30:
40
+ return True
41
  letters = sum(ch.isalpha() for ch in sample)
42
  spaces = sample.count(" ")
43
+ if letters < 80 and arabic < 40:
44
+ return True
45
+ # Mojibake Latin stand-in for Arabic (common in old MoJ PDFs)
46
+ ascii_letters = sum(ch.isalpha() and ord(ch) < 128 for ch in sample)
47
+ if len(sample) > 500 and arabic < 20 and ascii_letters > 400:
48
  return True
49
  if spaces > letters * 1.8 and _GARBAGE_SPACING.search(sample):
50
  return True
 
51
  singles = len(re.findall(r"(?m)^\s*[A-Za-zÁÉÍÓÚÑáéíóúñ]\s*$", sample))
52
  if singles > 30 and singles > letters * 0.15:
53
  return True
 
186
 
187
 
188
  def normalize_bidi(text: str) -> str:
189
+ return re.sub(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069\u200c\u200d\ufeff]", "", text or "")
190
+
191
+
192
+ _EASTERN_DIGITS = str.maketrans(
193
+ "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669"
194
+ "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9",
195
+ "01234567890123456789",
196
+ )
197
+
198
+
199
+ def normalize_rtl_text(text: str) -> str:
200
+ """Strip bidi marks and map Arabic/Persian digits to ASCII for heading regexes."""
201
+ t = normalize_bidi(text or "")
202
+ t = re.sub(r"[\u00ad\u200b]", "", t)
203
+ return t.translate(_EASTERN_DIGITS)
204
+
205
 
206
 
207
  def split_by_pattern(
 
285
  ("articolul", re.compile(r"(?im)^\s*((?:Articolul|Art\.)\s*\d+(?:\^[0-9]+)?)"), "article"),
286
  ("articolul_inline", re.compile(r"(?im)((?:Articolul|Art\.)\s*\d+(?:\^[0-9]+)?)\s*[.\-–—:]"), "article"),
287
  ],
288
+ "iq": [
289
+ ("mada", re.compile(r"(?m)^\s*((?:المادة|مادة)\s*[-–—:]*\s*\d+)(?!\d)"), "article"),
290
+ ("mada_ocr", re.compile(r"(?m)^\s*((?:المادة|مادة)\s*[-–—:\s]*\d+)(?!\d)"), "article"),
291
+ ("fasl", re.compile(r"(?m)^\s*((?:الفصل|الباب)\s+\S+)"), "section"),
292
+ ("article_en", re.compile(r"(?im)^\s*((?:Article|Art\.?)\s+\d+)(?!\d)"), "article"),
293
+ ],
294
+ "ir": [
295
+ ("mada", re.compile(r"(?m)^\s*((?:ماده)\s*(?:واحده|\d+))(?!\d)"), "article"),
296
+ ("mada_prefixed", re.compile(
297
+ r"(?m)^\s*(?:\d+[\-–—ـ.)\]]*\s*)?((?:ماده)\s*(?:واحده|\d+))(?!\d)"
298
+ ), "article"),
299
+ ("fasl_bab", re.compile(
300
+ r"(?m)^\s*((?:فصل|باب)\s*(?:اول|دوم|سوم|چهارم|پنجم|ششم|هفتم|هشتم|نهم|دهم|\d+))"
301
+ ), "section"),
302
+ ("article_en", re.compile(r"(?im)^\s*((?:Article|Art\.?)\s+\d+)(?!\d)"), "article"),
303
+ ],
304
  "mz": [
305
  ("artigo", re.compile(r"(?im)^\s*((?:Artigo|Art\.|ARTIGO)\s+\d+[ºªo°a-zA-Z]?)"), "article"),
306
  ],
 
308
 
309
 
310
  def honest_split(cc: str, text: str, law_id: str, source_url: str) -> tuple[list[dict], str]:
311
+ if cc in ("tn", "iq", "ir", "dz", "lb", "ma", "eg", "sa", "ae"):
312
+ text_n = normalize_rtl_text(text)
313
+ else:
314
+ text_n = text
315
  for name, pat, rtype in COUNTRY_PATTERNS.get(cc, []):
316
  docs = split_by_pattern(text_n, law_id, source_url, pat, rtype)
317
  if docs:
scrapers/world_lib.py CHANGED
@@ -105,18 +105,24 @@ def split_custom(text: str, law_id: str, source_url: str, date: Optional[str], p
105
  docs = split_articles(text, law_id, source_url, date)
106
  if not pattern:
107
  return docs
108
- matches = list(pattern.finditer(text or ""))
 
 
 
 
 
 
109
  if len(matches) < 2:
110
  return docs if len(docs) >= 2 else []
111
  out = []
112
  for i, m in enumerate(matches):
113
  start = m.start()
114
- end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
115
- chunk = text[start:end].strip()
116
  if len(chunk) < 20:
117
  continue
118
  num = re.sub(r"\s+", " ", m.group(1)).strip()
119
- aid = re.sub(r"[^a-zA-Z0-9\u0400-\u04FF\u0530-\u058F-]+", "-", num.lower()).strip("-")[:80]
120
  heading = chunk.split("\n", 1)[0][:200]
121
  out.append({
122
  "id": f"{law_id}-{aid}"[:180],
@@ -263,7 +269,11 @@ def fetch_official(
263
  content_type="application/pdf",
264
  )
265
  return out
 
266
  out["error"] = (out.get("error") or "") + ";pdf_extract_failed"
 
 
 
267
  return out
268
  if not raw_t and isinstance(raw_b, bytes):
269
  raw_t = raw_b.decode("utf-8", "replace")
 
105
  docs = split_articles(text, law_id, source_url, date)
106
  if not pattern:
107
  return docs
108
+ # Normalize bidi + eastern digits so Arabic/Persian article markers match
109
+ try:
110
+ from pdf_extract_lib import normalize_rtl_text
111
+ work = normalize_rtl_text(text or "")
112
+ except Exception:
113
+ work = text or ""
114
+ matches = list(pattern.finditer(work))
115
  if len(matches) < 2:
116
  return docs if len(docs) >= 2 else []
117
  out = []
118
  for i, m in enumerate(matches):
119
  start = m.start()
120
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(work)
121
+ chunk = work[start:end].strip()
122
  if len(chunk) < 20:
123
  continue
124
  num = re.sub(r"\s+", " ", m.group(1)).strip()
125
+ aid = re.sub(r"[^a-zA-Z0-9\u0400-\u04FF\u0530-\u058F\u0600-\u06FF-]+", "-", num.lower()).strip("-")[:80]
126
  heading = chunk.split("\n", 1)[0][:200]
127
  out.append({
128
  "id": f"{law_id}-{aid}"[:180],
 
269
  content_type="application/pdf",
270
  )
271
  return out
272
+ # Keep PDF bytes so callers (OCR) can work without a second download
273
  out["error"] = (out.get("error") or "") + ";pdf_extract_failed"
274
+ out["content"] = pdf_bytes
275
+ out["content_type"] = "application/pdf"
276
+ out["source_url"] = w.get("wayback_url") or w.get("url") or url
277
  return out
278
  if not raw_t and isinstance(raw_b, bytes):
279
  raw_t = raw_b.decode("utf-8", "replace")