website-rebuild
1:1 rebuild of award-winning creative websites (WebGL / scroll-animation / portfolio sites). Evidence-driven pipeline - mirror-first forensics, line-number-traceable reverse engineering of minified bundles, verbatim porting, quantitative verification gates. Use when user asks to
Install
npx skills add https://github.com/boyang-hu/website-rebuild-skill/tree/main/skills/website-rebuild
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install boyang-hu-website-rebuild-skill@llmmart
git clone https://github.com/boyang-hu/website-rebuild-skill.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole boyang-hu/website-rebuild-skill collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Website Rebuild(获奖创意站 1:1 复刻)
把一个获奖创意网站(WebGL / 滚动叙事 / 作品集站)以取证式方法复刻为可独立运行、可验证还原度的工程。不是"看着像"的仿制——是以源站 bundle 为唯一规格书、以量化验收门收口的逐行为移植。
本方法论提炼自六个连续实践项目(工期从 6.5 周收敛到 1 天),后经 22 个完整复刻 + 5 个死站存档抢救持续回填、43 站边界探测实测校准适用范围(清单见仓库 README「已验证过的网站」)。
使用前提与授权 ⛔ 必读
本 skill 面向学习与研究目的的保真复刻,用于研究获奖创意站的实现手法。适用对象是你自有的、已获授权的,或公开可访问且允许学习临摹的网站。它不是用于未授权地采集受保护内容、规避访问控制、或商业性盗用他人作品的工具。
执行时遵守下列边界:
- 尊重目标站规则:遵守其
robots.txt、服务条款与版权;抓取保持低频、单会话,不对目标站施加异常负载。⛔robots.txt是逐路径的许可声明,不是全站开关——逐 URL 判定(选组 → 最长匹配 → 无匹配即允许),不得因为存在任何Disallow行就判"整站禁止"(几乎每个商业站都有/cart、/checkout、/admin的Disallow);禁令要按行为类别归类,只有针对"抓取"的禁令才影响镜像范围,针对交易的禁令只意味着"别去点结账"。⭐ "读不懂 / 拿不准"不等于"禁止":走呈交,不走停工,更不自行缩小抓取范围。读法见 references/legal-and-deploy.md §0.3。 - 不触碰受保护边界:不采集需要登录态、付费墙或授权才能访问的内容;本 skill 只处理匿名可公开访问的资源。若目标站明确禁止此类复制,停止并告知用户——何为"明确禁止"见
legal-and-deploy.md§0.3.6 写死的四条门槛,其余一切不确定性走呈交不走停工。 - 产出默认私有:默认 noindex、不公开部署。任何公开前必须完成逐资产版权取证,并显著标注"非官方复刻"与原作者归属(见 references/legal-and-deploy.md)。
⛔ 法务判断归用户,skill 只取证与呈现(三条,全程有效):
- 决定权在用户:skill 收集事实(逐资产归属、许可状态、第三方权利人、源站是否仍在营业、产物内第三方标识符)、列出选项与各自的风险边界、给出建议与理由;凡涉及"能不能公开 / 部署 / 再分发 / 对外展示",必须用下文「User Input Tools」显式交回用户,不许 agent 自行下法律结论后继续往下走。
- 未获用户明确决定前按安全默认执行:私有仓库 +
noindex+ 不公开部署 + 不再分发。写给用户时说明这是默认动作("在你决定之前我不会把它发出去"),不是 agent 已作出的法务结论——两者责任归属完全不同。agent 只能往保守侧执行默认,往公开侧走必须有用户的明确决定。 - ⛔ 法务考量不得削减镜像完整性或门的覆盖面:镜像是证据基座,完整性是技术不变量(四遍法、闭包门、GAP=0 全建立在它之上)。不抓只能有技术性理由(不是文件 / 服务端不提供 / 需授权或登录态 / 源站明令禁止),一律登记;不得以"反正不公开""不该多存一份"这类法务理由留洞【objectarchive】(实证:
references/case-studies/skill.md「使用前提与授权」)。法务决定作用于产出怎么被使用,不是证据基座是否完整。
适用范围 ⛔ 必读
主场(A 类):内容静态托管、签名行为(动画/交互)全部存放在客户端静态资产里的站——命令式 WebGL/Canvas 场景、GSAP 时间轴、烘焙数据文件(GLB/.buf/.riv)、minified 或未混淆的 bundle。绝大多数 Awwwards 风格创意站属于此类。
有条件支持(B 类):管线成立但需要额外场景处理(Shopify 平台层剥离、第三方存储桶资产、运行时 API 快照、SSG payload 展开)。当前版本的指南覆盖大部分 B 类场景,遇到未覆盖的要向用户明示风险。
明确拒绝(C/D 类):
- C1(v0.3 起可做:重构式逆向):服务端组件源确实不下发,但它的完整输出(flight 流)内联在每页 HTML 里,是可对拍的规格书。路线:flight-decode 建坐标系 → 重构一个可构建的 Next 工程(客户端一方组件按 C2 逐字译,服务端组件从 flight 树反推为显式登记的推断物)→ verify-flight 语义门收口(模块 id 全局双射;实证:
references/case-studies/skill.md「适用范围」)。⚠ C1 的 L2/L3 合并——第一份产物就是「人写的源码 + 门证明的等价」。全流程见 references/rsc-reconstruction.md。 - C2(可做,按 A 类跑):⭐ 写法是声明式但源码下发(R3F / Theatre / Vue SFC 编译产物)。切片器不关心范式——它切的是字节。 渲染器当平台层从镜像伺服(实证:
references/case-studies/skill.md「适用范围」)。⛔ 判别器不是库名,是「客户端是否持有行为源」(scope-and-fingerprint.md§4.0.1)。 - D:行为主体在服务端(CMS 内容站、电商 cart/库存、A/B 实验分桶、个性化注水)——客户端没有可移植的目标物,且确定性验收无基准。
X 类(可抢救):原站已消失(域名易主 / 平台回收 / 路径移除 / 原地被替换),但 Internet Archive 往往有捕获——scripts/wayback-mirror.mjs 从 CDX 索引按锚点 + 时间窗选一个连贯时刻、以 id_ 原始字节抓成标准镜像(下游门原样工作),洞按既成事实登记进 wayback-holes.txt(读法与流程见 references/archival-rescue.md)。⭐ 抢救产出是标准镜像——X 类可走完 L3 全程(实证:references/case-studies/skill.md「适用范围」)。⛔ "CDX 无覆盖才是真不可做"按资产层读,不按站读:IA 爬虫不执行 JS,清单/拼接驱动的站可以代码层覆盖 100% 而画面层为零(实证:references/case-studies/skill.md「适用范围」)——Step 0 先做分层覆盖侦察(推导 + CDX 前缀查询)预判抢救深度,见 archival-rescue.md §1.9。历年获奖站实测消失率约 29%——这也是"第一时间镜像"是本 skill 第一纪律的原因。
判级由 Step 0 指纹侦察决定,完整判定树见 references/scope-and-fingerprint.md。拒绝时要解释原因并说明该站属于哪一类,不要硬跑。
User Input Tools
需要向用户提问时(确认范围、法务决定、外部依赖决策):优先使用当前运行时的内置提问工具(如 AskUserQuestion);没有则输出编号问题清单让用户回复编号。支持多问合并时一次问完。法务类提问按 legal-and-deploy.md §0.1 的五段式写:事实 / 查不清的 / 选项 / 每个选项的风险边界 / 建议与当前默认动作。
宪法(六条纪律,全程有效)
以下六条在六个源项目中被称为"宪法级",违反任何一条都会在后续阶段以 bug 形式偿还:
- 镜像神圣不可污染:
mirror/磁盘文件永不修改;一切本地化适配(CDN 改写、外链 stub)在服务层响应时动态完成。 - 源站代码是唯一裁决,不凭观感修:每个改动先在 bundle/CSS/镜像 HTML 里找到归属行号再落地。Do not tune visuals, motion, or interaction by eye.
- 源站有的都要有,源站没有的不做:不自创补偿性 CSS/JS。宁可先不像,也不要发明规则——自创补丁会在机制对齐后反转成 bug。
- bug / 死代码 / 怪写法照抄不修:压缩代码里的每个怪写法都可能是行为本身。"好心修正" no-op bug 曾导致转场崩溃(实证见 porting-discipline.md)。
- 有意偏差必须登记:写清"源站怎么做 / 我们怎么做 / 为什么 / 什么条件下重新考虑"。没登记的差异一律视为 bug。
- 代码与文档同一次提交:每个里程碑成对提交(
Port xxx+Update rebuild plan: xxx),日志固定含产出 / 验收 / 教训 / 下一步断点(带行号)。
⭐ 纪律 3 在 M(n+1) 的边界:src/ 是显式登记的衍生物,不是对源站的断言,所以在 src/ 里重命名、拆模块、写注释不算"发明"——纪律 3 约束的是"为了让它看起来像而自创行为",不是"让已证明等价的代码变得可读"。但两条硬边界不动:① 结构性重写默认禁止(合并重复、提取公共函数、改算法——它们让等价不可判定);② 注释里的推测必须标注为推测,不许把逆向笔记里的猜测写成陈述句。port/ 与 mirror/ 仍然一个字节都不许动。详见 references/readable-source.md §3.4 与 §5。
Workflow
Progress Checklist
[ ] Step 0 指纹侦察与范围门 ⛔(判级 A/B/C/D/X;C/D/X 拒绝或引导,不进入下一步)
[ ] Step 1 开工评级(架构证否、分项难度打星、工期预估、与用户确认范围 + 终点 L1/L2/L3)
[ ] M0 镜像取证 ⛔(BFS 爬虫 + CDP 补录 + manifest 账本;GAP=0)
[ ] M0.5 镜像断网跑通 ⛔(零 404 / 零控制台错误 / 零外联;serve.mjs 伺服)← L1 镜像存档 终点
[ ] M1 逆向建坐标系 ⛔(_pretty 钉版本展开;engine-notes 先于任何代码;技术栈钉死;REBUILD_PLAN 建立)
[ ] M2+ 严格溯源移植(依赖序里程碑推进;先竖切一条端到端链路;每里程碑冷启动实测 + CLEAN 门)
[ ] M(n-1) 对拍验收(按 verification-gates.md 决策树选门型;根因修复,不调参糊平)
[ ] M(n) 收口 ⛔(冷头评审 / 模块清单对账;版权取证 + 呈交用户决定——公开部署前必须完成)← L2 工程化复刻 终点
[ ] M(n+1) 源码化(port/ → src/:拆模块、去混淆重命名、补注释、自包含)← L3 源码化 终点
⛔ = 阻塞门:验收标准未达成不得进入下一阶段。
标记约定(全部文档通用):⛔ 硬规则,违反即 bug · ⛔⛔ 已在实战里付过高代价的硬规则 · ⭐ 经验证的做法 · ⭐⭐ 反直觉但已被数据证实的做法 · ⚠ 陷阱 / 边界 · 【代号】= 实证来源项目(对应 README「已验证过的网站」)。
Flow
Step 0 — 指纹侦察与范围门。加载 references/scope-and-fingerprint.md,对用户给的 URL 执行探测协议(GET 到路径粒度、最终 URL 同一性、双抓 diff、物种/年代校验、bundle 初检),输出判级与依据。A/B 类继续;C/D/X 类向用户解释后停止或引导。
Step 1 — 开工评级。加载 references/recon-and-rating.md。架构假设先证否(依赖表会撒谎),分项难度打星(素材/3D/滚动编排/私有格式/平台层),向用户确认复刻范围(整站或指定页面)与预期。
⭐ 同一次提问里让用户选终点(三级梯子,带着判级结论与分级成本估计问,不要干巴巴列选项):
| 终点 | 回答的问题 | 止于 | 典型用途 |
|---|---|---|---|
| L1 镜像存档 | 它长什么样 | M0.5 | 存档、离线欣赏(获奖站年消失率约 29%) |
| L2 工程化复刻 | 它在做什么 | M(n) | 可部署、可验证的 1:1(含版权取证与部署评估) |
| L3 源码化 | 它怎么做的 | M(n+1) | 研究与学习实现手法 |
梯子单调,选低不亏:每一级都是下一级的前缀,镜像纪律保证时间敏感的部分永远最先完成——今天选 L1,以后想升级随时续跑(向用户说明这一点)。"拿它做自己的项目"(脚手架化)不是本 skill 的阶段——用户问起时指向 references/beyond-the-rebuild.md 交接,那是他的工程,skill 到"人能读懂的真实"为止。
M0 / M0.5 — 镜像取证。加载 references/mirroring.md。用 scripts/mirror-site.mjs BFS 爬取 + scripts/netcapture.mjs 真实浏览器补录,manifest 逐文件登记 sha256,redirect: manual 纪律,外部依赖逐项决策。scripts/verify-mirror.mjs 是镜像自己的门(五项断言,跑在断网门之前——下游所有门问的都是"渲染得出来吗",错的镜像能让它们全绿;一个 HTTP 200 也不是"你拿到了那个资源"的证据)。scripts/serve.mjs 伺服镜像,断网验收。这一步永远最先做——原站随时可能消失或改版,镜像是全项目唯一证据基准,也是后续一切对拍的参照服。
M1 — 逆向建坐标系。加载 references/reverse-engineering.md。⛔ 第一个动作是判 bundle 形态(扁平拼接 / 模块化打包 / 多 chunk),再选工具——分层表扫顶层声明,而 webpack 打包产物的顶层声明数是 0,边界与依赖边由打包器给定(用 scripts/module-map.mjs;实证:references/case-studies/skill.md「Workflow / Flow — M1」)。认不出容器时 FATAL,禁止回退到分层表(§0.5)。scripts/beautify-bundle.mjs(js-beautify 钉 1.15.1)展开 bundle 到 _pretty/,此后行号是全项目唯一溯源坐标系。先写 docs/engine-notes.md(模板:assets/templates/engine-notes.md)再写任何代码。技术栈从 bundle 取证钉死精确版本。数据驱动动画先 dump 数值账本。建立 REBUILD_PLAN.md(模板:assets/templates/rebuild-plan.md)。
M2+ — 严格溯源移植。加载 references/porting-discipline.md,并按分支路由表加载对应场景指南。每个移植文件头部注明源行号区间;GLSL/魔数/数据逐字提取;数据资产脚本抽取入库不手抄。
M(n-1) — 对拍验收。加载 references/verification-gates.md 与 references/determinism.md;门红了或残差需要归类时再加载 references/gate-failure-modes.md,不要开局读。全站渲染广度用 scripts/sweep-routes.mjs(全路由一个浏览器,逐路由 0 错误/0 失败/0 外联 + 交互钩子与逐路由采集),单路由深度才用 probe.mjs——⛔ 不要手搓逐路由起 Chrome 的循环,成本按浏览器启动次数计,且并发探针会互相收割孤儿。⚠ 归因残差之前先建自比带宽(pixelcompare --self,逐侧 ≥4 次、交错跑)——没有带宽的残差一律 UNCLASSIFIED,而 UNCLASSIFIED 是失败不是通过。门型选择:有 SSR/静态 HTML 产物先建字节门 → DOM 静态场景冻结熵源走 byte-equal → 活场景(WebGL/视频/随机相位)降级量化指标 + 噪声归类 → 数据驱动动画补数值探针门 → CLEAN 门全程兜底。判定时序 bug 前先校准探针(references/environment-traps.md)。
M(n) — 收口。冷头评审:对 bundle 顶层类/模块清单逐一核对落点(功能测试测不出整块遗漏,只有清单式核对能)。加载 references/legal-and-deploy.md 完成版权取证并把决定呈交用户——在用户决定之前按安全默认执行(私有 + noindex + 不部署),公开前必须逐资产取证、显著标注非官方复刻。
M(n+1) — 源码化。加载 references/readable-source.md。到 M(n) 为止产物已证明正确但人读不了(实证:references/case-studies/skill.md「Workflow / Flow — M(n+1)」)。本阶段把 port/ 重写成 src/:拆模块 → 作用域安全地去混淆重命名 → 补分档注释 → 复制资产做到自包含。⛔ 拆分粒度不是自由选择——扁平脚本的声明顺序即求值顺序,粒度由三条硬约束决定(互相引用 / 求值顺序 / import 绑定不可赋值),先出划分方案让人过目,再切;遇到巨型模块时先测「延迟绑定少数末尾单例」的收益曲线再决定(换模块系统要赔上整条工具链才换来同样粒度;实证:references/case-studies/skill.md「Workflow / Flow — M(n+1)」)。⭐ "这件事做不到"这个判断极不可靠(实证:references/case-studies/skill.md「Workflow / Flow — M(n+1)」)——先怀疑测量它的工具,再怀疑对象(readable-source.md §3.1–3.1.3)。⛔ 前置条件不可协商:必须先有全绿的门。 没有裁判的重构是盲改;有了 meanAbsDiff 0.00 的裁判,每一步都能被证死——这是重构能有的最好条件,也是它必须排在最后的原因。现有门全部原样复用(目标换成 src/ 构建产物,容差不许放宽),另加符号映射门与自包含门。⛔ 结构性重写(合并重复、提取公共函数、改算法)默认禁止——它会让门从"证明等价"退化为"没测出不等价"。⭐ 纪律 4 在本阶段依然有效:你现在读得懂了,"这明显是个 bug"的冲动会比任何阶段都强,而它依然可能是行为本身。
⭐ 无容器 scope-hoisted 产物(Vite/esbuild,逐字分层交付的站)走另一条路:不重写,切——拼接式分解(scripts/census-bundles.mjs 出 chunk 图与坐标 → scripts/slice-esm.mjs 按声明切成语义命名的部件,按序拼接逐字节等于原件 → scripts/verify-reassembly.mjs 一门定案,字节等价成立时全部运行时门的裁决免费转移)。执行侧不变,浏览器继续跑原 chunk。详见 readable-source.md §3.0.6。
分支路由表
Step 1 侦察结果决定加载哪些场景指南(按需,不要全量加载):
| 侦察发现 | 加载 |
|---|---|
Next.js App Router / RSC(self.__next_f flight 流)——C1 重构式逆向 |
references/rsc-reconstruction.md |
| WebGL / Canvas 场景(three.js、自研引擎、GLSL) | references/webgl-scenes.md |
| GSAP / 烘焙动画数据 / CSS 变量动画 / 自研输入状态机 | references/animation-recovery.md |
| 私有二进制格式(.buf / .sog / VAT / GLB 时间线 / .riv) | references/binary-formats.md |
Shopify 店铺(指纹见 cdn/shop、Shopify.theme、cdn.shopify.com) |
references/shopify-platform.md |
Sanity CMS(指纹见 cdn.sanity.io/images/<projectId>/、*.api.sanity.io、载荷里成片 _key/_type/_ref)——⛔ 判级看内容烘焙时点不看库名,且 auto=format 资产按 Accept 协商返回不同字节 |
references/sanity-platform.md |
| 门红了、或像素 / 数值残差需要归类(真差异 vs 方法学噪声) | references/gate-failure-modes.md |
| 数值门 / 跨侧门 / 采集基线的用例设计;M(n) 清单式核对 | references/gate-case-design.md |
内联序列化载荷(flight / __NUXT__ / devalue 数据岛)或策略 A 外壳构建 |
references/payload-gates.md |
| DOM 层策略选型(所有站必经;Webflow 导出 / 静态单页 / 框架 SSR 分支不同,另有"DOM 被 3D 引擎当坐标源读"的正交约束) | references/dom-shell-strategies.md |
| 大体量资产(百 MB 级媒体 / 授权字体) | references/asset-management.md |
| 无头探测行为异常 / 疑似环境问题 | references/environment-traps.md |
Step Summary
| 阶段 | 关键动作 | 阻塞门验收 | 产出物 |
|---|---|---|---|
| Step 0 | 指纹探测判级 | 判级明确且已告知用户 | 判级结论与依据 |
| Step 1 | 证否 + 评级 + 确认范围 | 用户确认 | 难度评级表、范围共识 |
| M0/M0.5 | 镜像 + 账本 + 断网跑通 | verify-mirror 五项全绿;GAP=0;零 404/零错误/零外联 |
mirror/(只读)、manifest、serve.mjs 参照服 |
| M1 | 展开 bundle、逆向笔记、钉栈 | engine-notes 完成;版本钉死表完成 | _pretty/、docs/engine-notes.md、REBUILD_PLAN.md |
| M2+ | 溯源移植、里程碑成对提交 | 每里程碑冷启动实测 + CLEAN 门绿 | 带行号注释的源码、三张登记表滚动更新 |
| M(n-1) | 对拍验收 | 所选门型全绿或差异全部登记 | 验证脚本 + 对拍产物入库(docs/compare/) |
| M(n) | 冷头评审 + 版权取证 + 呈交用户 | 清单对账零缺口;用户已作出部署决定(未决则维持安全默认) | 审计记录、DEPLOY.md |
| M(n+1) | 拆模块 + 去混淆 + 注释 + 自包含 | 现有门全绿且容差未放宽;符号门双向单射零孤儿;自包含门(复制出去、断网、构建)过 | src/(可读工程)、docs/rename-map.json、src/README.md |
Script Directory
Node 22+,路径相对本 skill 目录。每个脚本都认 --help(打印头注用法 + 旗标清单)与 --version(skill 版本),未知旗标一律 FATAL(lib/cli.mjs)。本表只列一句话用途;旗标与完整规格看脚本自己:node scripts/<x>.mjs --help(头注即规格,含中文,v0.3.21 起 README 不再复述);选哪个脚本、阶段、出处、成熟度见 scripts/README.md 索引与 tools/README.md;设计实证见 references/case-studies/scripts.md。
⭐⭐ 依赖纪律是按阶段划的,不是按目录划的:源码化之前,整条流水线零依赖。
Step 0 → M(n) 全程不装任何东西;复刻项目要到 M(n+1) 才获得 devDependencies(作用域安全的重命名需要真正的 parser)。scripts/(零依赖)与 tools/(允许 devDeps)只是这条阶段线在目录上的投影——判据住 scripts/,源码化阶段的重构器住 tools/。
⛔ 任何门不许 import 任何工具(verification-gates.md §2.1.2)——检查者不能是生产者。
⭐ 前面的阶段需要真正的 parser 怎么办?外挂,不要 import。 beautify-bundle.mjs(js-beautify)与 module-map.mjs(acorn)都是 spawn 一个钉死版本的 npx,脚本自身仍然零依赖、仍然可独立审查。⛔ 不要改成手写词法器(实证:references/case-studies/skill.md「Script Directory」)。token 流上的括号匹配是精确的,文本上的括号匹配是对字符串/正则/注释的猜测。
⚠ 这条线是被违反之后才被发现的(实证:references/case-studies/skill.md「Script Directory」)。一条只写在文档里、没有任何东西去查的规矩,会安静地失效。
| 脚本 | 用途 | 使用阶段 |
|---|---|---|
scripts/fingerprint.mjs |
Step 0 探测协议的零依赖等价实现:存活 / 重定向终点 / 双抓 diff / 技术指纹 / bundle 初检 + Sanity 证据采集(只采证据,不出判级) | Step 0 |
scripts/mirror-site.mjs |
BFS 爬虫镜像:资产白名单、redirect:manual、三本账(含 sha256)跨运行累积、off-host 普查;--scope 只限页面不限资产 |
M0 第一遍 |
scripts/netcapture.mjs |
真实浏览器 CDP 抓包,对账补录运行时资源(CDN 站必须传 --hosts) |
M0 第二遍 |
scripts/verify-mirror.mjs |
镜像自己的门:映射单射 / 账本 sha256 / 真实性(魔数 + 挑战页)/ 闭包 / 抽样回源,跑在断网门之前 | M0 关账前 |
scripts/gapfill-video.mjs |
HLS/DASH 流媒体阶梯补录(master → rendition → 分片) | M0(有流媒体时) |
scripts/reconcile-gaps.mjs |
运行时缺口对账:netcapture 的 GAP 行 + 字节推导全集逐条补进镜像;请求头梯子 + 浏览器同款图片 Accept | M0(运行时资源多的站) |
scripts/wayback-mirror.mjs |
X 类抢救:从 CDX 按锚点 + 时间窗选一个连贯时刻,以 id_ 原始字节抓成标准镜像,洞登记 wayback-holes.txt |
M0(X 类) |
scripts/serve.mjs |
零依赖静态服务器兼参照服:MIME / Range / 服务层改写 / 重定向回放;--fallback-root 回落链、--stub-ext-hosts 桩、--stub-json PATH::FILE 端点桩(按源站 JSON 合同应答,首次命中打印)、--rewrite 登记式替换;未知旗标响亮失败 |
M0.5 起全程 |
scripts/probe.mjs |
CDP 无头探针:console / 异常 / 网络 CLEAN 判定进 CI,--no-external 零外联,--walk 全滚动走查 |
M0.5 起每 commit |
scripts/sweep-routes.mjs |
渲染广度门:全路由一个浏览器,逐路由 0 错误 / 0 失败 / 0 外联 + 交互钩子;不要手搓逐路由起 Chrome | M0.5 起(多路由站) |
scripts/verify-offline.mjs |
零外联门的静态一半:枚举产出里每个外部绝对 URL 并逐条裁决 | M0.5 起每 commit |
scripts/verify-payload.mjs |
SSG payload 门:内联序列化数据块(Nuxt / flight)求值展开后按结构对拍 | M0.5 起(有 SSG payload 时) |
scripts/verify-nextdata.mjs |
pages router 载荷门:__NEXT_DATA__ 与 /_next/data/*.json 单侧自洽 + 双侧深比较 |
M0.5 起(pages router 站) |
scripts/verify-lenprefix.mjs |
自带长度的载荷门:flight 流逐行按 T<hex> 字节数前进,改写后落点仍须是行首 |
M0.5 起(有 flight 载荷时) |
scripts/flight-decode.mjs |
C1 坐标系:把每页 flight 流解成模块引用表 / 预载 / 元素树 / JSX outline | M1(C1) |
scripts/beautify-bundle.mjs |
js-beautify@1.15.1 钉死展开 bundle 到 _pretty/,排版后 token 流自查,撞名断言 |
M1 |
scripts/module-map.mjs |
模块化 bundle 的分层表(spawn 钉死 acorn):认 webpack 容器与 Turbopack 扁平列表,认不出即 FATAL,覆盖率守卫 | M1(模块化打包产物) |
scripts/census-bundles.mjs |
无容器产物的 chunk 级坐标账本(sha256 / 行数 / ESM 边),拼接式分解的第一步 | M1(scope-hoisted 产物) |
scripts/dump-timelines.mjs |
GLB 动画曲线 dump 成 JSON 数值账本 | M1(数据驱动动画时) |
scripts/closure.mjs |
从种子模块算传递依赖闭包,竖切边界的唯一依据;未知种子 FATAL + did-you-mean | M2+(模块化打包产物) |
scripts/slice-modules.mjs |
按模块 id 逐字切片,容器外字节(前奏 / 尾注)逐字带走,--check 重切须字节一致 |
M2+(模块化打包产物) |
scripts/extract-source.mjs |
字节切片器:按钉死行号区间切 _pretty/ 拼成生成文件,sha256 守卫 + --check |
M2+(逐字移植期) |
scripts/emit-webpack-chunk.mjs |
多 chunk webpack 站的逐字再发射:按 module-map 边界切成部件再按源站容器形态拼回,--check 逐字节 |
M2+(webpack 多 chunk 站) |
scripts/slice-esm.mjs |
拼接式分解切片器:按声明把 ESM chunk 切成语义命名部件,按序拼接逐字节等于原件 | M2+ / M(n+1)(scope-hoisted 产物) |
scripts/verify-reassembly.mjs |
重拼门:逐部件 sha + 按序拼接 sha + --against 对活原件三重比对 |
M2+ / M(n+1)(scope-hoisted 产物) |
scripts/build-site.mjs |
策略 A 构建层:按 shell-config.mjs 变换表从镜像生成 site/,逐条命中下限 + --check |
M2+(策略 A) |
scripts/verify-shell.mjs |
外壳字节门:逐文档 patience diff,每个差异块须能被变换表重放解释(不 import 构建器) | M2+(策略 A) |
scripts/verify-tokens.mjs |
token 流等价门:排版 / 再发射件 ≟ 源站原件逐 token 相等;凡以 _pretty 字节交付必跑 |
M2+(排版字节交付时每 commit) |
scripts/verify-refs-served.mjs |
引用可达门:产出字节里每条资源引用逐条问服务器(不再实现一遍解析) | M2+ 起每 commit |
scripts/verify-routes.mjs |
路由 / 重定向 / 状态码契约门 | M2+ |
scripts/verify-ssr.mjs |
SSR / DOM 逐字节门 | M2+(有 SSR 产物时最先建) |
scripts/verify-tween.mjs |
竖切的数值门:同一关键帧规格喂两侧,逐点比补间值与缓动曲线 | M2+(有补间 / 时间轴引擎时) |
scripts/harvest-cases.mjs |
从源站活引擎采用例(harvest.config.mjs),只产出 A 侧 |
M2+(源站引擎可达时) |
scripts/verify-harvest.mjs |
采集基线的 B 侧:每条身份在移植侧恰好匹配一个,按行为把名字找回来 | M2+(有采集基线时) |
scripts/verify-crossside.mjs |
跨侧门:同一份输入串行喂镜像与移植逐条比(crossside.config.mjs),URL 相同直接 FATAL |
M2+(源站有可直接调用的接缝时) |
scripts/pixelcompare.mjs |
量化像素对拍:自比带宽 --self、状态对齐 --ready / --after-ready / --chunk、到达等待 --hold*、--freeze-css;非空帧前置条件;大视口用 jpeg |
M(n-1) |
scripts/pixel-walk.mjs |
检查点巡航:N 个滚动位置各跑一次像素门,滚两次、重复帧逐格报出,先 --self 测带宽 |
M(n-1) |
scripts/side-by-side.mjs |
双侧截图并排合成图(对拍产物留证) | M(n-1) |
scripts/frame-census.mjs |
截图普查:颜色数与主色占比,证明帧里有东西 | M(n-1) |
scripts/probe-shim.js |
确定性驱动 shim:接管 rAF / timer / 时钟 / Math.random / IntersectionObserver,手动泵到任意 t,双侧同位注入 |
M(n-1) |
scripts/verify-flight.mjs |
C1 语义门:构建产物 flight 树 ≟ 镜像 flight 树,模块 id 全局双射,自带解析器 | M(n-1)(C1) |
scripts/cold-audit-modules.mjs |
M(n) 冷头清点(模块化产物):逐模块对账 + 计算型 require 扫描,必须报 n/N examined |
M(n)(模块化打包产物) |
scripts/cold-audit-decls.mjs |
M(n) 冷头点名(扁平产物):深度 0 声明逐条判 cited / override / named / UNKNOWN | M(n)(扁平产物) |
scripts/verify-module-map.mjs |
M(n+1) 等价门(模块化产物):一模块一文件且与打包器字节 token 级一致 | M(n+1)(模块化打包产物) |
scripts/verify-symbols.mjs |
符号映射门:port/ 每个顶层声明在 src/ 有且仅有一个对应(读 rename-map.json) |
M(n+1)(扁平产物) |
scripts/verify-fresh.mjs |
新鲜度门:src/ → dist/ → site/ 是否同步;时间戳不是判据 |
M(n+1)(有构建步骤时每次) |
scripts/verify-standalone.mjs |
自包含门:src/ 复制到临时目录 → 断网 → 安装 → 构建 → CLEAN 与零外联 |
M(n+1) |
scripts/verify-zerodep.mjs |
依赖分界门:scripts/ 只许 node: / 相对 import,且没有门 import tools/ |
每次新增脚本 |
scripts/lib/urlpath.mjs |
唯一的 url → 本地路径映射(查询感知),爬虫 / 抓包 / 服务 / 门四方共用 | lib |
scripts/lib/extract-refs.mjs |
唯一的资产引用提取器(五种写法 × 原文 / 解码两遍),爬虫与闭包门共用 | lib |
scripts/lib/negotiate.mjs |
内容协商 Accept 策略(浏览器同款图片 Accept)+ Sanity 证据提取 | lib |
scripts/lib/ports.mjs |
端口分配与实例身份(21000 + slot×1000 + lane×10 + side),占用即响亮失败 |
lib |
scripts/lib/chrome.mjs |
无头浏览器生命周期:进程组收割 + 孤儿自检 + CDP 载荷硬顶常量 | lib |
scripts/lib/png.mjs |
零依赖 PNG 编解码 | lib |
scripts/lib/tokens.mjs |
token 流读法(acorn 钉死 spawn)+ 首分歧定位 | lib |
scripts/lib/cli.mjs |
唯一的 argv 合同:--help / --version / 未知旗标 FATAL,EXIT 退出码表 |
lib |
scripts/lib/hash.mjs |
唯一的 sha256 拼写(字符串 / Buffer / 流式文件) | lib |
scripts/lib/ledger.mjs |
镜像三本账(manifest / inventory / redirects)的唯一读写实现 + LEDGER_FILES |
lib |
scripts/lib/cdp.mjs |
唯一的 CDP 客户端:逐调用超时、断连响亮失败、事件订阅 | lib |
tools/name-modules.mjs |
模块提名:按 0–4 级证据给内容哈希 id 起名并记依据,无证据保留 id | M(n+1)(模块化打包产物) |
tools/accept-names.mjs |
命名的接受步:默认只接受 tier-1(打包器声明的导出名),其余保留 id | M(n+1) |
tools/modules-to-src.mjs |
按接受后的命名逐模块生成 src/modules/(作用域安全的重命名器) |
M(n+1)(模块化打包产物) |
tools/sourcify-chunk.mjs |
多 chunk 站的 M(n+1) 驱动:逐 chunk 跑 name-modules → accept-names → modules-to-src → verify-module-map | M(n+1)(多 chunk 站) |
tools/group-parts.mjs |
把 slice-esm 部件按域折进目录(只按 classy 证据分组) | M(n+1)(scope-hoisted 产物) |
tools/make-standalone.mjs |
交付物生成:按账本复制资产、生成 package.json / verify-bytes;--mirror a,b 回落链 |
M(n+1) |
tools/flight-to-mdx.mjs |
从 flight 树反推 MDX / 页面骨架 | M2+(C1 重构工程) |
tools/assemble-static.mjs |
把 next build 产物摊成静态树供 serve.mjs 伺服(像素门两侧同经 serve) |
M(n-1)(C1 重构工程) |
tools/harvest-optimized-images.mjs |
next/image 优化器产物补齐(镜像字节优先,本机优化器兜底) | M(n-1)(C1 重构工程) |
tools/verify-fresh-next.mjs |
verify-fresh 的 Next 形态:src → next build → assemble-static 链重建比字节(前提 generateBuildId 钉死) |
M(n+1)(C1 重构工程) |
复刻工程目录结构
三个阶段性产物,单向依赖,读作「证据 → 移植 → 源码」:
<site>-rebuild/
├── mirror/ # ① 只读证据:源站 URL 空间的字节级还原。永不修改
│ └── _pretty/ # beautify 展开产物 + 再生成说明 README
├── port/ # ② 逐字移植:机器读,extract-source --check 守着字节一致。永不手改
│ └── _gen/ # 切片器产物(行号头指回 mirror/_pretty/)
├── src/ # ③ 人写的工程:可读、可改、自包含(复制到任何地方都能跑)
│ ├── package.json # ⛔ 自己的 package.json——自包含门要把它复制出去单独跑
│ ├── assets/ # 资产在这里(③ 阶段必须复制,见 readable-source.md §2)
│ └── README.md # 怎么跑 / 坐标系怎么读 / 哪些注释是我们写的
├── docs/
│ ├── engine-notes.md # 逆向笔记(事实/怪癖/复刻结论三段式)
│ ├── rename-map.json # ③ 阶段符号映射(port 位置 → 旧名 → 新名 → 依据档位)
│ └── compare/ # 对拍产物留证
├── REBUILD_PLAN.md # §0 纪律 / 阶段计划 / §6 偏差表 / §Q 怪癖表 / §7 里程碑日志
├── mirror-manifest.json # 镜像账本(sha256 逐文件)
├── scripts/ # 判据与前置工序:零依赖,从本 skill 拷入
└── tools/ # 重构器:③ 阶段专用,允许 devDependencies(见下)
⛔ src/ 里发现行为不对,答案在 port/ 或 mirror/,不在 src/。 就地"改到对"会把移植 bug 变成无法追溯的本地补丁,而且门会变绿——这是纪律 2 在三段坐标系下的形式。port/ 在 src/ 建成后不删除,它是等价性的另一端。
⭐ 依赖分界按阶段:源码化之前零依赖——项目到 M(n+1) 才有 devDependencies。scripts/(判据与前置工序)零依赖,必要时 spawn 钉死版本的 npx;tools/(源码化重构器)允许 devDependencies。任何门不许 import 任何重构器。由 scripts/verify-zerodep.mjs 守。
References
按需加载(Step 0/1 与分支路由表决定),不要开局全量读入:
- scope-and-fingerprint.md — 第 0 步判级与路由(必经)
- recon-and-rating.md — 开工侦察与难度评级(必经)
- mirroring.md — 镜像取证全流程(必经)
- reverse-engineering.md — 行号坐标系与逆向笔记(必经)
- porting-discipline.md — 溯源移植纪律(必经)
- verification-gates.md — 门型定义、决策树、运行纪律、分层体系(必经)
- gate-failure-modes.md — 门的失效模式、根因修复与残差归类(门红了再读)
- gate-case-design.md — 用例设计与清单式核对(数值门 / 跨侧门 / M(n) 清点前读)
- payload-gates.md — 载荷与外壳变换的门(有内联载荷或策略 A 时)
- determinism.md — 确定性冻结协议与 probe-shim
- dom-shell-strategies.md — DOM 层策略选型(A/B/C + 正交约束 D)(所有站必经)
- webgl-scenes.md — WebGL/GLSL 场景逆向
- animation-recovery.md — 动画/输入逆向路径
- binary-formats.md — 私有二进制格式
- shopify-platform.md — Shopify 平台层剥离(B 类)
- sanity-platform.md — Sanity CMS 场景(判级三形态、
auto=format协商陷阱、变体阶梯两层展开、运行时拼接 API base) - asset-management.md — 资产不复制策略与字体决策
- environment-traps.md — 环境陷阱手册
- legal-and-deploy.md — 版权取证与部署决断(取证归 skill,决定归用户)
- readable-source.md — M(n+1) 源码化:port/ → src/ 的可读工程(拆模块、去混淆、注释纪律、自包含契约)
- rsc-reconstruction.md — C1(RSC)重构式逆向:flight 坐标系、MDX 反推、语义门、平台层工件
- archival-rescue.md — X 类死站抢救:CDX 分层覆盖侦察、锚点 + 时间窗、洞登记
- beyond-the-rebuild.md — 交接:拿产出做自己的项目(脚手架化不是本 skill 的阶段)
- assets/templates/rebuild-plan.md、assets/templates/engine-notes.md — 文档模板
- case-studies/skill.md 与
references/case-studies/<doc>.md— 各文档的实证记录(战史),不在必经集合里;只在需要证据时读
Notes
- 版权红线:本 skill 用于学习目的的复刻。产出默认私有 + noindex(安全默认,不是法务结论);公开部署前必须完成逐资产版权取证、把决定交回用户、并显著标注非官方复刻与原作者归属。最大风险是法务不是技术——但法务判断由用户作出,且永不用于削减镜像完整性或门的覆盖面。
- 工期预期:方法论成熟形态下,单页创意站 1-3 天(数十个 commit);多场景 WebGL 作品集站按周计。向用户给预估时参考 Step 1 的难度评级。
- 对拍失败先怀疑环境:后台节流、HMR 幽灵模块、探针时钟、headless 字体缺失都会伪装成代码 bug。判定源码问题前先过 environment-traps.md 的校准清单。
- 遇到本 skill 未覆盖的场景(B 类缺口),明确告诉用户"这一段没有既成指南,按通用纪律推进",并把新经验记入项目文档——它们是 skill 下一版的输入。
Files (website-rebuild-skill)
-
assets
-
templates
-
engine-notes.md 6.2 KB
# engine-notes 逆向笔记模板 > **何时使用本模板**:M1 逆向阶段复制为 `<项目根>/docs/engine-notes.md`,**在写任何复刻代码之前产出**(oryzo 把它列为独立里程碑 M2.0——"文档先行显著降低了后面每轮的返工")。`<!-- -->` 注释是填写说明,落盘后删除;`{...}` 是占位符。 --- # {项目名} 逆向笔记(engine-notes) > **纪律**:本文档只陈述源站事实,不做"应该怎么改"的判断;未坐实的一律标注 **[未确认]**,不猜【kimi】【noomo】。 > **坐标系**:全部行号引用 `mirror/_pretty/` 展开产物(js-beautify@{版本,钉死} 生成,再生成命令见 `_pretty/README.md`)。换 beautifier 版本行号会漂移,整套引用作废【samsy】【noomo】。 <!-- 三段式总结构【samsy】【noomo】:第一部分 源站事实 / 第二部分 怪癖清单(照抄不修)/ 第三部分 对复刻的直接结论。大型站可按 lando 拆成多份编号笔记(00-boot / 01-rive / 02-gl-core / …),但每份内部仍守三段式与行号纪律。 --> --- <!-- 无容器(Vite)目标:先跑 census-bundles --md docs/chunk-graph.md,把 chunk 依赖图与别名证据挂进本笔记的坐标系。 --> ## 第一部分:源站事实 ### 1. bundle 区段地图 <!-- 先画地图再挖矿【lando】:全 bundle 逐段标行号,vendor 边界与应用代码分开。这张表决定后面所有 grep 的范围。 --> | 行号区间(pretty) | 区段 | 性质 | |---|---|---| | L{起}-L{止} | {如 GSAP} | vendor | | L{起}-L{止} | {如 three} | vendor | | L{起}-L{止} | {如 taxi 装配 / home 页逻辑} | 应用代码 | ### 2. 技术栈取证表 <!-- 每行必须有 bundle 内证据。grep 混淆代码搜值不搜名:版本号字面量、十进制颜色字面量、GLSL 特征串比标识符可靠【noomo】。 --> | 依赖 | 版本 | 证据(值 + 行号) | |---|---|---| | {three} | {0.179.0} | {如 `const nv="179"`,L19973} | ### 3. 混淆名对照表 <!-- 逐个坐实的混淆符号 → 语义名映射【noomo】。移植代码可沿用混淆别名作 import 别名,使代码、笔记、pretty 源三方可互相对照【lando】。 --> | 混淆名 | 语义 | 定义行号 | |---|---|---| | {nn} | {RenderingPipeline} | L{NNNN} | ### 4. 启动链 <!-- 从入口到首帧的时序:boot 顺序、preloader 编排、路由装配、事件门控。带行号。 --> {逐步描述,每步带 L 行号} ### 5. {渲染管线 / 材质清单 / 后处理链}(按站型取舍) <!-- WebGL 站填:场景层级、RenderTarget 清单、材质逐项、后处理 pass 拓扑【samsy】【noomo】。DOM 站填:CSS 变量清单、场景编排机制、像素渲染器常数【kimi】。逐项带行号。 --> ### 6. 协议与数据 schema <!-- 私有二进制格式布局(如 .buf 的 [uint32 头长][JSON 头][顺序属性载荷] 与量化解包公式【oryzo】)、worker 协议、实时协议、数据文件 schema、i18n 表结构。数据驱动的动画先 dump 成数值账本(JSON),在此登记账本路径(如 docs/timeline-baseline/)【noomo】。 --> ### 7. 路由与状态(store) <!-- 路由表、守卫怪癖、store 逐字段用途(state/getters/actions 全签名,含死代码)【samsy】【noomo】。带行号。 --> ### 8. GLSL / shader 清单 <!-- WebGL 站必填:shader 定位方式(如搜 `#define GLSLIFY 1` 标记【oryzo】)、逐段行号;登记提取落点(集中存放 + "Do not edit by hand" 头注释,逐字提取不做优化【oryzo】)。 --> | shader | 行号 | 提取落点 | |---|---|---| | {名称/用途} | L{NNNN} | {文件路径} | ### 9. 动画/交互参数抄录表 <!-- 复刻"手感"的唯一合法来源。GSAP 时间轴/缓动/延迟逐字抄录(含贝塞尔控制点公式、ScrollTrigger start/end/scrub 配置)【lando】【noomo】;输入魔数(如 wheelEaseCoeff=12)【oryzo】;滚轮/触摸状态机阈值【kimi】。全部带行号,禁止"大概是这个值"。 --> | 参数 | 值(逐字) | 行号 | |---|---|---| | {如 Lenis 配置 / 缓动 / 阈值} | {原样抄录} | L{NNNN} | ### 10. 平台层/HTML 契约(平台导出站适用) <!-- 平台运行时当行为契约逆向【lando 05-webflow-html】:哪些模块必须保留及原因、页面骨架顺序、head 契约、data-* 属性命名体系、静态烘焙数据的字段字典。 --> ### 11. bundle 内联资产提取登记 <!-- base64 内嵌的纹理/LUT/查找表提取到 mirror/_extracted/ 并在此登记,注明缺失后果(如"缺 colorsMap 玻璃会变灰白")【noomo】。 --> ### 12. 页面 init/destroy 矩阵 <!-- 每个页面/路由的初始化与销毁函数及行号【lando】——这张表直接变成移植任务清单。 --> | 页面 | init | destroy | |---|---|---| | {data-page 值} | {函数名 L{NNNN}} | {函数名 L{NNNN}} | ### 13. 已证伪的假设 <!-- signature grep 只能提假设不能当结论【kimi】。把证伪结果显式留档,防止后来者重走弯路:如 "leva/swr 为子串误命中"【kimi】、"有 GPU compute 证伪——dispatchWorkgroups 全部来自 three 内部"【samsy】、"依赖表里有 three.js 但不是 WebGL 站"【kimi】。 --> | 假设 | 结论 | 证据 | |---|---|---| | {假设内容} | 证伪 / 证实 / [未确认] | {行号 / 实测} | --- ## 第二部分:怪癖清单(照抄不修) <!-- 源站自己的 bug / 死代码 / 怪写法,逐条编号 Q1..Qn,带行号证据【noomo Q1-Q14】【kimi 26 条】【samsy 13 条】。这里只登记事实,处置(照抄)与验证记录同步进 REBUILD_PLAN §Q。"修好它才是偏离"【kimi】。 --> | # | 怪癖 | 行号 | |---|---|---| | Q1 | {现象描述} | L{NNNN} | --- ## 第三部分:对复刻的直接结论 <!-- 唯一允许"面向复刻"下判断的一节,与事实部分严格分离【samsy】【noomo】。写成编号指令,如 noomo 的 10 条:"先实现三个元系统再写任何材质"、"缺 colorsMap 玻璃会变灰白";samsy §16 的"不要发明"清单(哪些能力在 bundle 里存在但从未挂载,复刻不做)。 --> 1. {移植顺序结论:先做什么再做什么,为什么} 2. {"不要发明"条目:bundle 里有但从未生效的能力,列明不做} 3. {关键数据依赖:缺了哪个资产/账本会出什么症状} -
rebuild-plan.md 6.3 KB
# REBUILD_PLAN 模板 > **何时使用本模板**:项目开工时复制为 `<项目根>/REBUILD_PLAN.md`。这是全项目唯一的过程账本:前向队列 + 登记表 + 里程碑日志。它随每个里程碑滚动生长,**代码与文档同一次提交**【lando §0.6】。`<!-- -->` 注释是填写说明,落盘后删除;`{...}` 是占位符。 --- # {项目名}-rebuild 重建计划 > 源站:{URL} 开工:{日期} 目标:逐行为对齐的 1:1 工程化复刻——源站有的都要有,源站没有的不做,所有代码逻辑可溯源到 bundle 行号。 > 工具链:website-rebuild skill v{版本}(`SKILL.md` sha256 `{前 12 位}`,runtime:{Claude Code / Codex / …}) <!-- 溯源行:结论依赖工具,工具在长版本;复核一份旧产出时,第一个要知道的就是"它是哪一版工具跑出来的"。sha 取安装目录里实际加载的那份 SKILL.md【hashgraphvc】。 --> ## §0 执行纪律(宪法级,开工即定稿,此后只读) <!-- 六条照抄自 lando §0,全系六项目同构。不要删减;可按站型补充,但不得与前六条冲突。 --> 1. **源站代码是唯一裁决**:每个改动先归属到 `mirror/_pretty/*.pretty.js` 行号(或镜像 HTML/CSS 位置)再落地。 2. **源站有的都要有,没有的不做**;bug 与死代码照抄不修,登记为怪癖(§Q)。 3. **有意偏差必须登记**在 §6;未登记的差异一律视为 bug。 4. 不自创补偿性 CSS/JS——宁可先不像,也不要发明规则。 5. 每个里程碑过浏览器实测取证,**全新加载**验证(不手动切效果)。 6. 代码与文档同一次提交。 ## §1 镜像清单与外部依赖 <!-- M0 完成时填写。镜像账本本体在 mirror/(manifest),这里只记摘要与决策。 --> - 镜像时间:{时间};文件数 / 体积:{N 文件 / N MB};权威清单:`mirror/{manifest 文件名}` - 抓取手段:{正则 BFS / CDP 抓包补录 / 模板字面量静态求解 / 逐 URL 实测状态码,逐项列出} - 镜像验收(M0.5 阻塞门):断网伺服零 404 / 零控制台错误 / 零外联,{通过情况} **外部依赖决策表**(抓不进镜像的依赖逐项决策)【oryzo】: | 外部依赖 | 用途 | 决策(保留原引用 / 换端点 / 接受降级) | 理由 | |---|---|---|---| | {Adobe Fonts / CDN / SaaS…} | | | | ## §2 技术栈取证表 <!-- 每个版本必须有 bundle 内证据,钉死不带 ^(--save-exact)。取证方式示例:版本字符串、pnpm 路径泄漏、wasm URL、API 指纹;REVISION 类常量搜值不搜名【noomo】。 --> | 层 | 选型与精确版本 | 取证证据(bundle 位置 / 特征) | |---|---|---| | 框架 | {名称@版本} | {如 `versions:{...}` 字面量 + 行号} | | 3D / 动画 / 其他 | | | | 传递依赖钉死 | {如 overrides: unhead 2.0.17} | {为什么必须钉——同框架版本不等于同输出【noomo】} | ## §3 镜像盲区销账清单 <!-- 静态爬取必漏三类:worker 运行时 fetch 的文件、懒加载资源、移动端变体【oryzo】。发现一条记一条,补录后销账【samsy】。 --> | # | 盲区资源 | 发现方式(实跑 404 / 抓包 diff / 目视) | 补录状态 | |---|---|---|---| | 1 | {路径} | | ☐ 待补 / ☑ 已补 | ## §4 阶段计划(前向队列) <!-- 按依赖序排里程碑:M0 镜像 → M0.5 镜像断网跑通 → M1 逆向(engine-notes + 技术栈钉死)→ M2+ 移植(元系统 → 场景/组件 → 页面专属)→ M(n) 验证收口(冷头评审 + 版权决断)。每条里程碑写清验收标准(机器可断言的门),完成后在 §7 记日志。 --> | 里程碑 | 范围 | 验收标准(门) | 状态 | |---|---|---|---| | M0 | 镜像取证 | manifest 齐 + 外部依赖表 | ☐ | | M0.5 | 镜像断网跑通 | 零 404 / 零控制台错误 / 零外联 | ☐ | | M1 | 逆向笔记 + 技术栈钉死 | engine-notes 三段齐 + §2 填完 | ☐ | | M{n} | {范围} | {量化验收} | ☐ | ## §5 难点与风险评级 <!-- 开工前分项打星并与前作横向对标,用于预估工期与攻坚顺序【lando】。素材版权通常是 ★★★★★——最大风险是法务不是技术【oryzo】【kimi】。 --> | 分项 | 评级 | 说明 | |---|---|---| | 素材版权 | ★★★★★ | {逐资产评估见 DEPLOY.md} | | {3D / 滚动编排 / 私有格式 / 平台层…} | {☆-★★★★★} | | ## §6 有意偏差登记表 <!-- 登记原则【kimi】:"凡是明知与源站不同的实现,必须留一条,写清『源站怎么做的 / 我们怎么做 / 为什么 / 什么条件下重新考虑』。没登记的差异一律视为 bug。"典型条目:npm 替代 vendored 库、符号链接资产、验证仪器注入、遥测剥离。代码内对应处加 "REGISTERED DEVIATION" 注释【samsy】。 --> | # | 源站怎么做 | 我们怎么做 | 为什么 | 什么条件下重新考虑 | |---|---|---|---|---| | 6.1 | {源站行为 + 行号证据} | {复刻实现} | {理由} | {重新考虑条件} | ## §Q 源站怪癖登记表(照抄不修) <!-- 与 §6 相反:这里登记的是"源站自己的 bug/死代码/怪写法",处置一律照抄。每条带 pretty 行号证据【lando】。警示案例:lando Q13——"修好" no-op 的 scene.remove(Q.name) 后真删除反而破坏遍历导致转场崩溃,最终回抄。 --> | # | 怪癖现象 | 行号证据 | 处置 | |---|---|---|---| | Q1 | {如:调用即崩的死方法 / 拼错的事件名 / 恒为 true 的旗标} | pretty L{NNNN} | 照抄不修 | ## §7 里程碑日志(倒序追加) <!-- 每完成一个里程碑追加一条,与代码同 commit("Port xxx" + "Update rebuild plan: xxx" 成对【oryzo】)。四要素缺一不可【kimi】【samsy】;"下一步断点待办"必须带精确行号,让跨会话续作有明确入口。注意:断点笔记里的"下一步很简单"也是待验证断言【kimi】。 --> ### M{n} {标题}({日期},commit {hash}) - **产出**:{做了什么,带行号溯源,如 "Port of Eu0 controls, pretty L63486-L63732"} - **验收**:{门的结果,机器可断言,如 "SSR 门 9/9 逐字节一致;探针 CLEAN"} - **教训**:{根因分析 / 环境陷阱 / 方法学发现;没有可写"无"} - **下一步断点待办**:{下个里程碑入口,带 pretty L{NNNN} 行号} ### M{n-1} …
-
-
-
references
-
case-studies
-
animation-recovery.md 8.7 KB
# case-studies/animation-recovery.md — 动画/交互逆向路径选择 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `animation-recovery.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `animation-recovery.md` 一一对应。 ## 0. 唯一禁令(先读) 规则见 `animation-recovery.md` §0。 - oryzo 曾用目测近似实现先跑通,随后被强制替换为溯源版(M2.3 commit 明言"全部逻辑溯源 bundle,替换了此前的近似实现")【oryzo】。 - 目测版只允许作为临时 stub 存在,且必须显式标记生命周期("将被溯源实现取代"),如 lando 的 `stubs-notes.md` 骨架清单——每个 stub 标注对应源函数和行号区间,逐波替换【lando】。 ## 1. 核心判据:动画的事实来源在哪 规则见 `animation-recovery.md` §1。 - 一段动画可能混合多种来源(noomo:GLB 曲线驱动相机 + GSAP 驱动页面过渡 + 弹簧驱动插值),**逐段判定、逐段选路径**,不要全站套一条路。 ## 2. 路径 A:GSAP/JS 代码 → 参数逐字抄录 ### 2.1 先在逆向笔记里逐字 dump 参数,再写代码 规则见 `animation-recovery.md` §2.1。 - lando 的 `04-dom-components.md` 把每个组件的 GSAP 参数逐字抄录:heroflip 的两段三次贝塞尔控制点公式(`CP1=(p0.x, p0.y+(p1.y-p0.y)*0.4)`…)、ScrollTrigger 的 `start "top top", end "bottom 25%", scrub true, invalidateOnRefresh`、三大文字揭示配方的 duration/ease 数值,全部带 pretty 行号【lando】。 - noomo 的 engine-notes §10.4 列出全部页面过渡的秒数/缓动/延迟及行号【noomo】。 ### 2.2 时间轴逐事件对齐,不是"总时长差不多" 规则见 `animation-recovery.md` §2.2。 - rogier 的 preloader 编排逐事件复刻:镜像 HTML 带 `<body style="opacity: 0;">`(FOUC 防线)照抄,由 preloader `init` 清除;预加载阶段只跑 `animateVersionIn/animateNameIn` + canvas `animateIn`,其余全部等 Enter 点击触发的 `ANIMATE_IN` 事件(重建用自定义事件对应)【rogier】。 - 事件的**触发者、门控条件、先后序**都是规格;preloader 最短展示时长这类门槛值(samsy 的 4000ms)也要抄【samsy】。 - 页面过渡链路按逆向笔记的 boot 时序图移植:lando 的 taxi 生命周期 → Rive 遮罩 → 1000ms 揭开【lando】。 ### 2.3 路由过渡要搞清"谁被替换、谁常驻" 规则见 `animation-recovery.md` §2.3。 - rogier 源站换页只替换 `.ui-main` 内视图,header/nav/声音开关是常驻组件;重建曾整块替换导致入场动画重放。 - 平台运行时的换页契约也是规格:lando 必须保留 "webflow 三连(jQuery→schunk→entry)",因为 taxi 换页后要调 `window.Webflow.destroy()+ready()`【lando】。 ### 2.4 入场态从初始值开始 规则见 `animation-recovery.md` §2.4。 - 源站以 CSS opacity 0 附加新视图再 `fromTo(0→1, 0.5s linear)`;重建直接置 1 造成闪帧,用 700ms/1200ms 阶段截图验证修复【rogier】。 ## 3. 路径 B:烘焙数据文件 → dump 数值账本 规则见 `animation-recovery.md` §3。 1. noomo 用手写 GLB 解析器(对应本 skill `scripts/dump-timelines.mjs`)把三条时间线 GLB 的全部动画曲线 dump 进 `docs/timeline-baseline/`(2.4MB:dev.glb 38 条参数轨道×481 帧、cam.glb 相机 601 帧 + 7 个 project 空物体 TRS)【noomo】。 2. noomo M4a 的验收是"相机位置在 t=0/5/10/19 与基准插值**小数点后三位全等**"【noomo】。 3. 收官期排查弱视觉差 F3 时,拿 dev.json 采样值逐项核对 8 个现场弹簧值,证明"参数绑定链无 bug"后才定性为已知差异登记【noomo】。 4. **把滚动→进度链逆向成纯函数**。noomo 的滚动链是 scrollTop → 段索引+比例 → [0,20] 直接当秒喂 mixer scrub("1 段 ≡ 1 秒"硬耦合)——逆向成纯函数后可以数值验证而不依赖手感【noomo】。 5. **相机轨迹类二进制同理**。oryzo 的相机运镜烘焙在 `.buf`(Points,每 vertex = 一帧的 position/orient/focal),播放器按帧插值(lerp + slerp + focal→fov)——先逆向出布局与量化公式(`value = (raw + half) * q * delta + from`),配调试页量化验收(25/25 模型解析成功)再接主站【oryzo】。私有格式细节见 `references/binary-formats.md`。 6. **bundle 内联的数据资产单独提取**。base64 LUT/纹理提取到 `_extracted/`,复刻侧内嵌后做**字节级一致性验证**(noomo 的 colorsMap 1024×2 光谱 LUT,缺了玻璃会变灰白)【noomo】。 ## 4. 路径 C:CSS 变量/内部 state → 录基准 + 拟合/重放验证 规则见 `animation-recovery.md` §4。 1. **纯函数层与 DOM 层分离**。把编排数学抽成无 DOM、无框架的纯函数库(kimi 的 `deck.ts`:纯几何 + 18 个 CSS 变量推导,文件头逐函数映射 minified 名与行号),让数学可以脱离浏览器被验证,验证通过后再接组件层【kimi】。 2. **先在源站上录基准**。探针在镜像上录 CSS 变量随驱动量(滚动/deck 位置)变化的时间序列,存成 JSON 基准(kimi 的 `docs/deck-baseline/source-*.json`);录制探针与验证器成对出现(probe-* 录源站基准 / verify-* 验复刻)【kimi】。 3. kimi 实绩:deck 661/661 通过、最大残差 4.75e-7;clip-path 擦除几何(Sutherland–Hodgman 半平面裁剪)439/439、残差 8.53e-14 px【kimi】。 5. kimi 曾只采 `<main>` 上 18 个变量,在位置 3.2 后"完全失明"(变量饱和,场景 3-7 由容器 opacity 驱动)——把 opacity 采进基准后覆盖立刻到 8.2【kimi】。 ## 5. 路径 D:物理/程序化模拟 → 常量表全抄 规则见 `animation-recovery.md` §5。 - 玩家物理常量全表照抄(samsy 的 MOVE/JUMP 对象,文件头注明 pretty 行号区间)、bloom strength 0.34 / radius 0.27×DPR、雾 IDLE 700/800——全部带 bundle 行号【samsy】。 - 复杂效果**先在笔记里拆成结构再移植**:samsy 的零光照氛围 = 黑雾 × 烘焙贴图 × 0.3 × 高度渐变 + bloom 只吃 emissive MRT,"复刻时必须按此结构而非『打灯调像』"【samsy】。 - 确定性随机源(LCG 种子 1111111114)、弹簧参数 (50,15)、限流(1s 内 5 次)等"手感参数"全部从 bundle 抄写【noomo】。 ## 6. 特殊模式:无全局时间轴,进度由 DOM 几何推出【oryzo】 规则见 `animation-recovery.md` §6。 oryzo 逆向确认:一切进度由 DOM 元素几何位置推出(`getDomRange` 映射),各 section 把 `showScreenOffset` 映射到场景 `animation` 值;相机运镜另走 `.buf` 按帧插值【oryzo】。 - 若进度源是 DOM 几何,则 DOM 骨架的字节级还原(见 `references/dom-shell-strategies.md`)就是动画正确性的前置条件——oryzo 的验收含"浏览器 scrollHeight 46410px 与源站一致"【oryzo】;scrollHeight 不对,全站进度都错。 ## 7. 输入/手感状态机 规则见 `animation-recovery.md` §7。 1. **魔数逐字照抄**:`wheelEaseCoeff=12`【oryzo】;Lenis 配置逐字 `{lerp:0.1, touchMultiplier:1.25, syncTouch:true…}`,连"两分支配置相同"的怪癖(Q7)也照抄【lando】。 2. **状态机参数从 bundle 取证**:kimi 的滚轮闩锁(阈值 6 累积、180ms 静默重置)、触摸离散滑动(阈值 48px、不跟手)、补间时长双段曲线【kimi】。 3. **用录制时间线重放验证,替代手调**:探针在镜像上注入带时间戳的输入序列录基准(kimi 录了 6892 帧),验证器把控制器放进虚拟时钟按同一时间线重放,逐帧比轨迹,p95 残差 0.0019。 ## 8. 常见坑 规则见 `animation-recovery.md` §8。 3. **"好心修正"怪写法**:带符号取模被修成正取模后 About 页浮动方块全部消失【rogier】;lando "修好" `scene.remove(Q.name)` 的 no-op 后真删除反而破坏遍历导致转场崩溃,最终回抄(Q13)【lando】。 4. **冷启动才暴露的动画 bug**:oryzo 的 NaN 传染(滚动指示器未初始化字段 → `u_pulseCenter.y = NaN` → 整屏恒定色)只在全新加载下暴露; 5. **检查点漏掉滚动两端**:noomo 探针没测滚动终点 t=20,HomeFooter 揭示动画整段缺失漏网,靠用户直连源站目视才发现——**终检必须包含滚动两端**【noomo】。 6. **基准录制的覆盖盲区**:只录部分变量/部分驱动域会"失明"(kimi 位置 3.2 后饱和)【kimi】;录完基准先验证覆盖度。 9. **自创补偿性动画/CSS**:JS 机制没对齐时用自创 CSS 补观感,等 JS 对齐后补丁反转成 bug(rogier 十余个视觉 bug 的共同根源)——"宁可先不像,也不要发明规则"【rogier】。 -
archival-rescue.md 6.4 KB
# case-studies/archival-rescue.md — X 类抢救:从 Wayback Machine 重建死站 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `archival-rescue.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `archival-rescue.md` 一一对应。 ## 0. 三个决定,缺一个产出就是汤不是证据 规则见 `archival-rescue.md` §0。 2. ⭐ **一个连贯的时刻**:`--anchor`(默认 auto:根页 200 捕获最密的年代取中位)+ `--window-days` 逐 URL 选窗口内离锚点最近的 200 捕获。**从任意年代乱缝的镜像是一个 从未存在过的站**;抢注者时代的 301 洪水(实测某死站 CDX 里 2025 年垃圾与 2020 年真身 同列)靠状态码 + 窗口天然出局。 ## 1. 别名回填:档案可能用另一个名字认识这个洞 规则见 `archival-rescue.md` §1。 实测:站点用缓存穿透前缀引用资产(`/version/<ts>/js/menu.svg`),该拼写从未被捕获—— 而 `/menu.svg` 在窗口内有 200 捕获。`wayback-mirror` 对每个洞做一次 CDX 同名(basename 精确匹配)查询,窗口内命中则抓取并**存到被引用的路径**上,让引用得以解析。 ## 1.5 ⛔ 铁律:抢救项目里永远不要对原域跑 mirror-site 规则见 `archival-rescue.md` §1.5。 死域的"死"有两种应答形态:3xx(跳走)与 **200(停车页夺舍)**。redirect:manual 纪律 只挡得住前者;停车页直接 200 应答,mirror-site 会**用停车字节覆写救回的真身,并把账 同步更新——五道门全绿地完成一次污染**(实测:Rakko 停车页覆写已救回的 2018 根页, 唯一的目击者是后来探针里冒出的 `cdn.rakkoid.com` 外联)。抢救项目的一切补种走 ## 1.6 验尸三件套:死亡时刻、停车页时代、伪身捕获【firstlaunch】 规则见 `archival-rescue.md` §1.6。 1. ⭐ **停车页时代有 CDX 签名**:`.well-known/ai-plugin.json`、`.well-known/security.txt`、 `ads.txt`、`app-ads.txt` 这类路径突然出现 200 捕获、且 mimetype 全是 `text/html`—— 真身不会拿 HTML 应答 `ads.txt`,停车页对任意路径都答同一张页。这些行冒出的年代 就是夺舍年代;判死亡时刻、选锚点前先把它们剔出统计(实测 2025 年根页 200 最密, 全是停车页——auto 锚点若不剔除会正中夺舍时代)。 2. ⭐ **根页 digest 变迁史就是站的年表**:按 digest 分段(first-launch: 2013 获奖版 → 2014-02 改版 → 2014-12 稳定版跑六年 → 2019-08 微调 → 2022-11 死),每段一个内容 时代;深爬时刻(全站资产同日被爬)是最连贯的锚点候选。 3. ⛔ **同 digest 交叉鉴伪**:一个路径的孤本捕获若与**停车页时代的根页**同 digest, 它是夺舍后的伪身,不采(实测 mobile.html 唯一捕获 @2022-11 与 2023 年被替换后的 根页同 digest——登记为洞,不冒充真身)。 ## 1.7 锚点偏置:一次罩住别的时代的孤本【firstlaunch】 规则见 `archival-rescue.md` §1.7。 有些文件只在**另一个内容时代**被捕获过(改版时摘掉的 awwwards.css、只挂过一个月的 节日子页)。逐 URL 取"窗内离锚点最近",所以**把锚点压向窗口一侧**能一次罩住:锚点 2015-01-01 ± 365d,窗口左缘伸进 2014 年初的 E2 孤本,而有多个捕获的 URL 仍然全部落在 离锚点 12 天的 2014-12 深爬上。代价是跨时代混入,按偏差登记(provenance 逐文件时间戳 本来就记着)。**不必为孤本二次抓取或扩窗重跑。** ## 1.8 第三方 CDN 的两跳种子:字体【firstlaunch】 规则见 `archival-rescue.md` §1.8。 Google Fonts 是两跳:CSS(`fonts.googleapis.com/css?family=…`)→ 字体文件 (`fonts.gstatic.com/...ttf`)。都问档案要当年的字节(§1.5):先 `--seeds` 种 CSS,读 救回的 CSS 提取字体 URL,再 `--seeds` 种字体文件。⚠ CSS 捕获的 digest 逐次都不同是 **正常的**——Google 按 UA 出不同格式,档案存的是当年爬虫 UA 拿到的那份(实测 2015 年 窗内是 v10 TTF);任选窗内一份即当年字节,不要因 digest 不稳去找"更对的一份"。 ## 1.9 ⛔⛔ 抢救深度要在锚点之前预判:运行时拼接的资产是档案的射杀区【mustachelab】 规则见 `archival-rescue.md` §1.9。 **IA 的爬虫不执行 JS。** 凡 URL 由代码在运行时拼出——加载器清单(CreateJS `LoadQueue(PATH)`)、 资源清单文件(`RESOURCE.dir + file`)、模板字面量——档案**从未请求过它们**。这不是洞多洞少 的问题,是**一整层内容成建制地不存在**:实测一个 2014 Awwwards 站,代码层捕获 100% (引擎逐行可读),画面层 **157/160 资产在任何年代、任何 host 拼写下零捕获**——断网跑起来 是一张纯白页。 **镜像期的配套动作——洞账要人工补全集**:wayback-mirror 内建洞扫描走 extract-refs(静态 提取),对运行时拼接**整类失明**(实测 157 个洞一条没报)。做法是写一个**站点侧推导器** (几十行,逐条带源码行号)把清单机械展开成 URL 全集 → 与 CDX/镜像对账 → 未捕获的整批 append 进 `wayback-holes.txt`——它同时就是"资产若回归"的 seeds 清单。这是 `reconcile-gaps` 的"字节推导全集"在死站上的同构物。 真正可能补齐画面的往往是**权利人本人**(实测该站母公司官网仍在线)——把"联系作者"写进 DEPLOY.md 的选项表,这是唯一现实的复活路径。 ## 4.5 CLEAN 门的死站语义:失败 ⊂ 洞账【firstlaunch】 规则见 `archival-rescue.md` §4.5。 抢救范围内的路由,断网门语义与活站完全一致(零 404/零错误/零外联)。但**引用着永久洞 的路由**(孤儿子页、洞在 CSS 里的页面)注定有 404——门的判据不是"零失败",而是 **"失败清单 ⊆ wayback-holes.txt,一条账外失败都没有"**。逐条比对(实测两个孤儿页 9 个失败全部对上洞账、零账外),把比对结果写进里程碑日志。⚠ probe/sweep 目前没有 `--allow-404 <holes>` 通道,这一步是人工比对——比对时警惕"差不多都对上了":一条 ⭐ 另一面:**源站生产环境自己的 404 不是洞**。CDX 里 statuscode 就是 404 的引用 (死 CSS 引用不存在的图),是源站行为,照抄——镜像伺服它 404 正是保真(实测两条, 对应选择器在 DOM 无宿主,浏览器根本不请求,门不受影响)【firstlaunch】。 -
asset-management.md 3.3 KB
# case-studies/asset-management.md — 资产管理:镜像即唯一资产库,不复制策略 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `asset-management.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `asset-management.md` 一一对应。 ## 1. 选型:先量体量,再看构建器 规则见 `asset-management.md` §1。 - **资产体量**:百 MB 级资产**必须走不复制路线**(samsy 238MB、lando 37MB/508 文件都没有进源码树);十几 MB 也建议不复制(kimi 15MB 用符号链接,"源站字节不复制两遍")。 ## 2. 六种做法(六个项目各一种,按代际排列) 规则见 `asset-management.md` §2。 | 做法 | 项目 | 要点 | |---|---|---| | ① 全量复制 + 哈希验证 | 【rogier】 | `public/` 与源站逐字节一致,且真的验证过:116 个非视频文件 byte-identical、23 个视频 size 匹配、3 个不一致的 png 替换为源站字节;约 111MB 媒体直接进 git,部署只上 `dist/`。**一代做法,后代全部演进为不复制** | ## 3. 配套机制:CDN 跨域引用与服务层改写 规则见 `asset-management.md` §3。 - **一代反例**:rogier 直接改磁盘 bundle(禁 service worker、detect-gpu benchmarks 本地化),但把每处重写登记在案(PHASE1_AUDIT "Known local JS rewrites")并在对比时扣除——后代演进为"干脆不改磁盘"【rogier】。若被迫改磁盘,必须照此逐处登记。 ## 4. 分层细则:轻资产可入库,重资产必不复制 规则见 `asset-management.md` §4。 kimi 的"省力路径"同理【kimi】:美术资产直接复用镜像;职位数据、i18n、metadata 从 bundle 抽成 JSON——"自己只重写编排层"。第三方在线依赖也可本地化进运行资产(rogier 把 detect-gpu 的 unpkg benchmarks 本地化到 `public/vendor/detect-gpu/`)【rogier】,但改写 bundle 指向它的每一处都要登记(见 §3 一代反例)。 ## 4.5 移动端变体:规则要逆向,不要猜 规则见 `asset-management.md` §4.5。 移动端资产往往是同名变体,命名规则藏在 bundle 里,必须逆向出来再补抓:oryzo 逆向出 `properties.getMobileUrl(url)` 在扩展名前插 `_MOBILE`、纹理上限 800px vs 桌面 2560px【oryzo】——静态爬取漏掉的 16 个移动端专属文件正是靠真实运行路径 404 才暴露、再从源站补录的【oryzo】。lando 的镜像同样含桌面 webp + 移动 ktx2 双端纹理变体(后补提交)【lando】。 ## 5. 资产保真细则(复用镜像时逐条执行) 规则见 `asset-management.md` §5。 - **逐字节一致才算复用**:rogier 镜像漏抓的字体补齐后,验证二进制与源 `/fonts/*` 逐字节一致——About 页的排版差异正是靠这个定位的【rogier】。 - **模型原样使用,不做归一化**:`me.gltf` 不做旋转翻转/包围盒归一化——模型自带 31.17 的内在 scale,是行为的一部分【rogier】。 ### 6.2 自托管字体:默认照抄原件,拒绝"顺手优化" 规则见 `asset-management.md` §6.2。 kimi 的"4.8MB 像素字体不子集化"决策是偏差登记的范本(全项目最完整的一次决策记录)【kimi】。字体含 35,825 字形而全站只用 538 个汉字,可压四十余倍,但拒绝子集化的五条理由按杀伤力排序: -
beyond-the-rebuild.md 633 B
# case-studies/beyond-the-rebuild.md — 复刻之后:通往你自己的项目 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `beyond-the-rebuild.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `beyond-the-rebuild.md` 一一对应。 ## 2. 带裁判的 fork:把变红的门当偏离台账用 规则见 `beyond-the-rebuild.md` §2。 这套工作法防的正是"近似漂移不可见":曾有一份 6,580 行的手写复刻,每一处近似当时都 显得合理,加起来与源站 99.95% 的像素不同——而作者对此毫无度量。 -
binary-formats.md 6.4 KB
# case-studies/binary-formats.md — 私有二进制格式逆向指南 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `binary-formats.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `binary-formats.md` 一一对应。 ## 0. 首要原则 规则见 `binary-formats.md` §0。 - **资产原件从镜像直接搬运**——绝不"找相似替代资源"(lando 的字体/GLB/HDRI/KTX2/.riv 全部来自镜像原件,没有任何替代环节)【lando】。 - 版本从 bundle 取证:lando 的 Rive 版本从 bundle 内 wasm URL 取证,钉 @rive-app/canvas-lite@2.26.4【lando】; - 源站 vendored 的库若有 npm 同款,先证明"同库同算法"再替换并登记偏差(samsy 的 layout-bmfont-text、word-wrapper 等 9 项)【samsy】; - oryzo 的 three-msdf-text-utils 即源站 vendored 的同一库【oryzo】。 - **解码逻辑就在 bundle 里 → 1:1 移植解码器**,而非黑盒猜格式(oryzo 的 BufLoader 是从 bundle 直译的)【oryzo】。 ## 1. 分诊:三个判断 规则见 `binary-formats.md` §1。 - 问 1:oryzo 的 `.sog` 被识别为 **PlayCanvas SOG 标准格式**(无压缩 ZIP + webp 平面 + codebook)——可借开源实现比对验证,不必从零逆向【oryzo】。 - 问 2:lando 的 `.riv` 就是数据文件,"直接播放即可;**难点在 DOM 集成层**"——预载缓存、resize 注册表、状态机接线,做法是把 bundle 里的全局变量表逐字 dump 后照抄,页面过渡链路(taxi 生命周期 → Rive 遮罩 → 1000ms 揭开)按 boot 时序图移植【lando】。 - 问 2:**需解析的格式**:消费端逻辑要自己重建时才需要理解布局(oryzo 的 `.buf` 由自研引擎解码消费,必须移植解码器)【oryzo】。 - 问 3:Web Worker 里 → 把 worker 也 beautify 进 `_pretty/`(samsy 的 baker worker 展开 33,458 行),**逆向 worker 协议**写进逆向笔记,再移植烘焙管线(samsy M6 的 VRM/VAT worker 烘焙管线)【samsy】。 ## 2. 完整逆向流程(以 oryzo `.buf` 为范本)【oryzo】 规则见 `binary-formats.md` §2。 **步骤 1**——oryzo 逆向结论: - 布局:`[uint32 头长][JSON 头][顺序属性载荷]`; - 量化解包公式:`value = (raw + half) * q * delta + from`; - 同格式的非显然用法也要挖出来:相机轨迹同样是 `.buf`(Points 类型,每 vertex = 一帧的 position/orient/focal),运镜按帧插值(lerp + slerp + focal→fov)【oryzo】。 **步骤 2**——`BufLoader.ts` 是源站解码逻辑的直译;死参数(`mipFilter`)、错误赋值(`format="R8"`)照抄不修——"修正它们反而会偏离源站的实际渲染结果"【oryzo】。 **步骤 3**——每个格式解析器配一个可视化调试路由(oryzo:`/debug/buf`,模型下拉 + OrbitControls + 贴图验证 + 相机轨迹可视化)——"比在主站里调试快得多"【oryzo】。 **步骤 4**——验收标准必须是可数的:oryzo 的门是 **25/25 模型全部解析成功**【oryzo】。 ## 3. 标准格式的非标准用法:GLB 时间线【noomo】 规则见 `binary-formats.md` §3。 - 标准容器(GLB)被当作私有数据载体时(noomo:三条 Blender 烘焙的动画时间线 GLB,相机 + 约 40 条参数曲线),处置方式不是"重实现",而是**先 dump 成数值账本**: - **手写最小解析器 dump 曲线成 JSON**:`scripts/dump-timelines.mjs`(手写 GLB 二进制解析器)把全部动画曲线 dump 成 JSON 数值基准入库 `docs/timeline-baseline/`(2.4MB:dev.glb 38 条参数轨道×481 帧、cam.glb 相机 601 帧 + 7 个 project 空物体 TRS)。脚本注释点明动机:"careers-kimi lesson: **compare recorded values, not screenshots**"【noomo】。 - **账本兼任排障 oracle**:F3 残差排查用 dev.json 采样值逐项核对 8 个现场弹簧值,**证明参数绑定链无 bug 后**才定性为弱视觉差登记【noomo】。 ## 4. worker 协议与烘焙管线【samsy】 规则见 `binary-formats.md` §4。 - 数据不在磁盘文件里,而在 **worker 协议**中——把 baker worker 与主 bundle 一样用钉死版本的 js-beautify 展开进 `_pretty/`(samsy:33,458 行),协议全量写进 engine-notes 再移植【samsy】。 - 验收走引擎状态数值断言(15 NPC / 7 舞者 / instancer / 25 作品)而非目测【samsy】。 ## 6. 常见坑 规则见 `binary-formats.md` §6。 1. **worker / WASM 是运行时才 fetch 的,静态镜像必漏**:oryzo 的 `.sog` WASM 排序 worker、samsy 的 baker.worker 都是事后用真实浏览器实跑抓 network 补录的。 2. **数据类资产用脚本抽取,不手抄**: - samsy:works.json(25 条)、cityLayout.json(35 处摆放,L65917-66615 逐字反解)、animations.json(1.64MB)、mixamoRig.json、preloaderFrames.json【samsy】; - kimi:i18n 用括号配平 + 隔离 vm 求值抽取,键集交叉校验(80=80),生成物不手改——"连源站的拼写错误都免费保真"【kimi】。 3. **bundle 内联 base64 资产容易漏**:noomo 的 colorsMap 光谱 LUT 藏在 bundle base64 里,缺了玻璃整体变灰白;提取到 `_extracted/`,复刻侧再内嵌时要做字节级一致性验证【noomo】。 5. **移动端变体有独立命名规则**:oryzo 的 `getMobileUrl(url)` 在扩展名前插 `_MOBILE`(纹理上限 800px vs 桌面 2560px)——镜像时按规则补全变体,否则移动分支 404(oryzo 曾一次补录 16 个移动端文件)【oryzo】。 6. **动态拼接的资产 URL 正则抓不到**:`` `/models/crystal${e}.glb` `` 类模板字面量要人工静态求解后逐个补抓(noomo 把 `${e}` 解为 0–6);lando 的 GL 资产基址 `vQ`、Rive 基址 `mj` 都是变量拼接,靠人工从 bundle 求解【noomo】【lando】。 7. **自写二进制工具必须对参照实现验证**: - kimi 的零依赖 PNG 编解码器对 Pillow 逐格验证过才可信; - 起因是 M7.3 事故——Chrome 截图是 colorType 2 三通道而临时诊断代码硬编码 `*4` 索引,画出一整轮几何假象; - 诊断工具与验收门要用不同的正确性标准,一份解码代码同时服务两者时,坏账会藏在全绿里【kimi】。 9. **模型的内在变换不要"归一化"**:rogier 的 `me.gltf` 按源站原样使用,不做旋转翻转/包围盒归一化——模型自带 31.17 的内在 scale 是行为的一部分【rogier】。 -
determinism.md 23.9 KB
# case-studies/determinism.md — 确定性协议(对拍门的前置条件) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `determinism.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `determinism.md` 一一对应。 ## 0.1 ⛔⛔ 冻结 JS 时钟冻不住 CSS 动画【v0-optimus】 规则见 `determinism.md` §0.1。 `probe-shim.js` 接管的是**每一个经过 JavaScript 的时钟**:rAF、`setTimeout`/`setInterval`、 `performance.now`、`Date.now`、`new Date`、定种 `Math.random`。在前几个目标上这就是全部—— 它们的动画由 GSAP / 自研引擎 / 裸 rAF 驱动,冻住 JS 就冻住了画面。 ⛔ **CSS 动画不经过 JS。** `animation: marquee 30s infinite` 跑在浏览器自己的动画时间线上, 一个"完全冻结"的页面里它照样在走。实测一个 v0 生成的站:7 个无限 CSS 动画 + 191 个带动画/ 过渡的元素,于是—— ⭐ **症状是"同侧对照比跨侧还大"**:源站与它自己比 `meanAbsDiff 0.31`,跨侧只有 0.22, **且最差格完全相同**。再加上残差**在两次运行之间换位置**(第一次最差在 25% 处,第二次那里 归零、最差跑到 0% 处),两条独立证据都指向同一结论:**不可归因于移植**(`gate-failure-modes.md` §3.1 (D))。 ### 0.1.1 补法与它的边界 规则见 `determinism.md` §0.1.1。 ⚠ 它**改变被渲染的内容**(marquee 被定格在行程中间而不是各自漂到的位置),这正是目的: **两侧定格在同一位置**。实测带宽 0.31 → **0.20**。 ### 0.1.2 ⭐⭐ IntersectionObserver 也是一个时钟,而且在滚动揭示站上是最要紧的那个 规则见 `determinism.md` §0.1.2。 `--freeze-css` 之后带宽仍停在 0.20,剩下的熵源是 **IO 的投递时机**:浏览器按自己的 节奏投递交叉记录,不在主线程的帧循环上。于是同一个"已冻结"页面的两次抓取,其入场动画 可能从不同的泵计数开始——**这种残差会在两次运行之间换位置**,正是它让残差无法归类。 实测(同一个 CSS/IO 驱动的站): | 阶段 | 自比带宽 | 跨侧最差 | 可用阈值 | |---|---:|---:|---:| | 只冻 JS 时钟 | 0.31 | 0.22(且换位置) | 0.5(形同虚设) | | +`--freeze-css` | 0.20 | — | — | | **+接管 IO** | **0.04** | **0.07**(9 点里 6 点为 0.00) | **0.1** | ⭐ **门的分辨率提高了 5 倍**,而且这时"跨侧 0.07 对带宽 0.04"才是一句有意义的话。 ⚠ 它改变的是回调**何时**触发,不是**是否**触发;只在 `?__probe` 下生效。⛔ 回归验证过: 一个 JS 引擎驱动的目标(不依赖 IO 做揭示)带宽仍为 **0**,没有被这次改动扰动。 ## 0.2 ⛔⛔ `--ready` 必须是**泵循环的退出条件**,不能是泵之前的等待【eightdesign】 规则见 `determinism.md` §0.2。 像素门原来在导航之后、泵之前等 `--ready`。⛔ 那样它**只能表达「不需要任何驱动就已就绪」**——而冻结页上值得等的状态,恰恰都是泵才能产生的:预加载走完、WebGL canvas 被定尺、入场动画结束。实测直接挂满 120 秒超时:**在一个前置条件尚未运行的条件上等待。** ⭐ 改成泵循环的退出条件:**泵到状态达成为止,以帧预算封顶**。状态早到就早停(实测某个判据 23 帧即达成,整轮 4 秒),永远不到就**响亮失败**——⛔ 不许拿一张"还在加载"的画面去比对,**两张加载屏会完美一致**。 ### 0.2.1 ⚠ 找 ready 判据时,容易挑到**太早**的状态 规则见 `determinism.md` §0.2.1。 同一个站上试过两个判据,都在"画面可判"之前就为真: | 判据 | 何时为真 | 那一刻的画面 | |---|---|---| | `canvas.width > 400` | 23 帧 | **202 色,99.3% 单色** | | 预加载屏消失 | 360 帧 | **669 色,97.6% 单色** | 两次都是**非空画面前置条件**把它们拦下来的——⭐ 这道防呆在这里第二次证明了它的价值:它是唯一知道"这张画面配不配拿去比"的东西。 ### 0.2.2 ⚠ 这个站需要**真实时间与泵同时**推进 规则见 `determinism.md` §0.2.2。 单独泵 960 帧:预加载屏纹丝不动。单独等 22 秒真实时间(不泵):同样纹丝不动。 **两者交错才前进**——资源在墙钟上到达,进度在 rAF 上推进。这正是 v0.1.22 那条的又一个实例, 而它也解释了为什么 `--ready` 必须住在**交错循环内部**:只有那里两个时钟才同时在走。 ### 0.2.3 ⛔⛔ 驱动也必须住在泵循环里——**就绪需要驱动,驱动需要就绪**【eightdesign】 规则见 `determinism.md` §0.2.3。 实测:一个站的滚动容器**在 `load` 时还不存在**——它由预加载流程创建并定尺。于是巡航门在 `load` 时发出的滚动种子算出 `scrollHeight - clientHeight = 0`,**每个检查点都滚到 0**,而残差全部落在带宽内,**看起来完全像一次成功的巡航**。 ### 0.2.4 ⚠ 平滑滚动库会把你的落点抢回去 规则见 `determinism.md` §0.2.4。 修好前两层之后,页面终于会动了,而两侧在同一检查点差出 115。原因:平滑滚动库**拥有**那个滚动值,`scrollTop = x` 只是一个**请求**,不是结果;两侧各自动画到不同位置,而门把它们当成同一位置比较。 ## 0. byte-equal 的前提假设与失效条件 规则见 `determinism.md` §0。 **⚠ 还有一个熵面整个落在本文件射程之外:取样时刻。** 冻结协议管的是"**跑起来之后**的熵";"**什么时候算测完**"——网络到达顺序、媒体元数据解码完成顺序、字体就绪——九种协议一条也覆盖不到。实证【shopifydesign】:该项目 `?__probe` 冻了 rAF / `setTimeout` / `performance.now` / `Date.now` / `Math.random` / `setInterval`,采样脚本却仍是 `navigate → sleep(8000) → 读`,于是**同一个镜像连跑两次差 35 个字段**(8 秒内到齐的视频元数据子集不同,而布局是它的函数)。**settle 必须是页面状态判据,不能是墙钟**——判据、三条做法与"先让基准侧连跑两次"的自检见 `references/verification-gates.md` §2.2。 ## 1. 方法内核:枚举熵源,逐个消掉 规则见 `determinism.md` §1。 > **⚠ 这份清单是"输入侧的账",它不能代替"输出侧的账"【objectarchive】**:熵源表回答"**什么输入会变**",回答不了"**这一帧上到底有哪些面在上色**"(DOM 文本与背景、`<img>` 解码结果、`<canvas>` 位图、`<video>` 帧、SVG、CSS 生成内容与伪元素、滤镜与合成层)。**两张账都要有**——某道像素门的指纹漏记了 `<canvas>` 的位图尺寸与内容摘要,结果两个"领先假设"各自被证伪、真正的差异所在却没有任何字段在看它;**在补齐记录之前,所有归因都是在猜**。建账方法与实证见 `gate-failure-modes.md` §3.1.1。 ### 1.1 ⭐ 选冻结手段之前先问:这条熵源的运动由谁驱动【objectarchive】 规则见 `determinism.md` §1.1。 > **实证【objectarchive】**:一个 Shopify 站的像素门面对**九条**熵源,**只冻了第 1 条**(`Math.random` → mulberry32 定种,决定首屏底色)。核心判断正是本节第 2 步:**本站的运动主体是 CSS transition,而 rAF/timer 泵管不到合成器时钟**——泵下去只买到一半确定性,却要付全额盲区(Lenis 由 `gsap.ticker → rAF` 驱动,一泵就把整条滚动脊柱连同它下面**全部** ScrollTrigger 产物挪进 §2.10 的盲区)。其余八条按第 3 步改道:首访门当**状态**(`first`/`return` 各跑一整套检查点)、弹窗计时/滚动竞速**断机制不断读数**(断"首次可见帧必须已满足源站自己的 `ratio > 0.5`",墙钟只作派生判定 `popupTimerCouldNotHaveFired`)、四条 storage **清掉**并对有像素后果的两条各补一个状态、nonce 不进像素、IntersectionObserver 时序靠 settle 消化。 > > **代价核对(这才是这条判断成立的证据)**:唯一冻的那条**没有盲区**——它的下游只有一个 CSS 变量(写首屏底色,像素门逐帧看得见)和一个死变量(源站算完就没人读,登记为怪癖)。而单侧确定性照样成立:四次独立镜像会话 × 95 帧,60 帧四次逐字节全同,其余落进自比带宽(`verification-gates.md` §1.3.2 那条带宽正来自这里)。**九条冻一条,不是纪律打折,是判据的结果。** ### 2.2 `clock+raf` 规则见 `determinism.md` §2.2。 在 `clock` 之上把 rAF 时间戳也喂 0。用于**rAF 时间戳直接驱动**的持续动画(跑马灯、轨道环)。注意有的渲染器需要**双冻**:kimi 的星云是 rAF 时间戳驱动的累积器,单冻 clock 不够(M4.2——"断点笔记里的『下一步很简单』也是待验证断言"即出自此坑)【kimi】。 ### 2.6 媒体层补丁 规则见 `determinism.md` §2.6。 ⭐ **实测形态(samsy,WebGPU 视频墙)**:视频不走 JS 时钟,冻结页里它照播;且作品墙的 `<video>` 是 `document.createElement` 出来**不挂 DOM** 的,`querySelectorAll('video')` 找不到。做法:在 shim 之后 hook `Document.prototype.createElement` 记下每个 video;每次截图前 `pause()` + `currentTime = 0`、等齐 `seeked`(用 shim 暴露的 `__nativeSetTimeout` 兜底超时,页面的 `setTimeout` 已被泵接管)、再泵 2 帧让 VideoTexture 采到第 0 帧。works 视图跨侧 3.94 → 1.5–1.9,第一大残差就此消失。 ⛔ **多人房间不是任一侧的属性,而 `Network.setBlockedURLs` 挡不住 WebSocket 握手**:屏蔽了 `*partykit.dev*`,镜像侧 HUD 照样 "Connected: 2"——别人的替身进了参照帧。对握手生效的是 DNS 层:Chrome 启动旗标 `--host-resolver-rules=MAP <host> 127.0.0.1`,两侧同加,登记为仪器条件(§2.8 同等隐藏)。 ⭐ **活世界的带宽来自它自己的骰子,reseed 是归类实验不是调参**:NPC 随机游走、粒子 spawn、CRT 屏的随机内容全走 `Math.random`——shim 把它定种了,但两侧在到达同一状态前消耗的次数不同(three 双拷贝 / vendored 库各消耗一串),于是跨侧残差成片(samsy 战役 1:home 34 格、about 61 格)。在每个视图截图前两侧同时 `__reseed(n)`,残差格 34→1、61→1——这证明它们是**骰子相位**不是移植差异;而同侧自比带宽照旧(活世界的骰子在截图前已经掷过了),门的容差就是这个带宽 + 常数,不许因为看见了残差再去动。 **【lamalama】`autoplay` 属性绕过 `play()` 补丁(2026-09)**:首页整幅背景是 `<video autoplay loop muted playsinline preload="none" data-src=…>`(HLS,hls.js 挂 MSE),"THIS IS US" 缩略图也是。只补丁 `HTMLMediaElement.prototype.play`(假成功 + `pause()` + `currentTime=0`)时,同侧自比 8 次交错:0.34 / 0.18 / 1.84 / 1.84(镜像)、0.23 / 0.87 / 1.80 / 1.81(复刻)——帧普查从 4956 色跳到 5530 色,两帧都是播放中的网点噪声视频的不同相位;loader 两侧都在第 263 泵帧移除,说明 JS 世界已定,跳的是媒体时钟。改为 `--seed` 里加 `document.addEventListener(ev, e => stop(e.target), true)`(`play/playing/loadeddata/timeupdate` 捕获相)后:两侧各 4 次交错全部 0.00,帧普查 3030 色(视频钉在第 0 帧,画面是人像剪影而非噪声)。同一补丁先按 `--drive` 传:驱动器跑了、loader 同帧就绪,却退 6 "recorded no landing"——`--drive` 的合同是写 `window.__walkScroll` 落点(滚动驱动器专用),报错文与头注当时都没说,已补。 ### 2.9 能力探测钉死【shopifydesign】 规则见 `determinism.md` §2.9。 **实证**:shopify.design 的 `V3()`(L22703–L22745)跑一个**活体 GPU 微基准**——512×512 画布上执行 200 次 `sin` 的片元着色器、10 次计时绘制、返回 ms/draw——喂给分级器定出 high/medium/low 档。档位不是只切个开关: - fbm octave 数(3/2/0)与径向模糊 `#define SAMPLES`(12/8/6)被**字符串插值进 shader 源码** → 两侧 tier 不同 = **编译出字节不同的 shader**; - `dprCap`(1.5/1.2/1)改变**渲染分辨率**; - `photoSlices`(6/4/3)改变**几何数量**。 微基准是活体计时,**同一台机器两次运行都可能翻档**。两侧不锁同一档,比的不是同一个程序——这比 `performance.now()` 严重得多。 > **实证**【shopifydesign】:shopify.design 的活体基准 `z3`/`H3`(L22746–L22752)把 `"SwiftShader"` 直接判 **low**,而档位被字符串插值进 shader 源码。该项目 `shot-at-spread.mjs` 从 M2 起沿用这两个 flag,**此前所有对拍截图都跑在 low 档 shader 上,而其余所有门跑在 high 档**——两侧一致所以两个里程碑无人发现,但它一直不是"验收对象"那个程序(已登记为偏差 D16,M4a 删除 flag 后同机实测回到 `high`,与 shim 钉死值一致)。 ### 2.9.1 ⭐⭐ 泵的**时机**:冻结页仍在真实时间里启动【lusion】 规则见 `determinism.md` §2.9.1。 **实测形态**【lusion】:泵满 240 帧后页面仍是空的,三个 canvas 停在默认 **300×150**(常规页 1728×1080),而 `__pump` 本身完全正常(60 帧推进 1002 ms)。**跨侧对拍于是报 `meanAbsDiff 0`、三条路由全绿**——两张空帧的完美一致。 改法之后同一个引擎在约 **2.5 s 虚拟时间**内把 canvas 调到 1730×1082,冻结自比带宽 **0.00**(三次会话),跨侧残差**逐位可复现**。 ### 2.10 ⚠ 冻结的盲区:被冻分支上的子系统对门隐身【shopifydesign】 规则见 `determinism.md` §2.10。 **实证**:shopify.design 的 `R5`(DOM 标题揭示,L45024–L45071)在复刻侧**完全不存在**,跨越整个 M2 与半个 M3 无人发现。链条是:`R5` 挂在 `site-ready` 事件上 → `site-ready` 从一个 `requestAnimationFrame` 里派发(`KB` L44440–L44443)→ `probe-shim` 把 rAF 换成手动泵队列,而探针从不泵到那一帧 → **两侧都不执行 `R5`**。实测:`?__probe` 下两侧 `.wr` span 数都是 **0**;不冻结时两侧都是 **18**,且 innerHTML 逐字相同。场景图数值门(`references/verification-gates.md` §1.4.1)全程报 0 字段差异。最终抓到它的是**不冻结的截图对拍**——首屏 hero tagline 整行不见、标题没有逐词 span。 - 反向再 grep 一次这些信号的消费者(`addEventListener("<名>"`、`.then(`、读该标志的地方),得到的清单就是**这个源的下游入口**。本站是 rAF → `site-ready` → 4 个 effect。 > **实证**【shopifydesign】:M3 的清单写成"rAF → `site-ready` → 4 个 effect",看起来是全的;真实形状是 `UB`(**整个引擎的装配**)由 `bx`(双 rAF,L27–L29)调起——**冻结盲区不是几个 effect,是整个 WebGL 场景图**。M3 没发现,因为它落地的三样东西恰好**全都写 DOM**、被场景图数值门看得见。M4a 第一次落地"产物完全不写 DOM"的子系统(110 个挤出字形组 + 15 个 SDF mesh),**数值门对新增产物的覆盖率当场是 0,而它照样报 0 差异**。 - **(1) 期望值从"镜像基线 + 源站自己的计数规则"推导,绝不从被测方读。** 实证(`verify-scene-content.mjs`,18 条绝对断言):镜像的场景图基线 JSON 给出 31 个文字元素 / 15 个 `sdfOnly` / 1 个 countdown;源站 `sB` L42491 硬编码 2×10 位数字、`Sb` L42322 遇空格 `continue`。于是 `textMeshes` 期望值 = 15 条挤出标题的非空格字符数 90 + 20 = **110**。实测第一次跑就是 110——**"第一次跑出来就对"才是移植正确的证据;抄来的数跑出来必然对,什么也证明不了。** ## 3. probe-shim 双侧确定性驱动【noomo】 规则见 `determinism.md` §3。 **适用条件**:滚动驱动的 WebGL/动画站 + **源站是别人的混淆 bundle、不可插桩**。问题:浏览器后台标签 rAF/timer 节流使这类站不可确定性驱动,而你不能改源站代码。noomo 的结论:这套东西"对任何『滚动驱动动画站』的 A/B 对拍都直接可复用"。 **⚠ 这四项是 noomo 那个站的熵源清单,不是通用清单**【shopifydesign】: shopify.design 上,出厂 shim 冻的三样(rAF + `setTimeout` + visibility)之外,**四个未冻的源全在关键路径上**——`performance.now()`(下潜过渡直接用它算插值)、`Date.now()`(按 `Math.floor(Date.now()/18e4 % n)` 选曲)、`Math.random()`(favicon 洗牌 / 模型随机散布 / 每 27 秒倒计时回绕重掷抖动表)、`setInterval`(倒计时)。**实测未冻时同一镜像两次采样差 7 个字段**(场景图数值门,见 `references/verification-gates.md` §1.4.1);接管齐全后镜像自比 0 字段差异。反过来,本站**没有任何 `visibilitychange` 监听**——shim 冻得最起劲的那一项在这里完全是 no-op。 > **同一份清单还要走第二遍**:对每个确定要冻的项,按 §2.10 列出它下游的入口并逐个处置。本站 shim 冻 rAF 是对的,但没人问"rAF 里派发了什么"——答案是 `site-ready`,`R5` 就挂在上面,两个里程碑没被任何门看见。**覆盖面验收要验两件事:冻得够不够,以及冻掉之后谁看不见了。** > **实证【objectarchive】**:该项目两侧都由**同一个** `serve.mjs` 伺服(复刻侧只是多一个 `--side rebuild --fallback-root mirror`),此时服务层注入有两个问题:① 该站的图片 CDN 是**查询参数化的变换接口**,url→路径映射因此做成**查询感知**的,挂一个 `?__probe` 会改变"到底服务哪一个镜像文件"——**探针开关变成了内容开关**;② 两侧的注入点不再是同一处代码,"同位"只剩口头保证。改用 CDP 在任何页面脚本之前注入同一份字节(与本 skill `scripts/probe-shim.js` 同一条流),两侧同一条命令、同一份补丁,且**不跑门时被测字节一字未动**。 **结果**:源站原 bundle 可在后台标签被确定性驱动到任意 t,与复刻侧逐检查点同帧截图。复刻侧另暴露 `window.__sweet3` 引擎句柄(同样 `?__probe` 门控)支持数值探针【noomo】。同类做法:rogier 的 `window.__rogier*Probe` 接口 + `?debug-output-probe` query 开关,约 2500 行调试脚手架作为有意偏差登记保留("回归门依赖它")【rogier】。 ## 5. 无头驱动的通用旗标与手段清单 规则见 `determinism.md` §5。 - **anti-throttling 旗标必带**:`--disable-background-timer-throttling --disable-renderer-backgrounding`——后台标签 rAF 节流 + gsap `lagSmoothing` 会把启动链冻成假死。samsy 曾因此误判源码 bug 并错误"修复",取证后撤销【samsy】;oryzo(人肉盯屏不可靠,因此上无头回归)、noomo(M0 亲历)独立踩过同一坑【oryzo】【noomo】。 - **视口/窗口锁定**:量化对拍必须同视口(oryzo 1456×830、kimi 1440×900/390×844/768×1024、samsy 1280×800);文字块随窗口高度命中相邻组,"复检需锁窗口"【noomo】。 ## 6. ⛔ 像素门两侧必须同经 serve.mjs【darkroom】 规则见 `determinism.md` §6。 `serve.mjs` 只对**自己伺服**的 HTML 注入 probe-shim(`?__probe` 冻结时钟)。重建侧若直接跑 `next start`,它那一侧不冻结——镜像帧 BLANK、重建帧有画,自比带宽不可比,跨侧差异全是 "冻结不对称"制造的。解法:`tools/assemble-static.mjs` 把 `next build` 的 `.next/server/app/**.html` 摊成 `<route>/index.html`、`_next/static` 与 `public/*` 软链进去,用 `serve --side rebuild` 伺服——两侧同一份 shim、同一个 t(darkroom:自比带宽全 0,home/contact/developers/privacy 0.00)。 ⚠ 只供对拍;`?_rsc=` 软导航载荷不在静态树,sweep 仍跑 `next start` 拓扑。 ## 7. ⭐ 状态对齐协议:先对齐状态,再等时推进(`--ready` + `--after-ready N` + `--chunk N`)【darkroom】 规则见 `determinism.md` §7。 等"绝对泵数"(两侧都泵到第 240 帧)与等"状态相对时间"(两侧各自 READY 之后再泵 N 帧)差一个 **挂载相位**:单包重建的走马灯比镜像早 8–16 帧启动、`/work` 场景挂载相位不同——`/work` 在 180/210 泵差 1.8–2.5,在 60/90/120/240 泵为 0,周期性出现,这是相位噪声不是移植缺口。 ⛔ 而对齐的**分辨率 = 泵分块帧数**(默认 total/40 ≈ 6 帧):8–16 帧的相位差整个落在一个分块里, 钉不到同一帧。协议:`--ready <表达式>` 定义状态、`--chunk 1` 把分辨率提到 1 帧、`--after-ready N` 在两侧 READY 为真的那一帧之后各泵 N 帧再截图——/about、/work 两处 UNCLASSIFIED 残差由此归零 (0.00@+120/+240、0.00@+135/+165/+210)。⚠ `--self` 自比带宽要在同一协议下重建。 ### 7.1 到达与相位 **【lamalama】分块 5 让 hls.js 永远到不了 readyState 2(2026-09)**:巡航协议照搬首页协议只把 `--chunk 1` 改成 5 省时间,结果 5 档 × 2 侧全部 "never satisfied --ready within 900 pumped frames",而同判据在 `--chunk 1` 下 437/424 帧就绪、0.00。加了 `window.__why` 通道后一眼看到:`video rs=1 ll-part--video`——hero 视频卡在 HAVE_METADATA。同一 seed 还有一个自造的坑:`timeupdate → currentTime=0` 每次 seek 完成又触发 `timeupdate`,无限 seek 中 `readyState` 恒为 1;守卫成 `currentTime>0.001 && !seeking` 才 seek。 **【lamalama】陈旧的重复 seed 吃掉半天(2026-09)**:walk.sh 早期版本把 seed 放在 `SEED="…"` 变量里,后来改成行内 `--seed "…"`,两份都留在文件里;之后每次"修 seed"都只改到其中一份,探针脚本又用 `sed | head -1` 取到另一份。于是 25% 档(作品网格,8 个 HLS `webgl_video` 同框)无论怎么改都 "never satisfied",`__why` 里 `seeking@0.00` 无限循环、`sets=0 stops=0`——直到给 stop() 加计数器发现它根本没在跑,才回头看 seed 文本。真相是那份旧 seed 的 `if(readyState>0) currentTime=0`:HLS 首片 PTS 从 0.021 起,0 落在洞里,seek 永不完成,每个媒体事件再 seek 一次。清洁的 v6 seed(`currentTime` setter 把落在第一段缓冲之前的目标改到缓冲起点、同位不重复 seek;在播才 pause;停在缓冲外就搬进缓冲,每元素 ≤20 次;不谎报 `paused`——谎报会让 hls.js 的停滞检测去 nudge)下 25% 档自比两次 0.01。回哺:`protocol.env` 单一来源 + pixelcompare 开头打印 seed/ready/drive 指纹。 **【lamalama】/services/branding/ 自比 25% 档恒定 5.8、75% 档 24(2026-09-07)**:A 拍"NEXT SERVICE (+)" 折叠、GL 大图缺、页底照片带缺;B 拍全有。依次排除:`localStorage`(seed 清空,不变)、懒图 src 待命(判据加 lazy pending,不变)、把所有已开始的图算到达(跑马灯视口外懒图 `complete` 恒 false → 5 档全 never ready;改成 `naturalWidth>0||complete`)、`--after-ready` 120 → 600(75% 档归零,25% 档纹丝不动)。最后是缓存:同源两拍,B 热 A 冷,站点在 `img.complete` 上分支。pixelcompare 改为每拍冷缓存后 25% 档 0。整条路上每一步都是 `window.__why` 与指纹行让"改了什么、卡在谁"可见。 **【lamalama】/services/websites/ 自比 25% 档 3.68,冷缓存后仍在(2026-09-07)**:state-probe(同浏览器连拍两次、只带走位驱动不带走位 seed)两次状态全等;用 pixelcompare 逐字带上 pixel-walk 的走位 seed 才复现 3.7(worst 179.7,页底照片带);seed 前加 `history.scrollRestoration='manual'` → 0.00。机制:第二拍同 URL,Chrome 在 load 前恢复第一拍的落点 1639,站点 init 从 1639 起跑,照片带的 IO 立刻命中;第一拍从 0 起跑,驱动到 1639 时那个 IO 已经错过。跨侧两侧 URL 不同,没有恢复,所以跨侧 20 档全 0 而自比有一格。回哺:pixel-walk 走位 seed 第一句关闭 scroll restoration。 -
dom-shell-strategies.md 7.3 KB
# case-studies/dom-shell-strategies.md — DOM 层策略选型指南(A/B/C + 正交约束 D) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `dom-shell-strategies.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `dom-shell-strategies.md` 一一对应。 ## 2. 策略 A:零重写 shells(平台导出物)【lando】 规则见 `dom-shell-strategies.md` §2 步骤 3(防御按逐条下限写)。 - **弱形式为什么会失效**:只要站上有**一条高频变换**,"有变换发生"就恒为真,这道守卫在这类站上恒绿 = 等于没有。objectandarchive 的 5 条变换单次构建命中 **15 / 5 / 2,540 / 20 / 5**——URL 本地化一条就 **2,540 次**,它一个人就让总数非零永远成立,而**注入 noindex 的那条(5 次)悄悄失效不会有任何人发现**:产出的正是一批没有 noindex、可能还引用真实外域的坏 shells,恰恰是这道防御当初要防的东西【objectarchive】。 - ⭐⭐ **附加实证:这条为 A 目的立的门,抓到了 B 类问题**【objectarchive】。逐条下限本来只防一件事——"守卫因为某条高频变换而恒绿"。M0b 那一轮它报出 `T-WPM` 命中 **4 < 下限 5**,而真正的原因是**镜像被换掉了**:源站的 bot 挑战页顶替了 43 份真文档(含被构建的那几份),挑战页里根本没有那个平台脚本,所以命中数不够。**当时它是整条流水线上唯一的反对者**——`verify-mirror` 全程 PASS 0(账本 sha256 与挑战页完全吻合),五道下游门也都绿着(`mirroring.md` §5.1 实证二)。两条推论:① **交叉命中值得记,但不可依赖**——不要因为"上次它救了场"就不去补 `mirroring.md` §5.1「真实性」那道真正对口的门;② **命中数是文档形态的函数**,所以这条守卫顺带是一道"我构建的还是不是原来那份文档"的廉价断言:**下限跌了先查镜像,再查变换表**。 ## 4. 策略 C:框架内重建 + 字节对齐(框架编译产物) ### 4.4 CSS 层:双向 diff【rogier】 规则见 `dom-shell-strategies.md` §4.4。 - **反向扫描**:枚举重建侧**源 bundle 里没有的全部规则**(rogier:118 条 (media, selector)),逐条判定"必要机制 / 等价别名 / 多余发明"——rogier 揪出 3 条真发明(`.ui-header-bg` 桌面渐变、`.ui-work-a` 的 transform transition、移动端 text-shadow)并删除【rogier】。 - Tailwind 站的 grep 陷阱:产物可能走 server-inline 通道,grep .css 文件会误判 utility 是否存在【noomo】;noomo F2(大字偏小)根因就是 19 个 `text-sans/serif` `@utility` 整族缺失,从源站 CSS 逐条重建【noomo】。 ## 5. 策略 D:DOM 即场景图(DOM/CSS 是 3D 引擎的坐标源)【shopifydesign】 ### 5.2 取证判据(怎么认出自己遇到了策略 D) 规则见 `dom-shell-strategies.md` §5.2。 只做前者会漏掉后者——shopifydesign 的策略 D 结论出自逆向期的**静态观察**,第二问是竖切之后的**运行时观察**才补上的【shopifydesign】。 > **实证【shopifydesign】**:M2 把镜像 SSR 外壳原样端起来(只摘掉框架运行时、换上移植引擎),场景图数值门立刻红,且可逐个归因—— > - hero 三栏**贪心砌砖**(`z5` L45192–L45204,由 `H5` L45309–L45350 驱动,栏高权重 `1/aspect`),而输入 `aspect` 由 `<video onLoadedMetadata>` L45225–L45229 **异步回填**,每回填一次重排一次 → 24 张卡换栏(`worldX`/`worldZ` 变,尺寸/`depth`/`src` 不变)、`.hero-grid` 4078 vs 4175px、`docHeight` 13798 vs 13895、其后所有对象统一 **+97.078**; > - 倒计时舞台 `.countdown-stage-sticky > .manifesto` 的客户端定位 → `manifesto-*` **−3321.602**、`countdown-headline`/`cd-ring` **−1829.4**。 ### 5.3 三条推论(每条都改变工程决策) 规则见 `dom-shell-strategies.md` §5.3 推论 2(`readLayout()` 的副作用要连同还原顺序一起抄)。 **实测漏掉这一步:镜像与线上出现统一 158px 的 Z 偏移**——那正是被 `transform=""` 抹掉的入场位移。 ### 5.4 弱化形态:没有 3D 引擎,但块在运行时量矩形、据此写内联样式【objectarchive】 规则见 `dom-shell-strategies.md` §5.4。 **① 识别判据的命中实证:** > **实证【objectarchive】**:一个判 B 的 Shopify 站,指纹侦察明确"3D / WebGL:**无**"(`three` 弱匹配全部来自 `three_col` 类名,已证伪),但三块命中本形态——hero 分层轮播的 `calcBgScale()`(`max(panelW/cardW, panelH/cardH) × 1.15`,地板 1.7 → 写 `transform: scale()`)、PDP 画框合成器的 `getDisplaySize()`(读 `compositor.getBoundingClientRect()` → `refDim × 0.85 / max(fw,fh)` → 写画框台像素尺寸)、PDP 面板块(容器高 = 活动面板 `scrollHeight`、描述区 `max-height = lineHeight × 5`)。**站级判据说"不命中策略 D",块级约束照样在**——两件事要分开问。 **② 建门方法——实测量级,以及「同一个盒子三个口径」的正反两面:** - **实测量级**(同一批公式两侧都对得上;量本身就说明"1px 会显形"):**桌面 1728×1080**——画框台 **734px**、无框 **518px**(`refDim 864 × 0.60`)、面板容器**内联** **221px**(== 活动面板 `scrollHeight`)、描述区截断 **121.77px**(`lineHeight 24.35 × 5`);**移动 390×844**——画框台 **332px**、面板容器**内联 179px** 而**计算值 650px**(同一个盒子两个口径差 471px,原因见下条)、描述区截断 **108.24px**;房间视图小墙 / 大墙 **156 / 404px**(**这一组当初没记视口,按下条只能当形态证据读**); > **实证**:上面那块面板在 390×844 下**内联 179px、计算值 650px**——主题 CSS 在 `<750px` 用 `.pdp-card__panels { height: auto !important }` 把这套高度算术**整条盖掉**(同一段代码在桌面完全是活的)。门的第一版断计算值,**红在镜像侧**——按分诊表即"门错"(`verification-gates.md` §0.1),而它真正撞见的是这条 CSS;改断**内联产物**后两侧齐平,另记 `mobileStacked` / `computedFollowsInline` 两个派生判定。 > **反面实证(本节自己的旧记录)**:上一版这一行写的是"面板容器高 **221 / 564px**",既没写视口也没写口径,更没写它取自哪一个活动面板。下一个里程碑在钉死视口下复测:221 对上了,**564 一个口径都对不上**(桌面 221 / 移动内联 179 / 移动计算 650)——**无法判断是视口不同、口径不同还是活动面板不同,只能作废重测**。裸数字连"它错了"都证明不了。 ## 6. 常见坑(各策略通用) 规则见 `dom-shell-strategies.md` §6 坑 9。 ⭐⭐ **但下限管的是"这条变换还活着",不是"它达成了目的"——这两件事会分家。** 实证【objectarchive】:一条清除第三方标识符的变换 `T-IDENT` 跑满 **25 次**、过了下限、外壳字节门全绿,而它要清的那个 Storefront token **仍然躺在全部 5 份产出里**——同一个 token 还有第二种写法(`"accessToken":"…"` 之外还有 `<meta name="shopify-checkout-api-token">`),而规则清单是照着一份**散文描述**写的,从没人枚举过这一类。 -
environment-traps.md 8.7 KB
# case-studies/environment-traps.md — 环境陷阱手册 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `environment-traps.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `environment-traps.md` 一一对应。 ## 0. 总纪律:环境因素先取证再动代码 规则见 `environment-traps.md` §0。 反面案例(本手册存在的理由):samsy 项目曾把"启动链冻结假死"误判为源码 bug 并做了错误"修复",事后取证发现真凶是后台标签页 rAF 节流 + gsap `lagSmoothing`,撤销了那次修复【samsy】。**误判的代价不是浪费时间,而是引入一个偏离源站的假修复。** 配套惯例:探针超时要与产品缺陷显式区分——rogier 在真 GPU tier 3 机器上把探针等待调到 `PROBE_WAIT=25000/45000`,并明确标注 "probe timing, not a product mismatch"【rogier】。 ## 1. 陷阱:后台标签节流伪装站点假死(发生率最高) 规则见 `environment-traps.md` §1。 **三个项目独立踩过**: - oryzo:人肉盯屏验收不可靠,这是引入无头浏览器回归的直接起因【oryzo】; - samsy:误判为源码 bug、错误修复、取证后撤销(见 §0)【samsy】; - noomo:M0 镜像阶段亲历,后台标签 rAF/timer 节流使滚动驱动的 WebGL 站不可确定性驱动【noomo】。 ## 2. 陷阱:开发环境幽灵状态 规则见 `environment-traps.md` §2 第 6 条。 6. 框架挂载时序差也会造出假状态:samsy 记录过 Vue 挂载晚于引擎 IDLE 的时序差、以及 `window.camera` 被 ReflectorNode clone 覆盖成镜像相机——探针读全局句柄前先确认句柄归属【samsy】。 ## 3. 陷阱:部署拓扑差异竞态 规则见 `environment-traps.md` §3。 samsy M12 实例:部署后暴露一个源站永不触发的构造期纹理加载竞态,用 CDP Fetch 对单文件注入延迟做**二分定位**,锁定到两张纹理;根因判定为"部署拓扑差异(单源 vs CDN 分域)"而非代码,修复拆成"保真修正"与"登记偏差"两笔分开处理【samsy】。 ## 4. 陷阱:探针自身的盲区(绿灯不可全信) 规则见 `environment-traps.md` §4。 **探针的覆盖面本身是需要迭代的对象**——lando 的教训:镜像 CSS 被 Chrome 因 SRI 校验静默拦截,而**安全类报错走 CDP 的 `Log.entryAdded` 域**,探针只监听了 Runtime/Network,导致 M0.5 的 "CLEAN" 存在盲区;修复方式是给探针补上 Log 域监听【lando】。 `clip` 陷阱的实测:而如果站点的画布与页头都是 `position: fixed`,文档顶部那一屏里什么都没有——实测 **11 个滚动检查点返回 11 张全白 PNG**,肉眼看图才发现。 自检那一条的原文:- **自检**:**同一会话不同滚动位姿的截图哈希必须互异**(`gate-failure-modes.md` §1.2 的老规矩,正好也抓这个坑——11 张全白图的哈希全都一样)。 ## 5. 陷阱:headless 盲区——必须留真机/人眼兜底 规则见 `environment-traps.md` §5。 - **sRGB 色彩管理差异**:只有真机对比能暴露——oryzo 最后一轮真机对比在"噪声"里捞出真 bug:8 处纹理缺 sRGB→linear 解码导致整场景偏亮发灰【oryzo】; - **编码保真类问题自动门抓不到**:lando 收官后靠用户目视才发现头盔墙 7 张含空格文件名的图 404(vite 对 srcset 二次编码 `%20`→`%2520`),随后补了一次全站 URL×磁盘全量审计【lando】。 ## 6. 陷阱:检查点覆盖不足 规则见 `environment-traps.md` §6。 noomo 实例:探针检查点没测滚动终点 t=20,导致 HomeFooter 揭示动画**整段缺失**漏网,收官后靠用户直连源站对比才发现; 覆盖面下限的反证(`environment-traps.md` §6 指令原文):**这只是覆盖面的下限**——完整的枚举规则(位置**按内容分段** × **状态**两维取笛卡尔积)在 `verification-gates.md` §1.3.1:只覆盖两端仍会漏掉中段的整段内容(实证:85% 那一屏整段轮播缺失四个里程碑)。 ## 7. 陷阱:快门比被测运动慢——采样偏差伪装成"两侧相位不同"【shopifydesign】 规则见 `environment-traps.md` §7。 **实证链条(M4a,逐环都要看)**【shopifydesign】: `shot-at-spread.mjs` 从 M2 起带着 `--use-gl=swiftshader` → 软件渲染 1728×1080 WebGL → 单次 `captureScreenshot` 要 **1–2s** → 而 tile intro 全长只有 **2000ms** → "轮询到 `spreadT ≥ 0.30` 再截图"的快门落点完全由 CDP 往返决定 → 同样要 0.30,**镜像稳定抓到 0.31–0.34,复刻稳定抓到 0.43–0.56,连续三次可复现**。差点被写成"复刻侧动画更快"。 解法两步的实测数: 1. **先修快门**:删掉软件渲染 flag(本例删 `--use-gl=swiftshader --enable-unsafe-swiftshader`)后同机实测,单次 intro 可采 **22–28 帧**。 2. 实测 Δ 从 0.079(M3 的轮询法)收到 **0.005**,是三个里程碑里最紧的一次对齐。 ### 7.1 更强一档:把驱动的时间表也注入页面【shopifydesign】 规则见 `environment-traps.md` §7.1。 1. **直接对齐**:跨侧 Δ 相位 **0–14ms**(9 个检查点里 6 个 ≤ 2ms)。 2. **⭐ 顺带对齐了下游的派生锚点**:本站倒计时的相位锚点(`Ov` L30219)是"`digitsReady` 首次为真"那一帧,而 `digitsReady` 又是"滚到时钟那一屏"的函数。 - **时间表必须是真实的走查,不许"跳"到目标屏**:本站的 `digitsReady` 是一个闩锁,必须把倒计时区**整段滚过去**才置位(实测滚到 0.70 不置、0.75 置)。 ## 8. 陷阱:移动视口仿真的 `<meta viewport>` 布局切换【objectarchive】 规则见 `environment-traps.md` §8。 **实测**:移动档 390×844 的 collection 页被拍成**桌面三列**,8 次自比会话里 2 次命中;而**两次的 `innerWidth` 读数都是 390**(事后读),`screen.*` 也全程是 390——**"覆盖没落地"这个直觉是错的,覆盖一直是落地的**。 识别信号之一的坐标:同一条命令、同一个视口档,**分钟之隔跑出两种布局**(实证:`#product-grid` 高 12,763px 一列 / 2,261px 三列); 取证手段的收敛力:**它一次就能把候选机制砍到一种**——实测成功与失败的每一次 document-start 都是 980,于是"渲染进程换了""覆盖没落地"两个假设当场出局。 第二种用法的来历:上表是把抖动放在**取样之前**用;后来在同一站的块级运行时门上遇到了另一种场景——那道检查要求"在 <750px **解析**的文档上取样",于是最初写成 `resize(MOBILE)` → `sleep(600)` → 导航,实测 **3 轮全量普查红 1 轮**。 这条值得说一句它是怎么被抓住的——那道门的修法**第一版正是"重开直到窄分支"**,写的时候没去查本节,而本节两行之上就写着这个形态 0/6。 ⚠ **诚实边界(照抄这条边界的写法,不要照抄一个没验过的结论)**:上面这套"检测 + 抖动修复"在原项目里**只用 fixture 强制走过一次**(确认修复路径会执行、重采不污染好样本、断言仍在真实采样上判),而**竞态本身在随后 6 轮里一次没复现**(它是负载相关的)。所以"抖动能修好这道门里一份已排错版的文档"**在那道门上未被实证**; ## 9. 陷阱:驱动无限期挂起——**"挂住了"和"跑得慢"长得一模一样**【objectarchive】 规则见 `environment-traps.md` §9。 **实证**:一次块级普查在断网期间挂死,node 进程 **0% CPU、S 状态**卡在等 CDP 响应,**1 小时 16 分零输出**,而同一条命令正常单侧不到 7 分钟;**网络恢复后它也不会自己醒**——那次连接已经死了,没有人在超时。同一进程组里的 headless Chrome 还活着(一个渲染进程 2–4% CPU),所以从进程表看一切"正常"。 硬上限那一条援引的旧例:这与 v0.1.10 那条"大视口截图撞 WebSocket 载荷硬顶后无声超时 → 改为响亮失败"是同一族:**门可以红,可以慢,但不可以无声地不结束。** 挂死期间的数据有多脏:⛔ **挂死期间产生的任何数据都不可信**:本例网络恢复前后,同一道门的跨侧对拍出现了一条 §4.10 型的假红——**慢,会把两侧的采样时刻拉开到足以跨过某个状态边界**。 ## 9.6 陷阱:`npx <tool>` 是两层进程——杀 npx 留下 tool,端口从此有主【samsy】 规则见 `environment-traps.md` §9.6。 实测:2026-09-01 在 :5199 上发现一个 **2026-08-24 的 vite(8 天 18 小时,ppid 1)**,从 M14 那次"回归超时"的元凶开始一直伺服着一棵早就不存在的旧树——而后来的每一次"端口被占"都被当成了新问题。 -
gate-case-design.md 10.7 KB
# case-studies/gate-case-design.md — 用例设计与清单式核对 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `gate-case-design.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `gate-case-design.md` 一一对应。 ## 1. ⛔ 多用例的门,必须能证明它的用例彼此不同【airpodspro】 规则见 `gate-case-design.md` §1。 实测:给一个补间引擎写数值门,六个用例分别指定不同缓动,**六条全绿,而六条测的是同一条 linear 曲线**。原因是用例里写的字段名是猜的——`ease` 在这个引擎里是一个**数值权重**,曲线由 `easeFunction` 命名,值域是引擎内一张查找表。源码里写得很清楚,用例没抄它。 实测修好后:linear 给 `[0, 0.25, 0.5, 0.75, 1]`,easeInOutQuad 给 `[0, 0.125, 0.5, 0.875, 1]`。 ### 1.1 ⭐ 并排打印当场抓到两个"绿色的假通过" 规则见 `gate-case-design.md` §1.1。 上面那条判据真去执行之后(把每个用例的曲线采样与写入值并排打出来),**同一张表当场暴露两个问题,而它们此前都报 ok**: | 用例 | 曲线采样 | 写入值 | 真相 | |---|---|---|---| | `translate x` | `0,0.25,0.5,0.75,1` | **空** | transform 属性走另一条写通路,**探针读的位置根本没有值** | | `disabled when reduced-motion` | `0,0.25,0.5,0.75,1` | `0,0.5,1` | **与 linear 逐位相同**——禁用条件静默失效 | ⛔ 门当时报的是 **8 条全绿**。其中一条测的东西不存在,另一条读的位置是空的。 ### 1.2 ⛔ 声明式禁用条件要成对入例,且参数形状必须从源码抄 规则见 `gate-case-design.md` §1.2。 `disabledWhen: ["reduced-motion"]` 的判定是 `void 0 !== mask[name]`——**mask 是以 class 名为键的对象**,不是数组。传数组时 `mask[0]` 存在而 `mask["reduced-motion"]` 是 `undefined`,于是**每一条禁用条件都静默失效**,门照常全绿。 传对之后: ``` 默认 class → enabled:true, activeKeyframes:1, attributes:["opacity"] reduced-motion → enabled:false, activeKeyframes:0, attributes:[] ``` ## 2. ⭐⭐ 用例应该从源站的活引擎里**采**,而不是从你脑子里写【airpodspro】 规则见 `gate-case-design.md` §2。 手写用例编码的是**你相信引擎的参数是什么**。这在同一个目标上错过一次:六个用例、六个绿灯、全落在同一条曲线上(§1)。 ### 2.1 ⛔⛔ 找接缝之前不要断言"这个子系统没有接缝" 规则见 `gate-case-design.md` §2.1。 一个目标的镜像包没有模块注册表:唯一的三个全局里,一个是吃 DOM 节点的辅助函数,共享实例注册表在 0..30 每个主版本号上都返回空。看起来补间引擎无法从外部驱动。 ⭐ **接缝在 DOM 上**:引擎给每个参与元素挂了一个 expando(`el._animInfo = {group, controller, controllers, tweenProps}`)。它是**列一个元素的自有属性**时掉出来的。 ### 2.2 ⛔⛔ 枚举被测对象时,声明式属性通常**不是**索引 规则见 `gate-case-design.md` §2.2。 采集第一版按 `[data-anim-scroll-group]` 枚举——那个属性名就叫这个概念,看起来天经地义。它找到 **17 个元素、38 个关键帧、1 条曲线、0 个 mid-flight**。 走引擎自己的参与标记(那个 expando)则是 **254 个元素、798 个关键帧、3 条曲线、最多 76 个 mid-flight**。 ⛔ **按属性枚举漏掉了 95% 的被测对象,而当时所有检查依然全绿。** 属性给概念**命名**;引擎用来记"谁参与了"的那个东西才是**索引**。⚠ 同一页上 `data-anim-tween` 元素数是 **0**——引擎支持那条声明式路径,这个页面根本不走。**能被 grep 到的名字和实际在跑的机制是两件事。** ### 2.3 ⛔ 「有东西动了」不等于「我要测的那个量动了」 规则见 `gate-case-design.md` §2.3。 采集器自带一条防呆:所有状态记录完全相同就 FATAL。它**放行了一份关键帧数据全是死的基线**——9 个状态确实各不相同,但差异来自组进度在变,不是来自关键帧。 ⭐ **防呆必须盯住被测量本身。** 把摘要从"状态是否不同"改成"有多少关键帧处在 (0,1) 之间、出现了几条不同曲线"之后,它当场自己报出 `1 distinct curve, 0 kf mid-flight`——基线是死的,一眼可见。 ### 2.4 ⭐⭐ 按**行为**认身份,不要按名字——并且这样还能把名字找回来 规则见 `gate-case-design.md` §2.4。 这个 bundle 里每一个缓动函数的 `.name` 都是**空字符串**(匿名函数表达式)。按名字分组会把 342 个样本装进一个叫 `""` 的桶里。 ⭐ 更好的是:**按行为匹配把名字找回来了。** 移植侧的模块按可读键导出(`linear` / `easeInOutQuad` / `easeInOutCubic`),指纹一对上,就知道源页面实际在跑哪条曲线——**这是源页面自己说不出来的事实**。实测三条曲线分别被用了 4,240 / 2,502 / 288 次。 ## 3. ⛔⛔ 清单式核对能抓到逐像素全绿也看不见的整块遗漏【airpodspro】 规则见 `gate-case-design.md` §3。 一次整页移植做到**9 个滚动检查点、7 个不同画面、meanAbsDiff 全零**。随后的清单对账发现**移植少了两个模块**。 原因:分层表只把 `require("字面量")` 记成依赖边,而这个 bundle 里有一处**条件 require**——`require(t ? "c0e8c8…" : "2f0218…")`,用来按浏览器与选项选择视频播放器实现。两个分支都是字面量 id,但藏在三元表达式里,于是**没有产生任何入边**,两个目标被判成"没人 require 的死代码"。 ⛔ **那条分支在被驱动到的路径上不会走到,所以没有任何功能性证据会红。** 这就是 SKILL 里"功能测试测不出整块遗漏,只有清单式核对能"的实例——**逐像素 0.00 不是覆盖率的证明**。 修法:在 require 调用的**整个参数范围**里收集字面量,逐个对照真实 id 集合。效果:叶子模块 181 → 177(不止那一处),闭包 558 → 565 / 569。 ### 3.1 ⛔⛔ 一道检查必须报出它**查了多少**,否则它的沉默什么都不是【v0-optimus】 规则见 `gate-case-design.md` §3.1。 冷头清点在第二个模块容器形态上报告: ``` ok no call site resembles a require with a computed id ``` 而它**在 20 个模块上一个都没查**——它的签名判据只认 webpack 的 `function(m, e, r)`,Turbopack 的工厂是 `ctx => {…}`,于是每个模块都从 `continue` 掉了出去,循环里没有任何东西可看。**一道查了零个对象的检查,报出的是绿灯。** ⛔ **让它把覆盖率打印出来,并把覆盖率变成判据。** 第一版只在"恰好 0"时报错——结果它在**查了 1/20** 时照样报 `ok`,而扫了 1 个和扫了 0 个一样瞎,只是多了一个绿勾。判据要写成**"查到的比例够不够"**(实测阈值 80%),不是"有没有查"。 ### 3.2 ⛔⛔ 判据数东西的方式,必须和它审计的那个动作用同一个定义【eightdesign】 规则见 `gate-case-design.md` §3.2。 分层表的合理性判据是"文件里 require 形状的调用有多少,记到的依赖边有多少,比值太低就 FATAL"。它**挡住了一次完全正确的读取**:79 对 18,23%,刚好低于阈值。 ⛔ 毛病在于**两个数不是同一个定义下数出来的**。"require 形状"用的是宽松模式 `name(literal)`,而 `h("words")` 这类单参辅助调用也匹配;真正的 require 只有 18 个。**分子和分母来自两把不同的尺子,比值就没有意义。** ⭐ 改法不是调阈值,是换问题:**同一个模式下,有多少调用落在找到的容器之外**。两边同尺,比值才成立。改完之后:正确的 chunk 通过,而真正无容器的文件报出**更准的诊断**——"239 条 require 形状的调用**全部**落在模块之外"。 ## 4. ⛔ 逆向出来的引擎:「缺一个前置动作」和「移植错了」表象完全相同【airpodspro】 规则见 `gate-case-design.md` §4。 同一族失败在一次竖切里出现了**四次**,每次换一件外衣: | 症状 | 我以为 | 真相 | |---|---|---| | 引擎在动,补间值不变 | 移植错了 | `updateLocalProgress()` **只算进度**;写值的是 `reconcile(attr)` | | 第一步写对了,之后不动 | 状态被污染 | `onDOMWrite()` 出口处清掉 `needsWrite`;页面里由组的脏跟踪每帧置位,**探针自己推进了进度就得自己置位** | | 位置恒为 0 | 滚动没接上 | `getPosition()` 读 `pageMetrics.scrollY`,而**只有 `AnimSystem.onScroll()` 会写它** | | `100vh` 解析成 0(镜像侧 1080) | 视口单位没移植对 | `pageMetrics.windowHeight` 初值为 0,由 `onResizeImmediate()` 填;而它挂在 `initialize()` 装的 resize 监听器上。**探针从不 resize,就永远到不了那里** | ⭐ **四次的正确解法都是同一个动作:读源站真实调用点的调用序。** 第三次只用了三行——`onScroll` 的方法体写着"先写 `pageMetrics.scrollY`,再遍历 `scrollSystems`"。 ⭐ 第四次多一层教训:**前置动作可以由一个你没触发的浏览器事件间接持有。** `initialize()` 调过了,值还是 0,因为真正写值的是 resize 回调——而探针页从不 resize。查法是**从"谁写这个字段"倒推到"谁调它",一直推到某个真实事件为止**,中途停在 `initialize()` 就会得出"引擎移植错了"这个错误结论。同族的还有:某个字段只在 `IntersectionObserver` 回调、字体 `document.fonts.ready`、或首帧 `requestAnimationFrame` 里被填。**探针页要么触发那个事件,要么直接调回调本身,并在注释里写明它替代的是哪个事件。** ## 5. ⛔⛔ 跨侧门:两侧数值不同,先分清"条件差异"还是"移植差异"【airpodspro】 规则见 `gate-case-design.md` §5。 一道跨侧门(同一份输入喂给镜像和移植,比较输出)第一次跑就红,18 条里 11 条不一致。**其中 0 条是移植缺陷。** 三次红,三种非缺陷原因,每一种都值得单独防: **① 门把同一侧量了两遍。** 两个 CDP 探针并发启动,第二个接到了第一个拉起的浏览器。 **② 一侧缺前置动作**(见 §4 第四行)。跨侧门在这里格外有价值:单侧门看到 `100vh → 0` 只会以为这就是答案,**只有另一侧给出 1080,才知道有东西没跑**。 **③ 剩下的差异全部来自测量条件,不是被测对象。** 8 条含锚点绝对坐标(`a0t`/`a0b`)的表达式两侧不同,因为镜像是真实产品页(`docH=29556`),移植侧是探针页(`docH=1080`)。**页面高度不同,绝对坐标当然不同——这不是缺陷。** -
gate-failure-modes.md 24.7 KB
# case-studies/gate-failure-modes.md — 门的失效模式、根因修复与残差归类 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `gate-failure-modes.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `gate-failure-modes.md` 一一对应。 ## 1. 门的十种失效模式与防呆 规则见 `gate-failure-modes.md` §1。 前六条来自 kimi 对"门"本身的系统反思,全部有实锤事故【kimi】;1.7–1.10 由 shopifydesign 补上,1.11 由 objectarchive 补上(1.9 由 optimus、1.12 由 airpodspro、1.13 由 samsy、1.14 由 eightdesign、1.15 由 raycastkbd 补上)。 ### 1.1 门只断言想到的字段 规则见 `gate-failure-modes.md` §1.1。 - **事故**:`<main>` 只比 3 个固定字段,抓不到 shell 组件发明的源站没有的 DOM 属性;只测无斜杠形态,抓不到尾斜杠重定向链与源站相反(R1 审查 F2/F3)。 ### 1.2 门对"驱动步骤没生效"是盲的 - **事故**(M5.3):eclipse 位姿的驱动步骤悄悄失败,截的还是 hero 画面,门照样全绿。 ### 1.3 byte-equal 不证明"测的是想测的画面" - **事故**(M5.3):双侧一致地缺弧形文字,byte-equal 照样全绿——它只证明"复刻 == 镜像的这个截图",不证明截图里有该有的东西。 ### 1.4 门把录制巧合编码成规格 - **事故**(M3.5):"pixel-entry 首帧 anchor == 585px" 依赖源站的加载时序;复刻加载更快就假红。 ### 1.5 静止态门对过渡组件结构性失明 - **事故**(M7.5):只在场景过渡中出现的 ASCII 字母瀑布组件(模块 7868)从未移植,26 个静止位姿全绿。 ### 1.6 诊断工具与验收门混用一份代码 - **事故**(M7.3):门只需"确定性 + 双侧同函数",诊断需要"绝对正确";一份 PNG 解码代码同时服务两者,Chrome 截图是 colorType 2(三通道)而代码硬编码 `*4` 索引,画出一整轮几何假象——坏账藏在门的全绿里。 ### 1.7 冻结使子系统对门隐身【shopifydesign】 规则见 `gate-failure-modes.md` §1.7。 - **事故**(M3):`R5`(DOM 标题揭示,L45024–L45071)在复刻侧从未移植,跨越整个 M2 与半个 M3 无人发现。它挂在 `site-ready` 事件上,而 `site-ready` 从一个 `requestAnimationFrame` 里派发(`KB` L44440–L44443);probe-shim 把 rAF 换成手动泵队列、探针从不泵到那里 → **两侧都不执行 `R5`** → 场景图数值门 0 字段差异,稳定且对称地错着。实测:`?__probe` 下两侧 `.wr` span 数都是 0;不冻结时两侧都是 18 且 innerHTML 逐字相同。 - **防呆**: 2. **每个里程碑保留至少一条不冻结的对拍**(截图对拍 / DOM 结构断言)。本例正是靠不冻结截图抓到的:首屏 hero tagline 整行不见、标题没有逐词 span。**数值门与像素门不是替代关系**(`verification-gates.md` §1.4.1); ### 1.8 ⭐⭐ 全部的门拍在同一个状态里【shopifydesign】 规则见 `gate-failure-modes.md` §1.8。 - **事故**(M4c 之前):四道门——场景图数值门(双视口 **0 差异**)、CLEAN 探针门(双视口全零)、不冻结结构性抽查(**93 条绝对断言全过**)、全滚动量化像素门(18 个检查点、双视口、残差逐格归因)——**全部绿**,而站点**一半的状态零覆盖**。`oB`(引擎主绘制,L42668)在 `revealT === 0` 时清屏返回、只画时钟模型与胶囊;`revealT` 只有"下潜"才 > 0,而下潜的唯一入口 `qB` 没移植。于是 M4a/M4b 移植的 **21,996 行、六个子系统(文字 / 形状 / 轮播 / GLB / 透明视频 / 工作室浮层)从未被画出过一帧**,全部证据都是结构性断言。 - **每道门都按自己的定义正确运行**——这正是它可怕的地方:数值门读的是 DOM 排版,与 `revealT` 无关;像素门的 18 个检查点**全部拍在 `revealT === 0`**;冷头评审数的是符号,不是状态。**抓到它的不是任何一道门,是有人读了 `oB` 的第一行。** - **防呆**: 1. **建门之前先枚举状态**(`verification-gates.md` §1.3.1 的五步),回答"这个站有几个互斥的全局状态?我的门覆盖了几个?"——本站是两个(静止 / 下潜),M4c 之前覆盖 1 个; 2. **没有门的状态 = 该状态下全部移植代码的证据强度为零**,无论其他门多绿。这句话要**逐个里程碑写在报告里**,直到那个状态建起门为止(本项目在 M(n-1) 日志里挂了整整一个里程碑,M4c 关账时才销账); 3. **覆盖不到的就登记,不要强行宣称**——M4c 六个子系统里工作室浮层仍未覆盖:`oL`(L28394)把根节点停在隐藏态,只有 `studioGalleryMode` 才揭示,而这道门没有那个驱动。项目把它写成"本轮唯一的开口残留",而不是把"5 个拿到像素"说成"六个子系统已验证"。**登记一条开口的成本是一行字;宣称覆盖的代价是下一个里程碑替你发现。** #### 1.8.1 ⛔⛔ 检查点驱动**静默失效**:页面在自己的 init 里把滚动重置了【landonorris】【airpodspro】 规则见 `gate-failure-modes.md` §1.8.1。 实测两处,同一个根因: - 【landonorris】8 个桌面检查点跨两个页面**拍的全是页顶**——两页在 `init` 里重置滚动,**`load` 时发出的 `scrollTo` 被吃掉了**。 - 【airpodspro】9 个检查点里 3 个色数完全相同(751)。我当时的解释是"页面滚到底了",**而那是一个没验证过的猜测**:补上第二次滚动后,这三格色数变成 2337 / 3469 / 1502,**9 格各不相同**。那份"9/9 全绿"实际只覆盖了 7 个状态。 ⛔ 最后一条纪律,也是我自己犯的:**给一个退化的测量编一个合理解释,不等于验证了它。** "页面滚到底了"听起来完全说得通,代价是两个真实状态从未被比较过。 #### 1.8.2 ⛔⛔ 文档不一定是那个会滚的东西【eightdesign】 规则见 `gate-failure-modes.md` §1.8.2。 巡航门的种子算 `documentElement.scrollHeight - innerHeight`,再按比例 `scrollTo`。在一个用平滑滚动库的站上,这个差值是 **0**——页面滚的是一个内层 `overflow-y: auto` 的容器(实测 `scrollHeight 29846 / clientHeight 1080`)。 ⛔ 于是每个检查点都算出 `0 × f = 0`,**五个检查点、五次抓拍、同一个位置**,而且残差全部落在带宽内——**看起来完全像一次成功的巡航**。 ⭐ 只有**重复帧报告**看见了它:「3 distinct frames across 5 checkpoints,walk-025 = walk-075 = walk-100」。⚠ 这道判据是上一轮刚从另一个项目回填的,回填后第一次真实使用就抓到了一个全新的失效模式。 #### 1.8.3 ⭐⭐ 门必须报出**它在哪里测的**——这一行是归类的前提【eightdesign】 规则见 `gate-failure-modes.md` §1.8.3。 实测:滚动检查点上跨侧 11–20,我先判为 UNCLASSIFIED(那时是对的)。加一行"measured at"之后立刻看清: ``` [pixel] measured at — A: DIV 5327/21308 (target 5327) B: DIV 5327/21308 (target 5327) ``` 两侧落点**逐字相同**。于是残差不再是位置问题,而是必须用**同一位置的同侧对照**去归类的候选真差异: | 检查点 | 跨侧 | **同侧对照(同一位置)** | 归类 | |---|---:|---:|---| | 25% | 11.66 | **19.65** | 同侧更大 → 不可归因于移植 | | 50% | 11.46 | **14.13** | 同侧更大 → 不可归因于移植 | ⭐ 两条纪律合起来才成立:**① 报出测量位置;② 同侧对照必须与跨侧测在同一位置。** 早前用"首屏带宽"去衡量"滚动位置残差"是拿错了尺子——同一个站上,首屏带宽约 1.0,而 25% 处是 19.65。**带宽是逐检查点的,不是全站一个数。** #### 1.8.4 ⛔ 驱动器要匹配站点的输入通道:scrollTop 开不动 wheel 站【jiouhe】 规则见 `gate-failure-modes.md` §1.8.4。 走查驱动默认改 scrollTop/scrollTo——而一个滚轮驱动的站(animator 监听 wheel 事件, 文档本身不滚)对它完全无感:八档走查、逐档"通过",实际一帧都没推进,画廊懒加载 整层没被驱动到,0/0/0 报在一个从未开动的体验上。人手一滚,404 满屏。 ### 1.9 ⛔⛔ 链条上**只要有一步没有 `--check`,整条链的绿灯就可能是过期的**【optimus】 规则见 `gate-failure-modes.md` §1.9。 这条流水线上每个生成器都有 `--check`(切片器重切须字节一致、外壳构建可复现),**唯独打包那一步没有**。 ⛔ **时间戳不是判据。** 实测:`dist/site.js` 比 `src/` 旧一天,看起来就是过期,**而它不是**——重新构建出来的字节完全相同(那次 `src/` 的改动是删掉一个没人 import 的文件,够不到产物)。我从 mtime 推出的"过期"是错的。⚠ 错在无害的方向纯属运气:**同一个推理完全可以错在另一个方向**,而那一次就没人会发现。 ### 1.10 ⭐⭐ 门把差异所在的区域扣掉了【shopifydesign】 规则见 `gate-failure-modes.md` §1.10。 - **事故**(M4b):对拍报告里有一句很漂亮的话——**"扣掉时钟指针扫过的右下象限后 meanAbsDiff 0.000 —— 逐像素相同"**,并把指针的差异归因为"墙钟驱动的活体动画(`FL` L30329 的阻尼跟随)"。**真相是:镜像的 DOM 指针在转,复刻的 DOM 指针根本不转**——`eG`(L45717–L45743)里那句 `it.subscribe → hand.style.transform` 从未被转写。**那个被扣掉的象限里装的不是噪声,是当时项目唯一的真 bug。** 该 effect 落地后,同一位置 meanAbsDiff **0.89 → 0.01**(邻近检查点 2.64 → 0.00、2.69 → 0.01),**不需要扣任何象限**。 ### 1.11 ⭐ 记录量里混进了时钟:门把一个会流动的量当成了状态量【objectarchive】 规则见 `gate-failure-modes.md` §1.11。 - **事故**(同一形态在两个里程碑里出现 **4 次**,全部**先绿后红**——单侧跑全绿,跨侧对拍才揪出来): | # | 被记进产物的字段 | 两侧读数 | 它为什么在流动 | |---|---|---|---| | 1 | `elapsed` / `pollGaps`(兜底轮询的墙钟毫秒) | 19,222 vs 19,224 ms | 记的就是墙钟本身 | | 2 | `settledScrollHeight`(手风琴展开后的高度) | 149 vs 148 px | 过渡没跑完,量到的是**过渡中**的布局 | | 3 | `strayMove` 的 `scrollLeft` 绝对值 | 8 vs 5 px | 轮播带 `scroll-behavior: smooth`,`scrollLeft = 0` 是一段**动画**,读到的是飞行中的值 | | 4 | `transitionend` 里当场重量的 `toPanel.scrollHeight` | 写下 594,安静后 **221** px | **源站自己**写的飞行值(登记为怪癖),靠另一个 `ResizeObserver` 兜底纠正 | | 5 | `loaderHosts`(`#oa-loader` 元素个数) | 1 vs **0** | 源站入场动画的 `cleanup()` 在 `+LIFT_MS` 把这个元素**从文档里移除**——1 是"还没清理",0 是"清理完了",**个数本身就是一只时钟** | - ⭐ **第 5 例带来三条新的东西,都不是"再来一次"**: 1. **同一个字段,可以在同一道门里一半防住、一半没防。** 该块的**状态维**早就写着"loader 可能已经走了,那是这次运行的属性,所以断言的是**终态**"——作者当时就认出了这只时钟;而**位置维**照记原始个数。两个维度共用一个字段名,防御只做在其中一个上。**写门时逐字段问一句:这个字段在这道门的另一个维度里,是不是已经有人防过了?** 有 → 照抄那条防御,别重新发明。 2. **它可以只记不断,纯靠对拍才现形。** 这个字段**从头到尾没有出现在任何 `check()` 里**——它只被写进产物、只喂给差分器。所以"这个字段有断言在盯着"这个直觉是错的,**一个只记录不断言的流动量,唯一的作用就是制造假红**。收口动作:`record` 出去的每个字段,要么有断言用它,要么说明白为什么它值得被记(证据/归因)。 3. ⚠ **它可以绿很多个里程碑,因为两侧通常一起落在边界的同一侧。** 本例的显影剂是**一次断网**——页面加载变慢,两侧采样时刻被拉开,边界被跨过去。**"它一直是绿的"不构成"它不是流动量"的证据**:这类字段的红是**条件性**的,条件是环境抖动,而环境抖动不听你安排。 - ⭐ **派生判定去哪里找:读源站的代码,找它自己保证的原子不变式。** 本例的 `cleanup()` 在**同一个同步函数**里做完三件事——加 `.is-done`、`removeChild`、恢复 `body.style.overflow`——所以"loader 在不在"与"滚动锁没锁"**永远不可能被观测到不一致**。记这个不变量(并且**真的断言它**),时钟就出局了,而覆盖面比原来那个数字更强。**先读源码再设计记录字段**,比先记数字再想怎么容忍它便宜得多。 ### 1.12 ⛔⛔ 工具链把结果**截断**了,而截断出来的东西看起来是合法的【airpodspro】 规则见 `gate-failure-modes.md` §1.12。 一次探针返回的 JSON 在 **65,536 字节整**处被砍断。根因不在 CDP,也不在门里:**`process.exit()` 会丢掉尚未 flush 的 stdout**,而管道 stdout 是异步的,一次超过管道缓冲区的 `console.log` 就这样被切在 64 KiB。 **判据(每个会打印大结果的工具都该有一次)**:喂一个**已知长度**的载荷,量回来的字节数。实测 60,000 → 60,009、65,000 → 65,009、70,000 → **65,537**(封顶)。一次三行的测量,抵得上事后对着"为什么这份数据看着不完整"的所有猜测。 ### 1.13 ⛔⛔ 门订阅的 CDP 域不覆盖它声称的断言面【samsy】 规则见 `gate-failure-modes.md` §1.13。 一道自研启动门写着"零 404 / 零控制台错误",跑了十四个里程碑全绿。它订阅的只有 `Runtime.exceptionThrown` 与 `Runtime.consoleAPICalled(error)`——**没有 `Network.enable`,没有 `Log.enable`**。于是:本地资产 404 不可见(浏览器把它记在 Log 域)、`loadingFailed` 不可见、外联不可见;而为了压掉 PartyKit 断线的噪声加的过滤 `/net::|Failed to fetch/` 把它本来还能撞见的那一点回声也吞了。**三项断言只做了 1/3,且是最不会红的那 1/3。** 判据:一道门在**它订阅的事件面**之外的任何断言都是空话——把"我断言什么"和"我订阅了什么域"并排写出来,对不上就是假绿。CLEAN 门最少要开 `Runtime` + `Log` + `Network` 三个域(skill `probe.mjs` 的血统注释里 landonorris 那条 Log 域教训是同一课的前半句)。补上之后同一套页面首跑就报出两件事:请求面 0 失败(真干净)与**外联主机普查**(typekit / gtag / partykit 三族,全部在 `external.txt` 登记为 LINK 才放行)。 ### 1.14 ⛔ 把 class 1 修绿而 class 4 仍在,是化妆 规则见 `gate-failure-modes.md` §1.14。 删掉那条 `<link rel=preload href="…googletagmanager…">` 之后,`verify-offline` 的 class 1 变绿了。 **而真正的外联还在**:载荷里有个 `next/script` 描述符,水合时会注入 `<script src="https://www.googletagmanager.com/gtag/js?id=…">`,再由它拉起 `analytics.google.com` 与 `stats.g.doubleclick.net`。探针的 `external requests` 一直在报这三条。 修法是 `stubExtHosts`:构建把 URL 本地化为 `/ext/<host>/…`,服务器以空 JS 桩应答, 页面照常水合。结果:`external requests (0)`,RESULT CLEAN。 ### 1.15 ⛔ 切片交付的 token 门要对整个文件,容器外的字节也是原件【raycastkbd】 规则见 `gate-failure-modes.md` §1.15。 `verify-tokens` 对 61 个逐字切片 chunk **0/61 红**,每对恒差 87 token、首分歧在第 0 个 token—— 不是切错了模块,是切片器只带走容器(`push([...])`),把每个 chunk 开头 285 B 的 Sentry `_debugIds` 注册前奏与结尾的 `//# debugId` 尾注丢了。丢弃没登记,门也没法说"其实等价"。 两条纪律:① **切片器的单位是 chunk 文件,不是容器**——容器外字节逐字带走,gen 头写明 prologue/epilogue 字符数;② token 门**不加"对齐到容器"旗标**,红就是红——在门里给生产者开 豁免口,等于让门测自己的捷径(`verification-gates.md` §2.1.2 同族)。修在 `slice-modules`(v0.3.15)后 61/61 直接绿。 ## 2. 根因修复而非调参糊平 规则见 `gate-failure-modes.md` §2。 - noomo F1(全屏竖纹)追到流体求解器 GLSL 版本默认值与源站不符(源站默认 GLSL1、复刻误设 GLSL3 → 全部 shader 编译失败 → 空场 → NaN),一行修复级联解决三个表观 bug【noomo】。 - noomo F2(大字偏小)追到 19 个 `text-sans/serif` Tailwind `@utility` 整族缺失,从源站 CSS 逐条重建【noomo】。 - noomo F3 用 dev.json 基准逐项核对 8 个弹簧值**证明参数链无罪后**才定性为弱视觉差入偏差表【noomo】。 ## 3. 真差异与方法学噪声的归类纪律 规则见 `gate-failure-modes.md` §3。 - **正因为归类了噪声,才能在噪声里捞出真 bug**:oryzo 最后一轮真机对比在"噪声"里抓出 8 处纹理缺 sRGB→linear 解码导致整场景偏亮发灰【oryzo】。 - **自动门之外必须保留人工目视/真机兜底**:headless 盲区(授权字体、sRGB 色彩管理)只有真机对比能暴露【oryzo】;编码保真问题(vite 把 srcset 的 %20 二次编码成 %2520 导致 7 图 404)是用户目视抓到的,事后补"全站 URL×磁盘全量审计"【lando】;滚动终点未覆盖(HomeFooter 整段缺失)也是人眼抓到的【noomo】。 ### 3.1 残差分类器四件套:不掩蔽、逐格分类、排名、同侧对照【shopifydesign】【objectarchive】 #### 3.1.1 ⚠ 分类之前先修仪器,顺序不能反【objectarchive】 规则见 `gate-failure-modes.md` §3.1.1。 本节实证里的 `.pxl-done` 之所以留在记录里,是因为它是块自己的真实可观测产物(淡入完成的卡片数),没有等价替代——**先过 §1.11 那一关,过不了的才轮到修仪器**。 **实证【objectarchive】**:块级门里 `.pxl-done`(淡入完成的卡片数)逐检查点跨侧 `8 vs 7`。该量比它的触发晚 `HOLD 900 + FADE 1350 + 50 = 2,300 ms`;加一条"等这个量不再变"再采样,多数检查点当场归零。**修完仪器仍余两格,才做归因**:4 次镜像自比 × 逐检查点排名给出**30 对自比里 26 对差得比跨侧还多**(自比最大 |Δ| = 3 > 跨侧最大 |Δ| = 2),再加上"差异方向在 cp1 与 cp3 之间翻转",才把它定性为**属于这一次运行、不属于哪一侧**。**当场按分类器归档的话,偏差表里会多出一条其实不存在的残差。** **实证**:某像素门 8 次自比会话里 **2 次红**——移动视口下 collection 页的砌砖列数是 3,而块自己的判据说应该是 1。第一版修法基于"渲染进程换了、视口覆盖重新落地在 parse 之后"这个**从没量过的**假设,做法是"在文档自己的进程里重载一次"。重跑 8 次:**2/8 红 → 5/8 红**。**修法把问题放大了一倍多,而它看起来完全讲得通。** 停下来量,不再猜:加一个 **document-start 探针**(`Page.addScriptToEvaluateOnNewDocument`)记录 `window.innerWidth`——**成功与失败的每一次都是 980**,即"覆盖没落地"这个假设从一开始就是错的(`screen.*` 全程是 390)。真机制是 `<meta viewport>` 的布局切换,**一次探针把三种候选机制砍到一种**,随后的修法(用宽度抖动驱动页面自己的 resize 重建)实测 5/5(完整识别信号与三种补救的实测对比见 `environment-traps.md` §8)。 **纪律三条**: 2. **探针要落在被怀疑的那个时刻**,不是事后。上例的关键是 **document-start**:事后(load 之后)去读 `innerWidth` 一律是 390,什么都看不出来,因为要查的正是"块跑的那一刻它是多少"。 3. **仪器改动同样要重跑整组会话验收**,而且看的是**红的次数**而不是"这次绿了"。上例第一版修法如果只跑一次而恰好绿,会被当成修好了写进日志——**8 次里 5 次红的那个版本,单跑一次有 3/8 的概率看起来是对的**。 - **实证【objectarchive】(缺一个面会怎样)**:像素门第一轮的指纹记了两类面——文档几何与图片身份——**漏了第三类:`.pxl` 占位卡的 `<canvas>`**(而它恰恰是那两份文档上最主要的上色面,28 张卡各自盖在画作上 2.3 秒)。于是五个检查点跨侧不一致时,两个领先假设(亚像素滚动偏移、`?width=` 变体选择)**各自有判据、各自被证伪**,真正的差异所在**从头到尾没有任何字段在看它**。补上画布三问(`cw`/`ch` + 内容摘要 `dig`)之后一眼可见: ``` mirror {"cw":117,"ch":142, …} rebuild {"cw":351,"ch":425, …} ``` 同一张 351 px 宽的卡,两侧的**位图**差三倍(源站在 `drawImg.onload` 里按 `wrap.offsetWidth` 设位图尺寸,量到的是那一刻的布局)。根因随即定位到构建层一条变换丢了 `<script>` 的 `async`/`defer`——非阻塞脚本变成解析阻塞脚本,整份文档的加载序不同(`dom-shell-strategies.md` §2 步骤 3)。**与两个领先假设都无关。** - **代价对照**:在补齐这个字段之前,那五条残差的每一种"处置"都是错的——扣区域(§1.10)、事后调宽带宽(`verification-gates.md` §1.3.2)、写成"在带内"(§3.1 (C))各自都能让报告变绿,**而缺的那一格里装着项目当时唯一的真 bug**。补齐并修好之后,五个检查点的 `meanAbsDiff` 全部为 **0.00**,其中三个是逐像素相同。**在记录补齐之前,所有归因都是在猜。** 四个分类器的实测列【shopifydesign】: | 分类器 | 判定动作 | 成本 | 实测【shopifydesign】 | |---|---|---|---| | **(A) 它落在活体元素的矩形里吗** | 逐格与矩形求交 | 最低 | 桌面 cp1/cp2 的 **219 / 94** 个超阈格 **100% 落在 `<video>` 矩形内** | | **(B) 参照侧自比时这一格也超阈吗** | 把自比带宽(`verification-gates.md` §1.3.2)从**标量**降到**格** | 中(要自比会话的逐格产物) | 桌面 13 格、移动 104 格归此类 | | **(C) ⭐ 跨侧数字在参照侧自比分布里排第几** | 排序;粒度是**检查点级**(不逐格),它是给 (A)/(B) 之外那批未归类格撑腰的独立证据 | 最低(自比样本已经有了) | 移动端**每个检查点都至少有 1 次镜像自比比跨侧更差,cp0 是 5 次全部更差** | | **(D) ⭐ 同侧对照:让被测侧自己跟自己跑一次**(§3.1.2)【objectarchive】 | 把 (C) 的角色调过来:在**被测侧**重跑同一会话,比较 `rebuild↔rebuild` 与 `mirror↔rebuild` 在同一检查点上的大小 | 中(要新跑一次被测侧会话,只跑相关档) | `vendor/desktop cp2`:跨侧 **0.33**,同侧 **0.73**——同一侧自己跟自己差得更多 | **未归类的格不是失败,是必须逐个看的清单**——逐格打印坐标,一格不扣。实证:移动端 237 个超阈格里 **82 格** (A)/(B)/(C) 都不认,它们落在高光精灵与漂浮胶囊上;两者的相位原点都是**引擎构建时刻**,而两侧构建时刻差的正是已登记的那 ~575ms 启动偏移。**撑住这条归因的是 (C) 的排名,不是阈值。** #### 3.1.2 ⭐ 第四分类器:同侧对照——参照侧的带宽可能系统性地更幸运【objectarchive】 规则见 `gate-failure-modes.md` §3.1.2。 - 本项目实测五条残差(`vendor/desktop cp0/cp1/cp2`、`vendor/mobile cp1`、`home/desktop cp1`,跨侧 `meanAbsDiff` 0.18–0.79): | 检查点 | mirror ↔ rebuild | **rebuild ↔ rebuild** | |---|---|---| | vendor/desktop cp0 | 0.18 | 0.18 | | vendor/desktop cp1 | 0.49 | 0.63 | | **vendor/desktop cp2** | **0.33** | **0.73** | | vendor/mobile cp1 | 0.45 | 0.45 | | home/desktop cp1 | 0.79 | 0.53 | 据此把五条**判为"属于这一次运行"**,**当轮容差一个字没动**(§1.3.2 纪律 2:容差先于跨侧数字固化,事后不许调宽)。带宽的方法学缺口(只建在参照侧)写成下一轮开工第一件事——**改协议要发生在新数字之前**(§1.3.2 纪律 5)。 - **⚠ 读法:判定靠的是"同一条机制 + 整组的分布",不是每一条不等式都成立。** 上表五条里三条同侧 ≥ 跨侧(含 cp2 的 0.33 vs **0.73**)、一条相等、**一条(`home/desktop cp1`)同侧反而更小(0.79 vs 0.53)**。**同侧明显小于跨侧的那一条不许被组内其它条目带过去**——它要么单独拿到自己的机制归因,要么按"未归类"逐格列出(§3.1 末段)。**把一组数字当成一个结论用,正是 §1.10 那类事故的开头。** -
legal-and-deploy.md 11.7 KB
# case-studies/legal-and-deploy.md — 版权取证与部署决断(取证归 skill,决定归用户) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `legal-and-deploy.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `legal-and-deploy.md` 一一对应。 ## 0. 三条框架原则(先读这段,它管住本文件其余全部内容) ### 0.2 原则二(防火墙):法务考量**永不削减**镜像完整性与门的覆盖面 规则见 `legal-and-deploy.md` §0.2。 **实证(本项目亲历)**【objectarchive】:agent 曾在 DEPLOY.md 里以"产出永不公开"为由,对一类资产写下"**登记,不补抓**",镜像因此留洞。用户推翻后全量补抓,镜像从 **1,591 文件 / 587 MB** 涨到 **4,131 文件 / 1.4 GB**——**此前缺了约 60% 的资产,而五道门始终全绿**。法务理由挖穿了技术底线,代价是整条验收链在残缺的参照侧上跑了**四个里程碑**才被发现。 ### 0.3 原则三:站点策略文件**逐路径判定**,"读不懂"不等于"禁止" #### 0.3.1 Step 0 顺手取一份,留证 规则见 `legal-and-deploy.md` §0.3.1。 - ⛔ **取 CDN robots 是为了逐路径判定,不是为了找借口不抓资产**:实测 `cdn.shopify.com/robots.txt` 只有 `Disallow: /wpm/*.js` 与一条 utm 脚本模式两行,**其余全部资产路径无规则匹配 → 允许**(0.3.2 第 5 步)【objectarchive】。 #### 0.3.2 `robots.txt` 判定步骤(逐 URL 执行,五步) 规则见 `legal-and-deploy.md` §0.3.2。 ⭐ **实证:把目标站的 robots 逐条读完,净效果是"明确允许抓取"**【objectarchive】 | 规则原文 | 命中什么 | 对 M0 镜像的实际影响 | |---|---|---| | `User-agent: *` + `Allow: /` | 全站默认允许 | 产品/合集/页面/博客/政策 HTML 与 `/cdn/shop/**` 资产**明确可抓**。注释头亲口写着 "Public product, collection, page, blog, policy, cart, and localized HTML **is crawlable**" | | `Disallow: /cart/`、`/checkout`、`/checkouts/`、`/orders`、`/admin` | 交易与后台路径 | 这几个 URL 不抓,**与其余站点无关** | | `Allow: /account/login` + `Disallow: /account` | 最长匹配演示 | `/account/login` **允许**(更长的 Allow 胜),`/account/其它` 禁止——顺序在前的 Allow 不是因为"在前"才赢 | | `Disallow: /cdn/wpm/*.js` | 单个**资产**路径 | **唯一真正削到镜像的一条**:按 `DISALLOWED` 登记 + 服务层 stub(见 §1 问 3) | | `Disallow: /collections/*sort_by*`、`/*?*preview_theme_id=*` | 排序/筛选/预览爬取陷阱(**匹配 query**) | BFS 本来就该收敛掉的重复 URL | | 注释 "Checkouts are for humans. Do NOT complete checkout, payment, or order placement automatically…" | **交易类禁令**,不是抓取禁令 | 与镜像无关,见 0.3.3 B 类 | **结论**:该站禁止的是"替人付钱",允许的是抓取。读成"禁止自动化,停工"是误读。 #### 0.3.4 agent 策略文件(`agents.md` / `.well-known` / `llms.txt`)怎么读 规则见 `legal-and-deploy.md` §0.3.4。 - 实证【objectarchive】:目标站 `/agents.md` 通篇是代购流程——UCP/MCP 端点、`create_checkout`、"Checkout requires human approval"、推荐装 `shop.app/SKILL.md`;**对抓取与学习性复刻只字未提**;反而专列一节 "Read-Only Browsing (No Authentication Required)" 点名 `/products/{handle}`、`/collections/{handle}`、`/sitemap.xml` 可读。`/.well-known/ucp` 则是一份 JSON 商务能力声明(版本、端点、支付处理器),**与抓取毫无关系**。 - 该文件写着 "you should **prefer** the Shop skill over screen-scraping or scripting the storefront directly"——这句的上下文是**代买 agent 如何交易**,不是禁止读取;且那些端点面向交易,**根本取不到复刻所需的 HTML/JS/CSS/资产字节**。 #### 0.3.5 ⛔ 反自我瘫痪条款:**"读不懂 / 拿不准" ≠ "禁止"** 规则见 `legal-and-deploy.md` §0.3.5。 ⭐ **"保险起见少抓一点"不是保险**,是把不确定性从法务面转嫁到技术面——§0.2 的实证正是这么发生的:少抓约 60% 资产,五道门全绿,藏了四个里程碑【objectarchive】。 ## 1. 时点一:M0 就做版权**取证**(不是收官才想,也不是 M0 就下结论) 规则见 `legal-and-deploy.md` §1。 objectarchive 从 M0 起遵守 `Disallow: /cdn/wpm/*.js`,因此被迫在服务层 stub、进而必须删掉调用它的内联 loader——**"遵守源站规则优先于保真"是开工时就写进偏差表的取舍**【objectarchive】。 ## 2. 逐资产归属/许可表(取证产物) ### 2.1 表结构(**八列,比早期模板多「第三方权利人」与「数量」两列**) 规则见 `legal-and-deploy.md` §2.1。 **③ 尤其容易被"这些画都是老画"的印象盖住**——objectarchive 的镜像里 122 张画框叠加 PNG(190.8 MB)、605 张带框成品图、79 张房间场景图全部是站方当代商业摄影,与任何艺术家的卒年无关【objectarchive】。 ### 2.3 填表规则 规则见 `legal-and-deploy.md` §2.3。 - ⭐ **许可状态以「产物内证据」为准,不以「上游仓库是什么许可」为准**【objectarchive】。两个实证:① 主题代码——上游 Dawn 是 MIT,但店铺跑的是 **fork**,对 47 个主题资产文件做 `MIT` / `Copyright` / `@license` / `<平台公司名>` 定值扫描**零命中**,产物里没有任何许可头注 → `[未确认]`;② vendor 库——**不因为"是 vendor 就默认自由"**:GSAP 3.12.5 的文件内 banner 明写 `All rights reserved. Subject to the terms at …/standard-license`,**标准许可不是 MIT**(商用需会员);lenis 1.1.14 的产物里**没有任何许可 banner**,直接以可执行代码开头(上游仓库为 MIT,但产物内无声明 → 按规则记 `[未确认]`)。 - **文件名/路径本身就是证据**:objectarchive 有三个 woff2 文件名里带 `Unlicensed` 字样【objectarchive】。 ### 2.4 ⭐ 公共领域取证:**逐位具名作者做,不能按站做**【objectarchive】 规则见 `legal-and-deploy.md` §2.4。 **"这些作品反正都过期了"是按站得出的结论,而按站得出的结论一律无效。** 实证:一个站的 4 份取证文档里出现 **41 位具名艺术家**,逐人查卒年后,**"都过期了"在第 41 个名字上被证伪**——Marek Włodarski 卒 1960、William H. Johnson 卒 1970,在"卒年 + 70"法域下分别到 2031 / 2041 才届满,**今天仍在版权期内**,而他们的作品复制图此刻就在镜像里【objectarchive】。另有四位是最近两三年才届满的(1953–1955 卒)——**把决断建立在"刚好过期"上,等于把项目的法务风险押在日期算术与法域选择上**。 第 7 条:objectarchive 的源站有 82 条内部路由,本轮按与用户确认的范围只镜像了 4 条;**目录里还有没有在世艺术家、有没有在版授权作品,项目没有取证,也无从断言**【objectarchive】。 ### 2.5 ⭐ 逐资产表是一道**真的技术门**,不只是法务作业【objectarchive】 规则见 `legal-and-deploy.md` §2.5。 **实证一(探测器算错)**:objectarchive 数到 fonts 那一行时,发现被引用的 woff2 比盘上多两个——四道验收门全绿、闭包检查报"闭包 = ∅",**而闭包本身算错了**(发现侧的绝对 URL 正则不认转义写法 `https:\/\/…`,而改写侧早就补过同一种拼法——同一条教训只落实了一半)。运行时也永远不会暴露它:那个载荷只在某个表单渲染时才用,产出文档上表单不渲染 → 不发请求 → 404 门与零外联门天然看不见【objectarchive】。 ⭐ **实证二(这一条曾被写反)**:本文件早期版本在这里写着"**缺口登记 ≠ 必须补抓**",并给了个看起来很合理的例子:"缺的是字体,而字体的处置是不入库、不再分发——补抓等于为了让账本整齐去多下载一份未授权二进制,所以登记 + 修探测器 + 不补抓。"**这是错的,代价有实测数字**:同一项目按这套逻辑对一类资产写下"登记,不补抓",用户推翻后全量补抓,镜像从 **1,591 文件 / 587 MB** 涨到 **4,131 文件 / 1.4 GB**——**此前缺了约 60% 的资产,五道门却始终全绿**,四个里程碑的验收都跑在残缺的参照侧上【objectarchive】。 ### 2.6 收官前扫一遍产物里的**第三方标识符**【objectarchive】 规则见 `legal-and-deploy.md` §2.6。 - ⛔ **`GTM-/G-/UA-` 三个前缀不是清单,是清单里的一行**【raycastkbd】:那个项目的 DEPLOY 写"GTM-/G-/UA- 0 命中"就收了工,而产物里实际带着 PostHog 项目 token(`phc_…`,chunk 内 `posthog.init(...)`)、Rewardful 联盟 id(外壳 `data-rewardful=`)、Sentry DSN(`https://<key>@oNNN.ingest.us.sentry.io/<project>`)、Vercel Analytics / Speed Insights 脚本。 ### 2.7 案例参考:往届项目**当时怎么决定、依据是什么** 规则见 `legal-and-deploy.md` §2.7。 | 项目 | 当时的决定 | 当时依据的事实 | |---|---|---| | rogier | 部署到私人 VPS,部署只上 `dist/` | 【rogier】 | | oryzo | 仅个人学习研究,不公开部署(README 明确声明) | Adobe Fonts 商用授权条款、素材版权风险评为 ★★★★★【oryzo】 | | samsy | 私有预览 + nginx `X-Robots-Tag: noindex`,资产不再分发 | 【samsy】 | | kimi | 仓库私有、仅 noindex 私有预览 | 素材版权归 Moonshot AI,README 开头声明【kimi】 | | noomo | 不公开部署,私有仓库 + 本地/私有预览为终态 | 模型/音乐/视频/字体/品牌标识均查得不可再分发【noomo】 | | lando | 私有仓库、不公开部署 | F1/McLaren/肖像/商标不可再分发【lando】 | | objectarchive | 不公开部署为终态(明写"不是暂缓",且**故意不写重新考虑的条件**) | ① 主体资产是**第三方具名艺术家**作品的复制图,店方只是转售方;② **源站是一家仍在营业的真实商店**,字节级忠实副本本身就是混淆载体;③ 理由**过度决定**(§7.2)【objectarchive】 | **资产层面的实例可对照**(同样是事实与建议,不是裁定): - **objectarchive**:具名艺术家作品的复制图(站方只是转售方)+ 站方当代商业摄影 + 未授权字体 + fork 主题代码 + 平台运行时 → 五类 `[未确认]`、七类查得不可再分发【objectarchive】; - noomo:凤凰模型 / 音乐 / case 视频 / Trial 字体 / 品牌标识均不可再分发【noomo】; - lando:F1 / McLaren / 人物肖像 / 商标素材不可再分发【lando】; - oryzo:Adobe Fonts 的 Halyard 商用授权不可自托管 → 保留 Typekit 引用、不进运行资产【oryzo】。 ## 3. 呈交用户的决断包(**两个独立维度**) ### 3.3 取证清单:呈交之前必须做完的六项(第七项由用户完成) 规则见 `legal-and-deploy.md` §3.3。 5b:实证【samsy】:README 写着"不再分发 / 不公开部署",而仓库 PUBLIC、217 MB 镜像已推送、pages.dev 公网可达——不是谁做错了决定,是**没人把这四个事实放到同一页上给用户看**;用户看到后一句话就定了("小范围预览,分发由使用者自行考量"),并按 §7.2 记进 DEPLOY.md §1。 ## 6. 部署即验证——以及"不部署"的验收盲区 规则见 `legal-and-deploy.md` §6。 samsy M12 实例:部署后暴露源站永不触发的构造期纹理竞态,用 CDP Fetch 单文件延迟二分定位到两张纹理,根因判定为"部署拓扑差异(单源 vs CDN 分域)"而非代码;修复拆成"保真修正"与"登记偏差"两笔分开处理【samsy】。 -
mirroring.md 25.9 KB
# case-studies/mirroring.md — 镜像取证全流程(M0 → M0.5) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `mirroring.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `mirroring.md` 一一对应。 ### 0.10 ⛔ 弱标记的挑战页判据必须先问"这是不是一份文档"【v0-optimus】 规则见 `mirroring.md` §0.10。 ⛔ 但"小"不是"文档"。实测一个 **28 KB 的 Turbopack chunk** 被判成挑战页——因为 Next.js 把 `forbidden()` 作为 API 名、并内联 HTTP 状态常量,弱标记"refusal wording"命中了,而它是一份能正常解析、带打包器容器头的合法脚本。 ⚠ 这里的假红特别贵,门自己的注释已经写明了原因:**它训练你跳读这道门的输出**,而那正是当初 43 份挑战页能活下来的方式。修完必须回过头验证弱标记仍会在真挑战页上触发(实测 134 B 的 `Access Denied` 页仍然正确判红)。 ### 0.11 ⛔⛔ 防目录穿越的守卫写成 `includes("..")` 会误杀合法文件名【eightdesign】 规则见 `mirroring.md` §0.11。 目录穿越说的是 `..` 这个**路径段**,不是两字符子串。Next.js 的内容哈希会产出 `5053fba55258321d-s.p.10w.ec_utoj...woff2` 这种名字(扩展名前三个点),于是三个真实字体被拒。 ⚠ **症状离病因很远**:先是字体 404,然后是 14 条 `GSAP target not found`——因为字体没到、 元素没排版出来、选择器落空。**排查时看到的是动画报错,而毛病在服务器的一行守卫里。** ### 0.12 ⛔⛔ M0 第二遍要按**路由**跑,而且 `next/image` 是藏在静态站里的运行时接口【eightdesign】 规则见 `mirroring.md` §0.12。 两条,都是在"整站已经构建通过"之后才暴露的。 **① CDP 补录只跑了 `/`。** 其余路由有各自在运行时拼出来的资源,静态提取看不见。实测:一个路由在镜像侧就有 4 条 404,而它的 HTML、chunk、字体全都在——⚠ **镜像侧报错说明参照本身不完整**,此时任何跨侧数字都不该读。 ### 0.13 ⚠ 比对之前,先确认你拿到的是那份资源【eightdesign】 规则见 `mirroring.md` §0.13。 排查一个页面时我把镜像的 HTML 与"源站的 HTML"做了 sha256 比对,结论是**不同**—— 差点据此判定镜像被污染。 ⛔ 实际上裸 `curl` 吃了一个 **308 重定向**,拿到的是 15 字节的 `Redirecting...`。 带上常规 UA 再取,是 597,007 字节,**与镜像同尺寸**。 ### 0.14 一个 URL 可以按请求头返回两份不同的资源【eightdesign】 规则见 `mirroring.md` §0.14。 镜像模型是 **URL → 文件**。这个源站在同一个 URL 上按**请求头**发两份东西: | 请求 | 响应 | |---|---| | `GET /careers?_rsc=mmrp5` | 597,007 B `text/html` | | 同上,带 `RSC: 1` | **231,939 B `text/x-component`** | ⚠ 下游没有任何一道门能看见:文件在、是一份真文档、闭包完整。故障出现在三层之外, 表现为 React 解析器里的一个 TypeError。 ⛔ 修复时还有一条:**保住磁盘上的形状**。URL→路径映射决定了某条目是普通文件还是 带 `index.html` 的目录;只有**字节**是错的。第一版修复把目录拍平成文件, 下一道门就崩在 `ENOTDIR`——而它本该报错,不是抛栈。 ### 0.15 ⛔ 一个被切错的 URL 不是漏掉一个资源,是**凭空造出**一个【eightdesign】 规则见 `mirroring.md` §0.15。 ⚠ 片段不是"漏读",而是**一条被发明出来的引用**。爬虫接着去抓它、得到 404、 在账本里写下一条失败记录——**看起来和真实缺失的资源一模一样,而且永远补不上, 因为那个 URL 从来不存在**。实测 17 条。 ### 0.16 一个镜像,一本账 规则见 `mirroring.md` §0.16。 `netcapture --fetch` 曾经"只写字节不写账本",并附一条注释建议改用 `mirror-site --seeds`。 注释是对的,而它没有用:一次 `--fetch` 留下的文件会被 `verify-mirror` 永远报为 "nobody can name a URL for"。实测 324 个——每一个都是**故意抓来的**,却没有任何门能祝福。 ### 0.17 ⚠ 别把账本当作证据 规则见 `mirroring.md` §0.17。 排查那 17 条幻影时,我扫描整个镜像找"谁还在引用这些 URL",结论是 15 条仍被引用。 **唯一引用它们的文件是 `mirror-manifest.json` 自己**——账本记录了爬虫问过的每一个 URL, 幻影也在内。把它读回来当作"幻影仍被引用"的证据,是循环论证。 ### 0.18 运行时拼出来的 URL:静态扫描只会造出一个模板前缀【eightdesign】 规则见 `mirroring.md` §0.18。 ⭐ 提取器现在丢弃含 `${` 或以 `$` 结尾的候选——**模板前缀不是地址**。 而真正那条 URL **只有抓包看得见**,它也确实是这么找到的: 首页一次请求,零 404 的探针报告里唯一一条 `external requests`。 ### 0.19 ⛔ 嵌在另一个 URL 查询里的引用,对"把 URL 当原子"的提取器是隐形的 规则见 `mirroring.md` §0.19。 一个把 URL 当原子的提取器在这里只看到**一条**引用——那个端点——**永远不会去要那张图**。 实测 eightdesign:**8 张源图只以这种方式被引用**,不在镜像里,而闭包门自始至终是绿的, 因为从来没有人指名过它们。 ### 0.21 图片优化端点是个**接口**,不是一批文件 规则见 `mirroring.md` §0.21。 `/_next/image?url=X&w=N&q=Q` 的输出集合是**无界的**——`w` 取决于组件当时的视口。 "把它们都抓下来"不是一个计划,是一个不会收敛的循环:实测两轮抓包之后, 115 条路由里仍有 73 条在这个端点上 404,第三轮要跑九小时。 ### 0.22 ⚠ 文件名里可以有 `(`:把它排除掉就是在造幻影 规则见 `mirroring.md` §0.22。 新加的 §0.19 那条形状,取值时把 `)` 排除在字符集之外,于是把六条引用截断在 `… 9 28 46 (1` —— 真实文件名是 `写真 2022-04-04 9 28 46 (1).jpg`。 ⛔ **在一条为了消灭幻影而加的形状里,造出了新的幻影。** 规则应当是**按括号配平裁剪**:配平的 `(` … `)` 属于文件名,孤零零的尾随 `)` 才是 CSS `url(...)` 的收尾定界符。同一个坑在检查脚本里又踩了一次。 ### 0.23 Nuxt/Vite 目标的三个镜像必修课【hubtown】 规则见 `mirroring.md` §0.23。 1. ⛔ **Vite 的 chunk 清单是相对说明符**:`__vite__mapDeps` 与 `import("./Xxx.js")` 都相对 引用文件所在目录,根相对形状全部匹配不到——实测三分之一的懒加载 chunk 不在"闭包已空" 的镜像里。运行时 import 失败会触发 Nuxt 的 `app:chunkError` → `reloadNuxtApp`。 ## 0.9 ⛔ 三个只有大型商业站才会暴露的镜像缺陷【airpodspro】 规则见 `mirroring.md` §0.9。 前四个实测目标是创意站与店铺。换成**大厂商业站的单个产品页**后,工具链暴露了三处此前从未触发的缺陷——它们的共同点是:**都需要某种"前面几个站恰好没有"的形态**才会现身。 **① `--scope` 有个洞:`.html` 被当资产,绕开页面范围。** 页面链接 `href="/legal/…/site.html"` 会被**两个提取器分别判定**:页面提取器按 scope **正确拦截**,而资产提取器的判据是"有没有扩展名",`.html` 说有 → **当资产抓走**,而 scope 的文档明写"只限页面不限资产"。抓下来又被当文本重新扫描引用,**整棵跨地区 legal 树被拖进来**:5 页的微站爬成 **1,492 文件 / 239 MB**。 ⭐ **修法**:同源的 `.html`/`.htm` 是**页面**,交给页面队列(因而受 scope 管辖);跨源的保持资产处理。修完越界 HTML **265 → 0**。 ## 1. 镜像四遍法 + 一条实测 规则见 `mirroring.md` §1。 **每一遍都有别的遍够不到的"唯一发现区",不可互相替代**——shopify.design 322 文件的逐遍战果【shopifydesign】: | 遍 | 手段 | 净得 | 该遍**唯一**能发现的东西 | |---|---|---|---| | 1 | 正则 BFS 爬虫 | 226 文件 | HTML/CSS/JS 里**字面出现**的一切 | | 2 | CDP 真实浏览器抓包(3 路由 × 桌面/移动) | **+44** | GLB 模型 / mp3 / favicon 序列 / draco wasm / 懒加载 chunk——**全部只在运行时被拼出来** | | 3 | bundle 模板字面量静态求解 | **+52** | 编解码器分支的另一半(webm)、完整 13 组 favicon 序列、`.woff.txt` 字体变体 | | 4 | 静态闭包校验(引用集 − 磁盘集) | **+1** | 抓包与求解都够不到的 chunk(要点开特定 modal 才加载的 `WistiaPlayerWrapper-*.js`) | 反过来说:webm 分支只有静态求解拿得到、GLB 只有抓包拿得到、那个 wrapper chunk 只有闭包校验拿得到。**少跑任何一遍都会留下静默缺口**。 ### 第一遍:正则 BFS 爬虫(`scripts/mirror-site.mjs`) - **种子**:全部已知页面路由 + 已知关键资产路径(rogier 60 个初始路径;lando 从 `/` 爬 7 页并用 `/404-page-not-found` 探测出 404 模板)。 - **提取正则集**:对每个文本响应(HTML/JS/CSS/SVG/JSON)提取 `href/src/poster/content` 属性、CSS `url()`、动态 `import()`、`new Worker("...")`、`fetch("...")`、资产目录前缀字面量(`/assets|_astro|audio|content|fonts|images|models|workers/` 类)、按扩展名白名单匹配的绝对 URL【rogier】【noomo】。 - **格式感知深挖**:下载 `.gltf`/glTF 后解析 JSON,把 `buffers[].uri`、`images[].uri` 递归入队【rogier】【noomo】;扫描页面 chunk 内数据结构推导资产路径(rogier 用 `thumbnail:{...}` 正则推出 `/images/thumbs/*`——数据藏在 JS 里,DOM 抓不到)【rogier】。 - **host 白名单**:外部资源只收白名单 CDN 域(lando 12 个),防爬飞【lando】。 - **迭代到不动点**:每轮下载产生的新文本再过一遍正则,直到无新 URL(lando 4 轮——CSS 里的字体、JS 里的 .riv 在后续轮次才被发现)【lando】。 - **纯静态解析变体**:bundle 结构清晰时可不用爬虫,直接从 bundle 静态解析出完整资产清单逐个 curl(samsy 107 文件约 260MB 全部来自 bundle 静态解析)【samsy】。 ### 第二遍:真实浏览器 CDP 抓包补录(`scripts/netcapture.mjs`) - kimi 实测补齐 23 个运行时拼接资源:`avatar_01..16.png` 的序号、`buttons/zh-CN/` 的语言目录都是运行时拼的【kimi】。 - samsy 同思路:Chrome 实跑(/ → WORKS → ABOUT)抓 network 补录 `preloader.png`、worker chunk【samsy】。 - 轻量变体:真实 Chrome 加载后执行 `performance.getEntriesByType('resource')`,取运行时实际请求的同源路径逐一核对镜像命中(noomo 56 路径全命中)——静态爬取之外的运行时闭环【noomo】。 ### 第三遍:bundle 模板字面量静态求解(人工) - deck 深处资源(`about-us/process/step1..4.png` 等 6 个)靠解析 bundle 模板字面量补齐【kimi】。 - 基址变量拼接:lando 的 GL 资产基址 `vQ="https://lando.itsoffbrand.io/gl"`(4 GLB + 3 HDRI + 解码器 + MSDF 字体)与 Rive 基址 `mj=".../rive/"`(8 个 .riv)都是变量拼接,静态正则不可见——从 bundle 读出基址后枚举补抓【lando】。 ### 第四遍:静态闭包校验(引用集 − 磁盘集 = ∅)【shopifydesign】 前三遍跑完仍会漏一类东西:**既不字面出现在 HTML、又不被抓包触发、也不是模板拼接**的 chunk。shopify.design 的 `WistiaPlayerWrapper-*.js` 三条全占——它是普通 import 名(模板求解看不见),要点开特定视频 modal 才加载(6 次路由 × 视口抓包全程未触发),任何 HTML 里都没有它。 shopify.design 实测 26 个引用 vs 25 个文件 → 缺 1,补抓后归零。**差集为空是 M0 关账条件之一(§10)**;差集里若确有故意不入库的外部 chunk,按 §6 外部依赖决策表逐条登记,不许无声留着。 ## 2. redirect: "manual" 纪律(红线) 规则见 `mirroring.md` §2。 爬虫**绝不默认跟随重定向**。kimi 的著名教训:第一版爬虫用 `redirect: "follow"`,把 301 目标的 body 写在来源路径下,**凭空造出 10 个假文件——"把 301 误当成 200"**【kimi】。修复方案三件套: **这条红线的一般形式是"不许造出源站从未在那个 URL 上返回过的文件"**,而跟随重定向只是造假的一种方式。第二种是**把挑战页当成功响应落盘**(§5.1 实证二):源站在那个 URL 上返回的是一道门,不是文档。实测最刺眼的一例——一份挑战页恰好落在一条 301 上,于是那个路径下的文件**源站从来没有以 200 返回过任何东西**;正确处置是**连文件带账本行一并删除、重定向进 `redirects.tsv`**,那是**更正伪造,不是删证据**【objectarchive】。 ## 4. 镜像神圣 + 服务层改写 规则见 `mirroring.md` §4。 例外条款:rogier 一代曾直接改磁盘 bundle(禁 service worker、detect-gpu benchmarks 本地化、GPU fallback),但**每处重写登记在案**("Known local JS rewrites")并在对比时扣除【rogier】——后代演进为"干脆不改磁盘"。如确实不得已改磁盘,必须照 rogier 的登记纪律执行。 ## 5. 断网跑通验收门(M0.5,⛔ 阻塞门) 规则见 `mirroring.md` §5。 - **零 404**:noomo 断网服务 99/99 URL 全 200【noomo】;samsy 全新加载零 404【samsy】。 - **重定向断言**:kimi 7/7 路由零 4xx + 5 条重定向逐条断言(裸 fetch 独立跑,用 `scripts/verify-routes.mjs` 对镜像伺服执行路由/重定向/状态码契约)【kimi】。 - **关键流程走通**:首访交互流程实际走一遍(samsy 首访 /tutorial 流程走通)【samsy】。 实跑必然暴露盲区并当场补录,这是预期内流程而非失败:lando 实跑发现 head/helmet/glass 的 13 件 PBR 纹理"由纹理集拼接,正则不可见",只有真跑看网络请求才能发现【lando】。 ### 5.1 镜像要有属于自己的门:下游全绿证明不了镜像对【objectarchive】 规则见 `mirroring.md` §5.1。 ⭐ **实证一:缺 60% 的资产,五道门全绿,藏了四个里程碑**【objectarchive】。该项目一度以"产出永不公开、这类资产不该多存一份"为由,对一整类资产写下"**登记,不补抓**"——一个**法务理由**,作用在**技术底线**上。用户推翻后全量补抓,镜像从 **1,591 文件 / 587 MB** 涨到 **4,131 文件 / 1.4 GB**:**此前缺了约 60% 的资产,而零 404、零控制台错误、零外联、闭包校验、GAP=0 五道门始终全绿**,整条验收链在残缺的参照侧上跑完了四个里程碑。**这不是理论风险,是已经发生的事。** 由此的硬规则(详见 `legal-and-deploy.md` §0.2):**镜像完整性是技术不变量,任何法务考量都不得削减它**;不抓只能有技术性理由(不是文件 / 服务端不提供 / 需授权或登录态 / 源站明令禁止),逐条登记;缺口一律补抓,**登记是补抓之外的动作,不是它的替代**。理由有三:① 一份永不公开的私有镜像,多抓少抓法律地位不变;② 不完整的镜像让复刻**无法被验证**,反而更糟;③ 一旦允许法务理由挖洞,闭包门就变成可协商的,且**没人能再区分"法务豁免"与"技术失败"**——两者在账本上长得一模一样。 ⭐⭐⭐ **实证二:镜像里有 43 份 bot 挑战页,而镜像门是 PASS 0**【objectarchive】。一次 3 workers 的整站重抓触发了源站的 bot 挑战,爬虫把 **43 份挑战页写在了各自页面的 URL 下**——**包括整个逆向工作所依据的那份 PDP**。每一份都是 **HTTP 200 + `text/html`**,于是全流程无人反对: - `verify-mirror` 保持 **PASS 0**,而且**它没有错**——账本的 sha256 与磁盘上那份挑战页完全吻合。**账本记的是"你抓到了什么",从不记"它是不是你要的那个"。** - 文件 9.5 KB,真文档 300 KB+,**没有任何断言在看体量**。 - 本文件 §9 从四个项目以前就写着"catch-all 假 200"与"小响应告警"——**从来没有可执行形态**,所以它从来没有拦住过任何东西。 **唯一抓到它的是构建层的逐条变换命中下限**(`dom-shell-strategies.md` §2 步骤 3):一条登记变换报 4 次命中 < 下限 5,因为挑战页里根本没有那个平台脚本。**一条为"防止守卫恒绿"立的规矩,抓到的是"证据基座被换掉了"**——不要指望下次还有这种运气。 **实证三(objectandarchive M0)**:图片 CDN 是**查询参数化的变换接口**——`x.jpg?width=320` / `?width=600` / `?width=1200` 是三份不同字节的资源。而 url→路径映射只看 `pathname`,三个尺寸**坍缩成同一个文件**(谁最后写谁赢,142 条 CDN 路径里 57 条受影响);serve 端每个 `?width=` 又都回那同一个文件,页面照样把图渲染出来 → **零 404 门在错镜像上变绿**。这类错不会在 M0.5 暴露,会一路活到像素对拍才以"某张图糊了 / 尺寸不对"的形态出现,那时归因成本已经翻几倍。 - [ ] **闭包完整性**:引用集 − 磁盘集 = ∅(§1 第四遍),差集里每一条在 `external.txt` 有决策。**⛔ 审一道门先问它的输入怎么被界定,再问它的判据对不对**:这一项已经两次假绿在输入上而不是判据上——① 引用集少了一整类**转义拼写**(`https:\/\/host\/…`),差集在一个短了 60 条的集合上算出"= ∅";② **"什么算文本文件"是一张扩展名白名单**,16 份 `.atom` 从没被任何一侧打开过(引用集 3,109 → 3,521)。两条都修在 `scripts/lib/extract-refs.mjs`,**爬虫与门共用同一份判定**,"什么算文本"按 **声明的 content-type → 扩展名 → 内容嗅探** 三级决定,不是一张扩展名表【objectarchive】。 - **声明类型与魔数字节对照**(硬红):声明是图片/字体/媒体/脚本的,正文必须匹配对应魔数;声明是二进制而正文是 HTML 文档的一律红。这一条抓的是"拒绝页 / 登录墙 / SPA 兜底页顶着资产 URL 落盘"。⛔ **判据的依据是源站声明的 content-type,不是 URL 的扩展名**:扩展名是源站自己的命名选择、不承诺任何事(实测:某站在 `.woff` URL 上返回 `font/woff2` 字节,第一版拿扩展名当预期报了一条假红)。改成对着账本的 type 比之后,假红消失而判据**更严**。 - **同类体量离群**(**只报线索,不判红**):挑战页 9.5 KB vs 真文档 300 KB+ 差两个数量级,这是抓"还没有人有正则的那一类挑战页"的兜底。**不判红是有意的**:查询参数化的 CDN 上"同类"永远不精确(一张纯色卡与一张摄影共享 `?width=1200`,差 200 倍是诚实的),判红只会换来一个调参旋钮和一张豁免表——正是 `gate-failure-modes.md` §1 说门是怎么坏掉的那两条路。同类分组必须带上**变换参数本身**(`?width=` 之类):实测只按扩展名分组时 165px 缩略图会整批报成拒绝页(本仓 fixture:只按扩展名 9 条线索、8 条是假的;带变换参数 1 条、就是那条真的)。 ## 7. 跨域与受保护资产的抓取 规则见 `mirroring.md` §7。 - **小响应告警**:bundle 响应 <1KB 极可能是拒绝页(landonorris 的资产域缺 Referer 时返回 32 字节拒绝页,曾造成探测假阴性)——按字节数守卫,触发即补齐 Referer 重试【probe】。**绝对阈值只对最极端的一档有效**:9.5 KB 的挑战页顶替 300 KB 的真文档时它一声不响,所以镜像门里的形态是**同类体量离群**而不是固定字节数(§5.1「真实性」)【objectarchive】。 ## 8. 镜像盲区 checklist 规则见 `mirroring.md` §8。 静态爬取**必漏**的资产类型,逐项建"从源站补录"通道并 checklist 化销账【oryzo】【samsy】: - [ ] **流媒体清单阶梯**:HLS/DASH 的 master `.m3u8`/`.mpd` 能被静态爬到,但 rendition 播放列表与 `.ts`/`.m4s` 分片是播放器**运行时**才请求的,静态爬取全漏(racingshop 实测只抓到 master + 封面 MP4,漏了 3 个 rendition + 12 个分片,靠探针报 404 才暴露)——用 `scripts/gapfill-video.mjs` 递归解析清单阶梯补录【racingshop】 - [ ] ⛔ **路由预取载荷是外联的载体**:导航 `<Link>` 的 `?_rsc=` 预取载荷本身在镜像里,其内的绝对 URL(changelog 的 10 张 `misc-assets.raycast.com/releases/*.png`,51 MB)在滚动走查触发预取后由浏览器直接去要。netcapture 首跑没传 `--hosts`,同注册域的子域也一样看不见;`probe --no-external --walk` 才报出来。内容资产只能镜像(`assets/<host>/` + `--ext-hosts`),载荷里其它路由的家族按范围声明前缀豁免【raycastkbd】 - [ ] ⛔ **next/image 阶梯要按字节穷举且按浏览器 Accept 抓**:HTML srcset 里的每条 `/_next/image?url=…&w=<档>` 都是一份资源(raycastkbd:42 条,浏览器碰到 19),且 `Vary: Accept`——`*/*` 拿到 JPEG/PNG 回退,Chrome Accept 拿到 webp,两者 sha 不同、体积差 3–30×。存量镜像走独立记账树 `mirror-negotiated/`(sanity-platform §1.2),serve 用回落链 `--fallback-root mirror-negotiated,mirror`【raycastkbd】 - [ ] **HTML/CSS/JS 之外的文本格式**:`.atom` / `.rss` / `.xml` / sitemap / `.txt` / `.webmanifest` / `.map`,以及**无扩展名的路由**与源站 MIME 表不认识的扩展名(服务端一律回 `application/octet-stream`)。这些文件**装满商品链接与 CDN 图 URL**,但"什么算文本"如果是一张扩展名白名单,它们**从来不会被任何一侧打开**——而闭包门看不出来,因为爬虫与门共用同一张白名单(objectandarchive 实测 16 份 `.atom`,引用集 3,109 → 3,521)。做法:判定按 **声明的 content-type → 扩展名 → 内容嗅探** 三级走,`octet-stream` 当**没有声明**处理(它是"服务器不知道",不是"这是二进制"),且**爬虫与闭包门共用同一份实现**【objectarchive】 - [ ] **查询参数化的资产变换接口**:图片 CDN 把尺寸/裁剪/格式写在 query 里(`x.jpg?width=320|600|1200`、`?crop=center`、`&format=webp`),**同 pathname 不同字节**。按 pathname 落盘会让整组变体坍缩成一个文件,而下游零 404 门照样绿(§5.1)。做法:映射与落盘**查询感知**,并把"同 pathname 多变体"单独清点(objectandarchive:142 条 CDN 路径中 57 条有多个 `?width=` 变体)【objectarchive】 - [ ] **`srcset` 的非首个候选**:`srcset` 是逗号分隔的候选表,多数爬虫正则要求候选前有引号,于是**每组只命中第一条**——objectandarchive 68 组 × 约 5 条,约 270 个变体对第一遍完全隐形;浏览器按 DPR/视口只请求其中一条,**第二遍抓包也补不全**。做法:`srcset` / `imagesrcset` 属性单独按逗号拆开逐条入队【objectarchive】 - [ ] **不带尾斜杠的裸主机基址常量**:代码常写 `const B="https://cdn.example.com"`、`window.shopUrl='https://site.com'` 再拼路径;只匹配"带尾斜杠"形式的提取/改写规则对它天然失明——objectandarchive 实测因此漏了 4 个遥测外联 + **2 个到线上源站的主题资产请求(那份资产一直在盘上)**。同类还有 JSON 转义的协议相对写法 `\/\/host\/`。做法:提取与改写规则覆盖**裸主机 / 带尾斜杠 / 协议相对 / JSON 转义**四种形态,且**探针要报完整 URL 而不只是 host 直方图**,否则看不出漏的到底是哪一条【objectarchive】 - [ ] **App Router 的运行时面**:客户端导航预取的 `?_rsc=` 载荷(每个可见链接一条,query 值是路由状态哈希)与 `next/image` 优化器变体(`/_next/image?url=…&w=…`)。⭐ 变体阶梯**从 SSR HTML 的 srcset 穷举**成闭包全集,不靠浏览器碰运气——rauchg 实测 srcset 推出 1,078 条 vs 两个标准视口只碰到 217 条。用 `scripts/reconcile-gaps.mjs` 逐条容错补录【rauchg】 ## 9. 常见坑 规则见 `mirroring.md` §9。 - ⛔ **补录循环的账外文件**:`netcapture --fetch` 曾没有逐 URL 容错——一次 DNS/TLS 异常中止整个循环,`appendLedger` 永远没跑到,**已落盘的文件全部成为账外状态**(正是它自己注释里承诺防住的状态,只是又高了一层)。实测 rauchg:725 个 `/_next/image` 变体在盘上、账本里零条。现已逐条 try/catch + 每百条分批记账;教训通用:**任何"先写盘后记账"的循环,记账必须分批,不许全押在收尾一笔**【rauchg】 - ⭐⭐⭐ **一个 200 不是"你拿到了那个资源"的证据**:反爬挑战页 / 同意墙 / 地区拦截页 / catch-all 兜底页**全部是 200 + `text/html`**,账本会诚实地记下它们的 sha256,而单射性、闭包、覆盖度**每一项都在校验一份错误的内容**。实测 43 份挑战页顶替真文档、镜像门全程 PASS 0(§5.1 实证二)。**这条坑必须以门的形态存在,不能只是这里的一行提醒**——四个项目里它一直只是散文,代价是整个逆向工作所依据的文档被换掉而无人反对。可执行形态见 §5.1 的「真实性」断言(`scripts/verify-mirror.mjs` 的 AUTHENTICITY 门)【objectarchive】。 - **拿法务理由在镜像上开洞**:以"产出永不公开""这类资产不该多存一份"为由少抓一类资产,五道门照样全绿——实测缺 60% 资产藏了四个里程碑(§5.1)。镜像完整性是技术不变量,不抓只能有技术性理由;**法务决定作用于产出怎么被使用,不作用于证据基座是否完整**【objectarchive】。 - **后台标签节流伪装假死**:M0 阶段在后台标签实跑镜像,rAF 节流 + gsap lagSmoothing 会把站点冻成假死,误判"镜像坏了"(noomo M0 亲历,samsy 曾因此误改源码后撤销)——无头/实跑一律带 anti-throttling 旗标或保持前台【noomo】【samsy】【oryzo】。 -
payload-gates.md 4.1 KB
# case-studies/payload-gates.md — 载荷与外壳变换的门 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `payload-gates.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `payload-gates.md` 一一对应。 ## 1. 自带长度的载荷:改写它,就得重新声明它 ### 1.1 它为什么必须是一道门,而不是一条注意事项 规则见 `payload-gates.md` §1.1。 实测 eightdesign:115 条路由里 **2 条只渲染出 70 个字符**(对侧 2,440)。 ⚠ 而**其它每一道门都是绿的**:零 404、零请求失败、镜像账本自洽、chunk 字节一致、 HTML 尺寸相同。静态检查看不见它,因为字节合法;运行时探针看不见它,因为没有任何请求失败。 ⭐ 定位它的动作值得记下来:**拿 `python3 -m http.server` 伺服同一个目录**。两条路由完美渲染。 一个最朴素的静态服务器和我们的服务器在**同一份字节**上分歧,就把故障从"字节"钉到了"服务器"。 ⭐ **手上有两个能做同一件事的实现时,让它们跑同一份输入,是最便宜的二分。** ### 1.3 先拿真值校准这道门 规则见 `payload-gates.md` §1.3。 ⛔ 这道门的第一版断言"行的声明长度之后必须是换行",于是把**每一份**文档判为损坏—— **包括源站自己发出的字节**。 ⭐ **一道门判它所审计的源头有罪,是"错的是门"这件事最便宜的信号。** 值一次 fetch。 校准之后:源站 PASS、镜像原始字节 PASS(828 行/115 页)、服务层输出 PASS、 **port 构建字节 FAIL 17 页**——那 17 页正是构建层的同一个 bug 烤进交付物的地方。 ## 2. 一个处在文本位置的 URL 是内容,不是地址【eightdesign】 规则见 `payload-gates.md` §2。 实测:115 条路由里恰好 1 条——一篇讲本站改版的文章,把它谈论的那个地址印了出来: ```html <a href="https://host/">https://host/</a> ``` href 必须本地化。锚**文本**不能:改了以后页面读作「こちら /」,不再指名那个站点。 两侧各自稳定、相差 25 个字符。 ## 4. 载荷门要认得 flight,而它的判据是**分类**而不是相等 规则见 `payload-gates.md` §4。 `verify-payload` 原本只认 Nuxt2/Nuxt3 两种形状——**而野外最常见的序列化载荷是 React flight**, Next.js App Router 每一页都内联它。一道为"跨侧比较载荷含义"而存在的门,不认识大多数目标的载荷。 ### 4.1 判据:结构必须一致,每处值差异必须限于一个**引用** 规则见 `payload-gates.md` §4.1。 ⛔ 归一化写错三次,每次都把"移植合法做的事"判成内容差异: | 版本 | 症状 | |---|---| | 绝对 URL → `\0URL\0`,本地路径 → `\0PATH\0` | **本地化本身**被判为内容差异(两个占位符就是两个类,而这里只有一类) | | 只抹带扩展名的路径 | 无扩展名的路由 URL(`/eight-journal/x`)漏网,115 条里 52 条失败 | | 不认 `/ext/<host>/` | 桩掉的外部主机全部失败 | 修好后:**115 条路由全部 PASS,609,188 个叶子路径逐一比对。** ## 6. ⛔ devalue 数据岛是程序输入,不是地址【hubtown】 规则见 `payload-gates.md` §6。 hubtown 的岛里带着部署站点记录(`"hubtown-live"`,env,url)。WebGL 引导从中推导 Theatre 环境;把那个 url 本地化成 `/` 之后,`new URL(...)` 与 sheet 查找在**三层之外** 炸成 `addSheetObject reading 'object' of undefined`——期间每个请求都是 200。 ## 7. `notice: true` 会变成页面上的文字【raycastkbd】 规则见 `payload-gates.md` §7。 外壳构建器的 noindex 注入写作 `(cfg.notice || "") + '<meta …>'`。 配置里写 `notice: true`(再自然不过的写法),字符串拼接把它渲染成 `<head>` 里的 **裸文本 `true`**——HTML 解析器遇到 head 内文本会**提前闭合 head**, 把后面的 `<meta>`/`<link>` 全搬进 body。 ⚠ 症状:导航按钮消失、canvas 不挂载、页面角落一个 "true"。 而**每道静态门都是绿的**——外壳门重放的就是变换表自己产出的字节,长度门也对。 只有渲染层文本对拍抓到了它。 -
porting-discipline.md 19.2 KB
# case-studies/porting-discipline.md — 严格溯源移植(阶段 2:Port) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `porting-discipline.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `porting-discipline.md` 一一对应。 ## 0.25 ⭐ 门报出你没料到的数时,先假设错的是你【airpodspro】 规则见 `porting-discipline.md` §0.25。 实测:把表达式解析接上活布局后,`a0t`/`a0b` 解析成 `[0.25, 0.65]`,而我手算是 `[0.1667, 0.4333]`(按容器高度归一化)。**第一反应是"解析器移植错了"。** 回去读源码里的归一化函数——分母是**可滚动距离**,不是容器高度。**引擎是对的,心算是错的。** ⛔ **如果当时去"修"解析器迎合手算,就会把一个正确的移植改坏,而门会变绿。** ## 0.3 ⭐ 编排可能是一门表达式语言,而不是一组数值【airpodspro】 规则见 `porting-discipline.md` §0.3。 ✅ **实测已接通**:按源站自己的调用序(先 `refreshMetrics()` 让组自行构造元素集合——**不要手调 `refreshCollection`**,再 `evaluateConstraints()`),并给锚点选择器一个**真实布局的元素**,`a0t`/`a0b` 就解析成真实的滚动 t 值。⚠ 锚点数组为空时解析器读不到盒子,报 `reading 'top'`——**那不是解析器坏了,是它缺输入**。 实测把它算进依赖闭包后,切片从 31 模块 / 1,179 行变成 **41 模块 / 2,100 行**。 ⚠ 这与 v0.1.3「被冻结分支上挂着的子系统会以通过的形式消失」同型,但更难发现:那次至少有个分支可以去查,这次**缺失的是一个数据字段**。 ## 1. 宪法级纪律(五条) ### 1.1 源站代码是唯一裁决,不凭观感修 规则见 `porting-discipline.md` §1.1。 - **实证**:oryzo M2.3 曾用目测近似实现先跑通,随后整批替换——commit 明写"全部逻辑溯源 bundle,**替换了此前的近似实现**"。 ### 1.2 源站有的都要有,没有的不做;不自创补偿性 CSS/JS 规则见 `porting-discipline.md` §1.2。 - **实证**:rogier 十余个视觉 bug **全部**源于"JS 机制没对齐时用自创 CSS 补观感"——等 JS 对齐后,这些补丁反转成 bug【rogier】。 ### 1.3 bug / 死代码 / 怪写法照抄不修 规则见 `porting-discipline.md` §1.3。 - **最强实证【lando Q13】**:源站 `World.destroy` 里 `scene.remove(Q.name)` 传字符串——在 three 中是 no-op。复刻时曾"修好"改成真删除,结果**真删除破坏了场景遍历,导致转场崩溃**,最终按怪癖回抄 no-op。"bug 照抄不修"不是洁癖,是工程安全绳【lando】。 - **其余实证**: - rogier:`pz % 250 + 10` 带符号取模被"好心修正"为正取模后,About 页浮动方块全部消失【rogier】; - oryzo:`mipFilter` 死参数、光场 RT 错误的 `format="R8"` 照抄——"修正它们反而会偏离源站的实际渲染结果"【oryzo】; - samsy:`isSprinting` 恒为 true、事件名拼错导致监听器泄漏、三个调用即崩的死方法(引用全 bundle 无定义的标识符)逐字入库——"修好它们才是偏离"【samsy】; - kimi:`lineWidth 0.30000000000000004` 浮点残迹、被读不被写的 CSS 变量、硬编码英文 aria-label 等 26 条怪癖照抄并注明行号【kimi】; - noomo Q13:采样数切换事件把 define 大小写写错、从未生效——连这个"无效重编译"都照抄【noomo】; - 死代码同样移植:rogier 保留零引用的 `.ts-split` 规则【rogier】;kimi 移植九种轨道形状里八种死代码【kimi】。 ### 1.4 有意偏差必须登记 规则见 `porting-discipline.md` §1.4。 samsy 用 "REGISTERED DEVIATION" 注释 + 计划文档同步登记【samsy】;rogier 为 "Open Decisions" 表【rogier】;noomo 终版 14 项、lando 12 条、kimi 6 条【noomo】【lando】【kimi】。 - **范本**:kimi 的"4.8MB 字体拒绝子集化"——可压四十余倍但拒绝,理由按杀伤力排五条(首屏渲染门控时序、`measureText` 折行、点阵舍入敏感、538 字是移动靶、私有仓库收益为零),并附"重新考虑的条件"。 ## 2. 移植文件头注释规范与逐字落地形式 ### 2.2 逐字移植的首选实现形式:字节切片,不是重打字【shopifydesign】 规则见 `porting-discipline.md` §2.2。 实测规模:M2 时 33 段 / 2,475 行源站字节,收官时 **61 段 / 21,996 行**【shopifydesign】。 shopifydesign M2/M3 累计 5 次边界错,全部被平衡检查当场抓出,单次定位约 5 分钟——`scripts/extract-source.mjs --balance-check` 已内置该检查(原理与实证见 §6.2 删桩流程),不必自写。 > **实证**:M2/M3 累计 **5 次**切片边界错,**全部**是切**单个应用函数**时收尾少一行;M4a 一次切了 **13,147 行、四段大 vendor 岛**(troika 4,702 行 / opentype.js 8,154 行 / 文字模块 281 行 / 两段小补丁),`--balance-check` **一次没红**。 #### 跨 chunk 切片:一个源 chunk 一个输出模块(模块作用域是硬约束)【shopifydesign】 规则见 `porting-discipline.md` §2.2。 - **两个 chunk 是两个 ES 模块作用域,压平就会撞标识符。** 实证:M4c 跨 chunk 切 SiteHeader 的 blossom carousel(L58–L493,436 行),**第一次加载就 `SyntaxError: Identifier '$' has already been declared`**——SiteHeader 把某个 helper 压成了 `$`(它的 L93),主 chunk 的 `$` 是一个 three.js 类。**两个都没错**:它们在各自的模块作用域里都是唯一的,撞车完全是切片器把两个 ES 模块拼进一个文件造成的。 - **判据(什么时候必须建独立模块)**:**当你要从第二个 chunk 取的东西超过"一张常量表"时,先给它建自己的输出模块,再切。** 实证:M4b 跨 chunk 只取了 8 行(一张文案表),压平无事;M4c 取 436 行,第一次加载就炸。 #### 切片可行性判据:边界受源站**声明结构**约束【shopifydesign】 规则见 `porting-discipline.md` §2.2。 - 实证 1:`J1`(选曲,L28271–L28284)是纯函数,本该一切了事。切不了——它的数据 `Ev`(30 首曲目,L28182–L28266)焊在一个从 **L28176** 开始的 `const` 块里,而该块的前三个成员是 `De.createContext(...)`(React)。从 L28182 切会得到一段以 `Ev = [{` 开头的孤儿,**要补一个 `const` 才能解析**(**这正是 `wrap` 存在的理由**,见下)。 - 实证 2:`hx`/`dx`(L45169–L45170)是模块级可变量,与它的 `const` 邻居同块,且 importer 侧无法赋值。 - 实证 3(**边界形态**):**一行里同时装着上一个声明的收尾和下一个模块的 banner**,这是压缩产物的常态——`CU`(尚未移植的透明视频 dispose)的收尾 `}` 与 opentype 岛的 license banner **同在 L34086**。 **推论:起点/终点按语句判断,不按行判断。** 上例的处理是把岛的起点后移到 **L34087**,把 banner 留给尚未移植的邻居;将来切 `CU` 时终点写 L34086 会把 banner 一起带进来(可接受)。 #### 切片表的两种扩展形态:多源钉版与 `wrap`【shopifydesign】 规则见 `porting-discipline.md` §2.2。 - 实证:`Pa`(工作室浮层的文案表)住在 `SiteHeader-DOgAl6Q_.js`,主 chunk 只 import 它。 - 实证:全表 61 段里**只有 2 段**用它——一段的链头三个成员是 React context 绑定(按 D9 不跑),另一段的链头是引擎根本不读的文案。 ### 2.3 GLSL / 魔数 / 数据逐字提取 规则见 `porting-discipline.md` §2.3。 - **GLSL 逐字拷贝、集中存放、头注声明**:oryzo 的 `glsl/index.ts`(845 行、118 段 shader)头部声明 "GLSL extracted verbatim from … **Do not edit by hand**"【oryzo】;lando 流体六 pass 注明 "All GLSL verbatim" 并逐 pass 列源行号【lando】;noomo 连源站变量名 `yeahRaytracingBroWhySoComplex` 都照抄【noomo】。 - **逐字的直接收益**:noomo 离线 `node diff` 证明 shader 与源站逐字一致后,像素差异排查即可**聚焦到编译参数/数据链**(M7a F1 全屏竖纹最终定案为 GLSL 版本默认值差异,一行修复级联解决三个表观 bug)【noomo】。 - **魔数照抄**:`wheelEaseCoeff=12`【oryzo】、bloom strength 0.34 / radius 0.27×DPR(带行号)【samsy】、"噪声种子、灰阶表、4×4 与 8×8 抖动矩阵、量化级数全是硬编码魔数,目测调不出来,只能逐字抄"【kimi】、LCG 种子 1111111114、弹簧参数 (50,15)【noomo】、GSAP 贝塞尔控制点公式与 ScrollTrigger 配置逐字抄录【lando】。 ### 2.5 端口怎么被加载,是端口的一部分:三种交付形态【milknetwork】【raycastkbd】【hubtown】 规则见 `porting-discipline.md` §2.5。 **实证(milknetwork)**:main chunk 的 15 个模块闭包门报"自洽",但 module-map 的 `externalRequires` 列出 10 个跨 chunk id(gsap/three/swiper 全在 vendor 分包)——独立运行时形态在 app 第一次动画时必然 `module ./node_modules/gsap/index.js is not in the registry`。按 chunk 形交付后,原 runtime + 三个 vendor 原件不动,像素逐档与带宽全同。 #### 2.5.1 转写微运行时:字母语义从 runtime chunk 抄,不从调用点猜【basement】 规则见 `porting-discipline.md` §2.5.1。 实证:847851 按主 chunk 证据是 hls.js,但懒 chunk 里它是 18.5k 行 mux 播放器组件模块(内嵌 hls); 顶替成 npm hls.js,文章视频以 React #306 死在 next/dynamic。 ## 3. 数据资产:脚本从 bundle 抽取入库,禁止手抄 规则见 `porting-discipline.md` §3。 副产品:源站英文文案自己的拼写错误("Leaining rate" / "Senquential")经管道原样保留——"抽取式移植的免费收益:连错都不用自己抄"。 - **同类实践【samsy】**:`src/data/` 下 works.json(25 条)、cityLayout.json(bundle L65917-66615 逐字反解,35 处摆放)、animations.json(1.64MB)、mixamoRig.json、preloaderFrames.json。 ## 4. 三张登记表制度 ### 4.1 ⭐ 每张表都要有一道反查它的门:表会悄悄漂在现实前面【objectarchive】 规则见 `porting-discipline.md` §4.1。 它们的共同点是——**表声明的是"现实的某一部分已经如何",而现实变了不会来通知它。** 这条最初是作为变换表的局部论证写下的,实测证明它与"变换"无关,是关于**表这个东西本身**的。 > **实证【objectarchive】**:M3a 建 `runtimeGates` 销账表(哪一块由哪道门证明"真的在跑")时,任务书把 `oa-hero-bg-scale`(`B:fd64182e34da`)记为"M2 已做"。按纪律逐条回查,**不成立**:M2 的脊柱门断言的是**脊柱自己的** hero 视差补间(trigger 为 `#MainContent > .shopify-section:first-child`),而 `B:fd64182e34da` 是另一个东西——分层 hero 轮播 + `calcBgScale()` 覆盖度计算,宿主 `#hero-layered-*`,脊柱门一个字都没碰。**两者共用同一个宿主 section,所以表面看像已覆盖。** 该项退回后续里程碑队列——**销账表建起来的第一天就抓到一条虚报。** ### 4.2 ⭐ 复核必须是阶段固定动作:登记错误率是稳定量,不是偶发【objectarchive】 规则见 `porting-discipline.md` §4.2。 **五轮**连续实证说明这不够——**每一轮都抓到,而且抓到的是不同的错法**: | 轮 | 抓到什么 | 那条登记是谁写的 | |---|---|---| | M3a | **进度虚报**:销账表把 `oa-hero-bg-scale` 记为"上一里程碑已做",实测那块的分层轮播与 `calcBgScale()` 一个字没碰——它与脊柱**共用宿主 section**,所以表面看像已覆盖(详见 §4.1 实证) | 本轮任务书 | | M3b | **§Q 的内容错**:Q1 说"三份 `OPENINGS` 几何表相同",实为 **18 / 16 / 16** 两种;Q2 说"某尺寸预览静默消失"根本不成立——该项就在 PDP 那份表里,`render()` 还**显式**把它并入无框分支 | 早期登记,**且已被当成地面真相下过硬指令**(上一轮的断点待办照它写着"门要断言 `buildFrameUrl` 返回 null、预览静默消失") | | M3c | **同两条的坐标仍错**:Q1 的表区间应为 `+50..+67`(原写 `+50..+76`)、Q2 的 `'30x40'` 在 `+18`(原写 `+14`,那一行其实是 `'18x24'`);分层归属表里"the same 16-entry OPENINGS table"同样过时 | **M3b 刚刚更正过内容的那两条** | | M(n-1)a | **Q2 的坐标第三次错**:`if (!spec) return;` 的区间原写 `+143..+144`(那指的是"空行 + 查表"),实为 `+144..+145`——**恰好把守卫本身漏在区间外**;同一段逻辑的姊妹条目 `B:735c258faf0a+109..+110` 一直是对的,两者本该平行 | **M3c 刚刚更正过坐标的那一条**(同一条目连错三轮、被更正三次) | | M(n-1)b | **抽 5 条抓到 2 条,两条都落在"上一轮刚改 / 刚加"那一格**:① **坐标指错了文件**——某条 §Q 登记引的那段 `<750px` 覆盖 CSS 不在主题样式表里,而在该商品页文档自己的内联 `<style>`(`L5511` 横幅 / `L5514–L5521` 规则);主题样式表里**确有**一条同名规则,但被另一个商品类目的类限定,本页根本不命中。**内容字段逐字正确、坐标字段指到了另一个文件里一条同名不同义的规则**;② **数字差 1 px**——逆向笔记里上一轮刚补的那一列滚动几何写成 `14,443 / 13,599`,实为 `14,442 / 13,598`(把 `14442.047` 的小数位往上进了一格),而 `maxY` 正是像素门位置维的**末检查点**,差 1 px 等于"滚动终点"那一格永远对不上 | **上一轮刚新增的那条登记 + 上一轮刚补的那一列数** | **最贵的是第三、四行**:同一条登记(Q2)连错三轮——M3b 更正了**内容**、M3c 更正了**坐标**、M(n-1)a 抽验发现坐标**还是错**。而**更正过的条目看起来是最可信的**——它刚被人认真读过。 **第五行把规律收紧了两处**:① **抽验 5 条抓到 2 条,两条都落在"上一轮刚改 / 刚加"那一格**——**"刚新增"与"刚更正"是同一格**,而且刚新增的更危险:刚更正的至少被人认真读过一遍,刚新增的**一次复核都没经历过就已经开始被引用**;② **两条的错法完全一致——内容对、坐标 / 数字错**,都是"改对(写对)了被质疑的那个字段、整条其余字段原样照抄"留下的(纪律 3 的症状)。**反面证据同样有力**:同一轮里 Q2 的**七处坐标逐行数过、全对——四轮以来第一次不用更正**,因为这一轮是按纪律 3 回**源站字节**重新取证的,不是照上一版改。规矩生效的样子就是这样。 ⭐ **更正必须回一手来源重新取证,禁止基于上一版做增量修正。** 同一条登记连错三轮,机理不是"人不小心",是**更正这个动作本身的做法错了**:每一轮都照着**上一版登记**去改那个被质疑的字段,而不是回源重数一遍。 - **坐标逐行数到边界那一行,不按印象取区间。** Q2 的 `+143..+144` 与 `+144..+145` 差的正是 `if (!spec) return;` 那一行本身——区间少一行,"这条早退到底存不存在"就没有任何门盯着。 实证:Q2 被抓的三次里后两次落在这一格;M(n-1)b 抓到的 2 条**两条都在**这一格(一条上一轮新增的登记、一条上一轮新补的一列几何数)。 **⭐ 错误形态学:坐标比内容更容易错,抽验时按这个分配注意力【objectarchive】**——五轮抓到的错分布很不均匀:**进度/结论错一次(M3a)、内容错一次(M3b),其余三轮全是坐标 / 数字错**。 **为什么必须是开工时**:M3b 那条错登记不是"文档里的一句错话",它已经变成了下一轮的任务书。 ## 5. 里程碑推进与提交纪律 ### 5.1 依赖序推进 + 先竖切 规则见 `porting-discipline.md` §5.1。 noomo 遵循 engine-notes 结论"先移植三大自研元系统(provider 注入器 / ShaderRegistry / 时间线绑定原语)再写任何材质"【noomo】;lando 按 M3 站点 chrome 层 → M4 Rive 层 → M5 Three GL 层 → M6 页面专属逻辑分层【lando】;samsy M2→M9 同理【samsy】。 - **先竖切一条端到端链路**:oryzo 先把 hero 场景从加载到渲染整条链打通,再横向铺其余场景集群【oryzo】——竖切最早暴露架构级错误。 ### 5.2 每里程碑验收后才进下一个 规则见 `porting-discipline.md` §5.2。 - 已建立的底层验收门保持全绿:noomo 的 git log 里几乎每条 commit message 以 "SSR gates green" 收尾【noomo】。 ## 6. 临时代码生命周期标记 规则见 `porting-discipline.md` §6。 - oryzo:`phase1-shims.css` 每条 shim 注明"**将在 phase 2 被引擎逻辑取代**",后续果然全部删除【oryzo】; - lando:`stubs-notes.md` 是"临时骨架清单(逐波替换为溯源实现)",每个 stub 文件标注对应源函数与行号区间【lando】; ### 6.2 竖切期的 pending 桩:两种形状、两本清单、删桩流程【shopifydesign】 #### (a) 桩的两种形状 规则见 `porting-discipline.md` §6.2 (a)。 - **实证**:shopifydesign M2 立 26 条桩,boot 期连续抓出 3 处"以为不会走到"的路径,每次都是带行号的失败;M3 全程(桌面 + 移动、全滚动走查)无一 throw 被触发——这本身就是"这些子系统在当前可达状态下确实全走空分支"的正面证据。 #### (b) ⭐ 缺失清单要有两本:桩文件不是全部 规则见 `porting-discipline.md` §6.2 (b)。 > **实证**【shopifydesign】:`R5`(DOM 标题揭示,L45024–L45071)在复刻侧**完全缺失,跨越两个里程碑无人发现**。它只被一个 React effect(`D5` L45073–L45085)调用,而那个 effect 本身没被移植——**没有调用点就没有未定义符号,就没有桩**。(它同时对确定性冻结下的数值门隐身,两层原因叠加才拖了两个里程碑:`gate-failure-modes.md` §1.7 / `determinism.md`。) #### (c) 删桩流程 规则见 `porting-discipline.md` §6.2 (c)。 > **实证**【shopifydesign】:M2 关账时 102 个字段延后,归因写的是 hero 砌砖 + 倒计时舞台——**这两个东西在桩文件里一个都没有**(它们是 React effect,正是 (b) 的佐证)。M3 的第一步因此是"移植两个不在清单上的东西":桩文件一条没动,门就全绿了。反着做(先挑最小的桩删)会让里程碑的关账条件一直悬着,而且删掉的桩多半与门无关。 实证:`Q5` 的切片写成 L45530–L45553,少了收尾的 `}`(正确是 L45554),报 `Unexpected token ')'`,逐片二分 30 秒定位。 实证:一次性列出 7 个待解析别名,全部在写代码之前补进别名表。 这两道检查就是"删了才发现依赖没接上"的实际拦截点——shopifydesign 第一次执行删桩,全程零此类返工。 > **实证**【shopifydesign】:`tP`(线层深度剔除)的桩此前从未 throw,正说明 `lineLayers` 一直是空的;`LB`(网格 builder)一落地 `lineLayers` 立刻非空——若漏切 `tP`,得到的是一个**带行号的 throw**,而不是"线层不做深度剔除"的静默错画面。per-frame 桩的价值**在被删之前的最后一刻才兑现**。 -
readable-source.md 19.3 KB
# case-studies/readable-source.md — 可读源码阶段:从逐字移植到工程源文件 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `readable-source.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `readable-source.md` 一一对应。 ## 0. 这一阶段解决什么 规则见 `readable-source.md` §0。 到 M(n) 收口为止,产物是**一份已证明正确、但人读不了的代码**。实测形态(lusion,14,271 行): | | 现状 | 说明 | |---|---|---| | 文件数 | 1 | 583 个声明挤在一个文件里 | | 类 / 顶层函数 | 130 / 127 | ✅ 结构真实 | | 类名 / 方法名 / 常量名 | `GoalTunnelAstronauts`、`BLOCK_COUNT`、`sharedUniforms` | ✅ 压缩器动不了这些 | | 局部变量 | `e` × 2962、`t` × 2164、`r` × 1692 | ❌ 全是单字母 | | 注释 | 34 行 / 14,271 = 0.2% | ❌ 原作者的注释被构建剥掉了 | ## 2. 自包含契约 ### 2.2 复制什么、以及什么理由可以不复制 规则见 `readable-source.md` §2.2。 ⛔ 这条是 `legal-and-deploy.md` §0.2 那条纪律在本阶段的投影。实证是同一次:某项目以"产出永不公开"为由对一类资产登记不补抓,**缺了约 60% 而五道门始终全绿**【objectarchive】。在这一阶段它的表现形式会是"`src/` 看起来能跑,因为跑的那几条路由的资产恰好都在"。 ### 2.2.1 ⛔ 资产目录的层级就是 URL 空间契约 规则见 `readable-source.md` §2.2.1。 **资产必须保持镜像相对路径不变**,落在 `src/public/` 之类的服务根下,**不要再套一层**。实测把它们收进 `src/assets/` 之后,镜像里本就是顶层的 `_astro/` 变成了 `/assets/_astro/`,样式表 404。 ### 2.2.2 ⛔⛔ 自包含要按**账本**复制,不能按文档引用复制【airpodspro】 规则见 `readable-source.md` §2.2.2。 同一个交付物做了三次才真的能跑,三次失败都在同一句话上:**「能构建」不等于「能跑」**。 | 尝试 | 复制依据 | 仓库外副本的探针 | |---|---|---| | 一 | 文档里的 `src`/`href`/`srcset`,491 文件 | **17 条 page error** | | 二 | **资产账本**,1,047 文件 | **2 项,与源站逐项相同** | | 三 | 同上,但少带了一个文件 | 能跑,**但测不了** | ⛔ 第一次 `npm install --offline … ok` / `npm run build … ok` 之后自包含门报 PASS,而那份副本根本跑不起来。**文档扫描看不见脚本在运行时请求的东西**;账本才是完整性的权威(§2.2)。⭐ 自包含工具应当**从账本复制、用文档引用做交叉报告**,而不是反过来。 ### 2.2.3 ⛔⛔ 生成器里的硬编码值,是下一个项目的潜伏 bug【optimus】 规则见 `readable-source.md` §2.2.3。 自包含这一步在第二个项目上连续踩了**四个同族缺陷**,全部是"第一个项目的值被写死在生成器里": | 硬编码的东西 | 在第二个项目上的表现 | |---|---| | 移植产物的输出路径 `/assets/js/` | 本项目的产物在 `/_next/static/chunks/`,于是**移植产物自己被报成"缺失资产"**——报成了交付物的洞,而它就是交付物 | | 交付物 `package.json` 的 `--outfile=public/app.js` | 构建到了一个页面根本不引用的路径 | | `--external:@marcom/ac-analytics` | 上一个站的 external,在这里毫无意义 | | 交付物的包名 `airpodspro-src` | **新交付物自我介绍成了另一个项目** | | 无条件写出 `runtime.js` | Turbopack 移植**没有转写任何运行时**,这个文件是死代码,而且它的文件头**宣告了一条并不存在的偏差** | ⚠ 最后一条尤其值得单独记:**死代码不只是浪费,它还会说谎**。那个多余的 `runtime.js` 头部写着"REGISTERED DEVIATION:原运行时被重新实现",而这个移植的全部要点恰恰是**一个运行时都没转写**。读到它的人会得出相反的结论。 ### 2.3.1 ⚠ 资产可能不是"复制"而是"解引用" 规则见 `readable-source.md` §2.3.1。 ⚠ **体量是真代价**:实测一个站 313 个资产 **182 MB**,复制一份就是 182 MB。§2.2 允许"体量超出**仓库**承载"作为技术性理由,但那说的是 **git**,不是磁盘——**契约是"复制到任何地方能跑",就得真的复制**。 ### 2.4 ⭐ 交付物要自带它的验证钩子 规则见 `readable-source.md` §2.4。 自包含门的 `--full` 会说"复制出去、断网安装、构建成功",然后它自己会警告"构建通过不等于正确"。实测下一步就撞上了:复制出去那份**没法验证**——确定性 shim(`probe-shim.js`)没跟着走,`?__probe` 不注入任何东西,冻结对拍直接 FATAL。 ## 3. 四类操作,按风险从低到高 ### 3.0.1 ⭐⭐ 模块化产物:难点整体平移到「叫什么」【airpodspro】 规则见 `readable-source.md` §3.0.1。 ⛔ 规矩不变,而且在这里更硬:**没有证据就保留哈希 id。** 一个错的名字比哈希更糟——哈希会让人去看,错名会让人以为自己已经知道了。**不要为覆盖率去凑名字。** 实测 46 个模块只有 19 个拿到名字(覆盖 52% 的行数),这是正确结果,不是欠账。 #### 3.0.1.1 ⭐ 最强的证据在模块外面:属性名在压缩中活了下来 规则见 `readable-source.md` §3.0.1.1。 局部变量全被压成单字母,但**属性名不会被压缩**(它们要跨模块访问)。于是消费方的一行 `this._chapterPlayer = new M(...)` 给 M 命了名——哪怕 M 自己是个匿名 `class {}`、内部零线索。实测一个 97 行、按内部证据完全无名的模块因此拿到了真名字。 - **`X.field = M.method(...)` 命名的是方法的返回值,不是模块。** 它曾把 `breakpoint` 挂到一张 85 行的常量表上,只因为有一行 `pageMetrics.breakpoint = M.someMethod()`。只接受 `new M(...)` 与 `M(...)`——前两者用的是模块的**整体行为**,后者只用了它的一个切面。 ⚠ 还有一个不是 bug 的坑:**单个消费方的字段名命名的是用途,不是类型。** 抽验五个命名错一个——一个带 `epsilon`/`target`/`current`/`snapAtCreation` 的 33 行类是通用**阻尼标量**,只是某个调用点拿它做旋转。区分器是**佐证数**:≥2 个消费方各自选同一个名字是佐证,1 个只是当天的用法。所以单消费方要降级,并在理由里直说"这是用途的证据"。 ### 3.0.2 ⭐⭐ 模块化产物拆成文件:三条会毁掉等价性的决定【airpodspro】 规则见 `readable-source.md` §3.0.2。 **① 不要把 `require(id)` 换成静态 `import`。** 打包器的 require 是**惰性 + 记忆化**的——模块在第一次被要时才跑。ESM import 会被提升,在导入方的函数体之前就求值完毕,于是**每个模块顶层副作用的时机全部重排**。实测目标的引擎正是在模块顶层发布全局,重排直接可观测。 ⭐ **AST 负责定位,文本切片负责编辑。** 用 generator 回写会重排字节,而字节就是移植本身。⛔ 且必须同时收集 `referencePaths` **和** `constantViolations`——前者不含写入,漏掉后者会把一个绑定改一半,产出**能解析、能跑、但是错的**代码。改用作用域后:553 / 565。 ⛔ 这一条我是**从子集外推错的**:在 46 模块的竖切上量到这些助手调用次数为 0,就写下"不可达"。在 565 个模块上它们是被用的。⭐ **「我看的那部分没用到」不等于「没用到」**——同一个错误在 M(n) 冷头清点那一层被防住了,这里发生在低一层。 ### 3.0.3 ⛔ 这个形状的等价门问的是模块,不是顶层声明【airpodspro】 规则见 `readable-source.md` §3.0.3。 `verify-symbols.mjs` 的前提是"顶层声明即单位",那是**扁平拼接**产物的形状。模块容器的移植总共只有三个顶层名字、却有几百个单位,于是它报"3 条未映射 + 155 条孤儿",一句有意义的话都没说。 ### 3.0.4 ⛔⛔ 模块 id 的**类型**是容器契约的一部分【v0-optimus】 规则见 `readable-source.md` §3.0.4。 实测:Turbopack 容器里 id 是数字,模块也按数字字面量取依赖(`ctx.i(84998)`),运行时按推入的值做键。源码化发射器写了 `JSON.stringify(id)` → `"84998"`,于是查不到—— ``` Module 84998 was instantiated because it was required from module 64893, but the module factory is not available. ``` ⛔ **报错在三层之外,没有任何一个字提到引号。** 页面渲染成空白,而所有静态门(切片字节一致、外壳字节门、模块 token 门)**全绿**——它们检查的是内容,不是容器键的类型。 ### 3.0.6 ⭐⭐ 无容器 scope-hoisted 产物:不重写,切【hashgraphvc】 规则见 `readable-source.md` §3.0.6。 实测(hashgraphvc,Nuxt3+Vite,33 chunk / 44.9 万行):2,043 个部件,33/33 逐字节重拼一致, 18.9 万行的 worker chunk 拆出 751 件,3.2 万行的场景 chunk 拆出 CameraSplineSystem / WebGPUWaveSimulation / Gerstner / createInitSpectrumMaterial 等 151 件——全部是代码自己的名字。 ### 3.0.7 ⭐ 手写移植 + 冻结快照当 port/:第一段的裁判是声明级点名【samsy】 规则见 `readable-source.md` §3.0.7。 这一形态的正确裁判不是切片门(无字节可拼),是 `scripts/cold-audit-decls.mjs`:把 `_pretty` 应用区的每个深度 0 声明拿去问 port/src 的引用注释(区间含即 cited),问不到的必须进 `docs/cold-audit-overrides.json` 的一个桶(`collapsed` npm/addon/编译器产物顶替、`omitted` 登记死代码、`ported` 人工裁决点名文件)。实测 samsy 首跑 964 条里 349 条 UNKNOWN,**没有一条是缺口**:三大 vendor 与应用交错的区间(vuex / vue-router / TSL 别名块 / partysocket+uuid / three addon 的模块级作用域)、SFC 编译器提升出来的 vnode 常量、以及一整段**主线程里重复打包的 worker 模块**(retarget/packer 在 main 与 baker.worker 里各一份,port 只从 worker 那份抄了一次)。**归桶的过程就是那份评审第一次被写下来。** 引用注释的坐标形状要统一(`pretty L…`、`@L…`、`L30456-64` 短尾),头注释常常比声明少一行——`--slack 1` 是实测出来的默认值。 ### 3.1 拆模块 ⛔ 粒度不是自由选择 规则见 `readable-source.md` §3.1。 **① 环几乎必然存在,而且是结构性的**。实测 lusion:声明粒度上 **81 个环**,形态全部是 `JSONPItem -> _super$7 -> JSONPItem`——转译器的类继承惯用法。**它们不是画错的边界,是粒度选错的证据**。塌缩强连通分量(SCC)后 590 单元 → 442 模块。 ### 3.1.1 ⛔ 巨型模块可能化解不掉——一次被门推翻的尝试 规则见 `readable-source.md` §3.1.1。 三条约束跑完,lusion 得到 138 个模块,**但最大的一个吃掉 324 单元 / 11,246 行(占 79%)**。一个巨型模块等于没拆,所以要试着再切。**试了,失败了,记录如下**,因为这个失败比成功更该被下一个人读到。 **思路(听起来无懈可击)**:`class X extends Y` 的声明期只求值 `extends` 子句,是惰性的;把它单独成文件**不改变源序**(chunk 仍在原位,entry 仍按序导入)。该块内 102 个 class 共 9,070 行符合这个描述。提取后 327 个模块、中位数 24 行、最大 1,023 行——**看起来正是要的东西,而且构建通过**。 **门说不是**: ``` home FATAL: a captured frame is effectively BLANK about FATAL: a captured frame is effectively BLANK projects FATAL: a captured frame is effectively BLANK rebuild-*-frozen.png 201 色 #000000 99.5% ``` ⭐ **是非空帧前置条件抓住的,不是差异抓住的**。三条路由本来会报 `meanAbsDiff 0.00`——那是两块空画布的一致(`gate-failure-modes.md` §1.8)。**没有这条前置条件,这次失败会以完美收官的形态入库。** **根因**:`TypeError: ... is not a constructor`,前向 import 从 2 涨到 81。提取本身没错,**错在它把巨块碎成了两百多个残块**——原本块内的延迟引用(`Page` 的方法引用 `pagesManager`)此前不产生 import,碎开后变成跨块**前向 import**,把带副作用的残块提前求值,`extends` 撞上 TDZ。 ⛔ **提取规则收紧无用**:改成"只提取没有更早引用的 class"只挡掉 1 个,前向边 81→80。 ### 3.1.2 ⭐ 但"做不到"是错的——两次误判都是我自己工具里的 bug 规则见 `readable-source.md` §3.1.2。 上一节最初的结论是"**扁平脚本有一个由自身耦合决定的分解下界,可能远粗于可读**"。**这个结论是错的**,推翻它的过程比结论本身更值得读。 ⚠ **这张表最初的数字是虚高的**(0→138 / 3→376 / 6→474),因为收益曲线工具里保留着一条**已被 §3.1.2 自己证伪的规则**——"前向 import 指向惰性块无害"。修正后 baseline 与划分器完全一致(138),k=6 的 **389** 也正是实际建成并通过门验证的那个数。⛔ **一个不成立的假设必须从它存在的每一处移除,而不只是从它第一次咬人的地方**;两个工具回答同一个问题却给出不同答案(实测差 9 倍),本身就是最强的信号。 ⭐ **收益曲线在 6 个之后就平了**,地板 1,013 行是 `SVGParser`——一个真实存在的类。**六条登记偏差换来这个,而"换模块系统"要赔上 import 头和整条工具链才能拿到同样的粒度。** **但接下来连挂两次,而两次的诊断我都搞错了**: | 轮次 | 现象 | 我的判断 | 真相 | |---|---|---|---| | 1 | 6 个绑定,389 模块,前向 import **60**,页面挂 | "引擎抗拒分解" | ❌ | | 2 | 收紧到 7 个,前向 import **仍是 60**,仍挂 | "确认了下界" | ❌ | ⛔ **两次都是同一行 bug**:改写**确实生效了**(58 个文件在用 `registry.homePage`),但 **import 列表是另一个 pass 从原始 AST 统计的**,它不知道改写发生过——于是每个被改写的引用**同时**还生成一条旧的 `import`。加一句 `if (LATE.has(n)) continue;` 之后,前向 import **60 → 0**,三条路由 `meanAbsDiff 0.00`。 ⭐ **免费的外部不变量就摆在那里**:前向 import 计数在两轮"修复"里**一格没动**。**一个不随你的修复而变化的指标,说明你没在修真正的东西**——这比任何事后分析都早地指向了正确方向。 ### 3.1.2.1 ⚠ "打散巨块"和"清零前向边"是两个量 规则见 `readable-source.md` §3.1.2.1。 收益曲线回答前者,§7 的判据是后者,**两者可以差很远**: | | 一个 14k 行的站 | 一个 23k 行的站 | |---|---|---| | 打散巨块所需 | 6 个 | **1 个** | | 清零前向边所需 | 9 个 | **34 个** | ### 3.1.3 两个只有真去构建才会暴露的切分细节 规则见 `readable-source.md` §3.1.3。 - ⛔ **块与块之间不能留空隙。** 块若取 `[首单元起点, 末单元终点]`,**单元之间的空隙不属于任何块**——而**原作者幸存的注释正好住在那里**。实测 port 全文只剩 34 行注释,其中 **28 行在空隙里被静默丢弃**,即在一个以可读性为目的的阶段丢掉幸存注释的 **82%**。让块首尾相接(`chunk[k].c0 = chunk[k-1].c1`)即可。⚠ 相接后块可能**从行中开始**,任何按行号计算的插入点都要以块的实际起始行为基准重算。 ### 3.2 去混淆重命名(作用域安全) 规则见 `readable-source.md` §3.2。 **⭐ 先测证据覆盖率,再决定做多少。** 实测 lusion 6,881 个混淆局部,**63% 没有任何机械证据**——但那 63% 的构成说明它们本来就不值得命名:**2,338 个是函数参数**、**1,916 个只被引用一次**、**274 个零引用**、**3,626 个(84%)从不读任何属性**。这种绑定叫 `e` 的可读性代价接近零。**目标不是消灭所有单字母,是给读者必须追踪的绑定命名**(被反复引用的、被解引用的)。最终 1,083 个有证据可命名,2,875 个保持原样。 #### 3.2.1 ⛔ 三类"有证据却仍然是假陈述"的命名(实测抽查所得) 规则见 `readable-source.md` §3.2.1。 §4.4 说编造的名字是**门永远抓不到的唯一一类错**,所以只能人工抽查。**真去抽查了 425 条,出了三类真问题**——它们**全部通过了此前每一道门,包括逐像素 0.00**。 ⚠ **写个排序器把量级压下来,但别让它替人判定。** 实测排序器标出 68 条,其中多数是它自己的误报。它的作用是把 425 条压到读得完,判定仍然只能靠读代码。 ## 4. 门 ### 4.5 ⭐ 门第一次跑在真实数据上时,先假设红灯是门的问题 规则见 `readable-source.md` §4.5。 实测两道新门首次真跑:符号门报 3 条 FAIL、**2 条是它自己的 bug**;自包含门报 4 条、**3 条是误报**。 ## 6. 工具与门的依赖分界 ⭐ 规则见 `readable-source.md` §6。 ⚠ 这条线**被违反了八个版本才被发现**:`module-map.mjs` import 了 `@babel/*` 住在 `scripts/` 里,而禁止它的原话就在同一份文档上方三行。**只写在文档里、没有东西去查的规矩会安静失效**——`scripts/verify-zerodep.mjs` 现在两个方向都查。 ## 7. 执行顺序 规则见 `readable-source.md` §7。 ⛔ **第 0 步不是形式**。实测在一个已宣告 M(n) 收口、计划书记着"冷头评审 464/464 零遗漏"的项目上重跑门,得到的是 `FAIL 116 missing`——**没有任何东西回归**,是分层表在最后一次提交里被重新生成,而依赖它的门再没跑过。**门的绿灯会随输入过期**。同一次还发现构建命令、伺服命令、像素门命令三者都只存在于 shell 历史里,仓库内零记录;其中伺服的 `--rewrite` 是登记式变换,丢了它门照样跑、照样出数,**只是在比另一件事**。所以第 0 步的真实内容是:**把每条门固化成仓库里的脚本,然后跑,看它是不是真绿。** ## 9. 交付物不是一个页面【eightdesign】 规则见 `readable-source.md` §9。 `make-standalone` 一直假设**只有一个外壳**,默认值甚至还写着上一个项目的页面路径。 整站移植有多少条路由就有多少个外壳,而且**移植自己的产物就摆在它们旁边**—— 这里是外壳按名引用的 23 个逐字 chunk。只复制 `.html`,交付出去的站每一页都会 向一个没跟着走的脚本发请求。 ⛔ 顺带记下第四次:删除那条 `/assets/js/app.js` 的写死路径时才发现,它就在一条 抱怨"这是本阶段第三次写死上一个项目的值"的注释**下方三行**。 ### 9.1 ⛔ 引用检查必须走那一套映射,不能自己再写一遍 规则见 `readable-source.md` §9.1。 "哪些被引用的资产不在交付物里"这个检查,第一版用朴素的路径拼接来找文件,于是: | 报告缺失 | 真实原因 | |---:|---| | 524 | 丢掉了查询串,而镜像映射是**查询感知**的(`x.woff2?dpl=…` 与 `x.woff2` 是两个文件) | | 92 | 不认识 `/ext/<host>/…` 这条**伺服层约定**(磁盘上在 `assets/<host>/…`) | | 36 | 不认识**百分号转义**(`Group%20633683.svg` 就是 `Group 633683.svg`) | | 0 | 改用 `lib/urlpath.mjs` 之后 | ⭐ 每一条都是"第二个 url→path 实现"造成的。**第二个实现就是一次等着被报成窟窿的分歧。** -
recon-and-rating.md 6.8 KB
# case-studies/recon-and-rating.md — 开工侦察与难度评级 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `recon-and-rating.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `recon-and-rating.md` 一一对应。 ## 0. 侦察的产出物 规则见 `recon-and-rating.md` §0。 - **视觉验收基线** = 线上站——bundle 字面值与线上实况冲突时以实况为准(rogier 判例:bundle 写 `depthBuffer:false` 但线上人头背面遮挡正常 → 开深度缓冲并登记为有意偏差); ## 1. 架构证否:依赖表会撒谎 规则见 `recon-and-rating.md` §1。 **判例(本条纪律的来源)**:careers.kimi.com 的依赖表里有 three.js + @react-three/fiber,按直觉会判"WebGL 站"并把主力投进 3D 管线。逆向坐实的事实是:视觉主体是 DOM + 18 个 CSS 自定义属性 + `clip-path` 擦除 + 三个 2D canvas 软件像素渲染器;`<Canvas>` 只有两个且都懒加载,唯一真 3D 是正交相机 + 32 个 plane、零着色器。README 把"误判架构"列为头号风险【kimi】。 取证动作与结构性事实的实证(对应 §1 操作程序第 2、5 步): - "有 GPU compute"假设:samsy 早期指纹误判"有 GPU compute",M1 证伪——`dispatchWorkgroups` 字符串**全部来自 three 内部**,应用层从未调用【samsy】。命中字符串必须回溯归属:落在 vendor 区段还是应用区段(先画 bundle 区段地图再下结论,见 `references/reverse-engineering.md`)。 - "能力已挂载"假设:KTX2/meshopt 能力在 GLTFLoader 里但**从未挂载**;作品数是 25 条不是 26(derivative.mp4 是城市装饰屏)【samsy】。库里"有"不等于站点"用"。 - "无全局时间轴"这类结构性问题也在此阶段定案:oryzo 逆向确认滚动编排没有全局时间轴,一切进度由 DOM 几何位置推出——这直接决定动画逆向路径【oryzo】。 - 结构性事实**从产物读出,不凭框架惯例猜**:kimi 的 Next 段树形状从 RSC flight payload 读出(根 layout 挂在 `"(lang)"` 边界而非默认位置)——惯例猜测在此类站上会猜错【kimi】。 两条已判实例(证否记录格式的填法): | 假设 | 来源 | 取证动作 | 证据(带 pretty 行号) | 结论 | |---|---|---|---|---| | "这是 WebGL 站" | 依赖表含 three/r3f | 数 Canvas 挂载条件、shader 数、视觉主体驱动层 | `<Canvas>` ×2 皆懒加载、零着色器 | **证否**:DOM+CSS 变量站【kimi】 | | "有 GPU compute" | grep 命中 dispatchWorkgroups | 命中回溯 bundle 区段归属 | 全部落在 three vendor 区段 | **证否**:应用层零调用【samsy】 | ## 2. signature grep:只能提假设,不能当结论 规则见 `recon-and-rating.md` §2。 1. **每条命中回上下文确认**。实测误命中:`leva` 命中的是 React SVG 属性列表里的 `…decelerate|descent…`;`swr` 同为子串误命中【kimi】。 2. **误命中的反向也存在**:`zustand` 确实在用,只是被 r3f 内联——grep 命不中不代表不存在(API 指纹反而可坐实:`getInitialState` 无 `destroy` ⇒ zustand v5)【kimi】。 4. **命中归属靠 bundle 区段地图**:判断"命中在 vendor 还是应用区段"的前提是先画区段地图——lando 对全 47k 行 pretty bundle 逐段标行号(GSAP 5043-6743、three 10334-30143、Lenis 46469-47010 为 vendor;home 44665-45000、taxi 装配 46377-46467 为应用代码),"先画地图再挖矿"【lando】【samsy】。区段地图的完整做法见 `references/reverse-engineering.md`;侦察阶段至少要把 vendor 边界粗标出来,否则 §1 的归属判断无从谈起。 四类版本证据各自的原始出处: 技术栈版本的坐实标准(六项目一致【6/6】):版本字符串(`versions:{get nuxt(){return"4.2.1"}`【noomo】、`window.next={version:"16.1.6"}`【kimi】)、pnpm 路径泄漏(一次钉死 next/react/babel/sass 四个版本【kimi】)、wasm URL(Rive 版本取证【lando】)、API 指纹。传递依赖必要时用 overrides 钉死(unhead 2.0.17 vs 2.1.17 会反转脚本顺序——"同一框架版本不等于同一输出")【noomo】。 ## 3. 分项难度评级与横向对标【lando】 规则见 `recon-and-rating.md` §3。 打星纪律的实证: - 每一星级写一句"为什么",引用镜像/bundle 证据,不凭平台名/框架名印象(webflow.com 被预判不适用,实测有手写 GSAP/three.js bundle 判 A【probe】)。 - **素材版权单独评估且经常是最高星**:oryzo 与 kimi 都评 ★★★★★,"最大风险是法务不是技术"【oryzo】【kimi】;lando 同样"素材版权 ★★★★★ 远大于技术",因此**开工就按安全默认执行"私有仓库 + 不公开部署"**并写进 DEPLOY.md【lando】。 注意工期从 6.5 周收敛到 1 天靠的是方法论成熟,不是站变简单——首次执行按保守端估。 横向对标锚点(六项目谱系,用于工期预估): | 前作 | 原站类型 | 规模/工期 | |---|---|---| | rogierdeboeve | Three.js 多场景 WebGL 作品集 | 699 commits / 约 6.5 周【rogier】 | | oryzo | Lusion WebGL2 滚动叙事单页(46,000px) | 47 commits / 约 3 天【oryzo】 | | samsyninja | Vue3 + WebGPU/TSL 3D 小城(78,409 行 bundle) | 41 commits / 2 天【samsy】 | | careers-kimi | Next.js 16 像素风 DOM 站(非 WebGL) | 33 commits / 2 天【kimi】 | | storytellingnoomo | Nuxt 4 SSR + GLB 烘焙滚动叙事 | 30 commits / 2 天【noomo】 | | landonorris | Webflow 外壳 + 1.3MB 自定义 bundle | 15 commits / 1 天【lando】 | 攻坚顺序:星多的分项先**竖切一条端到端链路**验证可行性(oryzo 先打通 hero 场景完整链路再铺开【oryzo】)。 ## 4. 三判据复核(与第 0 步衔接) 规则见 `recon-and-rating.md` §4。 若第 0 步在框架标记(`__NUXT__`/`data-v-` 等)命中下judged A,侦察阶段用 bundle 实物复核三判据(定义见 `references/scope-and-fingerprint.md` §4):签名动画确实以客户端命令式代码存在(noomo:GSAP 在 entry.js 与独立 chunk,40 处命中)【probe】。复核不过 → 回到第 0 步重新判级,而不是硬做。 ## 6. 常见坑 规则见 `recon-and-rating.md` §6。 - **"目测近似先跑通"的技术债**:oryzo 曾用目测近似实现先跑通,随后必须整体替换为溯源版(M2.3 三轮 commit 重做)——侦察阶段把事实来源定清楚,能避免这次返工【oryzo】。 - **凭平台名/框架名预判难度**:webflow.com 被预判不适用,实测判 A【probe】;Nuxt 站也可以完全适用(noomo 三判据)【probe】。评级只认取证。 - 实证:某项目以"产出永不公开"为由少抓一类资产,**缺了约 60% 而五道门全绿**(`mirroring.md` §5.1)【objectarchive】。 -
reverse-engineering.md 19.3 KB
# case-studies/reverse-engineering.md — 逆向建坐标系(阶段 1:Reverse) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `reverse-engineering.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `reverse-engineering.md` 一一对应。 ## 0.35 ⚠ 诊断输出不要截断标识符【airpodspro】 规则见 `reverse-engineering.md` §0.35。 工具为可读把模块 id 截到 10 位打印,那个截断值随后被当作完整 id 写进了配置。**截断的标识符会被原样复制回去**——这不是使用者不小心,是输出的诱导。 ⛔ 而下游**静默丢弃了未知 id**(`filter(map.has)`),于是它照常打印一个合理的模块数、切片照常成功,失败推迟到运行时的 `Cannot read properties of undefined`,离根因隔了三层。 ## 0.4 ⛔ 关键词计数只缩小范围,不下定位结论【airpodspro】 规则见 `reverse-engineering.md` §0.4。 用计数找签名行为的落点是对的,**把计数结果当成定位结论是错的**。实测一次完整的误判: - 按 `scrubber=27` 把签名行为定位到一个 85 行的模块,写进了竖切计划; - 读代码后发现那是**视频播放器进度条的缩略图预览**,平台层的播放器 UI,与滚动无关; - ⭐ **否证就在同一张表的同一行里:`scrubber=27 scroll=0`。** 一个"滚动驱动视频"的候选出现 `scroll=0`,本身就是结论——数字被读到了,含义没有。 ⭐ **而真实机制藏在一个 11 行的模块里**——一张补间引擎的属性表: ```js domAttributes: ["scrollLeft", "scrollTop", "scrollBy", "scrollTo", "currentTime"] ``` `currentTime` 只是一个**可补间属性**,和 `opacity` 走同一条通路。所谓"视频洗刷子系统"根本不存在。**信息密度最高的模块可能是最小的那个,而计数会把它排到最后。** ## 0.45 ⚠ "自研 vs 用库",bundle 外部计数给不出答案【airpodspro】 规则见 `reverse-engineering.md` §0.45。 Step 0 依据"81 处 `requestAnimationFrame`、0 处 `gsap`"判为"自研命令式引擎"。没错,但**不完整**:站点建立在源站自己的组件框架上(13 个模块引用框架对象、65 个模块是组件式继承/注册),**框架层与业务层打进了同一个 bundle**。 ## 0.5 ⛔ 先判 bundle 形态,再选工具【airpodspro】 规则见 `reverse-engineering.md` §0.5。 分层表扫的是**顶层声明**,而那个前提只对**扁平拼接**的 bundle 成立。前四个实测项目恰好都是那一种,于是这个前提从未被检验。换成 webpack 打包产物后它**零命中**——不是少扫,是一个都没有。 #### 0.5.1 ⛔ 两种模块容器语法,一个读错会**安静地**给你一张小得离谱的表【airpodspro】【v0-optimus】 规则见 `reverse-engineering.md` §0.5.1。 3. ⛔⛔ **读错容器时工具会"成功"。** 实测:webpack 读取器指向一个 Turbopack chunk,找到两处不相干的 `key: function` 属性,报告 **2 个模块**并打印愉快的摘要——而该文件真实有 20 个工厂、45 处 require 调用。 实测在一个无容器的 vendor chunk 上:覆盖 7%、239 处 require 调用对 0 条边 → 正确 FATAL(exit 5)。 ⛔ **两个读取器必须都跑完再裁决,不能让第一个先 exit。** 第一版把 Turbopack 探测放在 webpack 分支之后,而 webpack 分支找不到容器时直接 `process.exit`——于是一个 Turbopack chunk 因为"webpack 形状的属性不足两处"被判成无容器,而它的容器就在第 1 行。⚠ 更糟的是:**其余 chunk 之所以能通过,只是因为它们恰好含有两处不相干的 `key: function`**——那是巧合,不是代码路径。 ⚠ 只取紧挨工厂的那一个,会**静默丢掉其余全部**。实测一个站的 5 个 chunk 里有 **10 个别名 id** 被丢;症状是运行时抛 `module 73692 … the module factory is not available`,**而那个 chunk 切片是字节一致的、静态门全绿**——因为丢的不是字节,是**注册表里的键**。 ⭐ 副产品:**Turbopack 只给站点自有模块写导出名,vendor 模块没有。** 实测 192 个模块里 18 个有导出名,而这 18 个恰好就是站点自己的组件(`HeroSection` / `Navigation` / `PricingSection` …)。这比任何"按体积/按目录"的启发式都干净——**分层证据由打包器直接给出**。 ⭐ **模块化打包产物的模块边界与依赖边是给定的**——打包器已经写下了它们。实测一个 24,378 行的 bundle:**569 个不同模块**(容器里 597 条属性,28 条被同名键遮蔽),181 个叶子、416 个有依赖,最大 1,544 行,`requires` 直接可读。 ⚠ **认不出容器时必须 FATAL,且禁止回退到分层表。** 分层表对模块化 bundle 会安静地返回"0 个声明",下游会读成"这个 bundle 是空的"。**一个错的单位边界是静默的 65% 误差**(四层模型那次实证)。 ## ⭐ 分层表必须认「顶层裸语句」,否则一行配置会继承邻居的层【lusion】 规则见 `reverse-engineering.md` 同名节。 **实测代价**【lusion】: ``` _pretty L16964: ColorManagement.enabled = !1; ← 站点关掉 three 的色彩管理 ``` 它落在 detect-ua 那段 vendor 区间的**最后一行**。移植产物里没有它,three r158 的默认色彩管理生效: | | 症状 | |---|---| | 网格差异 | **2,335 / 2,560** 格 | | 通道均值差 | R **−11.3** / G **−13.3** / B **−7.5**(不等比 → 色彩管理签名,不是亮度常数) | | 差异形状 | **整帧**普遍偏移 + 一个热点,**没有任何一处指向"一行赋值"** | | 可复现性 | 跨侧三次 `10.56 / 30.7 / [46,11]` **逐位相同** | ⭐ **一行赋值造出的差异,长得和"某个子系统没移植"一模一样**——而且更难查,因为整帧都在动。 **两条纪律**: 1. **扫描器要同时认声明与顶层裸语句**(`X.y = …` / `f(…)` 在第 0 列)。本 bundle 里有 **151 条** 这样的语句——它们不是边角料,配置、注册、单例初始化都长这样。 2. **vendor 区间的边界要精确到行**,且**边界处的裸语句默认归自研**:库的结尾之后紧跟一行配置 是极常见的打包形态。本例区间多划一行,代价是整个复刻站的色彩。 ## 0. 预检:先问"有没有 bundle",再判 bundle 形态 规则见 `reverse-engineering.md` §0。 **本文件的主干(`_pretty/` 行号坐标系、混淆别名表、区段地图、vendor 岛、字节切片器)整体建立在"签名行为住在可下载的 bundle 里"这个前提上。** 前八个项目无一例外满足它;objectandarchive 第一次不满足——行为住在 Liquid 渲染出来的 62 个内联 `<script>` 块里,且是**带作者注释的未压缩源码**【objectarchive】。所以预检先问载体,再问形态;命中"无 bundle"就走 §0.1 的**平行分支**。 边界探测实录(形态表的原始行): | 形态 | 判据 | 流程分支 | |---|---|---| | **未混淆 esbuild 产物** | 标识符全保留、自带换行缩进——开头即 `var __defProp = Object.defineProperty;`、内部函数名(如 `copyAttributeData`)原样可读 | **跳过 beautify**,直接以原文件行号为坐标系(边界探测实录:bruno-simon 4.86MB 产物、star-atlas 均属此类) | | 带公开 sourcemap 且 sourcesContent 完整 | map 可下载且含完整源码 | 直取 sourcesContent 替代 beautify(边界探测实录:orano) | | **手写多文件站(2013 时代,无打包器)** | 每 `<script>` 一文件、原始命名;CoffeeScript 特征(`_i/_len/_ref`、`(function(){}).call(this)`)、Compass 行号注释 | **跳过 beautify 并把这次跳过登记进日志**("行号指 \_pretty"是全库默认约定,静默跳过会让后来者找错文件);坐标系 = mirror 原文件行号。⭐ **先做 vendor 逐字节鉴真**:站上的库文件与上游官方 release 直接 diff(skrollr 0.6.11 diff 为空、jquery.min sha1 与官方 CDN 一致)——一次 diff 杀掉整棵"站方魔改库"假设树,剩下的应用文件就是全部逆向面【firstlaunch】 | ### 0.1 无 bundle 站:平行分支(不是替代分支)【objectarchive】 规则见 `reverse-engineering.md` §0.1。 **两条分支并列。** 判据是"签名行为的**载体**是什么",不是站的好坏、也不是方法论偏好。同一个项目里两者常常并存——objectandarchive 的 vendor(gsap / ScrollTrigger / lenis / jQuery)是 CDN 上的独立文件,自研行为在内联块里——此时**按载体分别建坐标系**:需要读的 vendor 文件按 §1 走 `_pretty/`,内联块按本节走内容哈希。 #### 0.1.1 坐标系:内容哈希作主坐标,行号降级为快照内导航 规则见 `reverse-engineering.md` §0.1.1。 **朴素方案"行号建在镜像 HTML 上"实测不成立。** objectandarchive 抓了六份同一路由的 HTML(同时刻双抓 / 60 秒间隔三抓 / 移动 UA / `Accept-Language: fr-FR` / 隔一天),全部 486,622 字节 10,410 行,逐块比: | 比较 | 块数(首页全部 `<script>`,含外链) | sha 不同 | 起始行不同 | |---|---|---|---| | 同时刻双抓 · 60 秒间隔 · 换语言 | 80 | 0 | 0 | | 换移动 UA | 80 | 4(**全是 nonce 字段**) | 0 | | **隔一天(跨 CDN 缓存条目)** | 80 | 12 | **4** | 隔天那 4 处行号差异不是内容变化,**是两个平台 app-embed 块换了注入顺序**:Hulk Form Builder 昨天是第 44 块(L850),今天是第 41 块(L817)。掩掉 nonce 后整页 diff 只有 87 行、全部落在 app-embed 区段内、总行数不变——**同一份内容,两种注入顺序**。 **旁证(免费得到的正确性检查)**:同一个块在三条路由上出现在完全不同的行号——Lenis+GSAP 脊柱 `B:41e7f747ed2a` 在首页 L9564-9687 / collection L8468-8591 / product L11658-11781——而 `B:` 值一致。按行号编目会记成 9 条互不相干的条目;按内容哈希编目自动收敛成 3 条,且"三页共用同一份实现"这个事实白送。 ## 1. 建立 `_pretty/` 行号坐标系 ### 1.1 展开命令(版本钉死 1.15.1) 规则见 `reverse-engineering.md` §1.1。 - 多 chunk 站(Next 等)把**全部 chunk 逐个展开**(kimi 展开 21 个 chunk 共 57,068 行)【kimi】。 - 版本沿革:samsy 首次把版本钉死制度明文化(当时 2.0.3),kimi/noomo/lando 三代统一 1.15.1——本 skill 钉 **1.15.1**,不要用别的版本【samsy】【kimi】【noomo】【lando】。 ### 1.3 行号引用格式(全项目唯一坐标系) 规则见 `reverse-engineering.md` §1.3。 - 里程碑日志的"下一步断点待办"——如 samsy M7a 待办直接写 "字体管理器 **pretty L60740-L60844(未读)**",跨会话交接靠它【samsy】; - 实践规模参考:oryzo 107 处 / samsy 276 处 / noomo 161 处 / lando 400+ 处行号引用【oryzo】【samsy】【noomo】【lando】。 ### 1.4 坐标系稳定性是 M1 的第一道必答题(两个分支通用)【objectarchive】 规则见 `reverse-engineering.md` §1.4。 - **结论写成区段级,不是全局级**:objectandarchive 的答案不是"稳/不稳",而是"**平台 app-embed 区段不稳,其余(含全部 26 个自研块)稳**"。这个更细的答案才可用——自研块的行号可以放心当导航坐标;而它只有把"同缓存条目 / 跨缓存条目"当成两个自变量分开抓才看得见。 **为什么必须前置**:objectandarchive 在 Step 0 预登记、M1 开头证伪,于是**在写第一行移植代码之前**就换掉了坐标方案,代价是半天。**同样的发现若拖到 M2 中途才撞上,笔记、移植文件头注释、里程碑待办、怪癖/偏差表里的坐标引用早已铺开(前作规模 107–400+ 处),一次性全部作废且无法自动修复**——与"beautifier 版本漂移"是同一类灾难(§1 ⛔),只是触发源不同。 ## 2. 逆向笔记 `docs/engine-notes.md` 先行 规则见 `reverse-engineering.md` §2。 **独立里程碑,产出并提交这份笔记之前不写任何复刻代码**——oryzo 把它列为 M2.0,"文档先行显著降低了后面每轮的返工"【oryzo】;后四代全部沿用【samsy】【kimi】【noomo】【lando(6 份笔记 00-05)】。 ### 2.1 三段式内容结构 规则见 `reverse-engineering.md` §2.1。 - **bundle 区段地图**:vendor 边界逐段标行号——lando 给 47k 行画了全区段地图(GSAP 5043-6743、three 10334-30143、Lenis 46469-47010、应用代码各段),"先画地图再挖矿"【lando】;samsy 同样逐段标 vendor 边界【samsy】。**边界怎么划见 §2.2——只按 license banner 划会错**【shopifydesign】; - 渲染管线、RenderTarget 清单、材质清单(samsy 26 项 TSL 材质、后处理链逐步拆解)【samsy】; - **无 bundle 站的等价物**:区段地图换成**内联块普查表**(逐块:语义 id / 层归属 / 字节 / `B:<sha12>` / 各页行号 / 首条作者注释)。它同时承担 §2.2 的职责——**应用层规模 = 归属为"站点自研"的那些块**,平台层与上游主题存量都要从规模统计里扣掉,否则任务表虚高(objectandarchive 若把 Dawn 存量算进去,虚高 65%)【objectarchive】。 **第二段:怪癖清单(照抄不修)**:源站 bug / 死代码 / 怪写法逐条登记并带坐标(行号或 `B:`),移植时逐字照抄。规模参考:noomo Q1–Q14、samsy 13 条、kimi 26 条【noomo】【samsy】【kimi】。 **第三段:对复刻的直接结论**:如 noomo 的 10 条("先实现三个元系统再写任何材质"、"缺 colorsMap 玻璃会变灰白")【noomo】;samsy 的"不要发明"清单(engine-notes §16)【samsy】。 ### 2.2 区段地图的边界校准(license banner 只给起点)【shopifydesign】 规则见 `reverse-engineering.md` §2.2。 **vendor 区不是连续的一块,license banner 也不标终点。** shopify.design 初版按"最后一段 license banner"定 vendor 边界,把应用起点标在 L28141,**错了 5,832 行**——真实边界是 L22309:three.js 的**后处理 addon**(`Pass` L22057 / `ShaderPass` L22091 / `EffectComposer` L22128 / `RenderPass` L22198 / `OutputPass` L22293)整段排在最后一段 license 之后,而应用区间内部还夹着三座 **vendor 岛**(troika-three-text L23527–L26525、SVGLoader L28869–L29895、GLTFLoader+DRACOLoader L31727–L33785)。 1. **起点用 banner,终点用 `class X extends Y` 的收尾校准**:banner 之后继续往下扫到最后一个 vendor 类定义的闭合处(本站 `class c3 extends sc` L22292),再往下第一处**应用配置常量/魔数**才是真起点(本站 `const ac = 800, Pn = 0, v1 = 50 …` L22309——设计基准高度、相机 Y 这类值只可能是应用配置)。 **下游代价**(为什么这不是洁癖):区段地图错 → 应用层规模误判(本站虚高 5,832 行)→ **难度评级与工期估算一起偏**;且后续每一次"这段要不要移植"的判断都建在错的坐标上,返工时整片行号引用作废。 ### 2.3 笔记纪律 规则见 `reverse-engineering.md` §2.3。 - **上一阶段(Step 0)的数字与附带结论一律当假设复核**,不要直接抄进笔记——shopify.design 的 Step 0 判级正确,但附带的路由数、资产数、漏抓归因三条全被 M0 证伪【shopifydesign】。 ## 3. 技术栈从 bundle 取证、精确钉死【6/6】 ### 3.2 钉死落地 规则见 `reverse-engineering.md` §3.2。 - **传递依赖也要钉**:noomo 用 `overrides` 钉 unhead 2.0.17——2.1.17 会反转 bodyClose 脚本顺序、破坏与源站的尾部字节序,"同一 Nuxt 版本不等于同一输出,传递依赖也要对齐"【noomo】; - 源站用 dev 分支时取最接近正式版并**登记为偏差**(samsy:源站 three r182dev → 复刻 0.182.0)【samsy】; ## 4. 证伪流程:假设必须先证否 ### 4.1 signature grep 只提假设,不当结论 规则见 `reverse-engineering.md` §4.1。 grep 命中只是假设,**每条必须回上下文确认**;**计数同理**——`grep -c` 数的是匹配行数不是出现次数,且 vendor 自带字符串(报错串、内置 shader chunk)会把应用层用量抬高一个数量级(shopify.design:`ScrollTrigger ×8` 实为 0 次真实使用、`GLSL ×107` 实为应用层 27 段)。这条纪律已前移复述到 Step 0,见 `references/scope-and-fingerprint.md` §2《计数硬约束》【shopifydesign】。逐条实例:kimi 站 grep 到 `leva` 实为 React SVG 属性列表里 `…decelerate|descent…` 的子串误命中,`swr` 同类;`zustand` 反而真实存在只是被内联【kimi】。samsy 早期指纹误判"有 GPU compute",M1 证伪——`dispatchWorkgroups` 字符串全部来自 three 内部;KTX2/meshopt 能力在 GLTFLoader 里但从未挂载【samsy】。 ### 4.2 架构假设先证否再动工 规则见 `reverse-engineering.md` §4.2。 最强案例【kimi】:依赖表里有 three.js + r3f,但**这不是 WebGL 站**——视觉主体是 DOM + 18 个 CSS 自定义属性 + `clip-path` 擦除 + 三个 2D canvas 软件渲染器;`<Canvas>` 只有两个且都懒加载,唯一真 3D 是正交相机 + 32 个 plane、零着色器。"这个误判如果没在动手前发现,会把绝大部分力气花在极小部分画面上"。 ## 5. grep 混淆代码:搜值不搜名 规则见 `reverse-engineering.md` §5。 - **常量名会被混淆重命名**:three 的 `REVISION` 在 noomo 的 bundle 里搜不到,最终靠常量值 `const nv="179"`(`_pretty` L19973)锁定版本【noomo】。 ## 6. 数据驱动动画:先 dump 成数值账本 规则见 `reverse-engineering.md` §6。 - **GLB 烘焙曲线**:手写解析器 dump 全部动画曲线(noomo `docs/timeline-baseline/` 2.4MB:dev.glb 38 条参数轨道 ×481 帧、cam.glb 相机 601 帧);后续验收即"相机位置在 t=0/5/10/19 与基准插值小数点后三位全等"【noomo】; **基准覆盖面判据**:录之前先确认"观感由哪些量驱动",把全部驱动量采进基准——kimi 只采 `<main>` 上 18 个变量,位置 3.2 之后变量饱和、场景 3-7 实由容器 opacity 驱动,基准"完全失明";补采 opacity 后覆盖立刻到 8.2【kimi】。 ## §0.5.2 ⛔ Turbopack 的运行时自带按文件名索引的 chunk 清单【raycastkbd】 规则见 `reverse-engineering.md` §0.5.2。 eightdesign 的容器图是**平的**:每个 chunk 的名字都出现在 HTML 的 flight 清单里, 把外壳里的名字改成 `.port.js` 就完成了移植交付,跨侧 0.00。 raycast.com 不是。它的 **Turbopack 运行时 chunk(`turbopack-*.js`)内部嵌着一份 按原始文件名索引的 chunk 清单**,动态导入按它解析路径;另有 chunk 之间按原名交叉引用 (实测 8 处)。把外壳与 flight 里的名字改成 `.port.js` 之后: - flight 驱动的水合加载 `.port.js`(移植件) - 运行时清单驱动的动态导入**仍按原名再加载一遍原件** - 同一批模块**被求值两次**,单例状态分裂 ⚠ 症状与病灶隔了三层:导航的登录/下载按钮消失、一个 canvas 不再挂载、 **控制台零报错**——没有崩溃,只有两份互不相识的 store。二分 22 个 chunk 全都"有问题", 才看清不是某个 chunk 坏了,是**改名机制本身**在这个形状下不成立。 -
rsc-reconstruction.md 3.5 KB
# case-studies/rsc-reconstruction.md — RSC 重构式逆向(C1)— flight 载荷到可构建源码 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `rsc-reconstruction.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `rsc-reconstruction.md` 一一对应。 ## §1 坐标系:flight 流(C1 的 `_pretty/`) ### §1.1 flight 是保真神谕(比 DOM 更细的证据面) 规则见 `rsc-reconstruction.md` §1.1。 1. ⭐ **键序 = JSX prop 序**。flight 按源码 prop 顺序序列化,两侧键序不同 = 你的 prop 写序和作者不同(实测靠它照出脚注反链的 href/className/id 顺序)。 4. ⭐ 标题文本尾空格 + 独立 id 锚 → 还原 `## 标题 [#custom-id]` 源约定 (99/101 个标题带作者自选 id,剥离 `[#id]` 后的空格就是化石)。 5. ⭐ **作者的不一致本身是保真面**:同站两篇脚注一有 `"\n"` 分隔一没有、 一页整个忘写 metadata、og:title 写错——照抄,不"修好"。线上 bug 也一样 (对活源站复测是最强豁免证据)。 ## §3 重构工程(rebuild/ = 可构建的 Next 工程) ### §3.2 CSS 面:tailwind 扫描面与 token 必须对着镜像编译 CSS 对账【basement】 规则见 `rsc-reconstruction.md` §3.2。 语义门只看 flight 树,**看不见 CSS**——重建工程的样式面是独立债务,且塌法极具 迷惑性:白色 SVG logo 因 `text-*` 未生成变黑底黑字"消失"、grid 塌成换行、 自定义字体回退系统 sans、reveal 幕布类缺失导致整页盖黑(machine 模式黑屏 = `.machine-reveal` 只有 keyframes 没有类规则)。四轮用户实测报障同一根因: 3. ⭐ **carry-css 方法论**(tailwind 生成不了的规则,机器搬运不手抄): 需求面 = 代表路由 SSR DOM 类名并集——**必须覆盖每个路由家族,含备用 模式家族**(basement 的 /ai 机器可读镜像有独立配色与幕布,漏采样 = 该 家族类全缺);减去构建产物已有的类;剩余到镜像 CSS 逐条找规则原文搬运: 4. **镜像里也无规则的类 = 源站自身死类**(`bg-brank-k` 拼写错、`text-caption` 等 16 个实测)——照抄不修(§1.3),报告里点名即可。 ### §3.5 next/image 优化器产物是像素门的一层资产【darkroom】 规则见 `rsc-reconstruction.md` §3.5。 镜像侧持有的是 **Vercel 优化器的输出**(`/_next/image?url=…&w=1440&q=…`,实测 naturalWidth 1280); 重建的静态树没有优化器,serve 回落到原图(2592 宽)——两侧源分辨率不同,浏览器重采样差就是 looped/badomens 0.2 的残差。两件事分开做:① `images.deviceSizes/imageSizes/qualities` 从镜像 srcset 普查**反推**进 next.config(⚠ `qualities` 默认 `[75]` 会把源站的 `quality=90` 静默压回 75); ② `tools/harvest-optimized-images.mjs` 把静态树引用的全部 `/_next/image` 档位补齐——**镜像字节 优先**(源站发了什么才是参照,动态图片生成器只拿得到输出字节,§6),镜像没有的档位才向本机 `next start` 的优化器取并登记为重建侧生成物(darkroom:镜像 55 + 本机 936 → 0.00)。 ## §5 平台层工件(登记,不复刻) 规则见 `rsc-reconstruction.md` §5。 - ⭐ **Vercel 边缘把 / 重写到 /index**:镜像首页 `c:["","index"]`、SSR 里 usePathname 撞见 "/index"(Logo 渲染成回链)、客户端水合撞 "/" → **线上的 React #418 就是这么来的**。静态预渲染侧 `c:["",""]`、无水合错误——登记 D 类偏差;要逐字节复刻线上 bug 得加边缘重写,通常不值得。 -
sanity-platform.md 3.3 KB
# case-studies/sanity-platform.md — Sanity CMS 场景(Next/Nuxt 创意站的主流内容层) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `sanity-platform.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `sanity-platform.md` 一一对应。 ## 0. 指纹与判级:Sanity 本身不定级,内容烘焙时点才定级 规则见 `sanity-platform.md` §0。 - 图片:`https://cdn.sanity.io/images/<projectId>/<dataset>/<sha1-40>-<W>x<H>.<ext>[?w=&h=&fit=&auto=format…]` (basement=`9syto90m`、hashgraphvc=`diak0tmr`、franshalsmuseum=`r35o2ddl`;dataset 常为 `production`) ⭐ **文件名自带元数据**:`<sha1>-<W>x<H>` 里的 WxH 是**源资产内在尺寸**(查询参数只做缩放裁剪), sha1 是内容地址——"多少个不同图"的清点、变体归并、资产去重对账,直接按 hash 段做 (basement:13,870 条响应式引用按 hash 收敛成 722 个源资产)【basement】。 ## 1. 镜像层 ### 1.1 `--hosts` 清单(CDN 站假 GAP=0 的老课,Sanity 版) 规则见 `sanity-platform.md` §1.1。 netcapture / mirror-site 的外部主机清单必含(按站取舍):`cdn.sanity.io`、 `<projectId>.api.sanity.io`、`<projectId>.apicdn.sanity.io`。hashgraphvc 实例: `--ext-hosts cdn.sanity.io,diak0tmr.api.sanity.io`【hashgraphvc】。 ### 1.2 ⛔ `auto=format` 是内容协商:裸 fetch 与浏览器拿到的是两种字节 规则见 `sanity-platform.md` §1.2。 实证【basement,D5 全量定案】:魔数普查 391 个 `@@auto=format` 变体,59 个扩展名↔魔数 分叉(56 `webp→jpeg`、3 `webp→png`——webp 源被转码回退,如 `…-1920x833@@auto=format &w=1200.webp` 魔数 JPEG);**双 Accept 采样 6/6 全分叉**——jpg/png 源在浏览器 Accept 下同样返回 webp(645KB png→54KB、**1.13MB png→61KB**)。即**分叉面是全部栅格变体, 不止扩展名穿帮的那 59 个**:魔数普查只看得见协商跨过扩展名边界的尖角,量化全貌必须 双 Accept 采样。三个配套事实: - **浏览器协商结果是一个分布,不是一种格式**:14islands 616 变体 604 webp / 4 avif / 8 png; basement 全量重抓 391 变体 **311 webp / 79 avif** / 1 svg(3840×2160 大图多,avif 份额 随站与资产尺寸变)——basement 采样 6 全 webp 曾让"未见 avif"成为论断,样本放大即修正 【14islands】【basement】。 实测 391/391、217MB→39.5MB、新树五项全绿(项目侧 `scripts/regrab-negotiated.mjs` 可参照)。 ## 4. 开工速查卡:Next + Vercel + Sanity 创意栈 规则见 `sanity-platform.md` §4。 (遥测,通常 D5 登记不抓)。⚠ **预设不会自己进命令行**——14islands 实测:本卡写着 mux 族,netcapture 命令里漏传,断网 sweep 才在 100/100 路由上把它报出来。开工时把 **运行时资源族清单**(BFS 看不见、netcapture/推导要补的;⭐ **能从字节推导的先推导,再拿 netcapture 对账**——14islands 实测:webpack runtime 的 `h.u`(chunk id→hash 表)+ `h.miniCssF` + `_buildManifest` 推出 28 chunk + 11 css + 9 页 chunk,其中 28 条是预览分支的死 chunk,任何路由 都跑不到;`_next/data/<buildId>/<route>.json` 按路由表推导 98 条得 95):`?_rsc=` 预取载荷、 -
scope-and-fingerprint.md 10.4 KB
# case-studies/scope-and-fingerprint.md — 第 0 步:范围判定与指纹路由(⛔ 阻塞门) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `scope-and-fingerprint.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `scope-and-fingerprint.md` 一一对应。 ## 2. 指纹探测流程(六步,curl-only 可执行) 规则见 `scope-and-fingerprint.md` §2;下列各步只记实测出处。 ### 步骤 1:存活性(GET,路径粒度) - **必须 GET,禁止用 HEAD(`curl -sIL`)作唯一判据**:API Gateway/CloudFront 前端对 HEAD 返回假 404(kprverse:HEAD 404 / GET 200)【probe】。 - **路径粒度**:根域 200 不等于作品存活——dontboardme 根站 200 但获奖路径 404【probe】。 - **最终 URL 同一性校验**:`final` 落点域 ≠ 目标域即 X 信号(darknetflix 301→netflix.com、umami-land 整域 301→google.com)【probe】。`curl -sIL` 表面 200 会掩盖 301 退役信号。 ### 步骤 2:双抓 diff(确定性) - **byte-identical**:理想镜像对象(apple、noomo 的 SSR 输出连 26KB 注水负载都逐字节一致)【probe】。 - **token 级差异**:仅 nonce/随机装饰串(kprverse 全文只差 12 个字符的装饰性编号;某些 WAF/CDN 每次注入的轮换 token 同类)→ 仍可镜像,验收门加掩码规则,**不要误判为动态渲染判 D**【probe】。 - **内容级差异**:文案/结构/数据随请求变(A/B 分桶、个性化注水)→ D 信号(airbnb)【probe】。 ### 步骤 3:物种/年代校验(防"隐性下线") - **技术栈年代与获奖年份矛盾** → X(dontboardme 根站已是 Nuxt3 重建版)【probe】。 - **generator/依赖 license 年份晚于获奖期 + 获奖期技术栈残留 grep 为零** → 隐性下线判 X(prometheus-fuels 域名 200 但已换成 WordPress+Elementor,原 WebGL 站残留为零;simply-chocolate 原域名原品牌但代码已是 Shopify Prestige 主题;koox 根 200 是 Shopify 替身)【probe】。 - 域名活 ≠ 作品活:star-atlas 获奖原版被重建版**原地偷换**(域名不变)——如需复刻"获奖那一版",须提示用户走 Wayback【probe】。 ### 步骤 5:bundle 可逆向性 - 有公开 sourcemap(`sourcesContent` 完整,如 orano/linear)→ 直取源码替代 beautify 流程,但 linear 型 RSC 站仍按 C 处理(sourcemap 不改变行为归属)【probe】。 - 未混淆产物(bruno-simon 4.86MB esbuild 标识符全保留、star-atlas)→ 跳过 js-beautify,行号坐标系直接建在原文件上【probe】。 ### ⛔ 计数硬约束(贯穿步骤 4/5;任何进难度评级表的数字必须先过这三条)【shopifydesign】 shopify.design 的 Step 0 用 `tr ';{}' '\n' | grep -c` 数 token,一次产出三个假数字,且**直接进了难度评级表**——"滚动/动画编排 ★★★"整条建立在一个根本没被使用的 ScrollTrigger 上: | Step 0 报的 | 逆向后的实际 | |---|---| | `ScrollTrigger` ×8 | **0 次真实使用**——全是 gsap core 对未注册插件的兜底钩子,插件从未 `registerPlugin` | | `WebGLRenderer` ×33 | **1 处真实构造**;其余多为 three.js 自带的 `"THREE.WebGLRenderer: …"` 报错字符串 | | 内联 GLSL ×107 | 应用层 **27 段**——`gl_FragColor` / `gl_Position` 命中了 three.js 自带的 shader chunk 库 | - 第 1 条的复核数字:独立复核实测同一 bundle:`ScrollTrigger` 原始字面量出现 **2** 次而 `grep -c` 报 1;`WebGLRenderer` 出现 **34** 次而 `grep -c` 报 33。 - 第 2 条的归属数字:**vendor 库自带字符串会污染计数。** 上述 34 次 `WebGLRenderer` 里 **7** 次是 three.js 自己的 `"THREE.WebGLRenderer: …"` 报错串;GLSL 命中绝大多数来自 three 内置 shader chunk 库。 ## 3. 判定树(按序执行,命中即停) 规则见 `scope-and-fingerprint.md` §3。 - 分层判级的实例:杂交站可分层判级:kprverse 整体 C,但 three 子层(独立 chunk 的命令式代码)可局部按 A 手法转写【probe】。 **【lamalama】D 信号第 1 条按字面命中、理由不成立(2026-09-06)**:目标 `https://lamalama.com/` 本身就是 WordPress 页——generator meta `WPML ver:4.9.6` + `WP Rocket 3.23.1.1`,`wp-content` 出现 1,306 次,robots 是 Yoast 模板。但步骤 2 双抓 **byte-identical**(431,958 B,WP Rocket 页面缓存使 PHP 输出事实上静态),步骤 5 的主题 bundle(`dist/assets/app-*.js` 852 KB,Vite ESM)里住着全部签名行为:自研 WebGL 基类(`attachShader` / `drawArrays` / 8 处 `void main`,非 three)、GSAP 3.15.0 + ScrollTrigger、Lenis 1.3.15、Swup 4.8.2 转场、hls.js 1.6.2;`admin-ajax.php` 只服务联系表单 POST,`/api/` 唯一命中是 flareapp 错误上报。按"签名行为住在哪"落「下发行为源 × 命令式引擎」格,四项附加条件(多 chunk 分包 / Bunny 桶 + HLS / 端点 stub / WPML 双语)→ **B**。与 aimservices 的区别:那是 WP 域下的静态子目录,这是 WP 主题站本体——**两种形态都会被"generator + wp-content 密度"的字面判据误伤**,而判据括号里"内容与行为主体在服务端"的两个主体要分别量。 ## 4. 二维判定表 + 三判据规则(防 noomo / shopify.design 型误判,宪法级) 规则见 `scope-and-fingerprint.md` §4。 **"检测到 Vue/Nuxt/声明式框架 → 判 C"是被锚点站证伪的错误捷径**【probe】。noomo 是 Nuxt3 SSR 站(`__NUXT__`、74 处 `data-v-`),按单因子规则会误判 C,而地面真值是 A——它的签名动画(GSAP ScrollSmoother 滚动叙事)全在客户端 chunk 里,已被成功 1:1 复刻。 **同一类错误在 `__reactRouterContext` 上重犯过一次**【shopifydesign】:该信号此前被本文件列为 C 信号,出处是 Shopify Editions spring2026——但那站判 C 的**真因是 R3F + Theatre.js(声明式引擎)**,与 React Router 本身无关。shopify.design 命中同一信号,逆向后确认为 **A**:47,224 行**命令式** three.js 引擎全在客户端 chunk 里(无 R3F、无 Theatre),React Router framework 模式**下发 route module**,1.24MB `_index` chunk 就是引擎本体。**信号被记在了错误的维度上**——框架名带来的是"下不下发行为源",引擎范式才决定"下发的东西能不能转写"。 #### 4.0.1 ⛔⛔ C 类要拆成两类:「源码不下发」≠「写法是声明式」【eightdesign】 规则见 `scope-and-fingerprint.md` §4.0.1。 实测(eightdesign.co.jp,Next + Turbopack + R3F 9.6.0): | 观测 | 结果 | |---|---| | R3F 是否真在用 | 是——`rendererPackageName: "@react-three/fiber"`,且**站点自有代码**调 `useFrame`/`useThree` | | 行为源在不在客户端 | **在**。`useFrame` 回调里是 `MathUtils.damp(x, y, 7, t)`、阈值 `0.01`、`1.02 * scrollDistortionWidth`——普通命令式代码 | | 该 chunk 的构成 | JSX 调用 557、命令式数学与状态写入 99、**三位以上小数的魔数 7,216** | | 逐字切片 | **18 个模块切片成功,`--check` 字节一致** | | 换进页面 | **CLEAN**,8 个 canvas 与镜像一致,跨侧 **99.5%**(残差 1.18 / 自比带宽 0.68,同量级) | ⚠ 判 C2 之前仍要坐实两件事:① 站点**自有**代码用了那个库(不是 vendor 里躺着);② 那些回调里确实是数学与状态写入,不是空壳。两条都可量化,如上表。 ## 5. 探测纪律(14 条协议修正,逐条为实测教训)【probe】【shopifydesign】 规则见 `scope-and-fingerprint.md` §5;编号与之对应,只列有实测出处的条目。 1. 存活性判定到**路径粒度**,且用 GET 不用 HEAD(kprverse API 网关对 HEAD 假 404)。 4. bundle 响应 <1KB → 补齐 **Referer** 请求头重试(landonorris 的资产域缺 Referer 时返回 32 字节拒绝页,造成假阴性)。 6. 现代站 HTML 可能**没有任何 `<script src>`**(Shopify Editions 三代全靠内联 `import()`)——只认 script 标签会漏掉全部 JS。 7. **catch-all 假 200**:请求 `.map` 返回 index.html(other-side-of-truth)——对下载物做 content-type 与哈希碰撞校验。 8. bundle 内出现 `/api/` 字符串 ⇒ 强制做**运行时 API 快照**(synchronized-studio 的导航数据在 Contentful,实测 5 个运行时 API)。 零命中假阴性的实测【airpodspro】:实测一个大厂产品页 Step 0 报 `/api/` = **0**,而 M0 的真实浏览器补录抓到: ``` /us/shop/mcm/product-price?parts=… /us/shop/bag/status?apikey=… /search-services/suggestions/defaultlinks/… /api-www/global-elements/…/flyouts ``` **全是运行时接口,前三条路径里没有 `/api/`**(大厂常按业务命名),第四条带 `/api` 但不在主 bundle 里。**M0 补录之前不得据此排除 B/D 类。** 那次签名行为仍在客户端所以判 A 不变,只是漏了一项 B 侧工作;但**同一个盲点用在别的站上可能把 D 类误判成 A 类**。 10. 未混淆产物(bruno-simon、star-atlas)可跳过 js-beautify——先做 **minification 形态预检**再决定流程。 11. 有公开 sourcemap(orano、linear)时直取 sourcesContent 源码,替代 beautify 流程。 13. **出现次数一律 `grep -o … | wc -l`,禁用 `grep -c`**——后者数的是匹配行数(shopify.design 实测:`ScrollTrigger` 真实 2 次报 1、`WebGLRenderer` 真实 34 次报 33)【shopifydesign】。 ## 6. 常见坑 规则见 `scope-and-fingerprint.md` §6。 - **平台名预判**:凭"这是 Webflow/大厂站"直接预判会错——webflow.com 预判不适用,实测有手写 GSAP/three.js bundle,判 A【probe】。判级只认指纹证据。 - **拖延镜像**:31 个历年获奖站 29% 已消失,集齐五种消亡形态(域名易主/转发、平台回收、域名抢注、路径移除、原地替换)。 - **判级正确 ≠ 附带结论正确**:shopify.design 判 A 是对的,但同一份 verdict 附带的三条结论全被 M0 证伪——"单页站 / 1 条路由"实为 **3 条路由**(`/dap` 在 HTML 里写成绝对 URL,BFS 的 `href="/..."` 正则看不见)、"243 个媒体 URL"实为 **322 文件**、"漏抓因媒体 URL 埋在转义 JSON 里"也不成立(转义态独占仅 1 个),真因是**运行时构造的路径**。判级可以继承,**Step 0 的每个数字与每条附带结论都必须在 M0 逐条复核**【shopifydesign】。 -
scripts.md 3.7 KB
# case-studies/scripts.md — scripts/README.md 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道某个脚本或某条工具链规则**为什么长这样**、或要核对它的实证强度时读;小节名与 `scripts/README.md` 的章节与表格行一一对应。规格本身不在这里:`node scripts/<x>.mjs --help`。 ## 本文件的形态史 v0.3.16–v0.3.20 之间本文件有两张表:主表记用法 / 出处 / 成熟度,速查表记用途与使用阶段。与上表互补:上表记用法 / 出处 / 成熟度,本表记**用途、实证与使用阶段**(原 SKILL.md「Script Directory」的完整版;SKILL.md 现在只保留一句话用途,细节以此处为准)。 ## serve.mjs 规则与规格:README 表格一行 + `node scripts/serve.mjs --help`。 v0.3.16:`--redirects`/`--cdp-port` 此前接受但无人读,现按未知旗标拒绝 ## probe.mjs 规则与规格:README 表格一行 + `node scripts/probe.mjs --help`。 v0.3.16:`--expect-side`/`--evalAfterDelay` 进 KNOWN_FLAGS(此前有文档却被判 unknown); ## pixelcompare.mjs 规则与规格:README 表格一行 + `node scripts/pixelcompare.mjs --help`。 实测带宽 0.31 → 0.20,**仍未归零**:剩下的是 IntersectionObserver 门控的 canvas,shim 尚未接管 ## probe-shim.js 规则与规格:README 表格一行 + `scripts/probe-shim.js` 的文件头注。 接管后实测带宽 0.31 → **0.04**,门的可用阈值从 0.5 收到 0.1 ## harvest-cases.mjs 规则与规格:README 表格一行 + `node scripts/harvest-cases.mjs --help`。 手写用例编码的是**你相信引擎的参数是什么**——六个手写用例曾全落在同一条曲线上、全绿。 ## verify-payload.mjs 规则与规格:README 表格一行 + `node scripts/verify-payload.mjs --help`。 实测:它抓到两侧本地化实现不一致(一侧留 `href="http://host"`、另一侧写出 `href=""`),而外壳字节门全绿 ## verify-lenprefix.mjs 规则与规格:README 表格一行 + `node scripts/verify-lenprefix.mjs --help`。 实测 eightdesign:115 条路由里 2 条只渲染出 70 字(对侧 2,440),**零 404、零请求失败、HTML 字节数一致、其它门全绿**; ## cold-audit-modules.mjs 规则与规格:README 表格一行 + `node scripts/cold-audit-modules.mjs --help`。 ⛔ 实测抓到一处条件 require(`require(t ? "a" : "b")`)导致闭包少算两个模块,**而 9 个检查点逐像素全零毫无察觉**。 此前落在两种签名之外,raycastkbd 7 个补抓 chunk 里 6 个报"只查了 2/3") `node cold-audit-modules.mjs [--src port]` ⚠ **v0.3.21 迁移时抓到的漂移**:上面那行用法是 README 曾经写的,脚本从未认过 `--src`——它只认 `--map` / `--closure`(`lib/cli.mjs` 的 known 集)。规格搬进头注时 selftest 的「用法行旗标必须在 known 集里」当场判红,所以它留在这里当证据,不进头注。 ## cold-audit-decls.mjs 规则与规格:README 表格一行 + `node scripts/cold-audit-decls.mjs --help`。 实测 samsy:964/4770 examined,首跑 349 UNKNOWN 全部归桶(三大 vendor 交错区 + 编译期常量 + 主线程重复打包的 worker 模块),0 缺口 ## sweep-routes.mjs 规则与规格:README 表格一行 + `node scripts/sweep-routes.mjs --help`。 源自四个项目重复手搓的逐路由 probe 循环——按启动次数计价的教训(§成本),122 路由从 ~40 分钟降到 7.5 分钟,并发收割事故随单实例消失。 ## lib/hash.mjs 规则与规格:README 表格一行 + `scripts/lib/hash.mjs` 的文件头注。 此前 19 个文件 23 处各写一份、三处各自实现流式 -
shopify-platform.md 12.7 KB
# case-studies/shopify-platform.md — Shopify 平台层剥离(B 类场景) 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `shopify-platform.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `shopify-platform.md` 一一对应。 ## 0. 分层模型(本指南的组织逻辑) 规则见 `shopify-platform.md` §0。 一个 Shopify 店铺的产物必须拆成**四层**看,**四层的复刻策略完全不同**,混作一团是 B 类最常见的失控源。前三层由 racing.shop 立起,第四层由 objectandarchive 实测补入:**主题层必须再切一刀**,把被 fork 的上游主题带来的**存量样板**与**店铺自研**分开【objectarchive】。 **为什么第四层非切不可**:`T-上游` 混进任一边都会直接损坏工作量估算——混进 `T-站点` 让移植任务表虚高(objectandarchive 实测 **+65%**),混进 `P` 则让剥离清单多出一批**本该原样保留**的块,删了就是未登记偏差。四层模型的直接产出就是"**你要移植多少东西**"(实测规模见 §0.3)。主题若非 fork 而来(从零定制),`T-上游` 为空集,四层退化回三层——但**"为空"必须是核验后的结论,不是默认假设**(§4 读法)。 - 路径形如 `/cdn/shop/t/<主题号>/assets/*` → **主题层**(`racing.shop` 是 `t/17`、allbirds `t/4159`、pangram `t/52`、simply-chocolate `t/126`、mana `t/18`、objectandarchive `t/11`)——落到 `T-上游` 还是 `T-站点` 再按 §0.2 判。 ### 0.2 `T-上游` vs `T-站点`:主题层内部怎么判【objectarchive】 规则见 `shopify-platform.md` §0.2(判据 2 的自加件实例、判据 4 的注释原文)。 2. **资产名清单**:`/cdn/shop/t/<N>/assets/` 里上游标准件与店主自加件并存。Dawn 标准件名固定:`constants.js` / `pubsub.js` / `global.js` / `cart-drawer.js` / `cart-notification.js` / `details-disclosure.js` / `details-modal.js` / `quantity-popover.js` / `localization-form.js` / `predictive-search.js` / `animations.js`;店主自加件带站点前缀(objectandarchive:`oa-wishlist.js` / `oa-color-library.js` / `color-swatches.js`)。 4. **注释里的人称(最强的一手证据)**:开发者注释**用第三人称提上游主题** —— objectandarchive 原文 `wraps Dawn's #CartDrawer`、`use oa-* classes to avoid Dawn's cart CSS`、`--- Currency: kill all Dawn disclosure def…`。**"提到 Dawn" = 站在 Dawn 外面写的 = `T-站点`**;上游自己的代码不会这样称呼自己。 ### 0.3 内联交织形态:没有文件边界时怎么分层【objectarchive】 规则见 `shopify-platform.md` §0.3。 **四层不一定分处不同文件。** racing.shop 三层各有各的 `<script src>`,按 §0 的路径判据即可分层;objectandarchive **既没有 bundle、也没有分层的文件边界**——三条路由合计 **62 个唯一内联 `<script>` 块**,四层混装其中: | 层 | 块数 | 字节(逐块取各页最大值) | 备注 | |---|---|---|---| | `P` 平台 | 26 | 814,361 | **96% 是两块数据**(collection 页 629 KB 的 analytics 商品元数据、product 页 162 KB 的 wpmLoader)——**剥离成本与字节数无关**,别被总量吓到 | | `A` 第三方 App | 4 | 15,259 | Klaviyo ×3 + Hulk Form Builder | | `T-上游`(Dawn 存量) | 6 | 87,548 | JSON-LD ×3、`window.routes`/`cartStrings` 块、designMode class、selected-variant JSON 岛 | | `T-站点`(自研) | **26** | **134,532** | **这 26 块就是移植任务表** | 把 87.5 KB 的 Dawn 存量误记成自研,任务表就是 222 KB 而不是 134.5 KB(**虚高 65%**),工期估算与里程碑切分一起偏。 **步骤 5——项目侧样例,以及「跑遍每一份文档」这条要求的由来:** 5. **归属门**:写脚本把普查结果与归属表 join,**任何未归属的块打印 UNCLASSIFIED、任何匹配到两条的打印 AMBIGUOUS,两者非零即退非零码**,并把它列进 M1 关账条件。这道门**本 skill 尚未提供现成脚本**(见 `scripts/README.md` TODO),按上面的判据自己写一个即可;可参照 objectandarchive 项目侧的 `layer-report.mjs` + `docs/layer-map.json`(三页 **0 UNCLASSIFIED / 0 AMBIGUOUS**)。**歧义不许用启发式自动消解**——猜一次就是几百 KB 在层间无声搬家。**归属门要跑在"构建层实际产出的每一份文档"上,不是"约定的那几条路由"上**——objectandarchive 的 404 页与被动入镜的 vendor 页因此在 M1 从未过门,直到 M2 的块级门把它们报成 UNCLASSIFIED 才补上。 **步骤 6 的两条实证:** - **上游存量被"顺手修好"**:`T-上游` 的字节是上游写的,改它等于为零收益污染上游产物。objectandarchive 实测差点修掉 Dawn JSON-LD 里的一处转义绝对 URL(`"url":"https:\/\/<源站主机>"`),正解是**在 `external.txt` 里逐条判定,而不是"修"**;判据仍是 §0.2 那句"这段字节是谁写的,不是它作用在谁的 DOM 上"(该拼写为什么会漏判,见 `verification-gates.md` §1.6 第 4 类); **与步骤 5 的分工**:步骤 5 断言**每块都归了层**(零 UNCLASSIFIED),本步断言**每层都按自己的规则被处置了**;两道门都要,且都进关账。objectandarchive M2 实测:`T-站点` 26 / `T-上游` 6 / `A` 4 唯一块全部"逐字或只被本地化",唯一被移除的块 = wpmLoader,0 UNCLASSIFIED;配套的 hunk 级门 5 页 **1,048 个差异 hunk 全部可由变换表重放**(变换表侧的下限纪律见 `dom-shell-strategies.md` §2 步骤 3)。 ## 1. 平台层清单(实测确定规格) ### 1.1 运行期端点(服务层 stub,`serve-rebuild.mjs` 的 STUBS 表,首个命中生效) 规则见 `shopify-platform.md` §1.1;下列各行为该表对应行的实测取证(chunk 数 / feature 清单 / theme.js 调用点坐标)。 | 端点 / 前缀 | 作用 | 处置 | 依据 | |---|---|---|---| | `/cdn/shopifycloud/shop-js/**` | shop-js loader 及其运行时 chunk 图。loader 文件内静态列出 `./chunk.*.esm.js` 约 37 个 + `client.*.esm.js`;HTML 的 `window.Shopify.featureAssets['shop-js']` 声明 **22 个 feature**(cart-sync、follow-button、login、toast-manager、avatar、windoid、fed-cm、cash-offers、checkout-modal、pay-button、payment-terms、lead-capture、user-recognition、customer-accounts…) | 整前缀 200 `export {};` | D6 | | `/cart.js` | Ajax Cart 读取(theme.js L1122 / L2237) | 200 空车 JSON | D2 | | `/cart/{add,update,change,clear}(.js)?` | 加购 / 改量 / 清空(theme.js add L2199·L2222、change L1248·L1275、update L1165·L1230·L1377) | 200 空车 JSON | D2 | | `/search/suggest*` | predictive-search(theme.js L3003 拼 `${Shopify.routes.root}search/suggest?q=…§ion_id=predictive-search`) | 200 —— **形状须核,见坑 2** | D4 | | `/recommendations/products*` | product-recommendations(theme.js L4357-4364) | 200 空 section —— **形状须核,见坑 2** | D2 | ## 2. 构建层登记变换清单 规则见 `shopify-platform.md` §2。 1. **D1a 同源绝对/协议相对 → 根相对**:`https://<host>/`、`http://<host>/`、`//<host>/` → `/`。**必须同时处理 JSON 转义形式** `https:\/\/<host>\/` → `\/`(内联 JSON-LD / 配置块里全是这种写法,漏了就留下真实外域引用)。**四种形态一个都不能少**:绝对 / 协议相对 / **转义绝对** `https:\/\/host\/` / **转义协议相对** `\/\/host\/`——objectandarchive 的主题注入脚本正是最后这种写法,只处理前三种时它会在 127.0.0.1 上解析成 `http://<源站>/…`,**离线镜像向线上真站要图**【objectarchive】。另见 D1c。 2. **D1b 外部 Shopify CDN / 其它外部主机 → 本地目录**:`https://cdn.shopify.com/` 与 `//cdn.shopify.com/` → `/cdn-shopify/`(含转义形式),对应镜像的 `assets/cdn.shopify.com/` 树。**转义形式对外部主机同样成立,别只给源站主机开**:objectandarchive 的 D1a 一开始只处理了源站主机的转义写法,外部主机漏掉,5 页共 25 处 `"input_custom_font_url":"https:\/\/cdn.shopify.com\/s\/files\/…woff2"` 就这样留在了一个已经关账、断言全绿的镜像里(登记为 D-T8;为什么每一道门都看不见它,见 `verification-gates.md` §1.6 第 4 类)【objectarchive】。 3. **D1c 裸主机基址常量 → 本地基址**【objectarchive】:遥测与主题代码常把基址写成**不带尾斜杠**的常量再拼路径(`"https://otlp-http-production.shopifysvc.com"`、`window.shopUrl='https://<host>'`)。只改写"带尾斜杠"形式时,objectandarchive 实测漏了 4 个遥测外联 + 2 个到线上源站的主题资产请求(那份资产一直在盘上)。**改写规则按主机匹配,不要求尾斜杠**;验收侧的配套要求见 `mirroring.md` §8。 ⛔ **计数必须逐条,不能用"总数 `n === 0` 才抛"这个弱形式**:本表的 D1a/D1b 在一个页面里就可能命中数千次(objectandarchive 单次构建 5 条变换命中 15 / 5 / **2,540** / 20 / 5),URL 本地化一条就让"有变换发生"永远为真,而 **D8 noindex 注入失效不会有任何人发现**——弱形式在 Shopify 站上基本恒绿。完整判据、下限怎么量、以及"验收要从产物字节反推而不是读构建脚本的计数器"见 `dom-shell-strategies.md` §2 步骤 3;块级的配套断言见本文 §0.3 步骤 6。 ## 3. 零外联的完整断言面(本次实测发现的门盲区) 规则见 `shopify-platform.md` §3。 **`零外联` 不等于"资源级探针没抓到外部请求"。** racingshop 的全页型 probe 报告零外联、零缺失资产,但构建产物里实际残留三类联网面【racingshop】: (三类各是什么见 `shopify-platform.md` §3。) ## 5. localhost 语义分叉(Shopify 主题的 dev 逃生门) 规则见 `shopify-platform.md` §5。 Shopify 主题(尤其 Vite 工作流的定制主题)常在页面尾部内联按 host 分叉的 dev 探测。racing.shop 每页至少 1 处(首页 2 处:carousel-3d 与 pixel-footer),实测原文【racingshop】: ## 6. 常见坑 规则见 `shopify-platform.md` §6,编号一一对应。 1. **内联遥测比 `<script src>` 难删,且极易漏**:src 能按 URL 前缀批量 stub,内联块只能按 `data-source-attribution` 属性或唯一起始字面量正则定位。racingshop 删了 2 块(event_observer.bootstrap、wpmLoader),**漏了 analytics/trekkie 块与 pagehide 弃单块**(§3②)。做法:先枚举全部无 src 的 `<script>`(racing.shop 首页 **38 个**、objectandarchive 首页 **51 个**),逐个分类为"配置 / 结构化数据 / 主题逻辑 / 遥测",再删——不要凭印象删。**这份枚举与 §0.3 的归属表是同一件事,做一次即可**:归属表落到层(`P`/`A`/`T-上游`/`T-站点`),删哪块是在层内再做的处置决定。 2. **stub 的响应形状必须按调用方的解析路径确定,不是"回 200 就行"**。racingshop 实测两处形状不匹配:`/recommendations/products` 回 `<div class="product-recommendations">`,而 theme.js L4364 用 `querySelector("product-recommendations")`(**标签名**)→ null → 读 `.childElementCount` 抛错;`/search/suggest` 回 JSON,而 theme.js L3003 走 `DOMParser` + `querySelector(".shopify-section")` → null → `importNode(null)` 抛错。**这类错只在交互态出现,load-time 探针全绿**——所以 §3 的交互态断言不是可选项。写 stub 前先去 bundle 里读一遍调用方怎么解析响应。 3. **no-op stub 必须同时是合法的 classic script 与 module**:racingshop 曾用 `export {}` 导致 classic script SyntaxError,改为纯注释文件才对。硬规则与判定方法见 `porting-discipline.md` §6.1。 4. **协议相对 URL 会被爬虫拼错**:`//<host>/x` 被误拼成 `https://<host>//<host>/x`,racingshop M0 因此产生 77 个假 404。修法是**旁路 gapfill 归一重解**(确认 76 个真实路径已在盘、1 个是目录基址属预期 404),**不要改共享爬虫脚本**。 5. **HLS 视频阶梯是静态爬取的盲区**:`.m3u8` 的 renditions 与 segments 不在 HTML 里,只有运行时才拉取——需单独补录(racingshop 补 3 renditions + 12 segments)。 6. **nonce 类字节不是内容差异,别判 D**:`<meta name="shopify-y">` 每请求变 UUID(racingshop、simply-chocolate 均实测),`__st` 里的 `reqid` / 用户 token `u` 也逐请求变(koox 实测)【probe】。冻结镜像值 + 对拍掩码即可。 9. **后端 stub 区的像素差是预期噪声,别用自创 CSS 去补**:racingshop 静态页对拍 FAQ 99% / Terms 96.9%,worstCell 精确落在 header 的账号头像(`shopify-account` 由被 stub 的 `account.js` 渲染)。归因到 stub 就结案,动 CSS 就是发明。 -
skill.md 3.4 KB
# case-studies/skill.md — SKILL.md 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 SKILL.md 某条规则**为什么存在**、或要核对它的实证强度时读;小节名与 SKILL.md 一一对应。 ## 使用前提与授权 **法务考量不得削减镜像完整性或门的覆盖面**:**不得**以"反正不公开""不该多存一份"这类法务理由留洞。实证:某项目以"产出永不公开"为由对一类资产"登记、不补抓",**缺了约 60% 的资产而五道门始终全绿**,藏了四个里程碑【objectarchive】。 ## 适用范围 **C1(重构式逆向)**——路线:flight-decode 建坐标系 → 重构一个可构建的 Next 工程(客户端一方组件按 C2 逐字译,服务端组件从 flight 树反推为显式登记的推断物)→ verify-flight 语义门收口(模块 id 全局双射)。实测 rauchg.com(Next 16/Turbopack):18/18 路由语义一致,盲逆向对答案结构 ≈95%/行为 ≈98%。 **C2(可做,按 A 类跑)**——实测一个 R3F 站:`useFrame` 回调里就是 `MathUtils.damp(x, y, 7, t)` 这样的普通命令式代码,逐字切片 18 个模块换进页面后 **CLEAN、8 个 canvas 齐全、跨侧 99.5%**。**切片器不关心范式——它切的是字节。** 渲染器当平台层从镜像伺服。 **X 类(可抢救)**——⭐ 抢救产出是**标准镜像**——X 类可走完 L3 全程(实测 first-launch:策略 A 外壳 + 数值门 9,856 样本全等 + 像素带宽内 + 自包含门,无一道门需改语义)。 **X 类(可抢救)**——⛔ **"CDX 无覆盖才是真不可做"按资产层读,不按站读**:IA 爬虫不执行 JS,清单/拼接驱动的站可以代码层覆盖 100% 而画面层为零(实测 157/160 资产任何年代零捕获,断网即白屏)——Step 0 先做分层覆盖侦察(推导 + CDX 前缀查询)预判抢救深度,见 `archival-rescue.md` §1.9。 ## Workflow / Flow — M1 ⛔ **第一个动作是判 bundle 形态**(扁平拼接 / 模块化打包 / 多 chunk),再选工具——分层表扫顶层声明,而 webpack 打包产物的顶层声明数是 **0**,边界与依赖边由打包器给定(实测 24,378 行 → 569 个现成模块,用 `scripts/module-map.mjs`)。 ## Workflow / Flow — M(n+1) 到 M(n) 为止产物**已证明正确但人读不了**(实测:14,271 行挤在一个文件里,`e` 出现 2962 次,注释占 0.2%)。 遇到巨型模块时**先测「延迟绑定少数末尾单例」的收益曲线再决定**(实测 6 个绑定即从 11,246 行降到 1,013 行,而换模块系统要赔上整条工具链才换来同样粒度)。 ⭐ **"这件事做不到"这个判断极不可靠**——实测两次判为结构性不可能,两次真凶都是自己工具里的一行 bug;先怀疑测量它的工具,再怀疑对象(`readable-source.md` §3.1–3.1.3)。 ## Script Directory ⛔ **不要改成手写词法器**——本 skill 里试过,一个含引号的正则字面量把它带偏了 16,177 行(F27)。**token 流上的括号匹配是精确的,文本上的括号匹配是对字符串/正则/注释的猜测。** ⚠ 这条线是**被违反之后才被发现的**:`module-map.mjs` 依赖 `@babel/*`,却在 `scripts/` 里住了整整八个版本,而同一份纪律的原话就写在它上面三行。**一条只写在文档里、没有任何东西去查的规矩,会安静地失效。** -
verification-gates.md 29.4 KB
# case-studies/verification-gates.md — 验收门选型与失效模式 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `verification-gates.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `verification-gates.md` 一一对应。 ## 0. 总原则 规则见 `verification-gates.md` §0。 - 门是分层的,成本递增;**底层门先建立,此后全项目期保持全绿**(noomo 的 SSR 门在 git log 里几乎每条 commit message 都以 "SSR gates green" 结尾)【noomo】。 ### 0.1 ⭐ 期望值必须让引擎往返一遍,不许手抄源站字面量【objectarchive】 规则见 `verification-gates.md` §0.1。 **实证【objectarchive】**:M3a 建块级运行时门时,五道断言里有三道是门自己红错的,且**全部先红在镜像侧(oracle)**。最典型的一条:源站 `+7` 写的是 `el.style.transition = 'opacity 0.4s ease, visibility 0.4s ease'`,门照抄这个字面量当期望值,而 `style.transition` 读回来是 `'opacity 0.4s, visibility 0.4s'`——**CSS 序列化把 `ease` 丢掉了**(它是 `transition-timing-function` 的初始值)。被测代码逐字正确,门红。 **顺带得到一个分诊信号:两侧跑同一道门时,镜像侧红 = 门错,复刻侧红 = 代码错**——一次跑就分得清方向。M3a 三条红灯全部先红在镜像侧;只在复刻侧跑门的话,这三条会被当成移植 bug 一路追下去。 > **实证强度(这是它值得当成规则用的原因)**:objectandarchive 在 M3a / M3b / M3c 三轮里**累计用它分诊 19 次,零反例**——**复刻侧从未单独红过**。最后一轮的 2 次镜像侧红同样全是门错:① 收敛判据分不清源站在 `+50`(钉住起始高)与 `+68`(动画结束)**两次写入** `transition:'none'`,门在 rAF 还没跑时就采样,把钉住的起始高当成稳定后的目标高,**一个根因红了两条断言**;② `+69` 记的是飞行值(见 `gate-failure-modes.md` §1.11)。**只在复刻侧跑门 = 主动放弃这个方向判据**:19 次全部会变成"移植 bug"去追【objectarchive】。 ## 1. 五类门定义与适用条件 ### 1.1 SSR/DOM 字节门(最先建立) 规则见 `verification-gates.md` §1.1。 - **实例**: - noomo `verify-ssr.mjs`:9 条路由的 body DOM / `__NUXT_DATA__` payload(1804 字节逐字节,连 `<html lang="en">` 双空格都对齐)/ config script(掩掉 buildId),外加尾部脚本顺序与未知 slug 404 行为,每 commit 回归【noomo】。 - oryzo:"构建产物 body 与源站空白归一化后 diff 为空" + 浏览器 scrollHeight 46410px 与源站一致【oryzo】。 - kimi `verify-routes.mjs`:94 项契约(head 8 字段 × 5 路由、12+8 条重定向**含状态码**与尾斜杠链、怪癖可达性、assetPrefix、favicon)【kimi】。 ### 1.2 整页像素 byte-equal 门 规则见 `verification-gates.md` §1.2。 - **实例**:kimi 32 个整页位姿(桌面 1440×900 × 8 + 移动 390×844 × 7 + 平板 768×1024 × 7 + hover 射线(CDP 真实鼠标)+ deck 内嵌覆盖层 ×2 + 过渡中间帧 + campus/social 独立页 6 位姿)与镜像逐字节相同;另有 4 个画布字节门(月球/星云/pixel-flow/WebGL Dither `readPixels` 直读)【kimi】。 - **搭建流程**:每个门自起镜像/复刻两个服务器 → 同一冻结协议驱动到同一位姿 → 截图/直读 → 哈希比对 → 产物成对入库(kimi 的 `docs/*-check/`;`side-by-side.mjs` 渲染 [镜像|重建|热力图] 合成图到 `docs/side-by-side/`,26 对全部 meanAbsDiff 0)【kimi】。 - **附带用法**:"应当不影响画面"的架构改动用"位姿哈希不变"关账(kimi M7.1 加动态加载后桌面 8 位姿哈希不变,证明改造像素零影响)【kimi】。 ### 1.3 量化像素对拍门(非 byte-equal) 规则见 `verification-gates.md` §1.3。 - **实例与指标**: - rogier:行亮度剖面差 ±4 灰阶(sharp 按行采样灰度,x 取 20%–80% 区间)+ 逐 band delta 收敛记录(修复前 +0.0691 → 修复后 -0.0011,残差用数字关账)+ 换页颜色时间轨迹逐点对齐(每 120ms 采样计算色,per-sample RGB delta ≤6)【rogier】。 - oryzo:按 section 对齐的 14 个滚动点、同视口 1456×830,平均亮度差 ±0.5【oryzo】。 - samsy:1280×800 截图 64×40 网格逐格色差相似度(home 99.4% / works 98.3% / about 98.6%)+ 最差格逐一目检归因——"因场景是活的(视频/glitch/粒子随机相位),刻意用粗网格而非逐像素 diff"【samsy】。 - noomo:六滚动检查点(t∈{1.2, 6.5, 10.5, 15, 17.5, 19.3})同帧对拍,双侧参考帧入库,逐帧登记差异(F1/F2/F3)【noomo】。 #### 1.3.1 检查点有两维:位置 × 状态(硬纪律)【shopifydesign】 规则见 `verification-gates.md` §1.3.1。 **位置维规则 1(按内容分段枚举)的实证**:实证:M(n-1) 的 9 个检查点(`f = 0, .1, .2, .3, .44, .55, .7, .85, 1`)里真正出问题的是 **cp7(85%)**——既不是首屏也不是终点;"Design in public" 整段轮播(18 张卡)就活在那一屏,在四个里程碑里从未在复刻侧出现过(相似度 **77.6%** / meanAbsDiff **57.23**,修复后 **100.0%** / **0.05**)。 **位置维规则 2(取景位置由本轮产物在第几屏决定)的实证**:**这一轮移植的子系统在第几屏?**实证:该项目 M4b 之前每一张对拍图都拍在首屏,而首屏静止态的画布本来就是空的(引擎主绘制的早退分支)——于是 M4a 报告里那句"本帧内没有任何未移植子系统",反过来说就是"**本帧内也没有任何本轮移植的子系统**"。M4b 的六个子系统全在首屏以下,继续只拍首屏则视觉证据是 **0 张**;给截图脚本加一个 `--scroll <px>`(先启动 → 再滚动 → 再等阻尼收敛)改拍倒计时时钟那一屏,当轮最强的一张证据才出现(粗网格 99.7%)。⚠ 同一张图后来也成了 `gate-failure-modes.md` §1.10 的反面教材——它当时的关账结论把指针象限扣掉了。 **驱动到状态之后要有独立证据(`gate-failure-modes.md` §1.2 的状态版)**:驱动必须走**源站自己的入口**(M4c 的下潜是朝 `document.body` 派发 `mousedown` 并全程按住,走 `qB` 自己的 `H` L44315 → `v` L44161 → `Di` 事件,而不是从外面戳一个状态变量),并且**逐帧断言状态量**(`spreadT ≥ 0.99`,否则 `exit 4`)。 **实测收益**【shopifydesign】:M4c 给下潜态补上这一维后,18 个检查点 × 双视口全部在带内(桌面相似度 96.9–99.9%、移动 97.9–99.7%,两侧全部帧 `spreadT` 恒为 1.000);此前 M4a/M4b 移植的六个子系统里 **5 个第一次拿到像素证据,从 0 帧到 18 帧 × 2 视口**。 #### 1.3.2 容差从哪来:先量自比带宽,而且**两侧各建一份**【shopifydesign】【objectarchive】 规则见 `verification-gates.md` §1.3.2。 **为什么必须先做**:M4b 的 tile intro 对拍给出 98.0%,比上一轮的 98.5% 差,看着像回归。同一仪器对镜像自比三次给出: | 对 | Δ 相位(spreadT) | 相似度 | meanAbsDiff | 最热格 | |---|---|---|---|---| | 镜像 vs 镜像 | 0.0008 | 99.6% | 1.00 | [23,9] 76.9 | | 镜像 vs 镜像 | 0.0023 | 99.4% | 1.60 | [24,9] 101.2 | | 镜像 vs 镜像 | 0.0099 | 98.6% | 3.68 | **[12,24] 200.4** | | 镜像 vs 复刻 | 0.0050 | 98.0% | 5.09 | **[12,24] 200.4** | 两条结论当场成立:① **最热格的坐标与数值在自比里一模一样地出现**,所以那是一条随相位移动的硬遮罩边,不是移植残差;② **指标对 Δ 相位不单调**——Δ0.0099 反而比 Δ0.005 好看,因为这一相位下整块图块在跳入跳出,粗网格量的是一个**不连续量**。 **纪律 1(样本数)的实证**:**样本数是判据的一部分:至少 3–4 次独立会话**(M4c 实取桌面 4 次 / 移动 5 次),**报告里写出每一次的值,而不只是最大值**。**2 次不构成带宽**——实测同一检查点两次自比给出 **0.99 与 2.00**(差一倍),而某个跨侧数字恰好落在"2 样本的带外、4 样本的带内"。 **纪律 5(两侧各建一份)的实证**:实测反证:`vendor/desktop cp2` 的跨侧 `meanAbsDiff` 是 **0.33**,而**复刻侧自己跟自己**跑同一个检查点是 **0.73**——差异在被测侧内部更大,与"哪一侧"无关。 **交错跑的实证**:实测:8 次自比会话跨约 50 分钟,同一台机器的 load average 在 **7.3–10.5** 之间漂;连跑一侧四次再连跑另一侧四次,等于让两侧的带宽**系统性地测在不同负载下**,于是"哪一侧更幸运"与"哪一侧跑得早"被混成同一个数字——**而纪律 5 存在的全部理由就是要把前者单独量出来**。 **发现当轮不许回头调宽的实证**:⚠ **发现这件事的那一轮,不许回头调宽当轮的带宽。** 本项目实测到 0.33 vs 0.73 之后的处置是:当轮容差**一个字不动**,用 `gate-failure-modes.md` §3.1.2 的**同侧对照**把那 5 条残差归因为"属于这一次运行",并把"两侧各建带宽 + 重新固化容差"写成下一轮开工的第一件事。 ### 1.4 数值探针门 规则见 `verification-gates.md` §1.4。 - **实例**: - rogier:16 步激活顺序数组逐项断言;**68 处 mode 字符串比对**——把源码语义编码成 `source-<符号>-<行为>` 常量嵌进运行时状态,实现端与探针端共享同一组常量逐一比对,"实现遵循了哪条源码语义"从口头承诺变成自动回归项【rogier】。 - samsy:引擎状态断言(15 NPC / 7 舞者 / instancer / 25 作品)+ `--full` 状态全遍历(IDLE→WORKS→ABOUT、皮肤机、Konami 派对链)【samsy】。 - kimi:拟合 661/661 最大残差 4.75e-7、事件重放 p95 残差 0.0019(不起浏览器的数学层重放)【kimi】。 - noomo:相机位姿与基准插值小数点后三位全等、42 层与源站 `X.create` 全序一致、RT 尺寸精确值("3650×1930→1460×772 = 视口×dpr×padding")【noomo】。 #### 1.4.1 场景图数值门(§1.4 最强的一个子类)【shopifydesign】 规则见 `verification-gates.md` §1.4.1。 - **实测数据**: - 镜像自比(未冻结):63 个对象里 2 个处于 mid-tween,**7 个字段漂移**; - 镜像自比(冻结后):**0 字段差异**; - 镜像(冻结)vs 线上:63 个场景对象 + 轮播记录 + docHeight **全部 0 差异**(唯一需要归一的是镜像侧 `/ext/` URL 改写)。 **关账前提(两侧必须是同一个程序)的实证**:实证:M2 竖切时门报 **149 个字段差异**,根因只有一个且不在移植代码里——hero 三栏砌砖 `z5`(L45192–L45204)被桩掉 → 24 张图落进不同槽位 → 57 个对象的 `worldX`/`worldZ`/`worldTop` 位移 → `docHeight` 差 97。**门量的是桩,不是移植。** **竖切期分组关账的兑现记录**: - **顺延 ≠ 放弃,且要被兑现**:M3 把 hero 砌砖与倒计时舞台落地后,关账判据切回全量模式,桌面 1728×1080 与移动 390×844 **双双 `0 field differences (63 objects, 1 carousels)`**,M2 延后的 102 个字段全部在这两个 0 里面;分组模式随即退休,不再有任何字段挂在它下面。 **"别和确定性冻结搞混"的实证**:本站这次分叉里**没有 PRNG 参与**——重排是 `<video onLoadedMetadata>` 回填 aspect 之后的**确定性贪心砌砖**,两侧各自完全可复现,就是不相等。 **"数值门不是像素门的替代品"的实证**:实证:`R5`(DOM 标题揭示,L45024–L45071)挂在 `site-ready` 上,`site-ready` 从一个 `requestAnimationFrame` 里派发(`KB` L44440–L44443),probe-shim 把 rAF 换成手动泵队列而探针从不泵到那里 → 两侧都不跑 `R5` → 数值门 0 差异地错了两个里程碑(`?__probe` 下两侧 `.wr` span 数都是 0;不冻结时两侧都是 18 且 innerHTML 逐字相同)。**抓到它的是不冻结的截图对拍**:首屏 hero tagline 整行不见、标题没有逐词 span。 **搭建纪律的实证**: - **读取器的副作用同样要转写**:`mG.readLayout()` L46372–L46385 在解析前把场景根改成 `transform:""; position:fixed; height:100vh`,读完还原并 `scrollTo`。漏掉这一步实测得到统一 158px 的 Z 偏移——读到的是活布局,不是引擎看到的布局。 - **先冻结,再取基准**:未冻结时同一镜像两次采样就漂 7 个字段。 ### 1.5 CLEAN 探针门(底线门) 规则见 `verification-gates.md` §1.5。 - ⛔ **退出码不许经过管道**:`node verify-x.mjs | tail -20` 让 shell 只看见 `tail` 的退出码,门红了也是 0——14islands 一次 commit 信息写"23/23"时门实际 22/23,就是这么来的。 - **实例**:lando `verify.mjs` 全 7 路由 × 桌面(1728×1080)/移动(390×844) = 14 个探针跑,滚动到 50%、等 12s、已知残留(iubenda badge)白名单放行,M7 关闭时 14/14 ALL PASS【lando】;samsy 零控制台错误门,无头回归必带 anti-throttling 旗标【samsy】;oryzo 无头双分支(桌面 + iPhone 级 390×844 触摸仿真)+ 三段截图【oryzo】。 ### 1.6 零外联门的完整断言面(⚠ 常被漏判) 规则见 `verification-gates.md` §1.6。 实证【racingshop】(第 1–3 类):一次判定"零外联"通过的 Shopify 复刻,21 个页面全部残留 `preconnect → shop.app` 与 `dns-prefetch → monorail-edge.shopifysvc.com`;且每页内联着完整 Monorail 实现,`Monorail.produce('monorail-edge.shopifysvc.com', …)` 经 `sendBeacon` 直打外部域——它位于 trekkie 脚本加载失败的兜底路径里,本体在盘时不触发,被广告拦截器按文件名拦截时会触发。三条都不产生常规资源请求,探针全绿。**这正是 `gate-failure-modes.md` §1.1「门只断言想到的字段」的实例。** **实证【objectarchive】(第 4 类:断言面缺的那一格)**:一个已经关账、四项断言全绿的镜像上,仍留着两处转义绝对写法,`grep 'https://<host>'` 一条都不命中: - 第三方 App 载荷里的 `"input_custom_font_url":"https:\/\/cdn.shopify.com\/s\/files\/…woff2"`——5 页共 25 处,指向的 woff2 **一直在盘上**,而**表单不渲染就不发请求**,所以资源探针一次都没见过它。它就这样坐在一个绿色的门里,直到下一个里程碑按拼写重查才暴露(登记为 D-T8;改写规则见 `shopify-platform.md` §2 D1b)。 - 上游主题 JSON-LD 里的 `"url":"https:\/\/<源站主机>"`——**产物里最后活下来的一处外部绝对 URL**。它是结构化数据的标识符、不是请求,所以判定是"逐条登记"而**不是"修"**(上游字节不因零收益去动,见 `shopify-platform.md` §0.2 / §0.3 步骤 6)。 **查法步骤 3 里"法务理由不是豁免理由"的实证**:⛔ **法务理由更不是豁免理由**:"反正不公开"不能用来关掉一格断言——门的覆盖面与法务考量互不相干(`legal-and-deploy.md` §0.2;实证:某项目以此为由少抓约 60% 资产,五道门全绿藏了四个里程碑)【objectarchive】。 **"别把本节的教训推广成断言面缺一格"的实证**:同一个项目下一个里程碑的三次红灯**一条都不属于这一类**——断言面都对,错的是**期望值写法**(从源站源码手抄了引擎会重新序列化的字面量),失明时表现为**红**,且红在镜像侧。 ### 1.7 声音是输出面:没有门断言过它,它就能整类缺失而全绿【overworldaudio】 规则见 `verification-gates.md` §1.7。 §5 的输出侧账列的全是**会上色的面**——而声音一个像素都不上。一个音频工作室站的 签名行为是声音(逐控件 hover/click 音效 + 主题音乐),它可以**整类缺失**而像素、DOM、 CLEAN、零外联全部绿:实测首爬后磁盘**零音频文件**,五门无一变红。两层课: ## 2. 门型选择决策树 ### 2.1 门的运行纪律 规则见 `verification-gates.md` §2.1。 - 选型后把**"改动区域 → 最小门集合"写成映射表**进 REBUILD_PLAN(rogier:改 Home WebGL → build + 渲染器审计 + Home 桌面/移动输出 + thumb spotlight;改路由 → focused 路由探针 + 受影响页面门)【rogier】。 #### 2.1.0 ⛔⛔ 判决包含它的调用方式与豁免清单,两者都必须住在仓库里【airpodspro】 规则见 `verification-gates.md` §2.1.0。 一道在计划里写着"七项全绿"的镜像门,几天后重跑变红。查下来两层,**没有一层是被测对象变了**: 1. **那次全绿是带着一个参数跑的**(`--allow-missing <豁免清单>`),而**参数只存在于当时的 shell 调用里**。后来把门登记成 npm script(正是"命令只在 shell 历史里"这条教训的补救)时,命令是**凭记忆重建**的,参数漏了。⭐ **"把命令记下来"不够,要"把命令连同它的全部参数记下来"——凭记忆重建一条调用,等于重建一个不同的判据。** 2. **豁免从 3 条变成 4 条,因为被测对象长大了。** 修掉爬虫的一个作用域漏洞后,两个新页面进了镜像,它们链向一个范围外的地址。**绿灯的记录早于产生这条引用的页面**——记录没错,只是过期了(§F26 同族)。 #### 2.1.1 ⭐ 验收工具链里"两处以上要算出同一个答案"的逻辑,必须单一实现【objectarchive】 规则见 `verification-gates.md` §2.1.1。 **识别信号的实证**: - **几份拷贝的参数已经不一样了**——这是拷贝存在的最好证据,也是它已经漂了的证据。实测:三份滚轮驱动的容差是 **6 / 6 / 4**、起点 x 是 **200 / 200 / 100**,没有任何一份写下过为什么; - 一份实现修好了,另一份**没人记得去修**(实测:URL 规范化剥 fragment 这件事,共享 lib 里做了,镜像器自带的那份副本没做——注入性门当场报一条**假红**:`base.css?v=1#Shape-Arch` 与 `base.css?v=1` 被判成两条 URL 映射坍缩,而它们本来就是同一份字节)。 **代价 3(拷贝里的 bug 会穿上"跨侧差异"的外衣)的实证**:实证【objectarchive】:三道运行时门各存一份 `wheelTo`,都是"发一个 wheel → 睡 25 ms → 重读 Lenis `targetScroll`"。负载高时 25 ms 不够事件传到 Lenis,于是读到**陈旧**的 target、算出**同一个** delta、再发一遍。实测 load average 10.5 时门要 `y=3046`,驱动落在 **3669 = 3046 + 623**(上一个 delta 打了两遍);而每个块的检查点记录都带着这个 `scrollY`——**一次落点失误直接变成 14 个观测组的"跨侧差异",把一道 0 差异 / 116 组的绿门变成 14 红**。三份拷贝,同一个 bug,三倍的复发面。 **第三步(验证它真的被共用)的实证**:**共享模块只有在两个调用点都 `import` 它时才是共享的;文件头写的是意图,不是代码。** 实证:某项目 `lib/extract-refs.mjs` 的第一行写着 "Shared by mirror-site.mjs and verify-mirror.mjs",而**爬虫从来没有 import 过它**——它一直带着自己那份副本,跨了四个里程碑。 **后果比"没有共享"更坏,而且方向是反的**:那一轮的修复(教会正则认转义写法)只落在**审计侧**,于是 ``` 门看得见爬虫抓不到的引用 -> 重跑爬虫永远收敛不了镜像 -> 每一轮都要人读门的输出、把 URL 手抄成 --seeds ``` **验收纪律 ② 的实证形态**:② 合并之后**跑一遍差分**证明共用真的改变了行为(实证形态:同一份完整镜像上,旧门扫 1 个文件报"引用集 − 磁盘集 = ∅",新门扫 3 个文件、看见 5 条引用,仍然 = ∅——而人为删掉两个真资产后,**旧门照样 PASS 0,新门逐条报红**)。 **作用域边界的实证**:**源站字节里的"同一份逻辑三份拷贝"必须照抄不修**(`porting-discipline.md` §1.3)——实证:某站同一张几何表在三个块里各存一份、源站注释自称"手工同步",实际已经漂成 **18 / 16 / 16** 项;正解是**三份各被自己的门钉住**(漂了就响),**不是**抽一个公共模块出来。 ### 2.1.2 ⭐⭐ 检查者不能是生产者:门永远不许 import 生产它所审计之物的模块【aimservices】 规则见 `verification-gates.md` §2.1.2。 **实测**(两个项目各做一次,做法相同):往一份产出外壳的 `<body>` 里注入一个 `data-injected="1"`,跑门。 | | 门的结论 | 跑完后注入还在吗 | |---|---|---| | 修之前 | **PASS 0** | **没了**(grep 1 → 0) | | 修之后 | FAIL,逐条列出无法解释的 hunk | 还在 | **那道门的收尾语写着"未解释的字节就是对源程序未登记的编辑"——而这正是它唯一测不出来的东西。** #### 2.1.3 ⛔ 未知参数必须 FATAL——一次静默忽略买了三小时追凶【hubtown】 规则见 `verification-gates.md` §2.1.3。 在此之上盖起了整座幻影大厦,每一层都有"证据": | 幻影 | "证据" | 真相 | |---|---|---| | 加载器卡死 | 每轮都停在同一画面 | 每轮都在第 6.2 秒拍照 | | 定时器永不触发 | 注册了、没清、没响 | 还差 2 秒到点 | | 页面重载循环 | `performance.now()`=6s 而"等了"150s | 只等了 6 秒 | | 渲染器崩溃 | 文档"总是很新" | 文档就是刚出生 | | 时钟被接管 | uptime 恒 6.2 | eval 恒在 +6.2s | ⚠ 期间做的每一次"验证"都真实通过了:新定时器 1 秒准点响(当然,页面才 6 秒)、 `setTimeout` 是 native(是)、双次采样时钟正常前进(是)。 **每个局部检验都对,前提错了,于是它们全在为幻影作证。** ### 2.2 采样时刻:settle 必须是页面状态,不能是墙钟【shopifydesign】 规则见 `verification-gates.md` §2.2。 **事故**:M4b 一开跑,场景图数值门就红了 **35 个字段**,而本轮移植一行 DOM 都不写。问题不在两侧的差,在**取样**——采样脚本从 M1 起就是 `Page.navigate` → `setTimeout(8000)` → 读;而本站的布局输入是 `<video>` 的容器元数据,**每到一个就重排一次**,冷文件缓存下 24 个 hero 视频 8 秒内到不齐。于是"在 T=8000ms 测量"等于"测量磁盘这一次恰好送到的那个子集":**镜像连自己都对不上**,同一个镜像连跑两次就差 35 个字段。**此前每一个"0 差异"都含着一个没写出来的前提:两侧恰好处在同一个未 settle 的中间态。** **做法 2(settle 指纹当可比性前置条件)的实证**:实证:本站在 `16/17` 与 `17/17` 两个媒体状态下**各自稳定**,而两者的布局结果差 **174 个字段**(移动端 `docHeight` 9263 vs 9313)——**任何一侧单独看都"稳定可复现",合起来却不可比**。M4b 轮播落地后第一次跑移动端门就命中这条,重采一次即 0 差异;**没有它,那里会是一份"轮播把移动端布局搞坏了"的假红。** **自检的实证**:**让基准侧连跑两次,差异不为 0 就先修仪器,不要看被测侧。** M4b 的 35 个红字段没有一个来自移植代码,而把方向从"查移植"扭到"查仪器"的就是这一步。 **前置条件同样适用的实证【objectarchive】**:实证:某块要求"在 <750px **解析**的文档上"取样(列数在解析期就定死,resize 够不到),代码是 `resize(MOBILE)` → `sleep(600)` → 导航。**3 轮全量普查红 1 轮**,永远是这一条断言、永远在参照侧——即永远是门,从不是移植。 **纪律 2(重试必须先量过)的实证**:⛔ **但"检查不通过就重试"必须先量过重试到底有没有用。** 本例的重试形态(重新导航)在 `environment-traps.md` §8 里**实测 0/6**——文档一旦以错的宽度 parse 过,错误排版是**稳定**的,重开多少次都一样;那里 5/5 有效的是**宽度抖动驱动页面自己的 resize 重建**。 ### 2.3 ⚠ 全站对拍的成本由**进程启动次数**支配 规则见 `verification-gates.md` §2.3。 带同侧对照的全站对拍要开 `路由数 × 4` 个浏览器实例(每侧采两次)。 115 条路由 = **460 次启动**。跑到后半程,机器 load average 到了 **248**, 推进速度掉到每条路由约九分钟。 排查结论:**没有实例泄漏**——存活的 headless 进程全都只有 1–10 秒。 代价在启动本身:macOS 的 `syspolicyd` 对每一次新启的浏览器二进制重新校验签名, 它一个人占了 48% CPU。 ⭐ 于是**降低并发泳道反而更快**,因为颠簸消失了:最后 4 条路由用单泳道几秒跑完, 而同样 4 条在三泳道下要花半小时。 ## 3. 分层验证体系(成本递增,全部要做) 规则见 `verification-gates.md` §3。 1. **每里程碑冷启动实测**:全新加载("不手动切效果——手动切换会掩盖初始化状态 bug"【kimi】;oryzo 的 NaN 传染 bug 只在冷启动暴露【oryzo】)、零控制台错误、截图取证,验收标准写进里程碑日志【samsy】。 4. **冷头评审(收官审计)**的四种形态,各项目实例: - 对 bundle 应用区**顶层类逐一核对落点**:samsy 60 个顶层类,揪出唯一真缺口(编辑器 raycast 盒工厂,回归和像素比对都测不出——场景 children 应为 35 实为 0)【samsy】。 - **模块清单对账**:kimi M7.5 抓出只在过渡中出现的 ASCII 瀑布组件从未移植(见 `gate-failure-modes.md` §1.5)【kimi】。 - **反向扫描**:枚举复刻独有的全部 118 条 (media, selector) CSS 规则逐条判定"必要机制 / 等价别名 / 多余发明",揪出 3 条真发明【rogier】。 - 评审姿态:"不信文档,逐条回到镜像与 bundle 复核"(kimi 专设 R1 审查里程碑,抓出 4 条实锤)【kimi】。 **零重写站粒度下沉的实证【objectarchive】**:该项目一开始就打算按"26 个内联块"逐块核对,那是**错的粒度**,而且它自己的怪癖表早就证伪了:一个块名盖住 **3** 个系统、另一个块名盖住 **8** 个系统,按块记账会对这 11 个系统里的 9 个说"已覆盖"。改按**挂载点**审计后的终版账:**194 个挂载点、0 未归属、13 条开口**(逐条带 needs 与 risk),其中 **150 个由机器自动销账**——门的断言标签里本来就写着源坐标(`eq("hover >=100ms prefetches once (+24..+26)", …)`),"挂载点普查 ∩ 门自己引用的坐标区间"可以直接 join,只剩 44 条要人写台账。**唯一靠这次粒度下沉才抓到的东西**,在块粒度上完全隐形:三处监听挂在**只有主题编辑器才派发**的事件上(`shopify:section:load` / `:reorder`)——宿主在、代码在、**派发方不存在**,而那两个宿主块在块清单上都是"已覆盖"。 **实证(这条规矩的全部来源)**:该项目把冷头评审写成脚本,枚举应用区间 **1,409 条顶层声明**逐条判定落点——**第一版跑出 0 未归属、报 PASS,而当时站上正缺三个整块行为**(轮播入场 effect `oG`、DOM 时钟指针 effect `eG`、hero-rise 结束派发 `resize` 的 `Y5`)。原因很干净:第一版把**整棵 React 组件树(L44410–L47224)用一条 range 规则**判为 declined("偏差 D9:不跑 React")。**那条规则为真,且无用**——组件可以整体不跑,但组件里某个 `useEffect` 的行为仍然是复刻必须复现的东西,D9 自己就写着"React 组件的 effect 与 render 输出逐语句转写"。decline 规则的粒度(组件/文件区间)比"能缺失的东西"的粒度(effect)**粗了一级**。加上第二遍——**枚举全部 49 个 `useEffect`/`useLayoutEffect` 站点,逐个要求"转写 / 切片 / 具名 decline"**——同样三条立刻变成 UNACCOUNTED。 **终版对账(两个粒度各一本账,退出码进 CI)**: | 粒度 | 总数 | 切片内 | 逐语句转写 | 有归属的 decline | **未归属** | |---|---|---|---|---|---| | 顶层声明 | **1,409** | 1,051 | 21 | 337 | **0** | | **React effect 站点** | **49** | — | 15 | 34 | **0** | 5. **部署即验证**:真实网络延迟暴露本地永不触发的竞态——samsy 用 CDP Fetch 单文件延迟**二分定位**到两张纹理,根因判定为"部署拓扑差异(单源 vs CDN 分域)"而非代码,修复分"保真修正"与"登记偏差"两笔【samsy】;真机对拍兜 headless 盲区(`gate-failure-modes.md` §3)。 ## 4. 常见坑 规则见 `verification-gates.md` §4。 4. **判定时序 bug 前先校准探针**:后台节流、HMR `?t=` 幽灵模块、探针时钟错位、部署拓扑都会伪装成代码 bug(samsy 曾误判源码 bug 并错误"修复",取证后撤销)【samsy】;探针超时明确区分为 "probe timing, not a product mismatch"(真 GPU tier 3 机器需要 `PROBE_WAIT=25000/45000`)【rogier】。 ## 5. 产出物 规则见 `verification-gates.md` §5。 - 门脚本 + 每次运行的结构化产物(summary.json / metric.json / 截图对)入库留证。 ⛔ **"入库"是字面义:git 追踪,不是"留在本地目录里"。** 实测一个跨 runtime 的完整复刻把 `docs/compare/*.png|*.json` 写进了 `.gitignore`——十几道门全绿、汇总文档齐全,而**证据本体不可移植**: 换一台机器,"像素差 0"就退化成一句无从复核的转述。体量确实装不下时,退路是**入库证据的哈希清单** (逐文件 sha256 + 字节数 + 生成命令),并在汇总文档里写明证据本体的存放处——哈希在,字节才可追讨【hashgraphvc】 -
webgl-scenes.md 9.5 KB
# case-studies/webgl-scenes.md — WebGL/GLSL 场景逆向指南 的实证记录 > **何时加载本文件**:不在必经集合里。只在想知道 `webgl-scenes.md` 某条规则**为什么存在**、或要核对它的实证强度时读;章节号与 `webgl-scenes.md` 一一对应。 ## 1. shader 定位与逐字提取 ### 定位手段(按序尝试) 规则见 `webgl-scenes.md` §1「定位手段(按序尝试)」。 1. **grep 特征标记**:搜 `#define GLSLIFY 1` 定位 bundle 里全部内联 shader 字符串(oryzo 以此定位 118 段 shader)【oryzo】。 2. **搜值不搜名**:混淆 bundle 里 REVISION 等常量会被重命名——用值锚定:版本字符串(`const nv="179"` 锁定 Three 0.179.0)、十进制颜色字面量(`15064825` = 0xE5DEF9)、GLSL 特征串,都比标识符可靠【noomo】。 3. **bundle 内联 base64 资产也要提取**:noomo 从 bundle 提出 base64 的 `colorsMap` 1024×2 光谱 LUT 和 SMAA area/search 纹理到 `_extracted/`——缺 colorsMap 玻璃会整体变灰白。复刻侧若反向内嵌 base64,需做字节级一致性验证【noomo】。 ### 提取纪律 规则见 `webgl-scenes.md` §1「提取纪律」。 - 集中存放 + 头注释声明来源与 "Do not edit by hand"(oryzo:`glsl/index.ts` 单文件 845 行 118 段)【oryzo】。 - 连源站变量名照抄(noomo 连 `yeahRaytracingBroWhySoComplex` 都保留)【noomo】。 - 死参数/错误赋值照抄:`mipFilter` 死参数、光场 RT 错误的 `format="R8"` 原样保留——"修正它们反而会偏离源站的实际渲染结果"【oryzo】。 - 每个场景/pass 文件头注明源行号区间(lando:`fluid.ts` 注明 "All GLSL verbatim",Advection/Viscous/Divergence/Poisson/Pressure 各 pass 行号一一列出)【lando】。 ### 对拍与证同 规则见 `webgl-scenes.md` §1「对拍与证同」。 - **ShaderChunk 展开对拍**:引擎(如 Three)会把 chunk 拼进最终 shader——rogier 的 `dump-va-shader.mjs` 从 bundle 提取源 shader 文本,与重建运行时(含 ShaderChunk 展开后)对拍,确认"源 bundle 光照 chunk 与本地 Three chunk 零差异,含 spotlight-map 乘法"【rogier】。 - noomo F1(全屏竖纹)即以此定案:shader 逐字相同,根因是 GLSL 版本默认值——源站默认 GLSL1、复刻误设 GLSL3 → 全部 shader 编译失败 → 空场 → NaN,一行修复级联解决三个表观 bug【noomo】。 ## 2. 渲染管线审计方法 规则见 `webgl-scenes.md` §2。 1. **列材质/pass 清单**:逆向笔记必须包含材质清单与后处理链逐步拆解(samsy:26 项 TSL 节点材质 + 后处理链 + RenderTarget 清单;oryzo:MRT → TAA → SMAA/FXAA → Bloom → Bokeh → BlurBox → ScreenPaint → Final 十余 pass)【samsy】【oryzo】。 2. **按拓扑逐个 pass 移植,每加一个 pass 验收一轮**【oryzo】;多场景管线(rogier:sky/work/thumb/main/wavves/media/character)按源码结构重建【rogier】。 3. **复杂效果先拆成结构再移植**:samsy 把"零光照氛围"拆解为"黑雾 × 烘焙贴图 × 0.3 × 高度渐变 + bloom 只吃 emissive MRT",并明写"复刻时必须按此结构而非『打灯调像』"【samsy】。 4. **渲染器配置审计(含拒绝清单)**:静态 + 运行时审计渲染器状态,不只记录"要有什么",还记录"不许有什么"——rogier 的 `audit-renderer-output.mjs` 明确**拒绝在构造函数里重新引入 `setClearColor`**,因为源构造器(`qw`)没有这一调用【rogier】。 5. **动画/材质参数逐字取证到行号**:bloom strength 0.34 / radius 0.27×DPR(L69161)、雾 IDLE 700/800、玩家物理常量全表——全部从 bundle 行号抄录,不目测调参【samsy】。 6. **逆向阶段做证伪**:指纹会骗人——samsy 早期误判"有 GPU compute",M1 证伪(`dispatchWorkgroups` 字符串全部来自 three 内部);KTX2/meshopt 能力在 GLTFLoader 里但从未挂载;作品是 25 条不是 26 条。证伪结论写成"不要发明"清单【samsy】。 7. **TSL/WebGPU 注意项**: - 第三方库魔改的识别用量化手段:源站 TransformControls fork 与官方 addon 的 diff 用"数字字面量多重集 + 轴键结构对比",洗掉正则假阳性(`.15` 无前导零、十六进制色值记法差异)后收敛出唯一真实增量(平移 gizmo 只留三支箭头)【samsy】。 8. **暴露数值探针句柄**: - 复刻侧留 `__probe` 门控的引擎句柄(noomo:`window.__sweet3`),断言层数与层序("42 层与源站 `X.create` 全序一致")、uniform 值、RT 尺寸精确值("3650×1930→1460×772 = 视口×dpr×padding")、相机位姿小数点后三位全等【noomo】; - rogier 更进一步把源码语义编码成 68 处 mode 字符串(如 `source-yD-onProjectActive-spotlight-reveal-woosh-uReveal-before-look-directional`),探针持同一组常量逐一比对(16 步激活顺序数组逐项断言)——"实现遵循了哪条源码语义"成为可自动回归的断言【rogier】; - 具体到对象级数值:rogier 的聚光灯探针断言 `SpotLight.map` 归属、位置 `(0,0,3.7)`/目标 `(0,0,-8)`/强度 220、3×3 投影采样亮度【rogier】。 管线审计 checklist 里的规模实证: - [ ] 材质清单逐项有落点,数量与源站清单对上(samsy:26 项 TSL 材质)【samsy】 ## 3. WebGL 对拍的特殊性 ### 量化指标替代逐像素 规则见 `webgl-scenes.md` §3「量化指标替代逐像素」。 - 静态构图:行亮度剖面(整屏按行采样灰度,x 取 20%–80% 区间,验收 ±4 灰阶噪声级)+ 逐 band delta 用数字关账(rogier 修 blocks-color fallback:band 0.35 delta +0.0691 → -0.0011)【rogier】。 - 滚动叙事站:多滚动点对拍(oryzo:同视口 1456×830、按 section 对齐的 14 个滚动点,PIL 上下拼接逐组核对,平均亮度差 ±0.5 为验收)【oryzo】。 - **活场景(视频帧相位 / glitch 文字 / 粒子随机相位):刻意用粗网格量化而非逐像素 diff**——samsy 用 1280×800 截图 64×40 网格逐格色差出相似度指标(home 99.4% / works 98.3% / about 98.6%),**最差格逐一目检归因**,产物入库(截图 + 并排图 + metric.json)【samsy】。 ### 双侧同参数、同状态、同帧 规则见 `webgl-scenes.md` §3「双侧同参数、同状态、同帧」。 - 驱动方式要抗噪:samsy 的菜单文字被 glitch 轮换、文本匹配不可用,改按 `#topmenu` 索引点击【samsy】。 ### 渲染确定性与读回防呆 规则见 `webgl-scenes.md` §3「渲染确定性与读回防呆」。 **⚠ 交叉警告**【shopifydesign】:`--use-gl=swiftshader` / `--disable-gpu` 属于 `determinism.md` §2.9 的**能力探测熵源**,会静默切换被测程序的分支——站点的 GPU 名黑名单正则里常常**就含 `swiftshader`**(shopify.design 的 `z3`/`H3` L22746–L22752 把 `"SwiftShader"` 直判 low),而画质档被插值进 shader 源码:**加了这条 flag,你对拍的就是 low 档 shader,而其余门跑的是 high 档**(该项目两个里程碑无人发现,两侧一致所以不红;已登记为偏差 D16)。另有一层代价:软件渲染下 1728×1080 单次 `captureScreenshot` 要 1–2s,按 `spreadT` 之类的状态量对齐抓帧会被采样偏差污染(`environment-traps.md` §7)。 - **无 `preserveDrawingBuffer` 的 WebGL canvas 不能用 `drawImage` 读**【kimi】;kimi 唯一的 WebGL 场景(Dither)用 `readPixels` 直读做字节门【kimi】。 ### 覆盖面与噪声归类 规则见 `webgl-scenes.md` §3「覆盖面与噪声归类」。 - **检查点必须覆盖滚动两端**:noomo 探针没测滚动终点 t=20,导致 HomeFooter 整段揭示动画缺失漏网——终检必须包含两端【noomo】。 ## 4. sRGB/色彩管理:headless 盲区必须真机兜底 规则见 `webgl-scenes.md` §4。 - **色彩管理**:oryzo 的 8 处纹理缺 sRGB→linear 解码,整场景偏亮发灰——headless 多轮对拍都归入"噪声",**最后一轮真机对比才捞出**【oryzo】。 - [ ] 收官前至少一轮**真机浏览器对比**(oryzo 真机抽查揪出 sRGB bug)【oryzo】。 - [ ] 建议做**真机三方对拍**:线上 / 本地镜像 / 复刻三方并排,截图入库留证(lando:`docs/compare/` 23 张,命名区分 mirror-*/rebuild-*/dist-*,同机位对拍)【lando】。 - [ ] 剩余细微差异登记为已知残留,不假装 100%(lando:wireframe 扫描层 uTime 相位细微差登记在案)【lando】。 ## 5. 常见坑 规则见 `webgl-scenes.md` §5。 1. 后台标签 rAF 节流 + gsap lagSmoothing 伪装成站点假死,三个项目独立踩过: - oryzo:人肉盯屏不可靠,因此建无头回归【oryzo】; - samsy:误判为源码 bug 并错误"修复",取证后撤销【samsy】; - noomo:M0 镜像阶段亲历【noomo】。 4. 部署拓扑差异触发本地永不出现的竞态: - samsy 上线后真实网络延迟暴露构造期纹理竞态——部署本身就是一道验证; 6. 修"明显的 bug"反而崩溃: - lando Q13——源站 `scene.remove(Q.name)` 传字符串(three 中 no-op),"修好"后真删除破坏遍历导致转场崩溃,最终按怪癖回抄【lando】; - rogier 的 `pz % 250 + 10` 带符号取模被"好心修正"成正取模后 About 页浮动方块全部消失【rogier】。 8. **NaN 类初始化 bug 只在冷启动暴露**:oryzo 的滚动指示器未初始化字段 → `u_pulseCenter.y = NaN` → 整屏恒定色,手动切换效果会掩盖它——每轮验收必须全新加载【oryzo】。
-
-
animation-recovery.md 12.8 KB
# animation-recovery.md — 动画/交互逆向路径选择 > **何时加载本文件**:逆向笔记(engine-notes)完成后、开始移植任何动画、滚动编排、页面过渡、文本动效或输入手感之前加载。本文件决定你用哪条路径复原动画,以及每条路径的验收方式。 ## 0. 唯一禁令(先读) **禁止目测调参**。动画复刻的每个数值必须能回答"它在 bundle/数据文件/录制基准的哪一行"。 - rogier 明写 "Do not tune visuals, motion, audio, or interaction by eye"【rogier】。 - 目测版只允许作为临时 stub 存在,且必须显式标记生命周期("将被溯源实现取代")——每个 stub 标注对应源函数和行号区间,逐波替换【lando】。 (实证:`case-studies/animation-recovery.md` §0) ## 1. 核心判据:动画的事实来源在哪 动手前先在 `_pretty/` 和镜像资产里回答一个问题:**驱动这段动画的"事实"存放在哪里?** 答案决定路径: | 事实来源 | 判定特征(grep/查证方法) | 走哪条路径 | 验收方式 | |---|---|---|---| | GSAP/JS 代码内联参数 | bundle 里能 grep 到 `gsap.timeline`、`ScrollTrigger`、`.to(/.fromTo(` 及字面量参数 | 路径 A:参数逐字抄录 | 时间轴逐事件对齐 + 阶段截图/DOM 身份断言 | | 烘焙数据文件 | 镜像里有 GLB 时间线 / `.buf` 相机轨迹等二进制,bundle 只做插值播放 | 路径 B:dump 数值账本 | 数值全等(如插值小数点后三位) | | CSS 变量 / 内部 state 推导 | 视觉由 CSS 自定义属性或框架 state 驱动,外部读不到内部值 | 路径 C:录基准 + 拟合/重放 | 拟合残差归零 + 事件重放轨迹一致 | | 物理/程序化模拟 | 代码里是常量表 + 每帧积分(弹簧、粒子、二阶动力学) | 路径 D:常量表全抄 | 常量逐项对行号 + 数值探针 | 判定注意: - 一段动画可能混合多种来源,**逐段判定、逐段选路径**,不要全站套一条路。(实证:`case-studies/animation-recovery.md` §1) - 判定结论写进逆向笔记的"对复刻的直接结论"节,先于任何移植代码【noomo】【samsy】。 - 数据文件型 2D 动画(如 .riv)本体直接播放即可,难点转移到 DOM 集成层(预载缓存、resize 注册表、状态机接线)——把集成层的全局变量表逐字 dump 后照抄【lando】。 ## 2. 路径 A:GSAP/JS 代码 → 参数逐字抄录 适用:编排全部写在 bundle 代码里。做法是把参数当数据抄,不是"照着效果重写"。 ### 2.1 先在逆向笔记里逐字 dump 参数,再写代码 - 每个组件的 GSAP 参数逐字抄录:贝塞尔控制点公式、ScrollTrigger 的 `start "top top", end "bottom 25%", scrub true, invalidateOnRefresh`、文字揭示配方的 duration/ease 数值,全部带 pretty 行号【lando】。 - 全部页面过渡的秒数/缓动/延迟及行号同样逐条列进笔记【noomo】。 - 笔记同时产出**页面 init/destroy 矩阵**(每个 data-page 的初始化/销毁函数及行号),它直接就是移植任务清单【lando】。 (实证:`case-studies/animation-recovery.md` §2.1) ### 2.2 时间轴逐事件对齐,不是"总时长差不多" - 事件的**触发者、门控条件、先后序**都是规格;preloader 最短展示时长这类门槛值也要抄【samsy】。 - 页面过渡链路按逆向笔记的 boot 时序图移植【lando】。 (实证:`case-studies/animation-recovery.md` §2.2) ### 2.3 路由过渡要搞清"谁被替换、谁常驻" - 换页只替换视图容器内的内容,header/nav/声音开关这类常驻组件不许跟着重建。 - 修复后用 **DOM 身份测试**验证:跨 home→about→home→project 导航断言 `.ui-header` 是同一个 JS 对象【rogier】。 - 平台运行时的换页契约也是规格(如换页后必须调 `window.Webflow.destroy()+ready()`)【lando】。 (实证:`case-studies/animation-recovery.md` §2.3) ### 2.4 入场态从初始值开始 - 源站若以 CSS opacity 0 附加新视图再 `fromTo(0→1)`,重建直接置 1 就会闪帧;用阶段截图验证修复【rogier】。 - README 总结:"时序即视觉:任何『先显示再动画』的偷懒都会闪"【rogier】。 (实证:`case-studies/animation-recovery.md` §2.4) ### 2.5 文本动效:拆分算法与不可见字符也是规格 - 自研 SplitText/行动画要逐字移植,含 CJK 分词逻辑;不可见字符逐码点核对(U+200B/U+00A0/U+202F)——拆分结果不同,动画单元就不同【samsy】。 - 悬停翻字(LetterFlippers)等交互组件各自对应独立源文件直译【oryzo】。 ### 2.6 Checklist(路径 A) - [ ] 每条 timeline/tween 的 duration、ease、delay、stagger 有行号出处 - [ ] ScrollTrigger 的 start/end/scrub/invalidateOnRefresh 逐字段抄录 - [ ] 事件触发链(谁 dispatch、谁监听、门控条件、最短展示门槛)与源站一致 - [ ] 常驻组件不随路由重建(DOM 身份断言) - [ ] 动画初始态 = 源站初始态(不许先显示再动画) - [ ] 文本拆分算法与源站逐码点一致 - [ ] 平台运行时的换页契约(destroy/ready 调用)保留 ## 3. 路径 B:烘焙数据文件 → dump 数值账本 适用:动画曲线烘焙在 GLB/`.buf` 等数据文件里,代码只负责按进度采样。原则:"compare recorded values, not screenshots"【noomo,脚本注释原话】。 操作步骤: 1. **先把数据文件 dump 成 JSON 数值账本,再写播放器**。本 skill 的 `scripts/dump-timelines.mjs` 即此用途。这份账本是后续一切验收和差异排查的基准。 2. **验收用数值全等,不用截图**。写明精度,如与基准插值小数点后三位全等【noomo】。 3. **差异排查也回到账本**。拿账本采样值逐项核对现场值,证明"参数绑定链无 bug"后才定性为已知差异登记【noomo】。 4. **把滚动→进度链逆向成纯函数**——逆向成纯函数后可以数值验证而不依赖手感【noomo】。 5. **相机轨迹类二进制同理**:先逆向出布局与量化公式,配调试页量化验收再接主站【oryzo】。私有格式细节见 `references/binary-formats.md`。 6. **bundle 内联的数据资产单独提取**。base64 LUT/纹理提取到 `_extracted/`,复刻侧内嵌后做**字节级一致性验证**【noomo】。 (1–6 各条的实证:`case-studies/animation-recovery.md` §3) ### Checklist(路径 B) - [ ] 数据文件已 dump 成 JSON 数值账本并入库(先于播放器代码) - [ ] 滚动→进度链已逆向成纯函数并写明映射公式(如"1 段 ≡ 1 秒") - [ ] 播放器验收 = 若干采样点与基准插值数值全等(写明精度,如小数点后三位) - [ ] 内联 base64 数据资产已提取并做字节级一致性验证 - [ ] 后续视觉差异排查优先回账本核对参数链,再谈渲染层 ## 4. 路径 C:CSS 变量/内部 state → 录基准 + 拟合/重放验证 适用:观感由 CSS 自定义属性或框架内部 state 驱动,值从外部读不到、代码是压缩推导式。kimi 的判词:"CSS 变量曲线形状就是观感本身,**不看截图看数值**"【kimi】。 操作步骤: 1. **纯函数层与 DOM 层分离**。把编排数学抽成无 DOM、无框架的纯函数库(文件头逐函数映射 minified 名与行号),让数学可以脱离浏览器被验证,验证通过后再接组件层【kimi】。 2. **先在源站上录基准**。探针在镜像上录 CSS 变量随驱动量(滚动/deck 位置)变化的时间序列,存成 JSON 基准;录制探针与验证器成对出现(probe-* 录源站基准 / verify-* 验复刻)【kimi】。 3. **拟合验证**(内部 state 读不到时):验证器对每个观测状态在参数域扫描找残差最小点——"如果移植是对的,每个观测状态都能把残差压到 0;只要有一项系数写错,就会有状态在任何位置都对不上"。 4. **接线后把同一套源站验证原封打回复刻**。纯函数验证通过 → 接进真 DOM → "整套针对源站的验证原封不动打在复刻上"再跑一遍【kimi】。 5. **坑:基准采样面要覆盖全部驱动通道**。录完基准先确认它在整个驱动域上有区分度。 (实证:`case-studies/animation-recovery.md` §4) ## 5. 路径 D:物理/程序化模拟 → 常量表全抄 适用:动画是常量 + 每帧积分,没有"曲线数据"可 dump。事实来源就是常量表本身。 - 物理常量全表照抄,文件头注明 pretty 行号区间——全部带 bundle 行号【samsy】。 - 复杂效果**先在笔记里拆成结构再移植**——"复刻时必须按此结构而非『打灯调像』"【samsy】。 - 确定性随机源(LCG 种子)、弹簧参数、限流等"手感参数"全部从 bundle 抄写【noomo】。 - 粒子/缓动直译不改算法:CPU 粒子的 O(n²) 接触解算、SecondOrderDynamics 鼠标缓动各自对应独立源文件逐字移植——**不许"优化"复杂度**【oryzo】。 - GPU 侧的动画数据管线(VAT 骨骼动画的 worker 烘焙协议)先在笔记里逆向出协议再移植【samsy】。 (前三条的实证:`case-studies/animation-recovery.md` §5) ## 6. 特殊模式:无全局时间轴,进度由 DOM 几何推出【oryzo】 滚动叙事站不一定有全局 timeline。进度可以完全由 DOM 元素几何位置推出,相机运镜另走数据文件按帧插值。(实证:`case-studies/animation-recovery.md` §6) 操作要点: - 先证实"有没有全局时间轴"再动手,结论写进逆向笔记。 - 若进度源是 DOM 几何,则 DOM 骨架的字节级还原(见 `references/dom-shell-strategies.md`)就是动画正确性的前置条件——验收要含"浏览器 scrollHeight 与源站一致"【oryzo】;scrollHeight 不对,全站进度都错。 - **不要发明源站没有的全局 timeline 来"统一管理"**——"源站没有的不做"。 ## 7. 输入/手感状态机 手感 = 状态机 + 魔数,两者都不许手调。 1. **魔数逐字照抄**:滚轮缓动系数、Lenis 配置逐字抄,连"两分支配置相同"的怪癖也照抄【oryzo】【lando】。 2. **状态机参数从 bundle 取证**:闩锁阈值与静默重置窗口、触摸离散滑动阈值、补间时长双段曲线【kimi】。 3. **用录制时间线重放验证,替代手调**:探针在镜像上注入带时间戳的输入序列录基准,验证器把控制器放进虚拟时钟按同一时间线重放,逐帧比轨迹。判据:"闩锁、阈值、目标、时长、缓动**任何一环错,轨迹都会以远超容差的幅度发散**"——重放门天然对所有参数敏感【kimi】。 4. **状态机实现要做成环境注入可重放**(时钟、事件源可替换构造参数),否则重放验证无法搭建【kimi】。 5. 过渡过程的连续性也可量化:rogier 在换页过程中每 120ms 采样元素计算色,两站颜色时间轨迹逐点对齐(per-sample RGB delta ≤6)【rogier】。 (1–3 条的魔数与实测残差:`case-studies/animation-recovery.md` §7) ## 8. 常见坑 1. **目测近似沉淀成正版**:近似实现只能当 stub,必须显式标记生命周期并替换为溯源版【oryzo】【lando】。 2. **"先显示再动画"必闪帧**:初始态也是规格,"时序即视觉"【rogier】。 3. **"好心修正"怪写法**【rogier】【lando】:"压缩代码里的每个怪写法都可能是行为本身"——照抄并登记怪癖表。 4. **冷启动才暴露的动画 bug**:每轮验收必须冷启动,"不手动切效果——手动切换会掩盖初始化状态 bug"【oryzo】【kimi】。 5. **检查点漏掉滚动两端**——**终检必须包含滚动两端**【noomo】。 6. **基准录制的覆盖盲区**:只录部分变量/部分驱动域会"失明"【kimi】;录完基准先验证覆盖度。 7. **常驻组件被路由重建**:入场动画反复重放是典型症状,用 DOM 身份断言抓【rogier】。 8. **手感验证依赖真实事件语义**:涉及用户激活门控(如 `experienceStarted` 需 isTrusted)时,驱动必须用真实点击而非合成事件【noomo】,详见 `references/determinism.md`。 9. **自创补偿性动画/CSS**:JS 机制没对齐时用自创 CSS 补观感,等 JS 对齐后补丁反转成 bug——"宁可先不像,也不要发明规则"【rogier】。 (3/4/5/6/9 条的实证:`case-studies/animation-recovery.md` §8) ## 9. 产出物 - 逆向笔记中的动画参数 dump(带行号)+ 路径判定结论——先于代码 - 数值账本/录制基准入库:`docs/timeline-baseline/`、`docs/deck-baseline/` 类目录 - 录制探针与验证器成对的脚本(probe-* / verify-*) - 对应验证结果(拟合残差、重放残差、数值全等断言),验收门选型见 `references/verification-gates.md` - 手感魔数与怪癖的登记条目(怪癖表 §Q) -
archival-rescue.md 10.1 KB
# archival-rescue.md — X 类抢救:从 Wayback Machine 重建死站 X 类(原站已消失)占历年获奖站约 29%。站死了,但 Internet Archive 里往往躺着捕获—— 本指南把它们变成**标准镜像**:`scripts/wayback-mirror.mjs` 产出与 `mirror-site.mjs` 同构的 `mirror/` + 账本,下游全部门(verify-mirror / serve / sweep / 外壳 / 对拍自比)原样工作。 ⭐ **"标准镜像"不是修辞——X 类可以走完 L3 全程**。实测 first-launch.com(2013 Awwwards 死站,skrollr 滚动叙事):抢救镜像之上,策略 A 外壳(verify-shell 全 hunk 可重放)、 CLEAN/零外联、refs-served、数值门(32 检查点 × 146 选择器 9,856 样本全等)、像素巡航、 源码化自包含门,**一路全绿到 M(n+1)**,没有一道门需要为"参照是档案而非活站"改语义 【firstlaunch】。此前三个抢救止于 L1 是选择,不是天花板。 ## 0. 三个决定,缺一个产出就是汤不是证据 1. ⭐ **只取原始字节**:一切抓取走 `id_`(identity)回放旗—— `https://web.archive.org/web/<ts>id_/<原URL>` 返回捕获的原始字节,无改写、无工具条。 **永远不要镜像回放 HTML**(它被注入了 archive 的脚本与 URL 改写,是另一个站)。 2. ⭐ **一个连贯的时刻**:`--anchor`(默认 auto:根页 200 捕获最密的年代取中位)+ `--window-days` 逐 URL 选窗口内离锚点最近的 200 捕获。**从任意年代乱缝的镜像是一个 从未存在过的站**;抢注者时代的 301 洪水靠状态码 + 窗口天然出局 (实证:`case-studies/archival-rescue.md` §0)。 3. ⛔ **洞是既成事实,只能诚实记账**:活站的闭包门要求 ∅、补爬可以填洞;死站的洞 **永远补不回来**。`mirror/wayback-holes.txt` 逐条登记(URL + 引用者),它同时就是 `verify-mirror --allow-missing` 的清单——门对**已登记**的洞保持绿,对未登记的照红。 账即交付物的一部分。 ## 1. 别名回填:档案可能用另一个名字认识这个洞 `wayback-mirror` 对每个洞做一次 CDX 同名(basename 精确匹配)查询,窗口内命中则抓取并**存到被引用的路径**上,让引用得以解析(实证:`case-studies/archival-rescue.md` §1)。 ⛔ **别名回填是推断,不是捕获**:"同名异路 ⇒ 同一文件"可能错(同名不同文件存在)。 所以它在 `wayback-holes.txt` 里单列 **FILLED BY ALIAS** 段(referencedAs ⇐ archivedAs @timestamp),provenance 记 `aliasOf`,**逐个目验**,永不冒充原路径的真捕获。 ## 1.5 ⛔ 铁律:抢救项目里永远不要对原域跑 mirror-site 死域的"死"有两种应答形态:3xx(跳走)与 **200(停车页夺舍)**。redirect:manual 纪律 只挡得住前者;停车页直接 200 应答,mirror-site 会**用停车字节覆写救回的真身,并把账 同步更新——五道门全绿地完成一次污染**(实证:`case-studies/archival-rescue.md` §1.5)。抢救项目的一切补种走 `wayback-mirror --seeds`;活的第三方 CDN(字体/jquery)也**问档案要当年的字节**, 不要问活 CDN 要今天的。verify-mirror 的 interstitial 表现已内置停车签名族, 但它只能事后抓——不犯是纪律,抓到是底网。 ## 1.6 验尸三件套:死亡时刻、停车页时代、伪身捕获【firstlaunch】 锚点要选在真身时代,所以先给站验尸。三个 CDX 层面的判据,全部零成本: 1. ⭐ **停车页时代有 CDX 签名**:`.well-known/ai-plugin.json`、`.well-known/security.txt`、 `ads.txt`、`app-ads.txt` 这类路径突然出现 200 捕获、且 mimetype 全是 `text/html`—— 真身不会拿 HTML 应答 `ads.txt`,停车页对任意路径都答同一张页。这些行冒出的年代 就是夺舍年代;判死亡时刻、选锚点前先把它们剔出统计。 2. ⭐ **根页 digest 变迁史就是站的年表**:按 digest 分段,每段一个内容 时代;深爬时刻(全站资产同日被爬)是最连贯的锚点候选。 3. ⛔ **同 digest 交叉鉴伪**:一个路径的孤本捕获若与**停车页时代的根页**同 digest, 它是夺舍后的伪身,不采(实证:`case-studies/archival-rescue.md` §1.6)。 ## 1.7 锚点偏置:一次罩住别的时代的孤本【firstlaunch】 有些文件只在**另一个内容时代**被捕获过(改版时摘掉的 awwwards.css、只挂过一个月的 节日子页)。逐 URL 取"窗内离锚点最近",所以**把锚点压向窗口一侧**能一次罩住(实证:`case-studies/archival-rescue.md` §1.7)。代价是跨时代混入,按偏差登记(provenance 逐文件时间戳 本来就记着)。**不必为孤本二次抓取或扩窗重跑。** ## 1.8 第三方 CDN 的两跳种子:字体【firstlaunch】 Google Fonts 是两跳:CSS(`fonts.googleapis.com/css?family=…`)→ 字体文件 (`fonts.gstatic.com/...ttf`)。都问档案要当年的字节(§1.5):先 `--seeds` 种 CSS,读 救回的 CSS 提取字体 URL,再 `--seeds` 种字体文件。⚠ CSS 捕获的 digest 逐次都不同是 **正常的**——Google 按 UA 出不同格式,档案存的是当年爬虫 UA 拿到的那份;任选窗内一份即当年字节,不要因 digest 不稳去找"更对的一份"(实证:`case-studies/archival-rescue.md` §1.8)。 ## 1.9 ⛔⛔ 抢救深度要在锚点之前预判:运行时拼接的资产是档案的射杀区【mustachelab】 **IA 的爬虫不执行 JS。** 凡 URL 由代码在运行时拼出——加载器清单(CreateJS `LoadQueue(PATH)`)、 资源清单文件(`RESOURCE.dir + file`)、模板字面量——档案**从未请求过它们**。这不是洞多洞少 的问题,是**一整层内容成建制地不存在**(实证:`case-studies/archival-rescue.md` §1.9)。 **因此 §0 的三个决定之前,先做第四个判断——分层覆盖侦察**(Step 0 的一部分,成本三次查询): 1. 读根页捕获的 HTML/JS 引用形态:资产是静态标签引用(`<img src>`/`<link>`/`<audio src>`, 爬虫看得见),还是清单/拼接驱动(爬虫瞎); 2. 对代码暗示的资产子树做 **CDX 前缀查询**(`matchType=prefix&collapse=urlkey` 一次问完 整个 `/assets/` 子树)——`collapse=urlkey` 清单是全量 urlkey 目录,零行 = 任何年代都没 被捕获过,权威; 3. 分层报告:代码层覆盖 x%、媒体层 y%——**"CDX 无覆盖才是真不可做"按层读,不按站读**。 媒体层为零的站,抢救终点在锚点选定之前就该改判(L1 + 引擎文档),不要跑到 M0.5 撞白屏。 **镜像期的配套动作——洞账要人工补全集**:wayback-mirror 内建洞扫描走 extract-refs(静态 提取),对运行时拼接**整类失明**(实证:`case-studies/archival-rescue.md` §1.9)。做法是写一个**站点侧推导器** (几十行,逐条带源码行号)把清单机械展开成 URL 全集 → 与 CDX/镜像对账 → 未捕获的整批 append 进 `wayback-holes.txt`——它同时就是"资产若回归"的 seeds 清单。这是 `reconcile-gaps` 的"字节推导全集"在死站上的同构物。 **外部档案没有备胎可自动化**:archive.today 有 CAPTCHA(agent 不代过验证码,⛔ 硬规则), Memento TimeTravel 聚合器长期时好时坏——两者只能作为**登记给人工的线索**,不进管线。 真正可能补齐画面的往往是**权利人本人**——把"联系作者"写进 DEPLOY.md 的选项表,这是唯一现实的复活路径。 ## 2. 礼貌是功能 web.archive.org 对高频访问限流(429/503)。默认 2 worker + 350ms 间隔 + 指数退避; **抢救不是竞速**——档案馆是公共资源,一次被封整跑作废。CDX 枚举也要间隔。 ## 3. 死站特有的门语义 - **抽样回源(--resample)无意义**:没有源可回。真实性(AUTHENTICITY)检查**照跑且更重要** ——archive 存的是当年爬虫看见的任何东西,**被存档的挑战页/拦截页是真实存在的危险** (魔数对声明类型、拦截正文模式,全部照常)。 - **provenance 取代"源站说的"**:`mirror/wayback-provenance.json`(锚点、窗口、逐文件 捕获时间戳 + CDX digest)是死站复刻的坐标系;逆向笔记引用它,不引用不存在的源站。 - **救不回来的,登记**:从未捕获的运行时 API 响应、档案外的第三方 CDN(off-host census 会点名,逐主机决策——死站的 CDN 可能也死了,也可能活着还能直抓)、POST 端点。 与活站同一条纪律:不抓只能有技术性理由,一律登记。 ## 4. 版权:站亡,权利不亡 站点下线**不改变**其内容的版权状态——作者/公司的权利在站死后继续存在。 抢救产物照旧:私有 + noindex + 不部署,逐资产取证,决定呈交用户 (`legal-and-deploy.md` 全套适用)。存档价值(防止创作永远消失)与再分发权是两件事。 ## 4.5 CLEAN 门的死站语义:失败 ⊂ 洞账【firstlaunch】 抢救范围内的路由,断网门语义与活站完全一致(零 404/零错误/零外联)。但**引用着永久洞 的路由**(孤儿子页、洞在 CSS 里的页面)注定有 404——门的判据不是"零失败",而是 **"失败清单 ⊆ wayback-holes.txt,一条账外失败都没有"**。逐条比对(实证:`case-studies/archival-rescue.md` §4.5),把比对结果写进里程碑日志。⚠ probe/sweep 目前没有 `--allow-404 <holes>` 通道,这一步是人工比对——比对时警惕"差不多都对上了":一条 账外失败就是一个真缺陷。 ⭐ 另一面:**源站生产环境自己的 404 不是洞**。CDX 里 statuscode 就是 404 的引用 (死 CSS 引用不存在的图),是源站行为,照抄——镜像伺服它 404 正是保真【firstlaunch】(实证:`case-studies/archival-rescue.md` §4.5)。 ## 5. 流程(与活站的差异点) ``` Step 0 判 X 类(域名易主/回收/路径移除/原地替换)→ CDX 覆盖侦察(有几条?哪些年代?) M0 wayback-mirror(anchor auto → 人工确认年代合理)→ verify-mirror --allow-missing mirror/wayback-holes.txt M0.5 serve + sweep-routes 照常(断网门语义不变:回放伺服的是本地字节) M1+ 逆向/外壳/对拍自比/源码化,全部标准 —— 参照系是镜像自身与 provenance 交付 DEPLOY.md 增加「存档抢救」一节:锚点、窗口、洞的账、别名回填清单 ``` -
asset-management.md 10.6 KB
# 资产管理:镜像即唯一资产库,不复制策略 > **何时加载本文件**:M0 镜像完成、开始搭建复刻工程骨架时——需要决定"运行工程如何消费镜像资产";以及遇到授权字体、百 MB 级媒体的处置决策时。 ⛔ **适用阶段:本文全部内容只作用于 ② `port/` 阶段(工作区)。** 到 ③ `src/` 阶段,不复制策略**反转**——自包含是那一阶段的定义性要求,资产必须完整复制进 `src/assets/`,否则"复制到任何地方都能跑"不成立。⚠ 反转的是**盘上复不复制**,不是**入不入 git**:git 里默认仍排除源站字节、只留账本,是否分发仍是用户的决定。见 [readable-source.md](readable-source.md) §2。 ## 0. 核心原则 1. **镜像神圣不可污染**:`mirror/` 磁盘文件永不修改,一切本地化适配在服务层/中间件动态完成【samsy】【noomo】【lando】。 2. **镜像是唯一资产库**:manifest(sha256)是权威清单;复刻工程尽量不产生资产的第二份拷贝,让"这些不是我写的"在文件系统层面成立【kimi】【lando】。 3. **不做"找相似替代资源"**:字体、GLB、HDRI、视频全部用镜像原件,没有任何替代资源环节【lando】。 ## 0.5 账本先行:manifest 是资产层的宪法 不复制策略成立的前提是账本可信,镜像阶段就要备好: - 权威清单逐文件登记:`mirror-manifest.json`(url → path/bytes/type)【lando】、`manifest.tsv` 逐文件记 OK/FAIL/大小留证【samsy】、`inventory.tsv`(sha256)作为资产比对的唯一来源【kimi】; - 多外部 host 的镜像按 `mirror/assets/<host>/<path>` 组织,URL 空间与磁盘一一对应【lando】; - 后续一切"资产在不在、对不对"的判断只对账本,不对目测。 ## 1. 选型:先量体量,再看构建器 决定因素只有两个: - **资产体量**:百 MB 级资产**必须走不复制路线**;十几 MB 也建议不复制(实证:`case-studies/asset-management.md` §1)。 - **构建器能力**:dev server 能不能挂中间件(vite 可以)、框架有没有静态资产挂载点(nitro `publicAssets`)、`public/` 是否接受符号链接(Next 可以)。 ## 2. 六种做法(六个项目各一种,按代际排列) | 做法 | 项目 | 要点 | |---|---|---| | ① 全量复制 + 哈希验证 | 【rogier】 | `public/` 与源站逐字节一致,且真的验证过;约 111MB 媒体直接进 git,部署只上 `dist/`。**一代做法,后代全部演进为不复制**(实证:`case-studies/asset-management.md` §2) | | ② 三目录分离 | 【oryzo】 | `mirror/`(只读逆向依据)→ `public/`(运行资产)→ `dist/`(部署产物)三层分离,镜像永远不动。仍有复制,但确立了"镜像 ≠ 运行资产"的边界 | | ③ dev 中间件回落 | 【samsy】 | vite dev 中间件 `mirrorFallback()` 把根路径资产请求(/textures、/videos 等 11 个目录)回落到 `mirror/`,238MB 二进制不进 `public/` | | ④ 符号链接 | 【kimi】 | `public/` 用符号链接指向 `mirror/`——"让『这些不是我写的』这件事在文件系统层面就成立",资产比对只有一个 sha256 来源(inventory.tsv),登记为偏差 §6.4 | | ⑤ nitro publicAssets 挂载 | 【noomo】 | 轻资产(images/audio/字体)复制进 `public/` 与 `app/assets/` 走 Vite 构建;重资产(models/textures/timelines/videos/libs)不复制入库,用 nitro `publicAssets` 把 `mirror/` 对应目录直接挂载到 URL 空间;构建时 nitro 自动拷入 `.output/public`,产物自包含 | | ⑥ `/ext/<host>/` 映射 + rsync -L | 【lando】 | 资产按 `mirror/assets/<host>/<path>` 组织;dev 用 vite 插件 `extAssets()` 把 `/ext/<host>/` 映射回镜像;build 后 `postbuild.mjs` 建 `dist/ext → mirror/assets` 符号链接,部署时 `rsync -L` 解引用(登记为偏差 6.4)。适合资产分散在多个外部 CDN 域的站 | **选型指令**: - 资产分散在多个外部 host(CDN 跨域引用限制、第三方域)→ 走 ⑥(`/ext/<host>/` 统一收编,服务层把外部 URL 改写为该前缀,"same trick as samsyninja-rebuild"【lando】)。 - 同源资产 + 框架有静态挂载点 → 走 ⑤(noomo/nitro)或 ④(符号链接,最简单,Next/静态站首选)。 - vite 工程且资产按源站根路径组织 → 走 ③(中间件回落)。 - ① 只在资产总量小且需要"public 即镜像副本"的哈希审计语义时考虑,且必须配全量哈希验证【rogier】。 - 无论选哪种,凡与"源站直接伺服资产"不同的机制(符号链接、挂载、映射)**登记进偏差表**【kimi】【lando】。 ## 3. 配套机制:CDN 跨域引用与服务层改写 不复制策略的前提是"镜像可被本地伺服",跨域与外链问题一律在**服务层响应时**解决,磁盘镜像保持纯净: - **抓取期补齐 Referer**:源站资产域要求同源 Referer、缺失时返回 403,抓取按其要求带 `Referer: {源站域}`【lando】。 - **运行期 CDN 基址改写**:samsy 的源 bundle 把音频路径无条件改写为 BunnyCDN 前缀且该 CDN 要求同源引用——`serve.mjs` 在响应层把 CDN 基址动态替换为 `/cdn/` 并按扩展名映射回本地目录【samsy】;复刻侧还可利用源站自带的 `?cdn=false` 查询参数分流到本地媒体【samsy】。 - **改写的副作用要登记**:lando 服务层改写文本响应后字节无法匹配原 SRI 哈希,因此剥离 integrity 属性并登记为偏差 6.10【lando】。 - **一代反例**:后代演进为"干脆不改磁盘"【rogier】。若被迫改磁盘,必须把每处重写登记在案并在对比时扣除(实证:`case-studies/asset-management.md` §3)。 ## 4. 分层细则:轻资产可入库,重资产必不复制 noomo 的双通道是范本【noomo】:模板直接引用的轻资产(图片/音频/字体)进 `public/`、`app/assets/` 走构建管线;重资产(模型/纹理/时间线/视频/解码库)一律留在镜像、挂载消费。 第三方在线依赖也可本地化进运行资产(rogier 把 detect-gpu 的 unpkg benchmarks 本地化到 `public/vendor/detect-gpu/`)【rogier】,但改写 bundle 指向它的每一处都要登记(见 §3 一代反例;实证:`case-studies/asset-management.md` §4)。 数据类资产(作品列表、布局、i18n)如何反解入库不属于本文件范畴——那是"用脚本从 bundle 抽成 JSON"的移植问题【samsy】【kimi】。 ## 4.5 移动端变体:规则要逆向,不要猜 移动端资产往往是同名变体,命名规则藏在 bundle 里,必须逆向出来再补抓(实证:`case-studies/asset-management.md` §4.5)。 ## 5. 资产保真细则(复用镜像时逐条执行) - **逐字节一致才算复用**:把复用的二进制与源站原件逐字节验证【rogier】(实证:`case-studies/asset-management.md` §5)。 - **模型原样使用,不做归一化**:不做旋转翻转/包围盒归一化——模型自带内在 scale,是行为的一部分【rogier】。 - **"坏资产"也要复刻**:源站 `favicon.svg` 是 404,rogier 删除本地占位文件但保留 head 里的 link——复刻"这个链接在源站就是坏的"【rogier】。 - **MSDF bitmap 字体直接镜像**:bmfont JSON+PNG 原件照用,`msdfunit = 6/图集尺寸` 等硬编码照抄;布局算法用同库同算法的 npm 包替代 vendored 版时登记偏差【samsy】。 - **第三方 WASM 也从镜像出**:Rive WASM 从本地镜像 `/ext/unpkg.com/...` 提供,使复刻离线自包含(登记为偏差 6.6)【lando】。 - **bundle 内联资产单独提取**:base64 内嵌的纹理/LUT 提取到 `mirror/_extracted/`;复刻侧反向内嵌时做字节级一致性验证【noomo】。 ## 6. 字体决策 ### 6.1 授权网页字体:默认保留原引用、不进运行资产——**这一决定归用户** ⚠ **先分清两件事**:**字体抓不抓进镜像**是技术问题(答案永远是抓,镜像完整性是技术不变量);**能不能把这份二进制自托管到我们自己的 origin、能不能再分发**是法务问题,**归用户决定**。agent 的活是取证(许可条款原文、文件内 banner、文件名信号如 `*Unlicensed*.woff2`)+ 给建议 + 在用户决定前执行安全默认,**不是自己下结论**(`references/legal-and-deploy.md` §0.1/§0.2)。 **安全默认(用户决定之前照此执行)**:Adobe Fonts(Typekit)类商用授权字体不自托管——保留源站的 Typekit CSS 引用、接受离线时的字体回退【oryzo】;**镜像抓到的副本照常存 `mirror/external/typekit/` 供逆向复核**,只是不进运行资产【samsy】。 **取证要点**:源站自己有没有授权**不改变我们的处境**——把同一份二进制自托管到另一个 origin 是一次独立的、我们自己的使用行为;商用网页字体常按域名/流量计价。把这句连同证据一起呈给用户,让他决定,别替他决定。 ### 6.2 自托管字体:默认照抄原件,拒绝"顺手优化" 自托管字体拒绝子集化的决策是偏差登记的范本【kimi】(实证:`case-studies/asset-management.md` §6.2)。拒绝子集化的五条理由按杀伤力排序: 1. **字体是首屏渲染门控**:deck 等 `document.fonts.ready` 才渲染,子集化会污染时序基线; 2. canvas `measureText` 折行结果会变; 3. 点阵字体对坐标舍入极敏感; 4. 538 个汉字是移动靶,缺字静默失败; 5. 私有仓库里体积收益为零。 并附"重新考虑的条件"。**指令**:任何"看起来该做的资产优化"(子集化、压缩、转格式)先按这五条的思路论证它是否破坏测量基准;做不做都写成偏差表条目(源站怎么做 / 我们怎么做 / 为什么 / 什么条件下重新考虑)。 ## 7. 落地检查清单 - [ ] 镜像磁盘零改写;本地化适配全部在服务层/中间件【samsy】【noomo】【lando】 - [ ] 百 MB 级资产没有第二份拷贝(源码树、git、public 均不含)【samsy】【kimi】【lando】 - [ ] manifest/inventory(sha256)是资产比对的唯一权威来源【kimi】 - [ ] 外部 host 资产有统一消费路径(`/ext/<host>/` 或服务层改写)【lando】【samsy】 - [ ] 授权字体默认不进运行资产、保留原引用;**镜像侧副本完整**,自托管/再分发与否已取证并交用户决定【oryzo】【samsy】 - [ ] 字体/媒体的任何改动(或拒绝改动)已进偏差表【kimi】 - [ ] 部署路径已验证符号链接/挂载在产物中真实解引用(`rsync -L`、`.output/public` 实测)【lando】【noomo】 -
beyond-the-rebuild.md 4.2 KB
# beyond-the-rebuild.md — 复刻之后:通往你自己的项目 ⛔ **这是一份交接文档,不是一个阶段。** 本 skill 的管线到 L3 源码化为止——"人能读懂的 真实"。从这里出发做**你自己的东西**(脚手架化、fork、二次创作)是普通软件工程:目标只有 你自己知道,等价性不再是判据,skill 不替你做,也没有任何门能替你的创作背书。本文只交接 三样东西:一条原则、一套工作法、一份权利地图。 (这个边界是深思后的决定,不是没做完:起好名字、写叙事注释、决定抽什么做模板、去品牌—— 这些动作全部取决于"你想做什么",开发者自己做得比任何工具好。skill 的比较优势在裁判, 而脚手架正是裁判退役的地方。) ## 1. 衍生层原则:另起一层,发明才合法 skill 的产物链是逐层衍生的:`mirror/`(证据)→ `port/`(逐字移植)→ `src/`(可读源码)。 每一层对上一层的偏离都有明确契约。你的项目是**再衍生一层**——`scaffold/`、或者干脆一个 新仓库,从 `src/` 复制出发: - **在你的层里,发明是合法的**:描述性命名、叙事注释、结构重构、内容替换——源码化阶段的 "无证据不改名"在这里**反转**,因为你的层不再声称"是源站",它声称"是我的作品"。 - **推测仍须标注**:写"我猜源站这里是为了 X"可以,但要写成推测。这条不是保真纪律, 是对未来读者(包括未来的你)的诚实。 - ⛔ **`mirror/` / `port/` / `src/` 一个字节不动**。它们是你的参照系;改了它们, 下面那套工作法就失去基准。 ## 2. 带裁判的 fork:把变红的门当偏离台账用 skill 给你留了一样别处拿不到的东西:**你精确地知道自己改了什么**。 `src/` 自带 `byte-manifest.json` + `verify-bytes.mjs`(拼接式分解的项目还有 `verify-reassembly.mjs`)。在复刻语境里它们是"必须绿"的门;在你的衍生层里,**红就是台账**: ```sh node verify-bytes.mjs # 每一条 MISMATCH = 你偏离源站的一个文件 node scripts/verify-reassembly.mjs --dir src/readable # 精确到部件 ``` 工作法四步: 1. **改之前先跑一遍全绿**——确认起点就是验收过的源站行为; 2. **每完成一批改动,把红名单收进 `docs/divergence.md`**,逐条写 "源站怎么做 / 我改成了什么 / 为什么"——和 skill 登记偏差同一个格式,只是方向反了: skill 登记的是"未能等价",你登记的是"有意不同"; 3. **没动过的子系统,运行时门照常跑**(serve + probe + pixelcompare 都随交付物走)—— "我只想改首页"的 fork 里,其余路由的等价性依然可证; 4. **某个子系统偏离到面目全非时,显式退役它的台账条目**("此处已完全是我的实现"), 不要让台账变成一堆无人再读的红。 这套工作法防的正是"近似漂移不可见"(实证:`case-studies/beyond-the-rebuild.md` §2)。 **从真实出发 + 知道每一步偏移**,是 fork 能有的最好起点。 ## 3. 权利地图:哪里最重,哪里在变轻 ⛔ 本节是事实陈述,不是法律意见;判断与责任在你(`legal-and-deploy.md` 的呈交原则 到这里依然成立)。 - **最重的部分是资产与内容**:图片、视频、字体(商业授权字体尤其)、文案、品牌标识、 CMS 数据。它们不会因为你改了代码而变轻——**做脚手架的第一步应该是把它们换成占位物**, 这既是法律止血,也逼着你的模板真正参数化。 - **代码按偏离度渐变**:保留的源站代码部分依然是源站作者的;你偏离越多、重写越多, 你自己的著作权积累越多。`docs/divergence.md` 顺带成了这条渐变线的记录。 - **交付物里的第三方**:vendor 库(three/GSAP 等)各有各的许可证——GSAP Club 插件这类 商业授权物**不因你 fork 而获得再分发权**;逆向笔记里的版本取证告诉你该从 npm 装回什么。 - 复刻项目的 `DEPLOY.md` 逐资产表在这里仍是你最好的清单:它列的每一行"不可再分发", 都是你的脚手架发布前要替换或取得授权的一行。 -
binary-formats.md 9.2 KB
# 私有二进制格式逆向指南 > **何时加载本文件**:镜像或逆向阶段发现原站使用非标准/私有二进制资产(`.buf`、`.sog`、VAT 烘焙数据、`.riv`、MSDF bmfont、被非常规使用的 GLB 等),需要决定"搬运、解析还是重实现"时加载。 ## 0. 首要原则 **"数据文件直接搬运 + 播放器行为对齐"优先于"重实现格式"。** 处置顺序(从省力到费力,逐级降级): 1. **资产原件从镜像直接搬运**——绝不"找相似替代资源"【lando】。(实证:`case-studies/binary-formats.md` §0) 2. **播放器用同版本现成库**: - 版本从 bundle 取证(版本字符串、wasm URL)【lando】; - 源站 vendored 的库若有 npm 同款,先证明"同库同算法"再替换并登记偏差【samsy】【oryzo】。 3. **解码逻辑就在 bundle 里 → 1:1 移植解码器**,而非黑盒猜格式【oryzo】。 4. 只有以上都不可行,才走"推导布局与量化公式"的完整逆向(§2)。 ## 1. 分诊:三个判断 处置路线一图流: ``` 未知二进制文件 ├── 有现成播放器消费(.riv 等) │ → 原件搬运 + 同版本播放器 + 攻坚集成层【lando】 ├── 有开源参照(.sog = PlayCanvas SOG) │ → 解析器对照开源实现写,开源实现当验证 oracle【oryzo】 ├── 解码逻辑在 bundle / worker 里(.buf、VAT) │ → 解码器 1:1 移植【oryzo】;worker 协议逆向【samsy】 └── 标准容器被当私有数据载体(GLB 时间线) → 数据 dump 成 JSON 数值账本 + 数据文件直接播放【noomo】 ``` 拿到未知二进制文件,先回答三问再动手: **问 1:格式是否有开源参照?** grep 文件魔数/结构特征,对照开源生态。判据:能找到开源参照 → 解析器对照开源实现写,验证用开源实现当 oracle。【oryzo】(实证:`case-studies/binary-formats.md` §1) **问 2:它是"数据文件"还是"需要理解的格式"?** - **数据文件**(有现成播放器消费):直接搬运 + 播放即可。**难点在 DOM 集成层**——预载缓存、resize 注册表、状态机接线,做法是把 bundle 里的全局变量表逐字 dump 后照抄【lando】。(实证:`case-studies/binary-formats.md` §1) - **需解析的格式**:消费端逻辑要自己重建时才需要理解布局【oryzo】。 **问 3:消费者代码在哪?** - 主 bundle 里 → 从 `_pretty/` 行号定位解码函数,1:1 移植【oryzo】。 - Web Worker 里 → 把 worker 也 beautify 进 `_pretty/`,**逆向 worker 协议**写进逆向笔记,再移植烘焙管线【samsy】。注意 worker 文件本身是运行时才 fetch 的,静态镜像抓不到,需实跑补录【oryzo】【samsy】。(实证:`case-studies/binary-formats.md` §1) 分诊 checklist: - [ ] 三问均有书面答案,写进逆向笔记(只陈述事实,未坐实标"未确认") - [ ] 该格式的 worker/解码器/WASM 已确认在镜像里(否则先补录)【oryzo】【samsy】 - [ ] 播放器/解码库版本已从 bundle 取证(版本字符串、wasm URL)【lando】【samsy】 - [ ] vendored 库若用 npm 替代,已证明"同库同算法"并登记偏差【samsy】 ## 2. 完整逆向流程(以 oryzo `.buf` 为范本)【oryzo】 当必须解析格式时,按以下四步走: **步骤 1:从消费端代码推导布局与量化公式。** 不做黑盒 hexdump 猜测——bundle 里的解码逻辑就是格式规格书。同格式的非显然用法也要挖出来【oryzo】。(实证:`case-studies/binary-formats.md` §2) 逆向结论先写进 `docs/engine-notes.md`(含"对复刻的直接结论"),再写解析器代码【oryzo】。 **步骤 2:解析器 1:1 移植,不重新设计。** 死参数(`mipFilter`)、错误赋值(`format="R8"`)照抄不修——"修正它们反而会偏离源站的实际渲染结果"【oryzo】。(实证:`case-studies/binary-formats.md` §2) **步骤 3:配专用调试页。** 每个格式解析器配一个可视化调试路由——"比在主站里调试快得多"【oryzo】。(实证:`case-studies/binary-formats.md` §2) **步骤 4:量化验收。** 验收标准必须是可数的(全量资产 N/N 解析成功)【oryzo】。"看起来能渲染"不是验收。(实证:`case-studies/binary-formats.md` §2) Checklist: - [ ] 布局/公式结论带 pretty 行号写进逆向笔记 - [ ] 解析器头注释标源行号区间;怪写法照抄并登记 - [ ] 专用调试路由可跑 - [ ] 全量资产 N/N 解析成功的量化门 ## 3. 标准格式的非标准用法:GLB 时间线【noomo】 标准容器(GLB)被当作私有数据载体时,处置方式不是"重实现",而是**先 dump 成数值账本**(实证:`case-studies/binary-formats.md` §3): 1. **手写最小解析器 dump 曲线成 JSON**:把全部动画曲线 dump 成 JSON 数值基准入库。脚本注释点明动机:"careers-kimi lesson: **compare recorded values, not screenshots**"【noomo】。 2. **数值账本先于任何引擎代码产出**(数据基准先行,在 M1 逆向阶段完成)【noomo】。 3. **验收用数值全等而非截图**:复刻引擎的验收是"相机位置在 t=0/5/10/19 与基准插值**小数点后三位全等**"【noomo】。 4. **账本兼任排障 oracle**:**证明参数绑定链无 bug 后**才定性为弱视觉差登记【noomo】。 5. 运行时消费仍然直接播放原 GLB 文件(重资产挂载镜像,不复制入库)——账本只是验证基准,不替代数据文件本身【noomo】。 这个模式可泛化:**一切被数据文件驱动的动画,先把数据 dump 成 JSON 数值账本,再谈移植与验收**【noomo】。 ## 4. worker 协议与烘焙管线【samsy】 VAT(Vertex Animation Texture)类"运行时烘焙"格式的要点: - 数据不在磁盘文件里,而在 **worker 协议**中——把 baker worker 与主 bundle 一样用钉死版本的 js-beautify 展开进 `_pretty/`,协议全量写进 engine-notes 再移植【samsy】。 - 关联硬编码常量照抄:MSDF bmfont 字体(JSON+PNG 4 套)直接镜像,布局算法用 npm 同库替代并登记,`msdfunit = 6/图集尺寸` 等硬编码照抄【samsy】。 - 验收走引擎状态数值断言而非目测【samsy】。(实证:`case-studies/binary-formats.md` §4) ## 5. 各格式速查表 | 格式 | 项目 | 定性 | 做法 | 验收 | |---|---|---|---|---| | `.buf`(Lusion 自研) | 【oryzo】 | 需解析的私有格式 | 从 bundle 推布局+量化公式,解码器 1:1 移植,配 `/debug/buf` 调试页 | 25/25 全解析成功 | | `.sog`(Gaussian Splats) | 【oryzo】 | 有开源参照(PlayCanvas SOG) | 借开源实现比对验证;WASM 排序 worker 运行时 fetch,需补录 | 与开源实现比对 | | VAT 烘焙(worker 协议) | 【samsy】 | worker 内私有协议 | worker beautify 进 `_pretty/`,协议逆向进 engine-notes,移植烘焙管线 | 引擎状态数值断言 | | GLB 时间线 | 【noomo】 | 标准容器非标准用法 | 手写解析器 dump 曲线成 JSON 数值账本,数据文件直接播放 | 采样值小数点后三位全等 | | `.riv`(Rive) | 【lando】 | 数据文件 + 现成播放器 | 直接播放(同版本 canvas-lite,版本从 wasm URL 取证);攻坚集成层:全局变量表逐字 dump 照抄 | 探针 CLEAN + 真机对拍 | ## 6. 常见坑 1. **worker / WASM 是运行时才 fetch 的,静态镜像必漏**:发现私有格式时立即检查其 worker/解码器是否已在镜像里【oryzo】【samsy】。(实证:`case-studies/binary-formats.md` §6) 2. **数据类资产用脚本抽取,不手抄**:bundle 内嵌的数据一律脚本反解成 JSON 入库。生成物不手改——"连源站的拼写错误都免费保真"【samsy】【kimi】。(实证:`case-studies/binary-formats.md` §6) 3. **bundle 内联 base64 资产容易漏**:提取到 `_extracted/`,复刻侧再内嵌时要做字节级一致性验证【noomo】。(实证:`case-studies/binary-formats.md` §6) 4. **格式里的死参数/错误赋值照抄**:`mipFilter`、`format="R8"` 修掉才是偏离【oryzo】。 5. **移动端变体有独立命名规则**:oryzo 的 `getMobileUrl(url)` 在扩展名前插 `_MOBILE`——镜像时按规则补全变体,否则移动分支 404【oryzo】。(实证:`case-studies/binary-formats.md` §6) 6. **动态拼接的资产 URL 正则抓不到**:`` `/models/crystal${e}.glb` `` 类模板字面量要人工静态求解后逐个补抓;变量拼接的资产基址同样靠人工从 bundle 求解【noomo】【lando】。(实证:`case-studies/binary-formats.md` §6) 7. **自写二进制工具必须对参照实现验证**:诊断工具与验收门要用不同的正确性标准,一份解码代码同时服务两者时,坏账会藏在全绿里【kimi】。(实证:`case-studies/binary-formats.md` §6) 8. **别在主站里调试格式解析器**:没有专用调试页时,格式 bug 与场景 bug 混在一起无法归因——先建调试路由再接主站【oryzo】。 9. **模型的内在变换不要"归一化"**:按源站原样使用,不做旋转翻转/包围盒归一化——模型自带的内在 scale 是行为的一部分【rogier】。(实证:`case-studies/binary-formats.md` §6) -
determinism.md 51.4 KB
# determinism.md — 确定性协议(对拍门的前置条件) > **何时加载本文件**:搭建任何像素对拍 / byte-equal / 同帧对拍门之前必须加载(与 `references/verification-gates.md` 配套);以及当对拍结果不稳定、双侧截图"每次都不一样"时回来排查。核心命题:**先把双侧驱动到可复现的同一状态,比对才有意义**。 > > **⚠ 回答"距对拍验收还差什么"时别去数脚本**:`pixelcompare` / `side-by-side` / `probe-shim` 都在盘上 ≠ 协议就绪。objectandarchive 在行为侧 26/26 块全绿(两侧各 PASS 0、跨侧 0 差异)之后清点这一问,缺口正是本文件这套东西——九条熵源里**三条本轮之前不存在或没登记**(`Math.random()` 选首屏底色、两条 `localStorage` 跨会话持久状态),而那三个脚本一次都没跑过。**脚本齐备 ≠ 协议就绪;开工前先按 §1 冻结熵源清单,再谈跑哪个脚本。**【objectarchive】 ## 0.1 ⛔⛔ 冻结 JS 时钟冻不住 CSS 动画【v0-optimus】 `probe-shim.js` 接管的是**每一个经过 JavaScript 的时钟**:rAF、`setTimeout`/`setInterval`、 `performance.now`、`Date.now`、`new Date`、定种 `Math.random`。 ⛔ **CSS 动画不经过 JS。** `animation: marquee 30s infinite` 跑在浏览器自己的动画时间线上, 一个"完全冻结"的页面里它照样在走。 ⭐ **症状是"同侧对照比跨侧还大"**,再加上残差**在两次运行之间换位置**,两条独立证据都指向同一结论:**不可归因于移植**(`gate-failure-modes.md` §3.1 (D))。(实证:`case-studies/determinism.md` §0.1) ⚠ 别把它误读成"移植很好"。正确的读法是**这道门在这个目标上带宽受限**——它此刻分辨不出 比 0.3 更小的差异,所以阈值必须写成"实测带宽",且计划里要注明它是带宽不是精度。 ### 0.1.1 补法与它的边界 `pixelcompare.mjs --freeze-css` 给所有元素(含 `::before`/`::after`)加: ```css animation-play-state: paused !important; animation-delay: -1s !important; /* 同一相位,两侧一致 */ transition: none !important; ``` ⚠ 它**改变被渲染的内容**(marquee 被定格在行程中间而不是各自漂到的位置),这正是目的: **两侧定格在同一位置**。(实证:`case-studies/determinism.md` §0.1.1) ### 0.1.2 ⭐⭐ IntersectionObserver 也是一个时钟,而且在滚动揭示站上是最要紧的那个 剩下的熵源是 **IO 的投递时机**:浏览器按自己的 节奏投递交叉记录,不在主线程的帧循环上。于是同一个"已冻结"页面的两次抓取,其入场动画 可能从不同的泵计数开始——**这种残差会在两次运行之间换位置**,正是它让残差无法归类。 ⭐ **修法是把它接管过来**:记录每个 observer,在**泵里**同步投递记录,按注册顺序, 且**只在状态变化时投递**(每帧都投递就成了另一种 observer,会让一次性揭示反复触发)。 两侧于是在同一个虚拟帧上看到同一批回调。 (各阶段自比带宽 / 跨侧最差 / 可用阈值的实测表与回归验证:`case-studies/determinism.md` §0.1.2) ⚠ 它改变的是回调**何时**触发,不是**是否**触发;只在 `?__probe` 下生效。 ### 0.1.3 ⚠ 冻结页上的探针不许用真实计时器 shim 把 `setTimeout` 换成了受泵驱动的队列。所以在 `?__probe` 页上写 `await new Promise(r => setTimeout(r, 3000))` **永远不会返回**——探针挂死,而看起来像页面卡住。 在冻结页上等待,只能用 `window.__pump(dt, n)` 推进,或者干脆去掉 `?__probe` 再测。 ## 0.2 ⛔⛔ `--ready` 必须是**泵循环的退出条件**,不能是泵之前的等待【eightdesign】 像素门原来在导航之后、泵之前等 `--ready`。⛔ 那样它**只能表达「不需要任何驱动就已就绪」**——而冻结页上值得等的状态,恰恰都是泵才能产生的:预加载走完、WebGL canvas 被定尺、入场动画结束——**在一个前置条件尚未运行的条件上等待。** ⭐ 改成泵循环的退出条件:**泵到状态达成为止,以帧预算封顶**。状态早到就早停,永远不到就**响亮失败**——⛔ 不许拿一张"还在加载"的画面去比对,**两张加载屏会完美一致**。(实证:`case-studies/determinism.md` §0.2) ### 0.2.1 ⚠ 找 ready 判据时,容易挑到**太早**的状态 判据在"画面可判"之前就为真的情形,靠**非空画面前置条件**拦下来——⭐ 这道防呆是唯一知道"这张画面配不配拿去比"的东西。(实证:`case-studies/determinism.md` §0.2.1) ⚠ 当一个站找不到便宜的可观测就绪态时,**退回墙钟 settle 是允许的,但要登记**:它偏离了「settle 必须是页面状态」(§2.2),理由要写清楚——本例是"该站在可渲染帧之前的所有可观测状态都太早"。⛔ 不要把这种偏差藏在默认值里。 ### 0.2.2 ⚠ 这个站需要**真实时间与泵同时**推进 **两者交错才前进**——资源在墙钟上到达,进度在 rAF 上推进。这正是 v0.1.22 那条的又一个实例, 而它也解释了为什么 `--ready` 必须住在**交错循环内部**:只有那里两个时钟才同时在走。(实证:`case-studies/determinism.md` §0.2.2) ### 0.2.3 ⛔⛔ 驱动也必须住在泵循环里——**就绪需要驱动,驱动需要就绪**【eightdesign】 `--ready` 要进泵循环(`gate-case-design.md` §1),而**驱动同样要**,理由是同一个。 ⭐ 结论:**一次性的 `load` 种子,对任何"目标物由页面自己异步创建"的站都从根上太早。** 驱动要成为泵循环里每轮重算的表达式(`pixelcompare --drive`),写成幂等的——它会被执行很多次。(实证:`case-studies/determinism.md` §0.2.3) ### 0.2.4 ⚠ 平滑滚动库会把你的落点抢回去 平滑滚动库**拥有**那个滚动值,`scrollTop = x` 只是一个**请求**,不是结果;两侧各自动画到不同位置,而门把它们当成同一位置比较。(实证:`case-studies/determinism.md` §0.2.4) ⭐ 这正是 §2.1.1 早就写过的那句:**「一个悄悄给出错误位置的驱动,比一个抛异常的驱动贵得多」**——本例是它的实证。所以驱动必须**读回落点并断言**(已做进 `pixel-walk`:种子记录 `landed`,像素门比较两侧落点,差超过 4px 直接 FATAL)。 ⚠ 完整的解法是**按站写驱动**:发滚动 → 泵 → 读回 → 未达目标就响亮失败。skill 不提供成品,因为驱动语义按站而异。 ## 0. byte-equal 的前提假设与失效条件 - **前提假设**:"同机同版本 Chrome 的 DOM 渲染是逐字节确定的"【kimi,M5.2 确立】——这个事实使"整页 byte-equal"成为可行的常规验收。但它只在**同一台机器、同一版本 Chrome、全部熵源被冻结**时成立。 - **失效条件**(任一命中则该画面降级为量化对拍门,见 `references/verification-gates.md` §1.3): 1. 画面含本性不可冻的随机源(视频帧相位、glitch/粒子随机相位)【samsy】——或对局部用"同等隐藏"协议(§2.8)剥离后其余部分仍走 byte-equal; 2. 跨机器/跨 Chrome 版本比对(渲染不再逐字节确定);WebGL 场景在无头下用 SwiftShader(`--use-gl=swiftshader`)保证可复现渲染【rogier】,但与真机输出仍有差异(sRGB 色彩管理、授权字体),须真机兜底【oryzo】。**⚠ 交叉警告:这条 flag 本身是 §2.9 的能力探测熵源**——站点若有 GPU 分级,GPU 名黑名单常直接含 `swiftshader`,加了它等于把被测程序静默切到低画质分支(shader 源码都不同);且软件渲染会让单次截图慢到 1–2s,按状态对齐抓帧的门会被采样偏差污染(`references/environment-traps.md` §7)。**适用边界与配套动作见 §5**【shopifydesign】; 3. 熵源没有枚举完(症状:同侧连续两次截图哈希就不相等——先自拍两次验证单侧确定性,再谈双侧对拍); 4. **双侧的能力探测结果不同**(画质档、编解码器分支、设备分支)——此时两侧跑的根本不是同一个程序(shader 源码都可能不同),任何门都无意义;先按 §2.9 在两侧钉死同一探测结果再谈对拍【shopifydesign】; 5. 字体加载时序被改动。kimi 拒绝子集化 4.8MB 字体的首要理由:字体是首屏渲染门控(deck 等 `document.fonts.ready` 才渲染),子集化会污染时序基线;canvas `measureText` 折行会变、点阵字体对坐标舍入极敏感【kimi】。**测量基准的稳定性优先于"看起来该做的优化"**。 **⚠ 还有一个熵面整个落在本文件射程之外:取样时刻。** 冻结协议管的是"**跑起来之后**的熵";"**什么时候算测完**"——网络到达顺序、媒体元数据解码完成顺序、字体就绪——九种协议一条也覆盖不到。(实证【shopifydesign】:`case-studies/determinism.md` §0)**settle 必须是页面状态判据,不能是墙钟**——判据、三条做法与"先让基准侧连跑两次"的自检见 `references/verification-gates.md` §2.2。 ## 1. 方法内核:枚举熵源,逐个消掉 kimi M4.3 日志原话:"**找出渲染器的全部熵源,逐个用环境补丁消掉**"【kimi】。熵源按类型分型,每型对应一种冻结手段: | 熵源类型 | 表现 | 对应协议(§2) | |---|---|---| | 墙钟(`performance.now`/`Date.now`) | 旋转积分、计时驱动的位置 | `clock`、`__warp` | | rAF 时间戳 | 时间戳驱动的累积器、跑马灯 | `clock+raf`、`framebudget` | | 媒体时钟 | 视频帧推进 | 媒体层补丁 | | `Math.random` | 洗牌、字符瀑布 | 种子化随机 | | 定时器(`setTimeout`/`setInterval`) | 倒计时、定时编排 | 泵驱定时队列(§3) | | **能力探测**(第四类熵源)【shopifydesign】 | GPU 微基准定画质档、codec 探测选资源、硬件参数/媒体查询分支——**随机器甚至随同机两次运行而变** | 探测结果钉死(§2.9) | | 合成层光栅缓存 | transform 过渡留下的历史次像素光栅 | 重光栅归一化 | | 本性不可冻 | 无法钉死的局部 | 同等隐藏 + 专门门 | **操作顺序**: 1. 读 `_pretty/` 找出这块画面消费了哪些时间/随机/探测源(grep `performance.now`、`Date.now`、`new Date`、rAF 回调签名、`setInterval`、`Math.random`、`video.currentTime`、`canPlayType`、`deviceMemory`、`hardwareConcurrency`、`matchMedia`、`WEBGL_debug_renderer_info`…)——**这份清单是本站专属的,不能套用上一个项目的**(§3 覆盖面验收); 2. **逐条问"它的运动由谁驱动"(§1.1)**:泵管不到的(合成器 / CSS transition)直接走非冻结手段,别进下一步; 3. 对**泵得到**的那些按 §2 协议表选冻结组合,位姿表里**每条位姿显式声明 freeze 模式**【kimi】; 4. **为每个即将冻结的源列出它下游的入口**(从它里面派发的事件、resolve 的 promise、翻转的就绪标志,以及这些信号的消费者),逐个决定"探针泵到 / 补不冻结抽查"——**冻结会让挂在被冻分支上的子系统对门隐身**,这一步是冻结的配套纪律而不是可选建议(§2.10)【shopifydesign】; 5. 双侧同协议注入(同一份补丁代码打在镜像与复刻两侧,注入点按 §3 的分支判据选服务层还是 CDP); 6. 跑 §4 防呆断言,确认冻结与驱动都真的生效了。 > **⚠ 这份清单是"输入侧的账",它不能代替"输出侧的账"【objectarchive】**:熵源表回答"**什么输入会变**",回答不了"**这一帧上到底有哪些面在上色**"(DOM 文本与背景、`<img>` 解码结果、`<canvas>` 位图、`<video>` 帧、SVG、CSS 生成内容与伪元素、滤镜与合成层)。**两张账都要有**——**在补齐记录之前,所有归因都是在猜**。建账方法与实证见 `gate-failure-modes.md` §3.1.1。(实证:`case-studies/determinism.md` §1) ### 1.1 ⭐ 选冻结手段之前先问:这条熵源的运动由谁驱动【objectarchive】 §2.10 的"冻得越狠盲区越大"容易被读成**"先全冻,再逐条补盲区"**。反了:那句话的操作含义是**能不冻就别冻**,而**冻结的正确答案有时就是"这一条不冻"**。判据四步,全部在开冻之前问完: 1. **这条熵源的运动,最终由谁在推?** 顺着它的下游读到运动落地的那一行,分两类: - **引擎时钟**:rAF / `setTimeout` / `setInterval` / `performance.now` 累积器 / gsap ticker / `video.currentTime` —— 全都跑在页面的 JS 里; - **合成器时钟**:CSS `transition` / `animation`、`scroll-behavior: smooth`、原生滚动惯性 —— 跑在浏览器进程里,页面 JS 只是**发起**它。 2. **泵管不管得到?** 引擎时钟归泵管(§3 的 `__pump` 换掉的就是页面的 rAF/timer),按 §2 选协议;**合成器时钟泵不到**——把 JS 侧的时间冻到 0,transition 照样按墙钟插值,你既停不住它也拨不动它。 3. **泵不到的熵源上冻结是净损失**:确定性没买到(运动照跑),盲区照付全额(§2.10——挂在被冻分支上的一切子系统对门隐身)。此时改用非冻结手段,四选一: - **状态化**:把两条分支当成两个**显式状态**,各跑一整套检查点(首访/回访、已保存/未保存); - **断机制不断读数**:断言"源站自己的判据在这一帧已经成立",墙钟只作**派生判定**入库(`gate-failure-modes.md` §1.11); - **清存储 + 补状态抽查**:跨会话持久状态(`localStorage`)清掉,再对有像素后果的那几条各补一个种好值的状态; - **靠 settle 消化**:把**正在插值的计算值**写进页面状态签名,过渡在跑时签名就一直在变,settle 自然不会落在中途(`verification-gates.md` §2.2)。 4. **反向检查(可以直接免冻的两种)**:这条熵源的下游产物**没有消费者**(死码)或**不进像素、不写 DOM**——它根本不需要冻,在清单里写"明确不需要"并注明理由。 > **实证【objectarchive】**(`case-studies/determinism.md` §1.1):九条熵源只冻了一条——**九条冻一条,不是纪律打折,是判据的结果。** **别走岔的两个方向**:手上有 shim 就把所有源都打上(付了全额盲区、买到一半确定性);或反过来,因为"泵不到"就宣布这条熵源没法处理(它有四条非冻结出路,全都要逐条登记进 §2.10 的那本账)。 ## 2. 冻结协议:kimi 八种 + 第九种(能力探测钉死) 前八种是 kimi README 自评"本项目最值得带走的东西";第九种由 shopifydesign 补上——它冻的既不是时间也不是随机数,而是"这台机器有多强"。**§2.10 不是第十种协议,是前九种共用的配套纪律**(冻结的盲区),每次动用任一协议都要一起执行。总表: | 协议 | 钉住什么 | 用在哪 | |---|---|---| | `clock` | `performance.now → 0`(rAF 真实,入场动画播完) | 大多数静止位姿 | | `clock+raf` | 再把 rAF 时间戳喂 0 | 跑马灯、轨道环、reduced-motion 判别 | | `framebudget` | rAF 时间戳改发 `帧序号×16.67ms`,n 帧后停摆 | 过渡中间帧 | | `__warp(t)` | 冻结时钟可拨动,damp/blend 一帧确定性收敛 | 轮盘 detail 态 | | 种子化 `Math.random` | mulberry32(42) 双侧同流 | 头像洗牌、字符瀑布 | | 媒体层补丁 | `play()` 假成功、`paused` 谎报 false | pixel-flow 视频 | | 重光栅归一化 | display 抖动强制重绘,清合成层缓存 | 带 transform 过渡的标题层 | | 同等隐藏 | 不可冻区域双侧同规则隐藏 | SwipeHint、LetterGlitch、星云 | | 能力探测钉死【shopifydesign】 | GPU 微基准结果 / `canPlayType` / `deviceMemory`·`hardwareConcurrency` / `matchMedia` | 画质分级、编解码器分支、设备分支 | 逐条要点: ### 2.1 `clock` 钉 `performance.now → 0`,rAF 保持真实——入场动画正常播完后画面静止。静止位姿的默认协议。 ### 2.2 `clock+raf` 在 `clock` 之上把 rAF 时间戳也喂 0。用于**rAF 时间戳直接驱动**的持续动画(跑马灯、轨道环)。注意有的渲染器需要**双冻**:rAF 时间戳驱动的累积器,单冻 clock 不够【kimi】。(实证:`case-studies/determinism.md` §2.2) ### 2.3 `framebudget` rAF 时间戳改发 `帧序号×16.67ms`、n 帧后停摆——一切 rAF 消费者变成帧序号的纯函数。为"静止态门对过渡组件结构性失明"补的洞(M7.5 ASCII 瀑布事故):使**过渡中间帧**(第 24 帧、u=0.5、字符带扫至半屏)也能字节比对【kimi】。 ### 2.4 `__warp(t)` 冻结时钟但可拨动(如 `__warp(100000)`),让 damp/blend 类惰性追赶在一帧内确定性收敛。用于含阻尼收敛的终态画面(轮盘 detail 态)【kimi】。 ### 2.5 种子化 `Math.random` mulberry32(42) 替换 `Math.random`,双侧同流——随机序列相同则洗牌/瀑布结果逐字节同。只对"启动后拉固定次数随机"的消费者有效;随机消费次数本身不确定的场景仍属不可冻。 ### 2.6 媒体层补丁 `play()` 假成功、`paused` 谎报 false——视频停在 seek 帧,同时防止站点的"卡死检测循环"发现视频没在播而进入异常分支("自己失明")【kimi】。seek 后必须重新驱帧再截图【noomo】。 ⛔ **`autoplay` 属性不经过 `play()`**【lamalama】:只补丁 `HTMLMediaElement.prototype.play` 拦不住 `<video autoplay muted>`——浏览器原生起播,帧随真实媒体时钟走,同侧自比在 0.2 与 1.8 之间随会话跳。补丁要同时在 `document` **捕获相**监听 `play / playing / loadeddata / timeupdate`(媒体事件不冒泡,捕获相接得到)→ `pause()` + `currentTime = 0`;补丁进 `--seed`(两侧同一份、加载前注入),不进 `--drive`(那是滚动驱动器的合同:必须写 `window.__walkScroll` 落点,否则退 6)。 ⭐ **视频不走 JS 时钟,冻结页里它照播**【samsy】;且作品墙的 `<video>` 是 `document.createElement` 出来**不挂 DOM** 的,`querySelectorAll('video')` 找不到。做法:在 shim 之后 hook `Document.prototype.createElement` 记下每个 video;每次截图前 `pause()` + `currentTime = 0`、等齐 `seeked`(用 shim 暴露的 `__nativeSetTimeout` 兜底超时,页面的 `setTimeout` 已被泵接管)、再泵 2 帧让 VideoTexture 采到第 0 帧。(实证:`case-studies/determinism.md` §2.6) ⛔ **多人房间不是任一侧的属性,而 `Network.setBlockedURLs` 挡不住 WebSocket 握手**。对握手生效的是 DNS 层:Chrome 启动旗标 `--host-resolver-rules=MAP <host> 127.0.0.1`,两侧同加,登记为仪器条件(§2.8 同等隐藏)。 ⭐ **活世界的带宽来自它自己的骰子,reseed 是归类实验不是调参**【samsy】:NPC 随机游走、粒子 spawn、CRT 屏的随机内容全走 `Math.random`——shim 把它定种了,但两侧在到达同一状态前消耗的次数不同(three 双拷贝 / vendored 库各消耗一串),于是跨侧残差成片。在每个视图截图前两侧同时 `__reseed(n)`,残差归零证明它们是**骰子相位**不是移植差异;而同侧自比带宽照旧(活世界的骰子在截图前已经掷过了),门的容差就是这个带宽 + 常数,不许因为看见了残差再去动。 ### 2.7 重光栅归一化 display 抖动强制重绘,清掉合成层缓存的历史次像素光栅——带 transform 过渡的层会在合成器里留下与过渡路径相关的光栅残迹,导致同终态不同字节【kimi】。 ### 2.8 同等隐藏 本性不可冻的区域**双侧同规则隐藏**,使整页门可以 byte-equal;被隐藏的部分**必须另建专门门覆盖**(kimi 的星云有自己的画布字节门),否则就是给自己挖 `gate-failure-modes.md` §1.3 的覆盖空洞【kimi】。 ### 2.9 能力探测钉死【shopifydesign】 **这是第四类熵源:它不在时钟里,也不在随机数里,在能力探测里。** 站点问一句"这台机器有多强 / 支持什么",答案随机器、甚至随同机两次运行而变,而这个答案会一路流进渲染参数、资源选择,乃至 **shader 源码字节**。上面八种协议一条也覆盖不到它。 微基准是活体计时,**同一台机器两次运行都可能翻档**。两侧不锁同一档,比的不是同一个程序——这比 `performance.now()` 严重得多。(实证:`case-studies/determinism.md` §2.9) **必查清单**(在应用区间 grep,连阈值常量一起抄进笔记): | 探测 | 典型形态 | 后果 | |---|---|---| | GPU 微基准 / `WEBGL_debug_renderer_info` GPU 名匹配 | 计时绘制返回 ms/draw;GPU 名黑名单正则(intel hd/uhd/iris、mali、adreno、swiftshader) | 画质档 → shader 源码、渲染分辨率、几何数量 | | `canPlayType` / `MediaSource.isTypeSupported` | `canPlayType('video/mp4; codecs="hvc1"')` 决定走 mp4 还是 webm | 两侧加载**不同的资源文件**,像素门必红且归因困难 | | `deviceMemory` / `hardwareConcurrency` / `maxTouchPoints` | `hardwareConcurrency<=2 → low`;`maxTouchPoints>1 && innerWidth<1024 → 移动分支` | 画质档;桌面/移动分支决定整块场景存在与否 | | `matchMedia` | `(hover:hover) and (pointer:fine)`、`prefers-reduced-motion` | 交互分支、动画是否播放 | ⚠ **第三行那个 `innerWidth` 还有一条与"能力"无关的陷阱:它在 document-start 恒为 980**(`<meta viewport>` 还没解析),真实宽度异步落地。**同步读一次 `innerWidth` 就定分支的代码会永久停在桌面态**,而 `screen.*` 全程正确、事后再读也正确——识别信号、取证手段(document-start 探针)与三种补救的实测对比见 `environment-traps.md` §8【objectarchive】。**这两件事要分开做**:宽度对了不等于能力分支对了(`setDeviceMetricsOverride({mobile:true})` 不动 `hover`)。 **做法**: 1. **对拍前把探测点全部枚举出来**,别等门红了再找——它伪装成"复刻侧画质不对",实际是两侧程序不同。 2. **两侧同一位置强制同一结果**:让 shim 直接返回钉死值(本例 `?__probe` 时强制 `high` 档),**不要去改站点的判级逻辑**;正常运行保持源站原逻辑不动(宪法第 3、4 条)。 3. **强制值登记为偏差**,注明"仅对拍时生效的仪器类偏差",并写上重新考虑条件:**若要验收分级逻辑本身,需另建"三档各跑一次"的门**——钉死一档会让另外两档的代码路径完全无门覆盖。 4. **探测的失败路径也要看**:本例微基准 `catch` 返回 `999`(必判 low)且无告警——只要一侧抛错,两侧就静默分道扬镳。 5. **⚠ 你自己的无头旗标就是探测输入。** `--use-gl=swiftshader` / `--enable-unsafe-swiftshader` / `--disable-gpu` 会改变 `UNMASKED_RENDERER_WEBGL` 的返回值与微基准耗时,**直接命中上表第一行的 GPU 名黑名单**(那条正则里就写着 `swiftshader`)。 > **纪律**:无头旗标与画质档**必须钉死在一起登记**——旗标写进偏差表的同一行,注明"该旗标下两侧实测档位 = X"。**任何一次改旗标都要重跑一次档位断言**(门脚本里直接断言 `quality.tier`,不要靠记忆)。适用边界与"仍然值得用 SwiftShader"的判据见 §5。(实证:`case-studies/determinism.md` §2.9) ### 2.9.1 ⭐⭐ 泵的**时机**:冻结页仍在真实时间里启动【lusion】 **冻结不改变资产什么时候到达。** XHR、解码、字体加载走的是墙钟;页面能观测到的**每一个时钟**只在被泵时前进。于是有一个很容易踩、且症状极具欺骗性的写法: ``` navigate → settle(N 秒) → __pump(dt, frames) → 截图 ⛔ 错 ``` 引擎在 settle 期间拿不到任何一帧(时间没动),等泵开始时资产早已到达但**启动序需要的是"资产到达那一帧"**——它永远等不到。 **正确写法是把泵摊进 settle 窗口,与真实时间交错**: ``` navigate → [ __pump(dt, chunk) → 真实等待 gap ] × N → 截图 ✅ ``` (实证【lusion】:`case-studies/determinism.md` §2.9.1) ⭐ **判据**:泵完之后先问一句「**这一帧上有东西吗**」。像素门已内置非空帧前置条件(`gate-failure-modes.md` §1.8),但更早的信号是**引擎自己的产物**——canvas 尺寸、实例数、场景对象数:**默认尺寸的 canvas 意味着 init 没跑**,而那比任何像素数字都早、都便宜。 ### 2.10 ⚠ 冻结的盲区:被冻分支上的子系统对门隐身【shopifydesign】 **这不是第十种协议,是前九种的配套纪律。** 冻结换来的是可复现,付出的是覆盖面: > **冻掉的那条分支上挂着的一切子系统,都从验收门的视野里消失了——而且是以"通过"的形式消失。冻得越狠,盲区越大。** **⚠ 这句话不是"先全冻、再补盲区"**:动用任一协议之前先过 §1.1——**泵不到的熵源(合成器 / CSS transition 驱动)上冻结是净损失**,确定性没买到而盲区照付,正确答案是这一条不冻、改走非冻结手段【objectarchive】。本节管的是**决定要冻之后**怎么把账付清。 机理:双侧对拍是**差分门**,只看得见**不对称**的差异。冻结造成的缺席是**对称**的——两侧都不执行,逐字段 diff 恒为 0、逐字节哈希恒相等,门稳定地、对称地错着。 (实证【shopifydesign】:`case-studies/determinism.md` §2.10) **操作要求(冻结前做,不是事后补)**: 1. **列出所有挂在被冻熵源上的入口,并把粒度追到产物。** 对每个要冻的源(rAF / timer / clock / random / visibility)做两跳 grep + 一跳粒度追问: - 第一跳:在**应用区间**内 grep 该源的调用点(`requestAnimationFrame(`、`setTimeout(`、`setInterval(`…); - 第二跳:在每个调用点的函数体里找**向外发出的信号**——`dispatchEvent` / `new CustomEvent` / promise `resolve(` / 回调调用 / 就绪标志翻转(`xxxReady = true`); - 反向再 grep 一次这些信号的消费者(`addEventListener("<名>"`、`.then(`、读该标志的地方),得到的清单就是**这个源的下游入口**。 - **第三跳(粒度):对每个入口再问一句"它的产物是什么形态,写进 DOM 了吗?"** 只枚举到"入口"粒度会漏掉整块子系统——⚠ **这是本纪律第一次实战时暴露的粒度错误**: > 实证:`case-studies/determinism.md` §2.10。 - **一句话判据:产物写不写 DOM?不写 → 数值门天然看不见 → 必须有不冻结的绝对断言。** 属于"不写 DOM"的常见产物:WebGL/WebGPU 场景图对象、Canvas 2D 像素、Web Audio 图、worker 内状态、只存在于 JS 内存里的模型/缓存。 - **枚举产物写成表**:清单每行 = 入口 → **产物类别** → **哪道门看得见它**。答案是"没有"的那一行,就是本轮必须新建绝对断言的地方;**一旦开始铺 WebGL 内容,就默认每落地一个子系统加一节断言,没有例外**。 2. **逐个入口做处置决定,二选一**(不允许留"没想到"的): - **在探针里泵到该事件**——首选。泵够帧数或主动派发,让被冻侧仍然走到那条分支,覆盖面不缩; - **补一条不冻结的结构性抽查**——不冻结加载一次,对该子系统的产物做**绝对断言**(本例:`.wr` span 数 == 18 且 innerHTML 与镜像逐字相同),而不是双侧 diff。**对称缺席只有绝对断言(或像素/人眼)看得见。** **⚠ 绝对断言的必填项是"期望值的出处",不是断言本身。** 最容易写坏的一步:从复刻侧读一次然后钉死——那门就变成"复刻等于它自己",永远绿,等于没测。三条纪律【shopifydesign】: - **(1) 期望值从"镜像基线 + 源站自己的计数规则"推导,绝不从被测方读。** **"第一次跑出来就对"才是移植正确的证据;抄来的数跑出来必然对,什么也证明不了。**(实证:`case-studies/determinism.md` §2.10) - **(2) 推导过程写进脚本,每一步带源行号。** 期望值不是常量表,是 `expectations(baseline)` 函数(入参就是镜像基线 JSON),换视口、换镜像版本它自己跟着变;每条断言的注释里写清依据的源行号。 - **(3) 同一个脚本要能在源站/镜像侧跑,哪怕只跑得动一半。** 源站通常没有调试句柄(本例只有 `window.__threeCtx` L43173),镜像侧就只验事件与 DOM 那一半(`sdf-ready` 派发过、`span.wr == 18`)。**这一半仍然必须跑**:它证明这套期望描述的是**源站可达的真实状态**,而不是复刻侧特有的形状。 - 归档要求:每条绝对断言在清单里都要有"期望值来源"一栏(镜像基线文件名 + 源站行号),**空着的一律视为自比,不算门**。 3. **清单入库**:被冻源 → 下游入口 → **产物类别(写不写 DOM)→ 哪道门看得见它** → 处置(泵到 / 不冻结抽查 / 明确不需要)逐条写进本站熵源清单(§10),与 shim 覆盖面验收记录同一本账。**冻结项每加一条,就得补一条处置**——这是"冻得越狠盲区越大"的收费口。 4. **推论:不冻结的对拍不可省。** 数值门与像素门不是替代关系,数值门原理上看不见被冻住的分支;每个里程碑至少保留一条不冻结的截图对拍或结构性抽查(见 `references/verification-gates.md` §1.4.1、`gate-failure-modes.md` §1.7)。 **新子系统落地时复查**:移植进来的子系统若挂在 `site-ready` / rAF / 定时器之后,它一进来就落在盲区里——建门时先回到本节第 1 步重新枚举,别假设上一轮的入口清单还是全的。**是"每落地一个子系统重做一次枚举",不是"复用上一轮的清单"**:上一轮的清单是按上一轮的产物形态写的,新子系统的产物可能根本不写 DOM(实证见第 1 步第三跳)【shopifydesign】。 ## 3. probe-shim 双侧确定性驱动【noomo】 **适用条件**:滚动驱动的 WebGL/动画站 + **源站是别人的混淆 bundle、不可插桩**。问题:浏览器后台标签 rAF/timer 节流使这类站不可确定性驱动,而你不能改源站代码。 **机制**(对应本 skill `scripts/probe-shim.js`,约 90 行,仅在 URL 带 `?__probe` 时激活): 1. 把 rAF 换成手动泵 `__pump(dt, frames)`——测试脚本主动喂帧,页面不再依赖浏览器调度。**⚠ 泵是有覆盖面的**:凡是从 rAF 里派发的东西(事件、promise resolve、就绪标志),探针不泵到那一帧就永远不发生,且两侧对称不发生(§2.10); 2. `document.hidden` / `visibilityState` / `hasFocus` 钉死为可见——绕开一切可见性门控; 3. `setTimeout` 接管进泵驱定时队列——定时器随泵推进而非墙钟; 4. 时间戳从 0 起——使双侧 `Tick.seconds` 驱动的 shader 相位可对齐。 **⚠ 这四项是 noomo 那个站的熵源清单,不是通用清单**【shopifydesign】(实证:`case-studies/determinism.md` §3): > **硬规则:不要套用固定的一套冻结项。** 冻结前先按 §1 步骤 1 grep 出**本站应用区间**的熵源清单,再拿这份清单逐项验收 shim 的覆盖面:清单上有而 shim 没冻的,要么补进 shim,要么写明为什么不需要冻。清单上没有的,冻了也只是心理安慰。`scripts/probe-shim.js` 现已扩展到接管 `performance.now` / `Date.now` / `new Date()` / `Math.random`(mulberry32 定种)/ `setInterval`——这是更好的**起点**,不是验收标准。 > > **同一份清单还要走第二遍**:对每个确定要冻的项,按 §2.10 列出它下游的入口并逐个处置。**覆盖面验收要验两件事:冻得够不够,以及冻掉之后谁看不见了。** **双侧同位注入**(关键在"同位"):**注入点有两条路线,先按下表判,再照选中那条的做法执行。** | 注入路线 | 什么情况下用 | 前提与代价 | |---|---|---| | **服务层按 query 注入**(`?__probe`,noomo 原始做法) | ① 两侧是**各自独立的服务器**(镜像侧 `serve.mjs` + 复刻侧框架 SSR/dev);② 需要"不带开关时**产物字节一字不变**"这条可验证性质(字节门/SSR 门要终身全绿) | 两侧各写一处注入点,"同位"靠人对齐并复验;**URL 上多一个 query 参数**——服务端的 url→路径映射必须对它无感 | | **CDP `Page.addScriptToEvaluateOnNewDocument`** | ① **两侧共用同一个服务器 / 同一份驱动脚本**;② 服务层的 url→路径映射是**查询感知**的(`x.jpg?width=600` 与 `?width=320` 是两份不同字节);③ 需要**严格同位**(同一份字节、在任何页面脚本之前) | 只在门跑的时候存在,**磁盘与响应字节都没动**,字节门天然不受污染;代价是仪器绑在 CDP 驱动上,人工打开页面复现不出门里的状态 | (实证【objectarchive】:`case-studies/determinism.md` §3) - **一句话判据**:先问"两侧是不是同一个服务器 / URL 映射认不认 query"。**是同一个服务器、或映射查询感知 → CDP 注入**;**两侧各自独立、且要求产物字节门不受污染 → 服务层 query 注入**。 - **教训**:"gsap 在模块求值期捕获 rAF,Nuxt 插件太晚,必须 head 首脚本"【noomo】——shim 必须先于一切消费者求值。这条对两条路线同样成立:服务层注入要落在 `<head>` **首部**,CDP 注入要用 `addScriptToEvaluateOnNewDocument`(不是 `Runtime.evaluate`,那已经晚了)。 - 服务层路线:镜像侧由 `scripts/serve.mjs` 按 query 注入、磁盘镜像文件保持字节纯净;复刻侧由框架 hook 在同一位置注入(noomo 用 Nitro `render:html` 钩子 `html.head.unshift`)。 - **两条路线共同的硬要求**:不跑门时两侧输出**字节不变**(SSR/字节门不受污染),且**注入方式本身登记进偏差表**——登记里要写清用的是哪条路线、为什么(选 CDP 就写明"两侧同服务器 / 映射查询感知"这个理由)【noomo】【objectarchive】。 **驱动方法论**(M7a 日志): - `__drive` 用真时钟配速泵帧 + **MessageChannel yield**(不受节流的宏任务边界,让页面内 await 链能推进); - 用户激活门控的状态(`experienceStarted`)需要 **isTrusted 真实点击**,合成事件不算; - `smoother.scrollTo(y, false)` **反复钉扎**消滚动动量残留; - seek 后重新驱帧再截图【noomo】。 (结果与同类做法【noomo】【rogier】:`case-studies/determinism.md` §3) ## 4. 防呆断言(冻结/驱动是否真的生效) 冻结协议最大的敌人是"没生效但门照样绿": 1. **同会话位姿哈希必须互异**:不同位姿的截图哈希相同 = 驱动步骤没生效(kimi M5.3:eclipse 位姿截的还是 hero,门全绿)【kimi】。 2. **jump 后补发同位跳转唤醒事件**:源站 jump-immediate 会让事件门控内容休眠,需补发唤醒事件,否则截到的是休眠态【kimi】。 3. **自拍两次先验证单侧确定性**:同侧同协议连续两次哈希不等 → 熵源没枚举完,回 §1 补。 4. **资产预检**:先确认镜像服务能出图再截图,否则截图误导归因【rogier】。 5. **状态到达要有独立证据**:等语义条件(IDLE、`hasStarted`)而不是裸 sleep【samsy】【oryzo】。**"等够了"本身也要有判据**:连续 N 次采样的页面状态签名不变才算 settle;没 settle 就非零退出、绝不落盘;settle 指纹写进产物,两侧指纹不同时判**"不可比、重采"**而不是判红或判绿(`verification-gates.md` §2.2)【shopifydesign】。 ## 5. 无头驱动的通用旗标与手段清单 搭无头对拍环境时逐项过: - **anti-throttling 旗标必带**:`--disable-background-timer-throttling --disable-renderer-backgrounding`——后台标签 rAF 节流 + gsap `lagSmoothing` 会把启动链冻成假死。(实证【samsy】【oryzo】【noomo】:`case-studies/determinism.md` §5) - **SwiftShader**:`--use-gl=swiftshader` 保证 WebGL 无头渲染可复现【rogier】——**这条建议在没有 GPU 分级的站上仍然成立,但不是默认项**。⚠ 它同时是 §2.9 的能力探测熵源与 `environment-traps.md` §7 的快门瓶颈,**加它之前先按下表判**【shopifydesign】: | 先 grep 应用区间 | 结论 | |---|---| | `WEBGL_debug_renderer_info` / GPU 名黑名单正则 / 计时型微基准 / `deviceMemory` / `hardwareConcurrency` **全部无命中** | 站点不分级 → **SwiftShader 可用**,rogier 的原建议直接适用(仍受下面第 3 条快门约束) | | 命中任意一项 | 站点分级 → SwiftShader 会把被测程序切到 low 档(shader 源码不同)→ **优先用真 GPU 跑无头**;确实只有软件渲染可用时,按下面三件事配套 | **一旦用了(含 CI 机器天然只有 SwiftShader 的情形),必须同时做三件事**: 1. **按 §2.9 钉死能力探测结果**,并在门脚本里直接断言实测档位(如 `quality.tier`),不能默认 `high`; 2. **旗标与档位钉死在偏差表的同一行**登记("该旗标下两侧实测档位 = X"),改旗标即重跑档位断言; 3. **若这道门要按状态对齐抓帧**,先量"单次截图耗时 / 被测运动全长"(`environment-traps.md` §7):软件渲染下 1728×1080 单次 `captureScreenshot` 实测 1–2s,2000ms 的入场动画根本采不到相位——不达标就换真 GPU,或改突发采样 + 取最近帧。 **CI 提醒**:无 GPU 的 CI 上会自然退回 SwiftShader → tier low。**两侧同档,对拍仍然成立**,但报告里必须写明档位,且不能与本机 high 档的基准混着比【shopifydesign】。 - **localStorage 预种**:`Page.addScriptToEvaluateOnNewDocument` 预种教程完成态等前置状态,跳过引导流程;配页内 gsap ticker 泵【samsy】。 - **query 开关跳过阻塞流程**:复刻侧 `?skip-preloader`;源站侧没有开关就模拟真实点击过 preloader(rogier 的对拍脚本对 original 模拟点击 Enter)【rogier】。 - **真实 DOM 点击驱动状态**:samsy 按 `#topmenu` 索引点击驱动三视图——菜单文字被 glitch 轮换、文本匹配不可用;真实点击同时绕过 router 探针问题【samsy】。 - **视口/窗口锁定**:量化对拍必须同视口;文字块随窗口高度命中相邻组,"复检需锁窗口"【noomo】。 - **双侧同参数启动**:复刻与镜像两个服务器同时起、无头参数一致、驱动脚本同一份【samsy】【kimi】。镜像参照服即 `scripts/serve.mjs`(终身兼任对拍基准端,如 `PORT=3200 SERVE_ROOT=mirror`)【noomo】。 - **hover 类位姿用 CDP 真实鼠标**(Input 域射线),不用 CSS 类模拟【kimi】。 ## 6. ⛔ 像素门两侧必须同经 serve.mjs【darkroom】 `serve.mjs` 只对**自己伺服**的 HTML 注入 probe-shim(`?__probe` 冻结时钟)。重建侧若直接跑 `next start`,它那一侧不冻结——镜像帧 BLANK、重建帧有画,自比带宽不可比,跨侧差异全是 "冻结不对称"制造的。解法:`tools/assemble-static.mjs` 把 `next build` 的 `.next/server/app/**.html` 摊成 `<route>/index.html`、`_next/static` 与 `public/*` 软链进去,用 `serve --side rebuild` 伺服——两侧同一份 shim、同一个 t。(实证:`case-studies/determinism.md` §6) ⚠ 只供对拍;`?_rsc=` 软导航载荷不在静态树,sweep 仍跑 `next start` 拓扑。 ## 7. ⭐ 状态对齐协议:先对齐状态,再等时推进(`--ready` + `--after-ready N` + `--chunk N`)【darkroom】 等"绝对泵数"(两侧都泵到第 240 帧)与等"状态相对时间"(两侧各自 READY 之后再泵 N 帧)差一个 **挂载相位**:它周期性出现,这是相位噪声不是移植缺口。 ⛔ 而对齐的**分辨率 = 泵分块帧数**(默认 total/40 ≈ 6 帧):8–16 帧的相位差整个落在一个分块里, 钉不到同一帧。协议:`--ready <表达式>` 定义状态、`--chunk 1` 把分辨率提到 1 帧、`--after-ready N` 在两侧 READY 为真的那一帧之后各泵 N 帧再截图。⚠ `--self` 自比带宽要在同一协议下重建。(实证:`case-studies/determinism.md` §7) ### 7.1 ⛔ 状态分两种:泵到的,和等到的——各自的协议不同【raycastkbd】 §7 的 `--ready` + `--after-ready` 对齐的是**由泵抵达**的状态(挂载相位,虚拟时间里的事件)。 另一种状态**由真实时间抵达**:GLB 在 worker 里解码、纹理到达、字体解析——泵再多帧也快不了它。 raycastkbd 的 25% 检查点两边都撞过: | 协议 | 自比带宽(walk-025) | 发生了什么 | |---|---|---| | 绝对泵 120 帧(无对齐) | 0 / **2.91** 各约 2/3、1/3 | 一帧场景到了、一帧没到——到达是真实时间事件 | | `--ready 到达 --after-ready 120`(状态相对) | **恒 1.7** | 两侧 READY 时的绝对泵数不同 → 轴体爆炸动画(虚拟时钟驱动)相位不同:一帧展开、一帧合拢 | | `--hold 到达`(泵前)| 60s 超时 5/5 | 请求本身要从泵的世界里发出(IO 记录、滚动驱动到该节)——钟钉在 0 时页面根本没开口要 | | `--hold 到达 --hold-after 30 --hold-grace 500` | **2.91** | 到达 ≠ 解码完成:worker 解码与挂载在 500ms 里没做完 | | `--hold 到达 --hold-after 30 --hold-grace 1500` + 绝对泵 120 | **0.01** | 先泵 30 帧让页面发出请求,真实时间等到达 + 1.5s,再两侧同样绝对泵完 | 规则:**到达用 `--hold`(真实时间,`--hold-after N` 让页面先开口要),相位用 `--ready/--after-ready`(虚拟时间,泵之中)**; 一个页面可能两者都要。⛔ **hold 的谓词要按名点名**:`≥5 条匹配 glb|hdr|wasm 的资源条目` 在 switch.glb 还没被请求时就被别的条目凑满了,复刻侧 1/3 概率拍到空轴体(2.91);改成五个文件名逐一 `some(includes)` 后逐次 0.01。`--hold-grace` 是对"解码完成没有页面可见信号"的让步——它是 §2.2 "settle 必须是页面状态"的一条登记偏差,写进 §6,不许藏在默认值里。 ⭐ **泵的分块也是"每个真实往返过几个虚拟 tick"**【lamalama】:`--chunk N` 每次泵 N 帧再让出一次真实时间;一条要 N 个真实网络往返才出画面的媒体管线(hls.js:清单 → 层级 → 分片 → append → seek → 分片)在 `--chunk 5` 下烧完 900 帧 `<video>` 仍是 `readyState 1`,`--chunk 1` 约 430 帧就绪。ready 判据等的是媒体到达时,分块取 1,帧数按 1 的实测给。`--ready` 可以把没就绪的原因写进 `window.__why`(字符串),pixelcompare 在"never satisfied"时原样打印——900 帧的沉默里唯一能问的人是页面自己。 ⛔ **协议表达式只能有一个来源,且每次跑都打指纹**【lamalama】:seed / ready / drive 是仪器;包装脚本里一份陈旧的重复 `--seed`(差一条语句:无条件 `currentTime=0`)让 25% 档在 2400 帧里永远 "never satisfied",而输出里没有任何一行说两次跑的仪器不同。把表达式放进一个被 source 的文件(`protocol.env`),pixelcompare 开头打印 `instrument — seed <sha10> (N chars) · ready … · drive …`;两次结果不同先比指纹。 ⛔ **缓存状态是仪器条件,每一拍都冷**【lamalama】:一个浏览器轮流拍两侧,第二拍缓存已热——`img.complete` 在构造时就是 true,站点走同步分支,"缩略图到了才展开"的面板在 B 开着、在 A 关着;同侧自比一格恒定 5.8,`--after-ready` 加到 600 也不动,因为差的不是时间是缓存。`--self` 下两侧同源,这条不对称是纯仪器;跨侧它藏在"第一次访问"里。pixelcompare 每次导航前 `Network.setCacheDisabled` + `clearBrowserCache`(指纹行标 `cold-cache`)。同一族:站点把 UI 状态写进 `localStorage`,第二次访问起点不同——seed 起跑清空。 ⭐ **到达判据要看"有图"不看 `complete`**【lamalama】:视口外的懒图在 `srcset` 重选后挂着一个永不开始的 pending 请求,`complete` 恒 false 而画面早就有图;判据用 `naturalWidth>0 || complete`。反过来,喂 GL 纹理的 `<img>`/`<video>` 自己永远 `opacity:0`,可见性只看 `visibility` 不看 `opacity`——它们的到达决定 GL 层画不画。脱离 DOM 的 `Image()` 预载器 `document.images` 看不见,到达只能用更长的 `--after-ready` 让两侧都走到终态(branding 75% 档 24 → 0)。 ⛔ **同 URL 连拍会恢复上一拍的滚动位置**【lamalama】:Chrome 的 scroll restoration 在 `load` 之前把第二拍放到第一拍落点,站点 init 看到的起点不同、观察者触发不同,`--self` 带宽里就多出一格恒定的 3.7(跨侧 URL 不同、没有这条)。走位 seed 第一句 `history.scrollRestoration = 'manual'`——巡航自己驱动滚动,恢复没有用处。 ## 8. 常见坑 1. **把环境问题当代码 bug 修**:后台节流假死、探针时钟与页面时钟错位(伪装成"计时器时间压缩")、vite HMR `?t=` 幽灵模块让探针读到假状态——**判定时序 bug 前先校准探针**【samsy】;环境陷阱全表见 `references/environment-traps.md`。 2. **shim 注入晚于消费者**:gsap 等库在模块求值期捕获 rAF——shim 必须是 head 首脚本,框架插件时机都太晚【noomo】。 3. **录制巧合进规格**:冻结环境下录的基准值可能编码了加载时序巧合("首帧 anchor == 585px"),断言机制而非环境量【kimi】——详见 `references/gate-failure-modes.md` §1.4。 4. **诊断解码器的正确性**:门只要求"确定性 + 双侧同函数",但诊断要求绝对正确;PNG 解码 colorType 事故(Chrome 截图是三通道而代码硬编码 `*4` 索引)画出几何假象【kimi】。用 `scripts/lib/png.mjs`(对 Pillow 逐格验证过,恒输出 RGBA)。 5. **WebGL 读回**:`readRenderTargetPixels` 前必查 `gl.getError`(全零缓冲是读回假象)【noomo】;无 `preserveDrawingBuffer` 不能 `drawImage` 读 canvas【kimi】。 6. **CDP 工程坑**:调用带超时、多兆 payload 分块取回、headless Chrome 无视 SIGTERM 要 SIGKILL【kimi】。 7. **探针等待时长是环境量**:真 GPU tier 3 机器需要 `PROBE_WAIT=25000/45000`,超时先判 "probe timing, not a product mismatch" 再查代码【rogier】。 8. **改动加载架构后要复验位姿哈希不变**:任何"应当不影响画面"的改动都用"位姿哈希不变"关账(kimi M7.1 动态加载改造后桌面 8 位姿哈希不变)【kimi】。 9. **无 `?__probe` 时必须字节无痕**:验证仪器不得污染被测输出(SSR 门保持全绿),且仪器本身登记进偏差表【noomo】【rogier】。 ## 9. 上门前快速自检 对拍门接入 CI 前逐项确认: - [ ] 该画面的熵源清单已从 `_pretty/` 取证列出(不是凭印象),**含能力探测点** - [ ] **每条熵源都回答过"它的运动由谁驱动"**:合成器 / CSS transition 驱动的**没有**被泵冻,而是走了状态化 / 断机制 / 清存储 / settle 四条出路之一,逐条登记(§1.1)【objectarchive】 - [ ] shim 覆盖面已**逐项对照该清单验收**,未冻项写明理由(不是套用出厂那套冻结项)【shopifydesign】 - [ ] **每个被冻源的下游入口已枚举**(从它里面派发的事件 / resolve 的 promise / 就绪标志及其消费者),每条入口有处置:探针泵到 / 不冻结结构性抽查 / 明确不需要(§2.10)【shopifydesign】 - [ ] 枚举**到产物类别粒度**,每类都回答了"哪道门看得见它";**产物不写 DOM 的(WebGL 场景图 / canvas / worker 内状态)已建绝对断言**(§2.10 第 1 步第三跳)【shopifydesign】 - [ ] 本里程碑保留了**至少一条不冻结的对拍**(截图或 DOM 结构绝对断言)——它是唯一能抓"两侧对称缺席"的手段【shopifydesign】 - [ ] 每条绝对断言都写明了**期望值出处**(镜像基线 + 源站计数规则 + 行号),没有一条是从复刻侧读出来钉死的自比(§2.10 第 2 步)【shopifydesign】 - [ ] 能力探测(GPU 基准 / codec / 硬件参数 / matchMedia)在两侧被强制为同一结果,强制值已登记为偏差【shopifydesign】 - [ ] 每条位姿的 freeze 协议已显式声明在位姿表里 - [ ] **每次采样有 settle 判据**(页面状态签名 + 地板时长,不是固定 sleep),未 settle 的采样直接丢弃不落盘,产物里带 settle/可比性指纹(`verification-gates.md` §2.2)【shopifydesign】 - [ ] 单侧连续两次截图哈希相等(单侧确定性成立) - [ ] 同会话不同位姿哈希互异(驱动确实生效) - [ ] 同等隐藏剥离的区域已有专门门覆盖 - [ ] 无头旗标齐全:anti-throttling;SwiftShader **按 §5 的判据决定用不用**(站点有 GPU 分级时它会切档),用了则档位已断言并与旗标同行登记【shopifydesign】 - [ ] **注入点按 §3 的分支判据选定并登记**(两侧同服务器 / URL 映射查询感知 → CDP `addScriptToEvaluateOnNewDocument`;两侧独立服务器 + 要求产物字节门不受污染 → 服务层 `?__probe`)【objectarchive】 - [ ] shim/探针在无开关时对输出字节无痕,且已登记进偏差表 - [ ] 不可冻场景已明确降级为量化门并写入噪声归类清单 ## 10. 产出物 - 位姿表:每条位姿 = 路由 + 视口 + 驱动步骤 + **显式 freeze 协议声明**【kimi】 + **settle 判据**(页面状态签名,不是 sleep 毫秒数)【shopifydesign】 - **本站熵源清单**(时钟 / 随机 / 定时器 / 媒体 / 能力探测点及其阈值常量)+ 逐项对照的 shim 覆盖面验收记录【shopifydesign】。**每条另记两栏:运动由谁驱动(引擎时钟 / 合成器)→ 处置(冻 / 状态化 / 断机制 / 清存储 / settle / 明确不需要)**,非冻结处置同样要写理由(§1.1)【objectarchive】 - **被冻源的下游入口清单**:被冻源 → 挂在其上的事件/promise/就绪标志 → 消费它的子系统(带源行号)→ **产物类别(写不写 DOM)** → **哪道门看得见它** → 处置(泵到 / 不冻结抽查 / 不需要),每次新增冻结项或新移植子系统落地时**重做枚举**(不是复用)【shopifydesign】 - **不冻结结构性抽查脚本**(如 `verify-scene-content.mjs`):绝对断言逐条带**期望值出处**(镜像基线文件 + 源站计数规则行号),期望值以 `expectations(baseline)` 函数形式推导而非常量表,且脚本在镜像侧也能跑(哪怕只跑事件/DOM 那一半)【shopifydesign】 - `scripts/probe-shim.js` 的双侧注入配置,**含选用的注入路线与判据**(服务层 query:镜像侧 serve query + 复刻侧框架 hook;或 CDP `addScriptToEvaluateOnNewDocument`——两侧同服务器 / 映射查询感知时用它),登记进偏差表【noomo】【objectarchive】 - 能力探测钉死值(画质档、codec 分支等)连同"若要验收分级逻辑本身需另建三档门"的重新考虑条件,登记进偏差表【shopifydesign】 - 确定性自检记录:单侧两次哈希相等 + 同会话位姿哈希互异 - 无头启动参数清单(旗标、视口、预种脚本)写进门脚本,环境变量参数化 - 对拍产物成对入库(见 `references/verification-gates.md` §5) -
dom-shell-strategies.md 31.4 KB
# DOM 层策略选型指南(A/B/C + 正交约束 D) > **何时加载本文件**:完成镜像(M0)与逆向(M1)后、搭工程骨架(M2)前——需要决定"页面 HTML/CSS 外壳如何获得"时加载。本文件回答两个问题:DOM 层是零重写生成、脚本切分、还是框架内重建(策略 A/B/C);以及 DOM 是否同时被 3D 引擎当坐标源读取(策略 D 的正交约束,它会锁死上一问的答案)。 ## 1. 选型决策树 选型判据有两条,**按序问**:先问"DOM 被谁消费"(决定字节门的性质与选型自由度),再问"DOM 由谁生成"(决定 A/B/C)。两条都在镜像 HTML / bundle 里取证。 ``` 先问:原站 DOM 的消费方是谁?【shopifydesign】 ├── 只有浏览器(DOM = 文档) │ → 外壳选型无额外约束,继续问下一条 │ ⚠ 但仍要扫一遍**块级弱化形态**:有没有块在运行时量容器矩形、 │ 算一段数、把结果写回 style.*(§5.4)——它不锁死选型, │ 但那几个块的门必须按**几何量**断言,不能只断类名【objectarchive】 └── 还有 3D 引擎:引擎用 getBoundingClientRect / getComputedStyle 把 CSS 排版结果读成世界坐标 → 命中策略 D:DOM 即场景图(§5)。它是**正交约束**而非第四种外壳来源—— 外壳选型被锁死为策略 A,且字节门升格为几何门 命中后追问一句:hydration 之后布局还会不会被客户端改写?(§5.2 ②) 会 → SSR DOM 只是场景图初值,重排代码才是场景顺序的规格 再问:原站 DOM 由谁生成? ├── 平台导出物(Webflow 等:镜像 HTML 即最终产物,含 webflow.js、平台 data-* 体系) │ → 策略 A:零重写 shells(镜像 HTML 经登记变换直接生成页面)【lando】 ├── 手写静态站(无构建器或仅 CoffeeScript/Compass 级编译;HTML/CSS/JS 即作者源码) │ → 策略 A,且站点自定义变换常为 0——只剩内置 T-LOCALIZE/T-NOINDEX。 │ 实测 2013 年 skrollr 站:4+1 变换、verify-shell 全 hunk 可重放; │ 目录模板的 port/ 层可不设(登记!):"逐字移植"与镜像重合【firstlaunch】 ├── 静态单页(单个 index.html 巨页,构建器产物但结构可直接切分) │ → 策略 B:脚本切组件(生成脚本保守切分,验收 diff 为空)【oryzo】 └── 框架编译产物(Vue SPA / Next RSC / Nuxt SSR 等,DOM 由运行时/服务端渲染) → 策略 C:框架内重建 + 字节对齐【samsy】【kimi】【noomo】【rogier】 ``` **取证判据**(判断生成方时逐项核对): - Webflow 特征:`webflow.js` + jQuery、约 120 种 `data-*` 属性命名体系【lando】。 - Next 特征:`window.next={version:...}`、RSC flight payload(带 `RSC: 1` 头可取回另一份 body)【kimi】。 - Nuxt 特征:`__NUXT_DATA__` payload、响应头 `x-powered-by: Nuxt`【noomo】。 - Vue SPA 特征:scoped CSS 的 `data-v-xxxxxxxx` 属性【samsy】。 - Astro/静态特征:`_astro/` 资产目录、单页巨型 HTML【oryzo】【rogier】。 - **策略 D 特征**:同一个函数里同时出现 `querySelectorAll("[data-*]")` + `getBoundingClientRect()` + `getComputedStyle()` 三件套【shopifydesign】。命中后**必须再做运行时取证**:hydration 前后同一批节点的 `getBoundingClientRect()` 是否变化(§5.2 ②)——SSR DOM 常常只是场景图的初值。 - 分支可组合:lando 是"平台外壳(策略 A)+ 自定义 bundle 应用层手写重写"的混合——外壳与应用层可分别选策略【lando】。 **共同验收(三策略通用)**:产出 HTML 与镜像做"空白归一化 diff 为空"或逐字节 diff 为空【oryzo】【noomo】【kimi】。字节层的门要**最先建立、终身保持全绿**——"字节层先行使后续所有视觉 debug 都能排除 DOM/payload 差异"【noomo】。 **策略速查表**: | 策略 | 适用 | HTML 来源 | 核心验收 | 出处 | |---|---|---|---|---| | A 零重写 shells | 平台导出物 | 镜像 HTML + 登记变换直接生成 | 仅登记变换处不同,其余逐字一致 | 【lando】 | | B 脚本切组件 | 静态单页 | 切分脚本保守 pretty-print | 空白归一化后 diff 为空 | 【oryzo】 | | C 框架内重建 | 框架编译产物 | 同栈同版本框架内重建 | SSR/payload 逐字节 diff 为空 + CSS 双向 diff | 【samsy】【kimi】【noomo】【rogier】 | | **D DOM 即场景图**(正交约束) | DOM/CSS 被 3D 引擎当**坐标源**读取 | 同 A(约束一旦命中,A 是唯一正确解;但 A 只保证初值,见 §5.2 ②) | 场景图**逐字段数值全等**(几何门,基准取**运行时静止态**),字节门是它的前提 | 【shopifydesign】 | ## 2. 策略 A:零重写 shells(平台导出物)【lando】 核心判断(写在生成脚本头注释里):"**平台生成的 DOM/CSS 就是字节级规格书**"——页面 HTML 一律不重写,从镜像直接生成。 操作步骤: 1. 写 `gen-shells.mjs` 类生成脚本,对镜像 HTML **只做登记在案的变换**。lando 全部只有 4 项: - ① 剥离遥测脚本(登记为偏差:私有部署不应上报); - ② 外部 host URL 重写为 `/ext/<host>/` 本地路径(登记为偏差); - ③ 把源站 bundle 的 `<script>` 标签替换为自己的模块入口(`<script type="module" src="/src/app/main.ts">`)——这一处替换就是"重建本体"; - ④ 仅当 parser 无法解析时做最小修复(lando 修一处畸形 SVG 属性边界让 parse5 能解析,浏览器 DOM 等价,登记为偏差)。 2. **其余一切逐字保留**——包括注释掉的历史脚本块(登记为怪癖 Q1)【lando】。 3. **脚本内置"变换没发生就 throw"的防御——按逐条下限写,不按总数写**【lando】【objectarchive】。这条纪律有两个强度,**默认用强形式**: - **弱形式(lando 原始版本)**:找不到 bundle 标签、或整页**没有任何变换发生**,直接 throw【lando】。意图是"镜像布局一变立刻大声失败,而不是静默产出坏 shells"。它在 lando 那种形态下够用——**守卫本身就绑在一条低频、承载结构的变换上**(每页一处的 bundle 标签替换),这条一旦找不到就直接 throw,不必等"总数为零"。 - **强形式(默认)**:**每条登记变换单独声明期望命中次数或下限,构建时逐条校验;任一条为 0 或低于下限即 throw**。下限按**钉死的镜像快照**量出来(快照钉死表见 `reverse-engineering.md` §0.1.1),所以重抓镜像改了 markup 形态同样会立刻响亮失败。 - **弱形式为什么会失效**:只要站上有**一条高频变换**,"有变换发生"就恒为真,这道守卫在这类站上恒绿 = 等于没有【objectarchive】(实证:`case-studies/dom-shell-strategies.md` §2)。 - ⭐⭐ **附加实证:这条为 A 目的立的门,抓到了 B 类问题**【objectarchive】(实证:`case-studies/dom-shell-strategies.md` §2)。逐条下限本来只防一件事——"守卫因为某条高频变换而恒绿"。两条推论:① **交叉命中值得记,但不可依赖**——不要因为"上次它救了场"就不去补 `mirroring.md` §5.1「真实性」那道真正对口的门;② **命中数是文档形态的函数**,所以这条守卫顺带是一道"我构建的还是不是原来那份文档"的廉价断言:**下限跌了先查镜像,再查变换表**。 - **验收不许读构建脚本自己的计数器**:计数器证明的只是"脚本以为自己做了什么"。要从**产物字节**反推——逐页 diff,**每个差异 hunk 都必须能被变换表重放出来**(objectandarchive:5 页 **1,048 个 hunk 全部可重放**);并且**表里登记、却从未在 diff 里被观测到的变换同样判 fail**,否则变换表会悄悄漂在现实前面。块级的配套断言("哪些层只允许逐字或只允许被本地化动过")见 `shopify-platform.md` §0.3 步骤 6。 - ⭐ **这条论证与"变换"无关,它是关于"表"这个东西本身的**——凡是**记录状态的表**(变换表 / 分层归属表 / 销账·进度表 / §6 偏差表 / §Q 怪癖表)都要有一道从产物或运行时**反查它**的门。通用条款、双向判据,以及"**共用宿主 ≠ 覆盖**"的实证(销账表建起来第一天就抓到一条虚报)见 `porting-discipline.md` §4.1【objectarchive】。 4. 配套路由/资产层(lando 的 vite 两个自定义插件): - `extAssets()`:dev 下把 `/ext/<host>/` 映射回 `mirror/assets/`(重资产永不复制进源码树); - `shellRouter()`:干净 URL 映射到 shells,未知 URL 回落源站 404 模板并返回 HTTP 404(复刻 Webflow 语义)【lando】。 5. **平台运行时当行为契约逆向**,写进逆向笔记(lando 的 `05-webflow-html.md`): - 哪些模块必须保留:"必须保留 webflow 三连(jQuery→schunk→entry)",因为 taxi 换页后要调 `window.Webflow.destroy()+ready()`; - 页面骨架顺序、head 契约(异步双 CSS 的 preload 技巧)、`data-*` 属性命名体系【lando】。 6. 构建产物侧的字节保真也要盯:lando 的 postbuild 把 vite 对 srcset 二次编码的 `%2520` 还原为 `%20`(登记为偏差 6.12)【lando】。 验收 checklist: - [ ] 每项变换均有偏差登记条目;变换数与登记数一致。 - [ ] 生成脚本的防御在位,且是**逐条下限**形式:每条登记变换有期望命中次数/下限,任一条为 0 或低于下限即 throw(§2 步骤 3)——不是"总数非零即通过"。 - [ ] 变换表被**产物字节**反证:每个差异 hunk 都能由变换表重放;表里登记却从未在 diff 里观测到的变换判 fail。 - [ ] shells 与镜像 diff:仅登记变换处不同,其余逐字一致。 - [ ] 未知路径 404 语义与源站一致。 - [ ] 全路由 × 双端探针 CLEAN(lando:7 路由 × 桌面/移动 = 14/14 PASS)。 ## 3. 策略 B:脚本切组件(静态单页)【oryzo】 适用:镜像里有一个可直接切分的静态 HTML(oryzo:单页 46,000px),目标框架能容纳原始标记。 操作步骤: 1. 写切分脚本(oryzo:`gen_components.py`)把镜像 `index.html` 按 section 切成组件文件(oryzo 切成 18 个 Astro 组件)。 2. 切分必须**保守 pretty-print**,三条规则【oryzo】: - 只在原有空白间隙处换行(不引入新空白); - 非空白文本字节级保留; - 目标框架的特殊字符转义(oryzo:花括号转义防 Astro 语法冲突)。 3. 临时补位样式(shim)显式标记生命周期:oryzo 的 `phase1-shims.css` 每条注明"将在 phase 2 被引擎逻辑取代",后续如期删除【oryzo】。 验收 checklist: - [ ] 构建产物 body 与源站 HTML **空白归一化后 diff 为空**【oryzo】。 - [ ] 浏览器几何一致(oryzo:scrollHeight 46410px 与源站相同)。 - [ ] shim 清单中每条都有取代计划,收官时清零。 ## 4. 策略 C:框架内重建 + 字节对齐(框架编译产物) 适用:DOM 由框架运行时/SSR 生成,无法"直接搬 HTML",必须在同栈同版本框架内重建,然后**用字节对齐门证明重建输出与源站编译产物等价**。按框架分四条子路线: ### 4.1 Vue SPA:指定原版 `__scopeId`【samsy】 - Vue 组件写成 options + template 字符串,**手动指定源站编译产出的原版 `__scopeId`**(如 `data-v-da121a04`)——这使源站编译好的 scoped CSS(`main.css` 原样拷贝)**零改写生效**。 - 代价要登记:vue 需 alias 到含运行时编译器的 esm-bundler 构建【samsy】。 - DOM/应用层 1:1 覆盖(samsy:13 组件、router 守卫怪癖照抄),文本细节到码点:自研 SplitText 移植时不可见字符逐码点核对(U+200B/U+00A0/U+202F)【samsy】。 - noomo 的等价做法:Vue scoped style 的 `data-v-*` hash 用显式模板属性复现,登记为偏差【noomo】。 ### 4.2 Nuxt SSR:逐字节 payload 对齐【noomo】 - 验收标准是 SSR 输出与镜像**逐字节一致**:`__NUXT_DATA__` payload(noomo:1804 字节全等)、body DOM、config script(掩掉 buildId),**连 `<html lang="en">` 的双空格都要对齐**【noomo】。 - 建立可重复执行的门 `verify-ssr.mjs`:9 路由 body/payload/config 与镜像逐字节 diff + 尾部脚本顺序 + 未知 slug 404 行为,**每 commit 必跑**(noomo 的 commit message 几乎每条以 "SSR gates green" 结尾)【noomo】。 - Pinia store 全签名移植(30 state + 29 getters + 33 actions,**死代码照抄**)——payload 字节对齐会暴露任何字段缺漏【noomo】。 - **传递依赖也要钉死**:同一 Nuxt 版本不等于同一输出——unhead 2.0.17 vs 2.1.17 会反转 bodyClose 脚本顺序,破坏尾部字节序,用 overrides 钉死【noomo】。 - 无法配置的框架行为用等价机制对齐并登记偏差(noomo:device 模块用 `modules:done` hook 裁剪 runtime config)【noomo】。 ### 4.3 Next RSC:从 flight payload 读段树形状【kimi】 - **段树/路由结构从 RSC flight payload 读出,不凭框架惯例猜**:kimi 的根 layout 放在 `app/(lang)/layout.tsx` 而非 `app/layout.tsx`,因为源站 `<html>/<body>` 挂在 `"(lang)"` 边界【kimi】。 - RSC payload 单独镜像到 `_rsc/`;其中含逐请求随机 nonce,**diff 前必须 mask**【kimi】。 - 服务端行为在客户端产物里零留痕:redirects 必须逐 URL 实测状态码;Next `permanent: true` 发 308 而源站发 301——**门必须断言状态码本身**,差异登记为偏差【kimi】。 - 契约门覆盖面(kimi `verify-routes.mjs`,81 项经审查扩到 94 项):head 8 字段 × 5 路由、12+8 条重定向含状态码与尾斜杠链、怪癖可达性、assetPrefix、favicon【kimi】。 ### 4.4 CSS 层:双向 diff【rogier】 框架内重建时 CSS 无法整体照搬的,用双向 diff 收口: - **正向 diff**:解析双方样式表,共享选择器**逐属性**比对,抓"差一点"的值(letter-spacing、字号阶梯 `.ts-1` 2rem/2.25rem@1000/2.625rem@1280、根字号作用域)【rogier】。 - **反向扫描**:枚举重建侧**源 bundle 里没有的全部规则**,逐条判定"必要机制 / 等价别名 / 多余发明"——揪出真发明并删除【rogier】(实证:`case-studies/dom-shell-strategies.md` §4.4)。 - **级联顺序即语义**:源站把布局工具类(`.grid`、`.col-*`)放在样式表**末尾**,重建放开头会让同特异性冲突全部反向解析、栅格坍塌——连"规则出现顺序"一起复刻【rogier】。 - 死规则照抄:`.ts-split` 在 JS bundle 和镜像 HTML 里零引用,确认死代码后仍原样保留【rogier】。 - Tailwind 站的 grep 陷阱:产物可能走 server-inline 通道,grep .css 文件会误判 utility 是否存在【noomo】(实证:`case-studies/dom-shell-strategies.md` §4.4)。 ## 4.5 环境门控分支:localhost 语义分叉 源站发布产物里常内联**按 host 判定环境**的分支,最典型是主题/框架的 dev 逃生门: ```js if (location.hostname === 'localhost') { /* 探测 vite dev 端口、连 HMR */ } else { /* 生产路径 */ } ``` 复刻工程在本地跑 = hostname 就是 `localhost`,于是**被迫走进一条线上永不执行的分支**,产生源站从不发出的 dev 端口探测噪声。这类分支在 Shopify/Webflow 等平台主题里很常见【racingshop】。 两条路线,按项目目标选,**都必须登记**: | 路线 | 做法 | 代价 | 何时选 | |---|---|---|---| | **保持 verbatim**(默认) | 一字不改,把分叉登记进 §Q 怪癖表 | 本地跑会有 dev 探测噪声;需在 CLEAN 门白名单里放行并写明理由 | 追求字节级忠实;噪声无外联、无副作用(racingshop 选此,登记为 Q1)【racingshop】 | | **强制生产分支** | 改写条件使其恒走 else 分支 | 属于**自创改动**——违反"源站有的都要有"的字面纪律,必须登记进 §6 偏差表并说明"何时重新考虑" | 噪声会污染验收门信噪比、或探测行为有真实副作用(外联/报错/阻塞渲染) | 判定顺序:先看这条分支**有没有副作用**(外联?抛错?阻塞?)。无副作用 → 一律 verbatim + 怪癖登记,这是纪律的默认答案。有副作用 → 才动它,且按偏差登记,不要顺手"清理干净"。 反模式:把分支**删掉**而不登记。这会让后续任何人无法从复刻侧还原源站真实行为,属未登记偏差 = bug。 ## 5. 策略 D:DOM 即场景图(DOM/CSS 是 3D 引擎的坐标源)【shopifydesign】 不是第四种"外壳来源",是一层**正交约束**:它不改变 DOM 由谁生成,只改变 DOM 层出错的**后果**——从"文档不像"变成"3D 物体位置错"。 **§5.1–§5.3 讲的是完整形态(站级,命中即锁死外壳选型);§5.4 是它的弱化形态**——站上没有任何 3D 引擎,只有个别块在运行时量矩形、写内联样式:同族、块级、**不锁死选型**,但那几个块的门必须按几何量断言【objectarchive】。 ### 5.1 准确形态:不是"DOM 被标注了场景数据",是"浏览器的 CSS 排版结果本身就是场景图" 预想的形态是 SSR HTML 上挂 `data-webgl-src`/`data-depth`,引擎读属性建场景。逆向后的真实形态强一档(shopify.design 场景解析器 `QL(n)` `_pretty/_index-c3dAurQC.js` L30737–L30899、布局读取器 `mG.readLayout` L46372–L46385)——引擎取的**第一手数据不是属性,是排版结果**: | 引擎读什么 | 得到什么 | |---|---| | `getBoundingClientRect()` × 全局缩放因子 | 世界坐标 `worldX` / `worldZ` / `worldWidth` / `worldHeight` | | `getComputedStyle()` 的 `fontSize`/`textAlign`/`fontFamily`/`fontWeight`/`lineHeight`/`letterSpacing` | SDF 文字的全部排版参数 | | `getComputedStyle()` 的 `border-radius` | 图片圆角 / pill 圆角 | | `getComputedStyle()` 的 `transform: matrix(...)` | 形状旋转角(`Math.atan2` 反解) | | CSS 自定义属性 `--card-width` / `--card-height` / `--card-gap` | 轮播卡片几何 | | `data-*` 属性 | **只补 CSS 表达不了的维度**:Z 景深、切片数、SDF 模式、形状类型、颜色 | 一句话记法:**HTML 与 CSS 不是外壳,是场景的坐标源。** ### 5.2 取证判据(怎么认出自己遇到了策略 D) **两问并列,都要做**:静态取证认出"DOM 是坐标源",运行时取证认出"**哪一份** DOM 才是坐标源"。只做前者会漏掉后者【shopifydesign】(实证:`case-studies/dom-shell-strategies.md` §5.2)。 **① 静态取证:三件套。** 在 bundle 里搜:**`querySelectorAll("[data-*]")` + `getBoundingClientRect()` + `getComputedStyle()` 同时出现在同一个函数里**——命中即按策略 D 处理。(三者单独出现不算数:测滚动位置、判响应式断点都会用到前两个。) **② 运行时取证:hydration 后布局是否被改写?——SSR DOM ≠ 场景图。** 在镜像上用**同一份场景解析探针**采两次,比对同一批节点的 `getBoundingClientRect()`:① **纯 SSR 排版**(摘掉框架运行时,或在 hydration 接管前量);② **hydration 后的静止态**(框架 effect 跑完、所有异步回填结束)。两次有差 → 服务端下发的 HTML 只是场景图的**初值**,改写后的结果才是引擎读到的坐标。竖切期会自然撞上这个形态:把 SSR 外壳原样端起来、只换引擎,数值门第一次跑就红。(实证:`case-studies/dom-shell-strategies.md` §5.2) **三条后果(判据命中后立即生效)**: 1. **镜像 HTML 的 DOM 顺序不能当作场景顺序的规格。** 规格在**重排代码**里(那段砌砖/定位函数),SSR 结果只是它某一次的输出。把 SSR 顺序抄成固定表 = 把一个中间态钉死成规格,等真移植了重排层,这张表要么删掉要么变成掩盖 bug 的补偿层。 2. **对拍基准必须取自运行时,不是静态 HTML。** 采基准要等到重排的输入齐了(本站 = 最后一次 `onLoadedMetadata` 回填、静止态达成)再抓;镜像侧与复刻侧都按同一个"静止态判据"抓,不按墙钟等待时长。 3. **框架布局层从"某个里程碑的一个模块"升格为场景正确性的前置依赖**,排期必须提前——不移植它,数值门在原理上就不可能变绿(shopifydesign M2 因此延后 102 个字段,M3 移植布局层后全部归零)。这也说明**策略 A 是必要条件而非充分条件**:零重写外壳只保证初值逐字正确,不保证场景正确。 ### 5.3 三条推论(每条都改变工程决策) 1. **字节门升格为几何门。** 现有三策略把 DOM 层当"外壳",字节门是**文档保真**的门;策略 D 站上 **CSS 差 1px,3D 物体就位移 1px × 全局缩放**,字节门变成 **3D 正确性**的门。于是策略 A(零重写 shells)从"可选的省事做法"升级为**唯一正确做法**——任何重写、切分、框架内重建都是在往坐标源里注入误差,且误差会以"3D 位置不对"的形态显现,不会被当成 DOM 问题去查。(同时注意 §5.2 ②:A 只保证 SSR 初值逐字正确,若 hydration 后布局被改写,还得把那段重排代码也逐字移植,几何门才可能变绿。) 2. **读取器自带副作用,必须逐字复刻。** `readLayout()` 在解析前把根节点改成 `transform:""; position:fixed; height:100vh`,读完立刻还原并 `window.scrollTo(0, a)`。语义是:清 `transform` 把**入场动画的位移排除在场景坐标之外**;`position:fixed` 让 `scrollY` 归零,使场景坐标成为**与滚动无关的绝对快照**。移植时连同还原顺序一起抄,不许"优化掉"。(实证:`case-studies/dom-shell-strategies.md` §5.3) 3. **它顺带带来一个比像素门更该先建的门**:把引擎读 DOM 的那个函数逐字转写成探针,两侧逐字段比数值。判据与建门方法见 `references/verification-gates.md`。 ### 5.4 弱化形态:没有 3D 引擎,但块在运行时量矩形、据此写内联样式【objectarchive】 完整策略 D 的判据(引擎把 CSS 排版结果读成世界坐标)是**站级**的,命中即锁死外壳选型。**同族还有一个块级的弱化形态**:站上一个 3D 引擎都没有,但某几个自研块在运行时量容器矩形、做一段算术、把结果**写回内联样式**。后果小一档但同型——**CSS 差 1px 不会显形为"类名不对",而是缩放比 / 像素高度不对**。 **① 识别判据**(三步齐备即命中;与 §5.2 ① 的三件套区别在于**没有 3D 引擎参与**): - 块内读**活盒子**:`getBoundingClientRect()` / `offsetWidth`·`offsetHeight` / `scrollHeight` / `getComputedStyle(...).lineHeight`; - 中间有一段**算术**:比值、`min`/`max`、乘系数、地板值、`ppi` 换算; - 结果**写回 `style.*`**(`transform: scale(x)`、`height`、`max-height`、`width`),而不是切一个类名。(实证:`case-studies/dom-shell-strategies.md` §5.4) **② 建门方法:几何按量断言,不断类名、也不断样式字符串。** - **用块自己的公式在页内对活矩形重算,再与页面产物比**——期望值是"公式作用在**这一刻的活盒子**上的结果",不是一个冻结的数字。冻结数字换个视口就全线红,而且会掩盖真错(同 `gate-failure-modes.md` §1.4:断机制本身,不断某次录制里它恰好等于多少); - **先在矩形上收敛,再采样**:等被量的那个盒子不再变,**不要去等块自己的防抖时钟**(实证里的合成器有 120 ms 防抖,门等的是矩形,全程无墙钟); - **实测量级**:见 `case-studies/dom-shell-strategies.md` §5.4; - ⭐ **几何类实证必须连同视口、DPR、测量口径一起记,否则它既不可复现也不可证伪**【objectarchive】。口径三选一,同一个盒子可以给出**三个不同的数**: - **内联样式值**(`el.style.height`)——**块自己写下的产物**,是这类门该断的东西; - **计算值**(`getComputedStyle`)——可能被别处的 CSS 覆盖掉,断它等于把主题 CSS 也算进被测块; - **客户端矩形**(`getBoundingClientRect`)——含 transform 与亚像素,`221` 与 `221.33` 是两个口径而不是两次测量误差。 - **推论(全库通用)**:既有实证里凡是只有像素数、没有视口/DPR/口径的,都只能当**形态证据**读("这类量会差、会显形"),**不许当期望值抄**,引用前必须在自己的视口下重测。已知同类:本文件 §3 的 `scrollHeight 46410px`、§5.2 实证的 `.hero-grid 4078 vs 4175px` 与 `docHeight 13798 vs 13895`、§5.3 推论 2 与 `verification-gates.md` §1.4.1 的"统一 158px Z 偏移"。 - **记录纪律**:过渡 / 动画途中量到的高度**不进比对产物**——它属于"这一次运行",记派生判定(`gate-failure-modes.md` §1.11)。 **③ 与完整策略 D 的关系(同族、弱化,两者不许相互冒充)**: | | 完整策略 D(§5.1–§5.3) | 本节的弱化形态 | |---|---|---| | 谁在读 DOM | 3D 引擎,把全站排版结果读成**世界坐标** | 站点自研的**单个块**,只读自己宿主的矩形 | | 出错后果 | 3D 物体位移,排查方向天然跑偏 | 这一个块的**缩放 / 高度**不对,作用域限于宿主 | | 对外壳选型的影响 | **锁死策略 A**,字节门升格为几何门 | **不锁死选型**——外壳照 §1 决策树选;但**字节门仍是几何正确性的前提**(宿主的 CSS 差 1px 就够了) | | 该建什么门 | 场景图数值门(`verification-gates.md` §1.4.1),全站一道 | **块级几何门**,逐块一条,挂在该块的运行时门里 | **两个方向都别走岔**:不要因为"块在量矩形"就把它升格成"DOM 即场景图"——没有引擎在读全站排版,写成策略 D 会让后来的人以为外壳选型被锁死了;也不要因为"站上没有 3D"就只断言类名和样式字符串——那正是这条弱化约束**唯一**会失效的方式。 ## 6. 常见坑(各策略通用) 0. **JSON 数据岛里的 URL 是内容,不是地址——T-LOCALIZE 不许进岛**【14islands】。pages router 的 `<script id="__NEXT_DATA__" type="application/json">`(以及 JSON-LD)里既有资产地址也有 **文本位置的 URL**(portable text 的 `markDefs.url`/`externalLink.url`、正文里的裸链接); 内建 T-LOCALIZE 的守卫只认 `>URL<` 与 `"children":"URL"` 两种位置,实测 17/104 路由的 文章内容 URL 被改成 `/`,而外壳字节门全绿(改写本身就是登记变换)。正确形状:**岛整段 保真(登记为 T-DATA-KEEP)**,运行时由 JSON 拼出的资产 URL 交给服务层 `serve --rewrite` / `/ext/<host>/` 响应改写(hashgraphvc 6.2 / 14islands 6.4 同族);Nuxt payload 那种"岛内 全是资产地址"的站另当别论(verify-payload 路线),**判据是岛里有没有文本位置的 URL**, 不是框架名。 1. **自创补偿性 CSS 会反转成 bug**:JS 机制没对齐时用 CSS 补观感,等 JS 对齐后补丁全部反转——rogier 十余个视觉 bug 全部源于此。"宁可先不像,也不要发明规则"【rogier】。 2. **门只断言想到的字段是盲的**:`<main>` 只比 3 个固定字段抓不到"shell 组件发明了源站没有的 DOM 属性";修法是**并集全量比对**替代字段名单【kimi】。 3. **只测一种 URL 形态漏掉重定向链**:kimi 只测无斜杠形态,尾斜杠重定向链与源站相反没被抓到,R1 审查才发现【kimi】。 4. **构建器会悄悄改字节**:vite 对 srcset 内 URL 二次编码 `%20→%2520` 导致 7 张含空格文件名的图 404,自动门抓不到,靠目视兜底 + postbuild 还原 + 登记偏差;教训:"srcset/style 内 URL 的编码保真需要纳入构建期对拍"【lando】。 5. **`<body style="opacity:0">` 这类 FOUC 防线是行为**,照抄,由 JS(preloader init)清除;"先显示再动画"必闪帧——"时序即视觉"【rogier】。 6. **路由换页只换该换的**:源站只替换 `.ui-main` 内视图,header/nav 是常驻组件——整块替换导致入场动画重放;修复后用 **DOM 身份测试**验证(跨多次导航断言 `.ui-header` 是同一个 JS 对象)【rogier】。 7. **坏链也要复刻**:源站 favicon.svg 404,重建应删除本地文件但保留 head 里的 link——补一个占位文件反而是偏离【rogier】。 8. **策略 C 忘记钉传递依赖**:框架小版本、传递依赖都会改变输出字节序,字节门红了先查依赖树再查代码【noomo】。 9. **策略 A/B 的生成脚本静默通过**:不加"变换没发生就 throw"的防御,镜像结构变化后会静默产出坏 shells【lando】。**而"总数非零即通过"这个弱形式在有高频变换的站上自己也会静默通过**——2,540 次的 URL 本地化把守卫顶成恒绿,5 次的 noindex 注入失效则无人可见;防御必须写成**逐条下限**,验收必须从产物字节反推(§2 步骤 3)【objectarchive】。 > ⭐⭐ **但下限管的是"这条变换还活着",不是"它达成了目的"——这两件事会分家。**(实证:`case-studies/dom-shell-strategies.md` §6 坑 9) > > **两条配套动作,缺一条这类变换就是自我安慰**: > 1. **枚举,不要采样**:用一条机械判据把整类倒出来(本例是"所有 `content` 长度 ≥20 且不含空白的 `<meta>`"),漏的那条当场现形。清单来自散文 = 清单来自记忆。 > 2. **加一条以目的为形式的断言**:构建时**从镜像原文反查出真值集合**,逐份产物搜,命中即红。⛔ 真值**不写进代码**——从镜像现读,既不让别人的活令牌进你的 git,也在源站轮换取值时自动跟上(写死的清单会悄悄开始断言空集,那比没有断言更坏)。 > > **一般形式**:凡是"移除/替换/注入"类变换,下限与**目的断言**要成对出现。下限回答"它还有靶子吗",目的断言回答"靶子被打掉了吗"。fixture 验一次:人为破坏一条规则,两条应该**各报一次红**。 10. **策略 D 站上按常规选型**:把 DOM 当外壳去切分/重建,等于改 3D 坐标源;症状显现为"物体位置不对",排查方向天然跑偏。同类错误还有漏抄 `readLayout()` 的三处副作用(§5.3 推论 2,实测统一 158px Z 偏移)【shopifydesign】。 11. **把 SSR HTML 的 DOM 顺序当成场景顺序的规格**:hydration 后若有客户端重排,SSR 结果只是初值;照它钉死顺序会在重排层落地时反转成补偿层,而对拍基准取静态 HTML 则一开始就量错了对象。判据与取证方法见 §5.2 ②【shopifydesign】。 12. **"站上没有 3D,所以几何不用管"**:站级判据不命中策略 D,不代表没有块在运行时量矩形、写内联样式——这类块的门若只断类名或样式字符串,1px 的 CSS 差会一路绿着穿过去,显形为缩放/高度不对。判据与建门方法见 §5.4【objectarchive】。 13. **几何实证记成裸数字**:只写像素数、不写视口 / DPR / 测量口径(内联样式值 · 计算值 · 客户端矩形),下一轮复现不出来也证伪不了——同一个盒子在 390×844 下内联 179px、计算值 650px,都是"对"的。记法要求与实证见 §5.4 ②【objectarchive】。 -
environment-traps.md 23.9 KB
# 环境陷阱手册 > **何时加载本文件**:进入验证阶段、编写任何无头探针/回归脚本之前;以及每次准备把"假死 / 时序异常 / 状态不一致 / 像素差异 / **两侧相位不同** / **移动端走了桌面分支**"判定为源码 bug 之前。先按本手册校准环境与探针,再动代码(相位类差异先过 §7 的快门比值)。 ## 0. 总纪律:环境因素先取证再动代码 **任何"bug"在归因到源码之前,必须先排除环境与探针自身的嫌疑。**旧构建缓存、DNS 负缓存、构建窗口期 fetch 失败都会伪装成产品 bug——先取证(computed style、DoH、直连 IP),再动代码【rogier】。 **误判的代价不是浪费时间,而是引入一个偏离源站的假修复。**(反面案例即本手册存在的理由,见 `case-studies/environment-traps.md` §0) 配套惯例:探针超时要与产品缺陷显式区分——真机上调大探针等待时,明确标注 "probe timing, not a product mismatch"【rogier】(实证:`case-studies/environment-traps.md` §0)。 --- ## 1. 陷阱:后台标签节流伪装站点假死(发生率最高) **现象**:页面在后台标签/无头环境里帧循环停摆、启动链走不完、动画不推进,看起来像站点死锁。gsap 的 `lagSmoothing` 会进一步放大伪装效果【samsy】。 **三个项目独立踩过**(实证:`case-studies/environment-traps.md` §1)。 **对策(按彻底程度递增)**: 1. **无头脚本必带 anti-throttling 旗标**:起 headless Chrome 时加 `--disable-background-timer-throttling --disable-renderer-backgrounding`(samsy M2 教训,写进 regression.mjs)【samsy】。 2. 需要页内驱动时,配合页内 gsap 泵 + `Page.addScriptToEvaluateOnNewDocument` 预种状态【samsy】。 3. 滚动驱动站的 A/B 对拍走 **probe-shim 路线**:约 90 行脚本接管 rAF/timer/visibility——rAF 换成手动泵 `__pump(dt, frames)`、`document.hidden/visibilityState/hasFocus` 钉死为可见、setTimeout 接管进泵驱定时队列、时间戳从 0 起【noomo】。注入位置有讲究:"gsap 在模块求值期捕获 rAF,Nuxt 插件太晚,必须 head 首脚本"【noomo】。驱动配套:`__drive` 真时钟配速泵 + MessageChannel yield(不受节流的宏任务边界)、需要 isTrusted 的交互用真实点击触发、`smoother.scrollTo(y, false)` 反复钉扎消动量残留【noomo】。 --- ## 2. 陷阱:开发环境幽灵状态 判定"状态不对"之前,逐条排查: 1. **vite HMR `?t=` 幽灵模块**:HMR 的 `?t=` 查询会造出幽灵模块实例,探针读到的是假状态【samsy】。对策:验证一律用全新加载,不信 HMR 会话里的读数。 2. **探针时钟与页面时钟错位**:会伪装成"计时器时间被压缩"【samsy】。断言时序前先确认双方时钟同源。 3. **旧构建缓存**:SPA 会话里旧构建的 JS/CSS 会让"已修复的 bug"复现【rogier】。rogier 的对策是把 service worker 改为 network-first,保证 QA 时不会供出旧构建【rogier】。 4. **DNS 负缓存**:代理工具的 DNS 负缓存会伪装成资源加载失败,取证手段:DoH 查询、直连 IP【rogier】。 5. **构建窗口期 fetch 失败**:构建进行中的 fetch 失败不是产品 bug【rogier】。 6. 框架挂载时序差也会造出假状态(框架挂载晚于引擎 IDLE、全局句柄被 clone 覆盖成另一个对象)——探针读全局句柄前先确认句柄归属【samsy】(实证:`case-studies/environment-traps.md` §2)。 --- ## 3. 陷阱:部署拓扑差异竞态 本地全绿不等于线上无竞态:**真实网络延迟会触发本地永不出现的竞态**,所以"部署即验证"是流程的一部分【samsy】。 典型形态:仅线上出现的构造期资源加载竞态,用 CDP Fetch 对单文件注入延迟做**二分定位**,根因归"部署拓扑差异(单源 vs CDN 分域)"而非代码,修复拆成"保真修正"与"登记偏差"两笔分开处理【samsy】(实证:`case-studies/environment-traps.md` §3)。 **指令**:部署后复测全部验收门;发现仅线上出现的问题时,先用延迟注入复现,再决定是修代码还是登记偏差。 --- ## 4. 陷阱:探针自身的盲区(绿灯不可全信) **探针的覆盖面本身是需要迭代的对象**——**安全类报错走 CDP 的 `Log.entryAdded` 域**(如 SRI 校验静默拦截),只监听 Runtime/Network 的探针会让 "CLEAN" 带盲区;修复方式是给探针补上 Log 域监听【lando】(实证:`case-studies/environment-traps.md` §4)。 写 CDP 探针时的工程红线(kimi 工具坑清单,逐条照办)【kimi】: - **CDP 调用必须带超时**,否则挂起的调用卡死整个脚本; - **大 payload 分块取回**:单次多兆字节的 `Runtime.evaluate` 会卡死管道; - **headless Chrome 无视 SIGTERM,收尾必须 SIGKILL**; - **别用 `drawImage` 读无 `preserveDrawingBuffer` 的 WebGL canvas**(读到的是空的); - **`Page.captureScreenshot` 的 `clip` 是文档坐标,不是视口坐标**【shopifydesign】——见下。 **`clip` 陷阱:固定布局的站点会拿到全白图**【shopifydesign】。写滚动截图门时为了降分辨率提速,很自然会写 `clip: {x:0, y:0, width, height, scale:.5}`。**那个坐标系是文档,不是视口**:滚到 `scrollY = 10810` 时它照样截**文档顶部**那一块。而如果站点的画布与页头都是 `position: fixed`,文档顶部那一屏里什么都没有——整批滚动检查点会返回全白 PNG,肉眼看图才发现(实证:`case-studies/environment-traps.md` §4)。 - **指令**:**带 `clip` 抓图前先问一句"这个页面有没有 fixed / sticky 元素"**;**滚动位姿一律用整视口抓图(不带 `clip`)**。代价是快门变慢,用 §7 的突发采样 + 事后取最近帧去接。 - **自检**:**同一会话不同滚动位姿的截图哈希必须互异**(`gate-failure-modes.md` §1.2 的老规矩,正好也抓这个坑:全白图的哈希全都一样)。 WebGL 读回专属陷阱:`readRenderTargetPixels` 读回前必查 `gl.getError()`——**全零缓冲是读回假象**,不是场景真的全黑【noomo】。 --- ## 5. 陷阱:headless 盲区——必须留真机/人眼兜底 自动门之外必须保留人工目视与真机对比,因为 headless 环境有结构性盲区: - **授权字体不加载**:headless 下 Adobe Fonts 等授权字体缺失,产生换行/排版差异,属方法学噪声而非 bug【oryzo】; - **sRGB 色彩管理差异**:只有真机对比能暴露——最后一轮真机对比常在"噪声"里捞出真 bug(如纹理缺 sRGB→linear 解码导致整场景偏亮发灰)【oryzo】; - **编码保真类问题自动门抓不到**:构建器对含空格文件名的二次编码(`%20`→`%2520`)造成的 404 靠用户目视才发现,随后要补一次全站 URL×磁盘全量审计【lando】。 (两条的实证出处:`case-studies/environment-traps.md` §5) **指令**:收官清单里固定一条"真机 Chrome 对拍 + 人工目视过一遍",重点看字体排版、色彩、以及自动门未覆盖的资源加载。 --- ## 6. 陷阱:检查点覆盖不足 探针检查点没覆盖到的区间就是漏网区。沉淀的教训是"**终检必须包含滚动两端**"【noomo】。(漏网实例见 `case-studies/environment-traps.md` §6) 配套细则:seek 之后必须重新驱帧再截图,否则截到的是 seek 前的残留帧【noomo】。 **指令**:设计对拍检查点时,滚动 0% 与 100% 两端必须在列;每个可交互终态(footer、最后一屏、404)都要有检查点。**这只是覆盖面的下限**——完整的枚举规则(位置**按内容分段** × **状态**两维取笛卡尔积)在 `verification-gates.md` §1.3.1:只覆盖两端仍会漏掉中段的整段内容(实证:`case-studies/environment-traps.md` §6)。 --- ## 7. 陷阱:快门比被测运动慢——采样偏差伪装成"两侧相位不同"【shopifydesign】 **命题**:任何"**按状态对齐抓帧**"的门(轮询到某个进度量再截图、按 `spreadT`/`progress`/`t` 对齐同帧对拍),开工前先测一个比值: > **单次截图耗时 / 被测运动全长**。**比值不小于约 1/10,就不要相信抓到的相位。** 比值超标时,快门落点由 CDP 往返耗时决定,不由页面状态决定——而**两侧的往返耗时是不一样的**(每帧开销不同),于是仪器自己造出一个稳定的、可复现的相位差。 实证链条(软件渲染 → 单次截图 1–2s → 被测运动全长 2000ms → 两侧稳定差出 0.1 以上的相位,差点被写成"复刻侧动画更快"):`case-studies/environment-traps.md` §7。 > **可复现的偏差最像真 bug。**"每次都一样"不是"是真差异"的证据——恒定的仪器开销给出的就是恒定的偏差。 **走过的弯路(别重走)**:把判据搬进页面做 in-page watcher(用站点自己的 rAF 检测阈值穿越,穿越瞬间再请求截图)。**那修的是判据延迟,不是快门延迟**,瓶颈在 `captureScreenshot` 上,数字一点没变好。同理,提高轮询频率也无效。 **正确解法:把快门变快,而不是把判据变准。** 1. **先修快门**:删掉软件渲染 flag(`--use-gl=swiftshader --enable-unsafe-swiftshader`)后,同一段运动可采的帧数提升一个量级(实证:`case-studies/environment-traps.md` §7)。⚠ 删/加这类 flag 会改变被测程序本身,必须同时读 `references/determinism.md` §2.9 并把画质档钉死登记——见本文件下一段。 2. **再改采样策略**:把"轮询到阈值再截一张"改成 **整段突发采样 + 每帧标注拍摄前读到的相位 + 事后取最接近目标的一帧**。实测 Δ 因此收紧一个数量级(`case-studies/environment-traps.md` §7)。 3. **报告里写实际相位,不写目标相位**:对拍产物旁边记两侧各自的实测相位与 Δ,让下一个人能判断残差里有多少是相位造成的。 **⚠ 这条与能力探测熵源是同一个坑的两面**:`--use-gl=swiftshader` / `--disable-gpu` 既让快门变慢,又会命中站点的 GPU 名黑名单把被测程序切到低画质分支(`determinism.md` §2.9)。**先量比值,再决定 flag;flag 定了必须连画质档一起钉死并登记。** ### 7.1 更强一档:把驱动的时间表也注入页面【shopifydesign】 上面的解法(突发采样 + 事后取最近帧)修的是**快门**。还剩一项同量级的仪器误差没修:**驱动步骤本身的时刻**。凡是"先驱动到状态 X 再抓图"的门,"什么时候滚的 / 什么时候按下去的"由 CDP 往返决定,而两侧的往返耗时**不一样**——这与 §7 是同一个病灶的两个器官。 **做法**:把驱动动作搬进页面——**整张时间表(滚动位置序列 / 交互序列)以各自站点的就绪事件为原点注入**(本例 `site-ready`),由**页面自己的定时器**执行;探针只负责突发抓图与事后配对。注入的记录器只登记事件时间戳,不读也不改站点任何状态,两侧注入字节完全相同。 **收益(两层,第二层才是关键)**: 1. **直接对齐**:跨侧 Δ 相位收到毫秒级(实证:`case-studies/environment-traps.md` §7.1)。 2. **⭐ 顺带对齐了下游的派生锚点**:外部轮询式驱动会让两侧的倒计时相位差一个随机量,**注入式时间表让这条派生链整体同相**——这类二级锚点你通常不会想到要去对齐它。 **两条配套纪律**: - **注入按仪器类偏差登记**(该项目 D19),与 probe-shim 同规格:无开关时输出字节不变。 - **时间表必须是真实的走查,不许"跳"到目标屏**:闩锁型状态要把对应区段**整段滚过去**才置位(实证:`case-studies/environment-traps.md` §7.1)。直接 `scrollTo` 到时钟屏的门会发现引擎是哑的——**那时量到的是自己的驱动步骤,不是被测程序**。 **指令**: - 写任何"按状态抓帧"的脚本之前,量一次快门耗时(连拍 5 次取中位数)与运动全长(从事件时间戳算),把比值写进脚本头注释; - 比值 ≥ 1/10:先修快门(真 GPU、降视口、去掉软件渲染),修不动就换突发采样 + 取最近帧;**不许**靠"把判据搬进页面"糊过去; - **报"两侧相位不同 / 复刻侧动画更快"之前,先证明你的快门比被测运动快**,否则默认归因为仪器。 --- ## 8. 陷阱:移动视口仿真的 `<meta viewport>` 布局切换【objectarchive】 **命题**:`Emulation.setDeviceMetricsOverride({mobile:true})` 落地了,**不等于页面已经按移动宽度排版**。每一个**新文档**的 document-start 时刻,`window.innerWidth` 都是 **980**——传统默认值,因为此刻还没解析到 `<meta name="viewport">`;真实宽度**异步**落地在其后。 **为什么它咬人**:任何在 `<body>` 里**同步读 `innerWidth`** 并据此选分支的代码,跑在切换之前就永久停在错的那一边。最常见的形态是"只在值变化时才重算"的守卫: ```js const cols = () => (innerWidth < 750 ? 1 : 3); buildColumns(); // 块体内同步立即调用 -> 在 980 下算出 3 列 addEventListener('resize', () => { if (cols() !== columnCount) buildColumns(); }); ``` **实测**:移动档会间歇地被拍成**桌面布局**,而事后读到的 `innerWidth` / `screen.*` 一切正常——**"覆盖没落地"这个直觉是错的,覆盖一直是落地的**(实证:`case-studies/environment-traps.md` §8)。 **识别信号(命中任一条就按本节处置,不要去查移植)**: - 同一条命令、同一个视口档,**分钟之隔跑出两种布局**(实证:`case-studies/environment-traps.md` §8); - 失败**同时出现在两侧**且**不确定**——这是仪器竞态的签名,不是移植缺陷(`gate-failure-modes.md` §3.1.1); - 事后读 `innerWidth` 一切正常,**只有 document-start 探针**才看得见 980。 **取证手段(这是本节唯一的"先量"动作)**:用 `Page.addScriptToEvaluateOnNewDocument` 在**任何页面脚本之前**记 `window.innerWidth` / `screen.width`,成功与失败的会话各看一遍。**它一次就能把候选机制砍到一种**(实证:`case-studies/environment-traps.md` §8)。 **三种补救,逐条实测(别重走前两条)**: | 试的办法 | 实测 | 为什么 | |---|---|---| | 停在自带 `<meta viewport>` 的 `data:` 页、等 `innerWidth === 390` 再导航 | **0/6** | 跨源那一跳把瞬态又带回来了 | | 反复重新导航(最多 6 次) | **0/6** | 目标一旦以桌面态 parse 过,错误的排版是**稳定的**,不是偶发的——这也解释了为什么"重载一次"那一版把红从 2/8 变成 **5/8** | | ⭐ **把宽度抖一下(W → W+1 → W),驱动页面自己的 resize 重建** | **5/5** | 页面自己注册了防抖 resize 监听,列数与当前值不符就重建——**真机旋转走的就是这条路**,重建产物与全新 parse 的 DOM 一致 | ⭐ **抖动有第二种用法:不只是"拍照前预防",也是"发现已排错版之后的定向修复"**【objectarchive】。(第二种用法的来历:`case-studies/environment-traps.md` §8)改法不是把 sleep 加长(那是把偶发红换成偶发慢),而是: 1. **用被测块自己的产物当探测器**——取样后先问"列数是 1 吗"。它比 `innerWidth` 诚实:事后读 `innerWidth` 永远是 390,而列数会老老实实说这份文档是按 980 排的版。 2. **命中就抖,抖完重采**,等的是块自己的重建产物(防抖 resize 监听),不是墙钟。 3. **把"抖了几次"写进记录**(纪律 2 的销账凭据),并**保留断言原样硬红**:抖动次数用完仍不是 1 列,就照常按最后一次真实采样判——修复手段绝不能把持续失败变绿。 ⛔ **不要把重试写成"重新导航"**:上表第二行已经量过,0/6。**回流写下的实测,只有在下次动手前被读到才算数。**(这条是怎么被抓住的:`case-studies/environment-traps.md` §8) ⚠ **诚实边界(照抄这条边界的写法,不要照抄一个没验过的结论)**:把握来自上表的 5/5,不来自那次执行。(那次"检测 + 抖动修复"只用 fixture 强制走过一次、竞态本身随后一次没复现,见 `case-studies/environment-traps.md` §8)**修完一个偶发缺陷却没能让它再发一次,就要把这句话写进登记**,否则下一个人会以为它验过了。 **三条纪律**: 1. **等的是渲染器承认新视口,读 `screen.*` 而不是 `innerWidth`**:`about:blank` / identity 页没有 `<meta viewport>`,移动仿真下一律按 980 排版,**在文档外面等不出来**。 2. **抖动是"不给一份已知排错版的文档拍照",不是调容差**。判据一字不动、仍然硬红;容差与带宽常数早于本轮任何数字冻结(§7 与 `verification-gates.md` §1.3.2)。**每次抖动实际触发了几处要打进日志**,它是这条仪器偏差的销账凭据。 3. **按仪器类偏差登记**,与 probe-shim / 焦点仿真同规格,写清"换真实设备驱动时回退"。 **配套**:能力探测那一档的移动分支(`matchMedia('(hover:none)')`、`maxTouchPoints`)是**另一件事**——`setDeviceMetricsOverride({mobile:true})` **不动** `hover`,不开触摸仿真的话 390×844 跑的是**桌面分支**,两侧一致所以门照样绿、绿的却是另一个程序(`determinism.md` §2.9)。**两条都要做**:宽度对了不等于能力分支对了。 --- ## 9. 陷阱:驱动无限期挂起——**"挂住了"和"跑得慢"长得一模一样**【objectarchive】 **命题**:CDP 驱动的门在等页面时,如果外部条件消失(**断网**是最常见的一种),它会**一直等下去**,而不是失败。终端上看到的是"还在跑",与"这轮比较慢"无法区分。 **实证**:断网期间的一次挂死,node 进程 0% CPU、S 状态卡在等 CDP 响应超过一小时零输出,**网络恢复后它也不会自己醒**——那次连接已经死了,没有人在超时;而同一进程组里的 headless Chrome 还活着,从进程表看一切"正常"(实证:`case-studies/environment-traps.md` §9)。 **为什么断网会打到一个本地门**:两侧都在 `127.0.0.1`,但页面本身仍会尝试解析/连接外部主机(预连接、被 stub 的域名在 DNS 层仍要走一遭),而**代理隧道断掉时这些请求既不成功也不失败**,`readyState === 'complete'` 于是永远不来。 **识别信号**: - 进程 CPU ≈ 0、状态 S、无输出,而它本该每几十秒打印一行; - 耗时超过同命令历史时长的 2–3 倍; - `route -n get default` / `ping 网关` / `curl` 三层里有任意一层不通(判据见下)。 **做法**: 1. ⭐ **每个"等页面"的循环都要有硬上限,且超时是响亮失败**:`open()` 之类的 settle 轮询必须带 deadline 并 `throw`("page never came up within Ns"),**不允许无上限的 `for(;;)`**。**门可以红,可以慢,但不可以无声地不结束。**(同一族的旧例:`case-studies/environment-traps.md` §9) 2. **外层给整条命令套 `timeout N`**,让挂死变成一个非零退出码而不是一个没人看的终端。 3. **分层诊断,不要只 ping 一个地址**:网关 → 公网 IP 直连 → DNS 解析 → HTTP。代理隧道(`utun*` + `198.18.0.0/16` 的 fake-IP 段)断掉时前两层全通、后两层全挂,只测第一层会得出"网络没问题"的错误结论。 4. **恢复之后不要指望进程自愈**:按进程组收掉(`kill -TERM -<pgid>`,见 `determinism.md` 的进程收割纪律),确认 headless 残留为 0 再重跑。 5. ⛔ **挂死期间产生的任何数据都不可信**:**慢,会把两侧的采样时刻拉开到足以跨过某个状态边界**,制造出 §4.10 型的假红(实证:`case-studies/environment-traps.md` §9)。挂死不只是浪费时间,它是一台**残差制造机**。 ## 9.5 陷阱:npm 生命周期钩子不跟人走——`npx next build` 不触发 postbuild【basement】 **症状**:构建后某类资源间歇性 404,"上一轮明明修好了"。同一台机、同一份代码, 有时好有时坏,坏的那几轮全是直接 `npx next build` 构建的。 **机理**:`pre*`/`post*` 钩子只在 `npm run <script>` 时执行;`npx next build` 直调 CLI,钩子静默跳过。凡钩子负责重建的产物(basement:`.next/static/` 里的 镜像 immutable 软链——`next build` 每次清空该目录)就悄悄消失,而构建本身 零警告全绿。下游症状还会变形:worker 脚本 404 产生**空字段 error 事件**, 被误判成跨域脱敏错误(porting-discipline §2.5.1 第 6 条)。 **对策**: - 关键产物的重建挂**多个**生命周期点(`postbuild` + `prestart` 双保险), 或干脆并进 build 命令本体(`"build": "next build && node …"`); - 无人值守脚本与文档里统一写 `npm run build`,不写 `npx next build`; - 自查:坏境复现前先 `ls` 一遍钩子负责的产物在不在。 ## 9.6 陷阱:`npx <tool>` 是两层进程——杀 npx 留下 tool,端口从此有主【samsy】 门脚本用 `spawn('npx', ['vite', …])` 起开发服务器,退出时 `child.kill('SIGKILL')`。杀掉的是 npx,真正监听端口的 vite 是它 fork 的孙进程,被过继给 pid 1 继续活着。这样的孤儿可以伺服一棵早就不存在的旧树好几天,而后来的每一次"端口被占"都被当成新问题(实证:`case-studies/environment-traps.md` §9.6)。 对策与 `lib/chrome.mjs` 同款:`spawn(…, { detached: true })` 让子进程自成进程组,退出路径上 `process.kill(-pid, 'SIGKILL')` 收割整组;起服务前先探一次端口,**有东西应答就响亮退出并指路 `lsof -i :<port>`**——静默换端口只会把孤儿留给下一个人。 ## 10. 判定 bug 前的自查清单 把问题归因到源码之前,逐项打勾: - [ ] 是全新加载复现的吗?(不是 HMR 会话 / 手动切换后的状态)【samsy】【kimi】 - [ ] 无头环境带了 anti-throttling 旗标吗?页面在前台吗?【samsy】 - [ ] 探针时钟与页面时钟同源吗?【samsy】 - [ ] 排除了旧构建缓存 / DNS 负缓存 / 构建窗口期吗?【rogier】 - [ ] 探针监听了 CDP Log 域吗?(安全报错不走 Runtime/Network)【lando】 - [ ] 读回 WebGL 数据前查过 `gl.getError()` 吗?【noomo】 - [ ] 差异是不是 headless 盲区(字体 / 色彩管理)?真机上还在吗?【oryzo】 - [ ] 检查点覆盖了滚动两端吗?【noomo】 - [ ] 差异是"相位不同"吗?量过**单次截图耗时 / 运动全长**了吗?(≥1/10 先判仪器)【shopifydesign】 - [ ] 驱动步骤的**时刻**对齐了吗?(外部轮询驱动 vs 注入页面的时间表,§7.1)【shopifydesign】 - [ ] 抓图带了 `clip` 吗?页面有 fixed/sticky 元素吗?(`clip` 是**文档坐标**,滚动位姿会拍成全白,§4)【shopifydesign】 - [ ] 无头 flag(`--use-gl=swiftshader` / `--disable-gpu`)有没有把被测程序切到另一条画质/能力分支?(`determinism.md` §2.9)【shopifydesign】 - [ ] 生命周期钩子负责的产物还在吗?这轮构建走的是 `npm run` 还是直调 CLI?(§9.5)【basement】 - [ ] **移动视口档**:用 document-start 探针量过 `innerWidth` 吗?(`<meta viewport>` 落地前它是 **980**,页面可能已经按桌面排好了版,§8)【objectarchive】 - [ ] 准备改仪器了——**有没有先用一个探针把候选机制砍到一种**?(凭症状猜修法实测把 2/8 红变成 5/8 红,`gate-failure-modes.md` §3.1.1)【objectarchive】 - [ ] 只在部署环境出现?先用延迟注入复现再归因【samsy】 全部排除后,才允许开始在 bundle 里找源码归属。反之,如果是环境问题:**修环境或修探针,不动复刻代码**。 -
gate-case-design.md 13.6 KB
# gate-case-design.md — 用例设计与清单式核对 > **何时加载本文件**:为数值门 / 跨侧门 / 采集基线设计用例之前(`verify-tween` / `harvest-cases` / `verify-harvest` / `verify-crossside`),以及 M(n) 清单式核对(`cold-audit-modules` / `cold-audit-decls`)之前。门型选择与运行纪律在 `verification-gates.md`;门红了之后的失效模式在 `gate-failure-modes.md`。 ## 1. ⛔ 多用例的门,必须能证明它的用例彼此不同【airpodspro】 `gate-failure-modes.md` §1.8 说"全部的门可能拍在同一个状态里"。这是它在**用例套件**上的同构:**一套用例可能全部落在同一条路径上**,然后以满屏 ok 通过。(实证:`case-studies/gate-case-design.md` §1) ⛔ **这种门的危险在于它看起来最健康**:用例多、命名清楚、全部通过、区分力为零。 **两条判据**: 1. **用例的字段名与值域必须从源码抄**,不能凭直觉写。写用例前先找到源站**自己的调用点**。 2. **必须能证明用例彼此不同**——把各用例的输出并排打出来看。**能看见差别,才叫有门。** ⭐ 通用形式:**全部用例返回同一形状时,先怀疑用例,再怀疑被测物。** ### 1.1 ⭐ 并排打印当场抓到两个"绿色的假通过" **"全绿"和"有区分力"是两件正交的事,而并排打印是唯一能同时看见两者的动作。** 每道多用例的门都应当有一个"把用例摊开看"的输出形态,并在收口前真的看一遍。(实证:`case-studies/gate-case-design.md` §1.1) ### 1.2 ⛔ 声明式禁用条件要成对入例,且参数形状必须从源码抄 ⭐ **必须成对入例**:同一规格 × 两种 mask,断言它们**给出不同答案**。单独一条用例无论传对传错都是绿的——**这一类只有对照才测得到**,而默认偏好下的整页对拍**永远看不见它**。(实证:`case-studies/gate-case-design.md` §1.2) ## 2. ⭐⭐ 用例应该从源站的活引擎里**采**,而不是从你脑子里写【airpodspro】 手写用例编码的是**你相信引擎的参数是什么**。(实证:`case-studies/gate-case-design.md` §2) ⭐ **只要源站页面还能够到它自己的引擎,用例就该采**:驱动源站到 N 个状态,逐状态记录**它自己对象里的数值**。每一条用例于是都是关于源站的事实,而不是关于你理解的事实。 ⛔ 采集只产出 A 侧。**一份基线自己跟自己 diff 是变更检测器,不是正确性门**——必须有东西把同一批输入喂给移植侧再比(§5)。 ⚠ 采**解析完的数值**,不要采它的源文本。文本是意图的声明,数值才是真正跑过的东西;采数值还能让两侧不必各自重新实现解析器。 ### 2.1 ⛔⛔ 找接缝之前不要断言"这个子系统没有接缝" **先做这一轮再下结论**,每一条都便宜:(实证:`case-studies/gate-case-design.md` §2.1) | 找什么 | 怎么找 | |---|---| | 全局 | `Object.getOwnPropertyNames(window)` 过滤掉标准项 | | 模块注册表 | grep 美化产物里的 `window.<Name> =`;试着用模块 id 调用可疑的单参函数 | | 共享实例表 | 平台层常有 `share(name, major, obj)` / `get(name, major)` | | **DOM 反向引用** | `Object.getOwnPropertyNames(someParticipatingElement)`——**最容易被忘掉的一个** | ### 2.2 ⛔⛔ 枚举被测对象时,声明式属性通常**不是**索引 属性给概念**命名**;引擎用来记"谁参与了"的那个东西才是**索引**。**能被 grep 到的名字和实际在跑的机制是两件事。**(实证:`case-studies/gate-case-design.md` §2.2) ### 2.3 ⛔ 「有东西动了」不等于「我要测的那个量动了」 ⭐ **防呆必须盯住被测量本身。**(实证:`case-studies/gate-case-design.md` §2.3) ### 2.4 ⭐⭐ 按**行为**认身份,不要按名字——并且这样还能把名字找回来 ⭐ **曲线的身份就是它的取值**:在固定 t 上采样,那串数字就是指纹——条件无关,两侧可直接匹配,而且不需要两侧对名字达成一致。(实证:`case-studies/gate-case-design.md` §2.4) ⭐ 更好的是:**按行为匹配把名字找回来了。** 移植侧的模块按可读键导出(`linear` / `easeInOutQuad` / `easeInOutCubic`),指纹一对上,就知道源页面实际在跑哪条曲线——**这是源页面自己说不出来的事实**。 ⛔ 每条采集到的身份必须匹配**恰好一个**:匹配到零个是移植缺了,匹配到两个说明移植里有重复行为、这个映射就不是事实。 ⚠ 覆盖率要说出口:移植侧定义 20 条曲线,本页只用到 3 条——**另外 17 条这道门一句话都没说**。 ## 3. ⛔⛔ 清单式核对能抓到逐像素全绿也看不见的整块遗漏【airpodspro】 原因:分层表只把 `require("字面量")` 记成依赖边,而这个 bundle 里有一处**条件 require**——`require(t ? "c0e8c8…" : "2f0218…")`,用来按浏览器与选项选择视频播放器实现。两个分支都是字面量 id,但藏在三元表达式里,于是**没有产生任何入边**,两个目标被判成"没人 require 的死代码"。(实证:`case-studies/gate-case-design.md` §3) 这就是 SKILL 里"功能测试测不出整块遗漏,只有清单式核对能"的实例——**逐像素 0.00 不是覆盖率的证明**。 修法:在 require 调用的**整个参数范围**里收集字面量,逐个对照真实 id 集合。 ### 3.1 ⛔⛔ 一道检查必须报出它**查了多少**,否则它的沉默什么都不是【v0-optimus】 **一道查了零个对象的检查,报出的是绿灯。**(实证:`case-studies/gate-case-design.md` §3.1) ⭐ 修法有两层,第二层才是要点: 1. 让它认第二种签名(`ctx => {}` / `(ctx, …) => {}`,调用形状是 `ctx.i(` / `ctx.r(`)。 2. ⛔ **让它把覆盖率打印出来,并把覆盖率变成判据。** 判据要写成**"查到的比例够不够"**(实测阈值 80%),不是"有没有查"。 ⚠ 这条对**任何**遍历式检查都成立:符号门、清单门、引用扫描、资产普查。**"我没发现问题"和"我没找到可看的东西"在输出里长得一模一样**,唯一能把它们分开的是那个计数。凡是逐对象遍历的检查,输出里都应当有 `n/N examined`。 ### 3.2 ⛔⛔ 判据数东西的方式,必须和它审计的那个动作用同一个定义【eightdesign】 分层表的合理性判据是"文件里 require 形状的调用有多少,记到的依赖边有多少,比值太低就 FATAL"。(实证:`case-studies/gate-case-design.md` §3.2) ⛔ 毛病在于**两个数不是同一个定义下数出来的**。"require 形状"用的是宽松模式 `name(literal)`,而 `h("words")` 这类单参辅助调用也匹配;**分子和分母来自两把不同的尺子,比值就没有意义。** ⭐ 改法不是调阈值,是换问题:**同一个模式下,有多少调用落在找到的容器之外**。两边同尺,比值才成立。 ⚠ 这类误报比漏报更贵:**一个挡住正确读取的守卫,会训练人绕过守卫。** ### 3.3 ⚠ 零依赖阶段的清点:把"能判的"和"要人读的"分开 M(n) 在源码化之前,工具零依赖,而文本扫描**分辨不了作用域遮蔽**——require 参数名是单字母,常被内部回调的同名参数遮住。所以清点工具应当: - **判定**可判定的那一半:**有没有未移植模块被已移植模块 require**(这一半完全可判); - **列出**疑似动态 require **交人读**,不要伪装成判决。⛔ **一份哭狼十三次的清单,第十四次没人看。** ⭐ 从实际判读里提炼出四个**不需要作用域分析**的判别器,把 13 条误报压到 6 条:require 从不被 `new`、**恰好收一个参数**、不会被判真假(`if (r)`)、名字未在模块内被重新声明。⭐ 还有一条把"已解决"分出来:**参数是计算的、但其中的字面量已全部记为边**,那它就是已清点的,不该继续占着待读清单。 ## 4. ⛔ 逆向出来的引擎:「缺一个前置动作」和「移植错了」表象完全相同【airpodspro】 ⭐ **四次的正确解法都是同一个动作:读源站真实调用点的调用序。**(实证:`case-studies/gate-case-design.md` §4) ⭐ 第四次多一层教训:**前置动作可以由一个你没触发的浏览器事件间接持有。** 查法是**从"谁写这个字段"倒推到"谁调它",一直推到某个真实事件为止**,中途停在 `initialize()` 就会得出"引擎移植错了"这个错误结论。同族的还有:某个字段只在 `IntersectionObserver` 回调、字体 `document.fonts.ready`、或首帧 `requestAnimationFrame` 里被填。**探针页要么触发那个事件,要么直接调回调本身,并在注释里写明它替代的是哪个事件。** ⛔ **方法名告诉你它叫什么,调用序才告诉你它需要什么先发生。** 在压缩产物里,方法名往往还是可读的,于是很容易照着名字猜用法——而这一族失败**全部源于猜对了名字、猜错了顺序**。 ⚠ 附带一条**探针页自身的陷阱**:`window.scrollTo(0, 2000)` 在内容 3000px、视口 1080px 的页面上最多滚到 1920。位置量"卡住"可能不是引擎钳位,而是**页面没那么高**——最大滚动量要按 `scrollHeight - innerHeight` 算。 ## 5. ⛔⛔ 跨侧门:两侧数值不同,先分清"条件差异"还是"移植差异"【airpodspro】 三次红,三种非缺陷原因,每一种都值得单独防:(实证:`case-studies/gate-case-design.md` §5) **① 门把同一侧量了两遍。** ⛔ **这是这道门出错时最有说服力的形态:它会报告完美一致。** 一道跨侧门必须**串行**驱动两侧,或者在结果里带上各自的来源指纹(URL、`document.title`、页面高度)并断言它们不同。 ```js // ⛔ 永远不要 Promise.all —— 两侧都在驱动 CDP。 const a = await evalOn(A, EXPR_JS(EXPRS)); const b = await evalOn(B, EXPR_JS(EXPRS)); ``` **② 一侧缺前置动作**(见 §4 第四行)。跨侧门在这里格外有价值:单侧门看到 `100vh → 0` 只会以为这就是答案,**只有另一侧给出 1080,才知道有东西没跑**。 **③ 剩下的差异全部来自测量条件,不是被测对象。** **页面高度不同,绝对坐标当然不同——这不是缺陷。** ⭐ 正确做法不是去把两侧环境拉平(那既昂贵又会把门变成对环境的测试),而是**把被测量按"是否依赖测量条件"分成两组**: | 组 | 例子 | 门怎么处理 | |---|---|---| | **条件无关**(判定) | 纯算术、视口单位、`css()` 读取、`a0h`、`(a0b - a0t) * k` 这类自抵消的组合 | 逐字比较,**必须相同** | | **条件相关**(只打印) | `a0t`、`a0b`、`a0t + 100` | 不判定数值,**但断言两侧都能解析出来**——否则就把"解析器根本不支持它"也一起藏掉了 | ⛔ **把条件相关量算进 FAIL,等于让门每次都红;而红得没有意义的门,人会开始忽略它。** 但也不许直接删掉它们——降级成 info 并保留"两侧都得解析成功"这条弱断言,才既不吵也不瞎。 ⭐ 这三条已经做进 `scripts/verify-crossside.mjs`(站点侧只写 `crossside.config.mjs`:接缝在哪、哪些用例进 `judged`、哪些进 `info`)。其中**指纹断言**是失败模式 ① 的自动化防呆:每次运行取两侧 URL / 视口 / 文档高度,**URL 相同直接 FATAL**。⛔ 这条防呆本身**必须被故意触发验证过一次**再算数——两侧指向同一 URL 应当 `exit 5`(`gate-failure-modes.md` §1.5 同理:没触发过的防呆等于没有)。 ⚠ 设计跨侧门时先问一句:**这个量在两侧的定义域一样吗?** 凡是答案为否的(页面尺寸、滚动量、时间戳、随机种子、设备像素比),要么归一化成比值,要么降级成 info。 ### 5.1 ⭐ verify-crossside 的同步合同边界:rAF 循环引擎要走异步采样【firstlaunch】 `verify-crossside` 的 `build()` 合同是**同步表达式**(WRAP 直接 `JSON.stringify(IIFE())`)。凡引擎在 **rAF 常驻循环里渲染**(skrollr 型:`setScrollTop` 只改输入,样式在下一帧的 `_render` 里落地),同步表达式采到的是**上一帧**——这类站的跨侧门要自己写,沿用 §5 的三条防护(串行 / 双侧指纹 / 同 URL FATAL),把采样表达式改成 async、走 `probe.mjs --eval` 的 awaitPromise 通道(spawn,不 import——`verification-gates.md` §2.1.2)。 实测可复用的驱动模式(first-launch,9,856 样本全等): 1. **用引擎自己的强制跳转 API,且先读源码确认其语义**:skrollr 的 `setScrollTop(S, true)` 里 force=true 置 `_lastTop = S`,令平滑滚动的 `topDiff = 0` 即瞬跳——不确认这一步,采样会落在 200ms smooth-scrolling 补间的中途,样本非确定; 2. 命令式层(`$(window).scroll` 处理器)用 `jQuery(window).trigger('scroll')` **同步**驱动——幂等、纯 scrollTop 函数,双保险不怕真事件再来一次; 3. **三重 rAF** 之后再采样(引擎一帧 + 浏览器一帧 + 余量); 4. 采样面选 **inline style 属性串**(`el.getAttribute('style')`):skrollr 与 jQuery `.css()` 都写内联,一个读数覆盖两层引擎,且 CSS 动画(跑在浏览器动画时间线上)不会污染它——这类站因此**不用冻结就有确定性数值门**。 ⭐ 这类门比像素门早、失败自带产生它的输入 `(scrollTop, selector, index)`;检查点按引擎魔数选(每幕起止、精灵图 start、clamp 两侧、正放/倒放分界),不按等分。 -
gate-failure-modes.md 37.3 KB
# gate-failure-modes.md — 门的失效模式、根因修复与残差归类 > **何时加载本文件**:一个门"全绿但用户/真机发现了问题"时,或像素/数值门出现残差、需要判定"真差异还是方法学噪声"时。门的定义、选型与运行纪律在 `verification-gates.md`;冻结与驱动的前置条件在 `determinism.md`;用例设计与清单式核对在 `gate-case-design.md`。 ## 1. 门的十种失效模式与防呆 1.7 与 1.8 讲的是**门的某个没写出来的前提反过来吃掉覆盖面**——1.7 是确定性冻结,1.8 是"页面处在哪个状态",它们与 `verification-gates.md` §1.4.1 是同一族(三者的对照表见 §1.8);1.10 不一样,它是**人主动把被测对象从画面里挖出去**;1.11 又不一样,它是**门把一个会流动的量当成状态量写进了产物**(前九条全绿的方向,它是"单侧全绿、跨侧才红")。各条来源与事故实录见 `case-studies/gate-failure-modes.md` §1。设计每个门时逐条自检: ### 1.1 门只断言想到的字段 - **防呆**:**并集全量比对**替代字段名单比对——把双侧出现过的字段取并集逐一比,而不是只比自己列出来的。(实证:`case-studies/gate-failure-modes.md` §1.1) ### 1.2 门对"驱动步骤没生效"是盲的 - **防呆**:**同会话位姿哈希必须互异**——不同位姿截图哈希相同,说明驱动没生效,直接判红。任何"先驱动到状态 X 再断言"的门都要有"确实到达了 X"的独立证据。(实证:`case-studies/gate-failure-modes.md` §1.2) ### 1.3 byte-equal 不证明"测的是想测的画面" - **防呆**:字节门之外保留人工目视产物(side-by-side 合成图逐张过目);关键内容加存在性断言(DOM/数值探针);覆盖面靠 §1.5 的清单对账,不靠门的颜色。(实证:`case-studies/gate-failure-modes.md` §1.3) ### 1.4 门把录制巧合编码成规格 - **防呆**:**断言机制本身而非环境量**——断言"anchor 由哪条公式推出",不断言某次录制里它恰好等于多少。写门时对每条断言问一句:这是源站的规格,还是那天录制环境的巧合?(实证:`case-studies/gate-failure-modes.md` §1.4) ### 1.5 静止态门对过渡组件结构性失明 - **防呆**:**枚举源站模块清单逐一对账落点**,作为独立收官步骤——"覆盖面的空洞要靠清单对账,而不是靠门的颜色"。对过渡态本身,用 framebudget 冻结协议把中间帧(第 24 帧、u=0.5)也纳入字节比对(见 `references/determinism.md` §2)。(实证:`case-studies/gate-failure-modes.md` §1.5) ### 1.6 诊断工具与验收门混用一份代码 - **防呆**:诊断代码与门代码**分离正确性标准**;基础库(如 PNG 编解码)对权威实现逐格验证(`scripts/lib/png.mjs` 对 Pillow 逐格验证过)。(实证:`case-studies/gate-failure-modes.md` §1.6) ### 1.7 冻结使子系统对门隐身【shopifydesign】 - **它与前六条的区别**:4.1 是"字段没想到"、4.5 是"位姿没覆盖到"——都是门的断言面不够;这一条是**门的前置条件把被测对象从两侧同时删掉了**。双侧对拍是差分门,只看得见不对称的差异,而冻结造成的缺席是对称的,于是缺席以"通过"的形式呈现。**冻得越狠,盲区越大**——每多冻一个熵源,就多一批子系统从视野里消失。(实证:`case-studies/gate-failure-modes.md` §1.7) - **防呆**: 1. **冻结前枚举挂在被冻熵源上的全部入口**(本例 rAF → `site-ready` → 4 个 effect),逐条决定"探针泵到该事件"或"补一条不冻结的结构性抽查",清单入库——枚举方法见 `references/determinism.md` §2.10; 2. **每个里程碑保留至少一条不冻结的对拍**(截图对拍 / DOM 结构断言)。**数值门与像素门不是替代关系**(`verification-gates.md` §1.4.1); 3. **对称缺席只有绝对断言看得见**:断言"`.wr` span 数 == 18",而不是"两侧 span 数相等"。凡是靠"两侧一致"关账的门,都要配一条"该有的东西确实在"的存在性断言(与 4.3 同源,但触发原因不同)。 ### 1.8 ⭐⭐ 全部的门拍在同一个状态里【shopifydesign】 - **每道门都按自己的定义正确运行**——这正是它可怕的地方:四道门全绿,而站点**一半的状态零覆盖**(实证:`case-studies/gate-failure-modes.md` §1.8)。 - **它与 `verification-gates.md` §1.4.1、§1.7 是同一族**——三者都是"门的一个**没写出来的前提**吃掉了覆盖面",别当成三条各不相干的坑: | 失效 | 门没写出来的前提 | 被测对象怎么消失的 | 谁能抓到 | |---|---|---|---| | `verification-gates.md` §1.4.1 | 两侧跑的是**同一个程序** | 竖切期有桩 = 两个程序,门量的是桩,红得与移植质量无关 | 按字段分组 + 逐条归因到桩 | | §1.7 | 被测对象**没被冻掉** | 冻结让它在两侧同时缺席,差分恒为 0,以"通过"呈现 | 不冻结的截图对拍 / 绝对断言 | | §1.8 | 页面处在被测对象**可见的那个状态** | 状态入口没移植,全部门拍在另一个状态 | 读主绘制函数的早退分支(`verification-gates.md` §1.3.1) | 共同形状:**双侧对拍是差分门,只看得见不对称的差异;凡是把被测对象从两侧同时移出视野的东西,一律以"通过"的形式呈现。** 操作化:每建一个门,把这三条前提写进门脚本的头注,并各配一条正面证据。 - **结构性断言只证明存在,不证明渲染正确**:93 / 100 / 135 条绝对断言全过,说明的是"这些对象被建出来了、DOM 属性对",它们**一个像素都不读**。"存在"与"画对了"之间隔着整条渲染管线(材质、shader 分级、绘制顺序、早退分支)。**凡是只有结构性覆盖的子系统,报告里必须写成"结构性覆盖,0 帧像素",不许写成"已验证"。** - **防呆**: 1. **建门之前先枚举状态**(`verification-gates.md` §1.3.1 的五步),回答"这个站有几个互斥的全局状态?我的门覆盖了几个?"; 2. **没有门的状态 = 该状态下全部移植代码的证据强度为零**,无论其他门多绿。这句话要**逐个里程碑写在报告里**,直到那个状态建起门为止; 3. **覆盖不到的就登记,不要强行宣称**。**登记一条开口的成本是一行字;宣称覆盖的代价是下一个里程碑替你发现。** #### 1.8.1 ⛔⛔ 检查点驱动**静默失效**:页面在自己的 init 里把滚动重置了【landonorris】【airpodspro】 §1.8 说的是"全部的门拍在同一个状态里"。**它有一个更隐蔽的形态:门以为自己驱动了 N 个状态,实际只驱动了 M 个,而两侧一致地拍到同一张错帧,于是全绿。** 实测两处,同一个根因(实证:`case-studies/gate-failure-modes.md` §1.8.1)。 ⭐ 修法两条,缺一不可: 1. **滚两次**:`load` 时一次,**再在虚拟时间 +1.5s 后一次**——第二次落在页面自己的 init 之后。⚠ 走泵的时间,不是墙钟,否则在冻结页上永远不触发。 2. ⛔ **"动没动"的判据必须是逐格的,不能是全局的。** 一个全局 distinct 计数在"9 格里 3 格重复"时照样通过,还会打印一句"the walk moved the page"。真正要报的是**重复的那几格**:它们各花了一次完整抓拍,量的却是已经量过的状态。 ⚠ 色数相同是"同一帧"的**强证据而非证明**(一段又高又平的页脚真的可能两处一样)。所以:**重复项逐条列出来要求解释,只有当重复占到一半以上才 FATAL**。 ⛔ 最后一条纪律,也是我自己犯的:**给一个退化的测量编一个合理解释,不等于验证了它。** #### 1.8.2 ⛔⛔ 文档不一定是那个会滚的东西【eightdesign】 巡航门的种子算 `documentElement.scrollHeight - innerHeight`,再按比例 `scrollTo`。在一个用平滑滚动库的站上,这个差值是 **0**——页面滚的是一个内层 `overflow-y: auto` 的容器。 ⛔ 于是每个检查点都算出 `0 × f = 0`,而且残差全部落在带宽内——**看起来完全像一次成功的巡航**。⭐ 只有**重复帧报告**看见了它(实证:`case-studies/gate-failure-modes.md` §1.8.2)。 **修法:让驱动自己找滚动容器**——文档能滚就用文档,否则取 `scrollHeight - clientHeight` 最大且 `overflow-y` 为 `auto|scroll` 的元素。⛔ 并且把"没有可滚动的东西"变成一个**门能看见的事实**,而不是一个静默的 0。 ⚠ 顺带排除的两条歧路:`window.scrollTo` 无效、window 上派发 `wheel` 事件同样无效——**因为事件根本不该发给 window**。在断定"这个站没法驱动"之前,先问一句**谁在滚**。 #### 1.8.3 ⭐⭐ 门必须报出**它在哪里测的**——这一行是归类的前提【eightdesign】 一个残差要么是移植差异,要么是两侧测在了不同状态上。⛔ **在门说出它测于何处之前,这两者无法分开**,而这恰恰是最容易被跳过的一行输出。 加一行"measured at"之后,残差不再是位置问题,而是必须用**同一位置的同侧对照**去归类的候选真差异(实证:`case-studies/gate-failure-modes.md` §1.8.3)。 ⭐ 两条纪律合起来才成立:**① 报出测量位置;② 同侧对照必须与跨侧测在同一位置。** 早前用"首屏带宽"去衡量"滚动位置残差"是拿错了尺子。**带宽是逐检查点的,不是全站一个数。** ⚠ 还有一条与之配套:**输入缺失的断言是静默失效的**。落点断言写好后一声不吭,可能是"两侧一致",也可能是"它读不到数据"。⛔ 所以要**无条件打印**,并在"给了驱动却没记录到落点"时直接 FATAL——否则你会把一个从未运行过的断言当成通过。 #### 1.8.4 ⛔ 驱动器要匹配站点的输入通道:scrollTop 开不动 wheel 站【jiouhe】 走查驱动默认改 scrollTop/scrollTo——而一个滚轮驱动的站(animator 监听 wheel 事件, 文档本身不滚)对它完全无感(实证:`case-studies/gate-failure-modes.md` §1.8.4)。 判据:走查前先问**这个站听什么**(wheel / touch / 键盘 / scrollTop),驱动器发对应 事件(WheelEvent 序列),并用**帧推进的可观察量**(当前帧号/激活区段)确认真的在动 ——"landed 位置"对 wheel 站无意义。§1.8.2 的姊妹课:那条说"文档不一定是滚的东西", 这条说"滚动位置不一定是输入信号"。 ### 1.9 ⛔⛔ 链条上**只要有一步没有 `--check`,整条链的绿灯就可能是过期的**【optimus】 链上少一个 `--check`,缺口是隐形的:过期的 `dist/` 照样能伺服,下游每道门照样全绿——**因为门比的是 `dist` 对镜像,不是 `dist` 对 `src`**。 ⛔ **时间戳不是判据。** ⚠ 从 mtime 推出"过期"错在无害的方向纯属运气:**同一个推理完全可以错在另一个方向**,而那一次就没人会发现(实证:`case-studies/gate-failure-modes.md` §1.9)。 ⭐ 正解是同一条老纪律:**重新生成,比字节**。补上 `verify-fresh.mjs` 之后它查两件事,缺一不可: 1. `dist/` 是不是 `src/` 现在构建出来的样子; 2. **`site/` 伺服的是不是 `dist/` 里的那一份**——只查第 1 条的话,一个没重跑的外壳构建会让**旧产物继续被伺服而 `dist/` 是对的**,缺口只是往下游挪了一步,没有关上。 ⛔ 判据本身也要被证伪过:往 `dist/` 追加一行注释,两条断言应当同时报红(实测 exit 1)。 ### 1.10 ⭐⭐ 门把差异所在的区域扣掉了【shopifydesign】 - **事故**:一句"扣掉某区域后逐像素相同"的漂亮结论,**那个被扣掉的象限里装的不是噪声,是当时项目唯一的真 bug**(实证:`case-studies/gate-failure-modes.md` §1.10)。 - **它与 4.3 同源但更狠**:4.3 是**门测了一张不含目标物的图**;这条是**人为地把目标物从图里挖掉**。而且挖掉的动作看起来非常专业——它有理由、有源行号、有归因,写进关账结论时没有任何人会拦。 - **防呆**: 1. **任何"排除某区域后达标"的结论,必须给出该区域差异的独立证据**——该区域内**两侧各自的时间序列**,或一条**针对该区域产物的绝对断言**("指针角度每帧在变、且两侧同源")。给不出就不许写进关账结论,该门判红或判"未覆盖"。 2. **一句话判据:你扣掉的那块,恰恰是你唯一没验证的那块。** 3. **正解不是排除,是分类**——把差异逐格归到已知机制上,并让"这是噪声"变成可证伪的排序命题:四个分类器见 §3.1(第四个"同侧对照"见 §3.1.2,它给的是**正面证据**,同样不许拿去挖区域)。 4. **归因的说服力不等于归因的正确性**:这条事故里的归因(阻尼跟随、带行号)**每一个字都成立**,唯一没被验证的是"复刻侧也在跑这段代码"。**凡是把差异归给'活体动画'的,先证明复刻侧那个活体确实活着。** ### 1.11 ⭐ 记录量里混进了时钟:门把一个会流动的量当成了状态量【objectarchive】 - **事故**:同一形态在两个里程碑里出现 **4 次**,全部**先绿后红**——单侧跑全绿,跨侧对拍才揪出来(五个流动字段的实例表见 `case-studies/gate-failure-modes.md` §1.11)。 - **三条特征合起来才认得出它,缺一条就会被误诊成前九条里的某一条**: 1. **单侧全绿**——两侧各自自洽、各自可复现,单侧跑一百遍也不会红; 2. **只有跨侧对拍能发现**——两侧的时钟不同步,差异只在把两份产物放在一起时才出现(这是"两侧都跑同一道门"的第二个用处,第一个是 `verification-gates.md` §0.1 的方向分诊); 3. **既不是覆盖问题也不是期望值问题**——门断的是对的东西,期望值也对,错的是**这个字段根本不该进记录**:它属于"**这一次运行**",不属于哪一侧。 - **⭐ 修法一句话:断言用原始数,记录用派生判定。** 不要把"当时读到多少"写进产物,要写"**它是否满足判据**"——判断在页内当场做完,跨边界的只有那个布尔/枚举: | 原始数(别记) | 派生判定(记这个) | |---|---| | `elapsed: 19222` | `pollCadenceIs3000ms: true` | | `scrollLeft: 8` | `scrollLeftFollowsPointerDelta: true`(差值在页内当场比) | | `settledScrollHeight: 149` | `maxHeightEqualsOwnScrollHeight: true`(断**关系**,不断像素数) | | `transitionend` 写下的 `594` | `containerHeightEqualsActivePanelScrollHeight: true`(安静之后的不变式) | **原始数只进 console 当证据,不进比对产物。** 第 4 例还多一层:飞行值是**源站写的**,那就照抄不修、把它作为证据打印出来,门只断言**安静之后的不变式**(该块自己的 banner 注释写着的那一条)。 - ⭐ **第 5 例带来三条新的东西,都不是"再来一次"**: 1. **同一个字段,可以在同一道门里一半防住、一半没防。** 两个维度共用一个字段名,防御只做在其中一个上。**写门时逐字段问一句:这个字段在这道门的另一个维度里,是不是已经有人防过了?** 有 → 照抄那条防御,别重新发明。 2. **它可以只记不断,纯靠对拍才现形。** 所以"这个字段有断言在盯着"这个直觉是错的,**一个只记录不断言的流动量,唯一的作用就是制造假红**。收口动作:`record` 出去的每个字段,要么有断言用它,要么说明白为什么它值得被记(证据/归因)。 3. ⚠ **它可以绿很多个里程碑,因为两侧通常一起落在边界的同一侧。** **"它一直是绿的"不构成"它不是流动量"的证据**:这类字段的红是**条件性**的,条件是环境抖动,而环境抖动不听你安排。 - ⭐ **派生判定去哪里找:读源站的代码,找它自己保证的原子不变式。** 记这个不变量(并且**真的断言它**),时钟就出局了,而覆盖面比原来那个数字更强。**先读源码再设计记录字段**,比先记数字再想怎么容忍它便宜得多(实证:`case-studies/gate-failure-modes.md` §1.11)。 - **识别信号**(写门时逐字段问,命中任一条就把它换成派生判定): - 同一条断言重跑,**数值漂而结论稳**; - 跨侧差值与**采样时刻**相关(多等一会儿就变),而字段的语义里根本不含时间; - 字段的单位是**时间**(ms、间隔、时长、次数×节拍),或它的值来自一段**正在跑的动画/过渡/平滑滚动**(`scroll-behavior: smooth`、`transition`、稍后会被 `ResizeObserver` 纠正的量); - **源站自己**就在飞行中量它。 - **与 §1.4 的关系(同一件事的两半)**:§1.4 是把一个环境量写进了**断言**("首帧 anchor == 585px"),本条是把它写进了**记录**。前者当场就红,后者要等跨侧对拍。两半的修法同源:**断机制、断关系、断判定,不断某次运行的读数。** - **与 `verification-gates.md` §0.1 / §3.1.1 的边界(三者最容易被混成一件事,按下表分诊)**: | | 错在哪 | 表现 | 修法 | |---|---|---|---| | **`verification-gates.md` §0.1 期望值往返** | 断言面对,**期望值**从源站源码手抄了一个引擎会重新序列化的字面量 | **红**,且先红在镜像侧;**单侧就能发现** | 让源站字面量在同一引擎里往返一遍再当期望值 | | **§3.1.1 先修仪器** | 字段**该**记,但**采样时刻**不对——量到的是探针的时刻,不是页面的状态 | 残差飘忽;换 settle 判据 / 多等一会儿就变或归零 | 把 settle 改成页面状态判据、重测;**修完仍余的**才做归因 | | **§1.11 本条** | **字段本身**是流动量,settle 修到完美它照样漂 | **单侧全绿、跨侧红** | 换**记录内容**:记派生判定,不记读数 | **判定顺序(先问 4.10,再问 6.1.0,最后问 0.1)**: ① **这个字段有没有一个等价的派生判定可以代替?** 有 → 换掉,问题在这里就消失了,不必进入归因流程; ② **没有等价替代**——即它是块自己的真实可观测产物(实证里的 `.pxl-done`:淡入完成的卡片数,无法用一个布尔替代)→ 走 §3.1.1 先修仪器(**含"输出侧账"那一问:这一帧的上色面是不是每个都有字段在看**),修完仍余的按 §3.1 分类器 (C) 排名归因;排名也判"不是噪声"而两侧指纹逐字段无差时,用 (D) 同侧对照做最后一步归因(§3.1.2); ③ 断言仍然红 → 回 `verification-gates.md` §0.1 查期望值的出处。 **反过来说**:`.pxl-done` 之所以**合法地**留在记录里,正是因为第 ① 问的答案是"没有等价替代";而 `elapsed` 在第 ① 问就该被换掉——**它根本不配走到分类器那一步**(走到了就是在给一个本不该存在的数字做精细归因)。 ### 1.12 ⛔⛔ 工具链把结果**截断**了,而截断出来的东西看起来是合法的【airpodspro】 一次探针返回的 JSON 在 **65,536 字节整**处被砍断。根因不在 CDP,也不在门里:**`process.exit()` 会丢掉尚未 flush 的 stdout**,而管道 stdout 是异步的,一次超过管道缓冲区的 `console.log` 就这样被切在 64 KiB。 ⛔ **危险的地方是它不报错。** 调用方拿到的是一个**格式良好的前缀**: - 恰好在一个字符串中间断开 → JSON 解析失败,尚且看得见; - **断在一个完整的元素边界上 → 解析成功,而记录少了一半,没有任何征兆。** 这一类才是真正会写进结论的。 **修法**:等 stdout drain 完再退出(`process.stdout.write("")` 返回 false 就等 `drain`)。⚠ 不要只改成设 `process.exitCode`——浏览器 socket 会吊住事件循环,进程会挂死而不是退出。 **判据(每个会打印大结果的工具都该有一次)**:喂一个**已知长度**的载荷,量回来的字节数。实测 60,000 → 60,009、65,000 → 65,009、70,000 → **65,537**(封顶)。一次三行的测量,抵得上事后对着"为什么这份数据看着不完整"的所有猜测。 ⭐ 同一族的还有 **`Runtime.evaluate` 不带 `awaitPromise`**:async 表达式会被序列化成 `{}`——**又一个"格式良好的空答案"**。任何要驱动页面再读的东西都必然是 async,所以这条几乎一定会撞上。 ### 1.13 ⛔⛔ 门订阅的 CDP 域不覆盖它声称的断言面【samsy】 判据:一道门在**它订阅的事件面**之外的任何断言都是空话——把"我断言什么"和"我订阅了什么域"并排写出来,对不上就是假绿。CLEAN 门最少要开 `Runtime` + `Log` + `Network` 三个域(skill `probe.mjs` 的血统注释里 landonorris 那条 Log 域教训是同一课的前半句)(实证:`case-studies/gate-failure-modes.md` §1.13)。 ### 1.14 ⛔ 把 class 1 修绿而 class 4 仍在,是化妆 ⭐ 静态门的四类断言是**互补**的,不是可以互相顶替的。一个只对着 class 1 动手的修法, 让报告变好看了,而浏览器该往外打的请求一个不少。(实证:`case-studies/gate-failure-modes.md` §1.14) 修法是 `stubExtHosts`:构建把 URL 本地化为 `/ext/<host>/…`,服务器以空 JS 桩应答, 页面照常水合。结果:`external requests (0)`,RESULT CLEAN。 ### 1.15 ⛔ 切片交付的 token 门要对整个文件,容器外的字节也是原件【raycastkbd】 切片器只带走容器(`push([...])`)、丢掉容器外字节时,token 门整批红而丢弃没登记,门也没法说"其实等价" (实证:`case-studies/gate-failure-modes.md` §1.15)。 两条纪律:① **切片器的单位是 chunk 文件,不是容器**——容器外字节逐字带走,gen 头写明 prologue/epilogue 字符数;② token 门**不加"对齐到容器"旗标**,红就是红——在门里给生产者开 豁免口,等于让门测自己的捷径(`verification-gates.md` §2.1.2 同族)。 ## 2. 根因修复而非调参糊平 像素差异出现后只有三条合法出路:**修复(追到取证级根因)/ 登记偏差 / 定性为采集条件敏感**。禁止调参数把差异糊平——**"把差异所在的区域扣掉"不是第四条出路**(§1.10),**"事后把容差调宽到能盖住它"也不是**(`verification-gates.md` §1.3.2:容差在跨侧数字产生之前就固化),**"把期望值改成产物读回来的那个值"同样不是**(`verification-gates.md` §0.1:期望值可以让引擎往返一遍,但**往返的输入必须仍然是源站字面量**;输入换成产物读数就是把裁决权交给了被测代码)【objectarchive】。三条"追到取证级根因"的实例见 `case-studies/gate-failure-modes.md` §2。 - 逐字提取的 shader 先离线 `node diff` 证同,差异排查就能聚焦到编译参数/数据链【noomo】。 - 修复分两笔账:"保真修正"与"登记偏差"分开处理【samsy】。 - 残差用数字关账:修复前后逐 band delta 留档【rogier】。 ## 3. 真差异与方法学噪声的归类纪律 量化门(`verification-gates.md` §1.3)必须显式归类噪声源,否则真 bug 淹没在噪声里: - **已知噪声源清单**(对拍报告里逐条列出):虚拟滚动缓动相位造成的构图偏移、动画相位不同的色温差、headless 下授权字体未加载的换行差【oryzo】;视频帧相位、glitch 文字随机相位、粒子随机相位【samsy】;文字块随窗口高度命中相邻组(复检需锁窗口)【noomo】。 - **最差格/最差点逐一目检归因**:每个超差点要么归入已知噪声、要么立案排查【samsy】。 - **正因为归类了噪声,才能在噪声里捞出真 bug**【oryzo】。 - **自动门之外必须保留人工目视/真机兜底**:headless 盲区(授权字体、sRGB 色彩管理)只有真机对比能暴露【oryzo】(实证:`case-studies/gate-failure-modes.md` §3)。 - lando 用**真机三方对拍**(线上/镜像/复刻同机位截图,命名区分 mirror-*/rebuild-*/dist-*,入库 `docs/compare/`)把目视也产物化【lando】。 ### 3.1 残差分类器四件套:不掩蔽、逐格分类、排名、同侧对照【shopifydesign】【objectarchive】 **前置规矩在 §1.10(排除必须配独立证据)。本节讲的是排除的替代品:分类。** 前三个分类器 (A)/(B)/(C) 由 shopifydesign 立起,第四个 (D)(同侧对照,§3.1.2)由 objectarchive 补上——它专治"参照侧的带宽本身可疑"这一格。 #### 3.1.1 ⚠ 分类之前先修仪器,顺序不能反【objectarchive】 **四个分类器回答的都是"这条残差属于哪种已知机制",它们共同默认了一件没写出来的事:这个数是量准的。** 仪器没修就分类,等于把一条**其实不存在的残差**写进偏差表——而**偏差表是长期账本,假条目会一直误导后人**:下一轮读到它的人会把这一格当成"已知噪声",从此对该字段免检,等于给它办了一张永久豁免。**登记一条假残差比漏登记一条真残差更贵。** **⚠ 修仪器之前还有更前面的一问:这个字段有没有等价的派生判定?** 有的话它**根本不该进记录**(§1.11:断言用原始数、记录用派生判定),那不是仪器问题,settle 修到完美也修不掉。**先过 §1.11 那一关,过不了的才轮到修仪器**(实证:`case-studies/gate-failure-modes.md` §3.1.1)。 **该先修仪器的信号(命中任一条,先回 `verification-gates.md` §2.2 把 settle 判据改成页面状态、重测,再谈归因)**: - **差异方向在检查点之间翻转**(这个点复刻侧多、下一个点复刻侧少)——真实缺陷不换方向; - **参照侧自比分布比跨侧还宽**,或自比的最大 |Δ| 大于跨侧最大 |Δ|(**被测侧的同侧对照比跨侧还大同理**,那是同一条信号的另一侧——用法与纪律见 §3.1.2); - **残差随采样方式变化**:多等一会儿、换 settle 判据、换驱动步长,它就变或归零; - **被测量有已知的落地延迟**(动画 / 过渡 / 计时器跑完才写进 DOM)——单次采样量到的是**探针的时刻**,不是页面的状态; - **被测量取决于"恰好渲染了哪几帧"**(IntersectionObserver 只在帧边界求交,滚得快时一张卡可能整个跨过视口而从未被观测为 intersecting)。 **修完仪器仍余的格,才做归因**;当场按分类器归档的话,偏差表里会多出一条其实不存在的残差(实证:`case-studies/gate-failure-modes.md` §3.1.1)。 **⛔ 修仪器之前先量出机制:凭症状猜修法,重跑会告诉你它把问题放大了【objectarchive】** 本节到这里说的是"先修仪器再归因"。**它缺半句:修仪器本身也要先量。** 仪器故障的症状("不确定、两侧都出现")通常兼容好几种机制,而**按其中一种去改,另外几种一个都没修,改动本身还会引入新的时序**。 停下来量,不再猜:**一次探针把三种候选机制砍到一种**,而按症状猜的第一版修法把红的次数翻了一倍多 (实证:`case-studies/gate-failure-modes.md` §3.1.1;完整识别信号与三种补救的实测对比见 `environment-traps.md` §8)。 **纪律三条**: 1. **改仪器之前,用一个能区分候选机制的探针取一次证据。** 判据是"这个读数在**成功那几次**和**失败那几次**之间有没有差别"——没差别就说明你的假设与失败无关,别改。 2. **探针要落在被怀疑的那个时刻**,不是事后——因为要查的正是"块跑的那一刻它是多少"。 3. **仪器改动同样要重跑整组会话验收**,而且看的是**红的次数**而不是"这次绿了":只跑一次而恰好绿,会被当成修好了写进日志。 **⭐ 修仪器之前还要知道"该记什么":熵源清单是输入侧账,还得有一张输出侧账【objectarchive】** 本节到这里讲的都是"量得准不准"(采样时刻)。还有一个更前面的问题:**仪器记的字段够不够**——量得再准,没被任何字段看着的那个面,差异只会以"某处不像"的形式落到别的字段头上。 - **熵源清单(`determinism.md` §1)是输入侧的账**:它回答"**什么输入会变**"(时钟、随机、存储、能力探测……)。它**不能**回答另一个问题:**这一帧上到底有哪些面在上色。** - **"给每个领先假设各配一条判据"不是兜底**——判别位可能根本不在你的假设清单里。兜底的是一张**输出侧账**:把这一帧**所有会上色的面逐类列出来**,每个面都要有指纹。 - **建账方法(逐类列,再逐类问三个问题)**: | 上色面 | 谁产生它 | 指纹要回答的三问 | |---|---|---| | DOM 文本 / 背景 / 边框 | 布局 + CSS | 画在哪(矩形、不取整的 `scrollY`)、什么颜色(关键节点的计算值)、内容是什么 | | `<img>` 的解码结果 | CDN + `srcset` / `?width=` 变体 | 哪一张(`currentSrc`)、多大(`naturalWidth/Height`)、放在哪(3 位小数矩形) | | **`<canvas>` 位图** | 页面自己画 | 画的是哪张源图、**位图**多大(`canvas.width/height`,**与 CSS 显示尺寸是两个数**)、画出来是什么(逐像素通道的 FNV/sha 摘要) | | `<video>` 帧 | 媒体时钟 | 哪个源、`currentTime` 相位、矩形 | | SVG / CSS 生成内容 / 伪元素 | 样式引擎 | 存不存在、几何、内容字符串 | | 滤镜与合成层 | 合成器 | 正在插值的 `opacity` / `filter` 计算值(同时兼作 settle 签名,`verification-gates.md` §2.2) | **判据一句话:每一个上色面至少要有一个字段能在两侧不一致时变红。** 一个面在指纹里挂零个字段 = 它在这道门下完全隐身。 - **缺一个面会怎样**:领先假设可以各自有判据、各自被证伪,而真正的差异所在**从头到尾没有任何字段在看它**;在记录补齐之前,那几条残差的每一种"处置"都是错的——扣区域(§1.10)、事后调宽带宽(`verification-gates.md` §1.3.2)、写成"在带内"(§3.1 (C))各自都能让报告变绿。**在记录补齐之前,所有归因都是在猜。**(实证:`case-studies/gate-failure-modes.md` §3.1.1) - **写在哪**:这张表属于逆向笔记(项目侧范本:objectandarchive `docs/engine-notes.md` §10.2「指纹要盖住这一帧上所有会上色的表面」,与 §10 熵源清单并列——**输入侧一张、输出侧一张,两张都要有**),门脚本的 `FINGERPRINT` 逐字段对着它写。 **规矩要落到工具上,否则每轮都靠自觉。** 门在跑的时候必须先记下三样东西,事后才做得了归因:① **每帧每个不可对齐的活体元素的视口矩形 + 相位读数**(本例是全部 `<video>` 的 rect 与 `currentTime`);② **全部超阈格的坐标与差值**,而不是只落一个标量指标;③ **上面那张输出侧账上每个上色面的指纹**。有了这三样,四个分类器成本递增、锋利度也递增: | 分类器 | 判定动作 | 成本 | |---|---|---| | **(A) 它落在活体元素的矩形里吗** | 逐格与矩形求交 | 最低 | | **(B) 参照侧自比时这一格也超阈吗** | 把自比带宽(`verification-gates.md` §1.3.2)从**标量**降到**格** | 中(要自比会话的逐格产物) | | **(C) ⭐ 跨侧数字在参照侧自比分布里排第几** | 排序;粒度是**检查点级**(不逐格),它是给 (A)/(B) 之外那批未归类格撑腰的独立证据 | 最低(自比样本已经有了) | | **(D) ⭐ 同侧对照:让被测侧自己跟自己跑一次**(§3.1.2)【objectarchive】 | 把 (C) 的角色调过来:在**被测侧**重跑同一会话,比较 `rebuild↔rebuild` 与 `mirror↔rebuild` 在同一检查点上的大小 | 中(要新跑一次被测侧会话,只跑相关档) | (四列的逐项实测见 `case-studies/gate-failure-modes.md` §3.1.1) **(C) 最锋利,而且报告里必须写排名,不许只写"在带内"**: - "在带内"只说明**没超过你自己设的那个阈值**;"5 次自比里 5 次更差"说明的是**被测差异比噪声还小**——这是两种强度完全不同的陈述。 - 它把"这是噪声"从一句归因变成一个**可证伪的排序命题**:如果移植真的错了,跨侧数字应当**稳定地排在自比分布之外**,而不是排在中位数附近。 **未归类的格不是失败,是必须逐个看的清单**——逐格打印坐标,一格不扣(实证:`case-studies/gate-failure-modes.md` §3.1.1)。**撑住这条归因的是 (C) 的排名,不是阈值。** **入带的两条前置条件不在本节**:自比样本数(≥3–4 次独立会话、逐次的值都要写进报告)见 `verification-gates.md` §1.3.2;会话可比性指纹(指纹不同直接丢弃重采,不许平均进带宽)见 `verification-gates.md` §2.2。**带宽本身没建对的时候,逐格分类器只是把错误的容差算得更精细。**而在这两条之前还有一条——**仪器要先修**(§3.1.1):量不准的时候,带宽与分类器都只是在给一个假数字做精细归因。 #### 3.1.2 ⭐ 第四分类器:同侧对照——参照侧的带宽可能系统性地更幸运【objectarchive】 **(A)/(B)/(C) 三个分类器共同默认了另一件没写出来的事:参照侧的自比分布代表了这道门的噪声水平。** 参照侧可能**系统性地更幸运**——站上但凡有一条**两侧都存在的源站竞速**,参照侧那几次会话就可能恰好都掷出同一面,于是它的自比带窄、排名分布也窄,而跨侧那一次落在带外。此时 (B)/(C) 会一致地把这条残差报成"不属于噪声",**而它既不属于镜像也不属于复刻,属于这一次运行**。只看参照侧的自比分布判不出这件事。 **定义**:**在被测侧自己跑两次、比一次跨侧**——同一把尺子、同一套检查点、同一驱动,让复刻侧再跑一次会话,得到 `rebuild↔rebuild`,与 `mirror↔rebuild` 在**同一检查点**上比大小。同侧 ≥ 跨侧,即这条残差在同一侧内部同样出现(甚至更大),判定为**属于这一次运行,不属于哪一侧**(这正是 §1.11 的第三条特征在像素层的形态)。 **什么时候必须用(三条同时成立)**: 1. **跨侧数字落在参照侧带宽之外**,或 (C) 的排名是 0/N(比全部参照侧自比都差)——即 (B)/(C) 判它"不是噪声"; 2. **逐字段核对两侧指纹无一不同**(`docHeight`、不取整的 `scrollY`、地标矩形、图片身份、画布位图尺寸与内容摘要、块自己的谓词重算值……)——**指纹有差就不是本节的事,回 §3.1.1 修仪器或直接归因到那个字段**; 3. **差异的形态指向一条已登记的、两侧都存在的源站竞速**(目视差分面板:差异严格落在某类元素的矩形内,页面其余部分全黑)。 **判定顺序(接在 §1.11 → §3.1.1 → (A)(B)(C) 之后)**:① 先问字段该不该进记录(§1.11);② 再修仪器、重测(§3.1.1);③ 再跑 (A) 活体矩形求交、(B) 逐格自比、(C) 参照侧排名;④ **(A)/(B)/(C) 都判"不是噪声",而指纹逐字段无差时,才跑 (D)**。(D) 排在最后不是因为它弱,是因为它**要新跑一次被测侧会话**,而前三个用的都是已有产物。 **⛔ 纪律:同侧对照是用来归因的,不是用来放宽容差的。** - 判为"属于这一次运行"之后,**当轮容差一个字没动**(§1.3.2 纪律 2:容差先于跨侧数字固化,事后不许调宽);带宽的方法学缺口(只建在参照侧)写成下一轮开工第一件事——**改协议要发生在新数字之前**(§1.3.2 纪律 5)(实证:`case-studies/gate-failure-modes.md` §3.1.2)。 - **⚠ 读法:判定靠的是"同一条机制 + 整组的分布",不是每一条不等式都成立。** **同侧明显小于跨侧的那一条不许被组内其它条目带过去**——它要么单独拿到自己的机制归因,要么按"未归类"逐格列出(§3.1 末段)。**把一组数字当成一个结论用,正是 §1.10 那类事故的开头。** - **它不是"扣掉某区域"的替代品**(§1.10):(D) 给的是**正面证据**——同一块区域的差异在被测侧自己身上同样出现;它不允许把该区域从画面里挖出去。判成"属于这一次运行"之后,这五条残差仍然**逐条留在报告里、逐格打印坐标、写出同侧与跨侧两个数**,只是归了类。**"归类"与"不看了"之间的距离,就是这几行数字。** - **它也不是带宽**:一对同侧会话回答的是一个**方向性不等式**(同侧 ≥ 跨侧),不产生容差;**要拿去当容差的量仍然是 ≥4 次**(§1.3.2 纪律 1"2 次不构成带宽"对 (D) 不适用,因为 (D) 的产物不进容差)。 - **归因要落到机制上,不能停在不等式**:本例的机制是一条已登记的源站竞速(画布位图尺寸由"图片解码完成那一刻的布局"决定,两侧都有),(D) 只是证明了"这一次运行掷出了不同的面"。**给不出机制时,(D) 的结论只能写成"未归类、逐格列出",不许写成"噪声"。** -
legal-and-deploy.md 49.2 KB
# 版权取证与部署决断(取证归 skill,决定归用户) > **何时加载本文件**:两个时点必须加载——M0 镜像阶段做版权**取证**时;收官阶段写 DEPLOY.md、把"是否公开部署"这个问题**呈交给用户**时。 > ⚠ **本文件写的是取证流程与呈现格式,不是法律意见,也不是让你替用户下判断的授权。** 它告诉你**该查什么、按什么顺序查、查不清时怎么记、怎么把事实与选项交到用户手里**;它不替你、更不替用户得出任何法域下的法律结论。凡出现"期限""公共领域""授权"字样处,法域规则各不相同且会变;**涉及真实对外发布、商业用途或已被权利人联系的情形,去咨询专业意见**。本文件的所有默认值都朝"更保守"一侧取。 ## 0. 三条框架原则(先读这段,它管住本文件其余全部内容) ### 0.1 原则一:法务判断属于用户,skill 只取证、呈现、建议 | 角色 | 做什么 | ⛔ 不做什么 | |---|---|---| | **skill / 执行的 agent** | ① **收集事实**:逐资产归属、许可状态、第三方权利人、源站是否仍在营业、产物内第三方标识符、平台规则原文;② **列出选项与各自的风险边界**;③ **给出建议与理由**(说明它是建议);④ 在用户决定之前**执行安全默认** | 不作法律结论;不代替用户决定"能不能公开 / 部署 / 再分发 / 对外展示";**不自行定论后继续往下走**;不把自己的建议写成既成事实 | | **用户** | 作决定 | — | **交回机制(强制)**:凡涉及"能不能公开 / 部署 / 再分发 / 对外展示",必须用 SKILL.md「User Input Tools」那套机制**显式提问**(有 `AskUserQuestion` 类工具就用,没有就输出编号问题清单等回复),**拿到用户的明确决定再往下走**。多个问题合并成一次问完,别挤牙膏。 **提问的五段式**(缺一段用户就没法决定): 1. **事实**:查到了什么,逐条带证据出处(镜像路径 / 文件内 banner 原文 / 页面字段); 2. **查不清的**:哪些是 `[未确认]`,为什么查不清; 3. **选项**:通常是「保持私有」/「公开但做去混淆改造」/「公开原样」这类,逐项写清**它意味着什么**; 4. **每个选项的风险边界**:谁的权利、哪一层、最坏情形是什么——**描述风险,不宣布违法与否**; 5. **建议 + 理由 + 当前默认动作**:明说"在你决定之前,我按默认动作执行"。 **安全默认(在用户明确决定之前一律如此)**:**私有仓库 + `noindex` + 不公开部署 + 不再分发**。 ⭐ **必须把它写成"默认动作",不能写成"法务结论"**——两者的责任归属完全不同。措辞对照: | ✅ 这样写(默认动作) | ⛔ 不要这样写(agent 的法务结论) | |---|---| | "在你决定之前我不会把它发出去。" | "这个不能公开,有版权问题。" | | "查到 N 类资产的权利人不是站方(证据如下);在你就此作出决定前,产出保持私有 + noindex。" | "经评估存在侵权风险,故判定不公开部署。" | | "第 41 位艺术家卒于 1970,在'卒年 + 70'法域下 2041 年届满——这条事实交给你判断。" | "作品仍在版权期内,因此不能用。" | | "以下三条各自独立地指向'不宜公开',我的建议是保持私有;要不要公开由你定。" | "结论:禁止公开部署。" | **agent 侧的硬规则只有一条方向性**:**agent 只能往保守侧执行默认,往公开侧走必须有用户的明确决定。** 用户要求公开时,先把 §3 的风险边界原样呈上、必要时建议咨询专业意见,然后照用户的决定执行——不许 agent 自行放行,也不许 agent 用"法务"名义否决用户。 ### 0.2 原则二(防火墙):法务考量**永不削减**镜像完整性与门的覆盖面 ⛔ **硬规则**:**镜像是证据基座,完整性是技术不变量。** 四遍法、闭包门、GAP=0 全都建立在它之上。**任何法务理由都不得成为"少抓一个文件"或"少断言一格"的依据。** 三条理由: 1. **一份永不公开的私有镜像,多抓少抓法律地位不变**——少抓不会让处境更安全,只会让证据更差; 2. **不完整的镜像让复刻无法被验证**,反而更糟:参照侧残缺时,所有对拍门都在拿错的基准打分; 3. ⭐ **一旦允许法务理由挖洞,闭包门就变成可协商的**,且**没人能再区分"法务豁免"与"技术失败"**——两者在账本上长得一模一样。【objectarchive】(实证:`case-studies/legal-and-deploy.md` §0.2——少抓约 60% 资产、五道门全绿、藏了四个里程碑) **作用域划分(写成硬规则)**: | 法务决定**作用于** | 法务决定**不作用于** | |---|---| | 产出**怎么被使用**:是否公开、是否部署、是否再分发、是否对外展示 | 镜像抓不抓、抓多全 | | 哪些字节进 git、哪些进运行资产 / `dist/` | 闭包门差集是否必须为 ∅ | | 产物里保留还是剥离第三方标识符 | GAP 是否必须为 0、账本是否必须对得上 | | 公开形态下要不要去混淆改造 | 门的断言面覆盖多少格、门报不报真话 | **配套:`external.txt` / 不抓清单的豁免只能用于技术性事实**,逐条写明属于哪一类: - `NOTFILE`:不是文件(结构化数据标识符、命名空间 URI、出站锚点); - `NOTFETCHED`:服务端不提供(404/410)、需要授权或登录态才能访问(本 skill 适用范围之外,见 SKILL.md)、付费墙; - `DISALLOWED`:源站 `robots.txt` / ToS / API 条款**明令禁止抓取**——这是遵守源站规则的既定边界(SKILL.md「使用前提与授权」),不是 agent 的法务判断;缺口照样登记,门照样报真话。⛔ **标这一类之前先过 §0.3**:它只覆盖**判定命中的那些路径**,且只能来自**针对抓取的**禁令(§0.3.3 A 类)——把交易类禁令或"拿不准"记成 `DISALLOWED`,就是用法务理由挖洞,本条禁止。 ⛔ **不得用于**:"出于版权考虑我们选择不抓"、"反正不公开所以不抓"、"这类资产不该多存一份"。**这些是法务理由,法务理由不进不抓清单。** ### 0.3 原则三:站点策略文件**逐路径判定**,"读不懂"不等于"禁止" ⛔ **硬规则**:`robots.txt` / `agents.md` / `.well-known/*` 是**逐路径的许可声明,不是全站开关**。读它们是**取证动作**——产出是"哪些 URL 不抓"加"原文摘录",**不是**"这站能不能做"的结论。**过度解读会让 skill 在绝大多数目标上拒绝开工**:几乎每个商业站的 robots 都带 `/cart`、`/checkout`、`/admin`、`/search` 的 `Disallow` 行,把任一 `Disallow` 读成"整站禁止"等于自废武功。 #### 0.3.1 Step 0 顺手取一份,留证 侦察时一次取全,存进证据区(镜像内对应路径 + 在 `REBUILD_PLAN.md` 记 URL / 取回时间 / sha256)。**这些文件会变,你遵守的是取回当时的那一份,要能证明。** | 取什么 | 是什么 | 通常谈什么 | |---|---|---| | `/robots.txt` | 逐路径爬取许可(RFC 9309 语义) | 路径粒度的 Allow/Disallow | | `/agents.md`、`/llms.txt`、`/.well-known/ai.txt` | 新兴 agent 约定,**无 RFC 级匹配语义**,自然语言 | 交易、API、账号 | | `/.well-known/*`(UCP 等) | 机器可读能力声明 | 端点、版本、支付处理器 | | ToS / `/policies/terms-of-service` | 服务条款 | 再分发、复制、账号 | | `Sitemap:` 行指向的 sitemap | 顺手取——它同时是 M0 的 URL 种子 | — | ⛔ 三条取回纪律: 1. **robots 按 scheme+host+port 生效**,一个 host 一份。CDN host(`cdn.shopify.com`、bunny、S3 桶…)**各有各的 robots.txt**,抓它们之前单独取——用源站的 robots 去判 CDN 的 URL 是错的。⛔ **取 CDN robots 是为了逐路径判定,不是为了找借口不抓资产**【objectarchive】。(实证:`case-studies/legal-and-deploy.md` §0.3.1) 2. **404 / 文件不存在 = 没有该声明,不是禁止。** 绝大多数站没有 `agents.md`,这不构成任何限制。 3. **持续 5xx / 取不到 ≠ 站方禁令**,属技术性不可达:重试,仍不可达就登记并按 0.3.5 呈交用户(说明依据是"取不到"而非"站方明令")。 #### 0.3.2 `robots.txt` 判定步骤(逐 URL 执行,五步) 1. **选组**:找与你的 UA token 最匹配的 `User-agent` 分组;**只有在没有任何具名组匹配时才用 `*` 组**。命中具名组后,`*` 组整组作废——**两组不叠加**。 2. **只看该组内的 `Allow` / `Disallow` 路径规则**(`#` 后是注释,**不参与路径匹配**)。 3. **逐 URL 匹配**:拿目标 URL 的 **path + query** 与规则做前缀匹配;`*` 通配任意串,`$` 锚定结尾。 4. **冲突取最长匹配**(按规则里路径写法的字符长度),长度相同 → **`Allow` 胜**。⭐ **书写顺序不决定结果,长度决定。** 5. **没有任何规则匹配 → 允许。** 空 `Disallow:` = 允许全部;**只有 `Disallow: /` 才是整站禁止。** ⛔ 三种必须避免的读法:**①** "存在 `Disallow` 行 → 整站禁止";**②** "robots 里出现 checkout / payment 字样 → 这站禁止自动化";**③** 拿注释当规则。注释不参与路径判定,但注释里的明令要按 0.3.3 归类、原文摘录登记——它可能是站方意图的证据,**证据走呈交,不走停工**。 ⭐ **逐字符读,别脑补**:注释头说 "cart … HTML is crawlable",规则却写 `Disallow: /cart/`——两者不矛盾,因为规则带尾斜杠,**只覆盖 `/cart/…`,不覆盖 `/cart` 本身**。碰到"注释与规则看似打架"时,按 0.3.2 以**规则的字面匹配**为准,注释另按 0.3.3 归类登记。(实证:`case-studies/legal-and-deploy.md` §0.3.2——把目标站的 robots 逐条读完,净效果是"明确允许抓取")【objectarchive】 #### 0.3.3 按**行为类别**归类,不按情绪 ⛔ 归类先于反应。先把每一条禁令落到下表某一格,**再**决定动作;**没落格就行动**(尤其把 B 类当成 A 类)是自我瘫痪的主要来源。 | 类别 | 典型措辞 | 影响什么 | 处置 | |---|---|---|---| | **A 抓取与镜像** | 适用分组的 `Disallow` 覆盖目标路径;"do not crawl / scrape / mirror";ToS 明禁**抓取或自动化访问**(⛔ 通用版权样板"不得复制转载"**不算 A,算 D**,见 0.3.6 第 2 条) | ⭐ **唯一影响 M0 抓取范围的一类** | 命中的**那些路径**按 `DISALLOWED` 登记不抓(§0.2),其余照抓;若覆盖复刻目标本体 → 0.3.6 停工门 | | **B 自动交易与付款** | "Do NOT complete checkout, payment, or order placement";"payment requires buyer approval" | 影响的是"**别去点结账**",**与镜像无关** | 记一条纪律:镜像与验证全程**不提交表单、不下单、不调交易 API**。**不缩镜像一个字节** | | **C 账号与登录态** | "login required";账号区 `Disallow` | 本 skill 本来就不碰(SKILL.md 既定边界) | 按 `NOTFETCHED` / `DISALLOWED` 登记,照常推进 | | **D 再分发与公开** | 版权声明、ToS 的再分发条款 | 影响**产出怎么被使用** | 走 §0.1 呈交用户;**不作用于镜像**(§0.2) | #### 0.3.4 agent 策略文件(`agents.md` / `.well-known` / `llms.txt`)怎么读 读法与 0.3.3 完全相同——**按 A/B/C/D 归类**。但它们**没有 RFC 级匹配语义、通篇自然语言,是最容易被过度解读的一类**,所以额外记两条实测: - ⭐ **它们通常谈交易与 API,不构成对学习性抓取的禁令**。【objectarchive】(实证:`case-studies/legal-and-deploy.md` §0.3.4) - ⛔ **"推荐"不是"禁令"**。把 should / prefer / recommend 读成"必须改用 API、不许抓 HTML"是自我瘫痪。 若某站的 agent 策略文件**确实**写了针对抓取或复制的明令(A 类),那就是 SKILL.md 的既定边界 → 走 0.3.6。 #### 0.3.4.1 Content-Signal(Cloudflare 托管 robots)怎么读【overworldaudio】 2025 年起 Cloudflare 给大量站点批量下发一段托管 robots:`User-agent: *` 组带 `Content-Signal: search=..., ai-train=..., use=...` 声明行,另对具名 AI 爬虫 (GPTBot / ClaudeBot / CCBot / Bytespider …)逐个 `Disallow: /`。读法三条: - **匹配语义不变**:Content-Signal 行不是 Disallow——通配组的 Allow/Disallow 照 0.3.2 五步判定(实测形态常是 `* → Allow: /`)。具名 UA 的 Disallow 只约束**以那些 UA 自我 标识的爬虫**;本 skill 的抓取(浏览器 UA、用户指令、低频单会话)不在其列。 - **信号按用途归类**:`ai-train=no` 针对**训练/微调**用途;`search` 针对建索引; `use=reference` 等值针对 AI 系统消费方式。逐条对照本次行为(用户指令的单次研究性 镜像)归类,**不适用 ≠ 禁止,适用 ≠ 自动停工**——见下一条。 - ⛔ **但意图要如实呈交,不许翻译成"没写禁止"**:这套配置作为整体,表达的是站方 **限制 AI 系统采集其内容**的明确意图(文件常自引 EU DSM 指令第 4 条的权利保留)。 这不落入 0.3.6 的四条停工门槛,也不该被 agent 悄悄消化——**按 0.1 五段式呈交**: 字面判定(通配组允许)与表达意图(限制 AI 采集)两者并列写明,张力交用户裁决; 用户选择继续时,全文与裁决登记进 verdict/DEPLOY,且该登记**加重"不公开"默认的分量**。 #### 0.3.5 ⛔ 反自我瘫痪条款:**"读不懂 / 拿不准" ≠ "禁止"** 策略文件语义不清、措辞含混、范围不明,或你只是"感觉不妥"时,正确动作是三步: 1. **继续按既定边界工作**:低频、单会话、只取匿名可公开访问的资源、不碰登录态与付费墙、不触发任何交易; 2. **登记 + 呈交**:把原文摘录(带 URL 与取回时间)、你的疑问、以及它可能落在 A/B/C/D 哪一格,写进待决清单,按 §0.1 五段式交给用户; 3. ⛔ **不是停工,也不是自行缩小抓取范围。** | 触发 | ⛔ 自我瘫痪 | ✅ 正确动作 | |---|---|---| | robots 有 `Disallow: /checkout` | "这站禁止自动化,停工" | 只把 `/checkout*` 排除,其余照抓 | | 注释写 "Do NOT complete checkout, payment…" | "禁止自动访问本站" | B 类:不提交表单、不下单;**镜像范围不动** | | `agents.md` 推荐用 MCP/UCP 端点 | "不许抓 HTML,改用 API" | 推荐≠禁令,且 API 取不到复刻所需字节;照抓,原文登记 | | ToS 里有一句看不懂的条款 | 自行缩小范围或停工 | 摘录 + 呈交用户;等待期间跑安全默认 | | 拿不准某类资产能不能抓 | "保险起见不抓" | ⛔ **直接违反 §0.2**;抓全,法务决定作用于产出怎么被使用 | ⭐ **"保险起见少抓一点"不是保险**,是把不确定性从法务面转嫁到技术面【objectarchive】。(实证:`case-studies/legal-and-deploy.md` §0.3.5) #### 0.3.6 停工门槛(写死,只有这四条) 只有下列**明确针对抓取/复制**的情形触发 SKILL.md「若目标站明确禁止此类复制,停止并告知用户」: 1. **按 0.3.2 判定**,适用分组的 `Disallow` 规则**覆盖了复刻目标路径本体**(如目标是站点主要页面,而适用组是 `Disallow: /`); 2. **ToS 明确禁止抓取 / 爬取 / 镜像 / 自动化访问**(A 类措辞)。⛔ **不含通用版权样板**:"保留所有权利"、"未经许可不得复制或转载本站内容"这类条款**几乎每个商业站都有**,它谈的是**再分发与使用**(D 类)→ 登记 + 按 §0.1 呈交 + 安全默认(私有 / noindex / 不部署 / 不再分发),**不是停工理由**。把 D 类样板当停工门,等于对整个互联网停工。 3. **站方针对性的 opt-out 声明**(`noai` / `noimageai` 一类明确面向 AI 采集的声明、agent 策略文件里的 A 类明令)。⛔ **`noindex` / `nofollow` 不算**:那是**索引**指令不是抓取禁令,且本 skill 的产出本来就默认 noindex。 4. 抓取需要**绕过访问控制**(登录态、付费墙、反爬规避)——本 skill 适用范围之外。 ⛔ **其余一切不确定性走呈交,不走停工。** 停工是**呈交的一个特例**(呈交时附一句"我已停止"),不是遇到疑问时的默认动作。 两条配套: - **停工同样是事实呈报,不是 agent 的法律结论**:把判定依据的**原文与命中的那条规则**一并给用户,用户有权提供授权证据或另行决定。措辞照 §0.1 的对照表——写"依据是 X,我已停下等你确认",不写"该站禁止复刻,故终止"。 - **目标站是用户自有或已获授权时,停工门不适用**(SKILL.md 适用对象第一、二类):robots 是站方面向第三方爬虫的声明。把用户的这一声明登记在案(谁、何时、以什么形式),继续推进。 ## 1. 时点一:M0 就做版权**取证**(不是收官才想,也不是 M0 就下结论) **M0 要产出的是事实条目,不是决策。** 版权取证放在 M0 的理由是**取证最耗时**(尤其 §2.4 的逐人查证),不是"技术方案要等它定形"——**技术方案不等它**:镜像照四遍法抓全,门照常全开(§0.2)。 **指令**:M0 里程碑日志里必须出现版权取证条目;把"素材版权"写进难点/风险表并评级【oryzo】【kimi】【lando】。评级表里那一行写的是**风险量级与待决问题**,不是"已决定不公开"。 **M0 取证至少要回答三问**(答案是事实,处置分两类:技术侧照常,法务侧留给用户): 1. **这站是不是"卖/展示别人作品"的站**?→ 见 §2.2 的识别判据。是 → 逐资产表必须带「第三方权利人」列,且 §2.4 的逐人取证在 M0 就排上日程(它耗时最长)。**这是取证工作量的决定,不是法务决定。** 2. **源站是否仍在营业、是否有交易功能**?→ 见 §3.2。是 → 它是呈交用户的**核心事实之一**,且从 M0 起按安全默认执行(不对外可访问)。⚠ **它不改变镜像范围**:源站在不在营业与我们抓不抓全,是两件毫无关系的事。 3. **有没有平台/robots 层面的明令**(`robots.txt` 的 `Disallow`、ToS、API 条款)?**⛔ 按 §0.3 逐路径判定后再回答这一问**——答案是"哪些 URL 命中了针对抓取的禁令",不是"这站禁不禁止"。→ 有 → 从 M0 起遵守(SKILL.md 的既定边界),相应的服务层 stub 与偏差登记从第一天开始,不是收官时补。**"遵守源站规则优先于保真"是开工时就写进偏差表的取舍**【objectarchive】。这类缺口按 `DISALLOWED` 登记,**门照报**。(实证:`case-studies/legal-and-deploy.md` §1) ## 2. 逐资产归属/许可表(取证产物) 收官前产出逐资产的归属/许可/可再分发性表格(noomo `DEPLOY.md` §3 与 objectarchive `DEPLOY.md` §2 是范本)【noomo】【objectarchive】。**这张表是给用户看的证据,不是 agent 的判决书**:它的每一格填的是**查到了什么**,"可再分发?"一列填的是**取证结论与不确定度**,最终"要不要公开"由用户在 §3 拍板。 ### 2.1 表结构(**八列,比早期模板多「第三方权利人」与「数量」两列**) | # | 资产类 | 数量(文件 / 字节) | 归属方 | **第三方权利人** | 许可状态 | 可再分发? | 建议处置 | |---|---|---|---|---|---|---|---| | A1 | (逐类填写:艺术品复制图 / 模型 / 音乐 / 视频 / 字体 / 品牌标识 / 人物肖像 / 商标 / 主题代码 / 平台运行时 / 第三方 App 资产 / vendor 库 / 站点自研代码 / 文案…) | 由**磁盘普查**得出,见 §2.5 | 站方或其供应商 | 具名作者 / 遗产 / 摄影师 / 肖像权人 / 字体厂商 / 平台方 / 上游库作者;确无则 `—`,查不清则 `[未确认]` | 以**产物内证据**为准,见 §2.3 | 是 / 否 / `[未确认]`→按否 | 不入运行资产 / 保留原引用 / 仅私有预览 / 建议不公开 | ⚠ 末列写的是**建议**("建议不公开"),不是 agent 已作出的裁定;**镜像侧无论哪一行都照抓不误**(§0.2)。 ⭐ **为什么必须有「第三方权利人」这一列**【objectarchive】:早期模板默认**资产归站方所有**,站方是唯一权利人,于是"查清站方态度"就等于查清了一切。**对一整类站这个默认是错的**——站上的图片是**别人作品的复制品**,图像上叠着**两层各自独立的权利**: | 层 | 权利物 | 典型权利人 | 期限如何起算 | |---|---|---|---| | **① 底层作品** | 被拍摄/被扫描的那件东西:画、书、唱片封套、海报、雕塑、旧照片 | **具名作者及其遗产**(与站方无关) | 通常按**作者卒年**(法域规则各异,见 §2.4) | | **② 复制件** | "拍摄/扫描这一动作"的产物:那张 JPG 本身 | 站方,或其供应商 / 受托摄影师 | 按复制件自身产生的时间与法域规则;**部分法域承认独立权利,部分不承认** | | ③ 呈现层(附带) | 画框叠加图、房间场景图、合成成品图、策展式文案与作品描述 | 站方(自己的新作品) | **与作者卒年完全无关**,是彻头彻尾的在版作品 | **两个独立的错误由此产生,必须同时防**:① 只看站方 → 漏掉①;② 只看作者卒年 → 漏掉②和③。**③ 尤其容易被"这些画都是老画"的印象盖住**【objectarchive】。(实证:`case-studies/legal-and-deploy.md` §2.1) ### 2.2 站型识别:怎么知道自己面对的就是这类站 **在 M0 就判**。命中**任意两条**即按"第三方权利人站型"处理(即启用第三方权利人列 + §2.4 逐人取证): - **商品词汇**:`print` / `reproduction` / `edition` / `scan` / `facsimile` / `poster` / `after <人名>` / `attributed to` / `vintage` / `pre-owned` / `secondhand` / `stock photo` / `royalty-free`; - **商品字段指向非站方的具名主体**:Shopify 的 `vendor` 字段、`author` / `artist` / `photographer` / `designer` / `label` / `publisher` 字段、JSON-LD 的 `creator` / `brand` / `author`; - **图片旁有 credit 行**(`© …` / `Courtesy of …` / `Photo: …`)或站上存在 `licensing` / `rights` / `credits` 页面; - **站上出现的人名数量远超站方规模**——一家几个人的店,商品页上有几十上百个具名主体; - **商品标题带年份或作品名**(`Untitled, 1953`),即典型的艺术史/目录学写法; - 站方自我描述为 gallery / museum shop / archive / library / stock / marketplace / reseller / consignment。 **同族站型清单**(命中即适用本节全部规则):图库/素材站、美术馆与博物馆商店、艺术品与印刷品店、二手书店 / 唱片店 / 古着店、转售与寄售平台、字体商店与模板/主题市场(转售别人的作品)、出版社与画廊的电商前台。 **反向判据(不属于此类)**:站上所有可版权物都由站方自己产生(自研产品、自摄影、自写文案),第三方权利人只剩字体 / vendor 库 / 平台代码这几类"工具性"资产。**注意即便如此,那几类也仍然是第三方权利人**,只是不叠在商品图像上。 ### 2.3 填表规则 - **逐资产**而非笼统一句"版权归原站"——每类资产单独一行,归属没查清的标 `[未确认]` 并**在建议里按不可分发处理**(是"建议按不可分发",不是"裁定不可分发"); - **覆盖面别只想着媒体文件**:字体授权(商用网页字体不可自托管【oryzo】)、人物肖像与商标【lando】、品牌标识【noomo】、**站方文案**(策展式描述、艺术史介绍是可版权的文字作品)【objectarchive】都是独立风险项; - ⭐ **许可状态以「产物内证据」为准,不以「上游仓库是什么许可」为准**【objectarchive】。① 主题代码——对主题资产文件做 `MIT` / `Copyright` / `@license` / `<平台公司名>` 定值扫描**零命中**,产物里没有任何许可头注 → `[未确认]`;② vendor 库——**不因为"是 vendor 就默认自由"**。**指令:逐库打开文件头读 banner,把 banner 原文抄进表里**——抄原文,不写你对它的解读。(实证:`case-studies/legal-and-deploy.md` §2.3) - **文件名/路径本身就是证据**:文件名里带 `Unlicensed` 字样【objectarchive】。这类信号的读法是——**源站自己有没有授权不改变我们的处境**:把同一份二进制自托管到另一个 origin 是一次**独立的、我们自己的**使用行为。商用网页字体按域名/流量计价,复刻站的自托管从来不在源站的授权范围内。⚠ **这条影响的是"产物/运行资产里放什么",不是"镜像抓不抓"**(§0.2);(实证:`case-studies/legal-and-deploy.md` §2.3) - 处置一栏与 `asset-management.md` §6 的选型联动:授权字体"保留原引用、不进运行资产"【oryzo】【samsy】、镜像副本"仅供逆向复核、不再分发"【kimi】、平台运行时与第三方 App 资产"整域 stub、不加载"【objectarchive】; - ⭐ **逐类计数必须与门/账本对账,对不上就停**——见 §2.5。 ### 2.4 ⭐ 公共领域取证:**逐位具名作者做,不能按站做**【objectarchive】 **"这些作品反正都过期了"是按站得出的结论,而按站得出的结论一律无效。** **把决断建立在"刚好过期"上,等于把项目的法务风险押在日期算术与法域选择上**【objectarchive】。(实证:`case-studies/legal-and-deploy.md` §2.4——41 位具名艺术家,"都过期了"在第 41 个名字上被证伪) **取证流程(逐步执行;任一步查不清即标 `[未确认]`,并在呈交时明说)**: 1. **枚举具名作者——从镜像取证,不从记忆、不从印象**。取证字段:商品 `vendor` 字段、artist/author/credit 标记、`alt` 文本、JSON-LD `creator`、页面上的策展文案。**记下这次枚举覆盖了哪些路由/文档**。 2. **逐人一行查卒年**(或首次发表年,视法域),**来源标注到人**。一人一行,不允许"其余若干位均已届满"这种合并写法——合并写法正是"按站"的变体。 3. **逐人按目标法域算期限,且不要只按一个法域算**。提示(不是结论,各法域规则以当地为准):常见规则有"卒年 + N 年"(N 各国不同)、"发表/注册 + N 年",另有战时延长、未发表作品的单独期限、匿名/法人/集体作品的单独期限。**法域选择本身就是一个变量**——产物一旦可公开访问,你不再能选择只面对一个法域。 4. **只要有一位仍在版,这件事就必须显式呈交用户**(§3.4),并在此期间保持安全默认。**不要用"算出来还在版"去替用户关掉公开这个选项**——把这一行原样端上去,它自己会说话。 5. ⭐ **分层:底层作品进入公共领域,不自动让某一张扫描/翻拍进入公共领域**。复制照片/扫描件本身可能另有权利主张,**各法域态度不同**(有的认为忠实复制无独创性因而无新权利,有的承认某种独立或邻接权利)。**取证不能跨层继承**:①层过期只解决①层。查不清 → `[未确认]`。 6. **站方自己的当代摄影与合成图(表 §2.1 的③层)不进本流程**——它们与作者卒年无关,直接按在版记录。 7. ⭐ **取证必须显式声明覆盖范围**:在 DEPLOY.md 里原样写出——「**本取证只覆盖已查证的 N 个资产 / M 条路由;未取证部分不作任何断言**」。**只要还有未取证的目录,"全部已过期"就不能作为事实使用。**【objectarchive】(实证:`case-studies/legal-and-deploy.md` §2.4) > **写进 DEPLOY.md 的形式**:一张「艺术家 / 卒年 / 在某法域下届满年 / 证据来源」的表,**按卒年倒序排**——因为**关键的不是名单有多长,而是它的尾巴**。倒序排让最晚的那位出现在第一行,谁都不会漏读。 ### 2.5 ⭐ 逐资产表是一道**真的技术门**,不只是法务作业【objectarchive】 **逼你把每一类资产各自数一遍,而门只数它看得见的那些。** **填表规则(强制)**:每一类资产的「数量」列必须写**文件数 + 字节数**,来源是**磁盘普查**(`find` / `du`,按扩展名与按用途各数一遍——**按用途的那份才是版权取证真正吃的输入**)。然后与另外两个账本交叉: | 交叉项 | 对不上意味着 | 处置 | |---|---|---| | 磁盘普查 ∩ **被引用集合**(发现侧提取出的引用) | **被引用、盘上没有** = 镜像缺口 | ⛔ **停下来查发现侧**——门可能全绿而闭包本身算错了 | | 磁盘普查 ∩ manifest / inventory 账本 | 盘上有、账本没有 = 账本漏登记 | 补登记,并查抓取脚本 | | 被引用集合 ∩ 豁免/不抓清单 | 差额没有任何一类**技术性**解释 | 逐条补抓或按 §0.2 的三类技术性理由登记(`NOTFILE` / `NOTFETCHED` / `DISALLOWED`),**不许"大概齐",更不许拿法务当理由** | (实证:`case-studies/legal-and-deploy.md` §2.5——实证一「探测器算错」:四道门全绿而闭包本身算错了;实证二「这一条曾被写反」:少抓约 60% 资产、五道门却始终全绿) **因此改成硬规则**:**缺口一律补抓;登记是补抓之外的动作,不是它的替代。** 不抓只能有 §0.2 的三类技术性理由。"反正不公开"、"不该多存一份"**不是理由**——一份永不公开的私有镜像,多抓少抓法律地位不变,而少抓会让整条验收链失去参照。**门必须报真话,探测器错了就修探测器**(配套的"豁免匹配粒度"问题见 `mirroring.md` §5.1 / `verification-gates.md`:基址豁免必须精确匹配,前缀匹配等于豁免整个目录)。 ### 2.6 收官前扫一遍产物里的**第三方标识符**【objectarchive】 这一类不在"媒体资产"的直觉里,但它是逐资产表必须覆盖的一行。**它们在源站的 HTML 里是公开的——换一个 origin 重新发布不是同一件事。** **扫描清单**(对产物 shell、内联脚本、meta 标签逐项 grep): - 平台 API 令牌 / publishable key(Storefront / 支付 / 地图 / 搜索); - 站点验证 meta:`google-site-verification`、`p:domain_verify`、`facebook-domain-verification`、`msvalidate.01`、`yandex-verification`; - 分析与像素 ID:`G-` / `UA-` / GTM 容器 ID、Meta pixel、各 SaaS 的 company id(常直接出现在资产路径里); - 错误上报 DSN、reCAPTCHA / hCaptcha site key; - **第三方公司或个人的邮箱、电话、地址**;商户号、结算入口。 - ⛔ **`GTM-/G-/UA-` 三个前缀不是清单,是清单里的一行**【raycastkbd】:产物里实际带着 PostHog 项目 token(`phc_…`,chunk 内 `posthog.init(...)`)、Rewardful 联盟 id(外壳 `data-rewardful=`)、Sentry DSN(`https://<key>@oNNN.ingest.us.sentry.io/<project>`)、Vercel Analytics / Speed Insights 脚本。每一样都会把复刻站访客的数据记进原作者账上。扫法:对 `site/`、`port/`(或 `src/`)与外壳 grep `phc_[A-Za-z0-9]{20,}` / `data-rewardful` / `@o\d+\.ingest` / `_vercel/` / `posthog\.init\(` / `Sentry\.init|dsn:` ,逐条进表。(实证:`case-studies/legal-and-deploy.md` §2.6) **为什么危险**(逐条形状不同,处置也不同):站点验证 meta 挂在**你控制的域名**上,形状上就是一次搜索引擎/社交平台的**验证劫持**;API token 是**别人的配额**;DSN 与 pixel 会把你的流量**记进别人账上**;邮箱地址在新 origin 上是**转发出去的个人信息**。 **处置**:**镜像里原样保留**(它是取证对象,§0.2);私有产物里也可以保留(是保真的一部分);**任何对外可访问的形态之前,必须逐条剥离或 stub,并登记为偏差**。这是"产出怎么被使用"层面的动作,加进 §4 检查清单与 §3.3 取证清单第 5 项。 ### 2.7 案例参考:往届项目**当时怎么决定、依据是什么** ⚠ **这张表是案例,不是默认预期,更不是你该照着得出的结论。** 每个项目的决定由当时的用户在当时的事实上作出;**你的项目要重新取证、重新交给用户决定**。放在这里是为了让你知道**这类决定长什么样、通常吃哪些事实**。(实证:`case-studies/legal-and-deploy.md` §2.7) **共同点值得说给用户听**:七个项目最后都落在私有——但那是**七次各自作出的决定**,不是一条默认规则;把它当默认规则,就等于又把决定权从用户手里拿走了一次。 ## 3. 呈交用户的决断包(**两个独立维度**) ⭐ **版权不是唯一维度。** 早期模板把"混淆"当成版权流程里的一个附加检查项("确认不与原站产生混淆"),默认它可以靠域名、SEO 设置和一句免责声明消除。**对一类站这个默认是错的**:两个维度必须**各自独立取证、各自独立呈现**——它们不是一个可以互相抵消的加权和,用户需要分别看到它们。 ### 3.1 维度一:版权与再分发 按 §2 的逐资产表取证:**是否存在查得不可再分发的资产、是否存在 `[未确认]`**。判据、流程见 §2.1–§2.6。呈现时**把 `[未确认]` 单独成组**——用户对"查清了是坏消息"和"没查清"的态度往往不同。 ### 3.2 ⭐ 维度二:混淆与冒用——**源站是否仍在营业**【objectarchive】 **这条超出版权范畴,且技术上越成功越严重。** 一份字节级忠实、带着真实价格与"Add to Bag"按钮的公开副本,**本身就是消费者混淆与被人拿去行骗的载体**——与授权无关,与技术质量无关。**复刻做得越好,这一条越危险**,因为"忠实"正是钓鱼页的定义性属性。 **三问,逐问取证作答,写进 DEPLOY.md 并当面呈给用户**: 1. **源站是否仍在营业?**(域名是否解析、是否仍在更新、是否有实体经营主体) 2. **源站是否有交易功能或收集用户输入?**(结算入口、加购、价格、登录、表单、订阅、支付方式图标——**价格与结算入口是最强信号**) 3. **副本是否会被误认为官方?**——判据不是"我们有没有说明",而是"**它长得像不像**":品牌标识、商标、trade dress、真实价格、真实商品名、可用的表单、指向真实域名的链接、复刻侧域名是否形似源站。 **风险边界矩阵(呈给用户的东西,不是 agent 的裁定)**: | 情形 | 风险边界 | agent 的默认动作与建议 | |---|---|---| | 源站在营业 **且** 有交易功能 | 对外可访问的忠实副本在形状上等同于一个可用的仿冒店面;风险**独立于版权维度**存在,即使全部资产可再分发也不消失。含"临时""给朋友看""内网穿透演示"——一个链接发出去就不再受你控制 | **保持不对外可访问**;**强烈建议不公开**,并建议在考虑公开前咨询专业意见。**agent 不得自行放行**;用户坚持时,先把本行原样复述给用户再执行其决定 | | 源站在营业、无交易功能(纯作品集/宣传站) | 混淆风险仍在(品牌、trade dress、作品归属) | 默认私有;若用户要公开,先做**去混淆改造**(见下)并逐项取证后再交用户确认 | | 源站已停止运营 | 混淆风险降低但**不消失**(商标与品牌仍有权利人),**版权维度完全不受影响** | 默认私有;如实告诉用户"这一条变弱了,另一条没变" | **去混淆改造**(**改造后就不再是字节级忠实的复刻**——这个代价要明写进偏差表,并在提问时告诉用户):移除或替换品牌标识与商标、去掉真实价格、禁用所有交易与表单入口、换掉商品名与文案、域名与源站无形似关系、页首**不可关闭**的非官方声明。 ⛔ **免责声明只解决"混淆"的一半,解决不了"再分发"。** 呈给用户时要点破这一点:任何"加个免责声明就放出去"的想法,对维度一的每一条都无效。 ### 3.3 取证清单:呈交之前必须做完的六项(第七项由用户完成) 按顺序执行。**这六项是 agent 的活;第七项是用户的活。** 1. **逐资产表完成**,`[未确认]` 逐条列出并说明为什么查不清(§2.1–§2.4)。 2. **不可再分发的资产逐条点名**(哪一类、多少文件/字节、权利人是谁)【noomo】【lando】【samsy】【objectarchive】。 3. ⭐ **混淆维度独立取证**(§3.2 三问),**与维度一分开呈现**,不合并成一个"综合风险"【objectarchive】。 4. **逐类计数与门/账本对账无未解释差额**(§2.5)——对不上说明资产表本身不可信,**先修对账再呈交**:拿不可信的表去让用户决定,比不呈交更糟。 5. **产物内第三方标识符已扫描并逐条列出**(§2.6),附"公开前需剥离/stub 的清单"。 5b. **分发面事实已报出**:仓库可见性(`gh repo view --json isPrivate`)、是否已推送、`mirror/` 本体是否入库、预览部署是否公网可达及其 `X-Robots-Tag` / 访问控制——**这些是事实不是结论**,与"产物里有什么"是两个维度。⛔ 典型失败不是谁做错了决定,是**没人把这四个事实放到同一页上给用户看**【samsy】。(实证:`case-studies/legal-and-deploy.md` §3.3) 6. **把选项与风险边界写成 §0.1 的五段式**,一次问完。 7. **⛔ 用户作出决定**——`AskUserQuestion` 或编号清单,**拿到明确答复才继续**。在此之前按安全默认执行(§4)。 **指令**:把这七项做成一张「项 / 事实或结果 / 证据出处」的表放进 DEPLOY.md,**逐项写结果,包括通过的那些**——只写"有问题的"会让读者以为其余项没做。第七行写**用户的决定原话**与日期。 ### 3.4 怎么问(模板) > **需要你决定:这份复刻产出要保持私有,还是对外公开?** > > **事实**:(逐条,带证据)① 逐资产表 N 类资产中 X 类的权利人不是站方,其中 Y 类是具名艺术家(最晚一位卒于 1970);② 源站仍在营业,商品页有真实价格与结算入口;③ 产物内含 3 个第三方标识符(站点验证 meta ×1、pixel ID ×2)。 > **查不清的**:Z 类 `[未确认]`(原因:产物内无许可声明)。 > **选项**:A 保持私有 + noindex(现状);B 公开但做去混淆改造(代价:不再是字节级忠实,偏差表新增 N 条);C 公开原样。 > **各自的风险边界**:(逐项一句话,描述风险,不宣布合法与否) > **我的建议**:A。理由:①②③ 各自独立成立,即使其中一条被推翻其余两条不变。 > **在你决定之前**:我按 A 执行——产出保持私有仓库 + noindex + 不部署。**这是默认动作,不是我对法律问题的结论。** 涉及真实对外发布或商业用途,建议咨询专业意见。 ## 4. 私有部署检查清单(安全默认下的动作) 安全默认 ≠ 随便部署,各项目沉淀的合规配置逐项照做: - [ ] 仓库私有【kimi】【lando】 - [ ] 预览站加 `noindex`:samsy 用 nginx `X-Robots-Tag: noindex`【samsy】;kimi 部署仅 noindex 私有预览【kimi】;objectarchive 由构建变换向每份产物注入 `<meta name="robots" content="noindex,nofollow">` + 非官方声明注释【objectarchive】 - [ ] ⭐ **noindex 与非官方标注是被门盯着的,不是"写上去就算"**:给注入变换设**逐页命中下限**(低于下限构建直接 throw),再从**产物字节**反向确认这条变换真的出现过。理由:只判"有变换发生"时,一条高频变换(如 URL 本地化 2,540 次)会让总数永远非零,**注入 noindex 的那条悄悄失效不会有任何人发现**【objectarchive】(详见 `dom-shell-strategies.md`) - [ ] 源站的分析/遥测**不复刻、不上报**:GA 不接入【samsy】;lando 把"遥测不复刻"登记为偏差【lando】;noomo 用 CSP 拦截源站 GA 上报【noomo】;objectarchive 用服务层 stub + 整域 stub,并由**零外联门断言**(多路由 × 双视口,零外联)【objectarchive】 - [ ] ⭐ **产物内第三方标识符已扫描**(§2.6 清单)——私有形态下可保留但必须**登记在案**,任何对外形态之前逐条剥离【objectarchive】 - [ ] 镜像不再分发(镜像保留仅为逆向结论可复核)【oryzo】【samsy】【kimi】;`.gitignore` 只放行账本文件,**镜像字节一字节不入 git**【objectarchive】。⚠ **"不入 git"是产出侧动作,不影响镜像在盘上是否完整**(§0.2) - [ ] 第三方实时服务讲礼仪:samsy 的 PartyKit 直连源站生产房间,DEPLOY.md 明确提醒"别广播"【samsy】;objectarchive 对订阅接口接受降级,**不代理、不发真实请求**【objectarchive】 - [ ] 部署密钥/脚本不进仓库:oryzo 的 DEPLOY.md 与 deploy.sh 均 gitignore【oryzo】 - [ ] 合规边界从第一天写进 README/DEPLOY,而非事后补【samsy】;README **第一屏第一句**即声明【objectarchive】 - [ ] **允许形态白名单写明**:私有仓库、`127.0.0.1` 本地伺服、镜像只读且不入 git、截图不入 git、数字产物入 git【objectarchive】 ## 5. 私有部署方式参考 各项目实际用过的私有部署形态(供选型,不是必选项): - **私人 VPS + OpenResty**:rogier 部署到私人 VPS,部署只上 `dist/` 产物【rogier】; - **本地 build + rsync 到 VPS(1Panel + OpenResty)**:oryzo 的 `DEPLOY.md`/`scripts/deploy.sh` 均 gitignore,不进仓库【oryzo】; - **1Panel 私有部署 + CSP**:noomo 的 DEPLOY.md 含具体配置,CSP 拦截源站 GA 上报【noomo】; - **nginx 加 `X-Robots-Tag: noindex`**:samsy 预览站配置【samsy】; - **纯本地 `127.0.0.1`,不部署到任何主机**:objectarchive 的终态——**"无部署脚本,因为不部署"**【objectarchive】; - **符号链接资产的部署解引用**:lando 用 `rsync -L` 把 `dist/ext → mirror/assets` 符号链接解成实体文件【lando】;noomo 靠 nitro 构建时自动拷入 `.output/public` 使产物自包含(DEPLOY.md 实测更正过这一点——部署前实测,别想当然)【noomo】。 ## 6. 部署即验证——以及"不部署"的验收盲区 部署不是流程终点之后的杂务,而是**最后一重验证**:真实网络延迟会暴露本地永不触发的竞态【samsy】。(实证:`case-studies/legal-and-deploy.md` §6) **指令**:部署后在真实网络下复跑全部验收门;仅线上出现的问题按 `environment-traps.md` §3 的流程归因,不许直接改代码糊平。 ⭐ **用户决定不部署时,这一重验证不会被执行——它是一个已知盲区,必须明写登记,而不是让它消失**【objectarchive】: > 全部验收在 `127.0.0.1` 的同源、零延迟拓扑下完成。samsy 的实证是"部署后暴露了源站永不触发的构造期纹理竞态,根因是部署拓扑差异"。本项目已知有 N 条对加载序敏感的路径(逐条点名),**它们在真实网络下的表现没有被任何门覆盖**。这个盲区**不构成公开部署的理由**——它是"不部署"这个决定的代价,明写在这里而不是让它消失。 **指令**:照此写进 DEPLOY.md 的部署验证一节,**逐条点名**对加载序/网络拓扑敏感的路径(异步解码后才定尺寸的画布、`async`/`defer` 相关、字体加载门控的首屏、依赖解码完成时刻的布局量)。 ## 7. DEPLOY.md 产出要求 ### 7.1 结构(objectarchive `DEPLOY.md` 是范本,七节)【objectarchive】 DEPLOY.md 与项目其余文档的读者不同:**其余文档是给复刻工程自己用的,这一份是给"要不要、能不能把它放出去"这个问题用的**。开头点明这一点,并点明**决定是谁作出的**。 | 节 | 内容 | 要点 | |---|---|---| | §1 **决断(结论先行)** | 用户的决定 + 三句话理由 + 决定日期 | 见 §7.2 | | §2 **逐资产归属/许可表** | §2.1 的八列表 + 逐位作者的公共领域取证(§2.4)+ 实测普查(文件数/字节,按扩展名与按用途各一份)+ vendor 库逐个 banner + 收口时发现的缺口**及补抓结果** | 【noomo】【objectarchive】 | | §3 **取证清单与呈交记录** | §3.3 七项逐项写结果(**含通过的那些**)+ 呈给用户的问题原文 + 用户答复原话 | 【objectarchive】 | | §4 **非官方标注:落实位置与实测** | 注入了什么、注入在哪、**哪道门在盯着它**(逐页命中下限 + 产物字节反查) | 【objectarchive】 | | §5 **风险边界清单** | "什么情况下这份产出不应被公开",逐条对应一个已识别风险源 | 见 §7.3 | | §6 **私有部署检查清单落实记录** | §4 逐项 ✅/⚠ + 落实位置与实测证据 | 【samsy】【noomo】 | | §7 **部署验证结果** | 真实网络下门是否全绿、拓扑差异问题及处置;**不部署时按 §6 登记盲区** | 【samsy】【objectarchive】 | ### 7.2 ⭐ 记录决定的写法:结论先行 + 过度决定 + 永久决断不写重考条件【objectarchive】 三条写作纪律,缺一条这份文件就会被误读。⚠ **它们是"怎么记录用户已作出的决定"的纪律,不是"怎么下判断"的纪律**——写之前先确认这个决定**确实来自用户**(§3.3 第 7 项)。 1. **结论先行,写在文件最前**,写成一句祈使句,**注明决定人与日期**,并**区分"终态"与"暂缓"**——用词本身是信息: > ⛔ 不公开部署(用户 2025-XX-XX 决定)。私有仓库 + 本地/私有预览 + `noindex` 为**终态**,不是"暂缓"。 紧跟**三句话理由**,每条一句,逐条标明它属于哪个维度(版权 / 混淆 / 平台规则),并注明该理由基于哪条取证事实。 2. ⭐ **理由若是「过度决定的」(over-determined),显式写出来**:写明"即使某一层理由被推翻,其余任一条仍独立支撑这个决定"。 **为什么必须写**:不写的话,读者(包括三个月后的你自己)会把这些理由当成一个**连乘条件**——于是"把字体全换掉"或"艺术家那层查清了都过期"看起来就像是撬开结论的路径。**写明过度决定,就是把这条撬棍提前拿掉**:objectarchive 的表述是"即使艺术家那一层全部进入公共领域、即使字体全部换掉,**源站在营业**这一条仍独立地支撑同一个决定"。 ⚠ 这是**呈现理由的结构**(几条理由彼此独立),属于取证与呈现的范畴——不是替用户加固结论,用户随时可以改主意。 3. ⭐ **被用户定为永久性的决定,故意不写"重新考虑的条件",并说明"故意不写"**。 偏差表的其余条目**都要写重考条件**(`asset-management.md` §6.2 的字体子集化决策是范本)。**正因为其余条目都写,这一条的不写本身就是信息**——但只有明说才读得出来,否则读者会默认存在一条"待满足的条件",只是暂时没写。写法: > **这条决定没有"以后再看"的条件**(对照偏差表其余条目都写了重新考虑的条件——这一条故意不写)。 ⚠ **前提是用户明确表示这是终态**。用户没这么说时,写"暂缓"或原样记录用户的措辞,不要替他升级成永久。 ### 7.3 §5 风险边界清单的写法 写成清单,**逐条对应一个已识别的风险源**;清单的用途是让用户在**任何**未来时点重新决定时,手边就有当时的事实。通用骨架: 1. 只要它还渲染源站的图像、文案或字体——**这是默认状态,其余各条是它的具体化**; 2. 只要源站仍在营业(§3.2); 3. 只要产物里还带着源站的第三方标识符(§2.6); 4. 只要具名作者名单没有对**全部路由**逐人取证(§2.4 第 7 条); 5. 只要字体还是自托管的原件; 6. **任何"加个免责声明就放出去"的方案**——声明解决混淆的一半,解决不了再分发。 配套写法:每条后面跟一句"这意味着什么",并写明 **agent 侧的默认动作是保持不对外可访问,解除只能由用户明确决定**。最后附**允许的形态**(白名单,与 §4 末条一致),让"不能做什么"旁边永远有"能做什么"。 ### 7.4 若用户决定公开部署(罕见路径),追加清单 - [ ] 页面显著位置标注"非官方复刻 / 学习目的"与原作者、原站链接,且该标注**由门断言**(§4) - [ ] 逐资产表(含第三方权利人列与逐位作者取证)随仓库公开,作为可审计依据 - [ ] 与原站的偏差表(REBUILD_PLAN §6)保持最新——公开产物的忠实性声明依赖它 - [ ] 第三方标识符已逐条剥离或 stub,并复扫确认(§2.6) - [ ] 去混淆改造已完成并逐项取证(§3.2),改造造成的失真已登记进偏差表 - [ ] 用户的决定与其依据的事实版本已记录在 DEPLOY.md §1/§3(事实变了要重新问) - [ ] **必要时已咨询专业意见**——本文件给的是取证流程,不是放行许可,agent 的建议也不是 -
mirroring.md 49.9 KB
# 镜像取证全流程(M0 → M0.5) > **何时加载本文件**:第 0 步判级为 A/B 后**立即**加载并动工。镜像先于一切分析——历年获奖站 29% 已消失(域名易主/平台回收/抢注/路径移除/HTTP 200 的原地替换五种形态俱全),"第一时间全站镜像作只读证据"不是最佳实践,是抢救行为【probe】。M0.5 断网跑通是阻塞门:镜像不可跑,不得进入逆向与移植。 ### 0.10 ⛔ 弱标记的挑战页判据必须先问"这是不是一份文档"【v0-optimus】 真实性门用两档标记查挑战页:强标记(只可能出现在挑战体里)对所有文本文件生效;弱标记(在真页面上也会出现)只对**小于 32 KB 的文档**生效。 ⛔ 但"小"不是"文档"。(实证:`case-studies/mirroring.md` §0.10) ⭐ 补一条判据即可:**挑战页是以页面形式送达的**——开头是 `<!doctype` / `<html` / `<?xml`,或同时含 `<html` 与 `<body`。不满足就不适用弱标记。强标记保持对所有文本文件生效:**一个挑战体被写在 `.js` 路径上,正是强标记存在的理由。** ⚠ 这里的假红特别贵,门自己的注释已经写明了原因:**它训练你跳读这道门的输出**,而那正是当初 43 份挑战页能活下来的方式。修完必须回过头验证弱标记仍会在真挑战页上触发。 ### 0.11 ⛔⛔ 防目录穿越的守卫写成 `includes("..")` 会误杀合法文件名【eightdesign】 镜像服务器对一个**磁盘上确实存在**的字体返回 404。根因在守卫: ```js if (clean.includes("..")) return null; // ⛔ 子串匹配 ``` 目录穿越说的是 `..` 这个**路径段**,不是两字符子串。构建器的内容哈希会产出扩展名前带多个点的合法文件名(实证:`case-studies/mirroring.md` §0.11)。 ⚠ **症状离病因很远**:**排查时看到的是动画报错,而毛病在服务器的一行守卫里。**(实证:`case-studies/mirroring.md` §0.11) ⭐ 正确写法是段级判断,配合 join 之后的容纳性断言: ```js const clean = path.normalize(decodeURIComponent(pathname)); if (clean.split(/[/\\]/).some((seg) => seg === "..")) return null; ``` ⛔ 修完必须双向验证:那个字体应当 200,而 `/../../etc/passwd` 与 `/a/../../../etc/hosts` 仍应当 404(实测均如此)。**放宽一条安全守卫时,证明它仍然守着是修复的一部分,不是可选项。** ### 0.12 ⛔⛔ M0 第二遍要按**路由**跑,而且 `next/image` 是藏在静态站里的运行时接口【eightdesign】 **① CDP 补录只跑了 `/`。** 其余路由有各自在运行时拼出来的资源,静态提取看不见。⚠ **镜像侧报错说明参照本身不完整**,此时任何跨侧数字都不该读(实证:`case-studies/mirroring.md` §0.12)。 ⭐ **要对拍哪条路由,就先给哪条路由跑第二遍。** 不是"跑一次首页就代表全站"。 **② 那 4 条 404 是 Next 的图片优化端点**: ``` /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fthumb_2.xxx.png&w=1920&q=75&dpl=… ``` 这是**服务端按需缩放**的接口,URL 由 `<Image>` 组件在运行时按视口拼出。所以: - 静态引用提取**必然**看不见它; - ⛔ **连 CDP 抓包也只抓得到当次视口请求的那几个 `w`**。换个视口(或换个 DPR)就是另一组 URL,而镜像里没有。 ⚠ 这是一个**藏在静态站里的 B 类特征**(运行时接口),判级时容易漏:站点本身是确定性 HTML、双抓字节相同,`/api/` 为 0——但它的图片是服务端生成的。**判 A 不等于"没有服务端参与"。** 处置:把该端点的响应按 `url+w+q` 三元组入库(`@@` 查询编码天然支持),并在偏差表里写明**镜像覆盖的是哪几组 `w`**——因为覆盖面就是"你能对拍哪些视口"的上限。 ### 0.13 ⚠ 比对之前,先确认你拿到的是那份资源【eightdesign】 ⭐ 一个 diff 的两边各自是什么,是 diff 结论的前提。**取证脚本必须先断言"我拿到的东西 像那份资源"**——尺寸量级、状态码、内容类型,任一条不合就停下,而不是把它喂进比对。 这与 `verify-mirror` 的真实性门是同一条纪律,只不过那道门管的是**存进来的**字节, 这里管的是**你临时取来做对照的**字节。(实证:`case-studies/mirroring.md` §0.13) ### 0.14 一个 URL 可以按请求头返回两份不同的资源【eightdesign】 镜像模型是 **URL → 文件**。而一个源站可以在同一个 URL 上按**请求头**发两份东西—— 同一条路由带 `RSC: 1` 头取回的是 flight 载荷,不带则是整页 HTML(实证:`case-studies/mirroring.md` §0.14)。 爬虫没带那个头,拿到 200 和一份看着合理的正文,于是把**页面**存在了 flight 载荷的位置上。 ⚠ 下游没有任何一道门能看见:文件在、是一份真文档、闭包完整。 ⭐ **200 不是"你拿到了那份资源"的证明,只是"你拿到了一份资源"的证明。** 这与 §0.13 是同一条纪律的两半:那里管你临时取来做对照的字节,这里管你存进来的字节。 ⛔ 修复时还有一条:**保住磁盘上的形状**。URL→路径映射决定了某条目是普通文件还是 带 `index.html` 的目录;只有**字节**是错的。(实证:`case-studies/mirroring.md` §0.14) ### 0.15 ⛔ 一个被切错的 URL 不是漏掉一个资源,是**凭空造出**一个【eightdesign】 流式载荷在任意位置被切开,包括 URL 中间。Next.js 把 flight 载荷分成一串 `self.__next_f.push([1,"…"])`,切点落在编码器缓冲区用完的地方——经常就在 URL 里。 于是从原始 HTML 扫引用,读到的是**片段**: ``` .../media/1f9dadf367424346-s.p.04 尾巴被切掉 https://host/static/media/9010da… "/_next" 在上一个 push 里 https://host/9dc1a6fb114b646f-s.p… 整个路径前缀都在上一个 push 里 ``` ⚠ 片段不是"漏读",而是**一条被发明出来的引用**。爬虫接着去抓它、得到 404、 在账本里写下一条失败记录——**看起来和真实缺失的资源一模一样,而且永远补不上, 因为那个 URL 从来不存在**。(实证:`case-studies/mirroring.md` §0.15) ⭐ 解法是**先重组再扫描**:客户端本来就是拼接完再解析,push 边界不携带任何意义, 去掉它不丢东西(`lib/extract-refs.mjs` 的 `joinFlightPushes`)。 ⛔ 并且必须**取代**原始扫描而不是与之合并——两个都扫,截断拼写会连同完整 URL 一起留下。 ### 0.16 一个镜像,一本账 `netcapture --fetch` 曾经"只写字节不写账本",并附一条注释建议改用 `mirror-site --seeds`。 注释是对的,而它没有用:一次 `--fetch` 留下的文件会被 `verify-mirror` 永远报为 "nobody can name a URL for"。(实证:`case-studies/mirroring.md` §0.16) ⚠ 而第二本账(`netcapture.tsv`)只记 URL、**不记它写到了哪个路径**,所以根本无法与磁盘对账。 ⛔ **一个能把产物留在"没有门接受"状态的工具,是一把带注释的枪。** 追加账本行是十五行代码。 ### 0.17 ⚠ 别把账本当作证据 扫描镜像统计"还有谁在引用那些幻影 URL"时(实证:`case-studies/mirroring.md` §0.17): **唯一引用它们的文件是 `mirror-manifest.json` 自己**——账本记录了爬虫问过的每一个 URL, 幻影也在内。把它读回来当作"幻影仍被引用"的证据,是循环论证。 ⭐ 扫描引用时**永远排除账本文件**。账本是关于证据的陈述,不是证据。 ### 0.18 运行时拼出来的 URL:静态扫描只会造出一个模板前缀【eightdesign】 首页的 lottie 播放器这样取它的 wasm: ```js `https://cdn.jsdelivr.net/npm/${pkg}@${ver}/dist/dotlottie-player.wasm` ``` ⛔ 静态扫描读到的是 `https://cdn.jsdelivr.net/npm/$` —— 到第一个 `${` 为止的前缀。 这和 §0.15 的 push 边界截断是**同一类错误**:它不是漏读,是**一条被发明出来的引用**, 去抓会 404,账本里于是留下一个永远补不上的洞。 ⭐ 提取器现在丢弃含 `${` 或以 `$` 结尾的候选——**模板前缀不是地址**。 而真正那条 URL **只有抓包看得见**(实证:`case-studies/mirroring.md` §0.18)。 ⚠ 值得记住这条资产的处境:闭包门不会报缺(完整 URL 在字节里根本不存在), 静态外联门也不会报(同理),**只有资源级探针看得见它**。§1.6 的四类断言互补, 这是第四类唯一能抓到的那一格。 ### 0.19 ⛔ 嵌在另一个 URL 查询里的引用,对"把 URL 当原子"的提取器是隐形的 图片优化端点把它的**主体**写在参数里: ``` /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpic_3.0w8q….png&w=2048&q=75 ``` 一个把 URL 当原子的提取器在这里只看到**一条**引用——那个端点——**永远不会去要那张图**。(实证:`case-studies/mirroring.md` §0.19) ⭐ 它们是把**产出字节里的每条引用逐条问服务器**之后才浮出来的 (`verify-refs-served.mjs`)。提取器现在解码 `url=`/`src=`/`file=` 这类参数, 把它们的值也当作引用。 ### 0.20 裸请求 / 带后缀文件:回退的另一半 `serveCandidates()` 处理的是"请求**有**查询、文件没有"——镜像忽略掉的缓存破坏参数。 反向也会发生:文档引用 `/x.svg`,而镜像存的是 `x@@dpl=….svg`, 因为**抓取它时用的 URL** 带着源站的部署 id。 ⛔ 只在变体**唯一**时回退。一个路径的两个变体是两份资源(`?width=320` 对 `?width=1200`), 从裸请求里回答其中任意一个,正是单射门存在要抓的那种坍缩。唯一就给,多个就 404 让门说话。 ### 0.21 图片优化端点是个**接口**,不是一批文件 `/_next/image?url=X&w=N&q=Q` 的输出集合是**无界的**——`w` 取决于组件当时的视口。 "把它们都抓下来"不是一个计划,是一个不会收敛的循环(实证:`case-studies/mirroring.md` §0.21)。 ⭐ 改为**解析这个接口**:服务器按 `url=` 参数从镜像里的**原图**应答。 ⛔ 这要登记为 deviation——发出的字节是原图,不是源站缩放重压过的那份,更大也更锐。 这是一个真实差异,而它被声明了;另一个选项是大多数路由上的永久 404,那是更大的差异。 **原图确实在镜像里——这是在解析接口,不是在发明资产。** ### 0.22 ⚠ 文件名里可以有 `(`:把它排除掉就是在造幻影 ⛔ **在一条为了消灭幻影而加的形状里,造出了新的幻影。** 规则应当是**按括号配平裁剪**:配平的 `(` … `)` 属于文件名,孤零零的尾随 `)` 才是 CSS `url(...)` 的收尾定界符。(实证:`case-studies/mirroring.md` §0.22) ### 0.23 Nuxt/Vite 目标的三个镜像必修课【hubtown】 1. ⛔ **Vite 的 chunk 清单是相对说明符**:`__vite__mapDeps` 与 `import("./Xxx.js")` 都相对 引用文件所在目录,根相对形状全部匹配不到。运行时 import 失败会触发 Nuxt 的 `app:chunkError` → `reloadNuxtApp`(实证:`case-studies/mirroring.md` §0.23)。 提取器已加 4c 形状(JS 内 `"./x.ext"` 按 baseUrl 目录解析)。 2. ⭐ **`/_nuxt/builds/latest.json` 是运行时拼的**,静态字节里不完整出现;Nuxt 拿它比对 构建号,404/不一致会走重载路径。抓包看得到,记得补种。 3. ⛔ **无扩展名的服务端路由要按 manifest 记录的 content-type 伺服**:`/api/_auth/session` 存成 `<path>/index.html`,按后缀猜成 `text/html`;ofetch **按 content-type 解析**, 拿到字符串而不是对象,应用侧静默断链。netcapture --fetch 现在把观测到的类型写进两本账, serve.mjs 优先用 manifest 记录的类型应答。 ### 0.24 ⚠ `.ttf` 后缀里可以住着 OpenType/CFF `OTTO` 魔数 + `font/ttf` 声明是**源站自己的标注习惯**,字节是真字体。 真实性门的 ttf 魔数现在认 `00010000`、`true`、`OTTO` 三种。 > ⭐ **目标站已死?** 本文假定有活源可爬。X 类(原站消失)的抢救走 > [archival-rescue.md](archival-rescue.md):`wayback-mirror.mjs` 从 Internet Archive > 产出与本文同构的标准镜像,唯一的语义差异是"洞是既成事实,登记即交付"。 ## 0. 三条地基原则 1. **镜像神圣不可污染**:`mirror/` 磁盘文件抓下来后永不修改。它既是逆向的唯一原始依据,又是后续所有对拍验收的基准端——污染镜像 = 污染裁判【samsy】【noomo】【lando】。 2. **目录结构 = 源站 URL 空间的字节级还原**:页面按路径落成 `<path>/index.html`,资产按原路径落盘【noomo】【lando】。外部 host 资产落 `assets/<host>/<path>`【lando】。 3. **账本先行**:每个文件的来源 URL、字节数、sha256、下载结果都要有账(§3)。没有账本的镜像不能作为对账与验收的依据【6/6】。 **目录分离**:`mirror/`(① 只读证据)≠ `port/`(② 逐字移植)≠ `src/`(③ 人写的工程)≠ `dist/`(部署产物)【oryzo】。⚠ **不复制策略只作用于 ② 阶段**——工作区靠符号链接/中间件映射消费镜像资产,永不复制重资产;**③ 阶段必须复制**,自包含是它的定义性要求(`references/asset-management.md`、`references/readable-source.md` §2)。 ## 0.9 ⛔ 三个只有大型商业站才会暴露的镜像缺陷【airpodspro】 下面三处缺陷此前从未触发,它们的共同点是:**都需要某种"前面几个站恰好没有"的形态**才会现身。(实证:`case-studies/mirroring.md` §0.9) **① `--scope` 有个洞:`.html` 被当资产,绕开页面范围。** 页面链接 `href="/legal/…/site.html"` 会被**两个提取器分别判定**:页面提取器按 scope **正确拦截**,而资产提取器的判据是"有没有扩展名",`.html` 说有 → **当资产抓走**,而 scope 的文档明写"只限页面不限资产"。抓下来又被当文本重新扫描引用,**整棵跨地区 legal 树被拖进来**。 ⭐ **修法**:同源的 `.html`/`.htm` 是**页面**,交给页面队列(因而受 scope 管辖);跨源的保持资产处理。(实证:`case-studies/mirroring.md` §0.9) ⭐ **副作用是好的**:从"静默过量抓取"变成"闭包门响亮地报告有引用离开了范围"。 **② 爬虫与服务器对同一个 URL 算出两个文件名。** 带查询串的**目录式** URL(`/path/?a=1&b=2`)上,"挂 `@@query` 后缀"与"补 `/index.html`"的**先后顺序**两边是反的:爬虫写 `…/name@@query/index.html`,服务器找 `…/name/@@query`。无尾斜杠时两者一致,**只有目录式 URL 才分叉**。症状是断网门 404 而文件就在盘上。 ⛔ **v0.1.11 早有"同一个答案在工具链里只能有一份实现"这条纪律,而这两份实现就在同一个文件里、隔了 50 行。同文件不等于同一份实现。** **③ 分析信标不是资产,是一次上报。** 每次请求带唯一 session id 与事件参数,服务端不返回可复用字节。⛔ **镜像它没有意义**:URL 不可复现、下次访问就是另一个。登记进 `external.txt`、复刻侧服务层挡掉——**这是"技术性理由不抓"的标准形态**。 ⚠ 且服务层**改写不到它**:分析库把主机名当字符串存着、运行时拼 URL,而改写的六种形状全要求 `://host/` 字面量。与 F21(源程序按域名分支)同族——**服务层只能改写字面量,改不了运算**。 ## 1. 镜像四遍法 + 一条实测 单一手段必漏。HTML 外壳信息量决定主手段:Webflow/静态站资源在 HTML/CSS 里可爬;Next/RSC 站资源藏在 hash chunk 与 flight payload 的转义字符串里,"链接跟随式爬虫第一层就走到头"【kimi】。所以标准动作是四遍互补 + 一条实测。 **每一遍都有别的遍够不到的"唯一发现区",不可互相替代**(实证:`case-studies/mirroring.md` §1)。 **少跑任何一遍都会留下静默缺口**。 ### 第一遍:正则 BFS 爬虫(`scripts/mirror-site.mjs`) rogier 首创、noomo/lando 三代实战传承的骨架【rogier】【noomo】【lando】: - **种子**:全部已知页面路由 + 已知关键资产路径。 - **提取正则集**:对每个文本响应(HTML/JS/CSS/SVG/JSON)提取 `href/src/poster/content` 属性、CSS `url()`、动态 `import()`、`new Worker("...")`、`fetch("...")`、资产目录前缀字面量(`/assets|_astro|audio|content|fonts|images|models|workers/` 类)、按扩展名白名单匹配的绝对 URL【rogier】【noomo】。 - **格式感知深挖**:下载 `.gltf`/glTF 后解析 JSON,把 `buffers[].uri`、`images[].uri` 递归入队【rogier】【noomo】;扫描页面 chunk 内数据结构推导资产路径(数据藏在 JS 里,DOM 抓不到)【rogier】。 - **host 白名单**:外部资源只收白名单 CDN 域,防爬飞【lando】。 - **迭代到不动点**:每轮下载产生的新文本再过一遍正则,直到无新 URL【lando】。 - **纯静态解析变体**:bundle 结构清晰时可不用爬虫,直接从 bundle 静态解析出完整资产清单逐个 curl【samsy】。 ### 第二遍:真实浏览器 CDP 抓包补录(`scripts/netcapture.mjs`) 静态解析对**运行时拼接的 URL**天然失明。headless Chrome 实跑全路由 × 桌面/移动双视口、走完整个滚动/交互流程,用 CDP 记录实际发出的同源请求,与磁盘 diff 出 GAP 清单逐项补录【kimi】: - 轻量变体:真实 Chrome 加载后执行 `performance.getEntriesByType('resource')`,取运行时实际请求的同源路径逐一核对镜像命中——静态爬取之外的运行时闭环【noomo】(实证:`case-studies/mirroring.md` §1)。 - 工具零依赖:Node 22+ 内置 WebSocket 直连 CDP,不装 puppeteer【kimi】【samsy】。 ### 第三遍:bundle 模板字面量静态求解(人工) 抓包也有盲区——滚动深度够不到、条件分支不触发的资源,回到 bundle 里人工解模板字面量: - `` `/models/crystal${e}.glb` `` 把 `${e}` 求解为 0–6 逐个补抓【noomo】。 - 基址变量拼接:资产基址存放在变量里再拼路径,静态正则不可见——从 bundle 读出基址后枚举补抓【lando】(实证:`case-studies/mirroring.md` §1)。 - 语言变体:浏览器只请求当前语言那份,`en-US` 等按同构路径手工拉【kimi】。 - **能力探测分支**:`` `/video/${i}.${SU}` `` 里的 `SU` 由 `canPlayType` 决定,抓包只走当前浏览器那半边——两个分支都要求解补抓(详见 §8 盲区 checklist)【shopifydesign】。 ### 第四遍:静态闭包校验(引用集 − 磁盘集 = ∅)【shopifydesign】 前三遍跑完仍会漏一类东西:**既不字面出现在 HTML、又不被抓包触发、也不是模板拼接**的 chunk。(实证:`case-studies/mirroring.md` §1) 抓法成本极低(一个 grep + 一次集合差),却能兜住前三遍的共同盲区: 1. 在**所有已镜像的 js/css/html** 里 grep 构建器产物的文件名形态 `<name>-<hash>.{js,css}`,取并集 = **引用集**; 2. 列出磁盘上同类文件的 basename 集合 = **磁盘集**; 3. 做差 `引用集 − 磁盘集`,逐个补抓(走与前三遍同一个下载器,账本才是一本),直到差集为空。 ```bash # 引用集(hash 长度按目标站构建器调整;Vite 常见 8 位) grep -rhoE '[A-Za-z0-9_.$-]+-[A-Za-z0-9_-]{8}\.(js|css)' mirror \ --include='*.js' --include='*.css' --include='*.html' | sort -u > /tmp/refs.txt # 磁盘集 find mirror -type f \( -name '*.js' -o -name '*.css' \) -exec basename {} \; | sort -u > /tmp/disk.txt comm -23 /tmp/refs.txt /tmp/disk.txt # 输出非空 = 还有没抓到的 chunk ``` **差集为空是 M0 关账条件之一(§10)**;差集里若确有故意不入库的外部 chunk,按 §6 外部依赖决策表逐条登记,不许无声留着。 ### 逐 URL 实测状态码(不可省略) **服务端重定向在客户端产物里零留痕**:光读 bundle 永远看不出 `/zh-cn/*` 是 301——必须对每条路由裸 fetch 实测状态码并记账【kimi】。注意用裸 fetch 而非浏览器(浏览器自动跟随重定向,正是造假文件的动作)。 ## 2. redirect: "manual" 纪律(红线) 爬虫**绝不默认跟随重定向**。跟随重定向会把 301 目标的 body 写在来源路径下,**凭空造出假文件**【kimi】(实证:`case-studies/mirroring.md` §2)。修复方案三件套: 1. 爬虫 fetch 一律 `redirect: "manual"`; 2. 重定向单独记入 `redirects.tsv` 账本("这是源站行为,不是爬虫记账"); 3. 独立验证脚本用裸 fetch 断言每条重定向的**状态码本身**——Next 的 `permanent: true` 发 308 而源站发 301,门必须断言状态码而不只断言"有重定向"【kimi】。 **这条红线的一般形式是"不许造出源站从未在那个 URL 上返回过的文件"**,而跟随重定向只是造假的一种方式。第二种是**把挑战页当成功响应落盘**(§5.1 实证二):源站在那个 URL 上返回的是一道门,不是文档。正确处置是**连文件带账本行一并删除、重定向进 `redirects.tsv`**,那是**更正伪造,不是删证据**【objectarchive】(实证:`case-studies/mirroring.md` §2)。 ## 3. manifest 账本体系 镜像目录旁必备的账本(kimi 制度最完整,按需裁剪)【kimi】【samsy】【noomo】【lando】: | 账本 | 内容 | 作用 | |---|---|---| | `inventory.tsv` | 逐文件 sha256 权威清单 | 一切资产比对的唯一来源【kimi】 | | `manifest.tsv` / `mirror-manifest.json` | 下载流水:url → path/bytes/type/OK-FAIL,含 mirroredAt/downloaded/failed | 留证 + 重刷依据【samsy】【noomo】【lando】 | | `redirects.tsv` | 源站重定向逐条(来源、目标、状态码) | 重定向是源站行为,需回放与断言【kimi】 | | `netcapture.tsv` | 抓包 HAVE/GAP 对账表 | GAP=0 是 M0 关账条件之一【kimi】 | | `external.txt` | 外部 URL 逐条甄别(kimi 47 条) | 喂给 §6 外部依赖决策表【kimi】 | ⛔ **`external.txt` 里的"不抓"豁免只能是技术性事实**,逐条标类:`NOTFILE`(不是文件:结构化数据标识符、命名空间 URI、出站锚点)/`NOTFETCHED`(服务端不提供 404/410、需授权或登录态、付费墙——后两类属本 skill 适用范围之外)/`DISALLOWED`(源站 `robots.txt`/ToS/API 条款明令禁止,属 SKILL.md 的既定边界;⛔ **标这一类先过 `legal-and-deploy.md` §0.3 逐路径判定**——只覆盖命中的那几条路径,且只能来自**针对抓取**的禁令,交易类禁令与"拿不准"都不算)。**不得**写"出于版权考虑我们选择不抓""反正不公开所以不抓"——法务理由不进这本账(`legal-and-deploy.md` §0.2;实证见 §5.1)。 特殊载荷单独镜像:RSC flight payload 带 `RSC: 1` 头取回的另一份 body 存 `_rsc/`,其中含逐请求随机 nonce,**diff 前必须 mask**【kimi】。bundle 内联的 base64 资产(LUT、SMAA 纹理)提取到 `_extracted/`(分析产物区,与原件字节纯净区分开)【noomo】。 ## 4. 镜像神圣 + 服务层改写 一切本地化适配在**服务层响应时动态完成**,磁盘纯净【samsy】【noomo】【lando】。`scripts/serve.mjs`(samsy 首创响应层改写,kimi→noomo→lando 四代传承)职责清单: - **MIME 补全**(glb/hdr/ktx2 等)+ **Range 请求**支持(视频可 seek)【noomo】。HLS 站另需 `.m3u8`/`.ts`/`.m4s` 正确 MIME,否则播放器拒绝清单、补录下来的阶梯照样不播(`scripts/serve.mjs` 已内置)【racingshop】。 - **CDN 基址动态改写**:源 bundle 无条件写死 BunnyCDN 前缀且该 CDN 要求同源引用 → 响应层把基址替换为 `/cdn/` 并映射回本地目录【samsy】;外部 host URL 统一重写为 `/ext/<host>/` 路径【lando】。 - **遥测 stub**:GA 反代路径返回 JS stub,不外联【lando】。 - **404 语义复刻**:未知路径回落源站 404 模板并返回真 HTTP 404(平台语义)【lando】。 - **RSC 路由**:带 RSC 请求头的请求路由到 `_rsc/` 镜像【kimi】。 - **probe 注入口**:`?__probe` 时在 `<head>` 首部注入确定性 shim,无 query 时输出字节不变【noomo】。 - **SRI 剥离**:服务层改写过的文本字节无法匹配原 integrity 哈希,需剥离 SRI 属性并**登记为偏差**【lando】。 例外条款:**后代演进为"干脆不改磁盘"**。如确实不得已改磁盘,必须照 rogier 的登记纪律执行。(实证:`case-studies/mirroring.md` §4) ## 5. 断网跑通验收门(M0.5,⛔ 阻塞门) "镜像可跑才能当对拍基准,且实跑必然暴露静态解析盲区"——隐藏关键步【lando】。**先过 §5.1 的镜像自检门**(本门的每一项都拿镜像当输入,镜像错了它照样能全绿),再用 `scripts/serve.mjs` 伺服镜像,断网(或禁外联监控下)执行: 验收标准(全部满足才关账): - **零 404**。 - **零控制台错误**:全路由 + 404 页跑 `scripts/probe.mjs` 探针全 CLEAN,首页含**全滚动**【lando】。 - **零外联**:无任何对源站/CDN 的真实网络请求【samsy】。 - **重定向断言**:裸 fetch 独立跑,用 `scripts/verify-routes.mjs` 对镜像伺服执行路由/重定向/状态码契约【kimi】。 - **关键流程走通**:首访交互流程实际走一遍【samsy】。 - **GAP=0 对账**:netcapture 对账表无未销账条目【kimi】。 实跑必然暴露盲区并当场补录,这是预期内流程而非失败(实证:`case-studies/mirroring.md` §5)。 M0.5 之后,`serve.mjs` 终身兼任后续所有对拍的"源站参照服"(如 `PORT=3200 SERVE_ROOT=mirror`)【noomo】。 ### 5.1 镜像要有属于自己的门:下游全绿证明不了镜像对【objectarchive】 **下游所有门测的是"渲染得出来吗",不是"字节对不对"。** 零 404、零控制台错误、零外联、像素对拍——每一道都跑在镜像**之上**、拿镜像当输入。于是镜像是全项目的证据基座,却是**唯一没有独立验收**的一环:镜像错了,下游照样可以全绿。 ⭐ **实证一:缺 60% 的资产,五道门全绿,藏了四个里程碑**【objectarchive】(详见 `case-studies/mirroring.md` §5.1)。由此的硬规则(详见 `legal-and-deploy.md` §0.2):**镜像完整性是技术不变量,任何法务考量都不得削减它**;不抓只能有技术性理由(不是文件 / 服务端不提供 / 需授权或登录态 / 源站明令禁止),逐条登记;缺口一律补抓,**登记是补抓之外的动作,不是它的替代**。理由有三:① 一份永不公开的私有镜像,多抓少抓法律地位不变;② 不完整的镜像让复刻**无法被验证**,反而更糟;③ 一旦允许法务理由挖洞,闭包门就变成可协商的,且**没人能再区分"法务豁免"与"技术失败"**——两者在账本上长得一模一样。 ⭐⭐⭐ **实证二:镜像里有 43 份 bot 挑战页,而镜像门是 PASS 0**【objectarchive】(详见 `case-studies/mirroring.md` §5.1)。 **账本记的是"你抓到了什么",从不记"它是不是你要的那个"。** **一条为"防止守卫恒绿"立的规矩,抓到的是"证据基座被换掉了"**——不要指望下次还有这种运气。 由此的命题,也是下面「真实性」那一项断言存在的理由:**一个 HTTP 200 不是"你拿到了那个资源"的证据。** 反爬挑战页、同意墙、地区拦截页、catch-all 兜底页**全部以 200 + `text/html` 返回**,而账本 / 单射性 / 闭包 / 覆盖度**每一项都在诚实地校验一份错误的内容**。 **实证三**:图片 CDN 是**查询参数化的变换接口**——`x.jpg?width=320` / `?width=600` / `?width=1200` 是三份不同字节的资源。而 url→路径映射只看 `pathname`,三个尺寸**坍缩成同一个文件**;serve 端每个 `?width=` 又都回那同一个文件,页面照样把图渲染出来 → **零 404 门在错镜像上变绿**。这类错不会在 M0.5 暴露,会一路活到像素对拍才以"某张图糊了 / 尺寸不对"的形态出现,那时归因成本已经翻几倍(实证:`case-studies/mirroring.md` §5.1)。 因此镜像自检门与 M0.5 断网门**并列,且跑在它之前**。断言面五项,而其中**「真实性」那一项与其余四项正交**:其余四项校验的是"**账本与磁盘是否自洽**",它校验的是"**磁盘上的东西是不是你以为的那个东西**"——其余四项全绿说不出它的任何事(实证二): - [ ] **映射单射性**:把账本里全部 URL 过一遍 url→本地路径的映射函数,**任何两个不同 URL 落到同一路径即红**。这一项直接抓查询参数化资产;修法是让映射**查询感知**(如 `x.jpg?v=1&width=600` → `x@@v=1&width=600.jpg`),并且**镜像端、serve 端、闭包校验三方共用同一个映射实现**(写成一个模块,不许各写一份——三份实现分歧本身就是新的静默错源)。**这条的通用形态**(工具链里凡是"两处以上要算出同一个答案"的逻辑一律单一实现,含识别信号与代价)见 `verification-gates.md` §2.1.1。 - [ ] **账本与磁盘一致**:`inventory.tsv` 的逐文件 sha256 与磁盘现状重算一致;文件数、字节数对得上;"账本有磁盘无"与"磁盘有账本无"**两个方向都要报**。 - [ ] **闭包完整性**:引用集 − 磁盘集 = ∅(§1 第四遍),差集里每一条在 `external.txt` 有决策。**⛔ 审一道门先问它的输入怎么被界定,再问它的判据对不对**:这一项已经两次假绿在输入上而不是判据上(实证:`case-studies/mirroring.md` §5.1)。修法都在 `scripts/lib/extract-refs.mjs`:**爬虫与门共用同一份判定**,"什么算文本"按 **声明的 content-type → 扩展名 → 内容嗅探** 三级决定,不是一张扩展名表【objectarchive】。 - [ ] ⭐⭐⭐ **真实性(AUTHENTICITY):磁盘上的东西是不是你要的那个**。至少两条硬断言 + 一条线索: - **挑战 / 拦截正文匹配**(硬红):Cloudflare(`_cf_chl_opt` / "Just a moment" / "Checking your browser")、Imperva/Incapsula、Akamai、Sucuri、PerimeterX 等已知挑战页正文。**判据要分强弱**——厂商专有标记任意体量都判红,而"页面里有 reCAPTCHA / 有 WAF 脚本"这类**真页面也会命中**的弱标记,只在**整份文档很小**时才算数(挑战页**就是**整份文档,真页面只是包含一个控件)。**表必须可扩展**:每家厂商都在造新的,脚本留 `--interstitial-extra` 口子,见到一次就登记一条。 - **声明类型与魔数字节对照**(硬红):声明是图片/字体/媒体/脚本的,正文必须匹配对应魔数;声明是二进制而正文是 HTML 文档的一律红。这一条抓的是"拒绝页 / 登录墙 / SPA 兜底页顶着资产 URL 落盘"。⛔ **判据的依据是源站声明的 content-type,不是 URL 的扩展名**:扩展名是源站自己的命名选择、不承诺任何事。改成对着账本的 type 比之后,假红消失而判据**更严**。 - **同类体量离群**(**只报线索,不判红**):挑战页 9.5 KB vs 真文档 300 KB+ 差两个数量级,这是抓"还没有人有正则的那一类挑战页"的兜底。**不判红是有意的**:查询参数化的 CDN 上"同类"永远不精确(一张纯色卡与一张摄影共享 `?width=1200`,差 200 倍是诚实的),判红只会换来一个调参旋钮和一张豁免表——正是 `gate-failure-modes.md` §1 说门是怎么坏掉的那两条路。同类分组必须带上**变换参数本身**(`?width=` 之类)(实证:`case-studies/mirroring.md` §5.1)。 - [ ] **抽样回源核对**(联网时做,可选):从账本随机抽 N 条重新拉一次比 sha256。它抓的是"镜像与源站已经分道"(内容漂移、CDN 重编码),**不再是"拿到的是不是拒绝页"的唯一手段**——那件事现在由上一条离线完成,不必联网、不必打扰源站。 本 skill 自带 `scripts/verify-mirror.mjs`(五项全部已实现);objectandarchive 侧另有一个项目脚本 `verify-offline.mjs`(不在本 skill 内)。两者分工明确:**前者管"镜像本身对不对",后者管"镜像跑起来对不对"**。 ## 6. 外部依赖决策表 ⚠ **先划清这张表管什么**:它管的是**运行侧怎么消费一个外部依赖**(复刻工程加载谁、`public/`/`dist/` 里放什么),**不管镜像抓不抓**。镜像侧照四遍法抓全,"保留原引用不入库"的资产**副本照样落在 `mirror/external/` 供逆向复核**【oryzo】【samsy】——这正是原判例的做法。**不许用这张表在镜像上开洞**(`legal-and-deploy.md` §0.2)。 **两类决策要分开,混在一起就是越权**: - **技术性决策**(agent 自己做):能不能自托管跑得起来、换端点会不会改行为、降级会不会影响签名行为、WASM/解码器要不要本地化; - **法务性决策**(取证后**交回用户**,用 SKILL.md「User Input Tools」提问):**能不能把这份二进制自托管到我们自己的 origin**、能不能再分发、能不能进 git、能不能对外可访问。典型如商用授权字体——**"源站有没有授权"不等于"我们有没有"**,把同一份二进制自托管到另一个 origin 是一次独立的使用行为。agent 取证(许可条款原文、文件内 banner、文件名信号)并给建议,**决定由用户作出**;在用户决定之前按安全默认执行(不进运行资产、不再分发)。 外部依赖单独列表(授权字体、第三方 SaaS、CDN),**逐项显式决策**,三选一【oryzo】【samsy】【kimi】: | 处置 | 适用 | 判例 | 归谁决定 | |---|---|---|---| | 保留原引用、不进运行资产 | 授权条款禁止自托管的资产 | Adobe Fonts (Typekit) CSS 引用保留,副本仍存 `mirror/external/` 供参考【oryzo】【samsy】 | 法务侧 → **交用户**(agent 取证 + 建议 + 默认保守) | | 换端点/本地化 | 可自托管的 vendor 资源 | detect-gpu 的 unpkg benchmarks 指向本地 `/vendor/`【rogier】;Rive WASM 从 `/ext/unpkg.com/...` 本地提供【lando】 | 纯技术 → agent 决定并登记偏差 | | 接受降级 | 纯统计/非行为依赖 | GA/Cloudflare Insights 不接入【oryzo】【samsy】 | 纯技术 → agent 决定并登记偏差 | 特别小心有行为副作用的第三方:samsy 的 PartyKit 多人服务直连的是**源站生产房间**——决策表里要写明礼仪边界("别广播")【samsy】。bundle 内出现 `/api/` 字符串 ⇒ 强制做运行时 API 快照(导航数据可能在 headless CMS 里)【probe】。 ## 7. 跨域与受保护资产的抓取 - **补齐 Referer 请求头**:部分资产域要求同源 Referer,缺失时按其约定返回 403 → 抓取请求按要求带上 `Referer: https://<目标站>/`,满足服务器对合法引用的期望【lando】。 - **小响应告警**:bundle 响应 <1KB 极可能是拒绝页——按字节数守卫,触发即补齐 Referer 重试【probe】。**绝对阈值只对最极端的一档有效**:9.5 KB 的挑战页顶替 300 KB 的真文档时它一声不响,所以镜像门里的形态是**同类体量离群**而不是固定字节数(§5.1「真实性」)【objectarchive】。 - **CDN 跨域引用的运行期处理**:镜像抓取解决"抓得下来",本地回放还要解决"bundle 会去请求 CDN"——用 §4 的服务层基址改写把引用指回本地,不改磁盘【samsy】。 - 遇到需要登录态、付费墙或授权的资产(本 skill 适用范围之外),停止并告知用户,不尝试获取。 ## 8. 镜像盲区 checklist 静态爬取**必漏**的资产类型,逐项建"从源站补录"通道并 checklist 化销账【oryzo】【samsy】(实证:`case-studies/mirroring.md` §8): - [ ] worker 运行时才 fetch 的文件(WASM 排序 worker、baker.worker)【oryzo】【samsy】 - [ ] 懒加载资源(画廊图片、preloader 图、懒加载 chunk)【oryzo】【samsy】 - [ ] **流媒体清单阶梯**:HLS/DASH 的 master `.m3u8`/`.mpd` 能被静态爬到,但 rendition 播放列表与 `.ts`/`.m4s` 分片是播放器**运行时**才请求的,静态爬取全漏——用 `scripts/gapfill-video.mjs` 递归解析清单阶梯补录【racingshop】(实证:`case-studies/mirroring.md` §8) - [ ] 移动端变体:oryzo 规则是扩展名前插 `_MOBILE`(纹理上限 800px vs 桌面 2560px)——逆向出命名规则后批量补抓【oryzo】;双端纹理变体(桌面 webp + 移动 ktx2)【lando】 - [ ] 仅特定 query 触发的 chunk(samsy 的 `?editor` / `?gameboy` 才加载的 editor-*.js / gb-*.js)【samsy】 - [ ] 纹理集拼接路径(正则不可见,只有实跑网络请求可见)【lando】 - [ ] 非当前语言的本地化资源(浏览器只请求当前语言)【kimi】 - [ ] 抓包滚动深度够不到的深处资源(回第三遍模板字面量求解)【kimi】 - [ ] 字体文件(rogier 首轮漏抓,后补齐并验证与源站逐字节一致)【rogier】 - [ ] **编解码器 / 能力探测分支变体**:源站按浏览器能力选资产格式,抓包只会拿到当前浏览器那一半分支—— `SU = document.createElement("video").canPlayType('video/mp4; codecs="hvc1"') !== "" ? "mp4" : "webm"`, Chrome 走 mp4,**另一半 4 个 webm 文件只有第三遍静态求解拿得到**;同类还有 webp/avif、ktx2/basis 的能力分叉。 做法:在 bundle 里 grep `canPlayType` / `createImageBitmap` / 扩展名三元表达式,把**每个分支的取值全枚举**后补抓【shopifydesign】 - [ ] 前三遍共同盲区:既不字面出现、又不被抓包触发、也非模板拼接的 chunk → 用第四遍静态闭包校验兜底【shopifydesign】 - [ ] ⛔ **Turbopack loader-stub 家族**:`e.v(t=>Promise.all(["static/immutable/chunks/x.css","…/y.js"].map(e.l)).then(()=>t(<id>)))` 里的相对 chunk 路径只在交互态(`next/dynamic ssr:false` 组件、`await ctx.A(<id>)` 的上传器)被请求——load + 滚动走查永远不碰。**从 module-map 聚合查**:所有已加载 chunk 的 require/alias 全集减定义全集,非空即有家族缺席(raycastkbd:7 个 stub 目标 → 13 个文件,`s.l(path)` 的路径按 runtime 常量 `r="/_next/"` 拼绝对 URL 喂 reconcile-gaps,迭代到不动点——本例一轮即闭)【raycastkbd】 - [ ] ⛔ **路由预取载荷是外联的载体**:导航 `<Link>` 的 `?_rsc=` 预取载荷本身在镜像里,其内的绝对 URL 在滚动走查触发预取后由浏览器直接去要。netcapture 首跑没传 `--hosts`,同注册域的子域也一样看不见;`probe --no-external --walk` 才报出来。内容资产只能镜像(`assets/<host>/` + `--ext-hosts`),载荷里其它路由的家族按范围声明前缀豁免【raycastkbd】 - [ ] ⛔ **next/image 阶梯要按字节穷举且按浏览器 Accept 抓**:HTML srcset 里的每条 `/_next/image?url=…&w=<档>` 都是一份资源,且 `Vary: Accept`——`*/*` 拿到 JPEG/PNG 回退,Chrome Accept 拿到 webp,两者 sha 不同、体积差 3–30×。存量镜像走独立记账树 `mirror-negotiated/`(sanity-platform §1.2),serve 用回落链 `--fallback-root mirror-negotiated,mirror`【raycastkbd】 - [ ] **HTML/CSS/JS 之外的文本格式**:`.atom` / `.rss` / `.xml` / sitemap / `.txt` / `.webmanifest` / `.map`,以及**无扩展名的路由**与源站 MIME 表不认识的扩展名(服务端一律回 `application/octet-stream`)。这些文件**装满商品链接与 CDN 图 URL**,但"什么算文本"如果是一张扩展名白名单,它们**从来不会被任何一侧打开**——而闭包门看不出来,因为爬虫与门共用同一张白名单。做法:判定按 **声明的 content-type → 扩展名 → 内容嗅探** 三级走,`octet-stream` 当**没有声明**处理(它是"服务器不知道",不是"这是二进制"),且**爬虫与闭包门共用同一份实现**【objectarchive】 - [ ] **查询参数化的资产变换接口**:图片 CDN 把尺寸/裁剪/格式写在 query 里(`x.jpg?width=320|600|1200`、`?crop=center`、`&format=webp`),**同 pathname 不同字节**。按 pathname 落盘会让整组变体坍缩成一个文件,而下游零 404 门照样绿(§5.1)。做法:映射与落盘**查询感知**,并把"同 pathname 多变体"单独清点【objectarchive】 - [ ] **`srcset` 的非首个候选**:`srcset` 是逗号分隔的候选表,多数爬虫正则要求候选前有引号,于是**每组只命中第一条**;浏览器按 DPR/视口只请求其中一条,**第二遍抓包也补不全**。做法:`srcset` / `imagesrcset` 属性单独按逗号拆开逐条入队【objectarchive】 - [ ] **不带尾斜杠的裸主机基址常量**:代码常写 `const B="https://cdn.example.com"`、`window.shopUrl='https://site.com'` 再拼路径;只匹配"带尾斜杠"形式的提取/改写规则对它天然失明。同类还有 JSON 转义的协议相对写法 `\/\/host\/`。做法:提取与改写规则覆盖**裸主机 / 带尾斜杠 / 协议相对 / JSON 转义**四种形态,且**探针要报完整 URL 而不只是 host 直方图**,否则看不出漏的到底是哪一条【objectarchive】 - [ ] **App Router 的运行时面**:客户端导航预取的 `?_rsc=` 载荷(每个可见链接一条,query 值是路由状态哈希)与 `next/image` 优化器变体(`/_next/image?url=…&w=…`)。⭐ 变体阶梯**从 SSR HTML 的 srcset 穷举**成闭包全集,不靠浏览器碰运气。用 `scripts/reconcile-gaps.mjs` 逐条容错补录【rauchg】 - [ ] **爬虫专供路由**:`og:image` / `twitter:image` 指向的动态 OG 图(`/opengraph-image`、`/og/<slug>`)只有社交爬虫访问,BFS 与 CDP 补录都看不见——从每页 head 的 meta 内容里收 URL 逐个补抓【rauchg】 - [ ] **无入链的 well-known 路由**:`/atom` `/rss` `/feed` `/sitemap.xml` 页面上没有任何链接就永远不进队列——M0 收尾逐个 GET 一次,200 即入镜。rauchg 盲逆向对答案暴露的三个盲区里两个是这类(/atom 订阅、隐藏短链系统);后者原理不可枚举,如实登记为盲区【rauchg】 销账方式:每项要么"已补录(见 manifest 行)",要么"确认源站不存在此类",不许留空。 ## 9. 常见坑 - ⭐ **同一个 403 有两种相反的药**:一族 CDN 缺 same-origin Referer 就 403(landonorris——于是爬虫带上了 Referer),另一族**带浏览器式请求头才 403、裸 curl 反而 200**(video.twimg.com,rauchg 实测)。单一请求头配置对其中一族永远是错的——`mirror-site.mjs` 的 get() 现在带**请求头梯子**:标准 profile 撞 401/403 时用最小 profile 重试一次;404 不重试(404 就是 404)【rauchg】 - ⛔ **补录循环的账外文件**:一次异常中止整个循环、`appendLedger` 永远没跑到,**已落盘的文件全部成为账外状态**(实证:`case-studies/mirroring.md` §9)。做法是逐条 try/catch + 每百条分批记账;教训通用:**任何"先写盘后记账"的循环,记账必须分批,不许全押在收尾一笔**【rauchg】 - **redirect follow 造假文件**:默认跟随重定向会把 301 误当成 200,凭空造出假文件——`redirect: "manual"` 红线【kimi】。 - **服务端行为零留痕**:redirects/状态码必须逐 URL 实测,读产物读不出来;308 vs 301 这种差异只有断言状态码本身才能抓住【kimi】。 - ⭐⭐⭐ **一个 200 不是"你拿到了那个资源"的证据**:反爬挑战页 / 同意墙 / 地区拦截页 / catch-all 兜底页**全部是 200 + `text/html`**,账本会诚实地记下它们的 sha256,而单射性、闭包、覆盖度**每一项都在校验一份错误的内容**。**这条坑必须以门的形态存在,不能只是这里的一行提醒**——四个项目里它一直只是散文,代价是整个逆向工作所依据的文档被换掉而无人反对。可执行形态见 §5.1 的「真实性」断言(`scripts/verify-mirror.mjs` 的 AUTHENTICITY 门)【objectarchive】(实证:`case-studies/mirroring.md` §5.1)。 - **catch-all 假 200**:请求 `.map`/任意路径返回 index.html(other-side-of-truth)——对每个下载物做 content-type 校验与哈希碰撞检测(大量文件同 hash = catch-all 兜底页)【probe】。**校验的方向要对**:拿**源站声明的 content-type** 与正文魔数对照,不要拿 URL 扩展名当预期(扩展名是源站的命名选择,实测会报假红)【objectarchive】。 - **爬虫把挑战页当成功响应落盘**:门是事后的补救,爬虫侧的正解是**把挑战页正文当可重试状态**——退避重试、绝不落盘,正则只认挑战页独有的标记。落盘之后它就成了一份"源站从未在那个 URL 上返回过的文件",与 §2 的 `redirect: follow` 造假文件是同一类东西【objectarchive】。 - **门的输入短一截,和门的判据错,是同一族失效,而前者更难看见**:发现正则少认一种拼法、豁免按前缀匹配、"什么算文本"是一张扩展名白名单——同一轮里撞到三层。**审一道门,先问它的输入是怎么被界定的,再问它的断言对不对**【objectarchive】。 - **拿法务理由在镜像上开洞**:以"产出永不公开""这类资产不该多存一份"为由少抓一类资产,五道门照样全绿。镜像完整性是技术不变量,不抓只能有技术性理由;**法务决定作用于产出怎么被使用,不作用于证据基座是否完整**【objectarchive】(实证:`case-studies/mirroring.md` §9)。 - **零 404 门在错镜像上变绿**:下游每一道门测的都是"渲染得出来吗",不是"字节对不对"。查询参数化资产坍缩成一个文件后,serve 端每个尺寸都回同一份文件、页面照常渲染,四道验收门全绿——**镜像必须有属于自己的门**(§5.1)【objectarchive】。 - **HTML 里没有 `<script src>`**:现代站可能全靠内联 `import()`(Shopify Editions 三代)——爬虫只认 script 标签会漏掉全部 JS【probe】;script 枚举还要排除 HTML 注释内的脚本【probe】。 - **RSC nonce 假 diff**:`_rsc/` 载荷含逐请求随机 nonce,不 mask 直接 diff 会误报不确定【kimi】。 - **镜像跑不通就开工**:镜像没过 M0.5 门就逆向/移植,等于没有对拍基准,后续一切"像不像"都无法归因【lando】【samsy】。 - **探针自身盲区**:镜像 CSS 被 Chrome 因 SRI 校验**静默拦截**,安全报错走 CDP Log 域——探针若只监听 Runtime/Network,M0.5 的"CLEAN"存在盲区(`scripts/probe.mjs` 已并入 Log 域监听;自查时确认这一点)【lando】。 - **后台标签节流伪装假死**:M0 阶段在后台标签实跑镜像,rAF 节流 + gsap lagSmoothing 会把站点冻成假死,误判"镜像坏了"——无头/实跑一律带 anti-throttling 旗标或保持前台【noomo】【samsy】【oryzo】。 - **直接改磁盘镜像**:一切适配走服务层;确实不得已改磁盘必须逐处登记并在对比时扣除(rogier 一代纪律)【rogier】。 ## 10. M0/M0.5 关账条件(产出物清单) - [ ] `mirror/`:目录结构 = 源站 URL 空间,磁盘纯净、只读 - [ ] 账本齐备:manifest(含 sha256)、redirects.tsv、netcapture GAP 对账(=0)、external.txt - [ ] **静态闭包校验通过**:全镜像的 `<name>-<hash>.{js,css}` 引用集 − 磁盘集 **= ∅**(差集里的外部 chunk 须在 external.txt 有决策)【shopifydesign】 - [ ] **镜像自检门通过**(§5.1,跑在断网门之前):映射单射性 / 账本与磁盘 sha256 一致 / 闭包完整 / **真实性(挑战页正文 + 声明类型对魔数;体量离群线索逐条读过)** /(联网可选)抽样回源核对 - [ ] `scripts/serve.mjs` 可伺服镜像,服务层改写清单逐项登记 - [ ] 断网验收全绿:零 404 / 零控制台错误(probe CLEAN,含全滚动)/ 零外联 / 重定向状态码断言通过 - [ ] 外部依赖决策表:每条外部 URL 有归属决策(保留引用/换端点/接受降级) - [ ] 镜像盲区 checklist 逐项销账 - [ ] 版权**取证**已出(哪些资产不可再分发、第三方权利人是谁——事实与建议,**不是 agent 的决断**;"是否公开部署"到收官时交用户决定,详见 `references/legal-and-deploy.md`) - [ ] **法务考量未削减镜像完整性**:不抓清单里每一条都是技术性理由(`NOTFILE` / `NOTFETCHED` / `DISALLOWED`),零条"出于版权考虑不抓"【objectarchive】 - [ ] **镜像的存档策略已定并写明**(见 §11):账本(含 sha256)**必须 git 追踪**;`mirror/` 本体是否入库交用户决定,不入库时产出文档须写明它的存放处与再验证命令 全部勾完 → M0 关账,进入 M1 逆向(`references/reverse-engineering.md`)。 ## 11. 镜像的存档策略:账本必须入库,本体交用户裁量 `mirror/` 本体是否 git 追踪,是一个**大小 vs 可追溯性**的取舍,没有普适答案——几十 MB 的站直接入库; 一个 864 MB 的 Strapi 桶入库会把仓库变成不可 clone 的东西。这是**用户的决定**,M0 关账时问一次。 但两条不随裁量浮动: - ⛔ **账本永远入库**:`mirror-manifest.json`(逐文件 sha256 + 字节 + content-type)、`redirects.tsv`、 `external.txt`、`urlpath-policy.json` 加起来不过几 MB,而它们是"镜像曾经是什么"的**可校验陈述**—— 本体丢了,哈希还能对任何一份声称是副本的东西做裁决。 - ⛔ **不入库 ≠ 不存在**:"镜像只在本机"是单点故障,而获奖站年消失率约 29%——源站死了,本机盘一坏, 证据链就断了。选择不入库时,产出文档(result/DEPLOY)必须写明:本体存放在哪、怎么用账本重新验证它 (`verify-mirror --mirror <path>`)、以及(源站还活着时)怎么按账本重抓。 ⭐ 一个实测可行的折中【hashgraphvc】:交付所需的子集以**字节钉死**的形式入库(逐文件 sha256 清单 + 构建时 逐字节复核后才物化),完整镜像留本地——仓库保持可 clone,交付物自证完整,全量证据另行存放并登记去处。 -
payload-gates.md 7.7 KB
# payload-gates.md — 载荷与外壳变换的门 > **何时加载本文件**:站点有内联序列化载荷(React flight `self.__next_f`、Nuxt `__NUXT__` / `__NUXT_DATA__` / `_payload.json`、devalue 数据岛)或走策略 A 外壳构建(`build-site` / `verify-shell` / `verify-payload` / `verify-lenprefix`)时。通用门型与运行纪律在 `verification-gates.md`。 ## 1. 自带长度的载荷:改写它,就得重新声明它 ⛔ **一次字符串替换,只在"没有别处写下过这段字符串有多长"的地方是安全的。** 本工具链有两层做同一件事——把绝对外链本地化:构建层烤进 port 的字节(`T-LOCALIZE`), 服务层在镜像出门时改写(`serve.mjs`)。两者在 `href`/`src`、CSS `url()`、普通 JSON 里都对。 两者在 React 的 flight 流里都错。那个流是这样的行: ``` <id>:T<十六进制>,<正好那么多 UTF-8 字节的文本> ``` ⭐ **长度前缀行没有终止符。** 下一行的行首紧贴在声明的末尾——**长度本身就是分隔符**, 这正是它被写下来的全部理由。于是把 `https://media.host/x` 缩短成 `/ext/media.host/x`, 读取者就会把下一行的行首当正文吞掉,解析死在与病因毫无关系的地方: ``` TypeError: t.reason.enqueueModel is not a function ``` ### 1.1 它为什么必须是一道门,而不是一条注意事项 静态检查看不见它,因为字节合法;运行时探针看不见它,因为没有任何请求失败(实证:`case-studies/payload-gates.md` §1.1)。 ⭐ 定位它的动作值得记下来:**拿 `python3 -m http.server` 伺服同一个目录**。 ⭐ **手上有两个能做同一件事的实现时,让它们跑同一份输入,是最便宜的二分。** ### 1.2 修的是改写方式,不是放弃改写 URL 必须本地化,所以正解不是跳过这些行,而是: 1. **先按原始声明长度切出行**——行边界只有在改动之前才知道,改完再想恢复就是猜; 2. 逐行改写各自的正文; 3. 按正文现在的 UTF-8 字节数**重新声明**(不是字符数——日文两者差三倍); 4. ⚠ 保住原有的 push 分块边界。分块对客户端没有意义(它先拼接再解析),对**外壳门**却意义重大: 任意重新分块会把几处局部 URL 改动变成 73 个"只是搬了家"的 hunk,而变换表解释不了那些。 ⛔ 并且每一段只能被改写**一次**。修好的行若再被通篇改写扫一遍,长度会二次失配。 ### 1.3 先拿真值校准这道门 ⭐ **一道门判它所审计的源头有罪,是"错的是门"这件事最便宜的信号。** 值一次 fetch。 校准的四级输入:源站、镜像原始字节、服务层输出、port 构建字节(实证:`case-studies/payload-gates.md` §1.3)。 ### 1.4 两个调用方共用同一份实现 镜像与 port 若由**不同的代码**做本地化,它们必然会分歧,而分歧看起来像移植错误。 这与 `lib/extract-refs.mjs` 被共享而非复制是同一条纪律,所以本地化的长度感知版本 落在 `lib/flight.mjs`,`serve.mjs` 与 `lib/shell-build.mjs` 同时 import 它。 ## 2. 一个处在文本位置的 URL 是内容,不是地址【eightdesign】 本地化管的是**浏览器去哪里**,它不许改变**页面说了什么**。 href 必须本地化。锚**文本**不能(实证:`case-studies/payload-gates.md` §2)。 ⭐ **这就是全站对拍值回票价的地方:一个变换对了 114 次,在第 115 次改变了含义。** ⚠ 守卫要**故意收窄**。只保护两种文本位置无歧义的拼写:HTML 文本节点(`>URL<`) 与序列化载荷的 children 字段(`"children":"URL"`)。**一个会猜的守卫比没有守卫更糟。** ## 3. 每一个改文档的变换都要走长度感知的路径 §1 说的是本地化,但**改动就是改动**。这里要删的是一条 ```html <link rel="preload" href="https://www.googletagmanager.com/gtag/js?id=…"> ``` 而它就坐在一个 flight 行**内部**——通篇删除会像 URL 改写一样把那行缩短。 所以站点变换表与本地化走同一条 `lib/flight.mjs` 路径。 ⚠ 代价是每个变换**逐区域**看到文档(push 之间的间隙,以及每一行的正文)。 需要跨全文计数的变换必须自己记账——按变换 id 登记命中下限本来就是这么工作的。 ⭐ 而这条 preload 正是 `verify-offline` 的 class 1 存在的理由:**它做 DNS+TLS 却不取任何资源**, 资源级探针永远看不见它。产物内 0 个分析标识符,却仍在向第三方预热连接。 ## 4. 载荷门要认得 flight,而它的判据是**分类**而不是相等 野外最常见的序列化载荷是 React flight,Next.js App Router 每一页都内联它(实证:`case-studies/payload-gates.md` §4)。 ⭐ flight 不是一个可求值的 JS 表达式,但**每一行的内容是 JSON**,所以按行展开成 `{"<行号>:<标签>": 值}` 就正好落进 `paths()` 已经理解的形状里,差异因此被报成 **一个行号加一条路径**,而不是"这两个 230 KB 的字符串不一样"。 ### 4.1 判据:结构必须一致,每处值差异必须限于一个**引用** port 合法地改 URL 与资源路径(本地化、每 chunk 一条替换),它不合法地改**载荷说了什么**。 把两侧所有引用形状的跨度抹平就能把两者分开,而且**不需要变换表**—— 一道重放变换表的门是在附和构建器,不是在检查它(`verification-gates.md` §2.1.2)。 ⛔ 归一化只有一类:绝对 URL、本地路径(含无扩展名的路由 URL)、`/ext/<host>/` 桩都是同一个引用类, 少认一种就把"移植合法做的事"判成内容差异(实证:`case-studies/payload-gates.md` §4.1)。 ⚠ 而这道门**分不清"URL 是地址"与"URL 是内容"**——锚文本恰好是它所链接的地址时, 两侧归一化后一样。那条差异是被**渲染对拍**抓到的(§2)。 **知道一个 PASS 值多少,是这个 PASS 的一部分。** ## 5. 载荷门认 Nuxt3 外置载荷文件,且它**优先**于内联形状 Nuxt3 可把载荷外置为 `/_payload.json?<buildId>`(devalue JSON 数组),页面里同时还有 `window.__NUXT__={};__NUXT__.config={...}` 的**运行时配置**。nuxt2 形状会抓住后者, 求值失败,报"载荷损坏"——**错误的形状匹配,报成了内容损坏**。 外置载荷引用存在时,它就是载荷,先于一切内联形状。 ## 6. ⛔ devalue 数据岛是程序输入,不是地址【hubtown】 §2 说锚文本里的 URL 是内容;这一课在它内圈:**被应用解析的数据里的 URL 也是内容**。 Nuxt 内联 `<script type="application/json" id="__NUXT_DATA__">`(devalue 编码)。 把岛里的 url 本地化会在**三层之外**炸,期间每个请求都是 200(实证:`case-studies/payload-gates.md` §6)。 ⚠ 载荷门抓不到它:它的判据"每处差异限于一个引用"会放行这种改动—— **知道一个 PASS 值多少,是这个 PASS 的一部分**(§4 同款忠告,这次真的兑了现)。 抓到它的是渲染层文本对拍 + 外壳二分(仅 noindex 的变体完好,仅 localize 的变体损坏)。 ⭐ 修法:本地化前把 `__NUXT_DATA__` 岛整体切出,之后原样放回(`lib/shell-build.mjs`)。 零外联不受影响:那个 URL 是数据,运行时从不请求它(实测 external = 0)。 ## 7. `notice: true` 会变成页面上的文字【raycastkbd】 HTML 解析器遇到 head 内文本会**提前闭合 head**,把后面的 `<meta>`/`<link>` 全搬进 body; 而每道静态门都是绿的,只有渲染层文本对拍抓到了它(实证:`case-studies/payload-gates.md` §7)。 ⭐ 两条纪律:**配置值的形状要校验**(只有字符串才是 notice,其它一律只注 meta); 以及**外壳门对"变换表本身就错"是盲的**——它审计的是"产出与表一致",不是"表是对的"。 渲染对拍是后者唯一的门。 -
porting-discipline.md 47.7 KB
# 严格溯源移植(阶段 2:Port) > **何时加载本文件**:`_pretty/` 坐标系、`docs/engine-notes.md`、技术栈钉死与阶段计划全部就绪(阶段 1 通过判据勾完)之后,开始写第一行复刻代码时加载。本文件的五条纪律在整个移植期持续生效,直到验证收口。 ## 0.25 ⭐ 门报出你没料到的数时,先假设错的是你【airpodspro】 纪律 2 说"以源码为唯一裁决,不凭观感修"。**直觉不只穿"观感"这一件外衣,它也会穿成"心算出来的期望值"。** ⭐ **一道好门的价值恰恰在于它报出你没料到的数。** 期望值与实测不符时的正确顺序是:① 回源码读那个量是怎么算的;② 只有源码支持你的期望时,才怀疑移植。**先改被测物去迎合期望,是把门变成镜子。**(实证:`case-studies/porting-discipline.md` §0.25) ## 0.3 ⭐ 编排可能是一门表达式语言,而不是一组数值【airpodspro】 移植滚动编排时的默认假设是"关键帧里存的是数字"。实测一个大厂产品页**不是**: ```js addKeyframe(this.stickyImage, { start: `a0b - (100vh + (0.35 * css(--scroll-height)))`, end: `a0b - (100vh + (0.62 * css(--scroll-height)))`, opacity: [0, 1], anchors: [".sticky-container"], disabledWhen: ["no-enhanced", "reduced-motion"], }) ``` `a0b` 是锚点引用、`100vh` 是视口单位、`css(--var)` 读 CSS 自定义属性,整体做算术。 ⛔ **推论一:表达式解析器是竖切的必需依赖。** 它不是"以后再补的周边"——没有它,一个关键帧都驱动不起来。**竖切边界要由依赖闭包决定,不由直觉决定。**(实证:`case-studies/porting-discipline.md` §0.3) ⛔ **推论二,更隐蔽**:`disabledWhen: ["reduced-motion"]` —— **无障碍分支不是代码里的 if,而是每个关键帧上的一个声明式字段**。移植时把这个字段漏掉,代码照常跑、默认偏好下的对拍照常全绿,而在 `prefers-reduced-motion` 下多播了本该被抑制的动画。 ⚠ **声明式的禁用条件必须逐条随关键帧移植,并单独验收**(把 documentElement 的对应 class 打开再跑一次门)。(实证:`case-studies/porting-discipline.md` §0.3) ## 1. 宪法级纪律(五条) 多个项目在 REBUILD_PLAN §0 里自称"宪法级"【noomo】【lando】。每条 = 规则 + 操作化 + 实证。 ### 1.1 源站代码是唯一裁决,不凭观感修 - **规则**:每个改动先在 bundle/CSS/镜像 HTML 里找到归属行号,再落地【6/6】。rogier 明写进执行规则:"Do not tune visuals, motion, audio, or interaction by eye"【rogier】。 - **操作化**:动手前完成"归属"这一步——写不出 `pretty LNNNN` 出处的改动不许提交。rogier 的 batch 流程固化为:本地复现 → 先归属再动手 → 只修 source-owned 行为 → focused 探针 + 回归门 → 文档与代码同 commit【rogier】。 - **实证**:近似实现只许当脚手架,且必须显式替换归零【oryzo】。(实证:`case-studies/porting-discipline.md` §1.1) - **⚠ 这条纪律裁决的是"该做什么行为 / 该断言什么行为",不是"这个值在引擎里长什么样"**:写门时把源站的 CSS/URL/数字字面量直接抄进期望值,会因为引擎的规范化(`'0.4s ease'` 读回 `'0.4s'`)让**被测代码正确的那一侧**变红。正解是**让源站字面量在同一个引擎里往返一遍再当期望值**——出处仍是源站,不是观感。做法与陷阱清单见 `verification-gates.md` §0.1【objectarchive】。 ### 1.2 源站有的都要有,没有的不做;不自创补偿性 CSS/JS - **规则**:"宁可先不像,也不要发明规则"【rogier】【noomo】。视觉不对时去找没对齐的机制,不许用自创样式/逻辑把观感糊平。(实证:`case-studies/porting-discipline.md` §1.2) - **操作化(反向扫描)**:定期枚举"复刻独有"的规则逐条判罪。rogier 枚举 `global.css` 里源 bundle 没有的全部 118 条 (media, selector) 规则,逐条判定"必要机制 / 等价别名 / 多余发明",揪出 3 条真发明并删除【rogier】。 ### 1.3 bug / 死代码 / 怪写法照抄不修 - **规则**:"压缩代码里的每个怪写法都可能是行为本身"【rogier】。修好它才是偏离。照抄的同时登记进怪癖表(§Q)并注明行号。 - **实证**:"bug 照抄不修"不是洁癖,是工程安全绳【lando】;死代码同样移植【rogier】【kimi】(实证:`case-studies/porting-discipline.md` §1.3)。 - **⚠ 边界:这条只管源站字节,别推广到验收工具链**。源站把同一张表 / 同一段逻辑存三份(哪怕已经手工同步漂成 18 / 16 / 16 项),**照抄三份**,门的正解是**三份各被自己的门钉住**、漂了就响。反过来,**我们自己写的仪器**里凡是"两处以上要算出同一个答案"的逻辑必须**单一实现、共享引用**——三份各自拷贝的滚轮驱动带着同一个 bug,把一道 0 差异 / 116 组的绿门变成 14 红(识别信号与代价见 `verification-gates.md` §2.1.1)【objectarchive】。 ### 1.4 有意偏差必须登记 - **规则**(kimi 登记原则原文):"凡是明知与源站不同的实现,必须留一条,写清『源站怎么做的 / 我们怎么做 / 为什么 / 什么条件下重新考虑』。**没登记的差异一律视为 bug**"【kimi】。(实证:`case-studies/porting-discipline.md` §1.4) - **范本**:把"看起来该做的优化"论证为对测量基准的破坏【kimi】。(实证:`case-studies/porting-discipline.md` §1.4) ### 1.5 代码与文档同一次提交 - **规则**:每个里程碑成对提交——`Port xxx`(代码)+ `Update rebuild plan: xxx`(文档)【oryzo】【samsy】【kimi】【noomo】【lando】;rogier 为"文档与代码同 commit"【rogier】。 - 日志四要素见 §5.3。 ## 2. 移植文件头注释规范与逐字落地形式 ### 2.1 行号区间映射(每个移植文件必写) 文件头部注明源模块 / minified 名 → 移植名 + `pretty` 行号区间。各代实例(照此格式写): - samsy:`Port of source player controller Eu0 ... pretty L63486-L63732`【samsy】 - noomo:`// BlenderTimeline (dr L56346-56442)`、`// Jr CasePage — L51058-51110`【noomo】 - lando:`V9 TrackPoint 32502-32515, z9 Tracks 32516-32848, UN/GN shaders 32849-32944`【lando】 - kimi(函数级映射,多函数文件逐条列):`scenePosition = source R (L2609-L2617)`、`deriveDeckState = source eL/eF/…(L2914-L2966)`;组件头注 `1:1 port of the MoonEclipse component shell (source module 73655, function b)`【kimi】 混淆别名保留为线索:lando 的移植代码写 `import { gsap as m, ScrollTrigger as TA }`——沿用 bundle 混淆名,让移植代码、逆向笔记、pretty 源码三方可互相对照【lando】。 ### 2.2 逐字移植的首选实现形式:字节切片,不是重打字【shopifydesign】 - **规则**:凡是能从 `_pretty/` 按行号区间**切字节**的,就不要重新打字。用本 skill 的切片器 [`scripts/extract-source.mjs`](../scripts/extract-source.mjs)(站点数据——切片表、别名表、路径、sha256——全部外置到 `--slices` 配置,带注释样例见 [`scripts/slices.config.example.mjs`](../scripts/slices.config.example.mjs)),把切片按**源序**拼成一个生成文件(如 `src/engine/_gen/engine.gen.js`),压缩标识符原样保留,头部注明 `AUTO-GENERATED … DO NOT EDIT BY HAND`。(实证:`case-studies/porting-discipline.md` §2.2) - **三件套(缺一不可)**: 1. **切片表**:每条 `{from, to, note, symbols}`(`to` 含尾行)——它就是 §2.1 行号区间映射的**可执行形式**,不必再手写一遍。另有两个可选字段应对两种常见形态(符号住在别的 chunk 里 / 符号焊在逗号链中段),见本节末《切片表的两种扩展形态》; 2. **源文件 sha256 守卫**:切片器开头校验目标 `_pretty` 文件的 sha256,不符**直接退出**并打印"坐标系已移动,所有 `L####` 引用作废"。换 beautifier 或重新镜像时脚本拒跑,而不是静默切错行; 3. **符号别名表**:把压缩别名重绑到真库导出(`zl`=Scene、`t3`=WebGLRenderer、`lm`=CustomBlending…,shopifydesign M2 为 32 个、M3 增至 39),**每行注明解析依据与类定义行号**(类按 `isXxx` 品牌逐个坐实;枚举常量按数值定位时注意**同值不同组**——shopifydesign 的 blending 组与 side 组各有一个值为 2 的常量);尚未移植子系统的符号绑到桩(§6.2)。 - **收益**:① 移植物与源站可直接 `diff`;② 抄写型笔误在物理上不可能发生;③ 切片器提供 `--check` 模式(磁盘生成物与切片表不同步即失败),可直接进验收门。 - **代价**:切片边界要自己找。`scripts/extract-source.mjs --balance-check` 已内置该检查(原理与实证见 §6.2 删桩流程),不必自写(实证:`case-studies/porting-discipline.md` §2.2)。**这个代价与行数无关,只与边界形态有关**(见下"切片粒度")。 - **什么切、什么转写**:纯函数、数据表、GLSL、装配顺序这类**自足的顶层声明**走字节切片;框架层 effect、需重新绑定 import 的胶水走**逐语句转写**,转写文件同样按 §2.1 注明行号区间。两者可在同一个文件里混用(shopifydesign M3 的 hero 布局层:effect 逐语句转写,其中的纯函数 `B5`/`G5`/`z5` L45172–L45204 走切片)。 - **切片粒度:大 vendor 岛整块切,成本随行数近似不变——不要按符号拆**【shopifydesign】。 > 原因很实在:**vendor 岛的边界是 license banner 与命名空间 freeze,是 bundle 里最显眼的地标**;而应用函数的边界要自己数括号。所以难度与行数无关,只与"边界好不好认"有关。(实证:`case-studies/porting-discipline.md` §2.2) **操作化**:遇到打进 bundle 的第三方库(three addon、字体栈、加载器、解码器),**按岛切一刀**,别按"我这轮只要那两个符号"去切碎——切碎既要多数几遍括号,又会**发明源站没有的边界**(岛在源站本来就是连续的)。多切进来的死代码照单全收(§1.3:死代码同样移植)。 #### 跨 chunk 切片:一个源 chunk 一个输出模块(模块作用域是硬约束)【shopifydesign】 符号住在**另一个 chunk** 里、主 chunk 只是 `import` 它,这是打包产物的常态;切片表用**多源钉版**应对(写法见本节末《切片表的两种扩展形态》形态一)。多源之后有一条三件套没写、必须补上的作用域约束: - **两个 chunk 是两个 ES 模块作用域,压平就会撞标识符。**(实证:`case-studies/porting-discipline.md` §2.2) - **改名任意一个都终结逐字性**,所以正解是**不要压平**:**切片的输出文件必须与源 chunk 一一对应**,一个源 chunk 一个生成模块,主模块跨边界 `import`。这恰好就是 bundle 自己 L8 那句 `import { R as Pa }` 在做的事——不压平反而让移植更贴近源站结构。 - **`--balance-check` 也要按 chunk 分开解析。** 把多个 chunk 的生成物合在一起喂 `new Function()`,等于在检查阶段重新制造同一次撞车,给出的是与真实加载行为无关的误导性结果(假红,或红在错误的位置上)。 - **判据(什么时候必须建独立模块)**:**当你要从第二个 chunk 取的东西超过"一张常量表"时,先给它建自己的输出模块,再切。**(实证:`case-studies/porting-discipline.md` §2.2) #### 切片可行性判据:边界受源站**声明结构**约束【shopifydesign】 **一个符号能不能切,取决于它所在的顶层声明语句是否整体可切**——不是想切哪行就切哪行。压缩器把互不相关的东西并进同一条 `const` 逗号链是常态: - **一行里同时装着上一个声明的收尾和下一个模块的 banner**,这是压缩产物的常态(实证 1–3:`case-studies/porting-discipline.md` §2.2)。 **推论:起点/终点按语句判断,不按行判断。** **banner 归谁必须写进切片表的 `note`**——不写,下一个人切邻居时不是重复带入就是整段漏掉,而这两种错都不一定被平衡检查抓到(banner 是注释,括号照样配平)。 三条合法出路(按优先级):① **整块切**,连带把同块的依赖一并移植;② 在**转写文件**里重新声明并注明源行号,登记进 §6 偏差表(⚠ **版权纪律会堵死这一条**,替代解法见下);③ 该符号继续挂桩(§6.2),等整块能切时再落地。 **绝不偷偷补 keyword。** 补 `const` 的那一刻逐字性就没了,而且这种破坏没有任何门抓得到——在 `diff` 里它只差一个词。**唯一的例外是切片器显式声明的 `wrap`,三条区别见下。** #### 切片表的两种扩展形态:多源钉版与 `wrap`【shopifydesign】 **形态一:多源钉版——符号住在另一个 chunk 里。** - **写法**:切片表的一行可以指定它的源 chunk(`src: "siteHeader"`),**每个源各带自己的 sha256 守卫**;再配一张 `CHUNK_RENAMES`,把两个 chunk **自己写的**那两句转写成一句绑定——`SiteHeader` 里的 `export { Ye as R }` 与主 chunk L8 的 `import { R as Pa }` 合成 `const Pa = Ye;`。**跨 chunk 之后两边仍然是逐字的**:加的那一句是源站两句导入导出的等价物,不是发明。(实证:`case-studies/porting-discipline.md` §2.2) - **规模一超过"一张常量表"就必须先建独立输出模块**——模块作用域是硬约束,见上一节。 - **前置输入**:跨 chunk 的"符号 → 源 chunk → 导出名 → 主 chunk 内名 → 行号"对照表在阶段 1 就该记进逆向笔记(`reverse-engineering.md` §2.1)。 **形态二:`wrap`——把"补一个声明符"这件事显式化。** 上面三条出路里的第②条(在转写文件里重新声明)**会被版权纪律堵死**:在"源站字节不入 git"的项目里(该项目 D12),把 30 首曲目的数据表、整张文案表手抄一份进 `src/`,恰好违反的就是这一条。**正解是让切片器显式地做,而不是让人手抄**: - **写法**:切片表的一行声明 `wrap: { before: "const " }`,从逗号链**中段**起切时由切片器补上那个声明符。**加的字节只有那一个 `const`,写在切片表里、肉眼可见、进 diff、进 review。**(实证:`case-studies/porting-discipline.md` §2.2) - **与 §7 坑 11"偷偷补 keyword"的三条区别(缺一条就退回禁令)**: ① **补的位置是显式声明的**——它在切片表里,不是为了让 `--balance-check` 变绿而临时塞进生成物的; ② **它替代的是源站同一条语句里被跳过的声明符**,语义等价——源站写的就是 `const a = …, Ev = […]`,跳过 `a` 就要还它一个 `const`;不是发明新语法、不是改写表达式; ③ **理由写进切片表 `note`**:跳过了哪几行、为什么跳(跳过的是 React 绑定 / 引擎不读的文案)。 - **配额判据**:`wrap` 是例外机制,用量要小到能逐条数得过来(61 段里 2 段)。**一旦某段代码要靠一串 `wrap` 才切得下来,说明它该整块切或该转写**——回上面三条出路重选,别把例外变成常规。 ### 2.3 GLSL / 魔数 / 数据逐字提取 - **GLSL 逐字拷贝、集中存放、头注声明**("**Do not edit by hand**")【oryzo】【lando】【noomo】(实证:`case-studies/porting-discipline.md` §2.3)。 - **逐字的直接收益**:证明 shader 与源站逐字一致后,像素差异排查即可**聚焦到编译参数/数据链**【noomo】。 - **魔数照抄**:"噪声种子、灰阶表、4×4 与 8×8 抖动矩阵、量化级数全是硬编码魔数,目测调不出来,只能逐字抄"【kimi】【oryzo】【samsy】【noomo】【lando】。 ### 2.4 把源码语义编码成可断言的 mode 字符串(可选进阶) rogier 在实现里嵌入 68 处 mode 字符串,把"当前遵循哪条源码语义"直接编码进运行时状态,如: ``` "source-yD-onProjectActive-spotlight-reveal-woosh-uReveal-before-look-directional" "source-Lo-update-renderTargetA-to-renderTargetComposite" ``` 探针脚本持有同一组 `source-<符号>-<行为>` 常量逐一比对——"实现遵循了哪条源码语义"从口头承诺变成自动回归项【rogier】。移植复杂状态机/渲染链时值得采用:写实现的同时就把语义锚点留给阶段 3 的验证门。 ### 2.5 端口怎么被加载,是端口的一部分:三种交付形态【milknetwork】【raycastkbd】【hubtown】 移植完的代码要**替换**源站的某个产物进入页面,而"以什么身份进入"不是包装细节——选错形态的失败发生在运行时深处,栈指向端口、只字不提加载方式。三种形态,判别器是**模块容器与跨 chunk 依赖**: | 形态 | 适用 | 做法 | |---|---|---| | **独立运行时** | 端口的 require 闭包**自洽**(closure 门证明,且无跨 chunk id——看 module-map 的 `externalRequires`) | `modules-to-src.mjs` 产出的 `registry.js + runtime.js + index.js`,自带最小 require 实现,整包替换 | | **chunk 形交付** | 端口跨 chunk require 别的分包(vendor 拆分是 webpack 常态) | 端口以**真 chunk 身份** push 进页面原有的加载器(`(self.webpackChunk<name>=…\|\|[]).push([["main"], modules, startup])`);**原 runtime 与 vendor 分包逐字节不动**,跨 chunk require 经真运行时解析。⛔ startup 必须**转写原 chunk 的启动尾**——`t.O(0, [vendor 名列表], () => entry)` 的延迟门保证入口在 vendor 注册完才跑,丢了这道门,app 会在 gsap 存在之前开始动画(与 Turbopack chunk 序言同族的加载序破坏) | | **verbatim 分层** | Vite/Turbopack 等 scope-hoisted 无容器产物,没有可整体替换的模块清单 | 不重打包:移植件以原名放 `site/`,其余原件留镜像经 `--fallback-root` 分层伺服 | | **逐字图 + 转写微运行时** | 端口必须活在**另一个应用外壳里**(重建的 Next app 内嵌源站编译组件),三种形态都接不上——没有页面级替换点 | 工厂逐字切出(`emit-verbatim`),在自写 `runGraph(factories, deps)` 微运行时下按需运行;**每个 runtime 助手字母的语义先从源站 runtime chunk 逐字转写,再落 shim**(§2.5.1);边界依赖(react/three 等必须单实例的)经显式 id→npm 登记表接桥 | **闭包"自洽"只对本 chunk 的注册表成立,它说不出这个 chunk 能不能独自运行**——先看 `externalRequires`,再选形态。(实证:`case-studies/porting-discipline.md` §2.5) #### 2.5.1 转写微运行时:字母语义从 runtime chunk 抄,不从调用点猜【basement】 第四形态的核心风险:turbopack 压缩 runtime 的单字母助手(`e.i/e.s/e.A/e.v/e.n/…`) 语义**只能从源站 runtime chunk(`turbopack-*.js`)逐字转写**——从调用点用法反推 会得到"看起来能跑"的错语义,错误在远处以完全无关的形状爆炸。两个实证: - **`A` 不是"异步 resolve",是"resolve 后调用"**。源站原文: `u.A=function(e){return this.r(e)(g.bind(this))}` —— A 边目标恒为 loader stub (`e.v(cb)` 注册的函数),A require 出它后**以模块 require 为参调用**,返回其 Promise。shim 若只 `Promise.resolve(require(id))`,next/dynamic 会把 stub 函数 当组件渲染 → React 深处 `t is not a function`,栈不指向 shim。 - **`n` 是 exportNamespace,不是 default 互操作 getter**。源站原文: `u.n=function(e,t){(r=t!=null?f(this.c,t):this.m).exports=r.namespaceObject=e}` —— 把当前模块 exports **整体设为**该命名空间(重导出模块 `function(e){e.n(e.i(<id>))}` 的全部语义)。猜成 getter,重导出模块导出空,远处 React #306(lazy 解析到非组件)。 配套判据与陷阱: 1. **`e.v(值)` 三形态,靠消费方消歧**:worker 工厂(函数,消费方 `e.i` 取出后 带业务参调用)/ css-module 类名表(对象,消费方取 `.default`)/ loader stub (函数,消费方 `e.A` 以 require 为参调用)。v0.3.3 说"stub 不进叶图"是错的: stub 就在组件闭包里,其 resolve 目标与目标闭包必须同图在场(`l` 才能空转)。 2. **registry 顶替一个 id 前,读它在每个 chunk 作用域里的注册体**——含镜像 从未静态抓到的懒加载 chunk(v0.3.3 钉出的 31 个)。(实证:`case-studies/porting-discipline.md` §2.5.1) 3. **id 碰撞陷阱**:你自己的 turbopack 构建对相同 node_modules 路径派生**与源站 相同的数字 id**。调试自家编译产物时,`e.s(…,847851)` 可能是你的模块也可能是 源站原件——判据是它注册在**谁的 chunk 注册表**里,不是数字本身。 4. 闭包走查的 require 形态是 `.i(`/`.r(`/`.A(` 三者同权(module-map.mjs 已收); 手写临时 grep 只匹配 `.i(` 会在运行时以"依赖未映射"补课。`t` 不是模块边 (源站原文:node require 直通,浏览器侧 throw)。 5. ⛔ **worker chunk 的供片前缀是烤死的**:worker 侧 runtime 的 `registerChunk` 以硬编码前缀(basement 实测 `r="/_next/"`)把 `otherChunks` 路径转"等待键", 与已注册 chunk 的真实 src 比对——从任何其它前缀供片(如自建的 `/origin-runtime/`),键永不相遇,**entry 静默不执行**:chunk 全加载、 URLS 被 pop 清空、零监听、零报错。修法 = 按源站前缀供片(镜像 immutable 目录软链进 `.next/static/`,与自家构建产物子目录不冲突)。死状签名值得 背下来:「全部注册完成 + 无人监听 + 无异常」= 先查前缀,不是查代码。 6. ⭐ **Worker 对象上空字段的 error 事件,第一嫌疑是脚本 URL 本身**:worker 脚本加载失败产生的 error 事件没有 message/filename/lineno,长得和"跨域 脱敏"一模一样——先 curl 一下 worker 脚本 URL 再谈别的(basement 实测: 构建重建吞掉软链 → 间歇 404 → 被误判为 draco 跨域错误登记了一轮)。 7. **worker 静默死的解剖工具**:CDP `Target.setAutoAttach`(flatten + waitForDebuggerOnStart)平铺附加进每个 worker,恢复执行**前**注入 进/出消息账本与监听计数,页面侧再包一层 Worker 构造器记录双向 `{type}` ——三层账本对齐后,"哪一环没发生"一目了然(worker-probe 形态,零依赖, 基建复用 probe 家族的 chrome 生命周期库)。 ### 2.5.2 逐字图交付的三条硬规则【darkroom】 1. ⛔ **全站一张图,单例只有一份**。按组件分图会把共享模块(tempus/lenis/theme/orchestra 一类 单例)在每张图里各实例化一次,像素门以**相位漂移 / 状态反相**报出(contact 2.59 / privacy 0.39 → 全站单图后 0.00)。全站单图(或 basement 式显式 ported 单例映射)是必须,不是优化。 2. ⛔ **浏览器 chunk ≠ 服务端 chunk**。模块作用域裸 `window`(无 `typeof` 守卫)在源站 SSR 未执行(SSR DOM 证明),而逐字工厂在 Next 的 Node 渲染里会跑——登记变换 **T-SSRGUARD** (`tools/ssr-guards.json`,精确字面替换、命中数须为 1、verbatim 文件头注标记)恢复 `"undefined" != typeof window &&` 守卫,是"证明未执行"的最小等价。查法:缩进层 + 浏览器 全局 + 无 `typeof` 的行普查(4 命中 3 假阳性,逐条读)。它在 build 期就暴露,比运行时便宜。 3. ⛔ **坏字节不能进编译器**。源站可能下发语法错误的懒 chunk(darkroom Q6:dev 工具 chunk,V8 报重复声明 `r`)——照抄失败语义:用**同形抛错工厂**顶替(加载到它时抛与源站同类的错), 而不是修字节或删引用。 ### 2.6 chunk 形交付的入口文件规范 (承 §2.5 第二行。)入口文件唯一的职责是**转写**,不是发明: 1. `import { modules } from "./registry.js"` —— 模块工厂原样,键为原 id; 2. push 的三元组 `[[chunk 名], modules, startup]` 里,chunk 名与 startup **逐 token 对照原 bundle 尾部抄**(`t.O` 的 vendor 名列表、入口 id、双入口时的求值顺序——milknetwork 的 startup 同时跑 `index.js` 与 `index.scss` 两个入口,漏后者会掉 CSS 注册); 3. 文件头注声明"startup TRANSCRIBED from 原 bundle 尾部"并贴原文——这是 §2.1 行号区间映射在入口文件上的形态。 ## 3. 数据资产:脚本从 bundle 抽取入库,禁止手抄 - **规则**:数据类资产(i18n、文案、布局表、动画配置、作品清单)写脚本从 bundle 抽成 JSON 入库;**生成物不手改**,要改就改脚本重跑。 - **范本【kimi】**:`extract-i18n.mjs` 用**括号配平**定位对象字面量 + **隔离 vm 求值**抽成 JSON,两语言键集交叉校验(80=80)。(实证:`case-studies/porting-discipline.md` §3) - 为什么不手抄:手抄错误无法审计也无法重放;脚本抽取可重跑、可交叉校验,且连源站的错误都保真。 ## 4. 三张登记表制度 REBUILD_PLAN 固定维护三张表(lando 定型,各项目同构【lando】【kimi】【noomo】【samsy】): | 表 | 内容 | 判据 | |---|---|---| | **§0 纪律表** | 本文件 §1 的五条宪法(lando 的六条版本为蓝本,多一条"每里程碑浏览器实测") | 开工时写死,全程不改 | | **§6 偏差表** | 有意偏差,逐条四要素:源站怎么做 / 我们怎么做 / 为什么 / 什么条件下重新考虑 | **没登记的差异一律视为 bug** | | **§Q 怪癖表** | 源站 bug/死代码/怪写法"照抄不修"的登记,每条带 pretty 行号证据 | 照抄也要留痕,防后人"顺手修好" | 裁决规则:复刻与源站的任何差异,只有三个合法去处——§Q(源站怪癖,我们照抄了)、§6(有意偏差,已登记四要素)、bug(立即修)。**不存在第四类。** ### 4.1 ⭐ 每张表都要有一道反查它的门:表会悄悄漂在现实前面【objectarchive】 **适用对象是"记录状态的表"的全体**:§6 偏差表、§Q 怪癖表、构建层的**变换表**(`dom-shell-strategies.md` §2 步骤 3)、M1 的**分层归属表**、任务/**销账(进度)表**。它们的共同点是——**表声明的是"现实的某一部分已经如何",而现实变了不会来通知它。**(实证:`case-studies/porting-discipline.md` §4.1) - **规则**:每张表都要有一道**从现实反查它**的门,判据双向——**表里 claim 的必须在现实里存在**(否则是虚报),**现实里有的必须在表里登记**(否则是漏记);**剩余未销账项每次跑门都打印**。表的正确性不许靠"写的时候很认真"。 - **反查的对象是产物 / 运行时,不是写表那个脚本自己的计数器**——计数器只证明"脚本以为自己做了什么"。实证形式见 `dom-shell-strategies.md` §2 步骤 3:变换表要从**产物字节**逐 hunk 反推,且**表里登记、却从未在 diff 里被观测到的变换同样判 fail**。 - **⭐ 「共用宿主 ≠ 覆盖」**:两项工作作用在同一个宿主(同一个 section / 同一个 DOM 节点 / 同一个文件)上,表面看像"顺手一起做了",而门究竟断言了哪一项,只有回去读那道门才知道。(实证:`case-studies/porting-discipline.md` §4.1) - **⭐ 更普适的一句:把"某项已完成"从一句话变成一条可执行断言的过程本身,就是发现它没完成的机制。** 所以建表不是文书工作:**每登记一条,就当场写出"哪道门 / 断言了什么 / 在哪个产物里看得到",写不出来的那条就是没做完。** 反过来,一张只活在文档里、没有门反查的表,它的可信度等于"上一次有人认真看它的那一刻"。 ### 4.2 ⭐ 复核必须是阶段固定动作:登记错误率是稳定量,不是偶发【objectarchive】 §4.1 那道反查门每轮都在生效,但它是**被动的**:一条登记只有在**被实际用到**的那一刻才会暴露它是错的。(实证:`case-studies/porting-discipline.md` §4.2) 四条纪律因此成立: 1. **逐字段复核,不是逐条复核。** 一条登记至少有三个**各自独立可错**的字段:**内容**(这件事是不是这样)/**坐标**(`B:<sha12>+<n>`、`pretty L####` 指的是不是那一行)/**结论**(照抄不修 / 已覆盖 / 判定无影响)。**改对一个不会顺手改对另外两个**——复核时逐字段打勾,别在"这条我上轮看过"上省事。 2. **一次更正不等于整条可信:更正过的条目重新进复核池,而不是移出。** 更正动作本身就是"有人在这条上翻过车"的证据,它的先验错误率**高于**平均,不是低于。 3. ⭐ **更正必须回一手来源重新取证,禁止基于上一版做增量修正。** 症状极好认——**被质疑的那个字段改对了,同一条里其余字段原样照抄**(M3b 改对了内容、坐标原封不动;M3c 改对了两个坐标、第三个坐标原封不动)。操作化四条: - **取证材料只能是一手来源**:源站字节 / `_pretty` 切片 / **钉死快照**里的镜像 HTML(坐标系与快照钉版见 `reverse-engineering.md` §0.1.1)。上一版登记、分层归属表的 note、上一轮日志、本轮任务书**都不是来源**——它们是**待验对象**,拿它们当依据就是把上一轮的错误又抄了一遍。 - **更正时把整条重验一遍**,不只改被质疑的那个字段:内容 / 坐标 / 结论三项各自重新落一次证(纪律 1)。**改一个字段的成本是重读那几行,重验整条只多几分钟;漏验的那个字段会再活一整轮,而且下一轮它顶着"刚更正过"的光环。** - **坐标逐行数到边界那一行,不按印象取区间。** 若这段逻辑有**姊妹条目**(同一张表 / 同一段逻辑在别处的副本),两条必须**平行**;不平行就说明其中一条错了(本例姊妹条目 `B:735c258faf0a+109..+110` 一直是对的,平行性一比就能看出该改哪边)。 - **更正也要留痕**:日志里写清"这一次是从哪份一手来源重数的"。下一轮抽验时它排在队列最前(纪律 4)。 4. **把复核变成阶段的固定动作,而不是事后补救。** 每个里程碑**开工时**(不是关账时)从三张表里**抽验 N 条**(实测 N = 3–5 就足以每轮见血;M(n-1)a 抽 18 条抓到 1 条、M(n-1)b 抽 5 条抓到 **2** 条,**抓到的全部落在排在最前的那一格**)。抽样优先级是**有序的,不是并列项**: - **① ⭐⭐ 上一轮刚改过 / 刚加过的那几行——第一优先级,写死。** 它包含两类:**刚更正过的**(纪律 2、3:更正动作本身就是"有人在这条上翻过车"的证据)与**上一轮全新登记 / 新补的那一列数**。(实证:`case-studies/porting-discipline.md` §4.2) **本轮新写的登记,下一轮排队首位**——不是等它被质疑才复核。 - ② **最近被引用过**的(正在或即将被拿去下指令); - ③ **跨里程碑存活最久**、从未被任何门碰过的; - ④ **被用来下过硬指令**的——"门要断言 X 消失"这类,它错了会连带把门写错。 复核结论**写进本轮日志**:核了哪几条、改了哪个字段、哪几条退回队列。 **⭐ 错误形态学:坐标比内容更容易错,抽验时按这个分配注意力【objectarchive】**(实证:`case-studies/porting-discipline.md` §4.2)。机理不是"数字难抄",是两类字段的**暴露机制不同**: - **内容错会在使用时被行为暴露**:照它写的门一跑就红(M3b 那条"预览静默消失"一进门就不成立)。所以内容错**活不过它被使用的那一轮**。 - **坐标错只在有人回去核对时才暴露**:`B:<sha12>+<n>` / `pretty L####` / 几何读数**不进任何断言**——门照样绿、报告照样引用它。**没人回去数,它可以活到项目结束**(Q2 的坐标连错三轮正是这么活下来的;那条差 1 px 的几何数则是靠"它是末检查点"才被撞见)。 两条推论:**① 抽验时把注意力压在坐标字段上**——内容有门帮你看着,坐标只有你;**② 凡是被写进报告、被拿去下指令、或被某道门当作位置输入的坐标,一律回一手来源逐行数到边界那一行**(纪律 3),不按印象取区间。 **为什么必须是开工时**:**表错一天,门就照着错的写一天**;开工时复核的成本是十分钟读源码,关账时才发现的成本是一道方向写反的门(外加它产生过的全部绿灯)。(实证:`case-studies/porting-discipline.md` §4.2) ## 5. 里程碑推进与提交纪律 ### 5.1 依赖序推进 + 先竖切 - **依赖序**:元系统 → 场景/组件 → 页面专属逻辑。(实证:`case-studies/porting-discipline.md` §5.1) - **先竖切一条端到端链路**:竖切最早暴露架构级错误(实证:`case-studies/porting-discipline.md` §5.1)。 - **竖切必然踩到"逐字函数调用了尚未移植的子系统"**:处理办法是给缺失符号绑响亮桩,**不是删掉调用**(删了逐字性就没了,而装配顺序正是竖切要建立的东西)。桩的两种形状、缺失清单的两本账、删桩流程见 §6.2【shopifydesign】。 ### 5.2 每里程碑验收后才进下一个 - 浏览器**全新加载**实测(禁止手动切效果——"手动切换会掩盖初始化状态 bug"【kimi】;oryzo 的 NaN 传染 bug 只在冷启动暴露【oryzo】)、零控制台错误、截图取证。 - 已建立的底层验收门保持全绿(实证:`case-studies/porting-discipline.md` §5.2)。 ### 5.3 成对提交 + 日志四要素 每个里程碑一对提交(`Port xxx` + `Update rebuild plan: xxx`),日志固定四要素【kimi】【samsy】【noomo】: 1. **产出**(做了什么,带行号); 2. **验收**(跑了什么门、结果数值); 3. **教训**(本轮踩坑与根因); 4. **下一步断点待办**(带精确行号,如 samsy M7a 待办 "字体管理器 **pretty L60740-L60844(未读)**")——这是跨会话/跨人交接的入口【samsy】。 ### 5.4 里程碑关闭后的重开判据(Phase gate) - Phase 关闭后不许"因为老了"或"看着可疑"重开——**只有把新问题的 owner 归属到具体源码路径才允许重开**。rogier 的 Reopen Queue 五步:按 owner 分类 → 先查 bundle 证据 → 扣除镜像重写后对比线上 → 窄补丁 → focused 验证 + 共享回归门【rogier】。 - 按改动区域定义**最小 gate 集合**映射表(改 Home WebGL → build + 渲染器审计 + 双视口输出探针;改路由 → focused route 探针 + 受影响页面 gate……),每个 batch 只跑受影响的最小集合,避免全量回归拖慢节奏【rogier】。 ## 6. 临时代码生命周期标记 - **规则**:一切 shim/stub 必须显式标注生命周期,否则视为未登记偏差(= bug)。(实证:`case-studies/porting-discipline.md` §6) - 收口时冷头评审要做"零 TODO/stub 审计"(noomo M7c)【noomo】——生命周期标记就是那次审计的对账清单。 ### 6.1 no-op stub 必须同时是合法的 classic script 与 module(硬规则) 替换外部脚本的空 stub 文件,**必须在两种加载模式下都能解析**。同一个 stub 文件常被多处引用,而这些引用未必都带 `type="module"`: - ❌ `export {}` —— 被 **classic**(非 module)`<script src>` 加载时抛 `SyntaxError`,脚本整体不执行。后果会级联:源站后续代码假定该模块已注册全局对象,于是变成 `.init` on undefined 的崩溃,错误现场离真因很远【racingshop】。 - ❌ `export default null`、顶层 `import`、顶层 `await` —— 同理。 - ✅ **纯注释文件**(如 `// no-op stub: replaces <原脚本名>, see REBUILD_PLAN §6 D6`)——两种模式下都合法、都无副作用。**首选**。 - ✅ 受保护的 IIFE(`(function(){ /* no-op */ })();`)——需要 stub 真的建立某个全局占位对象时用。 判定方法:不要凭引用点当前的写法猜。grep 构建产物里**全部**指向该 stub 的 `<script>` 标签,确认 `type` 属性分布;只要存在一处不带 `type="module"`,就必须走双模式合法写法。 ### 6.2 竖切期的 pending 桩:两种形状、两本清单、删桩流程【shopifydesign】 竖切(§5.1)意味着已逐字移植的函数里必然留着对**尚未移植子系统**的调用。**根本纪律:桩是为了保住 verbatim。** 两条路——(a) 改逐字切片、删掉那些调用;(b) 保留调用,把缺失符号绑到桩文件(shopifydesign `src/engine/pending.js`)。选 (a) 就不再是逐字移植,而且"装配顺序"正是本里程碑要建立的东西,删完就无从验证。所以一律选 (b)。 #### (a) 桩的两种形状 | 桩形 | 写法 | 适用 | 每条必带 | |---|---|---|---| | **builder 型** | 返回逐字切片所期望的**空形状**(`CI()` → `{root: new Group(), cardGroups: [], layout: {cards: []}}`),让装配顺序端到端跑完、该子系统贡献零对象 | 被装配函数调用、返回值会被下游解构的构建器 | **源行号区间** + **最终必须返回的真实形状**(写进注释,替换时按它对齐键名) | | **per-frame 型** | **逐字复刻源码自己的早退守卫**(`if (!n) return;`、`n.clockModels && …`),守卫之后 `throw` 一条带符号名与源行号的错误 | 逐帧更新器、relayout、dispose | 源行号区间 + 守卫的出处 | per-frame 型为什么这么写:这类更新器在源码里**本来就守在一个集合上**,而该集合在竖切期恒为空,**守卫就是全部可达体**。守卫逐字来自源站,所以"这条路径可不可达"不是移植者猜的,是源站自己写的;一旦守卫被跨过,说明竖切期的假设破了——此时要的是**带行号的响亮失败**,而不是静默的错误画面(与 `gate-failure-modes.md` §1 的防呆同源:静默通过比红更贵)。(实证:`case-studies/porting-discipline.md` §6.2 (a)) - **文件名里别写里程碑**:`pending-m3.js` → `pending.js`。条目一旦熬过那个里程碑,文件名就成了谎话【shopifydesign】。 - 桩同样受 §6 生命周期标记纪律约束:每条桩 = 一条"将被 M_N 的逐字移植取代"的登记,收口时清零。 #### (b) ⭐ 缺失清单要有两本:桩文件不是全部 桩清单是从"**逐字切片里出现的未定义符号**"倒推出来的,因此它对**纯 effect 型子系统是盲的**: (实证:`case-studies/porting-discipline.md` §6.2 (b)) 所以竖切期必须同时维护两本清单: | 清单 | 建立方法 | 覆盖 | |---|---|---| | **① 符号桩清单**(引擎侧) | **可机械生成**:自由标识符扫描(见 (c) 第 2 条)的产物减去三方别名与已声明符号,剩下的就是要挂桩的 | 逐字切片**调用得到**的一切 | | **② effect 清单**(框架层) | **只能人工点名**:从 hydration 入口组件沿组件树走一遍,把每个 `useEffect`/`useLayoutEffect`/生命周期钩子**逐个登记**——{源行号区间 / 它写什么(DOM、store、全局事件) / 状态:已移植 \| 挂桩 \| 未移植} | 没有任何调用点的**纯 effect 副作用** | 建 ② 的三条要求: - **不许用"grep 引擎符号"来建**——那等于把 ① 的盲区再跑一遍。必须按组件树点名,一个 effect 都不跳过;"这个看起来只是埋点"也要登记,登记成"未移植·判定无影响"。 - **把自定义事件名当第二条线索**:挂在 `site-ready` 这类自定义事件上的 effect,调用点在事件派发处,组件树上看不出耦合——把源站自定义事件名逐个 grep 监听点,补进 ②。 - **桩文件头必须写明"本文件不是缺失清单的全部,它只是切片能看见的那一部分"**;里程碑关账时**两本一起清点**,缺失清单 = ① + ②。 #### (c) 删桩流程 1. **先关门,再按依赖序删桩**——不是"从桩文件里挑一个最小的删"。从上一个里程碑**延后/飘红的字段**出发问"哪个子系统挡着门",那才是本轮的第一个目标。(实证:`case-studies/porting-discipline.md` §6.2 (c)) 2. **删之前跑两道机械检查**(都不需要浏览器,秒级): - **切片平衡检查**(`scripts/extract-source.mjs --balance-check`,已内置;原理:剥掉 import/export 后把生成物全文喂给 `new Function()` 解析)。它抓的是**切片边界错**。(实证:`case-studies/porting-discipline.md` §6.2 (c)) - **自由标识符扫描**:把新切片区间里的 token 减去(区间内声明的 + 已生成文件里声明的 + 别名表里的 + JS 全局),剩下的就是**待解析别名 / 需要新挂的桩**。**没有这一步,它们会变成 rAF 里的 `ReferenceError`,而且容易被误判成"引擎根本没跑起来"。** 这两道检查就是"删了才发现依赖没接上"的实际拦截点(实证:`case-studies/porting-discipline.md` §6.2 (c))。 3. **每删一个桩即成对提交**(`Port xxx` + `Update rebuild plan: xxx`,§5.3),**不要攒**。每条桩落地都要重跑门;攒着删会让"门变绿/变红是谁造成的"失去归因。 4. **删桩要连带它遮住的邻居一起看**:builder 桩一落地,原本走空数组的循环立刻变成有内容的循环,此前从未触发的 per-frame 守卫会第一次被跨过。 per-frame 桩的价值**在被删之前的最后一刻才兑现**(实证:`case-studies/porting-discipline.md` §6.2 (c))。 5. **替换成本**:builder 桩低——它已把返回值形状钉死在注释里,替换时只需让真实端口产出同样的键,下游一行不改(`LB` 换成真实切片后,装配函数 `UB` 下游零改动)。 ## 7. 常见坑(移植坑) 1. **CSS 级联顺序即语义**【rogier】:源站把布局工具类(`.grid`、`.col-*`)放在样式表**末尾**,复刻放开头导致同特异性冲突全部反向解析、项目页媒体栅格坍塌。对策:逐字复刻连"规则出现的顺序"一起复刻。 2. **"先显示再动画"必闪帧**【rogier】:源站以 CSS opacity 0 附加新视图再 `fromTo(0→1, 0.5s)`,复刻直接置 1 造成闪帧——"时序即视觉"。对策:入场/揭示动画的初始态与触发时序逐事件对齐,用阶段截图(如 700ms/1200ms 两帧)验证。 3. **传递依赖反转行为**【noomo】:unhead 2.0.17 → 2.1.17 会反转 bodyClose 脚本顺序,破坏尾部字节序。对策:字节级验收失败时先怀疑传递依赖版本,用 `overrides` 钉死并登记偏差。 4. **Vite 分包使 `instanceof` 跨 chunk 失败**【oryzo】:同一类在不同 chunk 各有一份构造器。对策:改鸭子类型判定。 5. **avif 探测竞态静默 404**【oryzo】:格式探测竞态导致 gobo 纹理静默丢失。对策:资源加载失败不许静默,纳入零 404 门。 6. **构建器对 srcset 二次编码**【lando】:vite 把 `%20` 编成 `%2520`,7 张含空格文件名的图 404,自动门没抓到、人眼抓到。对策:postbuild 还原 + 全站 URL×磁盘全量审计 + 登记偏差;"srcset/style 内 URL 的编码保真需要纳入构建期对拍"。 7. **"好心修正"怪写法即引入 bug**:rogier 带符号取模、lando Q13 修 no-op 反而崩溃(见 §1.3)。对策:照抄 + 登记 §Q。 8. **自创补偿性 CSS 在 JS 对齐后反转成 bug**【rogier】(见 §1.2)。对策:反向扫描定期判罪复刻独有规则。 9. **框架行为无法配置时**:用等价机制对齐并登记偏差——noomo 用显式模板属性复现 Vue scoped 的 `data-v-*` hash【noomo】;samsy 手动指定源站原版 `__scopeId` 使源站编译产出的 scoped CSS 零改写生效【samsy】。 10. **把桩文件当成缺失清单**【shopifydesign】:桩由"未定义符号"倒推,纯 effect 子系统没有调用点因而没有桩——shopifydesign 的 `R5` 就这样缺失了两个里程碑。对策:另建 effect 清单,按组件树逐个 `useEffect` 点名(§6.2 (b))。 11. **切片时偷偷补 keyword**【shopifydesign】:符号被压缩器焊进别人的 `const` 逗号链,从中间切下来解析不了,补个 `const` 就"能跑了"——逐字性在这一刻消失且无门可抓。对策:整块切 / 转写并登记 / 继续挂桩,三选一(§2.2)。**唯一例外是切片器的 `wrap`**:写在切片表里、只补那一个声明符、`note` 里注明跳过的行与理由——三条区别见 §2.2《切片表的两种扩展形态》。**"转写并登记"这条出路在"源站字节不入 git"的项目里被版权纪律堵死**,那时用 `wrap`,不许手抄。 12. **跨 chunk 切片压平后撞标识符**【shopifydesign】:两个 chunk 是两个模块作用域,各自的 `$` 都合法,拼进同一个生成文件就是加载期的 `SyntaxError: Identifier '$' has already been declared`;改名任意一个都终结逐字性。对策:输出文件与源 chunk 一一对应、跨边界 `import`,且 `--balance-check` 按 chunk 分开解析(§2.2)。 ## 8. 阶段通过判据 每个移植里程碑关闭前自查: - [ ] 本轮全部改动都能写出 `pretty LNNNN` 归属(纪律 1.1) - [ ] 没有新增任何"源站没有"的规则/逻辑;如有补偿性代码,已删除或已走偏差登记(纪律 1.2) - [ ] 新遇到的怪写法已照抄并登记 §Q 带行号(纪律 1.3) - [ ] 新产生的差异已四要素登记 §6,或已修复(纪律 1.4) - [ ] 移植文件头有行号区间映射注释;GLSL/魔数/数据为逐字提取,数据类经脚本抽取且生成物未手改 - [ ] 走字节切片的部分:sha256 守卫在位(**多源切片每个源各一份**)、切片器 `--check` 通过、别名表每行有解析依据;**无任何手工"补 keyword"式切片**——用到 `wrap` 的每一段都在切片表里写明补了什么、跳过哪几行、为什么(§2.2);**vendor 岛整块切未按符号拆,与邻居共用一行的 banner 归属已写进切片表 `note`**(§2.2) - [ ] 跨 chunk 切片:**输出文件与源 chunk 一一对应**(没有把两个 chunk 压平进一个文件),`--balance-check` **按 chunk 分开解析**;从第二个 chunk 取用超过一张常量表的,已先建独立输出模块(§2.2) - [ ] 新增 shim/stub 已标注"将被 phase N 取代";竖切期新增的桩符合 §6.2 两种形状之一(带源行号 + 真实形状/守卫出处) - [ ] 两本缺失清单都已更新并清点:① 符号桩清单 ② effect 清单(按组件树逐个 `useEffect` 点名,含挂在自定义事件上的)(§6.2 (b)) - [ ] 本轮若删桩:删前跑过切片平衡检查与自由标识符扫描,且每个桩单独成对提交(§6.2 (c)) - [ ] 本轮碰过的每张表(变换表 / 分层归属表 / 销账表 / §6 / §Q)都被门反查过:claim 的在现实里存在、现实里有的都已登记、剩余项已打印;表里每条"已完成"都写得出"哪道门断言了什么"(§4.1) - [ ] **开工时抽验过 N 条既有登记**(**"上一轮刚改过 / 刚加过的行"是第一优先级,含刚更正的与全新登记的,不是并列项**),逐字段核**内容 / 坐标 / 结论**三项,**坐标字段格外查**(内容错会被门暴露,坐标错只有复核时才暴露),结果写进本轮日志(§4.2) - [ ] 本轮做过的每一次登记更正都**回一手来源重新取证**(源站字节 / `_pretty` / 钉死快照的镜像 HTML),不是照上一版增量修正;更正时整条三个字段一起重验,有姊妹条目的已核对平行性(§4.2 纪律 3) - [ ] 浏览器全新加载实测通过、零控制台错误、既有验收门全绿 - [ ] 成对提交完成,日志含四要素,断点待办带精确行号 全部勾选后进入下一里程碑;全部里程碑完成后进入阶段 3(验证收口:三重验证 + 冷头评审)。 -
readable-source.md 51.9 KB
# 可读源码阶段:从逐字移植到工程源文件 > **实战校准状态**:§0–§3.5、§6、§7 已在**两个**项目上跑通并回填——一个 14,233 行的自研 WebGL 引擎站【lusion】,与一个 23,130 行、原生 ES 模块加载、带公共导出面的站【shopifydesign】;后者打破了工具的五个隐含假设,见 §3.5,收口于 **389 个模块、中位数 18 行、三条路由 `meanAbsDiff 0.00`**——**§3.1 的初稿有两条断言被实测推翻,§3.1.1 的结论被 §3.1.2 推翻,均已重写并保留推翻过程**。§3.2(重命名,1,083 处证据命名)与 §3.3/§5(模块头事实档)亦已跑通并回填。§4.2–4.3(符号门 / 自包含门)亦已首次真跑并回填——**两道门第一次跑在真实数据上时,报出的红灯里多数是门自己的 bug**(符号门 3 条里 2 条、自包含门 4 条里 3 条),这条本身已写进 §4.5。本章现已全章经实战校准。 ## 0. 这一阶段解决什么 到 M(n) 收口为止,产物是**一份已证明正确、但人读不了的代码**。(实证:`case-studies/readable-source.md` §0) **目标**:打开产物就像打开工程师写的源文件——模块划分清楚、变量有名字、有必要的注释、复制到任何地方都能直接改和跑。 ⛔ **这一阶段的前置条件不可协商:必须先有全绿的验收门。** 没有裁判的重构是盲改;有了 `meanAbsDiff 0.00` 的裁判,每一步重构都能被证死。**这是重构能有的最好条件,也是它必须排在最后的原因。** ## 1. 三阶段产物契约 ``` mirror/ ① 只读证据 源站字节快照 永不修改 port/ ② 逐字移植 --check 守着字节一致 永不手改 src/ ③ 人写的工程 可读、可改、自包含 在这里工作 ``` **单向依赖,不可逆流**: - `src/` 的每一个符号都必须能指回 `port/` 的声明,`port/` 的每一行都必须能指回 `mirror/_pretty/` 的行号。**坐标系是三段接力的,不是两段。** - ⛔ **`src/` 里发现行为不对,答案在 `port/` 或 `mirror/`,不在 `src/`。** 就地"改到对"是这一阶段最容易犯的错——它把一个移植 bug 变成一个无法追溯的本地补丁,且**门会变绿**(因为你确实调对了)。正确动作是回上一段找归属行号,和纪律 2 完全一致。 - `port/` 在 `src/` 建成之后**不删除**。它是等价性的另一端,删了就没有可对照的基准了。 ## 2. 自包含契约 **定义**:把 `src/` 整个目录复制到任意路径,断网执行 `npm install --offline` 与 `npm run dev`,站点完整跑起来。 ### 2.1 与「不复制策略」的关系 ⛔ 必读 `asset-management.md` 的不复制策略**只作用于 ② port 阶段**——那是工作区,靠 `serve.mjs --fallback-root` 从只读镜像读资产,避免同样的字节在盘上存两遍。 **③ src 阶段必须复制。** 自包含是这一阶段的定义性要求,不复制就不成立。 ### 2.2 复制什么、以及什么理由可以不复制 | | | |---|---| | ✅ 可以不复制的理由 | **纯技术性**:不是文件 / 服务端不提供 / 需授权或登录态 / 体量确实超出仓库承载(此时改为构建期挂载,并在 README 写明如何获取) | | ⛔ 不能不复制的理由 | **任何法务理由**。"反正不公开"、"不该多存一份"、"可能有版权"——这些作用于**产物怎么被使用**,不作用于盘上是否完整 | ⛔ 这条是 `legal-and-deploy.md` §0.2 那条纪律在本阶段的投影。(实证:`case-studies/readable-source.md` §2.2) ### 2.2.1 ⛔ 资产目录的层级就是 URL 空间契约 **资产必须保持镜像相对路径不变**,落在 `src/public/` 之类的服务根下,**不要再套一层**。(实证:`case-studies/readable-source.md` §2.2.1) ⚠ **失败形态很轻,所以最难发现**:引擎照常启动、页面出得来,只是没有样式;CLEAN 门也不一定报(走了 fallback 就不计入 request failures)。**URL 空间是契约,不是实现细节。** ### 2.2.2 ⛔⛔ 自包含要按**账本**复制,不能按文档引用复制【airpodspro】 **文档扫描看不见脚本在运行时请求的东西**;账本才是完整性的权威(§2.2)。⭐ 自包含工具应当**从账本复制、用文档引用做交叉报告**,而不是反过来。(实证:`case-studies/readable-source.md` §2.2.2) ⚠ 复制账本时要**排除取证材料**:美化产物、账本自身、以及**被这次移植替换掉的那个源站 bundle**——把被替换的东西和它的替代品并排发出去,会让"到底在跑哪一个"变成读者要做实验才能回答的问题。 ⭐ 第三次失败最细也最容易重犯:副本**能跑但测不了**——服务器跟着走了,**确定性 shim 没跟着走**,于是每个检查点都报 `window.__pump never appeared`。**验证钩子走一半等于没走**(§2.4)。 ⚠ 报告未解析引用时要**分类**:范围外的**页面**链接(语言备选、站点级导航、带查询串被编码存盘的接口)是预期的;缺失的**资产**才是交付物的洞。一份不分类的清单没人会读——实测 180 条里 179 条是页面。⛔ 并且 `.html` 是页面,别把它按扩展名归进资产。 ### 2.2.3 ⛔⛔ 生成器里的硬编码值,是下一个项目的潜伏 bug【optimus】 ⭐ 共同的形状:**一个"生成的"文件里携带了上一个目标的事实**。它在第一个项目上不会红——那里的值恰好是对的;它只在第二个项目上现形,而且现形的方式往往是**指着正确的东西说它错了**。(实证:`case-studies/readable-source.md` §2.2.3) ⛔ 判据很简单:**生成器里出现任何具体路径、名字、外部依赖,都要问一句"这是这个项目的事实还是上一个项目的事实"。** 是项目事实就参数化,并且**让调用方在登记的命令里显式传**——`package.json` 里那行命令就是它的登记处。 ⚠ 还有一条值得单独记:**死代码不只是浪费,它还会说谎**。 ### 2.3 git 与盘的分离 **盘上完整、git 里默认排除源站字节**,两件事互不影响: ```gitignore # src/ 必须自包含,所以源站资产字节在盘上是完整的。 # 是否随仓库分发,是用户的决定,不是默认动作。 src/assets/** !src/assets/ASSETS.md # 账本留下:url → 路径 → sha256 → 归属 ``` `src/README.md` 里写明:资产不在仓库中,用 `npm run assets:restore` 从 `mirror/` 按账本还原。**这样克隆者拿到的是一个能自己补齐的工程,而不是一个坏掉的工程。** ### 2.3.1 ⚠ 资产可能不是"复制"而是"解引用" 不复制策略在 ② 阶段有两种实现,自包含的做法随之不同: | ② 阶段的做法 | ③ 阶段怎么自包含 | |---|---| | 服务层 `--fallback-root` 从镜像读 | 按账本**复制**进服务根 | | **符号链接**把镜像目录挂进产物 | **`cp -RL` 解引用复制整棵树**,符号链接必须归零 | §2.2 允许"体量超出**仓库**承载"作为技术性理由,但那说的是 **git**,不是磁盘——**契约是"复制到任何地方能跑",就得真的复制**。(实证:`case-studies/readable-source.md` §2.3.1) ### 2.3.2 ⭐ 无打包器的项目:可读源码就是部署产物 用 importmap + 原生 ES 模块加载的站,**浏览器读的就是人读的那一份**。好处是本阶段的价值直接可见(不用 sourcemap 就能在 DevTools 里读到真实模块);⚠ 代价是**模块数直接等于 HTTP 请求数**(实测 921 个)。生产部署要不要打包是**另一次登记式决定**,不是自动的。 ### 2.4 ⭐ 交付物要自带它的验证钩子 **把验证钩子一并纳入 `src/`**(开发服务器 + shim,都是零依赖的)。否则关于这份交付物,"它能构建"就是你能说的全部。(实证:`case-studies/readable-source.md` §2.4) ## 3. 四类操作,按风险从低到高 ### 3.0 ⭐ 先问一句:这个 port 需要拆吗 ⛔ **本节整套机制只对"扁平拼接"的 port 成立。** 它解决的是"几百个声明共享一个作用域、模块边界不存在、必须重建"。**如果源 bundle 是 webpack/rollup 打包产物,边界是打包器写下的、依赖边是显式的,这一整节都不适用**——实测一个 24,378 行的 webpack bundle 有 569 个现成模块(`reverse-engineering.md` §0.5)。 先判形态再往下读。判错的代价是把一套为不同问题设计的机制,套在一个没有那个问题的目标上。 ### 3.0.0 ⭐ 第三种形态:源码已经可读——不拆、不重命名【firstlaunch】 早年手写站(2013 时代:CoffeeScript/Compass 编译产物、原始命名、每文件一模块)没有"拆"与"提名"的问题——**文件就是模块,名字就是作者起的名字**。这一阶段随之塌缩: - **可读性增量只剩一种**:应用侧文本文件的**出处头注释**(provenance:捕获时间戳 / sha256 / 角色一句话 / 指回 engine-notes),以一条标记行收尾(`===== VERBATIM MIRROR BYTES BELOW =====`); - **等价门从双向单射符号表退化为一条 suffix 断言**:`src 文件 = 头注释 + mirror 字节` 的精确拼接(逐行验证头部只含注释、标记行之后逐字节等于镜像)——一条断言顶一整套符号门;`rename-map.json` 落一份**恒等声明**留档,说明为什么是空表; - **自包含照旧是定义性要求**(§2 全套:按账本复制、URL 空间不动、验证钩子随行); - ⛔ **别把"可读"当成"可改写"的许可**:CoffeeScript 产物的 `_i/_len/_results` 是行为字节,"反编译回 .coffee"是在发明一份不存在的源——结构性重写禁令(§3.3)在这里同样有效,且诱惑更大。 ### 3.0.1 ⭐⭐ 模块化产物:难点整体平移到「叫什么」【airpodspro】 边界免费拿到之后,剩下的问题是模块 id **是内容哈希**(`2439ebf272610f59b73f`),不携带任何信息。文件名是读者进入代码的第一个入口,所以这一阶段的主要工作变成**从证据里提名**。 ⛔ 规矩不变,而且在这里更硬:**没有证据就保留哈希 id。** 一个错的名字比哈希更糟——哈希会让人去看,错名会让人以为自己已经知道了。**不要为覆盖率去凑名字。**(实证:`case-studies/readable-source.md` §3.0.1) `tools/name-modules.mjs` 按下表提名,并把**提名所依据的那句话**一起写进 `docs/module-names.json`: | 级 | 证据 | 例 | |---|---|---| | 0 | **人读过之后写下的裁决**(override 文件,必须附上读到了什么) | `damped-value` | | 1 | 模块发布的全局,或它**给自己注册的名字** | `window.ExpressionParser =` / `share("AnimSystem")` | | 2 | **两个以上消费方各自把它存进同名字段** | `.uuid` | | 3 | 静态常量的**值**、拼名字用的 PascalCase 前缀、**单个**消费方的字段名 | `DATA_ATTRIBUTE = "data-anim-tween"`、`"TimeGroup-" + n` | | 4 | 错误信息里的**主语**类型名 | `KeyframeController` | | — | 无证据 | 保留 id | #### 3.0.1.1 ⭐ 最强的证据在模块外面:属性名在压缩中活了下来 局部变量全被压成单字母,但**属性名不会被压缩**(它们要跨模块访问)。(实证:`case-studies/readable-source.md` §3.0.1.1) ⛔ 两个必须挡掉的假阳性,都是实测撞到的: - **`X.field = M.method(...)` 命名的是方法的返回值,不是模块。** 只接受 `new M(...)` 与 `M(...)`——前两者用的是模块的**整体行为**,后者只用了它的一个切面。 - **左边的字段必须属于别人。** `M.pageMetrics.x = M.f()` 是模块在写自己的导出,不是有人在称呼它。 ⚠ 还有一个不是 bug 的坑:**单个消费方的字段名命名的是用途,不是类型。** 区分器是**佐证数**:≥2 个消费方各自选同一个名字是佐证,1 个只是当天的用法。所以单消费方要降级,并在理由里直说"这是用途的证据"。 #### 3.0.1.2 ⛔ 四个在这道工序上被数据推翻的想法 1. **按行号把模块文本切出来再解析** → 0/46 全部解析失败。每一片都以容器的分隔逗号结尾,`function(){...},` 不成立。⭐ **整个容器解析一次,直接取函数节点**——打包器已经解决了边界问题,按行号重切一遍等于自造一个它没有的问题。 2. **用正则做形态普查** → 两次普查互相矛盾(9 个"纯副作用" vs 0 个)。行首锚定漏掉了逗号序列里的 `e.exports =`。**美化不保证一行一语句。** 两个计数互相矛盾时,先假设两个都错。 3. **拿错误信息的前几个词当名字** → `you-cannot-create-multiple`、`attempted-to-parse-a`。**看着像信息,什么都没说。** 真名字就印在同一句里:**错误信息会点名抛出它的类型。** 4. **按类型名出现频次投票** → 把 TimeGroup 模块命名成了 `anim-system`。它的报错是 “TimeGroup not instantiated correctly. Please use \`AnimSystem.createTimeGroup(el)\`”——**一句报错通常点两个名:抛错的那个,和你该调的那个。** 频次是错的轴;主语在前,建议在反引号或 "use …" 之后。这是 `reverse-engineering.md` §0.4「计数只能找到候选」的第三个实例。 #### 3.0.1.3 ⛔ 去重规则必须看证据强度 撞名时"双双丢弃"看起来最保守,实际上**先毁掉的是你最好的证据**:一个 tier-1 的正确名字(模块自注册的 `share("AnimSystem")`)被另一个模块的 tier-4 猜测撞掉,两个一起变回哈希。 ⭐ 正确做法:每个模块产出**候选阶梯**(按证据级排序),全局逐级裁决——某一级上只有一个模块认领的名字判给它;**同一级**多个模块认领才算真歧义,全部落空,并且这个名字对后续级别一并作废(否则更弱的证据会把它悄悄捡回去)。输给更强主张的模块,自动落到自己的下一个候选。 ⚠ 人工裁决要能**存活到下一次运行**:override 文件是 tier 0,且每条必须附上"读到了什么"。⛔ **override 里写了一个不在切片里的 id 必须 FATAL 并给 did-you-mean**——凭记忆抄 id 抄错过两次,而**静默无效的 override 在 diff 里看起来和生效了一模一样**。 #### 3.0.1.4 ⭐ 多 chunk 站:按 canonical 位逐 chunk 跑三件套,提名之后要有"接受"这一步【darkroom】 name-modules / modules-to-src / verify-module-map 三件套认的是**单文件 map**(`MAP.source` + 一个 容器)。多 chunk 站(darkroom 60 chunk / 339 模块)的做法不是改三件套,是**按 merged map 的 canonical 位把闭包切成逐 chunk 的子闭包与子 map,逐 chunk 跑**(`tools/sourcify-chunk.mjs`); ⛔ 闭包 id 必须与 map **同型**(字符串)——数字 id 整批"not in map",报的是缺失不是类型。 name-modules **只提名,不决定**。提名之后要有显式的"接受"步(`tools/accept-names.mjs`): Turbopack 站的默认规则是**只接受 tier-1(打包器声明的导出名)**,其余保留 id——darkroom 278 模块中 105 个由此得名,其余 173 个宁可叫 `m<id>` 也不用低档证据;"错名比哈希更糟"在 接受步上才真正生效。逐 chunk 产物仍以 verify-module-map 对压缩原件 token 边界收口(43/43)。 Turbopack 三参工厂是 `(ctx, module, exports)`(runtime 的 `n(u, o, i)`),不是 webpack 的 `(module, exports, require)`——modules-to-src 按容器种类给包装形参命名,位置对而名字反同样是错名。 ### 3.0.2 ⭐⭐ 模块化产物拆成文件:三条会毁掉等价性的决定【airpodspro】 边界免费,但把 565 个模块拆成 565 个文件时,有三处走错就不再等价: **① 不要把 `require(id)` 换成静态 `import`。** 打包器的 require 是**惰性 + 记忆化**的——模块在第一次被要时才跑。ESM import 会被提升,在导入方的函数体之前就求值完毕,于是**每个模块顶层副作用的时机全部重排**。(实证:`case-studies/readable-source.md` §3.0.2) ⭐ 折中方案:**registry 静态 import 的是「工厂函数」**——导入工厂只是定义一个函数,不会运行模块体。于是文件树同步加载(不引入异步入口、不改变加载语义),而模块体仍然惰性执行。 **② 重命名包装参数要用作用域,不要用文本。** 把 `function(e, t, i)` 改成 `function (module, exports, require)` 是**零风险的可读性收益**——这三个名字的含义由打包器契约固定,不是推测出来的。但一个文本级的遮蔽判断在单字母名字上必然太粗:实测 565 个里拒绝了 381 个,只因为某处内部函数也有个参数叫 `e`。 ⭐ **AST 负责定位,文本切片负责编辑。** 用 generator 回写会重排字节,而字节就是移植本身。⛔ 且必须同时收集 `referencePaths` **和** `constantViolations`——前者不含写入,漏掉后者会把一个绑定改一半,产出**能解析、能跑、但是错的**代码。 **③ 运行时助手是契约的一部分。** 转写的 require 若只实现 `require(id)`,页面会抛 `require.r is not a function`——ESM 互操作模块靠 `.r/.d/.n/.o` 标记与接线。 ⭐ **「我看的那部分没用到」不等于「没用到」**——同一个错误在 M(n) 冷头清点那一层被防住了,这里发生在低一层。 ### 3.0.3 ⛔ 这个形状的等价门问的是模块,不是顶层声明【airpodspro】 `verify-symbols.mjs` 的前提是"顶层声明即单位",那是**扁平拼接**产物的形状。(实证:`case-studies/readable-source.md` §3.0.3) ⭐ 这里的门是 `verify-module-map.mjs`,问两件事:**一模块一文件**(不多、不少、不重),且**每个文件与打包器的字节 token 级一致**(只允许包装重命名那一组一一映射的差异)。 ⛔ 第一版用文本把重命名 undo 回去比对,565 个里报错 392 个——因为 `module.exports` 里的 `exports` 是**属性名**,文本替换分不清绑定和同名属性。**这正是拆分时必须用 AST 的那个区别,而门用捷径把它请了回来**:门测的是自己的捷径,不是产物。 ⭐ 改成 **token 流比对**,这个区别自动消失:属性两侧拼写相同、自然匹配;标识符只允许按契约的一一映射差异。⛔ 门也不许重新跑一遍重命名去比对——那是门在生产它所审计之物(`verification-gates.md` §2.1.2)。 ### 3.0.4 ⛔⛔ 模块 id 的**类型**是容器契约的一部分【v0-optimus】 分层表把每个 id 归一化成字符串是**故意的**——不这么做,webpack 里一个数值 id(`14:` )会因为下游全用字符串比较而永远选不中。⚠ 但归一化之后,**发射器必须把类型放回去**。 页面渲染成空白,而所有静态门(切片字节一致、外壳字节门、模块 token 门)**全绿**——它们检查的是内容,不是容器键的类型。(实证:`case-studies/readable-source.md` §3.0.4) ⭐ 抓到它的是**非空画面前置条件**:巡航门在每个检查点报 “a captured frame is effectively BLANK”,拒绝在空帧上给出比较结果(`verification-gates.md` §1.3 的那条防呆)。**一道拒绝在空帧上出结论的门,这次是唯一说话的门。** ⚠ 同一目标上还有一层教训:**两个发射器对同一件事给出了不同答案**——逐字切片器写裸 id(能跑),源码化发射器写 JSON 字符串(不能跑)。这正是 `verification-gates.md` §2.1.1「同一个答案只能有一份实现」在发射侧的形态。 ### 3.0.5 ⭐⭐ 符号门在真实产物上是一道**分类门**,不是双射门【landonorris】 `verify-symbols.mjs` 问的是"port 每个顶层声明在 src 中有且仅有一个对应符号"——那是**扁平拼接**产物的形状。在真实打包产物上它会大面积误报,因为**有些 port 声明按构造就不该有 src 符号**。 实测第三种形态:**esbuild 输出**。它既不是 webpack 的对象容器,也不是 Turbopack 的扁平列表——模块体裹在 `var X = VA(() => {…})` 这样的**惰性初始化包装**(`__esm`)里,提升的绑定以 `var a, b, c;` **逗号链**出现,还有若干 var 其实是 import/alias 绑定。 ⭐ 正确的门把每个 port 声明**分类进恰好一个**桶: | 桶 | 含义 | |---|---| | `declarations` | port 名 → **唯一**那个 src 声明 | | `collapsed` | N 个 port 名 → 1 个 src 声明(结构性重写,**必须登记在偏差表**) | | `plumbing` | 打包器包装 / 空初始化 / 命名空间对象 / import 或 alias 绑定——**按构造就没有 src 符号**,注明它落在哪个 src 模块的作用域里 | | `omitted` | 故意不移植,**每条都是登记过的偏差或怪癖** | 反向也要闭合:**每个 src 顶层声明**要么是上面某条的目标,要么在 `allow_orphans` 里带理由(src 独有的 TS 辅助、被提升成具名常量的字面量……)。 ⛔ 关键在于 **`plumbing` 这个桶必须存在且必须窄**。没有它,门在这类产物上会报出成片的假红,然后被无视;而如果它宽到可以随手扔东西进去,门就失去了意义——**所以每条 plumbing 都要写清它是哪一类包装**。 ⚠ 同样适用 `gate-case-design.md` §3.1:**每条断言都要打印 `n/N examined`**。 ⭐ 一般化:**门的形状要跟着产物的形状走。** 三种打包器已经给出三种单位——扁平拼接的单位是顶层声明,模块容器的单位是模块(`verify-module-map.mjs`),esbuild 惰性包装的单位是**分类过的声明**。照搬上一个目标的门,得到的不是判据,是噪音。 ### 3.0.6 ⭐⭐ 无容器 scope-hoisted 产物:不重写,切【hashgraphvc】 webpack 容器把边界写下来了(§3.0.1 只剩"叫什么");Vite/esbuild scope-hoisted 产物把边界**抹掉了**—— 几百个源模块拼进同一作用域,§3.1 的三条硬约束让任何"拆成 ESM 文件再 import 回来"的重写都成为 静默重排机。这一形状的正确产物是**拼接式分解**(`scripts/slice-esm.mjs` + `scripts/verify-reassembly.mjs`): - **切,不重写**:部件是原 chunk 的连续字节区间,**按序拼接逐字节等于原件**。求值顺序与作用域 构造性不变——不是"论证等价",是"就是同一段程序文本"。 - **切点只在可证明安全处**:深度 0 + 前一 token 为 `;`/`}` + 下一 token 起始声明语句。漏切只让 部件更粗,错切在此规则下不可能发生——"只在不可能错的地方下刀,其余一律并入前一片"。 - **门是一次哈希**:逐部件 sha + 拼接 sha + 对活原件三重比对。字节等价成立时,**全部运行时门的 裁决免费转移**——同字节,同程序;编辑部件内容一个字节,门当场红并点名部件。 - **文件名 = 声明自己的标识符**(一级字面证据),前置的注释行随其后的声明走(license banner 归下一片)。呈现层编辑(改名/挪目录)以"门保持绿"为许可判据。 - 执行侧**不变**:浏览器继续跑原 hash 名 chunk;可读层是被门钉死的另一视图,`census-bundles.mjs` 先出 chunk 级依赖图与坐标(sha/行数/import 别名——别名即命名证据)。(实证:`case-studies/readable-source.md` §3.0.6) ⭐ **目录分组(tools/group-parts.mjs)**:平铺部件可按共享标识符 token 折进域目录——前导规则只认大写开头的类族(小写动词族是字面但糊的桶),先按新布局重拼验 sha 再动盘;压缩名 chunk 证据不足即保持平铺,与命名同一条纪律:**分错比不分更糟**。census 的 chunk 依赖图(--md)是这一层的坐标页。 ⚠ 本节与 §3.1 分工:§3.1 管**容器/扁平 port 重写为真 ESM 树**(粒度三约束),本节管 "重写不可行"的那一形状;两者都以"先有裁判,再动手"为前提。 ### 3.0.7 ⭐ 手写移植 + 冻结快照当 port/:第一段的裁判是声明级点名【samsy】 skill 采纳之前完成的项目会长成这个形状:M2–M12 是**人手逐字移植**(带 `pretty LNNNN` 头注释),M13 把它冻结为 `port/`,`src/` 由重命名器从它派生。三段接力里 **src→port 有 token 门,port→mirror 只有头注释**——纪律 2 的第一段没有机器裁判,冷头评审靠人读了一个区间的 60 个类、发现一处真缺口,然后没人能复跑。 这一形态的正确裁判不是切片门(无字节可拼),是 `scripts/cold-audit-decls.mjs`:把 `_pretty` 应用区的每个深度 0 声明拿去问 port/src 的引用注释(区间含即 cited),问不到的必须进 `docs/cold-audit-overrides.json` 的一个桶(`collapsed` npm/addon/编译器产物顶替、`omitted` 登记死代码、`ported` 人工裁决点名文件)。**归桶的过程就是那份评审第一次被写下来。** 引用注释的坐标形状要统一(`pretty L…`、`@L…`、`L30456-64` 短尾),头注释常常比声明少一行——`--slack 1` 是实测出来的默认值。(实证:`case-studies/readable-source.md` §3.0.7) ### 3.1 拆模块 ⛔ 粒度不是自由选择 ⚠ **本节 v0.1.24 初稿写错过两条,实测推翻**:写的是"一个顶层声明一个文件是默认"和"循环依赖是边界切错的信号"。**两条都不成立**——`port/` 是**扁平脚本**,声明顺序即求值顺序,而 ES 模块按 import 深度优先求值。粒度由三条硬约束决定,不由品味决定。 **先出划分方案,人看过再切。** 工具应当输出"哪些单元必须待在一起、各是因为哪条约束",而不是直接开切。 | # | 约束 | 表现 | 后果 | |---|---|---|---| | ① | **互相引用** | 声明粒度上出现引用环 | 环内成员不能分处不同模块 | | ② | **求值顺序** | 裸语句在改原型、`const x = new Y()` 在做事 | 顺序变了行为就变,**且安静** | | ③ | **import 绑定不可赋值** | `_populated = !0` 写别处声明的顶层 `var` | 构建**直接失败** | **① 环几乎必然存在,而且是结构性的**。**它们不是画错的边界,是粒度选错的证据**。(实证:`case-studies/readable-source.md` §3.1) **② 两种直觉排序都错**: - **按首行排序** → 跨度宽的 SCC 会排到它自己包含的模块前面,凭空报出一堆"危险"前向边。`class X extends Y` 就能证伪:class 有 TDZ,扁平脚本跑得起来就说明 `Y` 在前。 - **拓扑排序** → 对依赖有效,但**无依赖的两个单元可以互换**,而实测有 **126 条裸语句在改原型**,它们的相对顺序就是行为,依赖图上没有任何一条边记录它。 ⭐ **正解:让模块成为源码上连续的区间。** 取每个 SCC 的 `[min,max]` 跨度、合并重叠、按源序排列。这样求值顺序被**精确保持**,不只是拓扑等价。前向求值边要合并到不动点;剩下指向**不含副作用的纯函数块**的前向边可以放过(提前求值无害)。 **③ 双向都要查,而且有第三种形态**:不只"我写别人"和"别人写我",还有**我夹在写者与被写绑定之间**——把中间的声明提出去,就把已合并的写对拆散了(实测:`class Ease` 夹在 `self$1` 的声明与 `self$1 = new Ease` 之间)。每个写对贡献一个**禁区区间**,提取不得落入其中。 ⭐ **约束③ 是唯一会响的**,前两条错了都是安静的。**它最晚被发现,却最该最早被想到**——纸上推完前两条再去构建,它会当场拒绝你。 ### 3.1.1 ⛔ 巨型模块可能化解不掉——一次被门推翻的尝试 一个巨型模块等于没拆,所以要试着再切。实测一次"听起来无懈可击"、而且构建通过的惰性 class 提取被门推翻:⭐ **是非空帧前置条件抓住的,不是差异抓住的**——**没有这条前置条件,这次失败会以完美收官的形态入库。** ⛔ **提取规则收紧无用**。(实证:`case-studies/readable-source.md` §3.1.1) ### 3.1.2 ⭐ 但"做不到"是错的——两次误判都是我自己工具里的 bug **真正的解法:延迟绑定少数几个绑定。** 锁死巨块的不是普遍耦合,是**少数在文件末尾构造、却被前面所有类的方法体读取的单例**(`Page` L1717 读 `pagesManager` L12851,而 `pagesManager` 的构造又读 `Page`)。把这些改为经**惰性注册表**访问,读取仍在调用时发生(本来就是),而 import 图变成无环。 **先测收益再破纪律**(`tools/late-bind-study.mjs`,贪心扫描): | 延迟绑定个数 | 模块数 | 最大模块 | |---|---|---| | 0 | 138 | 11,246 行(占 79%) | | 3 | 292 | 1,992 行 | | **6** | **389** | **1,013 行** ← 触底 | | 7+ | 410+ | 不再变化 | ⛔ **一个不成立的假设必须从它存在的每一处移除,而不只是从它第一次咬人的地方**;两个工具回答同一个问题却给出不同答案(实测差 9 倍),本身就是最强的信号。 ⭐ **一个不随你的修复而变化的指标,说明你没在修真正的东西**——这比任何事后分析都早地指向了正确方向。(实证:`case-studies/readable-source.md` §3.1.2) ⭐ **推广出去的纪律**:本 skill 已有"门不许生产它所审计之物"(`verification-gates.md` §2.1.2)。这次是它的**改写侧同构**——**凡是"改写产物 + 另算一份清单"的结构,清单必须由改写后的产物导出,或至少与改写器共享同一份真相。** 两个 pass 各读各的,其中一个会落后,而落后的那个不会报错。 ⛔ **结论替换为**:分解下界存在,但它**低得多**,而且**"做不到"这个判断本身极不可靠**——它同时是"工具有 bug"最常见的表现形式。**当结论是"这件事做不到"时,先怀疑测量它的工具,再怀疑对象。** ### 3.1.2.1 ⚠ "打散巨块"和"清零前向边"是两个量 收益曲线回答前者,§7 的判据是后者,**两者可以差很远**。(实证:`case-studies/readable-source.md` §3.1.2.1) ⭐ **先跑收益曲线选出该不该做,再用严格求解器算出实际要登记多少条。** 别拿曲线的数字去估登记成本。 ### 3.1.3 两个只有真去构建才会暴露的切分细节 - ⛔ **按字符偏移切,永远不要按行切。** beautify 过的压缩代码**一行仍可能有多条语句**:实测 L6347 是 `}(function(o, e) {`——上一条顶层语句的尾巴和下一条的头共用一行。按行切会让两个块**在那一行上重叠**,各自吐出半条语句,而 esbuild 的报错(`Unexpected "export"`)离根因很远。 - ⛔ **块与块之间不能留空隙。** 块若取 `[首单元起点, 末单元终点]`,**单元之间的空隙不属于任何块**——而**原作者幸存的注释正好住在那里**。让块首尾相接(`chunk[k].c0 = chunk[k-1].c1`)即可。⚠ 相接后块可能**从行中开始**,任何按行号计算的插入点都要以块的实际起始行为基准重算。(实证:`case-studies/readable-source.md` §3.1.3) **目录结构从命名反推,不要发明分层**:`GoalTunnelAstronauts` / `HomeBalloons` → `scenes/`;`Bloom` / `FlipSim` → `effects/`。⚠ 但**文件名里的顺序前缀是承重的**——它编码求值顺序。要改成按子系统分目录,必须把顺序显式留在 entry 的导入列表里。 ### 3.2 去混淆重命名(作用域安全) 必须用真正的作用域分析(`tools/demangle.mjs`,见 §6)。正则改名会误伤三类东西:同名局部变量、遮蔽、字符串/GLSL 源码里的同名标识符。 **⭐ 先测证据覆盖率,再决定做多少。** **目标不是消灭所有单字母,是给读者必须追踪的绑定命名**(被反复引用的、被解引用的)。(实证:`case-studies/readable-source.md` §3.2) **⛔ 作用域安全的重命名有四个坑,四个都是"构建通过、运行时才炸"**: | # | 坑 | 症状 | |---|---|---| | 1 | babel 的 `referencePaths` **只含读** | 写在 `constantViolations` 里。声明改了、`_ = x` 没改 → ESM 严格模式 `ReferenceError` | | 2 | 同一绑定被遍历两次 | `Scopable` 对多种节点触发且可能共享作用域,两套编辑重叠、部分站点新名部分旧名 | | 3 | **碰撞域取小了** | 按块、按函数都不够——**内层取到同名会遮蔽外层**,中间的引用静默指向错的绑定。**只有按整个文件去重才安全** | | 4 | **解构** | ① `const {array: e, count: t} = obj.position` 里 `init` 对两者都是 `.position`,都命名为 `position` 是**假的**,证据应取**解构键**;② `decl.node.id` 在解构时是 ObjectPattern **不是 Identifier**,于是**声明处根本没改名**、只改了引用 | ⭐ **通用教训:`decl.node.id` 不等于 `binding.identifier`,永远用后者。** ⛔ **不要把属性名算进变量的命名空间。** `this.url = o` 里的 `.url` 是属性名,把它当碰撞会让 `o → url` 退化成 `url2`——实测使**几乎每个 tier 1/2 的名字都带上数字后缀**,修正后后缀率从近 100% 降到 23%。 **名字从哪来**,按可信度排序: 1. **同作用域里已存在的真名**——参数传给了 `setBlockCount(e)`,那 `e` 就是 `blockCount` 2. **用法推断**——`e.attributes`/`e.index` 被 `setAttribute` 消费 → `geometry` 3. **逆向笔记**(`docs/engine-notes.md`)里已确立的术语 4. **源站同类代码的惯例**——three.js 的 `Vector3` 参数惯例叫 `v` ⛔ **拿不准就用描述性通用名,不要编造语义名。** `e` → `node` / `item` / `count` 是安全的;`e` → `shadowBias` 在你没验证过它确实是 shadow bias 时**比 `e` 更有害**——`e` 只是没信息,错的名字是**错的断言**,而且读者会信它。 保留 `docs/rename-map.json`(`port` 位置 → 旧名 → 新名 → 依据档位 1-4),它是 §4.2 符号门的输入。 #### 3.2.1 ⛔ 三类"有证据却仍然是假陈述"的命名(实测抽查所得) §4.4 说编造的名字是**门永远抓不到的唯一一类错**,所以只能人工抽查。(实证:`case-studies/readable-source.md` §3.2.1) | | 形态 | 为什么是假陈述 | 对策 | |---|---|---|---| | ① | `for (var a=0, b=0, n=…; b<n; b++) a += …; return a` 里把 `a` 命名为 `i2` | **"在 for-init 里声明"不等于"是计数器"**。`a` 是累加值、是函数返回值,`i` 却在说它是下标 | 只有出现在循环 `test` 里、或被 `++`/`--` 步进的变量才配叫 `i` | | ② | `el.getBoundingClientRect()` 的结果只读了 `.x/.y`,被形状规则判成 `v2` | **形状是"布局"的证据,不是"类型"的证据**。DOMRect、Euler、纯字面量都有 `.x/.y/.z` | 重叠的布局最具体的先判(rect 规则前置);更根本的是**初始化表达式比使用形态强**——返回类型无歧义的调用直接定名 | | ③ | 顶层循环的计数器叫 `j` | 深度计数差一:for 循环的作用域**就是**那个 ForStatement,从它开始数必然 ≥1。实测 **46 个文件**的顶层循环都拿到了 `j`,而 `j` 暗示"第二层" | 从**父**作用域开始数 | ⭐ **抽查是划算的**:425 条里出了三类真问题,而且改完之后①那个累加器**回到了未命名状态**——**无证据就不编造,才是正确结果**,不是遗憾。 ⚠ **写个排序器把量级压下来,但别让它替人判定。** ### 3.3 补注释(纯增量,零行为风险) 见 §5。⭐ **机械可写的部分比想象中多,而且全是事实档**:溯源行号(`src` → `port` → `mirror` 三段接力)、导出了什么、被哪些模块引用、依赖谁、实现了引擎的哪些生命周期钩子。这些**从代码本身读得出**,可以工具生成(`tools/annotate.mjs`)。 ⛔ **工具写不出的恰恰是"它是干什么的"**,而那正是 §5 禁止猜的东西。所以模块头默认只有事实与溯源,**并显式声明本文件不含推测**——留白比一句听起来合理的假话有价值。 ### 3.4 结构性重写 ⛔ 默认禁止 合并重复代码、提取公共函数、改算法、换数据结构、"顺手优化"——**默认一律不做**。 理由:这一阶段的全部合法性来自"等价可判定"。拆模块和重命名不改变求值语义,门是充分的;结构性重写会让门从"证明等价"退化为"没测出不等价",而门只跑有限条路由(§4.3)。 要做必须**逐条登记**(`REBUILD_PLAN.md` §6 偏差表),写清源站怎么做 / 我们怎么做 / 为什么 / 什么条件下回退。 ⭐ **纪律 4 在本阶段依然有效**:`port/` 里照抄的 bug、死代码、怪写法,`src/` 里继续照抄。你现在能读懂它了,于是"这明显是个 bug"的冲动会比任何阶段都强——**它依然可能是行为本身**。想修就登记,不要静默修。 ### 3.5 ⛔ 切分工具的五个隐含假设(在第二个项目上全部被打破) 第一个走完本阶段的项目恰好是最简单的形态:单一第三方来源、无公共导出面、打包器构建。**工具把这些偶然当成了必然**,换一个项目全部失效。移植这套工具时逐条检查: | # | 隐含假设 | 打破它的形态 | 后果 | |---|---|---|---| | ① | port 没有公共导出面 | port 末尾 `export { …146 个 }`,被手写转写层消费 | 切分产物不是原地替换,**消费者全部解析失败** | | ② | 延迟绑定的引用都能改写成 `registry.X` | `export { X }` / `import { X }` **不能容纳表达式** | 语法错 | | ③ | 延迟绑定名一律不需要 import | ②里未被改写的引用**仍然需要真 import** | `Export 'X' is not defined in module`。**判定要按位置,不能按名字** | | ④ | port 只从一个模块导入 | 4 条 import 声明 | 所有名字解析到第一个路径 | | ⑤ | port 的相对路径在输出目录仍成立 | `./sibling.js` 相对的是 **port 目录** | 404。⭐ **逐字复制对代码是对的,对路径是错的** | ⚠ 还有 ①的第二层:**port 可能重导出它导入的东西**,那些名字不由任何块声明,会被静默丢出公共面。 ## 4. 门 ### 4.1 现有门原样复用 M(n-1) 建立的全部门(CLEAN / 零外联 / DOM / 几何探针 / 像素)**目标从 `port/` 的构建产物换成 `src/` 的构建产物即可**,判据、容差、自比带宽全部不变。 ⛔ 容差不许因为"重构了所以有点差异"而放宽。**重构的定义就是行为不变**;放宽容差等于放弃这一阶段唯一的合法性来源。 ### 4.2 新增:符号映射门(`verify-symbols.mjs`) **为什么是必需而不是可选**:门只跑有限条路由,没被跑到的代码改坏了门是绿的。冷启动清点在 M(n) 解决过一次同构问题("功能测试测不出整块遗漏"),本阶段需要它的重构版。 断言: - `port/` 的每个顶层声明,在 `src/` 中**有且仅有一个**对应符号(双向单射,靠 `rename-map.json` 建立) - 没有孤儿:`src/` 里不存在无 `port/` 来源的顶层声明(**新发明的代码会在这里现形**) - 每个 `src/` 模块头部的源行号区间,能在 `port/` 的切片表里找到 ### 4.3 新增:自包含门(`verify-standalone.mjs`) 把 `src/` 复制到临时目录 → 断网 → 安装 → 构建 → 跑 CLEAN 门与零外联门。 ⛔ **必须复制出去跑,不能原地跑。** 原地跑会命中项目根的 `node_modules`、`mirror/`、根 `package.json`——这三样恰好是自包含要证伪的东西。这是 `verification-gates.md` §2.1 "门不许依赖它所审计之物"的又一个实例。 ⛔ **然后要把复制出去那份真跑起来对拍。** 门自己写着"构建通过 ≠ 正确",而实测它是对的:`--full` 全绿的同一份产物,起服务后冻结对拍才是最终判据(实测三条路由 0.00)。 ⚠ **静态扫描的三类误报**(实测第一次真跑,四条 FAIL 里三条是门的问题):`../` **本身不是逃逸**,要 resolve 之后看它落到哪儿(102 条误报全是 `../vendor-aliases.js` 落在 `src/` 内部);**账本与 README 里的路径是散文不是依赖**。⚠ 但散文**会过期**——`--mirror ../mirror` 这句话在目录被复制走之后就是错的,措辞要写成"指向一份镜像"。 ### 4.4 这一阶段特有的假绿 | 假绿 | 为什么门看不见 | 对策 | |---|---|---| | 编造的变量名 | 名字不影响求值,**所有门永远全绿** | §3.2 档位纪律 + **人工抽查**档位 2-5。⭐ **实测这不是理论风险**:425 条抽查出三类真假陈述,全部通过了逐像素 0.00(§3.2.1) | | 门覆盖不到的代码被改坏 | 那条路由没跑 | §4.2 符号门 + 保持 §3.4 禁令 | | `src/` 就地"修好"了一个移植 bug | 门确实变绿了 | §1 逆流禁令;差异必须回 `port/` 归属 | | 自包含只对已测路由成立 | 只有那几条路由的资产被复制了 | §4.3 断网 + `--walk` 全滚动走查 | | **拆分把页面拆挂了,而差异报 0.00** | 两侧都白屏时"一致"成立 | ⛔ **非空帧前置条件**——实测唯一抓住 §3.1.1 那次失败的东西 | | **改写器与它的 import 清单是两个 pass** | 清单从改写前的 AST 统计,改写后的引用**同时**还生成旧 import;构建通过、看起来只是"没优化好" | §3.1.2:清单必须由改写后的产物导出。**外部不变量**(前向 import 计数)在两轮"修复"里一格不动,就是它 | ### 4.5 ⭐ 门第一次跑在真实数据上时,先假设红灯是门的问题 | 门 | 它自己的 bug | |---|---| | 符号门 | 用**普通对象**做别名查找,`renames["toString"]` 命中 `Object.prototype` 返回原生函数。任何声明了 `toString`/`valueOf`/`constructor`/`hasOwnProperty` 的代码库都中招。**外部数据的字典查找用 `Object.create(null)` 或 `Object.hasOwn`** | | 符号门 | 期望的 schema 与实际产出不符时,读成"0 条"**照常 PASS**——在什么都不知道的情况下通过,正是它存在意义的反面。**形状不符必须 FATAL** | | 自包含门 | 把一切 `../` 判为逃逸,而 `../vendor-aliases.js` 落在 `src/` 内部 | | 自包含门 | 把账本与 README 里的路径当成依赖 | | 自包含门 | **硬编码"必有构建步骤"**——`npm run build` 对一个 importmap 站不存在,门于是在完全正常的产物上判 FAIL。**跑 package 实际声明的步骤** | ⭐ **不是"门不可信",是"新门的第一次运行同时在测两件事"**:被审计的东西,和门自己。**红灯先分诊,别先归因。**(实证:`case-studies/readable-source.md` §4.5) ## 5. 注释纪律 注释是**我们发明的**,所以它只能出现在 `src/`——`port/` 除切片器生成的行号头之外不加任何注释。这不与纪律 3(源站没有的不发明)冲突:`src/` 是显式登记的衍生物,不是对源站的断言。**但注释的内容仍受约束。** 三个档位,**写进注释里必须可区分**: | 档 | 允许写 | 写法 | |---|---|---| | **事实** | 从代码本身直接读得出的 | 直述:`// 每帧把 fbm 位移写进共享 uniform` | | **溯源** | 指回坐标系的 | `// port/engine.gen.js L6001-6042 ← mirror/_pretty/hoisted.CUO_IjfL.js L21883` | | **推测** | 逆向得出但未验证的 | ⛔ **必须显式标注**:`// 推测:这里的 2 是 fbm 的振幅上限,未验证` | ⛔ **禁止把逆向笔记里的推测写成陈述句。** `engine-notes.md` 有"事实 / 怪癖 / 复刻结论"三段式正是为了区分这个;搬进注释时档位不能丢。一条被当成事实读的推测,会在下一个人基于它做修改时变成 bug 的源头。 **模块头部固定三行**:源行号区间(`port` 与 `mirror` 两级)、一句话职责、非显然的依赖关系。 ## 6. 工具与门的依赖分界 ⭐ ⭐ **这条线是按阶段划的:源码化之前,整条流水线零依赖。** 复刻项目从 Step 0 到 M(n) 不装任何东西,**到 M(n+1) 才获得 devDependencies**。目录只是这条阶段线的投影: ``` scripts/ 判据 + 源码化之前的全部工序 零依赖 verify-* / probe / pixelcompare / module-map / closure / slice-modules tools/ 源码化阶段的重构器 devDeps demangle / split-modules / symbol-map / name-modules ``` **理由**:门是用来**证明**的,必须能被独立审查、能在任何环境跑起来、不能因为一个依赖升级而改变判据。重构器是用来**改**的,改错了有门兜着——而它们只在最后一个阶段出现。 ⭐ **前面的阶段确实需要真正的 parser 时,外挂而不是 import**:`spawn` 一个**钉死版本**的 npx(`js-beautify@1.15.1`、`acorn@8.14.0`),脚本自身零依赖、仍可独立审查,版本写死在文件里。⛔ **不要改成手写词法器**——本 skill 里试过一次,一个含引号的正则字面量把它带偏 16,177 行(F27)。 ⚠ **只写在文档里、没有东西去查的规矩会安静失效**——`scripts/verify-zerodep.mjs` 现在两个方向都查。(实证:`case-studies/readable-source.md` §6) 所以作用域分析用 babel 是允许的,而任何门都不许 `import` 它。⛔ 推论:`verify-symbols.mjs` 不许 import `tools/demangle.mjs` 的解析器来"确认"重命名——那是门在生产它所审计之物(`verification-gates.md` §2.1.2)。符号门只读 `rename-map.json` 和两侧的文本。 ## 7. 执行顺序 每一步单独提交,每一步跑全部门: ``` 0. 前置:把门跑一遍 ⛔ 见下,这一步实测最容易被跳过 1. 建 src/ 骨架 + package.json + 构建配置 门:全绿(此时 src/ 只是 port/ 的一层壳) 2. 复制资产 + 建 ASSETS.md 账本 门:+ 自包含门 3a. 出划分方案(不切),人过目三类约束的代价 — 3b. 按方案切模块(不改任何标识符) 门:全绿 + 符号门 3c. 若有巨型模块:先测延迟绑定的收益曲线(不改码) — 3d. 按最小完备集做延迟绑定,逐条登记进 §6 偏差表 门:全绿 + 符号门 ⚠ 判据是「前向 import = 0」,不是「构建通过」 4. 重命名,按依赖序逐模块推进 门:每模块提交前全绿 5. 补注释 门:全绿(应当零变化) 6. src/README.md + 复制走实测 门:自包含门在干净机器上过 ``` ⛔ **第 0 步不是形式**。**门的绿灯会随输入过期**。所以第 0 步的真实内容是:**把每条门固化成仓库里的脚本,然后跑,看它是不是真绿。**(实证:`case-studies/readable-source.md` §7) ⭐ **第 3 步和第 4 步必须分开提交。** 合在一起时,门一旦变红你无法区分是切错了边界还是改错了名字——而这两者的排查路径完全不同。3a/3b/3c 同理分开。 ## 8. 检查清单 - [ ] 前置:**每一条门与服务命令都固化在仓库里**(不是 shell 历史),且刚刚真跑过一遍 - [ ] 前置:M(n-1) 全部门在 `port/` 侧全绿,且残差已分类(不是 UNCLASSIFIED)——⚠ **绿灯会过期**,输入被重新生成过就要重跑 - [ ] 拆分:每一次改变粒度后都跑了**非空帧普查**,不只看 meanAbsDiff - [ ] `port/` 的 `--check` 仍然过(本阶段全程不许动 `port/`) - [ ] 符号门:双向单射,零孤儿 - [ ] 自包含门:复制到临时目录、断网、构建、CLEAN + 零外联全过 - [ ] 现有全部门在 `src/` 侧全绿,**容差未放宽** - [ ] `rename-map.json` 中档位 2-4 的条目已人工抽查(编造名字是唯一门抓不到的错) - [ ] 注释三档位可区分,推测均已标注 - [ ] `src/assets/ASSETS.md` 账本完整;`npm run assets:restore` 实测可用 - [ ] 结构性重写:若有,全部登记在 `REBUILD_PLAN.md` §6 - [ ] `src/README.md` 写明:这是什么、怎么跑、坐标系怎么读、哪些是我们写的注释 ## 9. 交付物不是一个页面【eightdesign】 整站移植有多少条路由就有多少个外壳,而且**移植自己的产物就摆在它们旁边**。只复制 `.html`,交付出去的站每一页都会向一个没跟着走的脚本发请求。(实证:`case-studies/readable-source.md` §9) ### 9.1 ⛔ 引用检查必须走那一套映射,不能自己再写一遍 引用检查必须走 `lib/urlpath.mjs` 那一套映射(查询感知、伺服层约定、百分号转义),不能自己再写一遍朴素的路径拼接。**第二个实现就是一次等着被报成窟窿的分歧。**(实证:`case-studies/readable-source.md` §9.1) ### 9.2 生成的 package.json 不许带一个无事可做的构建步骤 整站移植没有入口模块、没有打包步骤——它交付的就是打包器自己的 chunk。 一条 `esbuild index.js …` 的 build 脚本在这里指向一个不存在的文件, **那是上一个项目的形状穿着这个项目的名字**。没有 `--own` 就没有 build。 ### 9.3 ⚠ 工具的第一次运行必须能成功 `name-modules` 会读取自己上次的输出(那是人工命名的存放处)。目录还不存在时它直接崩, 而报错信息指着**输出**路径,读起来像缺了个输入。**一个第一次跑就失败的工具,没有人能开始用它。** ### 9.4 逐字 chunk 移植里,`src/` 与自足交付物是**两件东西** 单页流程里它们重合:`src/index.js` 打包出那一个 bundle,`src/` 既可读又能跑。 整站逐字移植里它们不可能重合——**能跑的是打包器自己的 chunk,可读的是被拆开的模块**。 于是两道门要分别指向: | 门 | 指向 | 问的是 | |---|---|---| | `verify-module-map` | `src/`(可读源码) | 每个模块是否与打包器字节 token 级一致 | | `verify-standalone` | make-standalone 的产出 | 这份拷贝自己是不是一个能跑的项目 | ⛔ `verify-standalone` 默认 `--src src`,在这个形状下会报"目录里没有 package.json"—— 它没错,只是问错了对象。**默认值是对一种形状的假设。** ### 9.5 `verify-fresh` 的主题可以整个不存在 它重新跑打包、比对字节,而整站逐字移植**根本没有打包步骤**。 写死的 `src/index.js` 让它以 FATAL 收场,读起来像交付物坏了。 ⭐ 现在它检查入口是否存在,不存在就**明说自己什么也没查**,并指出等价覆盖在哪 (`slice-modules --check` 加 `build-site --check`,本项目 23/23 一致)。 **一道门沉默着退出 0,和查遍一切之后退出 0,从外面看是一样的——而只有一个有意义。** #### 9.5.1 ⭐ Next 工程的 verify-fresh 形态:链上没有 bundler,但有 buildId【darkroom】 C1 重构工程的新鲜度链是 `src-modules/ + app/ → next build → assemble-static`。判据不变 (重新生成、比字节、不看时间戳),前提多一条:⛔ **`generateBuildId` 必须钉死**——随机 buildId 让同一份源码两次 build 出不同 HTML,链条永远"过期"。`tools/verify-fresh-next.mjs` 把现存静态树 HTML 记 sha、备份 `.next`、重跑 build + assemble 到临时目录逐文件比对(10/10)。 ### 9.6 交付物的服务器必须**按项目那样配置** `make-standalone` 生成的 `serve` 脚本原本只有 `--root public --port N`。 而 `serve.mjs` 不是一个朴素静态服务器:`--stub-ext-hosts` 才是让被桩掉的第三方脚本 以空正文应答的开关,`--ext-hosts` 才是让镜像过的外部主机解析到 `/ext/<host>/` 的开关。 ⛔ 少了这两个标志,交付出去的那份拷贝会做**仓内那份不做的外联**,而且没有任何门会说话—— 它自己是绿的,只是跑法和被验证过的那份不一样。 ⭐ 这与"发了 `serve.mjs` 却没发 `probe-shim.js`"是同一个失败形状,只是高了一层: **验证钩子要跟着走,配置也要跟着走。** #### 9.6.1 ⭐ `.npmrc` 是交付物的一部分【darkroom】 verify-standalone 的 `--full`(复制出去、断网 `npm install`、构建)在 canary React + Next peer 范围 下会 ERESOLVE——`.npmrc`(`legacy-peer-deps`)不是本机习惯,是**交付物能装起来的条件**,随 `src/` 一起交付并由 `--full` 证明。另:`.next/` 是构建产物(`required-server-files.json` 记着构建 机的绝对路径),静态扫描要跳过,不是"绝对路径泄漏"。 -
recon-and-rating.md 12.8 KB
# 开工侦察与难度评级 > **何时加载本文件**:第 0 步判级为 A/B 之后、写下任何移植代码之前。侦察与 M0 镜像、M1 逆向并行推进,但 §5 的"开工问题清单"未全部回答前,禁止进入移植阶段(M2+)。本文件的目标只有一个:**在动手前证否错误的架构假设**——"这个误判如果没在动手前发现,会把绝大部分力气花在极小部分画面上"【kimi】。 ## 0. 侦察的产出物 侦察阶段结束时必须落盘三样东西(写进 REBUILD_PLAN 阶段计划节 / engine-notes): 1. **架构结论**(§1 证否后的事实,不是依赖表的说法)+ "不要发明"清单; 2. **分项难度评级表**(§3,含横向对标与工期预估); 3. **开工问题清单的逐条回答**(§5)。 同时确立三层"事实来源"模型(rogier 制度,整个项目的地基)【rogier】: - **实现规范** = 镜像里的压缩 bundle + CSS——每个行为、每个数值必须在这里找到归属; - **视觉验收基线** = 线上站——bundle 字面值与线上实况冲突时以实况为准(判例见 `case-studies/recon-and-rating.md` §0); - **本地镜像** = 带已知改写的 oracle——服务层/登记过的改写不得当作产品需求。 ## 1. 架构证否:依赖表会撒谎 **依赖表 ≠ 架构真相**。(实证:`case-studies/recon-and-rating.md` §1) 操作程序(对每个新站执行): 1. **先写下架构假设清单**,来源是依赖表、script 清单、第一印象。典型假设:"这是 WebGL 站"、"有 GPU compute"、"动画由全局 GSAP 时间轴驱动"、"加载了 X 库所以用了 X"。 2. **逐条找证否证据,而不是证实证据**。每条假设对应的取证动作: - "WebGL 站"假设:在展开后的 bundle(`scripts/beautify-bundle.mjs` 产物 `_pretty/`)里数 canvas/`<Canvas>` 出现位置与挂载条件(懒加载?条件渲染?);数 shader 字符串数量;确认视觉主体的驱动层(CSS 变量?2D canvas?DOM 动画?)【kimi】。 - "有 GPU compute"假设:命中字符串必须回溯归属:落在 vendor 区段还是应用区段(先画 bundle 区段地图再下结论,见 `references/reverse-engineering.md`)【samsy】。 - "能力已挂载"假设:库里"有"不等于站点"用"【samsy】。 - "无全局时间轴"这类结构性问题也在此阶段定案——这直接决定动画逆向路径【oryzo】。 3. **证否结论写成清单入逆向笔记**:"不要发明"清单 / "对复刻的直接结论"【samsy】【noomo】。 4. **未坐实的一律标注"未确认",不猜**——逆向笔记只陈述源站事实,不做"应该怎么改"的判断【kimi】【noomo】。 5. 结构性事实**从产物读出,不凭框架惯例猜**——惯例猜测在此类站上会猜错【kimi】。 (第 2、5 步四条取证动作的实证:`case-studies/recon-and-rating.md` §1) 证否记录格式(每条假设一行,随逆向笔记落盘): | 假设 | 来源 | 取证动作 | 证据(带 pretty 行号) | 结论 | |---|---|---|---|---| | (未取证的假设) | … | … | (空) | **未确认**——禁止当结论用 | (两条已判实例:`case-studies/recon-and-rating.md` §1) ## 2. signature grep:只能提假设,不能当结论 grep 混淆/压缩 bundle 的输出是**假设生成器**,不是证据。规则: 1. **每条命中回上下文确认**。子串误命中真实存在。 2. **误命中的反向也存在**:grep 命不中不代表不存在(被内联即可命不中),API 指纹反而可坐实。 3. **搜值不搜名**:REVISION 等常量名会被混淆重命名。可靠锚点是值与特征串【noomo】【oryzo】: ```bash # 单行 MB 级 bundle 先注入换行,防有界量词正则卡死【probe】 tr ';{}' '\n' < bundle.js > bundle.lines grep -n 'const nv="179"' bundle.lines # three 版本"值"(REVISION 名已被混淆)【noomo】 grep -n '15064825' bundle.lines # 十进制颜色字面量 = 0xE5DEF9【noomo】 grep -n '#define GLSLIFY 1' bundle.lines # GLSL 特征串定位 shader 段【oryzo】 grep -n 'WebGLRenderer\|dispatchWorkgroups' bundle.lines # 命中后必须归属 vendor/应用区段 ``` 4. **命中归属靠 bundle 区段地图**:"先画地图再挖矿"【lando】【samsy】。区段地图的完整做法见 `references/reverse-engineering.md`;侦察阶段至少要把 vendor 边界粗标出来,否则 §1 的归属判断无从谈起。 5. grep 输出统一整理成"假设表",逐条走 §1 的证否程序后才能写进技术栈取证表。 (1/2/4 三条的实测命中与区段地图实例:`case-studies/recon-and-rating.md` §2) 技术栈版本的坐实标准(六项目一致【6/6】):版本字符串、pnpm 路径泄漏、wasm URL、API 指纹(四类的原始出处见 `case-studies/recon-and-rating.md` §2)。每个版本号都要有 bundle 内证据,然后 `package.json` 钉死不带 `^`(`--save-exact`)【6/6】;传递依赖必要时用 overrides 钉死——"同一框架版本不等于同一输出"【noomo】。 ## 3. 分项难度评级与横向对标【lando】 开工前按分项打星(★~★★★★★)并与前作横向对标(lando 做法,总评 ★★★☆☆),用于**预估工期、确定攻坚顺序、决定加载哪些分场景指南**。 评级维度(lando 用法): | 维度 | 评什么 | 对应加载的指南 | |---|---|---| | 素材获取 | 资产体量、跨域引用限制、第三方桶、运行时拼接路径比例 | `references/mirroring.md` | | 3D/WebGL 复杂度 | 场景数、shader 数、渲染栈(WebGL1/2/WebGPU-TSL)、后处理链长度 | `references/webgl-scenes.md` | | 滚动/动画编排 | 事实来源形态(GSAP 代码/烘焙数据/CSS 变量/物理常量)、编排层数 | `references/animation-recovery.md` | | 私有格式 | 自研二进制格式有无、是否有开源参照(.sog = PlayCanvas SOG 可借开源比对【oryzo】) | `references/binary-formats.md` | | 平台层 | Webflow/Shopify 等平台运行时的行为契约复杂度 | `references/dom-shell-strategies.md` | | **素材版权** | 字体/媒体/人物肖像/商标授权 | `references/legal-and-deploy.md` | 打星纪律: - 每一星级写一句"为什么",引用镜像/bundle 证据,不凭平台名/框架名印象。 - **素材版权单独评估且经常是最高星**:"最大风险是法务不是技术",因此**开工就按安全默认执行"私有仓库 + 不公开部署"**并写进 DEPLOY.md【oryzo】【kimi】【lando】。⚠ 这一行写的是**风险量级与待决问题**,不是"已决定不公开"——**法务判断由用户作出**,agent 只取证、列选项、给建议,并在用户决定前执行安全默认(`references/legal-and-deploy.md` §0.1)。**它也不改变镜像范围**:镜像照四遍法抓全,法务考量不得削减完整性(§0.2)。 - (两条打星纪律的实证:`case-studies/recon-and-rating.md` §3) 对标方法:找出与目标站同型的前作("3D 复杂度接近 samsy、平台层接近 lando"),按分项差值修正工期预估。首次执行按保守端估——工期收敛靠的是方法论成熟,不是站变简单。六项目谱系的规模/工期锚点表见 `case-studies/recon-and-rating.md` §3。 评级表落盘格式(写进 REBUILD_PLAN 难点表;素材版权行永远存在且单独决断): | 分项 | 星级 | 为什么(引用证据) | 对标前作 | |---|---|---|---| | 素材获取 | ★★★ | 例:外部 CDN 域要求同源 Referer、运行时拼接基址 ×2 | lando | | 3D/WebGL | ★★ | 例:单场景、shader 全内联可提取 | rogier(多场景)之下 | | 滚动/动画编排 | ★★★ | 例:GSAP 命令式 + 无全局时间轴 | oryzo 同型 | | 私有格式 | ★ | 例:无自研二进制 | — | | 平台层 | ★★★ | 例:Webflow 运行时行为契约 | lando 同型 | | **素材版权** | ★★★★★ | 例:商用字体 + 人物肖像查得不可再分发 → 待用户决定,其间按安全默认(私有 + 不公开部署) | oryzo/kimi/lando 当时同样落私有 | 攻坚顺序:星多的分项先**竖切一条端到端链路**验证可行性【oryzo】。(实证:`case-studies/recon-and-rating.md` §3) ## 4. 三判据复核(与第 0 步衔接) 若第 0 步在框架标记(`__NUXT__`/`data-v-` 等)命中下judged A,侦察阶段用 bundle 实物复核三判据(定义见 `references/scope-and-fingerprint.md` §4):签名动画确实以客户端命令式代码存在【probe】。复核不过 → 回到第 0 步重新判级,而不是硬做。(实证:`case-studies/recon-and-rating.md` §4) ## 5. 开工前必须回答的问题清单 逐条回答并落盘。**答不出的条目 = 回逆向取证,不许带着问号进移植**: 1. **架构主体是什么?**(§1 证否后的结论)视觉主体由哪一层驱动:WebGL / DOM+CSS 变量 / 2D canvas / 平台运行时?【kimi】 2. **签名行为的事实来源在哪?** GSAP 命令式代码 → 参数逐字抄录【rogier】【lando】;烘焙数据文件(GLB/.buf)→ dump 成数值账本【noomo】【oryzo】;CSS 变量 → 录基准拟合【kimi】;物理模拟 → 常量表照抄【samsy】。这决定 `references/animation-recovery.md` 里的路径选择。 3. **bundle 形态?** minified 可 beautify / 未混淆可跳过 beautify / 有公开 sourcemap 直取 sourcesContent【probe】。 4. **技术栈版本能否逐项钉死?** 每个版本号的 bundle 内证据是什么?传递依赖是否需要 overrides?【6/6】【noomo】 5. **DOM 层的生成方是谁?** 平台导出物 / 静态单页 / 框架编译产物 → 决定 shell 策略(零重写 / 脚本切组件 / 框架重建+字节对齐),见 `references/dom-shell-strategies.md`。 6. **有无私有二进制格式?** 有无开源参照可借来比对验证?→ `references/binary-formats.md`【oryzo】 7. **验收门型初选?** 有 SSR/静态 HTML 产物先建字节门 → DOM 静态场景走冻结+byte-equal → 活场景(视频/glitch/随机相位)降级为量化网格+噪声归类 → 数据驱动动画补数值门 → CLEAN 门全程兜底,见 `references/verification-gates.md`。 8. **源站可插桩吗?** 滚动驱动 + 源站混淆 bundle 不可插桩 → 必须走 probe-shim 双侧确定性驱动路线(`scripts/probe-shim.js`),见 `references/determinism.md`【noomo】。 9. **版权与部署边界?** 逐资产列归属/许可(**取证,不下结论**),把"是否公开部署"作为**待用户决定项**登记;其间按安全默认(私有 + noindex)【oryzo】【samsy】【kimi】【noomo】【lando】。 10. **镜像盲区预期清单建了吗?** worker fetch / 懒加载 / 移动端变体三类必漏资产的补录通道,见 `references/mirroring.md`【oryzo】【samsy】。 ## 6. 常见坑 - **依赖表撒谎**:three.js 在依赖里但不是 WebGL 站——头号架构误判风险,动手前必须证否【kimi】。 - **grep 子串误命中**:`leva`/`swr` 型假阳性;以及 `zustand` 型假阴性(被内联)【kimi】。 - **vendor 字符串冒充应用行为**:`dispatchWorkgroups` 全部来自 three 内部,应用层零调用——命中必须归属到 bundle 区段【samsy】。 - **常量名被混淆**:搜 `REVISION` 搜不到不等于没有 three——搜值不搜名【noomo】。 - **凭框架惯例猜结构**:段树/布局边界要从产物(flight payload、`__NUXT_DATA__`)读出,不猜【kimi】。 - **"目测近似先跑通"的技术债**:侦察阶段把事实来源定清楚,能避免这次返工【oryzo】。 - **凭平台名/框架名预判难度**:评级只认取证【probe】。 - **把版权当收尾问题**:素材版权是最高风险项,取证最耗时(逐位具名作者查证尤甚),拖到收官会让整个收尾卡住,也让"产出怎么被使用"的选项在最后一刻才摆到用户面前【oryzo】【kimi】【lando】。 - **反向的坑:让法务判断跑到技术方案上游**。取证早做是对的,**据此改技术方案是错的**——法务考量作用于**产出怎么被使用**(是否公开/部署/再分发/入 git),**不作用于镜像抓多全、门断言多少格**。 - (后三条坑的实证:`case-studies/recon-and-rating.md` §6) ## 7. 侦察关账条件 - [ ] 假设表逐条有结论(证否/坐实/标注"未确认"),无裸猜 - [ ] 架构主体结论 + "不要发明"清单已入逆向笔记【samsy】【noomo】 - [ ] 技术栈取证表逐项有 bundle 内证据,版本可钉死【6/6】 - [ ] 分项难度评级表落盘,含对标与工期预估、素材版权取证进度与待用户决定项【lando】 - [ ] §5 十个问题全部有落盘回答,对应分场景指南已确定加载清单 - [ ] 三层事实来源模型(实现规范/验收基线/本地镜像)已写进 REBUILD_PLAN【rogier】 全部勾完才允许进入移植(M2+)。 -
reverse-engineering.md 36.9 KB
# 逆向建坐标系(阶段 1:Reverse) > **何时加载本文件**:镜像已完成且本地断网跑通(M0/M0.5 阻塞门通过)之后、写下任何复刻代码之前,进入逆向阶段时加载。本阶段产出**唯一溯源坐标系**(bundle 分支为 `_pretty/` 行号,无 bundle 分支为内容哈希,见 §0)、`docs/engine-notes.md` 逆向笔记、技术栈取证表、数值基准——它们是移植与验证的全部地基。 ## 0.35 ⚠ 诊断输出不要截断标识符【airpodspro】 **截断的标识符会被原样复制回去**——这不是使用者不小心,是输出的诱导。下游若静默丢弃未知 id(`filter(map.has)`),失败会推迟到运行时,离根因隔了三层(实证:`case-studies/reverse-engineering.md` §0.35)。 **两条都要**:① 打印完整标识符;② **未知的输入 ID 一律 FATAL**,并按前缀给出"你是不是想找 X"。⭐ 修完之后的那行报错,正是排查时最想看到的:`FATAL: 048cb669e0 did you mean: 048cb669e0708ebf9629`。 ## 0.4 ⛔ 关键词计数只缩小范围,不下定位结论【airpodspro】 用计数找签名行为的落点是对的,**把计数结果当成定位结论是错的**(实证:`case-studies/reverse-engineering.md` §0.4——一次完整的误判,否证就在同一张表的同一行里)。 **同形词是这类计数的系统性风险**:`scrubber` 在视频语境里指播放器拖动条,在滚动语境里指滚动洗刷;同一个词,两个毫不相干的子系统。`scroll`、`timeline`、`track`、`player`、`stage`、`frame` 都有同样的问题。 ⭐ **信息密度最高的模块可能是最小的那个,而计数会把它排到最后。** **做法**:计数用来产出**候选清单**;每个候选**必须读代码确认**;且**读计数表要横着读**——其他列常常已经在否证当前这一列。 ## 0.45 ⚠ "自研 vs 用库",bundle 外部计数给不出答案【airpodspro】 外部计数(多少处 `requestAnimationFrame`、多少处 `gsap`)能判"自研命令式引擎",但看不到**框架层与业务层打进了同一个 bundle**这种形态(实证:`case-studies/reverse-engineering.md` §0.45)。 ⚠ 这是 **B 类特征**(平台层需先剥离),而 Step 0 的判据里没有一项能看到它——它只在**读了模块结构之后**才显形。**判级可以先行,分层必须等到 M1。** ## 0.5 ⛔ 先判 bundle 形态,再选工具【airpodspro】 分层表扫的是**顶层声明**,而那个前提只对**扁平拼接**的 bundle 成立。换成 webpack 打包产物后它**零命中**——不是少扫,是一个都没有(实证:`case-studies/reverse-engineering.md` §0.5)。 **M1 的第一个动作是判形态**: | 形态 | 识别 | 工具 | 单位 | |---|---|---|---| | **扁平拼接** | 几百个顶层 `class`/`const`/`function`,共享一个作用域 | `layer-map`(分层表) | 行号区间 | | **模块化打包**(webpack/rollup/Turbopack 运行时) | `!function(m){…}([…])` 或 `({…})`,**顶层声明数 = 0** | `scripts/module-map.mjs` | **模块** | | **多 chunk** | 跨文件 import/export 重命名 | 分层表 + 跨 chunk 重命名表(§2.1) | 行号区间 + 别名表 | #### 0.5.1 ⛔ 两种模块容器语法,一个读错会**安静地**给你一张小得离谱的表【airpodspro】【v0-optimus】 | 打包器 | 容器 | 模块签名 | 依赖 | 导出 | |---|---|---|---|---| | webpack 4 | `!function(m){…}({ "id": function(…) })` —— **对象属性** | `function(module, exports, require)` | `require("id")` | `module.exports = …` | | Turbopack | `(globalThis.TURBOPACK\|\|=[]).push([currentScript, id, factory, id, factory, …])` —— **扁平交替列表** | `ctx => {…}` 或 `(ctx, …) => {…}` | `ctx.i(id)` / `ctx.r(id)` | `ctx.s([[name, () => binding], …], ownId)` | ⭐ **Turbopack 把导出名写在容器里**(`e.s(["HeroSection", …])`),所以在这种产物上,M(n+1) 的命名几乎每个模块都是 tier-1 证据——webpack 那边要从全局发布、自注册、消费方字段名里一点点推的东西,这里打包器直接给了。 ⛔ 三个必须处理的形状差异,漏掉任一条都产出错表: 1. **工厂可能是箭头函数,且单参数时没有括号**(`e => {…}`)。按"找下一个 `(`"去取参数会一路走过箭头、跨进下一个模块,产出**一个巨大的假模块**。 2. **相邻模块共用边界行**(`}, 12345, e => {` 一行既闭合上一个又开启下一个),所以逐模块行数之和会**超过文件总行数**。这不是 bug,但要说出来,否则读起来像读错了。 3. ⛔⛔ **读错容器时工具会"成功"。** 指错容器的读取器会找到几处不相干的 `key: function` 属性,报告一个小得离谱的模块数并打印愉快的摘要(实证:`case-studies/reverse-engineering.md` §0.5.1)。 ⭐ 因此**"认不出即 FATAL"不够,还要"认出来的东西必须解释得了这个文件"**。两条便宜的覆盖率判据(`module-map.mjs` 已内置,且**已被真实数据触发验证过**): - 模块跨度覆盖的行数 **< 文件的 50%** → FATAL; - 文件里 require 形状的调用 > 8 处,而记录到的依赖边 **< 其 25%** → FATAL。 ⛔ **两个读取器必须都跑完再裁决,不能让第一个先 exit。** 否则一个 Turbopack chunk 会因为"webpack 形状的属性不足两处"被判成无容器,而其余 chunk 能通过只是巧合(实证:`case-studies/reverse-engineering.md` §0.5.1)。 ⛔⛔ **Turbopack 容器有一段前言,必须原样带走。** `currentScript` 之后、第一个 id/工厂对之前,是一串**裸 id 没有工厂**——这个 chunk 声明的跨 chunk 依赖: ```js push([currentScript, 66256, 68812, 48737, 20852, e => { … }, …]) // ~~~~~~~~~~~~~~~~~~~ 前言:依赖 id ``` ⚠ 它们不是模块(读取器正确地跳过了),但**切片器把它们丢掉就会破坏加载时序**:运行时在依赖尚未注册时就求值某个模块,抛 ``` Module 66256 was instantiated because it was required from module 42195, but the module factory is not available. ``` ⛔ **报错的栈指着移植产物,一个字都不会提"少了个头"**——而且它**不在加载时失败,在求值时才失败**,所以静态门全绿。判别方式:容器数组里,`num` 后面跟着另一个 `num` 的是依赖,后面跟着函数的才是模块。 ⛔⛔ **一个模块可以有多个 id。** 容器不是严格的 `id, factory` 交替——**一串 id 后面跟一个工厂**,那些 id 全部解析到同一个模块体(打包器把相同模块去重后挂了多个 id): ```js }, 73692, 24109, 34281, 24850, 29053, 41630, 77117, e => { … } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 六个别名 + 一个规范 id ``` ⚠ 只取紧挨工厂的那一个,会**静默丢掉其余全部**。症状是运行时抛 `module 73692 … the module factory is not available`,**而那个 chunk 切片是字节一致的、静态门全绿**——因为丢的不是字节,是**注册表里的键**(实证:`case-studies/reverse-engineering.md` §0.5.1)。 ⭐ 三条一起构成 Turbopack 容器的完整模型: ``` [ currentScript, ...前言依赖id, (id+ , factory)... ] ``` ⚠ 解析这三段时有一个共同的坑:**元素必须以 `,` 或 `[` 开头才算数**。否则 `void 0`(currentScript 表达式里的零)会被当成一个 id,于是每个 chunk 的首模块都"额外答应 id 0"。 ⭐ 副产品:**Turbopack 只给站点自有模块写导出名,vendor 模块没有。** 这比任何"按体积/按目录"的启发式都干净——**分层证据由打包器直接给出**(实证:`case-studies/reverse-engineering.md` §0.5.1)。 ⭐ **模块化打包产物的模块边界与依赖边是给定的**——打包器已经写下了它们。`requires` 直接可读(实测规模见 `case-studies/reverse-engineering.md` §0.5.1)。 ⛔ **推论,且它一路影响到最后**:`readable-source.md` §3.1 那整套 SCC / 求值顺序 / 连续区间划分,**对这类 bundle 不需要**。那套机制存在的唯一理由是"扁平作用域里模块边界不存在,必须重建";这里不存在这个问题。 ⚠ **认不出容器时必须 FATAL,且禁止回退到分层表。** 分层表对模块化 bundle 会安静地返回"0 个声明",下游会读成"这个 bundle 是空的"。一个错的单位边界是静默的大比例误差(实证:`case-studies/reverse-engineering.md` §0.5.1)。 ## ⭐ 分层表必须认「顶层裸语句」,否则一行配置会继承邻居的层【lusion】 按**声明**建行区间的分层表有一个结构性盲区:**声明之间的裸语句**会被并进前一个声明的区间, 因此**继承那个声明的层**。若前一个声明恰好属于某个 vendor 库,这行自研配置就永远不会被切进移植产物。 ⭐ **一行赋值造出的差异,长得和"某个子系统没移植"一模一样**——而且更难查,因为整帧都在动(实证:`case-studies/reverse-engineering.md` 同名节——一行 `ColorManagement.enabled = !1` 落进 vendor 区间的代价)。 **两条纪律**: 1. **扫描器要同时认声明与顶层裸语句**(`X.y = …` / `f(…)` 在第 0 列)——它们不是边角料,配置、注册、单例初始化都长这样。 2. **vendor 区间的边界要精确到行**,且**边界处的裸语句默认归自研**:库的结尾之后紧跟一行配置 是极常见的打包形态。 **归因手法**(比逐块排查快得多):差异铺满整帧时,**先量两侧的通道均值**。 等比偏移 → 亮度/曝光;**不等比** → 色彩管理 / 色调映射 / 色彩空间,直接去 bundle 里 grep `ColorManagement` / `outputColorSpace` / `toneMapping` / `useLegacyLights`, 并**核对它们各自属于哪一层**。 ## 0. 预检:先问"有没有 bundle",再判 bundle 形态 **本文件的主干(`_pretty/` 行号坐标系、混淆别名表、区段地图、vendor 岛、字节切片器)整体建立在"签名行为住在可下载的 bundle 里"这个前提上。** 前八个项目无一例外满足它;objectandarchive 第一次不满足(实证:`case-studies/reverse-engineering.md` §0)【objectarchive】。所以预检先问载体,再问形态;命中"无 bundle"就走 §0.1 的**平行分支**。 有 bundle 时,先判形态,再决定是否需要 beautify: ```bash head -c 600 mirror/<path-to-bundle>.js # 看开头形态 awk '{ if (length($0) > m) m = length($0) } END { print m }' <bundle>.js # 最长行 grep -c 'sourceMappingURL' <bundle>.js # 有无 sourcemap 指针 ``` | 形态 | 判据 | 流程分支 | |---|---|---| | minified/混淆产物(常态) | 单行或数万字符长行;标识符压成 1–2 字符 | 走 §1 beautify 流程 | | **未混淆 esbuild 产物** | 标识符全保留、自带换行缩进——开头即 `var __defProp = Object.defineProperty;`、内部函数名(如 `copyAttributeData`)原样可读 | **跳过 beautify**,直接以原文件行号为坐标系 | | 带公开 sourcemap 且 sourcesContent 完整 | map 可下载且含完整源码 | 直取 sourcesContent 替代 beautify | | **手写多文件站(2013 时代,无打包器)** | 每 `<script>` 一文件、原始命名;CoffeeScript 特征(`_i/_len/_ref`、`(function(){}).call(this)`)、Compass 行号注释 | **跳过 beautify 并把这次跳过登记进日志**("行号指 \_pretty"是全库默认约定,静默跳过会让后来者找错文件);坐标系 = mirror 原文件行号。⭐ **先做 vendor 逐字节鉴真**:站上的库文件与上游官方 release 直接 diff——一次 diff 杀掉整棵"站方魔改库"假设树,剩下的应用文件就是全部逆向面【firstlaunch】 | | **无 bundle:行为在 HTML 内联块里** | 站点自己的 js 只有 vendor 与主题存量,签名行为的字面量只在 HTML 的 `<script>` 块内命中 | **走 §0.1 平行分支**:坐标系建在内联块上,主坐标是**内容哈希**而非行号【objectarchive】 | (各行的边界探测实录见 `case-studies/reverse-engineering.md` §0。) 无论走哪个分支,**"坐标系是全项目唯一溯源坐标"的制度不变**——唯一、贯穿四处引用(§1.3)、笔记先行(§2)、取证钉版本(§3)、假设先证否(§4)全部照旧;变的只是坐标落在哪份字节上、以什么做主键(行号 / 内容哈希)。 ### 0.1 无 bundle 站:平行分支(不是替代分支)【objectarchive】 **两条分支并列。** 判据是"签名行为的**载体**是什么",不是站的好坏、也不是方法论偏好。同一个项目里两者常常并存(vendor 是 CDN 上的独立文件,自研行为在内联块里)——此时**按载体分别建坐标系**:需要读的 vendor 文件按 §1 走 `_pretty/`,内联块按本节走内容哈希(实证:`case-studies/reverse-engineering.md` §0.1)。这不违反 §1.3 的"不允许第二套坐标系"——那条禁的是**同一份字节有两种坐标**,不是禁止不同载体各有各的坐标;要求是**每条引用一眼能看出落在哪个载体上**(`B:` 前缀 vs `pretty L`)。 **识别判据**(四条同时成立才判;有一条不成立要回头核,别急着删步骤): 1. **签名行为的字面量只在 HTML 里命中**:对镜像全量 grep 行为特征(`new Lenis` / `gsap.` / `ScrollTrigger` / `addEventListener("pointerdown"` / 自研类名前缀),命中集落在 `*.html` 的 `<script>` 块内,站点自己的 `.js` 文件里没有。 2. **vendor 走 CDN 且 URL 内即钉版本**:`cdn.jsdelivr.net/npm/gsap@3.12.5/…`、`lenis@1.1.14/…`、`code.jquery.com/jquery-3.7.1.min.js`——版本无需逆向,照 URL 钉即可(§3.1)。 3. **内联块未压缩且带作者注释**:框线注释(`/* ── Drag-to-scroll (carousel) ── */`)、完整标识符、正常缩进——这是源码风格,不是构建产物。 4. **页面由服务端模板渲染**(Liquid / ERB / Blade / 各类 PHP 模板):块的位置与顺序由模板与平台注入决定,不由构建器决定——这正是下面坐标不稳的根源。 **直接删掉的步骤**(bundle 分支的专属成本,无 bundle 站一项都不需要): | 删掉 | 因为 | |---|---| | §1 的 `js-beautify` 展开与版本钉死(`_pretty/`、`scripts/beautify-bundle.mjs`) | 代码本来就是格式化好的源码;再 beautify 一遍等于**凭空造出一份与源站字节不同的产物**,逐字移植的字节门会失去基准 | | §2.1 的混淆名对照表、跨 chunk 导入/导出重命名表 | 标识符与注释都在,没有混淆,也没有 chunk | | §0 的 minification 形态预检(最长行、`sourceMappingURL`) | 没有被压缩的对象 | | §2.2 的 vendor 区段地图与 vendor 岛边界校准 | vendor 是独立的 CDN 文件,边界即文件边界;"应用层规模"改由内联块归属表给出(见下) | **替换掉的步骤**(形式变了,制度没变): | bundle 分支 | 无 bundle 分支 | |---|---| | `_pretty/` 行号坐标系 | **内容哈希坐标系 `B:<sha12>`**(§0.1.1) | | `_pretty/README.md` 钉死 beautifier 版本 | **快照 sha256 钉死表 + 漂移守卫命令**(§0.1.1) | | §2.1 的 bundle 区段地图 | **内联块普查表 + 逐块层归属**(Shopify 站按 `shopify-platform.md` §0.3 的四层;零 UNCLASSIFIED 才算完成) | | `scripts/extract-source.mjs` 按行号切 `_pretty/` | 同一把切片器改切**镜像 HTML 的内联块**:切片表的 `source` 变成 HTML 文件,sha256 守卫照旧 | #### 0.1.1 坐标系:内容哈希作主坐标,行号降级为快照内导航 **朴素方案"行号建在镜像 HTML 上"实测不成立。** 同一路由多份抓取逐块比 sha 与起始行:同缓存条目内稳定,跨 CDN 缓存条目时平台 app-embed 块会换注入顺序——同一份内容,两种注入顺序(实证:`case-studies/reverse-engineering.md` §0.1.1——六份抓取的逐块比对表,以及"同一块在三条路由上行号各异而 `B:` 一致"的旁证)。 三条结论直接决定坐标系形状:**① 块序号不是稳定标识**;**② 行号在被平台注入的区段内不稳定**;**③ 区段外这次没漂是运气不是保证**——两个 app 块恰好占用同样多的行,所以后面的行号回到原位;多一个 app 或某个 app 改版,整页后半段就整体位移。**行号可以用来找路,不可以用来当契约。** 定案: | 用途 | 形式 | 稳定性 | |---|---|---| | **主坐标(权威)** | `B:<sha12>` = 块正文字节的 sha256 前 12 位;块内定位写 `B:<sha12>+<行内偏移>`(1-based,块首行为 1) | 内容寻址:跨渲染、跨页面、跨抓取不变 | | 辅坐标(导航) | `<page> L<起>-<止>` | **仅对钉死的快照有效**,不得进契约类文档的证据位 | | 漂移守卫 | `inline-scripts.mjs <新抓> <钉死快照> --compare`:逐块比 sha / 字节 / 起始行,有差异即退非零 | 等价于 bundle 分支"钉死 beautifier 版本"那条红线 | 配套三件,缺一不可: 1. **快照钉死表**:每份镜像 HTML 的 sha256 / 字节数 / 行数写进 REBUILD_PLAN(等价于 `_pretty/README.md` 的版本声明),并写明红线——**重抓镜像后 sha256 一变,全部 `L####` 引用作废;`B:` 引用不受影响**。 2. **漂移守卫进流程**:每次重抓镜像后必跑 `--compare`。sha 变了是**响亮失败**,该块的全部 `B:` 引用必须重新定位,而不是静默漂走。 3. **块名带语义**:普查表里每块起描述性 id(`oa-lenis-gsap-orchestration`、`oa-pdp-frame-compositor`),**不要 `block-37`**——序号会漂,而这些名字要直接当移植任务表的行标题用。 **先掩 nonce 再谈任何字节门**:逐请求变化但**长度固定**的字段(`<meta name="shopify-y">` UUID、`__st.reqid`、`eventMetadataId`、`requestId`)不引起行号位移,却会让哈希变。做法是在归属表里给这类块标 `nonce` 并改用锚点字面量匹配(`shopify-platform.md` §0.3 步骤 4)。**注意"跨请求"和"跨缓存条目"是两个不同的自变量**——只有后者会动结构;只抓前者会得出"完全稳定"的错误结论。 ## 1. 建立 `_pretty/` 行号坐标系 > **分支提示**:§1.1–§1.3 属 **bundle 分支**;无 bundle 站按 §0.1 建内容哈希坐标系后跳到 **§1.4**(该节两个分支通用)。 ### 1.1 展开命令(版本钉死 1.15.1) ```bash mkdir -p mirror/_pretty npx --yes js-beautify@1.15.1 mirror/<path>/<bundle>.js \ -o mirror/_pretty/<bundle>.pretty.js ``` - 多 chunk 站(Next 等)把**全部 chunk 逐个展开**【kimi】。 - 本 skill 钉 **1.15.1**,不要用别的版本【samsy】【kimi】【noomo】【lando】(版本沿革见 `case-studies/reverse-engineering.md` §1.1)。 ### 1.2 `_pretty/README.md`(必写,与展开同一次完成) 内容必须包含: 1. beautifier 精确版本(`js-beautify@1.15.1`); 2. 逐文件的**再生成命令**(照抄上面的命令行,可直接复制执行); 3. 警告原文级别的红线声明:**换 beautify 版本行号会漂移,整套引用作废**【samsy】【noomo】; 4. 原件纪律:镜像原件目录(`_nuxt/`、`assets/` 等)保持字节纯净,`_pretty/` 是分析产物,二者永不混淆【noomo】。 ### ⛔ 红线 **beautifier 版本漂移 = 整个溯源体系作废。** 行号一漂,全项目所有 `LNNNN` 引用(逆向笔记、移植文件头注释、里程碑待办、怪癖/偏差登记表)一次性失效且无法自动修复。任何人重新生成 `_pretty/` 只许用 README 里登记的命令与版本。 ### 1.3 行号引用格式(全项目唯一坐标系) - 单 bundle 站:`pretty LNNNN`(如 "BufItem,pretty 行 29722–29809"【oryzo】)。 - 多 chunk 站:`<chunk-hash> Lnnnn`(如 `_pretty/7020daab554f970c` L13231)【kimi】。 - 行号引用**贯穿四处**,不允许第二套坐标系: 1. 逆向笔记 `engine-notes.md` 的每条结论; 2. 每个移植文件的头注释(阶段 2 使用,见 porting-discipline.md); 3. 里程碑日志的"下一步断点待办"——跨会话交接靠它【samsy】; 4. 怪癖表与偏差表的每条证据。 - 实践规模参考(前作每站一两百到四百多处行号引用)见 `case-studies/reverse-engineering.md` §1.3。 - **无 bundle 站**:把上面四处的"行号"整体替换为 `B:<sha12>`(块内定位 `B:<sha12>+<n>`),格式与制度同构;行号只能作为**快照内导航**出现在这四处之外【objectarchive】。 ### 1.4 坐标系稳定性是 M1 的第一道必答题(两个分支通用)【objectarchive】 bundle 分支的坐标稳定性是**买来的**:文件字节固定 + beautifier 版本钉死 ⇒ 行号是不变量,红线只需一句"别换版本"。**只要坐标载体不是"钉死的文件",稳定性就变成必须实测的经验问题**——无 bundle 站(内联块随模板与平台注入漂)、每请求重渲染的 SSR 页面、随 A/B 分桶变化的产物,都属此类。 纪律(硬规则): 1. **Step 0 就预登记**:判级时若发现坐标载体不是固定文件,把"坐标系是否稳定"写进 `probe/verdict.md` 的待验风险清单。 2. **M1 第一件事就是验它**——**在写任何移植代码、落下任何坐标引用之前**。验法:同一路由多次抓取,**把自变量拆开分别抓**:同时刻双抓 / 分钟级间隔 / **跨天(跨 CDN 缓存条目)** / 换 UA / 换 `Accept-Language`;逐块比 sha、字节、起始行(objectandarchive 的实现是 `inline-scripts.mjs --compare`)。 3. **结论写成区段级,不是全局级**:"平台 app-embed 区段不稳,其余稳"这种形状的答案才可用——自研块的行号可以放心当导航坐标;而它只有把"同缓存条目 / 跨缓存条目"当成两个自变量分开抓才看得见(实证:`case-studies/reverse-engineering.md` §1.4)。 4. **结论进 `engine-notes.md` 第一节 + REBUILD_PLAN 的坐标系节**,并配一条可复跑的守卫命令(重抓镜像后必跑)。 **为什么必须前置**:在 Step 0 预登记、M1 开头证伪,代价是半天(实证:`case-studies/reverse-engineering.md` §1.4)。**同样的发现若拖到 M2 中途才撞上,笔记、移植文件头注释、里程碑待办、怪癖/偏差表里的坐标引用早已铺开(前作规模 107–400+ 处),一次性全部作废且无法自动修复**——与"beautifier 版本漂移"是同一类灾难(§1 ⛔),只是触发源不同。 ## 2. 逆向笔记 `docs/engine-notes.md` 先行 **独立里程碑,产出并提交这份笔记之前不写任何复刻代码**——"文档先行显著降低了后面每轮的返工"【oryzo】;后四代全部沿用【samsy】【kimi】【noomo】【lando】(实证:`case-studies/reverse-engineering.md` §2)。 ### 2.1 三段式内容结构 **第一段:源站事实**(全部带坐标:bundle 分支为行号,无 bundle 分支为 `B:<sha12>`) - **bundle 区段地图**:vendor 边界逐段标行号,"先画地图再挖矿"【lando】【samsy】(实证:`case-studies/reverse-engineering.md` §2.1)。**边界怎么划见 §2.2——只按 license banner 划会错**【shopifydesign】; - 启动链 / 路由 / store(逐字段用途); - 渲染管线、RenderTarget 清单、材质清单、后处理链逐步拆解【samsy】; - 协议与数据 schema(VAT worker 协议、PartyKit 协议全量【samsy】;i18n/数据 schema【kimi】); - 混淆名对照表(noomo:`nn`=RenderingPipeline、`X`=Root…)【noomo】; - **跨 chunk 导入/导出重命名表**(多 chunk 站必写)【shopifydesign】:同一个符号在两个 chunk 里叫两个名字——`SiteHeader` chunk 里写 `export { Ye as R }`,主 chunk L8 写 `import { R as Pa }`,于是笔记、bundle、移植代码要靠三个名字(`Ye` / `R` / `Pa`)对上号。**逐条记"符号 → 源 chunk → 导出名 → 主 chunk 内名 → 两侧行号"**:它是阶段 2 跨 chunk 字节切片的直接输入——切片器要靠这张表把那两句 import/export 转写成一句绑定(`porting-discipline.md` §2.2)。没有它,跨 chunk 的符号在笔记里表现为"来历不明的自由标识符"; - 页面 init/destroy 矩阵(每个页面的初始化/销毁函数及行号)——它直接变成移植阶段的任务清单【lando】。 - **无 bundle 站的等价物**:区段地图换成**内联块普查表**(逐块:语义 id / 层归属 / 字节 / `B:<sha12>` / 各页行号 / 首条作者注释)。它同时承担 §2.2 的职责——**应用层规模 = 归属为"站点自研"的那些块**,平台层与上游主题存量都要从规模统计里扣掉,否则任务表虚高【objectarchive】(实证:`case-studies/reverse-engineering.md` §2.1)。 **第二段:怪癖清单(照抄不修)**:源站 bug / 死代码 / 怪写法逐条登记并带坐标(行号或 `B:`),移植时逐字照抄【noomo】【samsy】【kimi】(规模参考见 `case-studies/reverse-engineering.md` §2.1)。 **第三段:对复刻的直接结论**:先做什么、缺什么会怎样、"不要发明"清单【noomo】【samsy】(实例见 `case-studies/reverse-engineering.md` §2.1)。 ### 2.2 区段地图的边界校准(license banner 只给起点)【shopifydesign】 **vendor 区不是连续的一块,license banner 也不标终点。**(实证:`case-studies/reverse-engineering.md` §2.2——初版按最后一段 banner 定边界,错了 5,832 行。) 三条操作规则: 1. **起点用 banner,终点用 `class X extends Y` 的收尾校准**:banner 之后继续往下扫到最后一个 vendor 类定义的闭合处,再往下第一处**应用配置常量/魔数**才是真起点(设计基准高度、相机 Y 这类值只可能是应用配置)。 2. **应用区间内要标出 vendor 岛**:构建器会把按需引入的 vendor(loader、字体引擎、后处理 addon)散插在应用代码之间。岛内代码不属应用层,规模统计与计数都要扣掉;岛的边界同样用 `class X extends Y` / `self.xxxDefine` 这类库自身入口锚点定。 3. **地图先于计数**:所有"多少段 GLSL / 多少次 X"的数字都必须**在应用区间内**数。Step 0 的原始计数含 vendor,必然虚高——见 `references/scope-and-fingerprint.md` §2《计数硬约束》。 **下游代价**(为什么这不是洁癖):区段地图错 → 应用层规模误判 → **难度评级与工期估算一起偏**;且后续每一次"这段要不要移植"的判断都建在错的坐标上,返工时整片行号引用作废。 ### 2.3 笔记纪律 - **只陈述源站事实,不做"应该怎么改"的判断**——决策写进 REBUILD_PLAN,不写进笔记【kimi】【noomo】; - **未坐实的一律标注"未确认",不猜**【kimi】; - 事实与决策分离,防止"边看边写"导致的臆造【samsy】; - **上一阶段(Step 0)的数字与附带结论一律当假设复核**,不要直接抄进笔记——判级正确不代表附带的路由数、资产数、漏抓归因也正确【shopifydesign】(实证:`case-studies/reverse-engineering.md` §2.3)。 ## 3. 技术栈从 bundle 取证、精确钉死【6/6】 ### 3.1 取证指纹类型(每个版本号都要有出处) | 指纹类型 | 实例 | |---|---| | bundle 内版本字符串 | `hN="3.5.25"`(Vue)、`versions:{get nuxt(){return"4.2.1"}`、GSAP `version:"3.13.0"` ×6【noomo】 | | 全局变量 | `window.next={version:"16.1.6",appDir:!0}`、`window.__THREE__="184"`【kimi】 | | pnpm 路径泄漏 | 一条路径一次性钉死 next/react/babel/sass 四个版本【kimi】 | | wasm/CDN URL | Rive 版本从 bundle 内 wasm URL 取证【lando】 | | **CDN URL 内即钉版本**(无 bundle 站常态) | `cdn.jsdelivr.net/npm/gsap@3.12.5/…`、`lenis@1.1.14/…`、`code.jquery.com/jquery-3.7.1.min.js`——URL 给版本,**下载到的文件里再复核一次**(`gsap.min.js` banner `GSAP 3.12.5`、`ScrollTrigger.min.js` 内 `version="3.12.5"`、文件内 `jQuery v3.7.1`),两处对得上才算取证【objectarchive】 | | API 指纹 | zustand `getInitialState` 无 `destroy` ⇒ v5【kimi】 | | CSS 特征 | `@property --tw-drop-shadow-alpha` ⇒ Tailwind v4.1.0+【kimi】 | | 响应头 | `x-powered-by: Nuxt`【noomo】 | ### 3.2 钉死落地 - 安装用 `npm i --save-exact`,`package.json` 不带 `^`【kimi】【lando】; - **传递依赖也要钉**(用 `overrides`):"同一 Nuxt 版本不等于同一输出,传递依赖也要对齐"【noomo】(实证:`case-studies/reverse-engineering.md` §3.2); - 源站用 dev 分支时取最接近正式版并**登记为偏差**【samsy】; - 逐项证据写成技术栈取证表(REBUILD_PLAN §2 格式:项 / 版本 / 取证方式)【noomo】。 ## 4. 证伪流程:假设必须先证否 ### 4.1 signature grep 只提假设,不当结论 grep 命中只是假设,**每条必须回上下文确认**;**计数同理**——`grep -c` 数的是匹配行数不是出现次数,且 vendor 自带字符串(报错串、内置 shader chunk)会把应用层用量抬高一个数量级。这条纪律已前移复述到 Step 0,见 `references/scope-and-fingerprint.md` §2《计数硬约束》【shopifydesign】。逐条实例(子串误命中、被内联的真实依赖、"有 GPU compute"被 M1 证伪、库里有但从未挂载)见 `case-studies/reverse-engineering.md` §4.1【kimi】【samsy】。 ### 4.2 架构假设先证否再动工 依赖表里有 three.js + r3f 的站也可以**不是 WebGL 站**——视觉主体在 DOM + CSS 自定义属性 + 2D canvas,`<Canvas>` 只是懒加载的点缀。"这个误判如果没在动手前发现,会把绝大部分力气花在极小部分画面上"【kimi】(实证:`case-studies/reverse-engineering.md` §4.2)。 操作化: 1. 写下架构假设("这是 WebGL 站 / GSAP 时间轴站 / …"); 2. 列出"若为真必然成立"的可检验推论(Canvas 实例数、着色器数量、视觉主体由什么驱动); 3. 逐条到 bundle / 运行时验证,**先找证否证据**; 4. 证否成本远低于沿错误方向移植的成本。 ## 5. grep 混淆代码:搜值不搜名 - **常量名会被混淆重命名**:three 的 `REVISION` 搜不到,靠常量值 `const nv="179"` 才锁定版本【noomo】(实证:`case-studies/reverse-engineering.md` §5)。 - 比标识符可靠的锚点:**版本号字符串值、十进制颜色字面量**(`15064825` = 0xE5DEF9)、**GLSL 特征串**【noomo】。 - 实操:MB 级单行文件先 `tr` 注入换行再 grep,防有界量词正则卡死(边界探测协议教训)。 ## 6. 数据驱动动画:先 dump 成数值账本 原则:"**compare recorded values, not screenshots**"(noomo `dump-timelines.mjs` 注释,显式引用 careers-kimi 教训)【noomo】【kimi】。凡被数据驱动的动画,逆向阶段就把数据源 dump 成 JSON 数值基准入库,之后验收用数值全等而非截图目测: - **GLB 烘焙曲线**:手写解析器 dump 全部动画曲线;后续验收即"相机位置在 t=0/5/10/19 与基准插值小数点后三位全等"【noomo】(实证:`case-studies/reverse-engineering.md` §6); - **CSS 变量时间序列**:探针在镜像上录基准(kimi `probe-deck-vars.mjs` → `docs/deck-baseline/source-*.json`)【kimi】; - **bundle 内联 base64 资产**提取到 `mirror/_extracted/`(noomo:colorsMap 1024×2 光谱 LUT、SMAA 纹理——缺 colorsMap 玻璃会变灰白)【noomo】。 **基准覆盖面判据**:录之前先确认"观感由哪些量驱动",把全部驱动量采进基准——只采一组变量,它们饱和之后基准就"完全失明"【kimi】(实证:`case-studies/reverse-engineering.md` §6)。 ## 7. 常见坑(逆向坑) 1. **beautifier 版本不钉死 → 行号漂移 → 整个溯源体系作废**【samsy】【noomo】。对策:§1.2 的 README 制度,任何再生成只用登记的命令。 2. **signature grep 子串误命中**(`leva` 命中 SVG 属性列表)【kimi】。对策:每条命中回上下文确认后才能写进笔记。 3. **依赖表撒谎**(three.js 在依赖里但不是 WebGL 站)【kimi】;指纹误判"有 GPU compute"【samsy】。对策:§4.2 架构假设先证否。 4. **搜名搜不到**:REVISION 等常量被重命名【noomo】。对策:§5 搜值不搜名。 5. **数值基准覆盖不全导致后段失明**(18 变量在 3.2 后饱和)【kimi】。对策:§6 先确认全部驱动量。 6. **正则假阳性污染 diff/取证**:`.15` 无前导零、十六进制色值记法差异造成两轮假阳性,samsy 改用"数字字面量多重集 + 结构对比"才收敛出真实增量【samsy】。对策:数值比较先归一化记法。 7. **逆向笔记混入改进判断**导致移植阶段"顺手修 bug"。对策:§2.3 事实/判断分离 + 怪癖单列"照抄不修"。 8. **区段地图只按 license banner 划**:vendor 尾部的 addon 段与散插的"vendor 岛"被算进应用层,应用规模虚高(shopify.design 虚高 5,832 行),评级与工期跟着偏【shopifydesign】。对策:§2.2 的收尾校准 + vendor 岛标注。 9. **把坐标系稳定性拖到 M2 才发现**:坐标载体不是钉死文件时(内联块 / 每请求渲染的 SSR),行号会随平台注入顺序漂,而"同一时刻双抓完全一致"极易让人提前收工【objectarchive】。对策:§1.4——M1 开头多自变量实测,结论按区段记录,主坐标改内容寻址。 10. **在无 bundle 站上照跑 bundle 流程**:对已经是源码的内联块跑 beautify(凭空造出与源站不同的字节,字节门失基准)、用块序号当标识(序号会漂)、按 license banner 找 vendor 边界(vendor 根本在别的文件里)【objectarchive】。对策:§0.1 的"删掉/替换"两张表逐条对照。 ## 8. 阶段产出物与通过判据 - [ ] `mirror/_pretty/`:全部 bundle/chunk 已展开(或按 §0 预检登记"无需 beautify,坐标系 = 原文件行号";**无 bundle 站按 §0.1 登记"坐标系 = 内容哈希 `B:<sha12>`"**) - [ ] `_pretty/README.md`:含 js-beautify@1.15.1 版本声明 + 逐文件再生成命令 + 版本漂移警告(**无 bundle 站的等价物**:快照 sha256 钉死表 + 漂移守卫命令 + "重抓即全部行号引用作废"警告) - [ ] **坐标系稳定性有实测结论**(§1.4):多自变量抓取比对已跑(含跨缓存条目),结论按**区段**写进 engine-notes,守卫命令可复跑 - [ ] 无 bundle 站:**内联块普查表 + 逐块层归属**完成,每块有语义 id,归属门零 UNCLASSIFIED(Shopify 站见 `shopify-platform.md` §0.3) - [ ] `docs/engine-notes.md`:三段式齐全(事实带坐标 / 怪癖清单 / 复刻直接结论),全文无"应该怎么改",未坐实处标"未确认" - [ ] 多 chunk 站:**跨 chunk 导入/导出重命名表**已进笔记(符号 → 源 chunk → 导出名 → 主 chunk 内名 → 行号),供阶段 2 的多源切片消费 - [ ] 技术栈取证表:每个依赖版本都有镜像内证据(bundle 内字符串,或**无 bundle 站的 CDN URL + 文件内复核**,§3.1),`package.json` 计划为 `--save-exact`,传递依赖风险已评估 - [ ] 架构假设已做过一轮显式证否(记录证否手段与结论) - [ ] 数据驱动动画的数值基准已 dump 入库(`docs/*-baseline/`),驱动量覆盖已确认 - [ ] bundle 内联资产已提取到 `_extracted/`(如有) - [ ] 阶段计划(REBUILD_PLAN)已按 engine-notes 的"复刻直接结论"排出依赖序里程碑 全部勾选后才进入阶段 2(加载 `porting-discipline.md`)。 ## §0.5.2 ⛔ Turbopack 的运行时自带按文件名索引的 chunk 清单【raycastkbd】 容器图是**平的**时(每个 chunk 的名字都出现在 HTML 的 flight 清单里),把外壳里的名字改成 `.port.js` 就完成了移植交付。但 **Turbopack 运行时 chunk(`turbopack-*.js`)内部可能嵌着一份按原始文件名索引的 chunk 清单**,动态导入按它解析路径;另有 chunk 之间按原名交叉引用。把外壳与 flight 里的名字改成 `.port.js` 之后: - flight 驱动的水合加载 `.port.js`(移植件) - 运行时清单驱动的动态导入**仍按原名再加载一遍原件** - 同一批模块**被求值两次**,单例状态分裂 ⚠ 症状与病灶隔了三层:导航的登录/下载按钮消失、一个 canvas 不再挂载、**控制台零报错**——没有崩溃,只有两份互不相识的 store。不是某个 chunk 坏了,是**改名机制本身**在这个形状下不成立(实证:`case-studies/reverse-engineering.md` §0.5.2)。 ### ⭐ 解法:分层交付,不改名 移植件以**原名**落在 `site/_next/.../chunks/`,镜像原件留在 `mirror/` 作证据; `serve --root site --fallback-root mirror` 让每个被移植的名字由移植件应答、其余回落。 外壳零 chunk 变换。移植的证据链改由 `slice --check`(可复现)、模块表门(token 级) 与渲染对拍(像素 0)承担。 ⛔ 判据:**看到运行时 chunk 内部引用兄弟 chunk 文件名,就不要用改名交付。** 一条 grep 就能判:`grep -l "chunks/" <runtime-chunk>`。 -
rsc-reconstruction.md 12.3 KB
# RSC 重构式逆向(C1)— flight 载荷到可构建源码 > v0.3 全程在 rauchg.com(Next 16.1.1 / Turbopack / React 19 canary)上实证: > 18/18 路由 flight 语义门 PASS、模块 id 双射 19 对、运行时 sweep 18/18; > 盲逆向对答案(rauchg/blog)判卷:结构 ≈95%、行为 ≈98%、字面 ≈90% > (docs/recovery-report.md 型报告是本路线的标准收口产物)。 ## §0 判级语义的修订 C1(服务端组件源不下发)从"拒绝"改为**可做:重构式逆向**。定义本身没变—— 确实没有可转写的服务端源码;变的是结论:**服务端组件的完整输出(flight 流) 就内联在每页 HTML 里,它是可对拍的规格书**。重构出一个 Next 工程,使其构建 产物通过对镜像的语义门,这就是 C1 的 L2/L3——在 C1 语境下这两级**合并**: 没有"逐字 port"可言,第一份产物就是"人写的源码 + 门证明的等价"。 ⭐ **纪律 3 在 C1 的读法**:「源站有的」= flight 树/HTML/资产字节;「源站没有的」= 任何 flight 推不出来的服务端行为。重构出的服务端源码是**显式登记的推断物**, 每个文件头注引用它依据的镜像证据坐标(flight 行、chunk 行号、CSS 字节)。 ## §1 坐标系:flight 流(C1 的 `_pretty/`) `scripts/flight-decode.mjs` 把每页的 `self.__next_f.push` 流解成:模块引用表 (I 行)、HL 预载、**已解引用的元素树** + JSX 式 outline。此后一切重构决定 引用这棵树,如同 A 类引用 `_pretty/` 行号。 线格式(react-server-dom,Next 15/16 实测): - 行 `<hexId?>:<payload>`,**id 可为空**(`:HL` 行)——行走器漏掉这条会在首个 HL 处断链; - `T<hex>,` 行按声明字节数走、无终止符(verify-lenprefix 的老课); - 行 `0:` = 路由载荷 `{P,b,c,q,i,f,m,G,S}`;`b`=buildId(用 generateBuildId 钉死), `f[i]=[routerState, seed 元素树, head 元素树, isPartial]`; - 元素 `["$",type,key,props]`;`I[turbopackId,[chunks],导出名]` 是客户端组件引用—— **导出名是白送的 tier-1 命名证据**(Logo/Posts/Header 直接写在载荷里)。 ### §1.1 flight 是保真神谕(比 DOM 更细的证据面) 1. ⭐ **键序 = JSX prop 序**。flight 按源码 prop 顺序序列化,两侧键序不同 = 你的 prop 写序和作者不同。 2. ⭐ **化石全下发**:`{cond && x}` 的 `false`/`undefined`、`{" "}` 显著空白、 模板字符串类名里的换行缩进与**尾空格**——全部要照抄发射,门会验。 3. ⭐ **`(post)` 这样的路由组名字面出现在 routerState 里**;segment 全是纯字符串 = 字面目录(动态段是 `[param,value,"d"]` 元组)——目录结构无损恢复。 4. ⭐ 标题文本尾空格 + 独立 id 锚 → 还原 `## 标题 [#custom-id]` 源约定。 5. ⭐ **作者的不一致本身是保真面**:照抄,不"修好"。线上 bug 也一样 (对活源站复测是最强豁免证据)(实证:`case-studies/rsc-reconstruction.md` §1.1)。 ## §2 镜像层的 C1 特有面 - **`?_rsc=` 载荷**(客户端导航预取)与 **next/image 变体**是运行时资源; ⭐ 变体阶梯**从 SSR HTML 的 srcset 穷举**(闭包全集),不靠浏览器碰运气 (实测 srcset 穷举 1,078 vs 浏览器只碰到 217)。`scripts/reconcile-gaps.mjs` 逐条容错 + 分批记账地补进镜像。 - **爬虫专供路由**要主动抓:`og:image` 指向的动态 OG 图(`/og/<slug>`、 `/opengraph-image`)只有爬虫访问,BFS 与 CDP 都看不见。 - ⛔ **well-known 路由探测**(对答案暴露的盲区):`/atom` `/rss` `/feed` `/sitemap.xml` 无入链即不可达——M0 收尾时逐个 GET 一次,200 就入镜。 纯隐藏路由空间(短链系统、未链接页面)原理不可枚举,如实登记为盲区。 - API 快照按 B 类办;⚠ 镜像各页可能是 **ISR 不同再生时刻**(每页一份数据纪元, 源站自己就在发不一致的数据)——重构架构用单一数据源,门对纪元字段做 `--normalize-props` 归一并登记偏差。 ## §3 重构工程(rebuild/ = 可构建的 Next 工程) - 版本从字节钓:`window.next={version:...}`、库的 sdkv/版本串、css 产物形态 (Tailwind v3 无 @layer/@property)、**字体管线看 css-module 名** (`geist_<hash>-module` = next/font/google;geist npm 包的字体字节 sha 对不上 即排除——实测 7 个依赖版本精确命中)。 - 客户端一方组件是 C2 情形:源码就在 chunk 里(module-map 认 Turbopack 容器), 逐字翻译并头注 `_pretty/` 行号。 - 服务端组件从 flight 树反推;正文用 `tools/flight-to-mdx.mjs`(站点侧适配 LINK_CLASS/SHAPE/FIRST_PARTY,机制通用)。 - 平台注入物从镜像快照入 `public/`(`/_vercel/insights` 脚本);动态 OG 图 以路由伺服快照字节(登记:不再生)。 ### §3.1 MDX 反推的四个陷阱(全部实测流血) 1. ⛔ **MDX 按块缩进剥多行模板字面量的前导空格**(6 空格类名被剥成 4)—— 含换行的属性值一律发 **JSON 字符串字面量** `className={"\n p-4…"}`。 2. ⛔ **组件映射按上下文分**:markdown 段落里的字面 `<a>` 不映射(要发全 props); JSX 表达式里的字面 `<a>` 映射(发最小形,组件补 props)——发错方向就是 类名翻倍或裸链接。 3. ⛔ **多行 JSX 流里的裸文本被当 markdown 包 p**(表格 th 里长出 `<p>`)—— 文本子节点一律发 `{"json"}` 表达式。 4. ⭐ **围栏指纹**:`pre>code>code` 嵌套 = markdown 围栏(Pre+Code 双包装); 单层 code 或内含元素/`{" "}` 串 = 作者手写字面 JSX,逐字发射(围栏路径的 textOf 会把内嵌链接压扁——先判形再选路)。 ### §3.2 CSS 面:tailwind 扫描面与 token 必须对着镜像编译 CSS 对账【basement】 语义门只看 flight 树,**看不见 CSS**——重建工程的样式面是独立债务,且塌法极具 迷惑性(实证:`case-studies/rsc-reconstruction.md` §3.2)。四轮用户实测报障同一根因: 1. ⛔ **扫描面**:逐字图交付(porting-discipline §2.5 第四形态)下,DOM 外壳的 类名活在 verbatim JS 的编译串里——tailwind `content` 不含 `verbatim/**/*.js` = JIT 全部不生成。凡类名所在的每种文件形态都要进 glob。 2. ⛔ **token 台账是相对扫描面的**:"JIT 只编译站上用到的,这就是全集"这句话 在扫描面扩大时作废——f-* 字号桌面档(藏在 `.lg\:text-f-*` 媒体查询里)、 z-navbar、备用模式配色族(machine-*)、自定义字体(fontFamily.sans/mono 被源站覆写到 next/font 变量 + 站点私有字体如 flauta)都要从镜像编译规则 重新取证。字体链尤险:ttf/localFont/CSS 变量三层全在,独缺 token 一层, 照样回退系统字体。 3. ⭐ **carry-css 方法论**(tailwind 生成不了的规则,机器搬运不手抄): 需求面 = 代表路由 SSR DOM 类名并集——**必须覆盖每个路由家族,含备用 模式家族**;减去构建产物已有的类;剩余到镜像 CSS 逐条找规则原文搬运: @media 上下文保留、@keyframes 随 animation-name 连带、元素级 base 规则 (`body{background:#000;font-family:…}`,缺它 = 水合前白闪)单独一道; 工具要幂等(上一轮产物已编进构建 CSS,重跑前先从 have 集剔除自身贡献)。 4. **镜像里也无规则的类 = 源站自身死类**——照抄不修(§1.3),报告里点名即可。 5. ⛔ 选择器分词陷阱:数字开头类名的 CSS 转义带尾随空格(`.\33 xl\:…` = `3xl:…`),naive 的 `\\.` 分词在空格处截断——`2xl/3xl` 断点变体整族漏判。 ### §3.3 从 flight 反推 next.config 的行为证据【darkroom】 配置猜不出来,但**行为会发射进 flight**,逐条对着 Next 源码核: - `cacheComponents`:`"use client"` 页的 `ClientPageRoot` 带 `serverProvidedParams === null` **只在该旗下发射**(Next 16.3.2 `create-component-tree.js` 逐行核对)——行为证据比配置猜测硬。 - React experimental 通道(构建串 `19.3.0-experimental-…`):由 `needsExperimentalReact` 四旗 之一触发(blockingSSR / taint / transitionIndicator / gestureTransition;16.3.2 已无 `viewTransition` 键),哪一旗不可从字节恢复——开最惰性的一旗(`taint`)并登记偏差。 - `react.view_transition` 符号出现在 flight 元素类型里 → `<ViewTransition>`(该 commit 两通道都 导出,非 `unstable_`)。 ### §3.4 flight-to-tsx 生成器的四个陷阱(darkroom 对 basement 版的适配,全部实测) 生成器仍是站点侧工具(basement / darkroom 各一份适配),机制通用,陷阱通用: 1. **LayoutRouter 按 `default#<id>` 判,不按 id 判**——同一模块 id 可同时导出 LoadingBoundaryProvider, 按 id 判会把整个 `(site)` 层吞成 `{children}`。 2. **loading 槽的顶层 key `"l"` 是 Next 给的,不是源码 key**——照抄成 `key="l"` 渲染出 `"l,l"`。 3. **"文件存在即跳过"只能跳过写,不能跳过 harvest**——否则后续路由的组件清单缺一截。 4. head 树 → `metadata`/`viewport` 导出;ClientPageRoot → `"use client"` 转发页;层体仅 `{children}` 的 layout 不生成文件(Next 的隐式层)。 ### §3.5 next/image 优化器产物是像素门的一层资产【darkroom】 镜像侧持有的是 **Vercel 优化器的输出**(`/_next/image?url=…&w=1440&q=…`); 重建的静态树没有优化器,serve 回落到原图——两侧源分辨率不同,浏览器重采样差就是残差 (实证:`case-studies/rsc-reconstruction.md` §3.5)。两件事分开做:① `images.deviceSizes/imageSizes/qualities` 从镜像 srcset 普查**反推**进 next.config(⚠ `qualities` 默认 `[75]` 会把源站的 `quality=90` 静默压回 75); ② `tools/harvest-optimized-images.mjs` 把静态树引用的全部 `/_next/image` 档位补齐——**镜像字节 优先**(源站发了什么才是参照,动态图片生成器只拿得到输出字节,§6),镜像没有的档位才向本机 `next start` 的优化器取并登记为重建侧生成物。 ## §4 语义门(scripts/verify-flight.mjs) 字节门到不了 C1 收口:chunk 名/模块 id/css-module 类/媒体哈希是**构建哈希 命名空间**,不携带行为。门自带解析器(⛔ 不 import flight-decode——检查者 不能是生产者),把两侧流解开、规范化、逐节点深比较;**其余一切差异照红**。 内建规范化族(每条对应一类"证明不携带行为"的论证): - N1 chunk 路径、N2 css-module 类哈希(两种命名形态)、N3 媒体文件哈希; - N4 **模块 id 全局双射**——同一导出名处处对应同一对 id,一对多即红 (符号门在 C1 的同构物); - N5 预载 script 与 precedence 样式链接 = **可提升资源**,挂载点归打包器 (react-tweet 的 css 两侧内容哈希相同、挂载点不同); - N7 children 尾部空白化石、N8 直接元素≡单元素数组、N9 相邻字符串合并 (DOM 渲染等价的编码自由度); - N6 首页 `c:["","index"]`(见 §5)。 站点登记项走旗标:`--normalize-props`(纪元字段)、`--normalize-class` (库渲染子树,如 react-tweet)。 ⭐ 配套:verify-lenprefix 跑构建侧;sweep 跑 `next start` 拓扑(外链按 "忠实于源站"豁免——重建引用原 CDN 是正确行为,和镜像侧的本地化目标不同)。 ## §5 平台层工件(登记,不复刻) - ⭐ **Vercel 边缘把 / 重写到 /index**:镜像首页 `c:["","index"]`、静态预渲染侧 `c:["",""]`——登记 D 类偏差;要逐字节复刻线上 bug 得加边缘重写,通常不值得(实证:`case-studies/rsc-reconstruction.md` §5)。 - PPR/动态渲染的 `BAILOUT_TO_CLIENT_SIDE_RENDERING` 模板 vs 全静态输出。 - Turbopack chunk 切分粒度(preload 数量、I 行 chunk 表长度)。 ## §6 原理性不可恢复面(如实写进恢复率报告) - 错误路径的响应形状(镜像只有 happy path); - 数据层实现(Redis/DB——方向可推断,凭据与写路径不可见); - 服务端缓存层(推文缓存等); - 动态图片生成器源码(只拿得到输出字节); - TS 类型(JS 是保真下界);无入链路由空间。 ## §7 盲逆向纪律(有公开源码的目标) 答案钥匙存在时:收口之前一个字节不看;每个重构决定在头注引用镜像证据坐标 (这是"盲"的操作性定义——模型训练数据可能含目标仓库印象,诚实性条款要写进 报告);对答案产出恢复率报告(结构/行为/字面三轴 + 盲区清单)。**先打有答案的 校准靶,再打闭源实战靶**——差距本身是方法论的下一版输入。 -
sanity-platform.md 14.2 KB
# Sanity CMS 场景(Next/Nuxt 创意站的主流内容层) > **何时加载本文件**:Step 0 指纹命中 Sanity——HTML/flight/payload 里出现 > `cdn.sanity.io/images/<projectId>/<dataset>/`、`*.api.sanity.io` / `*.apicdn.sanity.io` > 请求、载荷里成片的 `_key`/`_type`/`_ref` 字段、或 `/studio` 路由——时。 > 在判级前读完 §0,在 M0 镜像动工前读完 §1。 > > **置信度声明**:本文实证来自五个样本——hashgraphvc(Nuxt 3 + Sanity,L2 收口) > 【hashgraphvc】、basement.studio(Next + Sanity,已收官)【basement】、 > franshalsmuseum(Step 0 探测判 C/D)【franshalsmuseum】、darkroom.engineering > (Sanity 仅脚手架痕迹——反例样本)【darkroom】、14islands.com(pages router + > 直连 auto=format 大户)【14islands】。URL 参数全集(`w/h/fit/crop/q/fm/dpr/auto…`) > 是公开语法、未逐项实测;表外形态现场核验后回填本文。 ## 0. 指纹与判级:Sanity 本身不定级,内容烘焙时点才定级 **指纹语法**(确定规格): - 图片:`https://cdn.sanity.io/images/<projectId>/<dataset>/<sha1-40>-<W>x<H>.<ext>[?w=&h=&fit=&auto=format…]` (dataset 常为 `production`) - 文件/视频:`https://cdn.sanity.io/files/<projectId>/<dataset>/<sha1-40>.<ext>` - 内容 API:`https://<projectId>.api.sanity.io/v<日期>/data/query/<dataset>?query=<GROQ>`; `apicdn.sanity.io` 是它的 CDN 缓存层 - 管理端:`/studio` 路由 = 站内嵌 Sanity Studio(basement 实见,robots 排除)【basement】 ⭐ **文件名自带元数据**:`<sha1>-<W>x<H>` 里的 WxH 是**源资产内在尺寸**(查询参数只做缩放裁剪), sha1 是内容地址——"多少个不同图"的清点、变体归并、资产去重对账,直接按 hash 段做 (实证:`case-studies/sanity-platform.md` §0)【basement】。 **判级判据不是"有没有 Sanity",是内容在哪个时点被烘进客户端可见的字节**。三形态: | 形态 | 判据 | 处置 | 实证 | |---|---|---|---| | **构建期烘焙** | 内容全部已在 SSR HTML / flight / `__NUXT_DATA__` 里;运行时零 GROQ 流量 | 不改判级,A/B/C1 照跑;Sanity 只是资产 CDN + 载荷里的数据形状 | 【basement】【hashgraphvc】主路径 | | **局部 fallback 查询** | 特定路径(404 壳、预览态)才打 `*.api.sanity.io` | B 类 API 快照:镜像实测响应、按**完整 query 键**应答(见 §1.4) | 【hashgraphvc】未知路由的法律页查询 | | **运行时装配** | 首屏内容靠运行时 GROQ / `?_rsc=` 端点,且内容实体持续漂移(展讯、日程、库存) | **D 因素**:复刻对象必须改述为"某时点快照",否则对象错位按 D 拒绝 | 【franshalsmuseum】 | 操作化:断网伺服镜像看首屏是否发 `*.api(cdn)?.sanity.io` 请求;对照 flight/payload 里内容是否已齐。判定结论连同 projectId/dataset 钉入 `engine-notes.md`。 ## 1. 镜像层 ### 1.1 `--hosts` 清单(CDN 站假 GAP=0 的老课,Sanity 版) netcapture / mirror-site 的外部主机清单必含(按站取舍):`cdn.sanity.io`、 `<projectId>.api.sanity.io`、`<projectId>.apicdn.sanity.io`(实证:`case-studies/sanity-platform.md` §1.1)。 ⚠ **next/image 代理形态里 Sanity 主机是被编码嵌套的**: `/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2F…&w=1200&q=75`——off-host 普查与 `--hosts` 判定都要**先解码 `url=` 参数再判主机**,否则 Sanity 引用整批被计成同源流量。 ### 1.2 ⛔ `auto=format` 是内容协商:裸 fetch 与浏览器拿到的是两种字节 带 `auto=format` 的 URL,CDN 按请求的 `Accept` 头选返回格式。v0.3.9 之前本 skill 的 抓取 profile 全是 `accept: */*`——**从不声明图片格式支持**,于是 Sanity 一律回退 JPEG/PNG;而真浏览器(`Accept: image/avif,image/webp,…`)同一 URL 拿到 webp。 **同一 URL、两种字节,镜像与浏览器运行时就此分叉。** 魔数普查见 59 个扩展名↔魔数分叉,而**双 Accept 采样 6/6 全分叉**——即**分叉面是全部栅格变体, 不止扩展名穿帮的那 59 个**:魔数普查只看得见协商跨过扩展名边界的尖角,量化全貌必须 双 Accept 采样(实证:`case-studies/sanity-platform.md` §1.2)。三个配套事实: - **响应自己声明了协商**:`Vary: origin, accept`——凡 Vary 含 `accept` 的条目,字节都 随请求 profile 变(`lib/negotiate.mjs` 的 `isNegotiated()`); - **裸 Accept 重抓 6/6 sha256 与镜像精确一致**——分叉是 profile 级不是时间漂移,镜像 在 `*/*` 标尺下内部自洽; - **浏览器协商结果是一个分布,不是一种格式**:avif 份额随站与资产尺寸变,小样本"未见 avif" 不成为论断,样本放大即修正【14islands】【basement】。 后果三连: 1. 镜像伺服的字节 ≠ 浏览器在源站拿到的字节(jpeg 压痕 vs webp,体积差实测可达 18×), **对源站保真这一维度上是静默偏差**; 2. 两侧都从镜像读时,跨侧门、像素门**照绿**——这是"错的镜像能让下游门全绿"的又一实例; 3. `serve.mjs` 按扩展名猜 MIME 会报 `image/webp` 而实发 JPEG 字节(浏览器嗅探兜住了 渲染,兜不住保真)。 **处置**(第一条 v0.3.9 起已内置): - 新镜像:`mirror-site.mjs` / `reconcile-gaps.mjs` 对图片 URL 自动发**浏览器同款图片 Accept**(`lib/negotiate.mjs` 的 `IMG_ACCEPT`,逐字照抄 Chrome——标尺只有一把,不 自创格式偏好;判"是图片"优先信 CDP TYPE 提示,其次 URL 拼写,next/image 代理先解码 `url=`);账本每条新记 `profile` 与 `vary`,协商条目从此可审计。 - 查存量镜像(一条命令级):枚举含 `auto=format` 的镜像文件,魔数 vs URL 扩展名 vs 账本 content-type 三方对照(参照 basement 项目侧 `scripts/census-negotiated.mjs`); 老账本没记 `vary`/`profile` 的,这本身就是账本盲区,一并登记。 - 存量镜像:**镜像神圣,不许原地改字节**——分叉登记为偏差(源站怎么发 / 镜像存了什么 / 为什么 / 何时重抓),**是否重抓交用户**。重抓的落地形态(basement D5 实证):**独立 记账树** `mirror-negotiated/`——自有 manifest/inventory、同一套 `lib/urlpath` 映射、 账本记 `profile`/`vary`/`baseline`(旧 sha);旧树零改动、两树同 URL 键逐条可对照、 覆盖门无需改语义;以浏览器字节为参照的门用 `serve --fallback-root` 链 negotiated→mirror。 ### 1.3 变体阶梯:字节推导全集,两层展开 next/image 的 srcset 穷举课(1,078 vs 浏览器碰到 217)在 Sanity 站有两条通路,**都要展开**: 1. **直连**:`cdn.sanity.io/…?auto=format&fit=max&w=<档>`——阶梯从 SSR HTML / flight 的 srcset 逐条穷举; 2. **代理**:`/_next/image?url=<encoded sanity url>&w=<deviceSizes 档>&q=`——外层按 next/image 的 deviceSizes 档位穷举,内层解码后即直连形态,**两层各自入账**。 ⭐ 已观察形态(两样本,可当线索不当规格):`@sanity/image-url` builder 拼出的查询参数呈 字母序(`auto=format&fit=max&h=…&w=…`)——参数序稳定意味着阶梯 URL 可精确预生成, 也是"这批 URL 出自官方 builder 而非手拼"的弱指纹【basement】。 ### 1.4 ⛔ 运行时拼接的 API base:普通 host 改写命不中 Sanity client 由 `projectId + dataset` 在**运行时拼出** `https://<projectId>.api.sanity.io/…` ——镜像本地化的字面量 host 改写对拼接结果无效(字符串在源字节里不存在)。hashgraphvc 的登记做法(偏差 6.2):服务层把 client 的**URL 构造模板**改写为 `${location.origin}/ext/…`, GROQ query 原样保留;断网下 404 壳会**反复重试同一查询**(怪癖 Q3,照抄行为、只本地化 endpoint),应答按**完整 query 字符串为键**返回镜像实测的那份 JSON【hashgraphvc】。 这与 `serve.mjs --rewrite` 的"源程序按自己的域名分支"是同族问题——先 grep bundle 里的 `api.sanity.io` / `projectId` 构造点,再决定改写落在哪层。 ### 1.5 robots 与授权边界 - `cdn.sanity.io/robots.txt` 按 project 路径逐条声明(hashgraphvc 实测:允许 `/files/<projectId>/`,PDF 类 Disallow 未命中本站资产)——SKILL.md 的**逐路径判定 纪律**在第三方 CDN 上同样适用,不因存在 Disallow 行判全站禁止【hashgraphvc】。 - `/studio` 是管理端:登录墙之内 + robots 排除,**不抓**,登记为技术性排除【basement】。 ## 2. C1/重构层的 Sanity 面 - ⭐ **`_key` 是化石**:Sanity 数组项的随机 `_key` 串原样进 flight/payload——照抄发射, `verify-flight.mjs` 会照比。它是内容数据不是构建噪声,**不进任何 normalize 名单**。 - **ISR 纪元 = 内容漂移的投影**:`rsc-reconstruction.md` §2 的"各页可能是不同再生时刻" 在 Sanity 站是常态(编辑随时发布)。镜像**单会话紧凑抓完**压小纪元差;残余纪元字段走 `--normalize-props` 并登记偏差。 - **重构侧数据源**:C1 重构工程用单一数据快照(从 flight 反推的内容树)替代 GROQ 数据层 ——数据层实现属"原理性不可恢复面"(`rsc-reconstruction.md` §6),方向可推断、 凭据与查询不可见,如实写进恢复率报告。 ## 3. 复刻侧与部署决策 - **忠实拓扑**(C1 `next start` sweep)引用原 `cdn.sanity.io` 是正确行为(外链按"忠实于 源站"豁免);**镜像/断网拓扑**必须本地化。两个拓扑两套断言,别混。 - ⚠ **产物引用原 projectId = 内容仍挂在权利人的 Sanity 账上**:源项目改内容/删 dataset, 产物跟着漂移或断图;公开部署还持续消耗对方带宽配额。这条作为**事实**列进 `legal-and-deploy.md` 的呈交材料,决定归用户。 - 版权取证照常逐资产:Sanity 上的图/视频是站主(或其客户)的内容资产,CDN 只是载体。 ## 4. 开工速查卡:Next + Vercel + Sanity 创意栈 > 这个圈子的栈是模板化的(Next/Nuxt + Vercel + Sanity + three/GSAP/Lenis——Satus 一类 > starter 的直接后代)。本卡把四个实测站(basement / hashgraphvc / darkroom / 14islands) > 每次开工都要现场重推的东西固化成预设。Vercel 平台层工件另见 `rsc-reconstruction.md` §5。 **指纹速判**(Step 0,`fingerprint.mjs` 全部自动报): | 看什么 | App Router 形态 | pages router 形态 | |---|---|---| | 框架标记 | `self.__next_f`(flight 流;C1 路线) | `__NEXT_DATA__` + `"buildId"`(payload 路线,走 verify-payload) | | chunk 命名 | `/_next/static/immutable/chunks/`(Turbopack 另带 `turbopack-*.js`) | `/_next/static/chunks/` + 内容哈希 | | Sanity 接法 | 直连(basement)或仅 `:HC` preconnect(darkroom——**在栈里但资产同源**,别硬找 projectId) | next/image 代理(14islands:`/_next/image?url=<编码的 sanity url 含 rect=>&w=&q=`) | **M0 `--hosts` 预设**(按指纹删减):`cdn.sanity.io`、`<projectId>.api.sanity.io`、 `<projectId>.apicdn.sanity.io`、`fonts.googleapis.com` + `fonts.gstatic.com`、 `stream.mux.com` 等 `*.mux.com` 族(视频,basement/14islands)、`www.googletagmanager.com` (遥测,通常 D5 登记不抓)。⚠ **预设不会自己进命令行**(实证:`case-studies/sanity-platform.md` §4):开工时把 本行逐项抄进 mirror-site / netcapture 的 `--hosts`,抄完对着 off-host 普查核一遍。 **⛔ 协商面三族**(全部实测;镜像账本 v0.3.9 起记 `vary`,关账前对账本 Vary 普查一遍即得全景): 1. **图片**:Sanity `auto=format`(§1.2)与 **Vercel `/_next/image` 优化器**(同样按 Accept 返回 webp/avif)——两层都可能协商,`lib/negotiate.mjs` 的浏览器 Accept 覆盖两者; 2. **`.md` 孪生路由**:同一路由按 Accept 返回 HTML 或 markdown(darkroom:全部路由有 `.md` 孪生 + `llms.txt`,响应 `Vary: Accept`)——llms.txt 时代的新常态,镜像两份都收; 3. **flight**:`Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch`——同 URL 按 header 返回 HTML 或 flight 载荷(`?_rsc=` 的 header 形态),镜像收 HTML 形态、flight 走 `?_rsc=` 变体入镜(rsc-reconstruction §2)。 **运行时资源族清单**(BFS 看不见、netcapture/推导要补的;⭐ **能从字节推导的先推导,再拿 netcapture 对账**——webpack runtime 的 `h.u`(chunk id→hash 表)+ `h.miniCssF` + `_buildManifest` 推出 chunk/css 清单,`_next/data/<buildId>/<route>.json` 按路由表推导;实证见 `case-studies/sanity-platform.md` §4):`?_rsc=` 预取载荷、 next/image srcset 阶梯(§1.3 两层展开)、动态 OG 图(`og:image`/`twitter-image` 指向的 爬虫专供路由)、well-known 探测(`/sitemap.xml` `/robots.txt` `/llms.txt` `/openapi.json` `/*.md` 孪生——darkroom 实测五种都有)、`/_vercel/insights`(快照入 public/,登记)。 **verify-flight 常用旗标**(C1 收口):`--normalize-props` 收 ISR 纪元字段(Sanity 站 内容漂移的投影,§2);Vercel 动态流 vs 本地静态构建的 row-0 平台字段差由 N11 内建 (v0.3.2);`_key` **永不进 normalize**。 ## 5. 关账 checklist(Sanity 场景) - [ ] projectId / dataset / 内容烘焙时点(§0 三形态之一)钉入 `engine-notes.md`,判据留痕 - [ ] `--hosts` 含 cdn / api / apicdn 三主机;next/image 代理 URL 解码后计入 off-host 普查 - [ ] `auto=format` 魔数普查跑过(魔数 vs 扩展名 vs 账本三方对照),fetch profile 逐条入账; 存量分叉已登记为偏差 - [ ] 变体阶梯从 srcset/flight 字节推导,直连 + 代理两层各自入账,对账 GAP=0 - [ ] 运行时拼接 API base 的改写落在服务层并登记;断网 fallback 查询有 query-keyed 应答, 重试行为照抄 - [ ] `/studio` 等授权边界排除已登记;cdn robots 逐路径判定记录在案 - [ ] `_key` 未进 normalize 名单;ISR 纪元字段的 normalize 已逐字段登记 - [ ] 部署呈交材料含"内容仍在权利人账上"事实项 -
scope-and-fingerprint.md 24.6 KB
# 第 0 步:范围判定与指纹路由(⛔ 阻塞门) > **何时加载本文件**:拿到目标 URL 后、执行任何镜像/逆向动作之前。本步骤是阻塞门——判级未落地前,禁止进入 M0。全部判据来自 43 站边界探测实测【probe】,其中三个已复刻站(landonorris/lusion/noomo)作为阳性锚点全部通过校验。 ## 1. 判级体系与 v0.1 范围政策 | 判级 | 定义 | v0.1 政策(判出后立即执行) | |---|---|---| | **A** | 完全适用:与六个已完成项目同物种,管线(镜像→beautify 行号逆向→转写移植→确定性验收)①→④无断点 | **主场,直接做**。进入 SKILL.md 主流程 M0,加载 `references/mirroring.md` | | **B** | 适用但缺分场景指南:管线成立,断点全部是"缺某份操作指南" | **可做**。若对应分场景指南已存在则加载;尚缺则明确提示用户"该场景指南待补(v0.2+ roadmap),可继续但对应环节需自行摸索",列出缺口名称后再动工 | | **C** | 逆向模式需改变:声明式框架/资产化动画使"转写式移植"失效,需"重构式逆向" | **明确拒绝**并解释:"该站为声明式架构(RSC/编译后组件树),本 skill 的转写式方法论不适用;需要的是重构式逆向(从运行时输出反推组件结构再重写),是另一门手艺,v0.1 不支持" | | **D** | 方法论失效:行为主体在服务端,客户端无可移植目标 | **拒绝**。这是永久边界,不是待补指南 | | **X** | 原站已消失:断在第 0 步,无镜像对象 | **引导用户**:告知原站已消亡及消亡形态,给 archive.org(Wayback Machine)抢救路径,或建议换目标 | 判级的真正变量不是框架名、年代或站点类型,而是**签名行为(让这个站获奖的那些效果)住在哪里**【probe】: - 住在**静态资产**里(minified/未混淆 bundle、GLSL、GLB、视频、Rive 文件)→ A/B; - 住在**声明式组件树/时间轴数据**里(RSC flight 流、R3F+Theatre 的场景即组件树、动画即数据)→ C。注意"框架是 Vue/Nuxt/React Router"本身**不构成** C——框架名与引擎范式是两个维度,见 §3/§4 二维表【shopifydesign】; - 住在**服务端函数**里(WordPress、电商库存、A/B 分桶、个性化)→ D。 ## 2. 指纹探测流程(六步,curl-only 可执行) 准备:统一 UA、请求间隔 ≥1s、产物落 `probe/` 目录留证。 ```bash UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' TARGET='https://example.com/awarded-path' # 必须是获奖/目标路径本身,不是只探根域 mkdir -p probe ``` > **无 POSIX 工具链时**(如 Windows PowerShell 缺 curl/cmp/fold/tr/perl;PowerShell 里 `curl` 是 `Invoke-WebRequest` 别名且默认跟随重定向,恰好抹掉 §3 的 X 信号):用 [`scripts/fingerprint.mjs`](../scripts/fingerprint.mjs) 一条命令等价执行本节六步——`node <skill>/scripts/fingerprint.mjs --target "<TARGET>" [--bundle <bundle-url>]`。产物同样落 `probe/`(a.html / b.html / bundle-*.js / fingerprint-report.md),计数为出现次数语义(符合下文《计数硬约束》第 1 条),下载物逐个记 sha256。它**只采证据不出判级**——§3 判定树、§4 三判据与 §6 的解读仍须人工执行。 ### 步骤 1:存活性(GET,路径粒度) ```bash curl -sL -A "$UA" -o probe/a.html \ -w 'code=%{http_code} final=%{url_effective} redirects=%{num_redirects} time=%{time_total}s\n' "$TARGET" ``` - **必须 GET,禁止用 HEAD(`curl -sIL`)作唯一判据**:API Gateway/CloudFront 前端对 HEAD 返回假 404【probe】。 - **路径粒度**:根域 200 不等于作品存活——根站活、获奖路径 404 是常见形态【probe】。 - **最终 URL 同一性校验**:`final` 落点域 ≠ 目标域即 X 信号【probe】。`curl -sIL` 表面 200 会掩盖 301 退役信号。 (三条的实证出处:`case-studies/scope-and-fingerprint.md` §2 步骤 1) ### 步骤 2:双抓 diff(确定性) ```bash sleep 5 curl -sL -A "$UA" -o probe/b.html "$TARGET" cmp -s probe/a.html probe/b.html && echo BYTE-IDENTICAL || { wc -c probe/a.html probe/b.html; diff <(fold -w 80 probe/a.html) <(fold -w 80 probe/b.html) | head -40; } ``` 三分类,直接影响判级与后续验收门设计: - **byte-identical**:理想镜像对象(SSR 输出连注水负载都可逐字节一致)【probe】。 - **token 级差异**:仅 nonce/随机装饰串(含 WAF/CDN 每次注入的轮换 token)→ 仍可镜像,验收门加掩码规则,**不要误判为动态渲染判 D**【probe】。 - **内容级差异**:文案/结构/数据随请求变(A/B 分桶、个性化注水)→ D 信号【probe】。 (三类的实证出处:`case-studies/scope-and-fingerprint.md` §2 步骤 2) ### 步骤 3:物种/年代校验(防"隐性下线") 200 且确定 ≠ 是那个作品。**HTTP 200 的尸体**是五种消亡形态里最隐蔽的一种: ```bash grep -io '<meta name="generator"[^>]*>' probe/a.html # 平台/主题指纹 grep -c 'wp-content' probe/a.html # WordPress 密度 grep -oE '(Copyright|©)[^<]{0,80}(19|20)[0-9]{2}' probe/a.html | head # license/版权年份 grep -ciE 'shopify|Prestige|Dawn|elementor' probe/a.html # 商店主题替身 ``` - **技术栈年代与获奖年份矛盾** → X(根站已是当代重建版)【probe】。 - **generator/依赖 license 年份晚于获奖期 + 获奖期技术栈残留 grep 为零** → 隐性下线判 X【probe】。 - 域名活 ≠ 作品活:获奖原版可能被重建版**原地偷换**(域名不变)——如需复刻"获奖那一版",须提示用户走 Wayback【probe】。 (三条的实证出处:`case-studies/scope-and-fingerprint.md` §2 步骤 3) ### 步骤 4:技术指纹(HTML 层) ```bash # script 枚举必须先剥 HTML 注释(注释内脚本会污染清单) perl -0777 -pe 's/<!--.*?-->//gs' probe/a.html | grep -oE '<script[^>]*src="[^"]*"' | sort -u # 现代站可能没有任何 <script src>(Shopify Editions 三代全靠内联 import())——再搜内联动态导入 grep -oE 'import\("[^"]+"\)' probe/a.html | sort -u # 维度① 框架模式(框架下不下发行为源)——这些标记单独命中一律不判级,见 §3/§4 grep -o 'self.__next_f' probe/a.html | wc -l # Next App Router RSC flight → 不下发组件源 grep -o '__reactRouterContext' probe/a.html | wc -l # React Router framework 模式 → 下发 route module grep -o '__NUXT__' probe/a.html | wc -l # Nuxt → 下发组件源 grep -o 'data-v-[0-9a-f]\{6,8\}' probe/a.html | wc -l # Vue scoped 密度 grep -o '<!--\[-->' probe/a.html | wc -l # Vue3 SSR fragment 注释 # 维度② 引擎范式(签名行为怎么写的) grep -oiE 'theatre|@react-three' probe/a.html | wc -l # R3F / Theatre.js → 声明式引擎 → C ``` > 出现次数一律 `grep -o … | wc -l`,**不用 `grep -c`**(数的是匹配行数,不是出现次数)——理由与实测见本节末《计数硬约束》【shopifydesign】。 ### 步骤 5:bundle 可逆向性 ```bash BUNDLE='https://example.com/assets/main.xxxx.js' curl -s -A "$UA" -o probe/bundle.js -w 'size=%{size_download}\n' "$BUNDLE" # 响应 <1KB → 极可能是缺 Referer 的拒绝页(landonorris 返回 32 字节拒绝页造成假阴性),补齐 Referer 重试 [ "$(wc -c < probe/bundle.js)" -lt 1024 ] && curl -s -A "$UA" -e "${TARGET%/*}/" -o probe/bundle.js "$BUNDLE" # minification 形态预检(未混淆产物可跳过 beautify,省一道工) wc -lc probe/bundle.js awk '{ if (length($0)>m) m=length($0) } END { print "longest_line=" m }' probe/bundle.js grep -c 'sourceMappingURL' probe/bundle.js # MB 级单行文件先注入换行再 grep,防有界量词正则卡死(tr 只替换分隔符,不改变 token 出现次数) tr ';{}' '\n' < probe/bundle.js > probe/bundle.lines grep -o 'WebGLRenderer' probe/bundle.lines | wc -l # three 认强签名(WebGLRenderer/REVISION),不认弱字符串 "three" grep -o 'THREE.WebGLRenderer' probe/bundle.lines | wc -l # 其中属 three 自带报错串的份额 = vendor 污染量 grep -o '/api/' probe/bundle.lines | wc -l # >0 ⇒ 镜像阶段强制做运行时 API 快照(B 信号) ``` - 有公开 sourcemap(`sourcesContent` 完整)→ 直取源码替代 beautify 流程,但 RSC 站仍按 C 处理(sourcemap 不改变行为归属)【probe】。 - 未混淆产物(esbuild 标识符全保留一类)→ 跳过 js-beautify,行号坐标系直接建在原文件上【probe】。 (两条的实证出处:`case-studies/scope-and-fingerprint.md` §2 步骤 5) ### ⛔ 计数硬约束(贯穿步骤 4/5;任何进难度评级表的数字必须先过这三条)【shopifydesign】 用 `tr ';{}' '\n' | grep -c` 数 token 会一次产出多个假数字,且**直接进了难度评级表**——整条星级可能建立在一个根本没被使用的库上(实证:`case-studies/scope-and-fingerprint.md` §2《计数硬约束》)。 1. **`grep -c` 数的是"匹配行数",不是"出现次数"。** 同一行命中 5 次只记 1。**协议里凡是要"出现次数"的地方一律 `grep -o PATTERN FILE | wc -l`**;`grep -c` 只可用于回答"有没有"这种是非题。 2. **vendor 库自带字符串会污染计数**(three.js 自己的 `"THREE.WebGLRenderer: …"` 报错串、内置 shader chunk 库都会被计进来)。**任何要进评级表的数字,必须先做一次 vendor 归属剔除**:先定位应用区间(license banner 定起点、`class X extends Y` 收尾校准终点,并扣除中间的"vendor 岛"——方法见 `references/reverse-engineering.md` §2.2),**只在应用区间内计数**。Step 0 阶段若还没建区段地图,至少要把该数字标为"含 vendor,未剔除"。 3. **计数只提假设,不当结论。** 这条纪律原在 `references/reverse-engineering.md` §4.1,此处前移复述——因为**错误计数在 Step 0 就已经污染决策**(判级、评级、工期估算)。每个数字都是待证伪的假设:进评级表前至少回上下文确认一处**真实使用点**(构造调用 / `registerPlugin` / shader 被 material 消费),确认不了就在 verdict 里标"未确认",不许拿它抬高或拉低星级。 ### 步骤 6:行为归属 → 出判级 综合 1-5 步回答一个问题:**签名行为的行为源在客户端 chunk 里吗?** 按 §3 判定树落判级,写一句话断点("最先断掉的是第几步、为什么"),落盘 `probe/verdict.md`。 ## 3. 判定树(按序执行,命中即停) ``` 1. X 硬判据(任一命中 → X,停止): ├─ 最终落点域 ≠ 目标主体域(301/302 转发、域名易主/抢注/平台回收) ├─ 目标路径 GET 404(且已排除 HEAD 假 404) ├─ 技术栈年代与获奖年份矛盾(根站是当代重建版) └─ 隐性下线:generator/license 年份晚于获奖期 + 获奖期技术栈残留为零 2. D 信号(坐实任一 → D): ├─ wp-content 高密度 + WordPress generator meta(内容与行为主体在服务端 PHP+DB) │ ⛔ **在目标路径上量,不在宿主域上量**。企业站的周年微站、活动页、发布会页 │ 常以**静态子目录**挂在 WordPress/Drupal 域下:宿主的 `/wp-admin/`、 │ `/wp-sitemap.xml`、robots 里的 wp 痕迹**不构成目标的 D 信号**。 │ 实证【aimservices】:宿主 robots 三行全是 WordPress,而目标 `/50th/` 的 │ `wp-content` 命中 **0**、无 generator meta、资产全在 `/50th/assets/` 下—— │ 地面真值是 A。判据本身没错(它量的是路径),错的是照着 robots 先看的读法。 │ ⛔ **第二种误伤:目标路径本身就是 WordPress 页,但行为不在服务端**【lamalama】。 │ 这条判据的括号写的是"内容与**行为**主体在服务端"——两个主体要**分别**量: │ 内容由 PHP 渲染不构成 D;只有当签名行为也在服务端(客户端没有可移植目标物、 │ 或双抓为内容级差异)才是 D。判法:generator meta 命中后**先做步骤 2 与步骤 5**—— │ 双抓 byte-identical + 主题 bundle 里住着签名行为(自研 GL / GSAP / 转场 / 播放器) │ → 按 §4 二维表继续判 A/B,WordPress 只是外壳生成方(dom-shell-strategies 策略 A)。 │ 实证:`case-studies/scope-and-fingerprint.md` §3【lamalama】。 ├─ 双抓为内容级差异(A/B 实验分桶、个性化注水 → 确定性验收彻底断裂) └─ 签名行为依赖 cart/checkout/GraphQL 数据面(行为主体是服务端函数) 3. C 判定(**二维**,任何单信号命中都不判级)【shopifydesign】:先各取一维证据,再交叉查 §4 二维表 ├─ 维度① 框架模式 —— 框架下不下发行为源(HTML 层取证,用 §4 三判据坐实) │ ├─ 下发 route module:__reactRouterContext(React Router framework 模式)/ Remix / │ │ __NUXT__ / __NUXT_DATA__ / data-v- 高密度 + <!--[--> fragment 注释 │ └─ 不下发组件源:self.__next_f(Next App Router RSC flight 流) └─ 维度② 引擎范式 —— 签名行为用哪种范式写的(bundle 层取证) ├─ 命令式:three / GSAP / 裸 WebGL,渲染与交互逻辑本身在客户端 chunk 里 └─ 声明式:@react-three/fiber、Theatre.js(场景即组件树、动画即数据) → 落"下发 route module × 命令式"格 → 继续按 4/5 判 A 或 B;其余三格 → C 4. A 类签名: ├─ 【必要】静态构建器产物(webpack/Vite/Astro/Browserify 皆可,年代无关——2019 老栈照样 A) ├─ 【必要】少数几个 bundle(而非上百个组件粒度 chunk);单体 ≥1MB 是常见形态,不是门槛 ├─ 【必要】双抓 byte-identical(或仅 token 级差异) ├─ 【必要】无内容级 API 依赖(⚠ `/api/` 为零**不足以**判定,见 §8 的假阴性实测;以 M0 补录观测到的实际请求为准) └─ 【⚠ 条件式,不是必要条件】**若站上有 3D**,three 必须认强签名 (WebGLRenderer/REVISION 命中,弱字符串 "three" 不算)。 ⛔ **无 3D 不影响判 A**:GSAP 时间轴 / Canvas 2D / 纯 CSS-JS 编排的滚动站 本来就是 A 类主场(SKILL.md「适用范围」原文),它们的 three 计数必然是 0。 实证【aimservices】:一个 GSAP+ScrollTrigger+Swiper 的静态微站,其余四条全中、 three=0,按"全部命中"的旧写法落不进 A,而第 5 条 B 的附加条件清单里 **一条都对不上**——最典型的纯 GSAP 滚动站在判定树里无家可归。 A 类的实质判据是 §4 二维表那两格(行为源下不下发 × 命令式还是声明式), 不是有没有 three。 5. 其余 → B:管线主线成立,但存在以下任一附加条件(即"缺哪份指南"): 多 chunk 大规模切片(stripe 74 分包)/ Shopify 平台层剥离(✅ 指南已就绪: `references/shopify-platform.md`;allbirds、mana-yerba-mate、 pangram-pangram)/ SSR 快照锁定 + 端点 stub(hackernews)/ React-SSR 冻结与注水剥离 / 行为外置进 Rive、glTF、KTX2 二进制资产的直搬与 runtime 锁定 / Nuxt-Vue SSG payload 展开 (chungiyoo)/ 第三方 GCS 桶 + manifest 驱动资产发现(kodeclubs)/ 公开 sourcemap 直取 + WAF 轮换 token 掩码(orano)/ 运行时 API-headless CMS 快照(synchronized-studio)/ HAR 驱动镜像 (persepolis)——逐项列名后按 §1 的 B 政策执行【probe】 ``` 杂交站可分层判级:整体 C 的站,其 three 子层(独立 chunk 的命令式代码)可局部按 A 手法转写【probe】(实证:`case-studies/scope-and-fingerprint.md` §3)。v0.1 政策仍按整体判级执行,分层结论写进 verdict 供用户参考。 ## 4. 二维判定表 + 三判据规则(防 noomo / shopify.design 型误判,宪法级) **"检测到 Vue/Nuxt/声明式框架 → 判 C"是被锚点站证伪的错误捷径**【probe】。**同一类错误在 `__reactRouterContext` 上重犯过一次**【shopifydesign】:**信号被记在了错误的维度上**——框架名带来的是"下不下发行为源",引擎范式才决定"下发的东西能不能转写"。(两次误判的实证:`case-studies/scope-and-fingerprint.md` §4) 正确判据是 **框架模式 × 引擎范式** 二维: | | 命令式引擎(three / GSAP / 裸 WebGL) | 声明式引擎(R3F / Theatre) | |---|---|---| | **框架下发 route module**(React Router framework 模式、Nuxt、Remix) | **A**(shopify.design、noomo) | **C** | | **框架不下发组件源**(Next App Router RSC) | **C**(opal-tadpole) | **C** | #### 4.0.1 ⛔⛔ C 类要拆成两类:「源码不下发」≠「写法是声明式」【eightdesign】 上表把 R3F / Theatre 归进「声明式引擎 → C」,理由写的是「**行为源不在客户端可读代码里**」。⛔ **这个理由对 RSC 成立,对 R3F 不成立**;把两者并成一格,会拒掉一整类其实做得了的站。 实测一个 Next + Turbopack + R3F 的站:`useFrame` 回调里是普通命令式数学与状态写入,18 个模块逐字切片成功、换进页面 CLEAN、跨侧 99.5%(逐项观测:`case-studies/scope-and-fingerprint.md` §4.0.1)。 ⭐ **组件树是脚手架,行为住在钩子里,两者都下发、都能逐字切片。** 切片器不关心范式——它切的是字节。 判据应当拆成: | 子类 | 判据 | 处置 | |---|---|---| | **C1** | 组件源**根本不下发**(RSC 服务端组件,只下发 flight 序列化结果) | ⭐ **v0.3 起可做:重构式逆向**——flight 流是服务端组件的完整输出,内联在每页 HTML 里,即规格书。路线与门型见 `rsc-reconstruction.md`;实测 rauchg.com 18/18 路由语义一致。⚠ 没有逐字 port 可言,L2/L3 合并,产物是"人写的源码 + 语义门证明的等价" | | **C2** | 组织方式声明式,但**源码下发**(R3F / Theatre / Vue SFC 编译产物) | ⭐ **按 A 类跑**;渲染器当平台层从镜像伺服,与 Next 运行时、Apple 的 `ac/*` 同型 | ⛔ **判别器不是库名,是 §4 判据③本身**——「客户端是否持有行为源」。本文件此前用**库的身份**当它的代理,而 §4 开头警告的正是同一个错误:**「信号被记在了错误的维度上」**。这次它在低一层重犯了:R3F 出现被当成「源码不在」的代理,而它不是。 ⚠ 判 C2 之前仍要坐实两件事:① 站点**自有**代码用了那个库(不是 vendor 里躺着);② 那些回调里确实是数学与状态写入,不是空壳。两条都可量化(量化样例见 `case-studies/scope-and-fingerprint.md` §4.0.1)。 读法:**只有"下发行为源 × 命令式引擎"这一格是 A**。任一维塌向声明式或不下发,转写式移植要抓的那个"行为源"就不在客户端可读代码里,判 C。 三判据规则**保留**——它是取维度①证据的操作方法(判定"框架是否下发行为源"),二维表是它的结论形式,二者并用不可省。框架标记命中后,必须逐条回答: 1. **内容可镜像性**:同 URL 短间隔 HTML 是否确定(byte-identical / 仅 token 级差异)?全部内容能否落成静态文件? 2. **签名交互的承载层**:获奖视觉/交互是否为可下载、可 beautify、行号稳定的**客户端命令式代码**(GSAP/three/WebGL)? 3. **客户端是否持有行为源本身**:客户端 chunk 包含渲染/交互逻辑本身,还是仅有服务端序列化结果(RSC flight)? 三判据全"是" → 维度①落"下发 route module",再按维度②查表(声明式框架只是抬高脚手架复刻成本,不改变签名行为的转写可移植性)。判据③为"否" → 维度①落"不下发组件源",无论维度②如何一律 C(opal-tadpole 反例:Next App Router + RSC,服务端组件源码不下发客户端,只下发 flight 序列化结果——这才是真 C)【probe】。 区分口诀:**框架用于组织 DOM/状态的是脚手架;判级看的是签名行为存放在哪一层、用哪种范式写的**。 ## 5. 探测纪律(14 条协议修正,逐条为实测教训)【probe】【shopifydesign】 逐条的实测出处见 `case-studies/scope-and-fingerprint.md` §5。探测中的每一步都遵守本清单;违反任一条都产生过真实误判: 1. 存活性判定到**路径粒度**,且用 GET 不用 HEAD(API 网关对 HEAD 假 404)。 2. `curl -sIL` 表面 200 会掩盖 301 退役信号——必须校验**最终 URL 与目标主体同一性**。 3. 200 后必须做**物种/年代校验**:generator meta、主题 schema、依赖 license 版权年份、获奖期技术栈残留 grep。 4. bundle 响应 <1KB → 补齐 **Referer** 请求头重试(资产域缺 Referer 时会返回几十字节的拒绝页,造成假阴性)。 5. script 枚举要**排除 HTML 注释内的脚本**。 6. 现代站 HTML 可能**没有任何 `<script src>`**(全靠内联 `import()`)——只认 script 标签会漏掉全部 JS。 7. **catch-all 假 200**:请求 `.map` 返回 index.html——对下载物做 content-type 与哈希碰撞校验。 8. bundle 内出现 `/api/` 字符串 ⇒ 强制做**运行时 API 快照**(B 信号)。 ⛔ **但零命中不能反过来当作"无接口"——这条判据假阴性高发**【airpodspro】。它测的是**命名习惯**,不是行为。大厂常按业务命名,运行时接口路径里可以一个 `/api/` 都没有(实证:`case-studies/scope-and-fingerprint.md` §5)。**M0 补录之前不得据此排除 B/D 类。** 但**同一个盲点用在别的站上可能把 D 类误判成 A 类**。 9. MB 级单行文件先 `tr` 注入换行再 grep,防有界量词正则卡死。 10. 未混淆产物可跳过 js-beautify——先做 **minification 形态预检**再决定流程。 11. 有公开 sourcemap 时直取 sourcesContent 源码,替代 beautify 流程。 12. WAF/CDN 每次注入的轮换 token 是 nonce 级差异,**不要误判为动态渲染判 D**(把它当可掩码噪声即可)。 13. **出现次数一律 `grep -o … | wc -l`,禁用 `grep -c`**——后者数的是匹配行数【shopifydesign】。 14. **计数只提假设,不当结论**:vendor 自带字符串会污染计数,进评级表的数字必须先做 vendor 归属剔除并回上下文确认一处真实使用点(见 §2《计数硬约束》)【shopifydesign】。 ## 6. 常见坑 - **HEAD 假死 / GET 存活**:Lambda/API GW 托管静态站的常见形态,只用 `-I` 会把活站判 X【probe】。 - **平台名预判**:凭"这是 Webflow/大厂站"直接预判会错——平台站也可能是手写 GSAP/three.js bundle,判 A【probe】。判级只认指纹证据(实证:`case-studies/scope-and-fingerprint.md` §6)。 - **框架名单因子判级**:Nuxt 站可以是 A(noomo),Next RSC 站一定是 C(opal-tadpole)——差别在三判据③【probe】。同理 `__reactRouterContext` 不是 C 信号,只是"下发 route module"这一维的证据【shopifydesign】。 - **判级正确 ≠ 附带结论正确**:判级对了,同一份 verdict 附带的路由数、媒体文件数、漏抓归因仍可能整批被 M0 证伪(实证:`case-studies/scope-and-fingerprint.md` §6)。判级可以继承,**Step 0 的每个数字与每条附带结论都必须在 M0 逐条复核**【shopifydesign】。 - **把 token 噪声当动态渲染**:nonce/装饰性随机串/WAF 轮换 token 都是可掩码的确定性站【probe】。 - **只探根域**:获奖路径 404 而根域 200 的站会被误判存活【probe】。 - **拖延镜像**:历年获奖站里已消失的接近三成,五种消亡形态(域名易主/转发、平台回收、域名抢注、路径移除、原地替换)全都出现过(实证:`case-studies/scope-and-fingerprint.md` §6)。判级为 A/B 的瞬间,**第一时间全站镜像不是最佳实践,是抢救行为**——立即进入 `references/mirroring.md`【probe】。 ## 7. 门判定与产出物 - 产出 `probe/verdict.md`:判级 + 一句话断点 + 关键指纹证据(命令输出摘录)+ B 类缺口清单(如适用)+ 三判据逐条回答(框架标记命中时必填)。 - **A** → 进入 M0,加载 `references/mirroring.md` 与 `references/recon-and-rating.md`。 - **B** → 同上,另按 §1 政策提示指南缺口。 - **C/D** → 按 §1 政策拒绝并解释,流程终止。 - **X** → 按 §1 政策引导 Wayback 或换目标,流程终止。 -
shopify-platform.md 35.9 KB
# Shopify 平台层剥离(B 类场景) > **何时加载本文件**:Step 0 判级为 **B** 且指纹命中 Shopify(HTML 里 `cdn/shop`、`Shopify.theme = {...}`、`cdn.shopify.com`、`window.Shopify`、`myshopify.com`)时——在 M0 镜像动工前读完 §0/§3,在 M2 写构建/服务脚本时按 §1/§2 逐条对账。 ## 0. 分层模型(本指南的组织逻辑) 一个 Shopify 店铺的产物必须拆成**四层**看,**四层的复刻策略完全不同**,混作一团是 B 类最常见的失控源。**主题层必须再切一刀**,把被 fork 的上游主题带来的**存量样板**与**店铺自研**分开【objectarchive】(实证:`case-studies/shopify-platform.md` §0)。 | 层 | 代号 | 归属 | 各店是否相同 | 复刻策略 | |---|---|---|---|---| | **平台层** | `P` | Shopify 厂商 | **相同**(版本漂移,形态不变) | 按 §1 清单**剥离**:加载期脚本换 no-op stub、运行期端点服务层 stub。本层是不变量,本指南给确定规格 | | **应用层** | `A` | 店家装的第三方 App | **各店不同** | 逐个甄别,三分处置(见 §0.1) | | **主题层·上游存量** | `T-上游` | 被 fork 的上游主题(Dawn / Prestige / Ella …) | 同一上游的店之间**相同** | **原样带过**:既不是剥离目标(它不是 Shopify 运行时),也不是移植目标(不是店主写的)。判据见 §0.2 | | **主题层·站点自研** | `T-站点` | 店铺自己 | **各店不同** | 签名交互全部住这里。按 A 类手法**逐字移植**(`porting-discipline.md` + `dom-shell-strategies.md` 策略 A)。形态变量见 §4 | **为什么第四层非切不可**:`T-上游` 混进任一边都会直接损坏工作量估算——混进 `T-站点` 让移植任务表虚高,混进 `P` 则让剥离清单多出一批**本该原样保留**的块,删了就是未登记偏差。四层模型的直接产出就是"**你要移植多少东西**"(实测规模见 `case-studies/shopify-platform.md` §0.3)。主题若非 fork 而来(从零定制),`T-上游` 为空集,四层退化回三层——但**"为空"必须是核验后的结论,不是默认假设**(§4 读法)。 判层的机械判据(对每个 `<script src>` / 每条网络请求执行): - 路径含 `/cdn/shopifycloud/`、`/.well-known/shopify/`、`/checkouts/`、`/cart/*.js`、`/shopify_pay/`、`cdn.shopify.com/storefront/`、`shop.app`、`monorail-edge.shopifysvc.com` → **平台层 `P`**。 - 路径形如 `/cdn/shop/t/<主题号>/assets/*` → **主题层**——落到 `T-上游` 还是 `T-站点` 再按 §0.2 判(各站主题号实例:`case-studies/shopify-platform.md` §0)。 - 路径形如 `cdn.shopify.com/extensions/<uuid>/<app>-<ver>/assets/*`,或指向第三方域(klaviyo / weglot / wunderkind / stape …)→ **应用层 `A`**。 **上面这组判据只对"有 URL 的东西"有效。** 内联 `<script>` 块没有 URL,四层可以**交织在同一批内联块里**,分离方法见 §0.3。 ### 0.1 应用层三分处置 | 类型 | 例 | 处置 | |---|---|---| | 改变视觉/内容 | Weglot 运行时翻译文案【probe】、rimix-product-badges 徽章【probe】、shoplift A/B(上传了主题在用的 GTStandard 字体)【racingshop】 | **必须复刻**:镜像其产物,且对拍前固定其状态(否则出文案级伪差异)【probe】 | | 纯遥测/营销 | Klaviyo、stape、gtag、wunderkind-api【probe】 | stub 并登记为偏差(同 D5 一族) | | 后端依赖型 | 年龄验证 avp-age-verification【probe】、hCaptcha 表单保护【racingshop】 | 资产照抄入库;其后端调用按 §1 服务层 stub | ### 0.2 `T-上游` vs `T-站点`:主题层内部怎么判【objectarchive】 被 fork 的主题会往页面里塞一批**店主一行没碰过**的块与资产。总判据是**这段字节是谁写的,不是它作用在谁的 DOM 上**——一段专门去改 Dawn 组件的代码是店主写的(`T-站点`),一段 Dawn 原样带来的 JSON-LD 是上游写的(`T-上游`)。按序执行,前一条能定就不必用后一条: 1. **上游对照(最硬)**:取 `Shopify.theme.schema_version` 与血统年代(§4.1),去上游仓库(Dawn 直接读 `github.com/Shopify/dawn`)找同名 snippet/section 与块正文对照。**只有 Liquid 插值出来的值不同(文案、商品 JSON、路由前缀)→ `T-上游`**;结构、函数、类名有增删改 → `T-站点`。 2. **资产名清单**:`/cdn/shop/t/<N>/assets/` 里上游标准件与店主自加件并存。Dawn 标准件名固定:`constants.js` / `pubsub.js` / `global.js` / `cart-drawer.js` / `cart-notification.js` / `details-disclosure.js` / `details-modal.js` / `quantity-popover.js` / `localization-form.js` / `predictive-search.js` / `animations.js`;店主自加件带站点前缀(实证:`case-studies/shopify-platform.md` §0.2)。 3. **命名前缀**:自研代码几乎必然带站点前缀(`oa-*` 类名、`oa-*` CSS 变量、`OA` 全局),上游存量不带。 4. **注释里的人称(最强的一手证据)**:开发者注释**用第三人称提上游主题**(原文实证:`case-studies/shopify-platform.md` §0.2)。**"提到 Dawn" = 站在 Dawn 外面写的 = `T-站点`**;上游自己的代码不会这样称呼自己。 5. **角色**:只向上游组件**发布数据/文案**(`window.routes`、`cartStrings`、`variantStrings`、`accessibilityStrings`、JSON-LD、designMode class、selected-variant JSON 岛)→ `T-上游`;**动上游的 DOM / 打补丁 / 覆盖其行为**(搬 `<quantity-input>` 节点以保住 cart.js 的 handler、强显隐私横幅、用自定义事件把 scroll lock 与上游解耦)→ `T-站点`。 **边界情形**:上游块里被店主改过 Liquid 插值**值**的(如把 `Add to cart` 改成 `Add to Bag`)仍记 `T-上游`——代码形状是上游的,文案是内容不是行为;在归属表里加一条 note 说明即可。**判不出来的不许猜**:归属门要把它报成 UNCLASSIFIED / AMBIGUOUS 并挡住关账(§0.3 步骤 5),因为猜错一块就是几十 KB 在两层之间无声搬家。 ### 0.3 内联交织形态:没有文件边界时怎么分层【objectarchive】 **四层不一定分处不同文件。** 无 bundle 的站上四层可以混装在同一批内联块里;`P` 层的字节大头往往是数据块,**剥离成本与字节数无关**,别被总量吓到;把上游存量误记成自研,工期估算与里程碑切分一起偏(62 块四层交织的普查表与虚高比例:`case-studies/shopify-platform.md` §0.3)。 分离按下列步骤执行,**产出必须是机器可校验的归属表,不是文档里的一句话**: 1. **普查**:枚举每个在范围内页面的全部 `<script>`(含无 `src` 的),**先掩掉 HTML 注释再枚举**(注释里的脚本会被当成真块,`mirroring.md` §9 有实录)。逐块记:内联/外链、字节、**块正文 sha256 前 12 位**、起止行、第一条作者注释、正文头 140 字符。 2. **按内容哈希建表,不按块序号或行号**:内联块的序号与行号会随渲染漂(证据与坐标系定义见 `reverse-engineering.md` §0.1)。哈希做主键还有一个副产品——同一块在三条路由上出现在三组不同行号,按哈希编目自动收敛成一行记录。 3. **逐块判层**:`P` 用平台特征字面量(`Shopify.analytics` / `wpmLoader` / `trekkie` / `__st` / `monorailEndpoint` / `ShopifyAnalytics.meta`);`A` 用 App 名与其外部域;`T-上游` vs `T-站点` 用 §0.2。**每块起一个语义 id**(`oa-lenis-gsap-orchestration`、`oa-pdp-frame-compositor`,不要 `block-37`)——序号会漂,而这些名字要直接当移植任务表的行标题用。 4. **带 nonce 的块用锚点兜底**:少数平台块含逐请求 nonce(`eventMetadataId` / `requestId` / `reqid`),字节一变哈希就变。给这类条目补一条**只在该块出现、别处不出现的字面量**作探针;探针会**嵌套**(trekkie 引导块包含 shim 队列全文、analytics 载荷里含整个商品 JSON),所以要支持"必须出现在块首"的锚点形式。 5. **归属门**:写脚本把普查结果与归属表 join,**任何未归属的块打印 UNCLASSIFIED、任何匹配到两条的打印 AMBIGUOUS,两者非零即退非零码**,并把它列进 M1 关账条件。这道门**本 skill 尚未提供现成脚本**(见 `scripts/README.md` TODO),按上面的判据自己写一个即可(项目侧样例:`case-studies/shopify-platform.md` §0.3)。**歧义不许用启发式自动消解**——猜一次就是几百 KB 在层间无声搬家。**归属门要跑在"构建层实际产出的每一份文档"上,不是"约定的那几条路由"上**(实证:`case-studies/shopify-platform.md` §0.3)。 6. **把归属表当门用,不要止步于分类**:分层的结论必须落成**对产物字节的断言**,否则 `T-上游`「原样带过」只是一句口号——没人能证明它真的没被动过。断言的形状(objectandarchive 的 `verify-shell.mjs` BLOCKS 门,逐块按内容哈希 join 归属表): 对镜像里的**每个**内联块,在产物里找它的下落—— - 哈希相同 → **逐字保留**,通过; - 哈希不同 → 把**登记变换表**在这块的镜像正文上重放一遍,结果等于产物里的某块 → 通过,并记下**这次重放用到了哪几条变换**; - 两者都不成立 → 这块**消失了、或被改成了变换表之外的形态**,判 fail。 层规则就写在"用到了哪几条变换"这个集合上:**`T-上游` / `T-站点` / `A` 三层只允许该集合为空(逐字保留)或只含 URL 本地化那一条**(§2 的 D1a/D1b/D1c 同属 URL 本地化这一族,objectandarchive 把它们实现为一条 `T-LOCALIZE`),出现任何其它变换即 fail;**`P` 是唯一允许整块消失的层,且只允许消失 §2 D5b 登记过的那些块**(objectandarchive 全站唯一合法移除 = wpmLoader,D-P1b)。 它挡住的是三件 hunk 级 diff 看不清的事: - **构建层在改移植目标**:无 bundle 的站上**外壳就是行为源**(签名行为住在内联 `<script>` 里,见 `reverse-engineering.md` §0.1),构建层动 `T-站点` 一个字节,就是构建层在改源程序——这也是这类站上策略 A 不是"省事的做法"而是唯一自洽做法的原因; - **上游存量被"顺手修好"**:`T-上游` 的字节是上游写的,改它等于为零收益污染上游产物。正解是**在 `external.txt` 里逐条判定,而不是"修"**(实证:`case-studies/shopify-platform.md` §0.3);判据仍是 §0.2 那句"这段字节是谁写的,不是它作用在谁的 DOM 上"(该拼写为什么会漏判,见 `verification-gates.md` §1.6 第 4 类); - **整块无声消失**:这恰恰是 hunk 级 diff 最不可读的一种失败——删掉一个 18 KB 的块只是"一个巨大的 hunk",只有块级门报得出"哪个语义 id 没了"。 **与步骤 5 的分工**:步骤 5 断言**每块都归了层**(零 UNCLASSIFIED),本步断言**每层都按自己的规则被处置了**;两道门都要,且都进关账(实证:`case-studies/shopify-platform.md` §0.3;变换表侧的下限纪律见 `dom-shell-strategies.md` §2 步骤 3)。 这条与 §6 坑 1("内联遥测只能按属性或唯一起始字面量定位,不要凭印象删")是同一件事的两端:**先有全量归属表,才谈得上删哪块**;而步骤 6 是第三端——**删完之后还要能证明只删了该删的那块**。 --- ## 1. 平台层清单(实测确定规格) 以下两张表逐条来自 `racingshop-rebuild` 项目侧的 `serve-rebuild.mjs` 与 `build-site.mjs` 实际代码 + 镜像 HTML 取证【racingshop】(这两个是**项目脚本、不在本 skill 的 `scripts/` 里**——它们是每个项目按本表自己写的产物)。**先照此表建 stub,再用探针反查你的目标是否有表外项**。 ### 1.1 运行期端点(服务层 stub,`serve-rebuild.mjs` 的 STUBS 表,首个命中生效) | 端点 / 前缀 | 作用 | 处置 | 依据 | |---|---|---|---| | `/.well-known/shopify/monorail/**`(实见 `unstable/produce_batch`) | web-pixels-manager 的同源遥测批量上报口(HTML 内联配置 `monorailEndpoint`) | 服务层 200 `{}` | D5 | | `/api/collect` | `shopify-perf-kit-3.8.0` 的 RUM beacon 口(脚本属性 `data-shs-beacon-endpoint`),sendBeacon + fetch 双通道 | 服务层 200 `{}` | D5;allbirds 亦见此端点【probe】 | | 路径含 `web-pixels` 或 `/wpm@` | Web Pixels manager 沙箱与 loader | 200 `export {};`(JS) | D5 | | `/cdn/shopifycloud/shop-js/**` | shop-js loader 及其运行时 chunk 图(chunk 数与 feature 清单实证:`case-studies/shopify-platform.md` §1.1) | 整前缀 200 `export {};` | D6 | | `/cdn/shopifycloud/storefront/assets/storefront/{load_feature,event_observer_reporter}*` | 特性加载器与其动态 import 的遥测 reporter chunk | 200 `export {};` | D5 | | `/cart.js` | Ajax Cart 读取 | 200 空车 JSON | D2 | | `/cart/{add,update,change,clear}(.js)?` | 加购 / 改量 / 清空 | 200 空车 JSON | D2 | | `/search/suggest*` | predictive-search(拼 `${Shopify.routes.root}search/suggest?q=…§ion_id=predictive-search`) | 200 —— **形状须核,见坑 2** | D4 | | `/recommendations/products*` | product-recommendations | 200 空 section —— **形状须核,见坑 2** | D2 | | `/cdn/shopifycloud/portable-wallets/**` | Shop Pay 加速结算按钮资源 | 200 空 `<svg/>` | D3 | | `/cdn/shopifycloud/checkout-web/**` | 结算 web 运行时 | 200 `export {};` | D3 | | `/shopify_pay/**`(含 `accelerated_checkout`) | Shop Pay 会话 / 钱包 | 200 `{}` | D3 | | `/checkouts/**`(含 `internal/preloads.js`) | 结算流程 | 200 `export {};` | D3 | | `/cdn-shopify/storefront/web-components/account/**` | 客户账号 web components 懒加载 chunk | 200 `export {};` | D6 | | 未命中任何静态文件 | —— | 回落 `404.html` 且**真返回 HTTP 404**(复刻 Shopify 语义,不要 200) | —— | (theme.js 调用点坐标见 `case-studies/shopify-platform.md` §1.1。) 空车 JSON 用 Shopify Cart 对象的完整字段形状(`token/note/attributes/original_total_price/total_price/total_discount/total_weight/item_count/items/requires_shipping/currency/items_subtotal_price`),不要只回 `{}`——调用方会读字段。 ### 1.2 加载期 `<script src>`(构建层换 no-op stub,`build-site.mjs` 的 STUB_SCRIPTS) 按**改写后**的 src 做子串匹配,命中则整个 `<script>` 标签替换为 `/stubs/noop.js` 并保留 `type="module"`(保住 importmap / 模块图合法): `/cdn/shopifycloud/shop-js/`(D6)· `/cdn/shopifycloud/storefront/assets/shopify_pay/`(D3)· `/cdn/shopifycloud/storefront/assets/storefront/load_feature`(D5)· `/checkouts/internal/preloads.js`(D3)· `/cdn-shopify/storefront/web-components/account.js`(D6)· `googletagmanager.com/gtag/js`(D5,外部)· `shop.app/checkouts/internal/preloads.js`(D6,外部)。 ### 1.3 **不要**一并 stub 的平台脚本(verbatim 保留清单) 过度 stub 会改变 DOM 与时序,本身就是未登记偏差。racingshop 显式留下且实测无害的 11 项【racingshop】: `theme.js`、`vendor.min.js`(主题层本体)· `importmap-polyfill/es-modules-shim.2.4.0.js` · `storefront/assets/storefront/origin_trials-*.js` · `cdn.shopify.com/storefront/standard-actions.js` · `shopifycloud/perf-kit/shopify-perf-kit-3.8.0.min.js`(beacon 由 §1.1 `/api/collect` 兜)· `shopifycloud/privacy-banner/storefront-banner.js` · `storefront/assets/shop_events_listener-*.js` · `storefront/assets/storefront/autosizes-*.js`(内联条件注入的 polyfill)· `storefront-forms-hcaptcha/*.iife.js`(内联 `captcha-bootstrap` 注入)· `cdn/s/trekkie.storefront.<40hex>.min.js`(内联 analytics 块注入——**留着它反而更安全,见 §3**)。 --- ## 2. 构建层登记变换清单 对每个镜像 HTML 只做**登记在案**的变换,其余逐字保留(策略 A)【lando】【racingshop】: 1. **D1a 同源绝对/协议相对 → 根相对**:`https://<host>/`、`http://<host>/`、`//<host>/` → `/`。**必须同时处理 JSON 转义形式** `https:\/\/<host>\/` → `\/`(内联 JSON-LD / 配置块里全是这种写法,漏了就留下真实外域引用)。**四种形态一个都不能少**:绝对 / 协议相对 / **转义绝对** `https:\/\/host\/` / **转义协议相对** `\/\/host\/`【objectarchive】(漏掉第四种的后果实证:`case-studies/shopify-platform.md` §2)。另见 D1c。 2. **D1b 外部 Shopify CDN / 其它外部主机 → 本地目录**:`https://cdn.shopify.com/` 与 `//cdn.shopify.com/` → `/cdn-shopify/`(含转义形式),对应镜像的 `assets/cdn.shopify.com/` 树。**转义形式对外部主机同样成立,别只给源站主机开**【objectarchive】(实证:`case-studies/shopify-platform.md` §2;为什么每一道门都看不见它,见 `verification-gates.md` §1.6 第 4 类)。 3. **D1c 裸主机基址常量 → 本地基址**【objectarchive】:遥测与主题代码常把基址写成**不带尾斜杠**的常量再拼路径(`"https://otlp-http-production.shopifysvc.com"`、`window.shopUrl='https://<host>'`)。**改写规则按主机匹配,不要求尾斜杠**(只匹配尾斜杠形式的漏网实证:`case-studies/shopify-platform.md` §2);验收侧的配套要求见 `mirroring.md` §8。 > **D1c 与"转义写法"是同族不同格,两格都要单独想过**:D1c 是**没有尾斜杠**(`"https://host"` + 代码自己拼路径),D1a/D1b 的转义形式是**斜杠被转义**(`https:\/\/host\/`)。两者都会让"只匹配 `https://host/`"的提取 / 改写 / 断言规则天然失明,且失明时的表现都是绿灯。断言面见 `verification-gates.md` §1.6(第 4 类给了可执行查法)。 4. **D5b 内联遥测块移除**:按 `data-source-attribution="shopify.event_observer.bootstrap"` 属性、以及 `<script>(function(){var wpmLoader=` 起始字面量定位删除。这两块是纯分析、无视觉/行为角色;wpmLoader 在其后端模块被 stub 后还会 `.init` on undefined 抛错,不删则污染 CLEAN 门。 5. **D3/D5/D6 脚本 stub**:§1.2 清单。 6. **SRI 剥离**:被改写的标签响应字节已变,`integrity="..."` 必须去掉——否则 Chrome **静默拦截**该资源,且报错只走 CDP Log 域,探针不监听 Log 就会误报 CLEAN【lando】。 7. **D8 注入 noindex + 非官方声明**:`<head>` 后立刻插 `<meta name="robots" content="noindex,nofollow">` 与一段声明注释("非官方学习复刻 / 与 Shopify Inc. 及店主无关 / 未经决定不公开部署")。这是**安全默认动作**(用户就公开与否作出决定之前一律如此,见 `legal-and-deploy.md` §0.1),不可省。 8. **Q1 dev-port 探测片段 verbatim 保留**:见 §5。 **"变换没发生就 throw"防御(硬规则)**:`applyTransforms` 统计变换次数,**逐条**校验命中数——任一条为 0 或低于其登记下限 → 直接抛错终止构建【lando】【racingshop】【objectarchive】。意义:镜像/主题结构一变(换主题、Shopify 改 head 契约),脚本会**立刻大声失败**,而不是静默产出一批引用真实外域、没有 noindex 的坏 shells。没有这道防御的生成脚本不许合入。 ⛔ **计数必须逐条,不能用"总数 `n === 0` 才抛"这个弱形式**:本表的 D1a/D1b 在一个页面里就可能命中数千次,URL 本地化一条就让"有变换发生"永远为真,而 **D8 noindex 注入失效不会有任何人发现**——弱形式在 Shopify 站上基本恒绿(命中数实证:`case-studies/shopify-platform.md` §2)。完整判据、下限怎么量、以及"验收要从产物字节反推而不是读构建脚本的计数器"见 `dom-shell-strategies.md` §2 步骤 3;块级的配套断言见本文 §0.3 步骤 6。 --- ## 3. 零外联的完整断言面(本次实测发现的门盲区) **`零外联` 不等于"资源级探针没抓到外部请求"。** 构建产物里实际残留三类联网面【racingshop】(实证:`case-studies/shopify-platform.md` §3): **① 连接意图(无资源请求,探针天然抓不到)** —— 实测残留两条: ```html <link rel="preconnect" href="https://shop.app" crossorigin="anonymous"> <link href="https://monorail-edge.shopifysvc.com" rel="dns-prefetch"> ``` 浏览器一联网就会为这两条做 DNS 解析 / TLS 握手。资源级探针只看 `Network.requestWillBeSent`,完全看不见。→ **必须清理并登记,或至少登记为已知潜伏。** **② 内联自包含遥测块(潜伏 beacon)** —— 每页内联着两处打外部域的代码: - **弃单 beacon**:`pagehide` 监听器 → `navigator.sendBeacon("https://monorail-edge.shopifysvc.com/v1/produce", {schema_id:"online_store_buyer_site_abandonment/1.1", …})`。它自带 guard「performance 条目里没有 monorail 记录才发」——**离线复刻恰好满足该条件,所以是必发不是可能发**。load-time 探针从不触发 `pagehide`,一次都抓不到。 - **trekkie 加载失败兜底**:内联的完整 Monorail 实现(`Monorail.produce(monorailDomain, schemaId, payload)` → sendBeacon → XHR 兜底),挂在 `script.onerror → scriptFallback.onerror` 路径上,上报 `trekkie_storefront_load_errors/1.1` 到 `monorail-edge.shopifysvc.com`。trekkie 文件在盘时不触发;**被广告拦截器按文件名 `trekkie.storefront.*` 拦掉时会触发**——这正是"§1.3 建议把 trekkie 文件留在盘上"的理由,也是它必须登记为潜伏外联的理由。 **③ 出站 `<a href>` 锚点** —— 如页脚的 `https://www.shopify.com/legal/privacy`。这是**源站内容**,按宪法第 3 条(源站有的都要有)**保留,不算外联**。断言脚本必须按元素类型判定,不能"字符串里含外部域即红",否则会逼出删内容的错误修法。 **因此零外联门的断言面 = 四项,缺一不可:** - [ ] **资源级**:全路由 × 桌面/移动,probe 记录的请求 host 只有本地;带 `--scroll` 走完懒加载。 - [ ] **静态 grep 级**(对构建产物,不是对镜像):`rel="preconnect"|rel="dns-prefetch"|rel="preload".*//` 无外部域;`sendBeacon\(|new Image\(|fetch\(["'`]https` 无外部字面量;残存 `https?://` 白名单只剩命名空间(schema.org / w3.org / json-schema.org)与出站锚点,逐条点名。⚠ **只 grep `https?://` 会天然漏掉转义写法**(`https:\/\/host\/`,Liquid `| json` 与 JSON-LD 产物里全是这种)——四种拼写的完整查法与解码扫描见 `verification-gates.md` §1.6 第 4 类【objectarchive】。 - [ ] **交互 / 生命周期态**:探针内 `window.dispatchEvent(new Event('pagehide'))`(或真导航离开)后再采一次网络;另外打开 cart drawer、在搜索框输入、进商品页触发 recommendations。 - [ ] **拦截器模拟**:把 trekkie(及其他"在盘才安全"的脚本)临时改名/返回 404,确认不触发外联;不可消除的写进偏差表"已知潜伏"。 --- ## 4. 主题层变量矩阵(已观察形态,非确定规格) > **置信度声明**:§1 平台层是**实测确定规格**(同一套 Shopify 运行时,各店一致)。本节是**已观察形态的样本集**,不是穷举——Shopify 主题生态没有上界。遇到表外形态:现场按 §4.1 判据核验,处置完成后**回填本表**。 | 目标 | 主题(`schema_name` / 版本) | 前端栈 | 出处 | |---|---|---|---| | racing.shop | **Stretch 1.13.0**,主题商店 #1765,实例名 "V1 Launch 022626" | 原生 Web Components ×78(`class extends HTMLElement` ×71、IntersectionObserver ×13)+ `vendor.min.js` 72KB。**无** three / gsap / lenis / react / vue | 【racingshop】 | | allbirds | 未取 schema 名,主题号 `t/4159` | **ESM + Vite**(bundle 头 `__vite__mapDeps`)+ **GSAP ScrollTrigger** + **Swiper**;section 粒度切分共 20 个脚本(header / cart-drawer-section / full-bleed-hero / category-row …) | 【probe】 | | mana-yerba-mate | 定制主题(非 Dawn),`t/18` | 单 bundle `global.js` **1.16MB**:**three.js 整库内联**(`THREE`×203、自写 shader)+ **GSAP**(×322)+ **lottie-web** + Swiper;Weglot 运行时翻译 | 【probe】 | | pangram-pangram | 定制主题 `pp.com` 3.0.0,**`theme_store_id: null`** | **Alpine.js**(`x-data` 组件 40+:customFont / parallax / carousel / tabs)+ **Swiper**,Vite 单 bundle `index-<hash>.js` 488KB。无 gsap/three/react/vue | 【probe】 | | simply-chocolate | **Prestige 10.11.0**(Maestrooo 商业主题),`t/126` | Web Components(effect-carousel / scroll-carousel / marquee-text / cart-*,esbuild 产物)+ PhotoSwipe 5.4.4 / focus-trap / tabbable。**动画库指纹为 0**。`theme.js.map` **公网可取** | 【probe】 | | ch.maswitzerland | **Dawn**(Shopify 官方开源) | Dawn 全家桶 16 脚本:`global.js` / `cart-drawer.js` / `predictive-search.js` / `pubsub.js` / `quantity-popover.js` / `animations.js` … 带 sourceMappingURL | 【probe】 | | koox.co.uk | **Ella 6.5.4**(商业主题) | 未取样 | 【probe】 | | object & archive | **Dawn fork 后改名深度定制**:`schema_name` 已被改写为 `Object & Archive`、`schema_version` `1.0.0`、**`theme_store_id: null`**、实例名 `[LIVE] with GALLERY WALL w pins`,`t/11`(47 个资产)。血统 **Dawn ≥ 15.x**(`global.js` 里 `class SectionId{static#separator="__"}`,SectionId 助手是 Dawn 15 引入) | Dawn 全家桶 + `oa-*` 自加件(`oa-wishlist.js` / `oa-color-library.js` / `color-swatches.js`);**无 bundle**——签名行为在 62 个内联块里,四层交织(§0.3);gsap 3.12.5 + ScrollTrigger 3.12.5 + lenis 1.1.14 走 jsDelivr,**版本钉在 URL 里** | 【objectarchive】 | **读法**:`theme_store_id: null` = 主题不来自主题商店(创意站常见,逆向价值最高);命中官方/商业主题(Dawn、Prestige、Ella、Stretch)= 主题层不是店主原创,**逆向可能退化为读上游源码**,且引入主题版权问题——立项前就要向用户讲清。 **⚠ `theme_store_id: null` + 一个原创的 `schema_name` ≠ 从零定制。** 现实中最常见的拓扑是 **fork 官方/商业主题 → 改 `schema_name` → 深度定制**(objectandarchive 即 Dawn fork 改名 `Object & Archive`)——**此时 `schema_name` 会撒谎**,必须反查血统【objectarchive】: - **成套的上游标准件名**:`t/<N>/assets/` 里是否整套躺着上游九件套(§0.2 判据 2)。单个同名文件可能是巧合,**成套出现不是**。 - **有版本切面的 API 取证**:从上游标准件里挑一个随上游版本演进的构造取年代(objectandarchive:`global.js` 的 `SectionId` 助手 ⇒ Dawn ≥ 15.x)。它同时是 §0.2 判据 1 做对照时要取的上游版本。 - **开发者注释的人称**(§0.2 判据 4)——最强的一手证据,且比从文件名推断可靠得多。 **fork 拓扑的两个后果**:① 主题层**不是单一归属**,必须按 §0.2 切成 `T-上游` / `T-站点` 再排移植任务;② 版权**取证**要把上游主题与店主自研部分**分开**取证(许可以产物内证据为准,结论交用户,见 `legal-and-deploy.md`)。 ### 4.1 现场判定序列(按顺序执行,四步定型) 1. **取主题身份**:从 HTML 抠 `Shopify.theme = {...}`,读 `schema_name` / `schema_version` / `theme_store_id`(null = 不来自主题商店;**≠ 从零定制**,按上文 ⚠ 反查血统)/ 实例 `name`(运营迭代痕迹)。 2. **列主题资产**:`/cdn/shop/t/<N>/assets/` 下所有 js/css 全量下载;同时看是"单 bundle"还是"section 粒度多脚本"——这决定 M1 的坐标系粒度。**第三种可能:主题资产里根本没有承载签名行为的东西**(只有 vendor 与上游存量),行为全在页面内联块里 → 走 `reverse-engineering.md` §0.1 的**无 bundle 平行分支**建坐标系【objectarchive】。 3. **栈判定 grep 序列**(对下载的 bundle):`customElements.define|class extends HTMLElement`(Web Components)· `x-data=|alpine:init|\$persist`(Alpine)· `gsap|ScrollTrigger|GreenSock`(GSAP)· `THREE|WebGLRenderer|gl_FragColor`(three + 自写 shader)· `Swiper|swiper` · `lottie|bodymovin` · `__vite__mapDeps`(Vite 分块)· `sourceMappingURL`。 4. **抄近路检查**:有 sourcemap 就先 curl `.map` 验证可取(simply-chocolate 的 432KB map 带完整 `sourcesContent`);是 Dawn 或其 fork 就直接读 `github.com/Shopify/dawn`——这两种情况下 M1 的 beautify 环节近乎免费。**对 fork 站,读上游源码不只是抄近路,它同时是 §0.2 判据 1 的对照基准**(拿哪个版本对照由第 1 步的血统年代决定)【objectarchive】。 --- ## 5. localhost 语义分叉(Shopify 主题的 dev 逃生门) Shopify 主题(尤其 Vite 工作流的定制主题)常在页面尾部内联按 host 分叉的 dev 探测,实测原文【racingshop】(出现位置实证:`case-studies/shopify-platform.md` §5): ```js if (location.hostname === '127.0.0.1' || location.hostname === 'localhost') { const ports = [5173, 5174, 5175]; // 另一块是 [5176, 5177] (async () => { for (const port of ports) { try { await import(`http://localhost:${port}/src/main.ts`); return; } catch {} } console.warn('[Carousel3D] Vite dev server not found, falling back to asset'); import('//<host>/cdn/shop/t/17/assets/carousel-3d.js?v=…'); })(); } else { import('//<host>/cdn/shop/t/17/assets/carousel-3d.js?v=…'); // 与上面回落路径同一个文件 } ``` 复刻工程本地跑 = hostname 就是 localhost → **被迫走进一条线上永不执行的分支**。两条路线,**都必须登记**: | 路线 | 做法 | 代价 | 适用条件 | |---|---|---|---| | **保持 verbatim**(默认,racingshop 选此 = Q1) | 一字不改,登记进 §Q 怪癖表 | 每页产生 2-5 个 `ERR_CONNECTION_REFUSED` 到 localhost dev 端口 + `console.warn` 噪声;**probe 的 `Network.loadingFailed` 会计入 failures,CLEAN 门必须为其开白名单并写明理由**,此后该门的信噪比永久下降 | 追求字节级忠实;且已确认探测**无外联**(目标是 localhost,不出机器)、无副作用、不阻塞渲染 | | **强制走 production 分支** | 改写条件使其恒 false | 属**自创改动**,违反"源站有的都要有"的字面纪律,必须登记进 §6 偏差表并写"何时重新考虑" | 噪声污染验收门到无法判读;或探测有真实副作用(打外部域、抛错、阻塞首屏) | **Shopify 特有的成本判据**:注意上面两条分支**最终 import 的是同一个主题资产**——dev 分支只是多了一段探测前奏。这意味着强制走 production 的**行为后果为零**,代价纯粹是"多了一条自创改动记录"。所以这里的取舍是纪律取舍,不是功能取舍:默认仍选 verbatim(无副作用 → 一律不动),只有当 CLEAN 门被噪声淹没时才翻。**绝不允许直接删掉分支而不登记**——那是未登记偏差 = bug。通用规则见 `dom-shell-strategies.md` §4.5。 --- ## 6. 常见坑 1. **内联遥测比 `<script src>` 难删,且极易漏**:src 能按 URL 前缀批量 stub,内联块只能按 `data-source-attribution` 属性或唯一起始字面量正则定位(漏删实证:`case-studies/shopify-platform.md` §6 坑 1)。做法:先枚举全部无 src 的 `<script>`,逐个分类为"配置 / 结构化数据 / 主题逻辑 / 遥测",再删——不要凭印象删。**这份枚举与 §0.3 的归属表是同一件事,做一次即可**:归属表落到层(`P`/`A`/`T-上游`/`T-站点`),删哪块是在层内再做的处置决定。 2. **stub 的响应形状必须按调用方的解析路径确定,不是"回 200 就行"**(两处形状不匹配的实证:`case-studies/shopify-platform.md` §6 坑 2)。**这类错只在交互态出现,load-time 探针全绿**——所以 §3 的交互态断言不是可选项。写 stub 前先去 bundle 里读一遍调用方怎么解析响应。 3. **no-op stub 必须同时是合法的 classic script 与 module**(实证:`case-studies/shopify-platform.md` §6 坑 3)。硬规则与判定方法见 `porting-discipline.md` §6.1。 4. **协议相对 URL 会被爬虫拼错**:`//<host>/x` 被误拼成 `https://<host>//<host>/x`。修法是**旁路 gapfill 归一重解**,**不要改共享爬虫脚本**(实证:`case-studies/shopify-platform.md` §6 坑 4)。 5. **HLS 视频阶梯是静态爬取的盲区**:`.m3u8` 的 renditions 与 segments 不在 HTML 里,只有运行时才拉取——需单独补录(实证:`case-studies/shopify-platform.md` §6 坑 5)。 6. **nonce 类字节不是内容差异,别判 D**:`<meta name="shopify-y">` 每请求变 UUID,`__st` 里的 `reqid` / 用户 token `u` 也逐请求变【probe】(实证:`case-studies/shopify-platform.md` §6 坑 6)。冻结镜像值 + 对拍掩码即可。 7. **`section_id` 查询参数请求会命中静态页的假 200**:facets-form 发 `/collections/x?section_id=…` 期待 section 片段,静态服务器忽略 query 返回整页——**200 但内容错,探针不报错**。要么在服务层为带 `section_id` 的请求单独 stub,要么登记为已知降级。 8. **过度 stub 平台脚本**:perf-kit / privacy-banner / hcaptcha / origin_trials / standard-actions / es-modules-shim 该留则留(§1.3)。一律 stub 会改变 DOM 与加载时序,本身即未登记偏差。 9. **后端 stub 区的像素差是预期噪声,别用自创 CSS 去补**(实证:`case-studies/shopify-platform.md` §6 坑 9)。归因到 stub 就结案,动 CSS 就是发明。 --- ## 7. 关账 checklist(B 类 Shopify 平台层) - [ ] **四层已分清**:每个 `<script src>`、每条运行时请求**以及每个内联块**都归了 `P` / `A` / `T-上游` / `T-站点`(§0 判据 + §0.2 + §0.3);应用层每项有三分处置结论;`T-上游` 为空集时,"空"是核验过的结论而非默认假设 - [ ] §1.1 运行期端点逐条有 stub 或"确认本站无此端点"的记录;空车 JSON 用完整字段形状 - [ ] §1.2 加载期脚本逐条换 no-op stub,`type="module"` 保留;stub 文件双模式合法(`porting-discipline.md` §6.1) - [ ] §1.3 verbatim 保留清单逐条核对过,无过度 stub - [ ] 构建层变换 = 偏差表条目数(D1a/D1b/**D1c**/D5b/D3·D5·D6/SRI/D8),一一对应;**防御在位且是逐条下限形式**(任一条为 0 或低于下限即 throw,不是"总数非零即通过");每条变换都在产物 diff 里被实际观测到 - [ ] 内联 `<script>` 已逐个分类,遥测块处置有据(删 / 留 / 登记为潜伏) - [ ] **内联块归属表机器可校验**(§0.3 步骤 1–5):按内容哈希索引、每块有语义 id、归属门跑出 **零 UNCLASSIFIED / 零 AMBIGUOUS** 且退出码进 M1 关账,**跑遍构建层实际产出的每一份文档**(含 404 页与被动入镜的路由);移植任务表 = 归属为 `T-站点` 的那些行 - [ ] **归属表当门用**(§0.3 步骤 6):块级断言跑绿——`T-上游` / `T-站点` / `A` 三层每块只有"逐字保留"或"只被 URL 本地化动过"两种结局,`P` 层消失的块 = 登记过的那些;配套 hunk 级门里每个差异都能被变换表重放 - [ ] **零外联四项断言全绿**(§3):资源级 / 静态 grep 级 / 交互与 pagehide 态 / 拦截器模拟;出站锚点已点名豁免 - [ ] 交互态无控制台异常:cart drawer、predictive-search 输入、product recommendations 均实跑过(坑 2) - [ ] noindex + 非官方复刻声明在**每一页**产物中(不只是首页) - [ ] localhost 分叉的路线已选定并登记(Q 表或 D 表),CLEAN 门白名单写明理由 - [ ] 主题层身份钉死进 `docs/engine-notes.md`:`schema_name` / 版本 / `theme_store_id` / 主题资产目录 / 栈判定 grep 结果 / **是否 fork 及上游血统年代(§4 读法)**;**若为 §4 表外新形态,已回填本文件 §4 矩阵** -
verification-gates.md 65.8 KB
# verification-gates.md — 验收门选型与失效模式 > **何时加载本文件**:为任何里程碑设计验收方式之前必须加载(门型定义、决策树、运行纪律、分层体系)。三份配套文件按需加载:门"全绿但用户/真机发现了问题"或残差需要归类时读 `gate-failure-modes.md`;为数值门/跨侧门设计用例、做 M(n) 清单式核对时读 `gate-case-design.md`;站点有内联序列化载荷或走策略 A 外壳构建时读 `payload-gates.md`。像素/字节对拍类门的前置条件(冻结与驱动)在 `determinism.md`。 ## 0. 总原则 - 验收标准必须是**机器可断言的绿灯**:`failures: []` 空数组【rogier】、CLEAN 退出码【lando】、逐字节 diff 为空【noomo】、94 项契约检查【kimi】——不是"看起来像"。 - "目测像素会骗人,量化比对要用脚本量截图"【oryzo】。 - 门是分层的,成本递增;**底层门先建立,此后全项目期保持全绿**【noomo】(实证:`case-studies/verification-gates.md` §0)。 - 每类"像不像"都要有对应的数字化手段:行亮度剖面、逐 band delta、颜色时间轨迹、computed style/rect 逐值对比、canvas-only 隔离对比、DOM 身份断言【rogier】。 - 门本身会失效。全绿 ≠ 正确,见 `gate-failure-modes.md` §1——其中最贵的一种是**全部的门都拍在同一个状态里**:四道门全绿,而站点一半的状态零覆盖(`gate-failure-modes.md` §1.8);最隐蔽的一种是**门把差异所在的那块区域扣掉了**(`gate-failure-modes.md` §1.10)【shopifydesign】。 - **门也会红错,而根因分三类**:断言面缺了一格(覆盖问题,实例见 §1.6);断言面对了而**期望值写法**错——凡引擎会规范化的值,期望值必须让引擎自己往返一遍再取,不许从源站源码手抄字面量(§0.1);断言与期望值都对,而**记录的字段本身是个会流动的量**(`gate-failure-modes.md` §1.11:单侧全绿、只有跨侧对拍才红)【objectarchive】。 - **表不是文档,是断言**:变换表 / 分层归属表 / 销账表 / §6 偏差表 / §Q 怪癖表都要有一道**从产物或运行时反查它**的门,否则它会悄悄漂在现实前面(通用条款见 `porting-discipline.md` §4.1)【objectarchive】。 ### 0.1 ⭐ 期望值必须让引擎往返一遍,不许手抄源站字面量【objectarchive】 **断言面对了、期望值却写错,是门红错的另一大类根因,而且比覆盖问题更隐蔽**:门看起来很有针对性、行号也对,红的却是被测代码完全正确的那一侧。§1.6 讲的是**断言面缺一格**(该断的没断),本节讲的是**期望值的来源**(断了,但拿去比的那个值从一开始就不可能相等)。 **硬规则:凡是断言"引擎会规范化的值"(CSS 属性值、URL、数字格式、颜色写法、DOM/JSON 序列化…),期望值必须先把源站字面量放进同一个引擎里跑一个往返再读回来;不许把源码里的字面量直接抄进断言。**(实证:`case-studies/verification-gates.md` §0.1) **做法:在同一个引擎里、用一次性对象往返一次**,把读回的值当期望值: ```js // 期望值 = 源站 +124 的字面量(FADE_DURATION = 1350,逐字来自源站)经引擎序列化后的形态 canvasStyleFromSource: (() => { const el = document.createElement('div'); el.style.transition = 'filter ' + 1350 + 'ms ease, opacity ' + 1350 + 'ms ease'; return el.style.transition; })(), ``` 三条要求: - **往返的输入必须是源站字面量本身**,连同它的常量名与源站坐标写进注释——期望值的出处仍然是源站,只是多走了一次序列化; - **往返必须走一次性对象,不许读被测元素自己的产物值**——那是把被测对象当成规格,断言退化为重言式(同族:`gate-failure-modes.md` §1.2 的"驱动没生效但门照绿"、`gate-failure-modes.md` §1.7 防呆 3 的"要断言 `span 数 == 18`,而不是断言两侧相等"); - **两侧各自在自己的引擎里往返一次**,比较的是各自的观测值 vs 各自的期望值。这样期望值天生跟着引擎版本走,不必维护一张"哪个浏览器怎么序列化"的表。 **常见规范化陷阱(命中任一条就必须往返)**: | 类别 | 手抄字面量会怎么错 | |---|---| | CSS 属性值 | 初始值被丢弃(`ease`)、简写展开与重排(`margin` / `background` / `transform`)、单位与精度归一(`0.4s` vs `400ms`、`matrix(...)` 的小数位) | | 颜色 | `#fff` / `white` / `rgba(255,255,255,1)` 一律读回 `rgb(255, 255, 255)`(连空格形态都是引擎定的) | | URL | 相对 → 绝对、默认端口消失、百分号编码大小写与二次编码、末尾斜杠、query 顺序——`new URL()`、`el.href`、`el.src`、`fetch` 都会归一,源码里那个字面量与读回来的几乎不会字字相同 | | 数字 | 浮点序列化(`0.1+0.2`、`0.30000000000000004`)、`toFixed` 与指数形态、`-0` | | DOM / 文本 / JSON | `innerHTML` 的属性顺序与引号、实体解码(` ` → U+00A0)、`classList` 去重、`JSON.stringify` 的键序与转义 | **它与"源站代码是唯一裁决"(`porting-discipline.md` §1.1)不冲突——两者管的是不同的问题**: | 谁裁决 | 回答哪个问题 | 依据 | |---|---|---| | **源站代码** | **该断言什么行为**:断哪个属性、哪条分支、哪个常量、哪一行 | 源站坐标(`pretty L####` / `B:<sha12>+<n>`) | | **引擎往返** | **这个行为在引擎里长什么样**:同一个值的序列化形态 | 同一次运行里的一次性往返 | 所以它**不是**"按观感把期望值改到能过"。**判据一句话:改期望值时,源站字面量必须仍然是往返的输入**;一旦输入变成"产物读数"或"我看着像的值",就已经落进 `gate-failure-modes.md` §2 禁止的调参糊平(那时期望值的裁决权从源站转移给了被测代码)。 **顺带得到一个分诊信号:两侧跑同一道门时,镜像侧红 = 门错,复刻侧红 = 代码错**——一次跑就分得清方向。**只在复刻侧跑门 = 主动放弃这个方向判据**(实证与实证强度:`case-studies/verification-gates.md` §0.1——三轮累计分诊 19 次,零反例)【objectarchive】。 另两条同族的期望值错法一并记住: - **把"什么时候"写进期望值,而源站没这么说**:门断言"越过阈值之后的下一个检查点才可见",而源站监听器在滚动**过程中**就触发了——断言机制本身,不断言某次采样的时序(同 `gate-failure-modes.md` §1.4); - **期望值假定仪器行为与真实环境一致**:断言改视口后 `--header-height` 跟上,而 `Emulation.setDeviceMetricsOverride` 只发一次 `resize` 且发生在布局收敛前——**先校准仪器再谈断言**(`gate-failure-modes.md` §3.1.1 与 `environment-traps.md`)。 ## 1. 五类门定义与适用条件 ### 1.1 SSR/DOM 字节门(最先建立) - **定义**:SSR 输出 / 静态 HTML / payload / config 与镜像做逐字节(或空白归一化后)diff,diff 为空即绿。 - **实例**:见 `case-studies/verification-gates.md` §1.1(noomo `verify-ssr.mjs` / oryzo / kimi `verify-routes.mjs`)。 - **适用条件**:站点有 SSR/静态 HTML 产物。**应最先建立、终身保持全绿**——"字节层先行使后续所有视觉 debug 都能排除 DOM/payload 差异"【noomo】。 - **skill 脚本**:`scripts/verify-ssr.mjs`、`scripts/verify-routes.mjs`。 - **搭建注意**: - 含随机量的部分(RSC 逐请求 nonce、buildId)diff 前必须 mask【kimi】【noomo】。 - 重定向门必须断言**状态码本身**:Next `permanent:true` 发 308 而源站发 301,不断言状态码就漏【kimi】。 - 状态码断言用裸 fetch 独立实测,不走浏览器——浏览器自动跟随重定向正是当初镜像栽跟头的动作【kimi】。 - 验证仪器(probe shim 等)注入必须 query 门控,无参数时输出字节不变,否则污染本门【noomo】。 ### 1.2 整页像素 byte-equal 门 - **定义**:冻结全部熵源后,双侧同位姿截图(或 canvas `getImageData`/`readPixels` 直读),FNV 哈希相等即绿。 - **实例**:见 `case-studies/verification-gates.md` §1.2(kimi 32 个整页位姿 + 4 个画布字节门)。 - **适用条件**:DOM 渲染为主、熵源可枚举冻结的站点。前提假设:"同机同版本 Chrome 的 DOM 渲染是逐字节确定的"【kimi】——前提与九种冻结协议见 `references/determinism.md`。 - **skill 脚本**:`scripts/pixelcompare.mjs`(byte-equal 档)+ `scripts/lib/png.mjs`。 - **搭建流程**:每个门自起镜像/复刻两个服务器 → 同一冻结协议驱动到同一位姿 → 截图/直读 → 哈希比对 → 产物成对入库【kimi】(实证:`case-studies/verification-gates.md` §1.2)。 - **附带用法**:"应当不影响画面"的架构改动用"位姿哈希不变"关账【kimi】(实证:`case-studies/verification-gates.md` §1.2)。 ### 1.3 量化像素对拍门(非 byte-equal) - **定义**:双侧同参数、同状态截图后计算量化指标,指标落进显式容差即绿;**必须配噪声归类纪律**(`gate-failure-modes.md` §3,逐格分类器见 `gate-failure-modes.md` §3.1);检查点集合按 §1.3.1 的两维枚举;**容差不许拍脑袋,它是自比带宽,且两侧各建一份**(§1.3.2)。 - **实例与指标**:见 `case-studies/verification-gates.md` §1.3(rogier 行亮度剖面 / band delta / 颜色时间轨迹、oryzo 按 section 对齐的滚动点、samsy 粗网格逐格色差、noomo 六滚动检查点同帧对拍)。 - **适用条件**:WebGL/视频/随机相位等"活场景",逐字节不可行时的降级。 - **skill 脚本**:`scripts/pixelcompare.mjs`(网格/剖面档)。 - **辅助手段**:canvas-only 对比(隐藏 DOM 只比 WebGL 输出,把"DOM 截图噪声"从归因中剥离)、computed style / 几何矩形逐值对比(CDP 抓两站同一元素最终计算值逐值比)【rogier】。 #### 1.3.1 检查点有两维:位置 × 状态(硬纪律)【shopifydesign】 **检查点集合必须同时覆盖"位置"与"状态"两维,缺一维就只有一半覆盖面。** §4 坑 6 与 `environment-traps.md` §6 的"检查点必须覆盖滚动两端"只是**位置**这一维的下限(位置维的完整枚举规则见下);一个站还有若干**互斥的全局状态**(静止 / 下潜 / 打开覆盖层 / 某个模式开关),而**同一套位置检查点在不同状态下画的是完全不同的东西**。只做位置一维,门会按自己的定义完全正确地运行,却把整整一个状态排除在视野外——实证与后果见 `gate-failure-modes.md` §1.8。 **位置维的三条枚举规则**【shopifydesign】: 1. **按"这个页面有几段内容"枚举,不按"两端 + 中间"这种几何直觉枚举**——**每一段可视内容至少一个检查点**,并**显式包含 `scrollY = 0` 与 `scrollY = max`**。"覆盖滚动两端"(§4 坑 6)是这条规则的**下限**,不是它本身。(实证:`case-studies/verification-gates.md` §1.3.1) 2. **取景位置由"本轮产物在第几屏"决定,不由"页面从哪开始"决定。** 每个里程碑写验收之前先回答一句:**这一轮移植的子系统在第几屏?**(实证:`case-studies/verification-gates.md` §1.3.1) 3. **两维取笛卡尔积**:位置枚举完之后,**每个状态各跑一遍同一套位置检查点**(见下)。 **状态枚举的取证步骤(从代码里读出来,不是凭直觉列)**: 1. 打开引擎的**主绘制 / 主更新函数**(每帧被 rAF 调的那一个),从第一行读到第一个 `return`; 2. 逐条抄下每一个**早退分支与总开关**的条件——`if (revealT === 0) { clear(); return }`、`if (!enabled) return`、`switch (mode)`。**每一条就是一个状态**,而且这份清单不是你列的,是站点自己写下的; 3. 对每个状态问两句:**它的唯一入口是哪个符号?那个符号移植了吗?**入口没移植,这个状态就永远进不去,该状态下才可见的全部移植代码,像素证据恒为 0; 4. **每个状态各跑一遍同一套位置检查点**(同一仪器、同一位置集合,只换驱动步骤),产物文件名里写明状态(本例 `M4c-dive-{desktop,mobile}.json`); 5. 枚举得出、但本轮**没有驱动手段**的状态,**登记为开口**(写进里程碑日志与偏差表),不许在报告里默认它被覆盖了。 **驱动到状态之后要有独立证据(`gate-failure-modes.md` §1.2 的状态版)**:驱动必须走**源站自己的入口**,而不是从外面戳一个状态变量,并且**逐帧断言状态量**(`spreadT ≥ 0.99`,否则 `exit 4`)。少了这条断言,一次落在卡片上的按压会被命中判定变成"开弹窗",门就会产出一份**顶着状态名文件名的、绿油油的另一个状态的对拍**。(实证与实测收益:`case-studies/verification-gates.md` §1.3.1) #### 1.3.2 容差从哪来:先量自比带宽,而且**两侧各建一份**【shopifydesign】【objectarchive】 **跨侧数字在自比带宽建立之前读不出意义。** 建量化像素门的第一个动作不是拍两侧,是**用同一把尺子、同一套检查点,让每一侧自己跟自己比若干次**——得到的每检查点带宽就是这道门的容差来源。**"带宽建在参照侧"只是这条纪律的一半**——这条纪律最初只写了参照侧,实测证明不够(纪律 5)。 **为什么必须先做**:见 `case-studies/verification-gates.md` §1.3.2——跨侧 98.0% 看着像回归,镜像自比就给出 98.6%;最热格的坐标与数值在自比里一模一样地出现,且指标对 Δ 相位不单调。 **五条硬纪律**: 1. **样本数是判据的一部分:至少 3–4 次独立会话**,**报告里写出每一次的值,而不只是最大值**。**2 次不构成带宽**(实证:`case-studies/verification-gates.md` §1.3.2)。**单值带宽是在假装噪声是确定的。** 2. **容差在数字产生之前就写死在仪器里**:门吃自比 JSON(`--band`;按纪律 5 是两侧各一份),判据写成"每个检查点的跨侧 `meanAbsDiff` ≤ 该检查点的自比带 + 常数"。跨侧数字出来之后再调带宽,就是 `gate-failure-modes.md` §2 禁止的调参糊平。 3. **先判可比,再入带宽**:每次自比会话都要带一份可比性指纹(`docHeight`、已取到元数据的媒体数、字体状态),**指纹不同的会话直接丢弃重采,绝不平均进带宽**——那不是"稍微宽一点的噪声",是**另一个布局**(判据与实证见 §2.2)。 4. **选帧纪律:别在离散事件密集的相位上建像素门。** 整块图块跳入跳出、元素成批挂载的那几百毫秒,指标本身不连续,带宽宽且无意义;把检查点往前或往后挪到画面**连续变化**的段落。同理,**选帧只看相位、绝不看指标**——按指标挑帧就是调参糊平换了个说法。 5. ⭐ **带宽必须两侧各建一份:参照侧可能系统性地更幸运【objectarchive】。** 只在参照侧建带宽时,判据里藏着一个没写出来的前提——**参照侧那几次会话的运气代表了这道门的噪声水平**。它不成立:该项目有一条**源站自己的竞速**(画布位图尺寸取决于图片解码完成那一刻的布局,两侧都有),而参照侧的四次会话恰好都掷出了同一面,于是它的自比带宽偏窄;**偏窄的带宽会把"属于这一次运行"的抖动报成跨侧残差**。(实测反证:`case-studies/verification-gates.md` §1.3.2) - **操作化**:同一把尺子、同一套检查点,**两侧各跑 ≥4 次**(`MIN_SELF_SESSIONS` 写死在脚本里),容差公式吃两份带宽,**每检查点取两侧自比带里更宽的那一侧**再加同一个常数——单侧带宽等于让判据给参照侧的运气背书。这一切仍然**在跑出任何跨侧数字之前**固化(纪律 2 不因本条松动)。 - ⭐ **两侧会话必须交错跑:A/B/A/B…,不是先跑完一侧再跑另一侧【objectarchive】。** "两侧各建"只说了**多少次**,没说**什么顺序**,而顺序会把整件事做废。连跑一侧四次再连跑另一侧四次,等于让两侧的带宽**系统性地测在不同负载下**,于是"哪一侧更幸运"与"哪一侧跑得早"被混成同一个数字——**而纪律 5 存在的全部理由就是要把前者单独量出来**。交错还顺带保护纪律 3:相邻两次会话的机器状态最接近,可比性指纹最可能一致。(实证:`case-studies/verification-gates.md` §1.3.2) - **识别信号(发现自己已经踩了)**:**同一侧多次会话之间的方差与"跑的先后顺序"相关**——同侧样本按时间单调漂移、最早/最晚的那次总是极值、或两侧带宽之差恰好约等于两批之间的负载之差。命中任一条,量到的是机器状态不是两侧差异。 - **处置**:整批作废、交错重采,**不许事后用平均补救**——把一个系统性偏差平均进带宽,只是把它藏进容差里(`gate-failure-modes.md` §2 调参糊平的另一种写法)。采数期间不许改仪器这条同样成立(否则 N 次会话本身不可比)。 - ⚠ **发现这件事的那一轮,不许回头调宽当轮的带宽。** 当轮容差一个字不动,用 `gate-failure-modes.md` §3.1.2 的同侧对照归因,把"两侧各建带宽 + 重新固化容差"写成下一轮开工的第一件事(实证:`case-studies/verification-gates.md` §1.3.2)。**先归因、后改协议,且改协议要在新数字之前**——反过来做就是 `gate-failure-modes.md` §2 的调参糊平。 **它与 §2.2 是同一条原理的两个面**:A/B 门在报差之前,先要证明"同一侧、同一状态"能给出多小的差。**带宽是标量;把它降到"格"这一粒度就是 `gate-failure-modes.md` §3.1 的分类器 (B),把跨侧数字放回自比样本里排名就是分类器 (C),把同一套会话在被测侧再跑一次就是分类器 (D)(`gate-failure-modes.md` §3.1.2)。** ### 1.4 数值探针门 - **定义**:不比画面,直接断言内部状态/数学层数值。 - **实例**:见 `case-studies/verification-gates.md` §1.4(rogier / samsy / kimi / noomo)。其中一种可复用的形态——**68 处 mode 字符串比对**——把源码语义编码成 `source-<符号>-<行为>` 常量嵌进运行时状态,实现端与探针端共享同一组常量逐一比对,"实现遵循了哪条源码语义"从口头承诺变成自动回归项【rogier】。 - **适用条件**:内部状态可暴露——复刻侧自建句柄(noomo 的 `window.__sweet3`、rogier 的 `window.__rogier*Probe`,均 query 门控并登记偏差);源站侧读不到 state 时用拟合/重放绕过【kimi】。数据驱动动画必配此门(见 `references/animation-recovery.md`)。 - **skill 脚本**:`scripts/probe.mjs` 的 `--eval/--evalAfter`(延迟二次求值,用于断言异步导航结果【lando】)。 #### 1.4.1 场景图数值门(§1.4 最强的一个子类)【shopifydesign】 - **定义**:把引擎"读 DOM 建场景"的那个函数**逐字转写成独立探针脚本**,两侧各跑一次,输出结构化 JSON 基准,**逐字段数值 diff**。差异为空即绿——**但"0 差异"只有在两侧跑同一个程序时才是有效判据**,竖切期(有桩)的用法见下。 - **适用判据(唯一一条)**:**引擎的场景构建是 DOM/CSS 的纯函数**——即场景是 (HTML 字节, CSS 字节, 视口, scrollY) 的函数。取证信号:同一个函数里同时出现 `querySelectorAll("[data-*]")` + `getBoundingClientRect()` + `getComputedStyle()`。命中即可建门。 - **实例**【shopifydesign】:shopify.design 的 `QL` L30737–L30899 逐字转写为项目侧的 `dump-scene-graph.mjs`(零依赖、裸 CDP;**本 skill 不提供该脚本**——它是那个站 bundle 内部函数的逐字转写,换个站连挂载点都不存在,必须按目标站的引擎重写一份),产出 `objects[]`(world 坐标/尺寸/字号/对齐/行高/字距/圆角/旋转/颜色)+ `carousels[]`(`--card-width`/`--card-height`/`--card-gap` 与逐卡矩形)+ `docHeight`,入库 `docs/scene-baseline/`。 - **实测数据**:见 `case-studies/verification-gates.md` §1.4.1(未冻结自比漂 7 字段 → 冻结后 0 → 镜像 vs 线上全部 0 差异)。 - **⚠ 关账条件的隐含前提:它只在两侧运行同一个程序时才是有效的通过/失败门**【shopifydesign】。"0 字段差异"写出来的是判据,没写出来的是前提。**竖切按定义就是另一个程序——它有桩**:只要有任何一个布局决策位于被桩掉的子系统下游,两侧必然分叉,门必然红,且**红得与移植质量无关**。**门量的是桩,不是移植。**(实证:`case-studies/verification-gates.md` §1.4.1) **竖切期的正确用法:按字段分组关账。** | 字段组 | 竖切期判据 | 依据 | |---|---|---| | 结构 / 几何 / 排版(宽高、字号、行高、字距、对齐、圆角、旋转、颜色、`src`、`depth`、`slices`、`words`) | **立刻要求 0 差异**,红了就是移植 bug | 这些字段是 `getBoundingClientRect()`/`getComputedStyle()` 的直接产物,与"谁排在第几个"无关 | | 位置(`worldX`/`worldZ`/`worldTop`)+ `docHeight` | 标为 **deferred**,且**逐条标注归属的子系统与源行号**,写进偏差表并约定"该子系统落地后必须转 0" | 位置是"落进哪个槽位"的函数,槽位由被桩的子系统决定 | 配套三条纪律: - **对象按 `id` 匹配,不按下标**——`id` 是槽位变化搬不动的唯一锚点,正是它让"同一对象换了位置"与"同一槽位换了对象"可区分; - **分组不是放水,前提是同时给出正面证据**:非位置字段全等,才敢说"位移是桩造成的"(M2 实测 **0 blocking / 102 deferred**); - **顺延 ≠ 放弃,且要被兑现**:被桩子系统落地后关账判据切回全量模式,deferred 项全部转 0,分组模式随即退休,不再有任何字段挂在它下面(实证:`case-studies/verification-gates.md` §1.4.1)。 **别用"钉死重排结果"换全绿**:把镜像运行态的槽位分配抄成固定表注入复刻侧,门确实会绿,但你钉死的正是还没移植的那个子系统的输出——真移植时这张表要么删掉、要么变成掩盖 bug 的补偿层。只有该子系统明确不在复刻范围内时才值得。 - **⚠ 别把这个前提和确定性冻结搞混**【shopifydesign】:`references/determinism.md` 的定种 PRNG 解决的是"**同一个程序可复现**",**不等于"两个不同的程序可比"**。分叉可以完全没有 PRNG 参与(确定性贪心砌砖,两侧各自完全可复现,就是不相等;实证:`case-studies/verification-gates.md` §1.4.1)。反过来,若分叉点真的落在共享 PRNG 流上(桩改变了取随机数的次数),定种同样救不了:种子相同但消费序列错位。两种情形指向同一条结论——**竖切期不要指望靠冻结把门变绿**,要按字段分组关账。 - **它强在哪**:① **精确**——数值全等或不全等,没有容差、不需要噪声归类;② **可归因**——差异直接指向"哪个对象的哪个字段",像素门只能告诉你"某处不像";③ **它恰好卡住唯一会真正破坏 3D 的东西**:CSS 布局漂 1px = 3D 物体位移 1px×全局缩放。 - **⚠ 但它不是像素门的替代品——两者覆盖面不同,不存在"数值门 > 像素门"的排序**【shopifydesign】:数值门跑在冻结环境里,而**被冻掉的那条分支上挂着的子系统对它是隐身的**——两侧都不执行,逐字段 diff 恒为 0,门以"通过"的形式失明。(实证:`case-studies/verification-gates.md` §1.4.1——`R5` 挂在被冻的 rAF 上,数值门 0 差异地错了两个里程碑,抓到它的是不冻结的截图对拍)正确的分工是:**数值门管冻结得住的那一半,不冻结的截图对拍 / 结构性抽查管另一半,两条都得留**(冻结前的入口枚举纪律见 `references/determinism.md` §2.10,失效模式见 `gate-failure-modes.md` §1.7)。**⚠ 而这两条加起来仍然只覆盖"你驱动到的那个状态"**——同族的第三种失效(全部的门拍在同一个状态里)见 `gate-failure-modes.md` §1.8,三者的对照表也在那里。 - **它比 §1.4 一般形态强在哪**:§1.4 的前提是"内部状态可暴露"(复刻侧自建句柄、源站侧靠拟合/重放绕过)。场景图数值门**两侧都不需要插桩**——转写出来的探针在源站的原混淆 bundle 上照样跑。因此它是**唯一能直接从线上源站取到同一份数值基准的数值门**,可以在移植开工前就建立,并终身作为 DOM/CSS 改动的回归门。 - **搭建纪律**: - **逐字转写,连 bug 一起转**:`QL` 里 carousel 元素在第一遍遍历中落不进 text/image/shape 任何分支、空转一次——照抄;"改进"它就等于在比另一个程序。minified 标识符与源行号保留在注释里,两边可肉眼 diff。 - **读取器的副作用同样要转写**(解析前改场景根、读完还原并 `scrollTo` 这类):漏掉这一步读到的是活布局,不是引擎看到的布局(实证:`case-studies/verification-gates.md` §1.4.1)。 - **解析作用域按源站来**(本例是 `[data-dom-layout]` 而非 `document`);退化到 body 时要在产物里显式打标(`layoutRoot: "body(FALLBACK)"`),否则会静默比错东西。 - **先冻结,再取基准**:单侧自比 0 差异是双侧对拍的前置条件(熵源清单见 `references/determinism.md` §1、§3)。**但冻结同时制造盲区**——冻结前必须按 `references/determinism.md` §2.10 列出挂在被冻源上的入口,逐条决定"探针泵到"还是"补一条不冻结的结构性抽查"。 - **归一化只允许做偏差表里登记过的那一项**(本例 `/ext/` URL 改写),其余一律算真差异——否则这个门会退化成可调参的像素门。 ### 1.5 CLEAN 探针门(底线门) - **定义**:无头加载 + 滚动/遍历状态,采集 console 错误、页面异常、失败/非 2xx 请求,零错误(白名单放行已知残留)即 CLEAN,退出码进 CI。 - ⛔ **退出码不许经过管道**:`node verify-x.mjs | tail -20` 让 shell 只看见 `tail` 的退出码,门红了也是 0(实证:`case-studies/verification-gates.md` §1.5)。commit 前把门**单独跑一遍**读退出码(或 `set -o pipefail`);更稳的做法是让门把结论写进文件,日志引用文件而不是引用记忆【14islands】。 - **实例**:见 `case-studies/verification-gates.md` §1.5(lando 全路由 × 双视口 14 个探针跑;samsy 零控制台错误门,无头回归必带 anti-throttling 旗标;oryzo 无头双分支 + 三段截图)。 - **适用条件**:**所有站点**,成本最低,从 M0.5 镜像跑通开始终身使用(镜像与复刻两侧都跑)。 - **skill 脚本**:`scripts/probe.mjs`(必须含 CDP Log 域监听,见 §4 坑 1)。 - **纪律**:已知残留也登记在案(无头 SplitText 字体时序警告、uTime 相位细微差),不假装 100%【lando】。 ### 1.6 零外联门的完整断言面(⚠ 常被漏判) "零外联"是离线复刻的核心声明,但**资源级探针只数 request,会漏掉四类真实外联**。断言面必须覆盖: | 类别 | 为什么资源探针抓不到 | 怎么断言 | |---|---|---| | **1 连接预热**:`<link rel="preconnect">`、`<link rel="dns-prefetch">` | 不产生资源请求,但联网时**真的发起 DNS 查询与 TCP/TLS 握手** | 对构建产物静态 grep 这两类 `<link>`,外部 host 一律移除或登记 | | **2 内联自包含遥测** | 遥测实现整段内联在 HTML 里,不依赖被 stub 的外部脚本;`sendBeacon`/`fetch` 直打绝对外部域 | grep 构建产物里的外部绝对 URL 字符串,逐条判定是否可触发 | | **3 兜底路径外联** | 只在某脚本加载失败时才触发,正常跑不出现 | 读代码判定触发条件,不能只靠跑一遍 | | **4 转义的绝对写法**:`https:\/\/host\/…`、`\/\/host\/…`(JSON 字符串里的斜杠转义)【objectarchive】 | **双重隐身**:① 探针看不见——这类字节多半躺在 JSON 载荷 / 结构化数据里,宿主 DOM 不渲染就永远不发请求(甚至根本不是请求);② **静态面也看不见**——`grep 'https://host'` 一条都不命中,于是"外部绝对 URL 清单"看起来是空的 | 按下面的三步查:**先按四种拼写 grep,再把 JSON 块解码后遍历字符串值**,最后逐条判定归类 | 实证【racingshop】(第 1–3 类)与实证【objectarchive】(第 4 类:断言面缺的那一格)见 `case-studies/verification-gates.md` §1.6——前者是 `gate-failure-modes.md` §1.1「门只断言想到的字段」的实例;后者是一个已经关账、四项断言全绿的镜像上仍留着两处转义绝对写法,`grep 'https://<host>'` 一条都不命中。 **它与 `shopify-platform.md` §2 D1c 是同族不同格**:D1c 讲的是基址常量**没有尾斜杠**(`"https://host"` 再由代码拼路径),本格讲的是**斜杠被转义**(`https:\/\/host\/`)。两格的共同点是让"只匹配 `https://host/`"的提取 / 改写 / 断言规则天然失明,**且失明时的表现都是绿灯**。 **可执行查法(三步,缺一步就还是半盲)**: 1. **按四种拼写逐一 grep 构建产物**(ERE 里字面反斜杠要再转义一次;四种一个都不能少): ```bash grep -RnoE 'https?://[A-Za-z0-9.-]+' site/ | sort -u # 绝对 grep -RnoE '(^|[^:])//[A-Za-z0-9.-]+\.[A-Za-z]{2,}' site/ | sort -u # 协议相对 grep -RnoE 'https?:\\/\\/[A-Za-z0-9.-]+' site/ | sort -u # 转义绝对 ← 本格 grep -RnoE '\\/\\/[A-Za-z0-9.-]+\.[A-Za-z]{2,}' site/ | sort -u # 转义协议相对 ← 本格 ``` (末条会连带命中转义绝对写法的尾部,属预期重叠——**宁可重复,不可漏**。) 2. **再把 JSON 块解码后遍历字符串值**——这一步比 grep 稳,且能兜住你没想到的拼写:把 `<script type="application/ld+json">`、`type="application/json"`,以及模板序列化出来的内联 JSON(Liquid 的 `| json`、Rails 的 `to_json`、各类 `JSON.stringify` 载荷)整块 `JSON.parse`,**遍历解析后的字符串值再匹配主机名**。解码之后所有转义拼写归一成同一种,不必逐种猜正则。JSON-LD 内嵌尤其要单独走这一步:它整段就是转义写法,且**语法上完全合法**,没人会觉得它"看起来不对"。 3. **命中后逐条归类,三选一**:**已改写** / **不可触发但登记为潜伏** / **不是请求**(结构化数据标识符、命名空间 URI、出站锚点——逐条点名豁免)。⚠ **"资源在盘上"和"宿主不渲染"都不是豁免理由**:它们只解释了资源探针为什么抓不到,不改变"这是一处未处理的外部绝对 URL"这个事实。⛔ **法务理由更不是豁免理由**:"反正不公开"不能用来关掉一格断言——门的覆盖面与法务考量互不相干(`legal-and-deploy.md` §0.2;实证:`case-studies/verification-gates.md` §1.6)【objectarchive】。 反过来,**出站 `<a href>` 锚点不算外联**(如页脚的 shopify.com/legal 链接):它是源站内容,按宪法第 3 条应逐字保留,点击才跳转,加载时无网络活动。 **关账要求**:零外联门的产出物里要有"外部绝对 URL 清单 + 逐条判定(不可触发 / 已移除 / 已登记为潜伏外联 / 不是请求)",而不只是一句"probe 全绿"。**清单必须把转义拼写解码后归一进来**(否则它天生漏掉第 4 类),并且**报完整 URL 而不只是 host 直方图**——只报 host 看不出漏的到底是哪一条【objectarchive】。 **⚠ 别把本节的教训推广成"门出问题一定是断言面缺一格"**:本节全篇讲的是**覆盖**(该断的没断,失明时表现为绿)。同一个项目下一个里程碑的三次红灯一条都不属于这一类(实证:`case-studies/verification-gates.md` §1.6)。查门的时候三问要分开:**先问"这一条有没有被断言"(本节,失明时表现为绿),再问"这一条的期望值是怎么来的"(§0.1,单侧就会红),最后问"这个字段本身该不该进记录"(`gate-failure-modes.md` §1.11,单侧全绿、跨侧才红)**【objectarchive】。 ### 1.7 声音是输出面:没有门断言过它,它就能整类缺失而全绿【overworldaudio】 §5 的输出侧账列的全是**会上色的面**——而声音一个像素都不上。声音可以**整类缺失**而像素、DOM、CLEAN、零外联全部绿(实证:`case-studies/verification-gates.md` §1.7)。两层课: - **镜像层**:声音 URL 几乎总是运行时拼的(`/sound/<codec>/<name>_<变体号>.<ext>`), §1.6 class 4 的资源级探针才看得见。破法层级:bundle 提音名族(会漏变体号)→ 试探种子(靠猜)→ ⭐ **驱动到出声状态后从音频引擎的池子里倒出全部 src 当种子** (Howler:`Howler._howls[].._src`)——**池子即账本,实测不猜**。 - **门层**:音频普查判据 = 驱动入声音上下文(点击入场/开声)后, **池内全量 loaded + 零 `/sound/` 404 + 零外联**,镜像侧、端口侧、仓外副本三处一致。 ⚠ headless 下 `AudioContext.state === "suspended"` 属自动播放策略,两侧一致即可, 不判红;**"suspended 但 loaded"恰恰证明字节都在本地**——播放态另归交互门管。 推广:输出面不止像素。凡"源站有而不上色"的通道(声音、震动、剪贴板、下载), 开工时对照站型问一遍"这一类有没有门",挂零个门的通道逐条登记为开口或补门。 ## 2. 门型选择决策树 ``` 站点有 SSR/静态 HTML 产物? ├─ 是 → 先建 SSR/DOM 字节门(§1.1),每 commit 回归、终身全绿 └─ 否 → 至少建路由/契约门(重定向状态码、head 字段) 该画面/场景是否"静止且熵源可枚举"?(DOM 渲染为主,无不可冻随机源) ├─ 是 → 冻结协议 + 整页 byte-equal 门(§1.2);冻不住的局部用 │ "同等隐藏"协议剥离并另建专门门覆盖【kimi】 └─ 否(WebGL/视频/随机相位)→ 先别急着降级,再问一层: 场景构建是否为 DOM/CSS 的纯函数? (同一函数里同时出现 querySelectorAll("[data-*]") + getBoundingClientRect() + getComputedStyle()) ├─ 是 → 先建场景图数值门(§1.4.1):逐字转写该函数为探针, │ 两侧逐字段数值 diff。关账判据分两种情形: │ · 竖切期(有桩 = 两侧不是同一个程序)→ 按字段分组关账: │ 结构/几何/排版字段立刻 0;位置字段 + docHeight 标 deferred, │ 逐条归因到具体的桩(带源行号)并写进偏差表 │ · 被桩子系统全部落地后 → 切回全量 0 差异,deferred 项必须转 0 │ ⚠ 像素门不下岗:数值门跑在冻结环境里,看不见挂在被冻分支上 │ 的子系统(两侧对称缺席 = 0 差异),必须另留一条不冻结的 │ 截图对拍 / 结构性绝对断言【shopifydesign】 └─ 否 → 降级为量化像素对拍门(§1.3) + 先量自比带宽、两侧各 ≥4 次、用它定容差(§1.3.2) + 显式噪声归类(`gate-failure-modes.md` §3)+ 最差格/最差点目检归因 + 残差归因四分类器;参照侧带宽可疑时跑同侧对照(`gate-failure-modes.md` §3.1.2) 动画/交互由数据或纯函数驱动? ├─ 是 → 补数值探针门(§1.4):dump 基准 → 拟合/重放/全等断言 └─ 否 → 至少对关键内部状态加探针断言(mode 字符串 / 状态遍历) 选完门,对整个门集合做两次自检: ① 二维覆盖(§1.3.1):检查点 = 位置 × 状态。位置按内容分段枚举、含滚动两端; 状态清单从引擎主绘制函数的早退分支读出来,逐个问"这个状态有没有门"; 没有驱动手段的状态登记为开口,不许默认覆盖。 ② 采样时刻(§2.2):每个门写清"什么时候算测完",判据必须是页面状态; 固定 sleep 不是 settle 条件,未 settle 的采样不许落盘。 全程兜底:CLEAN 探针门(§1.5)对每条路由 × 每个视口跑,每次改动必过。 ``` ### 2.1 门的运行纪律 - 选型后把**"改动区域 → 最小门集合"写成映射表**进 REBUILD_PLAN【rogier】(实证:`case-studies/verification-gates.md` §2.1)。 - 维护**分级命令清单**(rogier 的 "Validation Profiles"):从 "docs-only(`git diff --check`)" 到"浏览器探针全家桶",可直接复制执行【rogier】。 - 每个工作单元以门收尾:HANDOFF/日志记录以 "Gates: ... green" 收尾,探针产物路径留档【rogier】。 - 门脚本环境变量参数化(OUT_DIR/CDP_PORT/VIEWPORT/PROBE_WAIT/REBUILD_URL),多端口并行跑【rogier】。 - 全部门用零依赖 Node 脚本(Node 22+ 内置 WebSocket 直连 CDP),"避免工具链自身版本漂移污染比对"【kimi】。 #### 2.1.0 ⛔⛔ 判决包含它的调用方式与豁免清单,两者都必须住在仓库里【airpodspro】 一道在计划里写着"七项全绿"的镜像门,几天后重跑变红。查下来两层,没有一层是被测对象变了(实证:`case-studies/verification-gates.md` §2.1.0):那次全绿是带着一个参数跑的(`--allow-missing <豁免清单>`),而参数只存在于当时的 shell 调用里,登记成 npm script 时凭记忆重建、参数漏了;**豁免从 3 条变成 4 条,因为被测对象长大了。** ⭐ **"把命令记下来"不够,要"把命令连同它的全部参数记下来"——凭记忆重建一条调用,等于重建一个不同的判据。** ⭐ 一般化:**豁免清单是判决的一部分。** 它住在仓库里(一个文件,每条附**可核查的理由**),判决才可复现;住在命令历史里,判决就只是一句话。 ⛔ 豁免文件的两条硬约束: - **每条都要写清为什么**,且理由必须是**技术性的**——"这个地址真的不存在"、"它在声明的抓取范围之外"。⛔ 不许出现"因为它是什么内容"这类理由:范围与存在性是技术边界,内容性质从来不是(`asset-management.md` 的同一条纪律)。 - **门每次运行都要把豁免逐条打印出来。** 一条没人读的豁免,就是一个没人知道的洞。 #### 2.1.1 ⭐ 验收工具链里"两处以上要算出同一个答案"的逻辑,必须单一实现【objectarchive】 **规则:门、驱动、镜像器、抓包器、构建器里凡是有两处或以上需要算出同一个答案的逻辑,必须写成一个模块、由各方共享引用,不许各写一份。** 这不是整洁问题——**它是一类会同时制造假红与假绿的 bug 源**。 **典型对象**(每一条都在实战里咬过人):url→本地路径映射、URL 规范化(剥 fragment、查询排序)、滚动驱动与落点判定、检查点枚举、指纹/摘要算法、settle 判据、端口分配、外部主机表。 **识别信号(命中任一条就去合并)**: - 同一个概念在多个脚本里**各写了一遍**(`grep` 同名函数、同一条正则、同一张常量表出现在两个以上文件); - **几份拷贝的参数已经不一样了**——这是拷贝存在的最好证据,也是它已经漂了的证据; - 一份实现修好了,另一份**没人记得去修**——拷贝里的漏修会当场变成一条假红(实证:`case-studies/verification-gates.md` §2.1.1)。 **代价三条**: 1. **bug 会复制 N 份**,而且是同一个 bug 的 N 个独立宿主——修一处,另外几处继续绿着错; 2. **门之间互相矛盾时无从判断谁对**:两道门对同一个位置给出不同答案,你分不清是被测对象变了还是尺子变了。**尺子有几把,"这道门红了"就有几种解释**; 3. **拷贝里的 bug 会穿上"跨侧差异"的外衣**。一次落点失误会流进那个检查点上的每一条记录,读起来像产品差异(实证:`case-studies/verification-gates.md` §2.1.1——三份 `wheelTo` 拷贝,一道 0 差异 / 116 组的绿门变成 14 红)。 **修法(三步,缺第二步等于没修,缺第三步等于没合并)**: 1. **合并成一份共享实现**(objectandarchive 侧把三份滚轮驱动并成了项目脚本 `lib/wheel.mjs`;本 skill 不提供该文件——驱动语义按站而异,但 `scripts/lib/urlpath.mjs`、`scripts/lib/ports.mjs`、`scripts/lib/chrome.mjs` 是同一做法的现成范例),文件头注写清"为什么这是共享文件"以及各拷贝原先漂到了哪里——**下一个人手痒复制之前先读到这段**; 2. **让共享实现自己断言它的产物**:驱动必须**等观察到状态变化**再发下一步,并**断言落点**(`exact`),达不到就响亮失败。**一个悄悄给出错误位置的驱动,比一个抛异常的驱动贵得多**——那个数会流进这个检查点上的每一条记录,读起来像产品差异。调用方按 `gate-failure-modes.md` §1.11 记**派生判定**(`landedExactly`)而不是记读数。 **⭐⭐ 第三步:验证它真的被共用——"共享"是一句声明,而声明可以是假的**【objectarchive】 **共享模块只有在两个调用点都 `import` 它时才是共享的;文件头写的是意图,不是代码。**(实证:`case-studies/verification-gates.md` §2.1.1——头注声称共用的 `lib/extract-refs.mjs`,爬虫从来没有 import 过它,修复只落在审计侧,于是门看得见爬虫抓不到的引用、重跑爬虫永远收敛不了镜像。) **一个"头注声称共用、实际没有"的模块,比两份公开的拷贝更危险**:两份拷贝至少还写在脸上,下一个人知道要改两处;而那行头注会让人以为改一处就够了,于是修复**看起来**落地了。 **可执行检查(写进合并那一步的收尾,别靠记性)**: ```bash grep -l "lib/<模块>.mjs" scripts/*.mjs # 结果必须等于头注里列的调用点 grep -n "<那条正则/那张常量表>" scripts/*.mjs # 除 lib/ 外应当零命中 ``` **验收纪律**:① 头注里逐个列出调用点,并把上面这条 `grep` 写在旁边;② 合并之后**跑一遍差分**证明共用真的改变了行为(实证形态:`case-studies/verification-gates.md` §2.1.1)。**没有这个差分,"已合并"和那行头注是同一种东西:一句声明。** **同族先例**:url→路径映射做成**镜像·服务·抓包·验收四方共用**(`mirroring.md` 的"映射单射性"一条),理由一模一样——**几方映射不一致本身就是 bug 源**,而它失效时的表现是"零 404 门在一个错的镜像上变绿"。 **⚠ 作用域边界(这条纪律为什么不放进 `porting-discipline.md`)**:它管的是**我们自己写的仪器**。**源站字节里的"同一份逻辑三份拷贝"必须照抄不修**(`porting-discipline.md` §1.3)——正解是**三份各被自己的门钉住**(漂了就响),**不是**抽一个公共模块出来(实证:`case-studies/verification-gates.md` §2.1.1)。两条方向相反,作用域一分就不会串。 ### 2.1.2 ⭐⭐ 检查者不能是生产者:门永远不许 import 生产它所审计之物的模块【aimservices】 §2.1.1 讲的是"同一个答案不能有两份实现"。这一条是同族的另一面,而它更隐蔽——**门与被审计物之间的依赖方向反了**。 **事故**:策略 A 的外壳字节门要一份页面清单,写法是 `import { PAGES } from "./build-site.mjs"`。而 `build-site.mjs` 的构建逻辑在**顶层**执行,于是**这个 import 跑了一次构建**:门先把 `site/` 重新生成,再去审计自己刚写下的东西。(实测与注入 fixture 前后对照:`case-studies/verification-gates.md` §2.1.2) **更深的一半:断言变成了循环论证。** 产物由变换表生成,再拿变换表去解释产物,于是"产物与镜像的差异只出现在变换表说的地方"**由构造成立**,而不是被验证。剩下还成立的只有一条弱得多的性质:每条变换在 hunk 局部可复现——真实,但远不是门宣称的那件事。 **⭐ 为什么它能活很久**:这类门的日常表现与正确的门**完全一样**——绿的时候绿,改坏变换表的时候也红(因为构建和重放共用同一张表,表错了两边一起错、hunk 对不上)。它只在**产物被构建之外的东西动过**时失效,而那恰恰是"未登记的编辑"的定义。 **纪律(三条,全部可机器检查)**: 1. ⛔ **门不 import 生产者。** 共享数据放进**无副作用**模块(`shell-config.mjs` / `lib/pages.mjs` 这类),生产与检查各自 import 它;两者**分属两个进程**。 2. **一个模块只要在顶层 `await`、写盘、`rm`,它就不是数据模块**——被 import 时它会做事。判断方法很简单:**假装你只想读它的一个常量,问它会不会顺手改磁盘**。 3. **每道门配一次注入 fixture**:往产物里塞一个未登记的字节,跑门。门必须**报红**,且跑完**那个字节还在**。第二项和第一项一样重要——门若把它擦掉了,你看到的红也可能是别的原因。 #### 2.1.3 ⛔ 未知参数必须 FATAL——一次静默忽略买了三小时追凶【hubtown】 `netcapture.mjs` 有 `--settle`;`probe.mjs` 的对应参数叫 `--wait`。给 probe 传 `--settle 150000` 不会报错——它被**静默忽略**,每一次"长观察"实际都在**默认 6 秒**上运行。 在此之上盖起了整座幻影大厦,每一层都有"证据"(实证:`case-studies/verification-gates.md` §2.1.3)。 **每个局部检验都对,前提错了,于是它们全在为幻影作证。** ⭐ 修复是一行纪律:**工具枚举自己的参数,未知的一律 FATAL 并列出已知集。** 兄弟工具之间的词汇差异(settle/wait)是这类事故的固定温床。 ### 2.2 采样时刻:settle 必须是页面状态,不能是墙钟【shopifydesign】 **任何 A/B 门都要写清"什么时候算测完",而这个判据必须是页面状态,不能是固定 sleep。** "等多久"是一个**未登记的熵源**:冻结协议管的是"跑起来之后的熵",**取样时刻是它管不到的第二个熵面**(该项目 `?__probe` 冻了 rAF/timer/random/Date,却完全没冻网络到达顺序)。 **事故**:见 `case-studies/verification-gates.md` §2.2——采样脚本是 `Page.navigate` → `setTimeout(8000)` → 读,而布局输入每到一个媒体就重排一次,于是镜像连自己都对不上。**此前每一个"0 差异"都含着一个没写出来的前提:两侧恰好处在同一个未 settle 的中间态。** **三条做法(全部在仪器侧,不动被测代码)**: 1. **`--wait` 从固定 sleep 改成 settle 轮询的上限**:每 250ms 采一次页面状态签名(本例 = `[data-layout]` 的 rect 签名 + 已取到元数据的媒体数 + `document.fonts.status`),**连续 12 次(3s)全不变、且过了地板时长**才算 settle。**没 settle 就非零退出,绝不落盘**——一份没 settle 的采样,门无法把它与"移植错了"区分开。 2. **settle 指纹写进产物 JSON,差分器把它当可比性前置条件**:两侧指纹不同就**报"不可比、要求重采",而不是给出一个红或绿**。两个媒体状态可以各自稳定而互不可比(实证:`case-studies/verification-gates.md` §2.2)。 3. **严格判据只能当前置条件,不能当等待条件**:"所有带 src 的视频都取到元数据"试过、不可用——Chrome 会随机让 17 个里的某一个永远停在 `readyState 0`,45s 也不来。 **自检(40 秒,收益极高)**:**让基准侧连跑两次,差异不为 0 就先修仪器,不要看被测侧。**(实证:`case-studies/verification-gates.md` §2.2) ⭐ **这条规矩同样适用于「前置条件」,而那是它最常被绕过的地方**【objectarchive】。上面三条讲的是"什么时候算测完";**"什么时候算可以开始"是同一个熵面的另一半,而它常常被写成一句 `await sleep(600)` 就过关**——因为它不在断言里,看起来不像门的一部分。(实证:`case-studies/verification-gates.md` §2.2——3 轮全量普查红 1 轮,永远是这一条断言、永远在参照侧。) **两条纪律**: 1. **前置条件也要被检查,而不是被希望。** 睡够毫秒数不等于条件成立;要能**观测到**条件成立才继续,观测手段优先用**被测块自己的产物**(本例:列数是不是 1),其次才是环境量。 2. ⛔ **但"检查不通过就重试"必须先量过重试到底有没有用。** 重新导航这种重试形态在 `environment-traps.md` §8 里实测无效,有效的是宽度抖动驱动页面自己的 resize 重建(实证:`case-studies/verification-gates.md` §2.2)。**没量过的重试是把偶发红变成偶发慢,不是修复。** 修复形态因此是**检测 + 定向修复**(用块自己的产物检测出"这份文档已经排错版了",再用已实测有效的手段把它修回来),并把修复触发次数写进记录——它是这条仪器偏差的销账凭据。 **配套**:这条与 §1.3.2(自比带宽)是同一条原理的两个面,与 `environment-traps.md` §7(快门速度与快门时机)是同一个仪器误差面的两段——`environment-traps.md` §7 管"拍得够不够快、驱动的时刻准不准",本节管"什么时候算可以拍"。`determinism.md` §4 亦有对应的防呆条目。 ### 2.3 ⚠ 全站对拍的成本由**进程启动次数**支配 带同侧对照的全站对拍要开 `路由数 × 4` 个浏览器实例(每侧采两次)。 代价在启动本身:macOS 的 `syspolicyd` 对每一次新启的浏览器二进制重新校验签名—— 没有实例泄漏,机器照样被拖进颠簸(实证:`case-studies/verification-gates.md` §2.3)。 ⭐ 于是**降低并发泳道反而更快**,因为颠簸消失了(实证:`case-studies/verification-gates.md` §2.3)。 ⚠ 规划全站对拍时按**启动次数**估算,不要按页面加载次数;而在机器开始颠簸时, 正确的动作是**减少**并发,不是增加。 ## 3. 分层验证体系(成本递增,全部要做) 1. **每里程碑冷启动实测**:全新加载("不手动切效果——手动切换会掩盖初始化状态 bug"【kimi】【oryzo】;实证:`case-studies/verification-gates.md` §3)、零控制台错误、截图取证,验收标准写进里程碑日志【samsy】。 2. **无头自动门每 commit 跑**:§1 的门按选型组合,判定必须机器可断言【6/6】。 3. **与源站对拍**:按 §2 选像素/数值门;产物入库留证(`docs/compare/`、`docs/pixelcompare/`、`docs/side-by-side/`)。 4. **冷头评审(收官审计)**:"功能测试测不出『整块遗漏』,只有清单式核对能"【samsy】。各项目实例见 `case-studies/verification-gates.md` §3。四种形态,按站型选用(可叠加): - 对 bundle 应用区**顶层类逐一核对落点**【samsy】。 - **模块清单对账**(只在过渡中出现的组件从未移植这一类,见 `gate-failure-modes.md` §1.5)【kimi】。 - **零 TODO/stub 审计** + 偏差表/怪癖表补全【noomo】。 - **反向扫描**:枚举复刻独有的全部 (media, selector) CSS 规则逐条判定"必要机制 / 等价别名 / 多余发明"【rogier】。 - 评审姿态:"不信文档,逐条回到镜像与 bundle 复核"【kimi】。 **⭐⭐ 先定粒度,再列清单:decline 的粒度必须等于"能整块缺失的东西"的粒度**【shopifydesign】 **这是本条(清单对账)唯一会整条失效的方式,且失效时的表现是 PASS。** 上面四种形态都默认了一个粒度——samsy 数的是"顶层类"、kimi 数的是"模块"。**粒度选错一级,缺失就落进你自己挖的洞里,而清单照样报 0 未归属。** **开工第一问(必须先答,再写脚本)**:**在这个站上,一个功能能以多小的单位整块消失?** 那个单位就是清单的粒度。 | 站型 | 最小可整块缺失单位 | 清单要枚举的东西 | |---|---|---| | 命令式 bundle(three/GSAP/裸 WebGL) | **顶层符号** | 应用区间的每一条顶层声明 | | **React / Vue 等框架站** | **effect / lifecycle hook** | 每一个 `useEffect`/`useLayoutEffect`/`onMounted`…**站点**,逐个 | | 数据驱动站(布局表、动画配置) | **数据表的一行** | 表里每一行的落点 | | ⭐ **零重写站(策略 A / 无 bundle:行为源就在内联 `<script>` 里,产物与源站逐字相同)**【objectarchive】 | **行为挂载点**,**不是块** | 每一个 listener / observer(Mutation·Resize·Intersection)/ timer·rAF / 自定义元素注册 / monkey-patch / 网络调用 / 能力探测 / 存储读写 | **⭐ 零重写站上"整块遗漏"换了形态,所以它的粒度也换了【objectarchive】**:策略 A 的字节门(逐字不变 / 只被登记变换动过)已经**保证代码不可能没移植**,于是这一型能缺的不是代码,是**"它在跑"这件事没有任何门证过**——**块级清单在这里天然报 PASS,因为块确实全在**。按块记账会把一个块名盖住的多个系统统统说成"已覆盖";改按挂载点审计后,"挂载点普查 ∩ 门自己引用的坐标区间"可以直接 join,且只有这个粒度才抓得到"宿主在、代码在、派发方不存在"的监听(实证:`case-studies/verification-gates.md` §3)。 **两条硬规矩**: - **decline 必须逐个单位登记**,每条带归属(§6 偏差号 / §Q 怪癖号 / 里程碑号),**没有归属的一律算遗漏**; - **range 级 decline 只能用于"整个文件都不移植"的 chunk**,不许用一条区间规则罩住一片应用代码。反例判据一句话:**如果你的一条 decline 规则能盖住几百个符号,它多半也盖住了你正在找的东西。** **实证(这条规矩的全部来源)**与**终版对账(两个粒度各一本账,退出码进 CI)**见 `case-studies/verification-gates.md` §3——第一版用一条 range 规则罩住整棵 React 组件树,跑出 0 未归属、报 PASS,而当时站上正缺三个整块行为;按 effect 站点再枚举一遍,同样三条立刻变成 UNACCOUNTED。 **它与 `gate-failure-modes.md` §1.8 是同一件事的两个面**:本条说的是"**清单粒度不够细**",`gate-failure-modes.md` §1.8 说的是"**清单再细,门也只覆盖你驱动到的那个状态**"。两条都要做——粒度对了但状态没覆盖,得到的仍然是"0 未归属 + 一整个状态零像素"。 5. **部署即验证**:真实网络延迟暴露本地永不触发的竞态——用 CDP Fetch 单文件延迟**二分定位**,根因可能是"部署拓扑差异(单源 vs CDN 分域)"而非代码,修复分"保真修正"与"登记偏差"两笔【samsy】(实证:`case-studies/verification-gates.md` §3);真机对拍兜 headless 盲区(`gate-failure-modes.md` §3)。 ### 3.1 标准门套花名册(按阶段;逐脚本细则见 `scripts/README.md`) 一次完整复刻的机器门,按管线阶段列全。**每一道的原理都在本文某节**——这张表只解决"这个阶段该跑哪几个、豁免清单接在哪": | 阶段 | 门 | 断言 | 豁免通道 | |---|---|---|---| | M0 镜像 | `verify-mirror` | 映射单射 / 账本一致 / 真实性 / 闭包 / 抽样回源(§2.1.1 单一实现) | `--allow-missing mirror/external.txt` | | 外壳构建 | `verify-shell` | 差异仅限变换表所述,从字节重推;floors + purpose(§0.1) | 变换表本身即登记 | | 外壳构建 | `verify-lenprefix` | 自带长度的载荷改写后重新声明(`payload-gates.md` §1) | — | | 外壳构建 | `verify-payload` | SSG 载荷求值展开后按叶路径比对(不是比字节) | `--allow-absent`(无数据岛站,两侧一致缺席才放行) | | 产出静态面 | `verify-offline` | 字节里的外部绝对 URL 普查,逐 host 对 `external.txt`(§1.6 静态半边) | external.txt 的 LINK/EMBED/stub 行 | | 产出静态面 | `verify-refs-served` | 每条资产引用由**真服务器**应答(§2.1.1:问服务器,不重实现映射) | `--allow mirror/external.txt`(源站自身 404) | | 运行时 | `probe --no-external` | CLEAN + 零外联的资源级半边(§1.5 / §1.6),**深度**:单路由走查/截图/长观察 | probe 输出里逐条归属 | | 运行时 | `sweep-routes` | 渲染**广度**:全路由一个浏览器逐一 0 错误/0 失败/0 外联,可带交互钩子与逐路由采集(§1.7 音频普查在此搭车) | `--allow-external`(已登记 EMBED 主机;其上的 4xx 报告不判红) | | 像素 | `pixelcompare` / `pixel-walk` | 位置 × 状态两维检查点(§1.3.1),跨侧对同侧带宽(§1.3.2),重复帧点名(`gate-failure-modes.md` §1.8) | 带宽由 `--self` 实测,不许手挑 | | 像素前置 | `frame-census` | 帧里有东西(`gate-failure-modes.md` §1.3:byte-equal 不证明测的是想测的画面) | — | | M(n) 关账 | `cold-audit-modules` | bundle 模块清单对账 + 检查覆盖率自报(`gate-case-design.md` §3.1) | 未移植模块须无人 require | | M(n) 关账(扁平产物) | `cold-audit-decls` | 深度 0 声明逐条点名:cited / override / named / UNKNOWN,`n/N examined` 自报(`gate-case-design.md` §3.1);手写移植形态里 mirror→port 那一段的唯一机器裁判 | `--overrides` 的 collapsed / omitted / ported 桶即登记(范围级 `match` 只收编译期常量) | | M(n+1) | `verify-symbols` **或** `verify-module-map` | 逐声明/逐模块存活,**按产物形状二选一**(平铺拼接 vs 模块容器;esbuild 惰性包装见 SKILL.md 的 verify-decls 范式) | 分类桶即登记 | | M(n+1) | `verify-fresh` | 盘上产物 = 生成器现在的产出(`gate-failure-modes.md` §1.9:没有 `--check` 的一步毁整条链的绿) | — | | M(n+1) | `verify-standalone` | src/ 拷出仓外真装真跑(契约在仓内不可测) | — | | 全程 | `verify-zerodep` | 门不 import 生产者(§2.1.2),scripts/ 零依赖 | — | | M(n-1)(C1) | `verify-flight` | flight 树逐节点深比较;规范化只收「证明不携带行为」的构建哈希命名空间;**模块 id 全局双射**(符号门在 C1 的同构物)。⭐ flight 比 D -
webgl-scenes.md 12.3 KB
# WebGL/GLSL 场景逆向指南 > **何时加载本文件**:侦察确认原站含 WebGL/WebGPU 渲染场景(Three.js、自研引擎、TSL 节点材质等),进入引擎逆向(M1)、场景移植或像素对拍验证阶段时加载。 ## 1. shader 定位与逐字提取 **原则:shader 一律逐字提取(verbatim),禁止任何"顺手优化"。** 像素级还原的前提是放弃"我能写得更好"的冲动【oryzo】。 ### 定位手段(按序尝试) 1. **grep 特征标记**:搜 `#define GLSLIFY 1` 定位 bundle 里全部内联 shader 字符串【oryzo】。 2. **搜值不搜名**:混淆 bundle 里 REVISION 等常量会被重命名——用值锚定:版本字符串、十进制颜色字面量、GLSL 特征串,都比标识符可靠【noomo】。 3. **bundle 内联 base64 资产也要提取**(光谱 LUT、SMAA area/search 纹理这类,缺了会整场变色)。复刻侧若反向内嵌 base64,需做字节级一致性验证【noomo】。 (实证:`case-studies/webgl-scenes.md` §1「定位手段(按序尝试)」) ### 提取纪律 - 集中存放 + 头注释声明来源与 "Do not edit by hand"【oryzo】。 - 连源站变量名照抄【noomo】。 - 死参数/错误赋值照抄——"修正它们反而会偏离源站的实际渲染结果"【oryzo】。 - 每个场景/pass 文件头注明源行号区间,逐 pass 一一列出【lando】。 (实证:`case-studies/webgl-scenes.md` §1「提取纪律」) ### 对拍与证同 - **ShaderChunk 展开对拍**:引擎(如 Three)会把 chunk 拼进最终 shader——从 bundle 提取的源 shader 文本,必须与重建运行时(含 ShaderChunk 展开后)对拍【rogier】。 - **离线 diff 证同后,差异排查聚焦编译参数/数据链**:先用 `node diff` 证明 shader 与源站逐字一致,此后像素差异就不必再怀疑 shader 文本本身。 (实证:`case-studies/webgl-scenes.md` §1「对拍与证同」) ## 2. 渲染管线审计方法 **顺序:先在逆向笔记里画完管线结构,再写任何材质代码。** 1. **列材质/pass 清单**:逆向笔记必须包含材质清单与后处理链逐步拆解【samsy】【oryzo】。 2. **按拓扑逐个 pass 移植,每加一个 pass 验收一轮**【oryzo】;多场景管线按源码结构重建【rogier】。 3. **复杂效果先拆成结构再移植**——"复刻时必须按此结构而非『打灯调像』"【samsy】。 4. **渲染器配置审计(含拒绝清单)**:静态 + 运行时审计渲染器状态,不只记录"要有什么",还记录"不许有什么"(源构造器没调的调用,复刻侧也不许重新引入)【rogier】。 5. **动画/材质参数逐字取证到行号**——全部从 bundle 行号抄录,不目测调参【samsy】。 6. **逆向阶段做证伪**:指纹会骗人。证伪结论写成"不要发明"清单【samsy】。 7. **TSL/WebGPU 注意项**: - 源站用 dev 分支版本(r182dev)时,取最接近的正式版并**登记为偏差**【samsy】; - 第三方库魔改的识别用量化手段:"数字字面量多重集 + 轴键结构对比",洗掉正则假阳性后收敛出唯一真实增量【samsy】。 8. **暴露数值探针句柄**: - 复刻侧留 `__probe` 门控的引擎句柄,断言层数与层序、uniform 值、RT 尺寸精确值、相机位姿小数点后三位全等【noomo】; - 更进一步把源码语义编码成 mode 字符串,探针持同一组常量逐一比对(激活顺序数组逐项断言)——"实现遵循了哪条源码语义"成为可自动回归的断言【rogier】; - 具体到对象级数值:聚光灯探针断言贴图归属、位置/目标/强度、投影采样亮度【rogier】。 (1–8 各条的实证:`case-studies/webgl-scenes.md` §2) 管线审计 checklist: - [ ] 材质清单逐项有落点,数量与源站清单对上【samsy】 - [ ] pass 链拓扑与源站一致,每个 pass 单独验收过一轮【oryzo】 - [ ] 渲染器状态审计通过,含"不许有什么"的拒绝清单【rogier】 - [ ] 数值探针句柄/mode 断言接入回归门,每次改动必跑【rogier】【noomo】 ## 3. WebGL 对拍的特殊性 WebGL 场景对拍与 DOM 字节门有本质不同:**GPU 渲染有容差、活场景有随机相位,逐字节比对不可行**。按下面的规则降级门型。 **门型速查表**: | 门型 | 指标与容差 | 适用 | 出处 | |---|---|---|---| | 行亮度剖面 | 按行采样灰度,差 ±4 灰阶为噪声级 | 静态构图整屏 | 【rogier】 | | 多滚动点平均亮度 | 14 个滚动点、平均亮度差 ±0.5 | 滚动叙事站 | 【oryzo】 | | 粗网格相似度 | 64×40 网格逐格色差 + 最差格目检归因 | 活场景(视频/glitch/粒子随机相位) | 【samsy】 | | 同帧检查点对拍 | 6 个滚动检查点、双侧泵到同一 t 截图 | 滚动驱动 + 源站不可插桩 | 【noomo】 | | readPixels 字节门 | WebGL canvas 直读像素、哈希相等 | 熵源可完全冻结的确定性场景 | 【kimi】 | | 颜色时间轨迹 | 每 120ms 采样计算色、RGB delta ≤6 | 换页/过渡等时间过程 | 【rogier】 | ### 量化指标替代逐像素 - 静态构图:行亮度剖面(整屏按行采样灰度,x 取 20%–80% 区间,验收 ±4 灰阶噪声级)+ 逐 band delta 用数字关账【rogier】。 - 滚动叙事站:多滚动点对拍(同视口、按 section 对齐的滚动点,PIL 上下拼接逐组核对,平均亮度差 ±0.5 为验收)【oryzo】。 - **活场景(视频帧相位 / glitch 文字 / 粒子随机相位):刻意用粗网格量化而非逐像素 diff**——64×40 网格逐格色差出相似度指标,**最差格逐一目检归因**,产物入库(截图 + 并排图 + metric.json)【samsy】。 - 换页等时间过程:颜色时间轨迹逐点对拍(每 120ms 采样计算色,per-sample RGB delta ≤6)【rogier】。 (实证:`case-studies/webgl-scenes.md` §3「量化指标替代逐像素」) ### 双侧同参数、同状态、同帧 - 镜像与复刻用同一脚本、同参数无头启动,驱动到同一状态再截图【samsy】【noomo】。**按状态量(`spreadT`/`progress`/`t`)对齐抓帧前,先量"单次截图耗时 / 被测运动全长",≥1/10 就先修快门再谈相位**(`environment-traps.md` §7)【shopifydesign】。 - 驱动方式要抗噪:文本被 glitch 轮换时文本匹配不可用,改按索引点击【samsy】。(实证:`case-studies/webgl-scenes.md` §3「双侧同参数、同状态、同帧」) - 滚动驱动 + 源站不可插桩 → probe-shim 路线【noomo】:约 90 行脚本在 `?__probe` 时接管 rAF/timer/visibility,手动泵 `__pump(dt, frames)` 把双侧驱动到同一 t;时间戳从 0 起,使 `Tick.seconds` 驱动的 shader 相位可对齐;**双侧同位注入**(镜像侧由静态服按 query 注入 `<head>` 首部、复刻侧用 Nitro `render:html` 钩子 unshift)——"gsap 在模块求值期捕获 rAF,注入太晚就失效,必须 head 首脚本"【noomo】。 - 驱动细节【noomo】: - `__drive` 真时钟配速泵 + MessageChannel yield(不受节流的宏任务边界,让 await 链推进); - `experienceStarted` 需 isTrusted 真实点击触发; - `smoother.scrollTo(y,false)` 反复钉扎消动量残留。 - 截图前**资产预检**:先确认镜像服务能出图再对拍,否则截图会误导归因【rogier】。 ### 渲染确定性与读回防呆 - headless 用 SwiftShader(`--use-gl=swiftshader`)保证可复现渲染【rogier】——**仅当站点没有 GPU 分级时才是无脑可用的**;必带 anti-throttling 旗标(`--disable-background-timer-throttling --disable-renderer-backgrounding`)【samsy】。 > **⚠ 交叉警告**【shopifydesign】:`--use-gl=swiftshader` / `--disable-gpu` 属于 `determinism.md` §2.9 的**能力探测熵源**,会静默切换被测程序的分支——站点的 GPU 名黑名单正则里常常**就含 `swiftshader`**,而画质档被插值进 shader 源码:**加了这条 flag,你对拍的就是 low 档 shader,而其余门跑的是 high 档**(两侧一致所以不红)。另有一层代价:软件渲染下单次 `captureScreenshot` 要 1–2s,按状态量对齐抓帧会被采样偏差污染(`environment-traps.md` §7)。(实证:`case-studies/webgl-scenes.md` §3「渲染确定性与读回防呆」) > **决策与配套动作**(先 grep 应用区间有无 GPU 分级/能力探测,再决定用不用;用了就必须钉死画质档、断言实测档位、旗标与档位同行登记进偏差表):见 `determinism.md` §5 的判定表。 - `readRenderTargetPixels` 读回前必查 `gl.getError`——全零缓冲是读回假象不是黑屏【noomo】。 - **无 `preserveDrawingBuffer` 的 WebGL canvas 不能用 `drawImage` 读**【kimi】;改用 `readPixels` 直读做字节门【kimi】。 - seek 后必须重新驱帧再截图【noomo】。 ### 覆盖面与噪声归类 - 隔离 DOM 噪声:canvas-only 对比(隐藏 DOM 只比 WebGL 输出)【rogier】。 - **检查点必须覆盖滚动两端**——终检必须包含两端【noomo】。(实证:`case-studies/webgl-scenes.md` §3「覆盖面与噪声归类」) - **显式归类噪声源再找真 bug**:对拍报告里把噪声显式归类(虚拟滚动缓动相位造成的构图偏移、动画相位不同的色温差、headless 字体缺失的换行差)——正因为有这个归类,才能在"噪声"里捞出真差异【oryzo】【samsy】。 ## 4. sRGB/色彩管理:headless 盲区必须真机兜底 **headless 对拍存在结构性盲区,自动门全绿 ≠ 收工。** 两类已实证的盲区: - **色彩管理**:纹理缺 sRGB→linear 解码会整场景偏亮发灰——headless 多轮对拍都归入"噪声",**最后一轮真机对比才捞出**【oryzo】。 - **授权字体**:headless 下 Adobe Fonts 等授权字体不加载,换行/排版差异全是假象【oryzo】。 操作要求: - [ ] 收官前至少一轮**真机浏览器对比**【oryzo】。 - [ ] 建议做**真机三方对拍**:线上 / 本地镜像 / 复刻三方并排,截图入库留证(命名区分 mirror-*/rebuild-*/dist-*,同机位对拍)【lando】。 - [ ] 剩余细微差异登记为已知残留,不假装 100%【lando】。 (实证:`case-studies/webgl-scenes.md` §4) ## 5. 常见坑 1. **后台标签 rAF 节流 + gsap lagSmoothing 伪装成站点假死**——三个项目独立踩过:(实证:`case-studies/webgl-scenes.md` §5) 结论:判定时序 bug 前先校准探针;无头脚本必带 anti-throttling 旗标【samsy】。 2. **探针环境本身骗人**:vite HMR `?t=` 查询造出幽灵模块让探针读到假状态;探针时钟与页面时钟错位伪装成"计时器时间压缩"【samsy】。探针超时要与产品差异区分("probe timing, not a product mismatch",真 GPU tier 3 机器需要拉长 PROBE_WAIT)【rogier】。 3. **全局句柄会被引擎自己覆盖**:samsy 的 `window.camera` 被 ReflectorNode clone 覆盖成镜像相机——探针读到的不一定是主相机【samsy】。 4. **部署拓扑差异触发本地永不出现的竞态**【samsy】: - 真实网络延迟会暴露构造期纹理竞态——部署本身就是一道验证; - 定位手段:CDP Fetch 给单文件加延迟做**二分定位**,收敛到两张纹理; - 根因归为"单源 vs CDN 分域"的环境差而非代码;修复分"保真修正"与"登记偏差"两笔分开处理。 5. **CDP 工具坑**:调用必须带超时、单次多兆字节 `Runtime.evaluate` 会卡死管道要分块、headless Chrome 无视 SIGTERM 要 SIGKILL【kimi】。 6. **修"明显的 bug"反而崩溃**:(两例实证:`case-studies/webgl-scenes.md` §5) "压缩代码里的每个怪写法都可能是行为本身"——WebGL 死代码/怪写法照抄不修,登记为怪癖【rogier】【lando】。 7. **GLSL 版本默认值是隐形编译参数**:shader 文本逐字相同仍可能全军覆没(GLSL1 vs GLSL3),报错形态是"空场/NaN"而非醒目的编译日志【noomo】。 8. **NaN 类初始化 bug 只在冷启动暴露**:手动切换效果会掩盖它——每轮验收必须全新加载【oryzo】。(实证:`case-studies/webgl-scenes.md` §5) 9. **像素差异不许调参糊平**:必须追到取证级根因(GLSL 版本默认值、utility 族缺失),或用基准数据证明参数链无罪后登记为已知差异【noomo】。
-
-
scripts
-
lib
-
cdp.mjs 5.6 KB · in bundle
-
chrome.mjs 25.4 KB · in bundle
-
cli.mjs 5.7 KB · in bundle
-
extract-refs.mjs 34.7 KB · in bundle
-
flight.mjs 5.9 KB · in bundle
-
hash.mjs 1.4 KB · in bundle
-
ledger.mjs 7.6 KB · in bundle
-
negotiate.mjs 7.9 KB · in bundle
-
png.mjs 7.9 KB · in bundle
-
ports.mjs 20.3 KB · in bundle
-
shell-build.mjs 12.6 KB · in bundle
-
tokens.mjs 2 KB · in bundle
-
urlpath.mjs 17.7 KB · in bundle
-
version.mjs 367 B · in bundle
-
-
beautify-bundle.mjs 11.8 KB · in bundle
-
build-site.mjs 6.9 KB · in bundle
-
census-bundles.mjs 5.9 KB · in bundle
-
closure.mjs 5 KB · in bundle
-
cold-audit-decls.mjs 16.2 KB · in bundle
-
cold-audit-modules.mjs 16 KB · in bundle
-
crossside.config.example.mjs 3.2 KB · in bundle
-
dump-timelines.mjs 4.2 KB · in bundle
-
emit-webpack-chunk.mjs 7.1 KB · in bundle
-
extract-source.mjs 15.6 KB · in bundle
-
fingerprint.mjs 20.5 KB · in bundle
-
flight-decode.mjs 12.5 KB · in bundle
-
frame-census.mjs 2.5 KB · in bundle
-
gapfill-video.mjs 14.3 KB · in bundle
-
harvest-cases.mjs 7 KB · in bundle
-
harvest.config.example.mjs 6.2 KB · in bundle
-
mirror-site.mjs 29.8 KB · in bundle
-
module-map.mjs 36.4 KB · in bundle
-
netcapture.mjs 25.1 KB · in bundle
-
pixel-walk.mjs 17.2 KB · in bundle
-
pixelcompare.mjs 41.6 KB · in bundle
-
probe-shim.js 12.3 KB
// probe-shim.js — deterministic driver shim for A/B same-frame comparison. // Adapted from storytellingnoomo-rebuild/scripts/probe-shim.js. // Lineage: storytellingnoomo-rebuild (rAF pump, timer queue, visibility pin) // -> shopifydesign-rebuild (froze the rest of the entropy surface: // performance.now, Date.now, new Date, setInterval, seeded Math.random). // // Usage: inject into <head> of HTML responses at the SERVING layer (serve.mjs // does this for requests carrying ?__probe) on BOTH the mirror and the rebuild, // then from a CDP probe call window.__pump(dt, frames) to advance both sides by // identical dt sequences and screenshot the same frame. Directly reusable for // any scroll- or time-driven animation site, including sites whose source // bundle is minified and cannot be instrumented from inside. // // Verification instrumentation only: when the page is opened with ?__probe, // replace requestAnimationFrame with a manually pumped queue and pin the // visibility API to "visible/focused", so BOTH the mirror (source bundle) and // the rebuild can be driven deterministically in a background tab. Timestamps // start at 0 so time-driven shader phases line up across tabs pumped with // identical dt sequences. Not part of source behavior; injected at the serving // layer (mirror) / a pre plugin (rebuild). // // EVERY clock and entropy source must be taken over, not just rAF. Freezing rAF // and setTimeout while performance.now(), Date.now(), new Date(), setInterval // and Math.random() keep running live does not make the page deterministic — // it only hides which parts are still free-running. Field case: transitions // interpolating on (performance.now() - start), a track picked by // Math.floor(Date.now() / 18e4 % n), scatter positions from Math.random(), and // a countdown on setInterval left two consecutive dumps of the SAME mirror // disagreeing on 7 numeric fields. With all of them pinned, __pump's time is // the only clock in the page and A/B comparison is frame-exact. // // 中文规格(自 scripts/README.md 迁入,v0.3.21;本表另一拼写:`probe-shim.js`) // 确定性驱动 shim(接管整个熵面:rAF/timer/`performance.now`/`Date.now`/定种 `Math.random`/**IntersectionObserver**,手动泵到任意 t,双侧同位注入)。⭐ **IO 也是一个时钟**——浏览器按自己的节奏投递交叉记录,滚动揭示站因此无法冻结; // 确定性驱动 shim:接管**整个熵面**——rAF / setTimeout / setInterval / visibility / `performance.now` / `Date.now` / `new Date` / `Math.random`(定种 mulberry32,可 `__reseed(n)`),`__pump(dt,frames)` 手动泵帧后这些时钟全部与帧时间锁步,双侧同位注入(serve.mjs `?__probe` 自动注入)。只冻 rAF 不够:漏掉的时钟会让同一镜像两次采样差出数值(实测 7 个字段) // 由 serve.mjs 注入;探针侧调 `window.__pump(16.7, 60)` (function () { if (typeof location === "undefined" || !location.search.includes("__probe")) return; // --- clock + entropy freeze (must be installed before any page script) --- var EPOCH = 1767225600000; // 2026-01-01T00:00:00Z, fixed so Date.now() is stable var __t = 0; // advanced by __pump; the single source of time var nativePerfNow = window.performance && performance.now ? performance.now.bind(performance) : function () { return 0; }; window.__nativePerfNow = nativePerfNow; // escape hatch for the harness itself try { performance.now = function () { return __t; }; } catch (e) {} var NativeDate = Date; try { Date.now = function () { return EPOCH + __t; }; // new Date() with no args must agree with Date.now(); every other form is // passed straight through so date math and parsing keep working. var PinnedDate = function (a, b, c, d, e2, f, g) { if (!(this instanceof PinnedDate)) return new NativeDate(EPOCH + __t).toString(); switch (arguments.length) { case 0: return new NativeDate(EPOCH + __t); case 1: return new NativeDate(a); case 2: return new NativeDate(a, b); case 3: return new NativeDate(a, b, c); case 4: return new NativeDate(a, b, c, d); case 5: return new NativeDate(a, b, c, d, e2); case 6: return new NativeDate(a, b, c, d, e2, f); default: return new NativeDate(a, b, c, d, e2, f, g); } }; PinnedDate.prototype = NativeDate.prototype; PinnedDate.now = function () { return EPOCH + __t; }; PinnedDate.parse = NativeDate.parse; PinnedDate.UTC = NativeDate.UTC; window.Date = PinnedDate; } catch (e) {} // mulberry32 — same seed, same sequence, on both sides. Reseed per state with // window.__reseed(n) when a walk needs each step to start from a known point. var __seed = 0x9e3779b9; window.__reseed = function (s) { __seed = (s >>> 0) || 0x9e3779b9; }; try { Math.random = function () { __seed = (__seed + 0x6d2b79f5) >>> 0; var t = __seed; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } catch (e) {} try { Object.defineProperty(Document.prototype, "hidden", { get: () => false, configurable: true }); Object.defineProperty(Document.prototype, "visibilityState", { get: () => "visible", configurable: true, }); } catch (e) {} document.hasFocus = () => true; var queue = []; var nextId = 1; var now = 0; window.__rafQueue = queue; // Background tabs throttle setTimeout to ~1/min; route timers through a // pump-driven queue keyed to the real clock so engine sleeps fire promptly. var timers = []; var timerId = 1000000; var intervals = []; var nativeSetTimeout = window.setTimeout.bind(window); var nativeClearTimeout = window.clearTimeout.bind(window); window.__nativeSetTimeout = nativeSetTimeout; window.setTimeout = function (cb, delay) { if (typeof cb !== "function") return nativeSetTimeout(cb, delay); var args = Array.prototype.slice.call(arguments, 2); var id = timerId++; timers.push({ id: id, cb: cb, due: __t + (delay || 0), args: args }); return id; }; // setInterval must ride the same clock, otherwise it fires on the real clock // while everything else is frozen (countdowns and pollers use one). var nativeSetInterval = window.setInterval.bind(window); var nativeClearInterval = window.clearInterval.bind(window); window.setInterval = function (cb, delay) { if (typeof cb !== "function") return nativeSetInterval(cb, delay); var args = Array.prototype.slice.call(arguments, 2); var id = timerId++; intervals.push({ id: id, cb: cb, every: delay || 0, next: __t + (delay || 0), args: args }); return id; }; window.clearInterval = function (id) { for (var i = 0; i < intervals.length; i++) if (intervals[i].id === id) { intervals.splice(i, 1); return; } nativeClearInterval(id); }; window.clearTimeout = function (id) { for (var i = 0; i < timers.length; i++) if (timers[i].id === id) { timers.splice(i, 1); return; } nativeClearTimeout(id); }; var runDueTimers = function () { for (var i = 0; i < timers.length; i++) { if (timers[i].due <= __t) { var t = timers.splice(i, 1)[0]; i--; try { t.cb.apply(null, t.args); } catch (e) { console.error("[__pump timer]", e); } } } for (var j = 0; j < intervals.length; j++) { var iv = intervals[j]; // Bounded catch-up: a large dt must not spin an interval thousands of // times; cap at 64 firings per pumped frame and resync. var fired = 0; while (iv.next <= __t && fired < 64) { iv.next += iv.every || 1; fired++; try { iv.cb.apply(null, iv.args); } catch (e) { console.error("[__pump interval]", e); } } if (iv.next <= __t) iv.next = __t + (iv.every || 1); } }; window.requestAnimationFrame = function (cb) { var id = nextId++; queue.push({ id: id, cb: cb }); return id; }; window.cancelAnimationFrame = function (id) { for (var i = 0; i < queue.length; i++) if (queue[i].id === id) { queue.splice(i, 1); return; } }; // --- IntersectionObserver ------------------------------------------------- // ⛔ IO is a CLOCK THIS SHIM DOES NOT OWN, and on a scroll-reveal site it is // the one that matters. The browser delivers intersection records on its own // schedule, off the main thread's frame loop, so two captures of the same // frozen page can start their entrance animations at different pump counts — // and the residual that produces MOVES between runs, which is exactly what // makes it unclassifiable. // // Measured on a CSS/IO-driven target: the same side compared with itself // drifted 0.2–0.31 meanAbsDiff and no amount of settling converged it. // // ⭐ So take it over: record every observer, and deliver its records ON THE // PUMP, synchronously, in registration order. Both sides then see the same // callbacks at the same virtual frame. // // ⚠ This changes WHEN callbacks fire, not WHETHER they do — and it is // verification instrumentation, active only under ?__probe. A page that never // pumps still gets its records, because the first pump delivers the backlog. var NativeIO = window.IntersectionObserver; var observers = []; if (NativeIO) { window.IntersectionObserver = function (cb, opts) { var targets = []; var self = this; var rec = { cb: cb, opts: opts || {}, targets: targets, seen: new Map() }; observers.push(rec); this.observe = function (el) { if (targets.indexOf(el) < 0) targets.push(el); }; this.unobserve = function (el) { var i = targets.indexOf(el); if (i >= 0) targets.splice(i, 1); }; this.disconnect = function () { targets.length = 0; }; this.takeRecords = function () { return []; }; this.root = (opts && opts.root) || null; this.rootMargin = (opts && opts.rootMargin) || "0px 0px 0px 0px"; this.thresholds = [].concat((opts && opts.threshold) || 0); void self; }; window.IntersectionObserver.prototype = {}; } // Compute intersection against the viewport the way the real IO would, and // deliver only on CHANGE — an observer that fires every frame is a different // observer, and would keep re-triggering one-shot reveals. function deliverIntersections() { for (var o = 0; o < observers.length; o++) { var rec = observers[o]; var entries = []; for (var t = 0; t < rec.targets.length; t++) { var el = rec.targets[t]; var r; try { r = el.getBoundingClientRect(); } catch (e) { continue; } var vh = window.innerHeight, vw = window.innerWidth; var iw = Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0)); var ih = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)); var area = r.width * r.height; var ratio = area > 0 ? (iw * ih) / area : 0; var isIn = ratio > 0; var prev = rec.seen.get(el); if (prev !== undefined && prev === isIn) continue; rec.seen.set(el, isIn); entries.push({ target: el, isIntersecting: isIn, intersectionRatio: ratio, boundingClientRect: r, intersectionRect: r, rootBounds: { top: 0, left: 0, right: vw, bottom: vh, width: vw, height: vh }, time: now, }); } if (entries.length) { try { rec.cb(entries, { takeRecords: function () { return []; } }); } catch (e) { console.error("[__pump/io]", e); } } } } window.__pump = function (dt, frames) { dt = dt || 16.7; frames = frames || 1; for (var f = 0; f < frames; f++) { now += dt; __t = now; // keep the frozen clocks in lockstep with the rAF timestamp runDueTimers(); // ⚠ Before the frame's callbacks, so a reveal triggered this frame is // animating within it — the order the browser would produce. deliverIntersections(); var batch = queue.splice(0, queue.length); for (var i = 0; i < batch.length; i++) { try { batch[i].cb(now); } catch (e) { console.error("[__pump]", e); } } } return now; }; window.__pumpTime = function () { return now; }; })(); -
probe.mjs 20.6 KB · in bundle
-
README.md 29.4 KB
# scripts/ — 零依赖工具脚本 全部为零依赖 Node 脚本(Node 22+ 内置 fetch / WebSocket 直连 CDP,不装任何 npm 包;个别工序 spawn 钉死版本的 npx,但从不 import)——六项目一致的工具哲学:"避免工具链自身版本漂移污染比对"。站点相关常量已提升为 CLI 参数或文件顶部 CONFIG 块(各文件头部有用法示例与传承注释)。 ## ⚠ 端口与实例身份(`lib/ports.mjs`,凡起服务/起浏览器的脚本都受此约束) **串台既造假红也造假绿**,而假绿是不可见的:两个进程连到同一个浏览器/服务时,你会拿到一份完美的双侧对拍报告,而它测的是同一侧。旧版每个脚本各自 `9222 + random*500` / `CDP_PORT || 9333` / `PORT || 5175`,区间重叠且默认值全局固定(实战事故见 shopifydesign §8.30:前台探针连上后台自比脚本的浏览器,报回"复刻侧有 19 次到镜像端口的外联")。现在统一为: 端口 = 21000 + slot×1000 + lane×10 + side # 21000..29999 - **slot(0..8)= 一个工作区**:由 git 根路径哈希得到,同机多项目并发默认不撞;`WRS_PORT_SLOT` 可显式指定(同一项目要并发跑两份同名脚本时也用它)。 - **lane(0..99)= 一个脚本角色**:编号写死在 `lib/ports.mjs` 的 `LANES` 里,**当 ABI 对待**;10–49 已为项目自带的 CDP 门(scroll/audio/scene-graph…)预留,50–99 留给项目自定义。 - **side(0..9)= 对拍的哪一侧**:`1=mirror 2=rebuild 3=live 0=不分侧`。所以**端口自己说明自己是谁**:`25001` 是镜像服务、`25002` 是复刻服务、`25012` 是探针在探复刻侧。 - **占用即响亮失败**(退 3,并打印占用方是谁:CDP 端点/serve.mjs 的 side+root+pid+token/普通 HTTP)。**绝不静默换端口**——换了端口的进程,伙伴脚本就会去跟留在原地的东西说话,这正是假绿的成因。 - **显式覆盖照常支持**(`--port` / `--cdp-port` / `PORT` / `CDP_PORT`),覆盖值一样走占用预检与身份校验,日志里标 `[EXPLICIT]`。 - **最后一道闸是身份校验,不是端口**:CDP 脚本用随机 sentinel 页启动浏览器,attach 时只认自己那一页,认不出立刻退 3 并打印实际看到的 target 列表;`serve.mjs` 每个响应带 `x-wrs-identity` token 并提供 `GET /__wrs/identity`,`pixelcompare.mjs` 据此断言 A/B 确实是两个进程(同 origin、或两个 URL 同一 token,都判死)。 node scripts/lib/ports.mjs # 打印本工作区的完整端口表 node scripts/lib/ports.mjs 25012 # 反解某个端口是谁 ## ⚠ 浏览器进程与 CDP 载荷(`lib/chrome.mjs`,凡起无头 Chrome 的脚本都受此约束) **漏一个渲染进程 = 把像素门调松了。** 旧版收尾一律 `chrome.kill('SIGKILL')`——那只杀浏览器主进程,它已经 fork 的 6–8 个 renderer/GPU/network 子进程不在信号范围内,父进程一死就被 reparent 到 pid 1 继续跑(实测:129 个存活 Chrome / 约 16 个泄漏 profile,最老 2 天 1 小时,"什么都没在跑"而 load average 8.7)。这不是整洁问题:像素门的容差不是手挑的 epsilon,而是**参照侧自己跟自己跑 N 次**得到的自比带宽,背景负载让这 N 次彼此更不一致 → 带宽变宽 → `cross ≤ selfBand + k` 静默原谅真实的跨侧残差。**一个进程泄漏 bug 会让整道像素门变松。**(带宽还有一条"测量中途不许改仪器"的规矩,所以持续增长的泄漏不止是抬高带宽,而是让 N 次会话不可比。) 现在统一为 `lib/chrome.mjs`: - **收进程组,不收进程**:`spawn(..., { detached: true })` 让 Chrome 成为进程组长,子进程继承同组,收尾 `process.kill(-pid, …)` 一次带走全部;**先 SIGTERM 后 SIGKILL**(留出关 profile 的时间)。 - **覆盖全部退出路径**:`exit` / `SIGINT` / `SIGTERM` / `SIGHUP` / `uncaughtException` / `unhandledRejection` 都收割。正常收尾是这几条里**最不重要**的一条——现场泄漏全部来自另外几条。`exit` 处的收割必须同步(用 `Atomics.wait` 而非 Promise)。 - **临时 user-data-dir 即身份**:`<tmp>/wrs-chrome-s<slot>-<role>-p<port>-XXXXXX`,收尾删除。这个名字才让"这 129 个 Chrome 里哪些是我的"成为可判定问题,也把清扫范围限死在本工具链自己起的实例上(永远碰不到你自己的浏览器)。 - **启动前自检**:每个脚本先扫本工作区同角色的**孤儿**实例(`ppid == 1`,即启动它的脚本已死),**响亮报出**(pid / 存活时长 / profile 路径 + 上面那条因果)再回收,然后才做端口预检——顺序反了的话,自己上一轮留下的残骸会变成一句要手工清理的"端口被占"。**判据是孤儿而不是同名**:活着的兄弟进程有活着的父进程,一律不碰,那种情况归 `lib/ports.mjs` 的端口闸响亮裁决。 <!-- --> node scripts/lib/chrome.mjs # 列出本工作区的实例(ORPHAN 会标出来) node scripts/lib/chrome.mjs --all # 本机所有工作区 node scripts/lib/chrome.mjs --reap # 回收列出的孤儿(连同其进程组) **截图有传输层硬顶,且旧版表现为无声超时。** `Page.captureScreenshot` 把整帧作为**一条** base64 WebSocket 消息回传,而 Node 内置 WebSocket 会在消息过大时直接 `close 1006`——此后每条 CDP 调用都超时且没有自己的错误信息。实测(objectandarchive D-G6,同机同 Chrome):`1280×800 png` = 2,395,616 字符可用(280ms);`390×844 png` = 734,240 可用;`1728×1080 jpeg q100` = 1,995,384 可用(106ms)、`q92` = 827,968 可用(**58ms**);**`1728×1080 png ≈ 3.6M` → 直接 1006**。可用上界在 2.40M–2.72M 之间。换一台机器(Chrome 150 / Node 22)复测:入站 3.33M 可用、出站 4.37M 可用,而 1728×1080 的**噪声** PNG(≈7M)照样死——**上界随机器/版本浮动,2.4M 是可以依赖的线,不是断裂点**。处置三条: - **失败必须响亮**:所有 CDP 客户端都装 `onclose`(拒绝在飞的调用)**加**逐调用超时,截图失败打印"载荷超限:`<size>`,视口 `<w×h>` 格式 `<fmt>`"+ 可操作的降级清单,退 4。**无声超时是最坏的失败形态**——它不告诉你任何事。 - **可行的规避**:`probe.mjs` / `pixelcompare.mjs` 新增 `--format png|jpeg --quality N`(默认仍 PNG 以保字节保真;`probe.mjs` 的 `--shot x.jpg` 会按扩展名自动切 jpeg)。**什么时候必须降**:视口 ≳ 1500×900 且内容是照片/噪声类时 PNG 到不了岸,改 `--format jpeg --quality 92`(58ms/张)。字节门保持 PNG;像素/指标门用 q92 已实测无编码噪声(同一静止态连拍两帧逐字节相同)。 - **注意二次放大**:`pixelcompare.mjs` 的指标与合成步骤把**两帧**内联进一条 `Runtime.evaluate` 再取回结果,所以那条消息约为单帧的 2 倍——两张截图都过了却死在指标步是正常的,这两步失败时会点名是哪一步、内联了多少字符。 ## ⚠ 镜像有自己的门(`verify-mirror.mjs`) 下游每一道门问的都是**"渲染得出来吗"**——零 404、零控制台错误、零外联、像素差多少。没有一道问**"字节对不对"**。所以一个错的镜像可以让所有门全绿:查询参数化的图片 CDN 上 `x.jpg?width=320/600/1200` 是三份不同字节,按 pathname 映射会把它们坍缩成一个文件(谁最后写谁赢),服务端每个 `?width=` 都回同一个文件,srcset 给 1200px 的槽选了 32px 的图,**页面照样渲染**。抓包那一遍按 url+search 记账、按 pathname 查盘,于是从第二个变体起全报 HAVE——GAP=0,假的。 结论写成纪律:**镜像层的缺陷只能在镜像层抓**。抓完镜像先跑 `verify-mirror.mjs`(映射单射性 / 账本一致性 / **真实性** / 闭包 / 可选抽样回源),它绿了,下游的门才有意义。四方共用 `lib/urlpath.mjs`(映射)与 `lib/extract-refs.mjs`(引用提取 + "什么算文本")也是同一条纪律的结构形式:**门不能自带一份被审对象的实现**,否则它继承的正是它要抓的盲区。 **再往下一层同样成立:门的输入本身可能是错的,而门会在错的输入上正确地报绿。** 闭包门实测**三次**假绿都不在判据上——① 引用集少了一整类**转义拼写**的引用(`https:\/\/host\/…`),差集在一个短了 60 条的集合上算出"= ∅";② **豁免的匹配粒度**过宽,一条基址豁免按前缀吞掉了整个子树;③ **"什么算文本文件"是一张扩展名白名单**,`.atom` / `.xml` / `.rss` / `.txt` 全在名单外,**爬虫与门共用同一个盲区**所以谁也看不出来(实测 16 份 `.atom`,引用集 3,109 → 3,521)。三条都修在这一层(详见下表两行),教训写成一句话:**"= ∅"只说明这两个集合相减为空,它说不出这两个集合本身是不是齐的**——所以对着门看"它断言了什么"不够,还要问"它拿到的是什么"。 **再往下还有一层,而这一层与上面全部正交:一个 HTTP 200 不是"你拿到了那个资源"的证据。** 上面每一项——单射性、账本 sha256、覆盖度、闭包——校验的都是"**账本与磁盘是否自洽**",而它们可以在**每一份字节都是 bot 挑战页**的情况下诚实地全绿。实测(objectandarchive M0b):3 workers 的整站重抓触发源站挑战,**43 份挑战页被写在各自页面的 URL 下**,包括整个逆向工作所依据的那份文档;`verify-mirror` 全程 **PASS 0 而且没有错**——账本记的是"你抓到了什么",从不记"它是不是你要的那个"。当时唯一的反对者是**构建层的逐条变换命中下限**(挑战页里没有那个平台脚本,命中 4 < 下限 5)。所以现在有第三项断言(AUTHENTICITY):**挑战正文匹配**(硬红,可 `--interstitial-extra` 扩展)+ **声明类型对魔数字节**(硬红,判据的依据是**源站声明的 content-type**,不是 URL 扩展名)+ **同类体量离群**(只报线索,不判红)。 ## 命令行约定(`lib/cli.mjs`,v0.3.17 起全部脚本一致) 每个脚本第一件事是 `cli({ known, bools, file: import.meta.url })`:**`--help`/`-h`** 打印文件头注(用法一直住在那里)+ 旗标清单 + skill 版本,退 0;**`--version`** 打印 skill 版本(项目里的 `scripts/` 是拷贝,这个数字是判断它有没有落后的唯一依据);**未知旗标一律 FATAL 退 2** 并列出已知集(此前 57 个脚本里只有 9 个这么做,`--settle` 给了只认 `--wait` 的工具、静默跑在 6 秒默认值上买过三小时追凶,`verification-gates.md` §2.1.3)。它只校验 argv 的形状,各脚本自己的 `flag()` 读法一个字不改。selftest 对每个脚本扫 `--help` 退 0 与未知旗标退 2,并断言头注用法行里出现的每个旗标都在已知集里。 ## 退出码约定(`lib/cli.mjs` 的 `EXIT`) | 码 | 含义 | 谁在用 | |---|---|---| | 0 | 门绿 / 任务完成 | 全部 | | 1 | 门红:被测对象不对(或工具读不到自己的账本) | verify-*、make-standalone、netcapture `--fetch` | | 2 | 调用错误:缺参 / 参数无效 / 未知旗标 / 配置坏 | 全部(`lib/cli.mjs` + 各脚本 usage) | | 3 | 身份:端口被占、side 不符、attach 到别人的浏览器 | `lib/ports.mjs` 家族 | | 4 | CDP 传输死了(载荷硬顶、close 1006、超时) | probe / pixelcompare | | 5 | 前置条件不成立:认不出容器、一个都没查到、空帧 | module-map / name-modules / cold-audit / pixelcompare 空帧 | | 6 | 页面没到达要求的状态(`--ready` / `--hold`) | pixelcompare / pixel-walk | | 130 | Ctrl-C,账本已落盘 | mirror-site | ⚠ 5 在 pixelcompare 里是"空帧"、在 module-map 里是"认不出容器"——同为"前置条件不成立",读退出码时按表里的含义读,不要按脚本名猜。新脚本从 `EXIT` 取常量,不要再写裸数字。 ## 脚本索引 一行一个脚本。**这张表回答「选哪个」;「怎么跑」由脚本自己回答**——每个脚本的完整规格(旗标、断言、语义)住在它的文件头注里,`node scripts/<x>.mjs --help` 原样打印;**为什么这么设计**的实证见 [references/case-studies/scripts.md](../references/case-studies/scripts.md)。三处各归一处:表里不再复述头注,头注不再讲故事。 | 脚本 | 用途 | 阶段 | 出处 | 成熟度 | |---|---|---|---|---| | `scripts/fingerprint.mjs` | Step 0 六步探测协议的跨平台等价实现(无 curl/cmp 也能跑) | Step 0(无 POSIX 工具链时) | 新写(把 §2 手工协议脚本化,协议内容零发明) | 中(逐条对照 §2 实现 + 实站冒烟三路:byte-identical / 301 链 / <1KB Referer 重试;未经完整项目实战) | | `scripts/mirror-site.mjs` | BFS 爬虫镜像:资产白名单迭代到不动点,三本账逐文件 sha256 | M0 第一遍 | lando 版(rogier→noomo→lando→shopifydesign→objectandarchive 五代传承) | 高 | | `scripts/wayback-mirror.mjs` | X 类抢救:把死站从 Wayback 抢成标准镜像 | M0(X 类死站抢救) | darknetflix/umamiland 版(v0.2.4) | 高(两个死站实跑:312+123 文件、0 抓取失败、洞账如实) | | `scripts/netcapture.mjs` | 真实浏览器 CDP 抓包,对账补录运行时资源(CDN 站必传 --hosts) | M0 第二遍 | kimi 版(+shopifydesign host 白名单,+objectandarchive 共享映射) | 高 | | `scripts/verify-mirror.mjs` | 镜像自己的门:单射 / 账本 / 真实性 / 闭包,跑在一切下游门之前 | M0 关账前,每次重抓镜像后 | 新写(objectandarchive M0 的五条镜像层缺陷是它的需求书 + M(n) 的 D-T10) | 高(本仓 fixture 实跑:旧爬虫产出的真实坍缩被映射与账本两项逐条抓出;错误 `--query-ignore` 当场判死;豁免语义 fixture 修前把基址下两个真缺文件静默豁免、修后逐条报出,整 host 豁免与 `*` 显式前缀各自照常。**AUTHENTICITY 与文本判定 fixture**:挑战页 + 声明 `image/png` 而正文是 HTML 的文件各自逐条报红,声明 `font/woff2` 的 `.woff` **不误报**;360 KB 真页面里嵌 reCAPTCHA + PerimeterX **不误报**;同一份完整镜像上旧门扫 1 个文件报 "= ∅"、新门扫 3 个文件看见 5 条引用,**人为删掉两个真资产后旧门照样 PASS 0、新门逐条报红**) | | `scripts/gapfill-video.mjs` | HLS / DASH 流媒体阶梯补录 | M0(有流媒体时) | racingshop 版(通用化:递归下降 + 相对 URI 解析 + 备用轨道/fMP4 分支) | 高(racingshop 实战验证扁平阶梯;递归与备用轨道分支为通用化新增,已用 fixture + 原站数据回归) | | `scripts/reconcile-gaps.mjs` | 运行时缺口对账器:GAP 行 + 字节推导全集,逐条补进镜像 | M0(运行时资源多的站) | rauchg 版 | 高(rauchg 实战 1,600+ URL 零失败) | | `scripts/flight-decode.mjs` | C1 的坐标系:把每页内联的 flight 流解成可寻址的树 | M1(C1) | rauchg 版 | 高(19 文档全解;selftest 合成流夹具) | | `scripts/verify-flight.mjs` | C1 语义门:重构工程与镜像 flight 树逐语义比对 | M(n-1)(C1) | rauchg 版 | 高(18/18 路由收口;selftest 绿/红双面夹具) | | `scripts/serve.mjs` | 零依赖静态服务器:MIME / Range / 重定向回放 / ext 改写 / 桩主机,带实例身份 | M0.5 起全程 | noomo+lando 合并版(samsy→kimi→noomo→lando→racingshop→shopifydesign→objectandarchive;kimi 的 RSC 层需按项目自加) | 高 | | `scripts/probe.mjs` | CDP 无头探针:404 / 控制台错误 / 外联 / 截图,一页一报 | M0.5 起每 commit | lando 版(rogier 探针家族→samsy regression→lando→shopifydesign) | 高 | | `scripts/verify-routes.mjs` | 路由 / 重定向 / <head> 契约门,状态码也比 | M2+ | kimi 版 | 高(CONFIG 需按项目填写) | | `scripts/verify-ssr.mjs` | SSR 逐字节契约门:body DOM / 载荷 / 运行时配置对镜像 | M2+(有 SSR 产物时最先建) | noomo 版 | 高(提取器为 Nuxt 专用,换框架需替换) | | `scripts/pixelcompare.mjs` | 量化像素对拍:自比带宽 + 跨侧残差,非空帧与双进程前置 | M(n-1);`--freeze-css` 时 M(n-1)(CSS 驱动的站) | samsy 版为主 | 高(驱动到特定状态的逻辑属调用方) | | `scripts/side-by-side.mjs` | 双侧截图并排合成图(展示用,不是门) | M(n-1) | kimi 版 | 高 | | `scripts/probe-shim.js` | 确定性驱动 shim:冻 rAF / 时钟 / 随机,让两侧采到同一时刻 | M(n-1) | noomo 版(+shopifydesign 熵面补全) | 高 | | `scripts/dump-timelines.mjs` | GLB 动画曲线 dump 成 JSON 数值账本 | M1(数据驱动动画时) | noomo 版 | 中(GLB 专用,范式可泛化) | | `scripts/beautify-bundle.mjs` | 钉版本 js-beautify 展开 bundle 到 _pretty/,行号即溯源坐标 | M1 | 新写薄封装(oryzo 引入流程、samsy 钉版本、kimi/noomo/lando 统一 1.15.1) | 中(新写,未经项目实战) | | `scripts/extract-source.mjs` | 字节切片器:按 _pretty/ 行号区间逐字取出源 | M2+(逐字移植期) | shopifydesign 版(原脚本切片表硬编码,通用化为配置驱动) | 高(shopifydesign 实战:M2 33 段/2,475 行,M3 增至 41 段;配置化 + `--balance-check` 为通用化新增,已 fixture 验证切片/守卫/`--check`/边界错四路) | | `scripts/module-map.mjs` | 模块化 bundle 的分层表:认容器、列模块、连依赖边 | M1(模块化打包产物) | airpodspro 版 | 中 | | `scripts/closure.mjs` | 从种子模块算传递依赖闭包,竖切边界的唯一依据 | M2+(模块化打包产物) | airpodspro 版 | 中 | | `scripts/slice-modules.mjs` | 按模块 id 逐字切片,gen 头带完整再生成命令 | M2+(模块化打包产物) | raycastkbd 版 | 中 | | `scripts/harvest-cases.mjs` | 从源站活引擎采用例(基线的 A 侧) | M2+(源站引擎可达时) | airpodspro 版 | 中 | | `scripts/verify-harvest.mjs` | 采集基线的 B 侧:港口按行为复现源站的用例 | M2+(有采集基线时) | airpodspro 版 | 中 | | `scripts/verify-crossside.mjs` | 跨侧门:两侧同一输入,比输出 | M2+(源站有可直接调用的接缝时) | airpodspro 版 | 中 | | `scripts/verify-zerodep.mjs` | 依赖分界门:scripts/ 只许 node: 与相对导入,门不引生产者 | 每次新增脚本 | airpodspro 版 | 中 | | `scripts/build-site.mjs` | 策略 A 构建层:按变换表把镜像外壳变成复刻外壳 | M2+(策略 A) | racingshop 版(v0.1.17 无人值守闭环) | 高 | | `scripts/verify-shell.mjs` | 外壳字节门:每个差异 hunk 必须能由变换表重放 | M2+(策略 A) | racingshop 版(v0.1.17) | 高 | | `scripts/verify-offline.mjs` | 零外联门的静态一半:预连接 / 内联信标 / 回退路径 | M0.5 起每 commit | racingshop 版(v0.1.17) | 高 | | `scripts/verify-payload.mjs` | SSG payload 门:把内联数据当数据比,不当文本比 | M0.5 起(有 SSG payload 时) | noomo 版谱系(v0.1.19;v0.1.71 Nuxt 3 外置载荷;v0.1.73 `--allow-absent`) | 高 | | `scripts/verify-lenprefix.mjs` | 自带长度的载荷门:flight T 行声明多少字节就得有多少 | M0.5 起(有 flight 载荷时) | eightdesign 版(v0.1.61) | 高 | | `scripts/verify-refs-served.mjs` | 引用可达门:产出里每条资源引用逐条问服务器 | M2+ 起每 commit | eightdesign 版(v0.1.68;v0.1.72 `--allow`) | 高 | | `scripts/verify-standalone.mjs` | 自包含门:src/ 复制到任何地方断网可跑 | M(n+1) | eightdesign 版(v0.1.64) | 高 | | `scripts/verify-fresh.mjs` | 新鲜度门:dist 是否等于此刻从 src 重建的字节 | M(n+1)(有构建步骤时每次) | eightdesign 版(v0.1.64) | 中 | | `scripts/verify-symbols.mjs` | 符号映射门:port/ 每个顶层声明在 src/ 里恰有一个去处 | M(n+1) | airpodspro 版(v0.1.24) | 高 | | `scripts/verify-module-map.mjs` | M(n+1) 等价门:src/modules 每个文件与打包器字节 token 级一致 | M(n+1)(模块化打包产物) | airpodspro 版(v0.1.46) | 高 | | `scripts/cold-audit-modules.mjs` | M(n) 冷头清点:闭包里每个模块都被 port 覆盖,报 n/N examined | M(n)(模块化打包产物) | airpodspro 版(v0.1.46;v0.1.73 箭头工厂;v0.3.15 单参工厂) | 高 | | `scripts/cold-audit-decls.mjs` | M(n) 冷头点名:扁平 bundle 的顶层声明逐个归桶 | M(n)(扁平产物;手写移植形态的第一段裁判) | samsy 版(v0.3.14) | 高 | | `scripts/verify-tween.mjs` | 竖切的数值门:同一关键帧规格喂两个引擎,比写出的值 | M2+(有补间/时间轴引擎时) | airpodspro 版(v0.1.36) | 中(切片专用范式) | | `scripts/frame-census.mjs` | 这一帧上有东西吗:事后复核任意截图是不是空帧 | M(n-1) | racingshop 版(v0.1.21) | 高 | | `scripts/census-bundles.mjs` | 无容器产物的 chunk 级坐标账本 | M1(无容器产物) | hashgraphvc 版(v0.2.0) | 高(对原项目 33/33 sha 交叉一致) | | `scripts/slice-esm.mjs` | 拼接式分解切片器:parts 逐字节拼回 chunk | M2+(拼接式分解) | hashgraphvc 版(v0.2.0) | 高(33 chunk / 44.9 万行 → 2,043 件全数重拼一致) | | `scripts/verify-reassembly.mjs` | 重拼门:每个 part 的 sha256 与拼接后的 chunk 哈希都对得上 | M(n+1)(拼接式分解) | hashgraphvc 版(v0.2.0) | 高 | | `scripts/sweep-routes.mjs` | 渲染广度门:全路由一个浏览器跑完,逐路由记错误 / 失败 / 外联 | M0.5 起(全路由广度) | overworld/milknetwork 版(v0.2.3) | 高(20 路由含音频钩子 4.4 分钟全清;122 路由 7.5 分钟,正确复认已登记的 Vimeo 401) | | `scripts/pixel-walk.mjs` | 检查点巡航:N 个滚动位置上跑像素门,先测自比带宽 | M(n-1) | shopifydesign 版(v0.1.52) | 高 | | `scripts/lib/ports.mjs` | 端口分配 + 实例身份注册表(slot / lane / side) | 所有起服务 / 起浏览器的脚本依赖 | 新写(shopifydesign §8.30 串台事故的根治) | 高(本仓 fixture 实跑验证:并发不冲突 / 占用响亮失败 / 双侧各连各的) | | `scripts/lib/chrome.mjs` | 无头浏览器生命周期:进程组收割、孤儿回收、载荷硬顶 | 所有 CDP 脚本依赖 | 新写(objectandarchive Mn-1a 仪器教训 #5 + D-G6) | 高(实跑验证:正常收尾 / SIGINT / SIGTERM 后零残留;SIGKILL 制造 11 个孤儿后下一轮自检全数回收并清 profile;1728×1080 PNG 复现 close 1006 并响亮退 4;jpeg q92 全程跑通) | | `scripts/lib/urlpath.mjs` | 唯一的 url→本地路径映射(查询感知),爬虫与门共用 | mirror-site / netcapture / serve / verify-mirror 共用 | objectandarchive 版(D-T1) | 高(本仓 fixture 实跑:同路径不同 query 落到不同文件;排序无关;敏感字符不撞名) | | `scripts/lib/extract-refs.mjs` | 唯一的资产引用提取器 + 唯一的「什么算文本」判定 | 爬虫与 verify-mirror / verify-refs-served 共用 | objectandarchive 版(D-T2 + D-T10) | 高(本仓 fixture 实跑:5 候选 srcset + imagesrcset 全数提取,旧版同页只提到 1 条 `src=`;6 种转义拼写修前引用集 1、修后 8。**真镜像差分实跑**:objectandarchive 的 197 个文本文件上 1,587 → 1,767,**0 丢失**,新增里含该项目版权审计手工找出的那 2 个 woff2;对着该项目自己那版"补两条转义正则"的修法再差分,仍多出 **121 条**——JSON-LD 里 `"image":"https:\/\/host\/….jpg?v=…\u0026width=1920"` 这种**一条字符串里两种转义**,按 `\/` 写的形状会在 `\u0026` 处停下,于是引用不是丢失而是被**截断**成 `?v=…`,而那个 URL 在查询感知映射下是**另一个确实在盘上的文件**——门照绿) | | `scripts/lib/negotiate.mjs` | 内容协商 Accept 策略与 std→bare 请求头梯子 | mirror-site / reconcile-gaps / fingerprint 依赖 | basement D5(v0.3.9)+ v0.3.18 收拢 | 高(selftest 钉合同 + 回环 403/404/302 梯子) | | `scripts/lib/png.mjs` | 零依赖 PNG 编解码 + 图像统计 / 比对,恒输出 RGBA | 对拍脚本依赖 | kimi 版 | 高 | | `scripts/lib/cli.mjs` | 唯一的 argv 合同:--help / --version / 未知旗标 FATAL / 退出码表 | 全部脚本 | v0.3.17 新写(评审回哺) | 高(selftest 逐脚本扫) | | `scripts/lib/hash.mjs` | 唯一的 sha256 拼写 | 全部账本与门 | v0.3.18 收拢 | 高(selftest 往返) | | `scripts/lib/ledger.mjs` | 镜像三本账的唯一读写实现 | mirror-site / verify-mirror / make-standalone 共用 | v0.3.18 收拢(四个写入方 / 六个读取方归一) | 高(selftest 往返 + mirror-site 回环爬取) | | `scripts/lib/cdp.mjs` | 唯一的 CDP 客户端:有界调用,断连响亮 | 所有 CDP 脚本依赖 | v0.3.18 收拢(probe / pixelcompare / netcapture / sweep-routes / ports) | 高(真 Chrome 冒烟:probe / sweep / pixelcompare 跨侧与自比) | | `scripts/verify-tokens.mjs` | token 流等价门 | M2+(排版字节交付时每 commit) | v0.3.10 新写(14islands L2 收口回哺) | 高(selftest 绿/红双面) | | `scripts/verify-nextdata.mjs` | pages router 载荷门(__NEXT_DATA__) | M0.5 起(pages router 站) | v0.3.10 新写(14islands L2 收口回哺) | 高(selftest 绿/红双面) | | `scripts/emit-webpack-chunk.mjs` | 多 chunk webpack 站的逐字再发射 | M2+(webpack 多 chunk 站) | v0.3.10 新写(14islands L2 收口回哺) | 中 | | `scripts/lib/tokens.mjs` | token 流读法(verify-tokens / verify-module-map 共用) | beautify-bundle / verify-tokens 依赖 | v0.3.10 新写(14islands L2 收口回哺) | 高(selftest) | | `scripts/lib/flight.mjs` | flight 流的解析与寻址(flight-decode / verify-flight / verify-refs-served 共用) | C1 与 flight 载荷门依赖 | rauchg 版谱系 | 高(selftest 合成流夹具) | | `scripts/lib/shell-build.mjs` | 策略 A 的变换表执行器(build-site 与 verify-shell 共用同一份,门不自带实现) | M2+(策略 A) | racingshop 版(v0.1.17) | 高(selftest 绿/红双面) | | `scripts/lib/version.mjs` | 这份 scripts/ 拷贝自哪个 skill 版本;`--help` / `--version` 都打印它 | 全部脚本 | v0.3.17 新写 | 高(selftest 钉 SKILL.md frontmatter) | 范式行——本 skill 不提供实现,写在这里是因为它定义了一种门的形状: | 范式 | 形状 | 阶段 | 出处 | 成熟度 | |---|---|---|---|---| | `scripts/verify-decls.mjs`(范式,非本 skill 提供) | **esbuild 形态的分类门**:模块体裹在 `var X = VA(() => {…})` 惰性包装里、绑定以逗号链出现,双射式符号门在这里成片假红。正确形状是把每个 port 声明分类进 `declarations` / `collapsed` / `plumbing` / `omitted` 恰好一个桶,反向要求每个 src 声明有来源或登记理由。⭐ **门的形状要跟着产物的形状走**(`readable-source.md` §3.0.5) | M(n)/M(n+1)(esbuild 产物) | — | — | ## TODO(未打包的缺口,需要时去源项目手工移植) - **extract-i18n.mjs**(括号配平 + 隔离 vm 求值抽取 bundle 内数据成 JSON,键集交叉校验)——抽取式移植范式,但解析逻辑绑定具体 bundle 结构。移植自 `careers-kimi-rebuild/scripts/extract-i18n.mjs`。 - **regression.mjs**(状态全遍历 CDP 回归:localStorage 预种、逐状态截图断言)——状态机定义站点专用,probe.mjs 已覆盖单页探测。移植自 `samsyninja-rebuild/scripts/regression.mjs`。 - **gen-shells.mjs / gen_components.py**(DOM 外壳生成:零重写流水线 vs 保守切组件)——策略绑定站点类型(见 dom-shell-strategies 分支),不宜做成单一通用脚本。移植自 `landonorris-rebuild/scripts/gen-shells.mjs` / oryzo 的 `gen_components.py`。 - **dump-scene-graph.mjs(运行时场景图 dump 成数值账本)**——评估后不纳入:shopifydesign 那份是源站 bundle 里某个内部函数的逐字转写,换个站点连挂载点都不存在。范式(先 dump 源站数值再移植再数值验收)已由 `dump-timelines.mjs` 代表;需要时按目标站的引擎重写一份。 - **rogier 的 capture.mjs / analyze-home-bands.mjs**(行亮度剖面分析)——依赖 sharp,违反零依赖哲学,未纳入;等价能力可用 `lib/png.mjs` + 自写剖面重做。 - **racingshop 的 gapfill.mjs(协议相对 URL 归一重解)**——评估后不纳入:它修的是爬虫把 `//host/path` 拼成 `https://origin//host/path` 的 bug,而 `mirror-site.mjs` 已在提取阶段就把协议相对 URL 归一成 `https://host/path`,根因不再产生,留着只会诱导别人跑一个针对不存在故障的补丁。若历史镜像里已有这类损坏条目,一次性重解那份 manifest 即可,不需要常备脚本。 - **kimi 确定性冻结协议(八协议表)**——是文档/协议不是脚本,应进 references/,不在本目录范围。 - **layer-report.mjs(内联块四层归属门)**——`shopify-platform.md` §0.3 步骤 5 把它定为 M1 关账条件,但本 skill 暂未提供实现。机械部分通用(枚举 `<script>`、先掩 HTML 注释、块正文 sha256、与归属表 join、UNCLASSIFIED/AMBIGUOUS 非零退出),站点专用的是那张归属表本身与 §0.2 的判层判据。移植参照 `objectarchive-rebuild/scripts/layer-report.mjs` + `docs/layer-map.json`。 -
reconcile-gaps.mjs 6.6 KB · in bundle
-
serve.mjs 47.9 KB · in bundle
-
shell-config.example.mjs 4.7 KB · in bundle
-
side-by-side.mjs 6.3 KB · in bundle
-
slice-esm.mjs 11.3 KB · in bundle
-
slice-modules.mjs 15.6 KB · in bundle
-
slices.config.example.mjs 5.8 KB · in bundle
-
sweep-routes.mjs 14.5 KB · in bundle
-
verify-crossside.mjs 10.4 KB · in bundle
-
verify-flight.mjs 23.3 KB · in bundle
-
verify-fresh.mjs 6.6 KB · in bundle
-
verify-harvest.mjs 6 KB · in bundle
-
verify-lenprefix.mjs 8.2 KB · in bundle
-
verify-mirror.mjs 53.2 KB · in bundle
-
verify-module-map.mjs 10.4 KB · in bundle
-
verify-nextdata.mjs 6.4 KB · in bundle
-
verify-offline.mjs 6.6 KB · in bundle
-
verify-payload.mjs 15.4 KB · in bundle
-
verify-reassembly.mjs 4.4 KB · in bundle
-
verify-refs-served.mjs 5.1 KB · in bundle
-
verify-routes.mjs 9.5 KB · in bundle
-
verify-shell.mjs 8.6 KB · in bundle
-
verify-ssr.mjs 6.2 KB · in bundle
-
verify-standalone.mjs 10 KB · in bundle
-
verify-symbols.mjs 8.9 KB · in bundle
-
verify-tokens.mjs 2.6 KB · in bundle
-
verify-tween.mjs 10.1 KB · in bundle
-
verify-zerodep.mjs 4.1 KB · in bundle
-
wayback-mirror.mjs 25.1 KB · in bundle
-
-
tools
-
accept-names.mjs 1.6 KB · in bundle
-
assemble-static.mjs 2.8 KB · in bundle
-
flight-to-mdx.mjs 28 KB · in bundle
-
group-parts.mjs 5.6 KB · in bundle
-
harvest-optimized-images.mjs 3.5 KB · in bundle
-
make-standalone.mjs 26.4 KB · in bundle
-
modules-to-src.mjs 16.3 KB · in bundle
-
name-modules.mjs 23.9 KB · in bundle
-
package.json 613 B
{ "name": "website-rebuild-tools", "private": true, "type": "module", "description": "devDependencies the M(n+1) refactorers in tools/ import. Copy these entries into the rebuild project's own package.json when the project reaches M(n+1); scripts/ stays zero-dependency and must never import from here. Pinned to the Babel 7 line the tools were built and re-run against (darkroom 7.25, raycastkbd 7.26, storytellingnoomo 7.29); one project ran them on 8.0.4 (basement) without incident, but that is one sample.", "devDependencies": { "@babel/parser": "7.29.8", "@babel/traverse": "7.29.8" } } -
README.md 6.2 KB
# tools/ — 源码化阶段的重构器 ⭐ **依赖纪律按阶段划:源码化之前,整条流水线零依赖。** 复刻项目从 Step 0 到 M(n) 不装任何东西;**到 M(n+1) 才获得 devDependencies**,因为作用域安全的分析需要真正的 parser(`@babel/parser` / `@babel/traverse`)。这里放的就是那个阶段的工具。 ⛔ 前面的阶段需要 parser 时,**外挂而不是 import**:spawn 一个钉死版本的 npx (见 `scripts/beautify-bundle.mjs`、`scripts/module-map.mjs`)。 ⛔ **`scripts/` 里的任何门都不许 import 这里的任何文件**——检查者不能是生产者 (`references/verification-gates.md` §2.1.2)。两条纪律都由 `scripts/verify-zerodep.mjs` 守。 ⭐ **这里的依赖钉在 `tools/package.json`**(`@babel/parser` / `@babel/traverse`,精确版本):项目到 M(n+1) 时把这两条抄进自己的 `package.json` 再 `npm install`;此前 skill 从未在任何地方声明过它们,版本靠运气。 | 工具 | 用途 | |---|---| | `name-modules.mjs` | 按 0–4 级证据给模块提名,并记下依据的那句话;无证据保留哈希 id——**错名比哈希更糟,因为哈希会让人去看** | | `modules-to-src.mjs` | 把一份模块容器端口摊成可读树:每个模块一个文件(名字可带子目录)、带溯源头注(源 bundle 行区间 + 命名证据层级)、包装器形参作用域安全地重命名为 `(module, exports, require)`(webpack)或 `(ctx)`(Turbopack)。⛔ **不把 require 转成静态 import**——require 惰性且记忆化,ESM import 提升求值,转换会重排每个模块的顶层副作用。产出 `registry.js` + `runtime.js`(独立运行时)+ `index.js`;⚠ 跨 chunk require 的场景不用独立运行时,改用 **chunk 形交付**(见 `references/porting-discipline.md` §2.6) | | `make-standalone.mjs` | 给 src/ 配齐离开仓库所需的一切:按账本把产出引用的资产复制进 `src/public/`(⭐ 到这一步"不复制"纪律**反转**——交付物的要求恰恰是"拷到哪都能跑")、生成 `package.json`(build/serve 脚本烤入 ext/stub/origin 主机参数)、`--replaced` 指定被端口替换的源 bundle **不随行**(被替换物躺在替换者旁边,"跑的是哪个"就要靠实验回答)、`--allow` 消费 `external.txt` 豁免源站自身 404、`--own` 声明端口自有构建产物。裸 `/ext/<host>`(本地化的 preconnect)不算资产缺口。⭐ **交付物自带字节清单**(`byte-manifest.json` + 生成的 `verify-bytes.mjs`):逐文件 sha256 在生成时对**落盘后的字节**钉死,`npm run check/build/serve` 每次先重验——副本从"验过一次"变成"随时自证",端口自有构建产物列为 unpinned(每次 build 重生成) |。v0.3.16:有资产缺失时 exit 1(此前打印 FAIL 仍退 0) | `group-parts.mjs` | 把拼接式分解的平铺部件按**字面证据**折进域目录:仅共享标识符 token 计入——前导规则只认原名大写开头的类族(Camera*/Wave* → camera/ wave/),小写动词(get*/create*)拒分(字面但糊的桶比平铺更藏东西);尾缀族要更长的重复。先按新布局重拼验 sha **再**动盘,压缩名 chunk 证据不足即整体保持平铺。实测 hashgraphvc:场景 chunk 151 件 → 24 个域目录,33/33 chunk 重拼仍逐字节一致 | | `flight-to-mdx.mjs` | C1 的正文反推器:flight 元素树 → MDX 源。markdown 构词(p/标题`[#id]`/列表/围栏/脚注对)回 markdown;站点组件形状回 JSX 调用;其余回带精确 className 的字面 JSX(不丢字节)。⚠ 站点侧适配区在文件头注明(LINK_CLASS/SHAPE/FIRST_PARTY,像 harvest.config 一样属于站点);四个 MDX 陷阱的规避已内建(多行模板字面量被按块缩进剥空格→属性值一律 JSON 字面量;组件映射按上下文分;JSX 流里裸文本被包 p→文本一律 `{"json"}` 表达式;`pre>code>code` 嵌套=围栏指纹)。实测 rauchg 17 页全过、语义门 18/18 | ⚠ 复制到复刻项目时放在项目的 `tools/` 下,与项目 `package.json` 的 devDependencies 一起走。 ## 速查表:用途与使用阶段(自 SKILL.md 迁入) | 脚本 | 用途 | 使用阶段 | |---|---|---| | `tools/assemble-static.mjs` | **像素门两侧同经 serve.mjs**:把 `next build` 的 `.next/server/app/**.html` 摊成 `<route>/index.html`、`_next/static` 与 `public/*` 软链进静态树,用 `serve --side rebuild` 伺服——`next start` 侧不注入 probe-shim,镜像帧 BLANK/重建有画是冻结不对称不是差异(darkroom)。只供对拍,sweep 仍跑 next start | M(n-1)(C1 重构工程) | | `tools/accept-names.mjs` | **命名的接受步**:name-modules 只提名不决定;默认只接受 tier-1(打包器声明的导出名),其余保留 id——"错名比哈希更糟"在这一步才真正生效(darkroom 278 模块接受 105) | M(n+1) | | `tools/sourcify-chunk.mjs` | **多 chunk 站的 M(n+1) 驱动**:按 merged map 的 canonical 位切子闭包(⛔ id 与 map 同型:字符串),逐 chunk 跑 name-modules → accept-names → modules-to-src → verify-module-map(darkroom 43/43) | M(n+1)(多 chunk 站) | | `tools/harvest-optimized-images.mjs` | **next/image 优化器产物补齐**:像素门重建侧的静态树没有优化器,serve 回落原图 → 重采样残差;镜像字节优先,本机 `next start` 优化器兜底并登记为重建侧生成物(rsc-reconstruction §3.5) | M(n-1)(C1 重构工程) | | `tools/verify-fresh-next.mjs` | **verify-fresh 的 Next 形态**:src → `next build` → assemble-static 链重建比字节;⛔ 前提 `generateBuildId` 钉死,否则链条永远"过期" | M(n+1)(C1 重构工程) | | `tools/name-modules.mjs` | **模块提名**:模块化 bundle 的 id 是内容哈希,文件名要从证据里来。按 0–4 级证据提名并把**依据的那句话**一起记下(人工裁决 / 自注册与全局 / 多消费方字段名 / 常量值与命名前缀 / 报错主语),⛔ **无证据保留 id——错名比哈希更糟**。⭐ 最强的证据在模块外面:属性名不被压缩,`this._chapterPlayer = new M(…)` 能给一个匿名 `class {}` 命名 | M(n+1)(模块化打包产物) | -
sourcify-chunk.mjs 3.5 KB · in bundle
-
verify-fresh-next.mjs 3.6 KB · in bundle
-
-
SKILL.md 40.8 KB
--- name: website-rebuild description: 1:1 rebuild of award-winning creative websites (WebGL / scroll-animation / portfolio sites). Evidence-driven pipeline - mirror-first forensics, line-number-traceable reverse engineering of minified bundles, verbatim porting, quantitative verification gates. Use when user asks to "复刻网站", "重建网站", "1:1 rebuild", "clone this site", or provides a URL of a creative/award site to reproduce. compatibility: Requires Node 22+ (bundled scripts use built-in WebSocket to talk to CDP), npx, and a local Chrome/Chromium for headless comparison. POSIX shell optional - the Step 0 probe protocol has a zero-dependency Node equivalent (scripts/fingerprint.mjs) for shells without curl/cmp/tr/perl (e.g. Windows PowerShell); everything after Step 0 (headless Chrome process groups, npx spawns, ps) is POSIX-only (macOS / Linux / WSL). Agent-agnostic - works in any Agent Skills-compatible runtime. metadata: version: "0.3.23" --- # Website Rebuild(获奖创意站 1:1 复刻) 把一个获奖创意网站(WebGL / 滚动叙事 / 作品集站)以**取证式方法**复刻为可独立运行、可验证还原度的工程。不是"看着像"的仿制——是以源站 bundle 为唯一规格书、以量化验收门收口的逐行为移植。 本方法论提炼自六个连续实践项目(工期从 6.5 周收敛到 1 天),后经 **22 个完整复刻 + 5 个死站存档抢救**持续回填、43 站边界探测实测校准适用范围(清单见仓库 README「已验证过的网站」)。 ## 使用前提与授权 ⛔ 必读 本 skill 面向**学习与研究目的**的保真复刻,用于研究获奖创意站的实现手法。适用对象是你**自有的、已获授权的,或公开可访问且允许学习临摹**的网站。它不是用于未授权地采集受保护内容、规避访问控制、或商业性盗用他人作品的工具。 执行时遵守下列边界: - **尊重目标站规则**:遵守其 `robots.txt`、服务条款与版权;抓取保持低频、单会话,不对目标站施加异常负载。⛔ **`robots.txt` 是逐路径的许可声明,不是全站开关**——逐 URL 判定(选组 → 最长匹配 → 无匹配即允许),**不得因为存在任何 `Disallow` 行就判"整站禁止"**(几乎每个商业站都有 `/cart`、`/checkout`、`/admin` 的 `Disallow`);禁令要按行为类别归类,**只有针对"抓取"的禁令才影响镜像范围**,针对交易的禁令只意味着"别去点结账"。⭐ **"读不懂 / 拿不准"不等于"禁止"**:走呈交,不走停工,更不自行缩小抓取范围。读法见 [references/legal-and-deploy.md](references/legal-and-deploy.md) §0.3。 - **不触碰受保护边界**:不采集需要登录态、付费墙或授权才能访问的内容;本 skill 只处理匿名可公开访问的资源。若目标站明确禁止此类复制,停止并告知用户——**何为"明确禁止"见 `legal-and-deploy.md` §0.3.6 写死的四条门槛,其余一切不确定性走呈交不走停工**。 - **产出默认私有**:默认 noindex、不公开部署。任何公开前必须完成逐资产版权取证,并显著标注"非官方复刻"与原作者归属(见 [references/legal-and-deploy.md](references/legal-and-deploy.md))。 ⛔ **法务判断归用户,skill 只取证与呈现**(三条,全程有效): 1. **决定权在用户**:skill 收集事实(逐资产归属、许可状态、第三方权利人、源站是否仍在营业、产物内第三方标识符)、列出选项与各自的风险边界、给出建议与理由;凡涉及"能不能公开 / 部署 / 再分发 / 对外展示",**必须用下文「User Input Tools」显式交回用户**,不许 agent 自行下法律结论后继续往下走。 2. **未获用户明确决定前按安全默认执行**:私有仓库 + `noindex` + 不公开部署 + 不再分发。写给用户时说明这是**默认动作**("在你决定之前我不会把它发出去"),**不是** agent 已作出的法务结论——两者责任归属完全不同。agent 只能往保守侧执行默认,往公开侧走必须有用户的明确决定。 3. ⛔ **法务考量不得削减镜像完整性或门的覆盖面**:镜像是证据基座,**完整性是技术不变量**(四遍法、闭包门、GAP=0 全建立在它之上)。不抓只能有**技术性理由**(不是文件 / 服务端不提供 / 需授权或登录态 / 源站明令禁止),一律登记;**不得**以"反正不公开""不该多存一份"这类法务理由留洞【objectarchive】(实证:`references/case-studies/skill.md`「使用前提与授权」)。法务决定作用于**产出怎么被使用**,不是证据基座是否完整。 ## 适用范围 ⛔ 必读 **主场(A 类)**:内容静态托管、签名行为(动画/交互)全部存放在客户端静态资产里的站——命令式 WebGL/Canvas 场景、GSAP 时间轴、烘焙数据文件(GLB/.buf/.riv)、minified 或未混淆的 bundle。绝大多数 Awwwards 风格创意站属于此类。 **有条件支持(B 类)**:管线成立但需要额外场景处理(Shopify 平台层剥离、第三方存储桶资产、运行时 API 快照、SSG payload 展开)。当前版本的指南覆盖大部分 B 类场景,遇到未覆盖的要向用户明示风险。 **明确拒绝(C/D 类)**: - **C1(v0.3 起可做:重构式逆向)**:服务端组件源确实不下发,但**它的完整输出(flight 流)内联在每页 HTML 里,是可对拍的规格书**。路线:flight-decode 建坐标系 → 重构一个可构建的 Next 工程(客户端一方组件按 C2 逐字译,服务端组件从 flight 树反推为显式登记的推断物)→ verify-flight 语义门收口(模块 id 全局双射;实证:`references/case-studies/skill.md`「适用范围」)。⚠ C1 的 L2/L3 合并——第一份产物就是「人写的源码 + 门证明的等价」。全流程见 [references/rsc-reconstruction.md](references/rsc-reconstruction.md)。 - **C2(可做,按 A 类跑)**:⭐ 写法是声明式但**源码下发**(R3F / Theatre / Vue SFC 编译产物)。**切片器不关心范式——它切的是字节。** 渲染器当平台层从镜像伺服(实证:`references/case-studies/skill.md`「适用范围」)。⛔ 判别器不是库名,是「客户端是否持有行为源」(`scope-and-fingerprint.md` §4.0.1)。 - **D**:行为主体在服务端(CMS 内容站、电商 cart/库存、A/B 实验分桶、个性化注水)——客户端没有可移植的目标物,且确定性验收无基准。 **X 类(可抢救)**:原站已消失(域名易主 / 平台回收 / 路径移除 / 原地被替换),但 Internet Archive 往往有捕获——`scripts/wayback-mirror.mjs` 从 CDX 索引按**锚点 + 时间窗**选一个连贯时刻、以 `id_` 原始字节抓成**标准镜像**(下游门原样工作),洞按既成事实登记进 `wayback-holes.txt`(读法与流程见 [references/archival-rescue.md](references/archival-rescue.md))。⭐ 抢救产出是**标准镜像**——X 类可走完 L3 全程(实证:`references/case-studies/skill.md`「适用范围」)。⛔ **"CDX 无覆盖才是真不可做"按资产层读,不按站读**:IA 爬虫不执行 JS,清单/拼接驱动的站可以代码层覆盖 100% 而画面层为零(实证:`references/case-studies/skill.md`「适用范围」)——Step 0 先做分层覆盖侦察(推导 + CDX 前缀查询)预判抢救深度,见 `archival-rescue.md` §1.9。历年获奖站实测消失率约 29%——这也是"第一时间镜像"是本 skill 第一纪律的原因。 判级由 Step 0 指纹侦察决定,完整判定树见 [references/scope-and-fingerprint.md](references/scope-and-fingerprint.md)。**拒绝时要解释原因并说明该站属于哪一类**,不要硬跑。 ## User Input Tools 需要向用户提问时(确认范围、**法务决定**、外部依赖决策):优先使用当前运行时的内置提问工具(如 `AskUserQuestion`);没有则输出编号问题清单让用户回复编号。支持多问合并时一次问完。法务类提问按 `legal-and-deploy.md` §0.1 的五段式写:事实 / 查不清的 / 选项 / 每个选项的风险边界 / 建议与当前默认动作。 ## 宪法(六条纪律,全程有效) 以下六条在六个源项目中被称为"宪法级",违反任何一条都会在后续阶段以 bug 形式偿还: 1. **镜像神圣不可污染**:`mirror/` 磁盘文件永不修改;一切本地化适配(CDN 改写、外链 stub)在服务层响应时动态完成。 2. **源站代码是唯一裁决,不凭观感修**:每个改动先在 bundle/CSS/镜像 HTML 里找到归属行号再落地。Do not tune visuals, motion, or interaction by eye. 3. **源站有的都要有,源站没有的不做**:不自创补偿性 CSS/JS。宁可先不像,也不要发明规则——自创补丁会在机制对齐后反转成 bug。 4. **bug / 死代码 / 怪写法照抄不修**:压缩代码里的每个怪写法都可能是行为本身。"好心修正" no-op bug 曾导致转场崩溃(实证见 porting-discipline.md)。 5. **有意偏差必须登记**:写清"源站怎么做 / 我们怎么做 / 为什么 / 什么条件下重新考虑"。**没登记的差异一律视为 bug**。 6. **代码与文档同一次提交**:每个里程碑成对提交(`Port xxx` + `Update rebuild plan: xxx`),日志固定含产出 / 验收 / 教训 / 下一步断点(带行号)。 ⭐ **纪律 3 在 M(n+1) 的边界**:`src/` 是显式登记的衍生物,不是对源站的断言,所以**在 `src/` 里重命名、拆模块、写注释不算"发明"**——纪律 3 约束的是"为了让它看起来像而自创行为",不是"让已证明等价的代码变得可读"。但两条硬边界不动:**① 结构性重写默认禁止**(合并重复、提取公共函数、改算法——它们让等价不可判定);**② 注释里的推测必须标注为推测**,不许把逆向笔记里的猜测写成陈述句。`port/` 与 `mirror/` 仍然一个字节都不许动。详见 [references/readable-source.md](references/readable-source.md) §3.4 与 §5。 ## Workflow ### Progress Checklist ``` [ ] Step 0 指纹侦察与范围门 ⛔(判级 A/B/C/D/X;C/D/X 拒绝或引导,不进入下一步) [ ] Step 1 开工评级(架构证否、分项难度打星、工期预估、与用户确认范围 + 终点 L1/L2/L3) [ ] M0 镜像取证 ⛔(BFS 爬虫 + CDP 补录 + manifest 账本;GAP=0) [ ] M0.5 镜像断网跑通 ⛔(零 404 / 零控制台错误 / 零外联;serve.mjs 伺服)← L1 镜像存档 终点 [ ] M1 逆向建坐标系 ⛔(_pretty 钉版本展开;engine-notes 先于任何代码;技术栈钉死;REBUILD_PLAN 建立) [ ] M2+ 严格溯源移植(依赖序里程碑推进;先竖切一条端到端链路;每里程碑冷启动实测 + CLEAN 门) [ ] M(n-1) 对拍验收(按 verification-gates.md 决策树选门型;根因修复,不调参糊平) [ ] M(n) 收口 ⛔(冷头评审 / 模块清单对账;版权取证 + 呈交用户决定——公开部署前必须完成)← L2 工程化复刻 终点 [ ] M(n+1) 源码化(port/ → src/:拆模块、去混淆重命名、补注释、自包含)← L3 源码化 终点 ``` ⛔ = 阻塞门:验收标准未达成不得进入下一阶段。 标记约定(全部文档通用):⛔ 硬规则,违反即 bug · ⛔⛔ 已在实战里付过高代价的硬规则 · ⭐ 经验证的做法 · ⭐⭐ 反直觉但已被数据证实的做法 · ⚠ 陷阱 / 边界 · 【代号】= 实证来源项目(对应 README「已验证过的网站」)。 ### Flow **Step 0 — 指纹侦察与范围门**。加载 [references/scope-and-fingerprint.md](references/scope-and-fingerprint.md),对用户给的 URL 执行探测协议(GET 到路径粒度、最终 URL 同一性、双抓 diff、物种/年代校验、bundle 初检),输出判级与依据。A/B 类继续;C/D/X 类向用户解释后停止或引导。 **Step 1 — 开工评级**。加载 [references/recon-and-rating.md](references/recon-and-rating.md)。架构假设先证否(依赖表会撒谎),分项难度打星(素材/3D/滚动编排/私有格式/平台层),向用户确认复刻范围(整站或指定页面)与预期。 ⭐ **同一次提问里让用户选终点**(三级梯子,带着判级结论与分级成本估计问,不要干巴巴列选项): | 终点 | 回答的问题 | 止于 | 典型用途 | |---|---|---|---| | **L1 镜像存档** | 它长什么样 | M0.5 | 存档、离线欣赏(获奖站年消失率约 29%) | | **L2 工程化复刻** | 它在做什么 | M(n) | 可部署、可验证的 1:1(含版权取证与部署评估) | | **L3 源码化** | 它怎么做的 | M(n+1) | 研究与学习实现手法 | **梯子单调,选低不亏**:每一级都是下一级的前缀,镜像纪律保证时间敏感的部分永远最先完成——今天选 L1,以后想升级随时续跑(向用户说明这一点)。**"拿它做自己的项目"(脚手架化)不是本 skill 的阶段**——用户问起时指向 [references/beyond-the-rebuild.md](references/beyond-the-rebuild.md) 交接,那是他的工程,skill 到"人能读懂的真实"为止。 **M0 / M0.5 — 镜像取证**。加载 [references/mirroring.md](references/mirroring.md)。用 `scripts/mirror-site.mjs` BFS 爬取 + `scripts/netcapture.mjs` 真实浏览器补录,manifest 逐文件登记 sha256,`redirect: manual` 纪律,外部依赖逐项决策。`scripts/verify-mirror.mjs` 是**镜像自己的门**(五项断言,跑在断网门之前——下游所有门问的都是"渲染得出来吗",错的镜像能让它们全绿;**一个 HTTP 200 也不是"你拿到了那个资源"的证据**)。`scripts/serve.mjs` 伺服镜像,断网验收。**这一步永远最先做**——原站随时可能消失或改版,镜像是全项目唯一证据基准,也是后续一切对拍的参照服。 **M1 — 逆向建坐标系**。加载 [references/reverse-engineering.md](references/reverse-engineering.md)。⛔ **第一个动作是判 bundle 形态**(扁平拼接 / 模块化打包 / 多 chunk),再选工具——分层表扫顶层声明,而 webpack 打包产物的顶层声明数是 **0**,边界与依赖边由打包器给定(用 `scripts/module-map.mjs`;实证:`references/case-studies/skill.md`「Workflow / Flow — M1」)。认不出容器时 FATAL,**禁止回退到分层表**(§0.5)。`scripts/beautify-bundle.mjs`(js-beautify 钉 1.15.1)展开 bundle 到 `_pretty/`,此后行号是全项目唯一溯源坐标系。先写 `docs/engine-notes.md`(模板:[assets/templates/engine-notes.md](assets/templates/engine-notes.md))再写任何代码。技术栈从 bundle 取证钉死精确版本。数据驱动动画先 dump 数值账本。建立 `REBUILD_PLAN.md`(模板:[assets/templates/rebuild-plan.md](assets/templates/rebuild-plan.md))。 **M2+ — 严格溯源移植**。加载 [references/porting-discipline.md](references/porting-discipline.md),并按分支路由表加载对应场景指南。每个移植文件头部注明源行号区间;GLSL/魔数/数据逐字提取;数据资产脚本抽取入库不手抄。 **M(n-1) — 对拍验收**。加载 [references/verification-gates.md](references/verification-gates.md) 与 [references/determinism.md](references/determinism.md);门红了或残差需要归类时再加载 [references/gate-failure-modes.md](references/gate-failure-modes.md),不要开局读。全站渲染**广度**用 `scripts/sweep-routes.mjs`(全路由一个浏览器,逐路由 0 错误/0 失败/0 外联 + 交互钩子与逐路由采集),单路由**深度**才用 `probe.mjs`——⛔ 不要手搓逐路由起 Chrome 的循环,成本按浏览器启动次数计,且并发探针会互相收割孤儿。⚠ **归因残差之前先建自比带宽**(`pixelcompare --self`,逐侧 ≥4 次、交错跑)——没有带宽的残差一律 UNCLASSIFIED,而 UNCLASSIFIED 是失败不是通过。门型选择:有 SSR/静态 HTML 产物先建字节门 → DOM 静态场景冻结熵源走 byte-equal → 活场景(WebGL/视频/随机相位)降级量化指标 + 噪声归类 → 数据驱动动画补数值探针门 → CLEAN 门全程兜底。判定时序 bug 前先校准探针([references/environment-traps.md](references/environment-traps.md))。 **M(n) — 收口**。冷头评审:对 bundle 顶层类/模块清单逐一核对落点(功能测试测不出整块遗漏,只有清单式核对能)。加载 [references/legal-and-deploy.md](references/legal-and-deploy.md) 完成版权**取证**并把决定**呈交用户**——在用户决定之前按安全默认执行(**私有 + noindex + 不部署**),公开前必须逐资产取证、显著标注非官方复刻。 **M(n+1) — 源码化**。加载 [references/readable-source.md](references/readable-source.md)。到 M(n) 为止产物**已证明正确但人读不了**(实证:`references/case-studies/skill.md`「Workflow / Flow — M(n+1)」)。本阶段把 `port/` 重写成 `src/`:拆模块 → 作用域安全地去混淆重命名 → 补分档注释 → 复制资产做到自包含。⛔ **拆分粒度不是自由选择**——扁平脚本的声明顺序即求值顺序,粒度由三条硬约束决定(互相引用 / 求值顺序 / import 绑定不可赋值),**先出划分方案让人过目,再切**;遇到巨型模块时**先测「延迟绑定少数末尾单例」的收益曲线再决定**(换模块系统要赔上整条工具链才换来同样粒度;实证:`references/case-studies/skill.md`「Workflow / Flow — M(n+1)」)。⭐ **"这件事做不到"这个判断极不可靠**(实证:`references/case-studies/skill.md`「Workflow / Flow — M(n+1)」)——先怀疑测量它的工具,再怀疑对象(`readable-source.md` §3.1–3.1.3)。⛔ **前置条件不可协商:必须先有全绿的门。** 没有裁判的重构是盲改;有了 `meanAbsDiff 0.00` 的裁判,每一步都能被证死——**这是重构能有的最好条件,也是它必须排在最后的原因**。现有门全部原样复用(目标换成 `src/` 构建产物,**容差不许放宽**),另加符号映射门与自包含门。⛔ 结构性重写(合并重复、提取公共函数、改算法)**默认禁止**——它会让门从"证明等价"退化为"没测出不等价"。⭐ **纪律 4 在本阶段依然有效**:你现在读得懂了,"这明显是个 bug"的冲动会比任何阶段都强,而它依然可能是行为本身。 ⭐ **无容器 scope-hoisted 产物(Vite/esbuild,逐字分层交付的站)走另一条路:不重写,切**——拼接式分解(`scripts/census-bundles.mjs` 出 chunk 图与坐标 → `scripts/slice-esm.mjs` 按声明切成语义命名的部件,按序拼接逐字节等于原件 → `scripts/verify-reassembly.mjs` 一门定案,字节等价成立时全部运行时门的裁决免费转移)。执行侧不变,浏览器继续跑原 chunk。详见 `readable-source.md` §3.0.6。 ### 分支路由表 Step 1 侦察结果决定加载哪些场景指南(按需,不要全量加载): | 侦察发现 | 加载 | |---|---| | Next.js App Router / RSC(`self.__next_f` flight 流)——C1 重构式逆向 | [references/rsc-reconstruction.md](references/rsc-reconstruction.md) | | WebGL / Canvas 场景(three.js、自研引擎、GLSL) | [references/webgl-scenes.md](references/webgl-scenes.md) | | GSAP / 烘焙动画数据 / CSS 变量动画 / 自研输入状态机 | [references/animation-recovery.md](references/animation-recovery.md) | | 私有二进制格式(.buf / .sog / VAT / GLB 时间线 / .riv) | [references/binary-formats.md](references/binary-formats.md) | | Shopify 店铺(指纹见 `cdn/shop`、`Shopify.theme`、`cdn.shopify.com`) | [references/shopify-platform.md](references/shopify-platform.md) | | Sanity CMS(指纹见 `cdn.sanity.io/images/<projectId>/`、`*.api.sanity.io`、载荷里成片 `_key`/`_type`/`_ref`)——⛔ 判级看内容烘焙时点不看库名,且 `auto=format` 资产按 Accept 协商返回不同字节 | [references/sanity-platform.md](references/sanity-platform.md) | | 门红了、或像素 / 数值残差需要归类(真差异 vs 方法学噪声) | [references/gate-failure-modes.md](references/gate-failure-modes.md) | | 数值门 / 跨侧门 / 采集基线的用例设计;M(n) 清单式核对 | [references/gate-case-design.md](references/gate-case-design.md) | | 内联序列化载荷(flight / `__NUXT__` / devalue 数据岛)或策略 A 外壳构建 | [references/payload-gates.md](references/payload-gates.md) | | DOM 层策略选型(所有站必经;Webflow 导出 / 静态单页 / 框架 SSR 分支不同,另有"DOM 被 3D 引擎当坐标源读"的正交约束) | [references/dom-shell-strategies.md](references/dom-shell-strategies.md) | | 大体量资产(百 MB 级媒体 / 授权字体) | [references/asset-management.md](references/asset-management.md) | | 无头探测行为异常 / 疑似环境问题 | [references/environment-traps.md](references/environment-traps.md) | ### Step Summary | 阶段 | 关键动作 | 阻塞门验收 | 产出物 | |---|---|---|---| | Step 0 | 指纹探测判级 | 判级明确且已告知用户 | 判级结论与依据 | | Step 1 | 证否 + 评级 + 确认范围 | 用户确认 | 难度评级表、范围共识 | | M0/M0.5 | 镜像 + 账本 + 断网跑通 | **`verify-mirror` 五项全绿**;GAP=0;零 404/零错误/零外联 | `mirror/`(只读)、manifest、`serve.mjs` 参照服 | | M1 | 展开 bundle、逆向笔记、钉栈 | engine-notes 完成;版本钉死表完成 | `_pretty/`、`docs/engine-notes.md`、`REBUILD_PLAN.md` | | M2+ | 溯源移植、里程碑成对提交 | 每里程碑冷启动实测 + CLEAN 门绿 | 带行号注释的源码、三张登记表滚动更新 | | M(n-1) | 对拍验收 | 所选门型全绿或差异全部登记 | 验证脚本 + 对拍产物入库(`docs/compare/`) | | M(n) | 冷头评审 + 版权取证 + 呈交用户 | 清单对账零缺口;用户已作出部署决定(未决则维持安全默认) | 审计记录、DEPLOY.md | | M(n+1) | 拆模块 + 去混淆 + 注释 + 自包含 | 现有门全绿且**容差未放宽**;符号门双向单射零孤儿;自包含门(复制出去、断网、构建)过 | `src/`(可读工程)、`docs/rename-map.json`、`src/README.md` | ## Script Directory Node 22+,路径相对本 skill 目录。每个脚本都认 `--help`(打印头注用法 + 旗标清单)与 `--version`(skill 版本),**未知旗标一律 FATAL**(`lib/cli.mjs`)。本表只列一句话用途;**旗标与完整规格看脚本自己:`node scripts/<x>.mjs --help`**(头注即规格,含中文,v0.3.21 起 README 不再复述);选哪个脚本、阶段、出处、成熟度见 [scripts/README.md](scripts/README.md) 索引与 [tools/README.md](tools/README.md);设计实证见 [references/case-studies/scripts.md](references/case-studies/scripts.md)。 ⭐⭐ **依赖纪律是按阶段划的,不是按目录划的:源码化之前,整条流水线零依赖。** Step 0 → M(n) 全程不装任何东西;**复刻项目要到 M(n+1) 才获得 devDependencies**(作用域安全的重命名需要真正的 parser)。`scripts/`(零依赖)与 `tools/`(允许 devDeps)只是这条阶段线在目录上的投影——**判据住 `scripts/`,源码化阶段的重构器住 `tools/`**。 ⛔ **任何门不许 import 任何工具**(`verification-gates.md` §2.1.2)——检查者不能是生产者。 ⭐ **前面的阶段需要真正的 parser 怎么办?外挂,不要 import。** `beautify-bundle.mjs`(js-beautify)与 `module-map.mjs`(acorn)都是 `spawn` 一个**钉死版本的 npx**,脚本自身仍然零依赖、仍然可独立审查。⛔ **不要改成手写词法器**(实证:`references/case-studies/skill.md`「Script Directory」)。**token 流上的括号匹配是精确的,文本上的括号匹配是对字符串/正则/注释的猜测。** ⚠ 这条线是**被违反之后才被发现的**(实证:`references/case-studies/skill.md`「Script Directory」)。**一条只写在文档里、没有任何东西去查的规矩,会安静地失效。** | 脚本 | 用途 | 使用阶段 | |---|---|---| | `scripts/fingerprint.mjs` | Step 0 探测协议的零依赖等价实现:存活 / 重定向终点 / 双抓 diff / 技术指纹 / bundle 初检 + Sanity 证据采集(只采证据,不出判级) | Step 0 | | `scripts/mirror-site.mjs` | BFS 爬虫镜像:资产白名单、`redirect:manual`、三本账(含 sha256)跨运行累积、off-host 普查;`--scope` 只限页面不限资产 | M0 第一遍 | | `scripts/netcapture.mjs` | 真实浏览器 CDP 抓包,对账补录运行时资源(CDN 站必须传 `--hosts`) | M0 第二遍 | | `scripts/verify-mirror.mjs` | 镜像自己的门:映射单射 / 账本 sha256 / 真实性(魔数 + 挑战页)/ 闭包 / 抽样回源,跑在断网门之前 | M0 关账前 | | `scripts/gapfill-video.mjs` | HLS/DASH 流媒体阶梯补录(master → rendition → 分片) | M0(有流媒体时) | | `scripts/reconcile-gaps.mjs` | 运行时缺口对账:netcapture 的 GAP 行 + 字节推导全集逐条补进镜像;请求头梯子 + 浏览器同款图片 Accept | M0(运行时资源多的站) | | `scripts/wayback-mirror.mjs` | X 类抢救:从 CDX 按锚点 + 时间窗选一个连贯时刻,以 `id_` 原始字节抓成标准镜像,洞登记 `wayback-holes.txt` | M0(X 类) | | `scripts/serve.mjs` | 零依赖静态服务器兼参照服:MIME / Range / 服务层改写 / 重定向回放;`--fallback-root` 回落链、`--stub-ext-hosts` 桩、`--stub-json PATH::FILE` 端点桩(按源站 JSON 合同应答,首次命中打印)、`--rewrite` 登记式替换;未知旗标响亮失败 | M0.5 起全程 | | `scripts/probe.mjs` | CDP 无头探针:console / 异常 / 网络 CLEAN 判定进 CI,`--no-external` 零外联,`--walk` 全滚动走查 | M0.5 起每 commit | | `scripts/sweep-routes.mjs` | 渲染广度门:全路由一个浏览器,逐路由 0 错误 / 0 失败 / 0 外联 + 交互钩子;不要手搓逐路由起 Chrome | M0.5 起(多路由站) | | `scripts/verify-offline.mjs` | 零外联门的静态一半:枚举产出里每个外部绝对 URL 并逐条裁决 | M0.5 起每 commit | | `scripts/verify-payload.mjs` | SSG payload 门:内联序列化数据块(Nuxt / flight)求值展开后按结构对拍 | M0.5 起(有 SSG payload 时) | | `scripts/verify-nextdata.mjs` | pages router 载荷门:`__NEXT_DATA__` 与 `/_next/data/*.json` 单侧自洽 + 双侧深比较 | M0.5 起(pages router 站) | | `scripts/verify-lenprefix.mjs` | 自带长度的载荷门:flight 流逐行按 `T<hex>` 字节数前进,改写后落点仍须是行首 | M0.5 起(有 flight 载荷时) | | `scripts/flight-decode.mjs` | C1 坐标系:把每页 flight 流解成模块引用表 / 预载 / 元素树 / JSX outline | M1(C1) | | `scripts/beautify-bundle.mjs` | js-beautify@1.15.1 钉死展开 bundle 到 `_pretty/`,排版后 token 流自查,撞名断言 | M1 | | `scripts/module-map.mjs` | 模块化 bundle 的分层表(spawn 钉死 acorn):认 webpack 容器与 Turbopack 扁平列表,认不出即 FATAL,覆盖率守卫 | M1(模块化打包产物) | | `scripts/census-bundles.mjs` | 无容器产物的 chunk 级坐标账本(sha256 / 行数 / ESM 边),拼接式分解的第一步 | M1(scope-hoisted 产物) | | `scripts/dump-timelines.mjs` | GLB 动画曲线 dump 成 JSON 数值账本 | M1(数据驱动动画时) | | `scripts/closure.mjs` | 从种子模块算传递依赖闭包,竖切边界的唯一依据;未知种子 FATAL + did-you-mean | M2+(模块化打包产物) | | `scripts/slice-modules.mjs` | 按模块 id 逐字切片,容器外字节(前奏 / 尾注)逐字带走,`--check` 重切须字节一致 | M2+(模块化打包产物) | | `scripts/extract-source.mjs` | 字节切片器:按钉死行号区间切 `_pretty/` 拼成生成文件,sha256 守卫 + `--check` | M2+(逐字移植期) | | `scripts/emit-webpack-chunk.mjs` | 多 chunk webpack 站的逐字再发射:按 module-map 边界切成部件再按源站容器形态拼回,`--check` 逐字节 | M2+(webpack 多 chunk 站) | | `scripts/slice-esm.mjs` | 拼接式分解切片器:按声明把 ESM chunk 切成语义命名部件,按序拼接逐字节等于原件 | M2+ / M(n+1)(scope-hoisted 产物) | | `scripts/verify-reassembly.mjs` | 重拼门:逐部件 sha + 按序拼接 sha + `--against` 对活原件三重比对 | M2+ / M(n+1)(scope-hoisted 产物) | | `scripts/build-site.mjs` | 策略 A 构建层:按 `shell-config.mjs` 变换表从镜像生成 `site/`,逐条命中下限 + `--check` | M2+(策略 A) | | `scripts/verify-shell.mjs` | 外壳字节门:逐文档 patience diff,每个差异块须能被变换表重放解释(不 import 构建器) | M2+(策略 A) | | `scripts/verify-tokens.mjs` | token 流等价门:排版 / 再发射件 ≟ 源站原件逐 token 相等;凡以 `_pretty` 字节交付必跑 | M2+(排版字节交付时每 commit) | | `scripts/verify-refs-served.mjs` | 引用可达门:产出字节里每条资源引用逐条问服务器(不再实现一遍解析) | M2+ 起每 commit | | `scripts/verify-routes.mjs` | 路由 / 重定向 / 状态码契约门 | M2+ | | `scripts/verify-ssr.mjs` | SSR / DOM 逐字节门 | M2+(有 SSR 产物时最先建) | | `scripts/verify-tween.mjs` | 竖切的数值门:同一关键帧规格喂两侧,逐点比补间值与缓动曲线 | M2+(有补间 / 时间轴引擎时) | | `scripts/harvest-cases.mjs` | 从源站活引擎采用例(`harvest.config.mjs`),只产出 A 侧 | M2+(源站引擎可达时) | | `scripts/verify-harvest.mjs` | 采集基线的 B 侧:每条身份在移植侧恰好匹配一个,按行为把名字找回来 | M2+(有采集基线时) | | `scripts/verify-crossside.mjs` | 跨侧门:同一份输入串行喂镜像与移植逐条比(`crossside.config.mjs`),URL 相同直接 FATAL | M2+(源站有可直接调用的接缝时) | | `scripts/pixelcompare.mjs` | 量化像素对拍:自比带宽 `--self`、状态对齐 `--ready / --after-ready / --chunk`、到达等待 `--hold*`、`--freeze-css`;非空帧前置条件;大视口用 jpeg | M(n-1) | | `scripts/pixel-walk.mjs` | 检查点巡航:N 个滚动位置各跑一次像素门,滚两次、重复帧逐格报出,先 `--self` 测带宽 | M(n-1) | | `scripts/side-by-side.mjs` | 双侧截图并排合成图(对拍产物留证) | M(n-1) | | `scripts/frame-census.mjs` | 截图普查:颜色数与主色占比,证明帧里有东西 | M(n-1) | | `scripts/probe-shim.js` | 确定性驱动 shim:接管 rAF / timer / 时钟 / `Math.random` / IntersectionObserver,手动泵到任意 t,双侧同位注入 | M(n-1) | | `scripts/verify-flight.mjs` | C1 语义门:构建产物 flight 树 ≟ 镜像 flight 树,模块 id 全局双射,自带解析器 | M(n-1)(C1) | | `scripts/cold-audit-modules.mjs` | M(n) 冷头清点(模块化产物):逐模块对账 + 计算型 require 扫描,必须报 `n/N examined` | M(n)(模块化打包产物) | | `scripts/cold-audit-decls.mjs` | M(n) 冷头点名(扁平产物):深度 0 声明逐条判 cited / override / named / UNKNOWN | M(n)(扁平产物) | | `scripts/verify-module-map.mjs` | M(n+1) 等价门(模块化产物):一模块一文件且与打包器字节 token 级一致 | M(n+1)(模块化打包产物) | | `scripts/verify-symbols.mjs` | 符号映射门:`port/` 每个顶层声明在 `src/` 有且仅有一个对应(读 `rename-map.json`) | M(n+1)(扁平产物) | | `scripts/verify-fresh.mjs` | 新鲜度门:`src/` → `dist/` → `site/` 是否同步;时间戳不是判据 | M(n+1)(有构建步骤时每次) | | `scripts/verify-standalone.mjs` | 自包含门:`src/` 复制到临时目录 → 断网 → 安装 → 构建 → CLEAN 与零外联 | M(n+1) | | `scripts/verify-zerodep.mjs` | 依赖分界门:`scripts/` 只许 node: / 相对 import,且没有门 import `tools/` | 每次新增脚本 | | `scripts/lib/urlpath.mjs` | 唯一的 url → 本地路径映射(查询感知),爬虫 / 抓包 / 服务 / 门四方共用 | lib | | `scripts/lib/extract-refs.mjs` | 唯一的资产引用提取器(五种写法 × 原文 / 解码两遍),爬虫与闭包门共用 | lib | | `scripts/lib/negotiate.mjs` | 内容协商 Accept 策略(浏览器同款图片 Accept)+ Sanity 证据提取 | lib | | `scripts/lib/ports.mjs` | 端口分配与实例身份(`21000 + slot×1000 + lane×10 + side`),占用即响亮失败 | lib | | `scripts/lib/chrome.mjs` | 无头浏览器生命周期:进程组收割 + 孤儿自检 + CDP 载荷硬顶常量 | lib | | `scripts/lib/png.mjs` | 零依赖 PNG 编解码 | lib | | `scripts/lib/tokens.mjs` | token 流读法(acorn 钉死 spawn)+ 首分歧定位 | lib | | `scripts/lib/cli.mjs` | 唯一的 argv 合同:`--help` / `--version` / 未知旗标 FATAL,`EXIT` 退出码表 | lib | | `scripts/lib/hash.mjs` | 唯一的 sha256 拼写(字符串 / Buffer / 流式文件) | lib | | `scripts/lib/ledger.mjs` | 镜像三本账(manifest / inventory / redirects)的唯一读写实现 + `LEDGER_FILES` | lib | | `scripts/lib/cdp.mjs` | 唯一的 CDP 客户端:逐调用超时、断连响亮失败、事件订阅 | lib | | `tools/name-modules.mjs` | 模块提名:按 0–4 级证据给内容哈希 id 起名并记依据,无证据保留 id | M(n+1)(模块化打包产物) | | `tools/accept-names.mjs` | 命名的接受步:默认只接受 tier-1(打包器声明的导出名),其余保留 id | M(n+1) | | `tools/modules-to-src.mjs` | 按接受后的命名逐模块生成 `src/modules/`(作用域安全的重命名器) | M(n+1)(模块化打包产物) | | `tools/sourcify-chunk.mjs` | 多 chunk 站的 M(n+1) 驱动:逐 chunk 跑 name-modules → accept-names → modules-to-src → verify-module-map | M(n+1)(多 chunk 站) | | `tools/group-parts.mjs` | 把 slice-esm 部件按域折进目录(只按 classy 证据分组) | M(n+1)(scope-hoisted 产物) | | `tools/make-standalone.mjs` | 交付物生成:按账本复制资产、生成 package.json / verify-bytes;`--mirror a,b` 回落链 | M(n+1) | | `tools/flight-to-mdx.mjs` | 从 flight 树反推 MDX / 页面骨架 | M2+(C1 重构工程) | | `tools/assemble-static.mjs` | 把 `next build` 产物摊成静态树供 serve.mjs 伺服(像素门两侧同经 serve) | M(n-1)(C1 重构工程) | | `tools/harvest-optimized-images.mjs` | next/image 优化器产物补齐(镜像字节优先,本机优化器兜底) | M(n-1)(C1 重构工程) | | `tools/verify-fresh-next.mjs` | verify-fresh 的 Next 形态:src → `next build` → assemble-static 链重建比字节(前提 `generateBuildId` 钉死) | M(n+1)(C1 重构工程) | ## 复刻工程目录结构 三个阶段性产物,**单向依赖,读作「证据 → 移植 → 源码」**: ``` <site>-rebuild/ ├── mirror/ # ① 只读证据:源站 URL 空间的字节级还原。永不修改 │ └── _pretty/ # beautify 展开产物 + 再生成说明 README ├── port/ # ② 逐字移植:机器读,extract-source --check 守着字节一致。永不手改 │ └── _gen/ # 切片器产物(行号头指回 mirror/_pretty/) ├── src/ # ③ 人写的工程:可读、可改、自包含(复制到任何地方都能跑) │ ├── package.json # ⛔ 自己的 package.json——自包含门要把它复制出去单独跑 │ ├── assets/ # 资产在这里(③ 阶段必须复制,见 readable-source.md §2) │ └── README.md # 怎么跑 / 坐标系怎么读 / 哪些注释是我们写的 ├── docs/ │ ├── engine-notes.md # 逆向笔记(事实/怪癖/复刻结论三段式) │ ├── rename-map.json # ③ 阶段符号映射(port 位置 → 旧名 → 新名 → 依据档位) │ └── compare/ # 对拍产物留证 ├── REBUILD_PLAN.md # §0 纪律 / 阶段计划 / §6 偏差表 / §Q 怪癖表 / §7 里程碑日志 ├── mirror-manifest.json # 镜像账本(sha256 逐文件) ├── scripts/ # 判据与前置工序:零依赖,从本 skill 拷入 └── tools/ # 重构器:③ 阶段专用,允许 devDependencies(见下) ``` ⛔ **`src/` 里发现行为不对,答案在 `port/` 或 `mirror/`,不在 `src/`。** 就地"改到对"会把移植 bug 变成无法追溯的本地补丁,**而且门会变绿**——这是纪律 2 在三段坐标系下的形式。`port/` 在 `src/` 建成后不删除,它是等价性的另一端。 ⭐ **依赖分界按阶段**:**源码化之前零依赖**——项目到 M(n+1) 才有 devDependencies。`scripts/`(判据与前置工序)零依赖,必要时 spawn 钉死版本的 npx;`tools/`(源码化重构器)允许 devDependencies。任何门不许 import 任何重构器。由 `scripts/verify-zerodep.mjs` 守。 ## References 按需加载(Step 0/1 与分支路由表决定),不要开局全量读入: - [scope-and-fingerprint.md](references/scope-and-fingerprint.md) — 第 0 步判级与路由(必经) - [recon-and-rating.md](references/recon-and-rating.md) — 开工侦察与难度评级(必经) - [mirroring.md](references/mirroring.md) — 镜像取证全流程(必经) - [reverse-engineering.md](references/reverse-engineering.md) — 行号坐标系与逆向笔记(必经) - [porting-discipline.md](references/porting-discipline.md) — 溯源移植纪律(必经) - [verification-gates.md](references/verification-gates.md) — 门型定义、决策树、运行纪律、分层体系(必经) - [gate-failure-modes.md](references/gate-failure-modes.md) — 门的失效模式、根因修复与残差归类(门红了再读) - [gate-case-design.md](references/gate-case-design.md) — 用例设计与清单式核对(数值门 / 跨侧门 / M(n) 清点前读) - [payload-gates.md](references/payload-gates.md) — 载荷与外壳变换的门(有内联载荷或策略 A 时) - [determinism.md](references/determinism.md) — 确定性冻结协议与 probe-shim - [dom-shell-strategies.md](references/dom-shell-strategies.md) — DOM 层策略选型(A/B/C + 正交约束 D)(所有站必经) - [webgl-scenes.md](references/webgl-scenes.md) — WebGL/GLSL 场景逆向 - [animation-recovery.md](references/animation-recovery.md) — 动画/输入逆向路径 - [binary-formats.md](references/binary-formats.md) — 私有二进制格式 - [shopify-platform.md](references/shopify-platform.md) — Shopify 平台层剥离(B 类) - [sanity-platform.md](references/sanity-platform.md) — Sanity CMS 场景(判级三形态、`auto=format` 协商陷阱、变体阶梯两层展开、运行时拼接 API base) - [asset-management.md](references/asset-management.md) — 资产不复制策略与字体决策 - [environment-traps.md](references/environment-traps.md) — 环境陷阱手册 - [legal-and-deploy.md](references/legal-and-deploy.md) — 版权取证与部署决断(取证归 skill,决定归用户) - [readable-source.md](references/readable-source.md) — M(n+1) 源码化:port/ → src/ 的可读工程(拆模块、去混淆、注释纪律、自包含契约) - [rsc-reconstruction.md](references/rsc-reconstruction.md) — C1(RSC)重构式逆向:flight 坐标系、MDX 反推、语义门、平台层工件 - [archival-rescue.md](references/archival-rescue.md) — X 类死站抢救:CDX 分层覆盖侦察、锚点 + 时间窗、洞登记 - [beyond-the-rebuild.md](references/beyond-the-rebuild.md) — 交接:拿产出做自己的项目(脚手架化不是本 skill 的阶段) - [assets/templates/rebuild-plan.md](assets/templates/rebuild-plan.md)、[assets/templates/engine-notes.md](assets/templates/engine-notes.md) — 文档模板 - [case-studies/skill.md](references/case-studies/skill.md) 与 `references/case-studies/<doc>.md` — 各文档的实证记录(战史),不在必经集合里;只在需要证据时读 ## Notes - **版权红线**:本 skill 用于学习目的的复刻。产出默认私有 + noindex(安全默认,不是法务结论);公开部署前必须完成逐资产版权取证、把决定交回用户、并显著标注非官方复刻与原作者归属。最大风险是法务不是技术——但**法务判断由用户作出,且永不用于削减镜像完整性或门的覆盖面**。 - **工期预期**:方法论成熟形态下,单页创意站 1-3 天(数十个 commit);多场景 WebGL 作品集站按周计。向用户给预估时参考 Step 1 的难度评级。 - **对拍失败先怀疑环境**:后台节流、HMR 幽灵模块、探针时钟、headless 字体缺失都会伪装成代码 bug。判定源码问题前先过 environment-traps.md 的校准清单。 - 遇到本 skill 未覆盖的场景(B 类缺口),明确告诉用户"这一段没有既成指南,按通用纪律推进",并把新经验记入项目文档——它们是 skill 下一版的输入。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.