@adgine/geo-cli 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +93 -0
  2. package/bin/adgine-geo.js +224 -0
  3. package/package.json +38 -0
  4. package/skills/.env.example +11 -0
  5. package/skills/README.md +247 -0
  6. package/skills/SKILL.md +117 -0
  7. package/skills/VERSION +1 -0
  8. package/skills/adgine-geo-aiagent/SKILL.md +200 -0
  9. package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
  10. package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
  11. package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
  12. package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
  13. package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
  14. package/skills/adgine-geo-analytics/API.md +89 -0
  15. package/skills/adgine-geo-analytics/SKILL.md +191 -0
  16. package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
  17. package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
  18. package/skills/adgine-geo-billing/SKILL.md +118 -0
  19. package/skills/adgine-geo-billing/scripts/_client.py +341 -0
  20. package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
  21. package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
  22. package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
  23. package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
  24. package/skills/adgine-geo-brand/SKILL.md +193 -0
  25. package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
  26. package/skills/adgine-geo-brand/scripts/_client.py +341 -0
  27. package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
  28. package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
  29. package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
  30. package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
  31. package/skills/adgine-geo-citation/SKILL.md +220 -0
  32. package/skills/adgine-geo-citation/scripts/_client.py +341 -0
  33. package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
  34. package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
  35. package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
  36. package/skills/adgine-geo-content/SKILL.md +224 -0
  37. package/skills/adgine-geo-content/WORKFLOW.md +100 -0
  38. package/skills/adgine-geo-content/scripts/_client.py +341 -0
  39. package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
  40. package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
  41. package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
  42. package/skills/adgine-geo-content/scripts/list_content.py +65 -0
  43. package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
  44. package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
  45. package/skills/adgine-geo-dashboard/SKILL.md +181 -0
  46. package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
  47. package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
  48. package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
  49. package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
  50. package/skills/adgine-geo-docs/SKILL.md +60 -0
  51. package/skills/adgine-geo-domains/SKILL.md +223 -0
  52. package/skills/adgine-geo-domains/scripts/_client.py +341 -0
  53. package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
  54. package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
  55. package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
  56. package/skills/adgine-geo-integrations/SKILL.md +188 -0
  57. package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
  58. package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
  59. package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
  60. package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
  61. package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
  62. package/skills/adgine-geo-opportunities/SKILL.md +162 -0
  63. package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
  64. package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
  65. package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
  66. package/skills/adgine-geo-performance/SKILL.md +177 -0
  67. package/skills/adgine-geo-performance/scripts/_client.py +341 -0
  68. package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
  69. package/skills/adgine-geo-projects/SKILL.md +279 -0
  70. package/skills/adgine-geo-projects/scripts/_client.py +341 -0
  71. package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
  72. package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
  73. package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
  74. package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
  75. package/skills/adgine-geo-saas/SKILL.md +144 -0
  76. package/skills/adgine-geo-saas/scripts/_client.py +341 -0
  77. package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
  78. package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
  79. package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
  80. package/skills/adgine-geo-site-audit/README.md +281 -0
  81. package/skills/adgine-geo-site-audit/SKILL.md +449 -0
  82. package/skills/adgine-geo-site-audit/requirements.txt +5 -0
  83. package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
  84. package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
  85. package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
  86. package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
  87. package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
  88. package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
  89. package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
  90. package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
  91. package/skills/adgine-geo-topics/SKILL.md +232 -0
  92. package/skills/adgine-geo-topics/scripts/_client.py +341 -0
  93. package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
  94. package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
  95. package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
  96. package/skills/adgine-geo-visibility/SKILL.md +168 -0
  97. package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
  98. package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
  99. package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
  100. package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
  101. package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
  102. package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
  103. package/skills/adgine-geo-wordpress/SKILL.md +151 -0
  104. package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
  105. package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
  106. package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
  107. package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
  108. package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
  109. package/skills/scripts/check_version.py +290 -0
  110. package/skills/setup.py +248 -0
  111. package/src/config.js +126 -0
  112. package/src/doctor.js +137 -0
  113. package/src/install.js +90 -0
  114. package/src/manifest.js +99 -0
  115. package/src/runner.js +159 -0
@@ -0,0 +1,1502 @@
1
+ #!/usr/bin/env python3
2
+ """Render a GEO audit Markdown report to PDF."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import glob
8
+ import html
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+
22
+ CHROME_PATHS = [
23
+ "headless_shell",
24
+ "headless_shell.exe",
25
+ "chromium",
26
+ "chromium.exe",
27
+ "chromium-browser",
28
+ "google-chrome",
29
+ "google-chrome-stable",
30
+ "chrome",
31
+ "chrome.exe",
32
+ "msedge",
33
+ "msedge.exe",
34
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
35
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
36
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
37
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
38
+ ]
39
+
40
+ PDF_EXPORT_PROMPT_RE = re.compile(
41
+ r"^\s*(?:[-*]\s*)?需要我将本报告导出为\s*PDF\s*吗?(?:回复[“\"]导出\s*PDF[”\"]即可。?)?\s*$",
42
+ re.MULTILINE,
43
+ )
44
+ PDF_COLLECT_RESULT_RE = re.compile(
45
+ r"^\s*(?:[-*]\s*)?(?:\*\*)?采集结果(?:\*\*)?\s*[::].*$",
46
+ re.MULTILINE,
47
+ )
48
+ AUDIT_TIME_CST_RE = re.compile(
49
+ r"^(\s*(?:\*\*)?审计时间(?:\*\*)?\s*[::]\s*[^\n]*?)\s+CST(\s*)$",
50
+ re.MULTILINE,
51
+ )
52
+ VISIBILITY_NOTE = (
53
+ "> 本章节基于当前运行模型采样,仅用于粗略观察品牌在 AI 回答中的可见性;"
54
+ "该采样不参与 GEO 总分。如需更精确、持续的 AI 可见性数据,"
55
+ "可注册 [adgine.ai](https://adgine.ai/) 进行持续监测。"
56
+ )
57
+ LEGACY_VISIBILITY_NOTES = (
58
+ "> 本章节基于当前运行模型的隔离采样,不代表 ChatGPT、Claude、Perplexity 等线上产品的真实可见性结果;该采样不参与 GEO 总分。",
59
+ "> 基于当前运行模型隔离采样,不代表 ChatGPT、Claude、Perplexity 等线上产品真实可见性;该采样不参与 GEO 总分。",
60
+ )
61
+ VISIBILITY_SCORE_RE = re.compile(
62
+ r"\*\*AI 可见性采样分\*\*\s*[::]\s*([^\n<]+)",
63
+ re.MULTILINE,
64
+ )
65
+
66
+ DIMENSION_DISPLAY_LABELS = {
67
+ "D1": "维度一:技术可达性",
68
+ "D2": "维度二:内容质量",
69
+ "D3": "维度三:品牌实体",
70
+ "D4": "维度四:AI 可引用性",
71
+ "D5": "维度五:AI 可推荐",
72
+ }
73
+
74
+
75
+ def absolute_path(path: Path) -> Path:
76
+ """Return an absolute path without resolving symlinks such as /tmp on macOS."""
77
+ return path.expanduser().absolute()
78
+
79
+
80
+ def _elapsed_since(started: float) -> float:
81
+ return round(time.perf_counter() - started, 3)
82
+
83
+
84
+ def write_timing_file(
85
+ output_path: Optional[str],
86
+ *,
87
+ input_path: Path,
88
+ pdf_path: Path,
89
+ engine: str,
90
+ timings: dict[str, float],
91
+ engine_errors: Optional[list[str]] = None,
92
+ ) -> None:
93
+ if not output_path:
94
+ return
95
+ payload = {
96
+ "input": str(input_path),
97
+ "output": str(pdf_path),
98
+ "engine": engine,
99
+ "timings": timings,
100
+ "engine_errors": engine_errors or [],
101
+ }
102
+ Path(output_path).expanduser().absolute().write_text(
103
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
104
+ encoding="utf-8",
105
+ )
106
+
107
+
108
+ def chrome_candidate_paths() -> list[str]:
109
+ candidates: list[str] = []
110
+ env_path = os.environ.get("GEO_AUDIT_CHROME_PATH")
111
+ if env_path:
112
+ candidates.append(env_path)
113
+
114
+ home = Path.home()
115
+ local_app_data = os.environ.get("LOCALAPPDATA")
116
+ program_files = [os.environ.get("PROGRAMFILES"), os.environ.get("PROGRAMFILES(X86)")]
117
+ playwright_patterns = [
118
+ str(home / "Library/Caches/ms-playwright/chromium-*/chrome-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
119
+ str(home / "Library/Caches/ms-playwright/chromium-*/chrome-*/Chromium.app/Contents/MacOS/Chromium"),
120
+ str(home / "Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-*/headless_shell"),
121
+ str(home / "Library/Caches/ms-playwright/chromium-*/chrome-*/headless_shell"),
122
+ str(home / ".cache/ms-playwright/chromium-*/chrome-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
123
+ str(home / ".cache/ms-playwright/chromium-*/chrome-*/Chromium.app/Contents/MacOS/Chromium"),
124
+ str(home / ".cache/ms-playwright/chromium_headless_shell-*/chrome-*/headless_shell"),
125
+ str(home / ".cache/ms-playwright/chromium-*/chrome-*/headless_shell"),
126
+ ]
127
+ if local_app_data:
128
+ local_ms_playwright = Path(local_app_data) / "ms-playwright"
129
+ playwright_patterns.extend([
130
+ str(local_ms_playwright / "chromium-*" / "chrome-win" / "chrome.exe"),
131
+ str(local_ms_playwright / "chromium-*" / "chrome-win" / "Chromium.exe"),
132
+ str(local_ms_playwright / "chromium_headless_shell-*" / "chrome-win" / "headless_shell.exe"),
133
+ str(local_ms_playwright / "chromium-*" / "chrome-win" / "headless_shell.exe"),
134
+ ])
135
+ for base in program_files:
136
+ if not base:
137
+ continue
138
+ root = Path(base)
139
+ candidates.extend([
140
+ str(root / "Google/Chrome for Testing/Application/chrome.exe"),
141
+ str(root / "Google/Chrome/Application/chrome.exe"),
142
+ str(root / "Microsoft/Edge/Application/msedge.exe"),
143
+ str(root / "Chromium/Application/chrome.exe"),
144
+ ])
145
+ for pattern in playwright_patterns:
146
+ candidates.extend(sorted(glob.glob(pattern), reverse=True))
147
+
148
+ candidates.extend(CHROME_PATHS)
149
+
150
+ deduped: list[str] = []
151
+ seen: set[str] = set()
152
+ for candidate in candidates:
153
+ if candidate and candidate not in seen:
154
+ deduped.append(candidate)
155
+ seen.add(candidate)
156
+ return deduped
157
+
158
+
159
+ def find_chrome() -> Optional[str]:
160
+ for path in chrome_candidate_paths():
161
+ found = shutil.which(path)
162
+ if found:
163
+ return found
164
+ if os.path.isfile(path) and os.access(path, os.X_OK):
165
+ return path
166
+ return None
167
+
168
+
169
+ def _popen_browser(cmd: list[str]) -> subprocess.Popen:
170
+ kwargs: dict[str, Any] = {
171
+ "stdout": subprocess.PIPE,
172
+ "stderr": subprocess.PIPE,
173
+ "text": True,
174
+ }
175
+ if os.name == "nt":
176
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
177
+ else:
178
+ kwargs["start_new_session"] = True
179
+ return subprocess.Popen(cmd, **kwargs)
180
+
181
+
182
+ def _terminate_browser(process: subprocess.Popen) -> None:
183
+ if process.poll() is not None:
184
+ return
185
+ try:
186
+ if os.name == "nt":
187
+ process.terminate()
188
+ else:
189
+ os.killpg(process.pid, signal.SIGTERM)
190
+ process.communicate(timeout=3)
191
+ return
192
+ except Exception:
193
+ pass
194
+
195
+ try:
196
+ if os.name == "nt":
197
+ process.kill()
198
+ else:
199
+ os.killpg(process.pid, signal.SIGKILL)
200
+ except Exception:
201
+ try:
202
+ process.kill()
203
+ except Exception:
204
+ pass
205
+
206
+ try:
207
+ process.communicate(timeout=2)
208
+ except Exception:
209
+ # Some macOS Chrome/headless_shell crashes can leave the child process
210
+ # temporarily uninterruptible. Do not let cleanup block the exporter.
211
+ pass
212
+
213
+
214
+ def chrome_pdf_ready(chrome_path: str) -> tuple[bool, str]:
215
+ with tempfile.TemporaryDirectory(prefix="geo-audit-pdf-check-") as tmpdir:
216
+ html_path = Path(tmpdir) / "check.html"
217
+ pdf_path = Path(tmpdir) / "check.pdf"
218
+ html_path.write_text("<!doctype html><html><body>PDF check</body></html>", encoding="utf-8")
219
+ code, message = print_to_pdf(chrome_path, html_path, pdf_path, timeout_seconds=5)
220
+ if code == 0:
221
+ return True, message
222
+ return False, message
223
+
224
+
225
+ def resolve_output_path(input_path: Path, output_arg: Optional[str]) -> Path:
226
+ if output_arg:
227
+ return absolute_path(Path(output_arg))
228
+ return absolute_path(input_path.with_suffix(".pdf"))
229
+
230
+
231
+ def _extract_title(markdown_text: str, fallback: str = "GEO Audit Report") -> str:
232
+ match = re.search(r"^#\s+(.+?)\s*$", markdown_text, re.MULTILINE)
233
+ return match.group(1).strip() if match else fallback
234
+
235
+
236
+ def strip_pdf_export_prompt(markdown_text: str) -> str:
237
+ cleaned = PDF_EXPORT_PROMPT_RE.sub("", markdown_text)
238
+ return re.sub(r"\n{3,}", "\n\n", cleaned).strip() + "\n"
239
+
240
+
241
+ def normalize_audit_timezone(markdown_text: str) -> str:
242
+ return AUDIT_TIME_CST_RE.sub(r"\1 Asia/Shanghai (UTC+08:00)\2", markdown_text)
243
+
244
+
245
+ def strip_legacy_visibility_detail_columns(markdown_text: str) -> str:
246
+ lines = markdown_text.splitlines()
247
+ output: list[str] = []
248
+ trimming_visibility_table = False
249
+ for line in lines:
250
+ stripped = line.strip()
251
+ if stripped == "| ID | 类型 | Prompt | 是否可见 | 排名 | 官网/站内来源 | 实体正确 | 幻觉 |":
252
+ output.append("| ID | 类型 | Prompt | 是否可见 | 排名 | 官网/站内来源 |")
253
+ trimming_visibility_table = True
254
+ continue
255
+ if trimming_visibility_table:
256
+ if stripped.startswith("|") and set(stripped) <= {"|", "-", ":", " "}:
257
+ output.append("|----|------|--------|------|---:|------|")
258
+ continue
259
+ if stripped.startswith("|"):
260
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
261
+ output.append("| " + " | ".join(cells[:6]) + " |")
262
+ continue
263
+ trimming_visibility_table = False
264
+ output.append(line)
265
+ return "\n".join(output)
266
+
267
+
268
+ def extract_ai_visibility_score(markdown_text: str) -> str:
269
+ match = VISIBILITY_SCORE_RE.search(markdown_text)
270
+ if not match:
271
+ return "未执行"
272
+ return match.group(1).replace("<br>", "").strip().rstrip()
273
+
274
+
275
+ def remove_legacy_visibility_score_heading(markdown_text: str) -> str:
276
+ lines = markdown_text.splitlines()
277
+ output: list[str] = []
278
+ i = 0
279
+ while i < len(lines):
280
+ if lines[i].strip() == "## 可见性得分":
281
+ i += 1
282
+ while i < len(lines) and not lines[i].strip().startswith("## GEO 总分:"):
283
+ i += 1
284
+ if i < len(lines) and lines[i].strip().startswith("## GEO 总分:"):
285
+ output.append(lines[i])
286
+ i += 1
287
+ continue
288
+ output.append(lines[i])
289
+ i += 1
290
+ return "\n".join(output)
291
+
292
+
293
+ def remove_geo_score_detail_lines(markdown_text: str) -> str:
294
+ detail_labels = (
295
+ "公开数据 GEO 分",
296
+ "AI 可见性采样分",
297
+ "最终综合分",
298
+ "公开数据 GEO 原始分",
299
+ "上限调整",
300
+ "综合分公式",
301
+ )
302
+ lines = markdown_text.splitlines()
303
+ output: list[str] = []
304
+ in_geo_score = False
305
+ for line in lines:
306
+ stripped = line.strip()
307
+ if stripped.startswith("## GEO 总分"):
308
+ in_geo_score = True
309
+ output.append(line)
310
+ continue
311
+ if in_geo_score and stripped.startswith("## ") and not stripped.startswith("## GEO 总分"):
312
+ in_geo_score = False
313
+ if in_geo_score and any(stripped.startswith(f"**{label}**") for label in detail_labels):
314
+ continue
315
+ output.append(line)
316
+ return "\n".join(output)
317
+
318
+
319
+ def inject_d5_score_row(markdown_text: str, d5_score: str) -> str:
320
+ # New reports use the Excel-derived 5-dimension table. AI visibility is a
321
+ # separate reference section and must not be injected into the score table.
322
+ if "维度五:AI 可推荐" in markdown_text or "AI 可见性采样参考" in markdown_text:
323
+ return markdown_text
324
+ lines = markdown_text.splitlines()
325
+ output: list[str] = []
326
+ in_score_table = False
327
+ has_d5 = False
328
+ comprehensive_weights = {
329
+ "D1": "14%",
330
+ "D2": "17.5%",
331
+ "D3": "17.5%",
332
+ "D4": "21%",
333
+ }
334
+ for line in lines:
335
+ stripped = line.strip()
336
+ was_d4_row = stripped.startswith("| D4 ")
337
+ if stripped in {"| 维度 | 得分 | 权重 |", "| 维度 | 得分 | 综合权重 |"}:
338
+ in_score_table = True
339
+ has_d5 = False
340
+ output.append("| 评估维度 | 得分 | 综合权重 |")
341
+ continue
342
+ if in_score_table and stripped.startswith("| D5 "):
343
+ has_d5 = True
344
+ line = line.replace("D5 AI 可推荐", DIMENSION_DISPLAY_LABELS["D5"])
345
+ stripped = line.strip()
346
+ if in_score_table and stripped.startswith("| D") and not stripped.startswith("| D5 "):
347
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
348
+ if len(cells) == 3:
349
+ dimension_key = cells[0].split(maxsplit=1)[0]
350
+ if dimension_key in comprehensive_weights:
351
+ line = f"| {DIMENSION_DISPLAY_LABELS[dimension_key]} | {cells[1]} | {comprehensive_weights[dimension_key]} |"
352
+ stripped = line.strip()
353
+ output.append(line)
354
+ if in_score_table and was_d4_row and not has_d5 and d5_score != "未执行":
355
+ output.append(f"| AI 可见性采样参考 | {d5_score} | 不参与总分 |")
356
+ has_d5 = True
357
+ continue
358
+ if in_score_table and stripped and not stripped.startswith("|"):
359
+ in_score_table = False
360
+ return "\n".join(output)
361
+
362
+
363
+ def normalize_dimension_display_labels(markdown_text: str) -> str:
364
+ replacements = {
365
+ r"^## D1:\s*技术可达性": "## 维度一:技术可达性",
366
+ r"^## D2:\s*内容质量": "## 维度二:内容质量",
367
+ r"^## D3:\s*品牌实体": "## 维度三:品牌实体",
368
+ r"^## D4:\s*AI\s*可引用性": "## 维度四:AI 可引用性",
369
+ r"^## D5:\s*AI\s*可推荐": "## 维度五:AI 可推荐",
370
+ }
371
+ normalized = markdown_text
372
+ for pattern, replacement in replacements.items():
373
+ normalized = re.sub(pattern, replacement, normalized, flags=re.MULTILINE)
374
+ normalized = re.sub(r"(\|\s*)D([1-4])\.(\d+)(\s*\|)", r"\1\2.\3\4", normalized)
375
+ return normalized
376
+
377
+
378
+ def remove_visibility_section_score_rows(markdown_text: str) -> str:
379
+ lines = markdown_text.splitlines()
380
+ output: list[str] = []
381
+ for line in lines:
382
+ stripped = line.strip()
383
+ if stripped.startswith("| 公开数据 GEO 分 |") or stripped.startswith("| 最终综合分 |"):
384
+ continue
385
+ output.append(line)
386
+ return "\n".join(output)
387
+
388
+
389
+ def normalize_numbered_list_continuations(markdown_text: str) -> str:
390
+ lines = markdown_text.splitlines()
391
+ output: list[str] = []
392
+ i = 0
393
+ while i < len(lines):
394
+ line = lines[i]
395
+ match = re.match(r"^(\d+)\.\s+(.+?)\s*$", line)
396
+ if not match:
397
+ output.append(line)
398
+ i += 1
399
+ continue
400
+
401
+ continuation: list[str] = []
402
+ j = i + 1
403
+ while j < len(lines) and not lines[j].strip():
404
+ j += 1
405
+ while j < len(lines):
406
+ stripped = lines[j].strip()
407
+ if not stripped:
408
+ break
409
+ if (
410
+ re.match(r"^\d+\.\s+", stripped)
411
+ or stripped.startswith("#")
412
+ or stripped.startswith("|")
413
+ or stripped.startswith("- ")
414
+ or stripped.startswith("```")
415
+ ):
416
+ break
417
+ continuation.append(stripped)
418
+ j += 1
419
+ if continuation:
420
+ item_text = re.sub(r"\s{2,}$", "", match.group(2)).strip()
421
+ output.append(f"{match.group(1)}. {item_text}: {' '.join(continuation)}")
422
+ k = j
423
+ while k < len(lines) and not lines[k].strip():
424
+ k += 1
425
+ i = k if k < len(lines) and re.match(r"^\d+\.\s+", lines[k].strip()) else j
426
+ continue
427
+
428
+ output.append(line)
429
+ i += 1
430
+ return "\n".join(output)
431
+
432
+
433
+ def prepare_markdown_for_pdf(markdown_text: str) -> str:
434
+ d5_score = extract_ai_visibility_score(markdown_text)
435
+ cleaned = strip_pdf_export_prompt(markdown_text)
436
+ cleaned = normalize_audit_timezone(cleaned)
437
+ cleaned = PDF_COLLECT_RESULT_RE.sub("", cleaned)
438
+ cleaned = re.sub(r"^##\s*演示说明\s*$", "## 报告说明", cleaned, flags=re.MULTILINE)
439
+ cleaned = cleaned.replace("Agent 模拟可见性测试", "AI 可见性采样参考")
440
+ cleaned = cleaned.replace("## D6: AI 可见性采样测试", "## AI 可见性采样参考")
441
+ cleaned = cleaned.replace("## D5: AI 可见性采样测试", "## AI 可见性采样参考")
442
+ cleaned = cleaned.replace("## AI 可见性采样测试", "## AI 可见性采样参考")
443
+ cleaned = cleaned.replace("Agent 模拟可见性分", "AI 可见性采样分")
444
+ cleaned = cleaned.replace("Agent 可见性总分", "AI 可见性采样分")
445
+ cleaned = cleaned.replace("Agent 可见性", "AI 可见性采样")
446
+ cleaned = cleaned.replace("agent 模型", "运行模型")
447
+ for legacy_note in LEGACY_VISIBILITY_NOTES:
448
+ cleaned = cleaned.replace(legacy_note, VISIBILITY_NOTE)
449
+ cleaned = cleaned.replace("| 幻觉率 |", "| 事实错误率 |")
450
+ cleaned = cleaned.replace(
451
+ "可在 https://adgine.ai/ 注册后使用",
452
+ "可在 [adgine.ai](https://adgine.ai/) 注册后使用",
453
+ )
454
+ cleaned = strip_legacy_visibility_detail_columns(cleaned)
455
+ cleaned = remove_legacy_visibility_score_heading(cleaned)
456
+ cleaned = remove_geo_score_detail_lines(cleaned)
457
+ cleaned = inject_d5_score_row(cleaned, d5_score)
458
+ cleaned = normalize_dimension_display_labels(cleaned)
459
+ cleaned = remove_visibility_section_score_rows(cleaned)
460
+ cleaned = normalize_numbered_list_continuations(cleaned)
461
+ return re.sub(r"\n{3,}", "\n\n", cleaned).strip() + "\n"
462
+
463
+
464
+ def _inline_markdown_to_html(text: str) -> str:
465
+ code_tokens: list[str] = []
466
+
467
+ def stash_code(match: re.Match[str]) -> str:
468
+ code_tokens.append(f"<code>{html.escape(match.group(1))}</code>")
469
+ return f"@@GEO_CODE_{len(code_tokens) - 1}@@"
470
+
471
+ text = re.sub(r"`([^`\n]+)`", stash_code, text)
472
+ rendered = html.escape(text)
473
+
474
+ def render_link(match: re.Match[str]) -> str:
475
+ label = match.group(1)
476
+ href = match.group(2)
477
+ return f'<a href="{href}">{label}</a>'
478
+
479
+ rendered = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", render_link, rendered)
480
+ rendered = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", rendered)
481
+ rendered = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", rendered)
482
+
483
+ for i, token in enumerate(code_tokens):
484
+ rendered = rendered.replace(f"@@GEO_CODE_{i}@@", token)
485
+ return rendered
486
+
487
+
488
+ def _fallback_markdown_to_html(markdown_text: str) -> str:
489
+ """Small fallback renderer for tests and clear errors when markdown is absent."""
490
+ lines = markdown_text.splitlines()
491
+ out: list[str] = []
492
+ i = 0
493
+ while i < len(lines):
494
+ line = lines[i]
495
+ stripped = line.strip()
496
+ if not stripped:
497
+ i += 1
498
+ continue
499
+ if stripped.startswith("#"):
500
+ level = min(len(stripped) - len(stripped.lstrip("#")), 6)
501
+ text = stripped[level:].strip()
502
+ out.append(f"<h{level}>{_inline_markdown_to_html(text)}</h{level}>")
503
+ i += 1
504
+ continue
505
+ if stripped.startswith("```"):
506
+ i += 1
507
+ code_lines = []
508
+ while i < len(lines) and not lines[i].strip().startswith("```"):
509
+ code_lines.append(lines[i])
510
+ i += 1
511
+ if i < len(lines):
512
+ i += 1
513
+ code_text = html.escape("\n".join(code_lines))
514
+ out.append(f"<pre><code>{code_text}</code></pre>")
515
+ continue
516
+ if stripped.startswith("|") and i + 1 < len(lines) and set(lines[i + 1].strip()) <= {"|", "-", ":", " "}:
517
+ headers = [c.strip() for c in stripped.strip("|").split("|")]
518
+ i += 2
519
+ rows = []
520
+ while i < len(lines) and lines[i].strip().startswith("|"):
521
+ rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
522
+ i += 1
523
+ out.append("<table><thead><tr>")
524
+ out.extend(f"<th>{_inline_markdown_to_html(cell)}</th>" for cell in headers)
525
+ out.append("</tr></thead><tbody>")
526
+ for row in rows:
527
+ out.append("<tr>")
528
+ out.extend(f"<td>{_inline_markdown_to_html(cell)}</td>" for cell in row)
529
+ out.append("</tr>")
530
+ out.append("</tbody></table>")
531
+ continue
532
+ if stripped.startswith("- "):
533
+ out.append("<ul>")
534
+ while i < len(lines) and lines[i].strip().startswith("- "):
535
+ out.append(f"<li>{_inline_markdown_to_html(lines[i].strip()[2:].strip())}</li>")
536
+ i += 1
537
+ out.append("</ul>")
538
+ continue
539
+ if re.match(r"^\d+\.\s+", stripped):
540
+ out.append("<ol>")
541
+ while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
542
+ item = re.sub(r"^\d+\.\s+", "", lines[i].strip()).strip()
543
+ out.append(f"<li>{_inline_markdown_to_html(item)}</li>")
544
+ i += 1
545
+ out.append("</ol>")
546
+ continue
547
+ out.append(f"<p>{_inline_markdown_to_html(stripped)}</p>")
548
+ i += 1
549
+ return "\n".join(out)
550
+
551
+
552
+ def markdown_body_to_html(markdown_text: str) -> str:
553
+ try:
554
+ import markdown as markdown_lib # type: ignore
555
+ except ImportError:
556
+ return _fallback_markdown_to_html(markdown_text)
557
+
558
+ return markdown_lib.markdown(
559
+ markdown_text,
560
+ extensions=["tables", "fenced_code", "sane_lists"],
561
+ output_format="html5",
562
+ )
563
+
564
+
565
+ def render_markdown_to_html(markdown_text: str, title: Optional[str] = None) -> str:
566
+ markdown_text = prepare_markdown_for_pdf(markdown_text)
567
+ title = title or _extract_title(markdown_text)
568
+ body = markdown_body_to_html(markdown_text)
569
+ escaped_title = html.escape(title)
570
+ return f"""<!doctype html>
571
+ <html lang="zh-CN">
572
+ <head>
573
+ <meta charset="utf-8">
574
+ <title>{escaped_title}</title>
575
+ <style>
576
+ @page {{
577
+ size: A4;
578
+ margin: 16mm 14mm;
579
+ }}
580
+ * {{
581
+ box-sizing: border-box;
582
+ }}
583
+ body {{
584
+ color: #111827;
585
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
586
+ "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
587
+ font-size: 13px;
588
+ line-height: 1.55;
589
+ margin: 0;
590
+ }}
591
+ h1, h2, h3 {{
592
+ color: #0f172a;
593
+ line-height: 1.25;
594
+ margin: 1.4em 0 0.55em;
595
+ page-break-after: avoid;
596
+ }}
597
+ h1 {{
598
+ font-size: 26px;
599
+ margin-top: 0;
600
+ padding-bottom: 8px;
601
+ border-bottom: 2px solid #e5e7eb;
602
+ }}
603
+ h2 {{
604
+ font-size: 18px;
605
+ border-bottom: 1px solid #e5e7eb;
606
+ padding-bottom: 4px;
607
+ }}
608
+ h3 {{
609
+ font-size: 15px;
610
+ }}
611
+ p, ul, ol {{
612
+ margin: 0.4em 0 0.9em;
613
+ }}
614
+ table {{
615
+ width: 100%;
616
+ border-collapse: collapse;
617
+ margin: 12px 0 18px;
618
+ page-break-inside: auto;
619
+ font-size: 11px;
620
+ }}
621
+ tr {{
622
+ page-break-inside: avoid;
623
+ page-break-after: auto;
624
+ }}
625
+ th, td {{
626
+ border: 1px solid #d1d5db;
627
+ padding: 6px 7px;
628
+ vertical-align: top;
629
+ word-break: break-word;
630
+ }}
631
+ th {{
632
+ background: #f3f4f6;
633
+ font-weight: 700;
634
+ }}
635
+ code {{
636
+ background: #f3f4f6;
637
+ border-radius: 4px;
638
+ padding: 1px 4px;
639
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
640
+ font-size: 0.92em;
641
+ }}
642
+ pre {{
643
+ background: #0f172a;
644
+ color: #f8fafc;
645
+ padding: 12px;
646
+ border-radius: 6px;
647
+ white-space: pre-wrap;
648
+ word-break: break-word;
649
+ }}
650
+ blockquote {{
651
+ margin: 12px 0;
652
+ padding-left: 12px;
653
+ border-left: 4px solid #cbd5e1;
654
+ color: #475569;
655
+ }}
656
+ a {{
657
+ color: #2563eb;
658
+ text-decoration: none;
659
+ }}
660
+ .status-pass,
661
+ .status-warn,
662
+ .status-fail,
663
+ .status-error {{
664
+ display: inline-block;
665
+ border-radius: 999px;
666
+ padding: 2px 7px;
667
+ font-size: 10px;
668
+ font-weight: 700;
669
+ white-space: nowrap;
670
+ }}
671
+ .status-pass {{
672
+ background: #ecfdf5;
673
+ color: #047857;
674
+ }}
675
+ .status-warn {{
676
+ background: #fffbeb;
677
+ color: #b45309;
678
+ }}
679
+ .status-fail {{
680
+ background: #fef2f2;
681
+ color: #b91c1c;
682
+ }}
683
+ .status-error {{
684
+ background: #fef2f2;
685
+ color: #7f1d1d;
686
+ }}
687
+ td.score-excellent {{ color: #047857; font-weight: 700; }}
688
+ td.score-good {{ color: #0369a1; font-weight: 700; }}
689
+ td.score-fair {{ color: #b45309; font-weight: 700; }}
690
+ td.score-poor {{ color: #c2410c; font-weight: 700; }}
691
+ td.score-critical {{ color: #b91c1c; font-weight: 700; }}
692
+ </style>
693
+ </head>
694
+ <body>
695
+ {body}
696
+ <script>
697
+ (function() {{
698
+ function scoreClass(n) {{
699
+ if (n >= 80) return 'score-excellent';
700
+ if (n >= 65) return 'score-good';
701
+ if (n >= 50) return 'score-fair';
702
+ if (n >= 35) return 'score-poor';
703
+ return 'score-critical';
704
+ }}
705
+
706
+ document.querySelectorAll('td').forEach(function(td) {{
707
+ var text = td.textContent.trim();
708
+ function replaceWithBadge(className) {{
709
+ td.textContent = '';
710
+ var span = document.createElement('span');
711
+ span.className = className;
712
+ span.textContent = text;
713
+ td.appendChild(span);
714
+ }}
715
+ if (/^✅\\s*PASS/.test(text)) replaceWithBadge('status-pass');
716
+ else if (/^⚠️?\\s*WARN/.test(text)) replaceWithBadge('status-warn');
717
+ else if (/^❌\\s*FAIL/.test(text)) replaceWithBadge('status-fail');
718
+ else if (/^🔴\\s*ERROR/.test(text)) replaceWithBadge('status-error');
719
+
720
+ var match = text.match(/^(\\d{{1,3}}(?:\\.\\d+)?)\\s*\\/\\s*100$/);
721
+ if (match) td.classList.add(scoreClass(parseFloat(match[1])));
722
+ }});
723
+ }})();
724
+ </script>
725
+ </body>
726
+ </html>
727
+ """
728
+
729
+
730
+ def write_html(markdown_path: Path, html_text: str, keep_html: bool) -> Path:
731
+ if keep_html:
732
+ html_path = absolute_path(markdown_path.with_suffix(".html"))
733
+ html_path.write_text(html_text, encoding="utf-8")
734
+ return html_path
735
+ fd, tmp_name = tempfile.mkstemp(prefix="geo-audit-report-", suffix=".html")
736
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
737
+ f.write(html_text)
738
+ return Path(tmp_name).resolve()
739
+
740
+
741
+ def print_to_pdf(
742
+ chrome_path: str,
743
+ html_path: Path,
744
+ output_path: Path,
745
+ *,
746
+ timeout_seconds: int = 60,
747
+ ) -> tuple[int, str]:
748
+ output_path.parent.mkdir(parents=True, exist_ok=True)
749
+ with tempfile.TemporaryDirectory(prefix="geo-audit-crash-dumps-") as crash_dir, tempfile.TemporaryDirectory(prefix="geo-audit-chrome-profile-") as profile_dir:
750
+ cmd = [
751
+ chrome_path,
752
+ "--headless",
753
+ "--disable-gpu",
754
+ "--disable-background-networking",
755
+ "--disable-breakpad",
756
+ "--disable-crash-reporter",
757
+ "--disable-dev-shm-usage",
758
+ "--disable-extensions",
759
+ "--disable-sync",
760
+ "--no-first-run",
761
+ "--noerrdialogs",
762
+ "--no-sandbox",
763
+ "--run-all-compositor-stages-before-draw",
764
+ "--no-pdf-header-footer",
765
+ "--print-to-pdf-no-header",
766
+ "--virtual-time-budget=5000",
767
+ f"--crash-dumps-dir={crash_dir}",
768
+ f"--user-data-dir={profile_dir}",
769
+ f"--print-to-pdf={str(output_path)}",
770
+ html_path.as_uri(),
771
+ ]
772
+ try:
773
+ process = _popen_browser(cmd)
774
+ stdout, stderr = process.communicate(timeout=timeout_seconds)
775
+ except subprocess.TimeoutExpired as exc:
776
+ _terminate_browser(process)
777
+ return 124, f"Chrome timed out after {exc.timeout} seconds while printing PDF."
778
+ except OSError as exc:
779
+ return 126, f"Chrome could not be started: {exc}"
780
+ combined = "\n".join(part for part in [stdout, stderr] if part)
781
+ if process.returncode != 0:
782
+ message = combined.strip()
783
+ if not message:
784
+ message = (
785
+ f"Chrome exited with code {process.returncode} without stderr/stdout. "
786
+ "It may be unavailable, blocked by the OS, or blocked by the execution sandbox."
787
+ )
788
+ return process.returncode or 1, message
789
+ if not output_path.exists() or output_path.stat().st_size == 0:
790
+ return 1, "Chrome completed but did not create a non-empty PDF."
791
+ return 0, combined.strip()
792
+
793
+
794
+ def install_playwright(timeout_seconds: int = 900) -> tuple[int, str]:
795
+ commands = [
796
+ [sys.executable, "-m", "pip", "install", "playwright"],
797
+ [sys.executable, "-m", "playwright", "install", "chromium"],
798
+ ]
799
+ output_parts: list[str] = []
800
+ for command in commands:
801
+ try:
802
+ result = subprocess.run(
803
+ command,
804
+ stdout=subprocess.PIPE,
805
+ stderr=subprocess.PIPE,
806
+ text=True,
807
+ timeout=timeout_seconds,
808
+ )
809
+ except subprocess.TimeoutExpired as exc:
810
+ return 124, f"{' '.join(command)} timed out after {exc.timeout} seconds."
811
+ except OSError as exc:
812
+ return 126, f"{' '.join(command)} could not be started: {exc}"
813
+ output_parts.append("\n".join(part for part in [result.stdout, result.stderr] if part).strip())
814
+ if result.returncode != 0:
815
+ message = output_parts[-1] or f"{' '.join(command)} exited with {result.returncode}."
816
+ return result.returncode or 1, message
817
+ return 0, "\n".join(part for part in output_parts if part)
818
+
819
+
820
+ def print_to_pdf_with_playwright(
821
+ html_path: Path,
822
+ output_path: Path,
823
+ *,
824
+ executable_path: Optional[str] = None,
825
+ timeout_seconds: int = 60,
826
+ ) -> tuple[int, str]:
827
+ try:
828
+ from playwright.sync_api import Error as PlaywrightError # type: ignore
829
+ from playwright.sync_api import sync_playwright # type: ignore
830
+ except ImportError as exc:
831
+ return 127, f"Playwright is not installed in this Python environment: {exc}"
832
+
833
+ output_path.parent.mkdir(parents=True, exist_ok=True)
834
+ browser = None
835
+ try:
836
+ with sync_playwright() as playwright:
837
+ launch_kwargs: dict[str, Any] = {
838
+ "headless": True,
839
+ "args": [
840
+ "--disable-background-networking",
841
+ "--disable-breakpad",
842
+ "--disable-dev-shm-usage",
843
+ "--disable-extensions",
844
+ "--disable-sync",
845
+ "--no-first-run",
846
+ "--no-sandbox",
847
+ ],
848
+ "timeout": timeout_seconds * 1000,
849
+ }
850
+ if executable_path:
851
+ launch_kwargs["executable_path"] = executable_path
852
+ browser = playwright.chromium.launch(
853
+ **launch_kwargs,
854
+ )
855
+ page = browser.new_page()
856
+ page.goto(html_path.as_uri(), wait_until="load", timeout=timeout_seconds * 1000)
857
+ page.emulate_media(media="print")
858
+ page.pdf(
859
+ path=str(output_path),
860
+ format="A4",
861
+ print_background=True,
862
+ prefer_css_page_size=True,
863
+ margin={"top": "16mm", "right": "14mm", "bottom": "16mm", "left": "14mm"},
864
+ )
865
+ browser.close()
866
+ browser = None
867
+ except PlaywrightError as exc:
868
+ if browser:
869
+ try:
870
+ browser.close()
871
+ except Exception:
872
+ pass
873
+ return 1, f"Playwright PDF export failed: {exc}"
874
+ except Exception as exc:
875
+ if browser:
876
+ try:
877
+ browser.close()
878
+ except Exception:
879
+ pass
880
+ return 1, f"Playwright PDF export failed: {exc}"
881
+
882
+ if not output_path.exists() or output_path.stat().st_size == 0:
883
+ return 1, "Playwright completed but did not create a non-empty PDF."
884
+ return 0, "PDF generated with Playwright Chromium."
885
+
886
+
887
+ def _is_table_separator(line: str) -> bool:
888
+ return bool(re.match(r"^\s*\|?[\s|:\-]+\|?\s*$", line)) and "-" in line
889
+
890
+
891
+ def _split_table_row(line: str) -> list[str]:
892
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
893
+
894
+
895
+ def _reportlab_inline(text: str) -> str:
896
+ status_replacements = [
897
+ ("✅ PASS", "PASS"),
898
+ ("⚠️ WARN", "WARN"),
899
+ ("⚠ WARN", "WARN"),
900
+ ("❌ FAIL", "FAIL"),
901
+ ("🔴 ERROR", "ERROR"),
902
+ ("✅", "是"),
903
+ ("❌", "否"),
904
+ ("⚠️", "注意"),
905
+ ("⚠", "注意"),
906
+ ]
907
+ for old, new in status_replacements:
908
+ text = text.replace(old, new)
909
+ code_tokens: list[str] = []
910
+ link_tokens: list[str] = []
911
+
912
+ def stash_link(match: re.Match[str]) -> str:
913
+ label = html.escape(match.group(1))
914
+ href = html.escape(match.group(2), quote=True)
915
+ link_tokens.append(f'<a href="{href}" color="#2563eb"><u>{label}</u></a>')
916
+ return f"@@GEO_RL_LINK_{len(link_tokens) - 1}@@"
917
+
918
+ def stash_code(match: re.Match[str]) -> str:
919
+ code_tokens.append(html.escape(match.group(1)))
920
+ return f"@@GEO_RL_CODE_{len(code_tokens) - 1}@@"
921
+
922
+ text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", stash_link, text)
923
+ text = re.sub(r"`([^`\n]+)`", stash_code, text)
924
+ rendered = html.escape(text)
925
+ rendered = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", rendered)
926
+ rendered = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<i>\1</i>", rendered)
927
+ for i, token in enumerate(code_tokens):
928
+ rendered = rendered.replace(
929
+ f"@@GEO_RL_CODE_{i}@@",
930
+ f'<font name="Courier">{token}</font>',
931
+ )
932
+ for i, token in enumerate(link_tokens):
933
+ rendered = rendered.replace(f"@@GEO_RL_LINK_{i}@@", token)
934
+ return rendered
935
+
936
+
937
+ def _table_col_widths(headers: list[str], total_width: float) -> list[float]:
938
+ weights: list[float] = []
939
+ for header in headers:
940
+ if "Prompt" in header or "说明" in header or "发现" in header or "建议" in header:
941
+ weights.append(2.4)
942
+ elif "URL" in header or "来源" in header:
943
+ weights.append(1.7)
944
+ elif header in {"#", "权重", "状态", "排名"}:
945
+ weights.append(0.8)
946
+ else:
947
+ weights.append(1.15)
948
+ total = sum(weights) or 1
949
+ return [total_width * weight / total for weight in weights]
950
+
951
+
952
+ def _existing_font_paths(paths: list[str]) -> list[str]:
953
+ return [path for path in paths if path and Path(path).exists()]
954
+
955
+
956
+ def _register_reportlab_fonts(pdfmetrics: Any, TTFont: Any, UnicodeCIDFont: Any) -> tuple[str, str]:
957
+ font_env = os.environ.get("GEO_AUDIT_PDF_FONT_PATH")
958
+ windir = os.environ.get("WINDIR", r"C:\Windows")
959
+ font_families = [
960
+ {
961
+ "normal": _existing_font_paths([
962
+ str(Path("/System/Library/Fonts/STHeiti Light.ttc")),
963
+ str(Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf")),
964
+ ]),
965
+ "bold": _existing_font_paths([
966
+ str(Path("/System/Library/Fonts/STHeiti Medium.ttc")),
967
+ str(Path("/System/Library/Fonts/STHeiti Light.ttc")),
968
+ str(Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf")),
969
+ ]),
970
+ },
971
+ {
972
+ "normal": _existing_font_paths([
973
+ str(Path(windir) / "Fonts" / "msyh.ttc"),
974
+ str(Path(windir) / "Fonts" / "simhei.ttf"),
975
+ str(Path(windir) / "Fonts" / "simsun.ttc"),
976
+ str(Path(windir) / "Fonts" / "arialuni.ttf"),
977
+ ]),
978
+ "bold": _existing_font_paths([
979
+ str(Path(windir) / "Fonts" / "msyhbd.ttc"),
980
+ str(Path(windir) / "Fonts" / "msyh.ttc"),
981
+ str(Path(windir) / "Fonts" / "simhei.ttf"),
982
+ str(Path(windir) / "Fonts" / "simsun.ttc"),
983
+ ]),
984
+ },
985
+ {
986
+ "normal": _existing_font_paths([
987
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
988
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
989
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
990
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.otf",
991
+ "/usr/share/fonts/opentype/source-han-sans/SourceHanSansCN-Regular.otf",
992
+ ]),
993
+ "bold": _existing_font_paths([
994
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
995
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.otf",
996
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
997
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.otf",
998
+ "/usr/share/fonts/opentype/source-han-sans/SourceHanSansCN-Bold.otf",
999
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
1000
+ ]),
1001
+ },
1002
+ ]
1003
+ if font_env:
1004
+ font_families.insert(0, {"normal": _existing_font_paths([font_env]), "bold": _existing_font_paths([font_env])})
1005
+
1006
+ for family in font_families:
1007
+ if not family["normal"]:
1008
+ continue
1009
+ normal_path = family["normal"][0]
1010
+ bold_path = (family["bold"] or family["normal"])[0]
1011
+ try:
1012
+ registered = set(pdfmetrics.getRegisteredFontNames())
1013
+ if "GeoAuditSans" not in registered:
1014
+ pdfmetrics.registerFont(TTFont("GeoAuditSans", normal_path, subfontIndex=0))
1015
+ if "GeoAuditSans-Bold" not in registered:
1016
+ pdfmetrics.registerFont(TTFont("GeoAuditSans-Bold", bold_path, subfontIndex=0))
1017
+ pdfmetrics.registerFontFamily(
1018
+ "GeoAuditSans",
1019
+ normal="GeoAuditSans",
1020
+ bold="GeoAuditSans-Bold",
1021
+ italic="GeoAuditSans",
1022
+ boldItalic="GeoAuditSans-Bold",
1023
+ )
1024
+ return "GeoAuditSans", "GeoAuditSans-Bold"
1025
+ except Exception:
1026
+ continue
1027
+
1028
+ try:
1029
+ pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
1030
+ pdfmetrics.registerFontFamily(
1031
+ "STSong-Light",
1032
+ normal="STSong-Light",
1033
+ bold="STSong-Light",
1034
+ italic="STSong-Light",
1035
+ boldItalic="STSong-Light",
1036
+ )
1037
+ except Exception:
1038
+ pass
1039
+ return "STSong-Light", "STSong-Light"
1040
+
1041
+
1042
+ def _render_markdown_to_pdf_reportlab(markdown_text: str, output_path: Path) -> tuple[int, str]:
1043
+ try:
1044
+ from reportlab.lib import colors # type: ignore
1045
+ from reportlab.lib.pagesizes import A4 # type: ignore
1046
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet # type: ignore
1047
+ from reportlab.lib.units import mm # type: ignore
1048
+ from reportlab.pdfbase import pdfmetrics # type: ignore
1049
+ from reportlab.pdfbase.cidfonts import UnicodeCIDFont # type: ignore
1050
+ from reportlab.pdfbase.ttfonts import TTFont # type: ignore
1051
+ from reportlab.platypus import ( # type: ignore
1052
+ ListFlowable,
1053
+ ListItem,
1054
+ PageBreak,
1055
+ Paragraph,
1056
+ Preformatted,
1057
+ SimpleDocTemplate,
1058
+ Spacer,
1059
+ Table,
1060
+ TableStyle,
1061
+ )
1062
+ except ImportError as exc:
1063
+ return 6, f"ReportLab renderer is unavailable: {exc}. Install dependencies with `pip install -r requirements.txt`."
1064
+
1065
+ markdown_text = prepare_markdown_for_pdf(markdown_text)
1066
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1067
+
1068
+ doc = SimpleDocTemplate(
1069
+ str(output_path),
1070
+ pagesize=A4,
1071
+ leftMargin=16 * mm,
1072
+ rightMargin=16 * mm,
1073
+ topMargin=15 * mm,
1074
+ bottomMargin=15 * mm,
1075
+ title=_extract_title(markdown_text),
1076
+ )
1077
+ base = getSampleStyleSheet()
1078
+ normal_font, bold_font = _register_reportlab_fonts(pdfmetrics, TTFont, UnicodeCIDFont)
1079
+ normal = ParagraphStyle(
1080
+ "GeoNormal",
1081
+ parent=base["Normal"],
1082
+ fontName=normal_font,
1083
+ fontSize=9.5,
1084
+ leading=14,
1085
+ textColor=colors.HexColor("#111827"),
1086
+ spaceAfter=6,
1087
+ wordWrap="CJK",
1088
+ )
1089
+ h1 = ParagraphStyle(
1090
+ "GeoH1",
1091
+ parent=normal,
1092
+ fontName=bold_font,
1093
+ fontSize=21,
1094
+ leading=27,
1095
+ textColor=colors.HexColor("#0f172a"),
1096
+ spaceAfter=12,
1097
+ )
1098
+ h2 = ParagraphStyle(
1099
+ "GeoH2",
1100
+ parent=normal,
1101
+ fontName=bold_font,
1102
+ fontSize=14.5,
1103
+ leading=19,
1104
+ textColor=colors.HexColor("#0f172a"),
1105
+ spaceBefore=10,
1106
+ spaceAfter=6,
1107
+ )
1108
+ h3 = ParagraphStyle(
1109
+ "GeoH3",
1110
+ parent=normal,
1111
+ fontName=bold_font,
1112
+ fontSize=11.5,
1113
+ leading=16,
1114
+ textColor=colors.HexColor("#0f172a"),
1115
+ spaceBefore=6,
1116
+ spaceAfter=4,
1117
+ )
1118
+ table_text = ParagraphStyle(
1119
+ "GeoTableText",
1120
+ parent=normal,
1121
+ fontSize=7.7,
1122
+ leading=10.2,
1123
+ spaceAfter=0,
1124
+ )
1125
+ code = ParagraphStyle(
1126
+ "GeoCode",
1127
+ parent=base["Code"],
1128
+ fontName="Courier",
1129
+ fontSize=7.2,
1130
+ leading=9,
1131
+ backColor=colors.HexColor("#f3f4f6"),
1132
+ borderPadding=5,
1133
+ wordWrap="CJK",
1134
+ )
1135
+ styles = {1: h1, 2: h2, 3: h3}
1136
+
1137
+ def paragraph(text: str, style: ParagraphStyle = normal) -> Paragraph:
1138
+ return Paragraph(_reportlab_inline(text), style)
1139
+
1140
+ def paragraph_block(block_lines: list[str], style: ParagraphStyle = normal) -> Paragraph:
1141
+ rendered_lines = [_reportlab_inline(line.rstrip()) for line in block_lines]
1142
+ return Paragraph("<br/>".join(rendered_lines), style)
1143
+
1144
+ story: list[Any] = []
1145
+ lines = markdown_text.splitlines()
1146
+ i = 0
1147
+ while i < len(lines):
1148
+ raw = lines[i]
1149
+ stripped = raw.strip()
1150
+ if not stripped:
1151
+ i += 1
1152
+ continue
1153
+ if stripped == "---":
1154
+ story.append(Spacer(1, 6))
1155
+ i += 1
1156
+ continue
1157
+ if stripped.startswith("#"):
1158
+ level = min(len(stripped) - len(stripped.lstrip("#")), 3)
1159
+ text = stripped[level:].strip()
1160
+ story.append(paragraph(text, styles[level]))
1161
+ story.append(Spacer(1, 2))
1162
+ i += 1
1163
+ continue
1164
+ if stripped.startswith("```"):
1165
+ i += 1
1166
+ code_lines: list[str] = []
1167
+ while i < len(lines) and not lines[i].strip().startswith("```"):
1168
+ code_lines.append(lines[i])
1169
+ i += 1
1170
+ if i < len(lines):
1171
+ i += 1
1172
+ story.append(Preformatted("\n".join(code_lines), code))
1173
+ story.append(Spacer(1, 6))
1174
+ continue
1175
+ if (
1176
+ stripped.startswith("|")
1177
+ and i + 1 < len(lines)
1178
+ and _is_table_separator(lines[i + 1])
1179
+ ):
1180
+ headers = _split_table_row(stripped)
1181
+ rows: list[list[str]] = []
1182
+ i += 2
1183
+ while i < len(lines) and lines[i].strip().startswith("|"):
1184
+ rows.append(_split_table_row(lines[i]))
1185
+ i += 1
1186
+ column_count = max([len(headers), *(len(row) for row in rows)] or [1])
1187
+ headers = headers + [""] * (column_count - len(headers))
1188
+ data: list[list[Any]] = [[paragraph(cell, table_text) for cell in headers]]
1189
+ for row in rows:
1190
+ row = row + [""] * (column_count - len(row))
1191
+ data.append([paragraph(cell, table_text) for cell in row[:column_count]])
1192
+ table = Table(
1193
+ data,
1194
+ colWidths=_table_col_widths(headers, doc.width),
1195
+ repeatRows=1,
1196
+ splitByRow=1,
1197
+ )
1198
+ table_commands: list[tuple[Any, ...]] = [
1199
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
1200
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
1201
+ ("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#d1d5db")),
1202
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
1203
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
1204
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
1205
+ ("TOPPADDING", (0, 0), (-1, -1), 4.5),
1206
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4.5),
1207
+ ]
1208
+ if "状态" in headers:
1209
+ status_col = headers.index("状态")
1210
+ for row_number, row in enumerate(rows, start=1):
1211
+ status_text = (row + [""] * column_count)[status_col]
1212
+ if "PASS" in status_text:
1213
+ bg, fg = "#ecfdf5", "#047857"
1214
+ elif "WARN" in status_text:
1215
+ bg, fg = "#fffbeb", "#b45309"
1216
+ elif "FAIL" in status_text:
1217
+ bg, fg = "#fef2f2", "#b91c1c"
1218
+ elif "ERROR" in status_text:
1219
+ bg, fg = "#fef2f2", "#7f1d1d"
1220
+ else:
1221
+ continue
1222
+ table_commands.extend([
1223
+ ("BACKGROUND", (status_col, row_number), (status_col, row_number), colors.HexColor(bg)),
1224
+ ("TEXTCOLOR", (status_col, row_number), (status_col, row_number), colors.HexColor(fg)),
1225
+ ])
1226
+ table.setStyle(
1227
+ TableStyle(table_commands)
1228
+ )
1229
+ story.append(table)
1230
+ story.append(Spacer(1, 8))
1231
+ continue
1232
+ if stripped.startswith("- "):
1233
+ items = []
1234
+ while i < len(lines) and lines[i].strip().startswith("- "):
1235
+ items.append(ListItem(paragraph(lines[i].strip()[2:].strip(), normal)))
1236
+ i += 1
1237
+ story.append(ListFlowable(items, bulletType="bullet", leftIndent=12))
1238
+ story.append(Spacer(1, 4))
1239
+ continue
1240
+ if re.match(r"^\d+\.\s+", stripped):
1241
+ items = []
1242
+ while i < len(lines):
1243
+ current = lines[i].strip()
1244
+ if not re.match(r"^\d+\.\s+", current):
1245
+ break
1246
+ item_lines = [re.sub(r"^\d+\.\s+", "", current).strip()]
1247
+ i += 1
1248
+ while i < len(lines):
1249
+ continuation = lines[i].strip()
1250
+ if not continuation:
1251
+ break
1252
+ if (
1253
+ re.match(r"^\d+\.\s+", continuation)
1254
+ or continuation.startswith("#")
1255
+ or continuation.startswith("|")
1256
+ or continuation.startswith("- ")
1257
+ or continuation.startswith("```")
1258
+ ):
1259
+ break
1260
+ item_lines.append(continuation)
1261
+ i += 1
1262
+ items.append(ListItem(paragraph_block(item_lines, normal)))
1263
+ while i < len(lines) and not lines[i].strip():
1264
+ i += 1
1265
+ if i >= len(lines) or not re.match(r"^\d+\.\s+", lines[i].strip()):
1266
+ break
1267
+ story.append(ListFlowable(items, bulletType="1", bulletFormat="%s.", leftIndent=14))
1268
+ story.append(Spacer(1, 4))
1269
+ continue
1270
+
1271
+ paragraph_lines = [raw.rstrip()]
1272
+ i += 1
1273
+ while i < len(lines):
1274
+ candidate_raw = lines[i]
1275
+ candidate = candidate_raw.strip()
1276
+ if (
1277
+ not candidate
1278
+ or candidate.startswith("#")
1279
+ or candidate.startswith("|")
1280
+ or candidate.startswith("- ")
1281
+ or candidate.startswith("```")
1282
+ or candidate == "---"
1283
+ or re.match(r"^\d+\.\s+", candidate)
1284
+ ):
1285
+ break
1286
+ paragraph_lines.append(candidate_raw.rstrip())
1287
+ i += 1
1288
+ should_keep_breaks = any(line.endswith(" ") or line.strip().startswith("**") for line in paragraph_lines)
1289
+ if should_keep_breaks:
1290
+ story.append(paragraph_block(paragraph_lines, normal))
1291
+ else:
1292
+ story.append(paragraph(" ".join(line.strip() for line in paragraph_lines), normal))
1293
+
1294
+ try:
1295
+ doc.build(story or [paragraph("GEO Audit Report", normal)])
1296
+ except Exception as exc:
1297
+ return 6, f"ReportLab renderer failed: {exc}"
1298
+ if not output_path.exists() or output_path.stat().st_size == 0:
1299
+ return 6, "ReportLab renderer completed but did not create a non-empty PDF."
1300
+ return 0, "PDF generated with ReportLab."
1301
+
1302
+
1303
+ def main(argv: Optional[list[str]] = None) -> int:
1304
+ parser = argparse.ArgumentParser(description="Render a GEO audit Markdown report to PDF.")
1305
+ parser.add_argument("report_md", help="Path to the UTF-8 Markdown report")
1306
+ parser.add_argument("--output", "-o", help="Output PDF path. Defaults to report_md with .pdf suffix")
1307
+ parser.add_argument("--keep-html", action="store_true", help="Keep the intermediate HTML next to the Markdown file")
1308
+ default_engine = os.environ.get("GEO_AUDIT_PDF_ENGINE", "reportlab").lower()
1309
+ if default_engine not in {"auto", "playwright", "chrome", "reportlab"}:
1310
+ default_engine = "reportlab"
1311
+ parser.add_argument(
1312
+ "--engine",
1313
+ choices=["auto", "playwright", "chrome", "reportlab"],
1314
+ default=default_engine,
1315
+ help="PDF engine to use. Default reportlab avoids launching Chrome; auto tries ReportLab, Playwright, then Chrome.",
1316
+ )
1317
+ parser.add_argument(
1318
+ "--install-playwright",
1319
+ action="store_true",
1320
+ help="Install Playwright and Chromium into the current Python environment before rendering.",
1321
+ )
1322
+ parser.add_argument(
1323
+ "--timings-output",
1324
+ help="Optional JSON path for PDF rendering timings, for debugging slow exports.",
1325
+ )
1326
+ args = parser.parse_args(argv)
1327
+ module_started = time.perf_counter()
1328
+ timings: dict[str, float] = {}
1329
+
1330
+ input_path = absolute_path(Path(args.report_md))
1331
+ if not input_path.exists():
1332
+ print(f"ERROR: Markdown report not found: {input_path}", file=sys.stderr)
1333
+ return 2
1334
+ if not input_path.is_file():
1335
+ print(f"ERROR: Markdown report is not a file: {input_path}", file=sys.stderr)
1336
+ return 2
1337
+
1338
+ output_path = resolve_output_path(input_path, args.output)
1339
+ stage_started = time.perf_counter()
1340
+ markdown_text = input_path.read_text(encoding="utf-8")
1341
+ timings["markdown_read_seconds"] = _elapsed_since(stage_started)
1342
+ stage_started = time.perf_counter()
1343
+ html_text = render_markdown_to_html(markdown_text)
1344
+ timings["markdown_to_html_seconds"] = _elapsed_since(stage_started)
1345
+ stage_started = time.perf_counter()
1346
+ html_path = write_html(input_path, html_text, args.keep_html)
1347
+ timings["html_write_seconds"] = _elapsed_since(stage_started)
1348
+
1349
+ if args.install_playwright and args.engine in {"auto", "playwright"}:
1350
+ stage_started = time.perf_counter()
1351
+ install_code, install_message = install_playwright()
1352
+ timings["playwright_install_seconds"] = _elapsed_since(stage_started)
1353
+ if install_code != 0:
1354
+ print(f"ERROR: Playwright installation failed: {install_message}", file=sys.stderr)
1355
+ if not args.keep_html:
1356
+ try:
1357
+ html_path.unlink(missing_ok=True)
1358
+ except OSError:
1359
+ pass
1360
+ return 4
1361
+
1362
+ def cleanup_html() -> None:
1363
+ if not args.keep_html:
1364
+ try:
1365
+ html_path.unlink(missing_ok=True)
1366
+ except OSError:
1367
+ pass
1368
+
1369
+ engine_errors: list[str] = []
1370
+ reportlab_attempted = False
1371
+
1372
+ if args.engine in {"auto", "reportlab"}:
1373
+ reportlab_attempted = True
1374
+ stage_started = time.perf_counter()
1375
+ fallback_code, fallback_message = _render_markdown_to_pdf_reportlab(markdown_text, output_path)
1376
+ timings["reportlab_pdf_seconds"] = _elapsed_since(stage_started)
1377
+ if fallback_code == 0:
1378
+ timings["total_pdf_module_seconds"] = _elapsed_since(module_started)
1379
+ write_timing_file(
1380
+ args.timings_output,
1381
+ input_path=input_path,
1382
+ pdf_path=output_path,
1383
+ engine="reportlab",
1384
+ timings=timings,
1385
+ engine_errors=engine_errors,
1386
+ )
1387
+ print(f"PDF written to {output_path}")
1388
+ if args.keep_html:
1389
+ print(f"HTML written to {html_path}")
1390
+ else:
1391
+ cleanup_html()
1392
+ return 0
1393
+ engine_errors.append(fallback_message)
1394
+ if args.engine == "reportlab":
1395
+ cleanup_html()
1396
+ print(f"ERROR: PDF generation failed: {fallback_message}", file=sys.stderr)
1397
+ return 5
1398
+
1399
+ try:
1400
+ if args.engine in {"auto", "playwright"}:
1401
+ stage_started = time.perf_counter()
1402
+ code, message = print_to_pdf_with_playwright(
1403
+ html_path,
1404
+ output_path,
1405
+ executable_path=os.environ.get("GEO_AUDIT_PLAYWRIGHT_EXECUTABLE_PATH"),
1406
+ )
1407
+ timings["playwright_pdf_seconds"] = _elapsed_since(stage_started)
1408
+ if code == 0:
1409
+ timings["total_pdf_module_seconds"] = _elapsed_since(module_started)
1410
+ write_timing_file(
1411
+ args.timings_output,
1412
+ input_path=input_path,
1413
+ pdf_path=output_path,
1414
+ engine="playwright",
1415
+ timings=timings,
1416
+ engine_errors=engine_errors,
1417
+ )
1418
+ print(f"PDF written to {output_path}")
1419
+ if args.keep_html:
1420
+ print(f"HTML written to {html_path}")
1421
+ else:
1422
+ cleanup_html()
1423
+ return 0
1424
+ engine_errors.append(message)
1425
+
1426
+ if args.engine == "playwright":
1427
+ cleanup_html()
1428
+ print(f"ERROR: PDF generation failed: {engine_errors[-1]}", file=sys.stderr)
1429
+ return 5
1430
+
1431
+ if args.engine in {"auto", "chrome"}:
1432
+ stage_started = time.perf_counter()
1433
+ chrome_path = find_chrome()
1434
+ timings["chrome_lookup_seconds"] = _elapsed_since(stage_started)
1435
+ if chrome_path:
1436
+ stage_started = time.perf_counter()
1437
+ code, message = print_to_pdf(chrome_path, html_path, output_path)
1438
+ timings["chrome_pdf_seconds"] = _elapsed_since(stage_started)
1439
+ if code == 0:
1440
+ timings["total_pdf_module_seconds"] = _elapsed_since(module_started)
1441
+ write_timing_file(
1442
+ args.timings_output,
1443
+ input_path=input_path,
1444
+ pdf_path=output_path,
1445
+ engine="chrome",
1446
+ timings=timings,
1447
+ engine_errors=engine_errors,
1448
+ )
1449
+ print(f"PDF written to {output_path}")
1450
+ if args.keep_html:
1451
+ print(f"HTML written to {html_path}")
1452
+ else:
1453
+ cleanup_html()
1454
+ return 0
1455
+ engine_errors.append(f"Chrome PDF export failed: {message}")
1456
+ else:
1457
+ engine_errors.append("Chrome/Chromium executable not found.")
1458
+ finally:
1459
+ cleanup_html()
1460
+
1461
+ if args.engine == "chrome":
1462
+ print(f"ERROR: PDF generation failed: {engine_errors[-1] if engine_errors else 'Chrome export unavailable.'}", file=sys.stderr)
1463
+ return 5
1464
+
1465
+ fallback_message = engine_errors[0] if reportlab_attempted and engine_errors else ""
1466
+ if not reportlab_attempted:
1467
+ stage_started = time.perf_counter()
1468
+ fallback_code, fallback_message = _render_markdown_to_pdf_reportlab(markdown_text, output_path)
1469
+ timings["reportlab_pdf_seconds"] = _elapsed_since(stage_started)
1470
+ if fallback_code == 0:
1471
+ timings["total_pdf_module_seconds"] = _elapsed_since(module_started)
1472
+ write_timing_file(
1473
+ args.timings_output,
1474
+ input_path=input_path,
1475
+ pdf_path=output_path,
1476
+ engine="reportlab",
1477
+ timings=timings,
1478
+ engine_errors=engine_errors,
1479
+ )
1480
+ if engine_errors:
1481
+ print(f"WARNING: {'; '.join(engine_errors)} Used ReportLab renderer.", file=sys.stderr)
1482
+ print(f"PDF written to {output_path}")
1483
+ if args.keep_html:
1484
+ print(f"HTML written to {html_path}")
1485
+ return 0
1486
+
1487
+ if engine_errors:
1488
+ print(f"ERROR: PDF generation failed: {'; '.join(engine_errors)}", file=sys.stderr)
1489
+ print(f"ERROR: {fallback_message}", file=sys.stderr)
1490
+ return 5
1491
+
1492
+ print(f"ERROR: PDF generation failed: {fallback_message}", file=sys.stderr)
1493
+ return 5
1494
+
1495
+
1496
+ if __name__ == "__main__":
1497
+ try:
1498
+ import _geo_notice
1499
+ _geo_notice.emit()
1500
+ except Exception:
1501
+ pass
1502
+ raise SystemExit(main())