初始化提交:contentm_agent 工作区全量快照

内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
This commit is contained in:
WorkBuddy committed 2026-10-08 08:13:02 +08:00
commit df56c2c137
1773 files changed
+205840

No files matched your search

@@ -0,0 +1,98 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""无头 Chrome 取证小工具(③段 3b/3d 用)。
--mode dom : 取渲染后的 DOM(自检输出、数值取证都从这里读)
--mode shot : 截图(视口帧或整页长图)
用法示例:
python _tools/cap.py --mode dom --route selftest --out _scratch/selftest.html
python _tools/cap.py --mode shot --route p/p1 --viewport 1440x900 --out _gate_shots/P1-1440.png
⚠️ 本机实测(Chrome 154 · headless=new)——**两条通道对 --window-size 的解释不一样**,
同一台机器、同一个窗口尺寸,拿到的是两个不同视口(探针页实测记录见 3b §五):
--window-size=656,995 ─┬─ --dump-dom ⇒ 视口 640 × 900 (mq<=640 true)
└─ --screenshot ⇒ 视口 656 × 995 (mq<=640 false)
即:dump-dom 通道会从窗口里扣掉滚动条与标题栏(16 / 95),截图通道不扣。
⇒ 想拿到「视口 W × H」,dom 模式要传 (W+16, H+95),shot 模式**原样传 (W, H)**。
⚠️ 视口宽度下限 500(窗口最小 516):`--window-size=390,844` 拿到的仍是 500×844。
要 390px 视口,本机 CLI 做不到(需要 CDP 的 setDeviceMetricsOverride),
所以窄视口只测到 500×844 —— 报告里⛔ 不许写成 390。
"""
import argparse, os, subprocess, sys
from urllib.parse import quote
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
HERE = os.path.dirname(os.path.abspath(__file__))
PROTO = os.path.abspath(os.path.join(HERE, "..", "mcn-workbench.html"))
FRAME_W, FRAME_H = 16, 95 # window-size 与视口的实测差值
MIN_VP_W = 500 # 视口宽度下限(Chrome 窗口最小 516)
def url(route, proto=None):
# Windows 盘符路径 -> file:///E:/...
p = (proto or PROTO).replace("\\", "/")
# ⚠️ 路由不带前导 #/ :本机 bash 是 Git Bash(MSYS),会把 "#/xxx" 当成路径改写成
# "#C:/.../PortableGit/xxx"(实测踩过)⇒ 这里由脚本自己拼 "#/"。
h = route.strip().lstrip("#").lstrip("/")
return "file:///" + quote(p, safe="/:") + ("#/" + h if h else "")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["dom", "shot"], required=True)
ap.add_argument("--route", default="p/p1/ok", help="例:selftest / p/p4/empty(⛔ 不带 #/)")
ap.add_argument("--hash", default=None, help="兼容旧参数(不带 #/)")
ap.add_argument("--viewport", default="1440x900", help="目标 CSS 视口,例 1440x900 / 500x844")
ap.add_argument("--proto", default=None, help="要渲染的文件(默认本目录的 mcn-workbench.html;变异对照用)")
ap.add_argument("--out", required=True)
ap.add_argument("--budget", default="6000")
a = ap.parse_args()
route = a.hash or a.route
vw, vh = [int(x) for x in a.viewport.lower().replace(",", "x").split("x")]
vw = max(vw, MIN_VP_W)
# 🔴 dom 与 shot 两条通道对 --window-size 的解释不同(见文件头实测):截图通道原样等于视口,
# dump-dom 通道要补回被扣掉的 16 / 95。这里分开算,⛔ 别再用同一个偏移量。
win = ("%d,%d" % (vw + FRAME_W, vh + FRAME_H)) if a.mode == "dom" else ("%d,%d" % (vw, vh))
out = os.path.abspath(a.out)
os.makedirs(os.path.dirname(out), exist_ok=True)
cmd = [CHROME, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
"--window-size=" + win, "--virtual-time-budget=" + a.budget,
"--run-all-compositor-stages-before-draw"]
if a.mode == "shot":
cmd += ["--screenshot=" + out]
else:
cmd += ["--dump-dom"]
cmd += [url(route, a.proto)]
r = subprocess.run(cmd, capture_output=True)
if a.mode == "dom":
with open(out, "wb") as f:
f.write(r.stdout)
sys.stderr.write("viewport %dx%d → window %s | dom bytes=%d\n" % (vw, vh, win, len(r.stdout)))
else:
# 🔴 截图出来必须**当面对尺寸**:这条通道曾经因为偏移量算错,把 1456×995 当成
# 「1440×900」交付出去(同一份报告里 dump-dom 又按 640 说话 ⇒ 两份证据互相打架)。
# 现在直接从 PNG 的 IHDR 里读真实像素,跟目标视口比,不等就报红。
if not os.path.exists(out):
sys.stderr.write("shot FAILED\n")
else:
with open(out, "rb") as f:
head = f.read(24)
pw = int.from_bytes(head[16:20], "big")
ph = int.from_bytes(head[20:24], "big")
if (pw, ph) == (vw, vh):
sys.stderr.write("shot ok: %s(视口 %dx%d,PNG 实测 %dx%d ✓)\n" % (out, vw, vh, pw, ph))
else:
sys.stderr.write("shot ⛔ 尺寸不符:目标视口 %dx%d,PNG 实际 %dx%d ⇒ 别拿它当 %dx%d 的证据\n"
% (vw, vh, pw, ph, vw, vh))
err = r.stderr.decode("utf-8", "replace")
if "ERROR" in err.upper():
sys.stderr.write(err[-1500:] + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,93 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""骨架单一可信源闸门:`prd/2b-界面布局.md` §二 ↔ 原型里的 FROZEN 表。
为什么单拎出来:页面里的自检只能证明「渲染出来的板块 == FROZEN 表」,
证明不了「FROZEN 表 == ②段原文」。两张表各改一边就没人发现,所以这条闸门
直接把②段那份 Markdown 当权威读,逐页逐位比。
判据:板块**数量**必须相等,**顺序**必须相等,名字**互为前缀**(②段的名字带
「(可点入)」这类注解,不是给界面抄的标题,⛔ 不逐字比)。
用法:python _tools/check_skeleton.py
"""
import argparse
import os, re, sys
HERE = os.path.dirname(os.path.abspath(__file__))
UI = os.path.abspath(os.path.join(HERE, ".."))
GOAL = os.path.abspath(os.path.join(UI, ".."))
DOC = os.path.join(GOAL, "prd", "2b-界面布局.md")
PROTO = os.path.join(UI, "mcn-workbench.html")
Z2 = "\u3000\u3000" # 全角空格:注释切到右边界用
def from_doc(path=None):
s = open(path or DOC, encoding="utf-8").read()
if "## 二、八页骨架" not in s or "## 三、页面流转" not in s:
raise SystemExit("⛔ 读不到②段的 §二 八页骨架 —— 文件被改结构了?")
seg = s[s.index("## 二、八页骨架"):s.index("## 三、页面流转")]
out, cur = {}, None
for line in seg.split("\n"):
m = re.match(r"^### (P\d) ", line)
if m:
cur = m.group(1).lower()
out[cur] = []
continue
m2 = re.match(r"^\s*(\d+)\.\s*(.+)$", line)
if cur and m2:
out[cur].append(m2.group(2).split("——")[0].strip())
return out
def from_proto(path=None):
s = open(path or PROTO, encoding="utf-8").read()
m = re.search(r"^const FROZEN=\{(.*?)^\};", s, re.S | re.M)
if not m:
raise SystemExit("⛔ 原型里找不到 const FROZEN 表")
out = {}
# ⚠️ 逗号必须可选:表里最后一项后面没有逗号 —— 早前写成必选,p8 被静默漏掉
# (表现为「页面集合不同」,不是静默通过,但那也是漏;已修)
for k, body in re.findall(r"^\s*(p\d):\[(.*?)\],?\s*$", m.group(1), re.M):
items = re.findall(r"'([^']*)'", body)
out[k] = items[1:] # 第 0 项是页面标题
if len(out) != 8:
raise SystemExit("⛔ FROZEN 表只解析出 %d 页(应为 8):%s" % (len(out), sorted(out)))
return out
def norm(x):
return x.split("(")[0].strip()
def main():
ap = argparse.ArgumentParser(description="骨架单一可信源闸门(②段原文 ↔ 原型 FROZEN 表)")
ap.add_argument("--doc", default=None, help="②段界面布局文档(默认 prd/2b-界面布局.md)")
ap.add_argument("--proto", default=None, help="原型 HTML(默认 ui/mcn-workbench.html)")
a = ap.parse_args()
doc, proto = from_doc(a.doc), from_proto(a.proto)
bad = []
if set(doc) != set(proto):
bad.append("页面集合不同:②段 %s / 原型 %s" % (sorted(doc), sorted(proto)))
for p in sorted(set(doc) & set(proto)):
a, b = doc[p], proto[p]
if len(a) != len(b):
bad.append("%s 板块数 ②段 %d ≠ 原型 %d(②段 %s / 原型 %s)" % (p, len(a), len(b), a, b))
continue
for i, (x, y) in enumerate(zip(a, b)):
nx, ny = norm(x), norm(y)
if not (nx.startswith(ny) or ny.startswith(nx)):
bad.append("%s 第 %d 块 ②段「%s」≠ 原型「%s」" % (p, i + 1, x, y))
n = sum(len(v) for v in doc.values())
if bad:
print("FAIL 骨架与②段不一致(②段 %d 个板块):" % n)
for x in bad:
print(" · " + x)
return 1
print("PASS 骨架冻结:八页 %d 个板块,②段 2b-界面布局.md §二 与原型 FROZEN 表逐页逐位一致" % n)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,174 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""变异对照:证明自检的判据不是「恒绿」。
做法:把原型复制一份,往里注入一处**已知的假病**,再跑一次自检,
要求它必须报红,并且红在**预期的那一条**上。测完即删临时件。
为什么要这个:一个永远 PASS 的自检等于没有自检。只有「注病 → 报红 → 复原 → 回绿」
这条链走通了,前面那串 PASS 才算证据。
用法:python _tools/mutate_test.py
"""
import os, re, subprocess, sys, tempfile, html
HERE = os.path.dirname(os.path.abspath(__file__))
UI = os.path.abspath(os.path.join(HERE, ".."))
PROTO = os.path.join(UI, "mcn-workbench.html")
PY = sys.executable
# (名称, 说明, 查找串, 替换串, 期望出现的报红片段, 视口[可选,默认 1440x900])
MUTANTS = [
("M1 死控件",
"把 P2-01「低粉爆款」的实现掏空 —— 点它不再有任何反馈",
"'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },",
"'P2-01':()=>{},",
"P2-01"),
("M2 未登记的可点元素",
"在页面上塞一个没写进交互清单的 data-act",
"<span class=\"action-cell\"><button class=\"btn btn-sm\" data-act=\"P2-03\"",
"<span class=\"action-cell\"><button class=\"btn btn-sm\" data-act=\"ZZ-99\">野按钮</button><button class=\"btn btn-sm\" data-act=\"P2-03\"",
"未登记的 data-act"),
("M3 悬空声明",
"清单里声明了一个页面上根本不存在的 ID",
"'CHECKOUT-OK':()=>{",
"'ZZ-98':()=>{},\n 'CHECKOUT-OK':()=>{",
"声明了但页面里没有"),
("M4 骨架漂移",
"给 P3 多塞一个板块 —— ②段那份骨架是冻结的,这属于越界",
"${head('分区切换',",
"${head('多出来的板块', '')}<div class=\"panel\"><p class=\"meta\">越界加的块</p></div>${head('分区切换',",
"骨架与②段不一致"),
("M5 对比度越界",
"把 --ink-faint 换成偏白的灰,压到契约线以下",
"--t-ink-faint:#8a887f;",
"--t-ink-faint:#c9c7c0;",
"对比度 --ink-faint"),
("M6 点击无反馈(跳页不同步)",
"把 go() 退回「只改 hash」的写法 —— 点完状态不动,自检应判它没反应",
" S.route=page; S.pageState=st||'ok'; S.layer=null;\n const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }\n paint();",
" const want='#/p/'+page+'/'+(st||'ok');\n if(location.hash!==want){ try{ location.hash=want; }catch(e){} }",
"驱动了没反应"),
("M7 行高不齐(放开换行)",
"删掉「行内每段不许换行」这条规则 —— 窄视口里文字一折行,行高就随文字长短乱跳",
".list-row:not(.is-dense) .primary-cell > *,\n"
".list-row:not(.is-dense) .secondary-cell > *{\n"
" min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;\n}",
"/* 变异:去掉不许换行 */",
"行高不齐", "768x900"),
("M8 密度退化(内衬变肥)",
"把列表行内衬从 12px 加到 36px —— 行变肥,一屏就装不下 6 行了",
" padding:var(--mg-12) var(--mg-8); border:0; border-top:1px solid var(--line-soft);",
" padding:36px var(--mg-8); border:0; border-top:1px solid var(--line-soft);",
"低于契约 6 行"),
("M9 量尺寸时页面不在零点",
"量 P1 之前先把页面滚下去 —— rect 取到的是相对视口的坐标,滚了之后 top 全变负,"
"「一屏装得下几行」的减法就会凭空变大(这条守卫盯的就是它)",
" guardScroll('P1');",
" window.scrollTo(0,300);\n guardScroll('P1');",
"被滚到", "768x900"),
("M10 行分隔线被关掉",
"把选择器退回成 `.list-row:first-child` —— 每行都包在 <li> 里,于是每行都是自己 <li> 的 "
"first-child,整张表的分隔线一起消失(原型里修掉过的真缺陷,原样退回)",
"li:first-child > .list-row{ border-top:0; }",
".list-row:first-child{ border-top:0; }",
"行分隔线根本没画出来"),
("M11 浮层没有遮罩",
"去掉遮罩层 —— 「点层外空白关掉浮层」这条通道就没了",
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh ? `<div class=\"layer-mask\" data-act=\"X-mask\" aria-hidden=\"true\"></div>`+lh : '';",
"const lh=layerHTML();\n $('#layer-root').innerHTML = lh;",
"没有遮罩"),
("M12 关掉浮层后焦点不还回去",
"把 closeLayer 退回「只清状态」的写法 —— 焦点会掉回 body,键盘用户就断了",
" const back=layerTrigger;\n S.layer=null; paint();\n if(back){ const el=document.querySelector('[data-act=\"'+back+'\"]'); if(el) el.focus(); }",
" S.layer=null; paint();",
"关掉后焦点没回到"),
("M13 浮层多出一个中性关闭出口",
"给「随手记」再加一颗 ×(多一个 X-close)—— 一个层两个中性出口会让用户找不着北,"
"这条判据原先只数 .layer-close 类名,多出来的是同类,应当报红",
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button>`;",
" const close=`<button class=\"layer-close\" data-act=\"X-close\" aria-label=\"关闭\">×</button><button class=\"layer-close\" data-act=\"X-close\" aria-label=\"再关一次\">×</button>`;",
"中性关闭出口 = 2"),
]
def read_out(dom_path):
s = open(dom_path, encoding="utf-8", errors="replace").read()
m = re.search(r'<div class="st-out" id="selftest-out">(.*?)</div>', s, re.S)
if not m:
return None
return html.unescape(re.sub(r"<[^>]+>", "", m.group(1)))
def run(proto, vp="1440x900"):
out = os.path.join(tempfile.gettempdir(), "mut-selftest.html")
r = subprocess.run([PY, os.path.join(HERE, "cap.py"), "--mode", "dom", "--route", "selftest",
"--viewport", vp, "--proto", proto, "--out", out],
capture_output=True, text=True)
if not os.path.exists(out):
return None
return read_out(out)
def headline(text):
m = re.search(r"自检结果:PASS (\d+) / FAIL (\d+)", text or "")
return (int(m.group(1)), int(m.group(2))) if m else None
def main():
src = open(PROTO, encoding="utf-8").read()
base = run(PROTO)
hb = headline(base)
print("基线(未注入):PASS %s / FAIL %s" % hb if hb else "基线读不到自检输出")
if not hb or hb[1] != 0:
print("⛔ 基线就不是全绿 ⇒ 先修原型,别急着做变异对照。")
return 2
bad = 0
for row in MUTANTS:
name, why, find, repl, expect = row[:5]
vp = row[5] if len(row) > 5 else "1440x900"
if find not in src:
print("⚠️ %s:找不到注入点,跳过(注入点失效本身也要修)" % name)
bad += 1
continue
tmp = os.path.join(tempfile.gettempdir(), "mut-proto.html")
with open(tmp, "w", encoding="utf-8") as f:
f.write(src.replace(find, repl, 1))
text = run(tmp, vp)
h = headline(text)
os.remove(tmp)
if not h:
print("❌ %s:注入后自检跑不出来(连输出都没有)" % name)
bad += 1
continue
caught = h[1] > 0 and expect in (text or "")
print("%s %s:注入(%s) ⇒ PASS %d / FAIL %d | %s" %
("✅" if caught else "❌", name, why, h[0], h[1],
"报红且红在预期处(%s,视口 %s)" % (expect, vp) if caught
else "**没在预期处报红(期望含「%s」,视口 %s)**" % (expect, vp)))
if not caught:
bad += 1
print()
print("变异对照:%d / %d 通过" % (len(MUTANTS) - bad, len(MUTANTS)))
if bad == 0:
print("⇒ %d 处假病全部被自检抓住,且基线回绿:这套判据不是恒绿的。" % len(MUTANTS))
return 0 if bad == 0 else 1
if __name__ == "__main__":
sys.exit(main())