skills-vault/skills/repair-markdown-citations/scripts/repair_markdown_citations.py

305 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Repair ChatGPT citation tokens in Markdown exports."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
from dataclasses import dataclass
from typing import Any
CITATION_RE = re.compile(r"cite(?:turn\d+(?:view|search)\d+)+")
REF_ID_RE = re.compile(r"turn\d+(?:view|search)\d+")
FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})")
LINK_RE = re.compile(r"\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)")
REFERENCE_HEADING_RE = re.compile(r"^## +参考资料\s*$", re.MULTILINE)
HTML_METADATA_RE = re.compile(
r"<!--\s*deep-research-metadata\s*(?P<json>\{.*?\})\s*-->",
re.DOTALL,
)
@dataclass(frozen=True)
class CitationRecord:
ref_id: str
url: str
title: str = ""
attribution: str = ""
def footnote_text(self) -> str:
label = self.title or self.url
if self.attribution and self.title:
return f"{self.attribution}{self.title}{self.url}"
return f"{label}{self.url}"
def extract_ref_ids(token: str) -> list[str]:
return REF_ID_RE.findall(token)
def ref_id_from_ref(ref: dict[str, Any]) -> str | None:
turn_index = ref.get("turn_index")
ref_type = ref.get("ref_type")
ref_index = ref.get("ref_index")
if turn_index is None or ref_type not in {"view", "search"} or ref_index is None:
return None
return f"turn{turn_index}{ref_type}{ref_index}"
def _first_string(*values: Any) -> str:
for value in values:
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def record_from_ref(ref: dict[str, Any]) -> CitationRecord | None:
ref_id = _first_string(ref.get("ref_id"), ref.get("id")) or ref_id_from_ref(ref)
url = _first_string(ref.get("url"), ref.get("safe_url"), ref.get("source_url"))
title = _first_string(ref.get("title"), ref.get("name"), ref.get("text"))
attribution = _first_string(ref.get("attribution"), ref.get("source"), ref.get("site_name"))
if not ref_id or not url:
return None
return CitationRecord(ref_id=ref_id, url=url, title=title, attribution=attribution)
def _walk_dicts(value: Any) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(value, dict):
found.append(value)
for child in value.values():
found.extend(_walk_dicts(child))
elif isinstance(value, list):
for child in value:
found.extend(_walk_dicts(child))
return found
def build_citation_map(metadata: dict[str, Any] | None) -> dict[str, CitationRecord]:
if not metadata:
return {}
citation_map: dict[str, CitationRecord] = {}
for node in _walk_dicts(metadata):
record = record_from_ref(node)
if record and record.ref_id not in citation_map:
citation_map[record.ref_id] = record
return citation_map
def split_existing_references(markdown: str) -> tuple[str, str]:
match = REFERENCE_HEADING_RE.search(markdown)
if not match:
return markdown.rstrip(), ""
return markdown[: match.start()].rstrip(), markdown[match.start() :].strip()
def collect_markdown_links(markdown: str) -> list[str]:
seen: set[str] = set()
links: list[str] = []
for link in LINK_RE.findall(markdown):
if link not in seen:
seen.add(link)
links.append(link)
return links
def replace_citations(
markdown: str, citation_map: dict[str, CitationRecord]
) -> tuple[str, list[CitationRecord], list[str]]:
footnote_numbers: dict[str, int] = {}
ordered_records: list[CitationRecord] = []
missing: list[str] = []
def marker_for_ref(ref_id: str) -> str:
if ref_id not in citation_map:
if ref_id not in missing:
missing.append(ref_id)
return ""
if ref_id not in footnote_numbers:
footnote_numbers[ref_id] = len(ordered_records) + 1
ordered_records.append(citation_map[ref_id])
return f"[^{footnote_numbers[ref_id]}]"
output: list[str] = []
in_fence = False
fence_marker = ""
for line in markdown.splitlines(keepends=True):
body = line[:-1] if line.endswith("\n") else line
newline = "\n" if line.endswith("\n") else ""
if body.endswith("\r"):
body = body[:-1]
newline = "\r" + newline
fence_match = FENCE_RE.match(body)
if fence_match:
marker = fence_match.group(2)
marker_char = marker[0]
if not in_fence:
in_fence = True
fence_marker = marker_char * len(marker)
elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker):
in_fence = False
fence_marker = ""
output.append(line)
continue
if in_fence:
output.append(line)
continue
def replace_token(match: re.Match[str]) -> str:
return "".join(marker_for_ref(ref_id) for ref_id in extract_ref_ids(match.group(0)))
output.append(CITATION_RE.sub(replace_token, body) + newline)
return "".join(output), ordered_records, missing
def strip_citation_tokens(markdown: str) -> str:
lines: list[str] = []
in_fence = False
fence_marker = ""
for line in markdown.splitlines(keepends=True):
body = line[:-1] if line.endswith("\n") else line
newline = "\n" if line.endswith("\n") else ""
if body.endswith("\r"):
body = body[:-1]
newline = "\r" + newline
fence_match = FENCE_RE.match(body)
if fence_match:
marker = fence_match.group(2)
marker_char = marker[0]
if not in_fence:
in_fence = True
fence_marker = marker_char * len(marker)
elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker):
in_fence = False
fence_marker = ""
lines.append(line)
continue
if in_fence:
lines.append(line)
else:
lines.append(CITATION_RE.sub("", body) + newline)
return "".join(lines)
def render_reference_section(
records: list[CitationRecord], existing_links: list[str], missing: list[str], degraded: bool
) -> str:
lines = ["## 参考资料"]
if degraded:
lines.append("")
lines.append("> 无法恢复逐句脚注映射:未找到可用元数据或部分 turn ID 缺少 URL。")
if records:
lines.append("")
for index, record in enumerate(records, start=1):
lines.append(f"[^{index}]: {record.footnote_text()}")
if existing_links:
lines.append("")
lines.append("### 原始来源链接")
for link in existing_links:
lines.append(f"- {link}")
if missing:
lines.append("")
lines.append("### 未解析引用 ID")
for ref_id in missing:
lines.append(f"- `{ref_id}`")
return "\n".join(lines)
def repair_markdown(markdown: str, metadata: dict[str, Any] | None = None) -> str:
body, existing_reference_section = split_existing_references(markdown)
existing_links = collect_markdown_links(existing_reference_section)
citation_map = build_citation_map(metadata)
if citation_map:
replaced, records, missing = replace_citations(body, citation_map)
degraded = bool(missing)
cleaned = strip_citation_tokens(replaced) if missing else replaced
reference_section = render_reference_section(records, existing_links, missing, degraded)
return f"{cleaned.rstrip()}\n\n{reference_section}\n"
cleaned = strip_citation_tokens(body)
reference_section = render_reference_section([], existing_links, [], degraded=True)
return f"{cleaned.rstrip()}\n\n{reference_section}\n"
def load_json(path: pathlib.Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError("metadata JSON must contain an object at the top level")
return value
def extract_embedded_metadata(markdown: str) -> dict[str, Any] | None:
match = HTML_METADATA_RE.search(markdown)
if not match:
return None
value = json.loads(match.group("json"))
if not isinstance(value, dict):
raise ValueError("embedded metadata JSON must contain an object at the top level")
return value
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Repair ChatGPT/Deep Research citation tokens in Markdown."
)
parser.add_argument("path", type=pathlib.Path, help="Markdown file to repair")
parser.add_argument("--metadata", type=pathlib.Path, help="Deep Research metadata JSON")
parser.add_argument("-o", "--output", type=pathlib.Path, help="Write repaired Markdown here")
parser.add_argument("--in-place", action="store_true", help="Rewrite the input Markdown file")
parser.add_argument("--dry-run", action="store_true", help="Print repaired Markdown to stdout")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
if sum(bool(value) for value in (args.output, args.in_place, args.dry_run)) != 1:
print("error: choose exactly one of --output, --in-place, or --dry-run", file=sys.stderr)
return 2
try:
markdown = args.path.read_text(encoding="utf-8")
metadata = load_json(args.metadata) if args.metadata else extract_embedded_metadata(markdown)
fixed = repair_markdown(markdown, metadata=metadata)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.dry_run:
sys.stdout.write(fixed)
return 0
destination = args.path if args.in_place else args.output
assert destination is not None
try:
destination.write_text(fixed, encoding="utf-8")
except OSError as exc:
print(f"error: cannot write {destination}: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))