@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.
- package/README.md +93 -0
- package/bin/adgine-geo.js +224 -0
- package/package.json +38 -0
- package/skills/.env.example +11 -0
- package/skills/README.md +247 -0
- package/skills/SKILL.md +117 -0
- package/skills/VERSION +1 -0
- package/skills/adgine-geo-aiagent/SKILL.md +200 -0
- package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
- package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
- package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
- package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
- package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
- package/skills/adgine-geo-analytics/API.md +89 -0
- package/skills/adgine-geo-analytics/SKILL.md +191 -0
- package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
- package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
- package/skills/adgine-geo-billing/SKILL.md +118 -0
- package/skills/adgine-geo-billing/scripts/_client.py +341 -0
- package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
- package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
- package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
- package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
- package/skills/adgine-geo-brand/SKILL.md +193 -0
- package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
- package/skills/adgine-geo-brand/scripts/_client.py +341 -0
- package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
- package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
- package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
- package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
- package/skills/adgine-geo-citation/SKILL.md +220 -0
- package/skills/adgine-geo-citation/scripts/_client.py +341 -0
- package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
- package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
- package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
- package/skills/adgine-geo-content/SKILL.md +224 -0
- package/skills/adgine-geo-content/WORKFLOW.md +100 -0
- package/skills/adgine-geo-content/scripts/_client.py +341 -0
- package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
- package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
- package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
- package/skills/adgine-geo-content/scripts/list_content.py +65 -0
- package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
- package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
- package/skills/adgine-geo-dashboard/SKILL.md +181 -0
- package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
- package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
- package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
- package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
- package/skills/adgine-geo-docs/SKILL.md +60 -0
- package/skills/adgine-geo-domains/SKILL.md +223 -0
- package/skills/adgine-geo-domains/scripts/_client.py +341 -0
- package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
- package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
- package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
- package/skills/adgine-geo-integrations/SKILL.md +188 -0
- package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
- package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
- package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
- package/skills/adgine-geo-opportunities/SKILL.md +162 -0
- package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
- package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
- package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
- package/skills/adgine-geo-performance/SKILL.md +177 -0
- package/skills/adgine-geo-performance/scripts/_client.py +341 -0
- package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
- package/skills/adgine-geo-projects/SKILL.md +279 -0
- package/skills/adgine-geo-projects/scripts/_client.py +341 -0
- package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
- package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
- package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
- package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
- package/skills/adgine-geo-saas/SKILL.md +144 -0
- package/skills/adgine-geo-saas/scripts/_client.py +341 -0
- package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
- package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
- package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
- package/skills/adgine-geo-site-audit/README.md +281 -0
- package/skills/adgine-geo-site-audit/SKILL.md +449 -0
- package/skills/adgine-geo-site-audit/requirements.txt +5 -0
- package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
- package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
- package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
- package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
- package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
- package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
- package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
- package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
- package/skills/adgine-geo-topics/SKILL.md +232 -0
- package/skills/adgine-geo-topics/scripts/_client.py +341 -0
- package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
- package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
- package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
- package/skills/adgine-geo-visibility/SKILL.md +168 -0
- package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
- package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
- package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
- package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
- package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
- package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
- package/skills/adgine-geo-wordpress/SKILL.md +151 -0
- package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
- package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
- package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
- package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
- package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
- package/skills/scripts/check_version.py +290 -0
- package/skills/setup.py +248 -0
- package/src/config.js +126 -0
- package/src/doctor.js +137 -0
- package/src/install.js +90 -0
- package/src/manifest.js +99 -0
- 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())
|