"""Materialize DBLP TOC JSON files from the local DBLP XML dump. The live DBLP TOC API is useful for small updates, but it is rate-limited and can become unstable for very large venues. This module builds the same JSON shape expected by the consolidator from a local ``dblp.xml`` or ``dblp.xml.gz`` snapshot, so corpus refreshes can run offline and deterministically. """ from __future__ import annotations import csv import json import re import xml.etree.ElementTree as ET from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from .bibtex_dump import _EntitySubstitutingStream, _load_entity_table, _open_dump from .models import Configuration, DownloadLogEntry, DownloadStatus from .venue_config import VenueStrategyRegistry _DBLP_PREFIX = "https://dblp.org/" _CONF_PAGE_RE = re.compile(r"^db/conf/(?P[^/]+)/(?P[^/]+)\.html$") _YEAR_RE = re.compile(r"(?P\d{4}(?:-\d+)?)") @dataclass class DumpTarget: event: str year: int file_path: Path page_paths: set[str] crossrefs: set[str] hits: list[dict] = field(default_factory=list) class DblpDumpMaterializer: """Generate per-event/year DBLP JSON files from a local XML dump.""" def __init__( self, config: Configuration, json_dir: Path, log_dir: Path, dump_path: Path, ) -> None: self.config = config self.json_dir = Path(json_dir) self.log_dir = Path(log_dir) self.dump_path = Path(dump_path) self.venue_registry = VenueStrategyRegistry() def materialize( self, events: list[str] | None = None, years: list[int] | None = None, overwrite: bool = False, ) -> list[DownloadLogEntry]: """Materialize JSON files for the selected events and years.""" if not self.dump_path.exists(): raise FileNotFoundError(f"DBLP dump not found at {self.dump_path}") self.json_dir.mkdir(parents=True, exist_ok=True) self.log_dir.mkdir(parents=True, exist_ok=True) selected_events = events or list(self.config.events) selected_years = years or self.config.effective_years() targets, initial_log = self._build_targets(selected_events, selected_years, overwrite) if targets: self._scan_dump(targets) log_entries = [*initial_log] timestamp = datetime.now() for target in targets: log_entries.append(self._write_target(target, timestamp)) self._save_log(log_entries) return log_entries def _build_targets( self, events: list[str], years: list[int], overwrite: bool, ) -> tuple[list[DumpTarget], list[DownloadLogEntry]]: targets: list[DumpTarget] = [] log_entries: list[DownloadLogEntry] = [] timestamp = datetime.now() for event in events: for year in years: file_path = self.json_dir / f"data_{event}{year}.json" if file_path.exists() and not overwrite and _json_has_hits(file_path): log_entries.append( DownloadLogEntry( event=event, year=year, file_name=str(file_path), url="local-dump", status=DownloadStatus.VALID, message="Valid file exists", timestamp=timestamp, ) ) continue page_paths = self._page_paths_for(event, year) if not page_paths: log_entries.append( DownloadLogEntry( event=event, year=year, file_name=str(file_path), url="local-dump", status=DownloadStatus.SKIPPED, message="No DBLP paths configured", timestamp=timestamp, ) ) continue targets.append( DumpTarget( event=event, year=year, file_path=file_path, page_paths=page_paths, crossrefs={_crossref_from_page_path(path) for path in page_paths}, ) ) return targets, log_entries def _page_paths_for(self, event: str, year: int) -> set[str]: urls = self.venue_registry.get_strategy(event).get_urls(event, year, self.config) if event == "esorics": urls.extend( f"https://dblp.org/db/conf/esorics/esorics{year}-{part}.html" for part in range(1, 5) ) elif event == "aisec": urls.append(f"https://dblp.org/db/conf/ccs/aisec{year}.html") elif event == "hotmobile": urls.append(f"https://dblp.org/db/conf/wmcsa/hotmobile{year}.html") elif event == "kdd": urls.extend( f"https://dblp.org/db/conf/kdd/kdd{year}-{part}.html" for part in range(1, 5) ) elif event in {"acl", "emnlp", "naacl"}: urls.extend( f"https://dblp.org/db/conf/{event}/{event}{year}-{part}.html" for part in range(1, 8) ) urls.extend( f"https://dblp.org/db/conf/{event}/{event}{year}{suffix}.html" for suffix in ("f", "s", "d", "i") ) return { url.removeprefix(_DBLP_PREFIX) for url in urls if url.startswith(_DBLP_PREFIX) and url.endswith(".html") } def _scan_dump(self, targets: list[DumpTarget]) -> None: page_index: dict[str, list[DumpTarget]] = {} crossref_index: dict[str, list[DumpTarget]] = {} for target in targets: for path in target.page_paths: page_index.setdefault(path, []).append(target) for crossref in target.crossrefs: crossref_index.setdefault(crossref, []).append(target) dtd_path = self.dump_path.parent / "dblp.dtd" entity_table = _load_entity_table(dtd_path) parser = ET.XMLPullParser(events=("end",)) with _open_dump(self.dump_path) as raw: stream = _EntitySubstitutingStream(raw, entity_table) while True: chunk = stream.read(1 << 16) if not chunk: parser.close() break parser.feed(chunk) for _, elem in parser.read_events(): if elem.tag not in {"article", "inproceedings", "proceedings"}: continue matched_targets = self._targets_for_entry(elem, page_index, crossref_index) if matched_targets: hit = _entry_to_hit(elem) for target in matched_targets: target.hits.append(hit) elem.clear() @staticmethod def _targets_for_entry( elem: ET.Element, page_index: dict[str, list[DumpTarget]], crossref_index: dict[str, list[DumpTarget]], ) -> list[DumpTarget]: matched: dict[tuple[str, int], DumpTarget] = {} for child in elem: if child.tag == "url" and child.text: page_path = child.text.split("#", 1)[0] for target in page_index.get(page_path, []): matched[(target.event, target.year)] = target elif child.tag == "crossref" and child.text: for target in crossref_index.get(child.text, []): matched[(target.event, target.year)] = target return list(matched.values()) def _write_target(self, target: DumpTarget, timestamp: datetime) -> DownloadLogEntry: target.hits.sort(key=lambda hit: str(hit.get("info", {}).get("key", ""))) payload = { "result": { "hits": { "@total": str(len(target.hits)), "@computed": str(len(target.hits)), "@sent": str(len(target.hits)), "@first": "0", "hit": target.hits, } } } if not target.hits: return DownloadLogEntry( event=target.event, year=target.year, file_name=str(target.file_path), url="local-dump", status=DownloadStatus.FAILED, message="No DBLP records found in local dump", timestamp=timestamp, ) target.file_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") return DownloadLogEntry( event=target.event, year=target.year, file_name=str(target.file_path), url="local-dump", http_code=200, status=DownloadStatus.DOWNLOADED, message=f"Materialized {len(target.hits)} DBLP records from local dump", timestamp=timestamp, ) def _save_log(self, entries: list[DownloadLogEntry]) -> None: log_file = self.log_dir / "download_log.csv" fieldnames = ["Event", "Year", "File", "URL", "HTTP_Code", "Status", "Message", "Timestamp"] with log_file.open("w", newline="", encoding="utf-8") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=fieldnames) writer.writeheader() for entry in entries: writer.writerow( { "Event": entry.event, "Year": entry.year, "File": entry.file_name, "URL": entry.url, "HTTP_Code": entry.http_code or "", "Status": entry.status.value, "Message": entry.message or "", "Timestamp": entry.timestamp.isoformat(), } ) def _json_has_hits(file_path: Path) -> bool: data = json.loads(file_path.read_text(encoding="utf-8")) hits = data.get("result", {}).get("hits", {}).get("hit", []) return bool(hits) def _crossref_from_page_path(page_path: str) -> str: match = _CONF_PAGE_RE.match(page_path) if not match: return "" year_match = _YEAR_RE.search(match.group("slug")) if not year_match: return "" return f"conf/{match.group('stream')}/{year_match.group('year')}" def _entry_to_hit(elem: ET.Element) -> dict: key = elem.get("key") or "" info: dict[str, object] = { "authors": {"author": []}, "type": _api_type_for_tag(elem.tag), "key": key, "url": f"https://dblp.org/rec/{key}" if key else None, } ee_values: list[str] = [] for child in elem: text = (child.text or "").strip() if not text: continue if child.tag == "author" or child.tag == "editor": author = {"text": text} if child.get("pid"): author["@pid"] = child.get("pid") info["authors"]["author"].append(author) elif child.tag == "booktitle" or child.tag == "journal": info["venue"] = text elif child.tag == "ee": ee_values.append(text) elif child.tag == "url": info["toc_url"] = text elif child.tag in {"title", "pages", "year", "doi"}: info[child.tag] = text if ee_values: info["ee"] = _preferred_ee(ee_values) if "doi" not in info and info.get("ee", "").startswith("https://doi.org/"): info["doi"] = str(info["ee"]).removeprefix("https://doi.org/") info["access"] = "closed" if isinstance(info["authors"]["author"], list) and len(info["authors"]["author"]) == 1: info["authors"]["author"] = info["authors"]["author"][0] return { "@score": "1", "@id": key, "info": {key_: value for key_, value in info.items() if value not in (None, [], {})}, "url": "URL#3995011" if not key else f"URL#{key}", } def _preferred_ee(ee_values: list[str]) -> str: for value in ee_values: if "doi.org/" in value: return value return ee_values[0] def _api_type_for_tag(tag: str) -> str: if tag == "proceedings": return "Editorship" if tag == "inproceedings": return "Conference and Workshop Papers" return "article"