蜘蛛爬虫巡检文档
用途
把一只程序化线框蜘蛛叠加到文档(Word 仿真页)上,输入关键词后蜘蛛逐处爬行抓取,产出巡检表格并导出 CSV / 生成可打印报告。适用于施工方案、技术交底、合同文本的通病词核查(待定/未整改/遗漏/TBD 等)。
两套模板(按需选)
| 模板 | 何时用 | 特点 |
|---|---|---|
templates/template-full.html |
默认首选,唯一维护目标 | 完整版:拖拽加载 .docx/.txt/.md(mammoth 已 base64 内联,离线可用)、多文档页签、自动分页、全库巡检、通病词库分级、正则、检查报告、流程控制、面板折叠、词回执。约 905KB |
templates/template.html |
仅供阅读参考,不要基于它开发 | v8 时期的轻量快照(内容写死在 HTML 里、无 mammoth、不含词回执 / 坐标补偿 / 关键词去重 / 面板常驻)。它保留下来只为让二次开发者快速通读骨架;要改代码一律从 template-full.html 裁剪。 约 32KB |
优先用 template-full.html,不要从零重写,也不要从 template.html 改。
三步改造法
- 换内容:要么直接拖文件加载(推荐,不改代码);要么改
#sample模板里的块级内容。 - 改配色(关键):线框颜色必须与底色相反才可见。深底用 hsl 亮度 58~65%,浅底(白纸/白底)必须压到 40~46%,丝线 48%。漏改 = 蜘蛛在白底上隐形。
- 调关键词:工具条输入框支持逗号/空格/分号分隔多词;URL 参数
?q=待定,未整改自动巡检。
高级能力(template-full.html 已内置)
① 通病词库分级(工程文档三档,可改 LEXICON 常量)
- high 高风险(未闭环事项,红色):待定/待确认/待补/待完善/待定稿/暂缺/暂按/暂定/另议/另行确定/另行明确/后补/未明确/未整改/尚未/TBD/待设计确认/由甲方确定/由监理确定(19 词)
- mid 中风险(模糊表述,橙色):视情况/酌情/原则上/基本/尽可能/适当/大概/可能/建议(9 词)
- low 低风险(规范性用语,蓝色):必须/严禁/不得/不应(4 词)
- 未收录词归 none(黄色)。级别驱动三处配色:canvas 高亮框、表格级别标签、报告统计。
② 正则匹配:输入 /待.{0,4}确认/ 形式即按正则解析(new RegExp(k.slice(1,-1), 'gi')),否则走 indexOf。统一由 findMatches(text, kw) 返回 [{start,end,text}],坐标级与文本级两条链路共用。非法正则会 try/catch 兜住返回空,不崩。
③ 检查报告:buildReport() 生成独立 HTML 文件下载(不是 window.open 弹窗,避免被拦截)。含:基本信息表、关键词统计表、命中明细表、结论与建议(按高风险数量自动切换措辞)、检查人/复核人/日期签字栏、@page A4 打印样式、打印按钮(@media print 隐藏)。用户双击打开后可直接打印或另存 PDF。
④ 双模巡检:单文档走坐标级(蜘蛛真实爬行 + canvas 高亮),全库走文本级(按段 indexOf,跨文档汇总,无需渲染)。把"爬行效果"与"批量统计"解耦。
⑤ 位移分层(跨页提速,必做):巡检多页文档时,若用同一套速度参数跨页爬行,一页 A4 高 1122px / 260px·s⁻¹ ≈ 4.9 秒,体感极慢。按距离分三档:
| 距离 | 行为 | 实现 |
|---|---|---|
| ≤ 300px | 正常爬行(保留 foot-lock 观感) | speedCap = P().walk |
300 ~ min(0.9·H, 900) |
疾走爬行 | speedCap = P().sprint |
> min(0.9·H, 900) |
搭丝飞跃(抛物线 + 拖丝 + 收腿) | startLeap() / updateLeap() |
- 阈值用
min(0.9·视口高, 900):上界 900 保证视口很高时同页远距也能飞跃;下界随视口缩放保证任何视口高度下跨页必触发(A4 页距 1144px 恒大于阈值)。 - 飞跃时长
max(0.08, max(0.3, min(0.6, d/4000+0.3)) * P().leap),跨页在标准档 0.59s、极速档 0.09s。 - 飞行期间
updateSpider开头if (leap) { updateLeap(dt); return; }接管位移,并每帧l.foot = restWorld(l)让腿收拢贴体;落地把pos硬置为to保证精确落位。 - 用"搭丝飞跃"而非零帧瞬移:速度感相当,但保住叙事(真实蜘蛛跨空间移动即靠丝飘行,ballooning),不至于"突然消失又出现"。
- 与滚动并行:飞跃触发时
scrollNear(cy, true)强制滚动到目标页居中,观感是"飞过去 + 翻页"同时发生。
⑥ 流程控制条(第二行固定条,位于页签上方):#ctrlbar 提供 暂停/继续、跳过当前、停止、速度档、进度条。
- 暂停:paused 标志 → 主循环改为 if (!paused) updateHunt(...);再用 else if (hunt.active && !leap) spider.forcedGoal = {...spider.pos} 让蜘蛛就地停稳(而不是转头去漫游)。snag 计时随 updateHunt 一并冻结。飞跃中暂停则先飞完再停(否则悬在半空)。
- 跳过当前:leap=null; thread=null; hunt.idx++; hunt.state='travel',不加 caught,进度不变但直接奔向下一处。
- 停止:抽一个 finishHunt(msg) 统一收口(清 active/paused/leap/thread/forcedGoal),保留 hits/caught/表格,与"清除"(清结果)语义区分开。
- 速度档:SPEED_PRESETS = {slow,normal,fast,turbo} 各含 {walk, sprint, leap},leap 是飞跃时长倍率。极速档 0.16 → 跨页 0.094s ≈ 瞬移(把"要不要闪现"的选择权交给用户,而不是硬编码)。const P = () => SPEED_PRESETS[speedLevel] 供 updateSpider / startLeap 读取。
- 进度:每帧 syncCtrl(),用 lastProgTxt 缓存比对避免无谓 DOM 写入;全库模式(allRows 有值但 hits 为空)单独显示"全库模式:命中 N 处(不逐个爬行)"。
- 快捷键:空格 暂停/继续、n 跳过、Esc 停止、/ 聚焦搜索框;统一先判断 document.activeElement.tagName 是否 INPUT/SELECT/TEXTAREA,避免在输入时误触。
- 布局联动:新增一行固定条后,body padding-top、#tabs top、#panel top 都要同步下移(150 / 92 / 136px),否则内容被压住。
⑦ 面板折叠 + 文档避让 + 蜘蛛解剖化(实机验收后的体验优化)
- 面板收起/展开:
#panel .p-head加#p-toggle按钮,onclick里toggle('panel-collapsed')后调syncPanelState()与refreshPageOX()(折叠会让文档横向平移 239px,必须重取命中坐标的参照原点)。折叠态#panel{width:46px}(transition: width .22s),头部改flex-direction: column,标题用writing-mode: vertical-rl竖排,#p-sub/#kw-chips/#tbl-wrap隐藏。收起后仍留竖条(带命中计数与展开按钮),不让用户"找不到面板"。 - 面板必须常驻(v10 起,踩过坑):
renderTable()在无结果时不要再把#panel置display:none,改为显示占位文案("在左侧输入关键词…")。否则"有结果/无结果"会引起body.panel-open反复切换 →.page在居中与左靠之间跳 239px → 已采集的命中坐标整体失效、框画到纸外。无结果时给占位提示,而不是把面板藏起来。 - 文档避让(关键:不要缩放 A4):A4 固定 21cm,缩放会改变
Range.getClientRects()与页面坐标的对应关系。只做平移:body.panel-open .page { margin-left: max(16px, calc((100vw - 21cm - var(--panel-w) - var(--panel-gap) - 24px) / 2)); margin-right: auto }。面板宽度用 CSS 变量--panel-w管,配@media (max-width:1400px){380px}/(max-width:1200px){320px}两档降宽。 - 窄屏默认折叠:
if (innerWidth < 1240) document.body.classList.add('panel-collapsed')。判据:A4 793.7px + 面板 440px ≈ 1234px,低于此必然重叠,折叠是唯一干净解。 - 状态单一来源:
syncPanelState()统一维护body.panel-open,renderTable()每次渲染后调用它,避免 JS 与 CSS 各记一份状态。任何会让文档横向位置变化的入口(折叠按钮、resize、selectDoc、采集前)都要调refreshPageOX(),它是命中坐标的参照原点。 - 蜘蛛解剖化四要点("看起来像蜘蛛"的决定性差异):
1. 腿展/体长 ≈ 2 倍:体长 62px 时落点侧向 58~78px、沿轴 −48~+66px。原 4.5 倍像"长脚蚊"。
2. 8 足必须全部着生于头胸部:hipAlong = 17 + s*9,落在头胸部椭圆带 x∈[−2,35] 内;侧向取bodyWid*0.48(恰在椭圆边外)。后足挂到腹部是最大的失真点(真实蜘蛛腹部无足)。
3. 身体两节一腰 + 实心淡填充:头胸部ellipse(bl*0.26, …, bl*0.30, bw*0.40)、腹柄粗线、腹部ellipse(-bl*0.26, …, bl*0.34*pulse, bw*0.50*pulse),fillStyle用hsla(h,68%,46%,0.22~0.26)给体积感(纯描边会显空)。腹部加pulse = 1+sin(t*2.2)*0.045呼吸。
4. 腿两段带锥度:基节 2.2px → 跗节 1.1px;关节球用同色系压暗(hsla(h,58%,24%))而非对比色,对比色关节一眼就是卡通。 - 5 秒定时变色:
PALETTE = [190,320,145,265,30,210,280,95],huePeriod = floor(time/5),取PALETTE[i]与PALETTE[i+1]之间HUE_FADE=0.6s过渡。色相沿最短弧插值:dh = ((h2-h1+540)%360)-180,否则 350°→10° 会反向绕一圈。腿、身体共用legHue,保证整只同步换色。
核心技术点
| 模块 | 做法 | 要点 |
|---|---|---|
| 腿 | 两段闭式 IK:solveKnee(hip, foot, L1, L2, sign) |
余弦定理求膝盖;d 必须 clamp 到 [|L1-L2|, L1+L2),否则 acos 出 NaN |
| 步态 | foot-lock | 脚存文档坐标;dist(foot, restWorld) > STRIDE 才抬腿;同侧只允许一条腿摆动 |
| 数值闭环 | L1+L2 必须 > rest 落点半径 |
否则腿永远拉直;STRIDE 超出可达域则永不换步。实测配比:L1=46, L2=60(总 106)、rest d∈[53,83] → 拉伸率 0.50~0.79 全程弯曲;STRIDE=46 |
| 步态落点 | 摆动预判 rest + heading*speed*0.18 要按可达余量收口 |
_room = (L1+L2)*0.94 - dist(rest, hip),_bias = min(speed*0.18, _room)。疾走档 700px/s 会算出 126px 预判,前足余量仅 ~18px,不收口就被 IK clamp 卡死、脚与落点错位(步态打滑) |
| 命中定位 | TreeWalker + Range.getClientRects() |
结果加 scrollX/Y 转文档坐标;同行碎片矩形按 y 差<5、x 间隔<60 合并 |
| 命中框坐标校验 | 面板常驻(文档横向定位恒定,坐标采集与绘制处于同一布局)+ 三层防御:overflow-wrap: break-word、裁剪到所属页 .page-body 内容盒、宽度按"字数×字号"收窄 |
见"已知坑:命中框跑到纸外"。必须按命中所属页取 box(tn.parentElement.closest('.page-body') 并缓存),不能全局取第一个 .page-body |
| 词自洽(三处一致) | 输入框 / 结果表 / 正文必须同词同源:parseKws 去重、td.kw 只显示输入词、#kw-chips 逐词回执 |
见"已知坑:三处词不一致" |
| docx 解析 | mammoth:convertToHtml({arrayBuffer}) |
浏览器版只认 arrayBuffer,传 buffer/path 会报 "Could not find file in options" |
| 库内联 | base64 + new Function(new TextDecoder().decode(bytes))() |
640KB 库编成 856KB base64;先确认库内无 </script>;.js 里 <!-- 会触发 script 转义状态,base64 方案彻底规避 |
| 自动分页 | 逐块 append 后测 body.scrollHeight > maxH,超了回退并开新页 |
.page 用 height:29.7cm + flex 布局 + .page-body{flex:1;overflow:hidden},clientHeight 即可用高度 |
| 页面跟随 | 目标距视口边缘 <140px 时 scrollTo({behavior:'smooth'}) |
必须节流 600ms,否则每帧调用互相打断 |
| CSV 导出 | 二维数组 → 逐字段转义 → join | 必须加 \ufeff BOM 否则 Excel 中文乱码;字段含 , " 换行 时双引号包裹、内部引号翻倍 |
| 安全 | 表格/清单/报告文本用 textContent 写入 |
不用 innerHTML 拼内容,避免注入 |
已知坑
- snag(吐丝抓取)阶段若把
forcedGoal置 null,蜘蛛刚到位就被漫游/鼠标逻辑带走 → 该阶段必须保持锚定。 - 蜘蛛爬不了原生 .docx:docx 是 zip+XML,无渲染坐标。必须先解析成 HTML 再爬。
- 腾讯文档等 canvas 渲染的在线文档抓不到文字(无 DOM 文本节点),不要承诺。
- 文件加载用
File.arrayBuffer()(docx)与File.text()(txt/md),后者中文需escapeHtml后再入 DOM。 - URL 中
&在 HTML 属性内需转义为&amp;,否则参数被截断。 - 多层转义静默降级(2026-10-02 踩到):
bash -c "python -c '...'"这类嵌套里,\\d会被逐层吃掉反斜杠,最终 JS 里'\d'退化为'd',正则测试假失败。测试脚本一律用 Write 工具写成 .js 文件再跑,不要用 shell 内联 -c。报错先怀疑测试脚手架,再怀疑产品代码——但必须实测确认,不能凭猜。 - 头号坑:命中框跑到纸张外面(2026-10-02 实机报障,已修)
- 现象:某一处命中的编号框落在 A4 纸右边缘之外,其余命中位置正常。
- 真实根因(真实合同实测锁定):面板显隐改变了文档的横向定位。
renderTable()原本在无结果时把#panel置display:none,syncPanelState()随之移除body.panel-open,.page回到margin:auto居中;而巡检是"先采集坐标、采集完才展开面板" →.page左靠约 240px → 采集时刻的 x 全部失效。同一命中实测:逐字符Range量得x=640,hits里却存x=879,恒定偏右 239px,y 完全吻合。 - 为何只在宽屏出现:
innerWidth < 1240时启动即加body.panel-collapsed,文档位置自始至终不变,坐标自然有效;宽屏(>1240)面板默认展开,于是必然踩中。 - 修复(治本):面板常驻——
renderTable()不再隐藏面板(无结果时给占位文案),文档横向定位由始至终恒定,采集与绘制处于同一布局。实测 5 档视口(1280/1366/1440/1600/1424)× 2 种输入,越界 3 → 0。 - 必须用 A/B 反向验证:只跑出"修复后 = 0"不足以定案(可能是测试失效)。把改动回退再跑一遍、越界重新出现(
overR=142/62/111)才算闭环。 - 另有三层防御(保留;针对真正的内容溢出场景,不是本次根因):
.page-body { overflow-wrap: break-word }—— 不可断行长串必要时断行。- 命中矩形裁剪到所属页
.page-body内容盒(L/R双向 clamp,裁剪后R-L<=1.5丢弃);垂直越界的丢弃(被overflow:hidden裁掉的不可见行)。 - 宽度校验:
merged.length===1且w > 字数×22时按字号×1.25×字数收窄。
> 某真实合同类文档(长 35858 字节、322 段、2 表格 205 单元格)实测:最长不可断串仅 20 字符、NBSP 0 个,上述三层对它完全无效——不要再用"长串撑爆"解释此类报障。该结论曾误导一轮修复。
- 通用教训:坐标采集与消费之间不允许发生布局变化。凡"先量后画"的坐标,要么把量测移到布局稳定之后,要么直接存相对纸张
.page的偏移,绘制时用page.getBoundingClientRect()反算,与滚动/平移彻底解耦。 - 诊断出口:
searchCurrent()末尾导出window.__H = hits(含x/y/w/h/kw/hitText/snippet)。自动化验证直接读它,比遍历 DOM 或给 canvas 打桩更准确、更省事。 - 已知坑:三处词不一致(2026-10-02 实机报障,已修)
- 现象:用户报"关键词巡检框里的词、右侧结果的词、正文蜘蛛爬的词不一致"。
- 真相:三个独立缺陷叠加,都不是"词被改了":
- 未命中零反馈(主因):输入 / 载入 32 个词,结果表只列命中的 5 个词 → 用户读成"结果丢词"。未命中的词必须有显式回执。修法:结果面板顶部加
#kw-chips词回执,逐词显示词 ×命中数,未命中显灰词 · 0,末尾追加总账输入 N 词 · 命中 M 词 / K 处。 parseKws未去重:输入不得,不得,基本,基本→ 同一处命中被 push 两次 → 结果表出现完全重复的行、正文两个编号框重叠。实测16 行 → 去重后 8 行。修法:split后按toLowerCase()装入Set过滤。- 结果表关键词列显示的是文档原文(
hitText)而非输入词 —— 大小写/全半角有差异时,同一列出现两种写法。修法:td.kw一律显示输入词h.kw,原文差异降级到title+ 虚线标记(.alt)。
- 未命中零反馈(主因):输入 / 载入 32 个词,结果表只列命中的 5 个词 → 用户读成"结果丢词"。未命中的词必须有显式回执。修法:结果面板顶部加
- 复现手法:探针里直接读
#kw的 value、#tbl-wrap td.kw列表、window.__H的kw/hitText,做集合差集(miss/ghost),一次就能分清"丢词"与"未命中"。 - 已知坑:面板折叠/展开让已采集坐标失效(2026-10-02 自检抓到,已修)
- 背景:v10 的"面板常驻"只解决了"有无巡检结果"引起的横向平移,漏了用户手动折叠/展开面板这条路径 —— 而折叠按钮正是老板上一轮明确要求加的。
- 实测:折叠态采集(
bodyL=397)→ 展开(bodyL=158,平移 239px)→ 同一批坐标 2 处越界(overR=142/111),与最初报障数值逐位一致。视口 <1240px 走panel-collapsed分支时反而不暴露,更隐蔽。 - 修复(治本):x 轴改为"相对纸张左边界"存储与换算。采集时记下
pgL: pageOX(.page当时的文档 X),绘制/寻靶时用hitDocX(h) = h.x - h.pgL + pageOX还原。pageOX只在refreshPageOX()里重取(触发点:p-toggle点击、resize、selectDoc、采集前),每帧零成本。纵向y不受横向平移影响,保持原样。 - A/B 反向:把
hitDocX退化为恒等h => h.x→ 折叠场景越界从 0 回到 2;展开态采集则两者都是 0(差值为 0)。这证明补偿确实是因,且只在"采集后布局变过"时才生效。 - 配套诊断出口:
refreshPageOX()末尾window.__HX = hits.map(hitDocX)。验证时读产品自己算的补偿结果,而不是在探针里复现公式 —— 后者会把公式抄错也判成通过。 - 改固定条布局要联动三处:
body padding-top、#tabs top、#panel top,漏改则内容被压或表格错位。 - 注入自动化探针时
</body>会被前面的副本截胡(2026-10-02 踩到):buildReport()的 JS 模板字符串里含</body></html>,Pythonstr.replace('</body>', probe+'</body>')会替换所有匹配(无 count 即全局),第一处落在主<script>内部;探针里的</script>于是提前闭合 script 元素,主脚本被截断、初始化代码静默不执行(现象:#doc为空、canvas 无 style、无任何报错)。改用html.rindex('</body>')精确插在真正的收尾标签前。 - 探针公共代码必须拼进场景自己的
<script>内部(2026-10-02 踩到,坑上加坑):把COMMON + scene(scene自带<script>…</script>)直接拼起来,COMMON会先独立成一个 script 块、被scene的</script>截断,scene的标签降级为纯文本 → 所有setTimeout从未注册 → 探针静默无输出、页面也无报错。现象与上一条几乎一样("没输出但没报错"),极易误判成产品有问题。正确写法:scene.replace('<script>', '<script>\n' + COMMON, 1)。诊断顺序:先re.findall(r'<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)</script>')数块数与每块长度,看公共代码是否真的在最后一块里。 - canvas
fillRect打桩在--virtual-time-budget下拿不到数据(2026-10-02 踩到):实测 rAF 只推进 4 帧,draw()里的命中框循环没被覆盖到,打桩数组恒为空。空结果必须当"测试无效"处理,不能反推"绘制正确"。要验证绘制层,改用产品侧诊断出口(如window.__HX)读它自己算出的值,或改用--screenshot走真实渲染路径。 - headless
--virtual-time-budget会冻结 CSS transition:类名切换后立刻或 400ms 后读getBoundingClientRect().width仍返回起始值(如折叠后仍是 440 而非 46)。同一份代码在初始加载态(无过渡)读值正确 —— 这足以区分"CSS 写错"与"测量时机失真"。要测最终值就临时注入#panel{transition:none !important}再读。
验证方式
F:/WorkDaddy/_libs/test.docx 是用 Python zipfile 构造的最小 docx(含中文段落)。Node 验证命令:
node -e "const fs=require('fs');const m=require('F:/WorkDaddy/_libs/mammoth.js');
const b=fs.readFileSync('F:/WorkDaddy/_libs/test.docx');
m.convertToHtml({arrayBuffer:b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength)})
.then(r=>console.log(r.value))"
匹配引擎 / 词库分级:把 LEXICON 段抽成独立 .js 跑断言(普通词索引、正则、非法正则、空词、大小写、五档分级、词库总数 32)。
位移分层:断言 7 种视口高度(600~2000px)下跨页必触发飞跃、邻近命中仍爬行、时长封顶、弧高上限、抛物线端点精确/中点抬升。
界面联动的无头浏览器断言(推荐做法):复制一份 HTML → 在真正的 </body> 前注入探针(用 rindex)→ 探针在 setTimeout 里点按钮、读 getBoundingClientRect(),把 key=value|key=value 写进 document.title → 跑:
chrome --headless=new --disable-gpu --no-sandbox --hide-scrollbars \
--window-size=1440,900 --virtual-time-budget=9000 \
--dump-dom "file:///.../test.html" | grep -o '<title>RESULT[^<]*</title>'
--dump-dom 输出含 base64 库会很大,用 grep 只取 title。多视口循环跑即可拿到覆盖表。遮挡判据:panel.getBoundingClientRect().left - page.getBoundingClientRect().right > 0。断言要点:面板可见/折叠类名/文档左右边界/gap/命中行数。注意读过渡属性的最终值需先禁用 transition(见"已知坑")。
参考
- 原始灵感:维基百科页面上爬行的线框蜘蛛(程序化角色,非骨骼动画)
- Three.js 真 3D 版:
majidmanzarpour/threejs-procedural-spider(闭式 IK + 即兴步态 + 爬墙)