单文件 HTML 工作台 · 模块注入与回归验证
适用于 <本地路径> 这类单文件、零外链、localStorage 持久化、
体积 3MB 级的工作台。核心难点:文件太大不能整体重写,必须做定点注入;改完必须验证,
否则一个吞掉的函数声明能让整个页面白屏。
一、注入前:先摸清五个挂点
新增一个业务模块(以「可研审核 → nav key = fs」为例)需要同时挂到 5 处,缺一个就残废:
| 挂点 | 位置 | 说明 |
|---|---|---|
| 导航项 | 侧栏 data-nav="fs" |
与 PAGES 的 key 一致,否则点击无反应 |
| PAGES 映射 | var PAGES = {...} |
加 fs:'page-fs';决定 hash 路由 |
| 页面容器 | <section class="page" id="page-fs" hidden> |
用 hidden 属性,不是 class |
| 数据块 | /*XX_START*/ … /*XX_END*/ |
成对标记,便于下次脚本重建时整块替换 |
| 渲染入口 | refreshAll() 里加 renderXxxAll() |
否则刷新后新模块是空的 |
状态持久化另需:boot() 里调 xxxLoad();localStorage key 统一前缀 wb_。
二、注入方法:写 Python 脚本,不要手改
3MB 文件不适合用 Edit 反复改大段。写一次性脚本(_add_xxx.py),用成对标记做幂等替换:
p = r'<本地路径>'
s = open(p, encoding='utf-8').read()
def inject(s, start, end, body):
"""标记存在→整块替换;不存在→在 end 锚点前插入"""
...
s = inject(s, '/*FS_DATA_START*/', '/*FS_DATA_END*/', data_js)
open(p, 'w', encoding='utf-8').write(s)
铁律:注入前先 open(p,encoding='utf-8').read() 并 cp 一份 index.bak.html。
三、三个高频坑(都已踩过)
- 吞掉函数声明 — 插入
var FS_DEMO={...}时若少了换行,会把下一行的
function renderFsCalc(){吃掉,页面全白。注入后必查:
node --check+ grep 确认每个function xxx仍独立成行。 - 重复 const 声明 — 曾经同时存在
const KB与var KB,文件涨到 6.09MB。
数据块一律用var,且全文件只允许一处声明。 - 事件委托误插 — 把新增的
fs-*分支写进了$('#fileInput')的 if 块里,
导致永远不触发。文件级事件是统一委托(closestAttr(el,'data-act')),
新分支必须加到主 switch/映射上,不要嵌套进别的元素判断里。 - Word/WPS 导出的 PDF 会把字符「逐字定位」,提取文本变成
项 目 概 况、5 0 0 0 2 平方米、
3 . 1 4。任何关键词检索/数字正则都会失配(章节全报「缺失」、数字只读到末位2平方米)。
解法是在检索层统一归一,不要指望解析层永远完美:
- 汉字:删「汉字/中文标点之间」的空格(fsNormCjk),保留换行。
- 数字:删「处于数字/小数点之间的空格」(fsNormNumText),5 0 0 0 2→50002。
- 归一必须区分「逐字拆散」与「表格相邻列」:只合并左右各自仅 1 个字符的空格
(逐字拆散=每段 1 字);若任一侧是多字符完整数字(表格列44,000.00 100.00)绝不合并,
否则会把金额列与占比列粘成44,000.00100,抽取值整体错位。
参考实现:(?<![0-9.,])([0-9.])[ \t\u00a0\u3000]+(?=[0-9.](?![0-9.,]))+ 最多 8 遍循环。
- 同步把复合单位并进正则(万平方米|万平米|万㎡),并做量纲折算(万×1e4、公顷×1e4、亩≈666.67)
统一到基准单位,否则「5万平方米」与「50000平方米」无法勾稽。
- 回归要同时锁两个方向:拆散文本「能读全」,表格文本「不粘连」。 - 表头 / 表号 / 行号会冒充数值,比「读不全」更隐蔽。真实投资估算表踩过的:
- 表头「占建设总投资比例(%)」→ 关键词「总投资」被当取值标签,抓到下一行的序号 1;
- 标题「建设投资估算 表11-1」→ 抓到11;
- 反向兜底(数字→标签)抓到行号6(「6 建设期利息」)。
在「取值构造函数」(如fsMkHit)里加闸门:① 数字后紧跟-的排除(表/图编号);
② 反向匹配里「无单位的小整数(1–2 位)」排除(行号);③ 钱/面积类指标,标签与数字之间的 gap
含占|比例|比重或单位是%→ 排除(表头/比率句)。
关键教训:判据只能用「标签↔数字」之间的 gap,绝不能用相邻字符窗口——否则会被上一行的%
(表头前一行6.81%)或括号里的参数(FNPV(ic=6%))误伤,把正确值也杀掉。
另外:金额要让v>=0——投资表里「建设期利息 0 / 增值税 0」是合规写法,视作缺失会误报「未检索到建设期利息」。 - 能读表就顺手做「表内勾稽 + 规模匹配」,价值远高于单点抽值:
逐行解析「序号 名称 金额 占比%」→ 校验 分项合计=合计行、各占比=金额/合计、基本预备费=费率×工程费用;
再按「工程规模与投资相匹配」提示可能错误的数字并给修改建议
(如工业项目「设备购置费 < 安装工程费」多为少写一位;单位建筑面积投资偏离常规区间)。
解析行要容许「同行」与「每列各占一行」两种 PDF 提取形态,名称允许带括号注释。 - 抽 XML 文本的正则要限定标签名边界:
<w:t[^>]*>会先匹配到<w:tblPr>/<w:tcPr>,
于是把整段表格标记当正文吞进去。必须写<w:t(?:\s[^>]*)?>(标签名后紧跟空白或>)。
通用版:'<' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + tag + '>'。 - 解 zip 里的条目要用
DecompressionStream('deflate-raw'),不是'deflate'
(zip 存的是裸 deflate,无 zlib 头)。用错会静默拿到空串。 - Edge 无头
--dump-dom会把内联 JS 源码一并 dump,于是脚本里的
class="sk-card"、data-act="…"等字符串也会被 grep 命中,计数比真实 DOM 多。
核对渲染数量时,要么选只存在于 DOM 的锚(如id="…"),要么先扣掉脚本里的字面量。
三 bis、需要「启动本机程序」时:加一个本地桥接服务
浏览器安全沙箱不允许 file:// 页面拉起本机进程(会静默失败或直接报 CORS)。
当工作台要「运行本机 Python 脚本 / 看控制台输出 / 打开生成的报告」时,
正确解法是旁边放一个零依赖的本机 HTTP 桥接,而不是试图在页面里做。
服务端(agent_bridge.py,纯标准库)
- 仅绑
127.0.0.1,端口被占则从 8777 自动顺延;同时支持 CORS 头和 JSONP。 - 接口:
/api/status(探测项目路径、脚本、解释器、报告清单)、/api/run(拉子进程)、
/api/tail?id=&from=N(增量取 console 输出,threading.Condition.wait(0.9)兼顾实时与省线程)、
/api/stop、/api/report(返回 HTML 原文给 iframe 直接src)。 - 安全底线:只能跑「白名单项目 × 白名单参数」,
shell=False,不拼接任何用户传入的自由文本;
报告路径只取basename再校验所在目录,杜绝../穿越。同一项目同时只允许一个实例。 - 子进程务必设
PYTHONIOENCODING=utf-8、PYTHONUNBUFFERED=1,并用python -u,
否则 Windows 下中文乱码且输出不会实时流出。解释器的 pandas 等依赖要保证齐全,
不要假设 PATH 上的 python 就有包——要逐个候选跑python -c "import pandas"实测。 - 「停止」要
proc.kill()+taskkill /PID <pid> /T /F双保险,否则残留进程。
前端
- 所有 GET 走 JSONP(动态
<script>+window[cb]),不走fetch。
理由:file://的 origin 是null,某些浏览器收紧策略时 CORS 响应会被吞掉;
<script>标签不受同源策略限制,实测可行——这是唯一能保证「怎么都能用」的通道。 - iframe 指向
http://127.0.0.1:PORT/api/report?agent=xxx即可在平台内嵌看报告。 - 必须优雅降级:桥接探测失败时,运行按钮自动换成「打开文件夹」链接,页面零报错;
并提供「未启动怎么办」弹窗(含可复制的启动命令)。 - 刷新后要能接管:长任务动辄十几分钟,用户很可能刷新页面。
启动/探测时若发现服务端仍在跑某任务,自动重建控制台并续接tail,同时把卡片置为「运行中」并禁用按钮。 - 「外链统一用某个浏览器打开」也走桥接(2026-10-05 实测):
file://页面无法指定用哪个浏览器开外链(只能交给系统默认),
但可以让桥接加一个GET /api/open?url=...,用subprocess.Popen([firefox_exe, url], shell=False)
拉起指定浏览器。前端用捕获阶段的全局 click 监听统一接管:
js
document.addEventListener('click', onExternalLinkClick, true); // 捕获:先于 onClick 的 data-act 分发
必须守住的边界(缺一条就会踩坑):
- 只接管 ^https?://;file:// / mailto: / 相对路径 / 带 download 的链接原样放行
—— 工作台里「打开文件夹」「打开原文」都是 file://,误接管会直接坏功能。
- 能力不满足时绝不 preventDefault(桥接离线 / 未装目标浏览器)——回退系统默认,
否则点链接会「毫无反应」,比不接管还糟。
- 桥接侧把「本机是否装了该浏览器」放进 /api/status(如 browser:{firefox,path}),
前端据此决定是否接管;先探测再接管,不要盲拦。
- 忽略修饰键(metaKey/ctrlKey/shiftKey/altKey)与中键 —— 尊重用户自己的开窗习惯。
- 打开失败要可见提示 + 回退 window.open,不能静默失败。
- 服务端只放行 http/https,且用 [exe, url] 数组传参(不拼字符串),路径可用环境变量覆盖。
三 ter、需要「在工作台里联网检索」时:桥接内做多引擎后台检索
同一类需求的第二面(2026-10-05 实测):用户要「找项目、把结果直接展示在平台里,
不要在外部打开浏览器」。file:// 页面不能自己跨域抓取,所以联网这件事同样得落到桥接里,
再让前端把结果渲染成平台内的卡片(而不是给一堆外链让人自己点)。
关键设计:一次查询 = 多引擎并发 + 硬预算
单一搜索引擎不可靠(会限流、会走代理隧道失败、会弹安全验证)。做法是四路并发、谁快用谁:
_BID_ENGINES = (("360搜索", _so_search), ("中国政府采购网", _ccgp_search),
("百度", _baidu_search), ("必应", _bing_rss))
_BID_BUDGET = 9.0 # 硬预算:到点未回的源直接标「超过 N 秒预算,本轮跳过」
futs = {ex.submit(fn, q, n): name for name, fn in _BID_ENGINES}
done, not_done = wait(list(futs), timeout=_BID_BUDGET) # 不等齐,够时间就出榜
ex.shutdown(wait=False)
- 别串行、别死等:政采网响应 0.5s~20s 还会限流,串行会把整轮拖成十几秒。
- 每个源独立降级:
_baidu_search遇<title>百度安全验证直接raise,
前端把该源显示成百度 ✗——如实报错,绝不假装"没搜到"(否则用户以为全网无结果)。 - 统一打分再排序,把「招标公告」顶到前面:
_bid_score(招标+4/采购+2/公告+2/中标成交+2/
项目+1/投标标段询价比选+1,封顶 10);_norm_key(忽略 www/query/末尾斜杠)跨源去重。 only_bid时真过滤([x for x in merged if x["score"]>=3]),不要「全不命中就回退」——
那等于没过滤,用户会以为筛选坏了。
抓取层的四个必备细节
_http_get返回三元组(正文, Content-Type, final_url)——final_url(r.geturl())
是跳转后的真实地址,后续「看正文」要用它,不能用请求时的壳地址。- 先删强调标签再去标签(
_clean_label):搜索结果标题常带<em>关键词</em>高亮,
直接re.sub('<[^>]+>','')会把天津厂房两边的标签变成空格 →
中交建筑九公司 天津厂房 项目。必须先re.sub(r'</?(?:em|b|strong|span)[^>]*>','',s)再清标签。 - 跳转壳延后解析(
_is_link_wrapper+_resolve_redirect):百度/搜狗的结果链接是
.../link?url=...壳,不要在每个引擎里同步解析(慢且重复);等榜单排序定下、只对
前 8 条壳做一次_resolve_redirect。360 则优先用它给的data-mdurl免跳转直取。 - 垂直源要剥
site:(_strip_site):把「百度结果里的site:ccgp.gov.cn」原样丢给
政采网自己的站内搜索,只会搜不到。 - 出口要过 SSRF 闸门(
_safe_url:仅 http/https 且解析到公网)——但注意
它会让离线单测失败(假域名 DNS 解析不了),测试里必须patch掉它。
前端侧:结果落在平台内,正文就地看
- 删掉一切检索外链:
<button data-act="query-project">查询项目▸</button>代替
<a target="_blank">。弹窗里零<a>、零target="_blank"——可用一条
!/qpModal[\s\S]{0,20000}target="_blank"/.test(html)断言锁死。 - 新增
qpViewBody(i):就地展开/收起正文,bridgeCall('/api/fetch',{url},25000),
正文块用white-space:pre-wrap+max-height+ 截断提示(如 1600 字)。 - 标题用纯文本节点(
<div class="qp-t">)而不是链接——从渲染层堵死"变成外链"。 - 显示各源健康状态(
360搜索 7 | 百度 ✗ | 必应 6):让人一眼知道哪路挂了、是没结果还是没搜成。 - 接口参数升级成
_api_search支持n(夹取 1–30)与only=bid,前端传{q, n:15}。
验收(关键:证明"没开浏览器")
- 端到端起桥接 →
curl /api/search拿到真实公告 → 检索前后tasklist | grep -c firefox数字不变
⚠️ 这才是"只在后台跑、不开浏览器"的硬证据,不要只用"没报错"敷衍。 - 前端四套:
test_project_query.js(去外链 +qpViewBody)、test_bid_links.js(第三项改按钮)、
test_query_headless.js(场景 D:桩桥接出结果 + 就地看正文,断言 dump > 4MB)、
test_bridge_search.py(纯离线 unittest,桩_http_get和_safe_url)。
三 quater、让「操作员在页面里就能启动服务」:自定义协议 + 静默启动器
> 触发场景:页面提示「请双击 xxx.bat 启动服务」,但页面里没有入口,操作员无从下手。
> 根因是 file:// 页面不能拉进程;光改提示文案没用,要给一条真能点的通路。
三件套
| 组件 | 作用 |
|---|---|
静默启动器 <name>_silent_launch.py |
幂等:先探端口范围的探活接口,命中即退 0;否则 pythonw + CREATE_NO_WINDOW 隐藏拉起 |
自定义协议 HKCU\Software\Classes\<proto> |
页面点一下 → Windows 去执行启动器(免管理员、仅当前用户) |
Startup 目录 .lnk |
登录后自动常驻,日常无感 |
启动器要点
- 探活必须校验服务自述(如
svc == "agent-bridge"),只看端口通不通会把别的服务当成自己。 - 命名互斥体(
ctypes.WinDLL("kernel32").CreateMutexW+ERROR_ALREADY_EXISTS)防并发;
拿到锁后再复检一次端口,否则两个并发调用会各起一个。 creationflags = DETACHED_PROCESS \| CREATE_NO_WINDOW \| CREATE_NEW_PROCESS_GROUP。- 脚本内不
print、不input(pythonw下sys.stdout是 None),一律写文件日志。 - 退出码约定化(0 已跑/成功、2 无解释器、3 拉起后未就绪、4 异常),便于排查。
前端要点
- 用动态 anchor +
.click(),绝不用location.href='proto://...':
协议未注册时后者会把整个页面导航走(白屏),前者只会静默失败。 - 触发后轮询探活(如 500ms × 12s),不要假设「点了就成功」;失败要落到可操作的帮助弹窗。
- 状态提示只留一条:长说明放结果区,按钮旁只放「已连接/未连接」短状态,
同屏出现 3 条「未连接」是典型的重复文案问题。 - 新按钮走既有的全局
data-act委托;不要在弹窗里覆写结果区去塞按钮(会打破别的场景的 DOM 断言)。
四个坑(都实测过)
- 沙箱屏蔽
reg.exe/wmic.exe→ 注册表一律用 Pythonwinreg或 PowerShell,
且只写 HKCU。写前先读:若协议已被别的应用占用,报冲突而不是覆盖。 - 单测会污染生产日志:被测模块写文件日志时,
setUp必须先把日志路径重定向到临时文件,
tearDown恢复并删除。否则跑一次单测就往真实日志里塞一堆假记录。 - 中文 Windows 的
netstat输出是 GBK:subprocess.run(..., text=True)会整段解码失败、
stdout变None(表现为"找不到进程")。要capture_output=True后手动.decode('gbk','replace')。 - 并发排序要带兜底键:多引擎并发合并时若只按分数排序,同分条目顺序会随线程完成顺序抖动
(flaky 测试的根因)。加key=lambda x: (-score, url)让输出确定。
验收
os.startfile('proto://start')(或点页面按钮)→ 端口 LISTENING、无黑窗;日志有 banner。- 幂等:已跑时再触发 →
pythonw进程数不变,日志already running ... exit 0。 - ⚠️ 端到端能否探活取决于进程怎么被拉起:ShellExecute / 协议拉起的不受沙箱网络限制,
可从脚本内直接验证;而 Bash 直接python -c派生的进程探活可能被沙箱拦(表现为"端口开着也连不上"),
别据此误判成 bug ——用netstat+tasklist佐证。
四、回归验证:Edge 无头 + DOMContentLoaded harness
1. 语法校验(必做,5 秒)
// _check.js —— 抽出全部内联 script 后 node --check
const re=/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g;
> 注意:只用 --check 不做静态分析,避免误报。
2. 交互回归(关键)
生成 _test.html = 原文件 + </body> 前注入 harness。必须挂 DOMContentLoaded:
脚本在解析期执行,boot() 还没跑,直接取 DOM 会全空;挂在 boot 之后(后注册的
监听器后执行)才能拿到渲染结果。
window.addEventListener('DOMContentLoaded', function(){
var out=[], __err=[];
// 逐页导航:任何一页 hidden 或抛错都记下来
Object.keys(PAGES).forEach(k=>{ nav(k); ... });
// 事件模拟:click 之后必须再 dispatch change,否则委托监听不到
cb.click(); cb.dispatchEvent(new Event('change',{bubbles:true}));
document.getElementById('TESTOUT').setAttribute('data-r','['+out.join('][')+']');
});
运行(Git Bash):
EDGE="/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
"$EDGE" --headless=new --disable-gpu --no-sandbox \
--virtual-time-budget=8000 --dump-dom "file:///F:/.../_test.html" > _dump.html
grep -o 'data-r="\[.*\]"' _dump.html | head -1 | sed 's/\]\[/\n/g'
3. 必备断言清单
PAGES数量与逐页hidden状态(导航回归)- 新模块容器
innerHTML.length > 0 - 至少 1 个勾选类交互:断言前后文本变化(
0/58 → 1/58),不要只断言不报错 window.onerror收集:最终必须是ERRORS=none- 统计卡 foot 文案正确(暴露汇总逻辑错误)
4. harness 选择器要先核实
教训:写 .lib-filters select 得到 NONE,真实是 #libFilter;.pcard 在库列表里
类名不同。先 grep 出真实 id/class 再写断言,否则假阴性会让你误判功能坏了。
5. 注入 harness 的时序坑:别嵌套 setTimeout、别覆盖页面全局函数
2026-10-05 实测(项目看板新增「查询项目」按钮,需要"点开弹窗再点弹窗内按钮"两级交互):
同一份 HTML,注入方式不同,结果天差地别——
load+ 单层setTimeout(..., 400)点击 → 弹窗正常打开,断言全绿;- 改成嵌套 setTimeout(先开弹窗、再点里面的「开始查询」),
或在 load 回调里覆盖页面全局函数(想桩掉桥接:window.detectBridge=()=>Promise.resolve(false))
→ 页面只渲染了一部分,dump 体积从 4.1MB 掉到 2.75MB,弹窗根本没开,
且 stderr 里一条报错都没有。表现像"功能坏了",实际是页面被 dump 在半渲染状态。
原因:--virtual-time-budget 下虚拟时钟会被页面自身 setInterval 快进,
多层定时器 / 中途改写全局函数会打乱 boot 的推进节奏。
对策:
- 注入脚本只保留一层 setTimeout,一次只测一个动作;多级交互拆成多次运行;
- 需要桩掉某函数(桥接探测、随机数、当前时间)时不要覆盖全局——
该分支交给 Node 侧 vm 桩测试(本次「桥接离线降级」就是 test_project_query.js 覆盖的),
浏览器侧只验"能开、能渲染、无报错";
- 浏览器侧判据三样:class="mask on"(弹窗真开了)、目标容器内有真实渲染内容、
stderr 过滤 Uncaught|not defined|not a function|SyntaxError 为空。
> 当晚更正:上面「别嵌套 setTimeout」的结论过强了。
> 同日补做「无项目也能查询」三场景回归(个人工作台/项目看板-测试/test_query_headless.js),
> 用的就是两层嵌套 setTimeout(500ms 开弹窗 → 700ms 点弹窗内按钮),
> 三场景 22/22 全绿、dump 稳定 4.16MB。真正的杀手是 虚拟时钟预算被真实网络探测吃光:
> 流程里一旦触发对 127.0.0.1:8777 的桥接探测,--virtual-time-budget 就被这个"慢"请求耗完,
> dump 停在半渲染状态(约 2.75MB)。所以:
> - 判据补一条 dump 体积——正常约 4.1MB,掉到 2.7MB 左右即半渲染,别急着怀疑功能坏了;
> - 流程会碰桥接探测的,--virtual-time-budget 给到 12000 以上;
> - 想彻底绕开就用 Node 侧 vm 桩覆盖该分支,浏览器侧只验"能开、能渲染、无报错"。
8. 「2.7MB 半渲染」还有第二个成因:读 dump 太早(比虚拟时钟更常见)
同晚再查:把上面那套 harness 改成 execFileSync(EDGE, ..., {stdio:['ignore', outFd, errFd]})
(Edge 直接写文件、Node 用 fd 打开)后,出现同一份文件
Node 读到 2,759,822 字节 → 几秒后同一路径变成 4,166,231 字节。
即:Edge 的启动进程已退出、execFileSync 已返回,但它派生的子进程仍在往同一个 fd 里续写 dump。
读早了就拿到"看着像半渲染、其实只是没写完"的 DOM——<html> 结尾还完整,极难察觉。
对策(写进 test_query_headless.js 的 waitStable()):
/* 等文件大小连续 3 次不变再解析;别用睡眠猜时长 */
let last=-1, same=0, t0=Date.now();
while(Date.now()-t0 < 8000){
const size = fs.statSync(file).size;
if(size>0 && size===last){ if(++same>=3) break; } else { same=0; }
last = size; /* 同步小睡 100ms */
}
并且给 dump 加一条体积下限断言(本工作台正常 ≈4.1MB,断 size > 4000000),
一旦真出现半渲染会立刻红,而不是让后续断言产生莫名其妙的假阴性。
四、已落地模块示例:总图概念设计(nav key = siteplan)
2026-10-06 接入,挂在建筑设计板块入口卡 + 侧栏 nav siteplan。
五个挂点对照(与上文「可研 fs」同构,但走的是"前端 UI + 独立 site-planning 技能判定"混合形态):
| 挂点 | 实现 |
|---|---|
| 导航项 | <button class="navi sub" data-nav="siteplan">(建筑设计按钮子级) |
| PAGES 映射 | siteplan:{title:'总图概念设计',sub:'…'}(挂在 cad 之后) |
| 页面容器 | <section class="page" id="page-siteplan" hidden> |
| 数据/JS 块 | SITEPLAN_JS 整段注入到 function boot(){ 前(用 function initSiteplan(){ 作存在性守卫) |
| 渲染入口 | boot() 内 initSiteplan();;事件委托 data-act="sp-generate/sp-cad/sp-reveal/siteplan-open" |
配套后端:~/.workbuddy/skills/site-planning/(Python 引擎 + agent_bridge.py 的 /api/siteplan 五端点 + 中望 CAD /b 布图.scr 拉起)。
重注入坑(务必记牢)
_add_siteplan.py 用 function initSiteplan(){ 已存在则跳过整段 JS 重注入。
所以改过 SITEPLAN_JS 后重跑脚本不会生效——必须直接改 index.html 里已注入的对应函数,
并同步改 _add_siteplan.py 源,让两者一致(将来在干净 index 上重跑才正确)。
该模块的功能要点
- 输入三类:规划条件 / 甲方需求楼栋(名称·面积㎡·层数·高度m)/ 地形(假设矩形 | 手动红线坐标 | DXF红线"后续接入"占位)。
- 单体楼栋可上传外轮廓 DXF:前端每栋行加
input[type=file],spGenerate用FileReader.readAsText读 DXF 文本随 payload 提交;
后端sp_bld_dxf.parse_outline()取面积最大的闭合多段线,mm/m 单位换算,按真实轮廓布图与核算基底面积
(布图仍用轴对齐外接框占位,与道路栅格模型自洽)。 - 指标口径:HTML 报告 / DXF 图签 / SVG 图签 三处统一调
sp_metrics.fmt_num(),确保数字同源同格式。 - 回归:Python
tests/全套(含test_consistency.py、test_bld_dxf.py)+test_siteplan_pure.js(node --check)+test_siteplan_headless.js(Edge 无头)。
9. 无头测试里别「真点」会导航的链接 —— 它会把 dump 毁掉
2026-10-05 实例(工作台外链改用 Firefox):为了验证「file:// 链接不被接管」,
在 harness 里对 <a href="file:///F:/workbuddy"> 派发了真实
dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))。
结果是页面真的导航走了(这恰恰证明它「没被接管」是正确行为),
dump 只剩 318KB、<title> 变成了那个 file URL,harness 的结论全丢,看着像功能坏了。
对策:
- 正向用例(期望被接管、preventDefault 已生效的链接)可以真点——不会导航;
- 反向用例(期望不被接管的 file:// / 非 http 链接)只发合成事件对象
({target:a, button:0, preventDefault(){this.prevented=true}} 直接调处理器),
或用 e.preventDefault 是否被调用作为判据,永远不要真的 dispatch 一次会导航的点击;
- 同理,浏览器级用例里桩 window.bridgeCall 而不是让桥接真去开浏览器——
否则每跑一次测试就在用户屏幕上弹一个窗口。真实拉起的验证放在 curl 端到端里做一次即可。
7. 用户报的 BUG,可能正被自己的测试断言保护着
2026-10-05 实例:用户反馈「项目看板里查询项目需要先新增一个项目」。根因是
openProjectQuery() 首行 if(!state.projects.length){ toast('还没有项目,请先新增一条'); return; },
而上一轮我自己写的 test_project_query.js 里赫然断言了这个行为:
ck('无项目时不打开弹窗', api.modals.length === 0);
ck('无项目时给出提示', /新增/.test(...));
单测全绿、回归跑多少遍都发现不了——BUG 被"测试固化"成了期望行为。
所以:拿到用户报的 BUG,第一步不是读实现,而是先 grep 测试文件里有没有把该行为写成断言。
找到后先翻转断言(改成「无项目也必须能打开」),跑一遍确认变红,再动实现,
这样才真正证明了「改前红、改后绿」,而不是改完顺手把断言也一起改了。
6. 改"默认值"做无头测试前,先搜全部出现处
同一次实测:想把看板默认切到 kanban 视图,只替换了 state 字面量与
load(KEY.view,'table') 两处,漏了 initState() 里 seed 分支的赋值语句
state.projView = 'table'; → 无头跑出来仍是表格视图,白白怀疑实现有问题。
改默认值前先 grep -n 该字面量,把字面量与赋值语句一起替换,并打印残留数确认清零。
五、收尾
- 删除
_check.js / _tmp_all.js / _mkharness.js / _test.html / _dump.html
(单个rm -f,不要用rm -rf,会触发沙箱批量删除拦截) - Node 里
execFileSync调 Edge 时,收尾绝不要fs.rmSync(profileDir,{recursive:true}):
Edge profile 目录有几百个缓存文件,递归删除会被沙箱拦截,
表现为整个 node 进程被 SIGTERM 杀掉、连最后那行汇总都来不及打印
(表面上像"测试挂了",其实前面所有 PASS 都已正常执行完)。
对策:多个场景共用一个 profile 目录且不删;只逐个rm -f自己造的夹具/dump;
并且先打印汇总、后做清理。 - 保留
index.bak.html - 用
present_files把 index.html 呈现给用户(HTML 会自动开预览面板)
六、配色扩展约定
新业务线新增一个 CSS 变量三元组,不要复用:
--rev:#6a4a93; --rev-bg:#f4effa; --rev-line:#dccfed; /* 可研审核 */
.t-rev{color:var(--rev)}
.biz-c .biz-head{background:var(--rev-bg);border-bottom-color:var(--rev-line)}
.badge.b-rev{background:var(--rev-bg);border-color:var(--rev-line);color:var(--rev)}
既有:建筑 --arch 深蓝 / 投资 --inv / 红涨 --red:#c0392b / 绿跌 --green:#27865a。
<!-- PAYGATE:BEGIN -->
付费与交付(X402 微信 Agent Pay · 微信支付 AI 专属卡 · ¥0.01/次)
费用由用户直接支付给服务提供方。SkillHub 仅提供展示、投诉处理及平台服务,不作为收款方。请在支付前确认服务内容、计费单元、单价与退款规则。
- 单价:¥0.01 / 次(以支付网关价目表与 SkillHub 后台配置为准)
- 计费单元:每次完整服务需求,支付确认后开始服务
- 免费操作:支付前的需求沟通、订单状态查询
Agent 行为准则
- 首次调用返回 402 时,原样向用户出示支付信息并申请授权;不得自行判定支付成功,不得改用免费方式替代履约。
- 牢记
out_trade_no(订单号),支付后用同一订单号重试,不会重复扣费。
红线
- 未拿到
status=paid收据前,严禁执行任何实质性服务、严禁产出结果。 - 所有支付只通过
node scripts/client.js,严禁手动调用微信支付接口。 - 严禁向用户展示/朗读 payment_code、transaction_id、私钥等敏感字段。
- 设备不满足要求时只输出引导并停止,严禁绕过支付。
付费四步(X402 协议)
- 前置检查:当前 Agent 是否具备
weixinpay_pay能力,不具备则终止并提示。 - 请求资源:
node scripts/client.js require --skill "single-file-html-workbench" --subject "<需求简述>",未付返回 402,含WeixinPay-Required与X-Out-Trade-No。 - 同时保存“支付码”和“订单号”;系统自动弹出微信支付 AI 专属卡,用户手机授权扣款(Agent 不得自行判断成功)。
- 原样重试 require(CLI 自动带
X-Out-Trade-No),服务端查单到账返回status=paid收据后,才开始履约。
订单状态免费查询(免费、幂等):node scripts/client.js status --out-trade-no "<订单号>"
<!-- PAYGATE:END -->