833 lines
29 KiB
Python
833 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""Build an independent searchable snapshot of the Tinysoft data dictionary."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import tempfile
|
||
import unicodedata
|
||
from dataclasses import dataclass, replace
|
||
from pathlib import Path
|
||
from typing import Iterable, Sequence
|
||
|
||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||
|
||
|
||
INDEX_COLUMNS = (
|
||
"kind",
|
||
"scope",
|
||
"table_id",
|
||
"table_name",
|
||
"table_alias",
|
||
"field_id",
|
||
"field_name",
|
||
"field_alias",
|
||
"data_type",
|
||
"unit",
|
||
"description",
|
||
"extract_method",
|
||
"access_code",
|
||
"api_name",
|
||
"page",
|
||
"tags",
|
||
)
|
||
|
||
SECTION_LABELS = {
|
||
"更新日志",
|
||
"数据说明",
|
||
"数据来源",
|
||
"数据更新情况",
|
||
"基本概况",
|
||
"字段说明",
|
||
"特别说明",
|
||
"数据范例",
|
||
"访问代码",
|
||
"访问方法",
|
||
"取数示例",
|
||
"参考",
|
||
}
|
||
|
||
SCOPE_NAMES = {
|
||
"股票": "stock",
|
||
"基金": "fund",
|
||
"基金扩展": "fund",
|
||
"债券": "bond",
|
||
"期货": "futures",
|
||
"期权": "options",
|
||
"新三板": "neeq",
|
||
"指数": "index",
|
||
"回购": "repo",
|
||
"现货": "spot",
|
||
"宏观": "macro",
|
||
"中证": "csindex",
|
||
"天软": "tinysoft",
|
||
"行情": "market",
|
||
"证券": "security",
|
||
}
|
||
|
||
FIELD_NAME_HEADERS = ("字段名", "函数名", "名称", "含义", "字段")
|
||
UNIT_HEADERS = ("单位", "单位【注1】", "单位【注4】")
|
||
DESCRIPTION_HEADERS = ("详细说明", "说明", "备注")
|
||
INVALID_SLUG_CHARS_RE = re.compile(r"[\\/:*?\"<>|]+")
|
||
SPACE_RE = re.compile(r"\s+")
|
||
|
||
|
||
class DictionaryParseError(ValueError):
|
||
"""A dictionary page advertises a schema that cannot be parsed."""
|
||
|
||
|
||
class NotDictionaryPage(DictionaryParseError):
|
||
"""The source page is outside the data-dictionary branch."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DictionaryField:
|
||
field_id: str
|
||
field_name: str
|
||
data_type: str
|
||
display_name: str
|
||
unit: str
|
||
description: str
|
||
api_name: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DictionaryTable:
|
||
name: str
|
||
table_id: str
|
||
extract_method: str
|
||
access_code: str
|
||
update_info: tuple[str, ...]
|
||
aliases: tuple[str, ...] = ()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DictionaryExample:
|
||
description: str
|
||
code: str
|
||
output: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DictionaryPage:
|
||
kind: str
|
||
scope: str
|
||
title: str
|
||
source_path: tuple[str, ...]
|
||
source_file: str
|
||
table: DictionaryTable
|
||
fields: tuple[DictionaryField, ...]
|
||
notes: tuple[str, ...]
|
||
examples: tuple[DictionaryExample, ...]
|
||
output_name: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DictionaryDocument:
|
||
source_file: str
|
||
source_path: tuple[str, ...]
|
||
records: tuple[DictionaryPage, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BuildReport:
|
||
scanned_page_count: int
|
||
dictionary_page_count: int
|
||
navigation_page_count: int
|
||
page_count: int
|
||
table_count: int
|
||
source_count: int
|
||
field_count: int
|
||
ignored_pages: tuple[str, ...]
|
||
malformed_dictionary_pages: tuple[str, ...]
|
||
|
||
|
||
def _clean_text(value: str) -> str:
|
||
value = value.replace("\xa0", " ")
|
||
return SPACE_RE.sub(" ", value).strip()
|
||
|
||
|
||
def _cell_text(cell: Tag) -> str:
|
||
return _clean_text(cell.get_text(" ", strip=True))
|
||
|
||
|
||
def _table_rows(table: Tag) -> tuple[list[str], list[list[str]]]:
|
||
rows = []
|
||
for row in table.find_all("tr"):
|
||
cells = [_cell_text(cell) for cell in row.find_all(["th", "td"], recursive=False)]
|
||
if cells:
|
||
rows.append(cells)
|
||
if not rows:
|
||
return [], []
|
||
return rows[0], rows[1:]
|
||
|
||
|
||
def _page_title(content: Tag) -> str:
|
||
heading = content.find(["h1", "h2", "h3"])
|
||
if heading is None:
|
||
return ""
|
||
return _clean_text(heading.get_text(" ", strip=True).replace("复制链接", ""))
|
||
|
||
|
||
def _breadcrumb(raw: str) -> tuple[str, ...]:
|
||
prefix = raw.split('<div id="help_content">', 1)[0]
|
||
soup = BeautifulSoup(prefix, "html.parser")
|
||
labels = [_clean_text(link.get_text(" ", strip=True)) for link in soup.find_all("a")]
|
||
starts = [index for index, label in enumerate(labels) if label == "知识库"]
|
||
if starts:
|
||
labels = labels[starts[-1] :]
|
||
return tuple(label for label in labels if label)
|
||
|
||
|
||
def _is_dictionary_path(source_path: Sequence[str], title: str) -> bool:
|
||
return "天软数据字典" in source_path or title == "天软数据字典"
|
||
|
||
|
||
def _scope(source_path: Sequence[str]) -> str:
|
||
if "天软数据字典" not in source_path:
|
||
return ""
|
||
start = source_path.index("天软数据字典") + 1
|
||
for label in source_path[start:]:
|
||
if label in SCOPE_NAMES:
|
||
return SCOPE_NAMES[label]
|
||
return ""
|
||
|
||
|
||
def _domain_path(source_path: Sequence[str]) -> tuple[str, ...]:
|
||
if "天软数据字典" not in source_path:
|
||
return ()
|
||
start = source_path.index("天软数据字典") + 1
|
||
return tuple(source_path[start:])
|
||
|
||
|
||
def _section_marker(content: Tag, label: str) -> Tag | None:
|
||
for tag in content.find_all(["span", "strong"]):
|
||
if _clean_text(tag.get_text(" ", strip=True)).rstrip("::") == label:
|
||
return tag
|
||
return None
|
||
|
||
|
||
def _is_section_marker(node: Tag) -> bool:
|
||
if node.name not in {"span", "strong"}:
|
||
return False
|
||
return _clean_text(node.get_text(" ", strip=True)).rstrip("::") in SECTION_LABELS
|
||
|
||
|
||
def _inside(node: NavigableString, tag_name: str, class_name: str | None = None) -> bool:
|
||
parent = node.parent
|
||
while isinstance(parent, Tag):
|
||
if parent.name == tag_name and (class_name is None or class_name in parent.get("class", [])):
|
||
return True
|
||
parent = parent.parent
|
||
return False
|
||
|
||
|
||
def _section_lines(content: Tag, label: str) -> tuple[str, ...]:
|
||
marker = _section_marker(content, label)
|
||
if marker is None:
|
||
return ()
|
||
lines = []
|
||
for node in marker.next_elements:
|
||
if isinstance(node, Tag) and node is not marker and _is_section_marker(node):
|
||
break
|
||
if not isinstance(node, NavigableString) or node.parent is marker:
|
||
continue
|
||
if _inside(node, "table") or _inside(node, "div", "text-container"):
|
||
continue
|
||
text = _clean_text(str(node))
|
||
if text and (not lines or lines[-1] != text):
|
||
lines.append(text)
|
||
return tuple(lines)
|
||
|
||
|
||
def _nearest_section(table: Tag) -> str:
|
||
for tag in table.find_all_previous(["span", "strong"]):
|
||
label = _clean_text(tag.get_text(" ", strip=True)).rstrip("::")
|
||
if label in SECTION_LABELS:
|
||
return label
|
||
return ""
|
||
|
||
|
||
def _find_basic_table(content: Tag) -> Tag | None:
|
||
for table in content.find_all("table"):
|
||
header, _ = _table_rows(table)
|
||
if "表ID" in header and "表名" in header and "提取方式" in header:
|
||
return table
|
||
return None
|
||
|
||
|
||
def _field_table_header(header: Sequence[str]) -> bool:
|
||
return "类型" in header and any(name in header for name in FIELD_NAME_HEADERS)
|
||
|
||
|
||
def _find_field_tables(content: Tag) -> tuple[Tag, ...]:
|
||
found = []
|
||
for table in content.find_all("table"):
|
||
header, _ = _table_rows(table)
|
||
if _field_table_header(header) and _nearest_section(table) == "字段说明":
|
||
found.append(table)
|
||
return tuple(found)
|
||
|
||
|
||
def _column_index(header: Sequence[str], names: Sequence[str]) -> int | None:
|
||
for name in names:
|
||
if name in header:
|
||
return header.index(name)
|
||
return None
|
||
|
||
|
||
def _value(row: Sequence[str], index: int | None) -> str:
|
||
if index is None or index >= len(row):
|
||
return ""
|
||
return row[index]
|
||
|
||
|
||
def _parse_fields(table: Tag) -> tuple[DictionaryField, ...]:
|
||
header, rows = _table_rows(table)
|
||
id_index = _column_index(header, ("ID",))
|
||
identifier_index = _column_index(header, ("字段名", "函数名", "字段"))
|
||
display_index = _column_index(header, ("中文名", "名称", "含义"))
|
||
type_index = _column_index(header, ("类型",))
|
||
unit_index = _column_index(header, UNIT_HEADERS)
|
||
description_indices = [header.index(name) for name in DESCRIPTION_HEADERS if name in header]
|
||
api_index = _column_index(header, ("对应函数名称",))
|
||
fields = []
|
||
for row in rows:
|
||
field_id = _value(row, id_index)
|
||
identifier = _value(row, identifier_index)
|
||
display_name = _value(row, display_index)
|
||
api_name = _value(row, api_index)
|
||
if "函数名" in header and identifier:
|
||
api_name = api_name or identifier
|
||
field_name = identifier or display_name or field_id
|
||
display_name = display_name or identifier or field_id
|
||
descriptions = []
|
||
for index in description_indices:
|
||
value = _value(row, index)
|
||
if value and value not in descriptions:
|
||
descriptions.append(value)
|
||
if not field_name:
|
||
continue
|
||
fields.append(
|
||
DictionaryField(
|
||
field_id=field_id,
|
||
field_name=field_name,
|
||
data_type=_value(row, type_index),
|
||
display_name=display_name,
|
||
unit=_value(row, unit_index),
|
||
description=" ".join(descriptions),
|
||
api_name=api_name,
|
||
)
|
||
)
|
||
return tuple(fields)
|
||
|
||
|
||
def _extract_examples(content: Tag) -> tuple[DictionaryExample, ...]:
|
||
marker = _section_marker(content, "取数示例")
|
||
if marker is None:
|
||
return ()
|
||
examples = []
|
||
for container in marker.find_all_next("div", class_="text-container"):
|
||
previous = container.find_previous(["span", "strong"])
|
||
if previous is not marker and _is_section_marker(previous):
|
||
break
|
||
raw_lines = [_clean_text(line) for line in container.get_text("\n").splitlines()]
|
||
chunks: list[list[str]] = []
|
||
current: list[str] = []
|
||
for line in (line for line in raw_lines if line):
|
||
empty_output = re.fullmatch(r"//\s*(?:返回|输出)\s*[::]?", line, re.IGNORECASE)
|
||
if empty_output:
|
||
if current:
|
||
chunks.append(current)
|
||
current = []
|
||
continue
|
||
output_match = re.match(r"//\s*(?:返回|输出)\s*[::]\s*(.+)", line, re.IGNORECASE)
|
||
if not output_match and line.startswith("//") and current and any(
|
||
re.match(r"(?i)^\s*return\b", existing) for existing in current
|
||
):
|
||
chunks.append(current)
|
||
current = []
|
||
if output_match:
|
||
line = f"// 输出:{output_match.group(1).strip()}"
|
||
elif line.startswith("//"):
|
||
line = f"// {line[2:].strip()}"
|
||
current.append(line)
|
||
if current:
|
||
chunks.append(current)
|
||
for lines in chunks:
|
||
description = lines[0][2:].strip() if lines[0].startswith("//") else ""
|
||
output = None
|
||
for line in lines:
|
||
match = re.match(r"//\s*输出:(.+)", line)
|
||
if match:
|
||
output = match.group(1).strip()
|
||
examples.append(DictionaryExample(description, "\n".join(lines), output))
|
||
return tuple(examples)
|
||
|
||
|
||
def _basic_metadata(table: Tag) -> tuple[str, str, str]:
|
||
header, rows = _table_rows(table)
|
||
if len(rows) != 1:
|
||
raise DictionaryParseError("basic table must contain exactly one data row")
|
||
row = rows[0]
|
||
return (
|
||
_value(row, header.index("表ID")),
|
||
_value(row, header.index("表名")),
|
||
_value(row, header.index("提取方式")),
|
||
)
|
||
|
||
|
||
def _resolve_extract_method(content: Tag, extract_method: str) -> str:
|
||
if not re.fullmatch(r"【注\d+】", extract_method):
|
||
return extract_method
|
||
page_text = _clean_text(content.get_text(" ", strip=True))
|
||
match = re.search(r"(?:取数接口|接口)\s*([A-Za-z][A-Za-z0-9_]*)", page_text)
|
||
return match.group(1) if match else extract_method
|
||
|
||
|
||
def _normal_aliases(table_name: str, title: str) -> tuple[str, ...]:
|
||
candidates = [title, table_name.replace(".", ""), table_name]
|
||
if "." in table_name:
|
||
candidates.append(table_name.rsplit(".", 1)[-1])
|
||
return tuple(dict.fromkeys(value for value in candidates if value and value != table_name))
|
||
|
||
|
||
def _field_available(field: DictionaryField, source_name: str) -> bool:
|
||
description = unicodedata.normalize("NFKC", field.description).casefold()
|
||
source = source_name.casefold()
|
||
pattern = rf"{re.escape(source)}\s*[::]\s*(?:没有|无)(?:该)?字段"
|
||
return re.search(pattern, description) is None
|
||
|
||
|
||
def _split_market_sources(page: DictionaryPage) -> tuple[DictionaryPage, ...]:
|
||
shared = page.table
|
||
trade_table = replace(
|
||
shared,
|
||
name="TradeTable",
|
||
extract_method="TradeTable",
|
||
aliases=("交易明细", "交易明细表", "tradetable"),
|
||
)
|
||
market_table = replace(
|
||
shared,
|
||
name="MarketTable",
|
||
extract_method="MarketTable",
|
||
aliases=("分时", "分时表", "markettable"),
|
||
)
|
||
trade_examples = tuple(example for example in page.examples if "tradetable" in example.code.casefold())
|
||
market_examples = tuple(example for example in page.examples if "markettable" in example.code.casefold())
|
||
return (
|
||
replace(
|
||
page,
|
||
kind="source",
|
||
title="交易明细",
|
||
table=trade_table,
|
||
fields=tuple(field for field in page.fields if _field_available(field, "tradetable")),
|
||
examples=trade_examples,
|
||
),
|
||
replace(
|
||
page,
|
||
kind="source",
|
||
title="分时",
|
||
table=market_table,
|
||
fields=tuple(field for field in page.fields if _field_available(field, "markettable")),
|
||
examples=market_examples,
|
||
),
|
||
)
|
||
|
||
|
||
def _special_field_records(
|
||
content: Tag,
|
||
title: str,
|
||
source_path: tuple[str, ...],
|
||
source_file: str,
|
||
fields_by_table: Sequence[tuple[Tag, tuple[DictionaryField, ...]]],
|
||
) -> tuple[DictionaryPage, ...]:
|
||
access_lines = _section_lines(content, "访问方法")
|
||
methods = []
|
||
for line in access_lines:
|
||
if ":" in line or ":" in line:
|
||
_, method = re.split(r"[::]", line, maxsplit=1)
|
||
methods.append(_clean_text(method))
|
||
all_examples = _extract_examples(content)
|
||
records = []
|
||
for index, (table, fields) in enumerate(fields_by_table):
|
||
label = ""
|
||
for sibling in table.previous_siblings:
|
||
if isinstance(sibling, NavigableString):
|
||
label = _clean_text(str(sibling)).rstrip("::")
|
||
if label:
|
||
break
|
||
record_title = f"{title}.{label}" if label else title
|
||
extract_method = methods[index] if index < len(methods) else ""
|
||
matching_examples = tuple(
|
||
example
|
||
for example in all_examples
|
||
if extract_method and extract_method.casefold() in example.code.casefold()
|
||
)
|
||
records.append(
|
||
DictionaryPage(
|
||
kind="source",
|
||
scope=_scope(source_path),
|
||
title=record_title,
|
||
source_path=_domain_path(source_path),
|
||
source_file=source_file,
|
||
table=DictionaryTable(
|
||
name=record_title,
|
||
table_id="",
|
||
extract_method=extract_method,
|
||
access_code=";".join(access_lines),
|
||
update_info=_section_lines(content, "数据更新情况"),
|
||
aliases=tuple(filter(None, (title, label))),
|
||
),
|
||
fields=fields,
|
||
notes=_section_lines(content, "数据说明"),
|
||
examples=matching_examples or (all_examples if len(fields_by_table) == 1 else ()),
|
||
)
|
||
)
|
||
return tuple(records)
|
||
|
||
|
||
def parse_dictionary_document(path: Path) -> DictionaryDocument:
|
||
raw = path.read_text(encoding="utf-8", errors="replace")
|
||
soup = BeautifulSoup(raw, "html.parser")
|
||
content = soup.select_one("#help_content")
|
||
if content is None:
|
||
raise DictionaryParseError("missing #help_content")
|
||
title = _page_title(content)
|
||
source_path = _breadcrumb(raw)
|
||
if not _is_dictionary_path(source_path, title):
|
||
raise NotDictionaryPage(path.name)
|
||
|
||
basic_table = _find_basic_table(content)
|
||
field_tables = _find_field_tables(content)
|
||
if basic_table is None and not field_tables:
|
||
return DictionaryDocument(path.name, source_path, ())
|
||
if not field_tables:
|
||
raise DictionaryParseError("dictionary record has no recognized field table")
|
||
|
||
parsed_tables = tuple((table, _parse_fields(table)) for table in field_tables)
|
||
if any(not fields for _, fields in parsed_tables):
|
||
raise DictionaryParseError("field table contains no data rows")
|
||
if basic_table is None:
|
||
records = _special_field_records(content, title, source_path, path.name, parsed_tables)
|
||
return DictionaryDocument(path.name, source_path, records)
|
||
|
||
table_id, table_name, extract_method = _basic_metadata(basic_table)
|
||
extract_method = _resolve_extract_method(content, extract_method)
|
||
if not table_name:
|
||
raise DictionaryParseError("basic table has no table name")
|
||
fields = tuple(field for _, group in parsed_tables for field in group)
|
||
page = DictionaryPage(
|
||
kind="table",
|
||
scope=_scope(source_path),
|
||
title=title,
|
||
source_path=_domain_path(source_path),
|
||
source_file=path.name,
|
||
table=DictionaryTable(
|
||
name=table_name,
|
||
table_id=table_id,
|
||
extract_method=extract_method,
|
||
access_code=";".join(_section_lines(content, "访问代码")),
|
||
update_info=_section_lines(content, "数据更新情况"),
|
||
aliases=_normal_aliases(table_name, title),
|
||
),
|
||
fields=fields,
|
||
notes=_section_lines(content, "数据说明"),
|
||
examples=_extract_examples(content),
|
||
)
|
||
method_key = re.sub(r"[^a-z]", "", extract_method.casefold())
|
||
if "markettable" in method_key and "tradetable" in method_key:
|
||
records = _split_market_sources(page)
|
||
else:
|
||
records = (page,)
|
||
return DictionaryDocument(path.name, source_path, records)
|
||
|
||
|
||
def _slug_part(value: str) -> str:
|
||
value = unicodedata.normalize("NFKC", value).strip().casefold()
|
||
value = INVALID_SLUG_CHARS_RE.sub("_", value)
|
||
value = re.sub(r"[.。·,,、]+", "_", value)
|
||
value = SPACE_RE.sub("_", value)
|
||
value = re.sub(r"_+", "_", value).strip("_")
|
||
return value
|
||
|
||
|
||
def dictionary_slug(page: DictionaryPage) -> str:
|
||
if page.output_name:
|
||
return page.output_name
|
||
parts = [*page.source_path, page.title]
|
||
if page.kind == "source" and page.table.name.casefold() not in page.title.casefold():
|
||
parts.append(page.table.name)
|
||
compact = []
|
||
for part in parts:
|
||
slug = _slug_part(part)
|
||
if slug and (not compact or compact[-1] != slug):
|
||
compact.append(slug)
|
||
if not compact:
|
||
raise DictionaryParseError(f"cannot create slug for {page.source_file}")
|
||
return "__".join(compact) + ".md"
|
||
|
||
|
||
def _resolve_slug_collisions(pages: Sequence[DictionaryPage]) -> tuple[DictionaryPage, ...]:
|
||
grouped: dict[str, list[DictionaryPage]] = {}
|
||
for page in pages:
|
||
grouped.setdefault(dictionary_slug(page), []).append(page)
|
||
resolved = []
|
||
used = set()
|
||
for page in pages:
|
||
base = dictionary_slug(page)
|
||
group = grouped[base]
|
||
if len(group) == 1:
|
||
output_name = base
|
||
else:
|
||
discriminator = _slug_part(page.table.table_id or page.table.extract_method)
|
||
if not discriminator or "注" in discriminator:
|
||
sources = ", ".join(item.source_file for item in group)
|
||
raise DictionaryParseError(f"slug collision needs a semantic discriminator: {base}: {sources}")
|
||
output_name = f"{Path(base).stem}__{discriminator}.md"
|
||
if output_name in used:
|
||
sources = ", ".join(item.source_file for item in group)
|
||
raise DictionaryParseError(f"slug collision: {output_name}: {sources}")
|
||
used.add(output_name)
|
||
resolved.append(replace(page, output_name=output_name))
|
||
return tuple(resolved)
|
||
|
||
|
||
def _markdown_cell(value: str) -> str:
|
||
return value.replace("|", "\\|").replace("\n", "<br>")
|
||
|
||
|
||
def render_dictionary_page(page: DictionaryPage) -> str:
|
||
path = " / ".join((*page.source_path, page.title))
|
||
lines = [f"# 天软数据字典 / {path}", "", f"类型:{page.kind}", "", "## 表信息", ""]
|
||
name_label = "数据源名称" if page.kind == "source" else "表名"
|
||
metadata = [(name_label, page.table.name)]
|
||
if page.table.table_id:
|
||
metadata.append(("表 ID", page.table.table_id))
|
||
if page.table.extract_method:
|
||
metadata.append(("提取方式", page.table.extract_method))
|
||
if page.table.access_code:
|
||
metadata.append(("访问代码", page.table.access_code))
|
||
if page.scope:
|
||
metadata.append(("范围", page.scope))
|
||
lines.extend(["| 项目 | 内容 |", "| --- | --- |"])
|
||
lines.extend(f"| {label} | {_markdown_cell(value)} |" for label, value in metadata)
|
||
lines.extend(["", "## 字段", "", "| 字段 ID | 字段名 | 类型 | 中文名 | 单位 | 说明 | 对应 API |", "| --- | --- | --- | --- | --- | --- | --- |"])
|
||
for field in page.fields:
|
||
values = (
|
||
field.field_id,
|
||
field.field_name,
|
||
field.data_type,
|
||
field.display_name,
|
||
field.unit,
|
||
field.description,
|
||
field.api_name,
|
||
)
|
||
lines.append("| " + " | ".join(_markdown_cell(value) for value in values) + " |")
|
||
if page.notes or page.table.update_info:
|
||
lines.extend(["", "## 数据说明", ""])
|
||
for note in (*page.notes, *page.table.update_info):
|
||
lines.append(f"- {note}")
|
||
if page.examples:
|
||
lines.extend(["", "## 取数示例", ""])
|
||
for index, example in enumerate(page.examples, start=1):
|
||
if example.description:
|
||
lines.extend([f"范例{index:02d}:{example.description}", ""])
|
||
lines.extend(["```tsl", example.code, "```", ""])
|
||
while lines and not lines[-1]:
|
||
lines.pop()
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _page_reference(page: DictionaryPage) -> str:
|
||
return f"references/data_dictionary/{dictionary_slug(page)}"
|
||
|
||
|
||
def _field_alias(field: DictionaryField) -> str:
|
||
values = []
|
||
for value in (field.display_name, field.field_name):
|
||
if value and value not in values:
|
||
values.append(value)
|
||
return "|".join(values)
|
||
|
||
|
||
def build_index(pages: Iterable[DictionaryPage]) -> list[dict[str, str]]:
|
||
rows = []
|
||
keys = set()
|
||
for page in sorted(pages, key=lambda item: (dictionary_slug(item), item.table.name.casefold())):
|
||
common = {
|
||
"scope": page.scope,
|
||
"table_id": page.table.table_id,
|
||
"table_name": page.table.name,
|
||
"table_alias": "|".join(page.table.aliases),
|
||
"extract_method": page.table.extract_method,
|
||
"access_code": page.table.access_code,
|
||
"page": _page_reference(page),
|
||
"tags": "|".join((*page.source_path, page.title)),
|
||
}
|
||
entity = {
|
||
**{column: "" for column in INDEX_COLUMNS},
|
||
**common,
|
||
"kind": page.kind,
|
||
"description": " ".join(page.notes),
|
||
}
|
||
rows.append(entity)
|
||
for field in page.fields:
|
||
row = {
|
||
**{column: "" for column in INDEX_COLUMNS},
|
||
**common,
|
||
"kind": "field",
|
||
"field_id": field.field_id,
|
||
"field_name": field.field_name,
|
||
"field_alias": _field_alias(field),
|
||
"data_type": field.data_type,
|
||
"unit": field.unit,
|
||
"description": field.description,
|
||
"api_name": field.api_name,
|
||
}
|
||
key = (
|
||
row["kind"],
|
||
row["table_id"],
|
||
row["table_name"].casefold(),
|
||
row["field_id"],
|
||
row["field_name"].casefold(),
|
||
row["field_alias"].casefold(),
|
||
row["page"],
|
||
)
|
||
if key in keys:
|
||
raise DictionaryParseError(f"duplicate dictionary index key: {key}")
|
||
keys.add(key)
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
def _atomic_write_text(path: Path, content: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
encoded = content.encode("utf-8")
|
||
descriptor, temporary_name = tempfile.mkstemp(
|
||
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
||
)
|
||
temporary = Path(temporary_name)
|
||
try:
|
||
with os.fdopen(descriptor, "wb") as handle:
|
||
handle.write(encoded)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(temporary, path)
|
||
if path.read_bytes() != encoded:
|
||
raise OSError(f"generated file verification failed: {path}")
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
|
||
|
||
def write_index(rows: Iterable[dict[str, str]], path: Path) -> None:
|
||
handle = io.StringIO(newline="")
|
||
writer = csv.DictWriter(handle, fieldnames=INDEX_COLUMNS, delimiter="\t", lineterminator="\n")
|
||
writer.writeheader()
|
||
for row in rows:
|
||
writer.writerow({column: row.get(column, "") for column in INDEX_COLUMNS})
|
||
_atomic_write_text(path, handle.getvalue())
|
||
|
||
|
||
def validate_lexicon(lexicon: object) -> dict[str, list[str]]:
|
||
if not isinstance(lexicon, dict):
|
||
raise ValueError("dictionary lexicon must be a JSON object")
|
||
validated = {}
|
||
for canonical, aliases in lexicon.items():
|
||
if not isinstance(canonical, str) or not canonical.strip():
|
||
raise ValueError("dictionary lexicon contains an empty canonical term")
|
||
if not isinstance(aliases, list) or not all(isinstance(item, str) and item.strip() for item in aliases):
|
||
raise ValueError(f"aliases for {canonical!r} must be non-empty strings")
|
||
validated[canonical] = list(dict.fromkeys(aliases))
|
||
return validated
|
||
|
||
|
||
def write_lexicon(lexicon: object, path: Path) -> None:
|
||
validated = validate_lexicon(lexicon)
|
||
_atomic_write_text(path, json.dumps(validated, ensure_ascii=False, indent=2) + "\n")
|
||
|
||
|
||
def load_lexicon(path: Path) -> dict[str, list[str]]:
|
||
return validate_lexicon(json.loads(path.read_text(encoding="utf-8")))
|
||
|
||
|
||
def build_snapshot(source_root: Path, output_root: Path, index_path: Path) -> BuildReport:
|
||
records = []
|
||
ignored = []
|
||
malformed = []
|
||
dictionary_count = 0
|
||
navigation_count = 0
|
||
source_paths = sorted(source_root.glob("*.html"), key=lambda path: int(path.stem) if path.stem.isdigit() else path.name)
|
||
for source_path in source_paths:
|
||
try:
|
||
document = parse_dictionary_document(source_path)
|
||
except NotDictionaryPage:
|
||
ignored.append(source_path.name)
|
||
continue
|
||
except DictionaryParseError as error:
|
||
dictionary_count += 1
|
||
malformed.append(f"{source_path.name}: {error}")
|
||
continue
|
||
dictionary_count += 1
|
||
if not document.records:
|
||
navigation_count += 1
|
||
continue
|
||
records.extend(document.records)
|
||
|
||
records = list(_resolve_slug_collisions(records))
|
||
slugs = {dictionary_slug(page): page.source_file for page in records}
|
||
|
||
output_root.mkdir(parents=True, exist_ok=True)
|
||
expected = set(slugs)
|
||
for stale in output_root.glob("*.md"):
|
||
if stale.name not in expected:
|
||
stale.unlink()
|
||
for page in records:
|
||
_atomic_write_text(output_root / dictionary_slug(page), render_dictionary_page(page))
|
||
rows = build_index(records)
|
||
write_index(rows, index_path)
|
||
return BuildReport(
|
||
scanned_page_count=len(source_paths),
|
||
dictionary_page_count=dictionary_count,
|
||
navigation_page_count=navigation_count,
|
||
page_count=len(records),
|
||
table_count=sum(page.kind == "table" for page in records),
|
||
source_count=sum(page.kind == "source" for page in records),
|
||
field_count=sum(len(page.fields) for page in records),
|
||
ignored_pages=tuple(ignored),
|
||
malformed_dictionary_pages=tuple(malformed),
|
||
)
|
||
|
||
|
||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
|
||
parser.add_argument("--source-root", required=True, type=Path)
|
||
parser.add_argument("--output-root", required=True, type=Path)
|
||
parser.add_argument("--index", required=True, type=Path)
|
||
parser.add_argument("--lexicon", type=Path)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main(argv: Sequence[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
if args.lexicon is not None:
|
||
load_lexicon(args.lexicon)
|
||
report = build_snapshot(args.source_root, args.output_root, args.index)
|
||
print(
|
||
f"dictionary_pages={report.dictionary_page_count} generated_pages={report.page_count} "
|
||
f"tables={report.table_count} sources={report.source_count} fields={report.field_count} "
|
||
f"navigation={report.navigation_page_count} malformed={len(report.malformed_dictionary_pages)}"
|
||
)
|
||
for item in report.malformed_dictionary_pages:
|
||
print(f"MALFORMED\t{item}")
|
||
return 1 if report.malformed_dictionary_pages else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|