#!/usr/bin/env python3 from __future__ import annotations import argparse import csv import json import re import zipfile import xml.etree.ElementTree as ET from dataclasses import dataclass, asdict from pathlib import Path FISH_SRC_DEFAULT = "coastline/C09-06.zip" @dataclass(frozen=True) class MatchRow: fpc: str prc: str | None primary_name: str | None secondary_name: str | None source_id: str | None score: tuple[int, int, str] def local_name(tag: str) -> str: return tag.split("}", 1)[-1] def normalize_text(value: str) -> str: value = value.strip() value = value.replace(" ", " ") value = re.sub(r"\s+", "", value) return value.lower() def load_xml(zip_path: Path) -> ET.Element: with zipfile.ZipFile(zip_path) as zf: xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name) return ET.fromstring(zf.read(xml_name)) def get_text(feature: ET.Element, key: str) -> str | None: el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}") if el is None or not (el.text or "").strip(): return None return el.text.strip() def feature_name_tokens(feature: ET.Element) -> list[str]: tokens: list[str] = [] for key in ("NA2", "NA4", "FCF", "AAC", "CFP", "FPA"): value = get_text(feature, key) if value: tokens.append(value) return tokens def feature_display_name(feature: ET.Element) -> tuple[str | None, str | None]: na2 = get_text(feature, "NA2") na4 = get_text(feature, "NA4") fcf = get_text(feature, "FCF") primary = na2 or na4 or fcf secondary = None if primary == na2: secondary = fcf or na4 elif primary == na4: secondary = na2 or fcf else: secondary = na2 or na4 return primary, secondary def catalog_display_name(row: dict[str, str]) -> str | None: for key in ("name", "name_ja", "port_name", "港名", "漁港名", "名称"): value = row.get(key) if value and value.strip(): return value.strip() return None def catalog_fpc(row: dict[str, str]) -> str | None: for key in ("fpc", "FPC"): value = row.get(key) if value and value.strip(): return value.strip() return None def catalog_prc(row: dict[str, str]) -> str | None: for key in ("prc", "PRC"): value = row.get(key) if value and value.strip(): return value.strip() return None def score_match(query: str, tokens: list[str], primary: str | None) -> tuple[int, int, str] | None: q = normalize_text(query) token_norms = [normalize_text(token) for token in tokens if token] primary_norm = normalize_text(primary) if primary else "" if primary_norm == q: return (0, 0, primary_norm) if q in token_norms: return (1, 0, primary_norm) if primary_norm and q in primary_norm: return (2, len(primary_norm), primary_norm) for token in token_norms: if q in token: return (3, len(token), primary_norm or token) return None def load_catalog_matches(catalog_path: Path, query: str) -> list[MatchRow]: suffix = catalog_path.suffix.lower() rows: list[dict[str, str]] = [] if suffix in (".csv", ".tsv"): delimiter = "\t" if suffix == ".tsv" else "," with catalog_path.open("r", encoding="utf-8-sig", newline="") as fh: reader = csv.DictReader(fh, delimiter=delimiter) rows.extend(dict(row) for row in reader) elif suffix in (".json", ".jsonl"): text = catalog_path.read_text(encoding="utf-8") if suffix == ".jsonl": rows.extend(json.loads(line) for line in text.splitlines() if line.strip()) else: payload = json.loads(text) if isinstance(payload, list): rows.extend(dict(row) for row in payload) elif isinstance(payload, dict) and isinstance(payload.get("items"), list): rows.extend(dict(row) for row in payload["items"]) else: raise SystemExit("catalog json must be a list or {items:[...]}") else: raise SystemExit("catalog must be .csv, .tsv, .json or .jsonl") matches: list[MatchRow] = [] for row in rows: name = catalog_display_name(row) fpc = catalog_fpc(row) if not name or not fpc: continue score = score_match(query, [name], name) if score is None: continue matches.append( MatchRow( fpc=fpc, prc=catalog_prc(row), primary_name=name, secondary_name=None, source_id=row.get("source_id") or row.get("id") or row.get("sid"), score=score, ) ) matches.sort(key=lambda row: (row.score, row.fpc)) return matches def collect_matches(zip_path: Path, query: str) -> list[MatchRow]: root = load_xml(zip_path) obj = root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ") if obj is None: raise SystemExit("OBJ block missing in input package") matches: list[MatchRow] = [] for feature in obj.findall(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}CB03"): fpc = get_text(feature, "FPC") prc = get_text(feature, "PRC") if not fpc: continue tokens = feature_name_tokens(feature) primary, secondary = feature_display_name(feature) score = score_match(query, tokens, primary) if score is None: continue source_id = None loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC") if loc is not None: source_id = loc.attrib.get("idref") matches.append( MatchRow( fpc=fpc, prc=prc, primary_name=primary, secondary_name=secondary, source_id=source_id, score=score, ) ) matches.sort(key=lambda row: (row.score, row.fpc)) return matches def main() -> None: parser = argparse.ArgumentParser(description="按渔港名字查找 FPC") parser.add_argument("name", help="渔港名字,支持包含匹配") parser.add_argument( "--source", default=FISH_SRC_DEFAULT, help="输入渔港原始包,默认 coastline/C09-06.zip", ) parser.add_argument( "--catalog", default=None, help="可选的港名对照表(CSV/TSV/JSON/JSONL);如果原始包里找不到名字,建议用它", ) parser.add_argument( "--exact", action="store_true", help="只保留精确匹配", ) parser.add_argument( "--json", action="store_true", help="以 JSON 输出", ) parser.add_argument( "--max-results", type=int, default=20, help="最多输出多少条候选结果", ) args = parser.parse_args() source_path = Path(args.source) if not source_path.is_absolute(): source_path = Path(__file__).resolve().parent / source_path if not source_path.exists(): raise SystemExit(f"missing input: {source_path}") matches: list[MatchRow] = [] if args.catalog: catalog_path = Path(args.catalog) if not catalog_path.is_absolute(): catalog_path = Path(__file__).resolve().parent / catalog_path if not catalog_path.exists(): raise SystemExit(f"missing catalog: {catalog_path}") matches = load_catalog_matches(catalog_path, args.name) if not matches: matches = collect_matches(source_path, args.name) if args.exact: q = normalize_text(args.name) matches = [row for row in matches if normalize_text(row.primary_name or "") == q] matches = matches[: args.max_results] if not matches: if args.catalog: raise SystemExit( f"no FPC matched for name={args.name!r} in catalog={args.catalog!r}" ) raise SystemExit( f"no FPC matched for name={args.name!r}; " "this source package may not contain human-readable port names, " "try --catalog with a port-name mapping file" ) if args.json: print( json.dumps( [asdict(row) | {"score": list(row.score)} for row in matches], ensure_ascii=False, indent=2, ) ) return for idx, row in enumerate(matches, start=1): name_bits = [bit for bit in [row.primary_name, row.secondary_name] if bit] print( f"{idx}. FPC={row.fpc} PRC={row.prc or '-'} " f"name={' / '.join(name_bits) if name_bits else '-'} " f"source_id={row.source_id or '-'}" ) if __name__ == "__main__": main()