Deepen Angola laws to 340/10357 (frozen parquet+README)
Browse files- MANIFEST.md +6 -6
- README.md +3 -3
- data/articles.parquet +2 -2
- data/laws.parquet +2 -2
- scrapers/collect_ao.py +65 -20
- scrapers/pdf_extract_lib.py +53 -5
- scrapers/world_lib.py +14 -4
MANIFEST.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# MANIFEST
|
| 2 |
|
| 3 |
-
- generated_at: 2026-09-
|
| 4 |
-
- laws:
|
| 5 |
-
- articles:
|
| 6 |
- files:
|
| 7 |
-
- data/laws.parquet:
|
| 8 |
-
- data/articles.parquet:
|
| 9 |
-
- README.md:
|
|
|
|
| 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 |
|
| 39 |
-
| Articles |
|
| 40 |
| Language | pt |
|
| 41 |
| Jurisdiction | Angola |
|
| 42 |
| License | `ao-diario-republica` |
|
|
@@ -61,4 +61,4 @@ The official source prevails.
|
|
| 61 |
|
| 62 |
## Notes
|
| 63 |
|
| 64 |
-
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 317 |
prefixes.extend(
|
| 318 |
[
|
| 319 |
"www.imprensanacional.gov.ao/downloads/",
|
| 320 |
"imprensanacional.gov.ao/downloads/",
|
| 321 |
]
|
| 322 |
)
|
| 323 |
-
limit = env_int("CDX_LIMIT",
|
|
|
|
|
|
|
| 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
|
| 474 |
|
| 475 |
-
|
| 476 |
-
|
|
|
|
| 477 |
"""
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
else:
|
| 481 |
-
got =
|
|
|
|
|
|
|
| 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
|
| 500 |
try:
|
| 501 |
import archive_fallbacks as af
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 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(
|
|
|
|
|
|
|
| 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;
|
| 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[:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 115 |
-
chunk =
|
| 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")
|