730 lines
28 KiB
Python
730 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Normalize the local Ministry of Justice CBD archive."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
from urllib.parse import urlsplit
|
|
|
|
APP_VERSION = "0.8.2"
|
|
SCHEMA_VERSION = "1"
|
|
NORMALIZER_VERSION = "1.0.0"
|
|
LANGUAGES = ("ru", "ky")
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
ALLOWED_TAGS = {
|
|
"a", "b", "blockquote", "br", "div", "em", "h1", "h2", "h3", "h4",
|
|
"h5", "h6", "i", "img", "li", "ol", "p", "pre", "span", "strong",
|
|
"sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr",
|
|
"u", "ul",
|
|
}
|
|
VOID_TAGS = {"br", "img"}
|
|
DROP_CONTENT_TAGS = {"applet", "iframe", "noscript", "object", "script", "style", "svg"}
|
|
DROP_ELEMENT_TAGS = {"link", "meta"}
|
|
BLOCK_TAGS = {"blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "pre", "td", "th"}
|
|
AUTO_CLOSE = {
|
|
"li": {"li"},
|
|
"p": {"blockquote", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ol", "p", "pre", "table", "ul"},
|
|
"td": {"td", "th"},
|
|
"th": {"td", "th"},
|
|
"tr": {"tr"},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NormalizeResult:
|
|
discovered: int = 0
|
|
normalized: int = 0
|
|
skipped: int = 0
|
|
failed: int = 0
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def atomic_write(path: Path, content: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
|
temporary.write(content)
|
|
temporary_path = Path(temporary.name)
|
|
os.replace(temporary_path, path)
|
|
|
|
|
|
def json_bytes(value: object) -> bytes:
|
|
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
|
|
|
|
def sha256_bytes(content: bytes) -> str:
|
|
return hashlib.sha256(content).hexdigest()
|
|
|
|
|
|
def source_inventory(document_directory: Path, input_root: Path) -> tuple[list[dict], str]:
|
|
files = []
|
|
combined = hashlib.sha256()
|
|
for path in sorted(item for item in document_directory.rglob("*") if item.is_file()):
|
|
relative = path.relative_to(input_root).as_posix()
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
checksum = digest.hexdigest()
|
|
files.append({"path": relative, "sha256": checksum})
|
|
combined.update(relative.encode("utf-8"))
|
|
combined.update(b"\0")
|
|
combined.update(checksum.encode("ascii"))
|
|
combined.update(b"\0")
|
|
return files, combined.hexdigest()
|
|
|
|
|
|
def clean_value(value):
|
|
if isinstance(value, str):
|
|
normalized = unicodedata.normalize("NFC", value)
|
|
return normalized if normalized.strip() else None
|
|
if isinstance(value, dict):
|
|
return {key: clean_value(item) for key, item in value.items()}
|
|
if isinstance(value, list):
|
|
return [clean_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def bilingual(value) -> dict[str, object]:
|
|
value = value if isinstance(value, dict) else {}
|
|
return {"ru": clean_value(value.get("Rus")), "ky": clean_value(value.get("Kyr"))}
|
|
|
|
|
|
def hierarchy_paths(items: object, child_key: str) -> list[dict]:
|
|
paths: list[dict] = []
|
|
|
|
def visit(nodes: object, ancestors: dict[str, list[str]]) -> None:
|
|
for node in nodes if isinstance(nodes, list) else []:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
names = bilingual(node.get("Name"))
|
|
current = {language: list(ancestors[language]) for language in LANGUAGES}
|
|
for language in LANGUAGES:
|
|
name = names[language]
|
|
if name:
|
|
current[language].append(str(name))
|
|
children = node.get(child_key)
|
|
if children:
|
|
visit(children, current)
|
|
else:
|
|
paths.append(current)
|
|
|
|
visit(items, {"ru": [], "ky": []})
|
|
return paths
|
|
|
|
|
|
class SafeHtmlParser(HTMLParser):
|
|
def __init__(self, edition_directory: Path) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.edition_directory = edition_directory.resolve()
|
|
self.parts: list[str] = []
|
|
self.stack: list[str] = []
|
|
self.drop_depth = 0
|
|
self.removed_elements = 0
|
|
self.removed_attributes = 0
|
|
self.removed_images = 0
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
tag = tag.lower()
|
|
if self.drop_depth:
|
|
if tag in DROP_CONTENT_TAGS:
|
|
self.drop_depth += 1
|
|
return
|
|
if tag in DROP_CONTENT_TAGS:
|
|
self.drop_depth = 1
|
|
self.removed_elements += 1
|
|
return
|
|
if tag in DROP_ELEMENT_TAGS:
|
|
self.removed_elements += 1
|
|
return
|
|
if tag not in ALLOWED_TAGS:
|
|
self.removed_elements += 1
|
|
return
|
|
for open_tag, closing_tags in AUTO_CLOSE.items():
|
|
if tag in closing_tags and open_tag in self.stack:
|
|
self._close(open_tag)
|
|
safe_attrs = self._attributes(tag, attrs)
|
|
if tag == "img" and not any(name == "src" for name, _ in safe_attrs):
|
|
self.removed_images += 1
|
|
return
|
|
rendered = "".join(
|
|
f' {name}="{html.escape(value, quote=True)}"' for name, value in safe_attrs
|
|
)
|
|
self.parts.append(f"<{tag}{rendered}>")
|
|
if tag not in VOID_TAGS:
|
|
self.stack.append(tag)
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
self.handle_starttag(tag, attrs)
|
|
if tag.lower() not in VOID_TAGS:
|
|
self.handle_endtag(tag)
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
tag = tag.lower()
|
|
if self.drop_depth:
|
|
if tag in DROP_CONTENT_TAGS:
|
|
self.drop_depth -= 1
|
|
return
|
|
if tag in self.stack:
|
|
self._close(tag)
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if not self.drop_depth:
|
|
self.parts.append(html.escape(unicodedata.normalize("NFC", data), quote=False))
|
|
|
|
def close(self) -> None:
|
|
super().close()
|
|
while self.stack:
|
|
self.parts.append(f"</{self.stack.pop()}>")
|
|
|
|
def _close(self, tag: str) -> None:
|
|
while self.stack:
|
|
current = self.stack.pop()
|
|
self.parts.append(f"</{current}>")
|
|
if current == tag:
|
|
break
|
|
|
|
def _attributes(self, tag: str, attrs: list[tuple[str, str | None]]) -> list[tuple[str, str]]:
|
|
allowed = {"title"}
|
|
if tag == "a":
|
|
allowed |= {"href"}
|
|
elif tag == "img":
|
|
allowed |= {"alt", "src"}
|
|
elif tag in {"td", "th"}:
|
|
allowed |= {"colspan", "rowspan"}
|
|
safe = []
|
|
for raw_name, raw_value in attrs:
|
|
name = raw_name.lower()
|
|
value = unicodedata.normalize("NFC", raw_value or "")
|
|
if name not in allowed:
|
|
self.removed_attributes += 1
|
|
continue
|
|
if name == "href" and not safe_link(value):
|
|
self.removed_attributes += 1
|
|
continue
|
|
if name == "src" and not self.safe_image(value):
|
|
self.removed_attributes += 1
|
|
continue
|
|
if name in {"colspan", "rowspan"} and not value.isdigit():
|
|
self.removed_attributes += 1
|
|
continue
|
|
safe.append((name, value))
|
|
if tag == "a" and any(name == "href" and urlsplit(value).scheme in {"http", "https"} for name, value in safe):
|
|
safe.append(("rel", "noopener noreferrer"))
|
|
return safe
|
|
|
|
def safe_image(self, value: str) -> bool:
|
|
try:
|
|
parsed = urlsplit(value)
|
|
except ValueError:
|
|
return False
|
|
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
|
|
return False
|
|
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
|
|
try:
|
|
candidate.relative_to(self.edition_directory)
|
|
except ValueError:
|
|
return False
|
|
return candidate.is_file()
|
|
|
|
|
|
def safe_link(value: str) -> bool:
|
|
value = value.strip()
|
|
if not value or value.startswith(("//", "\\\\")):
|
|
return False
|
|
try:
|
|
parsed = urlsplit(value)
|
|
except ValueError:
|
|
return False
|
|
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
|
|
not parsed.scheme and parsed.netloc
|
|
)
|
|
|
|
|
|
def sanitize_html(source: str, edition_directory: Path) -> tuple[str, dict]:
|
|
parser = SafeHtmlParser(edition_directory)
|
|
parser.feed(source)
|
|
parser.close()
|
|
return unicodedata.normalize("NFC", "".join(parser.parts)), {
|
|
"removed_elements": parser.removed_elements,
|
|
"removed_attributes": parser.removed_attributes,
|
|
"removed_images": parser.removed_images,
|
|
}
|
|
|
|
|
|
class TextBlockParser(HTMLParser):
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.blocks: list[tuple[str, str]] = []
|
|
self.active_tag: str | None = None
|
|
self.active: list[str] = []
|
|
self.loose: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs) -> None:
|
|
if tag in BLOCK_TAGS:
|
|
self._flush_active()
|
|
self._flush_loose()
|
|
self.active_tag = tag
|
|
elif tag == "br":
|
|
(self.active if self.active_tag else self.loose).append("\n")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag == self.active_tag:
|
|
self._flush_active()
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
(self.active if self.active_tag else self.loose).append(data)
|
|
|
|
def close(self) -> None:
|
|
super().close()
|
|
self._flush_active()
|
|
self._flush_loose()
|
|
|
|
def _flush_active(self) -> None:
|
|
if self.active_tag:
|
|
text = clean_text("".join(self.active))
|
|
if text:
|
|
self.blocks.append((self.active_tag, text))
|
|
self.active_tag = None
|
|
self.active = []
|
|
|
|
def _flush_loose(self) -> None:
|
|
text = clean_text("".join(self.loose))
|
|
if text:
|
|
self.blocks.append(("p", text))
|
|
self.loose = []
|
|
|
|
|
|
def clean_text(value: str) -> str:
|
|
lines = []
|
|
for line in unicodedata.normalize("NFC", value).replace("\xa0", " ").splitlines():
|
|
line = re.sub(r"[ \t\f\v]+", " ", line).strip()
|
|
if line:
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def fragment_type(tag: str, text: str, language: str) -> str:
|
|
lowered = text.casefold()
|
|
article_words = ("статья", "ст.") if language == "ru" else ("берене", "статья")
|
|
if any(re.match(rf"^{re.escape(word)}\s*\d", lowered) for word in article_words):
|
|
return "article"
|
|
if re.match(r"^\d+(?:\.\d+)*[.)]?\s+", text):
|
|
return "point"
|
|
if tag.startswith("h"):
|
|
return "heading"
|
|
return {"li": "list_item", "td": "table_cell", "th": "table_header"}.get(tag, "paragraph")
|
|
|
|
|
|
def extract_text_and_fragments(
|
|
sanitized: str,
|
|
document_code: str,
|
|
edition_code: str,
|
|
language: str,
|
|
source_path: str,
|
|
source_sha256: str,
|
|
) -> tuple[str, list[dict]]:
|
|
parser = TextBlockParser()
|
|
parser.feed(sanitized)
|
|
parser.close()
|
|
fragments = []
|
|
for position, (tag, text) in enumerate(parser.blocks, 1):
|
|
fragments.append(
|
|
{
|
|
"id": f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}",
|
|
"document_code": document_code,
|
|
"edition_code": edition_code,
|
|
"language": language,
|
|
"position": position,
|
|
"type": fragment_type(tag, text, language),
|
|
"text": text,
|
|
"text_sha256": sha256_bytes(text.encode("utf-8")),
|
|
"source_path": source_path,
|
|
"source_sha256": source_sha256,
|
|
}
|
|
)
|
|
return "\n\n".join(fragment["text"] for fragment in fragments), fragments
|
|
|
|
|
|
def load_json(path: Path) -> dict:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"Expected JSON object: {path}")
|
|
return value
|
|
|
|
|
|
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
|
|
metadata = load_json(edition_directory / "metadata.json")
|
|
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
|
|
return {
|
|
"source_code": str(metadata.get("Code", edition_directory.name)),
|
|
"name": bilingual(metadata.get("Name")),
|
|
"source_type": clean_value(metadata.get("Type")),
|
|
"available_languages": languages,
|
|
"source_path": edition_directory.relative_to(input_root).as_posix(),
|
|
}
|
|
|
|
|
|
def normalize_document(
|
|
document_directory: Path,
|
|
input_root: Path,
|
|
destination: Path,
|
|
files: list[dict] | None = None,
|
|
source_checksum: str | None = None,
|
|
) -> None:
|
|
metadata_path = document_directory / "metadata.json"
|
|
metadata = load_json(metadata_path)
|
|
document_code = str(metadata.get("Code", document_directory.name))
|
|
if document_code != document_directory.name:
|
|
raise ValueError(f"Document code mismatch in {metadata_path}")
|
|
if files is None or source_checksum is None:
|
|
files, source_checksum = source_inventory(document_directory, input_root)
|
|
file_checksums = {item["path"]: item["sha256"] for item in files}
|
|
edition_root = document_directory / "editions"
|
|
edition_directories = sorted(
|
|
(path for path in edition_root.iterdir() if path.is_dir()),
|
|
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
|
|
) if edition_root.is_dir() else []
|
|
summaries = [edition_summary(path, input_root) for path in edition_directories]
|
|
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
|
|
normalized_metadata = clean_value(metadata)
|
|
document = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"source_code": document_code,
|
|
"class": bilingual(metadata.get("Class")),
|
|
"type": bilingual(metadata.get("Type")),
|
|
"title": bilingual(metadata.get("Title")),
|
|
"name": bilingual(metadata.get("Name")),
|
|
"status": bilingual(metadata.get("Status")),
|
|
"number": clean_value(metadata.get("Number")),
|
|
"dates": {key: value for key, value in normalized_metadata.items() if key.startswith("Date")},
|
|
"registration_number": clean_value(metadata.get("NumberRegistration")),
|
|
"publication_number": clean_value(metadata.get("NumberPublication")),
|
|
"is_public_in_cdb": metadata.get("IsPublicInCdb"),
|
|
"is_public_in_register": metadata.get("IsPublicInRegister"),
|
|
"authorities": normalized_metadata.get("Authorities") or [],
|
|
"authority_paths": hierarchy_paths(metadata.get("Authorities"), "Authorities"),
|
|
"source_publications": normalized_metadata.get("SourcePublications") or [],
|
|
"source_publication_paths": hierarchy_paths(metadata.get("SourcePublications"), "SourcePublications"),
|
|
"keywords": normalized_metadata.get("Keywords") or [],
|
|
"keyword_paths": hierarchy_paths(metadata.get("Keywords"), "Keywords"),
|
|
"general_classifiers": normalized_metadata.get("GeneralClassifiers") or [],
|
|
"general_classifier_paths": hierarchy_paths(metadata.get("GeneralClassifiers"), "GeneralClassifiers"),
|
|
"references": normalized_metadata.get("References") or [],
|
|
"source_metadata": normalized_metadata,
|
|
"available_languages": available_languages,
|
|
"editions": summaries,
|
|
"source": {
|
|
"path": document_directory.relative_to(input_root).as_posix(),
|
|
"files": files,
|
|
"sha256": source_checksum,
|
|
},
|
|
"normalizer": {"version": NORMALIZER_VERSION, "processed_at": utc_now()},
|
|
}
|
|
atomic_write(destination / "document.json", json_bytes(document))
|
|
|
|
for edition_directory, summary in zip(edition_directories, summaries):
|
|
edition_code = summary["source_code"]
|
|
edition_metadata = load_json(edition_directory / "metadata.json")
|
|
edition_destination = destination / "editions" / edition_directory.name
|
|
image_records = []
|
|
for image in edition_metadata.get("Images") or []:
|
|
language = {"Russian": "ru", "Kyrgyz": "ky"}.get(image.get("Lang"), "unknown")
|
|
name = Path(str(image.get("Name") or "").replace("\\", "/")).name
|
|
source_path = edition_directory / "images" / language / name
|
|
relative = source_path.relative_to(input_root).as_posix()
|
|
image_records.append(
|
|
{
|
|
"language": language,
|
|
"name": clean_value(image.get("Name")),
|
|
"source_path": relative if source_path.is_file() else None,
|
|
"source_sha256": file_checksums.get(relative),
|
|
"source_metadata": clean_value(image),
|
|
}
|
|
)
|
|
quality = {"has_html": bool(summary["available_languages"]), "languages": {}}
|
|
for language in summary["available_languages"]:
|
|
html_path = edition_directory / f"{language}.html"
|
|
relative = html_path.relative_to(input_root).as_posix()
|
|
raw = html_path.read_text(encoding="utf-8")
|
|
sanitized, sanitizer_quality = sanitize_html(raw, edition_directory)
|
|
text, fragments = extract_text_and_fragments(
|
|
sanitized, document_code, edition_code, language, relative, file_checksums[relative]
|
|
)
|
|
language_destination = edition_destination / language
|
|
atomic_write(language_destination / "content.html", sanitized.encode("utf-8"))
|
|
atomic_write(language_destination / "content.txt", (text + ("\n" if text else "")).encode("utf-8"))
|
|
atomic_write(language_destination / "fragments.json", json_bytes(fragments))
|
|
quality["languages"][language] = {
|
|
**sanitizer_quality,
|
|
"empty_text": not bool(text),
|
|
"fragment_count": len(fragments),
|
|
}
|
|
edition = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"source_code": edition_code,
|
|
"name": summary["name"],
|
|
"source_type": summary["source_type"],
|
|
"available_languages": summary["available_languages"],
|
|
"images": image_records,
|
|
"source_metadata": clean_value(edition_metadata),
|
|
"source": {
|
|
"path": summary["source_path"],
|
|
"files": [item for item in files if item["path"].startswith(summary["source_path"] + "/")],
|
|
},
|
|
"quality": quality,
|
|
}
|
|
atomic_write(edition_destination / "edition.json", json_bytes(edition))
|
|
|
|
|
|
def connect_manifest(path: Path) -> sqlite3.Connection:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(path)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
code TEXT PRIMARY KEY,
|
|
source_sha256 TEXT,
|
|
schema_version TEXT NOT NULL,
|
|
normalizer_version TEXT NOT NULL,
|
|
processed_at TEXT,
|
|
state TEXT NOT NULL,
|
|
error TEXT,
|
|
failed_at TEXT
|
|
)
|
|
"""
|
|
)
|
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(documents)")}
|
|
if "failed_at" not in columns:
|
|
connection.execute("ALTER TABLE documents ADD COLUMN failed_at TEXT")
|
|
return connection
|
|
|
|
|
|
def publish_directory(staged: Path, target: Path) -> None:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
backup = target.parent / f".{target.name}.previous"
|
|
if backup.exists():
|
|
shutil.rmtree(backup)
|
|
if target.exists():
|
|
os.replace(target, backup)
|
|
try:
|
|
os.replace(staged, target)
|
|
except Exception:
|
|
if backup.exists():
|
|
os.replace(backup, target)
|
|
raise
|
|
if backup.exists():
|
|
shutil.rmtree(backup)
|
|
|
|
|
|
def recover_directory(target: Path) -> None:
|
|
backup = target.parent / f".{target.name}.previous"
|
|
if not backup.exists():
|
|
return
|
|
if target.exists():
|
|
shutil.rmtree(backup)
|
|
else:
|
|
os.replace(backup, target)
|
|
|
|
|
|
def validate_roots(input_root: Path, output: Path) -> None:
|
|
source = input_root.resolve()
|
|
destination = output.resolve()
|
|
if source == destination or source.is_relative_to(destination) or destination.is_relative_to(source):
|
|
raise ValueError("--input and --output must not overlap")
|
|
|
|
|
|
def normalize_archive(
|
|
input_root: Path = Path("data/minjust-cbd"),
|
|
output: Path = Path("data/minjust-normalized"),
|
|
limit: int | None = None,
|
|
refresh: bool = False,
|
|
progress: Callable[[NormalizeResult, int], None] | None = None,
|
|
) -> NormalizeResult:
|
|
validate_roots(input_root, output)
|
|
document_root = input_root / "documents"
|
|
if not document_root.is_dir():
|
|
raise FileNotFoundError(f"Document directory not found: {document_root}")
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
connection = connect_manifest(output / "manifest.sqlite3")
|
|
known = {
|
|
row[0]: (row[1], row[2], row[3], row[4])
|
|
for row in connection.execute(
|
|
"SELECT code, source_sha256, schema_version, normalizer_version, state FROM documents"
|
|
)
|
|
}
|
|
directories = sorted(
|
|
(path for path in document_root.iterdir() if path.is_dir()),
|
|
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
|
|
)
|
|
if limit is not None:
|
|
directories = directories[:limit]
|
|
total = len(directories)
|
|
discovered = normalized = skipped = failed = 0
|
|
staging_root = output / ".staging"
|
|
staging_root.mkdir(exist_ok=True)
|
|
try:
|
|
for source_directory in directories:
|
|
discovered += 1
|
|
code = source_directory.name
|
|
target = output / "documents" / code
|
|
checksum = None
|
|
try:
|
|
recover_directory(target)
|
|
files, checksum = source_inventory(source_directory, input_root)
|
|
if target.is_dir() and not refresh and known.get(code) == (
|
|
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
|
|
):
|
|
skipped += 1
|
|
else:
|
|
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
|
|
staged = Path(temporary) / code
|
|
normalize_document(source_directory, input_root, staged, files, checksum)
|
|
publish_directory(staged, target)
|
|
with connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO documents (
|
|
code, source_sha256, schema_version,
|
|
normalizer_version, processed_at, state, error,
|
|
failed_at
|
|
) VALUES (?, ?, ?, ?, ?, 'success', NULL, NULL)
|
|
ON CONFLICT(code) DO UPDATE SET
|
|
source_sha256=excluded.source_sha256,
|
|
schema_version=excluded.schema_version,
|
|
normalizer_version=excluded.normalizer_version,
|
|
processed_at=excluded.processed_at,
|
|
state='success', error=NULL, failed_at=NULL
|
|
""",
|
|
(code, checksum, SCHEMA_VERSION, NORMALIZER_VERSION, utc_now()),
|
|
)
|
|
normalized += 1
|
|
except Exception as error: # Keep a corpus run alive after one malformed record.
|
|
LOGGER.exception("Failed to normalize document %s", code)
|
|
with connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO documents (
|
|
code, source_sha256, schema_version,
|
|
normalizer_version, processed_at, state, error,
|
|
failed_at
|
|
) VALUES (?, ?, ?, ?, NULL, 'error', ?, ?)
|
|
ON CONFLICT(code) DO UPDATE SET
|
|
source_sha256=excluded.source_sha256,
|
|
schema_version=excluded.schema_version,
|
|
normalizer_version=excluded.normalizer_version,
|
|
state='error', error=excluded.error,
|
|
failed_at=excluded.failed_at
|
|
""",
|
|
(
|
|
code,
|
|
checksum,
|
|
SCHEMA_VERSION,
|
|
NORMALIZER_VERSION,
|
|
str(error),
|
|
utc_now(),
|
|
),
|
|
)
|
|
failed += 1
|
|
result = NormalizeResult(discovered, normalized, skipped, failed)
|
|
if progress:
|
|
progress(result, total)
|
|
elif discovered % 100 == 0:
|
|
LOGGER.info("discovered=%s normalized=%s skipped=%s failed=%s", discovered, normalized, skipped, failed)
|
|
finally:
|
|
connection.close()
|
|
try:
|
|
staging_root.rmdir()
|
|
except OSError:
|
|
pass
|
|
return NormalizeResult(discovered, normalized, skipped, failed)
|
|
|
|
|
|
def format_duration(seconds: float) -> str:
|
|
seconds = max(0, round(seconds))
|
|
hours, seconds = divmod(seconds, 3600)
|
|
minutes, seconds = divmod(seconds, 60)
|
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
|
|
|
|
def progress_line(result: NormalizeResult, total: int, elapsed: float, width: int = 24) -> str:
|
|
fraction = result.discovered / total if total else 0
|
|
filled = min(width, round(width * fraction))
|
|
rate = result.discovered / elapsed if elapsed > 0 else 0
|
|
eta = (total - result.discovered) / rate if rate else 0
|
|
return (
|
|
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
|
|
f"{result.discovered}/{total} normalized={result.normalized} "
|
|
f"skipped={result.skipped} failed={result.failed} "
|
|
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
|
|
)
|
|
|
|
|
|
def terminal_progress() -> Callable[[NormalizeResult, int], None]:
|
|
started = time.monotonic()
|
|
|
|
def update(result: NormalizeResult, total: int) -> None:
|
|
print(
|
|
f"\r{progress_line(result, total, time.monotonic() - started)}",
|
|
end="\n" if result.discovered >= total else "",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
return update
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--input", type=Path, default=Path("data/minjust-cbd"))
|
|
parser.add_argument("--output", type=Path, default=Path("data/minjust-normalized"))
|
|
parser.add_argument("--limit", type=int, help="normalize only the first N documents")
|
|
parser.add_argument("--refresh", action="store_true", help="renormalize unchanged documents")
|
|
parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO")
|
|
parser.add_argument("--version", action="version", version=APP_VERSION)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
arguments = parse_args()
|
|
if arguments.limit is not None and arguments.limit <= 0:
|
|
raise SystemExit("--limit must be greater than zero")
|
|
logging.basicConfig(level=arguments.log_level, format="%(asctime)s %(levelname)s %(message)s")
|
|
result = normalize_archive(
|
|
arguments.input,
|
|
arguments.output,
|
|
arguments.limit,
|
|
arguments.refresh,
|
|
terminal_progress() if sys.stderr.isatty() else None,
|
|
)
|
|
print(
|
|
f"discovered={result.discovered} normalized={result.normalized} "
|
|
f"skipped={result.skipped} failed={result.failed}\n"
|
|
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
|
)
|
|
return int(result.failed > 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|