#!/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"", 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:]))