@xiaobuyu/nodesign 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,422 +1,422 @@
1
- /**
2
- * server/engine/browse/capture.js — 从浏览的站上把可复用的东西带回来(2026-08-18)
3
- *
4
- * 用户的追加需求:「**此后从浏览器中获得的可复用内容都存到这里**」。关键词是
5
- * "此后" —— 采集要落盘,不能只进这一轮的上下文然后随会话消失。
6
- *
7
- * ## 落哪儿、为什么不新建目录
8
- *
9
- * `assets/references/web/`。**不新建"浏览器根"**:`assets/references/` 已经是既有
10
- * 目录、既有写入方(web-search 下载的参考图落那儿)、既有 skill 锚点。再立一个根就是
11
- * 「同一件事两个地方」—— 这个仓库最贵的一课。放它下面的 `web/` 子目录,与搜索图
12
- * 分栏但同根。
13
- *
14
- * 出处 sidecar 照抄 `generate-image.js` 已有的约定:`<产物层>/.meta/<name>.json`。
15
- * **不另发明一套** —— 而且那个约定的读者(assets.js 的清单合并)已经在,扩一处就能
16
- * 让参考素材也带出处。
17
- *
18
- * ## ⭐「可复用内容」比截图宽得多
19
- *
20
- * 对做设计这件事,从一个站上带回来最值钱的往往不是位图:**调色板**、**用了哪几个
21
- * 字体**、某一块的 **CSS 片段**、页面的**结构骨架**。这些能直接落成代码,位图只能
22
- * 靠眼睛转译。所以 capture 是五种,不是一种。
23
- *
24
- * ⚠️ 五种里四种是从 `getComputedStyle` / CDP 直接读出来的(可靠),
25
- * **`skeleton` 那三个数是启发式**(按布局签名聚类数"节的形状种类"),返回值里明确
26
- * 标着"粗略,当对照不当真相"—— 它的用处是让 site-craft 的「无聊诊断」从"靠眼睛数"
27
- * 变成"对参照站有对照数字",不是当成事实。
28
- */
29
-
30
- import path from 'node:path';
31
- import fs from 'node:fs/promises';
32
-
33
- /**
34
- * 采集根(工作区相对)。web-search 的搜索图在同一个 references 根下、分栏放。
35
- *
36
- * ⭐ **一个参照站一个文件夹**(2026-08-18 下午,用户拍板"按 site 那种范式"):
37
- * `assets/references/web/<站名>/`。理由是一次采集的五档本来就是**一个单位**
38
- * (同一个站、同一次、同一个 lookingFor),平铺到十几个文件之后靠文件名前缀
39
- * 认亲太弱;而目录 = 单位正是站点产物那条既有范式,桌面上一张卡装一个站。
40
- * 目录里再按**文件名表明类别**(`.screenshot.webp` / `.palette.json` / …)。
41
- */
42
- export const CAPTURE_DIR = path.posix.join('assets', 'references', 'web');
43
-
44
- /** 站名 → 目录名。去掉 www.、端口里的冒号,别让它在 Windows 上炸 */
45
- export const siteDirOf = (url) => {
46
- try {
47
- const u = new URL(url);
48
- return slug(`${u.hostname.replace(/^www\./, '')}${u.port ? `-${u.port}` : ''}`);
49
- } catch { return 'page'; }
50
- };
51
-
52
- /**
53
- * 命中规则 → **真的能抄的 CSS 文本**。
54
- *
55
- * 这一档的全部价值是"照抄就能复现那个技术",而包在 json 里的它得先被解开一层
56
- * 才能用。写成 .css 之后类别由扩展名表明,人和 agent 都能直接读。
57
- * 顺序 = CDP 给的级联顺序(后面的覆盖前面的),头注释里写清出处。
58
- */
59
- function cssText(rules, info, selector) {
60
- const head = [
61
- '/* 从参照站采下来的命中规则(按级联顺序,后面的覆盖前面的)',
62
- ` * 元素:${selector}`,
63
- ` * 来源:${info.url}`,
64
- ` * 采于:${new Date().toISOString()}`,
65
- ' */',
66
- '',
67
- ];
68
- const body = rules.map((r) => {
69
- const decls = r.declarations.map(d => ` ${d};`).join('\n');
70
- const rule = `${r.selector} {\n${decls}\n}`;
71
- return r.media?.length
72
- ? `@media ${r.media.join(' and ')} {\n${rule.replace(/^/gm, ' ')}\n}`
73
- : rule;
74
- });
75
- return `${head.concat(body).join('\n')}\n`;
76
- }
77
-
78
- const slug = (s) => String(s || '')
79
- .replace(/[^\w一-龥-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'capture';
80
-
81
- /** 页面里跑的采集器。⚠️ 整段在浏览器上下文里执行,不能引用外面的东西。 */
82
- /* eslint-disable no-undef */
83
- function collectorSource() {
84
- return () => {
85
- const cs = (el) => (el ? getComputedStyle(el) : null);
86
- const norm = (c) => {
87
- if (!c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent') return null;
88
- return c;
89
- };
90
-
91
- // ── 调色板:按"用途"取,不是把图量化 ──
92
- // 量化位图会给一堆没名字的相近色;从 computed style 取到的是**作者的选择**,
93
- // 而且自带用途,拿回来能直接用。
94
- // ⚠️ 每一行显式声明取哪个属性 —— 第一版对每个元素同时报 color 和 background,
95
- // 于是出现了 `page-bg/color=black` 这种自相矛盾的行(body 的 color 是正文色)。
96
- const uses = [
97
- ['page-bg', 'body', 'backgroundColor'],
98
- ['body-text', 'body', 'color'],
99
- ['h1', 'h1', 'color'],
100
- ['h2', 'h2', 'color'],
101
- ['link', 'a', 'color'],
102
- ['button-bg', 'button, .btn, [class*=button]', 'backgroundColor'],
103
- ['button-text', 'button, .btn, [class*=button]', 'color'],
104
- ['card-bg', 'article, .card, [class*=card]', 'backgroundColor'],
105
- ['footer-bg', 'footer', 'backgroundColor'],
106
- ['footer-text', 'footer', 'color'],
107
- ];
108
- const palette = [];
109
- for (const [role, sel, prop] of uses) {
110
- const el = document.querySelector(sel);
111
- const s = cs(el);
112
- const v = s ? norm(s[prop]) : null;
113
- if (v) palette.push({ role, value: v });
114
- }
115
-
116
- // ── 字体:字族 + 实际用到的字重/字号(光有字族没法照做)──
117
- const fontOfEl = (el, label) => {
118
- const s = cs(el);
119
- if (!s) return null;
120
- return {
121
- role: label,
122
- family: s.fontFamily,
123
- size: s.fontSize,
124
- weight: s.fontWeight,
125
- lineHeight: s.lineHeight,
126
- letterSpacing: s.letterSpacing,
127
- };
128
- };
129
- const fontOf = (sel, label) => fontOfEl(document.querySelector(sel), label);
130
-
131
- // ⚠️ 「正文」不能拿**第一个 `<p>`**(agent 上报逮到:真站上首个 `<p>` 常是
132
- // 顶部公告条/导航里的一句话,字号字族都不是正文的,量出来整份报告跟着错)。
133
- // 改成找**最大的可见叶子文字块**:字多、够宽、且没有块级孩子替它装内容。
134
- // 附 selector + 文字样本 —— 量具的读数要能核对出处,不然错了没人看得见。
135
- const pickParagraph = () => {
136
- let best = null; let bestScore = 0;
137
- for (const el of document.querySelectorAll('p, li, dd, blockquote')) {
138
- if (el.querySelector('p, div, ul, ol, table, blockquote, pre, h1, h2, h3')) continue;
139
- const r = el.getBoundingClientRect();
140
- const s = cs(el);
141
- if (!s || s.display === 'none' || s.visibility === 'hidden' || r.width < 120) continue;
142
- const text = (el.textContent || '').trim();
143
- if (text.length < 40) continue;
144
- // 长度封顶:一段 5000 字的法律条款不该只因为长就赢过真正的正文版式
145
- const score = Math.min(text.length, 600) * Math.min(r.width, window.innerWidth);
146
- if (score > bestScore) { bestScore = score; best = el; }
147
- }
148
- return best || document.querySelector('p');
149
- };
150
- const selectorOf = (el) => {
151
- const tag = el.tagName.toLowerCase();
152
- if (el.id) return `${tag}#${el.id}`;
153
- const cls = [...el.classList].slice(0, 2).join('.');
154
- return cls ? `${tag}.${cls}` : tag;
155
- };
156
- const pEl = pickParagraph();
157
- const pFont = fontOfEl(pEl, 'paragraph');
158
- if (pFont) {
159
- pFont.selector = selectorOf(pEl);
160
- pFont.sample = (pEl.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
161
- }
162
- const fonts = [
163
- fontOf('body', 'body'), fontOf('h1', 'h1'), fontOf('h2', 'h2'),
164
- pFont, fontOf('code, pre', 'mono'),
165
- ].filter(Boolean);
166
-
167
- // ── 结构骨架(⚠️ 启发式)──
168
- //
169
- // ⚠️ 第一版按标签选(`main > *, body > section` …),在第一个真站(MDN)上就
170
- // **数出 0 节**。一个在真站上读 0 的指标比没有更坏 —— 它会告诉 agent
171
- // 「这个参照站有 0 种形状」。所以改成按**视觉横带**找:宽度接近视口、够高、
172
- // 而且内部不再包含更小的带(取最内层)。人看一个页面看到的"一节"就是这个。
173
- const vw = window.innerWidth;
174
- const cand = [...document.querySelectorAll('body *')].filter((el) => {
175
- const r = el.getBoundingClientRect();
176
- // 高度门槛跟视口挂钩而不是写死 120 —— 真站上一条 104px 的 stats strip
177
- // 就这么整条消失了(它确实是"一节",人一眼就看见)
178
- if (r.width < vw * 0.7 || r.height < Math.max(72, window.innerHeight * 0.08)) return false;
179
- const s = cs(el);
180
- return s.display !== 'none' && s.visibility !== 'hidden';
181
- });
182
- // 取最内层的带:一个候选如果还含着别的候选,它是容器不是"一节"
183
- const candSet = new Set(cand);
184
- const sections = cand.filter(el => ![...el.querySelectorAll('*')].some(d => candSet.has(d)));
185
-
186
- // 「节的形状种类」按布局签名聚类:同签名的节读起来是"同一种节奏"。
187
- // 这是 site-craft「用户说无聊」那节要的第二个数。
188
- // 签名要**粗**。第一版把图/标题/文字块数都算进去(各封顶 3),结果在三个真站上
189
- // `形状种类 === 节数` —— 每节都独一无二,那第二个数就不携带任何信息。
190
- // 现在只留三个粗轴:排布方式 / 有没有图 / 文字密度档。同一种节奏才会撞在一起。
191
- const sig = (el) => {
192
- const s = cs(el);
193
- const layout = s.gridTemplateColumns !== 'none' ? 'grid'
194
- : (s.display.includes('flex') ? (s.flexDirection.startsWith('row') ? 'row' : 'col') : 'block');
195
- // ⚠️ `querySelector` **只找后代**。最内层的那条带很可能**自己就是**那张
196
- // 全幅 hero 图/视频 —— 于是它被标成 noimg,图 hero 和纯文字 hero 聚成一类。
197
- const IMGISH = 'img, picture, video, svg';
198
- const hasImg = (el.matches(IMGISH) || el.querySelector(IMGISH)) ? 'img' : 'noimg';
199
- const chars = (el.textContent || '').trim().length;
200
- const density = chars < 120 ? 'bare' : (chars < 800 ? 'some' : 'lots');
201
- return `${layout}|${hasImg}|${density}`;
202
- };
203
- const shapes = new Map();
204
- for (const el of sections) {
205
- const k = sig(el);
206
- shapes.set(k, (shapes.get(k) || 0) + 1);
207
- }
208
- // 交互重心:值得动手的东西。
209
- // ⚠️ 三个坑,都是审查在真站上撞出来的:
210
- // ① `[class*=tab]` 是子串匹配 → `.tabular-figures`、`.tabbed-wrapper` 全中;
211
- // `:not([class*=table])` 也补不完。改成按 class **token** 比对。
212
- // ② 一个元素同时命中两条选择器(既 `[role=tab]` 又 `.tab-item`)会被数两遍。
213
- // 改用 Set 存元素再取 size。
214
- // ③ 裸 `[aria-expanded]` 把汉堡菜单、下拉导航全算成"手风琴"。它们确实是
215
- // disclosure,但不是"这页的交互重心"。单列一档,别混进 accordions。
216
- const clsTokens = (el) => (typeof el.className === 'string'
217
- ? el.className.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean) : []);
218
- const hasTok = (el, names) => clsTokens(el).some(t => names.has(t));
219
- const setOf = (sel, pred) => {
220
- const out = new Set();
221
- for (const el of document.querySelectorAll(sel)) { if (!pred || pred(el)) out.add(el); }
222
- return out;
223
- };
224
- const TAB_TOK = new Set(['tab', 'tabs']);
225
- const ACC_TOK = new Set(['accordion', 'accordions', 'collapse', 'collapsible', 'disclosure']);
226
- const interactive = {
227
- forms: setOf('form').size,
228
- tabs: setOf('[role=tab], [role=tablist], [class]', el => el.getAttribute('role') === 'tab'
229
- || el.getAttribute('role') === 'tablist' || hasTok(el, TAB_TOK)).size,
230
- accordions: setOf('details, [class]', el => el.tagName === 'DETAILS' || hasTok(el, ACC_TOK)).size,
231
- disclosures: setOf('[aria-expanded]').size, // 多半是导航开关,单独列
232
- draggables: setOf('[draggable=true], input[type=range]').size,
233
- videos: setOf('video').size,
234
- };
235
-
236
- return {
237
- url: location.href,
238
- title: document.title,
239
- viewportWidth: window.innerWidth,
240
- palette,
241
- fonts,
242
- skeleton: {
243
- sectionCount: sections.length,
244
- shapeKinds: shapes.size,
245
- shapes: [...shapes.entries()].map(([k, n]) => ({ signature: k, count: n })),
246
- interactive,
247
- // disclosures 不计进"交互点":它多半是汉堡菜单/下拉导航,
248
- // 每个站都有,加进去只会把这个数字变成噪音
249
- interactivePoints: ['forms', 'tabs', 'accordions', 'draggables', 'videos']
250
- .reduce((a, k) => a + interactive[k], 0),
251
- _note: '启发式:节的形状种类是按布局签名聚类数出来的,当对照数字用,别当真相。'
252
- + '同一页做 A/B 最可靠;跨页比较受各家标记方式影响,看数量级别抠个位数。',
253
- },
254
- };
255
- };
256
- }
257
- /* eslint-enable no-undef */
258
-
259
- /**
260
- * 采一次。
261
- * @param {object} p
262
- * @param {import('playwright').Page} p.page
263
- * @param {string} p.workspaceRoot
264
- * @param {string[]} p.kinds screenshot / palette / fonts / css / skeleton
265
- * @param {string} [p.name] 文件名主干(不给就按站名+时间)
266
- * @param {string} [p.lookingFor] agent 当时在找什么 —— 三天后没有这句就是一堆不知道哪来的图
267
- * @param {string} [p.selector] css 那一种要指一块
268
- * @param {object} [p.ids] {sessionId, runId}
269
- * @param {(buf: Buffer) => Promise<{data: string, mimeType: string, note?: string}>} [p.normalize]
270
- * @returns {Promise<{files: Array<{rel: string, kind: string, bytes: number}>, data: object}>}
271
- */
272
- export async function capture({
273
- page, workspaceRoot, kinds, name, lookingFor, selector, ids = {}, normalize,
274
- }) {
275
- const info = await page.evaluate(`(${collectorSource().toString()})()`);
276
- // 一站一文件夹(见 CAPTURE_DIR 的注释)。子目录相对工作区根的前缀,
277
- // 落盘和返回给 agent 的 rel 都从它拼 —— 只有一处拼法。
278
- const siteDir = path.posix.join(CAPTURE_DIR, siteDirOf(info.url));
279
- const dir = path.join(workspaceRoot, siteDir);
280
- const metaDir = path.join(dir, '.meta');
281
- await fs.mkdir(metaDir, { recursive: true });
282
-
283
- // 文件名主干不再重复站名(目录已经说了是哪个站):给的名字优先,
284
- // 否则用页面在站内的位置 + 时间 —— 同一个站逛三层会采三份,得分得开。
285
- const pageSlug = (() => {
286
- try {
287
- const segs = new URL(info.url).pathname.split('/').filter(Boolean);
288
- return segs.length ? slug(segs.slice(-2).join('-')) : '首页';
289
- } catch { return '页面'; }
290
- })();
291
- // `0818-2253`(月日-时分,+8 时区)。原来是 `.slice(5,16)` 直接把 ISO 的
292
- // `08-18T22:53` 去掉分隔符,读出来是 `08-182253` —— 文件名是给人看的
293
- const iso = new Date(Date.now() + 8 * 3600e3).toISOString();
294
- const stamp = `${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}`;
295
- const stem = slug(name || `${pageSlug}-${stamp}`);
296
-
297
- const files = [];
298
- const failed = [];
299
-
300
- /** 出处 sidecar 的键 = 文件名去掉最后一节扩展名(`listReferences` 用的是同一条规则) */
301
- const metaStemOf = (fileName) => fileName.replace(/\.[^.]+$/, '');
302
-
303
- /**
304
- * 写一份素材 + 它自己的出处。
305
- *
306
- * ⭐ **类别写在文件名里**(2026-08-18 用户拍板):`.screenshot.webp` /
307
- * `.palette.json` / `.fonts.json` / `.skeleton.json` / `.css`。这跟站点产物是
308
- * 同一条规矩 —— 形态由文件名表明,不用打开文件才知道里面是什么。
309
- * 在这之前调色板/字体/结构/CSS **全挤在一个 `<名>.json` 里**,抽屉里看到的
310
- * 是一个什么都可能的 json,agent 下个会话想复用得先读一遍才知道有没有它要的。
311
- *
312
- * 出处**一份文件一份**,不是一次采集一份:每份素材要能单独被认出来
313
- * (三天后 `assets/references/web/` 里的东西是靠自己说明自己的)。
314
- */
315
- const write = async (suffix, body, kind) => {
316
- const fileName = `${stem}${suffix}`;
317
- const rel = path.posix.join(siteDir, fileName);
318
- await fs.writeFile(path.join(workspaceRoot, rel), body);
319
- files.push({ rel, kind, bytes: Buffer.byteLength(body) });
320
- await fs.writeFile(path.join(metaDir, `${metaStemOf(fileName)}.json`), `${JSON.stringify({
321
- kind,
322
- sourceUrl: info.url,
323
- pageTitle: info.title,
324
- capturedAt: new Date().toISOString(),
325
- viewportWidth: info.viewportWidth,
326
- lookingFor: lookingFor || null,
327
- selector: selector || null,
328
- sessionId: ids.sessionId ?? null,
329
- runId: ids.runId ?? null,
330
- }, null, 2)}\n`, 'utf8');
331
- return rel;
332
- };
333
-
334
- /** 一档一个 json,各自带齐来历 —— 拿单独一份出来看也知道它是什么 */
335
- const writeJson = (suffix, payload, kind) => write(suffix, `${JSON.stringify({
336
- source: info.url, title: info.title, kind, ...payload,
337
- }, null, 2)}\n`, kind);
338
-
339
- // ⚠️ **每一种单独 try**。第一版是一路顺着写下来的,真跑时 `selector: 'main'` 在
340
- // MDN 上等了 30 秒超时 → 整个函数抛错 → 调色板/字体/结构/CSS 明明都已经采到了
341
- // (evaluate 跑在最前面),连出处 sidecar 都没写下去,**一次白采**。
342
- // 部分失败不该丢掉已经成功的部分。
343
- if (kinds.includes('screenshot')) {
344
- try {
345
- let raw;
346
- if (selector) {
347
- const loc = page.locator(selector).first();
348
- // 短超时快速失败:一个选不中的选择器不该让整次采集卡 30 秒
349
- await loc.waitFor({ state: 'visible', timeout: 6000 });
350
- raw = await loc.screenshot({ type: 'png', timeout: 8000 });
351
- } else {
352
- raw = await page.screenshot({ type: 'png', fullPage: false });
353
- }
354
- // 走跟感知层同一条归一化(webp、缩到 API 反正会缩的规格)—— 参考图不需要
355
- // 无损,而 76MB 的 references 目录已经是这个项目的既有账
356
- const shot = normalize ? await normalize(raw) : null;
357
- const buf = shot ? Buffer.from(shot.data, 'base64') : raw;
358
- await write(shot ? '.screenshot.webp' : '.screenshot.png', buf, 'screenshot');
359
- } catch (err) {
360
- failed.push(`screenshot: ${err.message.split('\n')[0]}`
361
- + (selector ? `(选择器 ${selector} 可能选不中或不可见 —— 先用 browser_read 看页面上有什么)` : ''));
362
- }
363
- }
364
-
365
- const bundle = {};
366
- if (kinds.includes('palette')) bundle.palette = info.palette;
367
- if (kinds.includes('fonts')) bundle.fonts = info.fonts;
368
- if (kinds.includes('skeleton')) bundle.skeleton = info.skeleton;
369
- if (kinds.includes('css') && selector) {
370
- // 复用 explain_style 那条 CDP 通路:拿这个元素**命中的全部规则**,
371
- // 而不是 computed 值 —— 前者能照抄,后者只是结果
372
- try {
373
- const cdp = await page.context().newCDPSession(page);
374
- await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
375
- const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
376
- const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
377
- if (!nodeId) {
378
- // 原来这里没有 else:选择器选不中就静默少一份产物,agent 看不到
379
- failed.push(`css: 选择器 ${selector} 在页面上选不中(先 browser_read 看页面上有什么)`);
380
- } else {
381
- const m = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
382
- bundle.css = (m.matchedCSSRules || []).map(r => ({
383
- selector: r.rule?.selectorList?.text,
384
- media: (r.rule?.media || []).map(x => x.text).filter(Boolean),
385
- declarations: (r.rule?.style?.cssProperties || [])
386
- .filter(d => !d.disabled && d.value != null && d.range) // 有 range = 作者真写了这条
387
- .map(d => `${d.name}: ${d.value}${d.important ? ' !important' : ''}`),
388
- })).filter(r => r.declarations.length);
389
- }
390
- } catch (err) {
391
- // ⚠️ 原来只写进磁盘 JSON 的 cssError,返回给 agent 的文本里**一个字都没有**
392
- // (打印分支只在 `r.data.css` 存在时才跑)→ css 这一档完全静默失败
393
- bundle.cssError = err.message;
394
- failed.push(`css: ${err.message.split('\n')[0]}`);
395
- }
396
- }
397
- // motion 档(08-21):引擎在 engine/motion/inventory.js(对页面无知,产物会话也能用)。
398
- // 排在截图之后 —— 它会真滚一遍页面(做完滚回顶),别把滚动痕迹截进 screenshot 档。
399
- if (kinds.includes('motion')) {
400
- try {
401
- const { collectMotionInventory } = await import('../motion/inventory.js');
402
- bundle.motion = await collectMotionInventory(page);
403
- } catch (err) {
404
- failed.push(`motion: ${err.message.split('\n')[0]}`);
405
- }
406
- }
407
- // 一档一文件。写盘也各自 try —— 一档写不下去不该把别的档带走
408
- // (跟上面「每一种单独 try」同一条理由,只是这次是磁盘不是页面)。
409
- for (const [key, suffix] of [['palette', '.palette.json'], ['fonts', '.fonts.json'], ['skeleton', '.skeleton.json'], ['motion', '.motion.json']]) {
410
- if (bundle[key] == null) continue;
411
- try { await writeJson(suffix, { [key]: bundle[key] }, key); }
412
- catch (err) { failed.push(`${key}: 写盘失败 ${err.message}`); }
413
- }
414
- if (bundle.css) {
415
- // ⭐ CSS 落成**真的 CSS**,不是包在 json 里的字符串数组:这一档的全部价值
416
- // 就是"能照抄",而 json 里的它要先被解开一层才能用。扩展名本身就是类别。
417
- try { await write('.css', cssText(bundle.css, info, selector), 'css'); }
418
- catch (err) { failed.push(`css: 写盘失败 ${err.message}`); }
419
- }
420
-
421
- return { files, failed, data: { ...bundle, url: info.url, title: info.title } };
422
- }
1
+ /**
2
+ * server/engine/browse/capture.js — 从浏览的站上把可复用的东西带回来(2026-08-18)
3
+ *
4
+ * 用户的追加需求:「**此后从浏览器中获得的可复用内容都存到这里**」。关键词是
5
+ * "此后" —— 采集要落盘,不能只进这一轮的上下文然后随会话消失。
6
+ *
7
+ * ## 落哪儿、为什么不新建目录
8
+ *
9
+ * `assets/references/web/`。**不新建"浏览器根"**:`assets/references/` 已经是既有
10
+ * 目录、既有写入方(web-search 下载的参考图落那儿)、既有 skill 锚点。再立一个根就是
11
+ * 「同一件事两个地方」—— 这个仓库最贵的一课。放它下面的 `web/` 子目录,与搜索图
12
+ * 分栏但同根。
13
+ *
14
+ * 出处 sidecar 照抄 `generate-image.js` 已有的约定:`<产物层>/.meta/<name>.json`。
15
+ * **不另发明一套** —— 而且那个约定的读者(assets.js 的清单合并)已经在,扩一处就能
16
+ * 让参考素材也带出处。
17
+ *
18
+ * ## ⭐「可复用内容」比截图宽得多
19
+ *
20
+ * 对做设计这件事,从一个站上带回来最值钱的往往不是位图:**调色板**、**用了哪几个
21
+ * 字体**、某一块的 **CSS 片段**、页面的**结构骨架**。这些能直接落成代码,位图只能
22
+ * 靠眼睛转译。所以 capture 是五种,不是一种。
23
+ *
24
+ * ⚠️ 五种里四种是从 `getComputedStyle` / CDP 直接读出来的(可靠),
25
+ * **`skeleton` 那三个数是启发式**(按布局签名聚类数"节的形状种类"),返回值里明确
26
+ * 标着"粗略,当对照不当真相"—— 它的用处是让 site-craft 的「无聊诊断」从"靠眼睛数"
27
+ * 变成"对参照站有对照数字",不是当成事实。
28
+ */
29
+
30
+ import path from 'node:path';
31
+ import fs from 'node:fs/promises';
32
+
33
+ /**
34
+ * 采集根(工作区相对)。web-search 的搜索图在同一个 references 根下、分栏放。
35
+ *
36
+ * ⭐ **一个参照站一个文件夹**(2026-08-18 下午,用户拍板"按 site 那种范式"):
37
+ * `assets/references/web/<站名>/`。理由是一次采集的五档本来就是**一个单位**
38
+ * (同一个站、同一次、同一个 lookingFor),平铺到十几个文件之后靠文件名前缀
39
+ * 认亲太弱;而目录 = 单位正是站点产物那条既有范式,桌面上一张卡装一个站。
40
+ * 目录里再按**文件名表明类别**(`.screenshot.webp` / `.palette.json` / …)。
41
+ */
42
+ export const CAPTURE_DIR = path.posix.join('assets', 'references', 'web');
43
+
44
+ /** 站名 → 目录名。去掉 www.、端口里的冒号,别让它在 Windows 上炸 */
45
+ export const siteDirOf = (url) => {
46
+ try {
47
+ const u = new URL(url);
48
+ return slug(`${u.hostname.replace(/^www\./, '')}${u.port ? `-${u.port}` : ''}`);
49
+ } catch { return 'page'; }
50
+ };
51
+
52
+ /**
53
+ * 命中规则 → **真的能抄的 CSS 文本**。
54
+ *
55
+ * 这一档的全部价值是"照抄就能复现那个技术",而包在 json 里的它得先被解开一层
56
+ * 才能用。写成 .css 之后类别由扩展名表明,人和 agent 都能直接读。
57
+ * 顺序 = CDP 给的级联顺序(后面的覆盖前面的),头注释里写清出处。
58
+ */
59
+ function cssText(rules, info, selector) {
60
+ const head = [
61
+ '/* 从参照站采下来的命中规则(按级联顺序,后面的覆盖前面的)',
62
+ ` * 元素:${selector}`,
63
+ ` * 来源:${info.url}`,
64
+ ` * 采于:${new Date().toISOString()}`,
65
+ ' */',
66
+ '',
67
+ ];
68
+ const body = rules.map((r) => {
69
+ const decls = r.declarations.map(d => ` ${d};`).join('\n');
70
+ const rule = `${r.selector} {\n${decls}\n}`;
71
+ return r.media?.length
72
+ ? `@media ${r.media.join(' and ')} {\n${rule.replace(/^/gm, ' ')}\n}`
73
+ : rule;
74
+ });
75
+ return `${head.concat(body).join('\n')}\n`;
76
+ }
77
+
78
+ const slug = (s) => String(s || '')
79
+ .replace(/[^\w一-龥-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'capture';
80
+
81
+ /** 页面里跑的采集器。⚠️ 整段在浏览器上下文里执行,不能引用外面的东西。 */
82
+ /* eslint-disable no-undef */
83
+ function collectorSource() {
84
+ return () => {
85
+ const cs = (el) => (el ? getComputedStyle(el) : null);
86
+ const norm = (c) => {
87
+ if (!c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent') return null;
88
+ return c;
89
+ };
90
+
91
+ // ── 调色板:按"用途"取,不是把图量化 ──
92
+ // 量化位图会给一堆没名字的相近色;从 computed style 取到的是**作者的选择**,
93
+ // 而且自带用途,拿回来能直接用。
94
+ // ⚠️ 每一行显式声明取哪个属性 —— 第一版对每个元素同时报 color 和 background,
95
+ // 于是出现了 `page-bg/color=black` 这种自相矛盾的行(body 的 color 是正文色)。
96
+ const uses = [
97
+ ['page-bg', 'body', 'backgroundColor'],
98
+ ['body-text', 'body', 'color'],
99
+ ['h1', 'h1', 'color'],
100
+ ['h2', 'h2', 'color'],
101
+ ['link', 'a', 'color'],
102
+ ['button-bg', 'button, .btn, [class*=button]', 'backgroundColor'],
103
+ ['button-text', 'button, .btn, [class*=button]', 'color'],
104
+ ['card-bg', 'article, .card, [class*=card]', 'backgroundColor'],
105
+ ['footer-bg', 'footer', 'backgroundColor'],
106
+ ['footer-text', 'footer', 'color'],
107
+ ];
108
+ const palette = [];
109
+ for (const [role, sel, prop] of uses) {
110
+ const el = document.querySelector(sel);
111
+ const s = cs(el);
112
+ const v = s ? norm(s[prop]) : null;
113
+ if (v) palette.push({ role, value: v });
114
+ }
115
+
116
+ // ── 字体:字族 + 实际用到的字重/字号(光有字族没法照做)──
117
+ const fontOfEl = (el, label) => {
118
+ const s = cs(el);
119
+ if (!s) return null;
120
+ return {
121
+ role: label,
122
+ family: s.fontFamily,
123
+ size: s.fontSize,
124
+ weight: s.fontWeight,
125
+ lineHeight: s.lineHeight,
126
+ letterSpacing: s.letterSpacing,
127
+ };
128
+ };
129
+ const fontOf = (sel, label) => fontOfEl(document.querySelector(sel), label);
130
+
131
+ // ⚠️ 「正文」不能拿**第一个 `<p>`**(agent 上报逮到:真站上首个 `<p>` 常是
132
+ // 顶部公告条/导航里的一句话,字号字族都不是正文的,量出来整份报告跟着错)。
133
+ // 改成找**最大的可见叶子文字块**:字多、够宽、且没有块级孩子替它装内容。
134
+ // 附 selector + 文字样本 —— 量具的读数要能核对出处,不然错了没人看得见。
135
+ const pickParagraph = () => {
136
+ let best = null; let bestScore = 0;
137
+ for (const el of document.querySelectorAll('p, li, dd, blockquote')) {
138
+ if (el.querySelector('p, div, ul, ol, table, blockquote, pre, h1, h2, h3')) continue;
139
+ const r = el.getBoundingClientRect();
140
+ const s = cs(el);
141
+ if (!s || s.display === 'none' || s.visibility === 'hidden' || r.width < 120) continue;
142
+ const text = (el.textContent || '').trim();
143
+ if (text.length < 40) continue;
144
+ // 长度封顶:一段 5000 字的法律条款不该只因为长就赢过真正的正文版式
145
+ const score = Math.min(text.length, 600) * Math.min(r.width, window.innerWidth);
146
+ if (score > bestScore) { bestScore = score; best = el; }
147
+ }
148
+ return best || document.querySelector('p');
149
+ };
150
+ const selectorOf = (el) => {
151
+ const tag = el.tagName.toLowerCase();
152
+ if (el.id) return `${tag}#${el.id}`;
153
+ const cls = [...el.classList].slice(0, 2).join('.');
154
+ return cls ? `${tag}.${cls}` : tag;
155
+ };
156
+ const pEl = pickParagraph();
157
+ const pFont = fontOfEl(pEl, 'paragraph');
158
+ if (pFont) {
159
+ pFont.selector = selectorOf(pEl);
160
+ pFont.sample = (pEl.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
161
+ }
162
+ const fonts = [
163
+ fontOf('body', 'body'), fontOf('h1', 'h1'), fontOf('h2', 'h2'),
164
+ pFont, fontOf('code, pre', 'mono'),
165
+ ].filter(Boolean);
166
+
167
+ // ── 结构骨架(⚠️ 启发式)──
168
+ //
169
+ // ⚠️ 第一版按标签选(`main > *, body > section` …),在第一个真站(MDN)上就
170
+ // **数出 0 节**。一个在真站上读 0 的指标比没有更坏 —— 它会告诉 agent
171
+ // 「这个参照站有 0 种形状」。所以改成按**视觉横带**找:宽度接近视口、够高、
172
+ // 而且内部不再包含更小的带(取最内层)。人看一个页面看到的"一节"就是这个。
173
+ const vw = window.innerWidth;
174
+ const cand = [...document.querySelectorAll('body *')].filter((el) => {
175
+ const r = el.getBoundingClientRect();
176
+ // 高度门槛跟视口挂钩而不是写死 120 —— 真站上一条 104px 的 stats strip
177
+ // 就这么整条消失了(它确实是"一节",人一眼就看见)
178
+ if (r.width < vw * 0.7 || r.height < Math.max(72, window.innerHeight * 0.08)) return false;
179
+ const s = cs(el);
180
+ return s.display !== 'none' && s.visibility !== 'hidden';
181
+ });
182
+ // 取最内层的带:一个候选如果还含着别的候选,它是容器不是"一节"
183
+ const candSet = new Set(cand);
184
+ const sections = cand.filter(el => ![...el.querySelectorAll('*')].some(d => candSet.has(d)));
185
+
186
+ // 「节的形状种类」按布局签名聚类:同签名的节读起来是"同一种节奏"。
187
+ // 这是 site-craft「用户说无聊」那节要的第二个数。
188
+ // 签名要**粗**。第一版把图/标题/文字块数都算进去(各封顶 3),结果在三个真站上
189
+ // `形状种类 === 节数` —— 每节都独一无二,那第二个数就不携带任何信息。
190
+ // 现在只留三个粗轴:排布方式 / 有没有图 / 文字密度档。同一种节奏才会撞在一起。
191
+ const sig = (el) => {
192
+ const s = cs(el);
193
+ const layout = s.gridTemplateColumns !== 'none' ? 'grid'
194
+ : (s.display.includes('flex') ? (s.flexDirection.startsWith('row') ? 'row' : 'col') : 'block');
195
+ // ⚠️ `querySelector` **只找后代**。最内层的那条带很可能**自己就是**那张
196
+ // 全幅 hero 图/视频 —— 于是它被标成 noimg,图 hero 和纯文字 hero 聚成一类。
197
+ const IMGISH = 'img, picture, video, svg';
198
+ const hasImg = (el.matches(IMGISH) || el.querySelector(IMGISH)) ? 'img' : 'noimg';
199
+ const chars = (el.textContent || '').trim().length;
200
+ const density = chars < 120 ? 'bare' : (chars < 800 ? 'some' : 'lots');
201
+ return `${layout}|${hasImg}|${density}`;
202
+ };
203
+ const shapes = new Map();
204
+ for (const el of sections) {
205
+ const k = sig(el);
206
+ shapes.set(k, (shapes.get(k) || 0) + 1);
207
+ }
208
+ // 交互重心:值得动手的东西。
209
+ // ⚠️ 三个坑,都是审查在真站上撞出来的:
210
+ // ① `[class*=tab]` 是子串匹配 → `.tabular-figures`、`.tabbed-wrapper` 全中;
211
+ // `:not([class*=table])` 也补不完。改成按 class **token** 比对。
212
+ // ② 一个元素同时命中两条选择器(既 `[role=tab]` 又 `.tab-item`)会被数两遍。
213
+ // 改用 Set 存元素再取 size。
214
+ // ③ 裸 `[aria-expanded]` 把汉堡菜单、下拉导航全算成"手风琴"。它们确实是
215
+ // disclosure,但不是"这页的交互重心"。单列一档,别混进 accordions。
216
+ const clsTokens = (el) => (typeof el.className === 'string'
217
+ ? el.className.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean) : []);
218
+ const hasTok = (el, names) => clsTokens(el).some(t => names.has(t));
219
+ const setOf = (sel, pred) => {
220
+ const out = new Set();
221
+ for (const el of document.querySelectorAll(sel)) { if (!pred || pred(el)) out.add(el); }
222
+ return out;
223
+ };
224
+ const TAB_TOK = new Set(['tab', 'tabs']);
225
+ const ACC_TOK = new Set(['accordion', 'accordions', 'collapse', 'collapsible', 'disclosure']);
226
+ const interactive = {
227
+ forms: setOf('form').size,
228
+ tabs: setOf('[role=tab], [role=tablist], [class]', el => el.getAttribute('role') === 'tab'
229
+ || el.getAttribute('role') === 'tablist' || hasTok(el, TAB_TOK)).size,
230
+ accordions: setOf('details, [class]', el => el.tagName === 'DETAILS' || hasTok(el, ACC_TOK)).size,
231
+ disclosures: setOf('[aria-expanded]').size, // 多半是导航开关,单独列
232
+ draggables: setOf('[draggable=true], input[type=range]').size,
233
+ videos: setOf('video').size,
234
+ };
235
+
236
+ return {
237
+ url: location.href,
238
+ title: document.title,
239
+ viewportWidth: window.innerWidth,
240
+ palette,
241
+ fonts,
242
+ skeleton: {
243
+ sectionCount: sections.length,
244
+ shapeKinds: shapes.size,
245
+ shapes: [...shapes.entries()].map(([k, n]) => ({ signature: k, count: n })),
246
+ interactive,
247
+ // disclosures 不计进"交互点":它多半是汉堡菜单/下拉导航,
248
+ // 每个站都有,加进去只会把这个数字变成噪音
249
+ interactivePoints: ['forms', 'tabs', 'accordions', 'draggables', 'videos']
250
+ .reduce((a, k) => a + interactive[k], 0),
251
+ _note: '启发式:节的形状种类是按布局签名聚类数出来的,当对照数字用,别当真相。'
252
+ + '同一页做 A/B 最可靠;跨页比较受各家标记方式影响,看数量级别抠个位数。',
253
+ },
254
+ };
255
+ };
256
+ }
257
+ /* eslint-enable no-undef */
258
+
259
+ /**
260
+ * 采一次。
261
+ * @param {object} p
262
+ * @param {import('playwright').Page} p.page
263
+ * @param {string} p.workspaceRoot
264
+ * @param {string[]} p.kinds screenshot / palette / fonts / css / skeleton
265
+ * @param {string} [p.name] 文件名主干(不给就按站名+时间)
266
+ * @param {string} [p.lookingFor] agent 当时在找什么 —— 三天后没有这句就是一堆不知道哪来的图
267
+ * @param {string} [p.selector] css 那一种要指一块
268
+ * @param {object} [p.ids] {sessionId, runId}
269
+ * @param {(buf: Buffer) => Promise<{data: string, mimeType: string, note?: string}>} [p.normalize]
270
+ * @returns {Promise<{files: Array<{rel: string, kind: string, bytes: number}>, data: object}>}
271
+ */
272
+ export async function capture({
273
+ page, workspaceRoot, kinds, name, lookingFor, selector, ids = {}, normalize,
274
+ }) {
275
+ const info = await page.evaluate(`(${collectorSource().toString()})()`);
276
+ // 一站一文件夹(见 CAPTURE_DIR 的注释)。子目录相对工作区根的前缀,
277
+ // 落盘和返回给 agent 的 rel 都从它拼 —— 只有一处拼法。
278
+ const siteDir = path.posix.join(CAPTURE_DIR, siteDirOf(info.url));
279
+ const dir = path.join(workspaceRoot, siteDir);
280
+ const metaDir = path.join(dir, '.meta');
281
+ await fs.mkdir(metaDir, { recursive: true });
282
+
283
+ // 文件名主干不再重复站名(目录已经说了是哪个站):给的名字优先,
284
+ // 否则用页面在站内的位置 + 时间 —— 同一个站逛三层会采三份,得分得开。
285
+ const pageSlug = (() => {
286
+ try {
287
+ const segs = new URL(info.url).pathname.split('/').filter(Boolean);
288
+ return segs.length ? slug(segs.slice(-2).join('-')) : '首页';
289
+ } catch { return '页面'; }
290
+ })();
291
+ // `0818-2253`(月日-时分,+8 时区)。原来是 `.slice(5,16)` 直接把 ISO 的
292
+ // `08-18T22:53` 去掉分隔符,读出来是 `08-182253` —— 文件名是给人看的
293
+ const iso = new Date(Date.now() + 8 * 3600e3).toISOString();
294
+ const stamp = `${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}`;
295
+ const stem = slug(name || `${pageSlug}-${stamp}`);
296
+
297
+ const files = [];
298
+ const failed = [];
299
+
300
+ /** 出处 sidecar 的键 = 文件名去掉最后一节扩展名(`listReferences` 用的是同一条规则) */
301
+ const metaStemOf = (fileName) => fileName.replace(/\.[^.]+$/, '');
302
+
303
+ /**
304
+ * 写一份素材 + 它自己的出处。
305
+ *
306
+ * ⭐ **类别写在文件名里**(2026-08-18 用户拍板):`.screenshot.webp` /
307
+ * `.palette.json` / `.fonts.json` / `.skeleton.json` / `.css`。这跟站点产物是
308
+ * 同一条规矩 —— 形态由文件名表明,不用打开文件才知道里面是什么。
309
+ * 在这之前调色板/字体/结构/CSS **全挤在一个 `<名>.json` 里**,抽屉里看到的
310
+ * 是一个什么都可能的 json,agent 下个会话想复用得先读一遍才知道有没有它要的。
311
+ *
312
+ * 出处**一份文件一份**,不是一次采集一份:每份素材要能单独被认出来
313
+ * (三天后 `assets/references/web/` 里的东西是靠自己说明自己的)。
314
+ */
315
+ const write = async (suffix, body, kind) => {
316
+ const fileName = `${stem}${suffix}`;
317
+ const rel = path.posix.join(siteDir, fileName);
318
+ await fs.writeFile(path.join(workspaceRoot, rel), body);
319
+ files.push({ rel, kind, bytes: Buffer.byteLength(body) });
320
+ await fs.writeFile(path.join(metaDir, `${metaStemOf(fileName)}.json`), `${JSON.stringify({
321
+ kind,
322
+ sourceUrl: info.url,
323
+ pageTitle: info.title,
324
+ capturedAt: new Date().toISOString(),
325
+ viewportWidth: info.viewportWidth,
326
+ lookingFor: lookingFor || null,
327
+ selector: selector || null,
328
+ sessionId: ids.sessionId ?? null,
329
+ runId: ids.runId ?? null,
330
+ }, null, 2)}\n`, 'utf8');
331
+ return rel;
332
+ };
333
+
334
+ /** 一档一个 json,各自带齐来历 —— 拿单独一份出来看也知道它是什么 */
335
+ const writeJson = (suffix, payload, kind) => write(suffix, `${JSON.stringify({
336
+ source: info.url, title: info.title, kind, ...payload,
337
+ }, null, 2)}\n`, kind);
338
+
339
+ // ⚠️ **每一种单独 try**。第一版是一路顺着写下来的,真跑时 `selector: 'main'` 在
340
+ // MDN 上等了 30 秒超时 → 整个函数抛错 → 调色板/字体/结构/CSS 明明都已经采到了
341
+ // (evaluate 跑在最前面),连出处 sidecar 都没写下去,**一次白采**。
342
+ // 部分失败不该丢掉已经成功的部分。
343
+ if (kinds.includes('screenshot')) {
344
+ try {
345
+ let raw;
346
+ if (selector) {
347
+ const loc = page.locator(selector).first();
348
+ // 短超时快速失败:一个选不中的选择器不该让整次采集卡 30 秒
349
+ await loc.waitFor({ state: 'visible', timeout: 6000 });
350
+ raw = await loc.screenshot({ type: 'png', timeout: 8000 });
351
+ } else {
352
+ raw = await page.screenshot({ type: 'png', fullPage: false });
353
+ }
354
+ // 走跟感知层同一条归一化(webp、缩到 API 反正会缩的规格)—— 参考图不需要
355
+ // 无损,而 76MB 的 references 目录已经是这个项目的既有账
356
+ const shot = normalize ? await normalize(raw) : null;
357
+ const buf = shot ? Buffer.from(shot.data, 'base64') : raw;
358
+ await write(shot ? '.screenshot.webp' : '.screenshot.png', buf, 'screenshot');
359
+ } catch (err) {
360
+ failed.push(`screenshot: ${err.message.split('\n')[0]}`
361
+ + (selector ? `(选择器 ${selector} 可能选不中或不可见 —— 先用 browser_read 看页面上有什么)` : ''));
362
+ }
363
+ }
364
+
365
+ const bundle = {};
366
+ if (kinds.includes('palette')) bundle.palette = info.palette;
367
+ if (kinds.includes('fonts')) bundle.fonts = info.fonts;
368
+ if (kinds.includes('skeleton')) bundle.skeleton = info.skeleton;
369
+ if (kinds.includes('css') && selector) {
370
+ // 复用 explain_style 那条 CDP 通路:拿这个元素**命中的全部规则**,
371
+ // 而不是 computed 值 —— 前者能照抄,后者只是结果
372
+ try {
373
+ const cdp = await page.context().newCDPSession(page);
374
+ await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
375
+ const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
376
+ const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
377
+ if (!nodeId) {
378
+ // 原来这里没有 else:选择器选不中就静默少一份产物,agent 看不到
379
+ failed.push(`css: 选择器 ${selector} 在页面上选不中(先 browser_read 看页面上有什么)`);
380
+ } else {
381
+ const m = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
382
+ bundle.css = (m.matchedCSSRules || []).map(r => ({
383
+ selector: r.rule?.selectorList?.text,
384
+ media: (r.rule?.media || []).map(x => x.text).filter(Boolean),
385
+ declarations: (r.rule?.style?.cssProperties || [])
386
+ .filter(d => !d.disabled && d.value != null && d.range) // 有 range = 作者真写了这条
387
+ .map(d => `${d.name}: ${d.value}${d.important ? ' !important' : ''}`),
388
+ })).filter(r => r.declarations.length);
389
+ }
390
+ } catch (err) {
391
+ // ⚠️ 原来只写进磁盘 JSON 的 cssError,返回给 agent 的文本里**一个字都没有**
392
+ // (打印分支只在 `r.data.css` 存在时才跑)→ css 这一档完全静默失败
393
+ bundle.cssError = err.message;
394
+ failed.push(`css: ${err.message.split('\n')[0]}`);
395
+ }
396
+ }
397
+ // motion 档(08-21):引擎在 engine/motion/inventory.js(对页面无知,产物会话也能用)。
398
+ // 排在截图之后 —— 它会真滚一遍页面(做完滚回顶),别把滚动痕迹截进 screenshot 档。
399
+ if (kinds.includes('motion')) {
400
+ try {
401
+ const { collectMotionInventory } = await import('../motion/inventory.js');
402
+ bundle.motion = await collectMotionInventory(page);
403
+ } catch (err) {
404
+ failed.push(`motion: ${err.message.split('\n')[0]}`);
405
+ }
406
+ }
407
+ // 一档一文件。写盘也各自 try —— 一档写不下去不该把别的档带走
408
+ // (跟上面「每一种单独 try」同一条理由,只是这次是磁盘不是页面)。
409
+ for (const [key, suffix] of [['palette', '.palette.json'], ['fonts', '.fonts.json'], ['skeleton', '.skeleton.json'], ['motion', '.motion.json']]) {
410
+ if (bundle[key] == null) continue;
411
+ try { await writeJson(suffix, { [key]: bundle[key] }, key); }
412
+ catch (err) { failed.push(`${key}: 写盘失败 ${err.message}`); }
413
+ }
414
+ if (bundle.css) {
415
+ // ⭐ CSS 落成**真的 CSS**,不是包在 json 里的字符串数组:这一档的全部价值
416
+ // 就是"能照抄",而 json 里的它要先被解开一层才能用。扩展名本身就是类别。
417
+ try { await write('.css', cssText(bundle.css, info, selector), 'css'); }
418
+ catch (err) { failed.push(`css: 写盘失败 ${err.message}`); }
419
+ }
420
+
421
+ return { files, failed, data: { ...bundle, url: info.url, title: info.title } };
422
+ }