chore: 版本 1.0.1,清理过期文档,加入 Playwright 自动化测试工具

- 版本号从 1.0.0-SNAPSHOT 升级到 1.0.1
- 删除已过期的设计文档和调查文档
- 新增 workplace/ 目录:暗色模式扫描、CSS 冲突探测、登录管理脚本
- 更新 .gitignore 忽略截图纸张和浏览器缓存
- 更新 CLAUDE.md 反映当前仓库状态

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:06:28 +08:00
parent 154fc63eef
commit b36ad30d7a
12 changed files with 411 additions and 1455 deletions
+30
View File
@@ -0,0 +1,30 @@
# 聚合扫描结果:按 (元素, 类名, 问题) 分组,统计影响页面数
import json
import pathlib
from collections import defaultdict
data = json.loads(
(pathlib.Path(__file__).parent / "scan-results.json").read_text(encoding="utf-8")
)
groups = defaultdict(lambda: {"pages": [], "sample": None})
for route, items in data.items():
if route.startswith("__") or isinstance(items, dict):
continue
for it in items:
key = (it["tag"], it["cls"], tuple(it["issues"]))
groups[key]["pages"].append(route)
if groups[key]["sample"] is None:
groups[key]["sample"] = it
# 按影响页面数降序
ranked = sorted(groups.items(), key=lambda kv: -len(kv[1]["pages"]))
for (tag, cls, issues), g in ranked:
s = g["sample"]
pages = g["pages"]
print(f"[{len(pages)}页] <{tag}> .{cls[:80]}")
print(f" 问题: {', '.join(issues)}")
print(f" 路径: {s['path'][:130]}")
print(f" 文本: {s['text'][:40]} 尺寸: {s['size']}")
print(f" 页面: {', '.join(pages[:12])}{' ...' if len(pages) > 12 else ''}")
print()
+54
View File
@@ -0,0 +1,54 @@
# 抓取服务端实际部署的插件 bundle.css,与本地构建产物对比
import pathlib
import re
import time
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
OUT = ROOT / "deployed-bundle.css"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE), channel="msedge", headless=True
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://blog.liuhangyv.top/console/overview", wait_until="domcontentloaded")
time.sleep(4)
text = page.evaluate(
"""async () => {
const sheet = [...document.styleSheets].find(s => s.href && s.href.includes('bundle.css'));
if (!sheet) return null;
return await (await fetch(sheet.href)).text();
}"""
)
ctx.close()
if not text:
print("未找到 bundle.css")
raise SystemExit(1)
OUT.write_text(text, encoding="utf-8")
local = (ROOT.parent / "ui" / "build" / "dist" / "style.css").read_text(encoding="utf-8")
def dark_selectors(css: str) -> set:
# 提取所有 [data-halo-theme=dark] 规则的选择器(粗略切分)
return set(re.findall(r"(\[data-halo-theme=dark\][^{]+)\{", css))
dep = dark_selectors(text)
loc = dark_selectors(local)
print(f"部署版 dark 规则选择器数: {len(dep)}")
print(f"本地构建 dark 规则选择器数: {len(loc)}")
print(f"本地有而部署没有(未部署的新覆盖): {len(loc - dep)}")
for s in sorted(loc - dep)[:40]:
print(" +", s[:110])
print(f"部署有而本地没有(本地已删除的旧规则): {len(dep - loc)}")
for s in sorted(dep - loc)[:40]:
print(" -", s[:110])
for kw in ["description-item__label", "description-item__content", "empty-title",
"menu-item-title", "alert-wrapper"]:
print(f"关键字 {kw!r}: 部署版={'' if kw in text else ''} 本地={'' if kw in local else ''}")
+46
View File
@@ -0,0 +1,46 @@
# 启动带持久化配置的 Edge 窗口,等待用户登录 Halo 后台。
# 登录成功后脚本自动退出,会话会保留在 pw-profile 目录里供后续扫描使用。
import pathlib
import sys
import time
from playwright.sync_api import sync_playwright
PROFILE = pathlib.Path(__file__).parent / "pw-profile"
LOGIN_URL = "https://blog.liuhangyv.top/console/login"
TIMEOUT_S = 280
def main() -> int:
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE),
channel="msedge",
headless=False,
viewport={"width": 1600, "height": 950},
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(LOGIN_URL)
print("浏览器窗口已打开,请在其中登录 Halo 后台...", flush=True)
deadline = time.time() + TIMEOUT_S
while time.time() < deadline:
try:
url = page.url
except Exception:
print("检测到窗口被关闭", flush=True)
return 2
if "/console" in url and "/login" not in url:
time.sleep(3) # 等待会话 cookie 写入磁盘
print(f"检测到登录成功: {url}", flush=True)
ctx.close()
return 0
time.sleep(2)
print("等待超时,未检测到登录", flush=True)
ctx.close()
return 1
if __name__ == "__main__":
sys.exit(main())
+49
View File
@@ -0,0 +1,49 @@
# 冲突溯源:找出与插件暗色规则竞争的原生规则及其样式表加载顺序
import pathlib
import time
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE), channel="msedge", headless=True
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://blog.liuhangyv.top/console/overview", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
time.sleep(3)
result = page.evaluate(
"""() => {
const targets = ['description-item__label', 'menu-item-title', 'empty-title', 'alert-wrapper'];
const out = [];
const sheets = [...document.styleSheets];
sheets.forEach((sheet, si) => {
let rules;
try { rules = sheet.cssRules; } catch (e) { return; }
for (const r of rules) {
const sel = r.selectorText || '';
if (sel.includes('data-halo-theme')) continue;
for (const t of targets) {
if (sel.includes(t)) {
out.push({ sheetIndex: si, href: (sheet.href || '(inline)').slice(-60), selector: sel.slice(0, 120), body: r.style.cssText.slice(0, 120) });
}
}
}
});
// 插件 bundle.css 的位置
const pluginIdx = sheets.findIndex(s => s.href && s.href.includes('bundle.css'));
return { pluginIdx, totalSheets: sheets.length, matches: out };
}"""
)
ctx.close()
print(f"插件 bundle.css 样式表序号: {result['pluginIdx']} / 共 {result['totalSheets']}")
for m in result["matches"]:
print(f"[sheet #{m['sheetIndex']:>2}] {m['selector']}")
print(f" {m['body']} <- {m['href']}")
View File
+214
View File
@@ -0,0 +1,214 @@
# Halo 后台暗色模式残留扫描器
# 自动发现侧边栏全部 /console 路由,逐页扫描浅色背景 / 深色文字残留并截图。
import json
import pathlib
import re
import sys
import time
from playwright.sync_api import sync_playwright
BASE = "https://blog.liuhangyv.top"
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
SHOTS = ROOT / "shots"
OUT = ROOT / "scan-results.json"
# 在每个页面加载前强制插件进入深色模式
INIT_JS = """
try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch(e) {}
document.documentElement.setAttribute('data-halo-theme', 'dark');
"""
# 页面内扫描:浅色背景(RGB 均 >235 且不透明)、深色文字(RGB 均 <70)
SCAN_JS = r"""
() => {
const results = [];
const seen = new Set();
const isVisible = (el) => {
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden' && +cs.opacity > 0.05;
};
const shortPath = (el) => {
const parts = [];
let cur = el;
for (let i = 0; i < 5 && cur && cur !== document.body; i++) {
let p = cur.tagName.toLowerCase();
if (cur.id) p += '#' + cur.id;
else if (typeof cur.className === 'string' && cur.className.trim()) {
p += '.' + cur.className.trim().split(/\s+/).slice(0, 2).join('.');
}
parts.unshift(p);
cur = cur.parentElement;
}
return parts.join(' > ');
};
document.querySelectorAll('body *').forEach(el => {
if (!isVisible(el)) return;
const r = el.getBoundingClientRect();
if (r.width < 50 || r.height < 20) return;
const cs = getComputedStyle(el);
const issues = [];
const bg = cs.backgroundColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
if (bg && bg[4] !== '0' && +bg[1] > 235 && +bg[2] > 235 && +bg[3] > 235) {
issues.push('light-bg ' + cs.backgroundColor);
}
const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const c = cs.color.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/);
if (hasText && c && +c[1] < 70 && +c[2] < 70 && +c[3] < 70) {
issues.push('dark-text ' + cs.color);
}
if (!issues.length) return;
const cls = (typeof el.className === 'string' ? el.className : '').trim().replace(/\s+/g, ' ').slice(0, 150);
const key = el.tagName + '|' + cls + '|' + issues.join(',');
if (seen.has(key)) return;
seen.add(key);
results.push({
tag: el.tagName.toLowerCase(),
cls,
path: shortPath(el),
issues,
size: Math.round(r.width) + 'x' + Math.round(r.height),
text: (el.textContent || '').trim().slice(0, 40),
});
});
return results;
}
"""
def slug(route: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "root"
def main() -> int:
SHOTS.mkdir(exist_ok=True)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE),
channel="msedge",
headless=True,
viewport={"width": 1600, "height": 950},
)
ctx.add_init_script(INIT_JS)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(BASE + "/console/dashboard", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
time.sleep(3)
if "/login" in page.url:
print("SESSION_EXPIRED 登录态失效,需要重新登录", flush=True)
ctx.close()
return 3
dark = page.evaluate("document.documentElement.getAttribute('data-halo-theme')")
print(f"data-halo-theme = {dark}", flush=True)
# 普查样式表:确认服务端实际部署的插件 CSS 覆盖了哪些内容
census = page.evaluate(
"""() => {
const out = [];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
let darkRules = 0;
let samples = [];
for (const r of rules) {
const t = r.cssText || '';
if (t.includes('data-halo-theme')) {
darkRules++;
if (samples.length < 3) samples.push(t.slice(0, 100));
}
}
if (darkRules > 0) {
out.push({ href: sheet.href || '(inline)', total: rules.length, darkRules, samples });
}
}
return out;
}"""
)
print("=== 包含暗色规则的样式表 ===", flush=True)
for c in census:
print(f" {c['href']} dark规则数={c['darkRules']}", flush=True)
# 关键字探针:确认部署的 CSS 是否包含关键覆盖(判断部署版本新旧)
keywords = page.evaluate(
"""() => {
const kws = ['description-item', 'bytemd', 'week-picker', 'menu-item-title',
'alert-wrapper', 'entity-field-title', 'sidebar__profile', 'card-wrapper'];
const found = {};
for (const kw of kws) found[kw] = false;
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
for (const r of rules) {
const t = r.cssText || '';
if (!t.includes('data-halo-theme')) continue;
for (const kw of kws) if (t.includes(kw)) found[kw] = true;
}
}
return found;
}"""
)
print(f"=== 部署 CSS 关键字探针 === {keywords}", flush=True)
# 从 Vue Router 读取全部已注册路由(含插件注册的菜单页)
try:
routes = page.evaluate(
"""() => {
const app = document.querySelector('#app').__vue_app__;
const router = app.config.globalProperties.$router;
return router.getRoutes().map(r => r.path);
}"""
)
except Exception as e:
print(f"Router 读取失败,回退到锚点抓取: {e}", flush=True)
routes = page.evaluate(
"""() => [...new Set([...document.querySelectorAll('a[href]')]
.map(a => a.getAttribute('href'))
.filter(h => h && h.startsWith('/console')))]"""
)
# Halo Console 的 router base 是 /console/getRoutes() 返回的路径不带 base
routes = sorted(
{
r if r.startswith("/console") else "/console" + r
for r in routes
if r.startswith("/") and ":" not in r and r not in ("/", "/console")
}
)
# 编辑器路由只保留一个样本,避免重复扫描
editor = [r for r in routes if "editor" in r]
routes = [r for r in routes if "editor" not in r] + editor[:1]
print(f"发现 {len(routes)} 个后台路由: {routes}", flush=True)
all_results = {}
all_results["__stylesheet_census__"] = census
for route in routes:
try:
page.goto(BASE + route, wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=6000)
except Exception:
pass
time.sleep(1.5)
# 兜底:再设一次暗色属性,防止插件脚本时序问题
page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')")
time.sleep(0.3)
items = page.evaluate(SCAN_JS)
all_results[route] = items
page.screenshot(path=str(SHOTS / (slug(route) + ".png")))
print(f"{route}: {len(items)} 处疑似残留", flush=True)
except Exception as e: # noqa: BLE001
all_results[route] = {"error": str(e)[:200]}
print(f"{route}: 扫描失败 {e}", flush=True)
OUT.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8")
ctx.close()
print(f"DONE -> {OUT}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())