"""Ingest CBSA D-Memoranda (HTML) into structured, section-level chunks.
D-Memoranda are CBSA's administrative guidance on how it applies the Customs Act
and related law. They are persuasive, not binding -- every chunk is tagged
doc_type="memorandum" so the rest of CanLex can keep them distinct from statute.
"""
import io
import json
import re
import sys
import time
import urllib.request
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from pypdf import PdfReader
from .config import RAW_DIR, PROCESSED_DIR
INDEX_URL = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
DMEMO_DIR = RAW_DIR / "dmemos"
OUT_FILE = PROCESSED_DIR / "dmemos.json"
#
headings that are page boilerplate rather than memo content.
_SKIP_HEADINGS = {"contact us", "related links"}
_MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-eng\.html")
_URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-eng\.html")
def _norm(text):
return re.sub(r"\s+", " ", text or "").strip()
def _fetch(url, dest, force=False):
if dest.exists() and not force:
return dest.read_bytes()
req = urllib.request.Request(url, headers={"User-Agent": "CanLex/0.1"})
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
time.sleep(0.5) # be polite to the CBSA server
return data
def memo_urls(force=False):
"""All individual D-memo URLs listed on the CBSA index page."""
html = _fetch(INDEX_URL, DMEMO_DIR / "_index.html", force=force)
soup = BeautifulSoup(html, "html.parser")
urls, seen = [], set()
for a in soup.find_all("a", href=True):
if _MEMO_HREF.search(a["href"]):
full = urljoin(INDEX_URL, a["href"])
if full not in seen:
seen.add(full)
urls.append(full)
return urls
def _render_section(h2):
"""Readable text from an up to the next (sections already unwrapped)."""
lines = []
for sib in h2.find_next_siblings():
if sib.name == "h2" or sib.get("id") == "wb-dtmd":
break
if sib.name in ("ul", "ol"):
for li in sib.find_all("li", recursive=False):
item = _norm(li.get_text(" ", strip=True))
if item:
lines.append(f"- {item}")
else:
text = _norm(sib.get_text(" ", strip=True))
if text:
lines.append(text)
return "\n".join(lines)
def parse_memo(html, url):
"""Parse one D-memo HTML page into one chunk per content section."""
soup = BeautifulSoup(html, "html.parser")
main = soup.find("main")
if main is None:
return []
for section in main.find_all("section"):
section.unwrap() # flatten so each and its content become siblings
match = _URL_NUMBER.search(url)
number = match.group(1).upper() if match else url
h1 = main.find("h1")
topic = ""
if h1:
# Pages vary: most carry the memo title in , others as plain
# "Memorandum DNN-N-N: Title" h1 text. Use the if present, else
# the h1 text, and strip any leading memo-number prefix either way.
small = h1.find("small")
raw = (small.get_text(" ", strip=True) if small
else h1.get_text(" ", strip=True))
topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "",
_norm(raw), flags=re.I)
dm = main.find("time", attrs={"property": "dateModified"})
date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
chunks = []
for h2 in main.find_all("h2"):
heading = _norm(h2.get_text(" ", strip=True))
if not heading or heading.lower() in _SKIP_HEADINGS:
continue
body = _render_section(h2)
if not body:
continue
chunks.append({
"id": f"dmemo-{number}-{len(chunks) + 1}",
"doc_type": "memorandum",
"act_code": "D-Memo",
"act_short": "D-Memo",
"act_name": "CBSA D-Memoranda",
"section": number,
"marginal_note": heading,
"part": topic,
"division": "",
"heading": "",
"text": body,
"history": "",
"last_amended": date,
"current_to": date,
"citation": f"Memorandum {number}",
"source_url": url,
})
return chunks
def _pdf_clean(text):
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n[ \t]+", "\n", text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def _pdf_text(pdf_bytes):
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
return _pdf_clean("\n".join((p.extract_text() or "") for p in reader.pages))
except Exception:
return ""
def _split(text, target=3000):
"""Split long PDF text into ~target-sized pieces at line boundaries."""
if len(text) <= target:
return [text]
parts, buf, size = [], [], 0
for line in text.split("\n"):
if size + len(line) > target and buf:
parts.append("\n".join(buf))
buf, size = [], 0
buf.append(line)
size += len(line) + 1
if buf:
parts.append("\n".join(buf))
return parts
def parse_pdf_memo(html, url):
"""Fallback for memos whose HTML page is only a stub linking to a PDF."""
soup = BeautifulSoup(html, "html.parser")
main = soup.find("main")
if main is None:
return []
pdf_href = next((a["href"] for a in main.find_all("a", href=True)
if a["href"].lower().endswith(".pdf")), None)
if not pdf_href:
return []
pdf_url = urljoin(url, pdf_href)
match = _URL_NUMBER.search(url)
number = match.group(1).upper() if match else url
h1 = main.find("h1")
topic = _norm(h1.get_text(" ", strip=True)) if h1 else ""
topic = re.sub(r"^Memorandum\s+D[\w-]+\s*[:–-]\s*", "", topic, flags=re.I)
dm = main.find("time", attrs={"property": "dateModified"})
date = _norm(dm.get("datetime") or dm.get_text()) if dm else ""
pdf_bytes = _fetch(pdf_url, DMEMO_DIR / "pdf" / pdf_url.rsplit("/", 1)[-1])
text = _pdf_text(pdf_bytes)
if not text:
return []
parts = _split(text)
chunks = []
for i, part in enumerate(parts, 1):
label = topic or number
if len(parts) > 1:
label = f"{label} (part {i})"
chunks.append({
"id": f"dmemo-{number}-pdf{i}",
"doc_type": "memorandum",
"act_code": "D-Memo",
"act_short": "D-Memo",
"act_name": "CBSA D-Memoranda",
"section": number,
"marginal_note": label,
"part": topic,
"division": "",
"heading": "",
"text": part,
"history": "",
"last_amended": date,
"current_to": date,
"citation": f"Memorandum {number}",
"source_url": url,
})
return chunks
def ingest(force=False, limit=None):
urls = memo_urls(force=force)
if limit:
urls = urls[:limit]
print(f"Ingesting {len(urls)} D-Memoranda...")
all_chunks, failures = [], []
for i, url in enumerate(urls, 1):
try:
html = _fetch(url, DMEMO_DIR / url.rsplit("/", 1)[-1], force=force)
chunks = parse_memo(html, url) or parse_pdf_memo(html, url)
if chunks:
all_chunks.extend(chunks)
else:
failures.append((url, "no content parsed"))
except Exception as exc:
failures.append((url, f"{type(exc).__name__}: {exc}"))
if i % 50 == 0:
print(f" {i}/{len(urls)} ...")
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
OUT_FILE.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
f"-> {OUT_FILE.name}")
if failures:
print(f" {len(failures)} memos with no content / errors:")
for url, why in failures[:15]:
print(f" - {url.rsplit('/', 1)[-1]}: {why}")
def main():
force = "--force" in sys.argv
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
if a.startswith("--limit=")), None)
ingest(force=force, limit=limit)
if __name__ == "__main__":
main()