@web-auto/webauto 0.1.1 → 0.1.3

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 (354) hide show
  1. package/apps/desktop-console/default-settings.json +1 -0
  2. package/apps/desktop-console/dist/main/index.mjs +1618 -0
  3. package/apps/desktop-console/{src → dist}/main/preload.mjs +10 -0
  4. package/apps/desktop-console/dist/renderer/index.js +3063 -0
  5. package/apps/desktop-console/entry/ui-console.mjs +299 -0
  6. package/apps/webauto/entry/account.mjs +356 -0
  7. package/apps/webauto/entry/lib/account-detect.mjs +160 -0
  8. package/apps/webauto/entry/lib/account-store.mjs +587 -0
  9. package/apps/webauto/entry/lib/profilepool.mjs +1 -1
  10. package/apps/webauto/entry/xhs-install.mjs +27 -3
  11. package/apps/webauto/entry/xhs-status.mjs +152 -0
  12. package/apps/webauto/entry/xhs-unified.mjs +595 -17
  13. package/bin/webauto.mjs +263 -15
  14. package/dist/apps/webauto/server.js +66 -0
  15. package/dist/modules/camo-backend/src/index.js +575 -0
  16. package/dist/modules/camo-backend/src/internal/BrowserSession.js +817 -0
  17. package/dist/modules/camo-backend/src/internal/ElementRegistry.js +61 -0
  18. package/dist/modules/camo-backend/src/internal/ProfileLock.js +85 -0
  19. package/dist/modules/camo-backend/src/internal/SessionManager.js +172 -0
  20. package/dist/modules/camo-backend/src/internal/container-matcher.js +852 -0
  21. package/dist/modules/camo-backend/src/internal/engine-manager.js +258 -0
  22. package/dist/modules/camo-backend/src/internal/fingerprint.js +203 -0
  23. package/dist/modules/camo-backend/src/internal/pageRuntime.js +29 -0
  24. package/dist/modules/camo-backend/src/internal/runtimeInjector.js +30 -0
  25. package/dist/modules/camo-backend/src/internal/state-bus.js +46 -0
  26. package/dist/modules/camo-backend/src/internal/storage-paths.js +36 -0
  27. package/dist/modules/camo-backend/src/internal/ws-server.js +1202 -0
  28. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +423 -0
  29. package/dist/modules/camo-runtime/src/utils/config.mjs +77 -0
  30. package/dist/modules/container-registry/src/index.js +184 -0
  31. package/dist/modules/logging/src/index.js +92 -0
  32. package/dist/modules/operations/src/builtin.js +27 -0
  33. package/dist/modules/operations/src/container-binding.js +75 -0
  34. package/dist/modules/operations/src/executor.js +146 -0
  35. package/dist/modules/operations/src/operations/click.js +167 -0
  36. package/dist/modules/operations/src/operations/extract.js +204 -0
  37. package/dist/modules/operations/src/operations/find-child.js +17 -0
  38. package/dist/modules/operations/src/operations/highlight.js +138 -0
  39. package/dist/modules/operations/src/operations/key.js +61 -0
  40. package/dist/modules/operations/src/operations/navigate.js +148 -0
  41. package/dist/modules/operations/src/operations/scroll.js +126 -0
  42. package/dist/modules/operations/src/operations/type.js +190 -0
  43. package/dist/modules/operations/src/queue.js +100 -0
  44. package/dist/modules/operations/src/registry.js +11 -0
  45. package/dist/modules/operations/src/system/mouse.js +33 -0
  46. package/dist/modules/state/src/atomic-json.js +33 -0
  47. package/dist/modules/workflow/blocks/AnchorVerificationBlock.js +71 -0
  48. package/dist/modules/workflow/blocks/BehaviorRandomizer.js +26 -0
  49. package/dist/modules/workflow/blocks/CallWorkflowBlock.js +38 -0
  50. package/dist/modules/workflow/blocks/CloseDetailBlock.js +209 -0
  51. package/dist/modules/workflow/blocks/CollectBatch.js +137 -0
  52. package/dist/modules/workflow/blocks/CollectCommentsBlock.js +415 -0
  53. package/dist/modules/workflow/blocks/CollectSearchListBlock.js +599 -0
  54. package/dist/modules/workflow/blocks/CollectWeiboPosts.js +229 -0
  55. package/dist/modules/workflow/blocks/DetectPageStateBlock.js +259 -0
  56. package/dist/modules/workflow/blocks/EnsureLoginBlock.js +162 -0
  57. package/dist/modules/workflow/blocks/EnsureSession.js +426 -0
  58. package/dist/modules/workflow/blocks/ErrorClassifier.js +164 -0
  59. package/dist/modules/workflow/blocks/ErrorRecoveryBlock.js +319 -0
  60. package/dist/modules/workflow/blocks/ExpandCommentsBlock.js +1032 -0
  61. package/dist/modules/workflow/blocks/ExtractDetailBlock.js +310 -0
  62. package/dist/modules/workflow/blocks/ExtractPostFields.js +88 -0
  63. package/dist/modules/workflow/blocks/GenerateSmartReplyBlock.js +68 -0
  64. package/dist/modules/workflow/blocks/GoToSearchBlock.js +497 -0
  65. package/dist/modules/workflow/blocks/GracefulFallbackBlock.js +104 -0
  66. package/dist/modules/workflow/blocks/HighlightBlock.js +66 -0
  67. package/dist/modules/workflow/blocks/InitAutoScroll.js +65 -0
  68. package/dist/modules/workflow/blocks/LoadContainerDefinition.js +50 -0
  69. package/dist/modules/workflow/blocks/LoadContainerIndex.js +43 -0
  70. package/dist/modules/workflow/blocks/LocateAndGuardBlock.js +176 -0
  71. package/dist/modules/workflow/blocks/LoginRecoveryBlock.js +242 -0
  72. package/dist/modules/workflow/blocks/MatchContainers.js +64 -0
  73. package/dist/modules/workflow/blocks/MonitoringBlock.js +190 -0
  74. package/dist/modules/workflow/blocks/OpenDetailBlock.js +1240 -0
  75. package/dist/modules/workflow/blocks/OrganizeXhsNotesBlock.js +117 -0
  76. package/dist/modules/workflow/blocks/PersistXhsNoteBlock.js +270 -0
  77. package/dist/modules/workflow/blocks/PickSinglePost.js +69 -0
  78. package/dist/modules/workflow/blocks/ProgressTracker.js +125 -0
  79. package/dist/modules/workflow/blocks/RecordFixtureBlock.js +44 -0
  80. package/dist/modules/workflow/blocks/RenderMarkdown.js +48 -0
  81. package/dist/modules/workflow/blocks/SaveFile.js +54 -0
  82. package/dist/modules/workflow/blocks/ScrollNextBatch.js +72 -0
  83. package/dist/modules/workflow/blocks/SessionHealthBlock.js +73 -0
  84. package/dist/modules/workflow/blocks/StartBrowserService.js +45 -0
  85. package/dist/modules/workflow/blocks/ValidateContainerDefinition.js +67 -0
  86. package/dist/modules/workflow/blocks/ValidateExtract.js +35 -0
  87. package/dist/modules/workflow/blocks/WaitSearchPermitBlock.js +162 -0
  88. package/dist/modules/workflow/blocks/WaitStable.js +74 -0
  89. package/dist/modules/workflow/blocks/WarmupCommentsBlock.js +120 -0
  90. package/dist/modules/workflow/blocks/WorkflowExecutor.js +156 -0
  91. package/dist/modules/workflow/blocks/XiaohongshuCollectFromLinksBlock.js +1004 -0
  92. package/dist/modules/workflow/blocks/XiaohongshuCollectLinksBlock.js +1049 -0
  93. package/dist/modules/workflow/blocks/XiaohongshuFullCollectBlock.js +782 -0
  94. package/dist/modules/workflow/blocks/helpers/anchorVerify.js +198 -0
  95. package/dist/modules/workflow/blocks/helpers/asyncWorkQueue.js +53 -0
  96. package/dist/modules/workflow/blocks/helpers/commentScroller.js +334 -0
  97. package/dist/modules/workflow/blocks/helpers/commentSectionLocator.js +126 -0
  98. package/dist/modules/workflow/blocks/helpers/containerAnchors.js +301 -0
  99. package/dist/modules/workflow/blocks/helpers/debugArtifacts.js +6 -0
  100. package/dist/modules/workflow/blocks/helpers/downloadPaths.js +29 -0
  101. package/dist/modules/workflow/blocks/helpers/expandCommentsController.js +53 -0
  102. package/dist/modules/workflow/blocks/helpers/expandCommentsExtractor.js +129 -0
  103. package/dist/modules/workflow/blocks/helpers/macosVisionOcrPlugin.js +116 -0
  104. package/dist/modules/workflow/blocks/helpers/mergeXhsMarkdown.js +109 -0
  105. package/dist/modules/workflow/blocks/helpers/openDetailController.js +56 -0
  106. package/dist/modules/workflow/blocks/helpers/openDetailTypes.js +7 -0
  107. package/dist/modules/workflow/blocks/helpers/openDetailViewport.js +474 -0
  108. package/dist/modules/workflow/blocks/helpers/openDetailWaiter.js +104 -0
  109. package/dist/modules/workflow/blocks/helpers/operationLogger.js +195 -0
  110. package/dist/modules/workflow/blocks/helpers/persistedNotes.js +107 -0
  111. package/dist/modules/workflow/blocks/helpers/replyExpander.js +260 -0
  112. package/dist/modules/workflow/blocks/helpers/scrollIntoView.js +138 -0
  113. package/dist/modules/workflow/blocks/helpers/searchExecutor.js +328 -0
  114. package/dist/modules/workflow/blocks/helpers/searchGate.js +46 -0
  115. package/dist/modules/workflow/blocks/helpers/searchPageState.js +164 -0
  116. package/dist/modules/workflow/blocks/helpers/searchResultWaiter.js +64 -0
  117. package/dist/modules/workflow/blocks/helpers/simpleAnchor.js +134 -0
  118. package/dist/modules/workflow/blocks/helpers/smartReply.js +40 -0
  119. package/dist/modules/workflow/blocks/helpers/systemInput.js +635 -0
  120. package/dist/modules/workflow/blocks/helpers/targetCountMode.js +9 -0
  121. package/dist/modules/workflow/blocks/helpers/xhsCliArgs.js +80 -0
  122. package/dist/modules/workflow/blocks/helpers/xhsCommentDom.js +805 -0
  123. package/dist/modules/workflow/blocks/helpers/xhsNoteOrganizer.js +140 -0
  124. package/dist/modules/workflow/blocks/restore/RestorePhaseBlock.js +204 -0
  125. package/dist/modules/workflow/config/workflowRegistry.js +32 -0
  126. package/dist/modules/workflow/definitions/batch-collect-workflow.js +63 -0
  127. package/dist/modules/workflow/definitions/scroll-extract-workflow.js +74 -0
  128. package/dist/modules/workflow/definitions/xiaohongshu-collect-workflow-v2.js +81 -0
  129. package/dist/modules/workflow/definitions/xiaohongshu-collect-workflow.js +57 -0
  130. package/dist/modules/workflow/definitions/xiaohongshu-full-collect-workflow-v3.js +68 -0
  131. package/dist/modules/workflow/definitions/xiaohongshu-note-collect.js +49 -0
  132. package/dist/modules/workflow/definitions/xiaohongshu-phase1-workflow-v3.js +30 -0
  133. package/dist/modules/workflow/definitions/xiaohongshu-phase2-links-workflow-v3.js +40 -0
  134. package/dist/modules/workflow/definitions/xiaohongshu-phase3-collect-workflow-v1.js +54 -0
  135. package/dist/modules/workflow/definitions/xiaohongshu-phase34-from-links-workflow-v3.js +25 -0
  136. package/dist/modules/workflow/src/WeiboEventDrivenWorkflowRunner.js +308 -0
  137. package/dist/modules/workflow/src/context.js +70 -0
  138. package/dist/modules/workflow/src/index.js +5 -0
  139. package/dist/modules/workflow/src/orchestrator.js +230 -0
  140. package/dist/modules/workflow/src/runner.js +55 -0
  141. package/dist/modules/workflow/src/runtime.js +70 -0
  142. package/dist/modules/workflow/workflows/WeiboFeedExtractionWorkflow.js +359 -0
  143. package/dist/modules/workflow/workflows/XiaohongshuLoginWorkflow.js +110 -0
  144. package/dist/modules/xiaohongshu/app/src/blocks/MatchCommentsBlock.js +139 -0
  145. package/dist/modules/xiaohongshu/app/src/blocks/Phase1EnsureServicesBlock.js +36 -0
  146. package/dist/modules/xiaohongshu/app/src/blocks/Phase1MonitorCookieBlock.js +213 -0
  147. package/dist/modules/xiaohongshu/app/src/blocks/Phase1StartProfileBlock.js +121 -0
  148. package/dist/modules/xiaohongshu/app/src/blocks/Phase2CollectLinksBlock.js +1249 -0
  149. package/dist/modules/xiaohongshu/app/src/blocks/Phase2SearchBlock.js +703 -0
  150. package/dist/modules/xiaohongshu/app/src/blocks/Phase34CloseDetailBlock.js +41 -0
  151. package/dist/modules/xiaohongshu/app/src/blocks/Phase34CloseTabsBlock.js +44 -0
  152. package/dist/modules/xiaohongshu/app/src/blocks/Phase34CollectCommentsBlock.js +150 -0
  153. package/dist/modules/xiaohongshu/app/src/blocks/Phase34ExtractDetailBlock.js +117 -0
  154. package/dist/modules/xiaohongshu/app/src/blocks/Phase34OpenDetailBlock.js +102 -0
  155. package/dist/modules/xiaohongshu/app/src/blocks/Phase34OpenTabsBlock.js +109 -0
  156. package/dist/modules/xiaohongshu/app/src/blocks/Phase34PersistDetailBlock.js +117 -0
  157. package/dist/modules/xiaohongshu/app/src/blocks/Phase34ProcessSingleNoteBlock.js +114 -0
  158. package/dist/modules/xiaohongshu/app/src/blocks/Phase34ValidateLinksBlock.js +90 -0
  159. package/dist/modules/xiaohongshu/app/src/blocks/Phase3InteractBlock.js +1009 -0
  160. package/dist/modules/xiaohongshu/app/src/blocks/Phase4MultiTabHarvestBlock.js +233 -0
  161. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +291 -0
  162. package/dist/modules/xiaohongshu/app/src/blocks/XhsDiscoverFallbackBlock.js +240 -0
  163. package/dist/modules/xiaohongshu/app/src/blocks/helpers/commentMatchDsl.js +126 -0
  164. package/dist/modules/xiaohongshu/app/src/blocks/helpers/commentMatcher.js +99 -0
  165. package/dist/modules/xiaohongshu/app/src/blocks/helpers/evidence.js +27 -0
  166. package/dist/modules/xiaohongshu/app/src/blocks/helpers/sharding.js +42 -0
  167. package/dist/modules/xiaohongshu/app/src/blocks/helpers/xhsComments.js +270 -0
  168. package/dist/modules/xiaohongshu/app/src/index.js +9 -0
  169. package/dist/modules/xiaohongshu/app/src/utils/checkpoints.js +222 -0
  170. package/dist/modules/xiaohongshu/app/src/utils/controllerAction.js +43 -0
  171. package/dist/services/controller/src/controller.js +1476 -0
  172. package/dist/services/controller/src/index.js +2 -0
  173. package/dist/services/controller/src/payload-normalizer.js +129 -0
  174. package/dist/services/shared/heartbeat.js +120 -0
  175. package/dist/services/shared/lib/errorHandler.js +2 -0
  176. package/dist/services/shared/serviceProcessLogger.js +139 -0
  177. package/dist/services/unified-api/RemoteBrowserSession.js +176 -0
  178. package/dist/services/unified-api/RemoteSessionManager.js +148 -0
  179. package/dist/services/unified-api/container-operations-handler.js +115 -0
  180. package/dist/services/unified-api/server.js +652 -0
  181. package/dist/services/unified-api/state-registry.js +274 -0
  182. package/dist/services/unified-api/task-persistence.js +66 -0
  183. package/dist/services/unified-api/task-state.js +130 -0
  184. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +12 -5
  185. package/modules/xiaohongshu/app/pnpm-lock.yaml +24 -0
  186. package/package.json +38 -10
  187. package/.beads/README.md +0 -81
  188. package/.beads/config.yaml +0 -67
  189. package/.beads/interactions.jsonl +0 -0
  190. package/.beads/issues.jsonl +0 -180
  191. package/.beads/metadata.json +0 -4
  192. package/.claude/settings.local.json +0 -10
  193. package/.github/workflows/ci.yml +0 -55
  194. package/AGENTS.md +0 -253
  195. package/apps/desktop-console/README.md +0 -27
  196. package/apps/desktop-console/package-lock.json +0 -897
  197. package/apps/desktop-console/package.json +0 -20
  198. package/apps/desktop-console/scripts/build-and-install.mjs +0 -19
  199. package/apps/desktop-console/scripts/build.mjs +0 -45
  200. package/apps/desktop-console/scripts/test-preload.mjs +0 -13
  201. package/apps/desktop-console/src/main/config.mts +0 -26
  202. package/apps/desktop-console/src/main/core-daemon-manager.mts +0 -131
  203. package/apps/desktop-console/src/main/desktop-settings.mts +0 -267
  204. package/apps/desktop-console/src/main/heartbeat-watchdog.mts +0 -50
  205. package/apps/desktop-console/src/main/heartbeat-watchdog.test.mts +0 -68
  206. package/apps/desktop-console/src/main/index-streaming.test.mts +0 -20
  207. package/apps/desktop-console/src/main/index.mts +0 -980
  208. package/apps/desktop-console/src/main/profile-store.mts +0 -239
  209. package/apps/desktop-console/src/main/profile-store.test.mts +0 -54
  210. package/apps/desktop-console/src/main/state-bridge.mts +0 -114
  211. package/apps/desktop-console/src/main/task-state-types.ts +0 -32
  212. package/apps/desktop-console/src/renderer/hooks/use-task-state.mts +0 -120
  213. package/apps/desktop-console/src/renderer/index.mts +0 -133
  214. package/apps/desktop-console/src/renderer/index.test.mts +0 -34
  215. package/apps/desktop-console/src/renderer/path-helpers.mts +0 -46
  216. package/apps/desktop-console/src/renderer/path-helpers.test.mts +0 -14
  217. package/apps/desktop-console/src/renderer/tabs/debug.mts +0 -48
  218. package/apps/desktop-console/src/renderer/tabs/debug.test.mts +0 -22
  219. package/apps/desktop-console/src/renderer/tabs/logs.mts +0 -421
  220. package/apps/desktop-console/src/renderer/tabs/logs.test.mts +0 -27
  221. package/apps/desktop-console/src/renderer/tabs/preflight.mts +0 -486
  222. package/apps/desktop-console/src/renderer/tabs/preflight.test.mts +0 -33
  223. package/apps/desktop-console/src/renderer/tabs/profile-pool.mts +0 -213
  224. package/apps/desktop-console/src/renderer/tabs/results.mts +0 -171
  225. package/apps/desktop-console/src/renderer/tabs/run.test.mts +0 -63
  226. package/apps/desktop-console/src/renderer/tabs/runtime.mts +0 -151
  227. package/apps/desktop-console/src/renderer/tabs/settings.mts +0 -146
  228. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/account-flow.mts +0 -486
  229. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/guide-browser-check.mts +0 -56
  230. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/helpers.mts +0 -262
  231. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/layout-block.mts +0 -430
  232. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/live-stats.mts +0 -847
  233. package/apps/desktop-console/src/renderer/tabs/xiaohongshu/run-flow.mts +0 -443
  234. package/apps/desktop-console/src/renderer/tabs/xiaohongshu-state.mts +0 -425
  235. package/apps/desktop-console/src/renderer/tabs/xiaohongshu.mts +0 -497
  236. package/apps/desktop-console/src/renderer/tabs/xiaohongshu.test.mts +0 -291
  237. package/apps/desktop-console/src/renderer/ui-components.mts +0 -31
  238. package/docs/README_camoufox_chinese.md +0 -141
  239. package/docs/USAGE_V3.md +0 -163
  240. package/docs/arch/OCR_MACOS_PLUGIN.md +0 -39
  241. package/docs/arch/PORTS.md +0 -40
  242. package/docs/arch/REGRESSION_CHECKLIST.md +0 -121
  243. package/docs/arch/SEARCH_GATE.md +0 -224
  244. package/docs/arch/VIEWPORT_SAFETY.md +0 -182
  245. package/docs/arch/XIAOHONGSHU_OFFLINE_MOCK_DESIGN.md +0 -267
  246. package/docs/xiaohongshu-container-driven-summary.md +0 -221
  247. package/docs/xiaohongshu-full-collect-runbook.md +0 -134
  248. package/docs/xiaohongshu-next-steps.md +0 -228
  249. package/docs/xiaohongshu-quickstart.md +0 -73
  250. package/docs/xiaohongshu-workflow-summary.md +0 -227
  251. package/modules/container-registry/tests/container-registry.test.ts +0 -16
  252. package/modules/logging/tests/logging.test.ts +0 -38
  253. package/modules/operations/tests/operations.test.ts +0 -22
  254. package/modules/operations/tests/viewport-filter.test.ts +0 -161
  255. package/modules/operations/tests/visible-only.test.ts +0 -250
  256. package/modules/session-manager/tests/session-manager.test.ts +0 -23
  257. package/modules/state/src/atomic-json.test.ts +0 -30
  258. package/modules/state/src/paths.test.ts +0 -59
  259. package/modules/state/src/xiaohongshu-collect-state.test.ts +0 -259
  260. package/modules/workflow/blocks/AnchorVerificationBlock.d.ts.map +0 -1
  261. package/modules/workflow/blocks/AnchorVerificationBlock.js.map +0 -1
  262. package/modules/workflow/blocks/DetectPageStateBlock.d.ts.map +0 -1
  263. package/modules/workflow/blocks/DetectPageStateBlock.js.map +0 -1
  264. package/modules/workflow/blocks/ErrorRecoveryBlock.d.ts.map +0 -1
  265. package/modules/workflow/blocks/ErrorRecoveryBlock.js.map +0 -1
  266. package/modules/workflow/blocks/WaitSearchPermitBlock.d.ts.map +0 -1
  267. package/modules/workflow/blocks/WaitSearchPermitBlock.js.map +0 -1
  268. package/modules/workflow/blocks/helpers/containerAnchors.d.ts.map +0 -1
  269. package/modules/workflow/blocks/helpers/containerAnchors.js.map +0 -1
  270. package/modules/workflow/blocks/helpers/downloadPaths.test.ts +0 -62
  271. package/modules/workflow/blocks/helpers/mergeXhsMarkdown.test.ts +0 -121
  272. package/modules/workflow/blocks/helpers/operationLogger.d.ts.map +0 -1
  273. package/modules/workflow/blocks/helpers/operationLogger.js.map +0 -1
  274. package/modules/workflow/blocks/helpers/persistedNotes.test.ts +0 -268
  275. package/modules/workflow/blocks/helpers/searchPageState.d.ts.map +0 -1
  276. package/modules/workflow/blocks/helpers/searchPageState.js.map +0 -1
  277. package/modules/workflow/blocks/helpers/targetCountMode.test.ts +0 -29
  278. package/modules/workflow/blocks/helpers/xhsCliArgs.test.ts +0 -75
  279. package/modules/workflow/tests/smartReply.test.ts +0 -32
  280. package/modules/xiaohongshu/app/src/blocks/Phase3Interact.matcher.test.ts +0 -33
  281. package/modules/xiaohongshu/app/src/utils/__tests__/checkpoints.test.ts +0 -141
  282. package/modules/xiaohongshu/app/tests/commentMatchDsl.test.ts +0 -50
  283. package/modules/xiaohongshu/app/tests/commentMatcher.test.ts +0 -46
  284. package/modules/xiaohongshu/app/tests/sharding.test.ts +0 -31
  285. package/package-scripts.json +0 -8
  286. package/runtime/infra/utils/README.md +0 -13
  287. package/runtime/infra/utils/scripts/README.md +0 -0
  288. package/runtime/infra/utils/scripts/development/eval-in-session.mjs +0 -40
  289. package/runtime/infra/utils/scripts/development/highlight-search-containers.mjs +0 -35
  290. package/runtime/infra/utils/scripts/service/kill-port.mjs +0 -24
  291. package/runtime/infra/utils/scripts/service/start-api.mjs +0 -39
  292. package/runtime/infra/utils/scripts/service/start-browser-service.mjs +0 -106
  293. package/runtime/infra/utils/scripts/service/stop-api.mjs +0 -18
  294. package/runtime/infra/utils/scripts/service/stop-browser-service.mjs +0 -104
  295. package/runtime/infra/utils/scripts/test-services.mjs +0 -94
  296. package/services/shared/heartbeat.test.ts +0 -102
  297. package/services/unified-api/__tests__/task-state.test.ts +0 -95
  298. package/sitecustomize.py +0 -19
  299. package/tests/README.md +0 -194
  300. package/tests/e2e/workflows/weibo-feed-extraction.test.ts +0 -171
  301. package/tests/fixtures/data/container-definitions.json +0 -67
  302. package/tests/fixtures/pages/simple-page.html +0 -69
  303. package/tests/integration/01-test-container-match.mjs +0 -188
  304. package/tests/integration/02-test-dom-branch.mjs +0 -161
  305. package/tests/integration/03-test-container-operation-system.mjs +0 -91
  306. package/tests/integration/05-test-container-lifecycle-events.mjs +0 -224
  307. package/tests/integration/05-test-container-lifecycle-with-events.mjs +0 -250
  308. package/tests/integration/06-test-container-dom-tree-drawing.mjs +0 -256
  309. package/tests/integration/07-test-weibo-container-lifecycle.mjs +0 -355
  310. package/tests/integration/08-test-weibo-feed-workflow.test.mjs +0 -164
  311. package/tests/integration/10-test-visual-analyzer.mjs +0 -312
  312. package/tests/integration/11-test-visual-loop.mjs +0 -284
  313. package/tests/integration/12-test-simple-visual-loop.mjs +0 -242
  314. package/tests/integration/13-test-visual-robust.mjs +0 -185
  315. package/tests/integration/14-test-visual-highlight-loop.mjs +0 -271
  316. package/tests/integration/inspect-page.mjs +0 -50
  317. package/tests/integration/run-all-tests.mjs +0 -95
  318. package/tests/patch_verification/CODEX_PATCH_TEST.md +0 -103
  319. package/tests/patch_verification/PHASE2_ANALYSIS.md +0 -179
  320. package/tests/patch_verification/PHASE2_OPTIMIZATION_REPORT.md +0 -55
  321. package/tests/patch_verification/PHASE2_TO_PHASE4_SUMMARY.md +0 -126
  322. package/tests/patch_verification/QUICK_TEST_SEQUENCE.md +0 -262
  323. package/tests/patch_verification/README.md +0 -143
  324. package/tests/patch_verification/RUN_TESTS.md +0 -60
  325. package/tests/patch_verification/TEST_EXECUTION.md +0 -99
  326. package/tests/patch_verification/TEST_PLAN.md +0 -328
  327. package/tests/patch_verification/TEST_RESULTS.md +0 -34
  328. package/tests/patch_verification/TOOL_TEST_PLAN.md +0 -48
  329. package/tests/patch_verification/run-tool-test.mjs +0 -121
  330. package/tests/patch_verification/temp_test_files/test01.txt +0 -1
  331. package/tests/patch_verification/temp_test_files/test02.txt +0 -3
  332. package/tests/patch_verification/temp_test_files/test02_gnu.txt +0 -3
  333. package/tests/patch_verification/temp_test_files/test03.txt +0 -1
  334. package/tests/patch_verification/temp_test_files/test03_multiline.txt +0 -5
  335. package/tests/patch_verification/temp_test_files/test04_function.ts +0 -5
  336. package/tests/patch_verification/temp_test_files/test05_import.ts +0 -4
  337. package/tests/patch_verification/temp_test_files/test06_special_chars.txt +0 -4
  338. package/tests/patch_verification/temp_test_files/test07_indentation.ts +0 -5
  339. package/tests/patch_verification/temp_test_files/test08_mismatch.txt +0 -1
  340. package/tests/patch_verification/temp_test_files/test_add_02.txt +0 -3
  341. package/tests/patch_verification/temp_test_files/test_simple.txt +0 -1
  342. package/tests/runner/TestReporter.mjs +0 -57
  343. package/tests/runner/TestRunner.mjs +0 -244
  344. package/tests/unit/commands/profile.test.mjs +0 -10
  345. package/tests/unit/container/change-notifier.test.mjs +0 -181
  346. package/tests/unit/lifecycle/session-registry.test.mjs +0 -135
  347. package/tests/unit/operations/registry.test.ts +0 -73
  348. package/tests/unit/utils/browser-service.test.mjs +0 -153
  349. package/tests/unit/utils/config.test.mjs +0 -166
  350. package/tests/unit/utils/fingerprint.test.mjs +0 -166
  351. package/tsconfig.json +0 -31
  352. package/tsconfig.services.json +0 -26
  353. /package/apps/desktop-console/{src → dist}/renderer/index.html +0 -0
  354. /package/apps/desktop-console/{src/renderer/tabs → dist/renderer}/run.mts +0 -0
@@ -0,0 +1,1249 @@
1
+ /**
2
+ * Phase 2 Block: 采集安全链接
3
+ *
4
+ * 职责:通过容器点击进入详情,获取带 xsec_token 的安全 URL
5
+ */
6
+ import { ContainerRegistry } from '../../../../container-registry/src/index.js';
7
+ import { execute as discoverFallback } from './XhsDiscoverFallbackBlock.js';
8
+ import { detectXhsCheckpoint, ensureXhsCheckpoint } from '../utils/checkpoints.js';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { promises as fs } from 'node:fs';
12
+ import { controllerAction, delay } from '../utils/controllerAction.js';
13
+ // controllerAction/delay are shared utilities (with safer timeouts) to avoid per-block drift.
14
+ function decodeURIComponentSafe(value) {
15
+ try {
16
+ return decodeURIComponent(value);
17
+ }
18
+ catch {
19
+ return value;
20
+ }
21
+ }
22
+ function decodeRepeated(value, maxRounds = 3) {
23
+ let current = value;
24
+ for (let i = 0; i < maxRounds; i++) {
25
+ const next = decodeURIComponentSafe(current);
26
+ if (next === current)
27
+ break;
28
+ current = next;
29
+ }
30
+ return current;
31
+ }
32
+ function getKeywordFromSearchUrl(searchUrl) {
33
+ try {
34
+ const url = new URL(searchUrl);
35
+ const raw = url.searchParams.get('keyword') || '';
36
+ if (raw) {
37
+ const decoded = decodeRepeated(raw);
38
+ return decoded.trim();
39
+ }
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ return null;
45
+ }
46
+ function matchesKeywordFromSearchUrlStrict(searchUrl, keyword) {
47
+ return getKeywordFromSearchUrl(searchUrl) === keyword;
48
+ }
49
+ function resolveDownloadRoot() {
50
+ const custom = process.env.WEBAUTO_DOWNLOAD_ROOT || process.env.WEBAUTO_DOWNLOAD_DIR;
51
+ if (custom && custom.trim())
52
+ return custom;
53
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
54
+ return path.join(home, '.webauto', 'download');
55
+ }
56
+ function isDebugArtifactsEnabled() {
57
+ return (process.env.WEBAUTO_DEBUG === '1' ||
58
+ process.env.WEBAUTO_DEBUG_ARTIFACTS === '1' ||
59
+ process.env.WEBAUTO_DEBUG_SCREENSHOT === '1');
60
+ }
61
+ function isValidSearchUrl(searchUrl, keyword) {
62
+ try {
63
+ const url = new URL(searchUrl);
64
+ if (!url.hostname.endsWith('xiaohongshu.com'))
65
+ return false;
66
+ if (!url.pathname.includes('/search_result'))
67
+ return false;
68
+ return matchesKeywordFromSearchUrlStrict(searchUrl, keyword);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ function isValidSafeUrl(safeUrl) {
75
+ try {
76
+ const url = new URL(safeUrl);
77
+ if (!url.hostname.endsWith('xiaohongshu.com'))
78
+ return false;
79
+ if (!/\/explore\/[a-f0-9]+/.test(url.pathname))
80
+ return false;
81
+ if (!url.searchParams.get('xsec_token'))
82
+ return false;
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ async function clickDiscoverAndRetrySearch(opts) {
90
+ const { profile, unifiedApiUrl, keyword, env, appendTrace } = opts;
91
+ await appendTrace({ type: 'discover_fallback_block_start', ts: new Date().toISOString() });
92
+ const out = await discoverFallback({ profile, unifiedApiUrl, keyword, env });
93
+ await appendTrace({ type: 'discover_fallback_block_done', ts: new Date().toISOString(), out });
94
+ if (!out.success) {
95
+ throw new Error(`[Phase2Collect] Discover fallback block failed: checkpoint=${out.finalCheckpoint} url=${out.finalUrl} screenshot=${out.screenshotPath || ''} dom=${out.domDumpPath || ''}`);
96
+ }
97
+ }
98
+ function getExploreIdFromUrl(urlString) {
99
+ try {
100
+ const url = new URL(urlString);
101
+ const m = url.pathname.match(/\/explore\/([a-f0-9]+)/);
102
+ return m ? m[1] : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ function normalizeNoteId(raw) {
109
+ const value = String(raw || '').trim();
110
+ if (!value)
111
+ return null;
112
+ const fromPath = value.match(/\/explore\/([a-f0-9]+)/i);
113
+ if (fromPath?.[1])
114
+ return fromPath[1].toLowerCase();
115
+ if (/^[a-f0-9]{8,}$/i.test(value))
116
+ return value.toLowerCase();
117
+ return null;
118
+ }
119
+ export async function execute(input) {
120
+ const { keyword, targetCount, profile = 'xiaohongshu_fresh', unifiedApiUrl = 'http://127.0.0.1:7701', env = 'debug', alreadyCollectedNoteIds = [], onLink, } = input;
121
+ console.log(`[Phase2CollectLinks] 目标: ${targetCount} 条链接`);
122
+ // Ensure we are in a safe starting state (search). Recover from detail/comments if needed.
123
+ const ensureRes = await ensureXhsCheckpoint({
124
+ sessionId: profile,
125
+ target: 'search_ready',
126
+ serviceUrl: unifiedApiUrl,
127
+ timeoutMs: 15000,
128
+ allowOneLevelUpFallback: true,
129
+ });
130
+ if (!ensureRes.success && ensureRes.reached !== 'home_ready' && ensureRes.reached !== 'search_ready') {
131
+ throw new Error(`[Phase2CollectLinks] ensure checkpoint failed: reached=${ensureRes.reached} url=${ensureRes.url}`);
132
+ }
133
+ // 开发期硬门禁:进入采集前先定位,避免在详情/风控态继续执行。
134
+ const det = await detectXhsCheckpoint({ sessionId: profile, serviceUrl: unifiedApiUrl });
135
+ console.log(`[Phase2CollectLinks] locate: checkpoint=${det.checkpoint} url=${det.url}`);
136
+ if (det.checkpoint === 'risk_control' || det.checkpoint === 'login_guard' || det.checkpoint === 'offsite') {
137
+ throw new Error(`[Phase2CollectLinks] hard_stop checkpoint=${det.checkpoint} url=${det.url}`);
138
+ }
139
+ const links = [];
140
+ const seen = new Set();
141
+ const seenNoteIds = new Set(alreadyCollectedNoteIds
142
+ .map((id) => normalizeNoteId(String(id || '')))
143
+ .filter((id) => Boolean(id)));
144
+ const seenExploreIds = new Set(Array.from(seenNoteIds));
145
+ const registry = new ContainerRegistry();
146
+ await registry.load();
147
+ let attempts = 0;
148
+ const maxAttempts = targetCount * 6;
149
+ let scrollCount = 0;
150
+ let scrollLocked = false;
151
+ const debugArtifactsEnabled = isDebugArtifactsEnabled();
152
+ let scrollForVisibilityCount = 0; // Separate counter for visibility scrolls
153
+ let noProgressRetryCount = 0;
154
+ const maxNoProgressRetries = 3;
155
+ let lastLinksCount = 0;
156
+ const traceDir = path.join(resolveDownloadRoot(), 'xiaohongshu', env, keyword, 'click-trace');
157
+ const tracePath = path.join(traceDir, 'trace.jsonl');
158
+ const traceEnabled = debugArtifactsEnabled || process.env.WEBAUTO_CLICK_TRACE === '1';
159
+ const shouldEnsureFocusBeforeMouseFallback = process.platform === 'win32';
160
+ const ensureTraceDir = async () => {
161
+ if (!traceEnabled)
162
+ return;
163
+ await fs.mkdir(traceDir, { recursive: true });
164
+ };
165
+ const appendTrace = async (row) => {
166
+ if (!traceEnabled)
167
+ return;
168
+ await ensureTraceDir();
169
+ await fs.appendFile(tracePath, `${JSON.stringify(row)}\n`, 'utf8');
170
+ };
171
+ const saveScreenshot = async (base64, fileName) => {
172
+ if (!traceEnabled)
173
+ return null;
174
+ await ensureTraceDir();
175
+ const buf = Buffer.from(base64, 'base64');
176
+ const filePath = path.join(traceDir, fileName);
177
+ await fs.writeFile(filePath, buf);
178
+ return filePath;
179
+ };
180
+ let preClickStallCount = 0;
181
+ const preClickStallThreshold = 12;
182
+ const recoverFromPreClickStall = async (reason, extra = {}, options = {}) => {
183
+ preClickStallCount += 1;
184
+ if (preClickStallCount < preClickStallThreshold)
185
+ return;
186
+ const allowScroll = options.allowScroll === true;
187
+ console.log(`[Phase2Collect] pre-click stall ${preClickStallCount}/${preClickStallThreshold} reason=${reason}, ` +
188
+ (allowScroll ? 'forcing scroll' : 'no scroll (picker decides)'));
189
+ await appendTrace({
190
+ type: allowScroll ? 'pre_click_stall_scroll' : 'pre_click_stall_noop',
191
+ ts: new Date().toISOString(),
192
+ reason,
193
+ stallCount: preClickStallCount,
194
+ ...extra,
195
+ });
196
+ if (allowScroll) {
197
+ await controllerAction('container:operation', {
198
+ containerId: 'xiaohongshu_search.search_result_list',
199
+ operationId: 'scroll',
200
+ sessionId: profile,
201
+ config: { direction: 'down', amount: 360 },
202
+ }, unifiedApiUrl);
203
+ scrollCount++;
204
+ await delay(400);
205
+ }
206
+ preClickStallCount = 0;
207
+ };
208
+ const readCurrentUrl = async () => controllerAction('browser:execute', {
209
+ profile,
210
+ script: 'window.location.href',
211
+ }, unifiedApiUrl).then((res) => String(res?.result || res?.data?.result || ''));
212
+ const waitForSafeExploreUrl = async (timeoutMs = 4800, intervalMs = 250) => {
213
+ const startMs = Date.now();
214
+ let lastUrl = '';
215
+ while (Date.now() - startMs < timeoutMs) {
216
+ const url = await readCurrentUrl();
217
+ if (url)
218
+ lastUrl = url;
219
+ if (url.includes('/explore/') && url.includes('xsec_token=')) {
220
+ return { safeUrl: url, lastUrl: url, waitedMs: Date.now() - startMs };
221
+ }
222
+ await delay(intervalMs);
223
+ }
224
+ return { safeUrl: '', lastUrl, waitedMs: Date.now() - startMs };
225
+ };
226
+ const performCoordinateClick = async (point) => {
227
+ try {
228
+ const x = Math.round(Number(point?.x));
229
+ const y = Math.round(Number(point?.y));
230
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
231
+ return { ok: false, error: 'invalid_click_point' };
232
+ }
233
+ await controllerAction('mouse:click', { profileId: profile, x, y, clicks: 1 }, unifiedApiUrl);
234
+ return { ok: true };
235
+ }
236
+ catch (e) {
237
+ return { ok: false, error: String(e?.message || e || 'mouse_click_failed') };
238
+ }
239
+ };
240
+ const readFocusState = async () => {
241
+ const state = await controllerAction('browser:execute', {
242
+ profile,
243
+ script: `(() => {
244
+ const active = document.activeElement;
245
+ const hasFocus = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
246
+ return {
247
+ hasFocus,
248
+ activeTag: active?.tagName || '',
249
+ activeId: active?.id || '',
250
+ activeClass: active?.className || '',
251
+ };
252
+ })()`,
253
+ }, unifiedApiUrl).then((res) => res?.result || res?.data?.result || res?.data || {});
254
+ const tag = String(state?.activeTag || '').trim() || 'unknown';
255
+ const id = String(state?.activeId || '').trim();
256
+ const cls = String(state?.activeClass || '').trim().replace(/\s+/g, '.');
257
+ const activeLabel = `${tag}${id ? `#${id}` : ''}${cls ? `.${cls}` : ''}`;
258
+ return {
259
+ hasFocus: Boolean(state?.hasFocus ?? true),
260
+ activeLabel,
261
+ };
262
+ };
263
+ const ensureBrowserFocus = async (strategy) => {
264
+ const before = await readFocusState().catch(() => ({ hasFocus: false, activeLabel: 'unknown' }));
265
+ const focusRes = await controllerAction('browser:focus', { profile }, unifiedApiUrl)
266
+ .catch((e) => ({ ok: false, error: String(e?.message || e || 'focus_failed') }));
267
+ const after = await readFocusState().catch(() => ({ hasFocus: false, activeLabel: 'unknown' }));
268
+ const ok = Boolean(focusRes?.success ?? focusRes?.ok ?? false);
269
+ console.log(`[Phase2Collect] Focus ensure: strategy=${strategy} ok=${ok} beforeFocus=${before.hasFocus} beforeActive=${before.activeLabel} afterFocus=${after.hasFocus} afterActive=${after.activeLabel}`);
270
+ await appendTrace({
271
+ type: 'focus_ensure',
272
+ ts: new Date().toISOString(),
273
+ strategy,
274
+ ok,
275
+ before,
276
+ after,
277
+ });
278
+ return { ok, before, after };
279
+ };
280
+ const performContainerClick = async (domIndex, useSystemMouse) => {
281
+ const clickRes = await controllerAction('container:operation', {
282
+ containerId: 'xiaohongshu_search.search_result_item',
283
+ operationId: 'click',
284
+ sessionId: profile,
285
+ config: {
286
+ index: domIndex,
287
+ target: 'a.cover',
288
+ useSystemMouse,
289
+ visibleOnly: true,
290
+ },
291
+ timeoutMs: 20000,
292
+ }, unifiedApiUrl).catch((e) => ({ success: false, error: String(e?.message || e || 'container_click_failed') }));
293
+ if (clickRes?.success === false) {
294
+ return { ok: false, error: String(clickRes?.error || 'container_click_failed') };
295
+ }
296
+ return { ok: true };
297
+ };
298
+ // Updated by ensureOnExpectedSearch once we confirmed we are on the correct search_result.
299
+ // Used as a safe fallback to return from detail pages without triggering a new search.
300
+ let expectedSearchUrl = '';
301
+ const ensureOnExpectedSearch = async () => {
302
+ const currentUrl = await controllerAction('browser:execute', {
303
+ profile,
304
+ script: 'window.location.href',
305
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || '');
306
+ if (!currentUrl || typeof currentUrl !== 'string') {
307
+ throw new Error('[Phase2Collect] 无法读取当前 URL');
308
+ }
309
+ // XHS 在某些情况下 URL 会停留在 /explore/<id>,但 DOM 已经是搜索结果页。
310
+ // 这里不能只依赖 URL,需要用 DOM 信号判断是否已经在 search_result。
311
+ const looksLikeSearchResult = await controllerAction('browser:execute', {
312
+ profile,
313
+ script: `(function(){
314
+ const hasResultList = !!document.querySelector('.feeds-container, .search-result-list, .note-list');
315
+ const hasFilter = !!document.querySelector('.tabs, .filter-tabs, [role="tablist"], .filter');
316
+ return Boolean(hasResultList || hasFilter);
317
+ })()`,
318
+ }, unifiedApiUrl).then(res => Boolean(res?.result || res?.data?.result));
319
+ if (!currentUrl.includes('/search_result') && !looksLikeSearchResult) {
320
+ const waitForSearchResult = async (maxWaitMs) => {
321
+ const start = Date.now();
322
+ let url = '';
323
+ while (Date.now() - start < maxWaitMs) {
324
+ await delay(400);
325
+ url = await controllerAction('browser:execute', {
326
+ profile,
327
+ script: 'window.location.href',
328
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || '');
329
+ if (typeof url === 'string' && url.includes('/search_result'))
330
+ return url;
331
+ }
332
+ return url;
333
+ };
334
+ // 先基于 checkpoint 做状态确认
335
+ const det = await detectXhsCheckpoint({ sessionId: profile, serviceUrl: unifiedApiUrl });
336
+ // 详情页:先用 ESC 回退
337
+ if (det.checkpoint === 'detail_ready' || det.checkpoint === 'comments_ready') {
338
+ for (let i = 0; i < 3; i += 1) {
339
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
340
+ const afterEsc = await waitForSearchResult(5000);
341
+ if (typeof afterEsc === 'string' && afterEsc.includes('/search_result'))
342
+ return afterEsc;
343
+ }
344
+ }
345
+ // 禁止刷新兜底:不使用 goto 回到 search_result(高风控)。
346
+ // 如果无法回到搜索页,直接停止交由人工处理。
347
+ if (!expectedSearchUrl) {
348
+ throw new Error(`[Phase2Collect] expectedSearchUrl 为空,且当前不在搜索结果页(URL/DOM),停止(避免刷新)。current=${String(currentUrl)}`);
349
+ }
350
+ throw new Error(`[Phase2Collect] 当前不在搜索结果页(checkpoint=${det.checkpoint}),停止(避免 goto 刷新)。current=${String(currentUrl)} expected=${String(expectedSearchUrl)}`);
351
+ }
352
+ // 如果仍然是 /explore/<id> URL 但 DOM 已渲染搜索结果(XHS shell-page 行为),
353
+ // 避免任何 refresh/goto/Discover 回退:直接合成 search_result URL 作为 expectedSearchUrl。
354
+ if (currentUrl.includes('/explore/') && looksLikeSearchResult) {
355
+ const inputVal = await controllerAction('browser:execute', {
356
+ profile,
357
+ script: `(function(){
358
+ const el = document.querySelector('#search-input') || document.querySelector('input[type="search"]') || document.querySelector('input[placeholder*="搜索"], input[placeholder*="关键字"]');
359
+ if (!el) return '';
360
+ return String(el.value || '').trim();
361
+ })()`,
362
+ }, unifiedApiUrl).then((res) => String(res?.result || res?.data?.result || '').trim());
363
+ if (inputVal && inputVal === keyword) {
364
+ console.warn(`[Phase2Collect] shell-page detected (input matches); using current URL`);
365
+ return String(currentUrl || '') || '';
366
+ }
367
+ }
368
+ // If we're already on /search_result and keyword matches, accept.
369
+ if (matchesKeywordFromSearchUrlStrict(currentUrl, keyword)) {
370
+ return currentUrl;
371
+ }
372
+ // If DOM looks like search results but URL is not /search_result, treat as shell-page.
373
+ // Avoid any Enter/refresh/goto; synthesize expectedSearchUrl from the input value.
374
+ if (!currentUrl.includes('/search_result') && looksLikeSearchResult) {
375
+ const inputVal = await controllerAction('browser:execute', {
376
+ profile,
377
+ script: `(function(){
378
+ const el = document.querySelector('#search-input') || document.querySelector('input[type="search"]') || document.querySelector('input[placeholder*="搜索"], input[placeholder*="关键字"]');
379
+ if (!el) return '';
380
+ return String(el.value || '').trim();
381
+ })()`,
382
+ }, unifiedApiUrl).then(res => String(res?.result || res?.data?.result || '').trim());
383
+ if (inputVal && inputVal === keyword) {
384
+ const synthesized = new URL('https://www.xiaohongshu.com/search_result');
385
+ synthesized.searchParams.set('keyword', keyword);
386
+ console.warn(`[Phase2Collect] shell-page detected (non-search URL); proceed without synth, using DOM results`);
387
+ return String(currentUrl || '') || '';
388
+ }
389
+ // Fall through: shell-page with matching DOM is allowed (no synth URL)
390
+ console.warn(`[Phase2Collect] shell-page with matching DOM allowed, using current URL`);
391
+ return String(currentUrl || '') || '';
392
+ }
393
+ const actual = getKeywordFromSearchUrl(currentUrl);
394
+ console.warn(`[Phase2Collect] 检测到搜索漂移:expected="${keyword}" actual="${actual}" url=${currentUrl}`);
395
+ if (debugArtifactsEnabled) {
396
+ try {
397
+ const shot = await controllerAction('browser:screenshot', { profileId: profile, fullPage: false }, unifiedApiUrl)
398
+ .then(res => res?.data || res?.result || res?.data?.data || '');
399
+ if (typeof shot === 'string' && shot) {
400
+ const file = await saveScreenshot(shot, `drift-${Date.now()}.png`);
401
+ await appendTrace({ type: 'drift', ts: new Date().toISOString(), expected: keyword, actual, url: currentUrl, screenshot: file });
402
+ }
403
+ }
404
+ catch {
405
+ // ignore screenshot failures
406
+ }
407
+ }
408
+ // 开发阶段允许一次 Discover 回退(轻度风控),避免连续重搜触发风控。
409
+ console.warn('[Phase2Collect] Drift detected: attempting Discover fallback (once)');
410
+ await appendTrace({ type: 'discover_fallback_start', ts: new Date().toISOString(), url: currentUrl });
411
+ try {
412
+ await clickDiscoverAndRetrySearch({ profile, unifiedApiUrl, keyword, env, appendTrace });
413
+ const urlAfter = await controllerAction('browser:execute', { profile, script: 'window.location.href' }, unifiedApiUrl)
414
+ .then(res => res?.result || res?.data?.result || '');
415
+ if (typeof urlAfter === 'string' && matchesKeywordFromSearchUrlStrict(urlAfter, keyword)) {
416
+ await appendTrace({ type: 'discover_fallback_ok', ts: new Date().toISOString(), url: urlAfter });
417
+ return urlAfter;
418
+ }
419
+ }
420
+ catch (fallbackError) {
421
+ await appendTrace({ type: 'discover_fallback_fail', ts: new Date().toISOString(), error: String(fallbackError) });
422
+ }
423
+ // Discover fallback failed; stop to avoid repeated searches.
424
+ throw new Error(`[Phase2Collect] 搜索关键词漂移,Discover fallback 失败,停止执行。url=${currentUrl}`);
425
+ };
426
+ // 进入采集前,先固定一个"期望 searchUrl"(必须为 /search_result?keyword=<keyword> 且严格匹配)
427
+ let fallbackAttempts = 0;
428
+ while (fallbackAttempts < 1) {
429
+ try {
430
+ expectedSearchUrl = await ensureOnExpectedSearch();
431
+ break; // Success, exit loop
432
+ }
433
+ catch (e) {
434
+ throw e; // Other errors: rethrow
435
+ }
436
+ }
437
+ if (!expectedSearchUrl) {
438
+ throw new Error('[Phase2Collect] Failed to resolve expectedSearchUrl after fallback');
439
+ }
440
+ if (!isValidSearchUrl(expectedSearchUrl, keyword)) {
441
+ throw new Error(`[Phase2Collect] expectedSearchUrl invalid for keyword="${keyword}": ${String(expectedSearchUrl)}`);
442
+ }
443
+ console.log(`[Phase2Collect] expectedSearchUrl=${expectedSearchUrl}`);
444
+ // Now that we have a confirmed expectedSearchUrl, we can enable scrollLocked if the first view
445
+ // already contains enough cards to meet targetCount.
446
+ const initialCheckResult = await controllerAction('browser:execute', {
447
+ profile,
448
+ script: `(function(){
449
+ const itemSelector = '.note-item, [data-note-id], a[href*="/explore/"]';
450
+ const nodes = Array.from(document.querySelectorAll(itemSelector));
451
+ return { totalCards: nodes.length };
452
+ })()`,
453
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || { totalCards: 0 });
454
+ const initialTotalCards = Number(initialCheckResult?.totalCards ?? 0);
455
+ if (Number.isFinite(initialTotalCards) && initialTotalCards >= targetCount) {
456
+ scrollLocked = false;
457
+ console.log(`[Phase2Collect] scrollLocked=false (allow scrolling even if total>=target)`);
458
+ }
459
+ try {
460
+ while (links.length < targetCount && attempts < maxAttempts) {
461
+ await appendTrace({ type: 'while_loop_start', ts: new Date().toISOString(), attempt: attempts + 1, collected: links.length, targetCount });
462
+ attempts++;
463
+ // 0. 每轮开始应该处于搜索结果页,但避免每轮都通过 URL/DOM 重新定位(风控敏感)。
464
+ // Phase2 输出强绑定“冻结的 expectedSearchUrl”,并在 per-note 校验处做严格 gate。
465
+ const searchUrl = expectedSearchUrl;
466
+ // 2. 解析搜索结果卡片 selector(来自容器定义)
467
+ const defs = registry.getContainersForUrl(searchUrl);
468
+ const itemDef = defs?.['xiaohongshu_search.search_result_item'];
469
+ const selectorDefs = Array.isArray(itemDef?.selectors) ? itemDef.selectors : [];
470
+ const primarySelectorDef = selectorDefs.find((s) => s?.variant === 'primary') || selectorDefs[0];
471
+ const itemSelector = String(primarySelectorDef?.css || '.note-item');
472
+ const pick = await controllerAction('browser:execute', {
473
+ profile,
474
+ script: `(function(){
475
+ const sel = ${JSON.stringify(itemSelector)};
476
+ const seen = new Set(${JSON.stringify(Array.from(seenExploreIds))});
477
+ const nodes = Array.from(document.querySelectorAll(sel));
478
+ const pad = 10;
479
+ const vh = window.innerHeight;
480
+
481
+ function clampAmount(v){
482
+ const n = Math.ceil(Number(v) || 0);
483
+ if (n <= 0) return 120;
484
+ return Math.max(80, Math.min(360, n));
485
+ }
486
+
487
+ function normalizeId(raw){
488
+ const v = String(raw || '').trim();
489
+ if (!v) return '';
490
+ const m = v.match(/\\/explore\\/([a-f0-9]+)/i);
491
+ if (m && m[1]) return m[1].toLowerCase();
492
+ if (/^[a-f0-9]{8,}$/i.test(v)) return v.toLowerCase();
493
+ return '';
494
+ }
495
+
496
+ function extractNoteId(node){
497
+ if (!node) return '';
498
+ const direct =
499
+ node.getAttribute?.('data-note-id') ||
500
+ node.getAttribute?.('data-id') ||
501
+ node.getAttribute?.('href') ||
502
+ node.dataset?.noteId ||
503
+ '';
504
+ const directId = normalizeId(direct);
505
+ if (directId) return directId;
506
+
507
+ const nestedNote = node.querySelector?.('[data-note-id]');
508
+ if (nestedNote) {
509
+ const nestedId = normalizeId(nestedNote.getAttribute?.('data-note-id') || '');
510
+ if (nestedId) return nestedId;
511
+ }
512
+
513
+ const exploreA = node.querySelector?.('a[href*="/explore/"]');
514
+ const exploreHref = exploreA ? (exploreA.getAttribute('href') || '') : '';
515
+ return normalizeId(exploreHref);
516
+ }
517
+
518
+ const candidates = [];
519
+ let scrollUp = null;
520
+ let scrollDown = null;
521
+
522
+ for (let i = 0; i < nodes.length; i++) {
523
+ const node = nodes[i];
524
+ const exploreId = extractNoteId(node);
525
+ if (!exploreId || seen.has(exploreId)) continue;
526
+
527
+ const r = node.getBoundingClientRect();
528
+ if (!(r.width > 0 && r.height > 0)) continue;
529
+
530
+ if (r.top < pad) {
531
+ const amount = clampAmount((pad - r.top) + 12);
532
+ if (!scrollUp || amount > scrollUp.amount) {
533
+ scrollUp = {
534
+ direction: 'up',
535
+ amount,
536
+ reason: 'top_clipped',
537
+ index: i,
538
+ exploreId,
539
+ rect: { top: r.top, bottom: r.bottom, width: r.width, height: r.height },
540
+ };
541
+ }
542
+ continue;
543
+ }
544
+
545
+ if (r.bottom > (vh - pad)) {
546
+ const amount = clampAmount((r.bottom - (vh - pad)) + 12);
547
+ if (!scrollDown || amount > scrollDown.amount) {
548
+ scrollDown = {
549
+ direction: 'down',
550
+ amount,
551
+ reason: 'bottom_clipped',
552
+ index: i,
553
+ exploreId,
554
+ rect: { top: r.top, bottom: r.bottom, width: r.width, height: r.height },
555
+ };
556
+ }
557
+ continue;
558
+ }
559
+
560
+ candidates.push({
561
+ index: i,
562
+ exploreId,
563
+ rect: { top: r.top, bottom: r.bottom, width: r.width, height: r.height },
564
+ });
565
+ }
566
+
567
+ if (candidates.length > 0) {
568
+ candidates.sort((a, b) => a.rect.top - b.rect.top);
569
+ const chosen = candidates[0];
570
+ return {
571
+ action: 'ok',
572
+ index: chosen.index,
573
+ exploreId: chosen.exploreId,
574
+ rect: chosen.rect,
575
+ debug: {
576
+ total: nodes.length,
577
+ pad,
578
+ viewportH: vh,
579
+ candidatesCount: candidates.length,
580
+ pick: 'topmost',
581
+ chosenIdx: 0,
582
+ },
583
+ };
584
+ }
585
+
586
+ if (scrollUp || scrollDown) {
587
+ const scroll = scrollUp || scrollDown;
588
+ return {
589
+ action: 'scroll',
590
+ index: scroll.index,
591
+ exploreId: scroll.exploreId,
592
+ rect: scroll.rect,
593
+ scroll: { direction: scroll.direction, amount: scroll.amount, reason: scroll.reason },
594
+ debug: {
595
+ total: nodes.length,
596
+ pad,
597
+ viewportH: vh,
598
+ candidatesCount: 0,
599
+ },
600
+ };
601
+ }
602
+
603
+ return {
604
+ action: 'scroll',
605
+ scroll: { direction: 'down', amount: 360, reason: 'no_unseen_candidates' },
606
+ debug: { total: nodes.length, pad, viewportH: vh, candidatesCount: 0 },
607
+ };
608
+ })()`,
609
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || null);
610
+ if (!pick || typeof pick !== 'object') {
611
+ throw new Error('[Phase2Collect] pick target failed: empty result');
612
+ }
613
+ // Safety: if we haven't made progress for too long, re-verify the checkpoint to avoid shell-page drift.
614
+ if (noProgressRetryCount >= maxNoProgressRetries) {
615
+ const det = await detectXhsCheckpoint({ sessionId: profile, serviceUrl: unifiedApiUrl });
616
+ console.log(`[Phase2Collect] noProgress detected, checkpoint=${det.checkpoint} url=${det.url}`);
617
+ if (det.checkpoint !== 'search_ready' && det.checkpoint !== 'home_ready') {
618
+ throw new Error(`[Phase2Collect] 进入异常状态,停止采集。checkpoint=${det.checkpoint} url=${det.url}`);
619
+ }
620
+ // reset retry counter after checkpoint check
621
+ noProgressRetryCount = 0;
622
+ }
623
+ await appendTrace({ type: 'pick_done', ts: new Date().toISOString(), attempt: attempts, pick });
624
+ if (pick.action === 'scroll' && pick.scroll) {
625
+ const total = Number(pick?.debug?.total ?? 0);
626
+ const candidatesCount = Number(pick?.debug?.candidatesCount ?? 0);
627
+ // Only lock scroll if we have both:
628
+ // 1) Enough total cards in DOM (total >= targetCount)
629
+ // 2) At least one visible candidate we can click
630
+ // If candidatesCount=0 even with total>=target, we MUST keep scrolling to find visible items.
631
+ const visibleEnough = Number.isFinite(total) && total >= targetCount && candidatesCount > 0;
632
+ if (visibleEnough) {
633
+ if (!scrollLocked) {
634
+ scrollLocked = true;
635
+ console.log(`[Phase2Collect] scrollLocked=true (total=${total} >= target=${targetCount}) - skip scroll`);
636
+ }
637
+ // Continue to next attempt; pick() should now return action=ok because we won't scroll.
638
+ attempts++;
639
+ await delay(200);
640
+ continue;
641
+ }
642
+ console.log(`[Phase2Collect] scroll: reason=${pick.scroll.reason} dir=${pick.scroll.direction} amount=${pick.scroll.amount} visibleCount=${pick?.debug?.visibleCount ?? 'n/a'} total=${pick?.debug?.total ?? 'n/a'}`);
643
+ await appendTrace({
644
+ type: 'pick_scroll',
645
+ ts: new Date().toISOString(),
646
+ attempt: attempts,
647
+ collected: links.length,
648
+ searchUrl,
649
+ itemSelector,
650
+ pick,
651
+ });
652
+ await controllerAction('container:operation', {
653
+ containerId: 'xiaohongshu_search.search_result_list',
654
+ operationId: 'scroll',
655
+ sessionId: profile,
656
+ config: { direction: pick.scroll.direction, amount: pick.scroll.amount },
657
+ }, unifiedApiUrl);
658
+ scrollCount++;
659
+ // No-progress detection: if links.length hasn't changed after scroll
660
+ if (links.length === lastLinksCount) {
661
+ noProgressRetryCount++;
662
+ console.log(`[Phase2Collect] no progress after scroll: retry ${noProgressRetryCount}/${maxNoProgressRetries}`);
663
+ if (noProgressRetryCount >= maxNoProgressRetries) {
664
+ console.log(`[Phase2Collect] terminating: no_progress_after_${maxNoProgressRetries}_retries, collected=${links.length}/${targetCount}`);
665
+ await appendTrace({
666
+ type: 'terminate_no_progress',
667
+ ts: new Date().toISOString(),
668
+ collected: links.length,
669
+ targetCount,
670
+ scrollCount,
671
+ noProgressRetryCount,
672
+ });
673
+ break; // Exit while loop
674
+ }
675
+ // Backtrack strategy: scroll up then down to try different viewport
676
+ console.log(`[Phase2Collect] backtrack: scroll up 400 then down 800`);
677
+ await controllerAction('container:operation', {
678
+ containerId: 'xiaohongshu_search.search_result_list',
679
+ operationId: 'scroll',
680
+ sessionId: profile,
681
+ config: { direction: 'up', amount: 400 },
682
+ }, unifiedApiUrl);
683
+ await delay(800);
684
+ await controllerAction('container:operation', {
685
+ containerId: 'xiaohongshu_search.search_result_list',
686
+ operationId: 'scroll',
687
+ sessionId: profile,
688
+ config: { direction: 'down', amount: 800 },
689
+ }, unifiedApiUrl);
690
+ }
691
+ else {
692
+ // Reset counter when we make progress
693
+ noProgressRetryCount = 0;
694
+ lastLinksCount = links.length;
695
+ }
696
+ await delay(1200);
697
+ continue;
698
+ }
699
+ const domIndex = Number(pick.index ?? -1);
700
+ if (!Number.isFinite(domIndex) || domIndex < 0) {
701
+ throw new Error(`[Phase2Collect] invalid picked index: ${String(pick.index)}`);
702
+ }
703
+ // Ensure candidate is fully visible before highlight + click.
704
+ // Fully visible = rect.top >= pad && rect.bottom <= viewportH - pad
705
+ // This prevents unstable clicks on partially clipped cards.
706
+ const ensureVisibleMaxRounds = 4;
707
+ const ensureVisiblePad = 12;
708
+ let lastRect = null;
709
+ for (let vr = 0; vr < ensureVisibleMaxRounds; vr++) {
710
+ const rectCheck = await controllerAction('browser:execute', {
711
+ profile,
712
+ script: `(function(){
713
+ const sel = ${JSON.stringify(itemSelector)};
714
+ const idx = ${JSON.stringify(domIndex)};
715
+ const nodes = Array.from(document.querySelectorAll(sel));
716
+ if (idx < 0 || idx >= nodes.length) return { ok: false, error: 'index_out_of_range', idx, len: nodes.length };
717
+ const node = nodes[idx];
718
+ const r = node.getBoundingClientRect();
719
+ const pad = ${ensureVisiblePad};
720
+ const vh = window.innerHeight;
721
+ return {
722
+ ok: true,
723
+ idx,
724
+ rect: { top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width, height: r.height },
725
+ visible: {
726
+ fully: (r.top >= pad && r.bottom <= (vh - pad)),
727
+ topClipped: r.top < pad,
728
+ bottomClipped: r.bottom > (vh - pad),
729
+ },
730
+ viewportH: vh,
731
+ pad,
732
+ };
733
+ })()`,
734
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || null);
735
+ if (!rectCheck || rectCheck.ok !== true) {
736
+ throw new Error(`[Phase2Collect] ensureVisible rect check failed: ${JSON.stringify(rectCheck)}`);
737
+ }
738
+ lastRect = rectCheck.rect || lastRect;
739
+ if (rectCheck.visible?.fully) {
740
+ if (vr > 0) {
741
+ console.log(`[Phase2Collect] ensureVisible satisfied after ${vr} round(s), index=${domIndex}`);
742
+ }
743
+ break;
744
+ }
745
+ const scrollDir = rectCheck.visible?.topClipped ? 'up' : 'down';
746
+ const scrollAmount = Math.ceil(rectCheck.visible?.topClipped
747
+ ? (ensureVisiblePad - rectCheck.rect.top) + 24
748
+ : (rectCheck.rect.bottom - (rectCheck.viewportH - ensureVisiblePad)) + 24);
749
+ console.log(`[Phase2Collect] ensureVisible round=${vr + 1}/${ensureVisibleMaxRounds} index=${domIndex} dir=${scrollDir} amount=${scrollAmount}`);
750
+ await appendTrace({
751
+ type: 'ensure_visible_scroll',
752
+ ts: new Date().toISOString(),
753
+ attempt: attempts,
754
+ collected: links.length,
755
+ domIndex,
756
+ scrollForVisibilityCount,
757
+ scrollDir,
758
+ scrollAmount,
759
+ rectBefore: rectCheck.rect,
760
+ });
761
+ await controllerAction('container:operation', {
762
+ containerId: 'xiaohongshu_search.search_result_list',
763
+ operationId: 'scroll',
764
+ sessionId: profile,
765
+ config: { direction: scrollDir, amount: scrollAmount },
766
+ }, unifiedApiUrl);
767
+ scrollForVisibilityCount++;
768
+ await delay(400);
769
+ }
770
+ const exploreId = normalizeNoteId(String(pick.exploreId || '')) || '';
771
+ if (exploreId && seenNoteIds.has(exploreId)) {
772
+ console.log(`[Phase2Collect] skip pre-click: already_collected noteId=${exploreId}`);
773
+ await appendTrace({ type: 'skip_existing_note_preclick', ts: new Date().toISOString(), domIndex, noteId: exploreId });
774
+ await recoverFromPreClickStall('skip_existing_note_preclick', { domIndex, noteId: exploreId });
775
+ await delay(80);
776
+ continue;
777
+ }
778
+ // 4. 点击第 N 个搜索结果卡片(通过 DOM 下标精确定位,避免依赖 href)
779
+ await appendTrace({ type: 'highlight_start', ts: new Date().toISOString(), attempt: attempts, domIndex, exploreId });
780
+ let highlightInfo = null;
781
+ try {
782
+ await appendTrace({ type: 'highlight_calling', ts: new Date().toISOString(), domIndex });
783
+ highlightInfo = await controllerAction('container:operation', {
784
+ containerId: 'xiaohongshu_search.search_result_item',
785
+ operationId: 'highlight',
786
+ sessionId: profile,
787
+ config: { index: domIndex, duration: 900 },
788
+ }, unifiedApiUrl);
789
+ }
790
+ catch {
791
+ highlightInfo = null;
792
+ }
793
+ await appendTrace({ type: 'highlight_done', ts: new Date().toISOString(), attempt: attempts, domIndex, highlightInfo });
794
+ let preScreenshotPath = null;
795
+ if (debugArtifactsEnabled) {
796
+ try {
797
+ const shot = await controllerAction('browser:screenshot', { profileId: profile, fullPage: false }, unifiedApiUrl)
798
+ .then(res => res?.data || res?.result || res?.data?.data || '');
799
+ if (typeof shot === 'string' && shot) {
800
+ const name = `click-${String(attempts).padStart(4, '0')}-idx-${String(domIndex).padStart(4, '0')}-${Date.now()}.png`;
801
+ preScreenshotPath = await saveScreenshot(shot, name);
802
+ }
803
+ }
804
+ catch {
805
+ preScreenshotPath = null;
806
+ }
807
+ }
808
+ await appendTrace({
809
+ type: 'click_before',
810
+ ts: new Date().toISOString(),
811
+ attempt: attempts,
812
+ collected: links.length,
813
+ domIndex,
814
+ scrollCount,
815
+ searchUrl,
816
+ exploreId,
817
+ pick,
818
+ highlight: highlightInfo,
819
+ screenshot: preScreenshotPath,
820
+ });
821
+ // === RIGID CLICK GATE: Re-verify before click ===
822
+ // Gate 1: Re-read element signature at same index, must match pick
823
+ const preClickVerifyRaw = await controllerAction('browser:execute', {
824
+ profile,
825
+ script: `(function(){
826
+ const sel = ${JSON.stringify(itemSelector)};
827
+ const items = document.querySelectorAll(sel);
828
+ if (${domIndex} >= items.length) return {ok:false, reason:'index_out_of_range'};
829
+ const el = items[${domIndex}];
830
+ const rect = el.getBoundingClientRect();
831
+ // Hit-test center point
832
+ const cx = rect.left + rect.width/2;
833
+ const cy = rect.top + rect.height/2;
834
+ const hitEl = document.elementFromPoint(cx, cy);
835
+ const hitOk = hitEl && (el === hitEl || el.contains(hitEl) || hitEl.contains(el));
836
+ const x1 = Math.max(0, rect.left);
837
+ const y1 = Math.max(0, rect.top);
838
+ const x2 = Math.min(window.innerWidth, rect.right);
839
+ const y2 = Math.min(window.innerHeight, rect.bottom);
840
+ const points = [
841
+ { name: 'center', x: (x1 + x2) / 2, y: (y1 + y2) / 2 },
842
+ { name: 'left_mid', x: x1 + 14, y: (y1 + y2) / 2 },
843
+ { name: 'right_mid', x: x2 - 14, y: (y1 + y2) / 2 },
844
+ { name: 'top_mid', x: (x1 + x2) / 2, y: y1 + 14 },
845
+ { name: 'bottom_mid', x: (x1 + x2) / 2, y: y2 - 14 },
846
+ ];
847
+ const clickPoints = [];
848
+ for (const p of points) {
849
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
850
+ if (p.x < 0 || p.y < 0 || p.x > window.innerWidth || p.y > window.innerHeight) continue;
851
+ const hp = document.elementFromPoint(p.x, p.y);
852
+ if (hp && (el === hp || el.contains(hp) || hp.contains(el))) {
853
+ clickPoints.push({ name: p.name, x: Math.round(p.x), y: Math.round(p.y) });
854
+ }
855
+ }
856
+ const normalizeId = (raw) => {
857
+ const v = String(raw || '').trim();
858
+ if (!v) return '';
859
+ const m = v.match(/\\/explore\\/([a-f0-9]+)/i);
860
+ if (m && m[1]) return m[1].toLowerCase();
861
+ if (/^[a-f0-9]{8,}$/i.test(v)) return v.toLowerCase();
862
+ return '';
863
+ };
864
+ const readNoteId = (node) => {
865
+ if (!node) return '';
866
+ const direct =
867
+ node.getAttribute?.('data-note-id') ||
868
+ node.getAttribute?.('data-id') ||
869
+ node.getAttribute?.('href') ||
870
+ node.dataset?.noteId ||
871
+ '';
872
+ const directId = normalizeId(direct);
873
+ if (directId) return directId;
874
+ const nested = node.querySelector?.('[data-note-id]');
875
+ if (nested) {
876
+ const nestedId = normalizeId(nested.getAttribute?.('data-note-id') || '');
877
+ if (nestedId) return nestedId;
878
+ }
879
+ const exploreA = node.querySelector?.('a[href*="/explore/"]');
880
+ const exploreHref = exploreA ? (exploreA.getAttribute('href') || '') : '';
881
+ return normalizeId(exploreHref);
882
+ };
883
+ const noteLike = readNoteId(el) || '';
884
+ return {
885
+ ok: hitOk,
886
+ reason: hitOk ? 'hit_test_pass' : 'hit_test_fail',
887
+ rect: {left:rect.left, top:rect.top, right:rect.right, bottom:rect.bottom},
888
+ clickPoints,
889
+ noteId: noteLike,
890
+ };
891
+ })()`,
892
+ }, unifiedApiUrl);
893
+ const preClickVerify = preClickVerifyRaw?.data?.result ?? preClickVerifyRaw?.result ?? preClickVerifyRaw?.data ?? preClickVerifyRaw ?? { ok: false };
894
+ const preClickReason = String(preClickVerify?.reason || 'unknown');
895
+ const softHitTestPass = !preClickVerify?.ok && preClickReason === 'hit_test_fail';
896
+ if (!preClickVerify?.ok && !softHitTestPass) {
897
+ console.warn(`[Phase2Collect] Rigid gate blocked click index=${domIndex}: ${preClickReason}`);
898
+ await recoverFromPreClickStall('rigid_gate_blocked', { domIndex, gateReason: preClickReason });
899
+ await delay(300);
900
+ continue; // Re-pick next iteration
901
+ }
902
+ let preClickNoteId = normalizeNoteId(String(preClickVerify?.noteId || '')) || '';
903
+ if (!preClickNoteId && pick.exploreId) {
904
+ preClickNoteId = normalizeNoteId(String(pick.exploreId || '')) || '';
905
+ if (preClickNoteId) {
906
+ console.warn(`[Phase2Collect] Rigid gate soft-pass index=${domIndex}: missing_note_id, fallback to pick.exploreId`);
907
+ await appendTrace({ type: 'rigid_gate_soft_pass', ts: new Date().toISOString(), domIndex, reason: 'missing_note_id_fallback', noteId: preClickNoteId });
908
+ }
909
+ }
910
+ if (!preClickNoteId) {
911
+ console.warn(`[Phase2Collect] Rigid gate blocked click index=${domIndex}: missing_note_id`);
912
+ await appendTrace({ type: 'skip_missing_noteid_gate', ts: new Date().toISOString(), domIndex });
913
+ await recoverFromPreClickStall('missing_noteid_gate', { domIndex });
914
+ await delay(120);
915
+ continue;
916
+ }
917
+ if (seenNoteIds.has(preClickNoteId)) {
918
+ console.log(`[Phase2Collect] skip by gate: already_collected noteId=${preClickNoteId}`);
919
+ await appendTrace({ type: 'skip_existing_note_gate', ts: new Date().toISOString(), domIndex, noteId: preClickNoteId });
920
+ await recoverFromPreClickStall('skip_existing_note_gate', { domIndex, noteId: preClickNoteId });
921
+ await delay(80);
922
+ continue;
923
+ }
924
+ if (softHitTestPass) {
925
+ console.warn(`[Phase2Collect] Rigid gate soft-pass index=${domIndex}: hit_test_fail, continue with coordinate click points`);
926
+ await appendTrace({ type: 'rigid_gate_soft_pass', ts: new Date().toISOString(), domIndex, reason: preClickReason, noteId: preClickNoteId });
927
+ }
928
+ else {
929
+ console.log(`[Phase2Collect] Rigid gate passed index=${domIndex}, hit-test ok noteId=${preClickNoteId}`);
930
+ }
931
+ // Phase-based timeout tracking.
932
+ // Click using verified in-card points and only accept if URL has /explore/ + xsec_token.
933
+ const gateRect = preClickVerify?.rect || lastRect;
934
+ const clickStartMs = Date.now();
935
+ const clickPointsFromGate = Array.isArray(preClickVerify?.clickPoints) ? preClickVerify.clickPoints : [];
936
+ const clickPoints = [];
937
+ const pushPoint = (p, name) => {
938
+ const x = Math.round(Number(p?.x));
939
+ const y = Math.round(Number(p?.y));
940
+ if (!Number.isFinite(x) || !Number.isFinite(y))
941
+ return;
942
+ if (clickPoints.some((it) => it.x === x && it.y === y))
943
+ return;
944
+ clickPoints.push({ x, y, name: String(name || p?.name || 'point') });
945
+ };
946
+ for (const p of clickPointsFromGate)
947
+ pushPoint(p, p?.name);
948
+ if (gateRect && gateRect.left !== undefined && gateRect.top !== undefined) {
949
+ pushPoint({
950
+ x: Math.round((Number(gateRect.left) + Number(gateRect.right)) / 2),
951
+ y: Math.round((Number(gateRect.top) + Number(gateRect.bottom)) / 2),
952
+ }, 'center_fallback');
953
+ }
954
+ const clickErrors = [];
955
+ const attemptedStrategies = [];
956
+ let safeUrl = '';
957
+ let urlAfterClick = '';
958
+ let navWaitMs = 0;
959
+ const focusBeforeProtocol = await readFocusState().catch(() => ({ hasFocus: false, activeLabel: 'unknown' }));
960
+ console.log(`[Phase2Collect] Click decision: strategy=container_protocol mode=protocol focus=${focusBeforeProtocol.hasFocus} active=${focusBeforeProtocol.activeLabel}`);
961
+ attemptedStrategies.push('container_protocol');
962
+ const protocolRes = await performContainerClick(domIndex, false);
963
+ if (!protocolRes.ok) {
964
+ const errText = String(protocolRes.error || 'container_protocol_failed');
965
+ clickErrors.push({ strategy: 'container_protocol', error: errText });
966
+ console.warn(`[Phase2Collect] Click strategy failed: strategy=container_protocol reason=${errText}`);
967
+ }
968
+ else {
969
+ await delay(450);
970
+ const navProbe = await waitForSafeExploreUrl(4800, 250);
971
+ navWaitMs += Number(navProbe.waitedMs || 0);
972
+ if (navProbe.lastUrl)
973
+ urlAfterClick = navProbe.lastUrl;
974
+ if (navProbe.safeUrl) {
975
+ safeUrl = navProbe.safeUrl;
976
+ }
977
+ else {
978
+ await appendTrace({
979
+ type: 'click_strategy_miss',
980
+ ts: new Date().toISOString(),
981
+ domIndex,
982
+ strategy: 'container_protocol',
983
+ url: navProbe.lastUrl,
984
+ waitedMs: navProbe.waitedMs,
985
+ });
986
+ console.warn(`[Phase2Collect] Click strategy no-open: strategy=container_protocol url=${String(navProbe.lastUrl || '')} waitedMs=${Number(navProbe.waitedMs || 0)}`);
987
+ }
988
+ }
989
+ if (!safeUrl) {
990
+ if (shouldEnsureFocusBeforeMouseFallback) {
991
+ await ensureBrowserFocus('mouse_center');
992
+ }
993
+ else {
994
+ console.log(`[Phase2Collect] Focus ensure skipped: platform=${process.platform} strategy=mouse_center`);
995
+ await appendTrace({
996
+ type: 'focus_ensure_skipped',
997
+ ts: new Date().toISOString(),
998
+ strategy: 'mouse_center',
999
+ platform: process.platform,
1000
+ });
1001
+ }
1002
+ for (const point of clickPoints.slice(0, 3)) {
1003
+ const strategy = `point:${String(point?.name || 'unknown')}`;
1004
+ attemptedStrategies.push(strategy);
1005
+ const clickRes = await performCoordinateClick(point);
1006
+ if (!clickRes.ok) {
1007
+ const errText = String(clickRes.error || 'unknown_click_error');
1008
+ clickErrors.push({ strategy, error: errText });
1009
+ console.warn(`[Phase2Collect] Click strategy failed: strategy=${strategy} reason=${errText}`);
1010
+ continue;
1011
+ }
1012
+ await delay(450);
1013
+ const navProbe = await waitForSafeExploreUrl(4800, 250);
1014
+ navWaitMs += Number(navProbe.waitedMs || 0);
1015
+ if (navProbe.lastUrl)
1016
+ urlAfterClick = navProbe.lastUrl;
1017
+ if (navProbe.safeUrl) {
1018
+ safeUrl = navProbe.safeUrl;
1019
+ break;
1020
+ }
1021
+ await appendTrace({
1022
+ type: 'click_strategy_miss',
1023
+ ts: new Date().toISOString(),
1024
+ domIndex,
1025
+ strategy,
1026
+ point,
1027
+ url: navProbe.lastUrl,
1028
+ waitedMs: navProbe.waitedMs,
1029
+ });
1030
+ console.warn(`[Phase2Collect] Click strategy no-open: strategy=${strategy} url=${String(navProbe.lastUrl || '')} waitedMs=${Number(navProbe.waitedMs || 0)}`);
1031
+ // When detail opened but URL lacks xsec_token, exit and retry next strategy.
1032
+ if (navProbe.lastUrl.includes('/explore/') && !navProbe.lastUrl.includes('xsec_token=')) {
1033
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl).catch(() => { });
1034
+ await delay(500);
1035
+ }
1036
+ }
1037
+ }
1038
+ const clickPhaseMs = Date.now() - clickStartMs;
1039
+ if (clickPoints.length === 0) {
1040
+ clickErrors.push({ strategy: 'point:none', error: 'no_click_point' });
1041
+ }
1042
+ const clickStrategies = attemptedStrategies.length > 0
1043
+ ? attemptedStrategies
1044
+ : clickPoints.slice(0, 3).map((p) => `point:${String(p?.name || 'unknown')}`);
1045
+ console.log(`[Phase2Collect] click phase took ${clickPhaseMs}ms strategies=${clickStrategies.join('->')}`);
1046
+ // Rigid post-click gate: must have /explore/ and xsec_token
1047
+ if (!safeUrl) {
1048
+ const hasExplore = urlAfterClick.includes('/explore/');
1049
+ const hasXsec = urlAfterClick.includes('xsec_token=');
1050
+ console.warn(`[Phase2Collect] Post-click gate FAILED: explore=${hasExplore} xsec=${hasXsec}, will retry same index`);
1051
+ await appendTrace({
1052
+ type: 'click_no_xsec_retry',
1053
+ ts: new Date().toISOString(),
1054
+ index: domIndex,
1055
+ url: urlAfterClick,
1056
+ clickPhaseMs,
1057
+ navWaitMs,
1058
+ clickStrategies,
1059
+ clickErrors,
1060
+ });
1061
+ await recoverFromPreClickStall('click_no_xsec_retry', { domIndex, hasExplore, hasXsec, clickErrors });
1062
+ // Back to search results if stuck in detail without xsec
1063
+ if (!urlAfterClick.includes('search_result')) {
1064
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl).catch(() => { });
1065
+ await delay(500);
1066
+ }
1067
+ continue; // Retry same index
1068
+ }
1069
+ console.log(`[Phase2Collect] Post-click gate PASSED: xsec_token present`);
1070
+ await appendTrace({
1071
+ type: 'click_after',
1072
+ ts: new Date().toISOString(),
1073
+ attempt: attempts,
1074
+ domIndex,
1075
+ urlAfterClick: safeUrl,
1076
+ clickPhaseMs,
1077
+ navWaitMs,
1078
+ });
1079
+ // 6. 校验 searchUrl(严格匹配 keyword)
1080
+ if (!isValidSearchUrl(searchUrl, keyword)) {
1081
+ console.warn(`[Phase2Collect] drop: search_url_mismatch expectedKeyword="${keyword}" searchUrl=${String(searchUrl)}`);
1082
+ await appendTrace({ type: 'drop', ts: new Date().toISOString(), reason: 'search_url_mismatch', expectedKeyword: keyword, searchUrl });
1083
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
1084
+ await delay(1000);
1085
+ continue;
1086
+ }
1087
+ if (!isValidSafeUrl(safeUrl)) {
1088
+ console.warn(`[Phase2Collect] safeUrl invalid, skip: ${safeUrl}`);
1089
+ await controllerAction('keyboard:press', {
1090
+ profileId: profile,
1091
+ key: 'Escape',
1092
+ }, unifiedApiUrl);
1093
+ await delay(1000);
1094
+ continue;
1095
+ }
1096
+ // NOTE: We already validated searchUrl strictly via isValidSearchUrl().
1097
+ // 7. 提取 noteId + 去重(按 noteId 全局唯一)
1098
+ const noteId = normalizeNoteId(getExploreIdFromUrl(safeUrl) || '') || '';
1099
+ if (!noteId) {
1100
+ console.warn(`[Phase2Collect] drop: missing_note_id safeUrl=${safeUrl}`);
1101
+ await appendTrace({ type: 'drop', ts: new Date().toISOString(), reason: 'missing_note_id', safeUrl });
1102
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
1103
+ await delay(1000);
1104
+ continue;
1105
+ }
1106
+ if (seenNoteIds.has(noteId)) {
1107
+ console.warn(`[Phase2Collect] drop: duplicate_note_id noteId=${noteId}`);
1108
+ await appendTrace({ type: 'drop', ts: new Date().toISOString(), reason: 'duplicate_note_id', noteId, safeUrl });
1109
+ if (preClickNoteId) {
1110
+ // Prevent repeatedly re-opening the same source card when mapped URL resolves to an already-seen note.
1111
+ seenExploreIds.add(preClickNoteId);
1112
+ seenNoteIds.add(preClickNoteId);
1113
+ }
1114
+ await recoverFromPreClickStall('duplicate_note_id', { domIndex, noteId, preClickNoteId });
1115
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
1116
+ await delay(1000);
1117
+ continue;
1118
+ }
1119
+ seenNoteIds.add(noteId);
1120
+ // Mark as seen only after a verified successful open to avoid dropping candidates on pre-click failures.
1121
+ seenExploreIds.add(noteId);
1122
+ if (preClickNoteId) {
1123
+ seenExploreIds.add(preClickNoteId);
1124
+ }
1125
+ preClickStallCount = 0;
1126
+ const linkRow = {
1127
+ noteId,
1128
+ safeUrl,
1129
+ // Bind to strict search_result?keyword=<keyword>.
1130
+ searchUrl: expectedSearchUrl,
1131
+ ts: new Date().toISOString(),
1132
+ };
1133
+ links.push(linkRow);
1134
+ if (typeof onLink === 'function') {
1135
+ try {
1136
+ await onLink(linkRow, { collected: links.length, targetCount });
1137
+ }
1138
+ catch (persistErr) {
1139
+ console.warn(`[Phase2Collect] onLink callback failed: ${String(persistErr)}`);
1140
+ await appendTrace({
1141
+ type: 'on_link_callback_error',
1142
+ ts: new Date().toISOString(),
1143
+ noteId,
1144
+ error: String(persistErr),
1145
+ });
1146
+ }
1147
+ }
1148
+ console.log(`[Phase2Collect] ✅ ${links.length}/${targetCount}: ${noteId}`);
1149
+ // 8. Close detail modal: prefer system-level container close, then ESC fallback.
1150
+ let backOk = false;
1151
+ try {
1152
+ await controllerAction('container:operation', {
1153
+ containerId: 'xiaohongshu_detail.modal_shell',
1154
+ operationId: 'click',
1155
+ sessionId: profile,
1156
+ config: {
1157
+ selector: '.note-detail-mask .close-box, .note-detail-mask .close-circle',
1158
+ useSystemMouse: true,
1159
+ retries: 1,
1160
+ },
1161
+ }, unifiedApiUrl);
1162
+ await delay(1500);
1163
+ }
1164
+ catch {
1165
+ // ignore, fallback to ESC below
1166
+ }
1167
+ // ESC fallback (system keyboard)
1168
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
1169
+ for (let i = 0; i < 30; i++) {
1170
+ const res = await controllerAction('browser:execute', {
1171
+ profile,
1172
+ script: `(function(){
1173
+ const url = window.location.href;
1174
+ const hasResultList = !!document.querySelector('.feeds-container, .search-result-list, .note-list');
1175
+ const hasTabs = !!document.querySelector('.tabs, .filter-tabs, [role="tablist"], .filter');
1176
+ const hasSearchInput = !!document.querySelector('#search-input, input[type="search"], input[placeholder*="搜索"], input[placeholder*="关键字"]');
1177
+ const selectors = [
1178
+ '.note-detail-mask',
1179
+ '.note-detail-page',
1180
+ '.note-detail-dialog',
1181
+ '.note-detail',
1182
+ '.detail-container',
1183
+ '.media-container'
1184
+ ];
1185
+ const isVisible = (el) => {
1186
+ if (!el) return false;
1187
+ const style = window.getComputedStyle(el);
1188
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
1189
+ const r = el.getBoundingClientRect();
1190
+ if (!r.width || !r.height) return false;
1191
+ if (r.bottom <= 0 || r.top >= window.innerHeight) return false;
1192
+ return true;
1193
+ };
1194
+ let visibleOverlay = null;
1195
+ for (const sel of selectors) {
1196
+ const el = document.querySelector(sel);
1197
+ if (el && isVisible(el)) { visibleOverlay = el; break; }
1198
+ }
1199
+ return { url, hasResultList, hasTabs, hasSearchInput, hasDetailOverlay: !!visibleOverlay };
1200
+ })()`,
1201
+ }, unifiedApiUrl).then((r) => r?.result || r?.data?.result || null);
1202
+ if (res && (res.hasResultList || res.hasTabs) && res.hasSearchInput && !res.hasDetailOverlay) {
1203
+ backOk = true;
1204
+ break;
1205
+ }
1206
+ await delay(500);
1207
+ }
1208
+ if (!backOk) {
1209
+ const urlNow = await controllerAction('browser:execute', { profile, script: 'window.location.href' }, unifiedApiUrl).then((r) => r?.result || r?.data?.result || '');
1210
+ throw new Error(`[Phase2Collect] close detail failed: still not on search/home (overlay visible) url=${urlNow}`);
1211
+ }
1212
+ }
1213
+ }
1214
+ finally {
1215
+ // Always calculate termination reason based on final state.
1216
+ // If we did not reach target, treat as no_progress to avoid false "reached_target" when max attempts are exhausted.
1217
+ const termination = links.length >= targetCount ? 'reached_target' : 'no_progress_after_3_retries';
1218
+ console.log(`[Phase2Collect] 完成,滚动次数: ${scrollCount}, 终止原因: ${termination}`);
1219
+ // Always try to restore to search_result page on exit (success or failure)
1220
+ try {
1221
+ const det = await detectXhsCheckpoint({ sessionId: profile, serviceUrl: unifiedApiUrl });
1222
+ if (det.checkpoint === 'detail_ready' || det.checkpoint === 'comments_ready') {
1223
+ console.log('[Phase2Collect] Exit cleanup: restoring to search_result from detail page');
1224
+ for (let i = 0; i < 3; i++) {
1225
+ await controllerAction('keyboard:press', { profileId: profile, key: 'Escape' }, unifiedApiUrl);
1226
+ await delay(1500);
1227
+ const afterEsc = await controllerAction('browser:execute', {
1228
+ profile,
1229
+ script: 'window.location.href',
1230
+ }, unifiedApiUrl).then(res => res?.result || res?.data?.result || '');
1231
+ if (typeof afterEsc === 'string' && afterEsc.includes('/search_result')) {
1232
+ console.log('[Phase2Collect] Exit cleanup: ✅ restored to search_result');
1233
+ break;
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ catch (cleanupErr) {
1239
+ console.warn('[Phase2Collect] Exit cleanup failed:', String(cleanupErr));
1240
+ }
1241
+ }
1242
+ const termination = links.length >= targetCount ? 'reached_target' : 'no_progress_after_3_retries';
1243
+ return {
1244
+ links,
1245
+ totalCollected: links.length,
1246
+ termination,
1247
+ };
1248
+ }
1249
+ //# sourceMappingURL=Phase2CollectLinksBlock.js.map