{"id":"ce0e52c0-6c07-4e8f-9d99-8a91bb35c309","entityType":"agent","slug":"clawhub-kanikig-wechat-search-weread","name":"Wechat Search Weread","canonicalUrl":"https://www.xpersona.co/agent/clawhub-kanikig-wechat-search-weread","canonicalPath":"/agent/clawhub-kanikig-wechat-search-weread","generatedAt":"2026-10-10T08:46:39.078Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"editorial-content","verified":true,"confidence":"high","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":null},"description":"微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Skill: Wechat Search Weread Owner: kanikig Summary: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Tags: latest:1.0.3, search:0.1.3, social-media:0.1.3, wechat:0.1.3, weread:0.1.3 Version history: v1.0.3 | 2026-06-09T05:07:15.794Z | user 去浏览器绑定：前提一处说明，后续步骤不指定浏览器；Scroll事件统一用dispatchEvent解决 v1.0.2 | 2026-06-09T01:05:35.700Z | user Fix Edge CDP scroll event: dispatchEvent('scroll') workaround for v149+ v1.0.1 | 2026-","descriptionLabel":"Technical summary","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.7K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s17fjyh46ve2tpbjbrarkfm0jh86tgmx:wechat-search-weread","sourceUrl":"https://clawhub.ai/kanikig/wechat-search-weread","homepage":"https://clawhub.ai/kanikig/skills/wechat-search-weread","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/kanikig/wechat-search-weread","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/kanikig/skills/wechat-search-weread","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":65,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Skill: Wechat Search Weread Owner: kanikig Summary: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Tags: la"},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":null},"stars":null,"forks":null,"downloads":1683,"packageName":null,"latestVersion":"1.0.3","tractionLabel":"1.7K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T04:32:48.162Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T04:32:48.258Z","lastCrawledAt":"2026-10-10T04:32:48.162Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T04:32:48.162Z","lastVerifiedAt":null,"highlights":[{"version":"1.0.3","createdAt":"2026-06-09T05:07:15.794Z","changelog":"去浏览器绑定：前提一处说明，后续步骤不指定浏览器；Scroll事件统一用dispatchEvent解决","fileCount":13,"zipByteSize":35724},{"version":"1.0.2","createdAt":"2026-06-09T01:05:35.700Z","changelog":"Fix Edge CDP scroll event: dispatchEvent('scroll') workaround for v149+","fileCount":13,"zipByteSize":35341},{"version":"1.0.1","createdAt":"2026-05-25T17:57:50.752Z","changelog":"精简 SKILL.md，新增 LLM 语义筛选模式，移除 cron-dedup","fileCount":11,"zipByteSize":26258},{"version":"0.1.7","createdAt":"2026-05-19T10:10:22.055Z","changelog":"### wechat-search-weread v0.1.7 - Expanded and updated documentation across key reference files. - Improved clarity in the main workflow, especially in [references/detailed-workflow.md]. - Added/updated instructions and patterns for deduplication ([references/cron-dedup-pattern.md]) and extraction ([references/extraction-pattern.md]). - README streamlined with additional links to detailed guides. - No changes to code logic; changes are documentation-focused.","fileCount":10,"zipByteSize":24362},{"version":"0.1.6","createdAt":"2026-05-18T08:40:21.700Z","changelog":"docs: 添加 GitHub/ClawHub 互链","fileCount":10,"zipByteSize":22921},{"version":"0.1.5","createdAt":"2026-05-18T08:33:20.439Z","changelog":"chore: 输出标题简化为「公众号文章搜索结果」","fileCount":10,"zipByteSize":22864},{"version":"0.1.4","createdAt":"2026-05-18T08:30:12.104Z","changelog":"fix: 日期相对时间→真实日期(parse_date); 先备份weread标签页再导航搜索; 完成后关闭所有多余标签页只保留登录页","fileCount":10,"zipByteSize":22869},{"version":"0.1.3","createdAt":"2026-05-18T06:46:17.754Z","changelog":"Refactor: SKILL.md 479→85行精简，详细命令搬 references/detailed-workflow.md。修复skill内容泄露到无关回复的问题。登录成功后删临时QR。","fileCount":10,"zipByteSize":22443}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17fjyh46ve2tpbjbrarkfm0jh86tgmx:wechat-search-weread","setupComplexity":"low","setupSteps":["Setup complexity is classified as HIGH. You must provision dedicated cloud infrastructure or an isolated VM. Do not run this directly on your local workstation.","Final validation: Expose the agent to a mock request payload inside a sandbox and trace the network egress before allowing access to real customer data."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T08:46:39.075Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-kanikig-wechat-search-weread/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"high","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":null},"readme":"Skill: Wechat Search Weread\n\nOwner: kanikig\n\nSummary: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。\n\nTags: latest:1.0.3, search:0.1.3, social-media:0.1.3, wechat:0.1.3, weread:0.1.3\n\nVersion history:\n\nv1.0.3 | 2026-06-09T05:07:15.794Z | user\n\n去浏览器绑定：前提一处说明，后续步骤不指定浏览器；Scroll事件统一用dispatchEvent解决\n\nv1.0.2 | 2026-06-09T01:05:35.700Z | user\n\nFix Edge CDP scroll event: dispatchEvent('scroll') workaround for v149+\n\nv1.0.1 | 2026-05-25T17:57:50.752Z | user\n\n精简 SKILL.md，新增 LLM 语义筛选模式，移除 cron-dedup\n\nv0.1.7 | 2026-05-19T10:10:22.055Z | auto\n\n### wechat-search-weread v0.1.7\n\n- Expanded and updated documentation across key reference files.\n- Improved clarity in the main workflow, especially in [references/detailed-workflow.md].\n- Added/updated instructions and patterns for deduplication ([references/cron-dedup-pattern.md]) and extraction ([references/extraction-pattern.md]).\n- README streamlined with additional links to detailed guides.\n- No changes to code logic; changes are documentation-focused.\n\nv0.1.6 | 2026-05-18T08:40:21.700Z | user\n\ndocs: 添加 GitHub/ClawHub 互链\n\nv0.1.5 | 2026-05-18T08:33:20.439Z | user\n\nchore: 输出标题简化为「公众号文章搜索结果」\n\nv0.1.4 | 2026-05-18T08:30:12.104Z | user\n\nfix: 日期相对时间→真实日期(parse_date); 先备份weread标签页再导航搜索; 完成后关闭所有多余标签页只保留登录页\n\nv0.1.3 | 2026-05-18T06:46:17.754Z | user\n\nRefactor: SKILL.md 479→85行精简，详细命令搬 references/detailed-workflow.md。修复skill内容泄露到无关回复的问题。登录成功后删临时QR。\n\nv0.1.2 | 2026-05-18T06:01:35.751Z | user\n\nFix: 移除参数错误workaround — 根因是execute_code控制台输出截断URL。强制从/tmp/urls.json读取完整URL。新增api-limitations参考、cron-dedup-pattern。\n\nv0.1.1 | 2026-05-16T22:21:53.263Z | user\n\nUpdate description\n\nv0.1.0 | 2026-05-16T22:07:52.796Z | auto\n\nwechat-search-weread v0.1.0\n\n- Initial release of WeChat Read public account article search skill.\n- Allows searching WeChat public account articles via WeRead’s “搜一搜”, returning title, official account, publish date, summary, thumbnail, and article URL.\n- Requires agent-browser and valid WeRead login session.\n- Offers complete workflow instructions for browser interaction, WeRead login (including QR code extraction), search, scroll, data extraction, and session management.\n- Designed for use when Exa and Sogou search methods are insufficient.\n\nArchive index:\n\nArchive v1.0.3: 13 files, 35724 bytes\n\nFiles: README.md (2204b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/detailed-workflow.md (18184b), references/edge-cdp-scroll-issue.md (2478b), references/extraction-pattern.md (18669b), references/llm-filter-pattern.md (8408b), references/topology-optimization-filter.md (6198b), references/wsl-cdp-browser.md (2953b), scripts/search_weread.py (8575b), skill-card.md (2889b), SKILL.md (4407b), _meta.json (139b)\n\nFile v1.0.3:SKILL.md\n\n---\nname: wechat-search-weread\ndescription: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。\ncategory: social-media\n---\n\n# 微信读书公众号文章搜索\n\n> 📦 [GitHub](https://github.com/KANIKIG/wechat-search-weread) · [ClawHub](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜搜索微信公众号文章，返回完整数据（含 `mp.weixin.qq.com` 文章链接）。\n\n## 触发条件\n\n用户需要搜索微信公众号文章，且 Exa 和搜狗方案不满足需求时使用。\n\n> ⚠️ 微信读书官方 API (`/store/search` scope=2/4) 不支持公众号文章搜索（返回 `-2041`），详见 [api-limitations.md](references/api-limitations.md)。本浏览器方案是当前唯一可行途径。\n\n## 前提\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) 已安装\n- 微信读书已登录（首次需扫码）\n- WSL 用户需 CDP 端口转发（见 [wsl-cdp-browser.md](references/wsl-cdp-browser.md)），用 `agent-browser connect \"http://<IP>:9223\"` 建连后无需 `--cdp` flag\n- **浏览器**：有 Edge 用 Edge，没有就 Chrome。CDP scroll 事件不触发时用 `window.dispatchEvent(new Event('scroll'))` 解决（见 [detailed-workflow.md](references/detailed-workflow.md) Step 3.2）。\n\n## 流程概要（5 步，缺一不可）\n\n| 步骤 | 要点 |\n|------|------|\n| **1. 启动浏览器** | 非WSL自动；WSL 参考 [wsl-cdp-browser.md](references/wsl-cdp-browser.md) |\n| **2. 登录** | QR 扫码。首次 eval 触发的 QR 大概率过期 → 必须关闭弹窗→重新触发→提取（单 terminal 调用一气呵成）。**登录成功后删除 `/tmp/weread_qr.png`** |\n| **3. 搜索+滚动** | 先备份 weread 标签页（保持登录态）→ goto 搜索页 → eval 滚动到「暂无更多内容」（封顶500条） |\n| **4. 提取URL** | ① ≤50 篇：agent-browser eval 同步提取（最简单）；② >50 篇：agent-browser eval 分批提取（每批 50，已验证 246 篇 100% 成功）；③ execute_code + websockets 备选（有超时风险）。详见 [extraction-pattern.md](references/extraction-pattern.md) |\n| **5. 清理** | 回到微信读书首页 → 关闭搜索页、mp 链接等所有多余标签页，只保留一个 weread 页面 |\n\n> 🔴 **全部详细命令、代码块、陷阱表见 [references/detailed-workflow.md](references/detailed-workflow.md)**。\n\n## 原理简述\n\n- **搜索 API**: 页面 XHR → `weread.qq.com/web/wx_search_broker_proxy`（需 session cookie）\n- **URL 获取**: DOM 无 href，点击 `.search_list_item` → Vue → `window.open(mp.weixin.qq.com/...)` → eval 拦截\n- **滚动加载**: 每次 scrollTo 触发 XHR 加载 ~45 篇新文章\n- **去重**: 每篇文章有唯一 `data-id`\n\n## 结果投递\n\n搜索完成后，**直接以最终回复输出结果**，系统会自动投递到当前对话。\n\n输出格式（Step 4.4）：搜到 X 篇 → 提取 Y 篇 → Z 篇有链接（成功率 N%）+ Top 10 列表，完整数据存 /tmp/urls.json。\n\n🔴 **链接必须从 `/tmp/urls.json` 读取，严禁从控制台输出或记忆中复制 URL**。控制台输出会截断长 URL（丢失 `chksm` 或 `#rd`），导致微信打开显示\"参数错误\"。正确做法：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\nfor i, r in enumerate(data[:10]):\n    print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r.get('source','')} · {r.get('date','')}\\\")\n\"\n```\n\n## References\n\n- `references/detailed-workflow.md` — **完整操作手册**：Step 1-5 全部命令、QR 流程、滚动策略、URL 提取代码\n- `references/extraction-pattern.md` — Python 提取代码模板（execute_code 中直接执行）\n- `references/cdp-patterns.md` — CDP 交互模式参考\n- `references/wsl-cdp-browser.md` — WSL 配置指南\n- `references/api-limitations.md` — 微信读书 API 限制说明\n- `references/llm-filter-pattern.md` — LLM 语义筛选模式：搜索噪音过滤（替代关键词黑名单）\n- `references/topology-optimization-filter.md` — 拓扑优化领域专用筛选模式：已验证的排除正则、陷阱、持久化格式\n- `references/edge-cdp-scroll-issue.md` — CDP scroll 事件不触发问题：诊断、验证方法、解决方案\n- `scripts/search_weread.py` — 备选/调试脚本（非主流程）\n\nFile v1.0.3:README.md\n\n# wechat-search-weread\n\n[![GitHub](https://img.shields.io/badge/GitHub-KANIKIG%2Fwechat--search--weread-blue?logo=github)](https://github.com/KANIKIG/wechat-search-weread)\n[![ClawHub](https://img.shields.io/badge/ClawHub-wechat--search--weread-orange)](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜，搜索微信公众号文章。返回**标题、公众号、发布时间、简介**以及 **`mp.weixin.qq.com` 直链**。\n\n> ⚠️ **本项目只负责搜索和获取文章链接，不包含抓取文章正文内容。**\n\n## 和其他方案比\n\n| 方案 | 文章时效 | 有发布日期 | 有直链 | 需要 |\n|------|---------|-----------|--------|------|\n| **微信读书搜索（本项目）** | ✅ 最新 | ✅ | ✅ | 微信扫码 |\n| Exa (mcporter) | ❌ | ❌ | ✅ | API Key |\n| 搜狗微信搜索 | ❌ ~2021 | ✅ | ✅ | 无 |\n\n**唯一能同时拿到「最新文章 + 发布日期 + 直链」的方案。**\n\n## 使用方式\n\n通过 Agent 对话触发。在聊天窗口告诉 Agent 你要搜什么即可：\n\n> \"搜一下最近关于大模型的公众号文章\"\n> \"微信搜索 OpenAI\"\n\nAgent 会自动调用本 Skill 完成搜索。\n\n**如果微信读书还没登录**，Agent 会把登录二维码发到聊天窗口，微信扫码即可。登录后继续搜索，全程自动。\n\n搜索完成后，你会收到：\n\n```\n关键词 公众号文章搜索\n\n搜到 258 篇 → 提取 258 篇 → 252 篇有链接（成功率 98%）\n\n🔥 Top 10：\n\n1. [文章标题](http://mp.weixin.qq.com/s/...) — 2小时前\n2. [文章标题](http://mp.weixin.qq.com/s/...) — 昨天\n...\n```\n\n默认展示前 10 篇，完整数据保存在 `/tmp/urls.json`。\n\n## 原理简述\n\n微信读书搜索结果页面本身不暴露文章链接。点击文章卡片时页面通过 `window.open` 打开目标文章。本项目通过浏览器自动化拦截这个操作，批量获取所有文章的真实 `mp.weixin.qq.com` 链接。\n\n## 依赖\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) — 浏览器自动化控制\n\n## 声明\n\n- 本项目**仅搜索公众号文章并获取链接**，不抓取正文\n- 需要微信扫码登录，session 过期后需重新扫码\n\nFile v1.0.3:_meta.json\n\n{\n  \"ownerId\": \"kn7ay2vk3fst9dazwsrj83d4kn86ts13\",\n  \"slug\": \"wechat-search-weread\",\n  \"version\": \"1.0.3\",\n  \"publishedAt\": 1780981635794\n}\n\nFile v1.0.3:references/api-limitations.md\n\n# 微信读书官方 Agent API 限制\n\n## 背景\n\n微信读书提供了官方 Agent API（`POST https://i.weread.qq.com/api/agent/gateway`，需 API Key），声称通过 `/store/search` 的 `scope` 参数支持搜索公众号（scope=2）和文章（scope=4）。\n\n## 实测结论\n\n| scope | 名称 | 状态 | 说明 |\n|-------|------|------|------|\n| 0 | 全部 | ✅ | 搜书（电子书/有声书/书单等） |\n| 10 | 电子书 | ✅ | 正常 |\n| 12 | 全文 | ✅ | 搜书内正文，返回 `currentCount=0` |\n| 2 | 公众号 | ❌ `-2041` | 不可用 |\n| 4 | 文章 | ❌ `-2041` | 不可用 |\n\n**公众号文章搜索（scope=2/4）在后端未对 Agent API Key 开放。** 浏览器方案（weread.qq.com 搜一搜 + CDP 提取）是当前唯一的公众号文章搜索途径。\n\n## 测试时间\n\n2026-05-17，API Key `wrk-FGVctlbTTPaWm7gmFFAi7gAA`，skill_version 1.0.3。\n\nFile v1.0.3:references/cdp-patterns.md\n\n# CDP Interaction Patterns for WeChat Read Search\n\n## CDP Endpoint Levels\n\n### Browser level (`/json/version`)\n- Good for: `Browser.getVersion`, getting initial WS URL for agent-browser\n- **Cannot** receive: `Network.*`, `Page.windowOpen`, `Page.frameNavigated` events\n- URL: `curl -s http://<WINDOWS_IP>:9223/json/version | jq -r .webSocketDebuggerUrl`\n\n### Page level (`/json` list → page targets)\n- **Required** for: all page events, Network interception, Runtime.evaluate\n- URL: `curl -s http://<WINDOWS_IP>:9223/json | jq -r '.[] | select(.type==\"page\" and (.url | contains(\"search.weixin\"))) | .webSocketDebuggerUrl'`\n\n## Key CDP Commands\n\n### Enable domains (MUST do first)\n```json\n{\"id\": 1, \"method\": \"Page.enable\"}\n{\"id\": 2, \"method\": \"Network.enable\"}\n```\n\n### Mouse click (for triggering navigation)\nWeChat search page requires real mouse events for navigation. JS `element.click()` won't work.\n\n```json\n// Get coordinates\n{\"id\": 3, \"method\": \"Runtime.evaluate\", \"params\": {\n    \"expression\": \"document.querySelector('.search_list_item .article__title-text').getBoundingClientRect()\"\n}}\n// Response: {x, y, width, height}\n\n// Click\n{\"id\": 4, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mousePressed\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n{\"id\": 5, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mouseReleased\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n```\n\n### Catch article URL\nAfter click, listen for:\n```json\n{\"method\": \"Page.windowOpen\", \"params\": {\"url\": \"https://mp.weixin.qq.com/s?__biz=...&mid=...&idx=...&sn=...\"}}\n```\n\n## Common Errors\n\n### \"Frame with the given frameId is not found\"\n- Happens when clicking iframe elements via agent-browser before iframe fully renders\n- Fix: wait 3-6 seconds and retry\n\n### \"Debugger agent is not enabled\"\n- Happens when calling Debugger.* methods without `Debugger.enable`\n- Usually unnecessary; just use Runtime.evaluate instead\n\n### TimeoutError on ws.recv()\n- CDP sends events asynchronously; not all messages are responses to your commands\n- Use `asyncio.wait_for(ws.recv(), timeout=N)` in a loop\n- Filter by checking `data.get('method')` for the event you want\n\nFile v1.0.3:references/detailed-workflow.md\n\n# 完整操作流程\n\n> 主 SKILL.md 的扩展，包含每个 Step 的完整命令和代码。\n\n## 前提\n\n- agent-browser 已安装并连接（WSL: `agent-browser connect \"http://<IP>:9223\"`）\n- 微信读书已登录\n\n> ⚠️ **黄金法则**：每一步操作后都等 6-10 秒！页面元素需要时间加载。\n> ⚠️ **长滚动（>3 轮）必须用前台 terminal + 长 timeout（600s）**，不要用 background 进程。\n\n---\n\n## Step 1: 启动浏览器\n\n**非WSL**：跳过，agent-browser 自动管理浏览器。\n\n**WSL 用户**：参考 [wsl-cdp-browser.md](wsl-cdp-browser.md) 启动浏览器 CDP 并配置端口转发。\n\n验证连接：`agent-browser get cdp-url`（非WSL）或 `curl http://<IP>:9223/json/version`（WSL）\n\n---\n\n## Step 2: 登录微信读书（统一用扫码！）\n\n### 2.0 清除旧登录态（可选，cookie 残留时）\n\n微信读书的认证 cookie 是 httpOnly 的，需用 CDP `Storage.clearCookies`（**page 级别** WebSocket）：\n\n```python\nimport asyncio, json, subprocess, websockets\n\n# 获取 CDP browser WebSocket URL\n# 非WSL: result = subprocess.run(['agent-browser', 'get', 'cdp-url'], capture_output=True, text=True)\n# WSL:   result = subprocess.run(['curl', '-s', 'http://WINDOWS_IP:9223/json/version'], capture_output=True, text=True)\n#         version['webSocketDebuggerUrl']\n\n# 获取 weread 页面的 page-level WS URL\nresult = subprocess.run(['curl', '-s', '<browser_ws_url替换为/json>'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_ws = None\nfor t in targets:\n    if t['type'] == 'page' and 'weread' in t.get('url', ''):\n        weread_ws = t['webSocketDebuggerUrl']\n        break\n\nasync def clear():\n    async with websockets.connect(weread_ws, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id': 1, 'method': 'Storage.clearCookies', 'params': {}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=5)\n        print(resp)\n\nasyncio.run(clear())\n```\n\n> `Network.clearBrowserCookies` 在 CDP 可能返回 `-32601`，只用 `Storage.clearCookies`。\n\n清除后 reload 页面确认\"登录\"链接出现。\n\n### 2.1 导航并点击登录\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 8\n```\n\n触发登录弹窗（优先用 eval）：\n\n```bash\n# ✅ 方法1（推荐）：eval 触发\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# ⚠️ 方法2（备选）：agent-browser click — 经常无效\n# agent-browser snapshot | grep -i 登录\n# agent-browser click <登录的ref>\n# sleep 6\n```\n\n### 2.2 QR 码提取（默认「刷新后提取」）\n\n> 首次 eval 触发的 QR 经常是旧缓存的。必须：关闭弹窗 → 重新触发 → 提取。\n\n**必须用一个 terminal() 调用跑完 Step A-E！** 不要拆分。\n\n```bash\n# Step A: eval 触发第一次登录（仅用于唤起弹窗，5s 足够）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 5\n\n# Step B: 关闭弹窗（丢弃旧 QR）\nagent-browser eval \"var m=document.querySelector('.mask,.dialog_mask,.wr_overlay');if(m)m.click();'closed'\"\nsleep 2\n\n# Step C: 重新触发登录（拿到新鲜 QR，6s 即可）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# Step D: 一键提取 QR（移除 iframe + 提取 src）\nagent-browser eval \"\n(function() {\n  const iframes = document.querySelectorAll('iframe');\n  iframes.forEach(f => f.remove());\n  const imgs = document.querySelectorAll('img');\n  for (const img of imgs) {\n    if (img.width > 100 && img.height > 100) return img.src;\n  }\n  return '';\n})()\" > /tmp/qr_url.txt\n\n# Step E: 解析并下载 QR → 立即发码给用户\npython3 -c \"\nimport json, base64, os\nwith open('/tmp/qr_url.txt') as f:\n    raw = f.read().strip()\nsrc = json.loads(raw)\nif not src:\n    print('ERROR: no QR image found')\n    exit(1)\nif src.startswith('data:image'):\n    b64 = src.split(',', 1)[1]\n    b64 += '=' * (4 - len(b64) % 4) if len(b64) % 4 else ''\n    with open('/tmp/weread_qr.png', 'wb') as f:\n        f.write(base64.b64decode(b64))\nelse:\n    import subprocess; subprocess.run(['curl', '-s', '-o', '/tmp/weread_qr.png', src])\nprint(f'QR OK: {os.path.getsize(\\\"/tmp/weread_qr.png\\\")} bytes')\n\"\n# 整个 block 结束后立刻在回复里发 MEDIA:/tmp/weread_qr.png\n```\n\n**如果 QR 仍失效（极少情况）：** 重复 Step B-D。如果登录弹窗完全消失，重新 goto + 触发：\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n# 再次一键提取\n```\n\n**验证登录：**\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser snapshot | grep -i \"我的书架\\|继续阅读\"\n```\n\n**登录成功后清理临时文件：**\n\n```bash\nrm -f /tmp/weread_qr.png /tmp/qr_url.txt\n```\n\n---\n\n## Step 3: 搜索 + 大批量滚动加载\n\n> 🔴 **铁律：weread.qq.com 标签页绝对不能关！绝对不能离开！**\n> 🔴 **滚动必须用 agent-browser 的 eval 执行，Python page WS 的 Runtime.evaluate 不会触发 XHR。**\n\n### 3.1 导航到搜索页 + 保持 weread session\n\n```bash\n# A. 先保留登录会话（在导航走之前创建 weread 备份标签页）\n#    获取 browser WS URL（非WSL: agent-browser get cdp-url，WSL: curl http://IP:9223/json/version）\npython3 -c \"\nimport asyncio, json, subprocess, websockets\nWINDOWS_IP = subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout.split('default via ')[1].split()[0] if 'default via ' in subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout else '172.19.80.1'\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:9223/json/version'], capture_output=True, text=True)\nbw = json.loads(result.stdout)['webSocketDebuggerUrl']\n\nasync def create():\n    async with websockets.connect(bw, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id':1,'method':'Target.createTarget','params':{'url':'https://weread.qq.com/'}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=10)\n        print(resp)\nasyncio.run(create())\n\"\nsleep 5\n```\n\n> 💡 **简易替代**：当 CDP websockets 路径被阻断（cron 禁用 execute_code、安全扫描器拦截 heredoc）时，可用 `agent-browser open \"https://weread.qq.com/\"` 替代上面的 Python 代码创建备份标签页。效果相同，更简单。\n# B. 导航到搜索页（原标签页变为搜索页，备份的 weread 标签页保持登录态）\nagent-browser goto \\\n  \"https://search.weixin.qq.com/cgi-bin/newsearchweb/userclientjump?path=page/search/weread&query=URL编码关键词&platform=pc\"\nsleep 8\nagent-browser eval \"document.querySelectorAll('.search_list_item').length\"\n# → 初始 15 篇\n```\n\n### 3.2 滚动加载（默认滚到「暂无更多内容」，500 条封顶）\n\n> ⚠️ **CDP 滚动注意事项**：某些 CDP 实现中 `window.scrollTo()` / `scroll down` / `PageDown` 会改变 `scrollY` 但不会触发 `scroll` 事件。搜索页的无限滚动依赖 scroll 事件触发 XHR。**统一做法：每次滚动后手动 `window.dispatchEvent(new Event('scroll'))` 以确保触发加载**。\n\n```bash\n# 每轮 = 3 次 [scrollTo + dispatchEvent] + 2s 间隔 + 3s 渲染等待。每轮新增 ~45 篇。\nPREV=\"\"; PREV2=\"\"\nfor r in $(seq 1 20); do\n  for j in 1 2 3; do\n    agent-browser eval \"window.scrollTo(0, document.body.scrollHeight); window.dispatchEvent(new Event('scroll'));\" > /dev/null 2>&1\n    sleep 2\n  done\n  sleep 3\n  C=$(agent-browser eval \"document.querySelectorAll('.search_list_item').length\" 2>/dev/null)\n  echo \"R$r: $C\"\n  # 超过 500 封顶\n  if [ \"$C\" -gt 500 ] 2>/dev/null; then\n    echo \"HIT 500 cap, stopping\"\n    break\n  fi\n  # 检查\"暂无更多内容\"\n  NOMORE=$(agent-browser eval \"document.body.textContent.includes('暂无更多内容')\" 2>/dev/null)\n  if [ \"$NOMORE\" = \"true\" ]; then\n    echo \"HIT 暂无更多内容 at $C, stopping\"\n    break\n  fi\n  # 连续 3 轮不变（兜底）\n  if [ \"$C\" = \"$PREV\" ] && [ \"$C\" = \"$PREV2\" ]; then\n    echo \"PLATEAU at $C, stopping\"\n    break\n  fi\n  PREV2=$PREV; PREV=$C\ndone\n```\n\n> 典型增长曲线：R1→60, R2→105, R3→150, R4→195, R5→240, R6→258（然后持平）。\n\n如果两轮后数量不变（始终 15 篇）：① weread session 丢失 ② 用了 Python page WS 做滚动 ③ scroll 事件未触发（未加 `dispatchEvent`，见 [edge-cdp-scroll-issue.md](edge-cdp-scroll-issue.md)）。\n\n---\n\n## Step 4: 提取文章数据 + URL\n\n### 4.1 批量采集文章元数据\n\n```bash\nagent-browser eval \"\n(() => {\n    const arts = [];\n    document.querySelectorAll('.search_list_item').forEach((item, i) => {\n        const t = item.querySelector('.article__title-text');\n        const d = item.querySelector('.article__desc');\n        const s = item.querySelector('.source__title');\n        const dt = item.querySelector('.source__text.date');\n        arts.push({\n            idx: i,\n            title: (t?.textContent || '').trim(),\n            desc: (d?.textContent || '').trim().substring(0,200),\n            source: (s?.textContent || '').trim(),\n            date: (dt?.textContent || '').trim(),\n            dataId: item.getAttribute('data-id') || ''\n        });\n    });\n    return JSON.stringify(arts);\n})()\" > /tmp/arts.json\n```\n\n> `agent-browser eval` 返回的字符串被双重引号包裹（`\"[...]\"`），解析需 `json.loads(json.loads(raw))`。\n\n### 4.2 筛选目标文章\n\n用 Python 解析日期，筛选近一周（或用户指定的时间范围）：\n\n```python\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime。支持：X年前/个月前/周前/天前/小时前/分钟前、YYYY-MM-DD、YYYY/M/D、M月D日、M/D、M-D\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 365) if m else None\n    if '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 30) if m else None\n    if '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(weeks=int(m.group(1))) if m else None\n    if '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1))) if m else None\n    if '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(hours=int(m.group(1))) if m else None\n    if '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(minutes=int(m.group(1))) if m else None\n    if re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    if re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    if re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n```\n\n### 4.3 获取文章 URL（execute_code 中执行）\n\n用 execute_code + Python + websockets，连搜索页 page-level CDP WebSocket，一次 Runtime.evaluate 批量完成。\n\n获取 CDP WebSocket URL：\n- 非WSL：`agent-browser get cdp-url` 得浏览器级 WS → 访问 `/json` 获取 page targets\n- WSL：`curl -s \"http://$WINDOWS_IP:9223/json\"` 获取 page targets\n\n核心 JS（async + await sleep 防竞态）：\n\n```js\n(async function() {\n    var sleep = ms => new Promise(r => setTimeout(r, ms));\n    var items = document.querySelectorAll('.search_list_item');\n    var orig = window.open;\n    for (var i = 0; i < targets.length; i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; };\n        items[targets[i]].scrollIntoView({block: 'center', behavior: 'instant'});\n        items[targets[i]].click();\n        await sleep(50);\n        results.push({idx: targets[i], url: captured});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\n```\n\n关键点：\n- 点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`\n- 拦截 `window.open` 返回 null，不弹新标签页\n- 必须 async function + `await sleep(50)` + `awaitPromise: true`\n\n完整提取模板见 [extraction-pattern.md](extraction-pattern.md)。\n\n### 4.4 输出格式\n\n**严禁从 execute_code 控制台输出复制 URL。** 强制从 `/tmp/urls.json` 读取：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\ntotal = len(data)\nsucc = sum(1 for r in data if r['url'])\nprint(f'**公众号文章搜索结果**')\nprint()\nprint(f'搜到 {total} 篇 → 提取 {total} 篇 → {succ} 篇有链接（成功率 {100*succ//max(total,1)}%）')\nprint()\nprint('🔥 Top 10：')\nprint()\nfor i, r in enumerate(data[:10]):\n    if r['url']:\n        print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r['date']}\\\")\n\"\n```\n\n回复用户时只发摘要，完整数据存 `/tmp/urls.json`。\n\n---\n\n## Step 5: 完成后的清理\n\n**不关浏览器！** 回到微信读书登录页，关闭搜索页等所有多余标签页，只保留一个 weread 页面。\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# 先回到微信读书首页（agent-browser 标签页变为 weread）\nagent-browser goto \"https://weread.qq.com/\"\nsleep 5\n\n# 关闭其他所有标签页（搜索页 + mp.weixin.qq.com + 备份 weread），只保留当前 weread\npython3 -c \"\nimport json, subprocess\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\n# 第一轮：关掉所有非 weread 页面\nfor t in targets:\n    if t['type'] == 'page' and 'weread.qq.com' not in t.get('url', ''):\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed: {t[\\\"url\\\"][:80]}')\n# 第二轮：如果还有多个 weread 页面，把多余的也关了\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_tabs = [t for t in targets if t['type'] == 'page' and 'weread.qq.com' in t.get('url', '')]\nif len(weread_tabs) > 1:\n    for t in weread_tabs[1:]:  # 保留第一个，关掉其余的\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed duplicate weread: {t[\\\"id\\\"]}')\nprint('Done — only weread login page remains')\n\"\n```\n\n---\n\n## 完整陷阱表\n\n| 陷阱 | 解决方案 |\n|------|---------|\n| 滚动后 scroll 事件不触发（scrollY变但事件0） | 每次 scrollTo 后手动 `dispatchEvent(new Event('scroll'))` |\n| `agent-browser --cdp <WS_URL>` 返回 404（WSL） | 用 `agent-browser connect \"http://<IP>:9223\"` 建立连接 |\n| agent-browser click 登录按钮不弹窗 | 用 eval 触发 |\n| iframe \"微信快捷登录\" 遮挡二维码 | eval 中移除 iframe |\n| iframe 内按钮点击无效（跨域） | 直接移除 iframe |\n| agent-browser eval 输出被双重引号包裹 | `json.loads(json.loads(raw))`（仅 JSON；简单字符串只用一层） |\n| 链接获取效率低 | 用 `batch_extract_urls()` 一次 eval 完成全部提取 |\n| 滚动永远卡在 15 篇（scroll 事件不触发） | scrollTo/pageDown/scrollIntoView 改变 scrollY 但不一定触发 scroll 事件。**解决：每次 scrollTo 后手动 `dispatchEvent(new Event('scroll'))`**（见 3.2），无需换浏览器。详见 [edge-cdp-scroll-issue.md](edge-cdp-scroll-issue.md) |\n| 滚动永远卡在 15 篇（Python page WS） | 只有 agent-browser eval 能触发 XHR |\n| 滚动永远卡在 15 篇（session 丢失） | 保持 weread.qq.com 标签页活跃 |\n| 页面显示「加载中」但实际未加载 | 搜索页 body 中「加载中」字符串是**静态模板文本**，不是实时加载指示器。验证滚动是否工作的唯一标准是 `search_list_item` 数量变化。 |\n| 二维码过期 | 全流程单 terminal 调用，关闭→重新触发→提取 |\n| QR 流程拆成多个 terminal 调用 | ❌ 大忌 |\n| QR 提取后做验证浪费有效期 | ❌ 提取成功后立刻发 MEDIA |\n| 二维码 src 是 data: URI 非 HTTP URL | Python base64 解码，补 padding |\n| httpOnly cookie 清不掉 | CDP `Storage.clearCookies` |\n| `Network.clearBrowserCookies` 返回 -32601 | 用 `Storage.clearCookies` |\n| 搜一搜页面卡「加载中...」 | 清 cookie → 重启浏览器 → 重新扫码 |\n| 滚动中途停滞（非 15 篇） | 查「暂无更多内容」= 真上限；3 轮不变 = plateau 兜底 |\n| 长滚动不要用 background 进程 | 前台 terminal + 长 timeout（600s） |\n| 批量提取 URL 部分错位（~40%） | async + `await sleep(50)` + `awaitPromise: true` |\n| 部分链接点进去「参数错误」 | ✅ 已修复：从 `/tmp/urls.json` 读完整 URL |\n| execute_code websockets `recv()` 120s 超时（>50 篇） | 用 agent-browser eval 分批提取（每批 50），已验证 246 篇 100% 成功。不要用 execute_code + websockets 做大批量 URL 提取。 |\n| parse_date 不识别「X个月前」「X年前」「X月X日」「2025/3/24」等格式 | 使用 extraction-pattern.md 中的完整 parse_date（已覆盖所有已知格式） |\n\nFile v1.0.3:references/edge-cdp-scroll-issue.md\n\n# CDP 滚动事件不触发问题\n\n> **发现日期**: 2026-06-09  \n> **影响范围**: 所有依赖 scroll 事件触发 XHR 的页面（含微信搜一搜无限滚动）\n\n## 问题\n\n某些 CDP 实现下，**任何形式的程序化滚动都不会触发 `scroll` 事件**，即使 `scrollY` 确实改变了。\n\n## 验证方法\n\n在目标页面注册 scroll 事件监听器，然后用不同方式滚动并检查事件计数：\n\n```bash\n# 注册监听\nagent-browser eval \"window.__sc=0;window.addEventListener('scroll',()=>{window.__sc++});'ok'\"\n\n# 测试方式1: window.scrollTo\nagent-browser eval \"window.scrollTo(0,500)\"\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式2: scrollIntoView\nagent-browser eval \"document.querySelector('.search_list_item:last-child').scrollIntoView({block:'end'})\"\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式3: agent-browser scroll\nagent-browser scroll down 800\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式4: PageDown 键\nagent-browser press PageDown\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n```\n\n## 确认结果\n\n| 方式 | scrollY 改变？ | scroll 事件触发？ |\n|------|:---:|:---:|\n| `window.scrollTo()` | ✅ | ❌ |\n| `scrollIntoView()` | ✅ | ❌ |\n| `agent-browser scroll down` | ✅ | ❌ |\n| `agent-browser press PageDown` | ✅ | ❌ |\n\n> 重启浏览器、更换 user-data-dir 均无效。这是 CDP 实现层面的问题。\n\n## 对微信搜一搜的影响\n\n微信搜一搜搜索页的无限滚动机制依赖于 `scroll` 事件 → XHR 加载更多文章。`scroll` 事件不触发 ⇒ XHR 永远不发出 ⇒ 文章数永远停在初始的 **15 篇**。\n\n> ⚠️ 页面 body 中「加载中」字样是**静态模板文本**，不是实时加载指示器。不能据此判断滚动是否工作。\n\n## 解决方案\n\n**首选：每次 scrollTo 后手动 `window.dispatchEvent(new Event('scroll'))`**。已在 detailed-workflow.md Step 3.2 的滚动循环中实现，实测可用。\n\n如果上述方案仍不生效，可换用其他浏览器 CDP（如 Chrome）。Chrome 的 CDP 实现中 scroll 事件正常触发。启动方式见 wsl-cdp-browser.md。\n\n## 已知无法使用的替代方案\n\n- `Runtime.evaluate` 执行 `window.scrollTo`：不触发 XHR（本来就是如此，见 detailed-workflow.md）\n- CDP `Input.dispatchMouseEvent` (mouseWheel)：CDP 下同样不触发 scroll 事件\n- `agent-browser scroll` 命令：底层也是 CDP，同样不触发\n\nFile v1.0.3:references/extraction-pattern.md\n\n# 微信读书搜索 - 完整提取代码模板\n\n## 工作流分界\n\n- **Step 1-3（浏览器+登录+搜索+滚动）**：用 `agent-browser --cdp` terminal 命令。必须 agent-browser 执行滚动（Python page WS 的 `window.scrollTo` 不触发 XHR）。\n- **Step 4（元数据采集 + URL 批量提取）**：用 `execute_code` 中的 Python + websockets 连搜索页的 **page-level CDP WebSocket**。一次 `Runtime.evaluate` 批量完成全部 URL 提取。\n\n## 快速参考\n\n适用的任务：连接 CDP → 采集元数据 → 筛选 → 获取 URL。\n\n**两条路径选一条：**\n\n| 路径 | 适用场景 | 命令 |\n|------|---------|------|\n| **A: agent-browser eval（同步）** | 所有场景（推荐） | `agent-browser eval` 同步 click + 拦截 `window.open`。>50 篇时**分批执行**（每批 50），已验证 246 篇 100% 成功。 |\n| **B: Python + page CDP WS（异步）** | 备选，有超时风险 | `Runtime.evaluate` + `awaitPromise: true`。execute_code 中 websockets 连 page CDP。实测 100 篇 batch 即 120s 超时，不推荐用于大批量。 |\n\n> 🔴 **路径 A 已验证可行**（2026-06-09）：同步 `agent-browser eval` 中拦截 `window.open` 并 `.click()` 能即时捕获 URL，无需 async/await。Vue 的 click handler 同步触发 `window.open`，URL 在 eval 返回前就已写入。15 篇提取 15/15 URL，成功率 100%。246 篇分 5 批（50+50+50+50+46）成功率 100%。\n> ⚠️ **路径 B 超时风险**（2026-06-09 实测）：execute_code 中 websockets `recv()` timeout 120s，单批 100 篇即超时。仅适用于 ≤50 篇且 execute_code 环境稳定时。\n\n## 完整模板\n\n```python\nimport asyncio, json, subprocess, sys, re\nfrom datetime import datetime, timedelta\n\nWINDOWS_IP = \"172.19.80.1\"\nCDP_PORT = 9223\n\n# ── 1. 获取搜索页面 WebSocket URL ──\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nws_url = None\nfor t in targets:\n    if t['type'] == 'page' and 'search.weixin' in t.get('url', ''):\n        ws_url = t['webSocketDebuggerUrl']\n        break\nif not ws_url:\n    raise RuntimeError(\"Search page not found in CDP targets\")\n\nimport websockets\n\n# ── 2. 辅助函数 ──\n\nasync def extract_articles(ws):\n    \"\"\"提取当前 DOM 中所有文章元数据\"\"\"\n    js = \"\"\"\n    (function() {\n        const arts = [];\n        document.querySelectorAll('.search_list_item').forEach((item, i) => {\n            const t = item.querySelector('.article__title-text');\n            const d = item.querySelector('.article__desc');\n            const s = item.querySelector('.source__title');\n            const dt = item.querySelector('.source__text.date');\n            arts.push({\n                idx: i,\n                title: (t?.textContent || '').trim(),\n                desc: (d?.textContent || '').trim().substring(0,200),\n                source: (s?.textContent || '').trim(),\n                date: (dt?.textContent || '').trim(),\n                dataId: item.getAttribute('data-id') || ''\n            });\n        });\n        return arts;\n    })()\n    \"\"\"\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=5)\n    return json.loads(msg).get('result', {}).get('result', {}).get('value', [])\n\n\nasync def click_get_url(ws, dom_idx):\n    \"\"\"\n    已废弃。改用 batch_extract_urls() 一次 eval 批量完成所有提取。\n    \"\"\"\n    pass\n\n\nasync def batch_extract_urls(ws, target_indices):\n    \"\"\"\n    一次 eval 批量提取所有目标文章的 URL。\n    关键：点击 .search_list_item（整个卡片），不是 .article__title-text。\n    拦截 window.open（return null 防跳转），每次 click 后 await sleep(50ms) 等 Vue 异步处理。\n    N 篇约 N×50ms。\n    \"\"\"\n    idx_list = json.dumps(target_indices)\n    js = (\n        \"(async function() {\\n\"\n        \"    var sleep = function(ms) { return new Promise(function(r) { setTimeout(r, ms); }); };\\n\"\n        \"    var targets = \" + idx_list + \";\\n\"\n        \"    var allItems = document.querySelectorAll('.search_list_item');\\n\"\n        \"    var results = [];\\n\"\n        \"    var origOpen = window.open;\\n\"\n        \"    for (var i = 0; i < targets.length; i++) {\\n\"\n        \"        var idx = targets[i];\\n\"\n        \"        var el = allItems[idx];\\n\"\n        \"        if (!el) { results.push({idx: idx, url: ''}); continue; }\\n\"\n        \"        var captured = '';\\n\"\n        \"        window.open = function(u) { captured = u; return null; };\\n\"\n        \"        el.scrollIntoView({block: 'center', behavior: 'instant'});\\n\"\n        \"        el.click();\\n\"\n        \"        await sleep(50);\\n\"\n        \"        results.push({idx: idx, url: captured});\\n\"\n        \"    }\\n\"\n        \"    window.open = origOpen;\\n\"\n        \"    return JSON.stringify(results);\\n\"\n        \"})()\"\n    )\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True, 'awaitPromise': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=60)\n    result = json.loads(msg).get('result', {}).get('result', {}).get('value', '[]')\n    if isinstance(result, str):\n        return json.loads(result)\n    return result\n\n\ndef close_mp_weixin_tabs():\n    \"\"\"关闭所有 mp.weixin.qq.com 标签页（保留搜索页面）\"\"\"\n    result = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                           capture_output=True, text=True)\n    targets = json.loads(result.stdout)\n    closed = 0\n    for t in targets:\n        if t['type'] == 'page' and 'mp.weixin.qq.com' in t.get('url', ''):\n            subprocess.run(['curl', '-s', '-o', '/dev/null',\n                f'http://{WINDOWS_IP}:{CDP_PORT}/json/close/{t[\"id\"]}'])\n            closed += 1\n    return closed\n\n\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime (返回 None 表示无法解析)\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    # Relative: X年前, X个月前, X周前, X天前, X小时前, X分钟前\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            y = int(m.group(1))\n            return now - timedelta(days=y * 365)\n    elif '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            months = int(m.group(1))\n            return now - timedelta(days=months * 30)\n    elif '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            w = int(m.group(1))\n            return now - timedelta(weeks=w)\n    elif '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            d = int(m.group(1))\n            return now - timedelta(days=d)\n    elif '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            h = int(m.group(1))\n            return now - timedelta(hours=h)\n    elif '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            mins = int(m.group(1))\n            return now - timedelta(minutes=mins)\n    # Absolute: YYYY-MM-DD, YYYY/M/D, YYYY/M/D, M月D日, M-D\n    elif re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    elif re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    elif re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n\n\n# ── 3. 主流程（提取全部文章）──\n\nasync def extract_all(ws):\n    \"\"\"\n    提取全部已加载文章（滚动完成后调用）。\n    先采集元数据，再批量提取 URL。150 篇 ~0.6s。\n    \"\"\"\n    # 3.1 采集元数据\n    all_data = await extract_articles(ws)\n    total = len(all_data)\n    print(f\"Articles: {total}\")\n    \n    # 3.2 批量提取全部 URL\n    all_indices = list(range(total))\n    urls = await batch_extract_urls(ws, all_indices)\n    \n    # 3.3 组装结果（含日期转换）\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for a in all_data:\n        raw_date = a.get('date', '')\n        parsed = parse_date(raw_date)\n        if parsed:\n            date_str = parsed.strftime('%Y-%m-%d')\n        else:\n            date_str = raw_date\n        results.append({\n            'idx': a['idx'],\n            'title': a['title'],\n            'source': a['source'],\n            'date': date_str,\n            'url': url_map.get(a['idx'], '')\n        })\n    \n    success = sum(1 for r in results if r['url'])\n    print(f\"URLs: {success}/{total} ({100*success//total}%)\")\n    return results\n\n\nasync def extract_filtered(ws, target_indices, all_articles_data):\n    \"\"\"仅提取指定索引的文章 URL（需提前加载元数据到 all_articles_data）\"\"\"\n    urls = await batch_extract_urls(ws, target_indices)\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for dom_idx in target_indices:\n        a = all_articles_data[dom_idx] if dom_idx < len(all_articles_data) else {}\n        results.append({\n            'idx': dom_idx,\n            'title': a.get('title', ''),\n            'source': a.get('source', ''),\n            'date': a.get('date', ''),\n            'url': url_map.get(dom_idx, '')\n        })\n    return results\n\n\n# ── 4. 执行入口（根据场景选用 extract_all 或 extract_filtered）──\n# 场景A：提取全部已加载文章（最常用）\n# articles = asyncio.run(extract_all(ws))   # 见下方 async main 包装\n\n# 场景B：仅提取筛选后的子集（需先有 all_data）\n# articles = asyncio.run(extract_filtered(ws, keep_indices, all_data))\n\n# 完整 async main 包装（场景A）：\nasync def main_extract_all():\n    async with websockets.connect(ws_url, max_size=2**24) as ws:\n        return await extract_all(ws)\n\narticles = asyncio.run(main_extract_all())\nwith open('/tmp/urls.json', 'w') as f:\n    json.dump(articles, f, ensure_ascii=False, indent=2)\nprint(f\"Done: {sum(1 for a in articles if a['url'])}/{len(articles)} URLs\")\n```\n\n## 关键注意事项\n\n1. **滚动 vs 提取分离**：滚动（Step 3）用 `agent-browser --cdp` 的 terminal 命令；提取（Step 4）用本模板的 Python + page-level CDP WebSocket。**不要用 Python page WS 做滚动** — `Runtime.evaluate` 执行的 `window.scrollTo` 不会触发搜索页的无限滚动 XHR。只有 agent-browser 的 eval 能触发。实测：agent-browser 3轮滚动 → 60→105→150篇，Python page WS 始终卡在15篇。\n2. **批量 eval 提取（async + await sleep）**：一次 `Runtime.evaluate`（`awaitPromise: true`）完成所有 URL 提取。点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`。拦截 `window.open`（`return null` 防跳转）→ `.click()` 触发 Vue → `await sleep(50)` 等 Vue 异步 handler → 读 `captured`。N 篇约 N×50ms。**不加 await sleep 会导致 ~40% URL 错位**（`captured` 被下一轮覆写）。\n\n   **大数据集分批**：当文章数 >200 时，单次 `Runtime.evaluate` 超时风险增大（默认 60s timeout），且结果字符串可能过大。建议 `batch_extract_urls` 增加 `batch_size` 参数，每批 100 篇分批执行：\n\n   ```python\n   async def batch_extract_urls(ws, target_indices, batch_size=100):\n       all_results = []\n       for batch_start in range(0, len(target_indices), batch_size):\n           batch = target_indices[batch_start:batch_start + batch_size]\n           # ... 单批 eval（同上）...\n           all_results.extend(result)\n       return all_results\n   ```\n\n   实测：336 篇分 4 批（100+100+100+36），总耗时约 20s，全部成功。\n3. **无需关闭标签页**：`window.open` 被拦截，不真正打开新标签页。\n4. **URL 验证**：提取后用 CDP 浏览器 `goto` 抽查 3-5 条，确认页面加载了文章标题（而非空壳/参数错误）。不要用 curl 验证——curl 可能拿到空壳模板但 HTTP 200。也不要因部分链接「参数错误」反复调整 URL 编码——这是微信服务端行为，与提取无关。\n5. **元数据采集可选**：如果已通过 agent-browser 采集了 `/tmp/arts.json`，可跳过 `extract_articles`，直接用 `extract_filtered`。读取文件时注意 `agent-browser eval` 输出被双重引号包裹（`\"[{...}]\"` 而非 `[{...}]`），需嵌套解析：\n\n```python\nwith open('/tmp/arts.json', 'r') as f:\n    raw = f.read().strip()\n\n# Handle agent-browser eval double-quoting: parse repeatedly until we get a list\nall_data = raw\nfor _ in range(5):\n    try:\n        all_data = json.loads(all_data)\n    except (json.JSONDecodeError, TypeError):\n        break\n    if not isinstance(all_data, str):\n        break\n\nif isinstance(all_data, str):\n    raise RuntimeError(f\"Failed to parse /tmp/arts.json after 5 rounds\")\n```\n\n`all_data` 的结构与 `extract_articles()` 返回的一致，可直接喂给 `extract_filtered()` 或 `batch_extract_urls()`。\n6. **`Page.enable`**：保留但不再依赖 CDP 事件监听。\n7. **🚨 URL 防截断**：`extract_all` 后将 `results` 写入 `/tmp/urls.json`。回复用户时用 terminal 中 Python 脚本从 `/tmp/urls.json` 读取并 print Markdown Top 10，复制到回复。**严禁从 execute_code 控制台 print 输出中复制 URL**（可能截断缺参数）。\n\n## 路径 A：agent-browser eval 同步提取（简易路径）\n\n适用于 execute_code 被禁用（cron）、文章数 ≤ 50、或只想快速提取的场景。\n\n### Step A1: 采集元数据\n\n```bash\nagent-browser eval \"\n(() => {\n    const arts = [];\n    document.querySelectorAll('.search_list_item').forEach((item, i) => {\n        const t = item.querySelector('.article__title-text');\n        const d = item.querySelector('.article__desc');\n        const s = item.querySelector('.source__title');\n        const dt = item.querySelector('.source__text.date');\n        arts.push({\n            idx: i,\n            title: (t?.textContent || '').trim(),\n            desc: (d?.textContent || '').trim().substring(0,200),\n            source: (s?.textContent || '').trim(),\n            date: (dt?.textContent || '').trim(),\n            dataId: item.getAttribute('data-id') || ''\n        });\n    });\n    return JSON.stringify(arts);\n})()\" > /tmp/arts.json\n```\n\n### Step A2: 同步分批提取 URL（>50 篇推荐）\n\n当文章数 >50 时，分批执行 agent-browser eval（每批 50），避免单次 eval 超时。已验证 246 篇分 5 批全部成功：\n\n```bash\n# 每批提取 50 篇的 URL，写入独立文件\n# Batch N: idx_start 到 idx_end\n\nagent-browser eval \"\n(function() {\n    var items = document.querySelectorAll('.search_list_item');\n    var results = [];\n    var orig = window.open;\n    for (var i = IDX_START; i < Math.min(IDX_END, items.length); i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; return null; };\n        items[i].click();\n        results.push({idx: i, url: captured, dataId: items[i].getAttribute('data-id') || ''});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\" > /tmp/urls_batchN.json\n```\n\n**实测数据（2026-06-09，「agent」246篇）：**\n- Batch 1 (0-49): 50/50 ✅\n- Batch 2 (50-99): 50/50 ✅\n- Batch 3 (100-149): 50/50 ✅\n- Batch 4 (150-199): 50/50 ✅\n- Batch 5 (200-245): 46/46 ✅\n- 总成功率：100%\n\n```bash\nagent-browser eval \"\n(function() {\n    var items = document.querySelectorAll('.search_list_item');\n    var results = [];\n    var orig = window.open;\n    for (var i = 0; i < items.length; i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; return null; };\n        items[i].click();\n        results.push({idx: i, url: captured, dataId: items[i].getAttribute('data-id') || ''});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\" > /tmp/urls_raw.json\n```\n\n> ✅ Vue 的 click handler 同步触发 `window.open`，URL 在 eval 返回前就已写入。无需 async/await，无需 awaitPromise。\n> \n> ⚠️ 当文章数 > 50 时建议用路径 B（Python + websockets），因为 agent-browser eval 的单次执行时间更长。\n\n### Step A3: 解析并合并（Python）\n\n```python\nimport json\n\n# 元数据（处理 agent-browser eval 双重引号）\nwith open('/tmp/arts.json') as f:\n    raw = f.read().strip()\ndata = raw\nfor _ in range(5):\n    try: data = json.loads(data)\n    except: break\n    if not isinstance(data, str): break\narticles_data = data if isinstance(data, list) else []\n\n# URL 数据\nwith open('/tmp/urls_raw.json') as f:\n    raw = f.read().strip()\ndata = raw\nfor _ in range(5):\n    try: data = json.loads(data)\n    except: break\n    if not isinstance(data, str): break\nurl_data = data if isinstance(data, list) else []\n\n# 合并\nurl_map = {u['idx']: u.get('url', '') for u in url_data}\nresults = []\nfor a in articles_data:\n    results.append({\n        'idx': a['idx'],\n        'title': a['title'],\n        'desc': a.get('desc', ''),\n        'source': a.get('source', ''),\n        'date': a.get('date', ''),\n        'dataId': a.get('dataId', ''),\n        'url': url_map.get(a['idx'], '')\n    })\n\nwith open('/tmp/urls.json', 'w') as f:\n    json.dump(results, f, ensure_ascii=False, indent=2)\nprint(f\"Extracted: {sum(1 for r in results if r['url'])}/{len(results)} URLs\")\n```\n\n### 路径 A vs B 选择指南\n\n| 条件 | 选路径 A | 选路径 B |\n|------|---------|---------|\n| 文章数 ≤ 50 | ✓ 推荐 | 备选 |\n| 文章数 > 50 | **✓ 分批执行（每批50）** | ✗ 超时风险高（实测100篇120s超时） |\n| execute_code 被禁用（cron） | **✓ 唯一选择** | ✗ 不可用 |\n| 代码复杂度 | 低 | 高 |\n\n> 💡 **默认用路径 A**：同步 eval 已验证最可靠。路径 B 仅作备选记录。\n\nFile v1.0.3:references/llm-filter-pattern.md\n\n# LLM 语义筛选模式\n\n适用于 cron 作业中需要对搜索结果做语义筛选的场景：搜索返回大量噪音 → LLM 逐条判断 → 仅保留真正相关的内容。\n\n## 为什么不用关键词\n\n关键词规则（黑名单/白名单）有两个致命缺陷：\n1. **今天能用，明天过时**：今天的噪音类型（电网拓扑、电源纹波）不一定是明天的噪音\n2. **无法理解语义**：同一词语在不同上下文中含义不同。比如\"拓扑优化\"在结构力学和网络科学中是两个完全不同领域\n\nLLM 判断能理解上下文语义，适应任何新出现的噪音类型。\n\n## 流程\n\n```\n搜索 → 提取全部文章 → 打印标题/摘要供 LLM 判断 → LLM 输出 KEEP 列表 → 程序筛选 → 后续处理\n```\n\n## 实现模板\n\n### Step A：打印全部候选项（仅标题，不看正文省 token）\n\n```python\nimport json\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfor i, art in enumerate(articles):\n    print(f\"[{i}] {art.get('title', '')}  |  {art.get('source', '')}  |  {art.get('date', '')}\")\n\nprint()\nprint(\"===== 以上是全部文章，请逐条判断 =====\")\nprint(\"请列出应保留的序号，格式: KEEP=[0,2,5,7,...]\")\n```\n\n### Step B：LLM 阅读并输出 KEEP 列表\n\nAgent 阅读所有标题+摘要，按领域知识逐条判断是否属于目标领域。在回复中输出 KEEP 列表。\n\n### Step C：执行筛选\n\n```python\nimport json\n\nkeep_indices = [0,2,5,7]  # 替换为 LLM 判断结果\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfiltered = [articles[i] for i in keep_indices if i < len(articles)]\n\nwith open('/tmp/urls_filtered.json', 'w') as f:\n    json.dump(filtered, f, ensure_ascii=False, indent=2)\n\nprint(f\"筛选结果: {len(articles)} → {len(filtered)} 篇\")\n```\n\n## 判断标准编写要点\n\n在 cron prompt 中给出清晰的判断标准：\n- ✅ 什么是「属于」（正例特征）\n- ❌ 什么是「不属于」（反例特征+细分类型）\n- 🎯 核心区分问题（一句话帮 LLM 抓住本质）\n\n示例（拓扑优化领域）：\n```\n- ✅ 属于：讨论结构拓扑优化（SIMP/BESO/水平集/变密度法），工程应用，软件教程\n- ❌ 不属于：\n    - 网络拓扑优化、通用优化、数学拓扑学、广告招聘、景观艺术\n    - 卖课/课程推广/付费培训：标题含「课程」「专题课程」「一起学习」「训练营」等推销口吻，目标是卖课而非分享知识\n- 🎯 核心区分：「对结构做拓扑优化」还是「某系统/网络的拓扑结构优化」\n```\n\n### 卖课内容识别特征\n\n| 类型 | 特征词/句式 | 示例 |\n|------|-----------|------|\n| 课程推销 | 「XX课程」「专题课程」「带你发掘」「一起学习」 | 变密度法拓扑优化课程 / COMSOL专题课程 |\n| 培训广告 | 「训练营」「速成班」「从入门到精通」「限时优惠」 | XX软件7天训练营 |\n| 引流卖课 | 结尾带二维码/微信号、正文讲一半留一半 | 「扫码获取完整教程」 |\n\n> 💡 判断核心：文章目的是**分享知识/案例**还是**推销付费课程**？如果标题/内容有明显的课程推销属性，即使技术内容相关也要排除。\n\n## 两轮筛选（关键）\n\n当候选文章 > 100 篇时，**单轮筛选必然有漏网之鱼**。必须做两轮：\n\n### 第一轮：粗筛（排除明显噪音）\n\n在主干流程的 Step 4.5 完成。排除标准针对**明显的类别错误**（卖课、电源拓扑、光子学、招聘广告、视觉设计等）。排除目标是**高置信度错误**，宁可保留边界案例。\n\n### 第二轮：精筛（去重后审查新文章）\n\n第一轮粗筛后仍有漏网噪音——尤其是这些类型：\n- **行业报告**：标题不涉及 TO，正文只将\"拓扑优化\"列为众多趋势之一（如「汽车热管理电子风扇行业迎结构性变革」）\n- **综合概览**：AI/工具/行业 overview 文章，仅在列表中提一句 TO（如「AI生成CAD-BIM方法很多」「一个机械工程师眼中的人工智能」）\n- **非结构优化**：管道/电缆布线路由优化、物流路径优化（用\"拓扑\"指网络布局，非结构设计）\n- **无关领域**：水泥窑工艺、沸石转轮化工、液流电池 CFD（TO 非核心，仅是边缘工具）\n\n**第二轮流程**：\n1. 去重后得到\"新文章\"列表\n2. **先写入临时文件**（`/tmp/new_articles.json`），暂不更新持久化历史\n3. 逐条审查新文章的标题+摘要\n4. 如发现误保留文章，**从临时文件移除 + 不将其 dataId 加入 seen_ids**\n5. 审查通过后再更新持久化 JSON\n\n> 🔴 如果第二轮发现误保留文章且已写入持久化 JSON，必须**回退**：从 `articles` 数组末尾移除对应条目，从 `seen_ids` 中移除对应 dataId。\n\n## 自主模式（Cron/无人作业）\n\n当无人交互时，交互式的 `KEEP=[]` 流程不适用。改用**单次编程式排除**：LLM 阅读全部标题后，直接在 `execute_code` 中构建 `exclude` 集合并过滤。\n\n```python\n# 一次性完成：打印标题 → LLM 审查 → 编码 exclude 集合 → 过滤\nexclude = {\n    3,   # 技术邻招聘\n    56,  # KNX 网络拓扑\n    87,  # 光子结构\n    # ... 逐个编号\n}\n\nkept = [a for i, a in enumerate(articles) if i not in exclude]\n```\n\n**两轮过滤在自主模式下的执行顺序**：\n1. 第一轮：全量粗筛（对 `/tmp/urls.json` 全部文章）→ 写入 `/tmp/urls_filtered.json`\n2. 去重：从 `/tmp/urls_filtered.json` 中排除已见 dataId → 写入 `/tmp/new_articles.json`\n3. 第二轮：精筛仅对新文章 → **先审查再写持久化**（避免回退操作）\n4. 确认无误后一次性追加到持久化 JSON\n\n> 💡 第二轮精筛在去重**之后**执行（而非之前），这样只需审查少数新文章，大幅节省 token。\n\n### 第二轮精筛的额外排除类型\n\n以下类型常在第一轮漏网（标题含「拓扑优化」但实际非结构 TO）：\n| 类型 | 特征 | 示例 |\n|------|------|------|\n| 3D打印趋势 | 标题讲3DP/增材制造，TO只是手段之一 | 「航空航天融合3D打印与AI，未来真的不用画图了？」 |\n| 行业报告 | 标题广泛，TO仅为文中一小节 | 「汽车零部件产业技术发展与制造趋势」 |\n| 活动/会议通知 | 论文学术交流会、工作营招生 | 「中国航发涡轮院召开…学术交流会」 |\n| 期刊目录 | 期刊当期目录汇总 | 「期刊目录丨《推进技术》2026年第五期」 |\n| 尺寸优化（非拓扑） | 明确写「尺寸优化」而非「拓扑优化」 | 「基于OptiStruct的电池包壳体尺寸优化」 |\n| 纳米/生物医学 | 用了「拓扑」指纳米结构，非力学TO | 「人工拓扑纳米结构…生物医学应用」 |\n| 通用设计优化 | 不特定于TO的轻量化/结构设计概览 | 「在保证强度前提下减轻零件重量的几种思路」 |\n\n## 关键词排除陷阱\n\n| 陷阱 | 说明 | 修复 |\n|------|------|------|\n| 裸 `GPU` 匹配 | `GPU` 在 TO 文章中常指 GPU 加速计算（如「基于 CPU-GPU 异构并行…拓扑优化」），不是网络拓扑 | 用更具体模式：`NCP-AIN\\|英伟达.*网络认证\\|面向AI.*网络` |\n| `通信` 匹配 | 同上，可能匹配到「多物理场耦合通信」等工程描述 | 限定为 `通信网络\\|网络通信协议\\|5G通信` |\n| `路由` 匹配 | 可能匹配「传力路径」的工程语境 | 限定为 `路由协议\\|网络路由\\|IP路由` |\n\n> 💡 编写第一轮关键词排除时，测试数据集中的前 20 篇标题确认无假阳性。宁可保留边界案例，不要在粗筛阶段过度排除。\n\n## 注意事项\n\n- **仅根据标题+摘要判断**，不读正文，省 token\n- 候选项很多时（>100篇），打印会被截断。分两次 execute_code 调用：前半段 + 后半段\n- 筛选后写入新文件（如 `/tmp/urls_filtered.json`），不动原始数据\n- 后续去重/格式化从筛选后的文件取数据\n- **输出格式化必须用 execute_code 从 JSON 读 URL**，terminal 输出会截断长 URL\n- **自主模式第二轮必须先审查再写持久化**：先写 `/tmp/new_articles.json`，审查通过后再追加到持久化文件，避免回退操作\n- **持久化 dedup 用 dataId 做 key**：每篇微信文章有唯一 `data-id`，首次运行时初始化 `seen_ids` 集合，后续追加时检查\n\nFile v1.0.3:references/topology-optimization-filter.md\n\n# 拓扑优化领域筛选模式\n\n适用于 cron 作业 `搜索微信公众号「拓扑优化」最新文章，筛选真正与结构拓扑优化相关的内容`。记录已验证的排除模式和误判案例。\n\n## 第一轮粗筛排除（遵循 llm-filter-pattern.md 两轮流程）\n\n### 第一轮排除类型 + 正则\n\n| 类型 | 正则模式 | 示例 |\n|------|---------|------|\n| 网络/IT 拓扑 | `(?:NCP-AIN\\|GPU开发者\\|GPU网络\\|英伟达.*网络认证)` | NCP-AIN 备考（1）：网络拓扑优化核心知识 |\n| 光子/光学（非结构） | `(光子提取器\\|PNJ\\|Nanojet)` | 基于自由形状拓扑优化的光子提取器 |\n| 招聘广告 | `招聘` in source | 技术邻招聘 |\n| 课程推销 | `(讲师介绍\\|项目专题专题\\|训练营速成\\|极度喜欢上课团队\\|开课通知)` | 学术地震！…讲师介绍来自国家985… |\n| 游戏开发 | `(Unity\\|纹理图集\\|纹理压缩)` | Unity纹理图集自动导入优化全解析 |\n| 化学/COFs | `(共价有机框架\\|COF光催化\\|kgd共价)` | AFM π-桥调制同构kgd共价有机框架… |\n| 运筹/金融 | `(运筹优化.*知识库\\|投资组合\\|PyPortfolio)` | PyPortfolioOpt：Python 投资组合优化库 |\n| 锻造（非TO） | `锻造` in title AND `拓扑` NOT in title（含 `镦锻`） | 钛合金热镦锻成形工艺优化方法 |\n| 工作营招生 | `工作营招生` in source | 【工作营招生】皇家墨尔本理工大学… |\n| MDO 软件总览 | `(全球\\d+大主流.*优化软件)` | 全球6大主流多学科优化软件 |\n| 仿真概览（TO仅提及） | `(仿真驱动设计.*正在悄悄改变制造业)` | 【数字前沿】仿真驱动设计… |\n| DRL/翼型（流体非结构） | `(DRL主动流控\\|cGAN翼型反设计)` | 硬核成果！…可重构拓扑优化终登Nature封面 |\n| 形状优化AM（非核心TO） | `(神经场.*形状优化.*实现制造感知)` | 基于神经场的形状优化，实现制造感知的无支撑3D打印 |\n\n### ⚠️ 排除陷阱\n\n- **裸 `GPU` 关键词** → 误杀「基于 CPU-GPU 异构并行…拓扑优化」等 GPU 加速计算的合法 TO 文章。必须用 `GPU开发者\\|GPU网络` 等限定模式。\n- **`光学快门` 在热电磁器件 TO 中** → 「一种用于热电磁器件设计的拓扑优化双神经网络方法」含\"光学快门\"但本质是结构 TO。匹配光子排除时需确认非 TO 上下文。\n\n## 第二轮精筛排除\n\n执行顺序：去重后对新文章逐条审查，以下为已验证需排除的细分类型：\n\n| 类型 | 判断特征 | 本会话实例 |\n|------|---------|-----------|\n| 路径规划（图拓扑） | `(分层路径规划\\|路径规划算法)` | 拓扑信息引导的多连通分量分层路径规划算法 |\n| 光子/光学 TO | `(RSoft\\|光子设计\\|消色差超光栅)` in title OR source=`光学超材料` | RSoft 2026 引领光子设计进入全自动时代 / 通过B样条参数化水平集方法加速基于CMA-ES的拓扑优化 (光学超材料) |\n| ⚠️ 「超材料」陷阱 | 「超材料」标题需区分：**力学/结构超材料**（受电弓、吸能、点阵）→ ✅ KEEP；**声学超材料**（吸声行为、扩散场吸声）→ ❌ EXCLUDE；**光学超材料**（光子、超光栅）→ ❌ EXCLUDE | 基于挠性机制的受电弓超材料的带隙可调性 → ✅ / 具有周期多谐振腔和水中空隙的超材料的吸声行为 → ❌ |\n| LLM agent 拓扑 | `(LLM.*多智能体.*协调拓扑)` | LLM 最新论文速览：多智能体系统…协调拓扑优化 |\n| 背景提升/广告 | `有方背景提升` 公众号 | 航空航天课题 | 那么远拍摄… |\n| 电网/复杂网络 | `(电网事故\\|复杂网络.*期刊)` | 文献分享及心得…基于文本挖掘的电网事故… |\n| 企业公关/调侃 | `(华为.*韬定律\\|打孔穿线)` | 华为的\"韬定律\"是个啥？ |\n| 材料科学（非TO核心） | `(共晶纳米骨架.*3D打印铝合金)` | Nature Comms：利用可塑性变形的共晶纳米骨架… |\n| Pareto 多目标（OR） | `(Pareto.*非凸区域)` + `运筹` 公众号 | 多目标优化在复杂工业场景中的应用 |\n| 声学超材料 | `(纤维基复合材料吸声特性\\|非织造布\\|吸声行为\\|扩散场吸声\\|多孔材料.*吸声数值模型)` | 梯度阻抗纤维基复合材料吸声特性的仿真… / 具有周期多谐振腔和水中空隙的超材料的吸声行为 / 有限尺寸多孔材料扩散场吸声数值模型 |\n| 参数化课程 | `(Rhino.*Grasshopper.*微信群直播)` | 如何高效学习Rhino & Grasshopper？ |\n| 活动通知 | `(AI\\+CAE.*周六\\|欧洲中间件新贵)` | 【干货】实例 AI+CAE 代理模型…@5月30日 |\n| 3DP 策略总览 | `(3D打印制造技术结构设计的三个高度)` | 3D打印制造技术结构设计的三个高度… |\n\n### 2026-06-05 新增排除\n\n| 类型 | 判断特征 | 示例 |\n|------|---------|------|\n| 镦锻工艺优化 | `镦锻` in title | 钛合金热镦锻成形工艺优化方法 |\n| 极度喜欢上课团队 | `极度喜欢上课团队出品` in title | COMSOL with MATLAB拓扑优化…极度喜欢上课团队出品 |\n| 开课通知 | `开课通知` in title | 开课通知：金属疲劳寿命预测&ai结构可靠性分析优化设计 |\n| 通用机械设计 | `机械设计优化` in title AND `拓扑` NOT in title | 如何进行机械设计优化 |\n| CFD座舱优化 | `座舱CFD` in title | 特斯拉座舱CFD优化… |\n| NVH声辐射 | `声辐射.*板件贡献量` in title | 电驱动总成结构声辐射中板件贡献量的量化分析 |\n| 水泥工艺 | `水泥.*配比工艺` in title AND `拓扑` NOT in title | 水泥新型原料配比工艺的工业化应用 |\n| CAE软件大全 | `全球\\d+款.*CAE.*软件大全` in title | 全球137款主流CAE仿真软件大全 |\n\n## 持久化 dedup 格式\n\n```json\n{\n  \"articles\": [\n    {\n      \"title\": \"...\",\n      \"source\": \"...\",\n      \"date\": \"2026-06-02\",\n      \"url\": \"http://mp.weixin.qq.com/s?...\",\n      \"dataId\": \"3391257530720860736&...\",\n      \"desc\": \"...\"\n    }\n  ],\n  \"seen_ids\": [\"3391257530720860736&...\", \"...\"],\n  \"last_run\": \"2026-06-03T06:11:00\"\n}\n```\n\n数据文件路径：`~/.hermes/data/topology-optimization-articles.json`\n\nFile v1.0.3:references/wsl-cdp-browser.md\n\n# WSL 环境下连接 Windows CDP 浏览器\n\nWSL 用户需要通过以下步骤让 agent-browser 连接到 Windows 上的 CDP 浏览器（Edge 或 Chrome）。\n\n## 为什么需要这个\n\nWSL2 有独立的网络命名空间，Windows 上的 CDP 默认绑定 `127.0.0.1`，WSL 无法直接访问。\n\n## 步骤\n\n### 1. 启动 CDP 浏览器\n\n**Edge**（推荐，Windows 自带）：\n```bash\n# 先关掉已有的实例\n/mnt/c/Windows/System32/taskkill.exe /F /IM msedge.exe 2>/dev/null; sleep 2\n\n# 用独立 user-data-dir 启动\n\"/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=\"C:/Users/gdc3489/AppData/Local/Temp/edge_debug\" \\\n  --no-first-run --no-default-browser-check about:blank &\nsleep 5\n```\n\n**Chrome**（备选，需自行安装）：\n```bash\n\"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=C:/Users/gdc3489/AppData/Local/Temp/chrome_debug \\\n  --no-first-run about:blank &\nsleep 5\n```\n\n### 2. 配置端口转发\n\n```bash\n# 端口转发：0.0.0.0:9223 → 127.0.0.1:9222\n/mnt/c/Windows/System32/netsh.exe interface portproxy add v4tov4 \\\n  listenport=9223 listenaddress=0.0.0.0 \\\n  connectport=9222 connectaddress=127.0.0.1\n\n# Windows 防火墙放行（必须！否则 WSL→Windows 流量被拦截）\n/mnt/c/Windows/System32/netsh.exe advfirewall firewall add rule \\\n  name=\"WSL CDP Proxy 9223\" dir=in action=allow protocol=tcp localport=9223\n```\n\n### 3. 获取连接地址并验证\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\ncurl -s \"http://${WINDOWS_IP}:9223/json/version\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('Browser','FAIL'))\"\n# 输出浏览器名称即成功\n```\n\n### 4. 连接 agent-browser\n\n> ⚠️ `agent-browser --cdp <WS_URL>` 在 WSL 端口代理环境下不可用（报 404）。**改用两步法：先 `connect`，再正常使用命令。**\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# Step 1: connect（建立会话）\nagent-browser connect \"http://${WINDOWS_IP}:9223\"\n\n# Step 2: 正常使用（无需 --cdp 前缀）\nagent-browser goto \"https://weread.qq.com/\"\nagent-browser snapshot\n```\n\n> 💡 `connect` 之后所有 agent-browser 命令都不需要 `--cdp` 前缀，会话级保持连接。如果连接断开（如浏览器重启），重新 `connect` 即可。\n\n## 注意事项\n\n- **必须用独立 `--user-data-dir`**，否则已有浏览器实例会拦截启动\n- **用 `connect` 不用 `--cdp`**：`agent-browser --cdp <port>` 在当前版本（0.27.0）下对 WSL 端口代理返回 404。先用 `connect \"http://<IP>:9223\"`，之后命令不加任何 CDP flag\n- **用 `goto` 不用 `open`**：`agent-browser open` 会尝试 auto-launch 冲突，用 `goto`\n- **端口转发规则重启后持久**，只需运行一次\n- **Windows IP 会变**（DHCP），每次用 `ip route | grep default | awk '{print $3}'` 获取\n\nFile v1.0.3:skill-card.md\n\n## Description:\n\nSearches WeChat public-account articles through WeRead search and returns article titles, publishers, dates, summaries, and direct WeChat article links.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[kanikig](https://clawhub.ai/user/kanikig)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and agents use this skill when they need recent WeChat public-account search results with publication metadata and direct article links, especially when ordinary web search or WeRead API paths do not provide the same coverage.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The workflow can expose a browser-control CDP port that can control an authenticated browser session.\n\nMitigation: Use an isolated browser profile, keep CDP local or otherwise unreachable from the LAN, and remove any temporary portproxy or firewall rules after use.\n\nRisk: A documented API key appears in the artifact and should not be trusted as private.\n\nMitigation: Treat the key as compromised; rotate or disable it where possible and avoid relying on embedded secrets from the release.\n\nRisk: Login QR codes, cookies, and open tabs may expose unrelated accounts or session data during browser automation.\n\nMitigation: Use a dedicated WeRead/browser profile, avoid unrelated tabs or accounts, delete temporary QR/login artifacts, and verify cleanup after each run.\n\n## Reference(s):\n\n- [ClawHub skill page](https://clawhub.ai/kanikig/skills/wechat-search-weread)\n- [Detailed workflow](artifact/references/detailed-workflow.md)\n- [Extraction pattern](artifact/references/extraction-pattern.md)\n- [CDP patterns](artifact/references/cdp-patterns.md)\n- [WSL CDP browser](artifact/references/wsl-cdp-browser.md)\n- [WeRead API limitations](artifact/references/api-limitations.md)\n- [LLM filter pattern](artifact/references/llm-filter-pattern.md)\n- [Topology optimization filter](artifact/references/topology-optimization-filter.md)\n- [Edge CDP scroll issue](artifact/references/edge-cdp-scroll-issue.md)\n- [agent-browser dependency](https://github.com/vercel-labs/agent-browser)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, json, shell commands, code, guidance]\n\n**Output Format:** [Markdown search summary with article links, plus JSON result files and browser-automation command/code snippets]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Search result data is written to /tmp/urls.json; the default user-facing response shows a top-results list and summary counts.]\n\n## Skill Version(s):\n\n1.0.3 (source: server release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.2: 13 files, 35341 bytes\n\nFiles: README.md (2204b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/detailed-workflow.md (18055b), references/edge-cdp-scroll-issue.md (2665b), references/extraction-pattern.md (17217b), references/llm-filter-pattern.md (8408b), references/topology-optimization-filter.md (6198b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), skill-card.md (2640b), SKILL.md (4443b), _meta.json (139b)\n\nFile v1.0.2:SKILL.md\n\n---\nname: wechat-search-weread\ndescription: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。\ncategory: social-media\n---\n\n# 微信读书公众号文章搜索\n\n> 📦 [GitHub](https://github.com/KANIKIG/wechat-search-weread) · [ClawHub](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜搜索微信公众号文章，返回完整数据（含 `mp.weixin.qq.com` 文章链接）。\n\n## 触发条件\n\n用户需要搜索微信公众号文章，且 Exa 和搜狗方案不满足需求时使用。\n\n> ⚠️ 微信读书官方 API (`/store/search` scope=2/4) 不支持公众号文章搜索（返回 `-2041`），详见 [api-limitations.md](references/api-limitations.md)。本浏览器方案是当前唯一可行途径。\n\n## 前提\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) 已安装\n- 微信读书已登录（首次需扫码）\n- WSL 用户需 CDP 端口转发（见 [wsl-cdp-browser.md](references/wsl-cdp-browser.md)），用 `agent-browser connect \"http://<IP>:9223\"` 建连后无需 `--cdp` flag\n- 🔴 **推荐 Chrome CDP 而非 Edge**：Edge CDP 存在 scroll 事件不触发的缺陷，导致无限滚动完全失效（详见 [edge-cdp-scroll-issue.md](references/edge-cdp-scroll-issue.md)）。WSL 下 Chrome 启动：`\"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe\" --remote-debugging-port=9222 --user-data-dir=C:/Users/gdc3489/AppData/Local/Temp/chrome_debug --no-first-run about:blank`\n\n## 流程概要（5 步，缺一不可）\n\n| 步骤 | 要点 |\n|------|------|\n| **1. 启动浏览器** | 非WSL自动；WSL 参考 [wsl-cdp-browser.md](references/wsl-cdp-browser.md) |\n| **2. 登录** | QR 扫码。首次 eval 触发的 QR 大概率过期 → 必须关闭弹窗→重新触发→提取（单 terminal 调用一气呵成）。**登录成功后删除 `/tmp/weread_qr.png`** |\n| **3. 搜索+滚动** | 先备份 weread 标签页（保持登录态）→ goto 搜索页 → eval 滚动到「暂无更多内容」（封顶500条） |\n| **4. 提取URL** | execute_code 中 Python + websockets 连 page CDP → 批量点击 → 拦截 window.open → 写入 `/tmp/urls.json` |\n| **5. 清理** | 回到微信读书首页 → 关闭搜索页、mp 链接等所有多余标签页，只保留一个 weread 页面 |\n\n> 🔴 **全部详细命令、代码块、陷阱表见 [references/detailed-workflow.md](references/detailed-workflow.md)**。\n\n## 原理简述\n\n- **搜索 API**: 页面 XHR → `weread.qq.com/web/wx_search_broker_proxy`（需 session cookie）\n- **URL 获取**: DOM 无 href，点击 `.search_list_item` → Vue → `window.open(mp.weixin.qq.com/...)` → eval 拦截\n- **滚动加载**: 每次 scrollTo 触发 XHR 加载 ~45 篇新文章\n- **去重**: 每篇文章有唯一 `data-id`\n\n## 结果投递\n\n搜索完成后，**直接以最终回复输出结果**，系统会自动投递到当前对话。\n\n输出格式（Step 4.4）：搜到 X 篇 → 提取 Y 篇 → Z 篇有链接（成功率 N%）+ Top 10 列表，完整数据存 /tmp/urls.json。\n\n🔴 **链接必须从 `/tmp/urls.json` 读取，严禁从控制台输出或记忆中复制 URL**。控制台输出会截断长 URL（丢失 `chksm` 或 `#rd`），导致微信打开显示\"参数错误\"。正确做法：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\nfor i, r in enumerate(data[:10]):\n    print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r.get('source','')} · {r.get('date','')}\\\")\n\"\n```\n\n## References\n\n- `references/detailed-workflow.md` — **完整操作手册**：Step 1-5 全部命令、QR 流程、滚动策略、URL 提取代码\n- `references/extraction-pattern.md` — Python 提取代码模板（execute_code 中直接执行）\n- `references/cdp-patterns.md` — CDP 交互模式参考\n- `references/wsl-cdp-browser.md` — WSL 配置指南\n- `references/api-limitations.md` — 微信读书 API 限制说明\n- `references/llm-filter-pattern.md` — LLM 语义筛选模式：搜索噪音过滤（替代关键词黑名单）\n- `references/topology-optimization-filter.md` — 拓扑优化领域专用筛选模式：已验证的排除正则、陷阱、持久化格式\n- `references/edge-cdp-scroll-issue.md` — Edge CDP scroll 事件不触发问题：诊断、验证方法、Chrome 替代方案\n- `scripts/search_weread.py` — 备选/调试脚本（非主流程）\n\nFile v1.0.2:README.md\n\n# wechat-search-weread\n\n[![GitHub](https://img.shields.io/badge/GitHub-KANIKIG%2Fwechat--search--weread-blue?logo=github)](https://github.com/KANIKIG/wechat-search-weread)\n[![ClawHub](https://img.shields.io/badge/ClawHub-wechat--search--weread-orange)](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜，搜索微信公众号文章。返回**标题、公众号、发布时间、简介**以及 **`mp.weixin.qq.com` 直链**。\n\n> ⚠️ **本项目只负责搜索和获取文章链接，不包含抓取文章正文内容。**\n\n## 和其他方案比\n\n| 方案 | 文章时效 | 有发布日期 | 有直链 | 需要 |\n|------|---------|-----------|--------|------|\n| **微信读书搜索（本项目）** | ✅ 最新 | ✅ | ✅ | 微信扫码 |\n| Exa (mcporter) | ❌ | ❌ | ✅ | API Key |\n| 搜狗微信搜索 | ❌ ~2021 | ✅ | ✅ | 无 |\n\n**唯一能同时拿到「最新文章 + 发布日期 + 直链」的方案。**\n\n## 使用方式\n\n通过 Agent 对话触发。在聊天窗口告诉 Agent 你要搜什么即可：\n\n> \"搜一下最近关于大模型的公众号文章\"\n> \"微信搜索 OpenAI\"\n\nAgent 会自动调用本 Skill 完成搜索。\n\n**如果微信读书还没登录**，Agent 会把登录二维码发到聊天窗口，微信扫码即可。登录后继续搜索，全程自动。\n\n搜索完成后，你会收到：\n\n```\n关键词 公众号文章搜索\n\n搜到 258 篇 → 提取 258 篇 → 252 篇有链接（成功率 98%）\n\n🔥 Top 10：\n\n1. [文章标题](http://mp.weixin.qq.com/s/...) — 2小时前\n2. [文章标题](http://mp.weixin.qq.com/s/...) — 昨天\n...\n```\n\n默认展示前 10 篇，完整数据保存在 `/tmp/urls.json`。\n\n## 原理简述\n\n微信读书搜索结果页面本身不暴露文章链接。点击文章卡片时页面通过 `window.open` 打开目标文章。本项目通过浏览器自动化拦截这个操作，批量获取所有文章的真实 `mp.weixin.qq.com` 链接。\n\n## 依赖\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) — 浏览器自动化控制\n\n## 声明\n\n- 本项目**仅搜索公众号文章并获取链接**，不抓取正文\n- 需要微信扫码登录，session 过期后需重新扫码\n\nFile v1.0.2:_meta.json\n\n{\n  \"ownerId\": \"kn7ay2vk3fst9dazwsrj83d4kn86ts13\",\n  \"slug\": \"wechat-search-weread\",\n  \"version\": \"1.0.2\",\n  \"publishedAt\": 1780967135700\n}\n\nFile v1.0.2:references/api-limitations.md\n\n# 微信读书官方 Agent API 限制\n\n## 背景\n\n微信读书提供了官方 Agent API（`POST https://i.weread.qq.com/api/agent/gateway`，需 API Key），声称通过 `/store/search` 的 `scope` 参数支持搜索公众号（scope=2）和文章（scope=4）。\n\n## 实测结论\n\n| scope | 名称 | 状态 | 说明 |\n|-------|------|------|------|\n| 0 | 全部 | ✅ | 搜书（电子书/有声书/书单等） |\n| 10 | 电子书 | ✅ | 正常 |\n| 12 | 全文 | ✅ | 搜书内正文，返回 `currentCount=0` |\n| 2 | 公众号 | ❌ `-2041` | 不可用 |\n| 4 | 文章 | ❌ `-2041` | 不可用 |\n\n**公众号文章搜索（scope=2/4）在后端未对 Agent API Key 开放。** 浏览器方案（weread.qq.com 搜一搜 + CDP 提取）是当前唯一的公众号文章搜索途径。\n\n## 测试时间\n\n2026-05-17，API Key `wrk-FGVctlbTTPaWm7gmFFAi7gAA`，skill_version 1.0.3。\n\nFile v1.0.2:references/cdp-patterns.md\n\n# CDP Interaction Patterns for WeChat Read Search\n\n## CDP Endpoint Levels\n\n### Browser level (`/json/version`)\n- Good for: `Browser.getVersion`, getting initial WS URL for agent-browser\n- **Cannot** receive: `Network.*`, `Page.windowOpen`, `Page.frameNavigated` events\n- URL: `curl -s http://<WINDOWS_IP>:9223/json/version | jq -r .webSocketDebuggerUrl`\n\n### Page level (`/json` list → page targets)\n- **Required** for: all page events, Network interception, Runtime.evaluate\n- URL: `curl -s http://<WINDOWS_IP>:9223/json | jq -r '.[] | select(.type==\"page\" and (.url | contains(\"search.weixin\"))) | .webSocketDebuggerUrl'`\n\n## Key CDP Commands\n\n### Enable domains (MUST do first)\n```json\n{\"id\": 1, \"method\": \"Page.enable\"}\n{\"id\": 2, \"method\": \"Network.enable\"}\n```\n\n### Mouse click (for triggering navigation)\nWeChat search page requires real mouse events for navigation. JS `element.click()` won't work.\n\n```json\n// Get coordinates\n{\"id\": 3, \"method\": \"Runtime.evaluate\", \"params\": {\n    \"expression\": \"document.querySelector('.search_list_item .article__title-text').getBoundingClientRect()\"\n}}\n// Response: {x, y, width, height}\n\n// Click\n{\"id\": 4, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mousePressed\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n{\"id\": 5, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mouseReleased\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n```\n\n### Catch article URL\nAfter click, listen for:\n```json\n{\"method\": \"Page.windowOpen\", \"params\": {\"url\": \"https://mp.weixin.qq.com/s?__biz=...&mid=...&idx=...&sn=...\"}}\n```\n\n## Common Errors\n\n### \"Frame with the given frameId is not found\"\n- Happens when clicking iframe elements via agent-browser before iframe fully renders\n- Fix: wait 3-6 seconds and retry\n\n### \"Debugger agent is not enabled\"\n- Happens when calling Debugger.* methods without `Debugger.enable`\n- Usually unnecessary; just use Runtime.evaluate instead\n\n### TimeoutError on ws.recv()\n- CDP sends events asynchronously; not all messages are responses to your commands\n- Use `asyncio.wait_for(ws.recv(), timeout=N)` in a loop\n- Filter by checking `data.get('method')` for the event you want\n\nFile v1.0.2:references/detailed-workflow.md\n\n# 完整操作流程\n\n> 主 SKILL.md 的扩展，包含每个 Step 的完整命令和代码。\n\n## 前提\n\n- agent-browser 已安装并连接（WSL: `agent-browser connect \"http://<IP>:9223\"`）\n- 微信读书已登录\n\n> ⚠️ **黄金法则**：每一步操作后都等 6-10 秒！页面元素需要时间加载。\n> ⚠️ **长滚动（>3 轮）必须用前台 terminal + 长 timeout（600s）**，不要用 background 进程。\n\n---\n\n## Step 1: 启动浏览器\n\n**非WSL**：跳过，agent-browser 自动管理浏览器。\n\n**WSL 用户**：参考 [wsl-cdp-browser.md](wsl-cdp-browser.md) 启动 Edge CDP 并配置端口转发。\n\n验证连接：`agent-browser get cdp-url`（非WSL）或 `curl http://<IP>:9223/json/version`（WSL）\n\n---\n\n## Step 2: 登录微信读书（统一用扫码！）\n\n### 2.0 清除旧登录态（可选，cookie 残留时）\n\n微信读书的认证 cookie 是 httpOnly 的，需用 CDP `Storage.clearCookies`（**page 级别** WebSocket）：\n\n```python\nimport asyncio, json, subprocess, websockets\n\n# 获取 CDP browser WebSocket URL\n# 非WSL: result = subprocess.run(['agent-browser', 'get', 'cdp-url'], capture_output=True, text=True)\n# WSL:   result = subprocess.run(['curl', '-s', 'http://WINDOWS_IP:9223/json/version'], capture_output=True, text=True)\n#         version['webSocketDebuggerUrl']\n\n# 获取 weread 页面的 page-level WS URL\nresult = subprocess.run(['curl', '-s', '<browser_ws_url替换为/json>'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_ws = None\nfor t in targets:\n    if t['type'] == 'page' and 'weread' in t.get('url', ''):\n        weread_ws = t['webSocketDebuggerUrl']\n        break\n\nasync def clear():\n    async with websockets.connect(weread_ws, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id': 1, 'method': 'Storage.clearCookies', 'params': {}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=5)\n        print(resp)\n\nasyncio.run(clear())\n```\n\n> `Network.clearBrowserCookies` 在此 Edge 版本返回 `-32601`，只用 `Storage.clearCookies`。\n\n清除后 reload 页面确认\"登录\"链接出现。\n\n### 2.1 导航并点击登录\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 8\n```\n\n触发登录弹窗（优先用 eval）：\n\n```bash\n# ✅ 方法1（推荐）：eval 触发\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# ⚠️ 方法2（备选）：agent-browser click — 经常无效\n# agent-browser snapshot | grep -i 登录\n# agent-browser click <登录的ref>\n# sleep 6\n```\n\n### 2.2 QR 码提取（默认「刷新后提取」）\n\n> 首次 eval 触发的 QR 经常是旧缓存的。必须：关闭弹窗 → 重新触发 → 提取。\n\n**必须用一个 terminal() 调用跑完 Step A-E！** 不要拆分。\n\n```bash\n# Step A: eval 触发第一次登录（仅用于唤起弹窗，5s 足够）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 5\n\n# Step B: 关闭弹窗（丢弃旧 QR）\nagent-browser eval \"var m=document.querySelector('.mask,.dialog_mask,.wr_overlay');if(m)m.click();'closed'\"\nsleep 2\n\n# Step C: 重新触发登录（拿到新鲜 QR，6s 即可）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# Step D: 一键提取 QR（移除 iframe + 提取 src）\nagent-browser eval \"\n(function() {\n  const iframes = document.querySelectorAll('iframe');\n  iframes.forEach(f => f.remove());\n  const imgs = document.querySelectorAll('img');\n  for (const img of imgs) {\n    if (img.width > 100 && img.height > 100) return img.src;\n  }\n  return '';\n})()\" > /tmp/qr_url.txt\n\n# Step E: 解析并下载 QR → 立即发码给用户\npython3 -c \"\nimport json, base64, os\nwith open('/tmp/qr_url.txt') as f:\n    raw = f.read().strip()\nsrc = json.loads(raw)\nif not src:\n    print('ERROR: no QR image found')\n    exit(1)\nif src.startswith('data:image'):\n    b64 = src.split(',', 1)[1]\n    b64 += '=' * (4 - len(b64) % 4) if len(b64) % 4 else ''\n    with open('/tmp/weread_qr.png', 'wb') as f:\n        f.write(base64.b64decode(b64))\nelse:\n    import subprocess; subprocess.run(['curl', '-s', '-o', '/tmp/weread_qr.png', src])\nprint(f'QR OK: {os.path.getsize(\\\"/tmp/weread_qr.png\\\")} bytes')\n\"\n# 整个 block 结束后立刻在回复里发 MEDIA:/tmp/weread_qr.png\n```\n\n**如果 QR 仍失效（极少情况）：** 重复 Step B-D。如果登录弹窗完全消失，重新 goto + 触发：\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n# 再次一键提取\n```\n\n**验证登录：**\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser snapshot | grep -i \"我的书架\\|继续阅读\"\n```\n\n**登录成功后清理临时文件：**\n\n```bash\nrm -f /tmp/weread_qr.png /tmp/qr_url.txt\n```\n\n---\n\n## Step 3: 搜索 + 大批量滚动加载\n\n> 🔴 **铁律：weread.qq.com 标签页绝对不能关！绝对不能离开！**\n> 🔴 **滚动必须用 agent-browser 的 eval 执行，Python page WS 的 Runtime.evaluate 不会触发 XHR。**\n\n### 3.1 导航到搜索页 + 保持 weread session\n\n```bash\n# A. 先保留登录会话（在导航走之前创建 weread 备份标签页）\n#    获取 browser WS URL（非WSL: agent-browser get cdp-url，WSL: curl http://IP:9223/json/version）\npython3 -c \"\nimport asyncio, json, subprocess, websockets\nWINDOWS_IP = subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout.split('default via ')[1].split()[0] if 'default via ' in subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout else '172.19.80.1'\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:9223/json/version'], capture_output=True, text=True)\nbw = json.loads(result.stdout)['webSocketDebuggerUrl']\n\nasync def create():\n    async with websockets.connect(bw, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id':1,'method':'Target.createTarget','params':{'url':'https://weread.qq.com/'}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=10)\n        print(resp)\nasyncio.run(create())\n\"\nsleep 5\n```\n\n> 💡 **简易替代**：当 CDP websockets 路径被阻断（cron 禁用 execute_code、安全扫描器拦截 heredoc）时，可用 `agent-browser open \"https://weread.qq.com/\"` 替代上面的 Python 代码创建备份标签页。效果相同，更简单。\n# B. 导航到搜索页（原标签页变为搜索页，备份的 weread 标签页保持登录态）\nagent-browser goto \\\n  \"https://search.weixin.qq.com/cgi-bin/newsearchweb/userclientjump?path=page/search/weread&query=URL编码关键词&platform=pc\"\nsleep 8\nagent-browser eval \"document.querySelectorAll('.search_list_item').length\"\n# → 初始 15 篇\n```\n\n### 3.2 滚动加载（默认滚到「暂无更多内容」，500 条封顶）\n\n> ⚠️ **Edge CDP bug（2026-06）：`window.scrollTo()` / `scroll down` / `PageDown` 会改变 `scrollY` 但不会触发 `scroll` 事件。** 搜索页的无限滚动依赖 scroll 事件触发 XHR。必须每次滚动后手动 `window.dispatchEvent(new Event('scroll'))` 才能触发加载。\n\n```bash\n# 每轮 = 3 次 [scrollTo + dispatchEvent] + 2s 间隔 + 3s 渲染等待。每轮新增 ~45 篇。\nPREV=\"\"; PREV2=\"\"\nfor r in $(seq 1 20); do\n  for j in 1 2 3; do\n    agent-browser eval \"window.scrollTo(0, document.body.scrollHeight); window.dispatchEvent(new Event('scroll'));\" > /dev/null 2>&1\n    sleep 2\n  done\n  sleep 3\n  C=$(agent-browser eval \"document.querySelectorAll('.search_list_item').length\" 2>/dev/null)\n  echo \"R$r: $C\"\n  # 超过 500 封顶\n  if [ \"$C\" -gt 500 ] 2>/dev/null; then\n    echo \"HIT 500 cap, stopping\"\n    break\n  fi\n  # 检查\"暂无更多内容\"\n  NOMORE=$(agent-browser eval \"document.body.textContent.includes('暂无更多内容')\" 2>/dev/null)\n  if [ \"$NOMORE\" = \"true\" ]; then\n    echo \"HIT 暂无更多内容 at $C, stopping\"\n    break\n  fi\n  # 连续 3 轮不变（兜底）\n  if [ \"$C\" = \"$PREV\" ] && [ \"$C\" = \"$PREV2\" ]; then\n    echo \"PLATEAU at $C, stopping\"\n    break\n  fi\n  PREV2=$PREV; PREV=$C\ndone\n```\n\n> 典型增长曲线：R1→60, R2→105, R3→150, R4→195, R5→240, R6→258（然后持平）。\n\n如果两轮后数量不变（始终 15 篇）：① weread session 丢失 ② 用了 Python page WS 做滚动 ③ **Edge CDP 下 scroll 事件不触发**（见 [edge-cdp-scroll-issue.md](edge-cdp-scroll-issue.md)）——换用 Chrome CDP。\n\n---\n\n## Step 4: 提取文章数据 + URL\n\n### 4.1 批量采集文章元数据\n\n```bash\nagent-browser eval \"\n(() => {\n    const arts = [];\n    document.querySelectorAll('.search_list_item').forEach((item, i) => {\n        const t = item.querySelector('.article__title-text');\n        const d = item.querySelector('.article__desc');\n        const s = item.querySelector('.source__title');\n        const dt = item.querySelector('.source__text.date');\n        arts.push({\n            idx: i,\n            title: (t?.textContent || '').trim(),\n            desc: (d?.textContent || '').trim().substring(0,200),\n            source: (s?.textContent || '').trim(),\n            date: (dt?.textContent || '').trim(),\n            dataId: item.getAttribute('data-id') || ''\n        });\n    });\n    return JSON.stringify(arts);\n})()\" > /tmp/arts.json\n```\n\n> `agent-browser eval` 返回的字符串被双重引号包裹（`\"[...]\"`），解析需 `json.loads(json.loads(raw))`。\n\n### 4.2 筛选目标文章\n\n用 Python 解析日期，筛选近一周（或用户指定的时间范围）：\n\n```python\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime。支持：X年前/个月前/周前/天前/小时前/分钟前、YYYY-MM-DD、YYYY/M/D、M月D日、M/D、M-D\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 365) if m else None\n    if '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 30) if m else None\n    if '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(weeks=int(m.group(1))) if m else None\n    if '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1))) if m else None\n    if '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(hours=int(m.group(1))) if m else None\n    if '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(minutes=int(m.group(1))) if m else None\n    if re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    if re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    if re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n```\n\n### 4.3 获取文章 URL（execute_code 中执行）\n\n用 execute_code + Python + websockets，连搜索页 page-level CDP WebSocket，一次 Runtime.evaluate 批量完成。\n\n获取 CDP WebSocket URL：\n- 非WSL：`agent-browser get cdp-url` 得浏览器级 WS → 访问 `/json` 获取 page targets\n- WSL：`curl -s \"http://$WINDOWS_IP:9223/json\"` 获取 page targets\n\n核心 JS（async + await sleep 防竞态）：\n\n```js\n(async function() {\n    var sleep = ms => new Promise(r => setTimeout(r, ms));\n    var items = document.querySelectorAll('.search_list_item');\n    var orig = window.open;\n    for (var i = 0; i < targets.length; i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; };\n        items[targets[i]].scrollIntoView({block: 'center', behavior: 'instant'});\n        items[targets[i]].click();\n        await sleep(50);\n        results.push({idx: targets[i], url: captured});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\n```\n\n关键点：\n- 点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`\n- 拦截 `window.open` 返回 null，不弹新标签页\n- 必须 async function + `await sleep(50)` + `awaitPromise: true`\n\n完整提取模板见 [extraction-pattern.md](extraction-pattern.md)。\n\n### 4.4 输出格式\n\n**严禁从 execute_code 控制台输出复制 URL。** 强制从 `/tmp/urls.json` 读取：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\ntotal = len(data)\nsucc = sum(1 for r in data if r['url'])\nprint(f'**公众号文章搜索结果**')\nprint()\nprint(f'搜到 {total} 篇 → 提取 {total} 篇 → {succ} 篇有链接（成功率 {100*succ//max(total,1)}%）')\nprint()\nprint('🔥 Top 10：')\nprint()\nfor i, r in enumerate(data[:10]):\n    if r['url']:\n        print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r['date']}\\\")\n\"\n```\n\n回复用户时只发摘要，完整数据存 `/tmp/urls.json`。\n\n---\n\n## Step 5: 完成后的清理\n\n**不关浏览器！** 回到微信读书登录页，关闭搜索页等所有多余标签页，只保留一个 weread 页面。\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# 先回到微信读书首页（agent-browser 标签页变为 weread）\nagent-browser goto \"https://weread.qq.com/\"\nsleep 5\n\n# 关闭其他所有标签页（搜索页 + mp.weixin.qq.com + 备份 weread），只保留当前 weread\npython3 -c \"\nimport json, subprocess\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\n# 第一轮：关掉所有非 weread 页面\nfor t in targets:\n    if t['type'] == 'page' and 'weread.qq.com' not in t.get('url', ''):\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed: {t[\\\"url\\\"][:80]}')\n# 第二轮：如果还有多个 weread 页面，把多余的也关了\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_tabs = [t for t in targets if t['type'] == 'page' and 'weread.qq.com' in t.get('url', '')]\nif len(weread_tabs) > 1:\n    for t in weread_tabs[1:]:  # 保留第一个，关掉其余的\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed duplicate weread: {t[\\\"id\\\"]}')\nprint('Done — only weread login page remains')\n\"\n```\n\n---\n\n## 完整陷阱表\n\n| 陷阱 | 解决方案 |\n|------|---------|\n| Edge CDP 滚动不触发 scroll 事件（scrollY变但事件0） | 每次 scrollTo 后手动 `dispatchEvent(new Event('scroll'))` |\n| `agent-browser --cdp <WS_URL>` 返回 404（WSL） | 用 `agent-browser connect \"http://<IP>:9223\"` 建立连接 |\n| agent-browser click 登录按钮不弹窗 | 用 eval 触发 |\n| iframe \"微信快捷登录\" 遮挡二维码 | eval 中移除 iframe |\n| iframe 内按钮点击无效（跨域） | 直接移除 iframe |\n| agent-browser eval 输出被双重引号包裹 | `json.loads(json.loads(raw))`（仅 JSON；简单字符串只用一层） |\n| 链接获取效率低 | 用 `batch_extract_urls()` 一次 eval 完成全部提取 |\n| 滚动永远卡在 15 篇（Edge CDP scroll 事件不触发） | **Edge CDP 已知缺陷**：scrollTo/pageDown/scrollIntoView 都改变 scrollY 但从不触发 scroll 事件 → 搜一搜无限滚动完全失效。换用 Chrome CDP。详见 [edge-cdp-scroll-issue.md](edge-cdp-scroll-issue.md) |\n| 滚动永远卡在 15 篇（Python page WS） | 只有 agent-browser eval 能触发 XHR |\n| 滚动永远卡在 15 篇（session 丢失） | 保持 weread.qq.com 标签页活跃 |\n| 页面显示「加载中」但实际未加载 | 搜索页 body 中「加载中」字符串是**静态模板文本**，不是实时加载指示器。验证滚动是否工作的唯一标准是 `search_list_item` 数量变化。 |\n| 二维码过期 | 全流程单 terminal 调用，关闭→重新触发→提取 |\n| QR 流程拆成多个 terminal 调用 | ❌ 大忌 |\n| QR 提取后做验证浪费有效期 | ❌ 提取成功后立刻发 MEDIA |\n| 二维码 src 是 data: URI 非 HTTP URL | Python base64 解码，补 padding |\n| httpOnly cookie 清不掉 | CDP `Storage.clearCookies` |\n| `Network.clearBrowserCookies` 返回 -32601 | 用 `Storage.clearCookies` |\n| 搜一搜页面卡「加载中...」 | 清 cookie → 重启浏览器 → 重新扫码 |\n| 滚动中途停滞（非 15 篇） | 查「暂无更多内容」= 真上限；3 轮不变 = plateau 兜底 |\n| 长滚动不要用 background 进程 | 前台 terminal + 长 timeout（600s） |\n| 批量提取 URL 部分错位（~40%） | async + `await sleep(50)` + `awaitPromise: true` |\n| 部分链接点进去「参数错误」 | ✅ 已修复：从 `/tmp/urls.json` 读完整 URL |\n| execute_code 中搜索页 page WS 匹配失败 | 用 `'search.weixin'`（点号，不是斜杠）匹配 URL |\n| parse_date 不识别「X个月前」「X年前」「X月X日」「2025/3/24」等格式 | 使用 extraction-pattern.md 中的完整 parse_date（已覆盖所有已知格式） |\n\nFile v1.0.2:references/edge-cdp-scroll-issue.md\n\n# Edge CDP 滚动事件不触发问题\n\n> **发现日期**: 2026-06-09  \n> **Edge 版本**: 149.0.4022.52  \n> **影响范围**: 所有依赖 scroll 事件触发 XHR 的页面（含微信搜一搜无限滚动）\n\n## 问题\n\nEdge CDP 调试模式下，**任何形式的程序化滚动都不会触发 `scroll` 事件**，即使 `scrollY` 确实改变了。\n\n## 验证方法\n\n在目标页面注册 scroll 事件监听器，然后用不同方式滚动并检查事件计数：\n\n```bash\n# 注册监听\nagent-browser eval \"window.__sc=0;window.addEventListener('scroll',()=>{window.__sc++});'ok'\"\n\n# 测试方式1: window.scrollTo\nagent-browser eval \"window.scrollTo(0,500)\"\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式2: scrollIntoView\nagent-browser eval \"document.querySelector('.search_list_item:last-child').scrollIntoView({block:'end'})\"\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式3: agent-browser scroll\nagent-browser scroll down 800\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n\n# 测试方式4: PageDown 键\nagent-browser press PageDown\nsleep 2\nagent-browser eval \"window.__sc\"  # → 0\n```\n\n## 确认结果\n\n| 方式 | scrollY 改变？ | scroll 事件触发？ |\n|------|:---:|:---:|\n| `window.scrollTo()` | ✅ | ❌ |\n| `scrollIntoView()` | ✅ | ❌ |\n| `agent-browser scroll down` | ✅ | ❌ |\n| `agent-browser press PageDown` | ✅ | ❌ |\n\n> 重启 Edge、更换 user-data-dir 均无效。这是 Edge CDP 实现层面的问题。\n\n## 对微信搜一搜的影响\n\n微信搜一搜搜索页的无限滚动机制依赖于 `scroll` 事件 → XHR 加载更多文章。`scroll` 事件不触发 ⇒ XHR 永远不发出 ⇒ 文章数永远停在初始的 **15 篇**。\n\n> ⚠️ 页面 body 中「加载中」字样是**静态模板文本**，不是实时加载指示器。不能据此判断滚动是否工作。\n\n## 解决方案\n\n**换用 Chrome CDP 代替 Edge CDP**。Chrome 的 CDP 实现中 scroll 事件正常触发。\n\nChrome 启动命令（从 WSL）：\n```bash\n# Chrome 路径（若已安装）\n\"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=C:/Users/gdc3489/AppData/Local/Temp/chrome_debug \\\n  --no-first-run --no-default-browser-check about:blank\n```\n\n端口转发和防火墙规则与 Edge 相同（见 wsl-cdp-browser skill）。\n\n## 已知无法使用的替代方案\n\n- `Runtime.evaluate` 执行 `window.scrollTo`：不触发 XHR（本来就是如此，见 detailed-workflow.md）\n- CDP `Input.dispatchMouseEvent` (mouseWheel)：Edge 上同样不触发 scroll 事件\n- `agent-browser scroll` 命令：底层也是 CDP，同样不触发\n\nFile v1.0.2:references/extraction-pattern.md\n\n# 微信读书搜索 - 完整提取代码模板\n\n## 工作流分界\n\n- **Step 1-3（浏览器+登录+搜索+滚动）**：用 `agent-browser --cdp` terminal 命令。必须 agent-browser 执行滚动（Python page WS 的 `window.scrollTo` 不触发 XHR）。\n- **Step 4（元数据采集 + URL 批量提取）**：用 `execute_code` 中的 Python + websockets 连搜索页的 **page-level CDP WebSocket**。一次 `Runtime.evaluate` 批量完成全部 URL 提取。\n\n## 快速参考\n\n适用的任务：连接 CDP → 采集元数据 → 筛选 → 获取 URL。\n\n**两条路径选一条：**\n\n| 路径 | 适用场景 | 命令 |\n|------|---------|------|\n| **A: agent-browser eval（同步）** | 文章数 ≤ 50、execute_code 被禁用（cron）、简单直接 | `agent-browser eval` 同步 click + 拦截 `window.open` |\n| **B: Python + page CDP WS（异步）** | 文章数 > 50、需精确控制、execute_code 可用 | `Runtime.evaluate` + `awaitPromise: true` |\n\n> 🔴 **路径 A 已验证可行**（2026-06-09）：同步 `agent-browser eval` 中拦截 `window.open` 并 `.click()` 能即时捕获 URL，无需 async/await。Vue 的 click handler 同步触发 `window.open`，URL 在 eval 返回前就已写入。15 篇提取 15/15 URL，成功率 100%。\n\n## 完整模板\n\n```python\nimport asyncio, json, subprocess, sys, re\nfrom datetime import datetime, timedelta\n\nWINDOWS_IP = \"172.19.80.1\"\nCDP_PORT = 9223\n\n# ── 1. 获取搜索页面 WebSocket URL ──\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nws_url = None\nfor t in targets:\n    if t['type'] == 'page' and 'search.weixin' in t.get('url', ''):\n        ws_url = t['webSocketDebuggerUrl']\n        break\nif not ws_url:\n    raise RuntimeError(\"Search page not found in CDP targets\")\n\nimport websockets\n\n# ── 2. 辅助函数 ──\n\nasync def extract_articles(ws):\n    \"\"\"提取当前 DOM 中所有文章元数据\"\"\"\n    js = \"\"\"\n    (function() {\n        const arts = [];\n        document.querySelectorAll('.search_list_item').forEach((item, i) => {\n            const t = item.querySelector('.article__title-text');\n            const d = item.querySelector('.article__desc');\n            const s = item.querySelector('.source__title');\n            const dt = item.querySelector('.source__text.date');\n            arts.push({\n                idx: i,\n                title: (t?.textContent || '').trim(),\n                desc: (d?.textContent || '').trim().substring(0,200),\n                source: (s?.textContent || '').trim(),\n                date: (dt?.textContent || '').trim(),\n                dataId: item.getAttribute('data-id') || ''\n            });\n        });\n        return arts;\n    })()\n    \"\"\"\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=5)\n    return json.loads(msg).get('result', {}).get('result', {}).get('value', [])\n\n\nasync def click_get_url(ws, dom_idx):\n    \"\"\"\n    已废弃。改用 batch_extract_urls() 一次 eval 批量完成所有提取。\n    \"\"\"\n    pass\n\n\nasync def batch_extract_urls(ws, target_indices):\n    \"\"\"\n    一次 eval 批量提取所有目标文章的 URL。\n    关键：点击 .search_list_item（整个卡片），不是 .article__title-text。\n    拦截 window.open（return null 防跳转），每次 click 后 await sleep(50ms) 等 Vue 异步处理。\n    N 篇约 N×50ms。\n    \"\"\"\n    idx_list = json.dumps(target_indices)\n    js = (\n        \"(async function() {\\n\"\n        \"    var sleep = function(ms) { return new Promise(function(r) { setTimeout(r, ms); }); };\\n\"\n        \"    var targets = \" + idx_list + \";\\n\"\n        \"    var allItems = document.querySelectorAll('.search_list_item');\\n\"\n        \"    var results = [];\\n\"\n        \"    var origOpen = window.open;\\n\"\n        \"    for (var i = 0; i < targets.length; i++) {\\n\"\n        \"        var idx = targets[i];\\n\"\n        \"        var el = allItems[idx];\\n\"\n        \"        if (!el) { results.push({idx: idx, url: ''}); continue; }\\n\"\n        \"        var captured = '';\\n\"\n        \"        window.open = function(u) { captured = u; return null; };\\n\"\n        \"        el.scrollIntoView({block: 'center', behavior: 'instant'});\\n\"\n        \"        el.click();\\n\"\n        \"        await sleep(50);\\n\"\n        \"        results.push({idx: idx, url: captured});\\n\"\n        \"    }\\n\"\n        \"    window.open = origOpen;\\n\"\n        \"    return JSON.stringify(results);\\n\"\n        \"})()\"\n    )\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True, 'awaitPromise': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=60)\n    result = json.loads(msg).get('result', {}).get('result', {}).get('value', '[]')\n    if isinstance(result, str):\n        return json.loads(result)\n    return result\n\n\ndef close_mp_weixin_tabs():\n    \"\"\"关闭所有 mp.weixin.qq.com 标签页（保留搜索页面）\"\"\"\n    result = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                           capture_output=True, text=True)\n    targets = json.loads(result.stdout)\n    closed = 0\n    for t in targets:\n        if t['type'] == 'page' and 'mp.weixin.qq.com' in t.get('url', ''):\n            subprocess.run(['curl', '-s', '-o', '/dev/null',\n                f'http://{WINDOWS_IP}:{CDP_PORT}/json/close/{t[\"id\"]}'])\n            closed += 1\n    return closed\n\n\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime (返回 None 表示无法解析)\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    # Relative: X年前, X个月前, X周前, X天前, X小时前, X分钟前\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            y = int(m.group(1))\n            return now - timedelta(days=y * 365)\n    elif '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            months = int(m.group(1))\n            return now - timedelta(days=months * 30)\n    elif '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            w = int(m.group(1))\n            return now - timedelta(weeks=w)\n    elif '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            d = int(m.group(1))\n            return now - timedelta(days=d)\n    elif '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            h = int(m.group(1))\n            return now - timedelta(hours=h)\n    elif '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            mins = int(m.group(1))\n            return now - timedelta(minutes=mins)\n    # Absolute: YYYY-MM-DD, YYYY/M/D, YYYY/M/D, M月D日, M-D\n    elif re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    elif re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    elif re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n\n\n# ── 3. 主流程（提取全部文章）──\n\nasync def extract_all(ws):\n    \"\"\"\n    提取全部已加载文章（滚动完成后调用）。\n    先采集元数据，再批量提取 URL。150 篇 ~0.6s。\n    \"\"\"\n    # 3.1 采集元数据\n    all_data = await extract_articles(ws)\n    total = len(all_data)\n    print(f\"Articles: {total}\")\n    \n    # 3.2 批量提取全部 URL\n    all_indices = list(range(total))\n    urls = await batch_extract_urls(ws, all_indices)\n    \n    # 3.3 组装结果（含日期转换）\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for a in all_data:\n        raw_date = a.get('date', '')\n        parsed = parse_date(raw_date)\n        if parsed:\n            date_str = parsed.strftime('%Y-%m-%d')\n        else:\n            date_str = raw_date\n        results.append({\n            'idx': a['idx'],\n            'title': a['title'],\n            'source': a['source'],\n            'date': date_str,\n            'url': url_map.get(a['idx'], '')\n        })\n    \n    success = sum(1 for r in results if r['url'])\n    print(f\"URLs: {success}/{total} ({100*success//total}%)\")\n    return results\n\n\nasync def extract_filtered(ws, target_indices, all_articles_data):\n    \"\"\"仅提取指定索引的文章 URL（需提前加载元数据到 all_articles_data）\"\"\"\n    urls = await batch_extract_urls(ws, target_indices)\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for dom_idx in target_indices:\n        a = all_articles_data[dom_idx] if dom_idx < len(all_articles_data) else {}\n        results.append({\n            'idx': dom_idx,\n            'title': a.get('title', ''),\n            'source': a.get('source', ''),\n            'date': a.get('date', ''),\n            'url': url_map.get(dom_idx, '')\n        })\n    return results\n\n\n# ── 4. 执行入口（根据场景选用 extract_all 或 extract_filtered）──\n# 场景A：提取全部已加载文章（最常用）\n# articles = asyncio.run(extract_all(ws))   # 见下方 async main 包装\n\n# 场景B：仅提取筛选后的子集（需先有 all_data）\n# articles = asyncio.run(extract_filtered(ws, keep_indices, all_data))\n\n# 完整 async main 包装（场景A）：\nasync def main_extract_all():\n    async with websockets.connect(ws_url, max_size=2**24) as ws:\n        return await extract_all(ws)\n\narticles = asyncio.run(main_extract_all())\nwith open('/tmp/urls.json', 'w') as f:\n    json.dump(articles, f, ensure_ascii=False, indent=2)\nprint(f\"Done: {sum(1 for a in articles if a['url'])}/{len(articles)} URLs\")\n```\n\n## 关键注意事项\n\n1. **滚动 vs 提取分离**：滚动（Step 3）用 `agent-browser --cdp` 的 terminal 命令；提取（Step 4）用本模板的 Python + page-level CDP WebSocket。**不要用 Python page WS 做滚动** — `Runtime.evaluate` 执行的 `window.scrollTo` 不会触发搜索页的无限滚动 XHR。只有 agent-browser 的 eval 能触发。实测：agent-browser 3轮滚动 → 60→105→150篇，Python page WS 始终卡在15篇。\n2. **批量 eval 提取（async + await sleep）**：一次 `Runtime.evaluate`（`awaitPromise: true`）完成所有 URL 提取。点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`。拦截 `window.open`（`return null` 防跳转）→ `.click()` 触发 Vue → `await sleep(50)` 等 Vue 异步 handler → 读 `captured`。N 篇约 N×50ms。**不加 await sleep 会导致 ~40% URL 错位**（`captured` 被下一轮覆写）。\n\n   **大数据集分批**：当文章数 >200 时，单次 `Runtime.evaluate` 超时风险增大（默认 60s timeout），且结果字符串可能过大。建议 `batch_extract_urls` 增加 `batch_size` 参数，每批 100 篇分批执行：\n\n   ```python\n   async def batch_extract_urls(ws, target_indices, batch_size=100):\n       all_results = []\n       for batch_start in range(0, len(target_indices), batch_size):\n           batch = target_indices[batch_start:batch_start + batch_size]\n           # ... 单批 eval（同上）...\n           all_results.extend(result)\n       return all_results\n   ```\n\n   实测：336 篇分 4 批（100+100+100+36），总耗时约 20s，全部成功。\n3. **无需关闭标签页**：`window.open` 被拦截，不真正打开新标签页。\n4. **URL 验证**：提取后用 CDP 浏览器 `goto` 抽查 3-5 条，确认页面加载了文章标题（而非空壳/参数错误）。不要用 curl 验证——curl 可能拿到空壳模板但 HTTP 200。也不要因部分链接「参数错误」反复调整 URL 编码——这是微信服务端行为，与提取无关。\n5. **元数据采集可选**：如果已通过 agent-browser 采集了 `/tmp/arts.json`，可跳过 `extract_articles`，直接用 `extract_filtered`。读取文件时注意 `agent-browser eval` 输出被双重引号包裹（`\"[{...}]\"` 而非 `[{...}]`），需嵌套解析：\n\n```python\nwith open('/tmp/arts.json', 'r') as f:\n    raw = f.read().strip()\n\n# Handle agent-browser eval double-quoting: parse repeatedly until we get a list\nall_data = raw\nfor _ in range(5):\n    try:\n        all_data = json.loads(all_data)\n    except (json.JSONDecodeError, TypeError):\n        break\n    if not isinstance(all_data, str):\n        break\n\nif isinstance(all_data, str):\n    raise RuntimeError(f\"Failed to parse /tmp/arts.json after 5 rounds\")\n```\n\n`all_data` 的结构与 `extract_articles()` 返回的一致，可直接喂给 `extract_filtered()` 或 `batch_extract_urls()`。\n6. **`Page.enable`**：保留但不再依赖 CDP 事件监听。\n7. **🚨 URL 防截断**：`extract_all` 后将 `results` 写入 `/tmp/urls.json`。回复用户时用 terminal 中 Python 脚本从 `/tmp/urls.json` 读取并 print Markdown Top 10，复制到回复。**严禁从 execute_code 控制台 print 输出中复制 URL**（可能截断缺参数）。\n\n## 路径 A：agent-browser eval 同步提取（简易路径）\n\n适用于 execute_code 被禁用（cron）、文章数 ≤ 50、或只想快速提取的场景。\n\n### Step A1: 采集元数据\n\n```bash\nagent-browser eval \"\n(() => {\n    const arts = [];\n    document.querySelectorAll('.search_list_item').forEach((item, i) => {\n        const t = item.querySelector('.article__title-text');\n        const d = item.querySelector('.article__desc');\n        const s = item.querySelector('.source__title');\n        const dt = item.querySelector('.source__text.date');\n        arts.push({\n            idx: i,\n            title: (t?.textContent || '').trim(),\n            desc: (d?.textContent || '').trim().substring(0,200),\n            source: (s?.textContent || '').trim(),\n            date: (dt?.textContent || '').trim(),\n            dataId: item.getAttribute('data-id') || ''\n        });\n    });\n    return JSON.stringify(arts);\n})()\" > /tmp/arts.json\n```\n\n### Step A2: 同步批量提取 URL\n\n```bash\nagent-browser eval \"\n(function() {\n    var items = document.querySelectorAll('.search_list_item');\n    var results = [];\n    var orig = window.open;\n    for (var i = 0; i < items.length; i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; return null; };\n        items[i].click();\n        results.push({idx: i, url: captured, dataId: items[i].getAttribute('data-id') || ''});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\" > /tmp/urls_raw.json\n```\n\n> ✅ Vue 的 click handler 同步触发 `window.open`，URL 在 eval 返回前就已写入。无需 async/await，无需 awaitPromise。\n> \n> ⚠️ 当文章数 > 50 时建议用路径 B（Python + websockets），因为 agent-browser eval 的单次执行时间更长。\n\n### Step A3: 解析并合并（Python）\n\n```python\nimport json\n\n# 元数据（处理 agent-browser eval 双重引号）\nwith open('/tmp/arts.json') as f:\n    raw = f.read().strip()\ndata = raw\nfor _ in range(5):\n    try: data = json.loads(data)\n    except: break\n    if not isinstance(data, str): break\narticles_data = data if isinstance(data, list) else []\n\n# URL 数据\nwith open('/tmp/urls_raw.json') as f:\n    raw = f.read().strip()\ndata = raw\nfor _ in range(5):\n    try: data = json.loads(data)\n    except: break\n    if not isinstance(data, str): break\nurl_data = data if isinstance(data, list) else []\n\n# 合并\nurl_map = {u['idx']: u.get('url', '') for u in url_data}\nresults = []\nfor a in articles_data:\n    results.append({\n        'idx': a['idx'],\n        'title': a['title'],\n        'desc': a.get('desc', ''),\n        'source': a.get('source', ''),\n        'date': a.get('date', ''),\n        'dataId': a.get('dataId', ''),\n        'url': url_map.get(a['idx'], '')\n    })\n\nwith open('/tmp/urls.json', 'w') as f:\n    json.dump(results, f, ensure_ascii=False, indent=2)\nprint(f\"Extracted: {sum(1 for r in results if r['url'])}/{len(results)} URLs\")\n```\n\n### 路径 A vs B 选择指南\n\n| 条件 | 选路径 A | 选路径 B |\n|------|---------|---------|\n| execute_code 可用 | ✓ | ✓ |\n| execute_code 被禁用（cron） | **✓ 唯一选择** | ✗ 不可用 |\n| 文章数 ≤ 50 | ✓ 简单 | 过度设计 |\n| 文章数 > 50 | 可能超时 | **✓ 推荐** |\n| 需分批控制/batch_size | ✗ | ✓ |\n| 代码复杂度 | 低 | 高 |\n\nFile v1.0.2:references/llm-filter-pattern.md\n\n# LLM 语义筛选模式\n\n适用于 cron 作业中需要对搜索结果做语义筛选的场景：搜索返回大量噪音 → LLM 逐条判断 → 仅保留真正相关的内容。\n\n## 为什么不用关键词\n\n关键词规则（黑名单/白名单）有两个致命缺陷：\n1. **今天能用，明天过时**：今天的噪音类型（电网拓扑、电源纹波）不一定是明天的噪音\n2. **无法理解语义**：同一词语在不同上下文中含义不同。比如\"拓扑优化\"在结构力学和网络科学中是两个完全不同领域\n\nLLM 判断能理解上下文语义，适应任何新出现的噪音类型。\n\n## 流程\n\n```\n搜索 → 提取全部文章 → 打印标题/摘要供 LLM 判断 → LLM 输出 KEEP 列表 → 程序筛选 → 后续处理\n```\n\n## 实现模板\n\n### Step A：打印全部候选项（仅标题，不看正文省 token）\n\n```python\nimport json\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfor i, art in enumerate(articles):\n    print(f\"[{i}] {art.get('title', '')}  |  {art.get('source', '')}  |  {art.get('date', '')}\")\n\nprint()\nprint(\"===== 以上是全部文章，请逐条判断 =====\")\nprint(\"请列出应保留的序号，格式: KEEP=[0,2,5,7,...]\")\n```\n\n### Step B：LLM 阅读并输出 KEEP 列表\n\nAgent 阅读所有标题+摘要，按领域知识逐条判断是否属于目标领域。在回复中输出 KEEP 列表。\n\n### Step C：执行筛选\n\n```python\nimport json\n\nkeep_indices = [0,2,5,7]  # 替换为 LLM 判断结果\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfiltered = [articles[i] for i in keep_indices if i < len(articles)]\n\nwith open('/tmp/urls_filtered.json', 'w') as f:\n    json.dump(filtered, f, ensure_ascii=False, indent=2)\n\nprint(f\"筛选结果: {len(articles)} → {len(filtered)} 篇\")\n```\n\n## 判断标准编写要点\n\n在 cron prompt 中给出清晰的判断标准：\n- ✅ 什么是「属于」（正例特征）\n- ❌ 什么是「不属于」（反例特征+细分类型）\n- 🎯 核心区分问题（一句话帮 LLM 抓住本质）\n\n示例（拓扑优化领域）：\n```\n- ✅ 属于：讨论结构拓扑优化（SIMP/BESO/水平集/变密度法），工程应用，软件教程\n- ❌ 不属于：\n    - 网络拓扑优化、通用优化、数学拓扑学、广告招聘、景观艺术\n    - 卖课/课程推广/付费培训：标题含「课程」「专题课程」「一起学习」「训练营」等推销口吻，目标是卖课而非分享知识\n- 🎯 核心区分：「对结构做拓扑优化」还是「某系统/网络的拓扑结构优化」\n```\n\n### 卖课内容识别特征\n\n| 类型 | 特征词/句式 | 示例 |\n|------|-----------|------|\n| 课程推销 | 「XX课程」「专题课程」「带你发掘」「一起学习」 | 变密度法拓扑优化课程 / COMSOL专题课程 |\n| 培训广告 | 「训练营」「速成班」「从入门到精通」「限时优惠」 | XX软件7天训练营 |\n| 引流卖课 | 结尾带二维码/微信号、正文讲一半留一半 | 「扫码获取完整教程」 |\n\n> 💡 判断核心：文章目的是**分享知识/案例**还是**推销付费课程**？如果标题/内容有明显的课程推销属性，即使技术内容相关也要排除。\n\n## 两轮筛选（关键）\n\n当候选文章 > 100 篇时，**单轮筛选必然有漏网之鱼**。必须做两轮：\n\n### 第一轮：粗筛（排除明显噪音）\n\n在主干流程的 Step 4.5 完成。排除标准针对**明显的类别错误**（卖课、电源拓扑、光子学、招聘广告、视觉设计等）。排除目标是**高置信度错误**，宁可保留边界案例。\n\n### 第二轮：精筛（去重后审查新文章）\n\n第一轮粗筛后仍有漏网噪音——尤其是这些类型：\n- **行业报告**：标题不涉及 TO，正文只将\"拓扑优化\"列为众多趋势之一（如「汽车热管理电子风扇行业迎结构性变革」）\n- **综合概览**：AI/工具/行业 overview 文章，仅在列表中提一句 TO（如「AI生成CAD-BIM方法很多」「一个机械工程师眼中的人工智能」）\n- **非结构优化**：管道/电缆布线路由优化、物流路径优化（用\"拓扑\"指网络布局，非结构设计）\n- **无关领域**：水泥窑工艺、沸石转轮化工、液流电池 CFD（TO 非核心，仅是边缘工具）\n\n**第二轮流程**：\n1. 去重后得到\"新文章\"列表\n2. **先写入临时文件**（`/tmp/new_articles.json`），暂不更新持久化历史\n3. 逐条审查新文章的标题+摘要\n4. 如发现误保留文章，**从临时文件移除 + 不将其 dataId 加入 seen_ids**\n5. 审查通过后再更新持久化 JSON\n\n> 🔴 如果第二轮发现误保留文章且已写入持久化 JSON，必须**回退**：从 `articles` 数组末尾移除对应条目，从 `seen_ids` 中移除对应 dataId。\n\n## 自主模式（Cron/无人作业）\n\n当无人交互时，交互式的 `KEEP=[]` 流程不适用。改用**单次编程式排除**：LLM 阅读全部标题后，直接在 `execute_code` 中构建 `exclude` 集合并过滤。\n\n```python\n# 一次性完成：打印标题 → LLM 审查 → 编码 exclude 集合 → 过滤\nexclude = {\n    3,   # 技术邻招聘\n    56,  # KNX 网络拓扑\n    87,  # 光子结构\n    # ... 逐个编号\n}\n\nkept = [a for i, a in enumerate(articles) if i not in exclude]\n```\n\n**两轮过滤在自主模式下的执行顺序**：\n1. 第一轮：全量粗筛（对 `/tmp/urls.json` 全部文章）→ 写入 `/tmp/urls_filtered.json`\n2. 去重：从 `/tmp/urls_filtered.json` 中排除已见 dataId → 写入 `/tmp/new_articles.json`\n3. 第二轮：精筛仅对新文章 → **先审查再写持久化**（避免回退操作）\n4. 确认无误后一次性追加到持久化 JSON\n\n> 💡 第二轮精筛在去重**之后**执行（而非之前），这样只需审查少数新文章，大幅节省 token。\n\n### 第二轮精筛的额外排除类型\n\n以下类型常在第一轮漏网（标题含「拓扑优化」但实际非结构 TO）：\n| 类型 | 特征 | 示例 |\n|------|------|------|\n| 3D打印趋势 | 标题讲3DP/增材制造，TO只是手段之一 | 「航空航天融合3D打印与AI，未来真的不用画图了？」 |\n| 行业报告 | 标题广泛，TO仅为文中一小节 | 「汽车零部件产业技术发展与制造趋势」 |\n| 活动/会议通知 | 论文学术交流会、工作营招生 | 「中国航发涡轮院召开…学术交流会」 |\n| 期刊目录 | 期刊当期目录汇总 | 「期刊目录丨《推进技术》2026年第五期」 |\n| 尺寸优化（非拓扑） | 明确写「尺寸优化」而非「拓扑优化」 | 「基于OptiStruct的电池包壳体尺寸优化」 |\n| 纳米/生物医学 | 用了「拓扑」指纳米结构，非力学TO | 「人工拓扑纳米结构…生物医学应用」 |\n| 通用设计优化 | 不特定于TO的轻量化/结构设计概览 | 「在保证强度前提下减轻零件重量的几种思路」 |\n\n## 关键词排除陷阱\n\n| 陷阱 | 说明 | 修复 |\n|------|------|------|\n| 裸 `GPU` 匹配 | `GPU` 在 TO 文章中常指 GPU 加速计算（如「基于 CPU-GPU 异构并行…拓扑优化」），不是网络拓扑 | 用更具体模式：`NCP-AIN\\|英伟达.*网络认证\\|面向AI.*网络` |\n| `通信` 匹配 | 同上，可能匹配到「多物理场耦合通信」等工程描述 | 限定为 `通信网络\\|网络通信协议\\|5G通信` |\n| `路由` 匹配 | 可能匹配「传力路径」的工程语境 | 限定为 `路由协议\\|网络路由\\|IP路由` |\n\n> 💡 编写第一轮关键词排除时，测试数据集中的前 20 篇标题确认无假阳性。宁可保留边界案例，不要在粗筛阶段过度排除。\n\n## 注意事项\n\n- **仅根据标题+摘要判断**，不读正文，省 token\n- 候选项很多时（>100篇），打印会被截断。分两次 execute_code 调用：前半段 + 后半段\n- 筛选后写入新文件（如 `/tmp/urls_filtered.json`），不动原始数据\n- 后续去重/格式化从筛选后的文件取数据\n- **输出格式化必须用 execute_code 从 JSON 读 URL**，terminal 输出会截断长 URL\n- **自主模式第二轮必须先审查再写持久化**：先写 `/tmp/new_articles.json`，审查通过后再追加到持久化文件，避免回退操作\n- **持久化 dedup 用 dataId 做 key**：每篇微信文章有唯一 `data-id`，首次运行时初始化 `seen_ids` 集合，后续追加时检查\n\nFile v1.0.2:references/topology-optimization-filter.md\n\n# 拓扑优化领域筛选模式\n\n适用于 cron 作业 `搜索微信公众号「拓扑优化」最新文章，筛选真正与结构拓扑优化相关的内容`。记录已验证的排除模式和误判案例。\n\n## 第一轮粗筛排除（遵循 llm-filter-pattern.md 两轮流程）\n\n### 第一轮排除类型 + 正则\n\n| 类型 | 正则模式 | 示例 |\n|------|---------|------|\n| 网络/IT 拓扑 | `(?:NCP-AIN\\|GPU开发者\\|GPU网络\\|英伟达.*网络认证)` | NCP-AIN 备考（1）：网络拓扑优化核心知识 |\n| 光子/光学（非结构） | `(光子提取器\\|PNJ\\|Nanojet)` | 基于自由形状拓扑优化的光子提取器 |\n| 招聘广告 | `招聘` in source | 技术邻招聘 |\n| 课程推销 | `(讲师介绍\\|项目专题专题\\|训练营速成\\|极度喜欢上课团队\\|开课通知)` | 学术地震！…讲师介绍来自国家985… |\n| 游戏开发 | `(Unity\\|纹理图集\\|纹理压缩)` | Unity纹理图集自动导入优化全解析 |\n| 化学/COFs | `(共价有机框架\\|COF光催化\\|kgd共价)` | AFM π-桥调制同构kgd共价有机框架… |\n| 运筹/金融 | `(运筹优化.*知识库\\|投资组合\\|PyPortfolio)` | PyPortfolioOpt：Python 投资组合优化库 |\n| 锻造（非TO） | `锻造` in title AND `拓扑` NOT in title（含 `镦锻`） | 钛合金热镦锻成形工艺优化方法 |\n| 工作营招生 | `工作营招生` in source | 【工作营招生】皇家墨尔本理工大学… |\n| MDO 软件总览 | `(全球\\d+大主流.*优化软件)` | 全球6大主流多学科优化软件 |\n| 仿真概览（TO仅提及） | `(仿真驱动设计.*正在悄悄改变制造业)` | 【数字前沿】仿真驱动设计… |\n| DRL/翼型（流体非结构） | `(DRL主动流控\\|cGAN翼型反设计)` | 硬核成果！…可重构拓扑优化终登Nature封面 |\n| 形状优化AM（非核心TO） | `(神经场.*形状优化.*实现制造感知)` | 基于神经场的形状优化，实现制造感知的无支撑3D打印 |\n\n### ⚠️ 排除陷阱\n\n- **裸 `GPU` 关键词** → 误杀「基于 CPU-GPU 异构并行…拓扑优化」等 GPU 加速计算的合法 TO 文章。必须用 `GPU开发者\\|GPU网络` 等限定模式。\n- **`光学快门` 在热电磁器件 TO 中** → 「一种用于热电磁器件设计的拓扑优化双神经网络方法」含\"光学快门\"但本质是结构 TO。匹配光子排除时需确认非 TO 上下文。\n\n## 第二轮精筛排除\n\n执行顺序：去重后对新文章逐条审查，以下为已验证需排除的细分类型：\n\n| 类型 | 判断特征 | 本会话实例 |\n|------|---------|-----------|\n| 路径规划（图拓扑） | `(分层路径规划\\|路径规划算法)` | 拓扑信息引导的多连通分量分层路径规划算法 |\n| 光子/光学 TO | `(RSoft\\|光子设计\\|消色差超光栅)` in title OR source=`光学超材料` | RSoft 2026 引领光子设计进入全自动时代 / 通过B样条参数化水平集方法加速基于CMA-ES的拓扑优化 (光学超材料) |\n| ⚠️ 「超材料」陷阱 | 「超材料」标题需区分：**力学/结构超材料**（受电弓、吸能、点阵）→ ✅ KEEP；**声学超材料**（吸声行为、扩散场吸声）→ ❌ EXCLUDE；**光学超材料**（光子、超光栅）→ ❌ EXCLUDE | 基于挠性机制的受电弓超材料的带隙可调性 → ✅ / 具有周期多谐振腔和水中空隙的超材料的吸声行为 → ❌ |\n| LLM agent 拓扑 | `(LLM.*多智能体.*协调拓扑)` | LLM 最新论文速览：多智能体系统…协调拓扑优化 |\n| 背景提升/广告 | `有方背景提升` 公众号 | 航空航天课题 | 那么远拍摄… |\n| 电网/复杂网络 | `(电网事故\\|复杂网络.*期刊)` | 文献分享及心得…基于文本挖掘的电网事故… |\n| 企业公关/调侃 | `(华为.*韬定律\\|打孔穿线)` | 华为的\"韬定律\"是个啥？ |\n| 材料科学（非TO核心） | `(共晶纳米骨架.*3D打印铝合金)` | Nature Comms：利用可塑性变形的共晶纳米骨架… |\n| Pareto 多目标（OR） | `(Pareto.*非凸区域)` + `运筹` 公众号 | 多目标优化在复杂工业场景中的应用 |\n| 声学超材料 | `(纤维基复合材料吸声特性\\|非织造布\\|吸声行为\\|扩散场吸声\\|多孔材料.*吸声数值模型)` | 梯度阻抗纤维基复合材料吸声特性的仿真… / 具有周期多谐振腔和水中空隙的超材料的吸声行为 / 有限尺寸多孔材料扩散场吸声数值模型 |\n| 参数化课程 | `(Rhino.*Grasshopper.*微信群直播)` | 如何高效学习Rhino & Grasshopper？ |\n| 活动通知 | `(AI\\+CAE.*周六\\|欧洲中间件新贵)` | 【干货】实例 AI+CAE 代理模型…@5月30日 |\n| 3DP 策略总览 | `(3D打印制造技术结构设计的三个高度)` | 3D打印制造技术结构设计的三个高度… |\n\n### 2026-06-05 新增排除\n\n| 类型 | 判断特征 | 示例 |\n|------|---------|------|\n| 镦锻工艺优化 | `镦锻` in title | 钛合金热镦锻成形工艺优化方法 |\n| 极度喜欢上课团队 | `极度喜欢上课团队出品` in title | COMSOL with MATLAB拓扑优化…极度喜欢上课团队出品 |\n| 开课通知 | `开课通知` in title | 开课通知：金属疲劳寿命预测&ai结构可靠性分析优化设计 |\n| 通用机械设计 | `机械设计优化` in title AND `拓扑` NOT in title | 如何进行机械设计优化 |\n| CFD座舱优化 | `座舱CFD` in title | 特斯拉座舱CFD优化… |\n| NVH声辐射 | `声辐射.*板件贡献量` in title | 电驱动总成结构声辐射中板件贡献量的量化分析 |\n| 水泥工艺 | `水泥.*配比工艺` in title AND `拓扑` NOT in title | 水泥新型原料配比工艺的工业化应用 |\n| CAE软件大全 | `全球\\d+款.*CAE.*软件大全` in title | 全球137款主流CAE仿真软件大全 |\n\n## 持久化 dedup 格式\n\n```json\n{\n  \"articles\": [\n    {\n      \"title\": \"...\",\n      \"source\": \"...\",\n      \"date\": \"2026-06-02\",\n      \"url\": \"http://mp.weixin.qq.com/s?...\",\n      \"dataId\": \"3391257530720860736&...\",\n      \"desc\": \"...\"\n    }\n  ],\n  \"seen_ids\": [\"3391257530720860736&...\", \"...\"],\n  \"last_run\": \"2026-06-03T06:11:00\"\n}\n```\n\n数据文件路径：`~/.hermes/data/topology-optimization-articles.json`\n\nFile v1.0.2:references/wsl-cdp-browser.md\n\n# WSL 环境下连接 Windows CDP 浏览器\n\n如果你是 WSL 用户，需要通过以下步骤让 agent-browser 连接到 Windows 上的 Edge 浏览器。\n\n## 为什么需要这个\n\nWSL2 有独立的网络命名空间，Windows 上的 Chrome/Edge CDP 默认绑定 `127.0.0.1`，WSL 无法直接访问。`--remote-debugging-address=0.0.0.0` 在 Chrome/Edge 上均无效。\n\n## 步骤\n\n### 1. 启动 Edge CDP 浏览器\n\n```bash\n# 先关掉已有的 Edge\n/mnt/c/Windows/System32/taskkill.exe /F /IM msedge.exe 2>/dev/null; sleep 2\n\n# 用独立 user-data-dir 启动（否则会被已有 Edge 实例拦截）\n\"/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=\"C:/Users/gdc3489/AppData/Local/Temp/edge_debug\" \\\n  --no-first-run --no-default-browser-check about:blank &\n\nsleep 5\n```\n\n### 2. 配置端口转发\n\n```bash\n# 端口转发：0.0.0.0:9223 → 127.0.0.1:9222\n/mnt/c/Windows/System32/netsh.exe interface portproxy add v4tov4 \\\n  listenport=9223 listenaddress=0.0.0.0 \\\n  connectport=9222 connectaddress=127.0.0.1\n\n# Windows 防火墙放行（必须！否则 WSL→Windows 流量被拦截）\n/mnt/c/Windows/System32/netsh.exe advfirewall firewall add rule \\\n  name=\"WSL CDP Proxy 9223\" dir=in action=allow protocol=tcp localport=9223\n```\n\n### 3. 获取连接地址并验证\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\ncurl -s \"http://${WINDOWS_IP}:9223/json/version\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('Browser','FAIL'))\"\n# 输出：Edge/xxx 即成功\n```\n\n### 4. 连接 agent-browser\n\n> ⚠️ `agent-browser --cdp <WS_URL>` 在 WSL+Edge CDP 环境下不可用（报 `Auto-launch failed: CDP WebSocket connect failed: HTTP error: 404 Not Found`）。**改用两步法：先 `connect`，再正常使用命令。**\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# Step 1: connect（建立会话）\nagent-browser connect \"http://${WINDOWS_IP}:9223\"\n\n# Step 2: 正常使用（无需 --cdp 前缀）\nagent-browser goto \"https://weread.qq.com/\"\nagent-browser snapshot\n```\n\n> 💡 `connect` 之后所有 agent-browser 命令都不需要 `--cdp` 前缀，会话级保持连接。如果连接断开（如浏览器重启），重新 `connect` 即可。\n\n## 注意事项\n\n- **必须用独立 `--user-data-dir`**，否则已有 Edge 实例会拦截启动，`--remote-debugging-port` 被丢弃\n- **用 `connect` 不用 `--cdp`**：`agent-browser --cdp <port>` 在当前版本（0.27.0）下对 WSL 端口代理返回 404。先用 `connect \"http://<IP>:9223\"`，之后命令不加任何 CDP flag\n- **用 `goto` 不用 `open`**：`agent-browser open` 会尝试 auto-launch 冲突，用 `goto`\n- **端口转发规则重启后持久**，只需运行一次\n- **Windows IP 会变**（DHCP），每次用 `ip route | grep default | awk '{print $3}'` 获取\n\nFile v1.0.2:skill-card.md\n\n## Description: <br>\nSearches WeChat Official Account articles through WeRead search and returns titles, account names, dates, summaries, and direct article links. <br>\n\nThis skill is ready for commercial/non-commercial use. <br>\n\n## Publisher: <br>\n[kanikig](https://clawhub.ai/user/kanikig) <br>\n\n### License/Terms of Use: <br>\nMIT-0 <br>\n\n\n## Use Case: <br>\nExternal users and developers use this skill to search recent WeChat Official Account articles when general web search is insufficient. The agent returns a concise result list in the conversation and stores full extracted link data locally for follow-up use. <br>\n\n### Deployment Geography for Use: <br>\nGlobal <br>\n\n## Known Risks and Mitigations: <br>\nRisk: The skill controls a browser profile that may contain WeRead login session material. <br>\nMitigation: Use a dedicated browser profile for this skill, avoid normal browsing sessions, delete temporary QR and result files when finished, and rotate any exposed WeRead API key that belongs to you. <br>\nRisk: CDP port forwarding or broad listener configuration can expose browser control beyond the intended local workflow. <br>\nMitigation: Restrict CDP access to the minimum host and firewall scope needed, avoid 0.0.0.0 exposure unless tightly controlled, and remove temporary forwarding rules after use. <br>\n\n\n## Reference(s): <br>\n- [ClawHub skill page](https://clawhub.ai/kanikig/wechat-search-weread) <br>\n- [Detailed workflow](references/detailed-workflow.md) <br>\n- [Extraction pattern](references/extraction-pattern.md) <br>\n- [CDP patterns](references/cdp-patterns.md) <br>\n- [WeRead API limitations](references/api-limitations.md) <br>\n- [Edge CDP scroll issue](references/edge-cdp-scroll-issue.md) <br>\n- [WSL CDP browser setup](references/wsl-cdp-browser.md) <br>\n\n\n## Skill Output: <br>\n**Output Type(s):** [text, markdown, code, shell commands, configuration, guidance] <br>\n**Output Format:** [Markdown response with result summaries, inline shell or Python commands, and JSON link data written to /tmp/urls.json.] <br>\n**Output Parameters:** [1D] <br>\n**Other Properties Related to Output:** [Requires a dedicated browser profile with WeRead login; default conversation output shows the top results while full extracted data remains in /tmp/urls.json.] <br>\n\n## Skill Version(s): <br>\n1.0.2 (source: server release evidence) <br>\n\n## Ethical Considerations: <br>\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment. <br>\n\nArchive v1.0.1: 11 files, 26258 bytes\n\nFiles: README.md (2204b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/detailed-workflow.md (16639b), references/extraction-pattern.md (12011b), references/llm-filter-pattern.md (5253b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), skill-card.md (2729b), SKILL.md (3315b), _meta.json (139b)\n\nFile v1.0.1:SKILL.md\n\n---\nname: wechat-search-weread\ndescription: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。\ncategory: social-media\n---\n\n# 微信读书公众号文章搜索\n\n> 📦 [GitHub](https://github.com/KANIKIG/wechat-search-weread) · [ClawHub](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜搜索微信公众号文章，返回完整数据（含 `mp.weixin.qq.com` 文章链接）。\n\n## 触发条件\n\n用户需要搜索微信公众号文章，且 Exa 和搜狗方案不满足需求时使用。\n\n> ⚠️ 微信读书官方 API (`/store/search` scope=2/4) 不支持公众号文章搜索（返回 `-2041`），详见 [api-limitations.md](references/api-limitations.md)。本浏览器方案是当前唯一可行途径。\n\n## 前提\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) 已安装\n- 微信读书已登录（首次需扫码）\n- WSL 用户需 CDP 端口转发（见 [wsl-cdp-browser.md](references/wsl-cdp-browser.md)），用 `agent-browser connect \"http://<IP>:9223\"` 建连后无需 `--cdp` flag\n\n## 流程概要（5 步，缺一不可）\n\n| 步骤 | 要点 |\n|------|------|\n| **1. 启动浏览器** | 非WSL自动；WSL 参考 [wsl-cdp-browser.md](references/wsl-cdp-browser.md) |\n| **2. 登录** | QR 扫码。首次 eval 触发的 QR 大概率过期 → 必须关闭弹窗→重新触发→提取（单 terminal 调用一气呵成）。**登录成功后删除 `/tmp/weread_qr.png`** |\n| **3. 搜索+滚动** | 先备份 weread 标签页（保持登录态）→ goto 搜索页 → eval 滚动到「暂无更多内容」（封顶500条） |\n| **4. 提取URL** | execute_code 中 Python + websockets 连 page CDP → 批量点击 → 拦截 window.open → 写入 `/tmp/urls.json` |\n| **5. 清理** | 回到微信读书首页 → 关闭搜索页、mp 链接等所有多余标签页，只保留一个 weread 页面 |\n\n> 🔴 **全部详细命令、代码块、陷阱表见 [references/detailed-workflow.md](references/detailed-workflow.md)**。\n\n## 原理简述\n\n- **搜索 API**: 页面 XHR → `weread.qq.com/web/wx_search_broker_proxy`（需 session cookie）\n- **URL 获取**: DOM 无 href，点击 `.search_list_item` → Vue → `window.open(mp.weixin.qq.com/...)` → eval 拦截\n- **滚动加载**: 每次 scrollTo 触发 XHR 加载 ~45 篇新文章\n- **去重**: 每篇文章有唯一 `data-id`\n\n## 结果投递\n\n搜索完成后，**直接以最终回复输出结果**，系统会自动投递到当前对话。\n\n输出格式（Step 4.4）：搜到 X 篇 → 提取 Y 篇 → Z 篇有链接（成功率 N%）+ Top 10 列表，完整数据存 `/tmp/urls.json`。\n\n## References\n\n- `references/detailed-workflow.md` — **完整操作手册**：Step 1-5 全部命令、QR 流程、滚动策略、URL 提取代码\n- `references/extraction-pattern.md` — Python 提取代码模板（execute_code 中直接执行）\n- `references/cdp-patterns.md` — CDP 交互模式参考\n- `references/wsl-cdp-browser.md` — WSL 配置指南\n- `references/api-limitations.md` — 微信读书 API 限制说明\n- `references/llm-filter-pattern.md` — LLM 语义筛选模式：搜索噪音过滤（替代关键词黑名单）\n- `scripts/search_weread.py` — 备选/调试脚本（非主流程）\n\nFile v1.0.1:README.md\n\n# wechat-search-weread\n\n[![GitHub](https://img.shields.io/badge/GitHub-KANIKIG%2Fwechat--search--weread-blue?logo=github)](https://github.com/KANIKIG/wechat-search-weread)\n[![ClawHub](https://img.shields.io/badge/ClawHub-wechat--search--weread-orange)](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜，搜索微信公众号文章。返回**标题、公众号、发布时间、简介**以及 **`mp.weixin.qq.com` 直链**。\n\n> ⚠️ **本项目只负责搜索和获取文章链接，不包含抓取文章正文内容。**\n\n## 和其他方案比\n\n| 方案 | 文章时效 | 有发布日期 | 有直链 | 需要 |\n|------|---------|-----------|--------|------|\n| **微信读书搜索（本项目）** | ✅ 最新 | ✅ | ✅ | 微信扫码 |\n| Exa (mcporter) | ❌ | ❌ | ✅ | API Key |\n| 搜狗微信搜索 | ❌ ~2021 | ✅ | ✅ | 无 |\n\n**唯一能同时拿到「最新文章 + 发布日期 + 直链」的方案。**\n\n## 使用方式\n\n通过 Agent 对话触发。在聊天窗口告诉 Agent 你要搜什么即可：\n\n> \"搜一下最近关于大模型的公众号文章\"\n> \"微信搜索 OpenAI\"\n\nAgent 会自动调用本 Skill 完成搜索。\n\n**如果微信读书还没登录**，Agent 会把登录二维码发到聊天窗口，微信扫码即可。登录后继续搜索，全程自动。\n\n搜索完成后，你会收到：\n\n```\n关键词 公众号文章搜索\n\n搜到 258 篇 → 提取 258 篇 → 252 篇有链接（成功率 98%）\n\n🔥 Top 10：\n\n1. [文章标题](http://mp.weixin.qq.com/s/...) — 2小时前\n2. [文章标题](http://mp.weixin.qq.com/s/...) — 昨天\n...\n```\n\n默认展示前 10 篇，完整数据保存在 `/tmp/urls.json`。\n\n## 原理简述\n\n微信读书搜索结果页面本身不暴露文章链接。点击文章卡片时页面通过 `window.open` 打开目标文章。本项目通过浏览器自动化拦截这个操作，批量获取所有文章的真实 `mp.weixin.qq.com` 链接。\n\n## 依赖\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) — 浏览器自动化控制\n\n## 声明\n\n- 本项目**仅搜索公众号文章并获取链接**，不抓取正文\n- 需要微信扫码登录，session 过期后需重新扫码\n\nFile v1.0.1:_meta.json\n\n{\n  \"ownerId\": \"kn7ay2vk3fst9dazwsrj83d4kn86ts13\",\n  \"slug\": \"wechat-search-weread\",\n  \"version\": \"1.0.1\",\n  \"publishedAt\": 1779731870752\n}\n\nFile v1.0.1:references/api-limitations.md\n\n# 微信读书官方 Agent API 限制\n\n## 背景\n\n微信读书提供了官方 Agent API（`POST https://i.weread.qq.com/api/agent/gateway`，需 API Key），声称通过 `/store/search` 的 `scope` 参数支持搜索公众号（scope=2）和文章（scope=4）。\n\n## 实测结论\n\n| scope | 名称 | 状态 | 说明 |\n|-------|------|------|------|\n| 0 | 全部 | ✅ | 搜书（电子书/有声书/书单等） |\n| 10 | 电子书 | ✅ | 正常 |\n| 12 | 全文 | ✅ | 搜书内正文，返回 `currentCount=0` |\n| 2 | 公众号 | ❌ `-2041` | 不可用 |\n| 4 | 文章 | ❌ `-2041` | 不可用 |\n\n**公众号文章搜索（scope=2/4）在后端未对 Agent API Key 开放。** 浏览器方案（weread.qq.com 搜一搜 + CDP 提取）是当前唯一的公众号文章搜索途径。\n\n## 测试时间\n\n2026-05-17，API Key `wrk-FGVctlbTTPaWm7gmFFAi7gAA`，skill_version 1.0.3。\n\nFile v1.0.1:references/cdp-patterns.md\n\n# CDP Interaction Patterns for WeChat Read Search\n\n## CDP Endpoint Levels\n\n### Browser level (`/json/version`)\n- Good for: `Browser.getVersion`, getting initial WS URL for agent-browser\n- **Cannot** receive: `Network.*`, `Page.windowOpen`, `Page.frameNavigated` events\n- URL: `curl -s http://<WINDOWS_IP>:9223/json/version | jq -r .webSocketDebuggerUrl`\n\n### Page level (`/json` list → page targets)\n- **Required** for: all page events, Network interception, Runtime.evaluate\n- URL: `curl -s http://<WINDOWS_IP>:9223/json | jq -r '.[] | select(.type==\"page\" and (.url | contains(\"search.weixin\"))) | .webSocketDebuggerUrl'`\n\n## Key CDP Commands\n\n### Enable domains (MUST do first)\n```json\n{\"id\": 1, \"method\": \"Page.enable\"}\n{\"id\": 2, \"method\": \"Network.enable\"}\n```\n\n### Mouse click (for triggering navigation)\nWeChat search page requires real mouse events for navigation. JS `element.click()` won't work.\n\n```json\n// Get coordinates\n{\"id\": 3, \"method\": \"Runtime.evaluate\", \"params\": {\n    \"expression\": \"document.querySelector('.search_list_item .article__title-text').getBoundingClientRect()\"\n}}\n// Response: {x, y, width, height}\n\n// Click\n{\"id\": 4, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mousePressed\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n{\"id\": 5, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mouseReleased\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n```\n\n### Catch article URL\nAfter click, listen for:\n```json\n{\"method\": \"Page.windowOpen\", \"params\": {\"url\": \"https://mp.weixin.qq.com/s?__biz=...&mid=...&idx=...&sn=...\"}}\n```\n\n## Common Errors\n\n### \"Frame with the given frameId is not found\"\n- Happens when clicking iframe elements via agent-browser before iframe fully renders\n- Fix: wait 3-6 seconds and retry\n\n### \"Debugger agent is not enabled\"\n- Happens when calling Debugger.* methods without `Debugger.enable`\n- Usually unnecessary; just use Runtime.evaluate instead\n\n### TimeoutError on ws.recv()\n- CDP sends events asynchronously; not all messages are responses to your commands\n- Use `asyncio.wait_for(ws.recv(), timeout=N)` in a loop\n- Filter by checking `data.get('method')` for the event you want\n\nFile v1.0.1:references/detailed-workflow.md\n\n# 完整操作流程\n\n> 主 SKILL.md 的扩展，包含每个 Step 的完整命令和代码。\n\n## 前提\n\n- agent-browser 已安装并连接（WSL: `agent-browser connect \"http://<IP>:9223\"`）\n- 微信读书已登录\n\n> ⚠️ **黄金法则**：每一步操作后都等 6-10 秒！页面元素需要时间加载。\n> ⚠️ **长滚动（>3 轮）必须用前台 terminal + 长 timeout（600s）**，不要用 background 进程。\n\n---\n\n## Step 1: 启动浏览器\n\n**非WSL**：跳过，agent-browser 自动管理浏览器。\n\n**WSL 用户**：参考 [wsl-cdp-browser.md](wsl-cdp-browser.md) 启动 Edge CDP 并配置端口转发。\n\n验证连接：`agent-browser get cdp-url`（非WSL）或 `curl http://<IP>:9223/json/version`（WSL）\n\n---\n\n## Step 2: 登录微信读书（统一用扫码！）\n\n### 2.0 清除旧登录态（可选，cookie 残留时）\n\n微信读书的认证 cookie 是 httpOnly 的，需用 CDP `Storage.clearCookies`（**page 级别** WebSocket）：\n\n```python\nimport asyncio, json, subprocess, websockets\n\n# 获取 CDP browser WebSocket URL\n# 非WSL: result = subprocess.run(['agent-browser', 'get', 'cdp-url'], capture_output=True, text=True)\n# WSL:   result = subprocess.run(['curl', '-s', 'http://WINDOWS_IP:9223/json/version'], capture_output=True, text=True)\n#         version['webSocketDebuggerUrl']\n\n# 获取 weread 页面的 page-level WS URL\nresult = subprocess.run(['curl', '-s', '<browser_ws_url替换为/json>'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_ws = None\nfor t in targets:\n    if t['type'] == 'page' and 'weread' in t.get('url', ''):\n        weread_ws = t['webSocketDebuggerUrl']\n        break\n\nasync def clear():\n    async with websockets.connect(weread_ws, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id': 1, 'method': 'Storage.clearCookies', 'params': {}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=5)\n        print(resp)\n\nasyncio.run(clear())\n```\n\n> `Network.clearBrowserCookies` 在此 Edge 版本返回 `-32601`，只用 `Storage.clearCookies`。\n\n清除后 reload 页面确认\"登录\"链接出现。\n\n### 2.1 导航并点击登录\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 8\n```\n\n触发登录弹窗（优先用 eval）：\n\n```bash\n# ✅ 方法1（推荐）：eval 触发\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# ⚠️ 方法2（备选）：agent-browser click — 经常无效\n# agent-browser snapshot | grep -i 登录\n# agent-browser click <登录的ref>\n# sleep 6\n```\n\n### 2.2 QR 码提取（默认「刷新后提取」）\n\n> 首次 eval 触发的 QR 经常是旧缓存的。必须：关闭弹窗 → 重新触发 → 提取。\n\n**必须用一个 terminal() 调用跑完 Step A-E！** 不要拆分。\n\n```bash\n# Step A: eval 触发第一次登录（仅用于唤起弹窗，5s 足够）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 5\n\n# Step B: 关闭弹窗（丢弃旧 QR）\nagent-browser eval \"var m=document.querySelector('.mask,.dialog_mask,.wr_overlay');if(m)m.click();'closed'\"\nsleep 2\n\n# Step C: 重新触发登录（拿到新鲜 QR，6s 即可）\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n\n# Step D: 一键提取 QR（移除 iframe + 提取 src）\nagent-browser eval \"\n(function() {\n  const iframes = document.querySelectorAll('iframe');\n  iframes.forEach(f => f.remove());\n  const imgs = document.querySelectorAll('img');\n  for (const img of imgs) {\n    if (img.width > 100 && img.height > 100) return img.src;\n  }\n  return '';\n})()\" > /tmp/qr_url.txt\n\n# Step E: 解析并下载 QR → 立即发码给用户\npython3 -c \"\nimport json, base64, os\nwith open('/tmp/qr_url.txt') as f:\n    raw = f.read().strip()\nsrc = json.loads(raw)\nif not src:\n    print('ERROR: no QR image found')\n    exit(1)\nif src.startswith('data:image'):\n    b64 = src.split(',', 1)[1]\n    b64 += '=' * (4 - len(b64) % 4) if len(b64) % 4 else ''\n    with open('/tmp/weread_qr.png', 'wb') as f:\n        f.write(base64.b64decode(b64))\nelse:\n    import subprocess; subprocess.run(['curl', '-s', '-o', '/tmp/weread_qr.png', src])\nprint(f'QR OK: {os.path.getsize(\\\"/tmp/weread_qr.png\\\")} bytes')\n\"\n# 整个 block 结束后立刻在回复里发 MEDIA:/tmp/weread_qr.png\n```\n\n**如果 QR 仍失效（极少情况）：** 重复 Step B-D。如果登录弹窗完全消失，重新 goto + 触发：\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser eval \"document.querySelectorAll('a').forEach(a => { if (a.textContent.includes('登录')) a.click(); })\"\nsleep 6\n# 再次一键提取\n```\n\n**验证登录：**\n\n```bash\nagent-browser goto \"https://weread.qq.com/\"\nsleep 6\nagent-browser snapshot | grep -i \"我的书架\\|继续阅读\"\n```\n\n**登录成功后清理临时文件：**\n\n```bash\nrm -f /tmp/weread_qr.png /tmp/qr_url.txt\n```\n\n---\n\n## Step 3: 搜索 + 大批量滚动加载\n\n> 🔴 **铁律：weread.qq.com 标签页绝对不能关！绝对不能离开！**\n> 🔴 **滚动必须用 agent-browser 的 eval 执行，Python page WS 的 Runtime.evaluate 不会触发 XHR。**\n\n### 3.1 导航到搜索页 + 保持 weread session\n\n```bash\n# A. 先保留登录会话（在导航走之前创建 weread 备份标签页）\n#    获取 browser WS URL（非WSL: agent-browser get cdp-url，WSL: curl http://IP:9223/json/version）\npython3 -c \"\nimport asyncio, json, subprocess, websockets\nWINDOWS_IP = subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout.split('default via ')[1].split()[0] if 'default via ' in subprocess.run(['ip', 'route'], capture_output=True, text=True).stdout else '172.19.80.1'\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:9223/json/version'], capture_output=True, text=True)\nbw = json.loads(result.stdout)['webSocketDebuggerUrl']\n\nasync def create():\n    async with websockets.connect(bw, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id':1,'method':'Target.createTarget','params':{'url':'https://weread.qq.com/'}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=10)\n        print(resp)\nasyncio.run(create())\n\"\nsleep 5\n\n# B. 导航到搜索页（原标签页变为搜索页，备份的 weread 标签页保持登录态）\nagent-browser goto \\\n  \"https://search.weixin.qq.com/cgi-bin/newsearchweb/userclientjump?path=page/search/weread&query=URL编码关键词&platform=pc\"\nsleep 8\nagent-browser eval \"document.querySelectorAll('.search_list_item').length\"\n# → 初始 15 篇\n```\n\n### 3.2 滚动加载（默认滚到「暂无更多内容」，500 条封顶）\n\n```bash\n# 每轮 = 3 次 scrollTo + 2s 间隔 + 3s 渲染等待。每轮新增 ~45 篇。\nPREV=\"\"; PREV2=\"\"\nfor r in $(seq 1 20); do\n  for j in 1 2 3; do\n    agent-browser eval \"window.scrollTo(0, document.body.scrollHeight)\" > /dev/null 2>&1\n    sleep 2\n  done\n  sleep 3\n  C=$(agent-browser eval \"document.querySelectorAll('.search_list_item').length\" 2>/dev/null)\n  echo \"R$r: $C\"\n  # 超过 500 封顶\n  if [ \"$C\" -gt 500 ] 2>/dev/null; then\n    echo \"HIT 500 cap, stopping\"\n    break\n  fi\n  # 检查\"暂无更多内容\"\n  NOMORE=$(agent-browser eval \"document.body.textContent.includes('暂无更多内容')\" 2>/dev/null)\n  if [ \"$NOMORE\" = \"true\" ]; then\n    echo \"HIT 暂无更多内容 at $C, stopping\"\n    break\n  fi\n  # 连续 3 轮不变（兜底）\n  if [ \"$C\" = \"$PREV\" ] && [ \"$C\" = \"$PREV2\" ]; then\n    echo \"PLATEAU at $C, stopping\"\n    break\n  fi\n  PREV2=$PREV; PREV=$C\ndone\n```\n\n> 典型增长曲线：R1→60, R2→105, R3→150, R4→195, R5→240, R6→258（然后持平）。\n\n如果两轮后数量不变（始终 15 篇）：① weread session 丢失 ② 用了 Python page WS 做滚动。\n\n---\n\n## Step 4: 提取文章数据 + URL\n\n### 4.1 批量采集文章元数据\n\n```bash\nagent-browser eval \"\n(() => {\n    const arts = [];\n    document.querySelectorAll('.search_list_item').forEach((item, i) => {\n        const t = item.querySelector('.article__title-text');\n        const d = item.querySelector('.article__desc');\n        const s = item.querySelector('.source__title');\n        const dt = item.querySelector('.source__text.date');\n        arts.push({\n            idx: i,\n            title: (t?.textContent || '').trim(),\n            desc: (d?.textContent || '').trim().substring(0,200),\n            source: (s?.textContent || '').trim(),\n            date: (dt?.textContent || '').trim(),\n            dataId: item.getAttribute('data-id') || ''\n        });\n    });\n    return JSON.stringify(arts);\n})()\" > /tmp/arts.json\n```\n\n> `agent-browser eval` 返回的字符串被双重引号包裹（`\"[...]\"`），解析需 `json.loads(json.loads(raw))`。\n\n### 4.2 筛选目标文章\n\n用 Python 解析日期，筛选近一周（或用户指定的时间范围）：\n\n```python\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime。支持：X年前/个月前/周前/天前/小时前/分钟前、YYYY-MM-DD、YYYY/M/D、M月D日、M/D、M-D\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 365) if m else None\n    if '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1)) * 30) if m else None\n    if '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(weeks=int(m.group(1))) if m else None\n    if '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(days=int(m.group(1))) if m else None\n    if '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(hours=int(m.group(1))) if m else None\n    if '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        return now - timedelta(minutes=int(m.group(1))) if m else None\n    if re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    if re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    if re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    if re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n```\n\n### 4.3 获取文章 URL（execute_code 中执行）\n\n用 execute_code + Python + websockets，连搜索页 page-level CDP WebSocket，一次 Runtime.evaluate 批量完成。\n\n获取 CDP WebSocket URL：\n- 非WSL：`agent-browser get cdp-url` 得浏览器级 WS → 访问 `/json` 获取 page targets\n- WSL：`curl -s \"http://$WINDOWS_IP:9223/json\"` 获取 page targets\n\n核心 JS（async + await sleep 防竞态）：\n\n```js\n(async function() {\n    var sleep = ms => new Promise(r => setTimeout(r, ms));\n    var items = document.querySelectorAll('.search_list_item');\n    var orig = window.open;\n    for (var i = 0; i < targets.length; i++) {\n        var captured = '';\n        window.open = function(u) { captured = u; };\n        items[targets[i]].scrollIntoView({block: 'center', behavior: 'instant'});\n        items[targets[i]].click();\n        await sleep(50);\n        results.push({idx: targets[i], url: captured});\n    }\n    window.open = orig;\n    return JSON.stringify(results);\n})()\n```\n\n关键点：\n- 点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`\n- 拦截 `window.open` 返回 null，不弹新标签页\n- 必须 async function + `await sleep(50)` + `awaitPromise: true`\n\n完整提取模板见 [extraction-pattern.md](extraction-pattern.md)。\n\n### 4.4 输出格式\n\n**严禁从 execute_code 控制台输出复制 URL。** 强制从 `/tmp/urls.json` 读取：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\ntotal = len(data)\nsucc = sum(1 for r in data if r['url'])\nprint(f'**公众号文章搜索结果**')\nprint()\nprint(f'搜到 {total} 篇 → 提取 {total} 篇 → {succ} 篇有链接（成功率 {100*succ//max(total,1)}%）')\nprint()\nprint('🔥 Top 10：')\nprint()\nfor i, r in enumerate(data[:10]):\n    if r['url']:\n        print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r['date']}\\\")\n\"\n```\n\n回复用户时只发摘要，完整数据存 `/tmp/urls.json`。\n\n---\n\n## Step 5: 完成后的清理\n\n**不关浏览器！** 回到微信读书登录页，关闭搜索页等所有多余标签页，只保留一个 weread 页面。\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# 先回到微信读书首页（agent-browser 标签页变为 weread）\nagent-browser goto \"https://weread.qq.com/\"\nsleep 5\n\n# 关闭其他所有标签页（搜索页 + mp.weixin.qq.com + 备份 weread），只保留当前 weread\npython3 -c \"\nimport json, subprocess\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\n# 第一轮：关掉所有非 weread 页面\nfor t in targets:\n    if t['type'] == 'page' and 'weread.qq.com' not in t.get('url', ''):\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed: {t[\\\"url\\\"][:80]}')\n# 第二轮：如果还有多个 weread 页面，把多余的也关了\nresult = subprocess.run(['curl', '-s', 'http://$WINDOWS_IP:9223/json'], capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_tabs = [t for t in targets if t['type'] == 'page' and 'weread.qq.com' in t.get('url', '')]\nif len(weread_tabs) > 1:\n    for t in weread_tabs[1:]:  # 保留第一个，关掉其余的\n        subprocess.run(['curl', '-s', '-o', '/dev/null', f'http://$WINDOWS_IP:9223/json/close/{t[\\\"id\\\"]}'])\n        print(f'Closed duplicate weread: {t[\\\"id\\\"]}')\nprint('Done — only weread login page remains')\n\"\n```\n\n---\n\n## 完整陷阱表\n\n| 陷阱 | 解决方案 |\n|------|---------|\n| `agent-browser --cdp <WS_URL>` 返回 404（WSL） | 用 `agent-browser connect \"http://<IP>:9223\"` 建立连接 |\n| agent-browser click 登录按钮不弹窗 | 用 eval 触发 |\n| iframe \"微信快捷登录\" 遮挡二维码 | eval 中移除 iframe |\n| iframe 内按钮点击无效（跨域） | 直接移除 iframe |\n| agent-browser eval 输出被双重引号包裹 | `json.loads(json.loads(raw))`（仅 JSON；简单字符串只用一层） |\n| 链接获取效率低 | 用 `batch_extract_urls()` 一次 eval 完成全部提取 |\n| 滚动永远卡在 15 篇（Python page WS） | 只有 agent-browser eval 能触发 XHR |\n| 滚动永远卡在 15 篇（session 丢失） | 保持 weread.qq.com 标签页活跃 |\n| 二维码过期 | 全流程单 terminal 调用，关闭→重新触发→提取 |\n| QR 流程拆成多个 terminal 调用 | ❌ 大忌 |\n| QR 提取后做验证浪费有效期 | ❌ 提取成功后立刻发 MEDIA |\n| 二维码 src 是 data: URI 非 HTTP URL | Python base64 解码，补 padding |\n| httpOnly cookie 清不掉 | CDP `Storage.clearCookies` |\n| `Network.clearBrowserCookies` 返回 -32601 | 用 `Storage.clearCookies` |\n| 搜一搜页面卡「加载中...」 | 清 cookie → 重启浏览器 → 重新扫码 |\n| 滚动中途停滞（非 15 篇） | 查「暂无更多内容」= 真上限；3 轮不变 = plateau 兜底 |\n| 长滚动不要用 background 进程 | 前台 terminal + 长 timeout（600s） |\n| 批量提取 URL 部分错位（~40%） | async + `await sleep(50)` + `awaitPromise: true` |\n| 部分链接点进去「参数错误」 | ✅ 已修复：从 `/tmp/urls.json` 读完整 URL |\n| execute_code 中搜索页 page WS 匹配失败 | 用 `'search.weixin'`（点号，不是斜杠）匹配 URL |\n| parse_date 不识别「X个月前」「X年前」「X月X日」「2025/3/24」等格式 | 使用 extraction-pattern.md 中的完整 parse_date（已覆盖所有已知格式） |\n\nFile v1.0.1:references/extraction-pattern.md\n\n# 微信读书搜索 - 完整提取代码模板\n\n## 工作流分界\n\n- **Step 1-3（浏览器+登录+搜索+滚动）**：用 `agent-browser --cdp` terminal 命令。必须 agent-browser 执行滚动（Python page WS 的 `window.scrollTo` 不触发 XHR）。\n- **Step 4（元数据采集 + URL 批量提取）**：用 `execute_code` 中的 Python + websockets 连搜索页的 **page-level CDP WebSocket**。一次 `Runtime.evaluate` 批量完成全部 URL 提取。\n\n## 快速参考\n\n适用的任务：连接 CDP → 采集元数据 → 筛选 → 获取 URL。在 `execute_code` 中执行。\n\n## 完整模板\n\n```python\nimport asyncio, json, subprocess, sys, re\nfrom datetime import datetime, timedelta\n\nWINDOWS_IP = \"172.19.80.1\"\nCDP_PORT = 9223\n\n# ── 1. 获取搜索页面 WebSocket URL ──\nresult = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nws_url = None\nfor t in targets:\n    if t['type'] == 'page' and 'search.weixin' in t.get('url', ''):\n        ws_url = t['webSocketDebuggerUrl']\n        break\nif not ws_url:\n    raise RuntimeError(\"Search page not found in CDP targets\")\n\nimport websockets\n\n# ── 2. 辅助函数 ──\n\nasync def extract_articles(ws):\n    \"\"\"提取当前 DOM 中所有文章元数据\"\"\"\n    js = \"\"\"\n    (function() {\n        const arts = [];\n        document.querySelectorAll('.search_list_item').forEach((item, i) => {\n            const t = item.querySelector('.article__title-text');\n            const d = item.querySelector('.article__desc');\n            const s = item.querySelector('.source__title');\n            const dt = item.querySelector('.source__text.date');\n            arts.push({\n                idx: i,\n                title: (t?.textContent || '').trim(),\n                desc: (d?.textContent || '').trim().substring(0,200),\n                source: (s?.textContent || '').trim(),\n                date: (dt?.textContent || '').trim(),\n                dataId: item.getAttribute('data-id') || ''\n            });\n        });\n        return arts;\n    })()\n    \"\"\"\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=5)\n    return json.loads(msg).get('result', {}).get('result', {}).get('value', [])\n\n\nasync def click_get_url(ws, dom_idx):\n    \"\"\"\n    已废弃。改用 batch_extract_urls() 一次 eval 批量完成所有提取。\n    \"\"\"\n    pass\n\n\nasync def batch_extract_urls(ws, target_indices):\n    \"\"\"\n    一次 eval 批量提取所有目标文章的 URL。\n    关键：点击 .search_list_item（整个卡片），不是 .article__title-text。\n    拦截 window.open（return null 防跳转），每次 click 后 await sleep(50ms) 等 Vue 异步处理。\n    N 篇约 N×50ms。\n    \"\"\"\n    idx_list = json.dumps(target_indices)\n    js = (\n        \"(async function() {\\n\"\n        \"    var sleep = function(ms) { return new Promise(function(r) { setTimeout(r, ms); }); };\\n\"\n        \"    var targets = \" + idx_list + \";\\n\"\n        \"    var allItems = document.querySelectorAll('.search_list_item');\\n\"\n        \"    var results = [];\\n\"\n        \"    var origOpen = window.open;\\n\"\n        \"    for (var i = 0; i < targets.length; i++) {\\n\"\n        \"        var idx = targets[i];\\n\"\n        \"        var el = allItems[idx];\\n\"\n        \"        if (!el) { results.push({idx: idx, url: ''}); continue; }\\n\"\n        \"        var captured = '';\\n\"\n        \"        window.open = function(u) { captured = u; return null; };\\n\"\n        \"        el.scrollIntoView({block: 'center', behavior: 'instant'});\\n\"\n        \"        el.click();\\n\"\n        \"        await sleep(50);\\n\"\n        \"        results.push({idx: idx, url: captured});\\n\"\n        \"    }\\n\"\n        \"    window.open = origOpen;\\n\"\n        \"    return JSON.stringify(results);\\n\"\n        \"})()\"\n    )\n    await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate',\n                               'params': {'expression': js, 'returnByValue': True, 'awaitPromise': True}}))\n    msg = await asyncio.wait_for(ws.recv(), timeout=60)\n    result = json.loads(msg).get('result', {}).get('result', {}).get('value', '[]')\n    if isinstance(result, str):\n        return json.loads(result)\n    return result\n\n\ndef close_mp_weixin_tabs():\n    \"\"\"关闭所有 mp.weixin.qq.com 标签页（保留搜索页面）\"\"\"\n    result = subprocess.run(['curl', '-s', f'http://{WINDOWS_IP}:{CDP_PORT}/json'],\n                           capture_output=True, text=True)\n    targets = json.loads(result.stdout)\n    closed = 0\n    for t in targets:\n        if t['type'] == 'page' and 'mp.weixin.qq.com' in t.get('url', ''):\n            subprocess.run(['curl', '-s', '-o', '/dev/null',\n                f'http://{WINDOWS_IP}:{CDP_PORT}/json/close/{t[\"id\"]}'])\n            closed += 1\n    return closed\n\n\ndef parse_date(date_str):\n    \"\"\"解析相对/绝对时间字符串 → datetime (返回 None 表示无法解析)\"\"\"\n    now = datetime.now()\n    if not date_str:\n        return None\n    # Relative: X年前, X个月前, X周前, X天前, X小时前, X分钟前\n    if '年前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            y = int(m.group(1))\n            return now - timedelta(days=y * 365)\n    elif '个月前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            months = int(m.group(1))\n            return now - timedelta(days=months * 30)\n    elif '周前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            w = int(m.group(1))\n            return now - timedelta(weeks=w)\n    elif '天前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            d = int(m.group(1))\n            return now - timedelta(days=d)\n    elif '小时前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            h = int(m.group(1))\n            return now - timedelta(hours=h)\n    elif '分钟前' in date_str:\n        m = re.search(r'(\\d+)', date_str)\n        if m:\n            mins = int(m.group(1))\n            return now - timedelta(minutes=mins)\n    # Absolute: YYYY-MM-DD, YYYY/M/D, YYYY/M/D, M月D日, M-D\n    elif re.match(r'\\d{4}-\\d{2}-\\d{2}', date_str):\n        return datetime.strptime(date_str[:10], '%Y-%m-%d')\n    elif re.match(r'\\d{4}/\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        return datetime(int(parts[0]), int(parts[1]), int(parts[2]))\n    elif re.match(r'\\d{1,2}/\\d{1,2}', date_str):\n        parts = date_str.split('/')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}月\\d{1,2}日', date_str):\n        m = re.match(r'(\\d{1,2})月(\\d{1,2})日', date_str)\n        if m:\n            month, day = int(m.group(1)), int(m.group(2))\n            dt = datetime(now.year, month, day)\n            return dt if dt <= now else datetime(now.year - 1, month, day)\n    elif re.match(r'\\d{1,2}-\\d{1,2}', date_str):\n        parts = date_str.split('-')\n        month, day = int(parts[0]), int(parts[1])\n        dt = datetime(now.year, month, day)\n        return dt if dt <= now else datetime(now.year - 1, month, day)\n    return None\n\n\n# ── 3. 主流程（提取全部文章）──\n\nasync def extract_all(ws):\n    \"\"\"\n    提取全部已加载文章（滚动完成后调用）。\n    先采集元数据，再批量提取 URL。150 篇 ~0.6s。\n    \"\"\"\n    # 3.1 采集元数据\n    all_data = await extract_articles(ws)\n    total = len(all_data)\n    print(f\"Articles: {total}\")\n    \n    # 3.2 批量提取全部 URL\n    all_indices = list(range(total))\n    urls = await batch_extract_urls(ws, all_indices)\n    \n    # 3.3 组装结果（含日期转换）\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for a in all_data:\n        raw_date = a.get('date', '')\n        parsed = parse_date(raw_date)\n        if parsed:\n            date_str = parsed.strftime('%Y-%m-%d')\n        else:\n            date_str = raw_date\n        results.append({\n            'idx': a['idx'],\n            'title': a['title'],\n            'source': a['source'],\n            'date': date_str,\n            'url': url_map.get(a['idx'], '')\n        })\n    \n    success = sum(1 for r in results if r['url'])\n    print(f\"URLs: {success}/{total} ({100*success//total}%)\")\n    return results\n\n\nasync def extract_filtered(ws, target_indices, all_articles_data):\n    \"\"\"仅提取指定索引的文章 URL（需提前加载元数据到 all_articles_data）\"\"\"\n    urls = await batch_extract_urls(ws, target_indices)\n    url_map = {u['idx']: u['url'] for u in urls}\n    results = []\n    for dom_idx in target_indices:\n        a = all_articles_data[dom_idx] if dom_idx < len(all_articles_data) else {}\n        results.append({\n            'idx': dom_idx,\n            'title': a.get('title', ''),\n            'source': a.get('source', ''),\n            'date': a.get('date', ''),\n            'url': url_map.get(dom_idx, '')\n        })\n    return results\n\n\n# 执行\narticles = asyncio.run(main(recent_indices, all_data))\nwith open('/tmp/urls.json', 'w') as f:\n    json.dump(articles, f, ensure_ascii=False, indent=2)\nprint(f\"Done: {sum(1 for a in articles if a['url'])}/{len(articles)} URLs\")\n```\n\n## 关键注意事项\n\n1. **滚动 vs 提取分离**：滚动（Step 3）用 `agent-browser --cdp` 的 terminal 命令；提取（Step 4）用本模板的 Python + page-level CDP WebSocket。**不要用 Python page WS 做滚动** — `Runtime.evaluate` 执行的 `window.scrollTo` 不会触发搜索页的无限滚动 XHR。只有 agent-browser 的 eval 能触发。实测：agent-browser 3轮滚动 → 60→105→150篇，Python page WS 始终卡在15篇。\n2. **批量 eval 提取（async + await sleep）**：一次 `Runtime.evaluate`（`awaitPromise: true`）完成所有 URL 提取。点击 `.search_list_item`（卡片 DIV），不是 `.article__title-text`。拦截 `window.open`（`return null` 防跳转）→ `.click()` 触发 Vue → `await sleep(50)` 等 Vue 异步 handler → 读 `captured`。N 篇约 N×50ms。**不加 await sleep 会导致 ~40% URL 错位**（`captured` 被下一轮覆写）。\n3. **无需关闭标签页**：`window.open` 被拦截，不真正打开新标签页。\n4. **URL 验证**：提取后用 CDP 浏览器 `goto` 抽查 3-5 条，确认页面加载了文章标题（而非空壳/参数错误）。不要用 curl 验证——curl 可能拿到空壳模板但 HTTP 200。也不要因部分链接「参数错误」反复调整 URL 编码——这是微信服务端行为，与提取无关。\n5. **元数据采集可选**：如果已通过 agent-browser 采集了 `/tmp/arts.json`，可跳过 `extract_articles`，直接用 `extract_filtered`。读取文件时注意 `agent-browser eval` 输出被双重引号包裹（`\"[{...}]\"` 而非 `[{...}]`），需嵌套解析：\n\n```python\nwith open('/tmp/arts.json', 'r') as f:\n    raw = f.read().strip()\n\n# Handle agent-browser eval double-quoting: parse repeatedly until we get a list\nall_data = raw\nfor _ in range(5):\n    try:\n        all_data = json.loads(all_data)\n    except (json.JSONDecodeError, TypeError):\n        break\n    if not isinstance(all_data, str):\n        break\n\nif isinstance(all_data, str):\n    raise RuntimeError(f\"Failed to parse /tmp/arts.json after 5 rounds\")\n```\n\n`all_data` 的结构与 `extract_articles()` 返回的一致，可直接喂给 `extract_filtered()` 或 `batch_extract_urls()`。\n6. **`Page.enable`**：保留但不再依赖 CDP 事件监听。\n7. **🚨 URL 防截断**：`extract_all` 后将 `results` 写入 `/tmp/urls.json`。回复用户时用 terminal 中 Python 脚本从 `/tmp/urls.json` 读取并 print Markdown Top 10，复制到回复。**严禁从 execute_code 控制台 print 输出中复制 URL**（可能截断缺参数）。\n\nFile v1.0.1:references/llm-filter-pattern.md\n\n# LLM 语义筛选模式\n\n适用于 cron 作业中需要对搜索结果做语义筛选的场景：搜索返回大量噪音 → LLM 逐条判断 → 仅保留真正相关的内容。\n\n## 为什么不用关键词\n\n关键词规则（黑名单/白名单）有两个致命缺陷：\n1. **今天能用，明天过时**：今天的噪音类型（电网拓扑、电源纹波）不一定是明天的噪音\n2. **无法理解语义**：同一词语在不同上下文中含义不同。比如\"拓扑优化\"在结构力学和网络科学中是两个完全不同领域\n\nLLM 判断能理解上下文语义，适应任何新出现的噪音类型。\n\n## 流程\n\n```\n搜索 → 提取全部文章 → 打印标题/摘要供 LLM 判断 → LLM 输出 KEEP 列表 → 程序筛选 → 后续处理\n```\n\n## 实现模板\n\n### Step A：打印全部候选项（仅标题，不看正文省 token）\n\n```python\nimport json\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfor i, art in enumerate(articles):\n    print(f\"[{i}] {art.get('title', '')}  |  {art.get('source', '')}  |  {art.get('date', '')}\")\n\nprint()\nprint(\"===== 以上是全部文章，请逐条判断 =====\")\nprint(\"请列出应保留的序号，格式: KEEP=[0,2,5,7,...]\")\n```\n\n### Step B：LLM 阅读并输出 KEEP 列表\n\nAgent 阅读所有标题+摘要，按领域知识逐条判断是否属于目标领域。在回复中输出 KEEP 列表。\n\n### Step C：执行筛选\n\n```python\nimport json\n\nkeep_indices = [0,2,5,7]  # 替换为 LLM 判断结果\n\nwith open('/tmp/urls.json') as f:\n    articles = json.load(f)\n\nfiltered = [articles[i] for i in keep_indices if i < len(articles)]\n\nwith open('/tmp/urls_filtered.json', 'w') as f:\n    json.dump(filtered, f, ensure_ascii=False, indent=2)\n\nprint(f\"筛选结果: {len(articles)} → {len(filtered)} 篇\")\n```\n\n## 判断标准编写要点\n\n在 cron prompt 中给出清晰的判断标准：\n- ✅ 什么是「属于」（正例特征）\n- ❌ 什么是「不属于」（反例特征+细分类型）\n- 🎯 核心区分问题（一句话帮 LLM 抓住本质）\n\n示例（拓扑优化领域）：\n```\n- ✅ 属于：讨论结构拓扑优化（SIMP/BESO/水平集/变密度法），工程应用，软件教程\n- ❌ 不属于：\n    - 网络拓扑优化、通用优化、数学拓扑学、广告招聘、景观艺术\n    - 卖课/课程推广/付费培训：标题含「课程」「专题课程」「一起学习」「训练营」等推销口吻，目标是卖课而非分享知识\n- 🎯 核心区分：「对结构做拓扑优化」还是「某系统/网络的拓扑结构优化」\n```\n\n### 卖课内容识别特征\n\n| 类型 | 特征词/句式 | 示例 |\n|------|-----------|------|\n| 课程推销 | 「XX课程」「专题课程」「带你发掘」「一起学习」 | 变密度法拓扑优化课程 / COMSOL专题课程 |\n| 培训广告 | 「训练营」「速成班」「从入门到精通」「限时优惠」 | XX软件7天训练营 |\n| 引流卖课 | 结尾带二维码/微信号、正文讲一半留一半 | 「扫码获取完整教程」 |\n\n> 💡 判断核心：文章目的是**分享知识/案例**还是**推销付费课程**？如果标题/内容有明显的课程推销属性，即使技术内容相关也要排除。\n\n## 两轮筛选（关键）\n\n当候选文章 > 100 篇时，**单轮筛选必然有漏网之鱼**。必须做两轮：\n\n### 第一轮：粗筛（排除明显噪音）\n\n在主干流程的 Step 4.5 完成。排除标准针对**明显的类别错误**（卖课、电源拓扑、光子学、招聘广告、视觉设计等）。排除目标是**高置信度错误**，宁可保留边界案例。\n\n### 第二轮：精筛（去重后审查新文章）\n\n第一轮粗筛后仍有漏网噪音——尤其是这些类型：\n- **行业报告**：标题不涉及 TO，正文只将\"拓扑优化\"列为众多趋势之一（如「汽车热管理电子风扇行业迎结构性变革」）\n- **综合概览**：AI/工具/行业 overview 文章，仅在列表中提一句 TO（如「AI生成CAD-BIM方法很多」「一个机械工程师眼中的人工智能」）\n- **非结构优化**：管道/电缆布线路由优化、物流路径优化（用\"拓扑\"指网络布局，非结构设计）\n- **无关领域**：水泥窑工艺、沸石转轮化工、液流电池 CFD（TO 非核心，仅是边缘工具）\n\n**第二轮流程**：\n1. 去重后得到\"新文章\"列表\n2. **先写入临时文件**（`/tmp/new_articles.json`），暂不更新持久化历史\n3. 逐条审查新文章的标题+摘要\n4. 如发现误保留文章，**从临时文件移除 + 不将其 dataId 加入 seen_ids**\n5. 审查通过后再更新持久化 JSON\n\n> 🔴 如果第二轮发现误保留文章且已写入持久化 JSON，必须**回退**：从 `articles` 数组末尾移除对应条目，从 `seen_ids` 中移除对应 dataId。\n\n## 注意事项\n\n- **仅根据标题+摘要判断**，不读正文，省 token\n- 候选项很多时（>100篇），打印会被截断。分两次 execute_code 调用：前半段 + 后半段\n- 筛选后写入新文件（如 `/tmp/urls_filtered.json`），不动原始数据\n- 后续去重/格式化从筛选后的文件取数据\n- **输出格式化必须用 execute_code 从 JSON 读 URL**，terminal 输出会截断长 URL\n\nFile v1.0.1:references/wsl-cdp-browser.md\n\n# WSL 环境下连接 Windows CDP 浏览器\n\n如果你是 WSL 用户，需要通过以下步骤让 agent-browser 连接到 Windows 上的 Edge 浏览器。\n\n## 为什么需要这个\n\nWSL2 有独立的网络命名空间，Windows 上的 Chrome/Edge CDP 默认绑定 `127.0.0.1`，WSL 无法直接访问。`--remote-debugging-address=0.0.0.0` 在 Chrome/Edge 上均无效。\n\n## 步骤\n\n### 1. 启动 Edge CDP 浏览器\n\n```bash\n# 先关掉已有的 Edge\n/mnt/c/Windows/System32/taskkill.exe /F /IM msedge.exe 2>/dev/null; sleep 2\n\n# 用独立 user-data-dir 启动（否则会被已有 Edge 实例拦截）\n\"/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=\"C:/Users/gdc3489/AppData/Local/Temp/edge_debug\" \\\n  --no-first-run --no-default-browser-check about:blank &\n\nsleep 5\n```\n\n### 2. 配置端口转发\n\n```bash\n# 端口转发：0.0.0.0:9223 → 127.0.0.1:9222\n/mnt/c/Windows/System32/netsh.exe interface portproxy add v4tov4 \\\n  listenport=9223 listenaddress=0.0.0.0 \\\n  connectport=9222 connectaddress=127.0.0.1\n\n# Windows 防火墙放行（必须！否则 WSL→Windows 流量被拦截）\n/mnt/c/Windows/System32/netsh.exe advfirewall firewall add rule \\\n  name=\"WSL CDP Proxy 9223\" dir=in action=allow protocol=tcp localport=9223\n```\n\n### 3. 获取连接地址并验证\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\ncurl -s \"http://${WINDOWS_IP}:9223/json/version\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('Browser','FAIL'))\"\n# 输出：Edge/xxx 即成功\n```\n\n### 4. 连接 agent-browser\n\n> ⚠️ `agent-browser --cdp <WS_URL>` 在 WSL+Edge CDP 环境下不可用（报 `Auto-launch failed: CDP WebSocket connect failed: HTTP error: 404 Not Found`）。**改用两步法：先 `connect`，再正常使用命令。**\n\n```bash\nWINDOWS_IP=$(ip route | grep default | awk '{print $3}')\n\n# Step 1: connect（建立会话）\nagent-browser connect \"http://${WINDOWS_IP}:9223\"\n\n# Step 2: 正常使用（无需 --cdp 前缀）\nagent-browser goto \"https://weread.qq.com/\"\nagent-browser snapshot\n```\n\n> 💡 `connect` 之后所有 agent-browser 命令都不需要 `--cdp` 前缀，会话级保持连接。如果连接断开（如浏览器重启），重新 `connect` 即可。\n\n## 注意事项\n\n- **必须用独立 `--user-data-dir`**，否则已有 Edge 实例会拦截启动，`--remote-debugging-port` 被丢弃\n- **用 `connect` 不用 `--cdp`**：`agent-browser --cdp <port>` 在当前版本（0.27.0）下对 WSL 端口代理返回 404。先用 `connect \"http://<IP>:9223\"`，之后命令不加任何 CDP flag\n- **用 `goto` 不用 `open`**：`agent-browser open` 会尝试 auto-launch 冲突，用 `goto`\n- **端口转发规则重启后持久**，只需运行一次\n- **Windows IP 会变**（DHCP），每次用 `ip route | grep default | awk '{print $3}'` 获取\n\nFile v1.0.1:skill-card.md\n\n## Description: <br>\nSearches WeChat public-account articles through WeRead search and returns article titles, public-account names, publish times, summaries, and direct links. <br>\n\nThis skill is ready for commercial/non-commercial use. <br>\n\n## Publisher: <br>\n[kanikig](https://clawhub.ai/user/kanikig) <br>\n\n### License/Terms of Use: <br>\nMIT-0 <br>\n\n\n## Use Case: <br>\nExternal users and agents use this skill to search recent WeChat public-account articles when general web search or Sogou-style search does not provide current articles, dates, and direct mp.weixin.qq.com links. <br>\n\n### Deployment Geography for Use: <br>\nGlobal <br>\n\n## Known Risks and Mitigations: <br>\nRisk: The skill can control an authenticated WeRead browser session and interact with session cookies, QR login material, and article pages. <br>\nMitigation: Use a dedicated browser profile, treat QR images and browser sessions as sensitive, and delete temporary result and QR files after use. <br>\nRisk: CDP setup guidance can expose browser control if the debug port is reachable by untrusted hosts. <br>\nMitigation: Bind CDP access locally where possible, avoid exposing CDP on 0.0.0.0, and remove firewall or portproxy rules when the task is complete. <br>\nRisk: Release evidence reports unsafe setup guidance and a likely exposed API key. <br>\nMitigation: Review setup steps before use and rotate or remove the exposed API key before broader \n\nArchive v0.1.7: 10 files, 24362 bytes\n\nFiles: README.md (2204b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (3357b), references/detailed-workflow.md (16646b), references/extraction-pattern.md (12011b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (4479b), _meta.json (139b)\n\nArchive v0.1.6: 10 files, 22921 bytes\n\nFiles: README.md (1926b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (2092b), references/detailed-workflow.md (14825b), references/extraction-pattern.md (10237b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (4479b), _meta.json (139b)\n\nArchive v0.1.5: 10 files, 22864 bytes\n\nFiles: README.md (1926b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (2092b), references/detailed-workflow.md (14825b), references/extraction-pattern.md (10237b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (4352b), _meta.json (139b)\n\nArchive v0.1.4: 10 files, 22869 bytes\n\nFiles: README.md (1926b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (2092b), references/detailed-workflow.md (14825b), references/extraction-pattern.md (10237b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (4359b), _meta.json (139b)\n\nArchive v0.1.3: 10 files, 22443 bytes\n\nFiles: README.md (1926b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (2089b), references/detailed-workflow.md (13801b), references/extraction-pattern.md (10026b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (4275b), _meta.json (139b)\n\nArchive v0.1.2: 9 files, 24261 bytes\n\nFiles: README.md (1926b), references/api-limitations.md (883b), references/cdp-patterns.md (2201b), references/cron-dedup-pattern.md (2089b), references/extraction-pattern.md (10026b), references/wsl-cdp-browser.md (2878b), scripts/search_weread.py (8575b), SKILL.md (24945b), _meta.json (139b)\n\nArchive v0.1.1: 7 files, 21389 bytes\n\nFiles: README.md (1926b), references/cdp-patterns.md (2201b), references/extraction-pattern.md (9308b), references/wsl-cdp-browser.md (2579b), scripts/search_weread.py (8575b), SKILL.md (24196b), _meta.json (139b)","readmeExcerpt":"Skill: Wechat Search Weread Owner: kanikig Summary: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Tags: latest:1.0.3, search:0.1.3, social-media:0.1.3, wechat:0.1.3, weread:0.1.3 Version history: v1.0.3 | 2026-06-09T05:07:15.794Z | user 去浏览器绑定：前提一处说明，后续步骤不指定浏览器；Scroll事件统一用dispatchEvent解决 v1.0.2 | 2026-06-09T01:05:35.700Z | user Fix Edge CDP scroll event: dispatchEvent('scroll') workaround for v149+ v1.0.1 | 2026-","codeSnippets":[],"executableExamples":[{"language":"bash","snippet":"python3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\nfor i, r in enumerate(data[:10]):\n    print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r.get('source','')} · {r.get('date','')}\\\")\n\""},{"language":"text","snippet":"关键词 公众号文章搜索\n\n搜到 258 篇 → 提取 258 篇 → 252 篇有链接（成功率 98%）\n\n🔥 Top 10：\n\n1. [文章标题](http://mp.weixin.qq.com/s/...) — 2小时前\n2. [文章标题](http://mp.weixin.qq.com/s/...) — 昨天\n..."},{"language":"json","snippet":"{\"id\": 1, \"method\": \"Page.enable\"}\n{\"id\": 2, \"method\": \"Network.enable\"}"},{"language":"json","snippet":"// Get coordinates\n{\"id\": 3, \"method\": \"Runtime.evaluate\", \"params\": {\n    \"expression\": \"document.querySelector('.search_list_item .article__title-text').getBoundingClientRect()\"\n}}\n// Response: {x, y, width, height}\n\n// Click\n{\"id\": 4, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mousePressed\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n{\"id\": 5, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mouseReleased\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}"},{"language":"json","snippet":"{\"method\": \"Page.windowOpen\", \"params\": {\"url\": \"https://mp.weixin.qq.com/s?__biz=...&mid=...&idx=...&sn=...\"}}"},{"language":"python","snippet":"import asyncio, json, subprocess, websockets\n\n# 获取 CDP browser WebSocket URL\n# 非WSL: result = subprocess.run(['agent-browser', 'get', 'cdp-url'], capture_output=True, text=True)\n# WSL:   result = subprocess.run(['curl', '-s', 'http://WINDOWS_IP:9223/json/version'], capture_output=True, text=True)\n#         version['webSocketDebuggerUrl']\n\n# 获取 weread 页面的 page-level WS URL\nresult = subprocess.run(['curl', '-s', '<browser_ws_url替换为/json>'],\n                       capture_output=True, text=True)\ntargets = json.loads(result.stdout)\nweread_ws = None\nfor t in targets:\n    if t['type'] == 'page' and 'weread' in t.get('url', ''):\n        weread_ws = t['webSocketDebuggerUrl']\n        break\n\nasync def clear():\n    async with websockets.connect(weread_ws, max_size=2**24) as ws:\n        await ws.send(json.dumps({'id': 1, 'method': 'Storage.clearCookies', 'params': {}}))\n        resp = await asyncio.wait_for(ws.recv(), timeout=5)\n        print(resp)\n\nasyncio.run(clear())"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: wechat-search-weread\ndescription: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。\ncategory: social-media\n---\n\n# 微信读书公众号文章搜索\n\n> 📦 [GitHub](https://github.com/KANIKIG/wechat-search-weread) · [ClawHub](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜搜索微信公众号文章，返回完整数据（含 `mp.weixin.qq.com` 文章链接）。\n\n## 触发条件\n\n用户需要搜索微信公众号文章，且 Exa 和搜狗方案不满足需求时使用。\n\n> ⚠️ 微信读书官方 API (`/store/search` scope=2/4) 不支持公众号文章搜索（返回 `-2041`），详见 [api-limitations.md](references/api-limitations.md)。本浏览器方案是当前唯一可行途径。\n\n## 前提\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) 已安装\n- 微信读书已登录（首次需扫码）\n- WSL 用户需 CDP 端口转发（见 [wsl-cdp-browser.md](references/wsl-cdp-browser.md)），用 `agent-browser connect \"http://<IP>:9223\"` 建连后无需 `--cdp` flag\n- **浏览器**：有 Edge 用 Edge，没有就 Chrome。CDP scroll 事件不触发时用 `window.dispatchEvent(new Event('scroll'))` 解决（见 [detailed-workflow.md](references/detailed-workflow.md) Step 3.2）。\n\n## 流程概要（5 步，缺一不可）\n\n| 步骤 | 要点 |\n|------|------|\n| **1. 启动浏览器** | 非WSL自动；WSL 参考 [wsl-cdp-browser.md](references/wsl-cdp-browser.md) |\n| **2. 登录** | QR 扫码。首次 eval 触发的 QR 大概率过期 → 必须关闭弹窗→重新触发→提取（单 terminal 调用一气呵成）。**登录成功后删除 `/tmp/weread_qr.png`** |\n| **3. 搜索+滚动** | 先备份 weread 标签页（保持登录态）→ goto 搜索页 → eval 滚动到「暂无更多内容」（封顶500条） |\n| **4. 提取URL** | ① ≤50 篇：agent-browser eval 同步提取（最简单）；② >50 篇：agent-browser eval 分批提取（每批 50，已验证 246 篇 100% 成功）；③ execute_code + websockets 备选（有超时风险）。详见 [extraction-pattern.md](references/extraction-pattern.md) |\n| **5. 清理** | 回到微信读书首页 → 关闭搜索页、mp 链接等所有多余标签页，只保留一个 weread 页面 |\n\n> 🔴 **全部详细命令、代码块、陷阱表见 [references/detailed-workflow.md](references/detailed-workflow.md)**。\n\n## 原理简述\n\n- **搜索 API**: 页面 XHR → `weread.qq.com/web/wx_search_broker_proxy`（需 session cookie）\n- **URL 获取**: DOM 无 href，点击 `.search_list_item` → Vue → `window.open(mp.weixin.qq.com/...)` → eval 拦截\n- **滚动加载**: 每次 scrollTo 触发 XHR 加载 ~45 篇新文章\n- **去重**: 每篇文章有唯一 `data-id`\n\n## 结果投递\n\n搜索完成后，**直接以最终回复输出结果**，系统会自动投递到当前对话。\n\n输出格式（Step 4.4）：搜到 X 篇 → 提取 Y 篇 → Z 篇有链接（成功率 N%）+ Top 10 列表，完整数据存 /tmp/urls.json。\n\n🔴 **链接必须从 `/tmp/urls.json` 读取，严禁从控制台输出或记忆中复制 URL**。控制台输出会截断长 URL（丢失 `chksm` 或 `#rd`），导致微信打开显示\"参数错误\"。正确做法：\n\n```bash\npython3 -c \"\nimport json\nwith open('/tmp/urls.json') as f:\n    data = json.load(f)\nfor i, r in enumerate(data[:10]):\n    print(f\\\"{i+1}. [{r['title']}]({r['url']}) — {r.get('source','')} · {r.get('date','')}\\\")\n\"\n```\n\n## References\n\n- `references/detailed-workflow.md` — **完整操作手册**：Step 1-5 全部命令、QR 流程、滚动策略、URL 提取代码\n- `references/extraction-pattern.md` — Python 提取代码模板（execute_code 中直接执行）\n- `references/cdp-patterns.md` — CDP 交互模式参考\n- `references/wsl-cdp-browser.md` — WSL 配置指南\n- `references/api-limitations.md` — 微信读书 API 限制说明\n- `references/llm-filter-pattern.md` — LLM 语义筛选模式：搜索噪音过滤（替代关键词黑名单）\n- `references/topology-optimization-filter.md` — 拓扑优化领域专用筛选模式：已验证的排除正则、陷阱、持久化格式\n- `references/edge-cdp-scroll-issue.md` — CDP scroll 事件不触发问题：诊断、验证方法、解决方案\n- `scripts/search_weread.py` — 备选/调试脚本（非主流程）"},{"path":"README.md","content":"# wechat-search-weread\n\n[![GitHub](https://img.shields.io/badge/GitHub-KANIKIG%2Fwechat--search--weread-blue?logo=github)](https://github.com/KANIKIG/wechat-search-weread)\n[![ClawHub](https://img.shields.io/badge/ClawHub-wechat--search--weread-orange)](https://clawhub.ai/skills/wechat-search-weread)\n\n通过微信读书搜一搜，搜索微信公众号文章。返回**标题、公众号、发布时间、简介**以及 **`mp.weixin.qq.com` 直链**。\n\n> ⚠️ **本项目只负责搜索和获取文章链接，不包含抓取文章正文内容。**\n\n## 和其他方案比\n\n| 方案 | 文章时效 | 有发布日期 | 有直链 | 需要 |\n|------|---------|-----------|--------|------|\n| **微信读书搜索（本项目）** | ✅ 最新 | ✅ | ✅ | 微信扫码 |\n| Exa (mcporter) | ❌ | ❌ | ✅ | API Key |\n| 搜狗微信搜索 | ❌ ~2021 | ✅ | ✅ | 无 |\n\n**唯一能同时拿到「最新文章 + 发布日期 + 直链」的方案。**\n\n## 使用方式\n\n通过 Agent 对话触发。在聊天窗口告诉 Agent 你要搜什么即可：\n\n> \"搜一下最近关于大模型的公众号文章\"\n> \"微信搜索 OpenAI\"\n\nAgent 会自动调用本 Skill 完成搜索。\n\n**如果微信读书还没登录**，Agent 会把登录二维码发到聊天窗口，微信扫码即可。登录后继续搜索，全程自动。\n\n搜索完成后，你会收到：\n\n```\n关键词 公众号文章搜索\n\n搜到 258 篇 → 提取 258 篇 → 252 篇有链接（成功率 98%）\n\n🔥 Top 10：\n\n1. [文章标题](http://mp.weixin.qq.com/s/...) — 2小时前\n2. [文章标题](http://mp.weixin.qq.com/s/...) — 昨天\n...\n```\n\n默认展示前 10 篇，完整数据保存在 `/tmp/urls.json`。\n\n## 原理简述\n\n微信读书搜索结果页面本身不暴露文章链接。点击文章卡片时页面通过 `window.open` 打开目标文章。本项目通过浏览器自动化拦截这个操作，批量获取所有文章的真实 `mp.weixin.qq.com` 链接。\n\n## 依赖\n\n- [agent-browser](https://github.com/vercel-labs/agent-browser) — 浏览器自动化控制\n\n## 声明\n\n- 本项目**仅搜索公众号文章并获取链接**，不抓取正文\n- 需要微信扫码登录，session 过期后需重新扫码"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn7ay2vk3fst9dazwsrj83d4kn86ts13\",\n  \"slug\": \"wechat-search-weread\",\n  \"version\": \"1.0.3\",\n  \"publishedAt\": 1780981635794\n}"},{"path":"references/api-limitations.md","content":"# 微信读书官方 Agent API 限制\n\n## 背景\n\n微信读书提供了官方 Agent API（`POST https://i.weread.qq.com/api/agent/gateway`，需 API Key），声称通过 `/store/search` 的 `scope` 参数支持搜索公众号（scope=2）和文章（scope=4）。\n\n## 实测结论\n\n| scope | 名称 | 状态 | 说明 |\n|-------|------|------|------|\n| 0 | 全部 | ✅ | 搜书（电子书/有声书/书单等） |\n| 10 | 电子书 | ✅ | 正常 |\n| 12 | 全文 | ✅ | 搜书内正文，返回 `currentCount=0` |\n| 2 | 公众号 | ❌ `-2041` | 不可用 |\n| 4 | 文章 | ❌ `-2041` | 不可用 |\n\n**公众号文章搜索（scope=2/4）在后端未对 Agent API Key 开放。** 浏览器方案（weread.qq.com 搜一搜 + CDP 提取）是当前唯一的公众号文章搜索途径。\n\n## 测试时间\n\n2026-05-17，API Key `wrk-FGVctlbTTPaWm7gmFFAi7gAA`，skill_version 1.0.3。"},{"path":"references/cdp-patterns.md","content":"# CDP Interaction Patterns for WeChat Read Search\n\n## CDP Endpoint Levels\n\n### Browser level (`/json/version`)\n- Good for: `Browser.getVersion`, getting initial WS URL for agent-browser\n- **Cannot** receive: `Network.*`, `Page.windowOpen`, `Page.frameNavigated` events\n- URL: `curl -s http://<WINDOWS_IP>:9223/json/version | jq -r .webSocketDebuggerUrl`\n\n### Page level (`/json` list → page targets)\n- **Required** for: all page events, Network interception, Runtime.evaluate\n- URL: `curl -s http://<WINDOWS_IP>:9223/json | jq -r '.[] | select(.type==\"page\" and (.url | contains(\"search.weixin\"))) | .webSocketDebuggerUrl'`\n\n## Key CDP Commands\n\n### Enable domains (MUST do first)\n```json\n{\"id\": 1, \"method\": \"Page.enable\"}\n{\"id\": 2, \"method\": \"Network.enable\"}\n```\n\n### Mouse click (for triggering navigation)\nWeChat search page requires real mouse events for navigation. JS `element.click()` won't work.\n\n```json\n// Get coordinates\n{\"id\": 3, \"method\": \"Runtime.evaluate\", \"params\": {\n    \"expression\": \"document.querySelector('.search_list_item .article__title-text').getBoundingClientRect()\"\n}}\n// Response: {x, y, width, height}\n\n// Click\n{\"id\": 4, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mousePressed\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n{\"id\": 5, \"method\": \"Input.dispatchMouseEvent\", \"params\": {\n    \"type\": \"mouseReleased\", \"x\": <cx>, \"y\": <cy>, \"button\": \"left\", \"clickCount\": 1\n}}\n```\n\n### Catch article URL\nAfter click, listen for:\n```json\n{\"method\": \"Page.windowOpen\", \"params\": {\"url\": \"https://mp.weixin.qq.com/s?__biz=...&mid=...&idx=...&sn=...\"}}\n```\n\n## Common Errors\n\n### \"Frame with the given frameId is not found\"\n- Happens when clicking iframe elements via agent-browser before iframe fully renders\n- Fix: wait 3-6 seconds and retry\n\n### \"Debugger agent is not enabled\"\n- Happens when calling Debugger.* methods without `Debugger.enable`\n- Usually unnecessary; just use Runtime.evaluate instead\n\n### TimeoutError on ws.recv()\n- CDP sends events asynchronously; not all messages are responses to your commands\n- Use `asyncio.wait_for(ws.recv(), timeout=N)` in a loop\n- Filter by checking `data.get('method')` for the event you want"}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":"微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Skill: Wechat Search Weread Owner: kanikig Summary: 微信公众号文章搜索skill。通过微信读书搜一搜获取最新文章，返回标题/公众号/时间/简介/直链。 Tags: latest:1.0.3, search:0.1.3, social-media:0.1.3, wechat:0.1.3, weread:0.1.3 Version history: v1.0.3 | 2026-06-09T05:07:15.794Z | user 去浏览器绑定：前提一处说明，后续步骤不指定浏览器；Scroll事件统一用dispatchEvent解决 v1.0.2 | 2026-06-09T01:05:35.700Z | user Fix Edge CDP scroll event: dispatchEvent('scroll') workaround for v149+ v1.0.1 | 2026-","editorialQuality":{"score":100,"threshold":65,"status":"ready","wordCount":934,"uniquenessScore":53,"reasons":[]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T04:32:48.258Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:46:39.078Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}