From 9b395f5cc31439f56d5d3b52d15941b8179a3092 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:39:48 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .github/ISSUE_TEMPLATE/bug_report.yml | 83 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 42 + .github/ISSUE_TEMPLATE/new_site_adapter.yml | 57 + .github/actions/setup-chrome/action.yml | 29 + .github/pull_request_template.md | 33 + .github/workflows/build-extension.yml | 66 + .github/workflows/ci.yml | 173 + .github/workflows/doc-check.yml | 36 + .github/workflows/docs.yml | 17 + .github/workflows/e2e-headed.yml | 109 + .github/workflows/release.yml | 75 + .github/workflows/security.yml | 33 + .gitignore | 27 + CHANGELOG.md | 973 + CONTRIBUTING.md | 197 + LICENSE | 190 + PRIVACY.md | 57 + README.md | 300 + README.wehub.md | 7 + README.zh-CN.md | 343 + TESTING.md | 252 + autoresearch/baseline-browse.txt | 1 + autoresearch/baseline-skill.txt | 1 + autoresearch/browse-tasks.json | 686 + autoresearch/commands/debug.ts | 163 + autoresearch/commands/fix.ts | 146 + autoresearch/commands/plan.ts | 88 + autoresearch/commands/run.ts | 138 + autoresearch/config.ts | 82 + autoresearch/engine.ts | 363 + autoresearch/eval-all.ts | 127 + autoresearch/eval-browse.ts | 185 + autoresearch/eval-publish.ts | 238 + autoresearch/eval-save.ts | 249 + autoresearch/eval-skill.ts | 254 + autoresearch/eval-v2ex.ts | 220 + autoresearch/eval-zhihu.ts | 230 + autoresearch/logger.ts | 69 + autoresearch/presets/browser-reliability.ts | 24 + autoresearch/presets/combined-reliability.ts | 27 + autoresearch/presets/index.ts | 23 + autoresearch/presets/save-reliability.ts | 26 + autoresearch/presets/skill-quality.ts | 20 + autoresearch/presets/v2ex-reliability.ts | 24 + autoresearch/presets/zhihu-reliability.ts | 25 + autoresearch/publish-tasks.json | 345 + autoresearch/run-browse.sh | 9 + autoresearch/run-save.sh | 11 + autoresearch/run-skill.sh | 9 + .../save-adapters/xhs-explore-deep.ts | 64 + .../save-adapters/xhs-note-comments.ts | 61 + autoresearch/save-adapters/xhs-search-full.ts | 62 + .../save-adapters/zhihu-hot-detail.ts | 52 + .../save-adapters/zhihu-question-full.ts | 57 + .../save-adapters/zhihu-search-detail.ts | 53 + autoresearch/save-tasks.json | 281 + autoresearch/v2ex-tasks.json | 899 + autoresearch/zhihu-tasks.json | 848 + bun.lock | 615 + cases/README.md | 9 + cases/daily-rl-research-monitor.md | 56 + cases/find-paper-implementation.md | 57 + cases/track-conference-papers.md | 75 + cli-manifest.json | 45521 ++++++++++++++++ clis/12306/auth.js | 59 + clis/12306/me.js | 73 + clis/12306/orders.js | 96 + clis/12306/passengers.js | 90 + clis/12306/price.js | 166 + clis/12306/stations.js | 66 + clis/12306/train.js | 91 + clis/12306/trains.js | 166 + clis/12306/utils.js | 272 + clis/12306/utils.test.js | 424 + clis/1688/assets.js | 205 + clis/1688/assets.test.js | 39 + clis/1688/auth.js | 46 + clis/1688/download.js | 77 + clis/1688/download.test.js | 31 + clis/1688/item.js | 188 + clis/1688/item.test.js | 67 + clis/1688/search.js | 310 + clis/1688/search.test.js | 75 + clis/1688/shared.js | 557 + clis/1688/shared.test.js | 57 + clis/1688/store.js | 227 + clis/1688/store.test.js | 62 + clis/1point3acres/auth.js | 52 + clis/1point3acres/digest.js | 35 + clis/1point3acres/forum.js | 51 + clis/1point3acres/forums.js | 44 + clis/1point3acres/hot.js | 35 + clis/1point3acres/latest.js | 35 + clis/1point3acres/notifications.js | 64 + clis/1point3acres/search.js | 71 + clis/1point3acres/thread.js | 117 + clis/1point3acres/user.js | 77 + clis/1point3acres/utils.js | 247 + clis/36kr/article.js | 66 + clis/36kr/article.test.js | 46 + clis/36kr/hot.js | 87 + clis/36kr/hot.test.js | 15 + clis/36kr/news.js | 52 + clis/36kr/news.test.js | 85 + clis/36kr/search.js | 79 + clis/51job/company.js | 126 + clis/51job/detail.js | 109 + clis/51job/hot.js | 56 + clis/51job/search.js | 80 + clis/51job/utils.js | 302 + clis/51job/utils.test.js | 69 + clis/_atlassian/shared.js | 577 + clis/_atlassian/shared.test.js | 170 + clis/_shared/common.js | 32 + clis/_shared/desktop-commands.js | 112 + clis/_shared/search-adapter.js | 70 + clis/_shared/site-auth.js | 118 + clis/_shared/site-auth.test.js | 98 + clis/aibase/news.js | 110 + clis/aibase/news.test.js | 59 + clis/amazon/auth.js | 53 + clis/amazon/bestsellers.js | 8 + clis/amazon/bestsellers.test.js | 29 + clis/amazon/discussion.js | 123 + clis/amazon/discussion.test.js | 124 + clis/amazon/movers-shakers.js | 8 + clis/amazon/new-releases.js | 8 + clis/amazon/offer.js | 141 + clis/amazon/offer.test.js | 29 + clis/amazon/product.js | 93 + clis/amazon/product.test.js | 24 + clis/amazon/rankings.js | 227 + clis/amazon/rankings.test.js | 41 + clis/amazon/search.js | 88 + clis/amazon/search.test.js | 22 + clis/amazon/shared.js | 365 + clis/amazon/shared.test.js | 44 + clis/antigravity/SKILL.md | 38 + clis/antigravity/_actions.js | 318 + clis/antigravity/antigravity.test.js | 172 + clis/antigravity/audit-extras.js | 341 + clis/antigravity/delete.js | 60 + clis/antigravity/dump.js | 29 + clis/antigravity/extract-code.js | 33 + clis/antigravity/history.js | 26 + clis/antigravity/mark-read.js | 52 + clis/antigravity/model.js | 161 + clis/antigravity/new.js | 26 + clis/antigravity/read.js | 35 + clis/antigravity/rename.js | 33 + clis/antigravity/send.js | 36 + clis/antigravity/serve.js | 558 + clis/antigravity/status.js | 19 + clis/antigravity/storage.js | 366 + clis/antigravity/watch.js | 43 + clis/apple-podcasts/commands.test.js | 119 + clis/apple-podcasts/episodes.js | 29 + clis/apple-podcasts/search.js | 31 + clis/apple-podcasts/top.js | 45 + clis/apple-podcasts/utils.js | 30 + clis/apple-podcasts/utils.test.js | 57 + clis/archive/archive.test.js | 262 + clis/archive/item.js | 92 + clis/archive/search.js | 115 + clis/archive/snapshots.js | 129 + clis/archive/wayback.js | 83 + clis/arxiv/arxiv.test.js | 112 + clis/arxiv/author.js | 44 + clis/arxiv/paper.js | 22 + clis/arxiv/recent.js | 33 + clis/arxiv/search.js | 36 + clis/arxiv/utils.js | 112 + clis/autohome/__fixtures__/catalog.html | 11 + clis/autohome/__fixtures__/koubei.json | 116 + clis/autohome/autohome.test.js | 115 + clis/autohome/brand.js | 108 + clis/autohome/score.js | 103 + clis/autohome/utils.js | 157 + clis/baidu-scholar/search.js | 87 + clis/baidu-scholar/search.test.js | 23 + clis/band/auth.js | 62 + clis/band/bands.js | 73 + clis/band/mentions.js | 128 + clis/band/post.js | 176 + clis/band/posts.js | 95 + clis/barchart/flow.js | 116 + clis/barchart/greeks.js | 208 + clis/barchart/greeks.test.js | 138 + clis/barchart/options.js | 107 + clis/barchart/quote.js | 135 + clis/bbc/news.js | 42 + clis/bbc/topic.js | 57 + clis/bbc/utils.js | 79 + clis/bilibili/auth.js | 36 + clis/bilibili/comment.js | 107 + clis/bilibili/comment.test.js | 153 + clis/bilibili/comments.js | 136 + clis/bilibili/comments.test.js | 142 + clis/bilibili/download.js | 178 + clis/bilibili/download.test.js | 173 + clis/bilibili/dynamic.js | 34 + clis/bilibili/dynamic.test.js | 68 + clis/bilibili/favorite.js | 47 + clis/bilibili/feed.js | 220 + clis/bilibili/follow.js | 140 + clis/bilibili/follow.test.js | 203 + clis/bilibili/following.js | 44 + clis/bilibili/history.js | 45 + clis/bilibili/hot.js | 40 + clis/bilibili/hot.test.js | 17 + clis/bilibili/me.js | 13 + clis/bilibili/ranking.js | 25 + clis/bilibili/search.js | 24 + clis/bilibili/subtitle.js | 141 + clis/bilibili/subtitle.test.js | 240 + clis/bilibili/summary.js | 167 + clis/bilibili/summary.test.js | 210 + clis/bilibili/unfollow.js | 121 + clis/bilibili/user-videos.js | 39 + clis/bilibili/utils.js | 273 + clis/bilibili/utils.test.js | 97 + clis/bilibili/video.js | 155 + clis/bilibili/video.test.js | 308 + clis/binance/asks.js | 22 + clis/binance/commands.test.js | 70 + clis/binance/depth.js | 21 + clis/binance/gainers.js | 23 + clis/binance/klines.js | 22 + clis/binance/losers.js | 23 + clis/binance/pairs.js | 22 + clis/binance/price.js | 19 + clis/binance/prices.js | 20 + clis/binance/ticker.js | 22 + clis/binance/top.js | 22 + clis/binance/trades.js | 21 + clis/bloomberg/businessweek.js | 125 + clis/bloomberg/businessweek.test.js | 149 + clis/bloomberg/crypto.js | 18 + clis/bloomberg/economics.js | 18 + clis/bloomberg/feeds.js | 16 + clis/bloomberg/green.js | 18 + clis/bloomberg/industries.js | 18 + clis/bloomberg/main.js | 18 + clis/bloomberg/markets.js | 18 + clis/bloomberg/news.js | 106 + clis/bloomberg/opinions.js | 18 + clis/bloomberg/politics.js | 18 + clis/bloomberg/pursuits.js | 18 + clis/bloomberg/tech.js | 18 + clis/bloomberg/utils.js | 380 + clis/bloomberg/utils.test.js | 129 + clis/bluesky/feeds.js | 28 + clis/bluesky/followers.js | 28 + clis/bluesky/following.js | 28 + clis/bluesky/profile.js | 30 + clis/bluesky/search.js | 29 + clis/bluesky/starter-packs.js | 29 + clis/bluesky/thread.js | 31 + clis/bluesky/trending.js | 20 + clis/bluesky/user.js | 35 + clis/booking/booking.test.js | 356 + clis/booking/search.js | 351 + clis/boss/auth.js | 47 + clis/boss/batchgreet.js | 61 + clis/boss/chatlist.js | 116 + clis/boss/chatlist.test.js | 211 + clis/boss/chatmsg.js | 117 + clis/boss/chatmsg.test.js | 230 + clis/boss/detail.js | 63 + clis/boss/exchange.js | 46 + clis/boss/greet.js | 49 + clis/boss/invite.js | 64 + clis/boss/joblist.js | 31 + clis/boss/mark.js | 70 + clis/boss/recommend.js | 45 + clis/boss/resume.js | 150 + clis/boss/search.js | 197 + clis/boss/search.test.js | 78 + clis/boss/send.js | 44 + clis/boss/stats.js | 66 + clis/boss/utils.js | 468 + clis/boss/utils.test.js | 34 + clis/brave/search.js | 80 + clis/brave/search.test.js | 76 + clis/chaoxing/assignments.js | 76 + clis/chaoxing/auth.js | 54 + clis/chaoxing/exams.js | 75 + clis/chaoxing/utils.js | 223 + clis/chaoxing/utils.test.js | 45 + clis/chatgpt-app/ask.js | 87 + clis/chatgpt-app/ax.js | 603 + clis/chatgpt-app/ax.test.js | 95 + clis/chatgpt-app/commands.test.js | 45 + clis/chatgpt-app/model.js | 25 + clis/chatgpt-app/new.js | 61 + clis/chatgpt-app/read.js | 32 + clis/chatgpt-app/send.js | 37 + clis/chatgpt-app/status.js | 26 + clis/chatgpt/ask.js | 129 + clis/chatgpt/auth.js | 61 + clis/chatgpt/commands.test.js | 442 + clis/chatgpt/deep-research-result.js | 121 + clis/chatgpt/detail.js | 63 + clis/chatgpt/envelope.test.js | 108 + clis/chatgpt/history.js | 39 + clis/chatgpt/image.js | 167 + clis/chatgpt/image.test.js | 207 + clis/chatgpt/model.js | 31 + clis/chatgpt/model.test.js | 36 + clis/chatgpt/new.js | 32 + clis/chatgpt/project-file-add.js | 74 + clis/chatgpt/project-list.js | 37 + clis/chatgpt/read.js | 44 + clis/chatgpt/send.js | 68 + clis/chatgpt/status.js | 29 + clis/chatgpt/utils.js | 3345 ++ clis/chatgpt/utils.test.js | 1907 + clis/chatwise/ask.js | 54 + clis/chatwise/composer.test.js | 186 + clis/chatwise/export.js | 47 + clis/chatwise/history.js | 61 + clis/chatwise/model.js | 85 + clis/chatwise/new.js | 2 + clis/chatwise/read.js | 40 + clis/chatwise/screenshot.js | 2 + clis/chatwise/send.js | 28 + clis/chatwise/status.js | 2 + clis/chatwise/utils.js | 143 + clis/chess/analyze.js | 35 + clis/chess/analyze.test.js | 79 + clis/chess/game.js | 114 + clis/chess/game.test.js | 178 + clis/chess/games.js | 67 + clis/chess/games.test.js | 164 + clis/chess/stats.js | 32 + clis/chess/stats.test.js | 79 + clis/chess/utils.js | 170 + clis/chess/utils.test.js | 230 + clis/claude/ask.js | 144 + clis/claude/ask.test.js | 338 + clis/claude/auth.js | 55 + clis/claude/commands.test.js | 118 + clis/claude/detail.js | 38 + clis/claude/history.js | 33 + clis/claude/new.js | 29 + clis/claude/read.js | 27 + clis/claude/send.js | 48 + clis/claude/status.js | 26 + clis/claude/utils.js | 463 + clis/claude/utils.test.js | 148 + clis/cnki/search.js | 61 + clis/cnki/search.test.js | 18 + clis/codex/_actions.js | 270 + clis/codex/archive.js | 37 + clis/codex/ask.js | 78 + clis/codex/dump.js | 2 + clis/codex/export.js | 38 + clis/codex/extract-diff.js | 45 + clis/codex/history.js | 27 + clis/codex/model.js | 264 + clis/codex/new.js | 2 + clis/codex/pin.js | 30 + clis/codex/projects.js | 28 + clis/codex/read.js | 41 + clis/codex/rename.js | 86 + clis/codex/screenshot.js | 2 + clis/codex/send.js | 51 + clis/codex/sidebar.js | 356 + clis/codex/sidebar.test.js | 388 + clis/codex/status.js | 2 + clis/coingecko/categories.js | 75 + clis/coingecko/coin.js | 107 + clis/coingecko/coingecko.test.js | 109 + clis/coingecko/derivatives.js | 84 + clis/coingecko/exchanges.js | 74 + clis/coingecko/global.js | 71 + clis/coingecko/top.js | 64 + clis/coingecko/trending.js | 55 + clis/confluence/commands.test.js | 195 + clis/confluence/create.js | 39 + clis/confluence/page.js | 23 + clis/confluence/search.js | 34 + clis/confluence/shared.js | 173 + clis/confluence/update.js | 38 + clis/coupang/add-to-cart.js | 149 + clis/coupang/auth.js | 48 + clis/coupang/coupang.test.js | 159 + clis/coupang/product.js | 257 + clis/coupang/search.js | 474 + clis/coupang/utils.js | 316 + clis/coupang/utils.test.js | 62 + clis/crates/crate.js | 62 + clis/crates/search.js | 44 + clis/crates/utils.js | 72 + clis/ctrip/auth.js | 50 + clis/ctrip/ctrip.test.js | 719 + clis/ctrip/flight.js | 136 + clis/ctrip/hotel-search.js | 132 + clis/ctrip/hotel-suggest.js | 45 + clis/ctrip/search.js | 41 + clis/ctrip/utils.js | 473 + clis/cursor/ask.js | 74 + clis/cursor/composer.js | 43 + clis/cursor/dump.js | 2 + clis/cursor/export.js | 52 + clis/cursor/extract-code.js | 36 + clis/cursor/history.js | 44 + clis/cursor/model.js | 54 + clis/cursor/new.js | 2 + clis/cursor/read.js | 45 + clis/cursor/screenshot.js | 2 + clis/cursor/send.js | 41 + clis/cursor/status.js | 2 + clis/dblp/author.js | 133 + clis/dblp/dblp.test.js | 397 + clis/dblp/paper.js | 40 + clis/dblp/search.js | 45 + clis/dblp/utils.js | 290 + clis/dblp/venue.js | 64 + clis/deepseek/ask.js | 158 + clis/deepseek/ask.test.js | 399 + clis/deepseek/auth.js | 54 + clis/deepseek/detail.js | 46 + clis/deepseek/detail.test.js | 81 + clis/deepseek/history.js | 27 + clis/deepseek/new.js | 33 + clis/deepseek/read.js | 25 + clis/deepseek/send.js | 140 + clis/deepseek/send.test.js | 107 + clis/deepseek/status.js | 26 + clis/deepseek/utils.js | 487 + clis/deepseek/utils.test.js | 370 + clis/defillama/defillama.test.js | 99 + clis/defillama/protocol.js | 84 + clis/defillama/protocols.js | 55 + clis/defillama/utils.js | 99 + clis/devto/devto.test.js | 236 + clis/devto/latest.js | 74 + clis/devto/read.js | 103 + clis/devto/tag.js | 36 + clis/devto/top.js | 30 + clis/devto/user.js | 35 + clis/dianping/__fixtures__/search.html | 168 + clis/dianping/__fixtures__/shop.html | 6 + clis/dianping/auth.js | 49 + clis/dianping/cityResolver.js | 185 + clis/dianping/dianping.test.js | 578 + clis/dianping/search.js | 157 + clis/dianping/shop.js | 173 + clis/dianping/utils.js | 157 + clis/dictionary/examples.js | 28 + clis/dictionary/search.js | 30 + clis/dictionary/synonyms.js | 28 + clis/discord-app/channels.js | 26 + clis/discord-app/commands.test.js | 395 + clis/discord-app/delete.js | 115 + clis/discord-app/goto.js | 38 + clis/discord-app/members.js | 39 + clis/discord-app/read.js | 42 + clis/discord-app/search.js | 67 + clis/discord-app/send.js | 28 + clis/discord-app/servers.js | 26 + clis/discord-app/status.js | 17 + clis/discord-app/thread-read.js | 45 + clis/discord-app/threads.js | 37 + clis/discord-app/utils.js | 610 + clis/dockerhub/image.js | 52 + clis/dockerhub/search.js | 47 + clis/dockerhub/utils.js | 100 + clis/dongchedi/__fixtures__/search.json | 60 + .../dongchedi/__fixtures__/series-detail.json | 911 + clis/dongchedi/__fixtures__/series-score.json | 1396 + clis/dongchedi/dongchedi.test.js | 191 + clis/dongchedi/koubei.js | 85 + clis/dongchedi/models.js | 91 + clis/dongchedi/score.js | 83 + clis/dongchedi/search.js | 83 + clis/dongchedi/series.js | 87 + clis/dongchedi/specs.js | 115 + clis/dongchedi/utils.js | 180 + clis/douban/auth.js | 50 + clis/douban/book-hot.js | 15 + clis/douban/download.js | 68 + clis/douban/download.test.js | 170 + clis/douban/marks.js | 116 + clis/douban/movie-hot.js | 15 + clis/douban/movie-hot.test.js | 14 + clis/douban/photos.js | 35 + clis/douban/reviews.js | 107 + clis/douban/search.js | 18 + clis/douban/search.test.js | 11 + clis/douban/subject.js | 46 + clis/douban/subject.test.js | 11 + clis/douban/top250.js | 68 + clis/douban/utils.js | 657 + clis/douban/utils.test.js | 488 + clis/doubao-app/ask.js | 54 + clis/doubao-app/dump.js | 25 + clis/doubao-app/new.js | 21 + clis/doubao-app/read.js | 19 + clis/doubao-app/screenshot.js | 19 + clis/doubao-app/send.js | 28 + clis/doubao-app/status.js | 17 + clis/doubao-app/utils.js | 110 + clis/doubao/ask.js | 40 + clis/doubao/auth.js | 66 + clis/doubao/detail.js | 35 + clis/doubao/detail.test.js | 42 + clis/doubao/history.js | 30 + clis/doubao/history.test.js | 37 + clis/doubao/meeting-summary.js | 41 + clis/doubao/meeting-transcript.js | 38 + clis/doubao/new.js | 22 + clis/doubao/read.js | 21 + clis/doubao/send.js | 24 + clis/doubao/status.js | 26 + clis/doubao/utils.js | 1156 + clis/doubao/utils.test.js | 396 + clis/douyin/_shared/browser-fetch.js | 66 + clis/douyin/_shared/browser-fetch.test.js | 64 + clis/douyin/_shared/creation-id.js | 5 + clis/douyin/_shared/creation-id.test.js | 22 + clis/douyin/_shared/evaluate-result.js | 16 + clis/douyin/_shared/imagex-upload.js | 53 + clis/douyin/_shared/imagex-upload.test.js | 87 + clis/douyin/_shared/public-api.js | 29 + clis/douyin/_shared/sts2.js | 21 + clis/douyin/_shared/sts2.test.js | 27 + clis/douyin/_shared/text-extra.js | 15 + clis/douyin/_shared/text-extra.test.js | 37 + clis/douyin/_shared/timing.js | 22 + clis/douyin/_shared/timing.test.js | 28 + .../_shared/tos-upload-short-read.test.js | 83 + clis/douyin/_shared/tos-upload.js | 331 + clis/douyin/_shared/tos-upload.test.js | 229 + clis/douyin/_shared/transcode.js | 45 + clis/douyin/_shared/transcode.test.js | 91 + clis/douyin/_shared/types.js | 1 + clis/douyin/_shared/vod-upload.js | 212 + clis/douyin/_shared/vod-upload.test.js | 38 + clis/douyin/activities.js | 23 + clis/douyin/activities.test.js | 55 + clis/douyin/auth.js | 39 + clis/douyin/collections.js | 23 + clis/douyin/collections.test.js | 45 + clis/douyin/delete.js | 152 + clis/douyin/delete.test.js | 100 + clis/douyin/draft.js | 355 + clis/douyin/draft.test.js | 335 + clis/douyin/drafts.js | 24 + clis/douyin/drafts.test.js | 11 + clis/douyin/hashtag.js | 114 + clis/douyin/hashtag.test.js | 171 + clis/douyin/location.js | 25 + clis/douyin/location.test.js | 23 + clis/douyin/profile.js | 29 + clis/douyin/profile.test.js | 46 + clis/douyin/publish-upload-id.test.js | 206 + clis/douyin/publish.js | 341 + clis/douyin/publish.test.js | 38 + clis/douyin/search.js | 308 + clis/douyin/search.test.js | 307 + clis/douyin/stats.js | 28 + clis/douyin/stats.test.js | 22 + clis/douyin/update.js | 32 + clis/douyin/update.test.js | 11 + clis/douyin/user-videos.js | 82 + clis/douyin/user-videos.test.js | 151 + clis/douyin/videos.js | 52 + clis/douyin/videos.test.js | 54 + clis/duckduckgo/search.js | 131 + clis/duckduckgo/search.test.js | 128 + clis/duckduckgo/suggest.js | 45 + clis/duckduckgo/suggest.test.js | 66 + clis/eastmoney/_secid.js | 78 + clis/eastmoney/announcement.js | 53 + clis/eastmoney/convertible.js | 74 + clis/eastmoney/etf.js | 66 + clis/eastmoney/holders.js | 79 + clis/eastmoney/hot-rank.js | 51 + clis/eastmoney/hot-rank.test.js | 59 + clis/eastmoney/index-board.js | 97 + clis/eastmoney/kline.js | 88 + clis/eastmoney/kuaixun.js | 55 + clis/eastmoney/longhu.js | 68 + clis/eastmoney/money-flow.js | 79 + clis/eastmoney/northbound.js | 58 + clis/eastmoney/quote.js | 108 + clis/eastmoney/rank.js | 95 + clis/eastmoney/sectors.js | 77 + clis/endoflife/endoflife.test.js | 51 + clis/endoflife/product.js | 55 + clis/endoflife/utils.js | 89 + .../__fixtures__/notifications-page.html | 13 + clis/facebook/add-friend.js | 44 + clis/facebook/auth.js | 43 + clis/facebook/events.js | 41 + clis/facebook/feed.js | 394 + clis/facebook/feed.test.js | 255 + clis/facebook/friends.js | 39 + clis/facebook/groups.js | 47 + clis/facebook/join-group.js | 45 + clis/facebook/marketplace-inbox.js | 84 + clis/facebook/marketplace-listings.js | 84 + clis/facebook/marketplace.test.js | 91 + clis/facebook/memories.js | 36 + clis/facebook/notifications.js | 333 + clis/facebook/notifications.test.js | 458 + clis/facebook/profile.js | 38 + clis/facebook/search.js | 186 + clis/facebook/search.test.js | 114 + clis/flathub/app.js | 71 + clis/flathub/flathub.test.js | 90 + clis/flathub/search.js | 80 + clis/flathub/utils.js | 114 + clis/flomo/auth.js | 55 + clis/flomo/memos.js | 228 + clis/flomo/memos.test.js | 144 + clis/gemini/ask.js | 274 + clis/gemini/ask.test.js | 1261 + clis/gemini/auth.js | 48 + clis/gemini/commands.test.js | 212 + clis/gemini/deep-research-result.js | 96 + clis/gemini/deep-research-result.test.js | 155 + clis/gemini/deep-research.js | 103 + clis/gemini/deep-research.test.js | 181 + clis/gemini/detail.js | 82 + clis/gemini/history.js | 70 + clis/gemini/image.js | 111 + clis/gemini/models.js | 498 + clis/gemini/models.test.js | 645 + clis/gemini/new.js | 22 + clis/gemini/read.js | 36 + clis/gemini/reply-state.test.js | 671 + clis/gemini/status.js | 32 + clis/gemini/utils.js | 2689 + clis/gemini/utils.test.js | 618 + clis/geogebra/add-circle.js | 46 + clis/geogebra/add-line.js | 35 + clis/geogebra/add-point.js | 27 + clis/geogebra/add-polygon.js | 25 + clis/geogebra/eval.js | 35 + clis/geogebra/geogebra.test.js | 175 + clis/geogebra/hexagon.js | 62 + clis/geogebra/info.js | 72 + clis/geogebra/list.js | 35 + clis/geogebra/triangle.js | 60 + clis/geogebra/utils.js | 271 + clis/gitee/auth.js | 41 + clis/gitee/index.js | 3 + clis/gitee/search.js | 137 + clis/gitee/search.test.js | 65 + clis/gitee/trending.js | 568 + clis/gitee/user.js | 200 + clis/gitee/user.test.js | 63 + clis/github-trending/repos.js | 168 + clis/github-trending/repos.test.js | 149 + clis/github/auth.js | 44 + clis/google-scholar/cite.js | 74 + clis/google-scholar/cite.test.js | 47 + clis/google-scholar/profile.js | 92 + clis/google-scholar/profile.test.js | 49 + clis/google-scholar/search.js | 73 + clis/google-scholar/search.test.js | 71 + clis/google/news.js | 59 + clis/google/search.js | 138 + clis/google/suggest.js | 35 + clis/google/trends.js | 39 + clis/google/utils.js | 23 + clis/google/utils.test.js | 75 + clis/goproxy/goproxy.test.js | 103 + clis/goproxy/module.js | 47 + clis/goproxy/utils.js | 165 + clis/goproxy/versions.js | 59 + clis/gov-law/commands.test.js | 39 + clis/gov-law/recent.js | 22 + clis/gov-law/search.js | 41 + clis/gov-law/shared.js | 51 + clis/gov-policy/__fixtures__/recent.html | 16 + clis/gov-policy/__fixtures__/search.html | 41 + clis/gov-policy/gov-policy.test.js | 224 + clis/gov-policy/recent.js | 90 + clis/gov-policy/search.js | 91 + clis/gov-policy/utils.js | 54 + clis/grok/ask.js | 77 + clis/grok/ask.test.js | 29 + clis/grok/auth.js | 52 + clis/grok/delete.js | 54 + clis/grok/detail.js | 60 + clis/grok/export-all.js | 409 + clis/grok/export-utils.js | 94 + clis/grok/export.js | 189 + clis/grok/export.test.js | 210 + clis/grok/history.js | 48 + clis/grok/image.js | 343 + clis/grok/image.test.ts | 127 + clis/grok/new.js | 20 + clis/grok/pin.js | 68 + clis/grok/read.js | 39 + clis/grok/send.js | 50 + clis/grok/status.js | 41 + clis/grok/utils.js | 640 + clis/grok/utils.test.js | 141 + clis/guazi/__fixtures__/detail.html | 7 + clis/guazi/__fixtures__/list.html | 5 + clis/guazi/browse.js | 95 + clis/guazi/car.js | 110 + clis/guazi/guazi.test.js | 126 + clis/guazi/utils.js | 133 + clis/hackernews/ask.js | 31 + clis/hackernews/best.js | 31 + clis/hackernews/hackernews.test.js | 132 + clis/hackernews/jobs.js | 29 + clis/hackernews/new.js | 31 + clis/hackernews/read.js | 188 + clis/hackernews/search.js | 38 + clis/hackernews/show.js | 31 + clis/hackernews/top.js | 31 + clis/hackernews/user.js | 23 + clis/hf/auth.js | 41 + clis/hf/datasets.js | 88 + clis/hf/hf.test.js | 16 + clis/hf/models.js | 91 + clis/hf/paper.js | 79 + clis/hf/spaces.js | 101 + clis/hf/top.js | 121 + clis/hltv/event-matches.js | 90 + clis/hltv/hltv.test.js | 97 + clis/hltv/match-map.js | 21 + clis/hltv/match-series.js | 21 + clis/hltv/player-duel.js | 358 + clis/hltv/player-form.js | 121 + clis/hltv/player-map-pool.js | 104 + clis/hltv/player-matches.js | 29 + clis/hltv/player-summary.js | 103 + clis/hltv/player-teammate-impact.js | 95 + clis/hltv/player-vs-team.js | 95 + clis/hltv/search.js | 107 + clis/hltv/team-map-pool.js | 146 + clis/hltv/team-matches.js | 30 + clis/hltv/utils.js | 867 + clis/homebrew/cask.js | 39 + clis/homebrew/formula.js | 41 + clis/homebrew/popular.js | 54 + clis/homebrew/utils.js | 100 + clis/huodongxing/events.js | 322 + clis/huodongxing/events.test.js | 296 + clis/hupu/__fixtures__/hot-home.html | 64 + clis/hupu/auth.js | 47 + clis/hupu/detail.js | 72 + clis/hupu/hot.js | 163 + clis/hupu/hot.test.js | 224 + clis/hupu/like.js | 76 + clis/hupu/mentions.js | 160 + clis/hupu/reply.js | 72 + clis/hupu/search.js | 60 + clis/hupu/unlike.js | 76 + clis/hupu/utils.js | 319 + clis/imdb/person.js | 204 + clis/imdb/reviews.js | 89 + clis/imdb/search.js | 162 + clis/imdb/title.js | 94 + clis/imdb/top.js | 54 + clis/imdb/trending.js | 53 + clis/imdb/utils.js | 285 + clis/imdb/utils.test.js | 88 + clis/indeed/indeed.test.js | 375 + clis/indeed/job.js | 86 + clis/indeed/search.js | 110 + clis/indeed/utils.js | 152 + clis/instagram/_shared/private-publish.js | 1030 + .../instagram/_shared/private-publish.test.js | 705 + clis/instagram/_shared/protocol-capture.js | 282 + .../_shared/protocol-capture.test.js | 114 + clis/instagram/_shared/runtime-info.js | 81 + clis/instagram/auth.js | 57 + clis/instagram/collection-create.js | 58 + clis/instagram/collection-delete.js | 92 + clis/instagram/comment.js | 48 + clis/instagram/download.js | 226 + clis/instagram/download.test.js | 118 + clis/instagram/explore.js | 67 + clis/instagram/follow.js | 44 + clis/instagram/followers.js | 46 + clis/instagram/following.js | 90 + clis/instagram/instagram.test.js | 381 + clis/instagram/like.js | 46 + clis/instagram/note.js | 223 + clis/instagram/note.test.js | 53 + clis/instagram/post.js | 1497 + clis/instagram/post.test.js | 1647 + clis/instagram/profile.js | 40 + clis/instagram/reel.js | 827 + clis/instagram/reel.test.js | 167 + clis/instagram/save.js | 46 + clis/instagram/saved.js | 53 + clis/instagram/search.js | 39 + clis/instagram/story.js | 116 + clis/instagram/story.test.js | 134 + clis/instagram/unfollow.js | 41 + clis/instagram/unlike.js | 46 + clis/instagram/unsave.js | 46 + clis/instagram/user.js | 49 + clis/jd/add-cart.js | 72 + clis/jd/auth.js | 46 + clis/jd/cart.js | 80 + clis/jd/commands.test.js | 41 + clis/jd/detail.js | 63 + clis/jd/item.js | 730 + clis/jd/item.test.js | 354 + clis/jd/item.test.ts | 517 + clis/jd/reviews.js | 55 + clis/jd/search.js | 66 + clis/jianyu/auth.js | 62 + clis/jianyu/detail.js | 21 + clis/jianyu/search.js | 634 + clis/jianyu/search.test.js | 153 + clis/jianyu/shared/china-bid-search.js | 165 + clis/jianyu/shared/procurement-contract.js | 324 + .../shared/procurement-contract.test.js | 72 + clis/jianyu/shared/procurement-detail.js | 107 + clis/jianyu/shared/procurement-detail.test.js | 84 + clis/jike/auth.js | 48 + clis/jike/comment.js | 107 + clis/jike/create.js | 106 + clis/jike/feed.js | 68 + clis/jike/like.js | 61 + clis/jike/notifications.js | 169 + clis/jike/post.js | 72 + clis/jike/read.test.js | 86 + clis/jike/repost.js | 103 + clis/jike/search.js | 68 + clis/jike/topic.js | 65 + clis/jike/user.js | 65 + clis/jike/utils.js | 25 + clis/jimeng/auth.js | 46 + clis/jimeng/generate.js | 84 + clis/jimeng/history.js | 48 + clis/jimeng/new.js | 44 + clis/jimeng/workspaces.js | 42 + clis/jira/attachments.js | 28 + clis/jira/commands.test.js | 287 + clis/jira/comments.js | 28 + clis/jira/issue.js | 28 + clis/jira/links.js | 28 + clis/jira/search.js | 47 + clis/jira/shared.js | 256 + clis/juejin/hot.js | 37 + clis/juejin/juejin.test.js | 241 + clis/juejin/recommend.js | 61 + clis/juejin/utils.js | 199 + clis/ke/auth.js | 46 + clis/ke/chengjiao.js | 78 + clis/ke/ershoufang.js | 101 + clis/ke/utils.js | 104 + clis/ke/xiaoqu.js | 78 + clis/ke/zufang.js | 95 + clis/kimi/_utils.js | 110 + clis/kimi/audit-extras.js | 268 + clis/kimi/auth.js | 57 + clis/kimi/chat.js | 470 + clis/kimi/kimi.test.js | 187 + clis/kimi/storage.js | 169 + clis/kimi/ui.js | 314 + clis/kimi/usage.js | 144 + clis/lesswrong/_helpers.js | 75 + clis/lesswrong/comments.js | 65 + clis/lesswrong/curated.js | 31 + clis/lesswrong/frontpage.js | 31 + clis/lesswrong/frontpage.test.js | 37 + clis/lesswrong/new.js | 31 + clis/lesswrong/read.js | 46 + clis/lesswrong/sequences.js | 28 + clis/lesswrong/shortform.js | 31 + clis/lesswrong/tag.js | 46 + clis/lesswrong/tags.js | 28 + clis/lesswrong/top-month.js | 31 + clis/lesswrong/top-week.js | 31 + clis/lesswrong/top-year.js | 31 + clis/lesswrong/top.js | 31 + clis/lesswrong/user-posts.js | 42 + clis/lesswrong/user.js | 45 + clis/lichess/lichess.test.js | 85 + clis/lichess/top.js | 46 + clis/lichess/user.js | 91 + clis/lichess/utils.js | 97 + clis/linkedin-learning/auth.js | 62 + clis/linkedin-learning/course.js | 138 + clis/linkedin-learning/course.test.js | 114 + clis/linkedin-learning/search.js | 155 + clis/linkedin-learning/search.test.js | 144 + clis/linkedin-learning/trending.js | 133 + clis/linkedin-learning/trending.test.js | 123 + clis/linkedin/auth.js | 62 + clis/linkedin/company.js | 151 + clis/linkedin/company.test.js | 101 + clis/linkedin/connect.js | 524 + clis/linkedin/connect.test.js | 328 + clis/linkedin/connections.js | 126 + clis/linkedin/connections.test.js | 109 + clis/linkedin/inbox.js | 234 + clis/linkedin/inbox.test.js | 152 + clis/linkedin/job-detail.js | 167 + clis/linkedin/job-detail.test.js | 38 + clis/linkedin/jobs-preferences.js | 113 + clis/linkedin/jobs-preferences.test.js | 43 + clis/linkedin/people-search.js | 262 + clis/linkedin/people-search.test.js | 216 + clis/linkedin/post-analytics.js | 74 + clis/linkedin/post-analytics.test.js | 40 + clis/linkedin/posts-core.js | 241 + clis/linkedin/posts.js | 22 + clis/linkedin/posts.test.js | 40 + clis/linkedin/profile-analytics.js | 104 + clis/linkedin/profile-analytics.test.js | 67 + clis/linkedin/profile-experience.js | 671 + clis/linkedin/profile-experience.test.js | 152 + clis/linkedin/profile-projects.js | 311 + clis/linkedin/profile-projects.test.js | 111 + clis/linkedin/profile-read.js | 148 + clis/linkedin/profile-read.test.js | 77 + clis/linkedin/safe-send.js | 357 + clis/linkedin/safe-send.test.js | 204 + clis/linkedin/salesnav-inbox.js | 210 + clis/linkedin/salesnav-inbox.test.js | 113 + clis/linkedin/salesnav-message.js | 360 + clis/linkedin/salesnav-message.test.js | 172 + clis/linkedin/salesnav-search.js | 186 + clis/linkedin/salesnav-search.test.js | 76 + clis/linkedin/salesnav-thread.js | 212 + clis/linkedin/salesnav-thread.test.js | 79 + clis/linkedin/search.js | 462 + clis/linkedin/search.test.js | 222 + clis/linkedin/sent-invitations.js | 92 + clis/linkedin/sent-invitations.test.js | 62 + clis/linkedin/services-read.js | 213 + clis/linkedin/services-read.test.js | 105 + clis/linkedin/shared.js | 124 + clis/linkedin/thread-snapshot.js | 214 + clis/linkedin/thread-snapshot.test.js | 89 + clis/linkedin/timeline.js | 511 + clis/linkedin/timeline.test.js | 81 + clis/linux-do/auth.js | 55 + clis/linux-do/categories.js | 50 + clis/linux-do/feed.js | 395 + clis/linux-do/feed.test.js | 153 + clis/linux-do/search.js | 29 + clis/linux-do/tags.js | 28 + clis/linux-do/topic-content.js | 155 + clis/linux-do/topic-content.test.js | 59 + clis/linux-do/topic.js | 53 + clis/linux-do/user-posts.js | 57 + clis/linux-do/user-topics.js | 37 + clis/lobsters/active.js | 29 + clis/lobsters/domain.js | 92 + clis/lobsters/hot.js | 29 + clis/lobsters/lobsters.test.js | 169 + clis/lobsters/newest.js | 29 + clis/lobsters/read.js | 196 + clis/lobsters/tag.js | 35 + clis/maimai/auth.js | 47 + clis/maimai/search-talents.js | 192 + clis/maimai/search-talents.test.js | 29 + clis/manus/_utils.js | 133 + clis/manus/auth.js | 54 + clis/manus/connectors.js | 40 + clis/manus/credits.js | 39 + clis/manus/list.js | 66 + clis/manus/manus.test.js | 382 + clis/manus/read.js | 63 + clis/manus/skills.js | 61 + clis/manus/status.js | 43 + clis/maven/artifact.js | 49 + clis/maven/search.js | 51 + clis/maven/utils.js | 110 + clis/mdn/search.js | 97 + clis/medium/feed.js | 16 + clis/medium/search.js | 16 + clis/medium/tag.js | 135 + clis/medium/user.js | 16 + clis/medium/utils.js | 79 + clis/mercury/check-login.js | 29 + clis/mercury/mercury.test.js | 288 + clis/mercury/reimbursement-draft.js | 130 + clis/mercury/reimbursement-plan.js | 38 + clis/mercury/utils.js | 305 + clis/mubu/doc.js | 41 + clis/mubu/docs.js | 44 + clis/mubu/notes.js | 245 + clis/mubu/recent.js | 28 + clis/mubu/search.js | 63 + clis/mubu/utils.js | 304 + clis/notebooklm/add-source.js | 269 + clis/notebooklm/add-source.test.js | 97 + clis/notebooklm/auth.js | 55 + clis/notebooklm/compat.test.js | 16 + clis/notebooklm/create.js | 76 + clis/notebooklm/create.test.js | 58 + clis/notebooklm/current.js | 28 + clis/notebooklm/generate-audio.js | 91 + clis/notebooklm/generate-audio.test.js | 63 + clis/notebooklm/generate-slides.js | 106 + clis/notebooklm/generate-slides.test.js | 75 + clis/notebooklm/get.js | 37 + clis/notebooklm/history.js | 25 + clis/notebooklm/history.test.js | 58 + clis/notebooklm/list.js | 36 + clis/notebooklm/note-list.js | 28 + clis/notebooklm/note-list.test.js | 56 + clis/notebooklm/notes-get.js | 47 + clis/notebooklm/notes-get.test.js | 72 + clis/notebooklm/open.js | 42 + clis/notebooklm/open.test.js | 63 + clis/notebooklm/rpc.js | 203 + clis/notebooklm/rpc.test.js | 131 + clis/notebooklm/shared.js | 3 + clis/notebooklm/source-fulltext.js | 44 + clis/notebooklm/source-fulltext.test.js | 106 + clis/notebooklm/source-get.js | 40 + clis/notebooklm/source-get.test.js | 84 + clis/notebooklm/source-guide.js | 44 + clis/notebooklm/source-guide.test.js | 104 + clis/notebooklm/source-list.js | 30 + clis/notebooklm/status.js | 31 + clis/notebooklm/summary.js | 30 + clis/notebooklm/summary.test.js | 78 + clis/notebooklm/utils.js | 814 + clis/notebooklm/utils.test.js | 449 + clis/notebooklm/write-note.js | 103 + clis/notebooklm/write-note.test.js | 70 + clis/nowcoder/auth.js | 65 + clis/nowcoder/companies.js | 24 + clis/nowcoder/creators.js | 28 + clis/nowcoder/detail.js | 62 + clis/nowcoder/experience.js | 37 + clis/nowcoder/hot.js | 25 + clis/nowcoder/jobs.js | 22 + clis/nowcoder/notifications.js | 30 + clis/nowcoder/papers.js | 41 + clis/nowcoder/practice.js | 38 + clis/nowcoder/recommend.js | 31 + clis/nowcoder/referral.js | 40 + clis/nowcoder/salary.js | 41 + clis/nowcoder/search.js | 50 + clis/nowcoder/suggest.js | 34 + clis/nowcoder/topics.js | 28 + clis/nowcoder/trending.js | 26 + clis/npm/downloads.js | 59 + clis/npm/package.js | 70 + clis/npm/search.js | 49 + clis/npm/utils.js | 76 + clis/nuget/nuget.test.js | 111 + clis/nuget/package.js | 101 + clis/nuget/search.js | 69 + clis/nuget/utils.js | 87 + clis/nvd/cve.js | 121 + clis/oeis/oeis.test.js | 88 + clis/oeis/search.js | 63 + clis/oeis/sequence.js | 71 + clis/oeis/utils.js | 88 + clis/ones/common.js | 144 + clis/ones/enrich-tasks.js | 37 + clis/ones/login.js | 81 + clis/ones/logout.js | 18 + clis/ones/me.js | 31 + clis/ones/my-tasks.js | 121 + clis/ones/resolve-labels.js | 64 + clis/ones/task-helpers.js | 212 + clis/ones/task-helpers.test.js | 12 + clis/ones/task.js | 67 + clis/ones/tasks.js | 80 + clis/ones/token-info.js | 43 + clis/ones/worklog.js | 268 + clis/ones/worklog.test.js | 20 + clis/openalex/search.js | 69 + clis/openalex/utils.js | 160 + clis/openalex/work.js | 65 + clis/openfda/drug-label.js | 74 + clis/openfda/food-recall.js | 65 + clis/openfda/openfda.test.js | 114 + clis/openfda/utils.js | 67 + clis/openreview/author.js | 58 + clis/openreview/openreview.test.js | 427 + clis/openreview/paper.js | 43 + clis/openreview/reviews.js | 131 + clis/openreview/search.js | 46 + clis/openreview/utils.js | 172 + clis/openreview/venue.js | 63 + clis/osv/osv.test.js | 97 + clis/osv/query.js | 72 + clis/osv/utils.js | 169 + clis/osv/vulnerability.js | 54 + clis/packagist/package.js | 49 + clis/packagist/search.js | 43 + clis/packagist/utils.js | 113 + clis/paperreview/commands.test.js | 243 + clis/paperreview/feedback.js | 53 + clis/paperreview/review.js | 38 + clis/paperreview/submit.js | 86 + clis/paperreview/utils.js | 197 + clis/paperreview/utils.test.js | 49 + clis/pixiv/auth.js | 64 + clis/pixiv/detail.js | 66 + clis/pixiv/detail.test.js | 93 + clis/pixiv/download.js | 82 + clis/pixiv/download.test.js | 97 + clis/pixiv/illusts.js | 66 + clis/pixiv/illusts.test.js | 99 + clis/pixiv/ranking.js | 60 + clis/pixiv/search.js | 44 + clis/pixiv/search.test.js | 83 + clis/pixiv/user.js | 59 + clis/pixiv/user.test.js | 100 + clis/pixiv/utils.js | 98 + clis/powerchina/auth.js | 52 + clis/powerchina/search.js | 251 + clis/powerchina/search.test.js | 93 + clis/producthunt/browse.js | 100 + clis/producthunt/hot.js | 111 + clis/producthunt/posts.js | 29 + clis/producthunt/today.js | 36 + clis/producthunt/utils.js | 99 + clis/producthunt/utils.test.js | 64 + clis/pubmed/article.js | 55 + clis/pubmed/author.js | 64 + clis/pubmed/citations.js | 36 + clis/pubmed/clinical-trial.js | 59 + clis/pubmed/journal.js | 59 + clis/pubmed/mesh.js | 47 + clis/pubmed/pubmed.test.js | 464 + clis/pubmed/related.js | 45 + clis/pubmed/review.js | 58 + clis/pubmed/search.js | 75 + clis/pubmed/utils.js | 325 + clis/pypi/downloads.js | 66 + clis/pypi/package.js | 79 + clis/pypi/utils.js | 55 + clis/qoder/_utils.js | 191 + clis/qoder/composer.js | 45 + clis/qoder/history.js | 49 + clis/qoder/qoder.test.js | 125 + clis/qoder/quest.js | 147 + clis/qoder/read.js | 29 + clis/qoder/status.js | 21 + clis/qoder/ui.js | 332 + clis/quark/auth.js | 47 + clis/quark/ls.js | 64 + clis/quark/mkdir.js | 37 + clis/quark/mv.js | 54 + clis/quark/rename.js | 27 + clis/quark/rm.js | 25 + clis/quark/save.js | 81 + clis/quark/share-tree.js | 46 + clis/quark/utils.js | 146 + clis/quark/utils.test.js | 58 + clis/qwen/ask.js | 104 + clis/qwen/ask.test.js | 36 + clis/qwen/auth.js | 56 + clis/qwen/detail.js | 62 + clis/qwen/history.js | 61 + clis/qwen/image.js | 179 + clis/qwen/new.js | 23 + clis/qwen/read.js | 41 + clis/qwen/send.js | 55 + clis/qwen/status.js | 37 + clis/qwen/utils.js | 461 + clis/qwen/utils.test.js | 128 + clis/reddit/auth.js | 52 + clis/reddit/comment.js | 58 + clis/reddit/extract-media.test.js | 149 + clis/reddit/frontpage.js | 70 + clis/reddit/frontpage.test.js | 34 + clis/reddit/home.js | 146 + clis/reddit/home.test.js | 170 + clis/reddit/hot.js | 81 + clis/reddit/hot.test.js | 32 + clis/reddit/popular.js | 79 + clis/reddit/popular.test.js | 26 + clis/reddit/read.js | 588 + clis/reddit/read.test.js | 402 + clis/reddit/reply.js | 182 + clis/reddit/reply.test.js | 89 + clis/reddit/save.js | 52 + clis/reddit/saved.js | 50 + clis/reddit/search.js | 102 + clis/reddit/search.test.js | 26 + clis/reddit/subreddit-info.js | 116 + clis/reddit/subreddit-info.test.js | 163 + clis/reddit/subreddit.js | 97 + clis/reddit/subreddit.test.js | 31 + clis/reddit/subscribe.js | 51 + clis/reddit/subscribed.js | 165 + clis/reddit/subscribed.test.js | 168 + clis/reddit/upvote.js | 65 + clis/reddit/upvoted.js | 50 + clis/reddit/user-comments.js | 45 + clis/reddit/user-posts.js | 43 + clis/reddit/user.js | 38 + clis/reddit/whoami.js | 83 + clis/reddit/whoami.test.js | 105 + clis/rednote/auth.js | 52 + clis/rednote/comments.js | 85 + clis/rednote/download.js | 59 + clis/rednote/feed.js | 27 + clis/rednote/navigation.test.js | 26 + clis/rednote/note.js | 68 + clis/rednote/notifications.js | 139 + clis/rednote/rednote.test.js | 240 + clis/rednote/search.js | 107 + clis/rednote/user.js | 55 + clis/rest-countries/country.js | 65 + clis/rest-countries/region.js | 64 + clis/rest-countries/rest-countries.test.js | 83 + clis/rest-countries/utils.js | 126 + clis/reuters/article-detail.js | 53 + clis/reuters/auth.js | 67 + clis/reuters/reuters.test.js | 299 + clis/reuters/search.js | 63 + clis/reuters/utils.js | 159 + clis/rfc/rfc.js | 52 + clis/rfc/rfc.test.js | 74 + clis/rfc/utils.js | 72 + clis/rubygems/gem.js | 42 + clis/rubygems/search.js | 47 + clis/rubygems/utils.js | 86 + clis/semanticscholar/citations.js | 61 + clis/semanticscholar/paper.js | 52 + clis/semanticscholar/recommendations.js | 47 + clis/semanticscholar/search.js | 48 + clis/semanticscholar/semanticscholar.test.js | 279 + clis/semanticscholar/utils.js | 196 + clis/sinablog/article.js | 15 + clis/sinablog/hot.js | 15 + clis/sinablog/search.js | 52 + clis/sinablog/user.js | 16 + clis/sinablog/utils.js | 186 + clis/sinafinance/news.js | 62 + clis/sinafinance/rolling-news.js | 41 + clis/sinafinance/stock-rank.js | 66 + clis/sinafinance/stock.js | 121 + clis/sinafinance/stock.test.js | 59 + clis/slock/api-base-canary.test.js | 62 + clis/slock/attachment-download.js | 72 + clis/slock/attachment-download.test.js | 102 + clis/slock/attachment-upload.js | 112 + clis/slock/attachment-upload.test.js | 131 + clis/slock/attachment-url.js | 53 + clis/slock/attachment-url.test.js | 41 + clis/slock/auth-verify.js | 19 + clis/slock/bookmark-add.js | 43 + clis/slock/bookmark-add.test.js | 38 + clis/slock/bookmark-list.js | 50 + clis/slock/bookmark-list.test.js | 47 + clis/slock/bookmark-remove.js | 42 + clis/slock/bookmark-remove.test.js | 24 + clis/slock/channel-action.js | 63 + clis/slock/channel-action.test.js | 65 + clis/slock/channel-archive.js | 9 + clis/slock/channel-create.js | 48 + clis/slock/channel-create.test.js | 40 + clis/slock/channel-files.js | 51 + clis/slock/channel-files.test.js | 32 + clis/slock/channel-info.js | 42 + clis/slock/channel-info.test.js | 25 + clis/slock/channel-join.js | 9 + clis/slock/channel-leave.js | 9 + clis/slock/channel-list.js | 40 + clis/slock/channel-list.test.js | 37 + clis/slock/channel-mark.js | 71 + clis/slock/channel-mark.test.js | 49 + clis/slock/channel-members.js | 64 + clis/slock/channel-members.test.js | 44 + clis/slock/channel-unarchive.js | 11 + clis/slock/cross-command.test.js | 21 + clis/slock/dm-list.js | 41 + clis/slock/dm-list.test.js | 26 + clis/slock/error-detail-canary.test.js | 144 + clis/slock/errors.js | 30 + clis/slock/errors.test.js | 29 + clis/slock/in-page.js | 213 + clis/slock/in-page.test.js | 362 + clis/slock/inbox-done.js | 40 + clis/slock/inbox-done.test.js | 21 + clis/slock/inbox-read-all.js | 32 + clis/slock/inbox-read-all.test.js | 20 + clis/slock/inbox.js | 74 + clis/slock/inbox.test.js | 46 + clis/slock/login.js | 3 + clis/slock/message-read.js | 168 + clis/slock/message-read.test.js | 83 + clis/slock/message-search.js | 72 + clis/slock/message-search.test.js | 61 + clis/slock/message-send.js | 149 + clis/slock/message-send.test.js | 127 + clis/slock/reaction-add.js | 42 + clis/slock/reaction-add.test.js | 36 + clis/slock/reaction-remove.js | 42 + clis/slock/reaction-remove.test.js | 29 + clis/slock/resolve.js | 74 + clis/slock/resolve.test.js | 102 + clis/slock/server-list.js | 38 + clis/slock/server-list.test.js | 24 + clis/slock/server-override-canary.test.js | 87 + clis/slock/server-use.js | 53 + clis/slock/server-use.test.js | 48 + clis/slock/shared.js | 14 + clis/slock/short-id-canary.test.js | 86 + clis/slock/site-session-canary.test.js | 49 + clis/slock/task-claim.js | 72 + clis/slock/task-claim.test.js | 81 + clis/slock/task-convert.js | 106 + clis/slock/task-convert.test.js | 82 + clis/slock/task-create.js | 82 + clis/slock/task-create.test.js | 65 + clis/slock/task-delete.js | 55 + clis/slock/task-delete.test.js | 52 + clis/slock/task-get.js | 59 + clis/slock/task-get.test.js | 45 + clis/slock/task-list-server.js | 63 + clis/slock/task-list-server.test.js | 49 + clis/slock/task-list.js | 70 + clis/slock/task-list.test.js | 60 + clis/slock/task-status.js | 93 + clis/slock/task-status.test.js | 76 + clis/slock/task-unclaim.js | 66 + clis/slock/task-unclaim.test.js | 49 + clis/slock/thread-done.js | 9 + clis/slock/thread-follow.js | 46 + clis/slock/thread-follow.test.js | 34 + clis/slock/thread-list.js | 44 + clis/slock/thread-list.test.js | 24 + clis/slock/thread-state.js | 45 + clis/slock/thread-state.test.js | 38 + clis/slock/thread-undone.js | 9 + clis/slock/thread-unfollow.js | 9 + clis/slock/unread-summary.js | 49 + clis/slock/unread-summary.test.js | 31 + clis/slock/whoami.js | 12 + clis/slock/whoami.test.js | 23 + clis/smzdm/search.js | 149 + clis/smzdm/search.test.js | 114 + clis/spotify/spotify.js | 327 + clis/spotify/utils.js | 66 + clis/spotify/utils.test.js | 67 + clis/stackoverflow/bounties.js | 35 + clis/stackoverflow/hot.js | 32 + clis/stackoverflow/read.js | 314 + clis/stackoverflow/related.js | 66 + clis/stackoverflow/search.js | 35 + clis/stackoverflow/stackoverflow.test.js | 404 + clis/stackoverflow/tag.js | 60 + clis/stackoverflow/unanswered.js | 33 + clis/stackoverflow/user.js | 50 + clis/stackoverflow/utils.js | 118 + clis/steam/app.js | 67 + clis/steam/search.js | 58 + clis/steam/steam.test.js | 46 + clis/steam/top-sellers.js | 26 + clis/steam/utils.js | 107 + clis/substack/feed.js | 16 + clis/substack/publication.js | 16 + clis/substack/search.js | 79 + clis/substack/utils.js | 136 + clis/substack/utils.test.js | 44 + clis/suno/auth.js | 63 + clis/suno/commands.test.js | 188 + clis/suno/download.js | 140 + clis/suno/download.test.js | 151 + clis/suno/generate.js | 231 + clis/suno/generate.test.js | 252 + clis/suno/list.js | 79 + clis/suno/status.js | 63 + clis/suno/utils.js | 549 + clis/suno/utils.test.js | 329 + clis/taobao/add-cart.js | 150 + clis/taobao/auth.js | 58 + clis/taobao/cart.js | 96 + clis/taobao/commands.test.js | 41 + clis/taobao/detail.js | 71 + clis/taobao/reviews.js | 77 + clis/taobao/search.js | 97 + clis/tdx/hot-rank.js | 48 + clis/tdx/hot-rank.test.js | 59 + clis/test-utils.js | 61 + clis/ths/hot-rank.js | 50 + clis/ths/hot-rank.test.js | 64 + clis/tieba/commands.test.js | 79 + clis/tieba/hot.js | 48 + clis/tieba/posts.js | 86 + clis/tieba/read.js | 141 + clis/tieba/search.js | 109 + clis/tieba/utils.js | 240 + clis/tieba/utils.test.js | 290 + clis/tiktok/auth.js | 65 + clis/tiktok/comment.js | 145 + clis/tiktok/creator-videos.js | 270 + clis/tiktok/creator-videos.test.js | 113 + clis/tiktok/explore.js | 144 + clis/tiktok/follow.js | 122 + clis/tiktok/following.js | 164 + clis/tiktok/friends.js | 146 + clis/tiktok/like.js | 34 + clis/tiktok/live.js | 144 + clis/tiktok/notifications.js | 153 + clis/tiktok/profile.js | 55 + clis/tiktok/refactor.test.js | 389 + clis/tiktok/save.js | 30 + clis/tiktok/search.js | 40 + clis/tiktok/unfollow.js | 131 + clis/tiktok/unlike.js | 34 + clis/tiktok/unsave.js | 32 + clis/tiktok/user.js | 216 + clis/tiktok/utils.js | 505 + clis/tiktok/write-refactor.test.js | 370 + clis/toutiao/articles.js | 55 + clis/toutiao/auth.js | 70 + clis/toutiao/hot.js | 63 + clis/toutiao/toutiao.test.js | 378 + clis/toutiao/utils.js | 161 + clis/trae-cn/activity.js | 22 + clis/trae-cn/approve.js | 52 + clis/trae-cn/ask.js | 99 + clis/trae-cn/dump.js | 5 + clis/trae-cn/export.js | 40 + clis/trae-cn/model.js | 22 + clis/trae-cn/new.js | 63 + clis/trae-cn/read.js | 23 + clis/trae-cn/screenshot.js | 5 + clis/trae-cn/select-model.js | 25 + clis/trae-cn/send.js | 23 + clis/trae-cn/setup.js | 75 + clis/trae-cn/status.js | 27 + clis/trae-cn/targets.js | 96 + clis/trae-cn/trae-cn.test.js | 412 + clis/trae-cn/utils.js | 750 + clis/trae-cn/watch.js | 119 + clis/trae-solo/_actions.js | 60 + clis/trae-solo/_fs.js | 118 + clis/trae-solo/_state.js | 111 + clis/trae-solo/automation.js | 97 + clis/trae-solo/history.js | 54 + clis/trae-solo/mode.js | 81 + clis/trae-solo/model.js | 134 + clis/trae-solo/renderer-storage.js | 170 + clis/trae-solo/settings.js | 54 + clis/trae-solo/skill-fs.js | 123 + clis/trae-solo/skill.js | 220 + clis/trae-solo/state-fs.js | 156 + clis/trae-solo/status.js | 2 + clis/trae-solo/task-fs.js | 181 + clis/trae-solo/trae-solo.test.js | 67 + clis/trae-solo/user-rules.js | 29 + clis/trae-solo/workspaces-fs.js | 113 + clis/tvmaze/search.js | 61 + clis/tvmaze/show.js | 60 + clis/tvmaze/tvmaze.test.js | 93 + clis/tvmaze/utils.js | 110 + clis/twitter/accept.js | 204 + clis/twitter/article-evaluate.test.js | 35 + clis/twitter/article.js | 227 + clis/twitter/article.test.js | 167 + clis/twitter/auth.js | 41 + clis/twitter/block.js | 90 + clis/twitter/bookmark-folder.js | 190 + clis/twitter/bookmark-folder.test.js | 396 + clis/twitter/bookmark-folders.js | 114 + clis/twitter/bookmark-folders.test.js | 152 + clis/twitter/bookmark.js | 74 + clis/twitter/bookmark.test.js | 74 + clis/twitter/bookmarks.js | 181 + clis/twitter/bookmarks.test.js | 206 + clis/twitter/delete.js | 99 + clis/twitter/delete.test.js | 164 + clis/twitter/device-follow.js | 194 + clis/twitter/device-follow.test.js | 287 + clis/twitter/download.js | 481 + clis/twitter/download.test.js | 457 + clis/twitter/follow-batch.js | 162 + clis/twitter/follow-batch.test.js | 137 + clis/twitter/follow.js | 67 + clis/twitter/followers.js | 193 + clis/twitter/followers.test.js | 62 + clis/twitter/following.js | 274 + clis/twitter/following.test.js | 359 + clis/twitter/hide-reply.js | 87 + clis/twitter/hide-reply.test.js | 76 + clis/twitter/like.js | 80 + clis/twitter/like.test.js | 73 + clis/twitter/likes.js | 241 + clis/twitter/likes.test.js | 220 + clis/twitter/list-add-batch.js | 32 + clis/twitter/list-add-core.js | 245 + clis/twitter/list-add.js | 20 + clis/twitter/list-add.test.js | 133 + clis/twitter/list-batch-utils.js | 95 + clis/twitter/list-batch.test.js | 113 + clis/twitter/list-create.js | 155 + clis/twitter/list-create.test.js | 169 + clis/twitter/list-delete.js | 158 + clis/twitter/list-delete.test.js | 102 + clis/twitter/list-remove-batch.js | 32 + clis/twitter/list-remove-core.js | 291 + clis/twitter/list-remove.js | 20 + clis/twitter/list-remove.test.js | 147 + clis/twitter/list-tweets.js | 199 + clis/twitter/list-tweets.test.js | 198 + clis/twitter/lists.js | 178 + clis/twitter/lists.test.js | 253 + clis/twitter/notifications.js | 128 + clis/twitter/post.js | 316 + clis/twitter/post.test.js | 275 + clis/twitter/profile.js | 174 + clis/twitter/profile.test.js | 150 + clis/twitter/quote.js | 172 + clis/twitter/quote.test.js | 194 + clis/twitter/reply-dm.js | 183 + clis/twitter/reply.js | 248 + clis/twitter/reply.test.js | 237 + clis/twitter/retweet.js | 94 + clis/twitter/retweet.test.js | 73 + clis/twitter/search.js | 346 + clis/twitter/search.test.js | 400 + clis/twitter/shared.js | 564 + clis/twitter/shared.test.js | 1012 + clis/twitter/thread.js | 164 + clis/twitter/thread.test.js | 58 + clis/twitter/timeline.js | 211 + clis/twitter/timeline.test.js | 104 + clis/twitter/trending.js | 58 + clis/twitter/trending.test.js | 15 + clis/twitter/tweets.js | 347 + clis/twitter/tweets.test.js | 487 + clis/twitter/unblock.js | 73 + clis/twitter/unbookmark.js | 71 + clis/twitter/unbookmark.test.js | 73 + clis/twitter/unfollow.js | 73 + clis/twitter/unlike.js | 80 + clis/twitter/unlike.test.js | 75 + clis/twitter/unretweet.js | 94 + clis/twitter/unretweet.test.js | 73 + clis/twitter/utils.js | 323 + clis/twitter/utils.test.js | 169 + clis/uisdc/news.js | 105 + clis/uisdc/news.test.js | 66 + clis/uiverse/_shared.js | 427 + clis/uiverse/_shared.test.js | 62 + clis/uiverse/code.js | 49 + clis/uiverse/navigation.test.js | 12 + clis/uiverse/preview.js | 73 + clis/upwork/auth.js | 53 + clis/upwork/detail.js | 132 + clis/upwork/feed.js | 109 + clis/upwork/search.js | 115 + clis/upwork/upwork.test.js | 566 + clis/upwork/utils.js | 323 + clis/v2ex/auth.js | 44 + clis/v2ex/daily.js | 99 + clis/v2ex/hot.js | 26 + clis/v2ex/latest.js | 26 + clis/v2ex/me.js | 100 + clis/v2ex/member.js | 28 + clis/v2ex/node.js | 39 + clis/v2ex/nodes.js | 26 + clis/v2ex/notifications.js | 72 + clis/v2ex/replies.js | 27 + clis/v2ex/topic.js | 31 + clis/v2ex/user.js | 34 + clis/wanfang/search.js | 66 + clis/wanfang/search.test.js | 23 + clis/web/read.js | 491 + clis/web/read.test.js | 392 + clis/wechat-channels/auth.js | 62 + clis/wechat-channels/publish.js | 732 + clis/wechat-channels/publish.test.js | 144 + clis/weibo/auth.js | 73 + clis/weibo/auth.test.js | 96 + clis/weibo/comments.js | 53 + clis/weibo/delete.js | 172 + clis/weibo/delete.test.js | 94 + clis/weibo/envelope.test.js | 85 + clis/weibo/favorites.js | 170 + clis/weibo/favorites.test.js | 114 + clis/weibo/feed.js | 69 + clis/weibo/hot.js | 39 + clis/weibo/me.js | 75 + clis/weibo/post.js | 75 + clis/weibo/publish.js | 306 + clis/weibo/publish.test.js | 192 + clis/weibo/search.js | 80 + clis/weibo/user-posts.js | 234 + clis/weibo/user-posts.test.js | 92 + clis/weibo/user.js | 63 + clis/weibo/utils.js | 59 + clis/weibo/utils.test.js | 36 + clis/weixin/create-draft.js | 226 + clis/weixin/download.js | 371 + clis/weixin/download.test.js | 106 + clis/weixin/drafts.js | 66 + clis/weixin/drafts.test.js | 69 + clis/weixin/search.js | 157 + clis/weixin/search.test.js | 227 + clis/weread-official/book.js | 135 + clis/weread-official/commands.test.js | 385 + clis/weread-official/discover.js | 107 + clis/weread-official/list-apis.js | 95 + clis/weread-official/notes.js | 171 + clis/weread-official/readdata.js | 158 + clis/weread-official/review.js | 93 + clis/weread-official/search.js | 106 + clis/weread-official/shelf.js | 97 + clis/weread-official/utils.js | 293 + clis/weread-official/utils.test.js | 242 + clis/weread/ai-outline.js | 171 + clis/weread/ai-outline.test.js | 83 + clis/weread/auth.js | 59 + clis/weread/book-search.js | 438 + clis/weread/book-search.test.js | 242 + clis/weread/book.js | 233 + clis/weread/commands.test.js | 398 + clis/weread/highlights.js | 24 + clis/weread/notebooks.js | 22 + clis/weread/notes.js | 30 + clis/weread/private-api-regression.test.js | 369 + clis/weread/ranking.js | 29 + clis/weread/search-regression.test.js | 494 + clis/weread/search.js | 186 + clis/weread/shelf.js | 66 + clis/weread/utils.js | 321 + clis/weread/utils.test.js | 128 + clis/wikidata/entity.js | 60 + clis/wikidata/search.js | 50 + clis/wikidata/utils.js | 117 + clis/wikidata/wikidata.test.js | 83 + clis/wikipedia/page.js | 95 + clis/wikipedia/random.js | 20 + clis/wikipedia/search.js | 31 + clis/wikipedia/summary.js | 24 + clis/wikipedia/trending.js | 40 + clis/wikipedia/trending.test.js | 57 + clis/wikipedia/utils.js | 31 + clis/wttr/current.js | 63 + clis/wttr/forecast.js | 71 + clis/wttr/utils.js | 50 + clis/wttr/wttr.test.js | 84 + clis/xianyu/auth.js | 68 + clis/xianyu/chat.js | 74 + clis/xianyu/chat.test.js | 84 + clis/xianyu/im.js | 322 + clis/xianyu/im.test.js | 253 + clis/xianyu/inbox.js | 96 + clis/xianyu/item.js | 153 + clis/xianyu/item.test.js | 56 + clis/xianyu/messages.js | 91 + clis/xianyu/publish.js | 485 + clis/xianyu/publish.test.js | 220 + clis/xianyu/reply.js | 82 + clis/xianyu/search.js | 262 + clis/xianyu/search.test.js | 104 + clis/xianyu/utils.js | 8 + clis/xiaoe/auth.js | 55 + clis/xiaoe/catalog.js | 227 + clis/xiaoe/content.js | 175 + clis/xiaoe/courses.js | 127 + clis/xiaoe/detail.js | 36 + clis/xiaoe/play-url.js | 121 + clis/xiaoe/xiaoe.test.js | 486 + clis/xiaohongshu/ask.js | 426 + clis/xiaohongshu/ask.test.js | 271 + clis/xiaohongshu/auth.js | 53 + clis/xiaohongshu/collection-helpers.js | 283 + clis/xiaohongshu/collection-helpers.test.js | 125 + clis/xiaohongshu/comments.js | 228 + clis/xiaohongshu/comments.test.js | 320 + clis/xiaohongshu/creator-note-detail.js | 482 + clis/xiaohongshu/creator-note-detail.test.js | 451 + clis/xiaohongshu/creator-notes-summary.js | 98 + .../xiaohongshu/creator-notes-summary.test.js | 94 + clis/xiaohongshu/creator-notes.js | 479 + clis/xiaohongshu/creator-notes.test.js | 281 + clis/xiaohongshu/creator-profile.js | 55 + clis/xiaohongshu/creator-stats.js | 76 + clis/xiaohongshu/creator-stats.test.js | 24 + clis/xiaohongshu/delete-note.js | 260 + clis/xiaohongshu/delete-note.test.js | 172 + clis/xiaohongshu/download.js | 249 + clis/xiaohongshu/download.test.js | 339 + clis/xiaohongshu/draft-clear.js | 91 + clis/xiaohongshu/draft-delete.js | 103 + clis/xiaohongshu/draft-open.js | 43 + clis/xiaohongshu/draft-utils.js | 142 + clis/xiaohongshu/drafts.js | 28 + clis/xiaohongshu/drafts.test.js | 172 + clis/xiaohongshu/feed.js | 163 + clis/xiaohongshu/feed.test.js | 126 + clis/xiaohongshu/follow.js | 226 + clis/xiaohongshu/follow.test.js | 211 + clis/xiaohongshu/liked.js | 29 + clis/xiaohongshu/liked.test.js | 82 + clis/xiaohongshu/navigation.test.js | 38 + clis/xiaohongshu/note-helpers.js | 69 + clis/xiaohongshu/note.js | 104 + clis/xiaohongshu/note.test.js | 249 + clis/xiaohongshu/notifications.js | 46 + clis/xiaohongshu/publish.js | 1423 + clis/xiaohongshu/publish.test.js | 1141 + clis/xiaohongshu/saved.js | 29 + clis/xiaohongshu/saved.test.js | 143 + clis/xiaohongshu/search.js | 330 + clis/xiaohongshu/search.test.js | 325 + clis/xiaohongshu/unfollow.js | 285 + clis/xiaohongshu/user-helpers.js | 78 + clis/xiaohongshu/user-helpers.test.js | 160 + clis/xiaohongshu/user.js | 140 + clis/xiaohongshu/user.test.js | 141 + clis/xiaoyuzhou/auth.js | 303 + clis/xiaoyuzhou/auth.test.js | 124 + clis/xiaoyuzhou/download.js | 54 + clis/xiaoyuzhou/download.test.js | 135 + clis/xiaoyuzhou/episode.js | 34 + clis/xiaoyuzhou/podcast-episodes.js | 41 + clis/xiaoyuzhou/podcast-episodes.test.js | 78 + clis/xiaoyuzhou/podcast.js | 33 + clis/xiaoyuzhou/transcript.js | 77 + clis/xiaoyuzhou/transcript.test.js | 195 + clis/xiaoyuzhou/utils.js | 15 + clis/xiaoyuzhou/utils.test.js | 39 + clis/xueqiu/auth.js | 63 + clis/xueqiu/comments.js | 355 + clis/xueqiu/comments.test.js | 696 + clis/xueqiu/danjuan-utils.js | 126 + clis/xueqiu/danjuan-utils.test.js | 41 + clis/xueqiu/earnings-date.js | 45 + clis/xueqiu/feed.js | 39 + clis/xueqiu/fund-holdings.js | 29 + clis/xueqiu/fund-snapshot.js | 26 + clis/xueqiu/groups.js | 23 + clis/xueqiu/hot-stock.js | 32 + clis/xueqiu/hot.js | 37 + clis/xueqiu/kline.js | 44 + clis/xueqiu/search.js | 37 + clis/xueqiu/stock.js | 65 + clis/xueqiu/utils.js | 48 + clis/xueqiu/utils.test.js | 26 + clis/xueqiu/watchlist.js | 37 + clis/yahoo-finance/quote.js | 74 + clis/yahoo/search.js | 92 + clis/yahoo/search.test.js | 94 + clis/yollomi/background.js | 46 + clis/yollomi/edit.js | 57 + clis/yollomi/face-swap.js | 44 + clis/yollomi/generate.js | 101 + clis/yollomi/models.js | 34 + clis/yollomi/object-remover.js | 43 + clis/yollomi/remove-bg.js | 39 + clis/yollomi/restore.js | 39 + clis/yollomi/try-on.js | 47 + clis/yollomi/upload.js | 72 + clis/yollomi/upscale.js | 54 + clis/yollomi/utils.js | 180 + clis/yollomi/video.js | 57 + clis/youdao/note.js | 258 + clis/youdao/note.test.js | 99 + clis/youtube/auth.js | 54 + clis/youtube/channel.js | 227 + clis/youtube/channel.test.js | 283 + clis/youtube/comments.js | 96 + clis/youtube/feed.js | 121 + clis/youtube/feed.test.js | 131 + clis/youtube/history.js | 119 + clis/youtube/like.js | 67 + clis/youtube/playlist.js | 98 + clis/youtube/search.js | 100 + clis/youtube/subscribe.js | 76 + clis/youtube/subscriptions.js | 58 + clis/youtube/transcript-group.js | 226 + clis/youtube/transcript-group.test.js | 99 + clis/youtube/transcript.js | 656 + clis/youtube/transcript.test.js | 296 + clis/youtube/unlike.js | 67 + clis/youtube/unsubscribe.js | 76 + clis/youtube/utils.js | 220 + clis/youtube/utils.test.js | 84 + clis/youtube/video.js | 152 + clis/youtube/video.test.js | 111 + clis/youtube/watch-later.js | 77 + clis/yuanbao/ask.js | 362 + clis/yuanbao/ask.test.js | 124 + clis/yuanbao/auth.js | 59 + clis/yuanbao/detail.js | 65 + clis/yuanbao/history.js | 51 + clis/yuanbao/new.js | 72 + clis/yuanbao/new.test.js | 30 + clis/yuanbao/read.js | 38 + clis/yuanbao/send.js | 57 + clis/yuanbao/shared.js | 341 + clis/yuanbao/shared.test.js | 80 + clis/yuanbao/status.js | 44 + clis/zhihu/answer-comments.js | 280 + clis/zhihu/answer-comments.test.js | 287 + clis/zhihu/answer-detail.js | 216 + clis/zhihu/answer-detail.test.js | 338 + clis/zhihu/answer.js | 54 + clis/zhihu/answer.test.js | 54 + clis/zhihu/auth.js | 59 + clis/zhihu/collection.js | 199 + clis/zhihu/collection.test.js | 337 + clis/zhihu/collections.js | 128 + clis/zhihu/collections.test.js | 182 + clis/zhihu/comment.js | 55 + clis/zhihu/comment.test.js | 50 + clis/zhihu/download.js | 80 + clis/zhihu/download.test.js | 12 + clis/zhihu/favorite.js | 87 + clis/zhihu/favorite.test.js | 59 + clis/zhihu/follow.js | 50 + clis/zhihu/follow.test.js | 42 + clis/zhihu/followers.js | 40 + clis/zhihu/followers.test.js | 42 + clis/zhihu/following.js | 40 + clis/zhihu/following.test.js | 44 + clis/zhihu/hot.js | 44 + clis/zhihu/like.js | 47 + clis/zhihu/like.test.js | 41 + clis/zhihu/paginate.js | 128 + clis/zhihu/pins.js | 41 + clis/zhihu/pins.test.js | 44 + clis/zhihu/question.js | 113 + clis/zhihu/question.test.js | 251 + clis/zhihu/recommend.js | 103 + clis/zhihu/recommend.test.js | 143 + clis/zhihu/search.js | 206 + clis/zhihu/search.test.js | 198 + clis/zhihu/target.js | 91 + clis/zhihu/target.test.js | 77 + clis/zhihu/text.js | 29 + clis/zhihu/text.test.js | 24 + clis/zhihu/user-answers.js | 41 + clis/zhihu/user-answers.test.js | 69 + clis/zhihu/user-arg.js | 34 + clis/zhihu/user-articles.js | 40 + clis/zhihu/user-articles.test.js | 44 + clis/zhihu/user.js | 59 + clis/zhihu/user.test.js | 73 + clis/zhihu/write-shared.js | 228 + clis/zhihu/write-shared.test.js | 176 + clis/zlibrary/commands.test.js | 65 + clis/zlibrary/info.js | 48 + clis/zlibrary/search.js | 47 + clis/zlibrary/utils.js | 136 + clis/zsxq/auth.js | 68 + clis/zsxq/dynamics.js | 48 + clis/zsxq/groups.js | 33 + clis/zsxq/search.js | 44 + clis/zsxq/search.test.js | 24 + clis/zsxq/topic.js | 50 + clis/zsxq/topic.test.js | 30 + clis/zsxq/topics.js | 26 + clis/zsxq/topics.test.js | 24 + clis/zsxq/utils.js | 233 + clis/zsxq/utils.test.js | 31 + designs/self-repair-protocol.md | 151 + docs/.vitepress/config.mts | 289 + docs/adapters/browser/12306.md | 211 + docs/adapters/browser/1688.md | 61 + docs/adapters/browser/1point3acres.md | 77 + docs/adapters/browser/36kr.md | 48 + docs/adapters/browser/51job.md | 38 + docs/adapters/browser/aibase.md | 24 + docs/adapters/browser/amazon.md | 55 + docs/adapters/browser/apple-podcasts.md | 28 + docs/adapters/browser/archive.md | 81 + docs/adapters/browser/arxiv.md | 52 + docs/adapters/browser/autohome.md | 62 + docs/adapters/browser/baidu-scholar.md | 21 + docs/adapters/browser/band.md | 63 + docs/adapters/browser/barchart.md | 33 + docs/adapters/browser/bbc.md | 54 + docs/adapters/browser/bilibili.md | 105 + docs/adapters/browser/binance.md | 52 + docs/adapters/browser/bloomberg.md | 81 + docs/adapters/browser/bluesky.md | 53 + docs/adapters/browser/booking.md | 92 + docs/adapters/browser/boss.md | 28 + docs/adapters/browser/brave.md | 47 + docs/adapters/browser/chaoxing.md | 39 + docs/adapters/browser/chatgpt.md | 104 + docs/adapters/browser/chess.md | 110 + docs/adapters/browser/claude.md | 69 + docs/adapters/browser/cnki.md | 24 + docs/adapters/browser/coingecko.md | 126 + docs/adapters/browser/confluence.md | 75 + docs/adapters/browser/coupang.md | 106 + docs/adapters/browser/crates.md | 62 + docs/adapters/browser/ctrip.md | 123 + docs/adapters/browser/dblp.md | 87 + docs/adapters/browser/deepseek.md | 107 + docs/adapters/browser/defillama.md | 60 + docs/adapters/browser/devto.md | 81 + docs/adapters/browser/dianping.md | 66 + docs/adapters/browser/dictionary.md | 27 + docs/adapters/browser/dockerhub.md | 63 + docs/adapters/browser/dongchedi.md | 80 + docs/adapters/browser/douban.md | 68 + docs/adapters/browser/doubao.md | 40 + docs/adapters/browser/douyin.md | 75 + docs/adapters/browser/duckduckgo.md | 60 + docs/adapters/browser/eastmoney.md | 39 + docs/adapters/browser/endoflife.md | 48 + docs/adapters/browser/facebook.md | 65 + docs/adapters/browser/flathub.md | 62 + docs/adapters/browser/flomo.md | 58 + docs/adapters/browser/gemini.md | 119 + docs/adapters/browser/geogebra.md | 82 + docs/adapters/browser/gitee.md | 34 + docs/adapters/browser/github-trending.md | 40 + docs/adapters/browser/github.md | 34 + docs/adapters/browser/google-scholar.md | 25 + docs/adapters/browser/google.md | 62 + docs/adapters/browser/goproxy.md | 63 + docs/adapters/browser/gov-law.md | 22 + docs/adapters/browser/gov-policy.md | 22 + docs/adapters/browser/grok.md | 159 + docs/adapters/browser/guazi.md | 65 + docs/adapters/browser/hackernews.md | 46 + docs/adapters/browser/hf.md | 103 + docs/adapters/browser/hltv.md | 87 + docs/adapters/browser/homebrew.md | 77 + docs/adapters/browser/huodongxing.md | 39 + docs/adapters/browser/hupu.md | 78 + docs/adapters/browser/imdb.md | 47 + docs/adapters/browser/indeed.md | 89 + docs/adapters/browser/instagram.md | 65 + docs/adapters/browser/jd.md | 27 + docs/adapters/browser/jianyu.md | 42 + docs/adapters/browser/jike.md | 50 + docs/adapters/browser/jimeng.md | 39 + docs/adapters/browser/jira.md | 72 + docs/adapters/browser/juejin.md | 55 + docs/adapters/browser/ke.md | 38 + docs/adapters/browser/kimi.md | 101 + docs/adapters/browser/lesswrong.md | 56 + docs/adapters/browser/lichess.md | 60 + docs/adapters/browser/linkedin-learning.md | 93 + docs/adapters/browser/linkedin.md | 179 + docs/adapters/browser/linux-do.md | 205 + docs/adapters/browser/lobsters.md | 50 + docs/adapters/browser/maimai.md | 53 + docs/adapters/browser/manus.md | 54 + docs/adapters/browser/maven.md | 66 + docs/adapters/browser/mdn.md | 55 + docs/adapters/browser/medium.md | 45 + docs/adapters/browser/mercury.md | 167 + docs/adapters/browser/mubu.md | 61 + docs/adapters/browser/notebooklm.md | 86 + docs/adapters/browser/nowcoder.md | 63 + docs/adapters/browser/npm.md | 79 + docs/adapters/browser/nuget.md | 61 + docs/adapters/browser/nvd.md | 57 + docs/adapters/browser/oeis.md | 69 + docs/adapters/browser/ones.md | 59 + docs/adapters/browser/openalex.md | 67 + docs/adapters/browser/openfda.md | 66 + docs/adapters/browser/openreview.md | 79 + docs/adapters/browser/osv.md | 67 + docs/adapters/browser/packagist.md | 64 + docs/adapters/browser/paperreview.md | 43 + docs/adapters/browser/pixiv.md | 104 + docs/adapters/browser/powerchina.md | 36 + docs/adapters/browser/producthunt.md | 49 + docs/adapters/browser/pubmed.md | 76 + docs/adapters/browser/pypi.md | 62 + docs/adapters/browser/quark.md | 68 + docs/adapters/browser/qwen.md | 128 + docs/adapters/browser/reddit.md | 92 + docs/adapters/browser/rednote.md | 60 + docs/adapters/browser/rest-countries.md | 71 + docs/adapters/browser/reuters.md | 58 + docs/adapters/browser/rfc.md | 49 + docs/adapters/browser/rubygems.md | 62 + docs/adapters/browser/semanticscholar.md | 77 + docs/adapters/browser/sinablog.md | 36 + docs/adapters/browser/sinafinance.md | 115 + docs/adapters/browser/slock.md | 156 + docs/adapters/browser/smzdm.md | 27 + docs/adapters/browser/spotify.md | 62 + docs/adapters/browser/stackoverflow.md | 110 + docs/adapters/browser/steam.md | 54 + docs/adapters/browser/substack.md | 38 + docs/adapters/browser/suno.md | 90 + docs/adapters/browser/taobao.md | 31 + docs/adapters/browser/tdx.md | 39 + docs/adapters/browser/ths.md | 39 + docs/adapters/browser/tieba.md | 45 + docs/adapters/browser/tiktok.md | 258 + docs/adapters/browser/toutiao.md | 58 + docs/adapters/browser/tvmaze.md | 57 + docs/adapters/browser/twitter.md | 95 + docs/adapters/browser/uisdc.md | 24 + docs/adapters/browser/uiverse.md | 52 + docs/adapters/browser/upwork.md | 94 + docs/adapters/browser/v2ex.md | 53 + docs/adapters/browser/wanfang.md | 21 + docs/adapters/browser/web.md | 52 + docs/adapters/browser/wechat-channels.md | 67 + docs/adapters/browser/weibo.md | 70 + docs/adapters/browser/weixin.md | 62 + docs/adapters/browser/weread-official.md | 138 + docs/adapters/browser/weread.md | 48 + docs/adapters/browser/wikidata.md | 62 + docs/adapters/browser/wikipedia.md | 45 + docs/adapters/browser/wttr.md | 61 + docs/adapters/browser/xianyu.md | 68 + docs/adapters/browser/xiaoe.md | 88 + docs/adapters/browser/xiaohongshu.md | 83 + docs/adapters/browser/xiaoyuzhou.md | 55 + docs/adapters/browser/xueqiu.md | 65 + docs/adapters/browser/yahoo-finance.md | 27 + docs/adapters/browser/yahoo.md | 49 + docs/adapters/browser/yollomi.md | 69 + docs/adapters/browser/youdao.md | 41 + docs/adapters/browser/youtube.md | 48 + docs/adapters/browser/yuanbao.md | 108 + docs/adapters/browser/zhihu.md | 89 + docs/adapters/browser/zlibrary.md | 28 + docs/adapters/browser/zsxq.md | 49 + docs/adapters/desktop/antigravity.md | 67 + docs/adapters/desktop/chatgpt-app.md | 49 + docs/adapters/desktop/chatwise.md | 38 + docs/adapters/desktop/codex.md | 58 + docs/adapters/desktop/cursor.md | 37 + docs/adapters/desktop/discord.md | 65 + docs/adapters/desktop/doubao-app.md | 35 + docs/adapters/desktop/qoder.md | 57 + docs/adapters/desktop/trae-cn.md | 127 + docs/adapters/desktop/trae-solo.md | 61 + docs/adapters/index.md | 184 + docs/advanced/android-chrome.md | 178 + docs/advanced/cdp.md | 103 + docs/advanced/download.md | 81 + docs/advanced/electron.md | 126 + docs/advanced/rate-limiter-plugin.md | 99 + docs/advanced/remote-chrome.md | 64 + docs/comparison.md | 124 + docs/conventions/convention-audit.md | 72 + docs/conventions/listing-detail-id-pairing.md | 134 + docs/design/browser-agent-runtime.md | 503 + docs/design/mercury-fix-mvp.md | 268 + docs/developer/ai-workflow.md | 65 + docs/developer/architecture.md | 163 + docs/developer/contributing.md | 120 + docs/developer/documentation-audit-2026-05.md | 368 + docs/developer/testing.md | 157 + docs/developer/ts-adapter.md | 156 + docs/developer/yaml-adapter.md | 5 + docs/guide/browser-bridge.md | 94 + docs/guide/electron-app-cli.md | 199 + docs/guide/exit-codes.md | 24 + docs/guide/extending-opencli.md | 134 + docs/guide/getting-started.md | 81 + docs/guide/installation.md | 52 + docs/guide/plugins.md | 226 + docs/guide/remote-orchestration.md | 116 + docs/guide/troubleshooting.md | 69 + docs/index.md | 35 + .../plans/2026-03-28-perf-smart-wait.md | 1143 + .../2026-03-31-daemon-lifecycle-redesign.md | 857 + .../2026-03-28-perf-smart-wait-design.md | 170 + .../2026-03-31-daemon-lifecycle-redesign.md | 207 + .../2026-04-02-browse-skill-testing-design.md | 144 + .../2026-04-03-v2ex-autoresearch-design.md | 41 + docs/zh/adapters/index.md | 5 + docs/zh/advanced/android-chrome.md | 178 + docs/zh/advanced/cdp.md | 3 + docs/zh/developer/contributing.md | 24 + docs/zh/guide/browser-bridge.md | 77 + docs/zh/guide/electron-app-cli.md | 187 + docs/zh/guide/exit-codes.md | 26 + docs/zh/guide/extending-opencli.md | 132 + docs/zh/guide/getting-started.md | 63 + docs/zh/guide/installation.md | 52 + docs/zh/guide/plugins.md | 192 + docs/zh/index.md | 35 + extension/.gitignore | 1 + extension/README.md | 26 + extension/bun.lock | 151 + extension/dist/background.js | 2454 + extension/icons/icon-128.png | Bin 0 -> 15333 bytes extension/icons/icon-16.png | Bin 0 -> 628 bytes extension/icons/icon-32.png | Bin 0 -> 1828 bytes extension/icons/icon-48.png | Bin 0 -> 3490 bytes extension/manifest.json | 41 + extension/package-lock.json | 1156 + extension/package.json | 20 + extension/popup.html | 146 + extension/popup.js | 77 + extension/scripts/package-release.mjs | 179 + extension/src/background.test.ts | 2265 + extension/src/background.ts | 2308 + extension/src/cdp.test.ts | 656 + extension/src/cdp.ts | 937 + extension/src/identity.ts | 71 + extension/src/journal.test.ts | 121 + extension/src/journal.ts | 146 + extension/src/protocol.ts | 129 + .../store-assets/screenshot-1280x800.png | Bin 0 -> 578421 bytes extension/tsconfig.json | 15 + extension/vite.config.ts | 25 + llms.txt | 84 + package-lock.json | 5090 ++ package.json | 109 + scripts/check-doc-coverage.sh | 81 + scripts/check-listing-id-pairing.mjs | 193 + scripts/check-silent-column-drop.mjs | 105 + scripts/check-typed-error-lint.mjs | 118 + scripts/clean-dist.cjs | 13 + scripts/copy-yaml.cjs | 12 + scripts/fetch-adapters.js | 293 + scripts/postinstall.js | 174 + scripts/silent-column-drop-baseline.json | 910 + scripts/typed-error-lint-baseline.json | 1098 + sitemaps/hackernews/SITE.md | 40 + sitemaps/hackernews/apis.md | 62 + sitemaps/hackernews/pages/feed.md | 39 + sitemaps/hackernews/pages/front.md | 49 + sitemaps/hackernews/pages/item.md | 53 + sitemaps/hackernews/pages/user.md | 34 + sitemaps/hackernews/pitfalls.md | 37 + sitemaps/hackernews/workflows/read-story.md | 57 + sitemaps/hackernews/workflows/submit-story.md | 60 + sitemaps/hackernews/workflows/upvote.md | 53 + sitemaps/twitter/SITE.md | 40 + sitemaps/twitter/apis.md | 53 + sitemaps/twitter/pages/_tweet_card.md | 78 + sitemaps/twitter/pages/compose.md | 58 + sitemaps/twitter/pages/home.md | 63 + sitemaps/twitter/pages/profile.md | 56 + sitemaps/twitter/pages/status.md | 52 + sitemaps/twitter/pitfalls.md | 52 + sitemaps/twitter/workflows/bookmark.md | 64 + sitemaps/twitter/workflows/post.md | 78 + sitemaps/twitter/workflows/reply.md | 70 + sitemaps/twitter/workflows/search.md | 67 + sitemaps/xiaohongshu/SITE.md | 63 + sitemaps/xiaohongshu/apis.md | 57 + sitemaps/xiaohongshu/pages/_note_card.md | 47 + sitemaps/xiaohongshu/pages/compose.md | 79 + sitemaps/xiaohongshu/pages/explore.md | 55 + sitemaps/xiaohongshu/pages/note.md | 57 + sitemaps/xiaohongshu/pages/profile.md | 50 + sitemaps/xiaohongshu/pitfalls.md | 65 + sitemaps/xiaohongshu/workflows/comment.md | 86 + sitemaps/xiaohongshu/workflows/publish.md | 87 + sitemaps/xiaohongshu/workflows/search.md | 78 + skills/opencli-adapter-author/SKILL.md | 256 + .../references/adapter-template.md | 471 + .../references/api-discovery.md | 295 + .../references/coverage-matrix.md | 81 + .../references/field-conventions.md | 172 + .../references/field-decode-playbook.md | 181 + .../references/jsdom-fixture-pattern.md | 196 + .../references/output-design.md | 149 + .../references/site-memory.md | 231 + .../references/site-memory/bilibili.md | 70 + .../references/site-memory/eastmoney.md | 70 + .../references/site-memory/tonghuashun.md | 51 + .../references/site-memory/xueqiu.md | 58 + .../references/site-recon.md | 134 + .../references/strategy-selection.md | 169 + .../references/success-rate-pitfalls.md | 162 + .../references/typed-errors.md | 234 + skills/opencli-autofix/SKILL.md | 297 + skills/opencli-browser-sitemap/SKILL.md | 96 + skills/opencli-browser/SKILL.md | 444 + skills/opencli-sitemap-author/SKILL.md | 161 + .../references/sitemap-schema.md | 660 + skills/opencli-usage/SKILL.md | 170 + skills/smart-search/SKILL.md | 156 + skills/smart-search/references/sources-ai.md | 74 + .../smart-search/references/sources-info.md | 43 + .../smart-search/references/sources-media.md | 50 + .../smart-search/references/sources-other.md | 42 + .../references/sources-shopping.md | 31 + .../smart-search/references/sources-social.md | 51 + .../smart-search/references/sources-tech.md | 42 + .../smart-search/references/sources-travel.md | 20 + src/adapter-shadow.test.ts | 53 + src/adapter-shadow.ts | 87 + src/adapter-source.test.ts | 35 + src/adapter-source.ts | 28 + src/browser.test.ts | 345 + .../__fixtures__/article-extract/deno-v2.html | 501 + .../openai-cookbook-readme.txt | 20 + .../article-extract/wikipedia-markdown.html | 1249 + src/browser/analyze.test.ts | 263 + src/browser/analyze.ts | 532 + src/browser/article-extract.e2e.test.ts | 123 + src/browser/article-extract.test.ts | 110 + src/browser/article-extract.ts | 203 + src/browser/ax-snapshot.test.ts | 99 + src/browser/ax-snapshot.ts | 271 + src/browser/base-page.test.ts | 941 + src/browser/base-page.ts | 1285 + src/browser/bridge-readiness.test.ts | 76 + src/browser/bridge-readiness.ts | 36 + src/browser/bridge.ts | 71 + src/browser/cdp-click-fixture.test.ts | 100 + src/browser/cdp.test.ts | 99 + src/browser/cdp.ts | 585 + src/browser/compound.test.ts | 203 + src/browser/compound.ts | 149 + src/browser/config.ts | 15 + src/browser/daemon-client.test.ts | 696 + src/browser/daemon-client.ts | 502 + src/browser/daemon-lifecycle.ts | 204 + src/browser/daemon-transport.ts | 98 + src/browser/daemon-version.ts | 15 + src/browser/dom-helpers.test.ts | 115 + src/browser/dom-helpers.ts | 250 + src/browser/dom-snapshot.test.ts | 362 + src/browser/dom-snapshot.ts | 936 + src/browser/errors.test.ts | 71 + src/browser/errors.ts | 157 + src/browser/extract.test.ts | 142 + src/browser/extract.ts | 170 + src/browser/find.test.ts | 206 + src/browser/find.ts | 455 + src/browser/html-tree.test.ts | 229 + src/browser/html-tree.ts | 153 + src/browser/index.ts | 15 + src/browser/network-cache.test.ts | 97 + src/browser/network-cache.ts | 121 + src/browser/network-interceptor.ts | 11 + src/browser/network-key.test.ts | 55 + src/browser/network-key.ts | 69 + src/browser/page.test.ts | 509 + src/browser/page.ts | 471 + src/browser/profile.test.ts | 57 + src/browser/profile.ts | 128 + src/browser/shape-filter.test.ts | 128 + src/browser/shape-filter.ts | 114 + src/browser/shape.test.ts | 91 + src/browser/shape.ts | 93 + src/browser/stealth.test.ts | 153 + src/browser/stealth.ts | 360 + src/browser/tabs.ts | 76 + src/browser/target-errors.test.ts | 110 + src/browser/target-errors.ts | 68 + src/browser/target-resolver.test.ts | 274 + src/browser/target-resolver.ts | 701 + src/browser/utils.test.ts | 36 + src/browser/utils.ts | 70 + src/browser/verify-fixture.test.ts | 289 + src/browser/verify-fixture.ts | 375 + src/browser/visual-refs.ts | 111 + src/build-manifest.test.ts | 385 + src/build-manifest.ts | 451 + src/capabilityRouting.test.ts | 71 + src/capabilityRouting.ts | 55 + src/cli-argv-preprocess.test.ts | 341 + src/cli-argv-preprocess.ts | 306 + src/cli.test.ts | 3477 ++ src/cli.ts | 3642 ++ src/commanderAdapter.test.ts | 454 + src/commanderAdapter.ts | 229 + src/commands/auth.test.ts | 297 + src/commands/auth.ts | 518 + src/commands/daemon.test.ts | 339 + src/commands/daemon.ts | 102 + src/completion-fast.ts | 106 + src/completion-shared.ts | 65 + src/completion.test.ts | 31 + src/completion.ts | 82 + src/constants.ts | 59 + src/convention-audit.test.ts | 276 + src/convention-audit.ts | 614 + src/daemon-utils.ts | 127 + src/daemon.test.ts | 130 + src/daemon.ts | 638 + src/discovery.ts | 256 + src/doctor.test.ts | 348 + src/doctor.ts | 308 + src/download/article-download.test.ts | 300 + src/download/article-download.ts | 414 + src/download/index.test.ts | 200 + src/download/index.ts | 439 + src/download/media-download.test.ts | 122 + src/download/media-download.ts | 196 + src/download/progress.test.ts | 45 + src/download/progress.ts | 134 + src/electron-apps.test.ts | 89 + src/electron-apps.ts | 117 + src/engine.test.ts | 338 + src/errors.test.ts | 135 + src/errors.ts | 284 + src/execution.test.ts | 899 + src/execution.ts | 639 + src/extension-manifest-regression.test.ts | 17 + src/external-clis.yaml | 109 + src/external.test.ts | 199 + src/external.ts | 287 + src/help.test.ts | 66 + src/help.ts | 656 + src/hooks.test.ts | 126 + src/hooks.ts | 91 + src/interceptor.test.ts | 94 + src/interceptor.ts | 201 + src/launcher.test.ts | 198 + src/launcher.ts | 283 + src/logger.ts | 59 + src/main.ts | 181 + src/manifest-types.ts | 44 + src/node-network.test.ts | 93 + src/node-network.ts | 213 + src/observation/artifact.test.ts | 135 + src/observation/artifact.ts | 298 + src/observation/events.ts | 112 + src/observation/index.ts | 7 + src/observation/manager.test.ts | 15 + src/observation/manager.ts | 34 + src/observation/redaction.test.ts | 35 + src/observation/redaction.ts | 91 + src/observation/retention.test.ts | 137 + src/observation/retention.ts | 202 + src/observation/ring-buffer.test.ts | 25 + src/observation/ring-buffer.ts | 53 + src/observation/session.ts | 66 + src/output.test.ts | 62 + src/output.ts | 151 + src/package-exports.test.ts | 124 + src/package-paths.ts | 58 + src/pipeline/executor.test.ts | 202 + src/pipeline/executor.ts | 110 + src/pipeline/index.ts | 6 + src/pipeline/registry.ts | 75 + src/pipeline/steps/browser.ts | 86 + src/pipeline/steps/download.test.ts | 138 + src/pipeline/steps/download.ts | 344 + src/pipeline/steps/fetch.test.ts | 175 + src/pipeline/steps/fetch.ts | 140 + src/pipeline/steps/intercept.ts | 67 + src/pipeline/steps/tap.ts | 116 + src/pipeline/steps/transform.ts | 71 + src/pipeline/template.test.ts | 204 + src/pipeline/template.ts | 340 + src/pipeline/transform.test.ts | 156 + src/plugin-manifest.test.ts | 223 + src/plugin-manifest.ts | 206 + src/plugin-scaffold.test.ts | 98 + src/plugin-scaffold.ts | 171 + src/plugin.test.ts | 1469 + src/plugin.ts | 1578 + src/registry-api.ts | 14 + src/registry.test.ts | 241 + src/registry.ts | 252 + src/runtime-detect.test.ts | 44 + src/runtime-detect.ts | 58 + src/runtime.ts | 83 + src/scripts/framework.ts | 39 + src/scripts/interact.ts | 22 + src/scripts/store.ts | 62 + src/serialization.test.ts | 87 + src/serialization.ts | 116 + src/session-lease.test.ts | 257 + src/session-lease.ts | 215 + src/skills.test.ts | 78 + src/skills.ts | 153 + src/snapshotFormatter.test.ts | 579 + src/snapshotFormatter.ts | 359 + src/tui.test.ts | 23 + src/tui.ts | 223 + src/types.ts | 157 + src/types/turndown-plugin-gfm.d.ts | 10 + src/update-check.test.ts | 137 + src/update-check.ts | 236 + src/utils.test.ts | 246 + src/utils.ts | 212 + src/validate.test.ts | 94 + src/validate.ts | 153 + src/verify.ts | 93 + src/version.ts | 25 + src/weixin-download.test.ts | 92 + tests/e2e/article-download-pipeline.test.ts | 174 + tests/e2e/band-auth.test.ts | 20 + tests/e2e/browser-auth-helpers.ts | 18 + tests/e2e/browser-auth.test.ts | 183 + tests/e2e/browser-ax-chrome.test.ts | 425 + tests/e2e/browser-public-extended.test.ts | 193 + tests/e2e/browser-public.test.ts | 419 + tests/e2e/browser-tabs.test.ts | 379 + tests/e2e/daemon-transport.test.ts | 323 + tests/e2e/douban.test.ts | 52 + tests/e2e/helpers.ts | 78 + tests/e2e/management.test.ts | 106 + tests/e2e/output-formats.test.ts | 44 + tests/e2e/plugin-management.test.ts | 140 + tests/e2e/public-commands.test.ts | 524 + tests/smoke/api-health.test.ts | 131 + tsconfig.json | 19 + vitest.config.ts | 68 + 2400 files changed, 376116 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/new_site_adapter.yml create mode 100644 .github/actions/setup-chrome/action.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/build-extension.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/doc-check.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/e2e-headed.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/security.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 PRIVACY.md create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README.zh-CN.md create mode 100644 TESTING.md create mode 100644 autoresearch/baseline-browse.txt create mode 100644 autoresearch/baseline-skill.txt create mode 100644 autoresearch/browse-tasks.json create mode 100644 autoresearch/commands/debug.ts create mode 100644 autoresearch/commands/fix.ts create mode 100644 autoresearch/commands/plan.ts create mode 100644 autoresearch/commands/run.ts create mode 100644 autoresearch/config.ts create mode 100644 autoresearch/engine.ts create mode 100644 autoresearch/eval-all.ts create mode 100644 autoresearch/eval-browse.ts create mode 100644 autoresearch/eval-publish.ts create mode 100644 autoresearch/eval-save.ts create mode 100644 autoresearch/eval-skill.ts create mode 100644 autoresearch/eval-v2ex.ts create mode 100644 autoresearch/eval-zhihu.ts create mode 100644 autoresearch/logger.ts create mode 100644 autoresearch/presets/browser-reliability.ts create mode 100644 autoresearch/presets/combined-reliability.ts create mode 100644 autoresearch/presets/index.ts create mode 100644 autoresearch/presets/save-reliability.ts create mode 100644 autoresearch/presets/skill-quality.ts create mode 100644 autoresearch/presets/v2ex-reliability.ts create mode 100644 autoresearch/presets/zhihu-reliability.ts create mode 100644 autoresearch/publish-tasks.json create mode 100755 autoresearch/run-browse.sh create mode 100755 autoresearch/run-save.sh create mode 100755 autoresearch/run-skill.sh create mode 100644 autoresearch/save-adapters/xhs-explore-deep.ts create mode 100644 autoresearch/save-adapters/xhs-note-comments.ts create mode 100644 autoresearch/save-adapters/xhs-search-full.ts create mode 100644 autoresearch/save-adapters/zhihu-hot-detail.ts create mode 100644 autoresearch/save-adapters/zhihu-question-full.ts create mode 100644 autoresearch/save-adapters/zhihu-search-detail.ts create mode 100644 autoresearch/save-tasks.json create mode 100644 autoresearch/v2ex-tasks.json create mode 100644 autoresearch/zhihu-tasks.json create mode 100644 bun.lock create mode 100644 cases/README.md create mode 100644 cases/daily-rl-research-monitor.md create mode 100644 cases/find-paper-implementation.md create mode 100644 cases/track-conference-papers.md create mode 100644 cli-manifest.json create mode 100644 clis/12306/auth.js create mode 100644 clis/12306/me.js create mode 100644 clis/12306/orders.js create mode 100644 clis/12306/passengers.js create mode 100644 clis/12306/price.js create mode 100644 clis/12306/stations.js create mode 100644 clis/12306/train.js create mode 100644 clis/12306/trains.js create mode 100644 clis/12306/utils.js create mode 100644 clis/12306/utils.test.js create mode 100644 clis/1688/assets.js create mode 100644 clis/1688/assets.test.js create mode 100644 clis/1688/auth.js create mode 100644 clis/1688/download.js create mode 100644 clis/1688/download.test.js create mode 100644 clis/1688/item.js create mode 100644 clis/1688/item.test.js create mode 100644 clis/1688/search.js create mode 100644 clis/1688/search.test.js create mode 100644 clis/1688/shared.js create mode 100644 clis/1688/shared.test.js create mode 100644 clis/1688/store.js create mode 100644 clis/1688/store.test.js create mode 100644 clis/1point3acres/auth.js create mode 100644 clis/1point3acres/digest.js create mode 100644 clis/1point3acres/forum.js create mode 100644 clis/1point3acres/forums.js create mode 100644 clis/1point3acres/hot.js create mode 100644 clis/1point3acres/latest.js create mode 100644 clis/1point3acres/notifications.js create mode 100644 clis/1point3acres/search.js create mode 100644 clis/1point3acres/thread.js create mode 100644 clis/1point3acres/user.js create mode 100644 clis/1point3acres/utils.js create mode 100644 clis/36kr/article.js create mode 100644 clis/36kr/article.test.js create mode 100644 clis/36kr/hot.js create mode 100644 clis/36kr/hot.test.js create mode 100644 clis/36kr/news.js create mode 100644 clis/36kr/news.test.js create mode 100644 clis/36kr/search.js create mode 100644 clis/51job/company.js create mode 100644 clis/51job/detail.js create mode 100644 clis/51job/hot.js create mode 100644 clis/51job/search.js create mode 100644 clis/51job/utils.js create mode 100644 clis/51job/utils.test.js create mode 100644 clis/_atlassian/shared.js create mode 100644 clis/_atlassian/shared.test.js create mode 100644 clis/_shared/common.js create mode 100644 clis/_shared/desktop-commands.js create mode 100644 clis/_shared/search-adapter.js create mode 100644 clis/_shared/site-auth.js create mode 100644 clis/_shared/site-auth.test.js create mode 100644 clis/aibase/news.js create mode 100644 clis/aibase/news.test.js create mode 100644 clis/amazon/auth.js create mode 100644 clis/amazon/bestsellers.js create mode 100644 clis/amazon/bestsellers.test.js create mode 100644 clis/amazon/discussion.js create mode 100644 clis/amazon/discussion.test.js create mode 100644 clis/amazon/movers-shakers.js create mode 100644 clis/amazon/new-releases.js create mode 100644 clis/amazon/offer.js create mode 100644 clis/amazon/offer.test.js create mode 100644 clis/amazon/product.js create mode 100644 clis/amazon/product.test.js create mode 100644 clis/amazon/rankings.js create mode 100644 clis/amazon/rankings.test.js create mode 100644 clis/amazon/search.js create mode 100644 clis/amazon/search.test.js create mode 100644 clis/amazon/shared.js create mode 100644 clis/amazon/shared.test.js create mode 100644 clis/antigravity/SKILL.md create mode 100644 clis/antigravity/_actions.js create mode 100644 clis/antigravity/antigravity.test.js create mode 100644 clis/antigravity/audit-extras.js create mode 100644 clis/antigravity/delete.js create mode 100644 clis/antigravity/dump.js create mode 100644 clis/antigravity/extract-code.js create mode 100644 clis/antigravity/history.js create mode 100644 clis/antigravity/mark-read.js create mode 100644 clis/antigravity/model.js create mode 100644 clis/antigravity/new.js create mode 100644 clis/antigravity/read.js create mode 100644 clis/antigravity/rename.js create mode 100644 clis/antigravity/send.js create mode 100644 clis/antigravity/serve.js create mode 100644 clis/antigravity/status.js create mode 100644 clis/antigravity/storage.js create mode 100644 clis/antigravity/watch.js create mode 100644 clis/apple-podcasts/commands.test.js create mode 100644 clis/apple-podcasts/episodes.js create mode 100644 clis/apple-podcasts/search.js create mode 100644 clis/apple-podcasts/top.js create mode 100644 clis/apple-podcasts/utils.js create mode 100644 clis/apple-podcasts/utils.test.js create mode 100644 clis/archive/archive.test.js create mode 100644 clis/archive/item.js create mode 100644 clis/archive/search.js create mode 100644 clis/archive/snapshots.js create mode 100644 clis/archive/wayback.js create mode 100644 clis/arxiv/arxiv.test.js create mode 100644 clis/arxiv/author.js create mode 100644 clis/arxiv/paper.js create mode 100644 clis/arxiv/recent.js create mode 100644 clis/arxiv/search.js create mode 100644 clis/arxiv/utils.js create mode 100644 clis/autohome/__fixtures__/catalog.html create mode 100644 clis/autohome/__fixtures__/koubei.json create mode 100644 clis/autohome/autohome.test.js create mode 100644 clis/autohome/brand.js create mode 100644 clis/autohome/score.js create mode 100644 clis/autohome/utils.js create mode 100644 clis/baidu-scholar/search.js create mode 100644 clis/baidu-scholar/search.test.js create mode 100644 clis/band/auth.js create mode 100644 clis/band/bands.js create mode 100644 clis/band/mentions.js create mode 100644 clis/band/post.js create mode 100644 clis/band/posts.js create mode 100644 clis/barchart/flow.js create mode 100644 clis/barchart/greeks.js create mode 100644 clis/barchart/greeks.test.js create mode 100644 clis/barchart/options.js create mode 100644 clis/barchart/quote.js create mode 100644 clis/bbc/news.js create mode 100644 clis/bbc/topic.js create mode 100644 clis/bbc/utils.js create mode 100644 clis/bilibili/auth.js create mode 100644 clis/bilibili/comment.js create mode 100644 clis/bilibili/comment.test.js create mode 100644 clis/bilibili/comments.js create mode 100644 clis/bilibili/comments.test.js create mode 100644 clis/bilibili/download.js create mode 100644 clis/bilibili/download.test.js create mode 100644 clis/bilibili/dynamic.js create mode 100644 clis/bilibili/dynamic.test.js create mode 100644 clis/bilibili/favorite.js create mode 100644 clis/bilibili/feed.js create mode 100644 clis/bilibili/follow.js create mode 100644 clis/bilibili/follow.test.js create mode 100644 clis/bilibili/following.js create mode 100644 clis/bilibili/history.js create mode 100644 clis/bilibili/hot.js create mode 100644 clis/bilibili/hot.test.js create mode 100644 clis/bilibili/me.js create mode 100644 clis/bilibili/ranking.js create mode 100644 clis/bilibili/search.js create mode 100644 clis/bilibili/subtitle.js create mode 100644 clis/bilibili/subtitle.test.js create mode 100644 clis/bilibili/summary.js create mode 100644 clis/bilibili/summary.test.js create mode 100644 clis/bilibili/unfollow.js create mode 100644 clis/bilibili/user-videos.js create mode 100644 clis/bilibili/utils.js create mode 100644 clis/bilibili/utils.test.js create mode 100644 clis/bilibili/video.js create mode 100644 clis/bilibili/video.test.js create mode 100644 clis/binance/asks.js create mode 100644 clis/binance/commands.test.js create mode 100644 clis/binance/depth.js create mode 100644 clis/binance/gainers.js create mode 100644 clis/binance/klines.js create mode 100644 clis/binance/losers.js create mode 100644 clis/binance/pairs.js create mode 100644 clis/binance/price.js create mode 100644 clis/binance/prices.js create mode 100644 clis/binance/ticker.js create mode 100644 clis/binance/top.js create mode 100644 clis/binance/trades.js create mode 100644 clis/bloomberg/businessweek.js create mode 100644 clis/bloomberg/businessweek.test.js create mode 100644 clis/bloomberg/crypto.js create mode 100644 clis/bloomberg/economics.js create mode 100644 clis/bloomberg/feeds.js create mode 100644 clis/bloomberg/green.js create mode 100644 clis/bloomberg/industries.js create mode 100644 clis/bloomberg/main.js create mode 100644 clis/bloomberg/markets.js create mode 100644 clis/bloomberg/news.js create mode 100644 clis/bloomberg/opinions.js create mode 100644 clis/bloomberg/politics.js create mode 100644 clis/bloomberg/pursuits.js create mode 100644 clis/bloomberg/tech.js create mode 100644 clis/bloomberg/utils.js create mode 100644 clis/bloomberg/utils.test.js create mode 100644 clis/bluesky/feeds.js create mode 100644 clis/bluesky/followers.js create mode 100644 clis/bluesky/following.js create mode 100644 clis/bluesky/profile.js create mode 100644 clis/bluesky/search.js create mode 100644 clis/bluesky/starter-packs.js create mode 100644 clis/bluesky/thread.js create mode 100644 clis/bluesky/trending.js create mode 100644 clis/bluesky/user.js create mode 100644 clis/booking/booking.test.js create mode 100644 clis/booking/search.js create mode 100644 clis/boss/auth.js create mode 100644 clis/boss/batchgreet.js create mode 100644 clis/boss/chatlist.js create mode 100644 clis/boss/chatlist.test.js create mode 100644 clis/boss/chatmsg.js create mode 100644 clis/boss/chatmsg.test.js create mode 100644 clis/boss/detail.js create mode 100644 clis/boss/exchange.js create mode 100644 clis/boss/greet.js create mode 100644 clis/boss/invite.js create mode 100644 clis/boss/joblist.js create mode 100644 clis/boss/mark.js create mode 100644 clis/boss/recommend.js create mode 100644 clis/boss/resume.js create mode 100644 clis/boss/search.js create mode 100644 clis/boss/search.test.js create mode 100644 clis/boss/send.js create mode 100644 clis/boss/stats.js create mode 100644 clis/boss/utils.js create mode 100644 clis/boss/utils.test.js create mode 100644 clis/brave/search.js create mode 100644 clis/brave/search.test.js create mode 100644 clis/chaoxing/assignments.js create mode 100644 clis/chaoxing/auth.js create mode 100644 clis/chaoxing/exams.js create mode 100644 clis/chaoxing/utils.js create mode 100644 clis/chaoxing/utils.test.js create mode 100644 clis/chatgpt-app/ask.js create mode 100644 clis/chatgpt-app/ax.js create mode 100644 clis/chatgpt-app/ax.test.js create mode 100644 clis/chatgpt-app/commands.test.js create mode 100644 clis/chatgpt-app/model.js create mode 100644 clis/chatgpt-app/new.js create mode 100644 clis/chatgpt-app/read.js create mode 100644 clis/chatgpt-app/send.js create mode 100644 clis/chatgpt-app/status.js create mode 100644 clis/chatgpt/ask.js create mode 100644 clis/chatgpt/auth.js create mode 100644 clis/chatgpt/commands.test.js create mode 100644 clis/chatgpt/deep-research-result.js create mode 100644 clis/chatgpt/detail.js create mode 100644 clis/chatgpt/envelope.test.js create mode 100644 clis/chatgpt/history.js create mode 100644 clis/chatgpt/image.js create mode 100644 clis/chatgpt/image.test.js create mode 100644 clis/chatgpt/model.js create mode 100644 clis/chatgpt/model.test.js create mode 100644 clis/chatgpt/new.js create mode 100644 clis/chatgpt/project-file-add.js create mode 100644 clis/chatgpt/project-list.js create mode 100644 clis/chatgpt/read.js create mode 100644 clis/chatgpt/send.js create mode 100644 clis/chatgpt/status.js create mode 100644 clis/chatgpt/utils.js create mode 100644 clis/chatgpt/utils.test.js create mode 100644 clis/chatwise/ask.js create mode 100644 clis/chatwise/composer.test.js create mode 100644 clis/chatwise/export.js create mode 100644 clis/chatwise/history.js create mode 100644 clis/chatwise/model.js create mode 100644 clis/chatwise/new.js create mode 100644 clis/chatwise/read.js create mode 100644 clis/chatwise/screenshot.js create mode 100644 clis/chatwise/send.js create mode 100644 clis/chatwise/status.js create mode 100644 clis/chatwise/utils.js create mode 100644 clis/chess/analyze.js create mode 100644 clis/chess/analyze.test.js create mode 100644 clis/chess/game.js create mode 100644 clis/chess/game.test.js create mode 100644 clis/chess/games.js create mode 100644 clis/chess/games.test.js create mode 100644 clis/chess/stats.js create mode 100644 clis/chess/stats.test.js create mode 100644 clis/chess/utils.js create mode 100644 clis/chess/utils.test.js create mode 100644 clis/claude/ask.js create mode 100644 clis/claude/ask.test.js create mode 100644 clis/claude/auth.js create mode 100644 clis/claude/commands.test.js create mode 100644 clis/claude/detail.js create mode 100644 clis/claude/history.js create mode 100644 clis/claude/new.js create mode 100644 clis/claude/read.js create mode 100644 clis/claude/send.js create mode 100644 clis/claude/status.js create mode 100644 clis/claude/utils.js create mode 100644 clis/claude/utils.test.js create mode 100644 clis/cnki/search.js create mode 100644 clis/cnki/search.test.js create mode 100644 clis/codex/_actions.js create mode 100644 clis/codex/archive.js create mode 100644 clis/codex/ask.js create mode 100644 clis/codex/dump.js create mode 100644 clis/codex/export.js create mode 100644 clis/codex/extract-diff.js create mode 100644 clis/codex/history.js create mode 100644 clis/codex/model.js create mode 100644 clis/codex/new.js create mode 100644 clis/codex/pin.js create mode 100644 clis/codex/projects.js create mode 100644 clis/codex/read.js create mode 100644 clis/codex/rename.js create mode 100644 clis/codex/screenshot.js create mode 100644 clis/codex/send.js create mode 100644 clis/codex/sidebar.js create mode 100644 clis/codex/sidebar.test.js create mode 100644 clis/codex/status.js create mode 100644 clis/coingecko/categories.js create mode 100644 clis/coingecko/coin.js create mode 100644 clis/coingecko/coingecko.test.js create mode 100644 clis/coingecko/derivatives.js create mode 100644 clis/coingecko/exchanges.js create mode 100644 clis/coingecko/global.js create mode 100644 clis/coingecko/top.js create mode 100644 clis/coingecko/trending.js create mode 100644 clis/confluence/commands.test.js create mode 100644 clis/confluence/create.js create mode 100644 clis/confluence/page.js create mode 100644 clis/confluence/search.js create mode 100644 clis/confluence/shared.js create mode 100644 clis/confluence/update.js create mode 100644 clis/coupang/add-to-cart.js create mode 100644 clis/coupang/auth.js create mode 100644 clis/coupang/coupang.test.js create mode 100644 clis/coupang/product.js create mode 100644 clis/coupang/search.js create mode 100644 clis/coupang/utils.js create mode 100644 clis/coupang/utils.test.js create mode 100644 clis/crates/crate.js create mode 100644 clis/crates/search.js create mode 100644 clis/crates/utils.js create mode 100644 clis/ctrip/auth.js create mode 100644 clis/ctrip/ctrip.test.js create mode 100644 clis/ctrip/flight.js create mode 100644 clis/ctrip/hotel-search.js create mode 100644 clis/ctrip/hotel-suggest.js create mode 100644 clis/ctrip/search.js create mode 100644 clis/ctrip/utils.js create mode 100644 clis/cursor/ask.js create mode 100644 clis/cursor/composer.js create mode 100644 clis/cursor/dump.js create mode 100644 clis/cursor/export.js create mode 100644 clis/cursor/extract-code.js create mode 100644 clis/cursor/history.js create mode 100644 clis/cursor/model.js create mode 100644 clis/cursor/new.js create mode 100644 clis/cursor/read.js create mode 100644 clis/cursor/screenshot.js create mode 100644 clis/cursor/send.js create mode 100644 clis/cursor/status.js create mode 100644 clis/dblp/author.js create mode 100644 clis/dblp/dblp.test.js create mode 100644 clis/dblp/paper.js create mode 100644 clis/dblp/search.js create mode 100644 clis/dblp/utils.js create mode 100644 clis/dblp/venue.js create mode 100644 clis/deepseek/ask.js create mode 100644 clis/deepseek/ask.test.js create mode 100644 clis/deepseek/auth.js create mode 100644 clis/deepseek/detail.js create mode 100644 clis/deepseek/detail.test.js create mode 100644 clis/deepseek/history.js create mode 100644 clis/deepseek/new.js create mode 100644 clis/deepseek/read.js create mode 100644 clis/deepseek/send.js create mode 100644 clis/deepseek/send.test.js create mode 100644 clis/deepseek/status.js create mode 100644 clis/deepseek/utils.js create mode 100644 clis/deepseek/utils.test.js create mode 100644 clis/defillama/defillama.test.js create mode 100644 clis/defillama/protocol.js create mode 100644 clis/defillama/protocols.js create mode 100644 clis/defillama/utils.js create mode 100644 clis/devto/devto.test.js create mode 100644 clis/devto/latest.js create mode 100644 clis/devto/read.js create mode 100644 clis/devto/tag.js create mode 100644 clis/devto/top.js create mode 100644 clis/devto/user.js create mode 100644 clis/dianping/__fixtures__/search.html create mode 100644 clis/dianping/__fixtures__/shop.html create mode 100644 clis/dianping/auth.js create mode 100644 clis/dianping/cityResolver.js create mode 100644 clis/dianping/dianping.test.js create mode 100644 clis/dianping/search.js create mode 100644 clis/dianping/shop.js create mode 100644 clis/dianping/utils.js create mode 100644 clis/dictionary/examples.js create mode 100644 clis/dictionary/search.js create mode 100644 clis/dictionary/synonyms.js create mode 100644 clis/discord-app/channels.js create mode 100644 clis/discord-app/commands.test.js create mode 100644 clis/discord-app/delete.js create mode 100644 clis/discord-app/goto.js create mode 100644 clis/discord-app/members.js create mode 100644 clis/discord-app/read.js create mode 100644 clis/discord-app/search.js create mode 100644 clis/discord-app/send.js create mode 100644 clis/discord-app/servers.js create mode 100644 clis/discord-app/status.js create mode 100644 clis/discord-app/thread-read.js create mode 100644 clis/discord-app/threads.js create mode 100644 clis/discord-app/utils.js create mode 100644 clis/dockerhub/image.js create mode 100644 clis/dockerhub/search.js create mode 100644 clis/dockerhub/utils.js create mode 100644 clis/dongchedi/__fixtures__/search.json create mode 100644 clis/dongchedi/__fixtures__/series-detail.json create mode 100644 clis/dongchedi/__fixtures__/series-score.json create mode 100644 clis/dongchedi/dongchedi.test.js create mode 100644 clis/dongchedi/koubei.js create mode 100644 clis/dongchedi/models.js create mode 100644 clis/dongchedi/score.js create mode 100644 clis/dongchedi/search.js create mode 100644 clis/dongchedi/series.js create mode 100644 clis/dongchedi/specs.js create mode 100644 clis/dongchedi/utils.js create mode 100644 clis/douban/auth.js create mode 100644 clis/douban/book-hot.js create mode 100644 clis/douban/download.js create mode 100644 clis/douban/download.test.js create mode 100644 clis/douban/marks.js create mode 100644 clis/douban/movie-hot.js create mode 100644 clis/douban/movie-hot.test.js create mode 100644 clis/douban/photos.js create mode 100644 clis/douban/reviews.js create mode 100644 clis/douban/search.js create mode 100644 clis/douban/search.test.js create mode 100644 clis/douban/subject.js create mode 100644 clis/douban/subject.test.js create mode 100644 clis/douban/top250.js create mode 100644 clis/douban/utils.js create mode 100644 clis/douban/utils.test.js create mode 100644 clis/doubao-app/ask.js create mode 100644 clis/doubao-app/dump.js create mode 100644 clis/doubao-app/new.js create mode 100644 clis/doubao-app/read.js create mode 100644 clis/doubao-app/screenshot.js create mode 100644 clis/doubao-app/send.js create mode 100644 clis/doubao-app/status.js create mode 100644 clis/doubao-app/utils.js create mode 100644 clis/doubao/ask.js create mode 100644 clis/doubao/auth.js create mode 100644 clis/doubao/detail.js create mode 100644 clis/doubao/detail.test.js create mode 100644 clis/doubao/history.js create mode 100644 clis/doubao/history.test.js create mode 100644 clis/doubao/meeting-summary.js create mode 100644 clis/doubao/meeting-transcript.js create mode 100644 clis/doubao/new.js create mode 100644 clis/doubao/read.js create mode 100644 clis/doubao/send.js create mode 100644 clis/doubao/status.js create mode 100644 clis/doubao/utils.js create mode 100644 clis/doubao/utils.test.js create mode 100644 clis/douyin/_shared/browser-fetch.js create mode 100644 clis/douyin/_shared/browser-fetch.test.js create mode 100644 clis/douyin/_shared/creation-id.js create mode 100644 clis/douyin/_shared/creation-id.test.js create mode 100644 clis/douyin/_shared/evaluate-result.js create mode 100644 clis/douyin/_shared/imagex-upload.js create mode 100644 clis/douyin/_shared/imagex-upload.test.js create mode 100644 clis/douyin/_shared/public-api.js create mode 100644 clis/douyin/_shared/sts2.js create mode 100644 clis/douyin/_shared/sts2.test.js create mode 100644 clis/douyin/_shared/text-extra.js create mode 100644 clis/douyin/_shared/text-extra.test.js create mode 100644 clis/douyin/_shared/timing.js create mode 100644 clis/douyin/_shared/timing.test.js create mode 100644 clis/douyin/_shared/tos-upload-short-read.test.js create mode 100644 clis/douyin/_shared/tos-upload.js create mode 100644 clis/douyin/_shared/tos-upload.test.js create mode 100644 clis/douyin/_shared/transcode.js create mode 100644 clis/douyin/_shared/transcode.test.js create mode 100644 clis/douyin/_shared/types.js create mode 100644 clis/douyin/_shared/vod-upload.js create mode 100644 clis/douyin/_shared/vod-upload.test.js create mode 100644 clis/douyin/activities.js create mode 100644 clis/douyin/activities.test.js create mode 100644 clis/douyin/auth.js create mode 100644 clis/douyin/collections.js create mode 100644 clis/douyin/collections.test.js create mode 100644 clis/douyin/delete.js create mode 100644 clis/douyin/delete.test.js create mode 100644 clis/douyin/draft.js create mode 100644 clis/douyin/draft.test.js create mode 100644 clis/douyin/drafts.js create mode 100644 clis/douyin/drafts.test.js create mode 100644 clis/douyin/hashtag.js create mode 100644 clis/douyin/hashtag.test.js create mode 100644 clis/douyin/location.js create mode 100644 clis/douyin/location.test.js create mode 100644 clis/douyin/profile.js create mode 100644 clis/douyin/profile.test.js create mode 100644 clis/douyin/publish-upload-id.test.js create mode 100644 clis/douyin/publish.js create mode 100644 clis/douyin/publish.test.js create mode 100644 clis/douyin/search.js create mode 100644 clis/douyin/search.test.js create mode 100644 clis/douyin/stats.js create mode 100644 clis/douyin/stats.test.js create mode 100644 clis/douyin/update.js create mode 100644 clis/douyin/update.test.js create mode 100644 clis/douyin/user-videos.js create mode 100644 clis/douyin/user-videos.test.js create mode 100644 clis/douyin/videos.js create mode 100644 clis/douyin/videos.test.js create mode 100644 clis/duckduckgo/search.js create mode 100644 clis/duckduckgo/search.test.js create mode 100644 clis/duckduckgo/suggest.js create mode 100644 clis/duckduckgo/suggest.test.js create mode 100644 clis/eastmoney/_secid.js create mode 100644 clis/eastmoney/announcement.js create mode 100644 clis/eastmoney/convertible.js create mode 100644 clis/eastmoney/etf.js create mode 100644 clis/eastmoney/holders.js create mode 100644 clis/eastmoney/hot-rank.js create mode 100644 clis/eastmoney/hot-rank.test.js create mode 100644 clis/eastmoney/index-board.js create mode 100644 clis/eastmoney/kline.js create mode 100644 clis/eastmoney/kuaixun.js create mode 100644 clis/eastmoney/longhu.js create mode 100644 clis/eastmoney/money-flow.js create mode 100644 clis/eastmoney/northbound.js create mode 100644 clis/eastmoney/quote.js create mode 100644 clis/eastmoney/rank.js create mode 100644 clis/eastmoney/sectors.js create mode 100644 clis/endoflife/endoflife.test.js create mode 100644 clis/endoflife/product.js create mode 100644 clis/endoflife/utils.js create mode 100644 clis/facebook/__fixtures__/notifications-page.html create mode 100644 clis/facebook/add-friend.js create mode 100644 clis/facebook/auth.js create mode 100644 clis/facebook/events.js create mode 100644 clis/facebook/feed.js create mode 100644 clis/facebook/feed.test.js create mode 100644 clis/facebook/friends.js create mode 100644 clis/facebook/groups.js create mode 100644 clis/facebook/join-group.js create mode 100644 clis/facebook/marketplace-inbox.js create mode 100644 clis/facebook/marketplace-listings.js create mode 100644 clis/facebook/marketplace.test.js create mode 100644 clis/facebook/memories.js create mode 100644 clis/facebook/notifications.js create mode 100644 clis/facebook/notifications.test.js create mode 100644 clis/facebook/profile.js create mode 100644 clis/facebook/search.js create mode 100644 clis/facebook/search.test.js create mode 100644 clis/flathub/app.js create mode 100644 clis/flathub/flathub.test.js create mode 100644 clis/flathub/search.js create mode 100644 clis/flathub/utils.js create mode 100644 clis/flomo/auth.js create mode 100644 clis/flomo/memos.js create mode 100644 clis/flomo/memos.test.js create mode 100644 clis/gemini/ask.js create mode 100644 clis/gemini/ask.test.js create mode 100644 clis/gemini/auth.js create mode 100644 clis/gemini/commands.test.js create mode 100644 clis/gemini/deep-research-result.js create mode 100644 clis/gemini/deep-research-result.test.js create mode 100644 clis/gemini/deep-research.js create mode 100644 clis/gemini/deep-research.test.js create mode 100644 clis/gemini/detail.js create mode 100644 clis/gemini/history.js create mode 100644 clis/gemini/image.js create mode 100644 clis/gemini/models.js create mode 100644 clis/gemini/models.test.js create mode 100644 clis/gemini/new.js create mode 100644 clis/gemini/read.js create mode 100644 clis/gemini/reply-state.test.js create mode 100644 clis/gemini/status.js create mode 100644 clis/gemini/utils.js create mode 100644 clis/gemini/utils.test.js create mode 100644 clis/geogebra/add-circle.js create mode 100644 clis/geogebra/add-line.js create mode 100644 clis/geogebra/add-point.js create mode 100644 clis/geogebra/add-polygon.js create mode 100644 clis/geogebra/eval.js create mode 100644 clis/geogebra/geogebra.test.js create mode 100644 clis/geogebra/hexagon.js create mode 100644 clis/geogebra/info.js create mode 100644 clis/geogebra/list.js create mode 100644 clis/geogebra/triangle.js create mode 100644 clis/geogebra/utils.js create mode 100644 clis/gitee/auth.js create mode 100644 clis/gitee/index.js create mode 100644 clis/gitee/search.js create mode 100644 clis/gitee/search.test.js create mode 100644 clis/gitee/trending.js create mode 100644 clis/gitee/user.js create mode 100644 clis/gitee/user.test.js create mode 100644 clis/github-trending/repos.js create mode 100644 clis/github-trending/repos.test.js create mode 100644 clis/github/auth.js create mode 100644 clis/google-scholar/cite.js create mode 100644 clis/google-scholar/cite.test.js create mode 100644 clis/google-scholar/profile.js create mode 100644 clis/google-scholar/profile.test.js create mode 100644 clis/google-scholar/search.js create mode 100644 clis/google-scholar/search.test.js create mode 100644 clis/google/news.js create mode 100644 clis/google/search.js create mode 100644 clis/google/suggest.js create mode 100644 clis/google/trends.js create mode 100644 clis/google/utils.js create mode 100644 clis/google/utils.test.js create mode 100644 clis/goproxy/goproxy.test.js create mode 100644 clis/goproxy/module.js create mode 100644 clis/goproxy/utils.js create mode 100644 clis/goproxy/versions.js create mode 100644 clis/gov-law/commands.test.js create mode 100644 clis/gov-law/recent.js create mode 100644 clis/gov-law/search.js create mode 100644 clis/gov-law/shared.js create mode 100644 clis/gov-policy/__fixtures__/recent.html create mode 100644 clis/gov-policy/__fixtures__/search.html create mode 100644 clis/gov-policy/gov-policy.test.js create mode 100644 clis/gov-policy/recent.js create mode 100644 clis/gov-policy/search.js create mode 100644 clis/gov-policy/utils.js create mode 100644 clis/grok/ask.js create mode 100644 clis/grok/ask.test.js create mode 100644 clis/grok/auth.js create mode 100644 clis/grok/delete.js create mode 100644 clis/grok/detail.js create mode 100644 clis/grok/export-all.js create mode 100644 clis/grok/export-utils.js create mode 100644 clis/grok/export.js create mode 100644 clis/grok/export.test.js create mode 100644 clis/grok/history.js create mode 100644 clis/grok/image.js create mode 100644 clis/grok/image.test.ts create mode 100644 clis/grok/new.js create mode 100644 clis/grok/pin.js create mode 100644 clis/grok/read.js create mode 100644 clis/grok/send.js create mode 100644 clis/grok/status.js create mode 100644 clis/grok/utils.js create mode 100644 clis/grok/utils.test.js create mode 100644 clis/guazi/__fixtures__/detail.html create mode 100644 clis/guazi/__fixtures__/list.html create mode 100644 clis/guazi/browse.js create mode 100644 clis/guazi/car.js create mode 100644 clis/guazi/guazi.test.js create mode 100644 clis/guazi/utils.js create mode 100644 clis/hackernews/ask.js create mode 100644 clis/hackernews/best.js create mode 100644 clis/hackernews/hackernews.test.js create mode 100644 clis/hackernews/jobs.js create mode 100644 clis/hackernews/new.js create mode 100644 clis/hackernews/read.js create mode 100644 clis/hackernews/search.js create mode 100644 clis/hackernews/show.js create mode 100644 clis/hackernews/top.js create mode 100644 clis/hackernews/user.js create mode 100644 clis/hf/auth.js create mode 100644 clis/hf/datasets.js create mode 100644 clis/hf/hf.test.js create mode 100644 clis/hf/models.js create mode 100644 clis/hf/paper.js create mode 100644 clis/hf/spaces.js create mode 100644 clis/hf/top.js create mode 100644 clis/hltv/event-matches.js create mode 100644 clis/hltv/hltv.test.js create mode 100644 clis/hltv/match-map.js create mode 100644 clis/hltv/match-series.js create mode 100644 clis/hltv/player-duel.js create mode 100644 clis/hltv/player-form.js create mode 100644 clis/hltv/player-map-pool.js create mode 100644 clis/hltv/player-matches.js create mode 100644 clis/hltv/player-summary.js create mode 100644 clis/hltv/player-teammate-impact.js create mode 100644 clis/hltv/player-vs-team.js create mode 100644 clis/hltv/search.js create mode 100644 clis/hltv/team-map-pool.js create mode 100644 clis/hltv/team-matches.js create mode 100644 clis/hltv/utils.js create mode 100644 clis/homebrew/cask.js create mode 100644 clis/homebrew/formula.js create mode 100644 clis/homebrew/popular.js create mode 100644 clis/homebrew/utils.js create mode 100644 clis/huodongxing/events.js create mode 100644 clis/huodongxing/events.test.js create mode 100644 clis/hupu/__fixtures__/hot-home.html create mode 100644 clis/hupu/auth.js create mode 100644 clis/hupu/detail.js create mode 100644 clis/hupu/hot.js create mode 100644 clis/hupu/hot.test.js create mode 100644 clis/hupu/like.js create mode 100644 clis/hupu/mentions.js create mode 100644 clis/hupu/reply.js create mode 100644 clis/hupu/search.js create mode 100644 clis/hupu/unlike.js create mode 100644 clis/hupu/utils.js create mode 100644 clis/imdb/person.js create mode 100644 clis/imdb/reviews.js create mode 100644 clis/imdb/search.js create mode 100644 clis/imdb/title.js create mode 100644 clis/imdb/top.js create mode 100644 clis/imdb/trending.js create mode 100644 clis/imdb/utils.js create mode 100644 clis/imdb/utils.test.js create mode 100644 clis/indeed/indeed.test.js create mode 100644 clis/indeed/job.js create mode 100644 clis/indeed/search.js create mode 100644 clis/indeed/utils.js create mode 100644 clis/instagram/_shared/private-publish.js create mode 100644 clis/instagram/_shared/private-publish.test.js create mode 100644 clis/instagram/_shared/protocol-capture.js create mode 100644 clis/instagram/_shared/protocol-capture.test.js create mode 100644 clis/instagram/_shared/runtime-info.js create mode 100644 clis/instagram/auth.js create mode 100644 clis/instagram/collection-create.js create mode 100644 clis/instagram/collection-delete.js create mode 100644 clis/instagram/comment.js create mode 100644 clis/instagram/download.js create mode 100644 clis/instagram/download.test.js create mode 100644 clis/instagram/explore.js create mode 100644 clis/instagram/follow.js create mode 100644 clis/instagram/followers.js create mode 100644 clis/instagram/following.js create mode 100644 clis/instagram/instagram.test.js create mode 100644 clis/instagram/like.js create mode 100644 clis/instagram/note.js create mode 100644 clis/instagram/note.test.js create mode 100644 clis/instagram/post.js create mode 100644 clis/instagram/post.test.js create mode 100644 clis/instagram/profile.js create mode 100644 clis/instagram/reel.js create mode 100644 clis/instagram/reel.test.js create mode 100644 clis/instagram/save.js create mode 100644 clis/instagram/saved.js create mode 100644 clis/instagram/search.js create mode 100644 clis/instagram/story.js create mode 100644 clis/instagram/story.test.js create mode 100644 clis/instagram/unfollow.js create mode 100644 clis/instagram/unlike.js create mode 100644 clis/instagram/unsave.js create mode 100644 clis/instagram/user.js create mode 100644 clis/jd/add-cart.js create mode 100644 clis/jd/auth.js create mode 100644 clis/jd/cart.js create mode 100644 clis/jd/commands.test.js create mode 100644 clis/jd/detail.js create mode 100644 clis/jd/item.js create mode 100644 clis/jd/item.test.js create mode 100644 clis/jd/item.test.ts create mode 100644 clis/jd/reviews.js create mode 100644 clis/jd/search.js create mode 100644 clis/jianyu/auth.js create mode 100644 clis/jianyu/detail.js create mode 100644 clis/jianyu/search.js create mode 100644 clis/jianyu/search.test.js create mode 100644 clis/jianyu/shared/china-bid-search.js create mode 100644 clis/jianyu/shared/procurement-contract.js create mode 100644 clis/jianyu/shared/procurement-contract.test.js create mode 100644 clis/jianyu/shared/procurement-detail.js create mode 100644 clis/jianyu/shared/procurement-detail.test.js create mode 100644 clis/jike/auth.js create mode 100644 clis/jike/comment.js create mode 100644 clis/jike/create.js create mode 100644 clis/jike/feed.js create mode 100644 clis/jike/like.js create mode 100644 clis/jike/notifications.js create mode 100644 clis/jike/post.js create mode 100644 clis/jike/read.test.js create mode 100644 clis/jike/repost.js create mode 100644 clis/jike/search.js create mode 100644 clis/jike/topic.js create mode 100644 clis/jike/user.js create mode 100644 clis/jike/utils.js create mode 100644 clis/jimeng/auth.js create mode 100644 clis/jimeng/generate.js create mode 100644 clis/jimeng/history.js create mode 100644 clis/jimeng/new.js create mode 100644 clis/jimeng/workspaces.js create mode 100644 clis/jira/attachments.js create mode 100644 clis/jira/commands.test.js create mode 100644 clis/jira/comments.js create mode 100644 clis/jira/issue.js create mode 100644 clis/jira/links.js create mode 100644 clis/jira/search.js create mode 100644 clis/jira/shared.js create mode 100644 clis/juejin/hot.js create mode 100644 clis/juejin/juejin.test.js create mode 100644 clis/juejin/recommend.js create mode 100644 clis/juejin/utils.js create mode 100644 clis/ke/auth.js create mode 100644 clis/ke/chengjiao.js create mode 100644 clis/ke/ershoufang.js create mode 100644 clis/ke/utils.js create mode 100644 clis/ke/xiaoqu.js create mode 100644 clis/ke/zufang.js create mode 100644 clis/kimi/_utils.js create mode 100644 clis/kimi/audit-extras.js create mode 100644 clis/kimi/auth.js create mode 100644 clis/kimi/chat.js create mode 100644 clis/kimi/kimi.test.js create mode 100644 clis/kimi/storage.js create mode 100644 clis/kimi/ui.js create mode 100644 clis/kimi/usage.js create mode 100644 clis/lesswrong/_helpers.js create mode 100644 clis/lesswrong/comments.js create mode 100644 clis/lesswrong/curated.js create mode 100644 clis/lesswrong/frontpage.js create mode 100644 clis/lesswrong/frontpage.test.js create mode 100644 clis/lesswrong/new.js create mode 100644 clis/lesswrong/read.js create mode 100644 clis/lesswrong/sequences.js create mode 100644 clis/lesswrong/shortform.js create mode 100644 clis/lesswrong/tag.js create mode 100644 clis/lesswrong/tags.js create mode 100644 clis/lesswrong/top-month.js create mode 100644 clis/lesswrong/top-week.js create mode 100644 clis/lesswrong/top-year.js create mode 100644 clis/lesswrong/top.js create mode 100644 clis/lesswrong/user-posts.js create mode 100644 clis/lesswrong/user.js create mode 100644 clis/lichess/lichess.test.js create mode 100644 clis/lichess/top.js create mode 100644 clis/lichess/user.js create mode 100644 clis/lichess/utils.js create mode 100644 clis/linkedin-learning/auth.js create mode 100644 clis/linkedin-learning/course.js create mode 100644 clis/linkedin-learning/course.test.js create mode 100644 clis/linkedin-learning/search.js create mode 100644 clis/linkedin-learning/search.test.js create mode 100644 clis/linkedin-learning/trending.js create mode 100644 clis/linkedin-learning/trending.test.js create mode 100644 clis/linkedin/auth.js create mode 100644 clis/linkedin/company.js create mode 100644 clis/linkedin/company.test.js create mode 100644 clis/linkedin/connect.js create mode 100644 clis/linkedin/connect.test.js create mode 100644 clis/linkedin/connections.js create mode 100644 clis/linkedin/connections.test.js create mode 100644 clis/linkedin/inbox.js create mode 100644 clis/linkedin/inbox.test.js create mode 100644 clis/linkedin/job-detail.js create mode 100644 clis/linkedin/job-detail.test.js create mode 100644 clis/linkedin/jobs-preferences.js create mode 100644 clis/linkedin/jobs-preferences.test.js create mode 100644 clis/linkedin/people-search.js create mode 100644 clis/linkedin/people-search.test.js create mode 100644 clis/linkedin/post-analytics.js create mode 100644 clis/linkedin/post-analytics.test.js create mode 100644 clis/linkedin/posts-core.js create mode 100644 clis/linkedin/posts.js create mode 100644 clis/linkedin/posts.test.js create mode 100644 clis/linkedin/profile-analytics.js create mode 100644 clis/linkedin/profile-analytics.test.js create mode 100644 clis/linkedin/profile-experience.js create mode 100644 clis/linkedin/profile-experience.test.js create mode 100644 clis/linkedin/profile-projects.js create mode 100644 clis/linkedin/profile-projects.test.js create mode 100644 clis/linkedin/profile-read.js create mode 100644 clis/linkedin/profile-read.test.js create mode 100644 clis/linkedin/safe-send.js create mode 100644 clis/linkedin/safe-send.test.js create mode 100644 clis/linkedin/salesnav-inbox.js create mode 100644 clis/linkedin/salesnav-inbox.test.js create mode 100644 clis/linkedin/salesnav-message.js create mode 100644 clis/linkedin/salesnav-message.test.js create mode 100644 clis/linkedin/salesnav-search.js create mode 100644 clis/linkedin/salesnav-search.test.js create mode 100644 clis/linkedin/salesnav-thread.js create mode 100644 clis/linkedin/salesnav-thread.test.js create mode 100644 clis/linkedin/search.js create mode 100644 clis/linkedin/search.test.js create mode 100644 clis/linkedin/sent-invitations.js create mode 100644 clis/linkedin/sent-invitations.test.js create mode 100644 clis/linkedin/services-read.js create mode 100644 clis/linkedin/services-read.test.js create mode 100644 clis/linkedin/shared.js create mode 100644 clis/linkedin/thread-snapshot.js create mode 100644 clis/linkedin/thread-snapshot.test.js create mode 100644 clis/linkedin/timeline.js create mode 100644 clis/linkedin/timeline.test.js create mode 100644 clis/linux-do/auth.js create mode 100644 clis/linux-do/categories.js create mode 100644 clis/linux-do/feed.js create mode 100644 clis/linux-do/feed.test.js create mode 100644 clis/linux-do/search.js create mode 100644 clis/linux-do/tags.js create mode 100644 clis/linux-do/topic-content.js create mode 100644 clis/linux-do/topic-content.test.js create mode 100644 clis/linux-do/topic.js create mode 100644 clis/linux-do/user-posts.js create mode 100644 clis/linux-do/user-topics.js create mode 100644 clis/lobsters/active.js create mode 100644 clis/lobsters/domain.js create mode 100644 clis/lobsters/hot.js create mode 100644 clis/lobsters/lobsters.test.js create mode 100644 clis/lobsters/newest.js create mode 100644 clis/lobsters/read.js create mode 100644 clis/lobsters/tag.js create mode 100644 clis/maimai/auth.js create mode 100644 clis/maimai/search-talents.js create mode 100644 clis/maimai/search-talents.test.js create mode 100644 clis/manus/_utils.js create mode 100644 clis/manus/auth.js create mode 100644 clis/manus/connectors.js create mode 100644 clis/manus/credits.js create mode 100644 clis/manus/list.js create mode 100644 clis/manus/manus.test.js create mode 100644 clis/manus/read.js create mode 100644 clis/manus/skills.js create mode 100644 clis/manus/status.js create mode 100644 clis/maven/artifact.js create mode 100644 clis/maven/search.js create mode 100644 clis/maven/utils.js create mode 100644 clis/mdn/search.js create mode 100644 clis/medium/feed.js create mode 100644 clis/medium/search.js create mode 100644 clis/medium/tag.js create mode 100644 clis/medium/user.js create mode 100644 clis/medium/utils.js create mode 100644 clis/mercury/check-login.js create mode 100644 clis/mercury/mercury.test.js create mode 100644 clis/mercury/reimbursement-draft.js create mode 100644 clis/mercury/reimbursement-plan.js create mode 100644 clis/mercury/utils.js create mode 100644 clis/mubu/doc.js create mode 100644 clis/mubu/docs.js create mode 100644 clis/mubu/notes.js create mode 100644 clis/mubu/recent.js create mode 100644 clis/mubu/search.js create mode 100644 clis/mubu/utils.js create mode 100644 clis/notebooklm/add-source.js create mode 100644 clis/notebooklm/add-source.test.js create mode 100644 clis/notebooklm/auth.js create mode 100644 clis/notebooklm/compat.test.js create mode 100644 clis/notebooklm/create.js create mode 100644 clis/notebooklm/create.test.js create mode 100644 clis/notebooklm/current.js create mode 100644 clis/notebooklm/generate-audio.js create mode 100644 clis/notebooklm/generate-audio.test.js create mode 100644 clis/notebooklm/generate-slides.js create mode 100644 clis/notebooklm/generate-slides.test.js create mode 100644 clis/notebooklm/get.js create mode 100644 clis/notebooklm/history.js create mode 100644 clis/notebooklm/history.test.js create mode 100644 clis/notebooklm/list.js create mode 100644 clis/notebooklm/note-list.js create mode 100644 clis/notebooklm/note-list.test.js create mode 100644 clis/notebooklm/notes-get.js create mode 100644 clis/notebooklm/notes-get.test.js create mode 100644 clis/notebooklm/open.js create mode 100644 clis/notebooklm/open.test.js create mode 100644 clis/notebooklm/rpc.js create mode 100644 clis/notebooklm/rpc.test.js create mode 100644 clis/notebooklm/shared.js create mode 100644 clis/notebooklm/source-fulltext.js create mode 100644 clis/notebooklm/source-fulltext.test.js create mode 100644 clis/notebooklm/source-get.js create mode 100644 clis/notebooklm/source-get.test.js create mode 100644 clis/notebooklm/source-guide.js create mode 100644 clis/notebooklm/source-guide.test.js create mode 100644 clis/notebooklm/source-list.js create mode 100644 clis/notebooklm/status.js create mode 100644 clis/notebooklm/summary.js create mode 100644 clis/notebooklm/summary.test.js create mode 100644 clis/notebooklm/utils.js create mode 100644 clis/notebooklm/utils.test.js create mode 100644 clis/notebooklm/write-note.js create mode 100644 clis/notebooklm/write-note.test.js create mode 100644 clis/nowcoder/auth.js create mode 100644 clis/nowcoder/companies.js create mode 100644 clis/nowcoder/creators.js create mode 100644 clis/nowcoder/detail.js create mode 100644 clis/nowcoder/experience.js create mode 100644 clis/nowcoder/hot.js create mode 100644 clis/nowcoder/jobs.js create mode 100644 clis/nowcoder/notifications.js create mode 100644 clis/nowcoder/papers.js create mode 100644 clis/nowcoder/practice.js create mode 100644 clis/nowcoder/recommend.js create mode 100644 clis/nowcoder/referral.js create mode 100644 clis/nowcoder/salary.js create mode 100644 clis/nowcoder/search.js create mode 100644 clis/nowcoder/suggest.js create mode 100644 clis/nowcoder/topics.js create mode 100644 clis/nowcoder/trending.js create mode 100644 clis/npm/downloads.js create mode 100644 clis/npm/package.js create mode 100644 clis/npm/search.js create mode 100644 clis/npm/utils.js create mode 100644 clis/nuget/nuget.test.js create mode 100644 clis/nuget/package.js create mode 100644 clis/nuget/search.js create mode 100644 clis/nuget/utils.js create mode 100644 clis/nvd/cve.js create mode 100644 clis/oeis/oeis.test.js create mode 100644 clis/oeis/search.js create mode 100644 clis/oeis/sequence.js create mode 100644 clis/oeis/utils.js create mode 100644 clis/ones/common.js create mode 100644 clis/ones/enrich-tasks.js create mode 100644 clis/ones/login.js create mode 100644 clis/ones/logout.js create mode 100644 clis/ones/me.js create mode 100644 clis/ones/my-tasks.js create mode 100644 clis/ones/resolve-labels.js create mode 100644 clis/ones/task-helpers.js create mode 100644 clis/ones/task-helpers.test.js create mode 100644 clis/ones/task.js create mode 100644 clis/ones/tasks.js create mode 100644 clis/ones/token-info.js create mode 100644 clis/ones/worklog.js create mode 100644 clis/ones/worklog.test.js create mode 100644 clis/openalex/search.js create mode 100644 clis/openalex/utils.js create mode 100644 clis/openalex/work.js create mode 100644 clis/openfda/drug-label.js create mode 100644 clis/openfda/food-recall.js create mode 100644 clis/openfda/openfda.test.js create mode 100644 clis/openfda/utils.js create mode 100644 clis/openreview/author.js create mode 100644 clis/openreview/openreview.test.js create mode 100644 clis/openreview/paper.js create mode 100644 clis/openreview/reviews.js create mode 100644 clis/openreview/search.js create mode 100644 clis/openreview/utils.js create mode 100644 clis/openreview/venue.js create mode 100644 clis/osv/osv.test.js create mode 100644 clis/osv/query.js create mode 100644 clis/osv/utils.js create mode 100644 clis/osv/vulnerability.js create mode 100644 clis/packagist/package.js create mode 100644 clis/packagist/search.js create mode 100644 clis/packagist/utils.js create mode 100644 clis/paperreview/commands.test.js create mode 100644 clis/paperreview/feedback.js create mode 100644 clis/paperreview/review.js create mode 100644 clis/paperreview/submit.js create mode 100644 clis/paperreview/utils.js create mode 100644 clis/paperreview/utils.test.js create mode 100644 clis/pixiv/auth.js create mode 100644 clis/pixiv/detail.js create mode 100644 clis/pixiv/detail.test.js create mode 100644 clis/pixiv/download.js create mode 100644 clis/pixiv/download.test.js create mode 100644 clis/pixiv/illusts.js create mode 100644 clis/pixiv/illusts.test.js create mode 100644 clis/pixiv/ranking.js create mode 100644 clis/pixiv/search.js create mode 100644 clis/pixiv/search.test.js create mode 100644 clis/pixiv/user.js create mode 100644 clis/pixiv/user.test.js create mode 100644 clis/pixiv/utils.js create mode 100644 clis/powerchina/auth.js create mode 100644 clis/powerchina/search.js create mode 100644 clis/powerchina/search.test.js create mode 100644 clis/producthunt/browse.js create mode 100644 clis/producthunt/hot.js create mode 100644 clis/producthunt/posts.js create mode 100644 clis/producthunt/today.js create mode 100644 clis/producthunt/utils.js create mode 100644 clis/producthunt/utils.test.js create mode 100644 clis/pubmed/article.js create mode 100644 clis/pubmed/author.js create mode 100644 clis/pubmed/citations.js create mode 100644 clis/pubmed/clinical-trial.js create mode 100644 clis/pubmed/journal.js create mode 100644 clis/pubmed/mesh.js create mode 100644 clis/pubmed/pubmed.test.js create mode 100644 clis/pubmed/related.js create mode 100644 clis/pubmed/review.js create mode 100644 clis/pubmed/search.js create mode 100644 clis/pubmed/utils.js create mode 100644 clis/pypi/downloads.js create mode 100644 clis/pypi/package.js create mode 100644 clis/pypi/utils.js create mode 100644 clis/qoder/_utils.js create mode 100644 clis/qoder/composer.js create mode 100644 clis/qoder/history.js create mode 100644 clis/qoder/qoder.test.js create mode 100644 clis/qoder/quest.js create mode 100644 clis/qoder/read.js create mode 100644 clis/qoder/status.js create mode 100644 clis/qoder/ui.js create mode 100644 clis/quark/auth.js create mode 100644 clis/quark/ls.js create mode 100644 clis/quark/mkdir.js create mode 100644 clis/quark/mv.js create mode 100644 clis/quark/rename.js create mode 100644 clis/quark/rm.js create mode 100644 clis/quark/save.js create mode 100644 clis/quark/share-tree.js create mode 100644 clis/quark/utils.js create mode 100644 clis/quark/utils.test.js create mode 100644 clis/qwen/ask.js create mode 100644 clis/qwen/ask.test.js create mode 100644 clis/qwen/auth.js create mode 100644 clis/qwen/detail.js create mode 100644 clis/qwen/history.js create mode 100644 clis/qwen/image.js create mode 100644 clis/qwen/new.js create mode 100644 clis/qwen/read.js create mode 100644 clis/qwen/send.js create mode 100644 clis/qwen/status.js create mode 100644 clis/qwen/utils.js create mode 100644 clis/qwen/utils.test.js create mode 100644 clis/reddit/auth.js create mode 100644 clis/reddit/comment.js create mode 100644 clis/reddit/extract-media.test.js create mode 100644 clis/reddit/frontpage.js create mode 100644 clis/reddit/frontpage.test.js create mode 100644 clis/reddit/home.js create mode 100644 clis/reddit/home.test.js create mode 100644 clis/reddit/hot.js create mode 100644 clis/reddit/hot.test.js create mode 100644 clis/reddit/popular.js create mode 100644 clis/reddit/popular.test.js create mode 100644 clis/reddit/read.js create mode 100644 clis/reddit/read.test.js create mode 100644 clis/reddit/reply.js create mode 100644 clis/reddit/reply.test.js create mode 100644 clis/reddit/save.js create mode 100644 clis/reddit/saved.js create mode 100644 clis/reddit/search.js create mode 100644 clis/reddit/search.test.js create mode 100644 clis/reddit/subreddit-info.js create mode 100644 clis/reddit/subreddit-info.test.js create mode 100644 clis/reddit/subreddit.js create mode 100644 clis/reddit/subreddit.test.js create mode 100644 clis/reddit/subscribe.js create mode 100644 clis/reddit/subscribed.js create mode 100644 clis/reddit/subscribed.test.js create mode 100644 clis/reddit/upvote.js create mode 100644 clis/reddit/upvoted.js create mode 100644 clis/reddit/user-comments.js create mode 100644 clis/reddit/user-posts.js create mode 100644 clis/reddit/user.js create mode 100644 clis/reddit/whoami.js create mode 100644 clis/reddit/whoami.test.js create mode 100644 clis/rednote/auth.js create mode 100644 clis/rednote/comments.js create mode 100644 clis/rednote/download.js create mode 100644 clis/rednote/feed.js create mode 100644 clis/rednote/navigation.test.js create mode 100644 clis/rednote/note.js create mode 100644 clis/rednote/notifications.js create mode 100644 clis/rednote/rednote.test.js create mode 100644 clis/rednote/search.js create mode 100644 clis/rednote/user.js create mode 100644 clis/rest-countries/country.js create mode 100644 clis/rest-countries/region.js create mode 100644 clis/rest-countries/rest-countries.test.js create mode 100644 clis/rest-countries/utils.js create mode 100644 clis/reuters/article-detail.js create mode 100644 clis/reuters/auth.js create mode 100644 clis/reuters/reuters.test.js create mode 100644 clis/reuters/search.js create mode 100644 clis/reuters/utils.js create mode 100644 clis/rfc/rfc.js create mode 100644 clis/rfc/rfc.test.js create mode 100644 clis/rfc/utils.js create mode 100644 clis/rubygems/gem.js create mode 100644 clis/rubygems/search.js create mode 100644 clis/rubygems/utils.js create mode 100644 clis/semanticscholar/citations.js create mode 100644 clis/semanticscholar/paper.js create mode 100644 clis/semanticscholar/recommendations.js create mode 100644 clis/semanticscholar/search.js create mode 100644 clis/semanticscholar/semanticscholar.test.js create mode 100644 clis/semanticscholar/utils.js create mode 100644 clis/sinablog/article.js create mode 100644 clis/sinablog/hot.js create mode 100644 clis/sinablog/search.js create mode 100644 clis/sinablog/user.js create mode 100644 clis/sinablog/utils.js create mode 100644 clis/sinafinance/news.js create mode 100644 clis/sinafinance/rolling-news.js create mode 100644 clis/sinafinance/stock-rank.js create mode 100644 clis/sinafinance/stock.js create mode 100644 clis/sinafinance/stock.test.js create mode 100644 clis/slock/api-base-canary.test.js create mode 100644 clis/slock/attachment-download.js create mode 100644 clis/slock/attachment-download.test.js create mode 100644 clis/slock/attachment-upload.js create mode 100644 clis/slock/attachment-upload.test.js create mode 100644 clis/slock/attachment-url.js create mode 100644 clis/slock/attachment-url.test.js create mode 100644 clis/slock/auth-verify.js create mode 100644 clis/slock/bookmark-add.js create mode 100644 clis/slock/bookmark-add.test.js create mode 100644 clis/slock/bookmark-list.js create mode 100644 clis/slock/bookmark-list.test.js create mode 100644 clis/slock/bookmark-remove.js create mode 100644 clis/slock/bookmark-remove.test.js create mode 100644 clis/slock/channel-action.js create mode 100644 clis/slock/channel-action.test.js create mode 100644 clis/slock/channel-archive.js create mode 100644 clis/slock/channel-create.js create mode 100644 clis/slock/channel-create.test.js create mode 100644 clis/slock/channel-files.js create mode 100644 clis/slock/channel-files.test.js create mode 100644 clis/slock/channel-info.js create mode 100644 clis/slock/channel-info.test.js create mode 100644 clis/slock/channel-join.js create mode 100644 clis/slock/channel-leave.js create mode 100644 clis/slock/channel-list.js create mode 100644 clis/slock/channel-list.test.js create mode 100644 clis/slock/channel-mark.js create mode 100644 clis/slock/channel-mark.test.js create mode 100644 clis/slock/channel-members.js create mode 100644 clis/slock/channel-members.test.js create mode 100644 clis/slock/channel-unarchive.js create mode 100644 clis/slock/cross-command.test.js create mode 100644 clis/slock/dm-list.js create mode 100644 clis/slock/dm-list.test.js create mode 100644 clis/slock/error-detail-canary.test.js create mode 100644 clis/slock/errors.js create mode 100644 clis/slock/errors.test.js create mode 100644 clis/slock/in-page.js create mode 100644 clis/slock/in-page.test.js create mode 100644 clis/slock/inbox-done.js create mode 100644 clis/slock/inbox-done.test.js create mode 100644 clis/slock/inbox-read-all.js create mode 100644 clis/slock/inbox-read-all.test.js create mode 100644 clis/slock/inbox.js create mode 100644 clis/slock/inbox.test.js create mode 100644 clis/slock/login.js create mode 100644 clis/slock/message-read.js create mode 100644 clis/slock/message-read.test.js create mode 100644 clis/slock/message-search.js create mode 100644 clis/slock/message-search.test.js create mode 100644 clis/slock/message-send.js create mode 100644 clis/slock/message-send.test.js create mode 100644 clis/slock/reaction-add.js create mode 100644 clis/slock/reaction-add.test.js create mode 100644 clis/slock/reaction-remove.js create mode 100644 clis/slock/reaction-remove.test.js create mode 100644 clis/slock/resolve.js create mode 100644 clis/slock/resolve.test.js create mode 100644 clis/slock/server-list.js create mode 100644 clis/slock/server-list.test.js create mode 100644 clis/slock/server-override-canary.test.js create mode 100644 clis/slock/server-use.js create mode 100644 clis/slock/server-use.test.js create mode 100644 clis/slock/shared.js create mode 100644 clis/slock/short-id-canary.test.js create mode 100644 clis/slock/site-session-canary.test.js create mode 100644 clis/slock/task-claim.js create mode 100644 clis/slock/task-claim.test.js create mode 100644 clis/slock/task-convert.js create mode 100644 clis/slock/task-convert.test.js create mode 100644 clis/slock/task-create.js create mode 100644 clis/slock/task-create.test.js create mode 100644 clis/slock/task-delete.js create mode 100644 clis/slock/task-delete.test.js create mode 100644 clis/slock/task-get.js create mode 100644 clis/slock/task-get.test.js create mode 100644 clis/slock/task-list-server.js create mode 100644 clis/slock/task-list-server.test.js create mode 100644 clis/slock/task-list.js create mode 100644 clis/slock/task-list.test.js create mode 100644 clis/slock/task-status.js create mode 100644 clis/slock/task-status.test.js create mode 100644 clis/slock/task-unclaim.js create mode 100644 clis/slock/task-unclaim.test.js create mode 100644 clis/slock/thread-done.js create mode 100644 clis/slock/thread-follow.js create mode 100644 clis/slock/thread-follow.test.js create mode 100644 clis/slock/thread-list.js create mode 100644 clis/slock/thread-list.test.js create mode 100644 clis/slock/thread-state.js create mode 100644 clis/slock/thread-state.test.js create mode 100644 clis/slock/thread-undone.js create mode 100644 clis/slock/thread-unfollow.js create mode 100644 clis/slock/unread-summary.js create mode 100644 clis/slock/unread-summary.test.js create mode 100644 clis/slock/whoami.js create mode 100644 clis/slock/whoami.test.js create mode 100644 clis/smzdm/search.js create mode 100644 clis/smzdm/search.test.js create mode 100644 clis/spotify/spotify.js create mode 100644 clis/spotify/utils.js create mode 100644 clis/spotify/utils.test.js create mode 100644 clis/stackoverflow/bounties.js create mode 100644 clis/stackoverflow/hot.js create mode 100644 clis/stackoverflow/read.js create mode 100644 clis/stackoverflow/related.js create mode 100644 clis/stackoverflow/search.js create mode 100644 clis/stackoverflow/stackoverflow.test.js create mode 100644 clis/stackoverflow/tag.js create mode 100644 clis/stackoverflow/unanswered.js create mode 100644 clis/stackoverflow/user.js create mode 100644 clis/stackoverflow/utils.js create mode 100644 clis/steam/app.js create mode 100644 clis/steam/search.js create mode 100644 clis/steam/steam.test.js create mode 100644 clis/steam/top-sellers.js create mode 100644 clis/steam/utils.js create mode 100644 clis/substack/feed.js create mode 100644 clis/substack/publication.js create mode 100644 clis/substack/search.js create mode 100644 clis/substack/utils.js create mode 100644 clis/substack/utils.test.js create mode 100644 clis/suno/auth.js create mode 100644 clis/suno/commands.test.js create mode 100644 clis/suno/download.js create mode 100644 clis/suno/download.test.js create mode 100644 clis/suno/generate.js create mode 100644 clis/suno/generate.test.js create mode 100644 clis/suno/list.js create mode 100644 clis/suno/status.js create mode 100644 clis/suno/utils.js create mode 100644 clis/suno/utils.test.js create mode 100644 clis/taobao/add-cart.js create mode 100644 clis/taobao/auth.js create mode 100644 clis/taobao/cart.js create mode 100644 clis/taobao/commands.test.js create mode 100644 clis/taobao/detail.js create mode 100644 clis/taobao/reviews.js create mode 100644 clis/taobao/search.js create mode 100644 clis/tdx/hot-rank.js create mode 100644 clis/tdx/hot-rank.test.js create mode 100644 clis/test-utils.js create mode 100644 clis/ths/hot-rank.js create mode 100644 clis/ths/hot-rank.test.js create mode 100644 clis/tieba/commands.test.js create mode 100644 clis/tieba/hot.js create mode 100644 clis/tieba/posts.js create mode 100644 clis/tieba/read.js create mode 100644 clis/tieba/search.js create mode 100644 clis/tieba/utils.js create mode 100644 clis/tieba/utils.test.js create mode 100644 clis/tiktok/auth.js create mode 100644 clis/tiktok/comment.js create mode 100644 clis/tiktok/creator-videos.js create mode 100644 clis/tiktok/creator-videos.test.js create mode 100644 clis/tiktok/explore.js create mode 100644 clis/tiktok/follow.js create mode 100644 clis/tiktok/following.js create mode 100644 clis/tiktok/friends.js create mode 100644 clis/tiktok/like.js create mode 100644 clis/tiktok/live.js create mode 100644 clis/tiktok/notifications.js create mode 100644 clis/tiktok/profile.js create mode 100644 clis/tiktok/refactor.test.js create mode 100644 clis/tiktok/save.js create mode 100644 clis/tiktok/search.js create mode 100644 clis/tiktok/unfollow.js create mode 100644 clis/tiktok/unlike.js create mode 100644 clis/tiktok/unsave.js create mode 100644 clis/tiktok/user.js create mode 100644 clis/tiktok/utils.js create mode 100644 clis/tiktok/write-refactor.test.js create mode 100644 clis/toutiao/articles.js create mode 100644 clis/toutiao/auth.js create mode 100644 clis/toutiao/hot.js create mode 100644 clis/toutiao/toutiao.test.js create mode 100644 clis/toutiao/utils.js create mode 100644 clis/trae-cn/activity.js create mode 100644 clis/trae-cn/approve.js create mode 100644 clis/trae-cn/ask.js create mode 100644 clis/trae-cn/dump.js create mode 100644 clis/trae-cn/export.js create mode 100644 clis/trae-cn/model.js create mode 100644 clis/trae-cn/new.js create mode 100644 clis/trae-cn/read.js create mode 100644 clis/trae-cn/screenshot.js create mode 100644 clis/trae-cn/select-model.js create mode 100644 clis/trae-cn/send.js create mode 100644 clis/trae-cn/setup.js create mode 100644 clis/trae-cn/status.js create mode 100644 clis/trae-cn/targets.js create mode 100644 clis/trae-cn/trae-cn.test.js create mode 100644 clis/trae-cn/utils.js create mode 100644 clis/trae-cn/watch.js create mode 100644 clis/trae-solo/_actions.js create mode 100644 clis/trae-solo/_fs.js create mode 100644 clis/trae-solo/_state.js create mode 100644 clis/trae-solo/automation.js create mode 100644 clis/trae-solo/history.js create mode 100644 clis/trae-solo/mode.js create mode 100644 clis/trae-solo/model.js create mode 100644 clis/trae-solo/renderer-storage.js create mode 100644 clis/trae-solo/settings.js create mode 100644 clis/trae-solo/skill-fs.js create mode 100644 clis/trae-solo/skill.js create mode 100644 clis/trae-solo/state-fs.js create mode 100644 clis/trae-solo/status.js create mode 100644 clis/trae-solo/task-fs.js create mode 100644 clis/trae-solo/trae-solo.test.js create mode 100644 clis/trae-solo/user-rules.js create mode 100644 clis/trae-solo/workspaces-fs.js create mode 100644 clis/tvmaze/search.js create mode 100644 clis/tvmaze/show.js create mode 100644 clis/tvmaze/tvmaze.test.js create mode 100644 clis/tvmaze/utils.js create mode 100644 clis/twitter/accept.js create mode 100644 clis/twitter/article-evaluate.test.js create mode 100644 clis/twitter/article.js create mode 100644 clis/twitter/article.test.js create mode 100644 clis/twitter/auth.js create mode 100644 clis/twitter/block.js create mode 100644 clis/twitter/bookmark-folder.js create mode 100644 clis/twitter/bookmark-folder.test.js create mode 100644 clis/twitter/bookmark-folders.js create mode 100644 clis/twitter/bookmark-folders.test.js create mode 100644 clis/twitter/bookmark.js create mode 100644 clis/twitter/bookmark.test.js create mode 100644 clis/twitter/bookmarks.js create mode 100644 clis/twitter/bookmarks.test.js create mode 100644 clis/twitter/delete.js create mode 100644 clis/twitter/delete.test.js create mode 100644 clis/twitter/device-follow.js create mode 100644 clis/twitter/device-follow.test.js create mode 100644 clis/twitter/download.js create mode 100644 clis/twitter/download.test.js create mode 100644 clis/twitter/follow-batch.js create mode 100644 clis/twitter/follow-batch.test.js create mode 100644 clis/twitter/follow.js create mode 100644 clis/twitter/followers.js create mode 100644 clis/twitter/followers.test.js create mode 100644 clis/twitter/following.js create mode 100644 clis/twitter/following.test.js create mode 100644 clis/twitter/hide-reply.js create mode 100644 clis/twitter/hide-reply.test.js create mode 100644 clis/twitter/like.js create mode 100644 clis/twitter/like.test.js create mode 100644 clis/twitter/likes.js create mode 100644 clis/twitter/likes.test.js create mode 100644 clis/twitter/list-add-batch.js create mode 100644 clis/twitter/list-add-core.js create mode 100644 clis/twitter/list-add.js create mode 100644 clis/twitter/list-add.test.js create mode 100644 clis/twitter/list-batch-utils.js create mode 100644 clis/twitter/list-batch.test.js create mode 100644 clis/twitter/list-create.js create mode 100644 clis/twitter/list-create.test.js create mode 100644 clis/twitter/list-delete.js create mode 100644 clis/twitter/list-delete.test.js create mode 100644 clis/twitter/list-remove-batch.js create mode 100644 clis/twitter/list-remove-core.js create mode 100644 clis/twitter/list-remove.js create mode 100644 clis/twitter/list-remove.test.js create mode 100644 clis/twitter/list-tweets.js create mode 100644 clis/twitter/list-tweets.test.js create mode 100644 clis/twitter/lists.js create mode 100644 clis/twitter/lists.test.js create mode 100644 clis/twitter/notifications.js create mode 100644 clis/twitter/post.js create mode 100644 clis/twitter/post.test.js create mode 100644 clis/twitter/profile.js create mode 100644 clis/twitter/profile.test.js create mode 100644 clis/twitter/quote.js create mode 100644 clis/twitter/quote.test.js create mode 100644 clis/twitter/reply-dm.js create mode 100644 clis/twitter/reply.js create mode 100644 clis/twitter/reply.test.js create mode 100644 clis/twitter/retweet.js create mode 100644 clis/twitter/retweet.test.js create mode 100644 clis/twitter/search.js create mode 100644 clis/twitter/search.test.js create mode 100644 clis/twitter/shared.js create mode 100644 clis/twitter/shared.test.js create mode 100644 clis/twitter/thread.js create mode 100644 clis/twitter/thread.test.js create mode 100644 clis/twitter/timeline.js create mode 100644 clis/twitter/timeline.test.js create mode 100644 clis/twitter/trending.js create mode 100644 clis/twitter/trending.test.js create mode 100644 clis/twitter/tweets.js create mode 100644 clis/twitter/tweets.test.js create mode 100644 clis/twitter/unblock.js create mode 100644 clis/twitter/unbookmark.js create mode 100644 clis/twitter/unbookmark.test.js create mode 100644 clis/twitter/unfollow.js create mode 100644 clis/twitter/unlike.js create mode 100644 clis/twitter/unlike.test.js create mode 100644 clis/twitter/unretweet.js create mode 100644 clis/twitter/unretweet.test.js create mode 100644 clis/twitter/utils.js create mode 100644 clis/twitter/utils.test.js create mode 100644 clis/uisdc/news.js create mode 100644 clis/uisdc/news.test.js create mode 100644 clis/uiverse/_shared.js create mode 100644 clis/uiverse/_shared.test.js create mode 100644 clis/uiverse/code.js create mode 100644 clis/uiverse/navigation.test.js create mode 100644 clis/uiverse/preview.js create mode 100644 clis/upwork/auth.js create mode 100644 clis/upwork/detail.js create mode 100644 clis/upwork/feed.js create mode 100644 clis/upwork/search.js create mode 100644 clis/upwork/upwork.test.js create mode 100644 clis/upwork/utils.js create mode 100644 clis/v2ex/auth.js create mode 100644 clis/v2ex/daily.js create mode 100644 clis/v2ex/hot.js create mode 100644 clis/v2ex/latest.js create mode 100644 clis/v2ex/me.js create mode 100644 clis/v2ex/member.js create mode 100644 clis/v2ex/node.js create mode 100644 clis/v2ex/nodes.js create mode 100644 clis/v2ex/notifications.js create mode 100644 clis/v2ex/replies.js create mode 100644 clis/v2ex/topic.js create mode 100644 clis/v2ex/user.js create mode 100644 clis/wanfang/search.js create mode 100644 clis/wanfang/search.test.js create mode 100644 clis/web/read.js create mode 100644 clis/web/read.test.js create mode 100644 clis/wechat-channels/auth.js create mode 100644 clis/wechat-channels/publish.js create mode 100644 clis/wechat-channels/publish.test.js create mode 100644 clis/weibo/auth.js create mode 100644 clis/weibo/auth.test.js create mode 100644 clis/weibo/comments.js create mode 100644 clis/weibo/delete.js create mode 100644 clis/weibo/delete.test.js create mode 100644 clis/weibo/envelope.test.js create mode 100644 clis/weibo/favorites.js create mode 100644 clis/weibo/favorites.test.js create mode 100644 clis/weibo/feed.js create mode 100644 clis/weibo/hot.js create mode 100644 clis/weibo/me.js create mode 100644 clis/weibo/post.js create mode 100644 clis/weibo/publish.js create mode 100644 clis/weibo/publish.test.js create mode 100644 clis/weibo/search.js create mode 100644 clis/weibo/user-posts.js create mode 100644 clis/weibo/user-posts.test.js create mode 100644 clis/weibo/user.js create mode 100644 clis/weibo/utils.js create mode 100644 clis/weibo/utils.test.js create mode 100644 clis/weixin/create-draft.js create mode 100644 clis/weixin/download.js create mode 100644 clis/weixin/download.test.js create mode 100644 clis/weixin/drafts.js create mode 100644 clis/weixin/drafts.test.js create mode 100644 clis/weixin/search.js create mode 100644 clis/weixin/search.test.js create mode 100644 clis/weread-official/book.js create mode 100644 clis/weread-official/commands.test.js create mode 100644 clis/weread-official/discover.js create mode 100644 clis/weread-official/list-apis.js create mode 100644 clis/weread-official/notes.js create mode 100644 clis/weread-official/readdata.js create mode 100644 clis/weread-official/review.js create mode 100644 clis/weread-official/search.js create mode 100644 clis/weread-official/shelf.js create mode 100644 clis/weread-official/utils.js create mode 100644 clis/weread-official/utils.test.js create mode 100644 clis/weread/ai-outline.js create mode 100644 clis/weread/ai-outline.test.js create mode 100644 clis/weread/auth.js create mode 100644 clis/weread/book-search.js create mode 100644 clis/weread/book-search.test.js create mode 100644 clis/weread/book.js create mode 100644 clis/weread/commands.test.js create mode 100644 clis/weread/highlights.js create mode 100644 clis/weread/notebooks.js create mode 100644 clis/weread/notes.js create mode 100644 clis/weread/private-api-regression.test.js create mode 100644 clis/weread/ranking.js create mode 100644 clis/weread/search-regression.test.js create mode 100644 clis/weread/search.js create mode 100644 clis/weread/shelf.js create mode 100644 clis/weread/utils.js create mode 100644 clis/weread/utils.test.js create mode 100644 clis/wikidata/entity.js create mode 100644 clis/wikidata/search.js create mode 100644 clis/wikidata/utils.js create mode 100644 clis/wikidata/wikidata.test.js create mode 100644 clis/wikipedia/page.js create mode 100644 clis/wikipedia/random.js create mode 100644 clis/wikipedia/search.js create mode 100644 clis/wikipedia/summary.js create mode 100644 clis/wikipedia/trending.js create mode 100644 clis/wikipedia/trending.test.js create mode 100644 clis/wikipedia/utils.js create mode 100644 clis/wttr/current.js create mode 100644 clis/wttr/forecast.js create mode 100644 clis/wttr/utils.js create mode 100644 clis/wttr/wttr.test.js create mode 100644 clis/xianyu/auth.js create mode 100644 clis/xianyu/chat.js create mode 100644 clis/xianyu/chat.test.js create mode 100644 clis/xianyu/im.js create mode 100644 clis/xianyu/im.test.js create mode 100644 clis/xianyu/inbox.js create mode 100644 clis/xianyu/item.js create mode 100644 clis/xianyu/item.test.js create mode 100644 clis/xianyu/messages.js create mode 100644 clis/xianyu/publish.js create mode 100644 clis/xianyu/publish.test.js create mode 100644 clis/xianyu/reply.js create mode 100644 clis/xianyu/search.js create mode 100644 clis/xianyu/search.test.js create mode 100644 clis/xianyu/utils.js create mode 100644 clis/xiaoe/auth.js create mode 100644 clis/xiaoe/catalog.js create mode 100644 clis/xiaoe/content.js create mode 100644 clis/xiaoe/courses.js create mode 100644 clis/xiaoe/detail.js create mode 100644 clis/xiaoe/play-url.js create mode 100644 clis/xiaoe/xiaoe.test.js create mode 100644 clis/xiaohongshu/ask.js create mode 100644 clis/xiaohongshu/ask.test.js create mode 100644 clis/xiaohongshu/auth.js create mode 100644 clis/xiaohongshu/collection-helpers.js create mode 100644 clis/xiaohongshu/collection-helpers.test.js create mode 100644 clis/xiaohongshu/comments.js create mode 100644 clis/xiaohongshu/comments.test.js create mode 100644 clis/xiaohongshu/creator-note-detail.js create mode 100644 clis/xiaohongshu/creator-note-detail.test.js create mode 100644 clis/xiaohongshu/creator-notes-summary.js create mode 100644 clis/xiaohongshu/creator-notes-summary.test.js create mode 100644 clis/xiaohongshu/creator-notes.js create mode 100644 clis/xiaohongshu/creator-notes.test.js create mode 100644 clis/xiaohongshu/creator-profile.js create mode 100644 clis/xiaohongshu/creator-stats.js create mode 100644 clis/xiaohongshu/creator-stats.test.js create mode 100644 clis/xiaohongshu/delete-note.js create mode 100644 clis/xiaohongshu/delete-note.test.js create mode 100644 clis/xiaohongshu/download.js create mode 100644 clis/xiaohongshu/download.test.js create mode 100644 clis/xiaohongshu/draft-clear.js create mode 100644 clis/xiaohongshu/draft-delete.js create mode 100644 clis/xiaohongshu/draft-open.js create mode 100644 clis/xiaohongshu/draft-utils.js create mode 100644 clis/xiaohongshu/drafts.js create mode 100644 clis/xiaohongshu/drafts.test.js create mode 100644 clis/xiaohongshu/feed.js create mode 100644 clis/xiaohongshu/feed.test.js create mode 100644 clis/xiaohongshu/follow.js create mode 100644 clis/xiaohongshu/follow.test.js create mode 100644 clis/xiaohongshu/liked.js create mode 100644 clis/xiaohongshu/liked.test.js create mode 100644 clis/xiaohongshu/navigation.test.js create mode 100644 clis/xiaohongshu/note-helpers.js create mode 100644 clis/xiaohongshu/note.js create mode 100644 clis/xiaohongshu/note.test.js create mode 100644 clis/xiaohongshu/notifications.js create mode 100644 clis/xiaohongshu/publish.js create mode 100644 clis/xiaohongshu/publish.test.js create mode 100644 clis/xiaohongshu/saved.js create mode 100644 clis/xiaohongshu/saved.test.js create mode 100644 clis/xiaohongshu/search.js create mode 100644 clis/xiaohongshu/search.test.js create mode 100644 clis/xiaohongshu/unfollow.js create mode 100644 clis/xiaohongshu/user-helpers.js create mode 100644 clis/xiaohongshu/user-helpers.test.js create mode 100644 clis/xiaohongshu/user.js create mode 100644 clis/xiaohongshu/user.test.js create mode 100644 clis/xiaoyuzhou/auth.js create mode 100644 clis/xiaoyuzhou/auth.test.js create mode 100644 clis/xiaoyuzhou/download.js create mode 100644 clis/xiaoyuzhou/download.test.js create mode 100644 clis/xiaoyuzhou/episode.js create mode 100644 clis/xiaoyuzhou/podcast-episodes.js create mode 100644 clis/xiaoyuzhou/podcast-episodes.test.js create mode 100644 clis/xiaoyuzhou/podcast.js create mode 100644 clis/xiaoyuzhou/transcript.js create mode 100644 clis/xiaoyuzhou/transcript.test.js create mode 100644 clis/xiaoyuzhou/utils.js create mode 100644 clis/xiaoyuzhou/utils.test.js create mode 100644 clis/xueqiu/auth.js create mode 100644 clis/xueqiu/comments.js create mode 100644 clis/xueqiu/comments.test.js create mode 100644 clis/xueqiu/danjuan-utils.js create mode 100644 clis/xueqiu/danjuan-utils.test.js create mode 100644 clis/xueqiu/earnings-date.js create mode 100644 clis/xueqiu/feed.js create mode 100644 clis/xueqiu/fund-holdings.js create mode 100644 clis/xueqiu/fund-snapshot.js create mode 100644 clis/xueqiu/groups.js create mode 100644 clis/xueqiu/hot-stock.js create mode 100644 clis/xueqiu/hot.js create mode 100644 clis/xueqiu/kline.js create mode 100644 clis/xueqiu/search.js create mode 100644 clis/xueqiu/stock.js create mode 100644 clis/xueqiu/utils.js create mode 100644 clis/xueqiu/utils.test.js create mode 100644 clis/xueqiu/watchlist.js create mode 100644 clis/yahoo-finance/quote.js create mode 100644 clis/yahoo/search.js create mode 100644 clis/yahoo/search.test.js create mode 100644 clis/yollomi/background.js create mode 100644 clis/yollomi/edit.js create mode 100644 clis/yollomi/face-swap.js create mode 100644 clis/yollomi/generate.js create mode 100644 clis/yollomi/models.js create mode 100644 clis/yollomi/object-remover.js create mode 100644 clis/yollomi/remove-bg.js create mode 100644 clis/yollomi/restore.js create mode 100644 clis/yollomi/try-on.js create mode 100644 clis/yollomi/upload.js create mode 100644 clis/yollomi/upscale.js create mode 100644 clis/yollomi/utils.js create mode 100644 clis/yollomi/video.js create mode 100644 clis/youdao/note.js create mode 100644 clis/youdao/note.test.js create mode 100644 clis/youtube/auth.js create mode 100644 clis/youtube/channel.js create mode 100644 clis/youtube/channel.test.js create mode 100644 clis/youtube/comments.js create mode 100644 clis/youtube/feed.js create mode 100644 clis/youtube/feed.test.js create mode 100644 clis/youtube/history.js create mode 100644 clis/youtube/like.js create mode 100644 clis/youtube/playlist.js create mode 100644 clis/youtube/search.js create mode 100644 clis/youtube/subscribe.js create mode 100644 clis/youtube/subscriptions.js create mode 100644 clis/youtube/transcript-group.js create mode 100644 clis/youtube/transcript-group.test.js create mode 100644 clis/youtube/transcript.js create mode 100644 clis/youtube/transcript.test.js create mode 100644 clis/youtube/unlike.js create mode 100644 clis/youtube/unsubscribe.js create mode 100644 clis/youtube/utils.js create mode 100644 clis/youtube/utils.test.js create mode 100644 clis/youtube/video.js create mode 100644 clis/youtube/video.test.js create mode 100644 clis/youtube/watch-later.js create mode 100644 clis/yuanbao/ask.js create mode 100644 clis/yuanbao/ask.test.js create mode 100644 clis/yuanbao/auth.js create mode 100644 clis/yuanbao/detail.js create mode 100644 clis/yuanbao/history.js create mode 100644 clis/yuanbao/new.js create mode 100644 clis/yuanbao/new.test.js create mode 100644 clis/yuanbao/read.js create mode 100644 clis/yuanbao/send.js create mode 100644 clis/yuanbao/shared.js create mode 100644 clis/yuanbao/shared.test.js create mode 100644 clis/yuanbao/status.js create mode 100644 clis/zhihu/answer-comments.js create mode 100644 clis/zhihu/answer-comments.test.js create mode 100644 clis/zhihu/answer-detail.js create mode 100644 clis/zhihu/answer-detail.test.js create mode 100644 clis/zhihu/answer.js create mode 100644 clis/zhihu/answer.test.js create mode 100644 clis/zhihu/auth.js create mode 100644 clis/zhihu/collection.js create mode 100644 clis/zhihu/collection.test.js create mode 100644 clis/zhihu/collections.js create mode 100644 clis/zhihu/collections.test.js create mode 100644 clis/zhihu/comment.js create mode 100644 clis/zhihu/comment.test.js create mode 100644 clis/zhihu/download.js create mode 100644 clis/zhihu/download.test.js create mode 100644 clis/zhihu/favorite.js create mode 100644 clis/zhihu/favorite.test.js create mode 100644 clis/zhihu/follow.js create mode 100644 clis/zhihu/follow.test.js create mode 100644 clis/zhihu/followers.js create mode 100644 clis/zhihu/followers.test.js create mode 100644 clis/zhihu/following.js create mode 100644 clis/zhihu/following.test.js create mode 100644 clis/zhihu/hot.js create mode 100644 clis/zhihu/like.js create mode 100644 clis/zhihu/like.test.js create mode 100644 clis/zhihu/paginate.js create mode 100644 clis/zhihu/pins.js create mode 100644 clis/zhihu/pins.test.js create mode 100644 clis/zhihu/question.js create mode 100644 clis/zhihu/question.test.js create mode 100644 clis/zhihu/recommend.js create mode 100644 clis/zhihu/recommend.test.js create mode 100644 clis/zhihu/search.js create mode 100644 clis/zhihu/search.test.js create mode 100644 clis/zhihu/target.js create mode 100644 clis/zhihu/target.test.js create mode 100644 clis/zhihu/text.js create mode 100644 clis/zhihu/text.test.js create mode 100644 clis/zhihu/user-answers.js create mode 100644 clis/zhihu/user-answers.test.js create mode 100644 clis/zhihu/user-arg.js create mode 100644 clis/zhihu/user-articles.js create mode 100644 clis/zhihu/user-articles.test.js create mode 100644 clis/zhihu/user.js create mode 100644 clis/zhihu/user.test.js create mode 100644 clis/zhihu/write-shared.js create mode 100644 clis/zhihu/write-shared.test.js create mode 100644 clis/zlibrary/commands.test.js create mode 100644 clis/zlibrary/info.js create mode 100644 clis/zlibrary/search.js create mode 100644 clis/zlibrary/utils.js create mode 100644 clis/zsxq/auth.js create mode 100644 clis/zsxq/dynamics.js create mode 100644 clis/zsxq/groups.js create mode 100644 clis/zsxq/search.js create mode 100644 clis/zsxq/search.test.js create mode 100644 clis/zsxq/topic.js create mode 100644 clis/zsxq/topic.test.js create mode 100644 clis/zsxq/topics.js create mode 100644 clis/zsxq/topics.test.js create mode 100644 clis/zsxq/utils.js create mode 100644 clis/zsxq/utils.test.js create mode 100644 designs/self-repair-protocol.md create mode 100644 docs/.vitepress/config.mts create mode 100644 docs/adapters/browser/12306.md create mode 100644 docs/adapters/browser/1688.md create mode 100644 docs/adapters/browser/1point3acres.md create mode 100644 docs/adapters/browser/36kr.md create mode 100644 docs/adapters/browser/51job.md create mode 100644 docs/adapters/browser/aibase.md create mode 100644 docs/adapters/browser/amazon.md create mode 100644 docs/adapters/browser/apple-podcasts.md create mode 100644 docs/adapters/browser/archive.md create mode 100644 docs/adapters/browser/arxiv.md create mode 100644 docs/adapters/browser/autohome.md create mode 100644 docs/adapters/browser/baidu-scholar.md create mode 100644 docs/adapters/browser/band.md create mode 100644 docs/adapters/browser/barchart.md create mode 100644 docs/adapters/browser/bbc.md create mode 100644 docs/adapters/browser/bilibili.md create mode 100644 docs/adapters/browser/binance.md create mode 100644 docs/adapters/browser/bloomberg.md create mode 100644 docs/adapters/browser/bluesky.md create mode 100644 docs/adapters/browser/booking.md create mode 100644 docs/adapters/browser/boss.md create mode 100644 docs/adapters/browser/brave.md create mode 100644 docs/adapters/browser/chaoxing.md create mode 100644 docs/adapters/browser/chatgpt.md create mode 100644 docs/adapters/browser/chess.md create mode 100644 docs/adapters/browser/claude.md create mode 100644 docs/adapters/browser/cnki.md create mode 100644 docs/adapters/browser/coingecko.md create mode 100644 docs/adapters/browser/confluence.md create mode 100644 docs/adapters/browser/coupang.md create mode 100644 docs/adapters/browser/crates.md create mode 100644 docs/adapters/browser/ctrip.md create mode 100644 docs/adapters/browser/dblp.md create mode 100644 docs/adapters/browser/deepseek.md create mode 100644 docs/adapters/browser/defillama.md create mode 100644 docs/adapters/browser/devto.md create mode 100644 docs/adapters/browser/dianping.md create mode 100644 docs/adapters/browser/dictionary.md create mode 100644 docs/adapters/browser/dockerhub.md create mode 100644 docs/adapters/browser/dongchedi.md create mode 100644 docs/adapters/browser/douban.md create mode 100644 docs/adapters/browser/doubao.md create mode 100644 docs/adapters/browser/douyin.md create mode 100644 docs/adapters/browser/duckduckgo.md create mode 100644 docs/adapters/browser/eastmoney.md create mode 100644 docs/adapters/browser/endoflife.md create mode 100644 docs/adapters/browser/facebook.md create mode 100644 docs/adapters/browser/flathub.md create mode 100644 docs/adapters/browser/flomo.md create mode 100644 docs/adapters/browser/gemini.md create mode 100644 docs/adapters/browser/geogebra.md create mode 100644 docs/adapters/browser/gitee.md create mode 100644 docs/adapters/browser/github-trending.md create mode 100644 docs/adapters/browser/github.md create mode 100644 docs/adapters/browser/google-scholar.md create mode 100644 docs/adapters/browser/google.md create mode 100644 docs/adapters/browser/goproxy.md create mode 100644 docs/adapters/browser/gov-law.md create mode 100644 docs/adapters/browser/gov-policy.md create mode 100644 docs/adapters/browser/grok.md create mode 100644 docs/adapters/browser/guazi.md create mode 100644 docs/adapters/browser/hackernews.md create mode 100644 docs/adapters/browser/hf.md create mode 100644 docs/adapters/browser/hltv.md create mode 100644 docs/adapters/browser/homebrew.md create mode 100644 docs/adapters/browser/huodongxing.md create mode 100644 docs/adapters/browser/hupu.md create mode 100644 docs/adapters/browser/imdb.md create mode 100644 docs/adapters/browser/indeed.md create mode 100644 docs/adapters/browser/instagram.md create mode 100644 docs/adapters/browser/jd.md create mode 100644 docs/adapters/browser/jianyu.md create mode 100644 docs/adapters/browser/jike.md create mode 100644 docs/adapters/browser/jimeng.md create mode 100644 docs/adapters/browser/jira.md create mode 100644 docs/adapters/browser/juejin.md create mode 100644 docs/adapters/browser/ke.md create mode 100644 docs/adapters/browser/kimi.md create mode 100644 docs/adapters/browser/lesswrong.md create mode 100644 docs/adapters/browser/lichess.md create mode 100644 docs/adapters/browser/linkedin-learning.md create mode 100644 docs/adapters/browser/linkedin.md create mode 100644 docs/adapters/browser/linux-do.md create mode 100644 docs/adapters/browser/lobsters.md create mode 100644 docs/adapters/browser/maimai.md create mode 100644 docs/adapters/browser/manus.md create mode 100644 docs/adapters/browser/maven.md create mode 100644 docs/adapters/browser/mdn.md create mode 100644 docs/adapters/browser/medium.md create mode 100644 docs/adapters/browser/mercury.md create mode 100644 docs/adapters/browser/mubu.md create mode 100644 docs/adapters/browser/notebooklm.md create mode 100644 docs/adapters/browser/nowcoder.md create mode 100644 docs/adapters/browser/npm.md create mode 100644 docs/adapters/browser/nuget.md create mode 100644 docs/adapters/browser/nvd.md create mode 100644 docs/adapters/browser/oeis.md create mode 100644 docs/adapters/browser/ones.md create mode 100644 docs/adapters/browser/openalex.md create mode 100644 docs/adapters/browser/openfda.md create mode 100644 docs/adapters/browser/openreview.md create mode 100644 docs/adapters/browser/osv.md create mode 100644 docs/adapters/browser/packagist.md create mode 100644 docs/adapters/browser/paperreview.md create mode 100644 docs/adapters/browser/pixiv.md create mode 100644 docs/adapters/browser/powerchina.md create mode 100644 docs/adapters/browser/producthunt.md create mode 100644 docs/adapters/browser/pubmed.md create mode 100644 docs/adapters/browser/pypi.md create mode 100644 docs/adapters/browser/quark.md create mode 100644 docs/adapters/browser/qwen.md create mode 100644 docs/adapters/browser/reddit.md create mode 100644 docs/adapters/browser/rednote.md create mode 100644 docs/adapters/browser/rest-countries.md create mode 100644 docs/adapters/browser/reuters.md create mode 100644 docs/adapters/browser/rfc.md create mode 100644 docs/adapters/browser/rubygems.md create mode 100644 docs/adapters/browser/semanticscholar.md create mode 100644 docs/adapters/browser/sinablog.md create mode 100644 docs/adapters/browser/sinafinance.md create mode 100644 docs/adapters/browser/slock.md create mode 100644 docs/adapters/browser/smzdm.md create mode 100644 docs/adapters/browser/spotify.md create mode 100644 docs/adapters/browser/stackoverflow.md create mode 100644 docs/adapters/browser/steam.md create mode 100644 docs/adapters/browser/substack.md create mode 100644 docs/adapters/browser/suno.md create mode 100644 docs/adapters/browser/taobao.md create mode 100644 docs/adapters/browser/tdx.md create mode 100644 docs/adapters/browser/ths.md create mode 100644 docs/adapters/browser/tieba.md create mode 100644 docs/adapters/browser/tiktok.md create mode 100644 docs/adapters/browser/toutiao.md create mode 100644 docs/adapters/browser/tvmaze.md create mode 100644 docs/adapters/browser/twitter.md create mode 100644 docs/adapters/browser/uisdc.md create mode 100644 docs/adapters/browser/uiverse.md create mode 100644 docs/adapters/browser/upwork.md create mode 100644 docs/adapters/browser/v2ex.md create mode 100644 docs/adapters/browser/wanfang.md create mode 100644 docs/adapters/browser/web.md create mode 100644 docs/adapters/browser/wechat-channels.md create mode 100644 docs/adapters/browser/weibo.md create mode 100644 docs/adapters/browser/weixin.md create mode 100644 docs/adapters/browser/weread-official.md create mode 100644 docs/adapters/browser/weread.md create mode 100644 docs/adapters/browser/wikidata.md create mode 100644 docs/adapters/browser/wikipedia.md create mode 100644 docs/adapters/browser/wttr.md create mode 100644 docs/adapters/browser/xianyu.md create mode 100644 docs/adapters/browser/xiaoe.md create mode 100644 docs/adapters/browser/xiaohongshu.md create mode 100644 docs/adapters/browser/xiaoyuzhou.md create mode 100644 docs/adapters/browser/xueqiu.md create mode 100644 docs/adapters/browser/yahoo-finance.md create mode 100644 docs/adapters/browser/yahoo.md create mode 100644 docs/adapters/browser/yollomi.md create mode 100644 docs/adapters/browser/youdao.md create mode 100644 docs/adapters/browser/youtube.md create mode 100644 docs/adapters/browser/yuanbao.md create mode 100644 docs/adapters/browser/zhihu.md create mode 100644 docs/adapters/browser/zlibrary.md create mode 100644 docs/adapters/browser/zsxq.md create mode 100644 docs/adapters/desktop/antigravity.md create mode 100644 docs/adapters/desktop/chatgpt-app.md create mode 100644 docs/adapters/desktop/chatwise.md create mode 100644 docs/adapters/desktop/codex.md create mode 100644 docs/adapters/desktop/cursor.md create mode 100644 docs/adapters/desktop/discord.md create mode 100644 docs/adapters/desktop/doubao-app.md create mode 100644 docs/adapters/desktop/qoder.md create mode 100644 docs/adapters/desktop/trae-cn.md create mode 100644 docs/adapters/desktop/trae-solo.md create mode 100644 docs/adapters/index.md create mode 100644 docs/advanced/android-chrome.md create mode 100644 docs/advanced/cdp.md create mode 100644 docs/advanced/download.md create mode 100644 docs/advanced/electron.md create mode 100644 docs/advanced/rate-limiter-plugin.md create mode 100644 docs/advanced/remote-chrome.md create mode 100644 docs/comparison.md create mode 100644 docs/conventions/convention-audit.md create mode 100644 docs/conventions/listing-detail-id-pairing.md create mode 100644 docs/design/browser-agent-runtime.md create mode 100644 docs/design/mercury-fix-mvp.md create mode 100644 docs/developer/ai-workflow.md create mode 100644 docs/developer/architecture.md create mode 100644 docs/developer/contributing.md create mode 100644 docs/developer/documentation-audit-2026-05.md create mode 100644 docs/developer/testing.md create mode 100644 docs/developer/ts-adapter.md create mode 100644 docs/developer/yaml-adapter.md create mode 100644 docs/guide/browser-bridge.md create mode 100644 docs/guide/electron-app-cli.md create mode 100644 docs/guide/exit-codes.md create mode 100644 docs/guide/extending-opencli.md create mode 100644 docs/guide/getting-started.md create mode 100644 docs/guide/installation.md create mode 100644 docs/guide/plugins.md create mode 100644 docs/guide/remote-orchestration.md create mode 100644 docs/guide/troubleshooting.md create mode 100644 docs/index.md create mode 100644 docs/superpowers/plans/2026-03-28-perf-smart-wait.md create mode 100644 docs/superpowers/plans/2026-03-31-daemon-lifecycle-redesign.md create mode 100644 docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md create mode 100644 docs/superpowers/specs/2026-03-31-daemon-lifecycle-redesign.md create mode 100644 docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md create mode 100644 docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md create mode 100644 docs/zh/adapters/index.md create mode 100644 docs/zh/advanced/android-chrome.md create mode 100644 docs/zh/advanced/cdp.md create mode 100644 docs/zh/developer/contributing.md create mode 100644 docs/zh/guide/browser-bridge.md create mode 100644 docs/zh/guide/electron-app-cli.md create mode 100644 docs/zh/guide/exit-codes.md create mode 100644 docs/zh/guide/extending-opencli.md create mode 100644 docs/zh/guide/getting-started.md create mode 100644 docs/zh/guide/installation.md create mode 100644 docs/zh/guide/plugins.md create mode 100644 docs/zh/index.md create mode 100644 extension/.gitignore create mode 100644 extension/README.md create mode 100644 extension/bun.lock create mode 100644 extension/dist/background.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/package-lock.json create mode 100644 extension/package.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 extension/scripts/package-release.mjs create mode 100644 extension/src/background.test.ts create mode 100644 extension/src/background.ts create mode 100644 extension/src/cdp.test.ts create mode 100644 extension/src/cdp.ts create mode 100644 extension/src/identity.ts create mode 100644 extension/src/journal.test.ts create mode 100644 extension/src/journal.ts create mode 100644 extension/src/protocol.ts create mode 100644 extension/store-assets/screenshot-1280x800.png create mode 100644 extension/tsconfig.json create mode 100644 extension/vite.config.ts create mode 100644 llms.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/check-doc-coverage.sh create mode 100644 scripts/check-listing-id-pairing.mjs create mode 100644 scripts/check-silent-column-drop.mjs create mode 100644 scripts/check-typed-error-lint.mjs create mode 100644 scripts/clean-dist.cjs create mode 100644 scripts/copy-yaml.cjs create mode 100644 scripts/fetch-adapters.js create mode 100644 scripts/postinstall.js create mode 100644 scripts/silent-column-drop-baseline.json create mode 100644 scripts/typed-error-lint-baseline.json create mode 100644 sitemaps/hackernews/SITE.md create mode 100644 sitemaps/hackernews/apis.md create mode 100644 sitemaps/hackernews/pages/feed.md create mode 100644 sitemaps/hackernews/pages/front.md create mode 100644 sitemaps/hackernews/pages/item.md create mode 100644 sitemaps/hackernews/pages/user.md create mode 100644 sitemaps/hackernews/pitfalls.md create mode 100644 sitemaps/hackernews/workflows/read-story.md create mode 100644 sitemaps/hackernews/workflows/submit-story.md create mode 100644 sitemaps/hackernews/workflows/upvote.md create mode 100644 sitemaps/twitter/SITE.md create mode 100644 sitemaps/twitter/apis.md create mode 100644 sitemaps/twitter/pages/_tweet_card.md create mode 100644 sitemaps/twitter/pages/compose.md create mode 100644 sitemaps/twitter/pages/home.md create mode 100644 sitemaps/twitter/pages/profile.md create mode 100644 sitemaps/twitter/pages/status.md create mode 100644 sitemaps/twitter/pitfalls.md create mode 100644 sitemaps/twitter/workflows/bookmark.md create mode 100644 sitemaps/twitter/workflows/post.md create mode 100644 sitemaps/twitter/workflows/reply.md create mode 100644 sitemaps/twitter/workflows/search.md create mode 100644 sitemaps/xiaohongshu/SITE.md create mode 100644 sitemaps/xiaohongshu/apis.md create mode 100644 sitemaps/xiaohongshu/pages/_note_card.md create mode 100644 sitemaps/xiaohongshu/pages/compose.md create mode 100644 sitemaps/xiaohongshu/pages/explore.md create mode 100644 sitemaps/xiaohongshu/pages/note.md create mode 100644 sitemaps/xiaohongshu/pages/profile.md create mode 100644 sitemaps/xiaohongshu/pitfalls.md create mode 100644 sitemaps/xiaohongshu/workflows/comment.md create mode 100644 sitemaps/xiaohongshu/workflows/publish.md create mode 100644 sitemaps/xiaohongshu/workflows/search.md create mode 100644 skills/opencli-adapter-author/SKILL.md create mode 100644 skills/opencli-adapter-author/references/adapter-template.md create mode 100644 skills/opencli-adapter-author/references/api-discovery.md create mode 100644 skills/opencli-adapter-author/references/coverage-matrix.md create mode 100644 skills/opencli-adapter-author/references/field-conventions.md create mode 100644 skills/opencli-adapter-author/references/field-decode-playbook.md create mode 100644 skills/opencli-adapter-author/references/jsdom-fixture-pattern.md create mode 100644 skills/opencli-adapter-author/references/output-design.md create mode 100644 skills/opencli-adapter-author/references/site-memory.md create mode 100644 skills/opencli-adapter-author/references/site-memory/bilibili.md create mode 100644 skills/opencli-adapter-author/references/site-memory/eastmoney.md create mode 100644 skills/opencli-adapter-author/references/site-memory/tonghuashun.md create mode 100644 skills/opencli-adapter-author/references/site-memory/xueqiu.md create mode 100644 skills/opencli-adapter-author/references/site-recon.md create mode 100644 skills/opencli-adapter-author/references/strategy-selection.md create mode 100644 skills/opencli-adapter-author/references/success-rate-pitfalls.md create mode 100644 skills/opencli-adapter-author/references/typed-errors.md create mode 100644 skills/opencli-autofix/SKILL.md create mode 100644 skills/opencli-browser-sitemap/SKILL.md create mode 100644 skills/opencli-browser/SKILL.md create mode 100644 skills/opencli-sitemap-author/SKILL.md create mode 100644 skills/opencli-sitemap-author/references/sitemap-schema.md create mode 100644 skills/opencli-usage/SKILL.md create mode 100644 skills/smart-search/SKILL.md create mode 100644 skills/smart-search/references/sources-ai.md create mode 100644 skills/smart-search/references/sources-info.md create mode 100644 skills/smart-search/references/sources-media.md create mode 100644 skills/smart-search/references/sources-other.md create mode 100644 skills/smart-search/references/sources-shopping.md create mode 100644 skills/smart-search/references/sources-social.md create mode 100644 skills/smart-search/references/sources-tech.md create mode 100644 skills/smart-search/references/sources-travel.md create mode 100644 src/adapter-shadow.test.ts create mode 100644 src/adapter-shadow.ts create mode 100644 src/adapter-source.test.ts create mode 100644 src/adapter-source.ts create mode 100644 src/browser.test.ts create mode 100644 src/browser/__fixtures__/article-extract/deno-v2.html create mode 100644 src/browser/__fixtures__/article-extract/openai-cookbook-readme.txt create mode 100644 src/browser/__fixtures__/article-extract/wikipedia-markdown.html create mode 100644 src/browser/analyze.test.ts create mode 100644 src/browser/analyze.ts create mode 100644 src/browser/article-extract.e2e.test.ts create mode 100644 src/browser/article-extract.test.ts create mode 100644 src/browser/article-extract.ts create mode 100644 src/browser/ax-snapshot.test.ts create mode 100644 src/browser/ax-snapshot.ts create mode 100644 src/browser/base-page.test.ts create mode 100644 src/browser/base-page.ts create mode 100644 src/browser/bridge-readiness.test.ts create mode 100644 src/browser/bridge-readiness.ts create mode 100644 src/browser/bridge.ts create mode 100644 src/browser/cdp-click-fixture.test.ts create mode 100644 src/browser/cdp.test.ts create mode 100644 src/browser/cdp.ts create mode 100644 src/browser/compound.test.ts create mode 100644 src/browser/compound.ts create mode 100644 src/browser/config.ts create mode 100644 src/browser/daemon-client.test.ts create mode 100644 src/browser/daemon-client.ts create mode 100644 src/browser/daemon-lifecycle.ts create mode 100644 src/browser/daemon-transport.ts create mode 100644 src/browser/daemon-version.ts create mode 100644 src/browser/dom-helpers.test.ts create mode 100644 src/browser/dom-helpers.ts create mode 100644 src/browser/dom-snapshot.test.ts create mode 100644 src/browser/dom-snapshot.ts create mode 100644 src/browser/errors.test.ts create mode 100644 src/browser/errors.ts create mode 100644 src/browser/extract.test.ts create mode 100644 src/browser/extract.ts create mode 100644 src/browser/find.test.ts create mode 100644 src/browser/find.ts create mode 100644 src/browser/html-tree.test.ts create mode 100644 src/browser/html-tree.ts create mode 100644 src/browser/index.ts create mode 100644 src/browser/network-cache.test.ts create mode 100644 src/browser/network-cache.ts create mode 100644 src/browser/network-interceptor.ts create mode 100644 src/browser/network-key.test.ts create mode 100644 src/browser/network-key.ts create mode 100644 src/browser/page.test.ts create mode 100644 src/browser/page.ts create mode 100644 src/browser/profile.test.ts create mode 100644 src/browser/profile.ts create mode 100644 src/browser/shape-filter.test.ts create mode 100644 src/browser/shape-filter.ts create mode 100644 src/browser/shape.test.ts create mode 100644 src/browser/shape.ts create mode 100644 src/browser/stealth.test.ts create mode 100644 src/browser/stealth.ts create mode 100644 src/browser/tabs.ts create mode 100644 src/browser/target-errors.test.ts create mode 100644 src/browser/target-errors.ts create mode 100644 src/browser/target-resolver.test.ts create mode 100644 src/browser/target-resolver.ts create mode 100644 src/browser/utils.test.ts create mode 100644 src/browser/utils.ts create mode 100644 src/browser/verify-fixture.test.ts create mode 100644 src/browser/verify-fixture.ts create mode 100644 src/browser/visual-refs.ts create mode 100644 src/build-manifest.test.ts create mode 100644 src/build-manifest.ts create mode 100644 src/capabilityRouting.test.ts create mode 100644 src/capabilityRouting.ts create mode 100644 src/cli-argv-preprocess.test.ts create mode 100644 src/cli-argv-preprocess.ts create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/commanderAdapter.test.ts create mode 100644 src/commanderAdapter.ts create mode 100644 src/commands/auth.test.ts create mode 100644 src/commands/auth.ts create mode 100644 src/commands/daemon.test.ts create mode 100644 src/commands/daemon.ts create mode 100644 src/completion-fast.ts create mode 100644 src/completion-shared.ts create mode 100644 src/completion.test.ts create mode 100644 src/completion.ts create mode 100644 src/constants.ts create mode 100644 src/convention-audit.test.ts create mode 100644 src/convention-audit.ts create mode 100644 src/daemon-utils.ts create mode 100644 src/daemon.test.ts create mode 100644 src/daemon.ts create mode 100644 src/discovery.ts create mode 100644 src/doctor.test.ts create mode 100644 src/doctor.ts create mode 100644 src/download/article-download.test.ts create mode 100644 src/download/article-download.ts create mode 100644 src/download/index.test.ts create mode 100644 src/download/index.ts create mode 100644 src/download/media-download.test.ts create mode 100644 src/download/media-download.ts create mode 100644 src/download/progress.test.ts create mode 100644 src/download/progress.ts create mode 100644 src/electron-apps.test.ts create mode 100644 src/electron-apps.ts create mode 100644 src/engine.test.ts create mode 100644 src/errors.test.ts create mode 100644 src/errors.ts create mode 100644 src/execution.test.ts create mode 100644 src/execution.ts create mode 100644 src/extension-manifest-regression.test.ts create mode 100644 src/external-clis.yaml create mode 100644 src/external.test.ts create mode 100644 src/external.ts create mode 100644 src/help.test.ts create mode 100644 src/help.ts create mode 100644 src/hooks.test.ts create mode 100644 src/hooks.ts create mode 100644 src/interceptor.test.ts create mode 100644 src/interceptor.ts create mode 100644 src/launcher.test.ts create mode 100644 src/launcher.ts create mode 100644 src/logger.ts create mode 100644 src/main.ts create mode 100644 src/manifest-types.ts create mode 100644 src/node-network.test.ts create mode 100644 src/node-network.ts create mode 100644 src/observation/artifact.test.ts create mode 100644 src/observation/artifact.ts create mode 100644 src/observation/events.ts create mode 100644 src/observation/index.ts create mode 100644 src/observation/manager.test.ts create mode 100644 src/observation/manager.ts create mode 100644 src/observation/redaction.test.ts create mode 100644 src/observation/redaction.ts create mode 100644 src/observation/retention.test.ts create mode 100644 src/observation/retention.ts create mode 100644 src/observation/ring-buffer.test.ts create mode 100644 src/observation/ring-buffer.ts create mode 100644 src/observation/session.ts create mode 100644 src/output.test.ts create mode 100644 src/output.ts create mode 100644 src/package-exports.test.ts create mode 100644 src/package-paths.ts create mode 100644 src/pipeline/executor.test.ts create mode 100644 src/pipeline/executor.ts create mode 100644 src/pipeline/index.ts create mode 100644 src/pipeline/registry.ts create mode 100644 src/pipeline/steps/browser.ts create mode 100644 src/pipeline/steps/download.test.ts create mode 100644 src/pipeline/steps/download.ts create mode 100644 src/pipeline/steps/fetch.test.ts create mode 100644 src/pipeline/steps/fetch.ts create mode 100644 src/pipeline/steps/intercept.ts create mode 100644 src/pipeline/steps/tap.ts create mode 100644 src/pipeline/steps/transform.ts create mode 100644 src/pipeline/template.test.ts create mode 100644 src/pipeline/template.ts create mode 100644 src/pipeline/transform.test.ts create mode 100644 src/plugin-manifest.test.ts create mode 100644 src/plugin-manifest.ts create mode 100644 src/plugin-scaffold.test.ts create mode 100644 src/plugin-scaffold.ts create mode 100644 src/plugin.test.ts create mode 100644 src/plugin.ts create mode 100644 src/registry-api.ts create mode 100644 src/registry.test.ts create mode 100644 src/registry.ts create mode 100644 src/runtime-detect.test.ts create mode 100644 src/runtime-detect.ts create mode 100644 src/runtime.ts create mode 100644 src/scripts/framework.ts create mode 100644 src/scripts/interact.ts create mode 100644 src/scripts/store.ts create mode 100644 src/serialization.test.ts create mode 100644 src/serialization.ts create mode 100644 src/session-lease.test.ts create mode 100644 src/session-lease.ts create mode 100644 src/skills.test.ts create mode 100644 src/skills.ts create mode 100644 src/snapshotFormatter.test.ts create mode 100644 src/snapshotFormatter.ts create mode 100644 src/tui.test.ts create mode 100644 src/tui.ts create mode 100644 src/types.ts create mode 100644 src/types/turndown-plugin-gfm.d.ts create mode 100644 src/update-check.test.ts create mode 100644 src/update-check.ts create mode 100644 src/utils.test.ts create mode 100644 src/utils.ts create mode 100644 src/validate.test.ts create mode 100644 src/validate.ts create mode 100644 src/verify.ts create mode 100644 src/version.ts create mode 100644 src/weixin-download.test.ts create mode 100644 tests/e2e/article-download-pipeline.test.ts create mode 100644 tests/e2e/band-auth.test.ts create mode 100644 tests/e2e/browser-auth-helpers.ts create mode 100644 tests/e2e/browser-auth.test.ts create mode 100644 tests/e2e/browser-ax-chrome.test.ts create mode 100644 tests/e2e/browser-public-extended.test.ts create mode 100644 tests/e2e/browser-public.test.ts create mode 100644 tests/e2e/browser-tabs.test.ts create mode 100644 tests/e2e/daemon-transport.test.ts create mode 100644 tests/e2e/douban.test.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/management.test.ts create mode 100644 tests/e2e/output-formats.test.ts create mode 100644 tests/e2e/plugin-management.test.ts create mode 100644 tests/e2e/public-commands.test.ts create mode 100644 tests/smoke/api-health.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..68c86dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,83 @@ +name: "🐛 Bug Report" +description: Report a bug or unexpected behavior in OpenCLI +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. A short reproduction and any error output are usually enough. + + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the bug. + placeholder: What happened? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce this behavior? + value: | + 1. Run `opencli ...` + 2. ... + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: input + id: version + attributes: + label: OpenCLI Version + description: "Run `opencli --version` to find out." + placeholder: "0.8.0" + validations: + required: true + + - type: dropdown + id: node-version + attributes: + label: Node.js Version + options: + - "20.x" + - "22.x" + - Other + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS + - Linux + - Windows + - Other + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / Screenshots + description: | + Paste any relevant error output. Run with `-v` for verbose logs: + ``` + opencli -v + ``` + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a8bc2c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📖 Documentation + url: https://github.com/jackwener/opencli#readme + about: Check the README and docs before opening an issue. + - name: 🧪 Testing Guide + url: https://github.com/jackwener/opencli/blob/main/TESTING.md + about: How to run and write tests for OpenCLI. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..082cd34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: "✨ Feature Request" +description: Suggest a new feature or improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Have an idea to make OpenCLI better? We'd love to hear it! + + - type: textarea + id: description + attributes: + label: Feature Description + description: A clear and concise description of the feature you'd like. + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case + description: What problem does this solve? Who benefits from this feature? + placeholder: "As a user, I want to ... so that ..." + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: If you have a specific implementation in mind, describe it here. + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Any alternative approaches you've thought about? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/new_site_adapter.yml b/.github/ISSUE_TEMPLATE/new_site_adapter.yml new file mode 100644 index 0000000..aa5c4e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_site_adapter.yml @@ -0,0 +1,57 @@ +name: "🌐 New Site Adapter Request" +description: Request support for a new website +title: "[Site]: " +labels: ["new-adapter"] +body: + - type: markdown + attributes: + value: | + Want OpenCLI to support a new site? Tell us about it! + + - type: input + id: site-name + attributes: + label: Site Name + description: The name of the website. + placeholder: "e.g. Product Hunt" + validations: + required: true + + - type: input + id: site-url + attributes: + label: Site URL + description: The main URL of the website. + placeholder: "https://www.producthunt.com" + validations: + required: true + + - type: textarea + id: commands + attributes: + label: Desired Commands + description: What commands would you like? List them with a brief description. + value: | + - `hot` — trending / popular items + - `search` — search the site + validations: + required: true + + - type: textarea + id: api-examples + attributes: + label: Example Links or API Endpoints + description: Share any example page URLs or API endpoints if you have them (optional). + placeholder: | + Example page: https://www.producthunt.com/posts/example + GET https://api.producthunt.com/v2/posts?order=votes + Response: { "posts": [{ "name": "...", "tagline": "..." }] } + validations: + required: false + + - type: checkboxes + id: contribution + attributes: + label: Willing to Contribute? + options: + - label: I'm willing to submit a PR for this adapter diff --git a/.github/actions/setup-chrome/action.yml b/.github/actions/setup-chrome/action.yml new file mode 100644 index 0000000..d0c3abd --- /dev/null +++ b/.github/actions/setup-chrome/action.yml @@ -0,0 +1,29 @@ +name: Setup Chrome +description: Install real Chrome for browser testing (with xvfb on Linux) + +outputs: + chrome-path: + description: Path to the installed Chrome binary + value: ${{ steps.setup-chrome.outputs.chrome-path }} + +runs: + using: composite + steps: + - name: Install real Chrome for Testing + uses: browser-actions/setup-chrome@v2 + id: setup-chrome + with: + # Stable Chrome for Testing keeps headed E2E on a released browser. + # `latest` pulls Chromium snapshots, which can break extension startup. + chrome-version: stable + + - name: Verify Chrome installation + shell: bash + run: | + echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}" + "${{ steps.setup-chrome.outputs.chrome-path }}" --version + + - name: Install xvfb (Linux only) + if: runner.os == 'Linux' + shell: bash + run: sudo apt-get install -y xvfb diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9d68d68 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +## Description + + + +Related issue: + +## Type of Change + +- [ ] 🐛 Bug fix +- [ ] ✨ New feature +- [ ] 🌐 New site adapter +- [ ] 📝 Documentation +- [ ] ♻️ Refactor +- [ ] 🔧 CI / build / tooling + +## Checklist + +- [ ] I ran the checks relevant to this PR +- [ ] I updated tests or docs if needed +- [ ] I included output or screenshots when useful + +### Documentation (if adding/modifying an adapter) + +- [ ] Added doc page under `docs/adapters/` (if new adapter) +- [ ] Updated `docs/adapters/index.md` table (if new adapter) +- [ ] Updated sidebar in `docs/.vitepress/config.mts` (if new adapter) +- [ ] Updated `README.md` / `README.zh-CN.md` when command discoverability changed +- [ ] Used positional args for the command's primary subject unless a named flag is clearly better +- [ ] Normalized expected adapter failures to `CliError` subclasses instead of raw `Error` + +## Screenshots / Output + + diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml new file mode 100644 index 0000000..2620287 --- /dev/null +++ b/.github/workflows/build-extension.yml @@ -0,0 +1,66 @@ +name: Build Chrome Extension + +on: + push: + branches: [ "main" ] + tags: [ "ext-v*" ] + paths: + - 'extension/**' + - '.github/workflows/build-extension.yml' + pull_request: + branches: [ "main" ] + paths: + - 'extension/**' + - '.github/workflows/build-extension.yml' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: extension/package-lock.json + + - name: Install extension dependencies + run: npm ci + working-directory: extension + + - name: Build extension + run: npm run build + working-directory: extension + + - name: Prepare extension package + run: npm run package:release -- --out ../extension-package + working-directory: extension + + - name: Create Extension ZIP + run: | + EXT_VERSION=$(node -p "require('./extension/package.json').version") + cd extension-package + zip -r ../opencli-extension-v${EXT_VERSION}.zip . + + - name: Upload Artifacts (Action Run) + uses: actions/upload-artifact@v7 + with: + name: opencli-extension-build + path: opencli-extension-v*.zip + retention-days: 7 + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v3.0.0 + with: + files: opencli-extension-v*.zip + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b74f58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: '0 8 * * 1' # Weekly Monday 08:00 UTC — smoke tests + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Fast gate: typecheck + build ── + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Build + run: npm run build + + # Guard: committed cli-manifest.json must match the one build regenerates. + # Prevents silent drift where unrelated adapter entries vanish or change + # across PRs (agent hits unexpected manifest diff → surgical-merge churn). + - name: Check cli-manifest.json is up-to-date + if: runner.os == 'Linux' + shell: bash + run: | + if ! git diff --exit-code -- cli-manifest.json; then + echo "::error::cli-manifest.json is out of sync with the source. Run 'npm run build' and commit the result." + exit 1 + fi + + # Guard: adapter rows must not silently emit keys omitted from `columns`. + # Existing findings are tracked in scripts/silent-column-drop-baseline.json; + # this gate rejects newly introduced drops while allowing incremental cleanup. + - name: Check silent column drops + if: runner.os == 'Linux' + run: npm run check:silent-column-drop + + # Guard: adapters should fail with typed errors instead of silently + # returning empty arrays, clamping user input, or inventing sentinel data. + # Existing findings are tracked in scripts/typed-error-lint-baseline.json. + - name: Check typed-error lint baseline + if: runner.os == 'Linux' + run: npm run check:typed-error-lint + + # ── Unit tests (vitest shard) ── + # PR: ubuntu + Node 22 only (fast feedback, 2 jobs). + # Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs). + unit-test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["ubuntu-latest"]') }} + node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["22"]') || fromJSON('["22"]') }} + shard: [1, 2] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2) + run: npx vitest run --project unit --project extension --reporter=verbose --shard=${{ matrix.shard }}/2 + + # ── Bun compatibility check ── + bun-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests under Bun + run: bun vitest run --project unit --reporter=verbose + + # Adapter tests are pure unit tests — OS doesn't affect results. Gated off + # `pull_request` to keep PR CI under ~2 minutes; adapter authors run focused + # tests locally before pushing, and `push` to main / nightly cron / manual + # dispatch still guard the merged state. + adapter-test: + if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run focused adapter tests + run: npm run test:adapter -- --reporter=verbose + + # ── Smoke tests (scheduled / manual only) ── + smoke-test: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # NOTE: Windows excluded — browser-actions/setup-chrome hangs during + # Chrome MSI installation on Windows runners (known issue). + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + id: setup-chrome + + - name: Build + run: npm run build + + - name: Run smoke tests (Linux, via xvfb) + if: runner.os == 'Linux' + run: | + xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ + npx vitest run tests/smoke/ --reporter=verbose + + - name: Run smoke tests (macOS / Windows) + if: runner.os != 'Linux' + run: npx vitest run tests/smoke/ --reporter=verbose + timeout-minutes: 15 diff --git a/.github/workflows/doc-check.yml b/.github/workflows/doc-check.yml new file mode 100644 index 0000000..a831c99 --- /dev/null +++ b/.github/workflows/doc-check.yml @@ -0,0 +1,36 @@ +name: Doc Check + +on: + pull_request: + branches: [main, dev] + +concurrency: + group: doc-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Adapter doc coverage ── + doc-coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check adapter doc coverage + run: bash scripts/check-doc-coverage.sh --strict + + # ── VitePress build validation ── + docs-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build docs (catches broken links & sidebar refs) + run: npm run docs:build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d4fdf5f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,17 @@ +name: Trigger Website Rebuild (Docs Updated) + +on: + push: + branches: [main] + paths: ['docs/**'] + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Trigger opencli-website rebuild + uses: peter-evans/repository-dispatch@v4 + with: + token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }} + repository: jackwener/opencli-website + event-type: docs-updated diff --git a/.github/workflows/e2e-headed.yml b/.github/workflows/e2e-headed.yml new file mode 100644 index 0000000..e412568 --- /dev/null +++ b/.github/workflows/e2e-headed.yml @@ -0,0 +1,109 @@ +name: E2E Headed Chrome + +on: + # E2E removed from `pull_request` to keep PR feedback under ~2 minutes; PR-time + # protection is the CI workflow (typecheck / unit / lint / adapter / build). + # E2E still guards `main` directly, runs nightly, and on release tag push so + # protocol/CDP/extension contract regressions are caught before they ship. + push: + branches: [main, dev] + paths: + - 'extension/**' + - 'src/browser/**' + - 'src/daemon.ts' + - 'src/execution.ts' + - 'src/interceptor.ts' + - 'tests/e2e/**' + - 'tests/smoke/**' + - '.github/actions/setup-chrome/**' + - '.github/workflows/e2e-headed.yml' + tags: ['v*'] + schedule: + # Daily 08:00 UTC — catch flake / Chrome-version drift even when no commits + # touched the watched paths recently. + - cron: '0 8 * * *' + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-headed: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Gate placement by what each runner can run deterministically: + # - the real-browser extension smoke needs a Chrome that reliably runs + # an MV3 extension, which only Linux+xvfb provides on hosted runners + # (headed macOS crashes on Mach port rendezvous outside an Aqua + # session; headless does not connect the extension SW there); + # - the daemon transport contracts need no browser and run blocking on + # every OS, so macOS/Windows get a real gate, not a skipped one. + # macOS pinned to 15 while the macOS 26 image stabilizes. + os: [ubuntu-latest, macos-15, windows-latest] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Linux runs the extension smoke and macOS runs the full real-site e2e + # suite; both need a real Chrome. Windows runs only the browser-free + # transport gate, and the setup-chrome action hangs on Windows anyway. + - name: Setup Chrome + if: runner.os != 'Windows' + uses: ./.github/actions/setup-chrome + id: setup-chrome + + - name: Build + run: npm run build + + - name: Build extension + run: npm run build --prefix extension + + # Real-browser extension smoke: Linux under xvfb is the one hosted + # environment where a real Chrome reliably starts an MV3 extension, so + # this is the release-blocking browser gate. Headed (not headless): + # headless does not connect the extension service worker on hosted + # runners. See the matrix comment for why macOS/Windows don't run it. + - name: Run AX Chrome smoke (Linux, real extension via xvfb) + if: runner.os == 'Linux' + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + OPENCLI_AX_E2E: '1' + OPENCLI_E2E_HEADED: '1' + run: | + xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ + npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose + + # Transport contract E2E: real daemon process + scripted fake extension. + # Pins the cross-layer contracts (waiter attach, deadline 408, dispatched + # disconnect, profile fallback, graceful shutdown) end to end with the + # actual daemon binary — no browser required, so this is the blocking + # gate on EVERY OS, including macOS and Windows. + - name: Run daemon transport contract E2E + run: npx vitest run --project e2e-fixed-port tests/e2e/daemon-transport.test.ts --reporter=verbose + + - name: Run E2E tests (Linux, via xvfb) + if: runner.os == 'Linux' + env: + OPENCLI_AX_E2E: '0' + run: | + xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ + npx vitest run tests/e2e/ --reporter=verbose + + # Real-site adapter e2e stays on Linux/macOS; Windows runs the two + # deterministic gates above (unit coverage in ci.yml already spans it). + - name: Run E2E tests (macOS) + if: runner.os == 'macOS' + env: + OPENCLI_AX_E2E: '0' + run: npx vitest run tests/e2e/ --reporter=verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..113f157 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + # Build before the manifest drift gate: adapter modules import + # @jackwener/opencli/* through package exports, which resolve to dist/. + # A fresh release checkout has no dist/ until the full build runs. + - name: Build package and verify cli-manifest.json is up-to-date + run: | + npm run build + if ! git diff --exit-code -- cli-manifest.json; then + echo "::error::cli-manifest.json drift detected at release time. Run 'npm run build' locally and commit the result before tagging." + exit 1 + fi + + - name: Install extension dependencies + run: npm ci + working-directory: extension + + - name: Build extension + run: npm run build + working-directory: extension + + - name: Package extension + run: npm run package:release -- --out ../extension-package + working-directory: extension + + - name: Create extension ZIP + run: | + EXT_VERSION=$(jq -r .version extension/package.json) + cd extension-package + zip -r ../opencli-extension-v${EXT_VERSION}.zip . + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3.0.0 + with: + generate_release_notes: true + files: | + opencli-extension-v*.zip + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Trigger website rebuild + uses: peter-evans/repository-dispatch@v4 + with: + token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }} + repository: jackwener/opencli-website + event-type: version-released diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..0fd041e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,33 @@ +name: Security Audit + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: '0 9 * * 1' # Weekly Monday 09:00 UTC + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: npm audit (production) + run: npm audit --omit=dev --audit-level=high diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52559bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules/ +dist/ +!extension/dist/ +*.tsbuildinfo +.opencli/ +.worktrees/ +.mcp.json +*.log +.DS_Store + +# VitePress +docs/.vitepress/dist +docs/.vitepress/cache + +# Extensions & Secrets +*.pem +*.crx +*.zip +.envrc +.windsurf +.claude +.cortex + +# Database files +*.db +autoresearch/results/ +autoresearch-results.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25030eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,973 @@ +# Changelog + +## [1.8.4](https://github.com/jackwener/opencli/compare/v1.8.3...v1.8.4) (2026-06-15) + +Patch release surfacing the bundled skills directory, expanding the auth subsystem across 50+ adapters, refactoring the extension's tab-group model, and adding ten or so new adapter capabilities. + +### Features + +* **skills** — new `opencli skills list` and `opencli skills read [path]` commands expose the bundled `skills/opencli-*` directories as a canonical, version-bound source of agent-facing guidance. Skills are now published as part of the npm package (`skills/opencli-*/**`), so the Browser Bridge App's bundled OpenCLI carries the same skills the CLI version itself documents. Non-opencli skills, `../` path traversal, and unknown skill names are rejected with friendly error messages. ([#1948](https://github.com/jackwener/opencli/pull/1948)) +* **auth** — `opencli auth status` aggregate command lists per-adapter session health; `quickCheck` wired into 50 adapters so the aggregate is fast; `auth refresh` maintenance command extends the daily auth-refresh model; first auth coverage for `nowcoder`, `jike`, `maimai`, `jimeng` and another batch of sites. ([#1878](https://github.com/jackwener/opencli/pull/1878), [#1879](https://github.com/jackwener/opencli/pull/1879), [#1880](https://github.com/jackwener/opencli/pull/1880), [#1881](https://github.com/jackwener/opencli/pull/1881)) +* **extension 1.0.20** — `refactor(extension): remove visible adapter tab group` drops the visible Adapter tab-group surface; OpenCLI no longer creates a user-visible group for adapter tabs. ([#1925](https://github.com/jackwener/opencli/pull/1925)) +* **xiaohongshu** — `ask` adapter with citations; `follow` / `unfollow` commands; commenter user-identity columns on `read`. +* **bilibili** — `follow` / `unfollow` commands. +* **twitter** — expose media poster URLs in tweet output; harden SearchTimeline metadata and API error paths. +* **reddit** — media columns surfaced in `read` output. +* **discord-app** — targeted `read` navigation. +* **huodongxing** — new `events` adapter. +* **slock** — new collaboration adapter. +* **manus** / **gemini** — Patch release backports (carried in from 1.8.3 timeline coverage gap). +* **llms.txt** — generated for AI visibility / GEO. ([#1889](https://github.com/jackwener/opencli/pull/1889)) + +### Bug Fixes + +* **douban** — `title` splitting is now self-contained for the `page.evaluate` call (was depending on outer scope under chunked extraction). +* **bloomberg** — Businessweek reads now traverse from the section page instead of the legacy article landing. +* **deepseek** — reject search with incompatible models pre-navigation (saves a wasted page load). +* **chatgpt** — response extraction stabilized under virtual scrolling. + +## [1.8.3](https://github.com/jackwener/opencli/compare/v1.8.2...v1.8.3) (2026-06-06) + +Patch release focused on two architectural fixes around extension and daemon lifecycle, plus the first wave of the new site auth subsystem. + +### Bug Fixes + +* **extension 1.0.19** — close the MV3 Service Worker race that spawned duplicate `OpenCLI Adapter` tab groups (and, in the worst case, duplicate Adapter windows). The extension now persists the owned `windowId` immediately after `chrome.windows.create` returns and persists the owned `groupId` immediately after `chrome.tabs.group` returns, so a worker death between those API calls and the subsequent `chrome.tabGroups.update` no longer leaves a titleless orphan group and no longer drops the window pointer. Title-update failure no longer ungroups (it lets `ensureCanonicalGroupTitle` self-heal on the next ensure cycle), and `collectOwnedGroupCandidates` gains a fourth recovery layer: a global scan for empty-title groups containing a known owned `preferredTabId` for the role, with explicit hijack defense for user-built untitled groups. Closes the duplicate-tab-group bug report users had reported across the 1.8.2 window. ([#1862](https://github.com/jackwener/opencli/pull/1862)) +* **daemon** — SIGKILL fallback when the stale daemon refuses graceful shutdown. After `npm install -g @jackwener/opencli@latest`, the CLI detects a version-mismatched daemon (`daemonVersion !== PKG_VERSION`), asks it to exit via `/shutdown`, and now — if the port is still held after 3 s — reads the stale daemon's pid from its own `/status` response and `process.kill(pid, 'SIGKILL')` (cross-platform: maps to `TerminateProcess` on Windows). The previous flow surfaced `Stale daemon could not be replaced` and asked users to run `opencli daemon stop && opencli doctor`; this is now automatic. ([#1861](https://github.com/jackwener/opencli/pull/1861)) +* **xiaohongshu/publish** — prioritize the visible title input when the editor renders both a hidden draft input and a visible publish input. +* **xiaohongshu/publish** — accept inline topic suggestions with Enter when the dropdown lives inside a Shadow DOM surface, while still verifying the topic marker appears in the editor. +* **instagram/following** — paginate beyond the first endpoint page so high `--limit` values return more than the initial batch. + +### Features + +* **site auth subsystem** — new `opencli login` and `opencli whoami` commands, registered through a shared `clis/_shared/site-auth.js` helper. `login` opens the site's auth page in a foreground persistent session and polls the configured `verify` probe (cookie, JSON API, DOM scrape) until the browser session reports logged-in; `whoami` runs the same probe without opening the page. First five sites: twitter, github, bilibili, douyin, xiaohongshu. `whoami` outputs are PII-scrubbed (no email / phone / token in row columns). ([#1852](https://github.com/jackwener/opencli/pull/1852)) +* **gemini** — add read-only conversation commands (list / read / search). +* **manus** — add a read-only `manus.im` adapter. + +### Docs / Sitemap + +* **sitemaps/xiaohongshu** — Phase 2 sitemap content seeded with login schema dogfood, the first non-PoC consumer of the v1.1 sitemap schema. ([#1853](https://github.com/jackwener/opencli/pull/1853)) + +### Internal + +* **test(e2e)** — raise `runCli` `maxBuffer` so manifest-output snapshots no longer truncate on macOS / Windows CI. + +## [1.8.2](https://github.com/jackwener/opencli/compare/v1.8.1...v1.8.2) (2026-06-03) + +Mid-cycle release: introduces the **Site Maps Hub** subsystem (agent-facing per-site navigation knowledge), restores the **smart-search** skill, and ships a wide batch of new adapters / commands plus a long tail of read-path fixes. Extension bumped to 1.0.18 for an owned-group reusable-tab scope fix. + +### Site Maps Hub (new subsystem) + +* **`sitemaps//` top-level seed directory** — sitemap content lives alongside `clis/` and `skills/`, parallel first-class repo citizens. Twitter and HackerNews seeded as v1 baselines. +* **`opencli browser open` / `analyze` surface sitemap availability** — when the requested site has a sitemap (global seed or local overlay `~/.opencli/sites//sitemap/`), the JSON envelope gains an optional `sitemap` field with `{ available, source, hint }`. `open` emits the hint once per session per site (deduped via `~/.opencli/cache/browser-sitemap-hints/`); `analyze` emits every call since it is a planning command. Adds no new browser-action behavior and no `~/.opencli/sites/` writes unless an agent explicitly invokes a sitemap skill. +* **Two new skills**: + * `opencli-sitemap-author` — create / maintain per-site sitemaps. Two-layer storage (global repo seed + local overlay), Form B compact YAML action schema with `pre / do / post / fail / recover / evidence`, `adapter_health_update` directives, `selector_pattern` as first-class anchor type, partial pages (`_.md`) for cross-page UI, and a size-guidance table with hard 800-token / 1500-3000 cohesion / >3000 split tiers. + * `opencli-browser-sitemap` — consume site sitemaps while executing browser tasks. Lazy load, Trust-Reality rule (`browser state` is truth, sitemap is hint), stale-on-conflict writeback, `adapter_health` write-back closure so subsequent agents skip a known-suspect adapter. +* **`references/sitemap-schema.md`** — full field-level spec for `SITE.md / pages/.md / workflows/.md / apis.md / pitfalls.md`, action `state_signature` for re-entry, `adapter_health` enum, stable-id matching across overlay layers, draft placement rule, Phase 2 validation hooks. +* **Twitter + HackerNews v1.1 seeds** under `sitemaps/{twitter,hackernews}/` validating the schema on dense React UI and simple SSR HTML respectively. + +### Features + +* **smart-search** — restored as a skill (`skills/smart-search/`) with per-category source guides (AI / info / media / shopping / social / tech / travel / other). +* **twitter** — batch follow + list lifecycle (`list-create` / `list-delete` / `list-add` / `list-remove` batch forms). +* **xiaohongshu** — draft management commands (`drafts` / `draft-open` / `draft-delete` / `draft-clear`). +* **chatgpt-app** — temporary chat + multi-modal image attachment support. +* **antigravity** — history mgmt (`history` / `delete` / `mark-read`) and model read/switch commands. +* **codex** — conversation management (`pin` / `unpin` / `archive` / `rename`) plus model selector fix. +* **grok** — conversation management (`delete` / `pin` / `unpin`) with locale-independent selectors. +* **kimi** — new adapter for `kimi.com` (21 commands). +* **qoder** — new adapter for Qoder IDE (19 commands). +* **trae-cn** — new desktop adapter (Trae CN Electron app). +* **trae-solo** — new desktop adapter (Trae SOLO Electron app). +* **chatgpt** — add web model switch command. +* **douyin** — add `search` command for keyword video search. +* **wechat-channels** — add WeChat Video Channels (视频号) publish adapter. +* **pubmed** — add workflow presets and richer article metadata. + +### Bug Fixes + +* **extension 1.0.18** — scope reusable-tab selection to owned-group members (follow-up to the v1.0.17 owned-container convergence model; ensures `findReusableOwnedContainerTab` does not pick up user tabs that were dragged into the owned window). +* **chatgpt** — ignore image placeholders and upload previews when extracting the latest assistant message. +* **xiaohongshu** — attach real topics via inline dropdown; feed returns signed note URLs for drill-down; carousel order preserved on download. +* **twitter** — drop global tweetPhoto selector from the post-submit poll to avoid matching the wrong button. +* **grok** — fall back to `Enter` key dispatch when send button is hidden behind layout shifts. +* **daemon** — differentiate multi-profile status output so multiple Chrome profiles do not collapse into a single status row. +* **youtube** — Videos tab fallback now supports `lockupViewModel` format alongside the legacy `gridVideoRenderer`. +* **12306** — accept lowercase letters in `train_no` regex. +* **weixin** — strip typographic quotes from pasted URLs. +* **launcher** — Chromium 142+ CDP websocket origin check needs `--remote-allow-origins=*`. +* **douyin/publish** — handle illegal-title errors with a typed error rather than a silent retry. + +### Docs + +* **opencli-adapter-author** — add `references/strategy-selection.md` codifying the empirical contract ladder (PUBLIC_API / COOKIE_API / UI_SELECTOR / DOM_STATE as contracted vs PAGE_FETCH / INTERCEPT as internal-unstable, with fixes/adapter-year data from a 837-adapter / 30-day window) and update SKILL.md to require a `strategy` evidence block at the top of every new adapter. +* **opencli-adapter-author** — `browser analyze` upgrade: each candidate API gets `real_data_score` and a `likely_data` / `maybe_data` / `noise` verdict so Pattern A is no longer fired by analytics XHRs. +* **readme** — prefix "Let AI Agents operate any website" bullet with "Browser User &" in both EN and zh-CN. + +## [1.8.1](https://github.com/jackwener/opencli/compare/v1.8.0...v1.8.1) (2026-05-31) + +Patch release focused on the extension tab-group convergence fix, plus 10 new adapters/commands and a wave of read-path / security hardening across browser, download, and adapters. + +### Features + +* **chess** — add Chess.com browser adapter. +* **geogebra** — add GeoGebra browser adapter suite. +* **jira / confluence** — add Atlassian Jira and Confluence adapter support. ([#1690](https://github.com/jackwener/opencli/pull/1690)) +* **upwork** — add `search`, `feed`, and `detail` commands. +* **notebooklm** — add guarded write commands. +* **bilibili** — add comment commands. +* **weread** — add book search inside an open WeRead book. +* **linkedin** — consolidate read commands and add `profile-experience`. +* **xiaohongshu** — paginate `creator-notes` past the analyze list cap. + +### Bug Fixes + +* **extension 1.0.16** — ship the `OpenCLI Browser` / `OpenCLI Adapter` tab-group race fix from [#1693](https://github.com/jackwener/opencli/pull/1693). The extension now serializes owned tab-group creation per role so concurrent adapter/browser leases reuse the same group instead of creating duplicate same-title groups. +* **extension 1.0.17** — replace owned tab-group management with a Chrome-state-as-truth convergence model. The extension now keeps one canonical `OpenCLI Browser` / `OpenCLI Adapter` group per profile role, recovers renamed groups from stored hints or owned lease tabs, merges same-window and cross-window duplicates into the canonical group, and normalizes legacy or user-renamed container titles back to the canonical owned-container title. ⚠️ User-renamed `OpenCLI Browser` / `OpenCLI Adapter` groups are now force-renamed back; treat these as extension-managed automation containers, not user free-form bins. ([#1794](https://github.com/jackwener/opencli/pull/1794)) +* **browser** — write the network response cache file with `0o600` owner-only permissions to keep captured response bodies out of other local users' reach. +* **download** — write the yt-dlp cookie file with `0o600` owner-only permissions. +* **pixiv** — migrate `user/detail` to the shared `pixivFetch` helper. +* **twitter** — drop unknown silent sentinels; read profile `name` / `created_at` from `result.core`; handle `NotAllowed` image-upload fallback; detect private `likes` / `following` empty-timeline shape. ([#1702](https://github.com/jackwener/opencli/pull/1702)) +* **weread** — decode HTML entities in search results. +* **zhihu** — decode numeric HTML entities in text output. ([#1695](https://github.com/jackwener/opencli/pull/1695)) +* **xiaohongshu** — hook dashboard fetch to capture signed `datacenter/note/*` responses ([#1732](https://github.com/jackwener/opencli/pull/1732)); preserve carousel order via `__INITIAL_STATE__.imageList` on download ([#1687](https://github.com/jackwener/opencli/pull/1687)). +* **bilibili** — subtitle support for bangumi / PGC bvid (番剧 / 纪录片 / 电影 / 综艺). ([#1669](https://github.com/jackwener/opencli/pull/1669)) +* **suno** — derive current plan from subscription metadata. +* **douyin/hashtag** — validate action args before navigation. +* **byte-formatting** — stabilize byte formatting output. + +### Docs + +* **readme** — correct Node floor (>=20, not 21) and drop the Prerequisites section ([#1705](https://github.com/jackwener/opencli/pull/1705)); add CLI Hub brand aliases and split Exit Codes into the dedicated docs page ([#1685](https://github.com/jackwener/opencli/pull/1685)); drop the For Developers section ([#1684](https://github.com/jackwener/opencli/pull/1684)). + +### Internal + +* **ci** — disable Dependabot automated updates. +* **test(download)** — retry media-download Windows tests to absorb runner cold-start variance. ([#1708](https://github.com/jackwener/opencli/pull/1708)) + +## [1.8.0](https://github.com/jackwener/opencli/compare/v1.7.22...v1.8.0) (2026-05-20) + +Substantial release: a new official-API adapter (`weread-official`), wider LinkedIn / Twitter / Reddit / Zhihu coverage, the 12306 / Suno / Xianyu inbox additions, security and reliability fixes for the Browser Bridge and media downloads, plus a 20% README shrink. Node 20 compatibility is restored after an automated `undici` bump regression. + +### Features + +* **weread-official** — integrate WeRead's official Agent Gateway as the `weread-official` CLI namespace. Pure HTTP, Bearer auth via `WEREAD_API_KEY` (no browser, no cookies). 8 commands cover the official skill bundle: `search`, `shelf`, `book` (info + chapters + progress 3-in-1), `notes` (notebook overview or per-book highlights/thoughts), `review`, `readdata` (weekly/monthly/annually/overall), `discover` (recommend or similar-book), `list-apis`. Adapter surfaces typed errors for all documented failure modes — `AuthRequiredError` on missing/rejected key (errcodes -2010/-2012), `CommandExecutionError` on HTTP/`upgrade_info`/non-zero errcode, `EmptyResultError` on empty payloads. Coexists with the existing cookie-based `weread` adapter. +* **12306** — add full read adapter (`stations` / `trains` / `train` / `price` / `me` / `passengers` / `orders`). ([#1637](https://github.com/jackwener/opencli/issues/1637)) +* **xianyu** — add `inbox`, `messages`, and `reply` commands. ([#1639](https://github.com/jackwener/opencli/issues/1639)) +* **suno** — add Suno.com music-generation adapter. ([#1638](https://github.com/jackwener/opencli/issues/1638)) +* **linkedin** — consolidate messaging and Sales Navigator commands (`connect`, `inbox`, `safe-send`, `salesnav-search`, `salesnav-inbox`, `salesnav-message`, `salesnav-thread`, `sent-invitations`, `thread-snapshot`, `timeline`). ([#1647](https://github.com/jackwener/opencli/issues/1647)) +* **linkedin/people-search** — add a dedicated people-search command. ([#1649](https://github.com/jackwener/opencli/issues/1649)) +* **linkedin-learning** — add `search` / `trending` / `course` read commands. ([#1657](https://github.com/jackwener/opencli/issues/1657)) +* **twitter** — rewrite the download-profile path on GraphQL UserMedia with cursor pagination. ([#1636](https://github.com/jackwener/opencli/issues/1636)) +* **twitter** — add `list-create` (GraphQL CreateList mutation). ([#1656](https://github.com/jackwener/opencli/issues/1656)) +* **twitter** — add `device-follow` notification-stream command. +* **twitter** — expose `card.binding_values` on read commands for inline link-preview metadata. ([#1660](https://github.com/jackwener/opencli/issues/1660)) +* **twitter** — expose `quoted_tweet` on read commands. ([#1667](https://github.com/jackwener/opencli/issues/1667)) +* **twitter** — expose `bio` on read commands. +* **reddit/subscribed** — new `subscribed` command + listing-level `id` / `created_utc` / `selftext` exposure. ([#1651](https://github.com/jackwener/opencli/issues/1651)) +* **reddit** — expose `post_hint` / `url` / `preview` / `gallery` media routes on listing commands. ([#1676](https://github.com/jackwener/opencli/issues/1676)) +* **zhihu** — add answer-comments reader; include answer links in question results. +* **chatgpt** — detect generated image surfaces (CSS background and canvas, not just ``) so image generation works after UI drift. ([#1677](https://github.com/jackwener/opencli/issues/1677)) +* **external** — add Cloudflare Wrangler as a built-in external CLI passthrough. ([#1679](https://github.com/jackwener/opencli/pull/1679)) + +### Bug Fixes + +* **deps** — restore Node 20 runtime compatibility by pinning runtime `undici` back to the 6.x line (an automated dependabot bump to 8.x had moved the engines floor to Node ≥22.19, silently breaking the published Node 20 promise), and clear the docs build audit chain by overriding VitePress' Vite/PostCSS transitive dependencies to patched versions. ([#1673](https://github.com/jackwener/opencli/issues/1673)) +* **download** — keep custom media filenames inside the requested output directory by stripping POSIX/Windows path components and sanitizing the generated fallback prefix. Prevents remote-controlled fields (e.g. video titles used as filename) from escaping the output directory via `../`. ([#1642](https://github.com/jackwener/opencli/pull/1642)) +* **browser** — recover `Page.goto()` from stale page identities by clearing the cached targetId and retrying navigation once through the session lease; classify CDP `-32000 Cannot find default execution context` as retryable target navigation. ([#1645](https://github.com/jackwener/opencli/issues/1645)) +* **cli** — escape leading-dash positional values via the argv preprocessor so users can pass tokens starting with `-` without commander mis-classifying them as flags. ([#1658](https://github.com/jackwener/opencli/issues/1658)) +* **chatgpt/image** — fix ChatGPT web image generation after UI drift by letting the composer locator continue into the caller's readiness check and detecting generated images rendered as CSS backgrounds or canvases, not just plain `` elements. +* **adapters** — surface the remaining `silent-empty-fallback` adapter failures as typed errors (Douyin user video comments, Jike SSR JSON parse, WeRead search-page fetch). True empty Douyin/Jike/WeRead result sets now throw `EmptyResultError`. +* **adapters** — drop silent-sentinel row fallbacks across Apple Podcasts / Reddit / Gitee. ([#1634](https://github.com/jackwener/opencli/issues/1634)) +* **adapters** — migrate legal empty-data branches to `EmptyResultError` for `xhs` / YouTube and 5 follow-up commands. ([#1674](https://github.com/jackwener/opencli/issues/1674), [#1678](https://github.com/jackwener/opencli/issues/1678)) +* **lesswrong** — drop the `"Unknown"` silent sentinel in the author column; missing authors now propagate as `null`. ([#1611](https://github.com/jackwener/opencli/issues/1611)) +* **youtube/transcript** — scope timedtext URL matching to the current `videoId` across the in-page resource-buffer scan, the in-page fetch/XHR hook, and the Node-side CDP capture. SPA-style watch→watch navigation no longer returns a predecessor video's captions. ([#1655](https://github.com/jackwener/opencli/issues/1655)) +* **twitter/lists** — skip the "Discover new Lists" recommendation block so it is no longer treated as one of the user's lists. ([#1652](https://github.com/jackwener/opencli/issues/1652)) +* **zhihu** — harden search pagination. ([#1615](https://github.com/jackwener/opencli/issues/1615)) +* **zhihu** — decode numeric HTML entities in `answer-detail`. ([#1629](https://github.com/jackwener/opencli/issues/1629)) + +### Docs + +* **readme** — major shrink and reframing: tagline rephrased around "Browser Use", Highlights and Update sections folded into adjacent content, Built-in Commands curated to 11 popular sites, CLI Hub table reduced to a name enumeration, Desktop App Adapters collapsed to a one-liner, skill-attribution references audited against `SKILL.md` frontmatter, "For AI Agents (Developer Guide)" merged into "Writing a new adapter". Net: EN 410 → 326 (-20%), ZH 455 → 371 (-18%). ([#1654](https://github.com/jackwener/opencli/pull/1654), [#1666](https://github.com/jackwener/opencli/pull/1666), [#1679](https://github.com/jackwener/opencli/pull/1679), [#1681](https://github.com/jackwener/opencli/pull/1681)) + +### Internal + +* **audit** — stop flagging sentinel fallback strings inside thrown error messages as `silent-sentinel` violations. These are typed failure diagnostics rather than fake row data, reducing the typed-error baseline to actual adapter output fallbacks. + +## [1.7.22](https://github.com/jackwener/opencli/compare/v1.7.21...v1.7.22) (2026-05-15) + +External CLI ergonomics + two adapter envelope/auth fixes. New `longbridge` external CLI entry; `opencli list` / root help now render human-readable brand labels for executables whose bare name is ambiguous. + +### Features + +* **external** — add the Longbridge CLI as a built-in external CLI passthrough (`opencli longbridge ...`) for Longbridge OpenAPI market data, account, and trading commands. ([#1584](https://github.com/jackwener/opencli/issues/1584)) +* **external-cli** — render brand alias `name(package)` in `opencli list` and root help when the bare executable name is ambiguous. Built-in entries `ntn` → `ntn(notion)`, `dws` → `dws(DingTalk Workspace)`, `wecom-cli` → `wecom-cli(企业微信)` now self-explain in help output. `package` field is repurposed to cover both upstream distribution names (e.g. `tg-cli`) and human-readable brand labels (e.g. `notion`, `企业微信`). ([#1585](https://github.com/jackwener/opencli/issues/1585)) + +### Bug Fixes + +* **boss** — map `code=24` (identity mismatch) to `AuthRequiredError` so re-login is signaled instead of surfacing as a generic API error. ([#1573](https://github.com/jackwener/opencli/issues/1573)) +* **weibo** — unwrap Browser Bridge `page.evaluate` envelopes in read adapters. ([#1568](https://github.com/jackwener/opencli/issues/1568)) + +## [1.7.21](https://github.com/jackwener/opencli/compare/v1.7.20...v1.7.21) (2026-05-14) + +Adapter polish release: new web search adapters, better Browser Bridge tab group reuse, and social adapters returning to one-shot tab leases. Extension package version is bumped to 1.0.15 for the Browser Bridge fix. + +### Features + +* **search** — add DuckDuckGo, Brave, and Yahoo web search adapters. ([#1546](https://github.com/jackwener/opencli/issues/1546)) +* **boss** — support job-seeker `chatlist` and `chatmsg` adapters. ([#1539](https://github.com/jackwener/opencli/issues/1539)) + +### Bug Fixes + +* **extension** — reuse existing `OpenCLI Adapter` tab groups before creating new ones, including cross-window discovery, legacy `OpenCLI` title fallback, and deterministic candidate selection. ([#1541](https://github.com/jackwener/opencli/issues/1541)) +* **twitter, reddit** — default browser-backed social adapters back to ephemeral tab leases. Twitter/X and Reddit commands now release their site tab after each run while keeping the shared Adapter window available for reuse; persistent sessions remain reserved for AI/chat-style adapters that need long-lived conversation state. ([#1569](https://github.com/jackwener/opencli/issues/1569)) +* **xiaohongshu, rednote** — unwrap Browser Bridge `page.evaluate` envelopes in search adapters. ([#1561](https://github.com/jackwener/opencli/issues/1561)) +* **facebook/feed** — add fallback extraction for empty article nodes. ([#1538](https://github.com/jackwener/opencli/issues/1538)) + +### Internal + +* **ci** — add Windows native binding lockfile entries for Rolldown/Rollup optional packages. ([#1563](https://github.com/jackwener/opencli/issues/1563)) +* **extension** — add regression coverage for the adapter tab group `groupId` tiebreaker. ([#1566](https://github.com/jackwener/opencli/issues/1566)) + +## [1.7.20](https://github.com/jackwener/opencli/compare/v1.7.19...v1.7.20) (2026-05-14) + +External CLI surface cleanup + Browser Bridge WebSocket lifecycle hardening. Two BREAKING changes around external CLIs: built-in `tg`/`discord`/`wx` (was `tg-cli`/`discord-cli`/`wx-cli`) now match their real binary names, and Notion's in-tree CDP adapter is replaced by the official `ntn` external CLI. + +### ⚠ BREAKING CHANGES + +* **notion** — remove the in-tree `clis/notion/` CDP-on-Desktop adapter (8 commands: `status` / `search` / `read` / `new` / `write` / `sidebar` / `favorites` / `export`). Notion has shipped an official CLI at , registered as a first-class external CLI in `external-clis.yaml`. Migration: install `ntn` from (`curl -fsSL https://ntn.dev | bash`), then use `opencli ntn `. Auto-install is intentionally not configured because the official installer is a shell script while OpenCLI external installs run shell-free command strings. The official CLI uses the public Notion API rather than reverse-engineering the Desktop UI, so it survives Notion app updates and exposes a wider command surface (blocks / databases / properties / comments) than the reverse-engineered adapter could. ([#1559](https://github.com/jackwener/opencli/issues/1559)) +* **external** — drop the `-cli` suffix from built-in external CLI subcommand names. `opencli tg-cli`, `opencli discord-cli`, `opencli wx-cli` are now `opencli tg`, `opencli discord`, `opencli wx`, matching the real binary names that those tools install as. Root help still shows the package lineage as `tg(tg-cli)` / `discord(discord-cli)` / `wx(wx-cli)`. ([#1544](https://github.com/jackwener/opencli/issues/1544)) + +### Features + +* **twitter** — `bookmarks` and `bookmark-folder` now include media via `extractMedia`, reaching parity with `timeline` / `search`. ([#1555](https://github.com/jackwener/opencli/issues/1555)) +* **twitter/list-tweets** — include media via `extractMedia` (parity with `timeline` / `search`). ([#1464](https://github.com/jackwener/opencli/issues/1464)) + +### Bug Fixes + +* **daemon** — report ambiguous browser command outcomes with a distinct `command_result_unknown` errorCode and `503` when the extension WebSocket drops between command dispatch and result delivery. `sendCommandRaw()` treats this code as hard non-retryable, so write-side commands (`navigate` / `click` / `type` / `eval`) won't be silently re-issued and double-executed. Daemon exposes a `commandResultUnknown` counter on `/status` for future observability. ([#1558](https://github.com/jackwener/opencli/issues/1558)) +* **extension** — keep active daemon WebSocket; stale sockets no longer clobber active connection (`onopen` / `onclose` / `onmessage` are all gated by `ws !== thisWs` short-circuit), and `safeSend` only fires when `readyState === OPEN`. ([#1540](https://github.com/jackwener/opencli/issues/1540)) +* **extension** — coalesce concurrent daemon WebSocket connects via an in-flight promise. Startup / keepalive / reconnect triggering `connect()` during the daemon-probe or context-lookup async gap no longer creates duplicate real WebSocket connections. ([#1554](https://github.com/jackwener/opencli/issues/1554)) +* **external** — distinguish external CLI executable names from distribution/project names in root help. Built-in aliases such as `tg`, `discord`, `wx` remain the callable `opencli ...` entrypoints while help renders `tg(tg-cli)`, `discord(discord-cli)`, `wx(wx-cli)` to show their package lineage. ([#1560](https://github.com/jackwener/opencli/issues/1560)) + +### Docs + +* **browser** — clarify named session lifecycle in the Browser Bridge guide. ([#1542](https://github.com/jackwener/opencli/issues/1542)) + +## [1.7.19](https://github.com/jackwener/opencli/compare/v1.7.18...v1.7.19) (2026-05-14) + +Major hotfix + simplification batch. Extension bumped to 1.0.14. Node floor lowered to v20 so the long tail of Node v20–v21.6 users no longer crashes at module load. `opencli browser` user surface replaces required-flag `--session ` with a `` positional. `page.evaluate(fn, ...args)` adds a type-safe alternative to the implicit auto-IIFE string form. Twitter cursor pagination no longer silently caps at ~500 items. + +### ⚠ BREAKING CHANGES + +* **browser** — replace the `--session ` flag with a `` positional argument that immediately follows `browser`. `opencli browser work click 12` instead of `opencli browser --session work click 12`; `opencli browser work bind` instead of `opencli browser bind --session work`. Required-flag semantics are now encoded structurally as a positional, matching the Docker/git convention for required operation-target identifiers. The internal `--session` flag is preserved for the daemon protocol and for direct `program.parseAsync` callers but is no longer part of the user-facing surface. ([#1505](https://github.com/jackwener/opencli/issues/1505)) +* **env** — remove `OPENCLI_KEEP_TAB`. The flag was a debugging shortcut, not a config dimension: `--keep-tab true|false` on the command line is the single source of truth, and adapter `siteSession: 'persistent'` already pins persistent site tabs as a hard constraint. Removing the env eliminates a globally-leaking process state that overrode every browser command in the shell. ([#1509](https://github.com/jackwener/opencli/issues/1509)) +* **extension** — remove the internal `surface\\0session` command-session backdoor. Browser Bridge commands now route only through structured `session` + `surface` fields; lease-key strings remain an extension-internal registry detail. ([#1510](https://github.com/jackwener/opencli/issues/1510)) + +### Features + +* **browser** — add `page.evaluate(fn, ...args)` for type-safe browser-context evaluation with JSON-serialized arguments. String evaluation remains supported, but new adapter code should use function form to avoid implicit `wrapForEval` auto-IIFE magic. ([#1508](https://github.com/jackwener/opencli/issues/1508)) +* **twitter** — default `tweets` command to the logged-in user when `user` is omitted, and fix the sibling envelope-unwrap silent bug. ([#1531](https://github.com/jackwener/opencli/issues/1531)) +* **zhihu** — add `answer-detail` to fetch a single answer's full content. ([#1528](https://github.com/jackwener/opencli/issues/1528)) +* **zhihu** — paginate question answers and recommendations. ([#1517](https://github.com/jackwener/opencli/issues/1517)) +* **reddit/read** — `--expand-more` via `/api/morechildren` + 7-kind typed errors. ([#1492](https://github.com/jackwener/opencli/issues/1492)) +* **reddit** — add `whoami`, `home`, `subreddit-info` read commands. ([#1491](https://github.com/jackwener/opencli/issues/1491)) +* **ctrip** — add `hotel-search` + flight browser-mode commands. ([#1489](https://github.com/jackwener/opencli/issues/1489)) + +### Bug Fixes + +* **browser** — `page.evaluate()` / `evaluateInFrame()` now return the user JavaScript value directly. Browser Bridge `exec` previously routed through a shared `pageScopedResult` helper that spread / wrapped the lease's `session` into the result `data`, contaminating arbitrary user returns: array / primitive returns came back as `{ session, data }` envelopes, and plain-object returns had an extra `session` key injected (overwriting any user `session` field). `google search` and `xiaohongshu search` were the visible repro — Chrome rendered results correctly but adapters extracted an empty array. Fixed in extension 1.0.14 by reverting `pageScopedResult` to its pre-1461 form (`{ id, ok, data, page }`); no client-side unwrap is needed. ([#1518](https://github.com/jackwener/opencli/issues/1518)) +* **twitter** — raise fixed cursor-pagination caps in `bookmarks` / `likes` / `tweets` / `timeline` / `bookmark-folder` / `list-tweets` / `search` / `following`. The old `i < 5` / `i < 10` literals and following's `Math.ceil(limit / 50) + 2` formula imposed hidden result ceilings below `--limit`; the loop now treats the page count as a high runaway guard while `--limit` and cursor exhaustion control normal pagination. ([#1532](https://github.com/jackwener/opencli/issues/1532)) +* **twitter** — repair `list-add` / `list-tweets` / `lists` / `following` after 2026-05 site changes. ([#1503](https://github.com/jackwener/opencli/issues/1503)) +* **twitter** — repair `search` and `tweets` readback. ([#1512](https://github.com/jackwener/opencli/issues/1512)) +* **twitter** — make reply submission robust. ([#1511](https://github.com/jackwener/opencli/issues/1511)) +* **google/search** — wait for `#rso a h3` before extracting, falling back to the existing fixed wait. On Chrome 148 + Linux Wayland the DOM can settle before SERP anchors are populated, making extraction return empty even with the envelope bug fixed. ([#1518](https://github.com/jackwener/opencli/issues/1518)) +* **google/search** — wrap evaluate return value in object to fix serialization. ([#1523](https://github.com/jackwener/opencli/issues/1523)) +* **google-scholar/search** — wrap evaluate return to fix serialization. ([#1525](https://github.com/jackwener/opencli/issues/1525)) +* **xiaohongshu/search** — extract initially visible cards before scrolling, then merge post-scroll rows by URL. Xiaohongshu's virtualized masonry layout can evict the initial cards from the DOM after scroll, so the previous always-scroll-then-extract flow could lose the top results. ([#1518](https://github.com/jackwener/opencli/issues/1518)) +* **xiaohongshu** — `parseLikes` handles `2.1w` / `1.5万` / `1.2k` shortforms. ([#1504](https://github.com/jackwener/opencli/issues/1504)) +* **xiaohongshu+rednote/search** — fall back to href-based note cards when `section.note-item` class is dropped. ([#1507](https://github.com/jackwener/opencli/issues/1507)) +* **xueqiu** — `kline` / `earnings-date` format dates in Asia/Shanghai instead of UTC. ([#1498](https://github.com/jackwener/opencli/issues/1498)) +* **download** — clamp progress percentages. ([#1520](https://github.com/jackwener/opencli/issues/1520)) + +### Internal + +* **runtime** — lower the Node floor to `>=20.0.0`. Three coupled changes: drop all `util.styleText()` usage (added in Node v21.7.0 / v20.12.0; previously crashed v21.0–v21.6 at module load), downgrade `undici` from `^8.0.2` (engines `>=22.19.0`) to `^6.25.0` (engines `>=18.17`, retains `Agent` / `EnvHttpProxyAgent` / `fetch`), and lower `MIN_SUPPORTED_NODE_MAJOR` from 21 to 20 so the startup guard matches the declared `engines.node`. Smoke-tested on v20.0.0 / v21.2.0 / v22.22.2. The semantic markers (`[OK]` / `[WARN]` / `[FAIL]` / `ℹ` / `⚠` / `✖`) keep their meaning; ANSI colors were redundant for the primarily agent-facing CLI. ([#1524](https://github.com/jackwener/opencli/issues/1524)) +* **extension 1.0.14** — `pageScopedResult` no longer injects `session` into `data`. The field had no consumers and contaminated `exec` results with arbitrary user-JS shapes; routing-relevant identity is already exposed via `Result.page`. ([#1518](https://github.com/jackwener/opencli/issues/1518)) +* **extension 1.0.13** — remove the internal command-session lease-key backdoor. ([#1510](https://github.com/jackwener/opencli/issues/1510)) +* **ci** — drop `e2e-headed` and `adapter-test` from `pull_request` triggers (kept on `push` to main / nightly / `workflow_dispatch`). PR-time CI now targets ~2 min wall-time. ([#1521](https://github.com/jackwener/opencli/issues/1521), [#1522](https://github.com/jackwener/opencli/issues/1522)) +* **scripts** — auto-refresh `dist/` before `build-manifest`. ([#1490](https://github.com/jackwener/opencli/issues/1490)) + +## [1.7.18](https://github.com/jackwener/opencli/compare/v1.7.17...v1.7.18) (2026-05-12) + +Hotfix release for the 1.7.17 doctor regression: `opencli doctor` failed connectivity probe with `Browser session is required` because the doctor probe didn't pass a session to the new strict-session browser bridge. Also adds new adapters and adapter fixes that were ready immediately after 1.7.17. + +### Bug Fixes + +* **doctor** — pass an internal `__doctor__` browser session to the live connectivity probe so `opencli doctor` works again under the explicit-session browser model introduced in 1.7.17. ([#1485](https://github.com/jackwener/opencli/issues/1485)) +* **browser** — `--session ` is now declared as a `requiredOption` so Commander itself rejects calls missing the flag before runtime, and the help line is marked `(required)` instead of being hidden under `Options:`. ([#1485](https://github.com/jackwener/opencli/issues/1485)) +* **doubao/ask** — restore Assistant detection after the 2026-05 DOM refactor. ([#1484](https://github.com/jackwener/opencli/issues/1484)) +* **youtube** — request `srv3` format for caption URLs. ([#1422](https://github.com/jackwener/opencli/issues/1422)) + +### Features + +* **rednote** — add `rednote.com` adapter mirroring xiaohongshu read commands. ([#1475](https://github.com/jackwener/opencli/issues/1475)) +* **reddit** — add `reply` command for replying to comments. ([#1428](https://github.com/jackwener/opencli/issues/1428)) + +## [1.7.17](https://github.com/jackwener/opencli/compare/v1.7.16...v1.7.17) (2026-05-12) + +Extension bumped to 1.0.12 (workspace → session lease routing, drop `handleSessions` handler). Major simplification pass: browser/adapter session model rewrite, `--workspace` removed, doctor surface trimmed to its core job. + +### ⚠ BREAKING CHANGES + +* **browser session model** — replace the browser-facing `--workspace` model with explicit `--session ` on `opencli browser *`. Browser commands now require a session name, `browser bind`/`unbind` use `--session`, and bind no longer accepts `--domain`, `--path-prefix`, or `--allow-navigate-bound`. Browser primitives keep their session tab by design; the browser namespace no longer exposes `--keep-tab`. ([#1461](https://github.com/jackwener/opencli/issues/1461)) +* **adapter site sessions** — replace adapter metadata `browserSession: { reuse: 'site' }` with `siteSession: 'persistent'`, and replace the user override `--reuse ` / `OPENCLI_BROWSER_REUSE` with `--site-session `. Persistent site sessions keep a stable site tab open without idle expiry. ([#1462](https://github.com/jackwener/opencli/issues/1462)) +* **doctor** — remove `--no-live` and `--sessions` flags from `opencli doctor`. Doctor always runs the live browser connectivity probe (that's its core job); session enumeration was never part of health diagnosis. The underlying `'sessions'` daemon protocol action and the `BrowserSessionInfo` public type are removed as dead code. ([#1470](https://github.com/jackwener/opencli/issues/1470)) + +### Features + +* **chatgpt** — `ask` and `send` now accept local image paths and upload them through the composer before submitting the prompt. ([#1476](https://github.com/jackwener/opencli/issues/1476)) + +### Internal + +* **extension 1.0.12** — drop `handleSessions` action handler (no remaining consumers after doctor cleanup). +* **extension 1.0.11** — switch Browser Bridge lease routing from user-facing workspaces to explicit browser sessions. + +## [1.7.16](https://github.com/jackwener/opencli/compare/v1.7.15...v1.7.16) (2026-05-11) + +Extension bumped to 1.0.10 (rename adapter-owned tab group `OpenCLI Automation` → `OpenCLI Adapter`). Performance and stability sweep across browser-backed adapters; new external CLI integrations (tg-cli, discord-cli, wx-cli). + +### Features + +* **openreview** — add `author` command for ID-explicit publication lookup. ([#1365](https://github.com/jackwener/opencli/issues/1365)) +* **external** — register `tg-cli`, `discord-cli`, and `wx-cli` as external CLI integrations. ([#1458](https://github.com/jackwener/opencli/issues/1458)) + +### Bug Fixes + +* **xiaohongshu** — fall back to base64 upload when CDP `DOM.setFileInputFiles` returns `Not allowed` on creator center. ([#1374](https://github.com/jackwener/opencli/issues/1374)) +* **chatgpt** — switch to locale-stable send button selector so non-English UIs don't break send. ([#1354](https://github.com/jackwener/opencli/issues/1354)) + +### Performance + +* **adapters** — hoist cookie reads to `page.getCookies` across Tier 1 (25 files), eliminating per-call CDP round trips. ([#1450](https://github.com/jackwener/opencli/issues/1450)) +* **twitter** — drop redundant `goto + wait` in adapter steps; framework auto pre-navigates. ([#1451](https://github.com/jackwener/opencli/issues/1451)) +* **twitter** — enable `browserSession.reuse: 'site'` on 17 read-only adapters so repeated reads share one tab. ([#1454](https://github.com/jackwener/opencli/issues/1454)) +* **reddit** — opt 13 browser-backed adapters into shared site-tab lease. ([#1455](https://github.com/jackwener/opencli/issues/1455)) +* **claude** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1452](https://github.com/jackwener/opencli/issues/1452)) +* **deepseek** — replace fixed-sleep waits with selector-based readiness on streaming flows. ([#1449](https://github.com/jackwener/opencli/issues/1449)) +* **chatgpt** — replace fixed-sleep waits with selector-based readiness (D3). ([#1456](https://github.com/jackwener/opencli/issues/1456)) + +### Refactor + +* **browser** — split interactive and automation windows so `opencli browser *` and adapter-driven background commands no longer share one Chrome window; tab groups are isolated by role. + +### Internal + +* **extension 1.0.10** — rename the adapter-owned Chrome tab group from `OpenCLI Automation` to `OpenCLI Adapter`. ([#1457](https://github.com/jackwener/opencli/issues/1457)) +* **docs** — list `tg-cli`, `discord-cli`, `wx-cli` in External CLI README sections. ([#1459](https://github.com/jackwener/opencli/issues/1459)) + +## [1.7.15](https://github.com/jackwener/opencli/compare/v1.7.14...v1.7.15) (2026-05-10) + +Extension bumped to 1.0.9 (Accessibility.enable allowlist + downloads permission + cross-origin frame target attach for AX). Major Browser Agent Runtime release: full Phase 0/1/2 alignment with `vercel-labs/agent-browser` model — CDP-primary input, AX snapshot/refs with stale recovery, semantic locators across all primitives, full form toolbelt (hover/focus/dblclick/check/uncheck/upload/drag/wait-download), annotated screenshots, and same-origin iframe AX routing. Cross-origin OOPIF AX is best-effort (Chrome extension API limitation). + +### ⚠ BREAKING CHANGES + +* **browser lifecycle** — replace `--focus` / `OPENCLI_WINDOW_FOCUSED` with `--window foreground|background` / `OPENCLI_WINDOW`, and replace `--live` / `OPENCLI_LIVE` with `--keep-tab true|false` / `OPENCLI_KEEP_TAB`. `opencli browser *` defaults to a foreground window and keeps its tab; browser-backed adapter commands default to a background automation window and release their tab unless the adapter uses site-level reuse. + +### Features + +* **help / browser** — `opencli browser --help -f yaml|json` now emits a structured, agent-ready index of all browser leaf commands (including nested `tab`, `get`, and `dialog` commands), their positionals, command options, namespace options, and root global options. Individual browser commands also support structured help, backed by a shared Commander option/argument spec extractor. +* **help / built-in namespaces** — `opencli daemon|plugin|adapter|profile --help -f yaml|json` now emit the same structured payload as `browser`. One agent call returns every leaf's positionals, options, descriptions, and global options — no per-leaf `--help` follow-ups needed. Original namespace descriptions are preserved through `applyRootSubcommandSummaries()` via a snapshot at namespace declaration time. +* **browser state** — add opt-in AX snapshot refs via `browser state --source ax`, including backend-node click resolution and role/name stale-ref recovery for the Phase 0 browser-agent runtime prototype. +* **browser state** — AX snapshots now include same-origin iframe refs, and `browser state --compare-sources` prints DOM-vs-AX observation metrics for the Phase 1 default-source decision without dumping page contents. +* **browser locators** — `browser find`, `browser click`, and `browser get text|value|attributes` now accept semantic locator flags (`--role`, `--name`, `--label`, `--text`, `--testid`) so agents can act on common controls without a separate state-ref lookup. +* **browser locators** — semantic locator flags now work across input/action primitives (`type`, `fill`, `select`, `hover`, `focus`, `dblclick`, `check`, `uncheck`, `upload`) plus prefixed `--from-*` / `--to-*` locators for `drag`. +* **browser actions** — add `browser hover`, `browser focus`, and `browser dblclick` primitives backed by the same target resolver and CDP input path as `browser click`. +* **browser actions** — add `browser check` and `browser uncheck` primitives that ensure checkbox / radio / aria-checked controls reach the requested state instead of blindly toggling. +* **browser upload** — add `browser upload ` to attach local files to `input[type=file]` targets through CDP `DOM.setFileInputFiles`, with local path validation and file-input verification. +* **browser actions** — add `browser drag ` for CDP mouse drag sequences between two resolved element centers. +* **browser wait / extension 1.0.8** — add `browser wait download [pattern]` backed by Chrome's downloads lifecycle API, so agents can wait for file downloads by filename/URL pattern and receive completed/failed download metadata. +* **browser state / extension 1.0.9** — AX snapshots can now route same-origin iframe refs through `frameId`. Cross-origin OOPIF AX routing is best-effort because real Chrome extension smoke tests show `chrome.debugger` may not expose attachable iframe targets to extensions. +* **browser screenshot** — add `browser screenshot --annotate`, which refreshes DOM refs and overlays visible `[N]` labels on the screenshot so visual inspection maps back to `browser click ` targets. + +### Bug Fixes + +* **browser click** — `browser click` now prefers CDP `Input.dispatchMouseEvent` over DOM `el.click()`, so custom dropdowns that depend on pointer/mouse events (Radix, shadcn, Material UI, Mercury-style category pickers) open and select reliably while retaining JS click as a fallback for older backends or zero-rect targets. +* **browser state / extension 1.0.7** — `browser state --source ax` now enables the CDP Accessibility domain before reading the AX tree, fixing real-Chrome snapshots that previously returned only `RootWebArea` with zero refs. +* **help / build** — every positional arg must now declare a non-empty `help` string. The build-manifest step fails closed when a positional has empty / whitespace-only / missing `help`, so `opencli --help` always shows callers what each parameter is for. Pre-existing offenders (`twitter followers/following/list-add/list-remove/list-tweets/search/thread`, `reddit search/subreddit/user/user-comments/user-posts`, `douyin stats/update`, `bilibili subtitle`, `jike search`) now have explicit help text — most notably `twitter followers [user]` and `following [user]` now document that omitting the user fetches the currently logged-in account. + +## [1.7.14](https://github.com/jackwener/opencli/compare/v1.7.13...v1.7.14) (2026-05-08) + +### Features + +* **help** — adapter help is now agent-friendly: per-command listings drop the `[options]` noise from globally-shared options (`--format`, `--trace`, `-v`, `-h`, etc.) and only mention them at the site level, so `opencli twitter` etc. read like a flat command index. ([#1401](https://github.com/jackwener/opencli/issues/1401)) +* **twitter** — write-action symmetry P0: add `unlike`, `retweet`, `unretweet`, and `quote` to round out the read/write coverage. ([#1400](https://github.com/jackwener/opencli/issues/1400)) + +### Bug Fixes + +* **browser daemon** — `npm install -g @jackwener/opencli@latest` now correctly auto-restarts a stale ready-state daemon so users pick up the new version without a manual `opencli daemon restart`. ([#1399](https://github.com/jackwener/opencli/issues/1399)) + +## [1.7.13](https://github.com/jackwener/opencli/compare/v1.7.12...v1.7.13) (2026-05-07) + +Extension bumped to 1.0.6 (screenshot `--width` / `--height` / `--full-page` flags, automation tab group color marker, automation container reuse fix). + +### Bug Fixes + +* **xiaohongshu** — fix `publish --topics` leaving bare `#` characters with no linked topics. The adapter now types `#keyword` into the body editor to trigger the inline suggestion dropdown and selects the matching topic, matching the current creator-center UI. + +### ⚠ BREAKING CHANGES + +* **linux-do** — remove deprecated compatibility shims `linux-do hot`, `linux-do category`, `linux-do latest`. Use `linux-do feed --view top --period `, `linux-do feed --category `, and `linux-do feed --view latest` instead. +* **grok ask** — drop the `--web` flag and the legacy ` + +
你好
+ + `, __test__.buildExtractChatStateEvaluate()); + + expect(state.can_input).toBe(true); + expect(state.can_send).toBe(true); + expect(state.visible_messages).toEqual(['你好']); + }); + + it('activates the textarea and waits for a whitespace-split send button before clicking it', async () => { + let inputValue = ''; + let sendClicked = false; + const result = await runBrowserScript(` +
+ +
+
+ `, __test__.buildSendMessageEvaluate('还在吗?'), { + beforeEval(window) { + const textarea = window.document.querySelector('textarea'); + textarea.addEventListener('input', () => { + inputValue = textarea.value; + }); + textarea.addEventListener('click', () => { + const button = window.document.createElement('button'); + button.textContent = '发 送'; + button.addEventListener('click', () => { + sendClicked = true; + const row = window.document.createElement('div'); + row.className = 'message-row'; + row.innerHTML = '
还在吗?
'; + window.document.querySelector('#message-list-scrollable').append(row); + }); + window.document.body.append(button); + }); + }, + }); + + expect(result).toEqual({ ok: true }); + expect(inputValue).toBe('还在吗?'); + expect(sendClicked).toBe(true); + }); + + it('returns a typed failure reason when activation still does not reveal the send button', async () => { + const result = await runBrowserScript('', __test__.buildSendMessageEvaluate('ping'), { + beforeEval(window) { + window.setTimeout = (fn) => { + fn(); + return 0; + }; + }, + }); + + expect(result).toEqual({ ok: false, reason: 'send-button-not-found' }); + }); +}); diff --git a/clis/xianyu/im.js b/clis/xianyu/im.js new file mode 100644 index 0000000..1ed299a --- /dev/null +++ b/clis/xianyu/im.js @@ -0,0 +1,322 @@ +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; + +export const DEFAULT_INBOX_LIMIT = 20; +export const MAX_INBOX_LIMIT = 100; +export const DEFAULT_MESSAGE_LIMIT = 50; +export const MAX_MESSAGE_LIMIT = 200; + +export function normalizeLimit(value, defaultValue = DEFAULT_INBOX_LIMIT, maxValue = MAX_INBOX_LIMIT, label = 'limit') { + const raw = String(value ?? '').trim(); + if (!raw) return defaultValue; + if (!/^\d+$/.test(raw)) { + throw new ArgumentError(`xianyu ${label} must be an integer between 1 and ${maxValue}`); + } + const n = Number(raw); + if (!Number.isSafeInteger(n) || n < 1 || n > maxValue) { + throw new ArgumentError(`xianyu ${label} must be an integer between 1 and ${maxValue}`); + } + return n; +} + +export function normalizeRank(value) { + const raw = String(value ?? '').trim(); + if (!raw) return 0; + if (!/^\d+$/.test(raw)) { + throw new ArgumentError('xianyu rank must be a positive integer from xianyu inbox'); + } + const n = Number(raw); + if (!Number.isSafeInteger(n) || n < 1) { + throw new ArgumentError('xianyu rank must be a positive integer from xianyu inbox'); + } + return n; +} + +export function requireText(value, label) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!text) { + throw new ArgumentError(`${label} cannot be empty`); + } + return text; +} + +export function requireEvaluateObject(payload, label) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new CommandExecutionError(`Xianyu ${label} returned malformed browser payload`); + } + return payload; +} + +export function requireClickResult(payload, label) { + const result = requireEvaluateObject(payload, label); + if (result.ok !== true) { + throw new CommandExecutionError(`Xianyu ${label} failed: ${result.reason || 'unknown-reason'}`); + } + return result; +} + +export function buildChatUrl(itemId, peerUserId) { + return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`; +} + +export function buildInboxUrl() { + return 'https://www.goofish.com/im'; +} + +export function buildClickInboxConversationEvaluate(index) { + return ` + (() => { + const rows = Array.from(document.querySelectorAll('#conv-list-scrollable [class*="conversation-item"], a[href*="/im"], a[href*="itemId="][href*="peerUserId="]')); + const row = rows[${index}]; + if (!row) return { ok: false, reason: 'row-not-found' }; + row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window })); + row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); + row.click(); + row.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); + return { ok: true }; + })() + `; +} + +export function buildReadCurrentConversationUrlEvaluate() { + return ` + (() => { + const url = location.href || ''; + const params = new URL(url).searchParams; + return { + url, + item_id: params.get('itemId') || '', + peer_user_id: params.get('peerUserId') || '', + }; + })() + `; +} + +export function buildExtractInboxEvaluate(limit) { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const bodyText = document.body?.innerText || ''; + const requiresAuth = /请先登录|登录后|璇峰厛鐧诲綍|鐧诲綍鍚?/.test(bodyText); + const blocked = /验证码|安全验证|异常访问|楠岃瘉鐮亅瀹夊叏楠岃瘉|寮傚父璁块棶/.test(bodyText); + + const absoluteUrl = (url) => { + try { + return new URL(url, location.href).href; + } catch { + return ''; + } + }; + + const readId = (url, key) => { + try { + return new URL(url, location.href).searchParams.get(key) || ''; + } catch { + const match = String(url || '').match(new RegExp('[?&]' + key + '=(\\\\d+)')); + return match ? match[1] : ''; + } + }; + + const pick = (root, selectors) => { + for (const selector of selectors) { + const node = root.querySelector(selector); + const text = clean(node?.getAttribute?.('title') || node?.textContent || ''); + if (text) return text; + } + return ''; + }; + + const leafTexts = (root) => Array.from(root.querySelectorAll('div, span')) + .filter((node) => !Array.from(node.children || []).some((child) => ['DIV', 'SPAN'].includes(child.tagName))) + .map((node) => clean(node.textContent || '')) + .filter(Boolean); + + const looksLikeTime = (text) => /^(刚刚|\\d+分钟前|\\d+小时前|\\d+天前|昨天|前天|\\d{1,2}:\\d{2}|\\d{4}-\\d{1,2}-\\d{1,2})$/.test(text); + + const links = Array.from(document.querySelectorAll('a[href*="/im"], a[href*="itemId="][href*="peerUserId="]')); + const seen = new Set(); + const items = []; + for (const link of links) { + const href = link.href || link.getAttribute('href') || ''; + const itemId = readId(href, 'itemId'); + const peerUserId = readId(href, 'peerUserId'); + if (!itemId || !peerUserId) continue; + const key = itemId + ':' + peerUserId; + if (seen.has(key)) continue; + seen.add(key); + + const root = link.closest('[class*="conversation"], [class*="session"], [class*="contact"], [class*="chat"], li, [role="listitem"]') || link; + const peerName = pick(root, ['[class*="name"]', '[class*="nick"]', '[class*="user"]', '[class*="text1"]']); + const itemTitle = pick(root, ['[class*="title"]', '[class*="item"] [class*="desc"]', '[class*="desc"]']); + const price = pick(root, ['[class*="money"]', '[class*="price"]']); + const lastMessage = pick(root, ['[class*="message"]', '[class*="msg"]', '[class*="content"]', '[class*="summary"]']); + const rootText = clean(root.textContent || ''); + const unreadText = pick(root, ['[class*="badge"]', '[class*="unread"]', '[class*="red"]']); + const unreadCount = Number.parseInt(unreadText.replace(/\\D/g, ''), 10) || (unreadText ? 1 : 0); + + items.push({ + row_index: items.length, + peer_name: peerName, + peer_user_id: peerUserId, + item_id: itemId, + item_title: itemTitle, + price, + last_message: lastMessage || rootText, + unread: unreadCount > 0 || /unread|未读/.test(String(root.className || '') + ' ' + rootText), + unread_count: unreadCount, + url: absoluteUrl(href), + }); + if (items.length >= ${limit}) break; + } + + if (!items.length) { + const rows = Array.from(document.querySelectorAll('#conv-list-scrollable [class*="conversation-item"]')).slice(0, ${limit}); + for (const row of rows) { + const texts = leafTexts(row) + .filter((text) => !/^\\d+$/.test(text) || row.querySelector('sup[title="' + text.replace(/"/g, '\\"') + '"]') == null); + const time = [...texts].reverse().find(looksLikeTime) || ''; + const unreadTitle = clean(row.querySelector('sup')?.getAttribute('title') || row.querySelector('[class*="badge"], [class*="unread"]')?.textContent || ''); + const unreadCount = Number.parseInt(unreadTitle.replace(/\\D/g, ''), 10) || (unreadTitle ? 1 : 0); + const peerName = texts.find((text) => text !== time && text !== unreadTitle && !/^\\[.*\\]$/.test(text)) || ''; + const lastMessage = texts.find((text) => text !== peerName && text !== time && text !== unreadTitle) || ''; + if (!peerName && !lastMessage) continue; + items.push({ + row_index: items.length, + peer_name: peerName, + peer_user_id: '', + item_id: '', + item_title: '', + price: '', + last_message: lastMessage, + unread: unreadCount > 0, + unread_count: unreadCount, + url: '', + time, + }); + if (items.length >= ${limit}) break; + } + } + + return { requiresAuth, blocked, empty: !items.length, items }; + })() + `; +} + +export function buildExtractChatStateEvaluate(limit = DEFAULT_MESSAGE_LIMIT) { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const bodyText = document.body?.innerText || ''; + const requiresAuth = /请先登录|登录后|璇峰厛鐧诲綍|鐧诲綍鍚?/.test(bodyText); + + const textarea = document.querySelector('textarea'); + const normalizeBtn = (s) => String(s || '').replace(/\\s+/g, '').trim(); + const sendButton = Array.from(document.querySelectorAll('button')) + .find((btn) => /^(发送|鍙戦€?)$/.test(normalizeBtn(btn.textContent || ''))); + const topbar = document.querySelector('[class*="message-topbar"]'); + const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]')) + .find((el) => el.closest('main')) || document.querySelector('a[href*="/item?id="]'); + const itemTitleNode = + document.querySelector('[class*="container"] [class*="title"]') + || document.querySelector('[class*="item-main-info"] [class*="desc"]') + || document.querySelector('[class*="headSkuInfo"]') + || itemCard?.querySelector('[class*="title"]') + || itemCard?.previousElementSibling?.querySelector?.('[class*="title"]'); + + const messageRoot = document.querySelector('#message-list-scrollable') || document.querySelector('[class*="message-list"]'); + let visibleMessages = Array.from((messageRoot || document).querySelectorAll('[class*="message-row"]')) + .map((row) => { + const textNode = row.querySelector('[class*="message-text"]'); + return clean(textNode?.textContent || ''); + }) + .filter(Boolean); + + if (!visibleMessages.length) { + visibleMessages = Array.from( + (messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]') + ).map((el) => clean(el.textContent || '')); + } + + visibleMessages = visibleMessages + .filter(Boolean) + .filter((text) => !['发送', '闲鱼号', '立即购买', '鍙戦€?', '闂查奔鍙?', '绔嬪嵆璐拱'].includes(text)) + .filter((text) => !/^消息\\d*\\+?$/.test(text) && !/^娑堟伅\\d*\\+?$/.test(text)) + .slice(-${limit}); + + return { + requiresAuth, + title: clean(document.title || ''), + peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''), + peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''), + item_title: clean(itemTitleNode?.textContent || ''), + item_url: itemCard?.href || '', + price: clean(itemCard?.querySelector('[class*="money"], [class*="price"]')?.textContent || ''), + location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''), + can_input: Boolean(textarea && !textarea.disabled), + can_send: Boolean(sendButton), + visible_messages: visibleMessages, + messages: visibleMessages.map((text, index) => ({ index: index + 1, text })), + }; + })() + `; +} + +export function buildSendMessageEvaluate(text) { + return ` + (async () => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const readMessages = () => { + const messageRoot = document.querySelector('#message-list-scrollable') || document.querySelector('[class*="message-list"]'); + let messages = Array.from((messageRoot || document).querySelectorAll('[class*="message-row"]')) + .map((row) => clean(row.querySelector('[class*="message-text"]')?.textContent || '')) + .filter(Boolean); + if (!messages.length) { + messages = Array.from( + (messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]') + ).map((el) => clean(el.textContent || '')).filter(Boolean); + } + return messages + .filter((item) => !['发送', '闲鱼号', '立即购买', '鍙戦€?', '闂查奔鍙?', '绔嬪嵆璐拱'].includes(item)) + .filter((item) => !/^消息\\d*\\+?$/.test(item) && !/^娑堟伅\\d*\\+?$/.test(item)); + }; + + const textarea = document.querySelector('textarea'); + if (!textarea || textarea.disabled) { + return { ok: false, reason: 'input-not-found' }; + } + + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (!setter) { + return { ok: false, reason: 'textarea-setter-not-found' }; + } + + const beforeMessages = readMessages(); + textarea.click(); + textarea.focus(); + setter.call(textarea, ${JSON.stringify(text)}); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + + const normalizeBtn = (s) => String(s || '').replace(/\\s+/g, '').trim(); + let sendButton = null; + for (let i = 0; i < 30; i++) { + sendButton = Array.from(document.querySelectorAll('button')) + .find((btn) => /^(发送|鍙戦€?)$/.test(normalizeBtn(btn.textContent || ''))); + if (sendButton) break; + await new Promise(r => setTimeout(r, 100)); + } + if (!sendButton) { + return { ok: false, reason: 'send-button-not-found' }; + } + + sendButton.click(); + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 100)); + const afterMessages = readMessages(); + if (afterMessages.length > beforeMessages.length && afterMessages.at(-1) === ${JSON.stringify(text)}) { + return { ok: true }; + } + } + return { ok: false, reason: 'send-postcondition-timeout' }; + })() + `; +} diff --git a/clis/xianyu/im.test.js b/clis/xianyu/im.test.js new file mode 100644 index 0000000..02b24a1 --- /dev/null +++ b/clis/xianyu/im.test.js @@ -0,0 +1,253 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it } from 'vitest'; +import { + buildChatUrl, + buildExtractChatStateEvaluate, + buildExtractInboxEvaluate, + buildSendMessageEvaluate, + normalizeLimit, + normalizeRank, +} from './im.js'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import './messages.js'; +import './reply.js'; +import { getRegistry } from '@jackwener/opencli/registry'; + +async function runBrowserScript(html, script, { url = 'https://www.goofish.com/im', beforeEval } = {}) { + const dom = new JSDOM(html, { url, runScripts: 'outside-only' }); + beforeEval?.(dom.window); + return dom.window.eval(script); +} + +describe('xianyu im shared helpers', () => { + it('strictly validates limits and ranks before browser side effects', () => { + expect(normalizeLimit(undefined, 20, 100)).toBe(20); + expect(normalizeLimit('', 20, 100)).toBe(20); + expect(normalizeLimit('100', 20, 100)).toBe(100); + expect(() => normalizeLimit(0, 20, 100)).toThrow(ArgumentError); + expect(() => normalizeLimit(3.8, 20, 100)).toThrow(ArgumentError); + expect(() => normalizeLimit(999, 20, 100)).toThrow(ArgumentError); + expect(normalizeRank(undefined)).toBe(0); + expect(normalizeRank(1)).toBe(1); + expect(() => normalizeRank(0)).toThrow(ArgumentError); + expect(() => normalizeRank('1.5')).toThrow(ArgumentError); + }); + + it('extracts recent inbox conversations from visible IM rows', async () => { + const result = await runBrowserScript(` +
+ +
张三
+
MacBook Pro 14
+
¥5999
+
还在吗?
+ 2 +
+ +
李四
+
iPhone 15
+
明天能发货
+
+
+ `, buildExtractInboxEvaluate(10)); + + expect(result.requiresAuth).toBe(false); + expect(result.items).toEqual([ + { + peer_name: '张三', + peer_user_id: '90001', + item_id: '10001', + item_title: 'MacBook Pro 14', + price: '¥5999', + last_message: '还在吗?', + unread: true, + unread_count: 2, + url: 'https://www.goofish.com/im?itemId=10001&peerUserId=90001', + row_index: 0, + }, + { + peer_name: '李四', + peer_user_id: '90002', + item_id: '10002', + item_title: 'iPhone 15', + price: '', + last_message: '明天能发货', + unread: false, + unread_count: 0, + url: 'https://www.goofish.com/im?itemId=10002&peerUserId=90002', + row_index: 1, + }, + ]); + }); + + it('extracts inbox conversations from the real virtualized conversation row shape', async () => { + const result = await runBrowserScript(` +
+
+
+
+
+
+
+
3
+
+
通知消息
+
订单即将自动确认收货
+
3小时前
+
+
+
+
+
+
+
隔壁猫小小
+
亲,喜欢可以拍下,有问题留言哦~会尽快回复
+
21小时前
+
+
+
+
+
+
+
+
+ `, buildExtractInboxEvaluate(10)); + + expect(result.items).toEqual([ + expect.objectContaining({ + peer_name: '通知消息', + last_message: '订单即将自动确认收货', + unread: true, + unread_count: 3, + }), + expect.objectContaining({ + peer_name: '隔壁猫小小', + last_message: '亲,喜欢可以拍下,有问题留言哦~会尽快回复', + unread: false, + }), + ]); + }); + + it('extracts all visible messages for a specific chat', async () => { + const state = await runBrowserScript(` +
+ + +
张三(90001)
+ MacBook Pro 14¥5999 +
+
你好
+
还在吗?
+
在的
+
+
+ `, buildExtractChatStateEvaluate()); + + expect(state.peer_name).toBe('张三'); + expect(state.peer_masked_id).toBe('90001'); + expect(state.item_title).toBe('MacBook Pro 14'); + expect(state.messages).toEqual([ + { index: 1, text: '你好' }, + { index: 2, text: '还在吗?' }, + { index: 3, text: '在的' }, + ]); + expect(state.visible_messages).toEqual(['你好', '还在吗?', '在的']); + }); + + it('extracts messages from the real Xianyu message-row shape', async () => { + const state = await runBrowserScript(` +
+ + +
+
+
+
+
我发出的消息
+
已读
+
+
+
+
+
对方回复
+
+
+
+
+
+ `, buildExtractChatStateEvaluate()); + + expect(state.can_send).toBe(true); + expect(state.messages).toEqual([ + { index: 1, text: '我发出的消息' }, + { index: 2, text: '对方回复' }, + ]); + }); + + + it('registers reply as an explicit write command', () => { + const command = getRegistry().get('xianyu/reply'); + expect(command?.access).toBe('write'); + expect(command?.columns).toEqual(['status', 'peer_name', 'item_title', 'price', 'location', 'message']); + }); + + it('builds chat URLs and requires post-submit message evidence', async () => { + expect(buildChatUrl('10001', '90001')).toBe('https://www.goofish.com/im?itemId=10001&peerUserId=90001'); + + let clicked = false; + const result = await runBrowserScript(` + + +
+ `, buildSendMessageEvaluate('你好'), { + beforeEval(window) { + window.document.querySelector('button').addEventListener('click', () => { + clicked = true; + const row = window.document.createElement('div'); + row.className = 'message-row'; + row.innerHTML = '
你好
'; + window.document.querySelector('#message-list-scrollable').append(row); + }); + }, + }); + + expect(result).toEqual({ ok: true }); + expect(clicked).toBe(true); + + await expect(runBrowserScript('', buildSendMessageEvaluate('没证据'), { + beforeEval(window) { + window.setTimeout = (fn) => { + fn(); + return 0; + }; + }, + })) + .resolves.toEqual({ ok: false, reason: 'send-postcondition-timeout' }); + }); + + it('requires an explicit conversation target for messages and reply', async () => { + const messages = getRegistry().get('xianyu/messages'); + const reply = getRegistry().get('xianyu/reply'); + const page = { + goto: () => { throw new Error('should not navigate'); }, + wait: () => {}, + evaluate: () => ({}), + }; + + await expect(messages.func(page, { limit: 1 })).rejects.toBeInstanceOf(ArgumentError); + await expect(reply.func(page, { text: 'hello' })).rejects.toBeInstanceOf(ArgumentError); + await expect(messages.func(page, { item_id: '10001', user_id: '90001', rank: 1 })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('treats malformed evaluate payloads as command failures', async () => { + const messages = getRegistry().get('xianyu/messages'); + const page = { + goto: () => {}, + wait: () => {}, + evaluate: () => null, + }; + + await expect(messages.func(page, { item_id: '10001', user_id: '90001', limit: 1 })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); +}); diff --git a/clis/xianyu/inbox.js b/clis/xianyu/inbox.js new file mode 100644 index 0000000..324a76b --- /dev/null +++ b/clis/xianyu/inbox.js @@ -0,0 +1,96 @@ +import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + buildClickInboxConversationEvaluate, + buildExtractInboxEvaluate, + buildInboxUrl, + buildReadCurrentConversationUrlEvaluate, + DEFAULT_INBOX_LIMIT, + MAX_INBOX_LIMIT, + normalizeLimit, + requireClickResult, + requireEvaluateObject, +} from './im.js'; + +cli({ + site: 'xianyu', + name: 'inbox', + access: 'read', + description: '列出闲鱼最近私信会话', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'limit', type: 'int', default: DEFAULT_INBOX_LIMIT, help: 'Number of conversations to return' }, + { name: 'unread-only', type: 'bool', default: false, help: 'Return only conversations with unread messages' }, + { name: 'resolve-ids', type: 'bool', default: false, help: 'Click each visible conversation to resolve item_id and peer_user_id from the chat URL' }, + ], + columns: ['rank', 'peer_name', 'peer_user_id', 'item_id', 'item_title', 'price', 'last_message', 'unread', 'unread_count', 'url'], + func: async (page, kwargs) => { + const limit = normalizeLimit(kwargs.limit, DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT, 'inbox --limit'); + const unreadOnly = Boolean(kwargs['unread-only']); + const resolveIds = Boolean(kwargs['resolve-ids']); + let currentUrl = ''; + if (page.getCurrentUrl) { + try { + currentUrl = await page.getCurrentUrl(); + } catch { + currentUrl = ''; + } + } + if (!/https:\/\/www\.goofish\.com\/im\b/.test(currentUrl)) { + await page.goto(buildInboxUrl()); + } + await page.wait(4); + const payload = requireEvaluateObject(await page.evaluate(buildExtractInboxEvaluate(limit)), 'inbox'); + if (payload?.requiresAuth) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu inbox requires a logged-in browser session'); + } + if (payload?.blocked) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu inbox is blocked by verification or risk control'); + } + if (!Array.isArray(payload.items)) { + throw new CommandExecutionError('Xianyu inbox returned malformed conversation list'); + } + const items = payload.items; + if (!items.length) { + throw new EmptyResultError('xianyu inbox', 'No Xianyu inbox conversations were found'); + } + let conversations = items.slice(0, limit); + if (unreadOnly) { + conversations = conversations.filter((item) => Boolean(item.unread)); + } + if (resolveIds) { + for (const item of conversations) { + if (item.item_id && item.peer_user_id) continue; + const rowIndex = Number(item.row_index); + if (!Number.isInteger(rowIndex) || rowIndex < 0) continue; + requireClickResult(await page.evaluate(buildClickInboxConversationEvaluate(rowIndex)), 'inbox resolve-ids click'); + await page.wait(1); + const current = requireEvaluateObject(await page.evaluate(buildReadCurrentConversationUrlEvaluate()), 'inbox current-url'); + item.item_id = current?.item_id || item.item_id || ''; + item.peer_user_id = current?.peer_user_id || item.peer_user_id || ''; + item.url = current?.url || item.url || ''; + } + } + return conversations.map((item, index) => ({ + rank: index + 1, + peer_name: item.peer_name || '', + peer_user_id: item.peer_user_id || '', + item_id: item.item_id || '', + item_title: item.item_title || '', + price: item.price || '', + last_message: item.last_message || '', + unread: Boolean(item.unread), + unread_count: Number(item.unread_count || 0), + url: item.url || '', + })); + }, +}); + +export const __test__ = { + buildInboxUrl, + buildExtractInboxEvaluate, + normalizeLimit, +}; diff --git a/clis/xianyu/item.js b/clis/xianyu/item.js new file mode 100644 index 0000000..a842094 --- /dev/null +++ b/clis/xianyu/item.js @@ -0,0 +1,153 @@ +import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeNumericId } from './utils.js'; +function buildItemUrl(itemId) { + return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`; +} +function buildFetchItemEvaluate(itemId) { + return ` + (async () => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const extractRetCode = (ret) => { + const first = Array.isArray(ret) ? ret[0] : ''; + return clean(first).split('::')[0] || ''; + }; + + const waitFor = async (predicate, timeoutMs = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 150)); + } + return false; + }; + + const bodyText = document.body?.innerText || ''; + if (/请先登录|登录后/.test(bodyText)) { + return { error: 'auth-required' }; + } + + if (/验证码|安全验证|异常访问/.test(bodyText)) { + return { error: 'blocked' }; + } + + await waitFor(() => window.lib?.mtop?.request); + if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') { + return { error: 'mtop-not-ready' }; + } + + let response; + try { + response = await window.lib.mtop.request({ + api: 'mtop.taobao.idle.pc.detail', + data: { itemId: ${JSON.stringify(itemId)} }, + type: 'POST', + v: '1.0', + dataType: 'json', + needLogin: false, + needLoginPC: false, + sessionOption: 'AutoLoginOnly', + ecode: 0, + }); + } catch (error) { + const ret = error?.ret || []; + return { + error: 'mtop-request-failed', + error_code: extractRetCode(ret), + error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error), + }; + } + + const retCode = extractRetCode(response?.ret || []); + if (retCode && retCode !== 'SUCCESS') { + return { + error: 'mtop-response-error', + error_code: retCode, + error_message: clean((response?.ret || []).join(' | ')), + }; + } + + const data = response?.data || {}; + const item = data.itemDO || {}; + const seller = data.sellerDO || {}; + const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : []; + const findLabel = (name) => labels.find((label) => clean(label.propertyText) === name)?.text || ''; + const images = Array.isArray(item.imageInfos) + ? item.imageInfos.map((entry) => entry?.url).filter(Boolean) + : []; + + return { + item_id: clean(item.itemId || ${JSON.stringify(itemId)}), + title: clean(item.title || ''), + description: clean(item.desc || ''), + price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\\s*$/, ''), + original_price: clean(item.originalPrice || ''), + want_count: String(item.wantCnt ?? ''), + collect_count: String(item.collectCnt ?? ''), + browse_count: String(item.browseCnt ?? ''), + status: clean(item.itemStatusStr || ''), + condition: clean(findLabel('成色')), + brand: clean(findLabel('品牌')), + category: clean(findLabel('分类')), + location: clean(seller.publishCity || seller.city || ''), + seller_name: clean(seller.nick || seller.uniqueName || ''), + seller_id: String(seller.sellerId || ''), + seller_score: clean(seller.xianyuSummary || ''), + reply_ratio_24h: clean(seller.replyRatio24h || ''), + reply_interval: clean(seller.replyInterval || ''), + item_url: ${JSON.stringify(buildItemUrl(itemId))}, + seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '', + image_count: String(images.length), + image_urls: images, + }; + })() + `; +} +cli({ + site: 'xianyu', + name: 'item', + access: 'read', + description: '查看闲鱼商品详情', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' }, + ], + columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'], + func: async (page, kwargs) => { + const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1040754408976'); + await page.goto(buildItemUrl(itemId)); + await page.wait(2); + const result = await page.evaluate(buildFetchItemEvaluate(itemId)); + if (result?.error === 'auth-required') { + throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session'); + } + if (result?.error === 'blocked') { + throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control'); + } + if (result?.error === 'mtop-not-ready') { + throw selectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口'); + } + if (!result || typeof result !== 'object') { + throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据'); + } + const errorCode = String(result.error_code || ''); + const errorMessage = String(result.error_message || ''); + if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session'); + } + if (result.error) { + throw new EmptyResultError('xianyu item', errorMessage || `Xianyu item detail request failed: ${result.error}`); + } + if (!String(result.title || '').trim()) { + throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id'); + } + return [result]; + }, +}); +export const __test__ = { + normalizeNumericId, + buildItemUrl, +}; diff --git a/clis/xianyu/item.test.js b/clis/xianyu/item.test.js new file mode 100644 index 0000000..d1ea137 --- /dev/null +++ b/clis/xianyu/item.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { __test__ } from './item.js'; +import './item.js'; +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + }; +} +describe('xianyu item helpers', () => { + it('normalizes numeric item ids', () => { + expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976'); + expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976'); + }); + it('builds item urls', () => { + expect(__test__.buildItemUrl('1040754408976')).toBe('https://www.goofish.com/item?id=1040754408976'); + }); + it('rejects invalid item ids', () => { + expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow(); + }); +}); +describe('xianyu item command', () => { + const command = getRegistry().get('xianyu/item'); + it('throws AuthRequiredError on login wall before mtop is available', async () => { + const page = createPageMock({ error: 'auth-required' }); + await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(AuthRequiredError); + }); + it('throws EmptyResultError on verification or risk-control pages', async () => { + const page = createPageMock({ error: 'blocked' }); + await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError); + }); + it('keeps SELECTOR code for true mtop initialization failures', async () => { + const page = createPageMock({ error: 'mtop-not-ready' }); + await expect(command.func(page, { item_id: '1040754408976' })).rejects.toMatchObject({ code: 'SELECTOR' }); + }); +}); diff --git a/clis/xianyu/messages.js b/clis/xianyu/messages.js new file mode 100644 index 0000000..f859773 --- /dev/null +++ b/clis/xianyu/messages.js @@ -0,0 +1,91 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + buildChatUrl, + buildClickInboxConversationEvaluate, + buildExtractChatStateEvaluate, + DEFAULT_MESSAGE_LIMIT, + MAX_MESSAGE_LIMIT, + normalizeLimit, + normalizeRank, + requireClickResult, + requireEvaluateObject, +} from './im.js'; +import { normalizeNumericId } from './utils.js'; + +cli({ + site: 'xianyu', + name: 'messages', + access: 'read', + description: '读取指定闲鱼私信会话的最近聊天内容', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'item_id', positional: true, help: '闲鱼商品 item_id' }, + { name: 'user_id', positional: true, help: '聊一聊对方的 user_id / peerUserId' }, + { name: 'limit', type: 'int', default: DEFAULT_MESSAGE_LIMIT, help: 'Number of visible messages to return' }, + { name: 'rank', type: 'int', default: 0, help: 'Conversation rank from xianyu inbox; clicks the visible row instead of requiring IDs' }, + ], + columns: ['index', 'peer_name', 'item_title', 'message', 'item_id', 'peer_user_id', 'url'], + func: async (page, kwargs) => { + const hasItemId = kwargs.item_id != null && kwargs.item_id !== ''; + const hasUserId = kwargs.user_id != null && kwargs.user_id !== ''; + const rank = normalizeRank(kwargs.rank); + if (rank > 0 && (hasItemId || hasUserId)) { + throw new ArgumentError('xianyu messages accepts either item_id/user_id or --rank, not both'); + } + if (rank === 0 && hasItemId !== hasUserId) { + throw new ArgumentError('xianyu messages requires both item_id and user_id, or --rank from xianyu inbox'); + } + if (rank === 0 && !hasItemId && !hasUserId) { + throw new ArgumentError('xianyu messages requires item_id/user_id or --rank from xianyu inbox'); + } + const hasIds = hasItemId && hasUserId; + const itemId = hasIds ? normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192') : ''; + const userId = hasIds ? normalizeNumericId(kwargs.user_id, 'user_id', '3650092411') : ''; + const limit = normalizeLimit(kwargs.limit, DEFAULT_MESSAGE_LIMIT, MAX_MESSAGE_LIMIT, 'messages --limit'); + let url = ''; + if (hasIds) { + url = buildChatUrl(itemId, userId); + await page.goto(url); + } else { + if (!page.getCurrentUrl || !/https:\/\/www\.goofish\.com\/im\b/.test(await page.getCurrentUrl())) { + await page.goto('https://www.goofish.com/im'); + } + } + await page.wait(2); + if (rank > 0) { + requireClickResult(await page.evaluate(buildClickInboxConversationEvaluate(rank - 1)), 'messages rank click'); + await page.wait(2); + } + const state = requireEvaluateObject(await page.evaluate(buildExtractChatStateEvaluate(limit)), 'messages'); + if (state?.requiresAuth) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu messages requires a logged-in browser session'); + } + if (!Array.isArray(state.messages)) { + throw new CommandExecutionError('Xianyu messages returned malformed message list'); + } + const messages = state.messages; + if (!messages.length) { + throw new EmptyResultError('xianyu messages', 'No visible messages were found in this Xianyu conversation'); + } + return messages.slice(-limit).map((message, index) => ({ + index: index + 1, + peer_name: state.peer_name || '', + item_title: state.item_title || '', + message: message.text || '', + item_id: itemId, + peer_user_id: userId, + url: url || '', + })); + }, +}); + +export const __test__ = { + buildChatUrl, + buildExtractChatStateEvaluate, + normalizeLimit, + normalizeRank, +}; diff --git a/clis/xianyu/publish.js b/clis/xianyu/publish.js new file mode 100644 index 0000000..df30bd5 --- /dev/null +++ b/clis/xianyu/publish.js @@ -0,0 +1,485 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; + +const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']); +const MAX_IMAGES = 9; +const CONDITION_CHOICES = ['全新', '几乎全新', '轻微使用', '明显使用', '老旧']; + +function buildPublishUrl() { + return 'https://www.goofish.com/publish'; +} + +async function getCurrentPageUrl(page) { + if (page.getCurrentUrl) { + try { + const currentUrl = await page.getCurrentUrl(); + if (currentUrl) return currentUrl; + } catch { + // Best-effort URL is only used for operator diagnostics after submit. + } + } + return buildPublishUrl(); +} + +function requireText(value, label) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!text) { + throw new ArgumentError(`xianyu publish ${label} cannot be empty`); + } + return text; +} + +function parsePositivePrice(value, label) { + if (value == null || String(value).trim() === '') { + return null; + } + const text = String(value).trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(text)) { + throw new ArgumentError(`xianyu publish ${label} must be a positive price with at most 2 decimals`); + } + const price = Number(text); + if (!Number.isFinite(price) || price <= 0) { + throw new ArgumentError(`xianyu publish ${label} must be a positive price`); + } + return text; +} + +function validateCondition(value) { + const condition = requireText(value, 'condition'); + if (!CONDITION_CHOICES.includes(condition)) { + throw new ArgumentError(`xianyu publish condition must be one of: ${CONDITION_CHOICES.join(', ')}`); + } + return condition; +} + +function validateImagePaths(raw) { + if (!raw) return []; + const paths = String(raw).split(',').map((item) => item.trim()).filter(Boolean); + if (paths.length === 0) return []; + if (paths.length > MAX_IMAGES) { + throw new ArgumentError(`xianyu publish images supports at most ${MAX_IMAGES} files`); + } + return paths.map((item) => { + const absPath = path.resolve(item); + const ext = path.extname(absPath).toLowerCase(); + if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) { + throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`); + } + const stat = fs.statSync(absPath, { throwIfNoEntry: false }); + if (!stat || !stat.isFile()) { + throw new ArgumentError(`Not a valid image file: ${absPath}`); + } + return absPath; + }); +} + +function normalizePublishArgs(kwargs) { + const price = parsePositivePrice(kwargs.price, 'price'); + if (price == null) { + throw new ArgumentError('xianyu publish price cannot be empty'); + } + const normalized = {}; + normalized.title = requireText(kwargs.title, 'title'); + normalized.description = requireText(kwargs.description, 'description'); + normalized.price = price; + normalized.condition = validateCondition(kwargs.condition); + normalized.category = requireText(kwargs.category, 'category'); + normalized.original_price = parsePositivePrice(kwargs.original_price, 'original_price'); + normalized.location = kwargs.location ? requireText(kwargs.location, 'location') : ''; + normalized.images = validateImagePaths(kwargs.images); + return normalized; +} + +// ===== 表单填充 evaluate scripts ===== + +function buildFillFormEvaluate(data) { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + + const filled = []; + const missing = []; + + // 1. 填标题 + const titleInput = document.querySelector('input[id*="title"], input[placeholder*="标题"], textarea[id*="title"], [class*="titleInput"]'); + if (titleInput) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) { + titleInput.focus(); + setter.call(titleInput, ${JSON.stringify(data.title)}); + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + titleInput.dispatchEvent(new Event('change', { bubbles: true })); + filled.push('title'); + } + } + if (!filled.includes('title')) missing.push('title'); + + // 2. 填描述 + const descInput = document.querySelector('textarea[id*="desc"], textarea[id*="description"], [class*="descInput"], [class*="description"]'); + if (descInput) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) { + descInput.focus(); + setter.call(descInput, ${JSON.stringify(data.description)}); + descInput.dispatchEvent(new Event('input', { bubbles: true })); + descInput.dispatchEvent(new Event('change', { bubbles: true })); + filled.push('description'); + } + } + if (!filled.includes('description')) missing.push('description'); + + // 3. 填价格 + const priceInput = document.querySelector('input[id*="price"], input[placeholder*="价"], input[class*="price"]'); + if (priceInput) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (setter) { + priceInput.focus(); + setter.call(priceInput, ${JSON.stringify(String(data.price))}); + priceInput.dispatchEvent(new Event('input', { bubbles: true })); + priceInput.dispatchEvent(new Event('change', { bubbles: true })); + filled.push('price'); + } + } + if (!filled.includes('price')) missing.push('price'); + + // 4. 填原价(可选) + ${data.original_price ? ` + const originalPriceInput = document.querySelector('input[id*="original"], input[placeholder*="原价"], input[class*="original"]'); + if (originalPriceInput) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (setter) { + originalPriceInput.focus(); + setter.call(originalPriceInput, ${JSON.stringify(String(data.original_price))}); + originalPriceInput.dispatchEvent(new Event('input', { bubbles: true })); + originalPriceInput.dispatchEvent(new Event('change', { bubbles: true })); + filled.push('original_price'); + } + } + ` : ''} + + // 5. 填地址(可选) + ${data.location ? ` + const locationInput = document.querySelector('input[id*="location"], input[placeholder*="地"], input[class*="location"]'); + if (locationInput) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (setter) { + locationInput.focus(); + setter.call(locationInput, ${JSON.stringify(data.location)}); + locationInput.dispatchEvent(new Event('input', { bubbles: true })); + locationInput.dispatchEvent(new Event('change', { bubbles: true })); + filled.push('location'); + } + } + ` : ''} + + // 6. 选择成色(点击对应按钮) + if (${JSON.stringify(data.condition)}) { + const condition = ${JSON.stringify(data.condition)}; + const conditionMap = { + '全新': ['全新', '全新未使用', 'new'], + '几乎全新': ['几乎全新', '几乎全新无瑕疵', 'like-new'], + '轻微使用': ['轻微使用', '轻微使用痕迹'], + '明显使用': ['明显使用', '有明显使用痕迹'], + '老旧': ['老旧', '年代久远', '二手'], + }; + const keywords = conditionMap[condition] || [condition]; + const allButtons = Array.from(document.querySelectorAll('button, [class*="tag"], [class*="condition"], [class*="level"], [role="button"]')); + const matchBtn = allButtons.find((el) => { + const text = clean(el.textContent || ''); + return keywords.some((kw) => text === kw || text.includes(kw)); + }); + if (matchBtn) { + matchBtn.click(); + filled.push('condition'); + } + } + if (!filled.includes('condition')) missing.push('condition'); + + return { ok: missing.length === 0, filled, missing }; + })() + `; +} + +function buildSelectCategoryEvaluate(categoryName) { + return ` + (async () => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + + // 点击分类选择器 + const categoryTrigger = Array.from(document.querySelectorAll('button, [class*="trigger"], [class*="selector"], [role="button"]')) + .find((el) => /分类|category|类目/.test(el.textContent || '')) + || document.querySelector('[class*="category"], [class*="categorySelector"]'); + + if (categoryTrigger) { + categoryTrigger.click(); + } else { + return { ok: false, reason: 'category-trigger-not-found' }; + } + + // 等待分类弹窗/面板出现 + const waitFor = async (predicate, timeoutMs = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return false; + }; + + // 在分类弹窗中搜索并点击 + const searchKeyword = ${JSON.stringify(categoryName)}; + const hasMatch = await waitFor(() => { + const allNodes = Array.from(document.querySelectorAll('button, [class*="item"], [class*="node"], [role="option"]')); + return allNodes.some((el) => clean(el.textContent || '').includes(searchKeyword)); + }, 5000); + + if (!hasMatch) { + return { ok: false, reason: 'category-not-found' }; + } + + const allNodes = Array.from(document.querySelectorAll('button, [class*="item"], [class*="node"], [role="option"]')); + const matchNode = allNodes.find((el) => clean(el.textContent || '').includes(searchKeyword)); + if (matchNode) { + matchNode.click(); + return { ok: true }; + } + return { ok: false, reason: 'category-match-failed' }; + })() + `; +} + +function buildFindFileInputSelectorEvaluate() { + return ` + (() => { + // 找图片上传相关的 file input + const fileInput = document.querySelector('input[type="file"]'); + if (!fileInput) return { ok: false, reason: 'no-file-input' }; + + // 获取 selector 来唯一标识这个 input + const escapeAttr = (value) => String(value).replace(/["\\\\]/g, '\\\\$&'); + const selector = fileInput.id ? '[id="' + escapeAttr(fileInput.id) + '"]' + : fileInput.name ? '[name="' + fileInput.name + '"]' + : fileInput.className ? 'input.' + fileInput.className.split(' ').join('.') + : 'input[type="file"]'; + + return { ok: true, selector, hasMultiple: fileInput.multiple }; + })() + `; +} + +function buildSubmitEvaluate() { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + + // 找发布按钮 + const submitBtn = Array.from(document.querySelectorAll('button')) + .find((btn) => { + const text = clean(btn.textContent || ''); + return /发布|提交|上架|确认/.test(text) && !/取消/.test(text); + }) + || document.querySelector('[class*="publish"], [class*="submit"], [class*="confirm"]'); + + if (!submitBtn || submitBtn.disabled) { + return { ok: false, reason: 'submit-button-not-found-or-disabled' }; + } + + submitBtn.click(); + return { ok: true }; + })() + `; +} + +function buildDetectSuccessEvaluate() { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const bodyText = document.body?.innerText || ''; + const url = window.location.href || ''; + + // 成功标志:URL 变为商品详情页 + if (/item\\?id=\\d+/.test(url)) { + const match = url.match(/item\\?id=(\\d+)/); + return { status: 'published', item_id: match ? match[1] : '', url, message: '发布成功' }; + } + + // 成功标志:页面出现"发布成功" + if (/发布成功|上架成功|发布完成/.test(bodyText)) { + const idMatch = url.match(/item\\?id=(\\d+)/) || bodyText.match(/id[::]?\\s*(\\d{10,})/); + return { status: 'published', item_id: idMatch ? (idMatch[1] || idMatch[0]) : '', url, message: '发布成功' }; + } + + // 失败标志 + const errMatch = Array.from(document.querySelectorAll('[class*="error"], [class*="fail"]')) + .map((el) => clean(el.textContent || '')) + .filter(Boolean); + if (errMatch.length || /发布失败|上架失败|异常|错误|违规/.test(bodyText)) { + return { status: 'failed', message: errMatch.join(' | ') || 'publish-failed' }; + } + + return { ok: false, reason: 'unknown-state' }; + })() + `; +} + +function buildExtractPageStateEvaluate() { + return ` + (() => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const bodyText = document.body?.innerText || ''; + const url = window.location.href || ''; + + const requiresAuth = /请先登录|登录后/.test(bodyText); + const hasPublishForm = /发布闲置|发布宝贝|闲置描述|标题|价格|成色|分类/.test(bodyText); + const hasCategorySelect = /选择分类|选择类目|分类选择/.test(bodyText); + const hasImageUpload = /上传图片|添加图片|photo|图片/.test(bodyText); + + // 找各字段 + const titleInput = !!document.querySelector('input[id*="title"], input[placeholder*="标题"], textarea[id*="title"]'); + const descInput = !!document.querySelector('textarea[id*="desc"], textarea[id*="description"]'); + const priceInput = !!document.querySelector('input[id*="price"], input[placeholder*="价"]'); + const conditionSelect = !!document.querySelector('[class*="condition"], [class*="level"], button[class*="tag"]'); + const submitBtn = Array.from(document.querySelectorAll('button')) + .find((btn) => /发布|提交|上架/.test(clean(btn.textContent || ''))); + + return { + requiresAuth, + hasPublishForm, + hasCategorySelect, + hasImageUpload, + titleInput, + descInput, + priceInput, + conditionSelect, + submitBtn: !!submitBtn, + pageUrl: url, + bodySnippet: bodyText.slice(0, 500), + }; + })() + `; +} + +// ===== CLI definition ===== + +export const publishCommand = cli({ + site: 'xianyu', + name: 'publish', + access: 'write', + description: '发布闲鱼宝贝(需先在浏览器中登录闲鱼)', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'title', required: true, positional: true, help: '商品标题' }, + { name: 'description', required: true, positional: true, help: '商品描述/详情' }, + { name: 'price', required: true, positional: true, type: 'float', help: '出售价格(元)' }, + { name: 'condition', required: true, positional: true, help: '成色:全新 / 几乎全新 / 轻微使用 / 明显使用 / 老旧' }, + { name: 'category', required: true, positional: true, help: '商品分类关键词(如:手机、衣服、图书)' }, + { name: 'original_price', type: 'float', help: '原价(选填,用于显示折扣)' }, + { name: 'location', help: '所在地区(选填,如:杭州)' }, + { name: 'images', help: '本地图片路径,多张用逗号分隔(选填,如:/tmp/a.jpg,/tmp/b.jpg)' }, + ], + columns: ['status', 'item_id', 'title', 'price', 'condition', 'url', 'message'], + func: async (page, kwargs) => { + const data = normalizePublishArgs(kwargs); + // 1. 导航到发布页 + await page.goto(buildPublishUrl()); + await page.wait(3); + + // 2. 检查登录状态 + const initState = await page.evaluate(buildExtractPageStateEvaluate()); + if (initState?.requiresAuth) { + throw new AuthRequiredError('www.goofish.com', '发布闲鱼需要先登录,请在 Chrome 中打开 goofish.com 并完成登录'); + } + if (!initState?.hasPublishForm) { + throw new CommandExecutionError('Xianyu publish form was not detected', 'Confirm goofish.com is logged in and the publish page finished loading.'); + } + + // 3. 选择分类(先于其他字段,因为分类可能影响表单结构) + const categoryResult = await page.evaluate(buildSelectCategoryEvaluate(data.category)); + if (!categoryResult?.ok) { + throw new CommandExecutionError(`Xianyu category selection failed: ${categoryResult?.reason || 'unknown-reason'}`); + } + await page.wait(1.5); + + // 4. 填充表单 + const fillResult = await page.evaluate(buildFillFormEvaluate(data)); + if (!fillResult?.ok) { + const missing = Array.isArray(fillResult?.missing) ? fillResult.missing.join(', ') : 'unknown'; + throw new CommandExecutionError(`Xianyu publish form fill failed; missing fields: ${missing}`); + } + await page.wait(1); + + // 5. 上传图片(如果有) + if (data.images.length > 0) { + if (!page.setFileInput) { + throw new CommandExecutionError('Xianyu publish requires Browser Bridge file upload support', 'Use a browser mode that supports setFileInput.'); + } + const fileInput = await page.evaluate(buildFindFileInputSelectorEvaluate()); + if (!fileInput?.ok) { + throw new CommandExecutionError(`Xianyu image upload input was not found: ${fileInput?.reason || 'unknown-reason'}`); + } + try { + await page.setFileInput(data.images, fileInput.selector || 'input[type="file"]'); + await page.wait(3); // 等待图片上传处理 + } catch (err) { + throw new CommandExecutionError(`Xianyu image upload failed: ${err?.message || err}`); + } + } + + // 6. 点击发布按钮 + const submitResult = await page.evaluate(buildSubmitEvaluate()); + if (!submitResult?.ok) { + throw new CommandExecutionError(`Xianyu publish submit failed: ${submitResult?.reason || 'unknown-reason'}`); + } + + // 7. 等待发布结果(最多 15 秒轮询) + await page.wait(2); + let itemId = ''; + let finalUrl = await getCurrentPageUrl(page); + let failReason = ''; + + for (let i = 0; i < 10; i++) { + await page.wait(1.5); + const result = await page.evaluate(buildDetectSuccessEvaluate()); + finalUrl = await getCurrentPageUrl(page); + + if (result?.status === 'published') { + itemId = String(result.item_id || '').replace(/\D/g, ''); + return [{ + status: 'published', + item_id: itemId, + title: data.title.slice(0, 50), + price: `¥${data.price}`, + condition: data.condition, + url: result.url || finalUrl, + message: '发布成功', + }]; + } + + if (result?.status === 'failed') { + failReason = result.message || '发布失败'; + break; + } + } + + throw new CommandExecutionError(failReason || 'Xianyu publish result was not confirmed before timeout', `Open ${finalUrl} and verify whether the listing was published.`); + }, +}); + +export const __test__ = { + CONDITION_CHOICES, + MAX_IMAGES, + validateImagePaths, + normalizePublishArgs, + buildPublishUrl, + getCurrentPageUrl, + buildFillFormEvaluate, + buildSelectCategoryEvaluate, + buildFindFileInputSelectorEvaluate, + buildDetectSuccessEvaluate, +}; diff --git a/clis/xianyu/publish.test.js b/clis/xianyu/publish.test.js new file mode 100644 index 0000000..a180d5e --- /dev/null +++ b/clis/xianyu/publish.test.js @@ -0,0 +1,220 @@ +import { JSDOM } from 'jsdom'; +import { describe, it, expect, vi } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + statSync: vi.fn((input) => { + const value = String(input); + if (value.includes('missing')) return undefined; + return { isFile: () => !value.includes('directory') }; + }), + }; +}); + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolve: vi.fn((input) => `/abs/${input}`), + extname: vi.fn((input) => { + const match = String(input).match(/\.[^.]+$/); + return match ? match[0] : ''; + }), + }; +}); + +import { __test__, publishCommand } from './publish.js'; + +function makePage({ evaluateResults = [], overrides = {} } = {}) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + evaluate.mockResolvedValue({ ok: false, reason: 'unknown-state' }); + + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + setFileInput: vi.fn().mockResolvedValue(undefined), + getCurrentUrl: vi.fn().mockResolvedValue('https://www.goofish.com/publish'), + ...overrides, + }; +} + +async function runBrowserScript(html, script, { url = 'https://www.goofish.com/publish' } = {}) { + const dom = new JSDOM(html, { url, runScripts: 'outside-only' }); + return dom.window.eval(script); +} + +const validArgs = { + title: 'MacBook Pro', + description: '成色很好,功能正常', + price: '5999.99', + condition: '轻微使用', + category: '笔记本', +}; + +describe('xianyu/publish', () => { + it('builds the goofish publish URL', () => { + expect(__test__.buildPublishUrl()).toBe('https://www.goofish.com/publish'); + }); + + it('validates publish arguments before navigation', async () => { + const page = makePage(); + + await expect(publishCommand.func(page, { ...validArgs, title: ' ' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, price: '0' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, price: '12.345' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, condition: '八成新' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, images: 'a.bmp' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, images: 'missing.png' })).rejects.toBeInstanceOf(ArgumentError); + await expect(publishCommand.func(page, { ...validArgs, images: '1.png,2.png,3.png,4.png,5.png,6.png,7.png,8.png,9.png,10.png' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('publishes when every UI step has positive proof', async () => { + const page = makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: true, filled: ['title', 'description', 'price', 'condition'], missing: [] }, + { ok: true }, + { status: 'published', item_id: '123456789012', url: 'https://www.goofish.com/item?id=123456789012' }, + ], + }); + + const rows = await publishCommand.func(page, validArgs); + + expect(rows).toEqual([{ + status: 'published', + item_id: '123456789012', + title: 'MacBook Pro', + price: '¥5999.99', + condition: '轻微使用', + url: 'https://www.goofish.com/item?id=123456789012', + message: '发布成功', + }]); + }); + + it('uses IPage getCurrentUrl instead of a non-existent page.url method', async () => { + const page = makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: true, filled: ['title', 'description', 'price', 'condition'], missing: [] }, + { ok: true }, + { status: 'published', item_id: '123456789012' }, + ], + overrides: { + getCurrentUrl: vi.fn().mockResolvedValue('https://www.goofish.com/item?id=123456789012'), + }, + }); + + expect(page.url).toBeUndefined(); + + const rows = await publishCommand.func(page, validArgs); + + expect(page.getCurrentUrl).toHaveBeenCalled(); + expect(rows[0].url).toBe('https://www.goofish.com/item?id=123456789012'); + }); + + it('maps login walls to AuthRequiredError', async () => { + const page = makePage({ + evaluateResults: [ + { requiresAuth: true }, + ], + }); + + await expect(publishCommand.func(page, validArgs)).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('fails fast when category selection or form filling is not proven', async () => { + await expect(publishCommand.func(makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: false, reason: 'category-not-found' }, + ], + }), validArgs)).rejects.toBeInstanceOf(CommandExecutionError); + + await expect(publishCommand.func(makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: false, missing: ['price'] }, + ], + }), validArgs)).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('uploads validated local images through the discovered file input', async () => { + const page = makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: true, missing: [] }, + { ok: true, selector: '[id="upload"]' }, + { ok: true }, + { status: 'published', item_id: '123456789012', url: 'https://www.goofish.com/item?id=123456789012' }, + ], + }); + + await publishCommand.func(page, { ...validArgs, images: 'a.png,b.webp' }); + + expect(page.setFileInput).toHaveBeenCalledWith(['/abs/a.png', '/abs/b.webp'], '[id="upload"]'); + }); + + it('does not return a success row for failed or unconfirmed publish states', async () => { + await expect(publishCommand.func(makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: true, missing: [] }, + { ok: true }, + { status: 'failed', message: '内容违规' }, + ], + }), validArgs)).rejects.toBeInstanceOf(CommandExecutionError); + + await expect(publishCommand.func(makePage({ + evaluateResults: [ + { hasPublishForm: true }, + { ok: true }, + { ok: true, missing: [] }, + { ok: true }, + ], + }), validArgs)).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('browser category script is async and returns typed failure reasons', async () => { + const result = await runBrowserScript('
', __test__.buildSelectCategoryEvaluate('笔记本')); + + expect(result).toEqual({ ok: false, reason: 'category-trigger-not-found' }); + }); + + it('browser fill script reports missing required fields', async () => { + const result = await runBrowserScript(` +
+ + + +
+ `, __test__.buildFillFormEvaluate(validArgs)); + + expect(result.ok).toBe(false); + expect(result.missing).toContain('price'); + }); + + it('browser success detector distinguishes success from failure and unknown states', async () => { + await expect(runBrowserScript('发布成功', __test__.buildDetectSuccessEvaluate(), { + url: 'https://www.goofish.com/item?id=123456789012', + })).resolves.toMatchObject({ status: 'published', item_id: '123456789012' }); + + await expect(runBrowserScript('
内容违规
', __test__.buildDetectSuccessEvaluate())) + .resolves.toMatchObject({ status: 'failed', message: '内容违规' }); + + await expect(runBrowserScript('处理中', __test__.buildDetectSuccessEvaluate())) + .resolves.toEqual({ ok: false, reason: 'unknown-state' }); + }); +}); diff --git a/clis/xianyu/reply.js b/clis/xianyu/reply.js new file mode 100644 index 0000000..826c976 --- /dev/null +++ b/clis/xianyu/reply.js @@ -0,0 +1,82 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildChatUrl, buildClickInboxConversationEvaluate, buildExtractChatStateEvaluate, buildSendMessageEvaluate, normalizeRank, requireClickResult, requireEvaluateObject, requireText } from './im.js'; +import { normalizeNumericId } from './utils.js'; + +cli({ + site: 'xianyu', + name: 'reply', + access: 'write', + description: '回复指定闲鱼私信会话', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'item_id', positional: true, help: '闲鱼商品 item_id' }, + { name: 'user_id', positional: true, help: '聊一聊对方的 user_id / peerUserId' }, + { name: 'text', required: true, help: 'Message text to send' }, + { name: 'rank', type: 'int', default: 0, help: 'Conversation rank from xianyu inbox; clicks the visible row instead of requiring IDs' }, + ], + columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'], + func: async (page, kwargs) => { + const hasItemId = kwargs.item_id != null && kwargs.item_id !== ''; + const hasUserId = kwargs.user_id != null && kwargs.user_id !== ''; + const rank = normalizeRank(kwargs.rank); + if (rank > 0 && (hasItemId || hasUserId)) { + throw new ArgumentError('xianyu reply accepts either item_id/user_id or --rank, not both'); + } + if (rank === 0 && hasItemId !== hasUserId) { + throw new ArgumentError('xianyu reply requires both item_id and user_id, or --rank from xianyu inbox'); + } + if (rank === 0 && !hasItemId && !hasUserId) { + throw new ArgumentError('xianyu reply requires item_id/user_id or --rank from xianyu inbox'); + } + const hasIds = hasItemId && hasUserId; + const itemId = hasIds ? normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192') : ''; + const userId = hasIds ? normalizeNumericId(kwargs.user_id, 'user_id', '3650092411') : ''; + const text = requireText(kwargs.text, 'xianyu reply --text'); + const url = hasIds ? buildChatUrl(itemId, userId) : ''; + if (hasIds) { + await page.goto(url); + } else { + if (!page.getCurrentUrl || !/https:\/\/www\.goofish\.com\/im\b/.test(await page.getCurrentUrl())) { + await page.goto('https://www.goofish.com/im'); + } + } + await page.wait(2); + if (rank > 0) { + requireClickResult(await page.evaluate(buildClickInboxConversationEvaluate(rank - 1)), 'reply rank click'); + await page.wait(2); + } + const state = requireEvaluateObject(await page.evaluate(buildExtractChatStateEvaluate()), 'reply'); + if (state?.requiresAuth) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu reply requires a logged-in browser session'); + } + if (!state?.can_input) { + throw selectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载'); + } + const sent = requireEvaluateObject(await page.evaluate(buildSendMessageEvaluate(text)), 'reply send'); + if (!sent?.ok) { + throw new CommandExecutionError(`Xianyu reply did not observe the sent message: ${sent?.reason || 'unknown-reason'}`); + } + await page.wait(1); + return [{ + status: 'sent', + peer_name: state.peer_name || '', + item_title: state.item_title || '', + price: state.price || '', + location: state.location || '', + message: text, + peer_user_id: userId, + item_id: itemId, + url: url || '', + item_url: state.item_url || '', + }]; + }, +}); + +export const __test__ = { + buildChatUrl, + buildSendMessageEvaluate, +}; diff --git a/clis/xianyu/search.js b/clis/xianyu/search.js new file mode 100644 index 0000000..ee8eef2 --- /dev/null +++ b/clis/xianyu/search.js @@ -0,0 +1,262 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +const ROWS_PER_PAGE = 30; +const MAX_LIMIT = 60; +function normalizeLimit(value) { + const n = Number(value); + if (!Number.isFinite(n)) + return 20; + return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n))); +} +function buildSearchUrl(query) { + return `https://www.goofish.com/search?q=${encodeURIComponent(query)}`; +} +// Parse a --min-price / --max-price argument into a non-negative number, or null when omitted. +// (`float` args are not auto-coerced by the framework, so they arrive as strings.) +function parsePriceArg(value, label) { + if (value === undefined || value === null || value === '') + return null; + const n = Number(String(value).trim()); + if (!Number.isFinite(n) || n < 0) { + throw new ArgumentError(`xianyu search ${label} must be a non-negative number`, `For example: --${label} 100000`); + } + return n; +} +// Goofish's PC search applies price filtering server-side via +// propValueStr.searchFilter = "priceRange:,;" (values in 元). An omitted +// bound is filled with a wide default so a single-sided range still works. +function buildSearchFilter(minPrice, maxPrice) { + if (minPrice == null && maxPrice == null) + return ''; + const lo = minPrice != null ? minPrice : 0; + const hi = maxPrice != null ? maxPrice : 99999999; + return `priceRange:${lo},${hi};`; +} +// Region filtering is server-side via extraFilterValue (a JSON string) carrying a +// divisionList of {province, city} pairs. A city alone (empty province) is accepted, +// as is a province alone (empty city). Returns "{}" when no region is requested. +function buildExtraFilterValue(province, city) { + if (!province && !city) + return '{}'; + return JSON.stringify({ + divisionList: [{ province: province || '', city: city || '' }], + excludeMultiPlacesSellers: '0', + extraDivision: '', + }); +} +function buildSearchEvaluate({ keyword, searchFilter, extraFilterValue, fromFilter, maxItems }) { + return ` + (async () => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const cleanFirst = (...values) => values.map(clean).find(Boolean) || ''; + const cleanTagList = (value) => { + const nodes = Array.isArray(value) ? value : []; + return nodes + .map((entry) => cleanFirst(entry?.text, entry?.title, entry?.name, entry?.label, entry?.content, entry?.data?.content)) + .filter(Boolean) + .join(' | '); + }; + const extractRetCode = (ret) => { + const first = Array.isArray(ret) ? ret[0] : ''; + return clean(first).split('::')[0] || ''; + }; + const waitFor = async (predicate, timeoutMs = 6000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 150)); + } + return false; + }; + + const bodyText = document.body?.innerText || ''; + if (/请先登录|扫码登录|登录后/.test(bodyText)) return { error: 'auth-required' }; + if (/验证码|安全验证|异常访问/.test(bodyText)) return { error: 'blocked' }; + + await waitFor(() => window.lib?.mtop?.request); + if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') { + return { error: 'mtop-not-ready' }; + } + + const searchFilter = ${JSON.stringify(searchFilter)}; + const propValueStr = searchFilter ? { searchFilter } : {}; + const rows = ${ROWS_PER_PAGE}; + const maxItems = ${maxItems}; + const maxPages = Math.ceil(maxItems / rows); + const collected = []; + + for (let page = 1; page <= maxPages && collected.length < maxItems; page++) { + let response; + try { + response = await window.lib.mtop.request({ + api: 'mtop.taobao.idlemtopsearch.pc.search', + data: { + pageNumber: page, + keyword: ${JSON.stringify(keyword)}, + fromFilter: ${fromFilter ? 'true' : 'false'}, + rowsPerPage: rows, + sortValue: '', + sortField: '', + customDistance: '', + gps: '', + propValueStr, + customGps: '', + searchReqFromPage: 'pcSearch', + extraFilterValue: ${JSON.stringify(extraFilterValue)}, + userPositionJson: '{}', + }, + type: 'POST', + v: '1.0', + dataType: 'json', + needLogin: false, + needLoginPC: false, + sessionOption: 'AutoLoginOnly', + ecode: 0, + }); + } catch (error) { + const ret = error?.ret || []; + return { + error: 'mtop-request-failed', + error_code: extractRetCode(ret), + error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error), + }; + } + + const retCode = extractRetCode(response?.ret || []); + if (retCode && retCode !== 'SUCCESS') { + return { + error: 'mtop-response-error', + error_code: retCode, + error_message: clean((response?.ret || []).join(' | ')), + }; + } + + if (!response?.data || !Array.isArray(response.data.resultList)) { + return { + error: 'malformed-response', + error_message: 'Xianyu search response did not include a resultList array', + }; + } + + const list = response.data.resultList; + if (!list.length) break; + + let pageValidRows = 0; + let pageMalformedRows = 0; + for (const entry of list) { + const itemNode = entry?.data?.item || {}; + const main = itemNode.main || {}; + const args = main.clickParam?.args || {}; + const ex = main.exContent || itemNode.exContent || {}; + const itemId = clean(args.item_id || args.id || ''); + const title = clean(ex.title || ex.detailParams?.title || ''); + if (!itemId || !title) { + pageMalformedRows += 1; + continue; + } + const priceYuan = clean(args.price || args.displayPrice || ''); + const city = clean(args.p_city || ''); + const area = clean(ex.area || ''); + const tagText = cleanTagList(ex.fishTags || ex.labels || ex.tags || ex.tagList || []); + collected.push({ + item_id: itemId, + title, + price: priceYuan ? ('¥' + priceYuan) : '', + condition: cleanFirst(ex.condition, ex.stuffStatus, ex.detailParams?.condition), + brand: cleanFirst(ex.brand, ex.brandName, ex.detailParams?.brand), + location: city || area, + badge: cleanFirst(ex.badge, ex.creditText, ex.creditLevel, tagText), + want: clean(args.wantNum || ex.want || ''), + url: 'https://www.goofish.com/item?id=' + itemId, + }); + pageValidRows += 1; + if (collected.length >= maxItems) break; + } + + if (!pageValidRows && pageMalformedRows) { + return { + error: 'malformed-row', + error_message: 'Xianyu search result rows were missing item_id or title', + }; + } + + if (list.length < rows) break; + } + + return { items: collected }; + })() + `; +} +cli({ + site: 'xianyu', + name: 'search', + access: 'read', + description: '搜索闲鱼商品(支持服务端价格区间 / 地区筛选)', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'query', required: true, positional: true, help: '搜索关键词' }, + { name: 'limit', type: 'int', default: 20, help: `返回结果数(最多 ${MAX_LIMIT},自动翻页)` }, + { name: 'min-price', type: 'float', help: '最低价格(元),服务端筛选' }, + { name: 'max-price', type: 'float', help: '最高价格(元),服务端筛选' }, + { name: 'province', type: 'string', help: '省份名(如 广东),服务端按地区筛选' }, + { name: 'city', type: 'string', help: '城市名(如 深圳 / 湛江),可单独使用,服务端按地区筛选' }, + ], + columns: ['item_id', 'rank', 'title', 'price', 'condition', 'brand', 'location', 'badge', 'want', 'url'], + func: async (page, kwargs) => { + const query = String(kwargs.query || '').trim(); + const limit = normalizeLimit(kwargs.limit); + const minPrice = parsePriceArg(kwargs['min-price'], 'min-price'); + const maxPrice = parsePriceArg(kwargs['max-price'], 'max-price'); + if (minPrice != null && maxPrice != null && minPrice > maxPrice) { + throw new ArgumentError('xianyu search min-price cannot be greater than max-price', `Received --min-price ${minPrice} and --max-price ${maxPrice}`); + } + const province = String(kwargs.province || '').trim(); + const city = String(kwargs.city || '').trim(); + const searchFilter = buildSearchFilter(minPrice, maxPrice); + const extraFilterValue = buildExtraFilterValue(province, city); + const fromFilter = Boolean(searchFilter) || extraFilterValue !== '{}'; + await page.goto(buildSearchUrl(query)); + await page.wait(2); + const result = await page.evaluate(buildSearchEvaluate({ keyword: query, searchFilter, extraFilterValue, fromFilter, maxItems: limit })); + if (result?.error === 'auth-required') { + throw new AuthRequiredError('www.goofish.com', 'Xianyu search requires a logged-in browser session'); + } + if (result?.error === 'blocked') { + throw new CommandExecutionError('Xianyu returned a verification page or blocked the current browser session'); + } + if (result?.error === 'mtop-not-ready') { + throw selectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用搜索接口'); + } + if (!result || typeof result !== 'object') { + throw new CommandExecutionError('Xianyu search returned a malformed response'); + } + const errorCode = String(result?.error_code || ''); + const errorMessage = String(result?.error_message || ''); + if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS_TOKEN/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu search requires a logged-in browser session'); + } + if (result?.error) { + throw new CommandExecutionError(errorMessage || `Xianyu search request failed: ${result.error}`); + } + if (!Array.isArray(result.items)) { + throw new CommandExecutionError('Xianyu search response did not include an items array'); + } + const items = result.items; + if (!items.length) { + throw new EmptyResultError('xianyu search', '没有匹配的商品(筛选条件可能过窄,或当前关键词无结果)'); + } + return items.map((item, index) => ({ rank: index + 1, ...item })); + }, +}); +export const __test__ = { + ROWS_PER_PAGE, + MAX_LIMIT, + normalizeLimit, + buildSearchUrl, + parsePriceArg, + buildSearchFilter, + buildExtraFilterValue, +}; diff --git a/clis/xianyu/search.test.js b/clis/xianyu/search.test.js new file mode 100644 index 0000000..163ebe1 --- /dev/null +++ b/clis/xianyu/search.test.js @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { __test__ } from './search.js'; +import './search.js'; +describe('xianyu search helpers', () => { + it('normalizes limit into supported range', () => { + expect(__test__.normalizeLimit(undefined)).toBe(20); + expect(__test__.normalizeLimit(0)).toBe(1); + expect(__test__.normalizeLimit(3.8)).toBe(3); + expect(__test__.normalizeLimit(999)).toBe(__test__.MAX_LIMIT); + }); + it('builds search URLs with encoded queries', () => { + expect(__test__.buildSearchUrl('小鹏G9')).toBe('https://www.goofish.com/search?q=%E5%B0%8F%E9%B9%8FG9'); + }); +}); +describe('xianyu search price filter', () => { + it('parses price arguments, returning null when omitted', () => { + expect(__test__.parsePriceArg(undefined, 'min-price')).toBeNull(); + expect(__test__.parsePriceArg('', 'min-price')).toBeNull(); + expect(__test__.parsePriceArg('100000', 'min-price')).toBe(100000); + expect(__test__.parsePriceArg(200000.5, 'max-price')).toBe(200000.5); + }); + it('rejects negative or non-numeric price arguments', () => { + expect(() => __test__.parsePriceArg('-1', 'min-price')).toThrow(ArgumentError); + expect(() => __test__.parsePriceArg('abc', 'max-price')).toThrow(ArgumentError); + }); + it('encodes priceRange the way goofish expects, filling open-ended bounds', () => { + expect(__test__.buildSearchFilter(null, null)).toBe(''); + expect(__test__.buildSearchFilter(100000, 200000)).toBe('priceRange:100000,200000;'); + expect(__test__.buildSearchFilter(100000, null)).toBe('priceRange:100000,99999999;'); + expect(__test__.buildSearchFilter(null, 200000)).toBe('priceRange:0,200000;'); + }); +}); +describe('xianyu search region filter', () => { + it('returns {} when no region is requested', () => { + expect(__test__.buildExtraFilterValue('', '')).toBe('{}'); + }); + it('encodes a city alone (empty province)', () => { + expect(JSON.parse(__test__.buildExtraFilterValue('', '深圳'))).toEqual({ + divisionList: [{ province: '', city: '深圳' }], + excludeMultiPlacesSellers: '0', + extraDivision: '', + }); + }); + it('encodes a province alone, and a province+city pair', () => { + expect(JSON.parse(__test__.buildExtraFilterValue('广东', '')).divisionList).toEqual([{ province: '广东', city: '' }]); + expect(JSON.parse(__test__.buildExtraFilterValue('广东', '湛江')).divisionList).toEqual([{ province: '广东', city: '湛江' }]); + }); +}); +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + }; +} +describe('xianyu search command', () => { + const command = getRegistry().get('xianyu/search'); + it('keeps existing search columns while adding want count', () => { + expect(command.columns).toEqual(['item_id', 'rank', 'title', 'price', 'condition', 'brand', 'location', 'badge', 'want', 'url']); + }); + it('assigns contiguous ranks and passes item fields through', async () => { + const page = createPageMock({ + items: [ + { item_id: '111', title: 'A', price: '¥130000', condition: '几乎全新', brand: '小鹏', location: '深圳', badge: '信用极好', want: '3', url: 'u1' }, + { item_id: '222', title: 'B', price: '¥185000', condition: '', brand: '', location: '深圳', badge: '', want: '0', url: 'u2' }, + ], + }); + const rows = await command.func(page, { query: '小鹏G9', city: '深圳', 'min-price': 100000, 'max-price': 200000 }); + expect(rows.map((r) => r.rank)).toEqual([1, 2]); + expect(rows.map((r) => r.item_id)).toEqual(['111', '222']); + expect(rows[0]).toMatchObject({ rank: 1, price: '¥130000', condition: '几乎全新', brand: '小鹏', location: '深圳', badge: '信用极好' }); + }); + it('rejects an inverted price range before touching the page', async () => { + const page = createPageMock({ items: [] }); + await expect(command.func(page, { query: '小鹏G9', 'min-price': 200000, 'max-price': 100000 })).rejects.toBeInstanceOf(ArgumentError); + expect(page.evaluate).not.toHaveBeenCalled(); + }); + it('throws AuthRequiredError when the search needs a login', async () => { + const page = createPageMock({ error: 'auth-required' }); + await expect(command.func(page, { query: '小鹏G9' })).rejects.toBeInstanceOf(AuthRequiredError); + }); + it('throws AuthRequiredError on an expired mtop session', async () => { + const page = createPageMock({ error: 'mtop-response-error', error_code: 'FAIL_SYS_SESSION_EXPIRED', error_message: 'x' }); + await expect(command.func(page, { query: '小鹏G9' })).rejects.toBeInstanceOf(AuthRequiredError); + }); + it('throws CommandExecutionError on verification blocks', async () => { + const page = createPageMock({ error: 'blocked' }); + await expect(command.func(page, { query: '小鹏G9' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws CommandExecutionError on malformed mtop payloads', async () => { + const page = createPageMock({ error: 'malformed-response', error_message: 'missing resultList' }); + await expect(command.func(page, { query: '小鹏G9' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws CommandExecutionError when evaluate does not return an items array', async () => { + const page = createPageMock({}); + await expect(command.func(page, { query: '小鹏G9' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws EmptyResultError when nothing matches the filters', async () => { + const page = createPageMock({ items: [] }); + await expect(command.func(page, { query: '小鹏G9', city: '湛江', 'min-price': 5000000 })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/xianyu/utils.js b/clis/xianyu/utils.js new file mode 100644 index 0000000..f09e4c0 --- /dev/null +++ b/clis/xianyu/utils.js @@ -0,0 +1,8 @@ +import { ArgumentError } from '@jackwener/opencli/errors'; +export function normalizeNumericId(value, label, example) { + const normalized = String(value || '').trim(); + if (!/^\d+$/.test(normalized)) { + throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`); + } + return normalized; +} diff --git a/clis/xiaoe/auth.js b/clis/xiaoe/auth.js new file mode 100644 index 0000000..f4f7a10 --- /dev/null +++ b/clis/xiaoe/auth.js @@ -0,0 +1,55 @@ +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; + +async function hasXiaoeAdminCookie(page) { + const cookies = await page.getCookies({ url: 'https://admin.xiaoe-tech.com' }); + return cookies.some(c => (c.name === 'XIAOEID' || c.name === 'b_user_token') && c.value); +} + +async function verifyXiaoeIdentity(page) { + if (!await hasXiaoeAdminCookie(page)) { + throw new AuthRequiredError('xiaoe-tech.com', 'Xiaoe XIAOEID/b_user_token cookie missing — anonymous'); + } + await page.goto('https://admin.xiaoe-tech.com/t/account/muti_index'); + await page.wait(3); + const finalUrl = await page.evaluate(`location.href`); + if (/login|signin|#\/wx$/.test(String(finalUrl || ''))) { + throw new AuthRequiredError('xiaoe-tech.com', `Xiaoe admin page redirected to login: ${finalUrl}`); + } + const cookies = await page.getCookies({ url: 'https://admin.xiaoe-tech.com' }); + const xiaoeId = cookies.find(c => c.name === 'XIAOEID')?.value || ''; + const unionId = cookies.find(c => c.name === 'unionid')?.value || ''; + const probe = await page.evaluate(` + (() => { + const bodyText = document.body?.innerText || ''; + if (/微信扫码登录|手机号登录|登录小鹅通/.test(bodyText)) { + return { isLoginPage: true }; + } + const nick = document.querySelector('.user-name, .nickname, [class*="userName"], [class*="user-info"]')?.innerText?.trim() || ''; + return { isLoginPage: false, domNick: nick }; + })() + `); + if (probe.isLoginPage) { + throw new AuthRequiredError('xiaoe-tech.com', 'Xiaoe admin page showed login UI — anonymous session'); + } + const userId = xiaoeId || unionId; + if (!userId) { + throw new CommandExecutionError('Xiaoe admin page rendered but no user_id cookie extractable — stale session'); + } + return { user_id: String(userId), nickname: String(probe.domNick || '') }; +} + +registerSiteAuthCommands({ + site: 'xiaoe', + domain: 'xiaoe-tech.com', + loginUrl: 'https://admin.xiaoe-tech.com/', + columns: ['user_id', 'nickname'], + quickCheck: hasXiaoeAdminCookie, + verify: verifyXiaoeIdentity, + poll: async (page) => { + if (!await hasXiaoeAdminCookie(page)) { + throw new AuthRequiredError('xiaoe-tech.com', 'Waiting for Xiaoe XIAOEID/b_user_token cookie'); + } + return verifyXiaoeIdentity(page); + }, +}); diff --git a/clis/xiaoe/catalog.js b/clis/xiaoe/catalog.js new file mode 100644 index 0000000..feb5163 --- /dev/null +++ b/clis/xiaoe/catalog.js @@ -0,0 +1,227 @@ +// Xiaoe (小鹅通) catalog — list chapters + sections of a course / column +// page (`h5.xet.citv.cn`). +// +// Replaces the legacy `pipeline:[]` form. The in-browser extraction logic +// (Vue store walking + auto-scroll-to-load + Vue child traversal) is kept +// byte-for-byte — Xiaoe's pages are SPA-rendered and Vue's private API +// (`__vue__`, `$store`, `$children`, `chapter_box.__vue__`) is the only +// stable hook we have. JSDOM cannot reproduce the Vue runtime tree, so +// reorganising the IIFE without live verify would be silent-failure risk. +// +// What changes: +// - `func` form + `Strategy.COOKIE` + `browser:true`. +// - Typed errors: `ArgumentError` on missing url; `EmptyResultError` +// when the IIFE yields zero rows (almost always means the cookie +// expired or the URL is not a course page); `CommandExecutionError` +// when `page.evaluate` rejects. +// - Three pure helpers (`typeLabel`, `buildItemUrl`, `chapterUrlPath`) +// are module-level exports and are embedded into the in-page IIFE +// via `${fn.toString()}` so the live and the test path share one +// source of truth. + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { requireXiaoePageUrl } from './content.js'; + +// resource_type → human label. 1=图文 2=直播 3=音频 4=视频 6=专栏 8=大专栏. +// Returns the raw `String(t)` when the type is unknown (e.g. xiaoe rolls +// out a new resource type) — never silently swallows it. +export function typeLabel(t) { + const map = { 1: '图文', 2: '直播', 3: '音频', 4: '视频', 6: '专栏', 8: '大专栏' }; + return map[Number(t)] || String(t || ''); +} + +// Resolve a relative `jump_url` / `h5_url` / `url` against the page's +// origin. Returns '' when the item has no URL field at all. +export function buildItemUrl(item, origin) { + const u = item.jump_url || item.h5_url || item.url || ''; + if (!u) return ''; + return u.startsWith('http') ? u : (origin + u); +} + +// chapter_type → URL path for the section reader. Xiaoe routes chapter +// types to different player paths; returning `undefined` for unknown +// types lets the caller decide whether to emit `''` instead of guessing +// a bad URL. +export function chapterUrlPath(chType) { + const map = { 1: '/v1/course/text/', 2: '/v2/course/alive/', 3: '/v1/course/audio/', 4: '/v1/course/video/' }; + return map[Number(chType)]; +} + +export function buildCatalogScript() { + return `(async () => { + ${typeLabel.toString()} + ${buildItemUrl.toString()} + ${chapterUrlPath.toString()} + var el = document.querySelector('#app'); + var store = (el && el.__vue__) ? el.__vue__.$store : null; + if (!store) return []; + var coreInfo = store.state.coreInfo || {}; + var resourceType = coreInfo.resource_type || 0; + var origin = window.location.origin; + var courseName = coreInfo.resource_name || ''; + + function clickTab(name) { + var tabs = document.querySelectorAll('span, div'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === name) { + tabs[i].click(); return; + } + } + } + + clickTab('目录'); + await new Promise(function(r) { setTimeout(r, 2000); }); + + function getScrollTargets() { + return document.querySelectorAll('.scroll-view, .list-wrap, .scroller, #app'); + } + function getMaxScrollHeight(scrollers) { + var maxHeight = document.body.scrollHeight; + for (var i = 0; i < scrollers.length; i++) { + if (scrollers[i].scrollHeight > maxHeight) maxHeight = scrollers[i].scrollHeight; + } + return maxHeight; + } + + // 模拟滚动以实现动态加载 + var prevMaxScrollHeight = 0; + for (var sc = 0; sc < 20; sc++) { + window.scrollTo(0, 999999); + var scrollers = getScrollTargets(); + for(var si = 0; si < scrollers.length; si++) { + if(scrollers[si].scrollHeight > scrollers[si].clientHeight) scrollers[si].scrollTop = scrollers[si].scrollHeight; + } + await new Promise(function(r) { setTimeout(r, 800); }); + + // 点击可能存在的下拉/加载更多 + var moreTabs = document.querySelectorAll('span, div, p'); + for (var bi = 0; bi < moreTabs.length; bi++) { + var t = moreTabs[bi].textContent.trim(); + if ((t === '点击加载更多' || t === '展开更多' || t === '加载更多') && moreTabs[bi].clientHeight > 0) { + try { moreTabs[bi].click(); } catch(e){} + } + } + + var maxScrollHeight = getMaxScrollHeight(getScrollTargets()); + if (sc > 3 && maxScrollHeight === prevMaxScrollHeight) break; + prevMaxScrollHeight = maxScrollHeight; + } + await new Promise(function(r) { setTimeout(r, 1000); }); + + // ===== 专栏 / 大专栏 ===== + if (resourceType === 6 || resourceType === 8) { + await new Promise(function(r) { setTimeout(r, 1000); }); + var listData = []; + var walkList = function(vm, depth) { + if (!vm || depth > 6 || listData.length > 0) return; + var d = vm.$data || {}; + var keys = ['columnList', 'SingleItemList', 'chapterChildren']; + for (var ki = 0; ki < keys.length; ki++) { + var arr = d[keys[ki]]; + if (arr && Array.isArray(arr) && arr.length > 0 && arr[0].resource_id) { + for (var j = 0; j < arr.length; j++) { + var item = arr[j]; + if (!item.resource_id || !/^[pvlai]_/.test(item.resource_id)) continue; + listData.push({ + ch: 1, + chapter: courseName, + no: j + 1, + title: item.resource_title || item.title || item.chapter_title || '', + type: typeLabel(item.resource_type || item.chapter_type), + resource_id: item.resource_id, + url: buildItemUrl(item, origin), + status: item.finished_state === 1 ? '已完成' : (item.resource_count ? item.resource_count + '节' : ''), + }); + } + return; + } + } + if (vm.$children) { + for (var c = 0; c < vm.$children.length; c++) walkList(vm.$children[c], depth + 1); + } + }; + walkList(el.__vue__, 0); + return listData; + } + + // ===== 普通课程 ===== + var chapters = document.querySelectorAll('.chapter_box'); + for (var ci = 0; ci < chapters.length; ci++) { + var vue = chapters[ci].__vue__; + if (vue && typeof vue.getSecitonList === 'function' && (!vue.isShowSecitonsList || !vue.chapterChildren.length)) { + if (vue.isShowSecitonsList) vue.isShowSecitonsList = false; + try { vue.getSecitonList(); } catch(e) {} + await new Promise(function(r) { setTimeout(r, 1500); }); + } + } + await new Promise(function(r) { setTimeout(r, 3000); }); + + var result = []; + chapters = document.querySelectorAll('.chapter_box'); + for (var cj = 0; cj < chapters.length; cj++) { + var v = chapters[cj].__vue__; + if (!v) continue; + var chTitle = (v.chapterItem && v.chapterItem.chapter_title) || ''; + var children = v.chapterChildren || []; + for (var ck = 0; ck < children.length; ck++) { + var child = children[ck]; + var resId = child.resource_id || child.chapter_id || ''; + var chType = child.chapter_type || child.resource_type || 0; + var urlPath = chapterUrlPath(chType); + result.push({ + ch: cj + 1, + chapter: chTitle, + no: ck + 1, + title: child.chapter_title || child.resource_title || '', + type: typeLabel(chType), + resource_id: resId, + url: urlPath ? origin + urlPath + resId + '?type=2' : '', + status: child.is_finish === 1 ? '已完成' : (child.learn_progress > 0 ? child.learn_progress + '%' : '未学'), + }); + } + } + return result; +})()`; +} + +async function getXiaoeCatalog(page, args) { + const url = requireXiaoePageUrl(args.url, 'catalog'); + let rows; + try { + await page.goto(url, { waitUntil: 'load', settleMs: 8000 }); + rows = await page.evaluate(buildCatalogScript()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CommandExecutionError( + `Failed to read xiaoe catalog: ${message}`, + 'page may not have rendered or auth may be required', + ); + } + if (!Array.isArray(rows) || rows.length === 0) { + throw new EmptyResultError( + 'xiaoe/catalog', + 'No catalog rows extracted — the URL may not be a course page or the login session has expired', + ); + } + return rows; +} + +export const catalogCommand = cli({ + site: 'xiaoe', + name: 'catalog', + access: 'read', + description: '小鹅通课程目录(支持普通课程、专栏、大专栏)', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'url', required: true, positional: true, help: '课程页面 URL' }, + ], + columns: ['ch', 'chapter', 'no', 'title', 'type', 'resource_id', 'url', 'status'], + func: getXiaoeCatalog, +}); + +export const __test__ = { + buildCatalogScript, +}; diff --git a/clis/xiaoe/content.js b/clis/xiaoe/content.js new file mode 100644 index 0000000..a7221b4 --- /dev/null +++ b/clis/xiaoe/content.js @@ -0,0 +1,175 @@ +// Xiaoe (小鹅通) content extractor — pulls rendered article text from a +// rich-text page (h5.xet.citv.cn). +// +// Replaces the legacy `pipeline:[]` form. Two real bugs in the legacy +// adapter, both silent: +// 1. The IIFE returned `{title, content, content_length, image_count, +// images}` but `columns` declared `[title, content_length, +// image_count]` — `content` (the text the adapter exists to +// extract!) and `images` were silently dropped before reaching the +// caller. The user got "this article has 1234 chars" with no way +// to read those chars. +// 2. `JSON.stringify(images.slice(0, 20))` silently truncated to the +// first 20 image URLs and never told the caller the slice +// happened. +// +// New behavior: +// - `func` form + `Strategy.COOKIE` + `browser:true` (the page is +// gated behind a logged-in xiaoe session). +// - Pure helpers `pickContentText` / `countXiaoeImages` are +// module-level exports; the in-page IIFE embeds them via +// `${fn.toString()}` while JSDOM tests call the same exports +// directly against a hand-crafted fixture (same pattern as +// dianping #1313 / hupu #1387). +// - `content` is now a real column (the bug fix). `image_count` is +// metadata that helps callers decide whether to re-render the +// page for a JSON-image dump in a follow-up adapter. +// - Empty-content extraction → `EmptyResultError` with a +// login-likely hint (xiaoe routinely renders an empty shell when +// the cookie has expired). No silent `return [{ content: '' }]`. + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +export const CONTENT_SELECTORS = [ + '.rich-text-wrap', + '.content-wrap', + '.article-content', + '.text-content', + '.course-detail', + '.detail-content', + '[class*="richtext"]', + '[class*="rich-text"]', + '.ql-editor', +]; +export const CONTENT_MIN_LENGTH = 50; + +// Pure: walk `selectors` in order, return the trimmed text of the first +// element whose `.innerText` (with `.textContent` fallback for JSDOM) +// has more than `minLength` characters. Falls back to `
`, then +// `#app`, then `` — same chain the legacy IIFE used. +export function pickContentText(doc, selectors, minLength = CONTENT_MIN_LENGTH) { + for (const sel of selectors) { + const el = doc.querySelector(sel); + if (!el) continue; + const text = ((el.innerText || el.textContent) || '').trim(); + if (text.length > minLength) return text; + } + const fallback = doc.querySelector('main') + || doc.querySelector('#app') + || doc.body; + if (!fallback) return ''; + return ((fallback.innerText || fallback.textContent) || '').trim(); +} + +// Pure: count `` elements whose src looks like a real xiaoe-hosted +// asset. `data:` URIs and non-xiaoe CDNs (avatars, ads) are excluded. +export function countXiaoeImages(doc) { + let count = 0; + const imgs = doc.querySelectorAll('img'); + for (let i = 0; i < imgs.length; i += 1) { + const src = imgs[i].getAttribute('src') || imgs[i].src || ''; + if (!src) continue; + if (src.startsWith('data:')) continue; + if (!src.includes('xiaoe')) continue; + count += 1; + } + return count; +} + +export function requireXiaoePageUrl(value, commandName) { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) { + throw new ArgumentError('url is required (positional)'); + } + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new ArgumentError( + `invalid xiaoe URL: ${raw}`, + `Example: opencli xiaoe ${commandName} https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx`, + ); + } + if (parsed.protocol !== 'https:') { + throw new ArgumentError( + `xiaoe URL must use https (got ${parsed.protocol.replace(':', '')})`, + `Example: opencli xiaoe ${commandName} https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx`, + ); + } + const host = parsed.hostname.toLowerCase(); + if (host !== 'h5.xet.citv.cn' && !host.endsWith('.h5.xet.citv.cn')) { + throw new ArgumentError( + `url must be on h5.xet.citv.cn or a shop subdomain (got ${parsed.hostname})`, + `Example: opencli xiaoe ${commandName} https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx`, + ); + } + return parsed.toString(); +} + +export function buildContentScript() { + return ` +(() => { + ${pickContentText.toString()} + ${countXiaoeImages.toString()} + const selectors = ${JSON.stringify(CONTENT_SELECTORS)}; + const title = document.title || ''; + const content = pickContentText(document, selectors, ${JSON.stringify(CONTENT_MIN_LENGTH)}); + const imageCount = countXiaoeImages(document); + return [{ + title, + content, + content_length: content.length, + image_count: imageCount, + }]; +})() +`; +} + +async function getXiaoeContent(page, args) { + const url = requireXiaoePageUrl(args.url, 'content'); + let rows; + try { + await page.goto(url, { waitUntil: 'load', settleMs: 6000 }); + rows = await page.evaluate(buildContentScript()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CommandExecutionError( + `Failed to extract xiaoe content: ${message}`, + 'page may not have rendered or auth may be required', + ); + } + if (!Array.isArray(rows) || rows.length === 0) { + throw new EmptyResultError( + 'xiaoe/content', + 'No rows returned from page evaluator (page structure may have changed)', + ); + } + const row = rows[0]; + if (!row || typeof row.content !== 'string' || row.content.length === 0) { + throw new EmptyResultError( + 'xiaoe/content', + 'No article content extracted — login session may have expired or the page renders an empty shell', + ); + } + return rows; +} + +export const contentCommand = cli({ + site: 'xiaoe', + name: 'content', + access: 'read', + description: '提取小鹅通图文页面内容为文本', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'url', required: true, positional: true, help: '页面 URL' }, + ], + columns: ['title', 'content', 'content_length', 'image_count'], + func: getXiaoeContent, +}); + +export const __test__ = { + buildContentScript, +}; diff --git a/clis/xiaoe/courses.js b/clis/xiaoe/courses.js new file mode 100644 index 0000000..5977895 --- /dev/null +++ b/clis/xiaoe/courses.js @@ -0,0 +1,127 @@ +// Xiaoe (小鹅通) purchased-courses list — pulls "已购内容" tab cards from +// `study.xiaoe-tech.com`. +// +// Replaces the legacy `pipeline:[]` form. The in-page extraction logic +// (Vue `__vue__.$parent` walk to match a card title back to the original +// purchase entry) is kept byte-for-byte — Xiaoe's purchase list is not +// exposed as a public JSON endpoint, and Vue's private runtime tree is +// the only stable hook. JSDOM cannot reproduce the Vue runtime, so +// rewriting the IIFE without live verify would be silent-failure risk. +// +// What changes: +// - `func` form + `Strategy.COOKIE` + `browser:true`. +// - Typed errors: `EmptyResultError` when zero card rows are found +// (almost always means the cookie expired); `CommandExecutionError` +// when `page.evaluate` rejects. +// - One pure helper (`buildCourseUrl`) is extracted as a module-level +// export; the in-page IIFE embeds it via `${fn.toString()}` so the +// live and test paths share one source of truth. The helper covers +// the three URL fallbacks the legacy code had inline: +// 1. `entry.h5_url` if present +// 2. `entry.url` if present +// 3. otherwise build from `app_id` + `resource_id` + `resource_type` +// (column course `resource_type === 6` gets the `/v1/course/column/` +// path, everything else gets `/p/course/ecourse/`) + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +// Pure: derive the canonical course URL for a single purchase entry. +// Returns '' when `entry` is missing the fields we'd need to construct +// any of the three forms — never makes up a partial URL. +export function buildCourseUrl(entry) { + if (!entry) return ''; + if (entry.h5_url) return entry.h5_url; + if (entry.url) return entry.url; + if (entry.app_id && entry.resource_id) { + const base = 'https://' + entry.app_id + '.h5.xet.citv.cn'; + if (entry.resource_type === 6) { + return base + '/v1/course/column/' + entry.resource_id + '?type=3'; + } + return base + '/p/course/ecourse/' + entry.resource_id; + } + return ''; +} + +export function buildCoursesScript() { + return `(async () => { + ${buildCourseUrl.toString()} + // 切换到「内容」tab + var tabs = document.querySelectorAll('span, div'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === '内容') { + tabs[i].click(); + break; + } + } + await new Promise(function(r) { setTimeout(r, 2000); }); + + // 匹配课程卡片标题与 Vue 数据 + function matchEntry(title, vm, depth) { + if (!vm || depth > 5) return null; + var d = vm.$data || {}; + for (var k in d) { + if (!Array.isArray(d[k])) continue; + for (var j = 0; j < d[k].length; j++) { + var e = d[k][j]; + if (!e || typeof e !== 'object') continue; + var t = e.title || e.resource_name || ''; + if (t && title.includes(t.substring(0, 10))) return e; + } + } + return vm.$parent ? matchEntry(title, vm.$parent, depth + 1) : null; + } + + var cards = document.querySelectorAll('.course-card-list'); + var results = []; + for (var c = 0; c < cards.length; c++) { + var titleEl = cards[c].querySelector('.card-title-box'); + var title = titleEl ? titleEl.textContent.trim() : ''; + if (!title) continue; + var entry = matchEntry(title, cards[c].__vue__, 0); + results.push({ + title: title, + shop: entry ? (entry.shop_name || entry.app_name || '') : '', + url: entry ? buildCourseUrl(entry) : '', + }); + } + return results; +})()`; +} + +async function getXiaoeCourses(page) { + let rows; + try { + await page.goto('https://study.xiaoe-tech.com/', { waitUntil: 'load', settleMs: 8000 }); + rows = await page.evaluate(buildCoursesScript()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CommandExecutionError( + `Failed to list xiaoe courses: ${message}`, + 'page may not have rendered or auth may be required', + ); + } + if (!Array.isArray(rows) || rows.length === 0) { + throw new EmptyResultError( + 'xiaoe/courses', + 'No purchased courses found — login session may have expired or the "内容" tab has no items', + ); + } + return rows; +} + +export const coursesCommand = cli({ + site: 'xiaoe', + name: 'courses', + access: 'read', + description: '列出已购小鹅通课程(含 URL 和店铺名)', + domain: 'study.xiaoe-tech.com', + strategy: Strategy.COOKIE, + browser: true, + columns: ['title', 'shop', 'url'], + func: getXiaoeCourses, +}); + +export const __test__ = { + buildCoursesScript, +}; diff --git a/clis/xiaoe/detail.js b/clis/xiaoe/detail.js new file mode 100644 index 0000000..7581f80 --- /dev/null +++ b/clis/xiaoe/detail.js @@ -0,0 +1,36 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +cli({ + site: 'xiaoe', + name: 'detail', + access: 'read', + description: '小鹅通课程详情(名称、价格、学员数、店铺)', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '课程页面 URL' }, + ], + columns: ['name', 'price', 'original_price', 'user_count', 'shop_name'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 5 }, + { evaluate: `(() => { + var vm = (document.querySelector('#app') || {}).__vue__; + if (!vm || !vm.$store) return []; + var core = vm.$store.state.coreInfo || {}; + var goods = vm.$store.state.goodsInfo || {}; + var shop = ((vm.$store.state.compositeInfo || {}).shop_conf) || {}; + return [{ + name: core.resource_name || '', + resource_id: core.resource_id || '', + resource_type: core.resource_type || '', + cover: core.resource_img || '', + user_count: core.user_count || 0, + price: goods.price ? (goods.price / 100).toFixed(2) : '0', + original_price: goods.line_price ? (goods.line_price / 100).toFixed(2) : '0', + is_free: goods.is_free || 0, + shop_name: shop.shop_name || '', + }]; +})() +` }, + ], +}); diff --git a/clis/xiaoe/play-url.js b/clis/xiaoe/play-url.js new file mode 100644 index 0000000..d3ebf7e --- /dev/null +++ b/clis/xiaoe/play-url.js @@ -0,0 +1,121 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +cli({ + site: 'xiaoe', + name: 'play-url', + access: 'read', + description: '小鹅通视频/音频/直播回放 M3U8 播放地址', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '小节页面 URL' }, + ], + columns: ['title', 'resource_id', 'm3u8_url', 'duration_sec', 'method'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 2 }, + { evaluate: `(async () => { + var pageUrl = window.location.href; + var origin = window.location.origin; + var resourceId = (pageUrl.match(/[val]_[a-f0-9]+/) || [])[0] || ''; + var productId = (pageUrl.match(/product_id=([^&]+)/) || [])[1] || ''; + var appId = (origin.match(/(app[a-z0-9]+)\\./) || [])[1] || ''; + var isLive = resourceId.startsWith('l_') || pageUrl.includes('/alive/'); + var m3u8Url = '', method = '', title = document.title, duration = 0; + + // 深度搜索 Vue 组件树找 M3U8 + function searchVueM3u8() { + var el = document.querySelector('#app'); + if (!el || !el.__vue__) return ''; + var walk = function(vm, d) { + if (!vm || d > 10) return ''; + var data = vm.$data || {}; + for (var k in data) { + if (k[0] === '_' || k[0] === '$') continue; + var v = data[k]; + if (typeof v === 'string' && v.includes('.m3u8')) return v; + if (typeof v === 'object' && v) { + try { + var s = JSON.stringify(v); + var m = s.match(/https?:[^"]*\\.m3u8[^"]*/); + if (m) return m[0].replace(/\\\\\\//g, '/'); + } catch(e) {} + } + } + if (vm.$children) { + for (var c = 0; c < vm.$children.length; c++) { + var f = walk(vm.$children[c], d + 1); + if (f) return f; + } + } + return ''; + }; + return walk(el.__vue__, 0); + } + + // ===== 视频课: detail_info → getPlayUrl ===== + if (!isLive && resourceId.startsWith('v_')) { + try { + var detailRes = await fetch(origin + '/xe.course.business.video.detail_info.get/2.0.0', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + 'bizData[resource_id]': resourceId, + 'bizData[product_id]': productId || resourceId, + 'bizData[opr_sys]': 'MacIntel', + }), + }); + var detail = await detailRes.json(); + var vi = (detail.data || {}).video_info || {}; + title = vi.file_name || title; + duration = vi.video_length || 0; + if (vi.play_sign) { + var userId = (document.cookie.match(/ctx_user_id=([^;]+)/) || [])[1] || window.__user_id || ''; + var playRes = await fetch(origin + '/xe.material-center.play/getPlayUrl', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + org_app_id: appId, app_id: vi.material_app_id || appId, + user_id: userId, play_sign: [vi.play_sign], + play_line: 'A', opr_sys: 'MacIntel', + }), + }); + var playData = await playRes.json(); + if (playData.code === 0 && playData.data) { + var m = JSON.stringify(playData.data).match(/https?:[^"]*\\.m3u8[^"]*/); + if (m) { m3u8Url = m[0].replace(/\\\\u0026/g, '&').replace(/\\\\\\//g, '/'); method = 'api_direct'; } + } + } + } catch(e) {} + } + + // ===== 兜底: Performance API + Vue 搜索轮询 ===== + if (!m3u8Url) { + for (var attempt = 0; attempt < 30; attempt++) { + var entries = performance.getEntriesByType('resource'); + for (var i = 0; i < entries.length; i++) { + if (entries[i].name.includes('.m3u8')) { m3u8Url = entries[i].name; method = 'perf_api'; break; } + } + if (!m3u8Url) { m3u8Url = searchVueM3u8(); if (m3u8Url) method = 'vue_search'; } + if (m3u8Url) break; + await new Promise(function(r) { setTimeout(r, 500); }); + } + } + + if (!duration) { + var vid = document.querySelector('video'), aud = document.querySelector('audio'); + if (vid && vid.duration && !isNaN(vid.duration)) duration = Math.round(vid.duration); + if (aud && aud.duration && !isNaN(aud.duration)) duration = Math.round(aud.duration); + } + + return [{ title: title, resource_id: resourceId, m3u8_url: m3u8Url, duration_sec: duration, method: method }]; +})() +` }, + { map: { + title: '${{ item.title }}', + resource_id: '${{ item.resource_id }}', + m3u8_url: '${{ item.m3u8_url }}', + duration_sec: '${{ item.duration_sec }}', + method: '${{ item.method }}', + } }, + ], +}); diff --git a/clis/xiaoe/xiaoe.test.js b/clis/xiaoe/xiaoe.test.js new file mode 100644 index 0000000..338b235 --- /dev/null +++ b/clis/xiaoe/xiaoe.test.js @@ -0,0 +1,486 @@ +// Xiaoe adapter contract + helper tests. +// +// The IIFEs walk Vue's private runtime tree (`__vue__`, `$store`, +// `$children`) which JSDOM cannot reproduce — testing the full IIFE +// against a frozen fixture is not viable here. Instead this file +// exercises: +// 1. The pure helpers each IIFE embeds via `${fn.toString()}` — these +// are also called from JSDOM where the surface (URL building, +// simple DOM walks) does work without a Vue runtime. +// 2. Each adapter's `func`-form wiring: upfront validation, typed +// errors on empty results, rows passed through verbatim on the +// happy path, no wasted `page.goto` on bad input. +// 3. The `build*Script` outputs: the embedded helpers must be +// present, anti-patterns from the legacy adapters (silent column +// drop, silent slice truncation) must NOT regress. + +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { + ArgumentError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { + CONTENT_SELECTORS, + contentCommand, + countXiaoeImages, + pickContentText, + requireXiaoePageUrl, + __test__ as contentTest, +} from './content.js'; +import { + buildItemUrl, + catalogCommand, + chapterUrlPath, + typeLabel, + __test__ as catalogTest, +} from './catalog.js'; +import { + buildCourseUrl, + coursesCommand, + __test__ as coursesTest, +} from './courses.js'; + +// ─── Registration contract ────────────────────────────────────────── + +describe('xiaoe — adapter registration', () => { + it('content registers as cookie + browser, with the new full column shape', () => { + const reg = getRegistry(); + expect(reg.get('xiaoe/content')).toBe(contentCommand); + expect(contentCommand.browser).toBe(true); + expect(contentCommand.strategy).toBe('cookie'); + expect(contentCommand.access).toBe('read'); + expect(contentCommand.domain).toBe('h5.xet.citv.cn'); + // `content` is the new column — restoring the silently-dropped + // text the IIFE always extracted but the legacy `columns` + // declaration discarded. + expect(contentCommand.columns).toEqual([ + 'title', 'content', 'content_length', 'image_count', + ]); + }); + + it('catalog registers as cookie + browser, columns unchanged', () => { + const reg = getRegistry(); + expect(reg.get('xiaoe/catalog')).toBe(catalogCommand); + expect(catalogCommand.browser).toBe(true); + expect(catalogCommand.strategy).toBe('cookie'); + expect(catalogCommand.access).toBe('read'); + expect(catalogCommand.columns).toEqual([ + 'ch', 'chapter', 'no', 'title', 'type', 'resource_id', 'url', 'status', + ]); + }); + + it('courses registers as cookie + browser, columns unchanged', () => { + const reg = getRegistry(); + expect(reg.get('xiaoe/courses')).toBe(coursesCommand); + expect(coursesCommand.browser).toBe(true); + expect(coursesCommand.strategy).toBe('cookie'); + expect(coursesCommand.access).toBe('read'); + expect(coursesCommand.domain).toBe('study.xiaoe-tech.com'); + expect(coursesCommand.columns).toEqual(['title', 'shop', 'url']); + }); +}); + +// ─── content.js helpers ──────────────────────────────────────────── + +describe('xiaoe/content — pickContentText', () => { + function htmlDoc(body) { + return new JSDOM(`${body}`).window.document; + } + + it('returns the first selector whose text exceeds the min-length threshold', () => { + const doc = htmlDoc(` +
${'a'.repeat(60)}
+
${'b'.repeat(120)}
+ `); + const text = pickContentText(doc, CONTENT_SELECTORS, 50); + expect(text.startsWith('a')).toBe(true); + expect(text).toHaveLength(60); + }); + + it('skips selectors whose text is at or below the threshold (legacy used > 50)', () => { + const doc = htmlDoc(` +
${'a'.repeat(50)}
+
${'b'.repeat(120)}
+ `); + const text = pickContentText(doc, CONTENT_SELECTORS, 50); + expect(text.startsWith('b')).toBe(true); + }); + + it('falls back through main → #app → body when no selector qualifies', () => { + const doc1 = htmlDoc(`
${'m'.repeat(80)}
`); + expect(pickContentText(doc1, CONTENT_SELECTORS).startsWith('m')).toBe(true); + + const doc2 = htmlDoc(`
${'p'.repeat(80)}
`); + expect(pickContentText(doc2, CONTENT_SELECTORS).startsWith('p')).toBe(true); + + const doc3 = htmlDoc(`${'q'.repeat(80)}`); + expect(pickContentText(doc3, CONTENT_SELECTORS).startsWith('q')).toBe(true); + }); + + it('returns "" when the body is genuinely empty (legitimate empty signal)', () => { + const dom = new JSDOM(''); + // Force the body to be missing (edge case JSDOM actually creates one) + const doc = dom.window.document; + // empty body → fallback chain returns '' + expect(pickContentText(doc, CONTENT_SELECTORS)).toBe(''); + }); +}); + +describe('xiaoe/content — countXiaoeImages', () => { + function imgDoc(srcs) { + const tags = srcs.map((s) => ``).join(''); + return new JSDOM(`${tags}`).window.document; + } + + it('counts only xiaoe-hosted, non-data: images', () => { + const doc = imgDoc([ + 'https://commonresource-1252524126.cdn.xiaoe-tech.com/abc.jpg', + 'data:image/png;base64,iVBOR', + 'https://other-cdn.com/foo.jpg', + 'https://app.xiaoe-tech.com/img/bar.png', + ]); + expect(countXiaoeImages(doc)).toBe(2); + }); + + it('returns 0 when every image is excluded — never silently undefined', () => { + const doc = imgDoc([ + 'data:image/png;base64,xxx', + 'https://avatar.cdn.com/u.jpg', + ]); + expect(countXiaoeImages(doc)).toBe(0); + }); + + it('returns 0 on a doc with no elements', () => { + const doc = new JSDOM('

no images here

').window.document; + expect(countXiaoeImages(doc)).toBe(0); + }); +}); + +describe('xiaoe/content — requireXiaoePageUrl', () => { + it('rejects empty, malformed, and non-xiaoe URLs upfront', () => { + expect(() => requireXiaoePageUrl('', 'content')).toThrow(ArgumentError); + expect(() => requireXiaoePageUrl('not a url', 'content')).toThrow(ArgumentError); + expect(() => requireXiaoePageUrl('http://h5.xet.citv.cn/p/course/ecourse/v_x', 'content')).toThrow(ArgumentError); + expect(() => requireXiaoePageUrl('https://example.com/p/course/ecourse/v_x', 'content')).toThrow(ArgumentError); + }); + + it('accepts root and shop h5.xet.citv.cn URLs', () => { + expect(requireXiaoePageUrl('https://h5.xet.citv.cn/p/course/ecourse/v_x', 'content')) + .toBe('https://h5.xet.citv.cn/p/course/ecourse/v_x'); + expect(requireXiaoePageUrl('https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_x', 'content')) + .toBe('https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_x'); + }); +}); + +// ─── catalog.js helpers ──────────────────────────────────────────── + +describe('xiaoe/catalog — typeLabel', () => { + it('maps known resource types to Chinese labels', () => { + expect(typeLabel(1)).toBe('图文'); + expect(typeLabel(2)).toBe('直播'); + expect(typeLabel(3)).toBe('音频'); + expect(typeLabel(4)).toBe('视频'); + expect(typeLabel(6)).toBe('专栏'); + expect(typeLabel(8)).toBe('大专栏'); + }); + + it('coerces string-typed resource type ints', () => { + expect(typeLabel('1')).toBe('图文'); + expect(typeLabel('8')).toBe('大专栏'); + }); + + it('returns the raw string for unknown types — never silently swallows', () => { + expect(typeLabel(99)).toBe('99'); + expect(typeLabel('custom')).toBe('custom'); + }); + + it('returns "" for nullish input (no falsy crash)', () => { + expect(typeLabel(null)).toBe(''); + expect(typeLabel(undefined)).toBe(''); + expect(typeLabel(0)).toBe(''); + }); +}); + +describe('xiaoe/catalog — buildItemUrl', () => { + it('passes through fully-qualified URLs unchanged', () => { + expect(buildItemUrl({ jump_url: 'https://example.com/x' }, 'https://h5.xet.citv.cn')) + .toBe('https://example.com/x'); + expect(buildItemUrl({ h5_url: 'http://x.test/p' }, 'https://h5.xet.citv.cn')) + .toBe('http://x.test/p'); + }); + + it('prepends origin to relative URLs', () => { + expect(buildItemUrl({ jump_url: '/p/123' }, 'https://h5.xet.citv.cn')) + .toBe('https://h5.xet.citv.cn/p/123'); + }); + + it('falls through jump_url → h5_url → url priority', () => { + expect(buildItemUrl({ jump_url: '/a', h5_url: '/b', url: '/c' }, 'https://x.test')) + .toBe('https://x.test/a'); + expect(buildItemUrl({ h5_url: '/b', url: '/c' }, 'https://x.test')) + .toBe('https://x.test/b'); + expect(buildItemUrl({ url: '/c' }, 'https://x.test')) + .toBe('https://x.test/c'); + }); + + it('returns "" when no URL field is present (no synthetic URL)', () => { + expect(buildItemUrl({}, 'https://x.test')).toBe(''); + expect(buildItemUrl({ jump_url: '' }, 'https://x.test')).toBe(''); + }); +}); + +describe('xiaoe/catalog — chapterUrlPath', () => { + it('returns the right path for known chapter types', () => { + expect(chapterUrlPath(1)).toBe('/v1/course/text/'); + expect(chapterUrlPath(2)).toBe('/v2/course/alive/'); + expect(chapterUrlPath(3)).toBe('/v1/course/audio/'); + expect(chapterUrlPath(4)).toBe('/v1/course/video/'); + }); + + it('coerces string ints', () => { + expect(chapterUrlPath('1')).toBe('/v1/course/text/'); + }); + + it('returns undefined for unknown / nullish — caller decides empty-URL semantics', () => { + expect(chapterUrlPath(99)).toBeUndefined(); + expect(chapterUrlPath(0)).toBeUndefined(); + expect(chapterUrlPath(null)).toBeUndefined(); + }); +}); + +// ─── courses.js helper ───────────────────────────────────────────── + +describe('xiaoe/courses — buildCourseUrl', () => { + it('returns h5_url when present (highest priority)', () => { + expect(buildCourseUrl({ + h5_url: 'https://h5.xet.citv.cn/p/abc', + app_id: 'appXXX', + resource_id: 'p_999', + })).toBe('https://h5.xet.citv.cn/p/abc'); + }); + + it('returns url when h5_url missing', () => { + expect(buildCourseUrl({ url: 'https://study.xiaoe-tech.com/u/y' })) + .toBe('https://study.xiaoe-tech.com/u/y'); + }); + + it('builds column path for resource_type 6', () => { + expect(buildCourseUrl({ + app_id: 'appAAA', + resource_id: 'p_111', + resource_type: 6, + })).toBe('https://appAAA.h5.xet.citv.cn/v1/course/column/p_111?type=3'); + }); + + it('builds ecourse path for non-column types', () => { + expect(buildCourseUrl({ + app_id: 'appAAA', + resource_id: 'p_222', + resource_type: 4, + })).toBe('https://appAAA.h5.xet.citv.cn/p/course/ecourse/p_222'); + }); + + it('returns "" when entry has no URL fields and no app_id+resource_id pair (no synthetic URL)', () => { + expect(buildCourseUrl({})).toBe(''); + expect(buildCourseUrl({ app_id: 'x' })).toBe(''); // missing resource_id + expect(buildCourseUrl({ resource_id: 'p_1' })).toBe(''); // missing app_id + expect(buildCourseUrl(null)).toBe(''); + expect(buildCourseUrl(undefined)).toBe(''); + }); +}); + +// ─── buildScript invariants (anti-pattern regression guards) ────── + +describe('xiaoe — buildScript embeds helpers + no anti-patterns', () => { + it('content script embeds pickContentText + countXiaoeImages and never silently slices images', () => { + const script = contentTest.buildContentScript(); + expect(script).toContain('pickContentText'); + expect(script).toContain('countXiaoeImages'); + // The legacy adapter did `images.slice(0, 20)` and silently + // dropped everything past the 20th image. The new script + // exposes only `image_count` (counting all images via the + // helper), so a hard `.slice(0, 20)` should not appear. + expect(script).not.toMatch(/\.slice\(0,\s*20\)/); + // Embeds the selector list literally so the IIFE has no + // dependency on the host page's globals. + expect(script).toContain('rich-text-wrap'); + }); + + it('catalog script embeds typeLabel + buildItemUrl + chapterUrlPath', () => { + const script = catalogTest.buildCatalogScript(); + expect(script).toContain('typeLabel'); + expect(script).toContain('buildItemUrl'); + expect(script).toContain('chapterUrlPath'); + // Vue private API anchors must remain — these are the only + // stable hook into Xiaoe's SPA. If a future refactor removes + // them, the test will fail and force a deliberate decision. + expect(script).toContain('__vue__'); + expect(script).toContain('$store'); + }); + + it('courses script embeds buildCourseUrl', () => { + const script = coursesTest.buildCoursesScript(); + expect(script).toContain('buildCourseUrl'); + expect(script).toContain('__vue__'); + }); +}); + +// ─── Wire tests for the func form ───────────────────────────────── + +describe('xiaoe/content — getXiaoeContent func wiring', () => { + function pageMock(rows) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(rows), + }; + } + + it('throws ArgumentError on missing url BEFORE calling page.goto', async () => { + const page = pageMock([]); + await expect(contentCommand.func(page, {})).rejects.toThrow(ArgumentError); + await expect(contentCommand.func(page, { url: '' })).rejects.toThrow(ArgumentError); + await expect(contentCommand.func(page, { url: ' ' })).rejects.toThrow(ArgumentError); + await expect(contentCommand.func(page, { url: 'https://example.com/p/x' })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('wraps browser navigation failures as CommandExecutionError', async () => { + const page = { + goto: vi.fn().mockRejectedValue(new Error('net::ERR_ABORTED')), + evaluate: vi.fn(), + }; + await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' })) + .rejects.toThrow(CommandExecutionError); + }); + + it('throws EmptyResultError when no rows are returned', async () => { + const page = pageMock([]); + await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' })) + .rejects.toThrow(EmptyResultError); + }); + + it('throws EmptyResultError when content is empty (login likely expired — fail-fast not silent empty row)', async () => { + const page = pageMock([{ title: 'shell', content: '', content_length: 0, image_count: 0 }]); + await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' })) + .rejects.toThrow(EmptyResultError); + }); + + it('throws CommandExecutionError when page.evaluate rejects', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockRejectedValue(new Error('CDP exploded')), + }; + await expect(contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' })) + .rejects.toThrow(CommandExecutionError); + }); + + it('returns the rows verbatim on the happy path', async () => { + const fakeRow = { + title: 'demo', + content: 'hello world '.repeat(10), + content_length: 120, + image_count: 3, + }; + const page = pageMock([fakeRow]); + const result = await contentCommand.func(page, { url: 'https://h5.xet.citv.cn/p/x' }); + expect(result).toEqual([fakeRow]); + expect(page.goto).toHaveBeenCalledWith( + 'https://h5.xet.citv.cn/p/x', + expect.objectContaining({ waitUntil: 'load' }), + ); + }); +}); + +describe('xiaoe/catalog — getXiaoeCatalog func wiring', () => { + function pageMock(rows) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(rows), + }; + } + + it('throws ArgumentError on missing url BEFORE calling page.goto', async () => { + const page = pageMock([]); + await expect(catalogCommand.func(page, {})).rejects.toThrow(ArgumentError); + await expect(catalogCommand.func(page, { url: 'https://example.com/p/c' })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('wraps browser navigation failures as CommandExecutionError', async () => { + const page = { + goto: vi.fn().mockRejectedValue(new Error('Execution context was destroyed')), + evaluate: vi.fn(), + }; + await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' })) + .rejects.toThrow(CommandExecutionError); + }); + + it('throws EmptyResultError when no chapters extracted (cookie likely expired)', async () => { + const page = pageMock([]); + await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' })) + .rejects.toThrow(EmptyResultError); + }); + + it('returns rows verbatim on the happy path', async () => { + const fake = [{ + ch: 1, chapter: '入门', no: 1, title: '第一节', + type: '视频', resource_id: 'v_abc', url: 'https://x.test/v/abc', status: '未学', + }]; + const page = pageMock(fake); + expect(await catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' })) + .toEqual(fake); + }); + + it('throws CommandExecutionError when page.evaluate rejects', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockRejectedValue(new Error('boom')), + }; + await expect(catalogCommand.func(page, { url: 'https://h5.xet.citv.cn/p/c' })) + .rejects.toThrow(CommandExecutionError); + }); +}); + +describe('xiaoe/courses — getXiaoeCourses func wiring', () => { + function pageMock(rows) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(rows), + }; + } + + it('navigates to the study landing page (no positional arg required)', async () => { + const fake = [{ title: 'Course A', shop: 'Shop A', url: 'https://x.test/c/a' }]; + const page = pageMock(fake); + const result = await coursesCommand.func(page, {}); + expect(page.goto).toHaveBeenCalledWith( + 'https://study.xiaoe-tech.com/', + expect.objectContaining({ waitUntil: 'load' }), + ); + expect(result).toEqual(fake); + }); + + it('throws EmptyResultError when no cards found (cookie likely expired)', async () => { + const page = pageMock([]); + await expect(coursesCommand.func(page, {})).rejects.toThrow(EmptyResultError); + }); + + it('throws CommandExecutionError when page.evaluate rejects', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockRejectedValue(new Error('cdp')), + }; + await expect(coursesCommand.func(page, {})).rejects.toThrow(CommandExecutionError); + }); + + it('wraps browser navigation failures as CommandExecutionError', async () => { + const page = { + goto: vi.fn().mockRejectedValue(new Error('net::ERR_ABORTED')), + evaluate: vi.fn(), + }; + await expect(coursesCommand.func(page, {})).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/xiaohongshu/ask.js b/clis/xiaohongshu/ask.js new file mode 100644 index 0000000..61ae2ee --- /dev/null +++ b/clis/xiaohongshu/ask.js @@ -0,0 +1,426 @@ +/** + * Xiaohongshu 点点 ask. + * + * Strategy note: + * 点点 uses Xiaohongshu's signed in-page axios + longlink/HTTP fallback stack. + * This adapter intentionally calls the site's own webpack conversation store + * from an authenticated tab instead of replaying private signatures. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; + +const XHS_WEB_HOST = 'www.xiaohongshu.com'; +const ASK_COLUMNS = [ + 'query', + 'answer', + 'source_count', + 'source_total_text', + 'sources_summary', + 'sources', + 'warning', + 'message_id', + 'conversation_id', +]; + +export function unwrapEvaluateResult(payload) { + if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} + +function parseBoundedInteger(raw, defaultValue, min, max, label) { + const value = raw ?? defaultValue; + let parsed; + if (typeof value === 'number') { + parsed = value; + } else if (typeof value === 'string' && /^\d+$/.test(value)) { + parsed = Number(value); + } else { + throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(raw)}`); + } + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(raw)}`); + } + return parsed; +} + +export function parseAskTimeout(raw) { + return parseBoundedInteger(raw, 90, 1, 180, '--timeout'); +} + +export function parseAskLimit(raw) { + return parseBoundedInteger(raw, 10, 1, 50, '--source-limit'); +} + +function cleanText(value) { + return String(value ?? '') + .replace(/<[^>]+>/g, '') + .replace(/\u200b/g, '') + .replace(/\r\n/g, '\n') + .trim(); +} + +function compactSingleLine(value) { + return cleanText(value).replace(/\s+/g, ' ').trim(); +} + +function firstNonEmpty(...values) { + for (const value of values) { + const text = compactSingleLine(value); + if (text) return text; + } + return ''; +} + +function parseCount(value) { + if (value === null || value === undefined || value === '') return null; + const toSafeCount = (number) => ( + Number.isSafeInteger(number) && number >= 0 ? number : null + ); + if (typeof value === 'number') return toSafeCount(value); + const text = String(value).trim(); + const compact = text.match(/^(\d+(?:\.\d+)?)\s*(万|[wW]|亿)\+?$/); + if (compact) { + const number = Number(compact[1]); + const multiplier = compact[2] === '亿' ? 1e8 : 1e4; + return Number.isFinite(number) ? toSafeCount(Math.round(number * multiplier)) : null; + } + const plain = text.replace(/,/g, '').replace(/\+$/, ''); + if (/^\d+$/.test(plain) && (text === plain || text === `${plain}+` || /^\d{1,3}(?:,\d{3})+\+?$/.test(text))) { + return toSafeCount(Number(plain)); + } + return null; +} + +function parseUrlMaybe(value) { + if (!value) return null; + try { + return new URL(String(value)); + } catch { + return null; + } +} + +function parseTrustedSourceLink(value) { + const raw = String(value || '').trim(); + if (!raw) return null; + const xhsDiscoverMatch = raw.match(/^xhsdiscover:\/\/item\/([0-9a-f]{24})(?=[?#/]|$)/i); + if (xhsDiscoverMatch) { + const url = parseUrlMaybe(raw); + return { + noteId: xhsDiscoverMatch[1], + xsecToken: url?.searchParams.get('xsec_token') || url?.searchParams.get('xsecToken') || '', + href: raw, + }; + } + + let url = parseUrlMaybe(raw); + if (!url && raw.startsWith('/')) { + url = parseUrlMaybe(`https://${XHS_WEB_HOST}${raw}`); + } + const host = url?.hostname.toLowerCase(); + if ( + !url + || url.protocol !== 'https:' + || (host !== XHS_WEB_HOST && host !== 'xiaohongshu.com') + ) { + return null; + } + const match = url.pathname.match(/^\/(?:explore|search_result|note)\/([0-9a-f]{24})\/?$/i); + if (!match) return null; + return { + noteId: match[1], + xsecToken: url.searchParams.get('xsec_token') || url.searchParams.get('xsecToken') || '', + href: url.toString(), + }; +} + +function extractNoteId(source) { + const directId = compactSingleLine(source?.id || source?.noteId || source?.note_id); + if (/^[0-9a-f]{24}$/i.test(directId)) return directId; + const candidates = [source?.textLink, source?.link, source?.url]; + for (const candidate of candidates) { + const parsed = parseTrustedSourceLink(candidate); + if (parsed?.noteId) return parsed.noteId; + } + return ''; +} + +export function buildNoteUrl(noteId, xsecToken) { + if (!/^[0-9a-f]{24}$/i.test(String(noteId || ''))) return ''; + const url = new URL(`https://${XHS_WEB_HOST}/explore/${noteId}`); + if (xsecToken) { + url.searchParams.set('xsec_token', xsecToken); + url.searchParams.set('xsec_source', ''); + } + return url.toString(); +} + +function extractQuote(source) { + const content = cleanText(source?.content); + if (!content) return ''; + const origin = Array.isArray(source?.originLocation) ? source.originLocation : []; + const start = Number(origin[0]); + const length = Number(origin[1]); + if (Number.isInteger(start) && start >= 0 && Number.isInteger(length) && length > 0) { + return compactSingleLine(content.slice(start, start + length)).slice(0, 300); + } + return compactSingleLine(content).slice(0, 300); +} + +export function normalizeAskSource(source, index) { + const trustedLink = [source?.textLink, source?.link, source?.url] + .map(parseTrustedSourceLink) + .find(Boolean); + const noteId = extractNoteId(source); + const xsecToken = trustedLink?.xsecToken || ''; + const normalized = { + rank: index + 1, + type: 'note', + title: firstNonEmpty(source?.title, source?.displayTitle, source?.name), + url: buildNoteUrl(noteId, xsecToken), + note_id: noteId, + xsec_token: xsecToken, + author: firstNonEmpty(source?.nickName, source?.nickname, source?.author, source?.userName), + }; + const noteType = firstNonEmpty(source?.noteType); + if (noteType) normalized.note_type = noteType; + const likeCount = parseCount(source?.like); + if (likeCount !== null) normalized.like_count = likeCount; + const userId = compactSingleLine(source?.userId); + if (userId) normalized.user_id = userId; + const publishedAt = compactSingleLine(source?.time); + if (publishedAt) normalized.published_at = publishedAt; + const quote = extractQuote(source); + if (quote) normalized.quote = quote; + if (trustedLink?.href) normalized.deeplink = trustedLink.href; + return normalized; +} + +function buildSourcesSummary(sources) { + return sources + .slice(0, 5) + .map((source) => `${source.rank}. ${source.title || source.note_id}${source.author ? ` - ${source.author}` : ''}`) + .join('\n'); +} + +export function buildAskResult(raw) { + const sourceItems = Array.isArray(raw?.sources) ? raw.sources : []; + const sources = sourceItems + .map((source, index) => normalizeAskSource(source, index)) + .filter((source) => source.note_id || source.title || source.url); + const answer = cleanText(raw?.answer || raw?.base_info?.text || ''); + const sourceIssue = compactSingleLine(raw?.warning); + const warning = sources.length === 0 && answer + ? `Xiaohongshu 点点 returned an answer but no citation sources.${sourceIssue ? ` ${sourceIssue}` : ''}` + : sourceIssue; + return { + query: compactSingleLine(raw?.query), + answer, + source_count: sources.length, + source_total_text: compactSingleLine(raw?.source_total_text), + sources_summary: buildSourcesSummary(sources), + sources, + warning, + message_id: compactSingleLine(raw?.message_id), + conversation_id: compactSingleLine(raw?.conversation_id), + }; +} + +export function buildAskEvaluateJs(query, timeoutSeconds, sourceLimit) { + const prompt = String(query); + return ` + (async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const timeoutMs = ${Number(timeoutSeconds)} * 1000; + const sourceLimit = ${Number(sourceLimit)}; + const requestSourceSize = Math.max(sourceLimit, 10); + const prompt = ${JSON.stringify(prompt)}; + const simplifyText = (value, max = 6000) => { + const text = String(value || ''); + return text.length > max ? text.slice(0, max) : text; + }; + const latestRound = (store, scenes, msgId) => { + const rounds = typeof store.getSceneRounds === 'function' + ? store.getSceneRounds(scenes.AiChat) + : (Array.isArray(store.rounds) ? store.rounds : []); + return [...rounds].reverse().find((round) => round?.aiMessage?.msgId === msgId) || null; + }; + const slimSource = (item) => ({ + id: item?.id || item?.noteId || item?.note_id || '', + title: item?.title || item?.displayTitle || '', + type: item?.type || '', + noteType: item?.noteType || '', + userId: item?.userId || '', + nickName: item?.nickName || item?.nickname || '', + content: simplifyText(item?.content || '', 2000), + originLocation: item?.originLocation || null, + textLink: item?.textLink || '', + link: item?.link || item?.imageList?.[0]?.link || '', + url: item?.url || '', + like: item?.like ?? null, + time: item?.time || '', + }); + try { + if (!window.webpackChunkxhs_pc_web) { + return { ok: false, error: 'webpack_runtime_missing', page_url: location.href }; + } + let webpackRequire; + window.webpackChunkxhs_pc_web.push([[Date.now()], {}, (req) => { webpackRequire = req; }]); + if (!webpackRequire) return { ok: false, error: 'webpack_require_unavailable', page_url: location.href }; + const mod = webpackRequire(6404); + const useConversationStore = mod?.t; + const scenes = mod?.G || { AiChat: 'aiChat' }; + if (typeof useConversationStore !== 'function') { + return { ok: false, error: 'conversation_store_missing', page_url: location.href }; + } + const store = useConversationStore(); + if (typeof store.sendMessage !== 'function' || typeof store.createConversation !== 'function') { + return { ok: false, error: 'conversation_api_missing', page_url: location.href }; + } + if (typeof store.switchScene === 'function') store.switchScene(scenes.AiChat); + if (typeof store.clearConversation === 'function') { + await Promise.resolve(store.clearConversation(scenes.AiChat)); + } + const conversationId = crypto.randomUUID(); + store.createConversation(conversationId, scenes.AiChat); + const msgId = await store.sendMessage(conversationId, prompt, { + skipImmediateUI: true, + skipHistory: true, + visible: false, + }); + if (!msgId) { + return { ok: false, error: 'send_message_failed', page_url: location.href }; + } + + const deadline = Date.now() + timeoutMs; + let round = null; + let finished = false; + while (Date.now() < deadline) { + await sleep(1000); + round = latestRound(store, scenes, msgId); + const answer = round?.aiMessage?.text || ''; + if (round?.aiMessage?.isFinished && answer) { + finished = true; + break; + } + } + round = round || latestRound(store, scenes, msgId); + const aiMessage = round?.aiMessage || {}; + const answer = aiMessage.text || (Array.isArray(aiMessage.dataFragments) + ? aiMessage.dataFragments.map((fragment) => fragment?.text || '').join('') + : ''); + if (!finished || !answer) { + return { ok: false, error: 'answer_timeout', timeout_seconds: ${Number(timeoutSeconds)}, message_id: msgId, conversation_id: conversationId }; + } + + let detail = null; + let sourceError = ''; + try { + if (store.agent?.getResponseReferences) { + for (let attempt = 0; attempt < 5; attempt++) { + detail = await store.agent.getResponseReferences({ + query: prompt, + message_id: msgId, + page: 0, + size: requestSourceSize, + version: 0, + result_version: 0, + id: '', + }); + if (Array.isArray(detail?.items) && detail.items.length > 0) break; + await sleep(1000); + } + } + } catch (err) { + sourceError = String(err?.message || err || ''); + } + + const rawSources = Array.isArray(detail?.items) ? detail.items.slice(0, sourceLimit).map(slimSource) : []; + return { + ok: true, + query: prompt, + answer, + source_total_text: detail?.baseInfo?.totalCnt || aiMessage?.querySource?.text || aiMessage?.querySource?.oneboxText || '', + sources: rawSources, + warning: sourceError, + message_id: msgId, + conversation_id: conversationId, + }; + } catch (err) { + return { + ok: false, + error: String(err?.message || err || 'unknown_error'), + stack: String(err?.stack || '').slice(0, 1500), + page_url: location.href, + }; + } + })() + `; +} + +function requirePrompt(query) { + const prompt = String(query || '').trim(); + if (!prompt) throw new ArgumentError('query is required'); + return prompt; +} + +function mapAskError(raw, timeoutSeconds) { + const error = compactSingleLine(raw?.error); + if (error === 'answer_timeout') { + throw new TimeoutError('xiaohongshu ask', timeoutSeconds, '点点没有在超时时间内返回答案;可以重试或提高 --timeout。'); + } + if (error === 'send_message_failed') { + throw new AuthRequiredError(XHS_WEB_HOST, 'Xiaohongshu 点点 did not accept the query. Check login status for www.xiaohongshu.com.'); + } + throw new CommandExecutionError( + `xiaohongshu ask failed: ${error || 'unknown error'}`, + raw?.page_url ? `Page URL: ${raw.page_url}` : undefined, + ); +} + +function requireAskPayload(raw) { + if (!raw || typeof raw !== 'object') { + throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload'); + } + const answer = cleanText(raw.answer || raw.base_info?.text || ''); + if (!answer) { + throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload: missing answer'); + } + if (!compactSingleLine(raw.message_id) || !compactSingleLine(raw.conversation_id)) { + throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload: missing message identity'); + } +} + +export const command = cli({ + site: 'xiaohongshu', + name: 'ask', + access: 'write', + description: 'Ask 小红书点点 and return the answer with citation sources.', + domain: XHS_WEB_HOST, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'query', positional: true, required: true, help: 'Question for 点点' }, + { name: 'timeout', type: 'int', default: 90, help: 'Seconds to wait for the 点点 answer' }, + { name: 'source-limit', type: 'int', default: 10, help: 'Maximum citation sources to return' }, + ], + columns: ASK_COLUMNS, + func: async (page, kwargs) => { + const query = requirePrompt(kwargs?.query); + const timeout = parseAskTimeout(kwargs?.timeout); + const sourceLimit = parseAskLimit(kwargs?.['source-limit']); + const keyword = encodeURIComponent(query); + await page.goto(`https://${XHS_WEB_HOST}/search_result?keyword=${keyword}&source=web_search_result_notes`); + await page.wait?.(1); + const raw = unwrapEvaluateResult(await page.evaluate(buildAskEvaluateJs(query, timeout, sourceLimit))); + if (!raw || typeof raw !== 'object') { + throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload'); + } + if (raw.ok === false) mapAskError(raw, timeout); + requireAskPayload(raw); + return buildAskResult(raw); + }, +}); diff --git a/clis/xiaohongshu/ask.test.js b/clis/xiaohongshu/ask.test.js new file mode 100644 index 0000000..1040bc1 --- /dev/null +++ b/clis/xiaohongshu/ask.test.js @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; +import { + buildAskResult, + buildAskEvaluateJs, + buildNoteUrl, + normalizeAskSource, + parseAskLimit, + parseAskTimeout, + unwrapEvaluateResult, +} from './ask.js'; +import './ask.js'; + +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + }; +} + +describe('xiaohongshu ask', () => { + it('registers as a browser-backed write command with clean audit columns', () => { + const cmd = getRegistry().get('xiaohongshu/ask'); + expect(cmd).toMatchObject({ + site: 'xiaohongshu', + name: 'ask', + access: 'write', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + }); + expect(cmd.columns).toEqual([ + 'query', + 'answer', + 'source_count', + 'source_total_text', + 'sources_summary', + 'sources', + 'warning', + 'message_id', + 'conversation_id', + ]); + }); + + it('rejects invalid timeout and source-limit values', () => { + expect(() => parseAskTimeout(0)).toThrow('--timeout'); + expect(() => parseAskTimeout(181)).toThrow('--timeout'); + expect(() => parseAskTimeout('1e2')).toThrow('--timeout'); + expect(() => parseAskTimeout(' 30 ')).toThrow('--timeout'); + expect(() => parseAskLimit(0)).toThrow('--source-limit'); + expect(() => parseAskLimit(51)).toThrow('--source-limit'); + expect(() => parseAskLimit('1e1')).toThrow('--source-limit'); + expect(() => parseAskLimit(' 10 ')).toThrow('--source-limit'); + }); + + it('normalizes note sources while keeping xsec_token separate from terminal summary', () => { + const source = normalizeAskSource({ + id: '69d6fc08000000001f007646', + title: '新手露营有哪些建议?', + nickName: '郑小喜', + type: 'note', + noteType: 'normal', + textLink: 'xhsdiscover://item/69d6fc08000000001f007646?xsec_token=tok%20123', + content: '第一次露营踩了不少坑,整理了这份清单,新手可以直接参考。', + originLocation: [0, 10], + }, 0); + + expect(source).toEqual({ + rank: 1, + type: 'note', + title: '新手露营有哪些建议?', + url: 'https://www.xiaohongshu.com/explore/69d6fc08000000001f007646?xsec_token=tok+123&xsec_source=', + note_id: '69d6fc08000000001f007646', + xsec_token: 'tok 123', + author: '郑小喜', + note_type: 'normal', + quote: '第一次露营踩了不少坑', + deeplink: 'xhsdiscover://item/69d6fc08000000001f007646?xsec_token=tok%20123', + }); + }); + + it('forwards 点点 engagement metadata (like_count, user_id, published_at, note_type)', () => { + const source = normalizeAskSource({ + id: '69d6fc08000000001f007646', + title: '国产猫粮怎么选', + nickName: '杨Sir宠物圈', + noteType: 'video', + userId: '589a982f3460', + time: '04-22', + like: 132, + textLink: 'xhsdiscover://item/69d6fc08000000001f007646', + }, 0); + + expect(source).toMatchObject({ + note_id: '69d6fc08000000001f007646', + note_type: 'video', + like_count: 132, + user_id: '589a982f3460', + published_at: '04-22', + }); + }); + + it('parses 万 / 亿 like strings into integers and drops empty engagement', () => { + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like: '1.2万' }, 0).like_count).toBe(12000); + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like: '1,234+' }, 0).like_count).toBe(1234); + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like: '' }, 0)).not.toHaveProperty('like_count'); + expect(normalizeAskSource({ id: '69d6fc08000000001f007646' }, 0)).not.toHaveProperty('user_id'); + }); + + it('does not coerce malformed like counts into source metadata', () => { + const malformedLikes = ['1e2', '0x10', '-1', '1.5', '1..2万', '1,23']; + for (const like of malformedLikes) { + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like }, 0)).not.toHaveProperty('like_count'); + } + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like: -1 }, 0)).not.toHaveProperty('like_count'); + expect(normalizeAskSource({ id: '69d6fc08000000001f007646', like: 1.5 }, 0)).not.toHaveProperty('like_count'); + }); + + it('builds a bare note URL when 点点 source data has no xsec token', () => { + expect(buildNoteUrl('69d6fc08000000001f007646', '')).toBe( + 'https://www.xiaohongshu.com/explore/69d6fc08000000001f007646', + ); + }); + + it('does not project untrusted citation links into source URLs or deeplinks', () => { + const source = normalizeAskSource({ + id: 'not-a-note-id', + title: '来源标题', + textLink: 'https://evil.example/explore/69d6fc08000000001f007646?xsec_token=leak', + url: 'javascript:alert(1)', + }, 0); + + expect(source).toMatchObject({ + note_id: '', + url: '', + xsec_token: '', + title: '来源标题', + }); + expect(source).not.toHaveProperty('deeplink'); + }); + + it('uses direct note identity while ignoring untrusted token links', () => { + const source = normalizeAskSource({ + id: '69d6fc08000000001f007646', + title: '来源标题', + textLink: 'https://evil.example/explore/69d6fc08000000001f007646?xsec_token=leak', + }, 0); + + expect(source).toMatchObject({ + note_id: '69d6fc08000000001f007646', + url: 'https://www.xiaohongshu.com/explore/69d6fc08000000001f007646', + xsec_token: '', + }); + expect(source).not.toHaveProperty('deeplink'); + }); + + it('keeps answer output successful when no citation sources are returned', () => { + const result = buildAskResult({ + query: '上海露营需要注意什么?', + answer: '注意天气和营地规则。', + sources: [], + source_total_text: '', + message_id: 'mid', + conversation_id: 'cid', + }); + + expect(result).toMatchObject({ + answer: '注意天气和营地规则。', + source_count: 0, + sources: [], + warning: expect.stringContaining('no citation sources'), + }); + }); + + it('unwraps Browser Bridge envelopes', () => { + expect(unwrapEvaluateResult({ session: 'site:xiaohongshu', data: { ok: true } })).toEqual({ ok: true }); + }); + + it('runs the page evaluate path and normalizes returned sources', async () => { + const cmd = getRegistry().get('xiaohongshu/ask'); + const page = createPageMock({ + ok: true, + query: '上海露营需要注意什么?', + answer: '答案正文', + source_total_text: 'ai总结54篇笔记生成', + sources: [ + { + id: '69d6fc08000000001f007646', + title: '来源标题', + nickName: '作者A', + content: '引用片段来自笔记正文', + originLocation: [0, 4], + textLink: 'xhsdiscover://item/69d6fc08000000001f007646', + }, + ], + message_id: '7650435070293180448$prod', + conversation_id: 'conversation-id', + }); + + const result = await cmd.func(page, { query: '上海露营需要注意什么?', timeout: 30, 'source-limit': 10 }); + + expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('https://www.xiaohongshu.com/search_result?keyword=')); + expect(page.evaluate.mock.calls[0][0]).toContain('window.webpackChunkxhs_pc_web'); + expect(result).toMatchObject({ + answer: '答案正文', + source_count: 1, + source_total_text: 'ai总结54篇笔记生成', + sources_summary: '1. 来源标题 - 作者A', + sources: [ + { + rank: 1, + title: '来源标题', + author: '作者A', + note_id: '69d6fc08000000001f007646', + xsec_token: '', + }, + ], + message_id: '7650435070293180448$prod', + conversation_id: 'conversation-id', + }); + expect(result).not.toHaveProperty('raw_sources'); + expect(result).not.toHaveProperty('source_error'); + }); + + it('fails closed when the page returns ok without answer identity', async () => { + const cmd = getRegistry().get('xiaohongshu/ask'); + const page = createPageMock({ + ok: true, + query: '上海露营需要注意什么?', + answer: '', + sources: [], + message_id: '', + conversation_id: 'conversation-id', + }); + + await expect(cmd.func(page, { query: '上海露营需要注意什么?', timeout: 30, 'source-limit': 10 })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('maps unfinished answer polling to timeout instead of partial success', async () => { + const cmd = getRegistry().get('xiaohongshu/ask'); + const page = createPageMock({ + ok: false, + error: 'answer_timeout', + timeout_seconds: 1, + message_id: 'mid', + conversation_id: 'cid', + }); + + await expect(cmd.func(page, { query: '上海露营需要注意什么?', timeout: 1, 'source-limit': 10 })) + .rejects.toBeInstanceOf(TimeoutError); + + const script = buildAskEvaluateJs('上海露营需要注意什么?', 1, 10); + expect(script).toContain('if (!finished || !answer)'); + expect(script).not.toContain('answer did not finish before timeout'); + }); + + it('resets the 点点 scene and only accepts the sent message id on retry', () => { + const script = buildAskEvaluateJs('上海露营需要注意什么?', 30, 10); + const resetIndex = script.indexOf('clearConversation'); + const sendIndex = script.indexOf('store.sendMessage(conversationId'); + expect(resetIndex).toBeGreaterThan(0); + expect(sendIndex).toBeGreaterThan(0); + expect(resetIndex).toBeLessThan(sendIndex); + expect(script).toContain('await Promise.resolve(store.clearConversation(scenes.AiChat))'); + expect(script).not.toContain('userMessage?.text === prompt'); + expect(script).not.toContain('rounds[rounds.length - 1]'); + }); +}); diff --git a/clis/xiaohongshu/auth.js b/clis/xiaohongshu/auth.js new file mode 100644 index 0000000..0acd4eb --- /dev/null +++ b/clis/xiaohongshu/auth.js @@ -0,0 +1,53 @@ +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; + +async function hasXhsSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://creator.xiaohongshu.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('web_session'); +} + +async function verifyXhsIdentity(page) { + await page.goto('https://creator.xiaohongshu.com/new/home'); + const payload = await page.evaluate(` + async () => { + try { + const resp = await fetch('/api/galaxy/creator/home/personal_info', { credentials: 'include' }); + const text = await resp.text(); + let parsed = null; + try { parsed = JSON.parse(text); } catch {} + return [resp.ok, resp.status, parsed, text.slice(0, 200)]; + } catch (error) { + return [false, 0, null, String(error && error.message || error)]; + } + } + `); + const [ok, status, parsed, preview] = Array.isArray(payload) ? payload : []; + if (!ok) { + const detail = parsed?.msg ?? preview ?? `HTTP ${status ?? ''}`; + throw new AuthRequiredError('creator.xiaohongshu.com', `Xiaohongshu creator profile requires login: ${detail}`); + } + const data = parsed?.data; + if (!data) { + throw new CommandExecutionError('Xiaohongshu creator profile returned malformed personal_info payload'); + } + return { + username: data.name ?? '', + followers: data.fans_count ?? 0, + }; +} + +registerSiteAuthCommands({ + site: 'xiaohongshu', + domain: 'creator.xiaohongshu.com', + loginUrl: 'https://creator.xiaohongshu.com/', + columns: ['username', 'followers'], + quickCheck: hasXhsSessionCookies, + verify: verifyXhsIdentity, + poll: async (page) => { + if (!await hasXhsSessionCookies(page)) { + throw new AuthRequiredError('creator.xiaohongshu.com', 'Waiting for Xiaohongshu session cookies'); + } + return verifyXhsIdentity(page); + }, +}); diff --git a/clis/xiaohongshu/collection-helpers.js b/clis/xiaohongshu/collection-helpers.js new file mode 100644 index 0000000..8d595db --- /dev/null +++ b/clis/xiaohongshu/collection-helpers.js @@ -0,0 +1,283 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { buildXhsNoteUrl, normalizeXhsUserId } from './user-helpers.js'; + +export const COLLECT_API_PATTERN = 'note/collect/page'; +export const LIKE_API_PATTERN = 'note/like/page'; +export const SAVED_PROFILE_TAB = 'fav'; +export const LIKED_PROFILE_TAB = 'liked'; + +export function buildProfileCollectionUrl(userId, tab) { + const cleanUserId = toCleanString(userId); + const cleanTab = toCleanString(tab); + const url = new URL(`https://www.xiaohongshu.com/user/profile/${cleanUserId}`); + url.searchParams.set('tab', cleanTab); + url.searchParams.set('subTab', 'note'); + return url.toString(); +} + +function toCleanString(value) { + return typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim(); +} + +function isObject(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +export function unwrapBrowserResult(payload) { + if (isObject(payload) && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} + +export function parseCollectionLimit(raw) { + const parsed = Number(raw ?? 20); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ArgumentError(`--limit must be an integer between 1 and 100, got ${JSON.stringify(raw)}`); + } + if (parsed < 1 || parsed > 100) { + throw new ArgumentError(`--limit must be between 1 and 100, got ${parsed}`); + } + return parsed; +} + +export function readSelfUserIdFromState(state) { + const unwrapped = unwrapBrowserResult(state); + const user = unwrapped?.user?.userInfo; + const info = user?._value ?? user ?? {}; + return toCleanString(info.user_id ?? info.userId ?? info.userID ?? ''); +} + +export function mapCollectionNote(entry, options = {}) { + if (!isObject(entry)) + return null; + const noteCard = entry.note_card ?? entry.noteCard ?? entry; + const noteId = toCleanString(entry.note_id + ?? entry.noteId + ?? entry.id + ?? noteCard.note_id + ?? noteCard.noteId + ?? noteCard.id); + if (!noteId) + return null; + const user = noteCard.user ?? entry.user ?? {}; + const userId = toCleanString(user.user_id ?? user.userId ?? ''); + const xsecToken = toCleanString(entry.xsec_token + ?? entry.xsecToken + ?? noteCard.xsec_token + ?? noteCard.xsecToken); + if (!xsecToken) + return null; + const interact = noteCard.interact_info ?? noteCard.interactInfo ?? entry.interact_info ?? entry.interactInfo ?? {}; + const url = userId + ? buildXhsNoteUrl(userId, noteId, xsecToken) + : `https://www.xiaohongshu.com/explore/${encodeURIComponent(noteId)}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_user`; + return { + id: noteId, + title: toCleanString(noteCard.display_title ?? noteCard.displayTitle ?? noteCard.title ?? entry.title ?? entry.display_title), + author: toCleanString(user.nickname ?? user.nick_name ?? user.name), + likes: toCleanString(interact.liked_count ?? interact.likedCount ?? 0) || '0', + type: toCleanString(noteCard.type ?? entry.type), + url, + }; +} + +export function extractNotesFromResponses(requests, fallbackUserId) { + const rows = []; + const seen = new Set(); + for (const req of requests ?? []) { + const payload = unwrapBrowserResult(req); + if (!isObject(payload)) { + throw new CommandExecutionError('xiaohongshu collection API returned a malformed payload'); + } + const data = payload.data; + if (!isObject(data)) { + throw new CommandExecutionError('xiaohongshu collection API returned malformed data'); + } + const notes = data.notes ?? data.note_list; + if (!Array.isArray(notes)) + throw new CommandExecutionError('xiaohongshu collection API returned malformed notes'); + for (const entry of notes) { + const row = mapCollectionNote(entry, { fallbackUserId }); + if (!row?.id || !row.url.includes('xsec_token=')) { + throw new CommandExecutionError('xiaohongshu collection API returned a note without stable id/xsec token'); + } + if (seen.has(row.id)) + continue; + seen.add(row.id); + rows.push(row); + } + } + return rows; +} + +export const EXTRACT_COLLECTION_DOM_JS = ` + (() => { + const normalizeUrl = (href) => { + if (!href) return ''; + let url; + try { + url = new URL(href, 'https://www.xiaohongshu.com'); + } catch { + return ''; + } + if (url.protocol !== 'https:' || url.hostname !== 'www.xiaohongshu.com') return ''; + if (!url.searchParams.get('xsec_token')) return ''; + return url.toString(); + }; + const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const results = []; + const seen = new Set(); + document.querySelectorAll('section.note-item').forEach((el) => { + if (el.classList.contains('query-note-item')) return; + const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span'); + const nameEl = el.querySelector('a.author .name, .author-name, .nick-name, .name'); + const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count'); + const detailLinkEl = + el.querySelector('a.cover.mask') || + el.querySelector('a[href*="/search_result/"]') || + el.querySelector('a[href*="/explore/"]') || + el.querySelector('a[href*="/note/"]') || + el.querySelector('a[href*="/user/profile/"]'); + const url = normalizeUrl(detailLinkEl?.getAttribute('href') || ''); + if (!url) return; + const noteIdMatch = url.match(/\\/(?:search_result|explore|note)\\/([0-9a-f]{24})|\\/user\\/profile\\/[^/]+\\/([0-9a-f]{24})/i); + const id = noteIdMatch?.[1] || noteIdMatch?.[2] || ''; + if (!id || seen.has(id)) return; + seen.add(id); + results.push({ + id, + title: cleanText(titleEl?.textContent || ''), + author: cleanText(nameEl?.textContent || ''), + likes: cleanText(likesEl?.textContent || '0'), + type: '', + url, + }); + }); + return results; + })() +`; + +const LOGIN_WALL_JS = ` + (() => { + const pathName = (typeof location !== 'undefined' && location.pathname) ? location.pathname : ''; + const userStore = window.__INITIAL_STATE__?.user; + const loggedInVal = userStore ? (userStore.loggedIn?._value ?? userStore.loggedIn) : undefined; + const bodyText = document.body?.innerText || ''; + return Boolean(pathName.indexOf('/login') === 0 || loggedInVal === false || /登录后|请先登录|登录后查看/.test(bodyText)); + })() +`; + +const CURRENT_LOCATION_JS = ` + (() => ({ + href: location.href, + hostname: location.hostname, + pathname: location.pathname, + }))() +`; + +async function throwIfLoginWall(page) { + const payload = unwrapBrowserResult(await page.evaluate(LOGIN_WALL_JS)); + if (payload === true) { + throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu collection page requires login; re-login to xiaohongshu.com and retry.'); + } +} + +export async function assertOnCollectionProfile(page, userId) { + await throwIfLoginWall(page); + const payload = unwrapBrowserResult(await page.evaluate(CURRENT_LOCATION_JS)); + if (!isObject(payload)) { + throw new CommandExecutionError('xiaohongshu collection page returned malformed location'); + } + const hostname = toCleanString(payload.hostname).toLowerCase(); + const pathname = toCleanString(payload.pathname); + if (hostname === 'www.xiaohongshu.com' && pathname === '/login') { + throw new AuthRequiredError('xiaohongshu collection page requires login'); + } + const expectedPath = `/user/profile/${toCleanString(userId)}`; + if (hostname !== 'www.xiaohongshu.com' || pathname !== expectedPath) { + throw new CommandExecutionError(`xiaohongshu collection landed on unexpected page: ${toCleanString(payload.href) || `${hostname}${pathname}`}`); + } +} + +async function accumulateInterceptedNotes(page, bucket, fallbackUserId) { + const reqs = await page.getInterceptedRequests(); + if (!Array.isArray(reqs)) { + throw new CommandExecutionError('xiaohongshu collection interceptor returned malformed captures'); + } + if (Array.isArray(reqs) && reqs.length > 0) + bucket.push(...reqs); + return extractNotesFromResponses(bucket, fallbackUserId); +} + +export async function resolveXhsUserId(page, rawId) { + if (rawId) + return normalizeXhsUserId(String(rawId)); + await page.goto('https://www.xiaohongshu.com/explore'); + await page.wait(2); + await throwIfLoginWall(page); + const userId = unwrapBrowserResult(await page.evaluate(`() => { + const user = window.__INITIAL_STATE__?.user?.userInfo; + const info = user?._value ?? user ?? {}; + return info.user_id || info.userId || info.userID || ''; + }`)); + const clean = toCleanString(userId); + if (!clean) { + throw new AuthRequiredError('www.xiaohongshu.com', 'Not logged into Xiaohongshu (could not resolve current user id)'); + } + return clean; +} + +export async function extractNotesFromDom(page) { + const payload = unwrapBrowserResult(await page.evaluate(EXTRACT_COLLECTION_DOM_JS)); + if (!Array.isArray(payload)) { + throw new CommandExecutionError('xiaohongshu collection DOM extraction returned malformed rows'); + } + return payload.filter((item) => item?.id); +} + +export async function fetchXhsCollectionNotes(page, { + userId, + profileTab, + apiPattern, + limit, + emptyLabel, +}) { + const capturedRequests = []; + await page.installInterceptor(apiPattern); + await page.goto(buildProfileCollectionUrl(userId, profileTab)); + await page.wait(2); + await assertOnCollectionProfile(page, userId); + let notes = []; + for (let i = 0; i < 16; i++) { + await page.wait(0.5); + notes = await accumulateInterceptedNotes(page, capturedRequests, userId); + if (notes.length > 0) + break; + } + let previousCount = notes.length; + for (let i = 0; notes.length < limit && i < 4; i += 1) { + await page.autoScroll({ times: 1, delayMs: 1500 }); + await page.wait(1); + await assertOnCollectionProfile(page, userId); + const nextNotes = await accumulateInterceptedNotes(page, capturedRequests, userId); + if (nextNotes.length > previousCount) { + notes = nextNotes; + previousCount = nextNotes.length; + continue; + } + break; + } + if (notes.length === 0) { + const domNotes = await extractNotesFromDom(page); + if (domNotes.length > 0) + notes = domNotes; + } + if (notes.length === 0) { + throw new EmptyResultError('xiaohongshu collection', `No ${emptyLabel} notes found. Ensure you are logged in and this profile tab is visible.`); + } + return notes.slice(0, limit).map((item, index) => ({ + rank: index + 1, + ...item, + })); +} diff --git a/clis/xiaohongshu/collection-helpers.test.js b/clis/xiaohongshu/collection-helpers.test.js new file mode 100644 index 0000000..14d2eb0 --- /dev/null +++ b/clis/xiaohongshu/collection-helpers.test.js @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { + COLLECT_API_PATTERN, + LIKE_API_PATTERN, + LIKED_PROFILE_TAB, + SAVED_PROFILE_TAB, + buildProfileCollectionUrl, + extractNotesFromResponses, + mapCollectionNote, + parseCollectionLimit, + readSelfUserIdFromState, +} from './collection-helpers.js'; + +describe('xiaohongshu collection helpers', () => { + it('reads the logged-in user id from INITIAL_STATE', () => { + expect(readSelfUserIdFromState({ + user: { + userInfo: { + _value: { user_id: 'abc123' }, + }, + }, + })).toBe('abc123'); + }); + + it('unwraps Browser Bridge envelopes when reading logged-in user id', () => { + expect(readSelfUserIdFromState({ + session: 's1', + data: { + user: { + userInfo: { userId: 'self-user' }, + }, + }, + })).toBe('self-user'); + }); + + it('validates collection limits instead of silently clamping', () => { + expect(parseCollectionLimit('20')).toBe(20); + expect(() => parseCollectionLimit(0)).toThrow(/between 1 and 100/); + expect(() => parseCollectionLimit(101)).toThrow(/between 1 and 100/); + expect(() => parseCollectionLimit('1.5')).toThrow(/integer/); + }); + + it('maps collect API notes with xsec_token into profile URLs', () => { + const row = mapCollectionNote({ + note_id: '662908190000000001007366', + xsec_token: 'token-1', + note_card: { + display_title: 'Saved note', + type: 'normal', + user: { user_id: 'user-1', nickname: 'Alice' }, + interact_info: { liked_count: '12' }, + }, + }, { fallbackUserId: 'fallback' }); + expect(row).toMatchObject({ + id: '662908190000000001007366', + title: 'Saved note', + author: 'Alice', + likes: '12', + type: 'normal', + url: 'https://www.xiaohongshu.com/user/profile/user-1/662908190000000001007366?xsec_token=token-1&xsec_source=pc_user', + }); + }); + + it('dedupes notes across multiple intercepted responses', () => { + const requests = [ + { + data: { + notes: [ + { + note_id: 'note-1', + xsec_token: 'tok-1', + title: 'First', + user: { nickname: 'A' }, + interact_info: { liked_count: '1' }, + }, + ], + }, + }, + { + data: { + notes: [ + { + note_id: 'note-1', + xsec_token: 'tok-1b', + title: 'First duplicate', + user: { nickname: 'A' }, + interact_info: { liked_count: '1' }, + }, + { + note_id: 'note-2', + xsec_token: 'tok-2', + title: 'Second', + user: { nickname: 'B' }, + interact_info: { liked_count: '2' }, + }, + ], + }, + }, + ]; + const rows = extractNotesFromResponses(requests, 'self'); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.id)).toEqual(['note-1', 'note-2']); + }); + + it('fails closed when intercepted collection payload shape is malformed', () => { + expect(() => extractNotesFromResponses([{ data: { notes: null } }], 'self')) + .toThrow(CommandExecutionError); + expect(() => extractNotesFromResponses([{ data: { notes: [{ note_id: 'note-1' }] } }], 'self')) + .toThrow(/stable id\/xsec token/); + }); + + it('uses the expected API patterns', () => { + expect(COLLECT_API_PATTERN).toBe('note/collect/page'); + expect(LIKE_API_PATTERN).toBe('note/like/page'); + }); + + it('builds profile URLs for saved and liked tabs', () => { + const userId = '66c876f7000000001d023624'; + expect(buildProfileCollectionUrl(userId, SAVED_PROFILE_TAB)) + .toBe('https://www.xiaohongshu.com/user/profile/66c876f7000000001d023624?tab=fav&subTab=note'); + expect(buildProfileCollectionUrl(userId, LIKED_PROFILE_TAB)) + .toBe('https://www.xiaohongshu.com/user/profile/66c876f7000000001d023624?tab=liked&subTab=note'); + }); +}); diff --git a/clis/xiaohongshu/comments.js b/clis/xiaohongshu/comments.js new file mode 100644 index 0000000..5865f7e --- /dev/null +++ b/clis/xiaohongshu/comments.js @@ -0,0 +1,228 @@ +/** + * Xiaohongshu comments — DOM extraction from note detail page. + * XHS API requires signed requests, so we scrape the rendered DOM instead. + * + * Supports both top-level comments and nested replies (楼中楼) via + * the --with-replies flag. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors'; +import { parseNoteId, buildNoteUrl } from './note-helpers.js'; + +const XHS_PROFILE_HREF_SELECTOR = '.author-wrapper a[href*="/user/profile/"], a.name[href*="/user/profile/"], a.user-name[href*="/user/profile/"], a[href*="/user/profile/"]'; + +export function parseXhsProfileHref(href, webHost = 'www.xiaohongshu.com') { + const raw = typeof href === 'string' ? href.trim() : ''; + if (!raw) + return ''; + const expectedHost = String(webHost || 'www.xiaohongshu.com').toLowerCase(); + let parsed; + try { + parsed = new URL(raw, `https://${expectedHost}`); + } + catch { + return ''; + } + if (parsed.protocol !== 'https:') + return ''; + const host = parsed.hostname.toLowerCase(); + if (host !== expectedHost) + return ''; + const match = parsed.pathname.match(/^\/user\/profile\/([a-zA-Z0-9]+)\/?$/); + return match?.[1] ?? ''; +} + +export function buildXhsProfileUrl(href, webHost = 'www.xiaohongshu.com') { + const userId = parseXhsProfileHref(href, webHost); + if (!userId) + return ''; + return `https://${webHost}/user/profile/${userId}`; +} +export function parseCommentLimit(raw, fallback = 20) { + const n = Number(raw); + if (!Number.isFinite(n)) + return fallback; + return Math.max(1, Math.min(Math.floor(n), 50)); +} + +export function parseXhsLikeCountText(value) { + const integerRe = /^(?:\d+|\d{1,3}(?:[,,]\d{3})+)\+?$/u; + const shortformRe = /^((?:\d+|\d{1,3}(?:[,,]\d{3})+)(?:\.\d+)?)([wWkK万千])\+?$/u; + const raw = String(value ?? '').replace(/\s+/g, ''); + if (!raw) + return 0; + if (integerRe.test(raw)) + return Number(raw.replace(/[,+,]/g, '')); + const short = raw.match(shortformRe); + if (!short) + return 0; + const numeric = Number(short[1].replace(/[,,]/g, '')); + if (!Number.isFinite(numeric)) + return 0; + const unit = short[2].toLowerCase(); + const multiplier = unit === 'w' || unit === '万' ? 10000 : 1000; + return Math.round(numeric * multiplier); +} + +/** + * Host-agnostic IIFE that scrolls a note's comment list and extracts + * top-level comments (and optionally nested 楼中楼 replies). Exported so + * the rednote adapter can reuse the exact same selector chain. + */ +export function buildCommentsExtractJs(withReplies) { + const parseLikeCountText = parseXhsLikeCountText.toString(); + return ` + (async () => { + const wait = (ms) => new Promise(r => setTimeout(r, ms)) + const withReplies = ${withReplies} + + // Check login state + const bodyText = document.body?.innerText || '' + const loginWall = /登录后查看|请登录/.test(bodyText) + const securityBlock = /安全限制|访问链接异常/.test(bodyText) + || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href) + + // Scroll the note container to trigger comment loading + const scroller = document.querySelector('.note-scroller') || document.querySelector('.container') + if (scroller) { + for (let i = 0; i < 3; i++) { + const beforeCount = scroller.querySelectorAll('.parent-comment').length + scroller.scrollTo(0, scroller.scrollHeight) + await wait(800 + Math.random() * 1200) + const afterCount = scroller.querySelectorAll('.parent-comment').length + if (afterCount <= beforeCount) break + } + } + + const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim() + const parseLikeCountText = ${parseLikeCountText} + const parseLikes = (el) => { + return parseLikeCountText(clean(el)) + } + const HREF_SELECTOR = ${JSON.stringify(XHS_PROFILE_HREF_SELECTOR)} + const extractAuthorHref = (el) => { + if (!el) return '' + const anchor = el.querySelector(HREF_SELECTOR) + return anchor ? (anchor.getAttribute('href') || '') : '' + } + const expandReplyThreads = async (root) => { + if (!withReplies || !root) return + const clickedTexts = new Set() + for (let round = 0; round < 3; round++) { + const expanders = Array.from(root.querySelectorAll('button, [role="button"], span, div')).filter(el => { + if (!(el instanceof HTMLElement)) return false + const text = clean(el) + if (!text || text.length > 24) return false + if (!/(展开|更多回复|全部回复|查看.*回复|共\\d+条回复)/.test(text)) return false + if (clickedTexts.has(text)) return false + return true + }) + if (!expanders.length) break + for (const el of expanders) { + const text = clean(el) + el.click() + clickedTexts.add(text) + await wait(200 + Math.random() * 300) + } + } + } + + const results = [] + const parents = document.querySelectorAll('.parent-comment') + for (const p of parents) { + const item = p.querySelector('.comment-item') + if (!item) continue + + const author = clean(item.querySelector('.author-wrapper .name, .user-name')) + const authorHrefRaw = extractAuthorHref(item) + const text = clean(item.querySelector('.content, .note-text')) + const likes = parseLikes(item.querySelector('.count')) + const time = clean(item.querySelector('.date, .time')) + + if (!text) continue + results.push({ author, authorHrefRaw, text, likes, time, is_reply: false, reply_to: '' }) + + // Extract nested replies (楼中楼) + if (withReplies) { + await expandReplyThreads(p) + p.querySelectorAll('.reply-container .comment-item-sub, .sub-comment-list .comment-item').forEach(sub => { + const sAuthor = clean(sub.querySelector('.name, .user-name')) + const sAuthorHrefRaw = extractAuthorHref(sub) + const sText = clean(sub.querySelector('.content, .note-text')) + const sLikes = parseLikes(sub.querySelector('.count')) + const sTime = clean(sub.querySelector('.date, .time')) + if (!sText) return + results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author }) + }) + } + } + + return { pageUrl: location.href, securityBlock, loginWall, results } + })() + `; +} +export const command = cli({ + site: 'xiaohongshu', + name: 'comments', + access: 'read', + description: '获取小红书笔记评论(支持楼中楼子回复)', + domain: 'www.xiaohongshu.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' }, + { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' }, + ], + columns: ['rank', 'author', 'userId', 'profileUrl', 'text', 'likes', 'time', 'is_reply', 'reply_to'], + func: async (page, kwargs) => { + const limit = parseCommentLimit(kwargs.limit); + const withReplies = Boolean(kwargs['with-replies']); + const raw = String(kwargs['note-id']); + const noteId = parseNoteId(raw); + await page.goto(buildNoteUrl(raw, { commandName: 'xiaohongshu comments' })); + await page.wait({ time: 2 + Math.random() * 3 }); + const data = await page.evaluate(buildCommentsExtractJs(withReplies)); + if (!data || typeof data !== 'object') { + throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response'); + } + if (data.securityBlock) { + throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw) + ? 'The page may be temporarily restricted. Try again later or from a different session.' + : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.'); + } + if (data.loginWall) { + throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login'); + } + // noteId currently unused after parsing — kept for symmetry with the note command + void noteId; + const all = data.results ?? []; + // authorHrefRaw is a raw transport field from the extractor; it is consumed + // here into userId / profileUrl and intentionally not part of the row shape. + const enrich = (c, i) => ({ + rank: i + 1, + author: c.author, + userId: c.authorHrefRaw ? parseXhsProfileHref(c.authorHrefRaw) : '', + profileUrl: c.authorHrefRaw ? buildXhsProfileUrl(c.authorHrefRaw) : '', + text: c.text, + likes: c.likes, + time: c.time, + is_reply: c.is_reply, + reply_to: c.reply_to, + }); + // When limiting, count only top-level comments; their replies are included for free + if (withReplies) { + const limited = []; + let topCount = 0; + for (const c of all) { + if (!c.is_reply) + topCount++; + if (topCount > limit) + break; + limited.push(c); + } + return limited.map(enrich); + } + return all.slice(0, limit).map(enrich); + }, +}); diff --git a/clis/xiaohongshu/comments.test.js b/clis/xiaohongshu/comments.test.js new file mode 100644 index 0000000..15a536d --- /dev/null +++ b/clis/xiaohongshu/comments.test.js @@ -0,0 +1,320 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { buildCommentsExtractJs, buildXhsProfileUrl, parseXhsLikeCountText, parseXhsProfileHref } from './comments.js'; +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + }; +} + +async function runCommentsExtract(html) { + const dom = new JSDOM(html, { url: 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' }); + const previousDocument = globalThis.document; + const previousLocation = globalThis.location; + globalThis.document = dom.window.document; + globalThis.location = dom.window.location; + try { + return await eval(buildCommentsExtractJs(false)); + } finally { + globalThis.document = previousDocument; + globalThis.location = previousLocation; + } +} + +describe('parseXhsLikeCountText', () => { + it('parses exact integer and shortform like counts', () => { + expect(parseXhsLikeCountText('0')).toBe(0); + expect(parseXhsLikeCountText('42')).toBe(42); + expect(parseXhsLikeCountText('1,234')).toBe(1234); + expect(parseXhsLikeCountText('1,234+')).toBe(1234); + expect(parseXhsLikeCountText('2.1w')).toBe(21000); + expect(parseXhsLikeCountText('1.5万')).toBe(15000); + expect(parseXhsLikeCountText('1.2k')).toBe(1200); + expect(parseXhsLikeCountText('3千')).toBe(3000); + expect(parseXhsLikeCountText(' 2.1 w + ')).toBe(21000); + }); + + it('returns 0 for unknown shapes without overparsing arbitrary text', () => { + for (const raw of ['', null, undefined, '赞', 'likes 2.1w', '2w人', '1,23', '1.2.3k', '.', '1.5']) { + expect(parseXhsLikeCountText(raw)).toBe(0); + } + }); +}); + +describe('xiaohongshu comments', () => { + const command = getRegistry().get('xiaohongshu/comments'); + it('returns ranked comment rows for signed full URLs', async () => { + const page = createPageMock({ + loginWall: false, + results: [ + { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' }, + { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' }, + ], + }); + const signedUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search'; + const result = (await command.func(page, { 'note-id': signedUrl, limit: 5 })); + expect(page.goto.mock.calls[0][0]).toBe(signedUrl); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 }); + expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 }); + }); + it('rejects bare note IDs before browser navigation', async () => { + const page = createPageMock({ loginWall: false, results: [] }); + await expect(command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })).rejects.toMatchObject({ + code: 'ARGUMENT', + message: expect.stringContaining('signed URL'), + hint: expect.stringContaining('xsec_token'), + }); + expect(page.goto).not.toHaveBeenCalled(); + }); + it('preserves signed /explore/ URL as-is for navigation', async () => { + const page = createPageMock({ + loginWall: false, + results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }], + }); + await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search', + limit: 5, + }); + expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131?xsec_token=abc'); + }); + it('preserves full search_result URL with xsec_token for navigation', async () => { + const page = createPageMock({ + loginWall: false, + results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }], + }); + const fullUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search'; + await command.func(page, { 'note-id': fullUrl, limit: 5 }); + expect(page.goto.mock.calls[0][0]).toBe(fullUrl); + }); + it('preserves signed /user/profile// URLs for navigation', async () => { + const page = createPageMock({ + loginWall: false, + results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }], + }); + const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_user'; + await command.func(page, { 'note-id': fullUrl, limit: 5 }); + expect(page.goto.mock.calls[0][0]).toBe(fullUrl); + }); + it('throws AuthRequiredError when login wall is detected', async () => { + const page = createPageMock({ loginWall: true, results: [] }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: 5, + })).rejects.toThrow('Note comments require login'); + }); + it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031', + securityBlock: true, + loginWall: false, + results: [], + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search', + limit: 5, + })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + hint: expect.stringContaining('Try again later'), + }); + }); + it('returns empty array when no comments are found', async () => { + const page = createPageMock({ loginWall: false, results: [] }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: 5, + })).resolves.toEqual([]); + }); + it('uses condition-based comment scrolling instead of a fixed blind loop', async () => { + const page = createPageMock({ loginWall: false, results: [] }); + await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: 5, + }); + const script = page.evaluate.mock.calls[0][0]; + expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length"); + expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length"); + expect(script).toContain('if (afterCount <= beforeCount) break'); + }); + it('extracts shortform like counts from the shared xiaohongshu/rednote DOM script', async () => { + const data = await runCommentsExtract(` +
+
+
+
Alice
+
Great note
+ 2.1w + today +
+
+
+
+ Bob +
Malformed count
+ likes 2.1w +
+
+
+ `); + + expect(data.results).toEqual([ + { author: 'Alice', authorHrefRaw: '', text: 'Great note', likes: 21000, time: 'today', is_reply: false, reply_to: '' }, + { author: 'Bob', authorHrefRaw: '', text: 'Malformed count', likes: 0, time: '', is_reply: false, reply_to: '' }, + ]); + }); + it('extracts authorHrefRaw from /user/profile/ anchor wrapping the name', async () => { + const data = await runCommentsExtract(` +
+
+
+ +
Hi
+ 1 + today +
+
+
+
+ Bob +
Hey
+
+
+
+ `); + expect(data.results[0].author).toBe('Alice'); + expect(data.results[0].authorHrefRaw).toBe('/user/profile/5e8a1b2c3d4e5f6a7b8c9d0e?xsec_token=tok'); + expect(data.results[1].author).toBe('Bob'); + expect(data.results[1].authorHrefRaw).toBe('https://www.xiaohongshu.com/user/profile/abc123def456'); + }); + it('respects the limit for top-level comments', async () => { + const manyComments = Array.from({ length: 10 }, (_, i) => ({ + author: `User${i}`, + text: `Comment ${i}`, + likes: i, + time: '2024-01-01', + is_reply: false, + reply_to: '', + })); + const page = createPageMock({ loginWall: false, results: manyComments }); + const result = (await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: 3, + })); + expect(result).toHaveLength(3); + expect(result[0].rank).toBe(1); + expect(result[2].rank).toBe(3); + }); + it('enriches each row with userId and profileUrl derived from authorHrefRaw', async () => { + const page = createPageMock({ + loginWall: false, + results: [ + { author: 'Alice', authorHrefRaw: '/user/profile/abc123?xsec_token=tok', text: 'hi', likes: 1, time: 't', is_reply: false, reply_to: '' }, + { author: 'Bob', authorHrefRaw: 'https://www.xiaohongshu.com/user/profile/xyz789', text: 'hey', likes: 0, time: '', is_reply: false, reply_to: '' }, + { author: 'Anon', authorHrefRaw: '', text: 'no link', likes: 0, time: '', is_reply: false, reply_to: '' }, + ], + }); + const result = (await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: 5, + })); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', userId: 'abc123', profileUrl: 'https://www.xiaohongshu.com/user/profile/abc123' }); + expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', userId: 'xyz789', profileUrl: 'https://www.xiaohongshu.com/user/profile/xyz789' }); + expect(result[2]).toMatchObject({ rank: 3, author: 'Anon', userId: '', profileUrl: '' }); + // the raw transport field must not leak into the final row shape + for (const row of result) { + expect(row).not.toHaveProperty('authorHrefRaw'); + expect(row).not.toHaveProperty('authorHref'); + } + }); + it('buildXhsProfileUrl handles trusted relative/absolute inputs and rejects host/path drift', () => { + expect(parseXhsProfileHref('/user/profile/abc123')).toBe('abc123'); + expect(parseXhsProfileHref('https://www.xiaohongshu.com/user/profile/xyz?xsec_token=tok')).toBe('xyz'); + expect(buildXhsProfileUrl('/user/profile/abc123')).toBe('https://www.xiaohongshu.com/user/profile/abc123'); + expect(buildXhsProfileUrl('https://www.xiaohongshu.com/user/profile/xyz?xsec_token=tok')).toBe('https://www.xiaohongshu.com/user/profile/xyz'); + expect(buildXhsProfileUrl('')).toBe(''); + expect(buildXhsProfileUrl(null)).toBe(''); + expect(buildXhsProfileUrl('/user/profile/zzz', 'www.rednote.com')).toBe('https://www.rednote.com/user/profile/zzz'); + expect(buildXhsProfileUrl('http://www.xiaohongshu.com/user/profile/abc123')).toBe(''); + expect(buildXhsProfileUrl('https://evil.test/user/profile/abc123')).toBe(''); + expect(buildXhsProfileUrl('https://www.xiaohongshu.com/user/profile/abc123/extra')).toBe(''); + expect(buildXhsProfileUrl('/user/profile/abc123/extra')).toBe(''); + expect(buildXhsProfileUrl('https://www.rednote.com/user/profile/zzz', 'www.rednote.com')).toBe('https://www.rednote.com/user/profile/zzz'); + expect(buildXhsProfileUrl('https://www.xiaohongshu.com/user/profile/zzz', 'www.rednote.com')).toBe(''); + }); + it('clamps invalid negative limits to a safe minimum', async () => { + const page = createPageMock({ + loginWall: false, + results: [ + { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' }, + { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' }, + ], + }); + const result = (await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', + limit: -3, + })); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' }); + }); + describe('--with-replies', () => { + it('includes reply rows with is_reply=true and reply_to set', async () => { + const page = createPageMock({ + loginWall: false, + results: [ + { author: 'Alice', text: 'Main comment', likes: 10, time: '03-25', is_reply: false, reply_to: '' }, + { author: 'Bob', text: 'Reply to Alice', likes: 3, time: '03-25', is_reply: true, reply_to: 'Alice' }, + { author: 'Carol', text: 'Another top', likes: 5, time: '03-26', is_reply: false, reply_to: '' }, + ], + }); + const result = (await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 50, 'with-replies': true, + })); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' }); + expect(result[1]).toMatchObject({ author: 'Bob', is_reply: true, reply_to: 'Alice' }); + expect(result[2]).toMatchObject({ author: 'Carol', is_reply: false, reply_to: '' }); + const script = page.evaluate.mock.calls[0][0]; + expect(script).toContain('共\\d+条回复'); + expect(script).toContain('el.click()'); + }); + it('limits by top-level count, keeping attached replies', async () => { + const page = createPageMock({ + loginWall: false, + results: [ + { author: 'A', text: 'Top 1', likes: 0, time: '', is_reply: false, reply_to: '' }, + { author: 'A1', text: 'Reply 1', likes: 0, time: '', is_reply: true, reply_to: 'A' }, + { author: 'A2', text: 'Reply 2', likes: 0, time: '', is_reply: true, reply_to: 'A' }, + { author: 'B', text: 'Top 2', likes: 0, time: '', is_reply: false, reply_to: '' }, + { author: 'C', text: 'Top 3', likes: 0, time: '', is_reply: false, reply_to: '' }, + ], + }); + // Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows + const result = (await command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 2, 'with-replies': true, + })); + expect(result).toHaveLength(4); + expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']); + }); + }); +}); diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js new file mode 100644 index 0000000..876e296 --- /dev/null +++ b/clis/xiaohongshu/creator-note-detail.js @@ -0,0 +1,482 @@ +/** + * Xiaohongshu Creator Note Detail — per-note analytics from the creator detail page. + * + * The current creator center no longer serves stable single-note metrics from the legacy + * `/api/galaxy/creator/data/note_detail` endpoint. The real note detail page loads data + * through the newer `datacenter/note/*` API family, so this command navigates to the + * detail page and parses the rendered metrics that are backed by those APIs. + * + * Requires: logged into creator.xiaohongshu.com in Chrome. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/; +const NOTE_DETAIL_METRICS = [ + { label: '曝光数', section: '基础数据' }, + { label: '观看数', section: '基础数据' }, + { label: '封面点击率', section: '基础数据' }, + { label: '平均观看时长', section: '基础数据' }, + { label: '涨粉数', section: '基础数据' }, + { label: '点赞数', section: '互动数据' }, + { label: '评论数', section: '互动数据' }, + { label: '收藏数', section: '互动数据' }, + { label: '分享数', section: '互动数据' }, +]; +const NOTE_DETAIL_METRIC_LABELS = new Set(NOTE_DETAIL_METRICS.map((metric) => metric.label)); +const NOTE_DETAIL_SECTIONS = new Set(NOTE_DETAIL_METRICS.map((metric) => metric.section)); +const NOTE_DETAIL_NOISE_LINES = new Set([ + '切换笔记', + '笔记诊断', + '核心数据', + '观看来源', + '观众画像', + '提升建议', + '基础数据', + '互动数据', + '导出数据', + '实时', + '按小时', + '按天', +]); +function findNoteTitle(lines) { + const detailIndex = lines.indexOf('笔记数据详情'); + if (detailIndex < 0) + return ''; + for (let i = detailIndex + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line || line.startsWith('#') || NOTE_DETAIL_DATETIME_RE.test(line)) + continue; + if (NOTE_DETAIL_NOISE_LINES.has(line)) + continue; + return line; + } + return ''; +} +function findMetricValue(lines, startIndex) { + let value = ''; + let extra = ''; + for (let i = startIndex + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line) + continue; + if (NOTE_DETAIL_METRIC_LABELS.has(line)) + break; + if (NOTE_DETAIL_NOISE_LINES.has(line) || line.startsWith('数据更新至') || line.startsWith('部分数据统计中')) + continue; + if (!value) { + value = line; + continue; + } + if (!extra && line.startsWith('粉丝')) { + extra = line; + break; + } + if (line === '0' || /^\d/.test(line) || line.endsWith('%') || line.endsWith('秒')) { + break; + } + } + return { value, extra }; +} +function findPublishedAt(text) { + const match = text.match(/\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/); + return match?.[0] ?? ''; +} +export function parseCreatorNoteDetailText(bodyText, noteId) { + const lines = bodyText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const title = findNoteTitle(lines); + const publishedAt = lines.find((line) => NOTE_DETAIL_DATETIME_RE.test(line)) ?? ''; + const rows = [ + { section: '笔记信息', metric: 'note_id', value: noteId, extra: '' }, + { section: '笔记信息', metric: 'title', value: title, extra: '' }, + { section: '笔记信息', metric: 'published_at', value: publishedAt, extra: '' }, + ]; + for (const metric of NOTE_DETAIL_METRICS) { + const index = lines.indexOf(metric.label); + if (index < 0) + continue; + const { value, extra } = findMetricValue(lines, index); + rows.push({ + section: metric.section, + metric: metric.label, + value, + extra, + }); + } + return rows; +} +export function parseCreatorNoteDetailDomData(dom, noteId) { + if (!dom) + return []; + const title = typeof dom.title === 'string' ? dom.title.trim() : ''; + const infoText = typeof dom.infoText === 'string' ? dom.infoText : ''; + const sections = Array.isArray(dom.sections) ? dom.sections : []; + const rows = [ + { section: '笔记信息', metric: 'note_id', value: noteId, extra: '' }, + { section: '笔记信息', metric: 'title', value: title, extra: '' }, + { section: '笔记信息', metric: 'published_at', value: findPublishedAt(infoText), extra: '' }, + ]; + for (const section of sections) { + if (!NOTE_DETAIL_SECTIONS.has(section.title)) + continue; + for (const metric of section.metrics) { + if (!NOTE_DETAIL_METRIC_LABELS.has(metric.label)) + continue; + rows.push({ + section: section.title, + metric: metric.label, + value: metric.value, + extra: metric.extra, + }); + } + } + const hasMetric = rows.some((row) => row.section !== '笔记信息' && row.value); + return hasMetric ? rows : []; +} +function toPercentString(value) { + return value == null ? '' : `${value}%`; +} +function appendAudienceSourceRows(rows, payload) { + const sourceItems = payload?.audienceSource?.source ?? []; + for (const item of sourceItems) { + if (!item.title) + continue; + const extras = []; + if (item.info?.imp_count != null) + extras.push(`曝光 ${item.info.imp_count}`); + if (item.info?.view_count != null) + extras.push(`观看 ${item.info.view_count}`); + if (item.info?.interaction_count != null) + extras.push(`互动 ${item.info.interaction_count}`); + rows.push({ + section: '观看来源', + metric: item.title, + value: toPercentString(item.value_with_double), + extra: extras.join(' · '), + }); + } + return rows; +} +function appendAudiencePortraitGroup(rows, groupLabel, items) { + for (const item of items ?? []) { + if (!item.title) + continue; + rows.push({ + section: '观众画像', + metric: `${groupLabel}/${item.title}`, + value: toPercentString(item.value), + extra: '', + }); + } + return rows; +} +export function appendAudienceRows(rows, payload) { + appendAudienceSourceRows(rows, payload); + appendAudiencePortraitGroup(rows, '性别', payload?.audienceSourceDetail?.gender); + appendAudiencePortraitGroup(rows, '年龄', payload?.audienceSourceDetail?.age); + appendAudiencePortraitGroup(rows, '城市', payload?.audienceSourceDetail?.city); + appendAudiencePortraitGroup(rows, '兴趣', payload?.audienceSourceDetail?.interest); + return rows; +} +function formatTrendTimestamp(ts, granularity) { + if (!ts) + return ''; + // Use fixed UTC+8 offset to ensure consistent output regardless of CI server timezone. + const CST_OFFSET_MS = 8 * 60 * 60 * 1000; + const cstDate = new Date(ts + CST_OFFSET_MS); + const pad = (value) => String(value).padStart(2, '0'); + if (granularity === 'hour') { + return `${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())} ${pad(cstDate.getUTCHours())}:00`; + } + return `${cstDate.getUTCFullYear()}-${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())}`; +} +function formatTrendSeries(points, granularity) { + if (!points?.length) + return ''; + return points + .map((point) => { + const label = formatTrendTimestamp(point.date, granularity); + const value = point.count_with_double ?? point.count; + return label && value != null ? `${label}=${value}` : ''; + }) + .filter(Boolean) + .join(' | '); +} +const TREND_SERIES_CONFIG = [ + { key: 'imp_list', label: '曝光数' }, + { key: 'view_list', label: '观看数' }, + { key: 'view_time_list', label: '平均观看时长' }, + { key: 'like_list', label: '点赞数' }, + { key: 'comment_list', label: '评论数' }, + { key: 'collect_list', label: '收藏数' }, + { key: 'share_list', label: '分享数' }, + { key: 'rise_fans_list', label: '涨粉数' }, +]; +export function appendTrendRows(rows, payload) { + if (payload?.audienceTrend?.no_data_tip_msg) { + rows.push({ + section: '趋势说明', + metric: '观众趋势', + value: payload.audienceTrend.no_data ? '暂不可用' : '可用', + extra: payload.audienceTrend.no_data_tip_msg, + }); + } + const buckets = [ + { label: '按小时', granularity: 'hour', data: payload?.noteBase?.hour }, + { label: '按天', granularity: 'day', data: payload?.noteBase?.day }, + ]; + for (const bucket of buckets) { + for (const series of TREND_SERIES_CONFIG) { + const points = bucket.data?.[series.key]; + const formatted = formatTrendSeries(points, bucket.granularity); + if (!formatted) + continue; + rows.push({ + section: '趋势数据', + metric: `${bucket.label}/${series.label}`, + value: `${points.length} points`, + extra: formatted, + }); + } + } + return rows; +} +const DETAIL_API_ENDPOINTS = [ + { suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' }, + { suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' }, + { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' }, + { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' }, +]; +const CAPTURE_POLL_ATTEMPTS = 20; +const CAPTURE_POLL_INTERVAL_S = 0.5; +function detailApiEndpointForUrl(url) { + if (!url) + return null; + try { + const parsed = new URL(String(url), 'https://creator.xiaohongshu.com'); + return DETAIL_API_ENDPOINTS.find((endpoint) => parsed.pathname === endpoint.suffix) ?? null; + } + catch { + return null; + } +} +function findCapturedUrl(captureMap, suffix) { + return Object.keys(captureMap).find((url) => detailApiEndpointForUrl(url)?.suffix === suffix); +} +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +function assertOptionalArray(payload, key, suffix) { + if (key in payload && !Array.isArray(payload[key])) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`); + } +} +function assertOptionalPlainObject(payload, key, suffix) { + if (key in payload && !isPlainObject(payload[key])) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`); + } +} +function validateCapturedPayload(payload, endpoint) { + const suffix = endpoint.suffix; + if (!isPlainObject(payload)) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a malformed payload`); + } + if (endpoint.key === 'noteBase') { + assertOptionalPlainObject(payload, 'hour', suffix); + assertOptionalPlainObject(payload, 'day', suffix); + } + if (endpoint.key === 'audienceSource') { + assertOptionalArray(payload, 'source', suffix); + } + if (endpoint.key === 'audienceSourceDetail') { + for (const key of ['gender', 'age', 'city', 'interest']) { + assertOptionalArray(payload, key, suffix); + } + } + return payload; +} +function parseCapturedJson(capture, endpoint) { + const suffix = endpoint.suffix; + if (!capture || typeof capture !== 'object') { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: malformed capture for ${suffix}`); + } + if (capture.ok !== true) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned HTTP ${capture.status ?? 'non-2xx'}`); + } + if (typeof capture.body !== 'string') { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a non-text body`); + } + try { + const envelope = JSON.parse(capture.body); + const payload = isPlainObject(envelope) && Object.hasOwn(envelope, 'data') ? envelope.data : envelope; + return validateCapturedPayload(payload, endpoint); + } + catch { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned invalid JSON or payload shape`); + } +} +// Capture the dashboard's signed datacenter/note responses on window.__xhsCapture +// since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. +async function installXhsFetchCaptureHook(page) { + await page.evaluate(`(() => { + const targetPaths = ${JSON.stringify(DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix))}; + const shouldCapture = (url) => { + try { + return targetPaths.includes(new URL(String(url), window.location.origin).pathname); + } catch (_) { + return false; + } + }; + // Reset the buffer every call so stale captures from a previous run on + // the same tab cannot leak into the current navigation's harvest. + window.__xhsCapture = {}; + if (window.__xhsCaptureInstalled) return; + window.__xhsCaptureInstalled = true; + const origFetch = window.fetch; + window.fetch = async function(...args) { + const resp = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; + if (shouldCapture(url)) { + resp.clone().text().then((body) => { + try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {} + }).catch(() => {}); + } + } catch (_) {} + return resp; + }; + const OrigXHR = window.XMLHttpRequest; + function HookedXHR() { + const xhr = new OrigXHR(); + const origOpen = xhr.open; + let capturedUrl = ''; + xhr.open = function(method, url, ...rest) { + capturedUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + xhr.addEventListener('load', () => { + try { + if (shouldCapture(capturedUrl)) { + window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText }; + } + } catch (_) {} + }); + return xhr; + } + HookedXHR.prototype = OrigXHR.prototype; + // Preserve readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE) + // since dashboard code may read XMLHttpRequest.DONE etc against the constructor. + for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) { + if (key in OrigXHR) HookedXHR[key] = OrigXHR[key]; + } + window.XMLHttpRequest = HookedXHR; + })()`); +} +async function captureNoteDetailPayload(page, noteId) { + await installXhsFetchCaptureHook(page); + // SPA-navigate inside the dashboard so the React router re-fires the + // signed datacenter/note/* requests under our hook. A second page.goto + // would wipe the hook before the first auto-fetch can land. + await page.evaluate(`(() => { + const target = '/statistics/note-detail?noteId=' + ${JSON.stringify(noteId)}; + history.pushState({}, '', target); + window.dispatchEvent(new PopStateEvent('popstate')); + })()`); + const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix); + let captureMap = {}; + for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) { + await page.wait(CAPTURE_POLL_INTERVAL_S); + let raw; + try { + raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); + captureMap = typeof raw === 'string' ? JSON.parse(raw) : {}; + } + catch { + throw new CommandExecutionError('xiaohongshu creator-note-detail: failed to read signed datacenter/note capture buffer'); + } + if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) { + throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer'); + } + const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix)); + if (captured.length === wantedSuffixes.length) + break; + } + const payload = {}; + for (const endpoint of DETAIL_API_ENDPOINTS) { + const matchUrl = findCapturedUrl(captureMap, endpoint.suffix); + if (!matchUrl) + continue; + payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint); + } + return Object.keys(payload).length > 0 ? payload : null; +} +async function captureNoteDetailDomData(page) { + const result = await page.evaluate(`() => { + const norm = (value) => (value || '').trim(); + const sections = Array.from(document.querySelectorAll('.shell-container')).map((container) => { + const containerText = norm(container.innerText); + const title = containerText.startsWith('互动数据') + ? '互动数据' + : containerText.includes('基础数据') + ? '基础数据' + : ''; + const metrics = Array.from(container.querySelectorAll('.block-container.block')).map((block) => ({ + label: norm(block.querySelector('.des')?.innerText), + value: norm(block.querySelector('.content')?.innerText), + extra: norm(block.querySelector('.text-with-fans')?.innerText), + })).filter((metric) => metric.label && metric.value); + return { title, metrics }; + }).filter((section) => section.title && section.metrics.length > 0); + + return { + title: norm(document.querySelector('.note-title')?.innerText), + infoText: norm(document.querySelector('.note-info-content')?.innerText), + sections, + }; + }`); + if (!result || typeof result !== 'object') + return null; + return result; +} +export async function fetchCreatorNoteDetailRows(page, noteId) { + // Land on the dashboard root first so the React app boots before the + // note-specific signed APIs fire. captureNoteDetailPayload then installs + // the fetch+XHR hook and SPA-navigates to /statistics/note-detail under + // it, which is what surfaces the audience / trend rows. + await page.goto('https://creator.xiaohongshu.com/statistics'); + const apiPayload = await captureNoteDetailPayload(page, noteId); + const domData = await captureNoteDetailDomData(page).catch(() => null); + let rows = parseCreatorNoteDetailDomData(domData, noteId); + if (rows.length === 0) { + const bodyText = await page.evaluate('() => document.body.innerText'); + rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId); + } + appendTrendRows(rows, apiPayload ?? undefined); + appendAudienceRows(rows, apiPayload ?? undefined); + return rows; +} +cli({ + site: 'xiaohongshu', + name: 'creator-note-detail', + access: 'read', + description: '小红书单篇笔记详情页数据 (笔记信息 + 核心/互动数据 + 观看来源 + 观众画像 + 趋势数据)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'note-id', positional: true, type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' }, + ], + columns: ['section', 'metric', 'value', 'extra'], + func: async (page, kwargs) => { + const noteId = kwargs['note-id']; + const rows = await fetchCreatorNoteDetailRows(page, noteId); + const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value); + if (!hasCoreMetric) { + throw new EmptyResultError('xiaohongshu creator-note-detail', 'No note detail data found. Check note_id and login status for creator.xiaohongshu.com.'); + } + return rows; + }, +}); diff --git a/clis/xiaohongshu/creator-note-detail.test.js b/clis/xiaohongshu/creator-note-detail.test.js new file mode 100644 index 0000000..54f09c1 --- /dev/null +++ b/clis/xiaohongshu/creator-note-detail.test.js @@ -0,0 +1,451 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js'; +import './creator-note-detail.js'; +function createPageMock(evaluateResult) { + const evaluate = Array.isArray(evaluateResult) + ? vi.fn() + .mockResolvedValueOnce(evaluateResult[0]) + .mockResolvedValue(evaluateResult[evaluateResult.length - 1]) + : vi.fn().mockResolvedValue(evaluateResult); + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + }; +} +describe('xiaohongshu creator-note-detail', () => { + it('parses note detail page text into info and metric rows', () => { + const bodyText = `笔记数据详情 +示例内容复盘 +#测试标签 +#内容分析 +2026-03-18 20:01 +切换笔记 +笔记诊断 +核心数据 +观看来源 +观众画像 +核心数据 +基础数据 +部分数据统计中,次日可查看 +导出数据 +曝光数 +1733 +粉丝占比 6.6% +实时 +观看数 +544 +粉丝占比 7.2% +封面点击率 +18.6% +粉丝 19.1% +平均观看时长 +51.5秒 +粉丝 55.8秒 +涨粉数 +3 +按小时 +按天 +互动数据 +数据实时更新 +导出数据 +点赞数 +19 +粉丝占比 60% +评论数 +7 +粉丝占比 33.3% +收藏数 +10 +粉丝占比 33.3% +分享数 +6 +粉丝占比 0%`; + expect(parseCreatorNoteDetailText(bodyText, 'cccccccccccccccccccccccc')).toEqual([ + { section: '笔记信息', metric: 'note_id', value: 'cccccccccccccccccccccccc', extra: '' }, + { section: '笔记信息', metric: 'title', value: '示例内容复盘', extra: '' }, + { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' }, + { section: '基础数据', metric: '曝光数', value: '1733', extra: '粉丝占比 6.6%' }, + { section: '基础数据', metric: '观看数', value: '544', extra: '粉丝占比 7.2%' }, + { section: '基础数据', metric: '封面点击率', value: '18.6%', extra: '粉丝 19.1%' }, + { section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '粉丝 55.8秒' }, + { section: '基础数据', metric: '涨粉数', value: '3', extra: '' }, + { section: '互动数据', metric: '点赞数', value: '19', extra: '粉丝占比 60%' }, + { section: '互动数据', metric: '评论数', value: '7', extra: '粉丝占比 33.3%' }, + { section: '互动数据', metric: '收藏数', value: '10', extra: '粉丝占比 33.3%' }, + { section: '互动数据', metric: '分享数', value: '6', extra: '粉丝占比 0%' }, + ]); + }); + it('parses structured note detail dom data into rows', () => { + expect(parseCreatorNoteDetailDomData({ + title: '测试笔记一', + infoText: '测试笔记一\n#测试标签\n2025-12-04 19:45\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '898204', extra: '粉丝占比 0.5%' }, + { label: '观看数', value: '148284', extra: '粉丝占比 0.6%' }, + { label: '封面点击率', value: '17.1%', extra: '粉丝 19.1%' }, + { label: '平均观看时长', value: '30.1秒', extra: '粉丝 17.7秒' }, + { label: '涨粉数', value: '101', extra: '' }, + ], + }, + { + title: '互动数据', + metrics: [ + { label: '点赞数', value: '2280', extra: '粉丝占比 3.6%' }, + { label: '评论数', value: '319', extra: '粉丝占比 9.4%' }, + { label: '收藏数', value: '466', extra: '粉丝占比 9.4%' }, + { label: '分享数', value: '33', extra: '粉丝占比 17.7%' }, + ], + }, + ], + }, 'bbbbbbbbbbbbbbbbbbbbbbbb')).toEqual([ + { section: '笔记信息', metric: 'note_id', value: 'bbbbbbbbbbbbbbbbbbbbbbbb', extra: '' }, + { section: '笔记信息', metric: 'title', value: '测试笔记一', extra: '' }, + { section: '笔记信息', metric: 'published_at', value: '2025-12-04 19:45', extra: '' }, + { section: '基础数据', metric: '曝光数', value: '898204', extra: '粉丝占比 0.5%' }, + { section: '基础数据', metric: '观看数', value: '148284', extra: '粉丝占比 0.6%' }, + { section: '基础数据', metric: '封面点击率', value: '17.1%', extra: '粉丝 19.1%' }, + { section: '基础数据', metric: '平均观看时长', value: '30.1秒', extra: '粉丝 17.7秒' }, + { section: '基础数据', metric: '涨粉数', value: '101', extra: '' }, + { section: '互动数据', metric: '点赞数', value: '2280', extra: '粉丝占比 3.6%' }, + { section: '互动数据', metric: '评论数', value: '319', extra: '粉丝占比 9.4%' }, + { section: '互动数据', metric: '收藏数', value: '466', extra: '粉丝占比 9.4%' }, + { section: '互动数据', metric: '分享数', value: '33', extra: '粉丝占比 17.7%' }, + ]); + }); + it('appends audience source and portrait rows from API payloads', () => { + const rows = appendAudienceRows([], { + audienceSource: { + source: [ + { + title: '首页推荐', + value_with_double: 89.9, + info: { + imp_count: 1469, + view_count: 276, + interaction_count: 15, + }, + }, + ], + }, + audienceSourceDetail: { + gender: [ + { title: '男性', value: 82 }, + { title: '女性', value: 18 }, + ], + age: [ + { title: '25-34', value: 55 }, + ], + city: [ + { title: '上海', value: 8 }, + ], + interest: [ + { title: '二次元', value: 13 }, + ], + }, + }); + expect(rows).toEqual([ + { section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '曝光 1469 · 观看 276 · 互动 15' }, + { section: '观众画像', metric: '性别/男性', value: '82%', extra: '' }, + { section: '观众画像', metric: '性别/女性', value: '18%', extra: '' }, + { section: '观众画像', metric: '年龄/25-34', value: '55%', extra: '' }, + { section: '观众画像', metric: '城市/上海', value: '8%', extra: '' }, + { section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' }, + ]); + }); + it('appends trend summary rows from hour/day series payloads', () => { + const rows = appendTrendRows([], { + audienceTrend: { + no_data: true, + no_data_tip_msg: '数据统计中,请稍后查看', + }, + noteBase: { + hour: { + view_list: [ + { date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 54 }, + { date: new Date('2026-03-18T22:00:00+08:00').getTime(), count: 51 }, + ], + like_list: [ + { date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 2 }, + ], + }, + day: { + view_list: [ + { date: new Date('2026-03-18T00:00:00+08:00').getTime(), count: 307 }, + ], + }, + }, + }); + expect(rows).toEqual([ + { section: '趋势说明', metric: '观众趋势', value: '暂不可用', extra: '数据统计中,请稍后查看' }, + { section: '趋势数据', metric: '按小时/观看数', value: '2 points', extra: '03-18 21:00=54 | 03-18 22:00=51' }, + { section: '趋势数据', metric: '按小时/点赞数', value: '1 points', extra: '03-18 21:00=2' }, + { section: '趋势数据', metric: '按天/观看数', value: '1 points', extra: '2026-03-18=307' }, + ]); + }); + it('navigates to the note detail page and returns parsed rows', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + expect(cmd?.func).toBeTypeOf('function'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + { label: '观看数', value: '50', extra: '粉丝占比 20%' }, + { label: '封面点击率', value: '12%', extra: '粉丝 11%' }, + { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' }, + { label: '涨粉数', value: '2', extra: '' }, + ], + }, + { + title: '互动数据', + metrics: [ + { label: '点赞数', value: '8', extra: '粉丝占比 25%' }, + { label: '评论数', value: '1', extra: '粉丝占比 0%' }, + { label: '收藏数', value: '3', extra: '粉丝占比 50%' }, + { label: '分享数', value: '0', extra: '粉丝占比 0%' }, + ], + }, + ], + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({}); + if (s.includes("document.querySelector('.note-title')")) return domData; + if (s.includes('document.body.innerText')) return ''; + return undefined; + }); + const result = await cmd.func(page, { 'note-id': 'demo-note-id' }); + expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics'); + expect(page.evaluate.mock.calls[0][0]).toContain('window.__xhsCapture ='); + expect(result).toEqual([ + { section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' }, + { section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' }, + { section: '笔记信息', metric: 'published_at', value: '2026-03-19 12:00', extra: '' }, + { section: '基础数据', metric: '曝光数', value: '100', extra: '粉丝占比 10%' }, + { section: '基础数据', metric: '观看数', value: '50', extra: '粉丝占比 20%' }, + { section: '基础数据', metric: '封面点击率', value: '12%', extra: '粉丝 11%' }, + { section: '基础数据', metric: '平均观看时长', value: '30秒', extra: '粉丝 31秒' }, + { section: '基础数据', metric: '涨粉数', value: '2', extra: '' }, + { section: '互动数据', metric: '点赞数', value: '8', extra: '粉丝占比 25%' }, + { section: '互动数据', metric: '评论数', value: '1', extra: '粉丝占比 0%' }, + { section: '互动数据', metric: '收藏数', value: '3', extra: '粉丝占比 50%' }, + { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' }, + ]); + }); + it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + { label: '观看数', value: '50', extra: '粉丝占比 20%' }, + { label: '封面点击率', value: '12%', extra: '粉丝 11%' }, + { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' }, + { label: '涨粉数', value: '2', extra: '' }, + ], + }, + { + title: '互动数据', + metrics: [ + { label: '点赞数', value: '8', extra: '粉丝占比 25%' }, + { label: '评论数', value: '1', extra: '粉丝占比 0%' }, + { label: '收藏数', value: '3', extra: '粉丝占比 50%' }, + { label: '分享数', value: '0', extra: '粉丝占比 0%' }, + ], + }, + ], + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({}); + if (s.includes("document.querySelector('.note-title')")) return domData; + return undefined; + }); + await cmd.func(page, { 'note-id': 'demo-note-id' }); + // Capture loop polls until the deadline expires (no hits with empty mock). + expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4); + const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture')); + expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1); + }); + it('matches signed API captures by exact pathname so source/detail cannot shadow source', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + ], + }, + ], + }; + const detailCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { gender: [{ title: '女性', value: 64 }] } }), + }, + ]; + const sourceCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ + data: { + source: [ + { + title: '首页推荐', + value_with_double: 88.8, + info: { imp_count: 1000, view_count: 400, interaction_count: 20 }, + }, + ], + }, + }), + }, + ]; + const baseCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { hour: { view_list: [{ date: new Date('2026-03-19T12:00:00+08:00').getTime(), count: 7 }] } } }), + }, + ]; + const trendCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { no_data: false, no_data_tip_msg: '趋势可用' } }), + }, + ]; + for (const orderedCaptures of [ + [detailCapture, sourceCapture, baseCapture, trendCapture], + [sourceCapture, detailCapture, baseCapture, trendCapture], + ]) { + const captureMap = Object.fromEntries(orderedCaptures); + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + if (s.includes("document.querySelector('.note-title')")) return domData; + return undefined; + }); + const result = await cmd.func(page, { 'note-id': 'demo-note-id' }); + expect(result).toEqual(expect.arrayContaining([ + { section: '观看来源', metric: '首页推荐', value: '88.8%', extra: '曝光 1000 · 观看 400 · 互动 20' }, + { section: '观众画像', metric: '性别/女性', value: '64%', extra: '' }, + { section: '趋势数据', metric: '按小时/观看数', value: '1 points', extra: '03-19 12:00=7' }, + ])); + } + }); + it('throws a typed error when a captured signed API returns non-2xx', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const captureMap = { + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id': { + status: 406, + ok: false, + body: '{"msg":"not acceptable"}', + }, + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + return null; + }); + await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws a typed error for wrong-shaped signed API payloads instead of falling back to DOM rows', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + ], + }, + ], + }; + for (const body of [ + JSON.stringify({ data: null }), + JSON.stringify({ data: [] }), + JSON.stringify({ data: { source: {} } }), + ]) { + const captureMap = { + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id': { + status: 200, + ok: true, + body, + }, + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + if (s.includes("document.querySelector('.note-title')")) return domData; + return null; + }); + await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError); + } + }); + it('throws EmptyResultError when the detail page exposes no metrics', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const page = createPageMock(undefined); + page.evaluate = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('笔记数据详情\n暂无数据') + .mockResolvedValue(null); + + await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/xiaohongshu/creator-notes-summary.js b/clis/xiaohongshu/creator-notes-summary.js new file mode 100644 index 0000000..500ad1c --- /dev/null +++ b/clis/xiaohongshu/creator-notes-summary.js @@ -0,0 +1,98 @@ +/** + * Xiaohongshu Creator Notes Summary — batch summary for recent notes. + * + * Combines creator-notes and creator-note-detail into a single command that + * returns one summary row per note, suitable for quick review or downstream JSON use. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { fetchCreatorNotes } from './creator-notes.js'; +import { fetchCreatorNoteDetailRows } from './creator-note-detail.js'; +function findDetailValue(rows, metric) { + return rows.find((row) => row.metric === metric)?.value ?? ''; +} +function findTopBySectionPrefix(rows, section, prefix) { + const matches = rows.filter((row) => row.section === section && row.metric.startsWith(prefix) && row.value); + if (matches.length === 0) + return { label: '', value: '' }; + const sorted = [...matches].sort((a, b) => parseFloat(b.value) - parseFloat(a.value)); + const top = sorted[0]; + return { + label: top.metric.slice(prefix.length), + value: top.value, + }; +} +export function summarizeCreatorNote(note, rows, rank) { + const topSource = findTopBySectionPrefix(rows, '观看来源', ''); + const topInterest = findTopBySectionPrefix(rows, '观众画像', '兴趣/'); + return { + rank, + id: note.id, + title: note.title, + published_at: findDetailValue(rows, 'published_at') || note.date, + views: findDetailValue(rows, '观看数') || String(note.views), + likes: findDetailValue(rows, '点赞数') || String(note.likes), + collects: findDetailValue(rows, '收藏数') || String(note.collects), + comments: findDetailValue(rows, '评论数') || String(note.comments), + shares: findDetailValue(rows, '分享数'), + avg_view_time: findDetailValue(rows, '平均观看时长'), + rise_fans: findDetailValue(rows, '涨粉数'), + top_source: topSource.label, + top_source_pct: topSource.value, + top_interest: topInterest.label, + top_interest_pct: topInterest.value, + url: note.url, + }; +} +cli({ + site: 'xiaohongshu', + name: 'creator-notes-summary', + access: 'read', + description: '小红书最近笔记批量摘要 (列表 + 单篇关键数据汇总)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' }, + { name: 'timeout', type: 'int', required: false, default: 180, help: 'Max seconds for the overall command (default: 180)' }, + ], + columns: ['rank', 'id', 'title', 'views', 'likes', 'collects', 'comments', 'shares', 'avg_view_time', 'rise_fans', 'top_source', 'top_interest', 'url'], + func: async (page, kwargs) => { + const limit = kwargs.limit || 3; + const notes = await fetchCreatorNotes(page, limit); + if (!notes.length) { + throw new EmptyResultError('xiaohongshu creator-notes-summary', 'No notes found. Ensure you are logged into creator.xiaohongshu.com and the account has published notes.'); + } + const results = []; + for (const [index, note] of notes.entries()) { + if (index > 0) { + await page.wait({ time: 1 + Math.random() * 2 }); + } + if (!note.id) { + results.push({ + rank: index + 1, + id: note.id, + title: note.title, + published_at: note.date, + views: String(note.views), + likes: String(note.likes), + collects: String(note.collects), + comments: String(note.comments), + shares: '', + avg_view_time: '', + rise_fans: '', + top_source: '', + top_source_pct: '', + top_interest: '', + top_interest_pct: '', + url: note.url, + }); + continue; + } + const detailRows = await fetchCreatorNoteDetailRows(page, note.id); + results.push(summarizeCreatorNote(note, detailRows, index + 1)); + } + return results; + }, +}); diff --git a/clis/xiaohongshu/creator-notes-summary.test.js b/clis/xiaohongshu/creator-notes-summary.test.js new file mode 100644 index 0000000..226b1bb --- /dev/null +++ b/clis/xiaohongshu/creator-notes-summary.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { summarizeCreatorNote } from './creator-notes-summary.js'; +import { getRegistry } from '@jackwener/opencli/registry'; +import * as creatorNotesModule from './creator-notes.js'; +import * as creatorDetailModule from './creator-note-detail.js'; +import './creator-notes-summary.js'; +describe('xiaohongshu creator-notes-summary', () => { + it('summarizes note list row and detail rows into one compact row', () => { + const note = { + id: 'cccccccccccccccccccccccc', + title: '示例内容复盘', + date: '2026年03月18日 20:01', + views: 549, + likes: 19, + collects: 10, + comments: 7, + url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc', + }; + const rows = [ + { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' }, + { section: '基础数据', metric: '观看数', value: '549', extra: '' }, + { section: '互动数据', metric: '点赞数', value: '19', extra: '' }, + { section: '互动数据', metric: '收藏数', value: '10', extra: '' }, + { section: '互动数据', metric: '评论数', value: '7', extra: '' }, + { section: '互动数据', metric: '分享数', value: '6', extra: '' }, + { section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '' }, + { section: '基础数据', metric: '涨粉数', value: '3', extra: '' }, + { section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '' }, + { section: '观看来源', metric: '搜索', value: '0.3%', extra: '' }, + { section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' }, + { section: '观众画像', metric: '兴趣/游戏', value: '11%', extra: '' }, + ]; + expect(summarizeCreatorNote(note, rows, 1)).toEqual({ + rank: 1, + id: 'cccccccccccccccccccccccc', + title: '示例内容复盘', + published_at: '2026-03-18 20:01', + views: '549', + likes: '19', + collects: '10', + comments: '7', + shares: '6', + avg_view_time: '51.5秒', + rise_fans: '3', + top_source: '首页推荐', + top_source_pct: '89.9%', + top_interest: '二次元', + top_interest_pct: '13%', + url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc', + }); + }); + it('waits between note detail fetches after the first note', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes-summary'); + const page = { + wait: vi.fn().mockResolvedValue(undefined), + }; + vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([ + { + id: 'aaaaaaaaaaaaaaaaaaaaaaaa', + title: 'n1', + date: '2026年03月18日 20:01', + views: 1, + likes: 1, + collects: 1, + comments: 1, + url: 'u1', + }, + { + id: 'bbbbbbbbbbbbbbbbbbbbbbbb', + title: 'n2', + date: '2026年03月19日 20:01', + views: 2, + likes: 2, + collects: 2, + comments: 2, + url: 'u2', + }, + ]); + vi.spyOn(creatorDetailModule, 'fetchCreatorNoteDetailRows').mockResolvedValue([ + { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' }, + { section: '基础数据', metric: '观看数', value: '1', extra: '' }, + ]); + await cmd.func(page, { limit: 2 }); + expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) })); + expect(page.wait.mock.calls).toHaveLength(1); + }); + it('throws EmptyResultError when there are no notes to summarize', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes-summary'); + vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([]); + + await expect(cmd.func({ wait: vi.fn() }, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/xiaohongshu/creator-notes.js b/clis/xiaohongshu/creator-notes.js new file mode 100644 index 0000000..34f5c01 --- /dev/null +++ b/clis/xiaohongshu/creator-notes.js @@ -0,0 +1,479 @@ +/** + * Xiaohongshu Creator Note List — per-note metrics from the creator backend. + * + * In CDP mode we capture the real creator analytics API response so the list + * includes stable note ids and detail-page URLs. If that capture is unavailable, + * we fall back to the older interceptor and DOM parsing paths. + * + * Requires: logged into creator.xiaohongshu.com in Chrome. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/; +const METRIC_LINE_RE = /^\d+$/; +const VISIBILITY_LINE_RE = /可见$/; +const NOTE_ANALYZE_API_PATH = '/api/galaxy/creator/datacenter/note/analyze/list'; +const NOTE_ANALYZE_PAGE_SIZE = 10; +const CAPTURE_POLL_ATTEMPTS = 20; +const CAPTURE_POLL_INTERVAL_S = 0.5; +const NOTE_DETAIL_PAGE_URL = 'https://creator.xiaohongshu.com/statistics/note-detail'; +const NOTE_ID_HTML_RE = /"noteId":"([0-9a-f]{24})"/g; +function buildNoteDetailUrl(noteId) { + return noteId ? `${NOTE_DETAIL_PAGE_URL}?noteId=${encodeURIComponent(noteId)}` : ''; +} +function formatPostTime(ts) { + if (!ts) + return ''; + // XHS API timestamps are Beijing time (UTC+8) + const date = new Date(ts + 8 * 3600_000); + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getUTCFullYear()}年${pad(date.getUTCMonth() + 1)}月${pad(date.getUTCDate())}日 ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; +} +export function parseCreatorNotesText(bodyText) { + const lines = bodyText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const results = []; + const seen = new Set(); + for (let i = 0; i < lines.length; i++) { + const dateMatch = lines[i].match(DATE_LINE_RE); + if (!dateMatch) + continue; + let titleIndex = i - 1; + while (titleIndex >= 0 && VISIBILITY_LINE_RE.test(lines[titleIndex])) + titleIndex--; + if (titleIndex < 0) + continue; + const title = lines[titleIndex]; + const metrics = []; + let cursor = i + 1; + while (cursor < lines.length && METRIC_LINE_RE.test(lines[cursor]) && metrics.length < 5) { + metrics.push(parseInt(lines[cursor], 10)); + cursor++; + } + if (metrics.length < 4) + continue; + const key = `${title}@@${dateMatch[1]}`; + if (seen.has(key)) + continue; + seen.add(key); + results.push({ + id: '', + title, + date: dateMatch[1], + views: metrics[0] ?? 0, + likes: metrics[2] ?? 0, + collects: metrics[3] ?? 0, + comments: metrics[1] ?? 0, + url: '', + }); + i = cursor - 1; + } + return results; +} +export function parseCreatorNoteIdsFromHtml(bodyHtml) { + const ids = []; + const seen = new Set(); + for (const match of bodyHtml.matchAll(NOTE_ID_HTML_RE)) { + const id = match[1]; + if (!id || seen.has(id)) + continue; + seen.add(id); + ids.push(id); + } + return ids; +} +function mapDomCards(cards) { + return cards.map((card) => ({ + id: card.id, + title: card.title, + date: card.date, + views: card.metrics[0] ?? 0, + likes: card.metrics[2] ?? 0, + collects: card.metrics[3] ?? 0, + comments: card.metrics[1] ?? 0, + url: buildNoteDetailUrl(card.id), + })); +} +function mapAnalyzeItems(items) { + return (items ?? []).map((item) => ({ + id: item.id ?? '', + title: item.title ?? '', + date: formatPostTime(item.post_time), + views: item.read_count ?? 0, + likes: item.like_count ?? 0, + collects: item.fav_count ?? 0, + comments: item.comment_count ?? 0, + url: buildNoteDetailUrl(item.id), + })); +} +function unwrapEvaluateResult(payload) { + if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} +// Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture +// since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. +async function installXhsFetchCaptureHook(page) { + await page.evaluate(`(() => { + window.__xhsCapture = {}; + if (window.__xhsCaptureInstalled) return; + window.__xhsCaptureInstalled = true; + const origFetch = window.fetch; + window.fetch = async function(...args) { + const resp = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; + if (url.includes('/api/galaxy/')) { + resp.clone().text().then((body) => { + try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {} + }).catch(() => {}); + } + } catch (_) {} + return resp; + }; + const OrigXHR = window.XMLHttpRequest; + function HookedXHR() { + const xhr = new OrigXHR(); + const origOpen = xhr.open; + let capturedUrl = ''; + xhr.open = function(method, url, ...rest) { + capturedUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + xhr.addEventListener('load', () => { + try { + if (capturedUrl.includes('/api/galaxy/')) { + window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText }; + } + } catch (_) {} + }); + return xhr; + } + HookedXHR.prototype = OrigXHR.prototype; + for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) { + if (key in OrigXHR) HookedXHR[key] = OrigXHR[key]; + } + window.XMLHttpRequest = HookedXHR; + })()`); +} +function parseCaptureMapPayload(raw) { + const payload = unwrapEvaluateResult(raw); + if (typeof payload === 'string') { + try { + return JSON.parse(payload); + } + catch { + return {}; + } + } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + return payload; + } + return {}; +} +function getAnalyzeListPageNumber(url) { + try { + const parsed = new URL(url, 'https://creator.xiaohongshu.com'); + const pageNum = Number.parseInt(parsed.searchParams.get('page_num') || '', 10); + if (Number.isFinite(pageNum) && pageNum > 0) + return pageNum; + } + catch { } + const match = String(url || '').match(/[?&]page_num=(\d+)/); + const pageNum = Number.parseInt(match?.[1] || '', 10); + return Number.isFinite(pageNum) && pageNum > 0 ? pageNum : Number.MAX_SAFE_INTEGER; +} +function harvestAnalyzeListCaptures(captureMap) { + const items = []; + const seen = new Set(); + let total = 0; + const entries = Object.entries(captureMap) + .filter(([url]) => url.includes('/note/analyze/list')) + .sort(([a], [b]) => getAnalyzeListPageNumber(a) - getAnalyzeListPageNumber(b)); + for (const [url, capture] of entries) { + if (!capture?.ok) continue; + try { + const json = JSON.parse(capture.body); + const data = json?.data ?? {}; + if (typeof data.total === 'number' && data.total > total) total = data.total; + for (const note of data.note_infos ?? []) { + if (!note?.id || seen.has(note.id)) continue; + seen.add(note.id); + items.push(note); + } + } + catch { } + } + return { items, total }; +} +function isAnalyzeCaptureComplete(items, total, limit) { + if (total <= 0) + return true; + return items.length >= Math.min(total, limit); +} +async function pollCaptureMap(page) { + let captureMap = {}; + for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) { + await page.wait(CAPTURE_POLL_INTERVAL_S); + const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); + captureMap = parseCaptureMapPayload(raw); + if (Object.keys(captureMap).some((url) => url.includes('/note/analyze/list'))) break; + } + return captureMap; +} +// Fresh-published notes return title: "" from /note/analyze/list. Scrape the +// /new/note-manager card DOM (under its "全部笔记" tab, which surfaces every +// state including 审核中) so the rows the API leaves empty still get the +// derived title that the note-manager UI shows. +async function fetchNoteManagerTitleMap(page, neededCount) { + const map = new Map(); + const scrapeCards = async () => { + const cards = unwrapEvaluateResult(await page.evaluate(`() => { + const noteIdRe = /"noteId":"([0-9a-f]{24})"/; + return Array.from(document.querySelectorAll('div.note[data-impression], div.note')).map((card) => { + const impression = card.getAttribute('data-impression') || ''; + const id = impression.match(noteIdRe)?.[1] || ''; + const title = (card.querySelector('.title, .raw')?.innerText || '').trim(); + return { id, title }; + }).filter((entry) => entry.id && entry.title); + }`)); + for (const card of Array.isArray(cards) ? cards : []) { + if (!map.has(card.id)) map.set(card.id, card.title); + } + }; + // Scroll the first scrollable ancestor of a note card to the bottom so + // the list lazy-loads the rest of its rows. Page-level scrollTo does not + // work because the cards live inside an inner overflow-auto container. + const scrollInnerListToBottom = async () => { + return unwrapEvaluateResult(await page.evaluate(`(() => { + const firstCard = document.querySelector('div.note[data-impression]'); + let el = firstCard && firstCard.parentElement; + while (el) { + const s = window.getComputedStyle(el); + if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) { + el.scrollTop = el.scrollHeight; + return true; + } + el = el.parentElement; + } + return false; + })()`)); + }; + try { + await page.goto('https://creator.xiaohongshu.com/new/note-manager'); + // Poll for the initial hydration batch and then scroll the inner list + // container to surface the rest of the rows. The all-notes tab is the + // default state so no tab click is needed here. + for (let i = 0; i < 12; i++) { + await page.wait(1); + await scrapeCards(); + if (map.size >= neededCount) return map; + await scrollInnerListToBottom(); + } + return map; + } + catch { + return map; + } +} +async function fetchCreatorNotesByCapture(page, limit) { + // Land on dashboard root before installing the hook so the data-analysis + // SPA navigation fires page_num=1's signed request UNDER the hook. + await page.goto('https://creator.xiaohongshu.com/statistics'); + await installXhsFetchCaptureHook(page); + await page.evaluate(`(() => { + history.pushState({}, '', '/statistics/data-analysis?source=official'); + window.dispatchEvent(new PopStateEvent('popstate')); + })()`); + let captureMap = await pollCaptureMap(page); + let { items, total } = harvestAnalyzeListCaptures(captureMap); + if (items.length === 0) return []; + const totalPages = total > 0 ? Math.ceil(total / NOTE_ANALYZE_PAGE_SIZE) : 1; + const neededPages = Math.min(totalPages, Math.ceil(limit / NOTE_ANALYZE_PAGE_SIZE)); + for (let pageNum = 2; pageNum <= neededPages && items.length < limit; pageNum++) { + const clicked = unwrapEvaluateResult(await page.evaluate(`(() => { + const target = String(${pageNum}); + // .d-pagination-page renders the page number doubled (a visible span + + // an accessibility span), so textContent for page 2 reads "22". Match + // both the raw digit and the doubled form to tolerate either render. + const btns = Array.from(document.querySelectorAll('.d-pagination-page')); + const match = btns.find((btn) => { + const text = (btn.textContent || '').trim(); + return text === target || text === target + target; + }); + if (match) { match.click(); return true; } + return false; + })()`)); + if (!clicked) break; + const before = items.length; + let advanced = false; + for (let attempt = 0; attempt < CAPTURE_POLL_ATTEMPTS; attempt++) { + await page.wait(CAPTURE_POLL_INTERVAL_S); + const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); + captureMap = parseCaptureMapPayload(raw); + const harvested = harvestAnalyzeListCaptures(captureMap); + if (harvested.items.length > before) { + items = harvested.items; + total = Math.max(total, harvested.total); + advanced = true; + break; + } + } + if (!advanced) break; + } + if (!isAnalyzeCaptureComplete(items, total, limit)) { + throw new CommandExecutionError(`xiaohongshu creator-notes: captured ${items.length} of ${Math.min(total, limit)} expected analyze rows; refusing partial results`); + } + const notes = mapAnalyzeItems(items).slice(0, limit); + const missingTitles = notes.filter((note) => !note.title).length; + if (missingTitles > 0) { + const titleMap = await fetchNoteManagerTitleMap(page, notes.length); + for (const note of notes) { + if (!note.title && note.id && titleMap.has(note.id)) { + note.title = titleMap.get(note.id); + } + } + } + return notes; +} +async function fetchCreatorNotesByApi(page, limit) { + const pageSize = Math.min(Math.max(limit, 10), 20); + const maxPages = Math.max(1, Math.ceil(limit / pageSize)); + const notes = []; + await page.goto(`https://creator.xiaohongshu.com/statistics/data-analysis?type=0&page_size=${pageSize}&page_num=1`); + for (let pageNum = 1; pageNum <= maxPages && notes.length < limit; pageNum++) { + const apiPath = `${NOTE_ANALYZE_API_PATH}?type=0&page_size=${pageSize}&page_num=${pageNum}`; + const fetched = await page.evaluate(` + async () => { + try { + const resp = await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' }); + if (!resp.ok) return { error: 'HTTP ' + resp.status }; + return await resp.json(); + } catch (e) { + return { error: e?.message ?? String(e) }; + } + } + `); + let items = fetched?.data?.note_infos ?? []; + if (!items.length) { + await page.installInterceptor(NOTE_ANALYZE_API_PATH); + await page.evaluate(` + async () => { + try { + await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' }); + } catch {} + return true; + } + `); + await page.wait(1); + const intercepted = await page.getInterceptedRequests(); + const data = intercepted.find((entry) => Array.isArray(entry?.data?.note_infos)); + items = data?.data?.note_infos ?? []; + } + if (!items.length) + break; + notes.push(...mapAnalyzeItems(items)); + if (items.length < pageSize) + break; + } + return notes.slice(0, limit); +} +export async function fetchCreatorNotes(page, limit) { + let notes = []; + try { + notes = await fetchCreatorNotesByCapture(page, limit); + } + catch (error) { + if (error instanceof CommandExecutionError) throw error; + } + if (notes.length === 0) { + notes = await fetchCreatorNotesByApi(page, limit); + } + if (notes.length === 0) { + await page.goto('https://creator.xiaohongshu.com/new/note-manager'); + const maxPageDowns = Math.max(0, Math.ceil(limit / 10) + 1); + for (let i = 0; i <= maxPageDowns; i++) { + const domCards = await page.evaluate(`() => { + const noteIdRe = /"noteId":"([0-9a-f]{24})"/; + return Array.from(document.querySelectorAll('div.note[data-impression], div.note')).map((card) => { + const impression = card.getAttribute('data-impression') || ''; + const id = impression.match(noteIdRe)?.[1] || ''; + const title = (card.querySelector('.title, .raw')?.innerText || '').trim(); + const dateText = (card.querySelector('.time_status, .time')?.innerText || '').trim(); + const date = dateText.replace(/^发布于\\s*/, ''); + const metrics = Array.from(card.querySelectorAll('.icon_list .icon')) + .map((el) => parseInt((el.innerText || '').trim(), 10)) + .filter((value) => Number.isFinite(value)); + return { id, title, date, metrics }; + }); + }`); + const parsedDomNotes = mapDomCards(Array.isArray(domCards) ? domCards : []).filter((note) => note.title && note.date); + if (parsedDomNotes.length > 0) { + notes = parsedDomNotes; + } + if (notes.length >= limit || (notes.length > 0 && i === 0)) + break; + const body = await page.evaluate('() => ({ text: document.body.innerText, html: document.body.innerHTML })'); + const bodyText = typeof body?.text === 'string' ? body.text : ''; + const bodyHtml = typeof body?.html === 'string' ? body.html : ''; + const parsedNotes = parseCreatorNotesText(bodyText); + const noteIds = parseCreatorNoteIdsFromHtml(bodyHtml); + notes = parsedNotes.map((note, index) => { + const id = noteIds[index] ?? ''; + return { + ...note, + id, + url: buildNoteDetailUrl(id), + }; + }); + if (notes.length >= limit || i === maxPageDowns) + break; + await page.pressKey('PageDown'); + await page.wait(1); + } + } + return notes.slice(0, limit); +} +cli({ + site: 'xiaohongshu', + name: 'creator-notes', + access: 'read', + description: '小红书创作者笔记列表 + 每篇数据 (标题/日期/观看/点赞/收藏/评论)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of notes to return' }, + ], + columns: ['rank', 'id', 'title', 'date', 'views', 'likes', 'collects', 'comments', 'url'], + func: async (page, kwargs) => { + const limit = kwargs.limit || 20; + const notes = await fetchCreatorNotes(page, limit); + if (!Array.isArray(notes) || notes.length === 0) { + throw new EmptyResultError('xiaohongshu creator-notes', 'No notes found. Ensure you are logged into creator.xiaohongshu.com and the account has published notes.'); + } + return notes + .slice(0, limit) + .map((n, i) => ({ + rank: i + 1, + id: n.id, + title: n.title, + date: n.date, + views: n.views, + likes: n.likes, + collects: n.collects, + comments: n.comments, + url: n.url, + })); + }, +}); +export const __test__ = { + harvestAnalyzeListCaptures, + isAnalyzeCaptureComplete, + parseCaptureMapPayload, + unwrapEvaluateResult, +}; diff --git a/clis/xiaohongshu/creator-notes.test.js b/clis/xiaohongshu/creator-notes.test.js new file mode 100644 index 0000000..22d380f --- /dev/null +++ b/clis/xiaohongshu/creator-notes.test.js @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { __test__, parseCreatorNoteIdsFromHtml, parseCreatorNotesText } from './creator-notes.js'; +import './creator-notes.js'; +function createPageMock(evaluateResult, interceptedRequests = []) { + const evaluate = Array.isArray(evaluateResult) + ? vi.fn() + .mockResolvedValueOnce(evaluateResult[0]) + .mockResolvedValue(evaluateResult[evaluateResult.length - 1]) + : vi.fn().mockResolvedValue(evaluateResult); + const getInterceptedRequests = Array.isArray(interceptedRequests) + ? vi.fn().mockResolvedValue(interceptedRequests) + : vi.fn().mockResolvedValue([]); + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests, + getCookies: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + }; +} +describe('xiaohongshu creator-notes', () => { + it('parses creator note text blocks into rows', () => { + const bodyText = `笔记管理 +全部笔记(366) +已发布 +测试笔记一 +发布于 2025年12月04日 19:45 +148208 +324 +2279 +465 +32 +权限设置 +取消置顶 +编辑 +删除 +仅自己可见 +测试笔记二 +发布于 2026年03月18日 12:39 +10 +0 +0 +0 +0 +权限设置`; + expect(parseCreatorNotesText(bodyText)).toEqual([ + { + id: '', + title: '测试笔记一', + date: '2025年12月04日 19:45', + views: 148208, + likes: 2279, + collects: 465, + comments: 324, + url: '', + }, + { + id: '', + title: '测试笔记二', + date: '2026年03月18日 12:39', + views: 10, + likes: 0, + collects: 0, + comments: 0, + url: '', + }, + ]); + }); + it('reads body text and returns ranked rows', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + undefined, + { + text: `示例笔记 +发布于 2026年03月19日 12:00 +10 +2 +3 +4 +5 +权限设置`, + html: '"noteId":"aaaaaaaaaaaaaaaaaaaaaaaa"', + }, + ]); + const result = await cmd.func(page, { limit: 1 }); + expect(page.evaluate.mock.calls.at(-1)?.[0]).toBe('() => ({ text: document.body.innerText, html: document.body.innerHTML })'); + expect(result).toEqual([ + { + rank: 1, + id: 'aaaaaaaaaaaaaaaaaaaaaaaa', + title: '示例笔记', + date: '2026年03月19日 12:00', + views: 10, + likes: 3, + collects: 4, + comments: 2, + url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=aaaaaaaaaaaaaaaaaaaaaaaa', + }, + ]); + }); + it('prefers note card dom data when the analyze api is unavailable', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock([ + undefined, + [ + { + id: 'bbbbbbbbbbbbbbbbbbbbbbbb', + title: '测试笔记一', + date: '2025年12月04日 19:45', + metrics: [148284, 319, 2280, 466, 33], + }, + ], + ]); + const result = await cmd.func(page, { limit: 1 }); + expect(result).toEqual([ + { + rank: 1, + id: 'bbbbbbbbbbbbbbbbbbbbbbbb', + title: '测试笔记一', + date: '2025年12月04日 19:45', + views: 148284, + likes: 2280, + collects: 466, + comments: 319, + url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=bbbbbbbbbbbbbbbbbbbbbbbb', + }, + ]); + }); + it('prefers the creator analyze API and preserves note ids', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes'); + expect(cmd?.func).toBeTypeOf('function'); + const page = createPageMock(undefined, [{ + data: { + note_infos: [ + { + id: 'cccccccccccccccccccccccc', + title: '示例内容复盘', + post_time: new Date('2026-03-18T20:01:00+08:00').getTime(), + read_count: 521, + like_count: 18, + fav_count: 10, + comment_count: 7, + }, + ], + }, + }]); + const result = await cmd.func(page, { limit: 1 }); + expect(page.installInterceptor.mock.calls[0][0]).toContain('/api/galaxy/creator/datacenter/note/analyze/list'); + expect(result).toEqual([ + { + rank: 1, + id: 'cccccccccccccccccccccccc', + title: '示例内容复盘', + date: '2026年03月18日 20:01', + views: 521, + likes: 18, + collects: 10, + comments: 7, + url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc', + }, + ]); + }); + it('extracts note ids from creator note-manager html', () => { + const html = ` +
"noteId":"aaaaaaaaaaaaaaaaaaaaaaaa"
+
"noteId":"dddddddddddddddddddddddd"
+
"noteId":"aaaaaaaaaaaaaaaaaaaaaaaa"
+ `; + expect(parseCreatorNoteIdsFromHtml(html)).toEqual([ + 'aaaaaaaaaaaaaaaaaaaaaaaa', + 'dddddddddddddddddddddddd', + ]); + }); + it('harvests captured analyze pages in page order and dedupes note ids', () => { + const captureMap = { + '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=2': { + ok: true, + body: JSON.stringify({ + data: { + total: 3, + note_infos: [ + { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', title: 'page 2' }, + { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'duplicate from page 2' }, + ], + }, + }), + }, + '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=1': { + ok: true, + body: JSON.stringify({ + data: { + total: 3, + note_infos: [ + { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'page 1' }, + ], + }, + }), + }, + }; + expect(__test__.harvestAnalyzeListCaptures(captureMap)).toEqual({ + total: 3, + items: [ + { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'page 1' }, + { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', title: 'page 2' }, + ], + }); + }); + it('treats incomplete captured pagination as fallback-needed instead of partial success', () => { + const firstPageItems = Array.from({ length: 10 }, (_, index) => ({ + id: String(index).padStart(24, '0'), + })); + expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 25, 20)).toBe(false); + expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 25, 10)).toBe(true); + expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 0, 20)).toBe(true); + }); + it('unwraps browser bridge capture-map envelopes', () => { + const captureMap = { + '/api/galaxy/creator/datacenter/note/analyze/list?page_num=1': { + ok: true, + body: '{"data":{"total":0,"note_infos":[]}}', + }, + }; + expect(__test__.parseCaptureMapPayload({ session: 'site:xiaohongshu', data: JSON.stringify(captureMap) })).toEqual(captureMap); + expect(__test__.parseCaptureMapPayload({ session: 'site:xiaohongshu', data: captureMap })).toEqual(captureMap); + }); + it('does not fall back to partial DOM rows when captured total proves pagination is incomplete', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes'); + const captureMap = { + '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=1': { + ok: true, + body: JSON.stringify({ + data: { + total: 25, + note_infos: Array.from({ length: 10 }, (_, index) => ({ + id: String(index).padStart(24, '0'), + title: `note ${index}`, + })), + }, + }), + }, + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(JSON.stringify(captureMap)) + .mockResolvedValueOnce(false); + + await expect(cmd.func(page, { limit: 20 })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws EmptyResultError when the creator account has no notes', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-notes'); + const page = createPageMock(undefined); + page.evaluate = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ text: '', html: '' }); + + await expect(cmd.func(page, { limit: 1 })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/xiaohongshu/creator-profile.js b/clis/xiaohongshu/creator-profile.js new file mode 100644 index 0000000..0e3c9b9 --- /dev/null +++ b/clis/xiaohongshu/creator-profile.js @@ -0,0 +1,55 @@ +/** + * Xiaohongshu Creator Profile — creator account info and growth status. + * + * Uses the creator.xiaohongshu.com internal API (cookie auth). + * Returns follower/following counts, total likes+collects, and + * creator level growth info. + * + * Requires: logged into creator.xiaohongshu.com in Chrome. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +cli({ + site: 'xiaohongshu', + name: 'creator-profile', + access: 'read', + description: '小红书创作者账号信息 (粉丝/关注/获赞/成长等级)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['field', 'value'], + func: async (page, _kwargs) => { + await page.goto('https://creator.xiaohongshu.com/new/home'); + const data = await page.evaluate(` + async () => { + try { + const resp = await fetch('/api/galaxy/creator/home/personal_info', { + credentials: 'include', + }); + if (!resp.ok) return { error: 'HTTP ' + resp.status }; + return await resp.json(); + } catch (e) { + return { error: e.message }; + } + } + `); + if (data?.error) { + throw new Error(data.error + '. Are you logged into creator.xiaohongshu.com?'); + } + if (!data?.data) { + throw new Error('Unexpected response structure'); + } + const d = data.data; + const grow = d.grow_info || {}; + return [ + { field: 'Name', value: d.name ?? '' }, + { field: 'Followers', value: d.fans_count ?? 0 }, + { field: 'Following', value: d.follow_count ?? 0 }, + { field: 'Likes & Collects', value: d.faved_count ?? 0 }, + { field: 'Creator Level', value: grow.level ?? 0 }, + { field: 'Level Progress', value: `${grow.fans_count ?? 0}/${grow.max_fans_count ?? 0} fans` }, + { field: 'Bio', value: (d.personal_desc ?? '').replace(/\\n/g, ' | ') }, + ]; + }, +}); diff --git a/clis/xiaohongshu/creator-stats.js b/clis/xiaohongshu/creator-stats.js new file mode 100644 index 0000000..38532db --- /dev/null +++ b/clis/xiaohongshu/creator-stats.js @@ -0,0 +1,76 @@ +/** + * Xiaohongshu Creator Analytics — account-level metrics overview. + * + * Uses the creator.xiaohongshu.com internal API (cookie auth). + * Returns 7-day and 30-day aggregate stats: views, likes, collects, + * comments, shares, new followers, and daily trend data. + * + * Requires: logged into creator.xiaohongshu.com in Chrome. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +cli({ + site: 'xiaohongshu', + name: 'creator-stats', + access: 'read', + description: '小红书创作者数据总览 (观看/点赞/收藏/评论/分享/涨粉,含每日趋势)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'period', + type: 'string', + default: 'seven', + help: 'Stats period: seven or thirty', + choices: ['seven', 'thirty'], + }, + ], + columns: ['metric', 'total', 'trend'], + func: async (page, kwargs) => { + const period = kwargs.period || 'seven'; + // Navigate to creator center for cookie context + await page.goto('https://creator.xiaohongshu.com/new/home'); + const data = await page.evaluate(` + async () => { + try { + const resp = await fetch('/api/galaxy/creator/data/note_detail_new', { + credentials: 'include', + }); + if (!resp.ok) return { error: 'HTTP ' + resp.status }; + return await resp.json(); + } catch (e) { + return { error: e.message }; + } + } + `); + if (data?.error) { + throw new Error(data.error + '. Are you logged into creator.xiaohongshu.com?'); + } + if (!data?.data) { + throw new Error('Unexpected response structure'); + } + const stats = data.data[period]; + if (!stats) { + throw new EmptyResultError('xiaohongshu creator-stats', `No data for period "${period}". Available: ${Object.keys(data.data).join(', ')}`); + } + // Format daily trend as sparkline-like summary + const formatTrend = (list) => { + if (!list || !list.length) + return '-'; + return list.map((d) => d.count).join(' → '); + }; + return [ + { metric: '观看数 (views)', total: stats.view_count ?? 0, trend: formatTrend(stats.view_list) }, + { metric: '平均观看时长 (avg view time ms)', total: stats.view_time_avg ?? 0, trend: formatTrend(stats.view_time_list) }, + { metric: '主页访问 (home views)', total: stats.home_view_count ?? 0, trend: formatTrend(stats.home_view_list) }, + { metric: '点赞数 (likes)', total: stats.like_count ?? 0, trend: formatTrend(stats.like_list) }, + { metric: '收藏数 (collects)', total: stats.collect_count ?? 0, trend: formatTrend(stats.collect_list) }, + { metric: '评论数 (comments)', total: stats.comment_count ?? 0, trend: formatTrend(stats.comment_list) }, + { metric: '弹幕数 (danmaku)', total: stats.danmaku_count ?? 0, trend: '-' }, + { metric: '分享数 (shares)', total: stats.share_count ?? 0, trend: formatTrend(stats.share_list) }, + { metric: '涨粉数 (new followers)', total: stats.rise_fans_count ?? 0, trend: formatTrend(stats.rise_fans_list) }, + ]; + }, +}); diff --git a/clis/xiaohongshu/creator-stats.test.js b/clis/xiaohongshu/creator-stats.test.js new file mode 100644 index 0000000..120baa1 --- /dev/null +++ b/clis/xiaohongshu/creator-stats.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './creator-stats.js'; + +describe('xiaohongshu creator-stats', () => { + it('throws EmptyResultError when the requested stats period has no data', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-stats'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ + data: { + seven: null, + thirty: { + view_count: 1, + view_list: [{ count: 1 }], + }, + }, + }), + }; + + await expect(cmd.func(page, { period: 'seven' })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/xiaohongshu/delete-note.js b/clis/xiaohongshu/delete-note.js new file mode 100644 index 0000000..200625d --- /dev/null +++ b/clis/xiaohongshu/delete-note.js @@ -0,0 +1,260 @@ +/** + * Xiaohongshu delete-note: remove a published note via creator center UI. + * + * Flow: + * 1. Navigate to creator note-manager + * 2. Switch to "已发布" tab (delete is only available on published notes; + * "审核中" and "未通过" rows do not expose a web delete entry, only mobile) + * 3. Locate the row whose `data-impression` JSON contains the target noteId + * 4. Click the inline `` action + * 5. Click "确定" in the `.d-modal-footer` confirmation modal + * 6. Poll for the row disappearing from the list + * + * Requires: logged into creator.xiaohongshu.com in Chrome. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +const NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager'; +const ROW_SETTLE_MS = 3000; +const MODAL_SETTLE_MS = 2000; +const VERIFY_TIMEOUT_MS = 10_000; +const VERIFY_POLL_MS = 1000; +const NOTE_ID_RE = /^[0-9a-f]{24}$/i; +function unwrapEvaluateResult(payload) { + if (payload && typeof payload === 'object' && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} +function requireEvaluateString(payload, context) { + if (typeof payload !== 'string') { + throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`); + } + return payload; +} +function requireEvaluateBoolean(payload, context) { + if (typeof payload !== 'boolean') { + throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`); + } + return payload; +} +function requireEvaluateObject(payload, context) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`); + } + return payload; +} +function requireActionResult(payload, context) { + const result = requireEvaluateObject(payload, context); + if (typeof result.ok !== 'boolean') { + throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`); + } + return result; +} +function isXiaohongshuHost(hostname) { + const host = String(hostname || '').toLowerCase(); + return host === 'xiaohongshu.com' || host.endsWith('.xiaohongshu.com'); +} +function isSupportedQueryNoteUrl(url) { + return url.hostname.toLowerCase() === 'creator.xiaohongshu.com' + && url.pathname.replace(/\/+$/, '') === '/statistics/note-detail'; +} +function normalizeNoteId(input) { + const raw = String(input ?? '').trim(); + if (!raw) { + throw new ArgumentError('xiaohongshu/delete-note: note-id cannot be empty'); + } + if (NOTE_ID_RE.test(raw)) + return raw.toLowerCase(); + if (!/^https:\/\//i.test(raw)) { + throw new ArgumentError('xiaohongshu/delete-note: note-id must be a 24-character Xiaohongshu note ID or an exact Xiaohongshu note URL'); + } + let url; + try { + url = new URL(raw); + } + catch { + throw new ArgumentError('xiaohongshu/delete-note: invalid note URL'); + } + if (url.protocol !== 'https:' || url.username || url.password || url.port || !isXiaohongshuHost(url.hostname)) { + throw new ArgumentError('xiaohongshu/delete-note: note URL must be an exact https://*.xiaohongshu.com URL'); + } + const queryId = url.searchParams.get('noteId') || url.searchParams.get('note_id'); + if (queryId && NOTE_ID_RE.test(queryId) && isSupportedQueryNoteUrl(url)) + return queryId.toLowerCase(); + const pathMatch = url.pathname.match(/^\/(?:explore|note|search_result|discovery\/item)\/([0-9a-f]{24})\/?$/i) + || url.pathname.match(/^\/user\/profile\/[^/?#]+\/([0-9a-f]{24})\/?$/i); + if (pathMatch) + return pathMatch[1].toLowerCase(); + throw new ArgumentError('xiaohongshu/delete-note: note URL must contain a 24-character note ID'); +} +function buildLocateAndMaybeDeleteScript(noteId, shouldClick) { + return ` + (cfg => { + const { targetId, shouldClick } = cfg; + const isVisible = (el) => !!el && el.offsetParent !== null; + const matchesNoteId = (impressionRaw) => { + if (!impressionRaw) return false; + try { + const parsed = JSON.parse(impressionRaw); + const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId; + return typeof id === 'string' && id === targetId; + } catch { + return false; + } + }; + const notes = Array.from(document.querySelectorAll('.note')).filter(isVisible); + for (const note of notes) { + if (matchesNoteId(note.getAttribute('data-impression'))) { + const del = note.querySelector('span.control.data-del'); + if (!del || !isVisible(del)) { + return { ok: false, kind: 'no_delete_action', visibleRows: notes.length }; + } + if (!shouldClick) { + return { ok: true, clicked: false }; + } + del.click(); + return { ok: true, clicked: true }; + } + } + return { ok: false, kind: 'not_found', visibleRows: notes.length }; + })(${JSON.stringify({ targetId: noteId, shouldClick })}) + `; +} +function buildVerifyGoneScript(noteId) { + return ` + (targetId => { + const matchesNoteId = (impressionRaw) => { + if (!impressionRaw) return false; + try { + const parsed = JSON.parse(impressionRaw); + const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId; + return typeof id === 'string' && id === targetId; + } catch { + return false; + } + }; + const notes = Array.from(document.querySelectorAll('.note')); + return notes.some((n) => matchesNoteId(n.getAttribute('data-impression'))); + })(${JSON.stringify(noteId)}) + `; +} +cli({ + site: 'xiaohongshu', + name: 'delete-note', + access: 'write', + description: '删除小红书已发布笔记 (creator center UI automation)', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { + name: 'note-id', + required: true, + positional: true, + help: 'Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)', + }, + { + name: 'execute', + type: 'boolean', + default: false, + help: 'Actually click delete + confirm. Default is dry-run target verification only.', + }, + ], + columns: ['status', 'note_id', 'message'], + func: async (page, kwargs) => { + try { + const noteId = normalizeNoteId(kwargs['note-id']); + const execute = kwargs.execute === true; + await page.goto(NOTE_MANAGER_URL); + await page.wait({ time: ROW_SETTLE_MS / 1000 }); + // Detect login redirect (creator.xiaohongshu.com bounces to /login on auth failure) + const currentUrl = requireEvaluateString(unwrapEvaluateResult(await page.evaluate('() => location.href')), 'current-url'); + if (typeof currentUrl === 'string' && /\/login(?:[/?#]|$)/i.test(new URL(currentUrl).pathname + new URL(currentUrl).search)) { + throw new AuthRequiredError('creator.xiaohongshu.com'); + } + // Step 1: ensure 已发布 tab is active (delete only exposed there). + const tabClicked = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(` + () => { + const isVisible = (el) => !!el && el.offsetParent !== null; + for (const el of document.querySelectorAll('a, button, [role="tab"], div')) { + const text = (el.innerText || el.textContent || '').trim(); + if (text === '已发布' && isVisible(el)) { + el.click(); + return true; + } + } + return false; + } + `)), 'published-tab'); + if (!tabClicked) { + throw new CommandExecutionError('xiaohongshu/delete-note: 已发布 tab not found on note-manager; xhs creator UI may have changed.'); + } + await page.wait({ time: ROW_SETTLE_MS / 1000 }); + // Step 2: locate the .note row whose data-impression JSON carries the + // exact `noteId` field. Dry-run stops here; execute clicks delete. + // Substring matching on the raw attribute would risk matching unrelated + // fields whose values happen to share the noteId prefix, so parse the JSON + // and compare `noteTarget.value.noteId` explicitly. + const initResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(buildLocateAndMaybeDeleteScript(noteId, execute))), 'locate-note'); + if (!initResult?.ok) { + if (initResult?.kind === 'not_found') { + throw new EmptyResultError('xiaohongshu/delete-note', `Note ${noteId} not visible in the 已发布 tab. Verify the note belongs to the logged-in account and has cleared review (审核中 / 未通过 rows have no web delete entry).`); + } + if (initResult?.kind === 'no_delete_action') { + throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} row found but no delete action visible; xhs creator UI may have changed.`); + } + throw new CommandExecutionError('xiaohongshu/delete-note: failed to locate note row'); + } + if (!execute) { + return [{ status: 'dry-run', note_id: noteId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' }]; + } + await page.wait({ time: MODAL_SETTLE_MS / 1000 }); + // Step 3: click "确定" in the `.d-modal-footer` confirmation modal. + const confirmResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(` + () => { + const isVisible = (el) => !!el && el.offsetParent !== null; + const footer = Array.from(document.querySelectorAll('.d-modal-footer')).find(isVisible); + if (!footer) return { ok: false, kind: 'no_modal' }; + const buttons = Array.from(footer.querySelectorAll('button, [role="button"]')).filter(isVisible); + const confirmBtn = buttons.find((b) => (b.innerText || b.textContent || '').trim() === '确定'); + if (!confirmBtn) return { ok: false, kind: 'no_confirm', labels: buttons.map(b => (b.innerText || '').trim()) }; + confirmBtn.click(); + return { ok: true }; + } + `)), 'confirm-modal'); + if (!confirmResult?.ok) { + throw new CommandExecutionError(`xiaohongshu/delete-note: confirmation modal step failed (${confirmResult?.kind ?? 'unknown'})`); + } + // Step 4: poll for row removal (proves the delete actually committed, + // not just the modal was clicked). Iteration-bounded rather than + // wall-clock so tests with a mocked `page.wait` exhaust the loop + // quickly instead of stalling on real time. + const VERIFY_ITERATIONS = Math.ceil(VERIFY_TIMEOUT_MS / VERIFY_POLL_MS); + let stillPresent = true; + for (let i = 0; i < VERIFY_ITERATIONS; i++) { + await page.wait({ time: VERIFY_POLL_MS / 1000 }); + const probe = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(buildVerifyGoneScript(noteId))), 'verify-gone'); + if (probe === false) { + stillPresent = false; + break; + } + } + if (stillPresent) { + throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} still visible after confirm click; deletion may not have committed.`); + } + return [{ status: 'deleted', note_id: noteId, message: 'Delete confirmed and note row disappeared.' }]; + } + catch (err) { + if (err instanceof CliError) + throw err; + throw new CommandExecutionError(`xiaohongshu/delete-note failed: ${err?.message ?? String(err)}`); + } + }, +}); +export const __test__ = { + normalizeNoteId, + buildLocateAndMaybeDeleteScript, + buildVerifyGoneScript, +}; diff --git a/clis/xiaohongshu/delete-note.test.js b/clis/xiaohongshu/delete-note.test.js new file mode 100644 index 0000000..f2c6c00 --- /dev/null +++ b/clis/xiaohongshu/delete-note.test.js @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +import { __test__ } from './delete-note.js'; + +function makePage(evaluateResults = []) { + const evaluate = vi.fn(); + for (const r of evaluateResults) evaluate.mockResolvedValueOnce(r); + evaluate.mockResolvedValue(undefined); + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate, + }; +} + +describe('xiaohongshu delete-note command', () => { + const getCommand = () => getRegistry().get('xiaohongshu/delete-note'); + const validId = '6a08ba0b000000000702a893'; + + it('returns deleted status when delete + confirm + verify all succeed', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', // currentUrl + true, // 已发布 tab click + { ok: true, clicked: true }, // initResult: row found + delete clicked + { ok: true }, // confirmResult + false, // verify probe: row gone + ]); + const result = await getCommand().func(page, { 'note-id': validId, execute: true }); + expect(result).toEqual([ + { status: 'deleted', note_id: validId, message: 'Delete confirmed and note row disappeared.' }, + ]); + expect(page.goto).toHaveBeenCalledWith('https://creator.xiaohongshu.com/new/note-manager'); + }); + + it('dry-runs by default after verifying the exact target and delete affordance', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { ok: true, clicked: false }, + ]); + const result = await getCommand().func(page, { 'note-id': validId }); + expect(result).toEqual([ + { status: 'dry-run', note_id: validId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' }, + ]); + expect(page.evaluate).toHaveBeenCalledTimes(3); + }); + + it('unwraps browser bridge envelopes at every evaluate boundary', async () => { + const page = makePage([ + { session: 's', data: 'https://creator.xiaohongshu.com/new/note-manager' }, + { session: 's', data: true }, + { session: 's', data: { ok: true, clicked: true } }, + { session: 's', data: { ok: true } }, + { session: 's', data: false }, + ]); + const result = await getCommand().func(page, { 'note-id': validId, execute: true }); + expect(result[0]).toMatchObject({ status: 'deleted', note_id: validId }); + }); + + it('normalizes exact Xiaohongshu note IDs from supported URL forms', () => { + expect(__test__.normalizeNoteId(validId.toUpperCase())).toBe(validId); + expect(__test__.normalizeNoteId(`https://www.xiaohongshu.com/explore/${validId}?xsec_token=t`)).toBe(validId); + expect(__test__.normalizeNoteId(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${validId}`)).toBe(validId); + }); + + it('throws ArgumentError for missing or ambiguous note identity before navigation', async () => { + const page = makePage(); + await expect(getCommand().func(page, { 'note-id': '' })).rejects.toBeInstanceOf(ArgumentError); + await expect(getCommand().func(page, { 'note-id': ' ' })).rejects.toBeInstanceOf(ArgumentError); + await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toBeInstanceOf(ArgumentError); + await expect(getCommand().func(page, { 'note-id': 'https://evil.com/explore/6a08ba0b000000000702a893' })).rejects.toBeInstanceOf(ArgumentError); + await expect(getCommand().func(page, { 'note-id': 'https://xhslink.com/abc' })).rejects.toBeInstanceOf(ArgumentError); + await expect(getCommand().func(page, { 'note-id': `https://www.xiaohongshu.com/anything?noteId=${validId}` })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws CommandExecutionError for malformed evaluate payloads instead of trusting truthy objects', async () => { + await expect(getCommand().func(makePage([ + { session: 's', data: { href: 'https://creator.xiaohongshu.com/new/note-manager' } }, + ]), { 'note-id': validId })).rejects.toThrowError(/malformed current-url payload/); + + await expect(getCommand().func(makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + { session: 's', data: { ok: true } }, + ]), { 'note-id': validId })).rejects.toThrowError(/malformed published-tab payload/); + + await expect(getCommand().func(makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { session: 's', data: { ok: 'yes', clicked: false } }, + ]), { 'note-id': validId })).rejects.toThrowError(/malformed locate-note payload/); + }); + + it('throws AuthRequiredError when redirected to login', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/login?redirectReason=401', + ]); + await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('throws CommandExecutionError when 已发布 tab cannot be clicked (UI drift)', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + false, // tab click returns false + ]); + await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/已发布 tab not found/); + }); + + it('throws EmptyResultError when the note row is not in the 已发布 tab', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { ok: false, kind: 'not_found', visibleRows: 0 }, + ]); + await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('throws CommandExecutionError when the row has no visible delete action', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { ok: false, kind: 'no_delete_action', visibleRows: 1 }, + ]); + await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/no delete action/i); + }); + + it('throws CommandExecutionError when the confirmation modal does not appear', async () => { + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { ok: true }, + { ok: false, kind: 'no_modal' }, + ]); + await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/no_modal/); + }); + + it('throws CommandExecutionError when row stays visible after confirm (delete did not commit)', async () => { + // verify probes return true (note still present) for the entire poll window. + const probes = Array(15).fill(true); + const page = makePage([ + 'https://creator.xiaohongshu.com/new/note-manager', + true, + { ok: true }, + { ok: true }, + ...probes, + ]); + await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/still visible after confirm/i); + }); + + it('executes the generated row locator without substring-matching other impression fields', () => { + const otherId = '6a08ba0b000000000702a894'; + const dom = new JSDOM(` +
+ 删除 +
+
+ 删除 +
+ `, { runScripts: 'outside-only' }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', { + configurable: true, + get() { + return this.ownerDocument.body; + }, + }); + const result = dom.window.eval(__test__.buildLocateAndMaybeDeleteScript(validId, false)); + expect(result).toEqual({ ok: true, clicked: false }); + }); +}); diff --git a/clis/xiaohongshu/download.js b/clis/xiaohongshu/download.js new file mode 100644 index 0000000..a9df7fa --- /dev/null +++ b/clis/xiaohongshu/download.js @@ -0,0 +1,249 @@ +/** + * Xiaohongshu download — download images and videos from a note. + * + * Usage: + * opencli xiaohongshu download --output ./xhs + * + * Accepts a full xiaohongshu.com URL with xsec_token or an xhslink short link. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { formatCookieHeader } from '@jackwener/opencli/download'; +import { downloadMedia } from '@jackwener/opencli/download/media-download'; +import { CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { buildNoteUrl, parseNoteId } from './note-helpers.js'; +/** + * Build the media-extraction IIFE. The note id is interpolated as a default + * since the IIFE may also resolve it from `location.pathname`. The CDN + * substring allowlist includes `rednote` so the rednote adapter can reuse + * this script unchanged — image / video URLs on both sites are served from + * the same xhscdn family per #1136. + */ +export function buildDownloadExtractJs(noteId) { + return ` + (() => { + const bodyText = document.body?.innerText || ''; + const result = { + noteId: '${noteId}', + pageUrl: location.href, + securityBlock: /安全限制|访问链接异常/.test(bodyText) + || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href), + title: '', + author: '', + media: [] + }; + const seenMedia = new Set(); + const pushMedia = (type, url) => { + if (!url) return; + const key = type + ':' + url; + if (seenMedia.has(key)) return; + seenMedia.add(key); + result.media.push({ type, url }); + }; + const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)|\\/user\\/profile\\/[^/?#]+\\/([a-f0-9]+)/i); + if (locationMatch) { + result.noteId = locationMatch[1] || locationMatch[2]; + } + + // Get title + const titleEl = document.querySelector('.title, #detail-title, .note-content .title'); + result.title = titleEl?.textContent?.trim() || 'untitled'; + + // Get author + const authorEl = document.querySelector('.username, .author-name, .name'); + result.author = authorEl?.textContent?.trim() || 'unknown'; + + // Get images: prefer canonical carousel order from __INITIAL_STATE__ + // so the saved order matches what the user sees on the platform (#1514). + // DOM extraction is used only as a fallback because multiple selectors, + // hidden / duplicated / preloaded slides, and lazy rendering can reorder + // the discovered nodes away from the platform's display order. + + const normalizeImageUrl = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + let src = raw.split('?')[0]; + src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, ''); + return src; + }; + const orderedImageUrls = []; + const seenImageUrls = new Set(); + const pushImage = (url) => { + if (!url || seenImageUrls.has(url)) return; + seenImageUrls.add(url); + orderedImageUrls.push(url); + }; + + const getStructuredNotes = () => { + const state = window.__INITIAL_STATE__; + const noteData = state?.note?.noteDetailMap || state?.note?.note || {}; + if (!noteData || typeof noteData !== 'object') return []; + const currentIds = [...new Set([result.noteId, '${noteId}'].filter(Boolean))]; + const notes = []; + for (const id of currentIds) { + const entry = noteData[id]; + const note = entry?.note || entry; + if (note && typeof note === 'object') notes.push(note); + } + // Compatibility fallback for legacy single-note stores. Do not use this + // when keyed detail maps contain multiple notes, or carousel order can + // be polluted by preloaded/previous note entries. + const keys = Object.keys(noteData); + if (notes.length === 0 && keys.length === 1) { + const entry = noteData[keys[0]]; + const note = entry?.note || entry; + if (note && typeof note === 'object') notes.push(note); + } + return notes; + }; + + // Method 1: walk __INITIAL_STATE__.note.noteDetailMap[id].note.imageList + // in array order. Each entry exposes urlDefault as the canonical CDN URL. + let imageInitialStateUsed = false; + try { + for (const note of getStructuredNotes()) { + const list = Array.isArray(note?.imageList) ? note.imageList : []; + for (const item of list) { + const candidate = item?.urlDefault || item?.urlPre || item?.url + || item?.infoList?.find(i => i?.imageScene === 'WB_DFT')?.url + || item?.infoList?.[0]?.url + || ''; + const src = normalizeImageUrl(candidate); + if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) { + pushImage(src); + imageInitialStateUsed = true; + } + } + } + } catch(e) {} + + // Method 2: fallback to DOM scraping when the structured state is missing + // (e.g. preview pages without full SSR hydration). Order may differ from + // the carousel; surface it anyway rather than returning zero images. + if (!imageInitialStateUsed) { + const imageSelectors = [ + '.swiper-slide img', + '.carousel-image img', + '.note-slider img', + '.note-image img', + '.image-wrapper img', + '#noteContainer .media-container img[src*="xhscdn"]', + 'img[src*="ci.xiaohongshu.com"]' + ]; + for (const selector of imageSelectors) { + document.querySelectorAll(selector).forEach(img => { + const raw = img.src || img.getAttribute('data-src') || ''; + const src = normalizeImageUrl(raw); + if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) { + pushImage(src); + } + }); + } + } + + // Get video — prefer real URL from page state over blob: URLs + + // Method 1: Extract from __INITIAL_STATE__ (SSR hydration data) + try { + for (const note of getStructuredNotes()) { + const video = note?.video; + if (video) { + const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey; + if (vUrl) { + const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl; + pushMedia('video', fullUrl); + } + const streams = video.media?.stream?.h264 || []; + for (const stream of streams) { + if (stream.masterUrl) pushMedia('video', stream.masterUrl); + } + } + } + } catch(e) {} + + // Method 2: Extract video URLs from inline script JSON + if (result.media.filter(m => m.type === 'video').length === 0) { + try { + const scripts = document.querySelectorAll('script'); + for (const s of scripts) { + const text = s.textContent || ''; + const videoMatches = text.match(/https?:\\/\\/sns-video[^"'\\s]+\\.mp4[^"'\\s]*/g) + || text.match(/https?:\\/\\/[^"'\\s]*xhscdn[^"'\\s]*\\.mp4[^"'\\s]*/g); + if (videoMatches) { + videoMatches.forEach(url => { + pushMedia('video', url.replace(/\\\\u002F/g, '/')); + }); + } + } + } catch(e) {} + } + + // Method 3: Fallback to DOM video elements, skip blob: URLs + if (result.media.filter(m => m.type === 'video').length === 0) { + const videoSelectors = [ + 'video source', + 'video[src]', + '.player video', + '.video-player video' + ]; + for (const selector of videoSelectors) { + document.querySelectorAll(selector).forEach(v => { + const src = v.src || v.getAttribute('src') || ''; + if (src && !src.startsWith('blob:')) { + pushMedia('video', src); + } + }); + } + } + + // Preserve the pre-existing media type order (videos first, then images) + // while keeping image carousel order stable within the image batch. + orderedImageUrls.forEach(url => pushMedia('image', url)); + + return result; + })() + `; +} +export const command = cli({ + site: 'xiaohongshu', + name: 'download', + access: 'read', + description: '下载小红书笔记中的图片和视频', + domain: 'www.xiaohongshu.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' }, + { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' }, + ], + columns: ['index', 'type', 'status', 'size'], + func: async (page, kwargs) => { + const rawInput = String(kwargs['note-id']); + const output = kwargs.output; + const noteId = parseNoteId(rawInput); + await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' })); + await page.wait({ time: 1 + Math.random() * 2 }); + const data = await page.evaluate(buildDownloadExtractJs(noteId)); + if (data?.securityBlock) { + throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput) + ? 'The page may be temporarily restricted. Try again later or from a different session.' + : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.'); + } + if (!data || typeof data !== 'object' || !Array.isArray(data.media)) { + throw new CommandExecutionError('Xiaohongshu media extraction returned malformed payload.'); + } + if (data.media.length === 0) { + throw new EmptyResultError('xiaohongshu download', 'No downloadable media found on this note.'); + } + // Extract cookies for authenticated downloads + const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' })); + const resolvedNoteId = typeof data.noteId === 'string' && data.noteId.trim() + ? data.noteId.trim() + : noteId; + return downloadMedia(data.media, { + output, + subdir: resolvedNoteId, + cookies, + filenamePrefix: resolvedNoteId, + timeout: 60000, + }); + }, +}); diff --git a/clis/xiaohongshu/download.test.js b/clis/xiaohongshu/download.test.js new file mode 100644 index 0000000..b52c850 --- /dev/null +++ b/clis/xiaohongshu/download.test.js @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const { mockDownloadMedia, mockFormatCookieHeader } = vi.hoisted(() => ({ + mockDownloadMedia: vi.fn(), + mockFormatCookieHeader: vi.fn(() => 'a=b'), +})); +vi.mock('@jackwener/opencli/download/media-download', () => ({ + downloadMedia: mockDownloadMedia, +})); +vi.mock('@jackwener/opencli/download', () => ({ + formatCookieHeader: mockFormatCookieHeader, +})); +import { getRegistry } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { JSDOM } from 'jsdom'; +import './download.js'; +import { buildDownloadExtractJs } from './download.js'; +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: '.xiaohongshu.com' }]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + }; +} +describe('xiaohongshu download', () => { + const command = getRegistry().get('xiaohongshu/download'); + beforeEach(() => { + mockDownloadMedia.mockReset(); + mockFormatCookieHeader.mockClear(); + mockDownloadMedia.mockResolvedValue([{ index: 1, type: 'video', status: 'success', size: '1 MB' }]); + }); + it('preserves short links for navigation but uses canonical note id for output naming', async () => { + const page = createPageMock({ + noteId: '69bc166f000000001a02069a', + media: [{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }], + }); + const shortUrl = 'http://xhslink.com/o/4MKEjsZnhCz'; + await command.func(page, { 'note-id': shortUrl, output: './out' }); + expect(page.goto.mock.calls[0][0]).toBe(shortUrl); + expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }], expect.objectContaining({ + output: './out', + subdir: '69bc166f000000001a02069a', + filenamePrefix: '69bc166f000000001a02069a', + cookies: 'a=b', + })); + }); + it('preserves full note URL with xsec_token for navigation', async () => { + const page = createPageMock({ + noteId: '69bc166f000000001a02069a', + media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], + }); + const fullUrl = 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search'; + await command.func(page, { 'note-id': fullUrl, output: './out' }); + expect(page.goto.mock.calls[0][0]).toBe(fullUrl); + expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({ + subdir: '69bc166f000000001a02069a', + filenamePrefix: '69bc166f000000001a02069a', + })); + }); + it('uses canonical note id for signed user profile note URLs', async () => { + const page = createPageMock({ + noteId: '', + media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], + }); + const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_user'; + await command.func(page, { 'note-id': fullUrl, output: './out' }); + expect(page.goto.mock.calls[0][0]).toBe(fullUrl); + expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({ + subdir: '69bc166f000000001a02069a', + filenamePrefix: '69bc166f000000001a02069a', + })); + }); + it('rejects bare note IDs before browser navigation', async () => { + const page = createPageMock({ + noteId: '69bc166f000000001a02069a', + media: [], + }); + await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({ + code: 'ARGUMENT', + message: expect.stringContaining('signed URL'), + hint: expect.stringContaining('xsec_token'), + }); + expect(page.goto).not.toHaveBeenCalled(); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + }); + it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => { + const page = createPageMock({ + pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031', + securityBlock: true, + noteId: '69bc166f000000001a02069a', + media: [], + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search', + output: './out', + })).rejects.toMatchObject({ + code: 'SECURITY_BLOCK', + hint: expect.stringContaining('Try again later'), + }); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + }); + it('throws EmptyResultError when extraction returns an explicit empty media array', async () => { + const page = createPageMock({ + noteId: '69bc166f000000001a02069a', + media: [], + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc', + output: './out', + })).rejects.toBeInstanceOf(EmptyResultError); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + }); + it('throws CommandExecutionError when extraction media shape is malformed', async () => { + const page = createPageMock({ + noteId: '69bc166f000000001a02069a', + media: { url: 'https://ci.xiaohongshu.com/example.jpg' }, + }); + await expect(command.func(page, { + 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc', + output: './out', + })).rejects.toBeInstanceOf(CommandExecutionError); + expect(mockDownloadMedia).not.toHaveBeenCalled(); + }); +}); + +describe('xiaohongshu download buildDownloadExtractJs carousel ordering (JSDOM)', () => { + function runExtract({ html = '', initialState = null, url = 'https://www.xiaohongshu.com/explore/69f9716c000000003601f90e' } = {}) { + const dom = new JSDOM(`${html}`, { url, runScripts: 'outside-only' }); + if (initialState) { + dom.window.__INITIAL_STATE__ = initialState; + } + const js = buildDownloadExtractJs('69f9716c000000003601f90e'); + return dom.window.eval(js); + } + + it('preserves carousel order from __INITIAL_STATE__ imageList over DOM discovery order', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-cover.jpg' }, + { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-second.jpg' }, + { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-third.jpg' }, + ], + }, + }, + }, + }, + }; + // DOM has the same images but in a DIFFERENT order: repro for #1514. + const html = ` +
+
+
+ `; + const result = runExtract({ html, initialState }); + const images = result.media.filter((m) => m.type === 'image'); + expect(images.map((m) => m.url)).toEqual([ + 'https://sns-img-bd.xhscdn.com/canonical-cover.jpg', + 'https://sns-img-bd.xhscdn.com/canonical-second.jpg', + 'https://sns-img-bd.xhscdn.com/canonical-third.jpg', + ]); + }); + + it('prefers urlDefault but falls back to urlPre / url / infoList.WB_DFT / infoList[0]', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/a.jpg' }, + { urlPre: 'https://sns-img-bd.xhscdn.com/b.jpg' }, + { url: 'https://sns-img-bd.xhscdn.com/c.jpg' }, + { infoList: [{ imageScene: 'WB_PRV', url: 'https://sns-img-bd.xhscdn.com/d-low.jpg' }, { imageScene: 'WB_DFT', url: 'https://sns-img-bd.xhscdn.com/d.jpg' }] }, + { infoList: [{ url: 'https://sns-img-bd.xhscdn.com/e.jpg' }] }, + ], + }, + }, + }, + }, + }; + const result = runExtract({ initialState }); + const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url); + expect(urls).toEqual([ + 'https://sns-img-bd.xhscdn.com/a.jpg', + 'https://sns-img-bd.xhscdn.com/b.jpg', + 'https://sns-img-bd.xhscdn.com/c.jpg', + 'https://sns-img-bd.xhscdn.com/d.jpg', + 'https://sns-img-bd.xhscdn.com/e.jpg', + ]); + }); + + it('strips imageView resize params + query strings from canonical urls', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/raw/imageView2/2/w/1080/cover.jpg?expires=123' }, + ], + }, + }, + }, + }, + }; + const result = runExtract({ initialState }); + const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url); + expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/raw/cover.jpg']); + }); + + it('falls back to DOM extraction when __INITIAL_STATE__ omits imageList', () => { + const html = ` +
+
+ `; + const result = runExtract({ html }); + const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url); + expect(urls).toEqual([ + 'https://sns-img-bd.xhscdn.com/dom-1.jpg', + 'https://sns-img-bd.xhscdn.com/dom-2.jpg', + ]); + }); + + it('skips non-xhscdn / non-xiaohongshu / non-rednote urls in initial state', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/keep.jpg' }, + { urlDefault: 'https://imgur.com/drop.jpg' }, + { urlDefault: '' }, + ], + }, + }, + }, + }, + }; + const result = runExtract({ initialState }); + const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url); + expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/keep.jpg']); + }); + + it('does not run DOM fallback when initial state yielded any image (preserves canonical order)', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-only.jpg' }, + ], + }, + }, + }, + }, + }; + const html = ` +
+ `; + const result = runExtract({ html, initialState }); + const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url); + expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/canonical-only.jpg']); + }); + + it('uses only the current note entry from multi-note initial state maps', () => { + const initialState = { + note: { + noteDetailMap: { + othernote0000000000000001: { + note: { + imageList: [{ urlDefault: 'https://sns-img-bd.xhscdn.com/other.jpg' }], + video: { url: 'https://sns-video-bd.xhscdn.com/other.mp4' }, + }, + }, + '69f9716c000000003601f90e': { + note: { + imageList: [ + { urlDefault: 'https://sns-img-bd.xhscdn.com/current-1.jpg' }, + { urlDefault: 'https://sns-img-bd.xhscdn.com/current-2.jpg' }, + ], + video: { url: 'https://sns-video-bd.xhscdn.com/current.mp4' }, + }, + }, + }, + }, + }; + const result = runExtract({ initialState }); + const images = result.media.filter((m) => m.type === 'image').map((m) => m.url); + const videos = result.media.filter((m) => m.type === 'video').map((m) => m.url); + expect(images).toEqual([ + 'https://sns-img-bd.xhscdn.com/current-1.jpg', + 'https://sns-img-bd.xhscdn.com/current-2.jpg', + ]); + expect(videos).toEqual(['https://sns-video-bd.xhscdn.com/current.mp4']); + }); + + it('still resolves videos from __INITIAL_STATE__ alongside the image fix', () => { + const initialState = { + note: { + noteDetailMap: { + '69f9716c000000003601f90e': { + note: { + imageList: [{ urlDefault: 'https://sns-img-bd.xhscdn.com/cover.jpg' }], + video: { url: 'https://sns-video-bd.xhscdn.com/test.mp4' }, + }, + }, + }, + }, + }; + const result = runExtract({ initialState }); + const images = result.media.filter((m) => m.type === 'image').map((m) => m.url); + const videos = result.media.filter((m) => m.type === 'video').map((m) => m.url); + expect(images).toEqual(['https://sns-img-bd.xhscdn.com/cover.jpg']); + expect(videos).toEqual(['https://sns-video-bd.xhscdn.com/test.mp4']); + expect(result.media.map((m) => m.type)).toEqual(['video', 'image']); + }); +}); diff --git a/clis/xiaohongshu/draft-clear.js b/clis/xiaohongshu/draft-clear.js new file mode 100644 index 0000000..81faa76 --- /dev/null +++ b/clis/xiaohongshu/draft-clear.js @@ -0,0 +1,91 @@ +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + DRAFT_DB_NAME, + STORE_NAME_MAP, + ensureDraftDbPage, + normalizeDraftType, + unwrapBrowserResult, +} from './draft-utils.js'; + +function clearDraftsScript(storeNames, execute) { + return ` + (async () => { + const openDb = () => new Promise((resolve, reject) => { + const req = indexedDB.open(${JSON.stringify(DRAFT_DB_NAME)}); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error || new Error('failed to open IndexedDB')); + }); + const countStore = (db, storeName) => new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(storeName)) return resolve(0); + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const req = store.count(); + req.onsuccess = () => resolve(req.result || 0); + req.onerror = () => reject(req.error || new Error('failed to count drafts')); + tx.onerror = () => reject(tx.error || new Error('failed to count drafts')); + }); + const clearStore = (db, storeName) => new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(storeName)) return resolve(true); + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + const req = store.clear(); + req.onsuccess = () => resolve(true); + req.onerror = () => reject(req.error || new Error('failed to clear drafts')); + tx.onerror = () => reject(tx.error || new Error('failed to clear drafts')); + }); + try { + const db = await openDb(); + const storeNames = ${JSON.stringify(storeNames)}; + let before = 0; + for (const storeName of storeNames) before += await countStore(db, storeName); + if (${JSON.stringify(execute)}) { + for (const storeName of storeNames) await clearStore(db, storeName); + let after = 0; + for (const storeName of storeNames) after += await countStore(db, storeName); + db.close(); + return { ok: true, before, after, cleared: before - after }; + } + db.close(); + return { ok: true, before, after: before, cleared: 0 }; + } catch (error) { + return { ok: false, error: String(error && error.message || error) }; + } + })() + `; +} + +cli({ + site: 'xiaohongshu', + name: 'draft-clear', + access: 'write', + description: '清空小红书本地草稿', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'type', default: 'image', help: 'Draft type: image, video, article, audio, all' }, + { name: 'execute', type: 'bool', default: false, help: 'Actually clear local drafts. Default is dry-run count only.' }, + ], + columns: ['status', 'type', 'count', 'message'], + func: async (page, kwargs) => { + const draftType = normalizeDraftType(kwargs.type, { allowAll: true }); + const execute = kwargs.execute === true; + const storeNames = draftType === 'all' ? Object.values(STORE_NAME_MAP) : [STORE_NAME_MAP[draftType]]; + await ensureDraftDbPage(page); + const result = unwrapBrowserResult(await page.evaluate(clearDraftsScript(storeNames, execute))); + if (!result?.ok) { + throw new CommandExecutionError(result?.error || `Failed to ${execute ? 'clear' : 'count'} ${draftType} drafts`); + } + if (execute && Number(result.after) !== 0) { + throw new CommandExecutionError(`${result.after} ${draftType} drafts still exist after clear`); + } + return [{ + status: execute ? 'cleared' : 'dry-run', + type: draftType, + count: execute ? Number(result.cleared || 0) : Number(result.before || 0), + message: execute ? 'Drafts cleared.' : 'Drafts counted. Re-run with --execute to clear.', + }]; + }, +}); diff --git a/clis/xiaohongshu/draft-delete.js b/clis/xiaohongshu/draft-delete.js new file mode 100644 index 0000000..b6a7d76 --- /dev/null +++ b/clis/xiaohongshu/draft-delete.js @@ -0,0 +1,103 @@ +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + DRAFT_DB_NAME, + STORE_NAME_MAP, + draftNotFound, + ensureDraftDbPage, + findDraftEntry, + normalizeDraftId, + normalizeDraftRecord, + normalizeDraftType, + readDraftEntries, + unwrapBrowserResult, +} from './draft-utils.js'; + +function deleteDraftScript(storeName, key) { + return ` + (async () => { + const openDb = () => new Promise((resolve, reject) => { + const req = indexedDB.open(${JSON.stringify(DRAFT_DB_NAME)}); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error || new Error('failed to open IndexedDB')); + }); + try { + const db = await openDb(); + if (!db.objectStoreNames.contains(${JSON.stringify(storeName)})) { + db.close(); + return { ok: false, error: 'draft store not found' }; + } + const key = ${JSON.stringify(key)}; + await new Promise((resolve, reject) => { + const tx = db.transaction(${JSON.stringify(storeName)}, 'readwrite'); + const store = tx.objectStore(${JSON.stringify(storeName)}); + const req = store.delete(key); + req.onsuccess = () => resolve(true); + req.onerror = () => reject(req.error || new Error('failed to delete draft')); + tx.onerror = () => reject(tx.error || new Error('failed to delete draft')); + }); + const after = await new Promise((resolve, reject) => { + const tx = db.transaction(${JSON.stringify(storeName)}, 'readonly'); + const store = tx.objectStore(${JSON.stringify(storeName)}); + const req = store.get(key); + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => reject(req.error || new Error('failed to verify draft delete')); + tx.onerror = () => reject(tx.error || new Error('failed to verify draft delete')); + }); + db.close(); + return { ok: true, deleted: after === null }; + } catch (error) { + return { ok: false, error: String(error && error.message || error) }; + } + })() + `; +} + +cli({ + site: 'xiaohongshu', + name: 'draft-delete', + access: 'write', + description: '删除一条小红书本地草稿', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', positional: true, required: true, help: 'Draft id returned by `opencli xiaohongshu drafts`' }, + { name: 'type', default: 'image', help: 'Draft type: image, video, article, audio' }, + { name: 'execute', type: 'bool', default: false, help: 'Actually delete the local draft. Default is dry-run verification only.' }, + ], + columns: ['status', 'id', 'type', 'message'], + func: async (page, kwargs) => { + const id = normalizeDraftId(kwargs.id); + const draftType = normalizeDraftType(kwargs.type); + const execute = kwargs.execute === true; + await ensureDraftDbPage(page); + const entries = await readDraftEntries(page, draftType); + const entry = findDraftEntry(entries, id); + if (!entry) throw draftNotFound(id, draftType, 'xiaohongshu/draft-delete'); + const row = normalizeDraftRecord(entry.row, entry.key, draftType, 1); + if (!execute) { + return [{ + status: 'dry-run', + id: row.id, + type: draftType, + message: 'Draft exists. Re-run with --execute to delete.', + }]; + } + const storeName = STORE_NAME_MAP[draftType]; + const result = unwrapBrowserResult(await page.evaluate(deleteDraftScript(storeName, entry.key))); + if (!result?.ok) { + throw new CommandExecutionError(result?.error || `Failed to delete draft ${row.id}`); + } + if (!result.deleted) { + throw new CommandExecutionError(`Draft ${row.id} still exists after delete`); + } + return [{ + status: 'deleted', + id: row.id, + type: draftType, + message: 'Draft deleted.', + }]; + }, +}); diff --git a/clis/xiaohongshu/draft-open.js b/clis/xiaohongshu/draft-open.js new file mode 100644 index 0000000..4678721 --- /dev/null +++ b/clis/xiaohongshu/draft-open.js @@ -0,0 +1,43 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + draftNotFound, + ensureDraftDbPage, + findDraftEntry, + normalizeDraftId, + normalizeDraftRecord, + normalizeDraftType, + readDraftEntries, +} from './draft-utils.js'; + +cli({ + site: 'xiaohongshu', + name: 'draft-open', + access: 'read', + description: '读取一条小红书本地草稿详情', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', positional: true, required: true, help: 'Draft id returned by `opencli xiaohongshu drafts`' }, + { name: 'type', default: 'image', help: 'Draft type: image, video, article, audio' }, + ], + columns: ['id', 'type', 'title', 'updated_at', 'images', 'content'], + func: async (page, kwargs) => { + const id = normalizeDraftId(kwargs.id); + const draftType = normalizeDraftType(kwargs.type); + await ensureDraftDbPage(page); + const entries = await readDraftEntries(page, draftType); + const entry = findDraftEntry(entries, id); + if (!entry) throw draftNotFound(id, draftType, 'xiaohongshu/draft-open'); + const row = normalizeDraftRecord(entry.row, entry.key, draftType, 1, { contentLimit: 500 }); + return [{ + id: row.id, + type: row.type, + title: row.title, + updated_at: row.updated_at, + images: row.images, + content: row.text_preview, + }]; + }, +}); diff --git a/clis/xiaohongshu/draft-utils.js b/clis/xiaohongshu/draft-utils.js new file mode 100644 index 0000000..45e0595 --- /dev/null +++ b/clis/xiaohongshu/draft-utils.js @@ -0,0 +1,142 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +export const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_left&target=image'; +export const DRAFT_DB_NAME = 'draft-database-v1'; +export const STORE_NAME_MAP = { + image: 'image-draft', + video: 'video-draft', + article: 'article-draft', + audio: 'audio-draft', +}; + +export function unwrapBrowserResult(value) { + if ( + value + && typeof value === 'object' + && typeof value.session === 'string' + && Object.prototype.hasOwnProperty.call(value, 'data') + ) { + return value.data; + } + return value; +} + +export function normalizeDraftType(value, { allowAll = false } = {}) { + const raw = String(value ?? 'image').trim().toLowerCase(); + if (allowAll && raw === 'all') return raw; + if (!STORE_NAME_MAP[raw]) { + const choices = allowAll ? 'image, video, article, audio, all' : Object.keys(STORE_NAME_MAP).join(', '); + throw new ArgumentError(`Unsupported draft type "${raw}". Expected one of: ${choices}`); + } + return raw; +} + +export function normalizeDraftId(value) { + const id = String(value ?? '').trim(); + if (!id) throw new ArgumentError('Draft id is required'); + return id; +} + +export function encodeDraftKey(key) { + const type = typeof key; + if (type === 'string') return `s:${key}`; + if (type === 'number') return `n:${String(key)}`; + if (type === 'boolean') return `b:${String(key)}`; + try { + return `j:${encodeURIComponent(JSON.stringify(key))}`; + } + catch { + return `s:${String(key)}`; + } +} + +export function findDraftEntry(entries, id) { + return entries.find((entry) => encodeDraftKey(entry?.key) === id) || null; +} + +export function normalizeDraftRecord(row, key, type, rank, { contentLimit = 80 } = {}) { + const content = row?.content ?? {}; + const liveContext = content?.contextStore?.liveContext ?? {}; + const draftStore = content?.draftStore ?? {}; + const title = draftStore?.title || liveContext?.title || row?.title || row?.noteTitle || ''; + const editorText = content?.editorContent?.text || content?.editorContent?.plainText || ''; + const updatedAt = row?.updatedAt || row?.updateTime || row?.mtime || content?.updateTime || ''; + const imageCount = draftStore?.imgList?.length + || content?.noteImageConfig?.items?.length + || content?.noteImageConfig?.imageList?.length + || row?.images?.length + || row?.imageList?.length + || 0; + return { + rank, + id: encodeDraftKey(key), + type, + title: title || '(untitled)', + updated_at: updatedAt ? String(updatedAt) : '', + images: imageCount, + text_preview: String(editorText || '').replace(/\s+/g, ' ').trim().slice(0, contentLimit), + }; +} + +export async function ensureDraftDbPage(page) { + await page.goto(PUBLISH_URL); + await page.wait({ time: 2 }); +} + +function draftReadScript(storeName) { + return ` + (async () => { + const openDb = () => new Promise((resolve, reject) => { + const req = indexedDB.open(${JSON.stringify(DRAFT_DB_NAME)}); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error || new Error('failed to open IndexedDB')); + }); + try { + const db = await openDb(); + if (!db.objectStoreNames.contains(${JSON.stringify(storeName)})) { + db.close(); + return { ok: true, entries: [] }; + } + const tx = db.transaction(${JSON.stringify(storeName)}, 'readonly'); + const store = tx.objectStore(${JSON.stringify(storeName)}); + const allRows = await new Promise((resolve, reject) => { + const req = store.getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error || new Error('failed to read drafts')); + }); + const allKeys = await new Promise((resolve, reject) => { + if (!store.getAllKeys) return resolve([]); + const req = store.getAllKeys(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error || new Error('failed to read draft keys')); + }); + db.close(); + return { + ok: true, + entries: allRows.map((row, index) => ({ key: allKeys[index] ?? index, row })), + }; + } catch (error) { + return { ok: false, error: String(error && error.message || error) }; + } + })() + `; +} + +export async function readDraftEntries(page, draftType) { + const storeName = STORE_NAME_MAP[draftType]; + const payload = unwrapBrowserResult(await page.evaluate(draftReadScript(storeName))); + if (!payload?.ok) { + throw new CommandExecutionError(payload?.error || `Failed to read ${draftType} drafts`); + } + if (!Array.isArray(payload.entries)) { + throw new CommandExecutionError(`Malformed ${draftType} draft payload`); + } + return payload.entries; +} + +export function draftNotFound(id, draftType, command) { + return new EmptyResultError( + command, + `Draft ${id} was not found in ${draftType} drafts. Run opencli xiaohongshu drafts --type ${draftType} to list current ids.`, + ); +} diff --git a/clis/xiaohongshu/drafts.js b/clis/xiaohongshu/drafts.js new file mode 100644 index 0000000..c250285 --- /dev/null +++ b/clis/xiaohongshu/drafts.js @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ensureDraftDbPage, + normalizeDraftRecord, + normalizeDraftType, + readDraftEntries, +} from './draft-utils.js'; + +export const draftsCommand = cli({ + site: 'xiaohongshu', + name: 'drafts', + access: 'read', + description: '小红书本地草稿箱列表', + domain: 'creator.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'type', default: 'image', help: 'Draft type: image, video, article, audio' }, + ], + columns: ['rank', 'id', 'type', 'title', 'updated_at', 'images', 'text_preview'], + func: async (page, kwargs) => { + const draftType = normalizeDraftType(kwargs.type); + await ensureDraftDbPage(page); + const entries = await readDraftEntries(page, draftType); + return entries.map((entry, index) => normalizeDraftRecord(entry?.row, entry?.key, draftType, index + 1)); + }, +}); diff --git a/clis/xiaohongshu/drafts.test.js b/clis/xiaohongshu/drafts.test.js new file mode 100644 index 0000000..df853b1 --- /dev/null +++ b/clis/xiaohongshu/drafts.test.js @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './drafts.js'; +import './draft-delete.js'; +import './draft-clear.js'; +import './draft-open.js'; + +function createPageMock(evaluateResults = []) { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +const sampleDraft = { + content: { + contextStore: { liveContext: { title: '本地草稿' } }, + editorContent: { text: '草稿正文内容' }, + noteImageConfig: { items: [{}, {}] }, + }, + updatedAt: 1710000000000, +}; + +describe('xiaohongshu draft commands', () => { + it('registers draft commands with correct access and side-effect guard columns', () => { + expect(getRegistry().get('xiaohongshu/drafts')?.access).toBe('read'); + expect(getRegistry().get('xiaohongshu/draft-open')?.access).toBe('read'); + expect(getRegistry().get('xiaohongshu/draft-delete')?.access).toBe('write'); + expect(getRegistry().get('xiaohongshu/draft-clear')?.access).toBe('write'); + expect(getRegistry().get('xiaohongshu/draft-delete')?.columns).toEqual(['status', 'id', 'type', 'message']); + expect(getRegistry().get('xiaohongshu/draft-clear')?.columns).toEqual(['status', 'type', 'count', 'message']); + }); + + it('rejects invalid draft types before browser navigation', async () => { + const command = getRegistry().get('xiaohongshu/drafts'); + const page = createPageMock(); + + await expect(command.func(page, { type: 'bogus' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + expect(page.evaluate).not.toHaveBeenCalled(); + }); + + it('lists drafts with stable typed ids so numeric and string keys do not collide', async () => { + const command = getRegistry().get('xiaohongshu/drafts'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }, { key: '12', row: { title: '字符串键' } }] }, + ]); + + const rows = await command.func(page, { type: 'image' }); + + expect(rows).toMatchObject([ + { rank: 1, id: 'n:12', title: '本地草稿', images: 2, text_preview: '草稿正文内容' }, + { rank: 2, id: 's:12', title: '字符串键' }, + ]); + }); + + it('opens a draft by encoded id and returns full content', async () => { + const command = getRegistry().get('xiaohongshu/draft-open'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }] }, + ]); + + const rows = await command.func(page, { id: 'n:12', type: 'image' }); + + expect(rows).toEqual([ + { + id: 'n:12', + type: 'image', + title: '本地草稿', + updated_at: '1710000000000', + images: 2, + content: '草稿正文内容', + }, + ]); + }); + + it('fails typed when opening a missing draft id', async () => { + const command = getRegistry().get('xiaohongshu/draft-open'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }] }, + ]); + + await expect(command.func(page, { id: 'n:99', type: 'image' })).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('does not accept raw untyped ids that can collide across IndexedDB key types', async () => { + const command = getRegistry().get('xiaohongshu/draft-delete'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }, { key: '12', row: { title: '字符串键' } }] }, + ]); + + await expect(command.func(page, { id: '12', type: 'image', execute: true })).rejects.toBeInstanceOf(EmptyResultError); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('dry-runs draft-delete by default after proving the target exists', async () => { + const command = getRegistry().get('xiaohongshu/draft-delete'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }] }, + ]); + + const rows = await command.func(page, { id: 'n:12', type: 'image' }); + + expect(rows).toEqual([ + { + status: 'dry-run', + id: 'n:12', + type: 'image', + message: 'Draft exists. Re-run with --execute to delete.', + }, + ]); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('executes draft-delete only with --execute and verifies disappearance', async () => { + const command = getRegistry().get('xiaohongshu/draft-delete'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }] }, + { ok: true, deleted: true }, + ]); + + const rows = await command.func(page, { id: 'n:12', type: 'image', execute: true }); + + expect(rows[0]).toMatchObject({ status: 'deleted', id: 'n:12', type: 'image' }); + expect(page.evaluate).toHaveBeenCalledTimes(2); + expect(page.evaluate.mock.calls[1][0]).toContain('store.delete(key)'); + }); + + it('fails closed when draft-delete postcondition does not hold', async () => { + const command = getRegistry().get('xiaohongshu/draft-delete'); + const page = createPageMock([ + { ok: true, entries: [{ key: 12, row: sampleDraft }] }, + { ok: true, deleted: false }, + ]); + + await expect(command.func(page, { id: 'n:12', type: 'image', execute: true })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('dry-runs draft-clear by default and only clears with --execute', async () => { + const command = getRegistry().get('xiaohongshu/draft-clear'); + const dryRunPage = createPageMock([ + { ok: true, before: 3, after: 3, cleared: 0 }, + ]); + const executePage = createPageMock([ + { ok: true, before: 3, after: 0, cleared: 3 }, + ]); + + await expect(command.func(dryRunPage, { type: 'all' })).resolves.toEqual([ + { status: 'dry-run', type: 'all', count: 3, message: 'Drafts counted. Re-run with --execute to clear.' }, + ]); + await expect(command.func(executePage, { type: 'all', execute: true })).resolves.toEqual([ + { status: 'cleared', type: 'all', count: 3, message: 'Drafts cleared.' }, + ]); + expect(dryRunPage.evaluate.mock.calls[0][0]).toContain('return { ok: true, before, after: before, cleared: 0 }'); + expect(executePage.evaluate.mock.calls[0][0]).toContain('clearStore(db, storeName)'); + }); + + it('fails closed when draft-clear leaves drafts behind', async () => { + const command = getRegistry().get('xiaohongshu/draft-clear'); + const page = createPageMock([ + { ok: true, before: 3, after: 1, cleared: 2 }, + ]); + + await expect(command.func(page, { type: 'image', execute: true })).rejects.toBeInstanceOf(CommandExecutionError); + }); +}); diff --git a/clis/xiaohongshu/feed.js b/clis/xiaohongshu/feed.js new file mode 100644 index 0000000..5c47121 --- /dev/null +++ b/clis/xiaohongshu/feed.js @@ -0,0 +1,163 @@ +/** + * Xiaohongshu home feed — reads the hydrated Pinia `feed.feeds` array directly. + * + * Earlier versions used a `tap` step that called the `fetchFeeds` store action, + * which fetches the NEXT page of recommendations. Those API items carry no + * `xsecToken` and do not overlap the first-screen notes, so the feed's URLs + * could not be passed to `note`/`comments`/`download` (which require a signed + * URL). The hydrated store, by contrast, holds `entry.xsecToken` for every + * first-screen note, so a func-mode read yields signed, drill-down-ready URLs. + * + * Mirrors rednote/feed.js: the hydrated store is camelCase on both sites + * (`noteCard.displayTitle`, `interactInfo.likedCount`). This is the SSR store + * shape, not the snake_case `/homefeed` API response the old tap intercepted. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +function parseLimit(raw) { + const parsed = Number(raw ?? 20); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`); + } + if (parsed < 1) { + throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`); + } + return parsed; +} + +const FEEDS_READ_JS = ` + (() => { + let pinia = null; + const probe = (el) => el?.__vue_app__?.config?.globalProperties?.$pinia ?? null; + pinia = probe(document.querySelector('#app')); + if (!pinia) { + // Some builds mount under a different root id; fall back to a full scan + // only when the standard mount node misses. + for (const el of document.querySelectorAll('*')) { + pinia = probe(el); + if (pinia) break; + } + } + if (!pinia || !pinia._s) return { error: 'no_pinia' }; + const store = pinia._s.get('feed'); + if (!store) return { error: 'no_feed_store' }; + const feeds = store.feeds; + if (!Array.isArray(feeds)) return { error: 'feeds_not_array' }; + return { + items: feeds.map(entry => { + const card = entry?.noteCard ?? {}; + return { + id: entry?.id ?? '', + title: card.displayTitle ?? '', + type: card.type ?? '', + // Live store exposes both user.nickname and user.nickName; prefer + // nickname (observed populated on xhs), fall back to nickName. + author: card.user?.nickname ?? card.user?.nickName ?? '', + likes: card.interactInfo?.likedCount ?? '', + // The note's signing token lives on the top-level entry. Do NOT read + // card.user.xsecToken — that is the author profile's token, not the + // note's. + xsecToken: entry?.xsecToken ?? '', + }; + }), + }; + })() +`; + +function toCleanString(value) { + return typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim(); +} + +function unwrapEvaluateResult(payload) { + if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { + return payload.data; + } + return payload; +} + +/** + * Build a signed note URL for the given web host. Falls back to the bare + * /explore/{id} URL when the caller intentionally passes no token. + * + * The `xsec_source` param mirrors what XHS itself renders into feed-page note + * links: an empty value. Only `xsec_token` is actually required by the note + * detail endpoint (verified: the source value is not validated), so we + * reproduce the real-world shape rather than inventing a source label. + */ +export function buildFeedNoteUrl(webHost, id, xsecToken) { + const cleanId = toCleanString(id); + const url = new URL(`https://${webHost}/explore/${encodeURIComponent(cleanId)}`); + const cleanToken = toCleanString(xsecToken); + if (!cleanToken) + return url.toString(); + url.searchParams.set('xsec_token', cleanToken); + url.searchParams.set('xsec_source', ''); + return url.toString(); +} + +/** + * Shared func-mode implementation. Exported so the rednote adapter can run the + * same store read against www.rednote.com without duplicating the logic. + */ +export async function runFeed(page, kwargs, webHost) { + const limit = parseLimit(kwargs.limit); + await page.goto(`https://${webHost}/explore`); + // Pinia store hydrates from SSR; give the page a beat to finish + // bootstrapping before reading the array. + await page.wait({ time: 2 }); + const data = unwrapEvaluateResult(await page.evaluate(FEEDS_READ_JS)); + if (!data || typeof data !== 'object') { + throw new CommandExecutionError(`${webHost} feed: unexpected evaluate response`); + } + if (data.error) { + throw new CommandExecutionError(`${webHost} feed: ${data.error}`, `The SPA may still be hydrating; reload ${webHost}/explore and retry.`); + } + if (!Array.isArray(data.items)) { + throw new CommandExecutionError(`${webHost} feed: unexpected items payload shape`); + } + const rows = []; + for (const row of data.items) { + if (rows.length >= limit) + break; + if (!row || typeof row !== 'object') { + throw new CommandExecutionError(`${webHost} feed: malformed feed item`); + } + const id = toCleanString(row.id); + if (!id) { + throw new CommandExecutionError(`${webHost} feed: feed item is missing note id`); + } + const xsecToken = toCleanString(row.xsecToken); + if (!xsecToken) { + throw new CommandExecutionError(`${webHost} feed: feed item ${id} is missing xsecToken; cannot build a signed drill-down URL`); + } + rows.push({ + id, + title: toCleanString(row.title), + type: toCleanString(row.type), + author: toCleanString(row.author), + likes: toCleanString(row.likes), + url: buildFeedNoteUrl(webHost, id, xsecToken), + }); + } + if (rows.length === 0) { + throw new EmptyResultError(`${webHost}/feed`, 'No feed items in the hydrated store.'); + } + return rows; +} + +export const command = cli({ + site: 'xiaohongshu', + name: 'feed', + access: 'read', + description: '小红书首页推荐 Feed (reads hydrated Pinia store)', + domain: 'www.xiaohongshu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' }, + ], + columns: ['id', 'title', 'author', 'likes', 'type', 'url'], + func: async (page, kwargs) => runFeed(page, kwargs, 'www.xiaohongshu.com'), +}); diff --git a/clis/xiaohongshu/feed.test.js b/clis/xiaohongshu/feed.test.js new file mode 100644 index 0000000..64f6c8a --- /dev/null +++ b/clis/xiaohongshu/feed.test.js @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './feed.js'; +import { buildFeedNoteUrl } from './feed.js'; + +const HOST = 'www.xiaohongshu.com'; + +/** + * Minimal page mock: feed reads the store via a single page.evaluate call. + * `evaluateResult` is what that evaluate resolves to (the store-read envelope). + */ +function createPageMock(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + }; +} + +function entry(id, overrides = {}) { + return { + id, + title: `title-${id}`, + type: 'normal', + author: `author-${id}`, + likes: '100', + xsecToken: `tok-${id}`, + ...overrides, + }; +} + +describe('xiaohongshu/feed buildFeedNoteUrl', () => { + it('appends xsec_token and an empty xsec_source when a token is present', () => { + expect(buildFeedNoteUrl(HOST, 'abc123', 'TOK')).toBe( + `https://${HOST}/explore/abc123?xsec_token=TOK&xsec_source=`, + ); + }); + + it('falls back to a bare /explore URL when the token is empty', () => { + expect(buildFeedNoteUrl(HOST, 'abc123', '')).toBe(`https://${HOST}/explore/abc123`); + }); + + it('URL-encodes xsec_token instead of interpolating raw query text', () => { + expect(buildFeedNoteUrl(HOST, 'abc123', 'a&b=c d')).toBe( + `https://${HOST}/explore/abc123?xsec_token=a%26b%3Dc+d&xsec_source=`, + ); + }); +}); + +describe('xiaohongshu/feed func', () => { + const feed = getRegistry().get('xiaohongshu/feed'); + + beforeEach(() => { + expect(feed).toBeDefined(); + }); + + it('emits signed note URLs from the hydrated store', async () => { + const page = createPageMock({ items: [entry('id1'), entry('id2')] }); + const rows = await feed.func(page, { limit: 20 }); + expect(rows).toEqual([ + { id: 'id1', title: 'title-id1', type: 'normal', author: 'author-id1', likes: '100', url: `https://${HOST}/explore/id1?xsec_token=tok-id1&xsec_source=` }, + { id: 'id2', title: 'title-id2', type: 'normal', author: 'author-id2', likes: '100', url: `https://${HOST}/explore/id2?xsec_token=tok-id2&xsec_source=` }, + ]); + }); + + it('unwraps Browser Bridge evaluate envelopes', async () => { + const page = createPageMock({ session: { id: 's1' }, data: { items: [entry('id1')] } }); + const rows = await feed.func(page, { limit: 20 }); + expect(rows[0].url).toBe(`https://${HOST}/explore/id1?xsec_token=tok-id1&xsec_source=`); + }); + + it('typed-fails when a feed entry is missing its note token', async () => { + const page = createPageMock({ items: [entry('id1', { xsecToken: '' })] }); + await expect(feed.func(page, { limit: 20 })).rejects.toMatchObject({ + code: 'COMMAND_EXEC', + message: expect.stringContaining('xsecToken'), + }); + }); + + it('truncates to --limit', async () => { + const page = createPageMock({ items: [entry('a'), entry('b'), entry('c')] }); + const rows = await feed.func(page, { limit: 2 }); + expect(rows.map((r) => r.id)).toEqual(['a', 'b']); + }); + + it('typed-fails when a feed entry is missing its note id', async () => { + const page = createPageMock({ items: [entry(''), entry('keep')] }); + await expect(feed.func(page, { limit: 20 })).rejects.toMatchObject({ + code: 'COMMAND_EXEC', + message: expect.stringContaining('note id'), + }); + }); + + it('rejects invalid --limit before browser navigation', async () => { + const page = createPageMock({ items: [] }); + await expect(feed.func(page, { limit: 0 })).rejects.toMatchObject({ code: 'ARGUMENT' }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('maps a store read failure to CommandExecutionError', async () => { + const page = createPageMock({ error: 'no_pinia' }); + await expect(feed.func(page, { limit: 20 })).rejects.toMatchObject({ + code: 'COMMAND_EXEC', + message: expect.stringContaining('no_pinia'), + }); + }); + + it('maps an empty store to EmptyResultError', async () => { + const page = createPageMock({ items: [] }); + await expect(feed.func(page, { limit: 20 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' }); + }); + + it('maps a malformed items payload to CommandExecutionError', async () => { + const page = createPageMock({ items: {} }); + await expect(feed.func(page, { limit: 20 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + }); + + it('reads the note token from the entry top level, not card.user', async () => { + // entry.xsecToken is the note's token; card.user.xsecToken is the + // author profile's — the func must not confuse them. + const item = entry('id1', { xsecToken: 'note-token' }); + const page = createPageMock({ items: [item] }); + const rows = await feed.func(page, { limit: 20 }); + expect(rows[0].url).toContain('xsec_token=note-token'); + }); +}); diff --git a/clis/xiaohongshu/follow.js b/clis/xiaohongshu/follow.js new file mode 100644 index 0000000..41079a1 --- /dev/null +++ b/clis/xiaohongshu/follow.js @@ -0,0 +1,226 @@ +/** + * Xiaohongshu follow — clicks the follow button on a user's profile page. + * + * xhs public web APIs require `x-s`/`x-t`/`x-s-common` signing that the page + * context can produce but cannot be replayed reliably from outside, so we drive + * the UI instead (same approach as delete-note.js / publish.js). + * + * Flow: + * 1. Navigate to https://www.xiaohongshu.com/user/profile/ + * 2. Detect login redirect (xhs bounces to /login on auth failure) + * 3. Walk visible
Deno 2.0 +
+ + + +
Watch our video announcement.
+
+ +

Announcing Deno 2

+

The web is humanity’s largest software platform — building for it means +potentially reaching over 5 billion people. +But as +web development has accelerated in recent years, +it has also become increasingly and unmanageably complex. Before writing a +single line of code, developers must deal with tedious configuration and wading +through unnecessary boilerplate, when they would rather focus on shipping +product and delivering value to users.

+

Yet +despite these complexities, +JavaScript, the language of the web, +has remained the most popular language for the past decade, +with TypeScript quickly emerging as number three. This is a testament to +JavaScript’s ubiquity and usefulness for web development — and a sign that +JavaScript is not going anywhere.

+

In an effort to simplify web programming, we created Deno: a modern, all-in-one, +zero-config toolchain for JavaScript and TypeScript development.

+
    +
  • Native TypeScript support
  • +
  • Built on web standards: Promises, fetch, and ES Modules
  • +
  • Batteries included: builtin formatter, linter, type checker, test +framework, compile to executable, and more
  • +
  • Secure by default, +just like browsers
  • +
+

Today, hundreds of thousands of developers love using Deno, +with repository becoming one of the highest starred Rust project on GitHub second +only to the Rust language itself.

+

While we’ve accomplished a ton in Deno 1, the next major version is focused on +using Deno at scale. This means seamless interoperability with legacy +JavaScript infrastructure and support for a wider range of projects and +development teams. All without sacrificing the simplicity, security, and +“batteries included” nature that Deno users love.

+

Today, we’re thrilled to announce Deno 2, which includes:

+
    +
  • Backwards compatibility with Node.js and npm, allowing you to run existing +Node applications seamlessly
  • +
  • Native support for package.json and node_modules
  • +
  • Package management with new deno install, deno add, and deno remove +commands
  • +
  • A stabilized standard library
  • +
  • Support for private npm registries
  • +
  • Workspaces and monorepo support
  • +
  • Long Term Support (LTS) releases
  • +
  • JSR: a modern registry for sharing JavaScript libraries across runtimes
  • +
+

We are also continually improving many existing Deno features:

+
    +
  • deno fmt can now format HTML, CSS, and YAML
  • +
  • deno lint now has Node specific rules and quick fixes
  • +
  • deno test now supports running tests written using node:test
  • +
  • deno task can now run package.json scripts
  • +
  • deno doc’s HTML output has improved design and better search
  • +
  • deno compile now supports code signing and icons on Windows
  • +
  • deno serve can run HTTP servers across multiple cores, in parallel
  • +
  • deno init can now scaffold libraries or servers
  • +
  • deno jupyter now supports outputting images, graphs, and HTML
  • +
  • deno bench supports critical sections for more precise measurements
  • +
  • deno coverage can now output reports in HTML
  • +
+

Backwards-compatible, forward-thinking

+

Deno 2 is backwards compatible with Node and npm. This allows you to not only +run Deno in your current Node projects, but also incrementally adopt pieces of +Deno’s all-in-one toolchain. For instance, you can use deno install after you +clone a Node project to install your dependencies at lightning speed or run +deno fmt to format your code without needing Prettier.

+

Deno 2’s compatibility with Node and npm is robust. Deno 2 understands +package.json , the node_modules folder, and even npm workspaces, allowing +you to run Deno in any Node project using ESM. And if there are minor syntax +adjustments needed, you can fix them with deno lint --fix.

+
+ +

+
+ +

Don’t like the clutter of package.json and the node_modules directory, but +still need to use that npm package? You can directly import npm packages using +npm: specifiers. Without package.json and the node_modules folder, Deno +will install your package in the global cache. This allows you to write programs +with npm dependencies +in a single file — +no dependency manifest, configuration files, or node_modules needed.

+
import chalk from "npm:chalk@5.3.0";
+
+console.log(chalk.blue("Hello, world!"));
+// Hello, world! (in blue)

For larger projects, a dependency manifest makes it simple to manage your +dependencies. Placing an npm: specifier into an import map in a deno.json +file allows importing the bare name of the package:

+
// deno.json
+{
+  "imports": {
+    "chalk": "npm:chalk@5.3.0"
+  }
+}
import chalk from "chalk";
+
+console.log(chalk.blue("Hello, world!"));
+// Hello, world! (in blue)

With the ability to import npm packages via the npm: specifier, you can access +over 2 million npm modules in Deno. This even includes complex packages such as +gRPC, ssh2, Prisma, temporal.io, +duckdb, polars. Deno even supports advanced features like Node-API native +addons.

+

Finally, you can use Deno 2 with your favorite JavaScript framework. Deno 2 +supports Next.js, Astro, Remix, Angular, SvelteKit, QwikCity and many other +frameworks.

+
+ +

+
Running create-next-app with Deno.
+
+ +

Deno is now a package manager with deno install

+

Not only does Deno 2 support package.json and the node_modules folder, it +also comes with three important subcommands that allow you to easily install and +manage your dependencies.

+

deno install installs your dependencies at lightning speed. If you have a +package.json it will create a node_modules folder in the blink of an eye. If +you don’t use package.json, it will cache all of your dependencies to the +global cache.

+

deno install is 15% faster than npm with a cold cache, and 90% faster with a +hot cache. We’re already exceptionally fast here, but expect more improvements, +especially in cold cache scenarios, in the coming weeks.

+

Package install timings

+

deno add and deno remove can be used to add and remove packages to/from your +package.json or deno.json. If you’ve used npm install or npm remove +before, these will feel very familiar.

+
+ +

deno add demo 1

+

deno add demo 2

+
+ +

JavaScript Registry

+

Earlier this year we introduced a +modern, open sourced JavaScript registry +called JSR.

+

It supports TypeScript natively (you can publish modules as TypeScript source +code), handles the module loading intricacies multiple runtimes and +environments, only allows ESM, +auto-generates documentation from JSDoc-style comments, +and can be used with npm- and npx-like systems (yes, JSR turns TypeScript into +.js and .d.ts files, as well).

+

Because you upload TypeScript to JSR, it has an outstanding understanding of the +code that is being published. This allows us to deliver a seamless developer +experience for both publishing and consuming modules. If you are interested in +the details, you can read our post on +how we architected JSR.

+
+ +

+
Here is a side-by-side video of publishing a package to npm vs. to JSR.
+ +
+ +

The Standard Library is now stable

+

While there are over 2 million modules available on npm, the process of +searching, evaluating, and using a new module can be time consuming. That’s why +we’ve been building the Deno Standard Library for over 4 years.

+

The Standard Library consists of dozens of heavily audited utility modules +covering everything from data manipulation, web-related logic, +JavaScript-specific functionalities, and more. It is +available on JSR, and can be used by other runtimes and +environments.

+

To give you a sense of what kinds of modules are available in the Deno Standard +Library, here is a partial list of the Standard Library modules and their +equivalent in npm:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Deno Standard Library modulenpm package
@std/testingjest
@std/expectchai
@std/climinimist
@std/collectionslodash
@std/fmtchalk
@std/netget-port
@std/encodingrfc4648
+

For a complete list of available packages visit https://jsr.io/@std.

+

Private npm registries

+

Private npm registries in Deno 2 work +the same way they do in Node and npm, with an .npmrc file:

+
// .npmrc
+@mycompany:registry=http://mycompany.com:8111/
+//mycompany.com:8111/:_auth=secretToken

Deno will pick up this .npmrc file automatically, and will let you pull +private packages with no additional configuration.

+

Workspaces and monorepos

+

Deno 2 also supports workspaces, which is a robust solution to manage +monorepos. Simply use the workspace attribute in your deno.json to list +the member directories:

+
// deno.json
+{
+  "workspace": ["./add", "./subtract"]
+}

These members can have separate dependencies, linter and formatter +configuration, and more.

+

Not only does Deno support workspaces for Deno packages, it also understands +npm workspaces. This means +that you can create a hybrid Deno-npm monorepo +(see this example), +with workspace members that either have a package.json or deno.json:

+
+ +

This sample monorepo contains a mix of npm members and Deno members.

+
This sample monorepo contains a mix of npm members and Deno members.
+
+ +

You can also publish workspace members to JSR by running deno publish. For +example, refer to the Deno Standard Library. +No need to manually figure out in what order you need to publish your packages - +just run deno publish, and it will do it all for you.

+

LTS

+

Often, development teams in larger organizations need to carefully audit new +releases before using them in production. With Deno’s weekly bug-fix releases, +and 6 weekly minor releases this can become time-consuming. To make it easier +for these teams, we’re introducing a Long Term Support (LTS) release +channel.

+

Starting with Deno 2.1, the LTS channel will receive critical bug fixes +back-ported for six months, ensuring a stable and reliable base for production +use. After six months, a new LTS branch will be created based on the latest +stable version. All LTS releases are freely available and MIT licensed, making +them accessible to any team that needs a more stable and secure environment.

+
+ +

Starting with Deno 2.1, we’ll introduce a LTS branch that we’ll maintain and backport critical bug fixes to for six months.

+
Starting with Deno 2.1, we’ll introduce a LTS branch that we’ll maintain and +backport critical bug fixes to for six months.
+
+ +

Finally, for teams needing advanced support, we’ve introduced the +Deno for Enterprise program. It offers priority +support, direct access to our engineers, guaranteed response times, and priority +for your feature requests. We’ve partnered with companies like Netlify, Slack, +and Deco.cx to help their engineers move faster and deliver +more value to their users.

+

Deno is fast!

+

We’ve put tremendous effort into making Deno fast across a wide range of +real-world scenarios. Our focus is on delivering performance improvements that +truly matter in everyday JavaScript and TypeScript development—whether it’s +startup time, handling complex requests, or overall efficiency.

+

While benchmarks can never tell the full story, they can provide insight into +where a runtime excels. Here are some benchmarks that showcase Deno’s strengths, +demonstrating its ability to deliver top-notch performance for both development +and production environments.

+
+ +

Please refer to the links beneath each chart for further detail and reproducible steps.

+
Please refer to the links beneath each chart for further detail and reproducible +steps.
+
+ +

Correction: +The first HTTP benchmark +shown above was conducted using Deno 1.45, not Deno 2.0. In reality, Deno 2.0 is +about 20% slower than indicated here. This difference is due to +our recent disabling of V8 pointer compression +to address cases where users exceeded the 4GB heap limit. We plan to re-enable +pointer compression soon, as it’s the ideal default for most users, and +introduce a deno64 build for those needing larger heaps.

+

FAQs

+

If Deno is fully backward compatible with Node, why should I use Deno instead of Node?

+

While Deno can run Node programs, it’s designed to push JavaScript and +TypeScript forward. Deno offers features that Node lacks, such as native +TypeScript support, web-standard APIs, a complete toolchain for JavaScript +development, and a secure-by-default execution model—all in a single executable +with no external dependencies. Using Deno over Node can save you time on setup +and configuration, letting you start coding and delivering value faster.

+

Will Deno’s opt-in permission system be in effect when running Node programs?

+

Yes, Deno’s secure-by-default execution model applies when running Node programs +or importing npm modules, ensuring the same level of security.

+

Why the new logo? What happened to the cute dinosaur mascot?

+

Since the beginning, the cute sauropod in the rain has been Deno’s face. Its +quirky charm has always been a hallmark of Deno, but the design was never +consistent—there were at least two “official” versions and countless variations. +With Deno 2.0, we decided it was time for a refresh.

+

We wanted to keep the essence of the original character that Deno users love +while giving it a more refined look to match Deno’s professional and +production-grade nature. During the redesign, we realized that the rainy +background, while nostalgic, didn’t scale well and often went unnoticed. It was +too busy, especially at small sizes, so we had to let it go.

+

After many iterations, we found that simplifying the design to its core elements +struck the right balance—simple and friendly, yet serious and reliable—just like +Deno.

+

(Don’t worry, the cute dino is still here!)

+

Deno began with an ambitious vision to modernize JavaScript. But with all the work spent on backward compatibility, what’s left of Deno’s original vision?

+

Rewriting the entire JavaScript ecosystem isn’t practical. As Deno has scaled +beyond small programs, we’ve recognized that supporting Node and npm +compatibility is essential—especially for tools like gRPC and AWS SDKs, which +are impractical to rewrite from scratch.

+

But Deno’s goal is not to become a Node clone in Rust or a drop-in replacement. +Our aim is to level up JavaScript, moving beyond 2010-era CommonJS and narrowing +the gap between server-side and browser environments in a way that developers +can adopt practically. We refuse to accept that JavaScript must remain a tangle +of mismatched tooling and endless layers of transpilation, unable to evolve.

+

Deno’s original vision remains central to everything we do. This includes native +TypeScript support, built-in web standards like Promises, top-level await, Wasm, +fetch, and ES Modules, and a batteries-included toolchain—all packaged in a +single, dependency-free executable. And, of course, it is secure by default, +just like the web.

+

Supporting npm is just one step toward making Deno more versatile. Our mission +is to provide a modern, streamlined toolchain that enhances the JavaScript +experience—not just to support legacy code. While we’ve adjusted our approach, +our vision remains the same: to simplify and empower web development.

+

I loved Deno because it didn’t need any config files, but with the new package manager additions, is Deno 2 becoming more like Node, where you need a package.json to add dependencies?

+

Not at all. You can still run single-file programs or scripts without any config +or dependency manifest—nothing has changed there. The new package management +commands (deno install, deno add, and deno remove) are optional tools +designed to simplify managing dependencies, whether you use a deno.json or +package.json file. They’re especially useful for larger, more complex projects +but won’t get in the way if you prefer the simplicity of no configuration.

+

One of our core goals is that Deno scales down to simple, single-file programs +that can import any package without additional ceremony. For example, in +contexts like Jupyter notebooks or quick scripts, you can easily do:

+
import * as Plot from "npm:@observablehq/plot";

At the same time, Deno scales up to handle large projects with multiple files or +even multiple packages, such as in monorepos. This flexibility ensures that Deno +is just as effective for small scripts as it is for large, production-grade +applications.

+

I have a Fresh project. Are there breaking changes if I upgrade to Deno 2?

+

Nope! Your Fresh project should work out of the box +with Deno 2—no changes needed.

+

When should I expect Deno 2 to land on Deno Deploy?

+

Any moment now!

+

What’s next

+

Deno 2 takes all of the features developers love about Deno 1.x — zero-config, +all-in-one toolchain for JavaScript and TypeScript development, web standard API +support, secure by default — and makes it fully backwards compatible with Node +and npm (in ESM). This makes not only running Deno in any Node project simple, +but also allows incremental adoption of Deno (e.g. running deno fmt or +deno lint ) possible in larger, more complex projects. Along with improved +package management, JSR, and a slew of features for more advanced development +teams, Deno is ready to simplify and accelerate your development today.

+

However, given Deno’s vast capabilities, we weren’t able to cover everything in +a single blog post and video. There are many exciting features and use cases +with Deno that we didn’t touch upon. For instance, being able to use +deno compile to +turn a JavaScript game into a desktop executable +with cross compilation (yes, Windows) support. Or Deno’s +Jupyter notebook support +that allows you to explore and visualize data in TypeScript and +@observable/plot. Or generating documentation +or a static documentation site from your JSDoc comments and source code with +deno doc.

+
+ +

Deno’s features at a glance.

+
+
+ +

We invite you to try Deno 2 today and experience the future of JavaScript and +TypeScript development. Get started with Deno 2 now:

+ +

Join our community and let’s shape the future of JavaScript together!

+
+

Tweet 1

+
+

Tweet 2

+
+

Tweet 3

+
+
\ No newline at end of file diff --git a/src/browser/__fixtures__/article-extract/openai-cookbook-readme.txt b/src/browser/__fixtures__/article-extract/openai-cookbook-readme.txt new file mode 100644 index 0000000..b48159e --- /dev/null +++ b/src/browser/__fixtures__/article-extract/openai-cookbook-readme.txt @@ -0,0 +1,20 @@ + + + + OpenAI Cookbook Logo + + + +

+ +> ✨ Navigate at [cookbook.openai.com](https://cookbook.openai.com) + +Example code and guides for accomplishing common tasks with the [OpenAI API](https://platform.openai.com/docs/introduction). To run these examples, you'll need an OpenAI account and associated API key ([create a free account here](https://platform.openai.com/signup)). Set an environment variable called `OPENAI_API_KEY` with your API key. Alternatively, in most IDEs such as Visual Studio Code, you can create an `.env` file at the root of your repo containing `OPENAI_API_KEY=`, which will be picked up by the notebooks. + +Most code examples are written in Python, though the concepts can be applied in any language. + +For other useful tools, guides and courses, check out these [related resources from around the web](https://cookbook.openai.com/related_resources). + +## License + +MIT License diff --git a/src/browser/__fixtures__/article-extract/wikipedia-markdown.html b/src/browser/__fixtures__/article-extract/wikipedia-markdown.html new file mode 100644 index 0000000..079a944 --- /dev/null +++ b/src/browser/__fixtures__/article-extract/wikipedia-markdown.html @@ -0,0 +1,1249 @@ + + + + +Markdown - Wikipedia + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Jump to content +
+
+
+ + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ +

Markdown

+ + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ +
From Wikipedia, the free encyclopedia
+
+
+ + +
+
+ + +
Markdown
Filename extensions.md, .markdown[1][2]
Internet media typetext/markdown[2]
Uniform Type Identifier (UTI)net.daringfireball.markdown
UTI conformationpublic.plain-text
Magic numberNone
Developed by
Initial releaseMarch 9, 2004 (22 years ago) (2004-03-09)[3][4]
Latest release
1.0.1
December 17, 2004 (21 years ago) (2004-12-17)[5]
Type of formatOpen file format[6]
Extended topandoc, MultiMarkdown, Markdown Extra, CommonMark,[7] RMarkdown[8]
Websitedaringfireball.net/projects/markdown/
+

Markdown[9] is a lightweight markup language for creating formatted text using a plain-text editor. John Gruber created Markdown in 2004 as an easy-to-read markup language.[9] Markdown is widely used for blogging, instant messaging, and large language models,[10] and also used elsewhere in online forums, collaborative software, documentation pages, and readme files. +

The initial description of Markdown[11] contained ambiguities and raised unanswered questions, causing implementations to both intentionally and accidentally diverge from the original version. This was addressed in 2014 when long-standing Markdown contributors released CommonMark, an unambiguous specification and test suite for Markdown.[12][better source needed] +

+ +

History

[edit]
+

Markdown was inspired by pre-existing conventions for marking up plain text in email and usenet posts,[13] such as the earlier markup languages setext (c. 1992), Textile (c. 2002), and reStructuredText (c. 2002).[9] +

In 2002 Aaron Swartz created atx and referred to it as "the true structured text format". Gruber created the Markdown language in 2004 with Swartz as his "sounding board".[14] The goal of the language was to enable people "to write using an easy-to-read and easy-to-write plain text format, optionally convert it to structurally valid XHTML (or HTML)".[5] +

Another key design goal was readability, that the language be readable as-is, without looking like it has been marked up with tags or formatting instructions,[9] unlike text formatted with "heavier" markup languages, such as Rich Text Format (RTF), HTML, or even wikitext, each of which have obvious in-line tags and formatting instructions which can make the text more difficult for humans to read.[citation needed] +

Gruber wrote a Perl script, Markdown.pl, which converts marked-up text input to valid, well-formed XHTML or HTML, encoding angle brackets (<, >) and ampersands (&), which would be misinterpreted as special characters in those languages. It can take the role of a standalone script, a plugin for Blosxom or a Movable Type, or of a text filter for BBEdit.[5] +

+

Rise and divergence

[edit]
+

As Markdown's popularity grew rapidly, many Markdown implementations appeared, driven mostly by the need for additional features such as tables, footnotes, definition lists,[note 1] and Markdown inside HTML blocks.[citation needed] +

The behavior of some of these diverged from the reference implementation, as Markdown was only characterised by an informal specification[17] and a Perl implementation for conversion to HTML.[citation needed] +

At the same time, a number of ambiguities in the informal specification had attracted attention.[18] These issues spurred the creation of tools such as Babelmark[19][20] to compare the output of various implementations,[21] and an effort by some developers of Markdown parsers for standardization. However, Gruber has argued that complete standardization would be a mistake: "Different sites (and people) have different needs. No one syntax would make all happy."[22] +

Gruber avoided using curly braces in Markdown to unofficially reserve them for implementation-specific extensions.[23] +

+

Standardization

[edit]
+
CommonMark
Filename extensions.md, .markdown[2]
Internet media typetext/markdown; variant=CommonMark[7]
Uniform Type Identifier (UTI)uncertain[24]
UTI conformationpublic.plain-text
Developed byJohn MacFarlane, open source
Initial releaseOctober 25, 2014 (11 years ago) (2014-10-25)
Latest release
0.31.2
January 28, 2024 (2 years ago) (2024-01-28)[25]
Type of formatOpen file format
Extended fromMarkdown
Extended toGitHub Flavored Markdown
Websitecommonmark.org +spec.commonmark.org
+

In 2012, a group of people, including Jeff Atwood and John MacFarlane, launched what Atwood characterised as a standardization effort.[12] +

A community website now aims to "document various tools and resources available to document authors and developers, as well as implementors of the various Markdown implementations".[26] +

In September 2014, Gruber objected to the usage of "Markdown" in the name of this effort and it was rebranded as "CommonMark".[13][27][28] CommonMark.org published several versions of a specification, reference implementation, test suite, and "[plans] to announce a finalized 1.0 spec and test suite in 2019".[29] +

The finalized 1.0 spec has not been released, as major issues still remain unsolved.[30] +

Nonetheless, the following websites and projects have adopted CommonMark: Codeberg, Discourse, GitHub, GitLab, Reddit, Qt, Stack Exchange (Stack Overflow), and Swift. +

In March 2016, two relevant informational Internet RFCs were published: +

+
  • RFC 7763 – "The text/markdown Media Type,"[2] Informational.
    Introduces MIME type text/markdown.
  • +
  • RFC 7764 – "Guidance on Markdown: Design Philosophies, Stability Strategies, and Select Registrations,"[7] Informational.
    Discusses and registers the variants MultiMarkdown, GitHub Flavored Markdown (GFM), Pandoc, and Markdown Extra (among others).[31]
+

Variants

[edit]
+

Websites like Bitbucket, Diaspora, Discord,[32] GitHub,[33] OpenStreetMap, Reddit,[34] SourceForge[35] and Stack Exchange[36] use variants of Markdown to make discussions between users easier. +

Depending on implementation, basic inline HTML tags may be supported.[37] +

Italic text may be implemented by _underscores_ or *single-asterisks*.[38] +

+

GitHub Flavored Markdown

[edit]
+

GitHub had been using its own variant of Markdown since as early as 2009,[39] which added support for additional formatting such as tables and nesting block content inside list elements, as well as GitHub-specific features such as auto-linking references to commits, issues, usernames, etc. +

In 2017, GitHub released a formal specification of its GitHub Flavored Markdown (GFM) that is based on CommonMark.[33] It is a strict superset of CommonMark, following its specification exactly except for tables, strikethrough, autolinks and task lists, which GFM adds as extensions.[40] +

Accordingly, GitHub also changed the parser used on their sites, which required that some documents be changed. For instance, GFM now requires that the hash symbol that creates a heading be separated from the heading text by a space character. +

+

Markdown Extra

[edit]
+

Markdown Extra is a lightweight markup language based on Markdown implemented in PHP (originally), Python and Ruby.[41] It adds the following features that are not available with regular Markdown: +

+
  • Markdown markup inside HTML blocks
  • +
  • Elements with id/class attribute
  • +
  • "Fenced code blocks" that span multiple lines of code
  • +
  • Tables[42]
  • +
  • Definition lists
  • +
  • Footnotes
  • +
  • Abbreviations
+

Markdown Extra is supported in some content management systems such as Drupal,[43] Grav (CMS), Textpattern CMS[44] and TYPO3.[45] +

+

Examples

[edit]
+ + + + + + + + + + + + + + + + + + + + +
Text using Markdown syntax +Corresponding HTML produced by a Markdown processor +Text viewed in a browser +
+
Heading
+=======
+
+Sub-heading
+-----------
+
+# Alternative heading
+
+## Alternative sub-heading
+
+Paragraphs are separated 
+by a blank line.
+
+Two spaces at the end of a line  
+produce a line break.
+
+
+
<h1>Heading</h1>
+
+<h2>Sub-heading</h2>
+
+<h1>Alternative heading</h1>
+
+<h2>Alternative sub-heading</h2>
+
+<p>Paragraphs are separated
+by a blank line.</p>
+
+<p>Two spaces at the end of a line<br />
+produce a line break.</p>
+
+
Heading
+
Sub-heading
+
Alternative heading
+
Alternative sub-heading
+

Paragraphs are separated +by a blank line. +

Two spaces at the end of a line
+produce a line break. +

+
Text attributes _italic_, **bold**, `monospace`.
+
+Horizontal rule:
+
+---
+
+
+
<p>Text attributes <em>italic</em>, <strong>bold</strong>, <code>monospace</code>.</p>
+
+<p>Horizontal rule:</p>
+
+<hr />
+
+
Text attributes italic, bold, monospace.

+

Horizontal rule: +

+
+
Bullet lists nested within numbered list:
+
+  1. fruits
+     * apple
+     * banana
+  2. vegetables
+     - carrot
+     - broccoli
+
+
+
<p>Bullet lists nested within numbered list:</p>
+
+<ol>
+  <li>fruits <ul>
+      <li>apple</li>
+      <li>banana</li>
+  </ul></li>
+  <li>vegetables <ul>
+      <li>carrot</li>
+      <li>broccoli</li>
+  </ul></li>
+</ol>
+
+
Bullet lists nested within numbered list: +
  1. fruits +
    • apple
    • +
    • banana
  2. +
  3. vegetables +
    • carrot
    • +
    • broccoli
+
A [link](http://example.com).
+
+![Image](Icon-pictures.png "icon")
+
+> Markdown uses email-style
+characters for blockquoting.
+>
+> Multiple paragraphs need to be prepended individually.
+
+Most inline <abbr title="Hypertext Markup Language">HTML</abbr> tags are supported.
+
+
+
<p>A <a href="http://example.com">link</a>.</p>
+
+<p><img alt="Image" title="icon" src="Icon-pictures.png" /></p>
+
+<blockquote>
+<p>Markdown uses email-style characters for blockquoting.</p>
+<p>Multiple paragraphs need to be prepended individually.</p>
+</blockquote>
+
+<p>Most inline <abbr title="Hypertext Markup Language">HTML</abbr> tags are supported.</p>
+
+
A link. +

Image +

+
+

Markdown uses email-style characters for blockquoting. +

Multiple paragraphs need to be prepended individually. +

+
+

Most inline HTML tags are supported. +

+
+

Implementations

[edit]
+

Implementations of Markdown are available for over a dozen programming languages; in addition, many applications, platforms and frameworks support Markdown.[46] For example, Markdown plugins exist for every major blogging platform.[13] +

While Markdown is a minimal markup language and is read and edited with a normal text editor, there are specially designed editors that preview the files with styles, which are available for all major platforms. Many general-purpose text and code editors have syntax highlighting plugins for Markdown built into them or available as optional download. Editors may feature a side-by-side preview window or render the code directly in a WYSIWYG fashion. +

+

See also

[edit]
+ +

Explanatory notes

[edit]
+
+
    +
  1. ^ Technically HTML description lists +
  2. +
+

References

[edit]
+
    +
  1. ^ Gruber, John (8 January 2014). "The Markdown File Extension". The Daring Fireball Company, LLC. Archived from the original on 12 July 2020. Retrieved 27 March 2022. Too late now, I suppose, but the only file extension I would endorse is ".markdown", for the same reason offered by Hilton Lipschitz: We no longer live in a 8.3 world, so we should be using the most descriptive file extensions. It's sad that all our operating systems rely on this stupid convention instead of the better creator code or a metadata model, but great that they now support longer file extensions. +
  2. +
  3. ^ a b c d S. Leonard (March 2016). The text/markdown Media Type. Internet Engineering Task Force. doi:10.17487/RFC7763. ISSN 2070-1721. RFC 7763. Informational. +
  4. +
  5. ^ Swartz, Aaron (2004-03-19). "Markdown". Aaron Swartz: The Weblog. Archived from the original on 2017-12-24. Retrieved 2013-09-01. +
  6. +
  7. ^ Gruber, John. "Markdown". Daring Fireball. Archived from the original on 2004-03-11. Retrieved 2022-08-20. +
  8. +
  9. ^ a b c Markdown 1.0.1 readme source code "Daring Fireball – Markdown". 2004-12-17. Archived from the original on 2004-04-02. +
  10. +
  11. ^ "Markdown: License". Daring Fireball. Archived from the original on 2020-02-18. Retrieved 2014-04-25. +
  12. +
  13. ^ a b c S. Leonard (March 2016). Guidance on Markdown: Design Philosophies, Stability Strategies, and Select Registrations. Internet Engineering Task Force. doi:10.17487/RFC7764. ISSN 2070-1721. RFC 7764. Informational. +
  14. +
  15. ^ "RMarkdown Reference site". Archived from the original on 2020-03-03. Retrieved 2019-11-21. +
  16. +
  17. ^ a b c d Markdown Syntax "Daring Fireball – Markdown – Syntax". 2013-06-13. "Readability, however, is emphasized above all else. A Markdown-formatted document should be publishable as-is, as plain text, without looking like it's been marked up with tags or formatting instructions. While Markdown's syntax has been influenced by several existing text-to-HTML filters — including Setext, atx, Textile, reStructuredText, Grutatext[15], and EtText[16] — the single biggest source of inspiration for Markdown's syntax is the format of plain text email." +
  18. +
  19. ^ Dillet, Romain (6 March 2025). "Mistral adds a new API that turns any PDF document into an AI-ready Markdown file". TechCrunch. Retrieved 7 September 2025. +
  20. +
  21. ^ "Daring Fireball: Introducing Markdown". daringfireball.net. Archived from the original on 2020-09-20. Retrieved 2020-09-23. +
  22. +
  23. ^ a b Atwood, Jeff (2012-10-25). "The Future of Markdown". CodingHorror.com. Archived from the original on 2014-02-11. Retrieved 2014-04-25. +
  24. +
  25. ^ a b c Gilbertson, Scott (October 5, 2014). "Markdown throwdown: What happens when FOSS software gets corporate backing?". Ars Technica. Archived from the original on November 14, 2020. Retrieved June 14, 2017. CommonMark fork could end up better for users... but original creators seem to disagree. +
  26. +
  27. ^ @gruber (June 12, 2016). "I should write about it, but it's painful. More or less: Aaron was my sounding board, my muse" (Tweet) – via Twitter. +
  28. +
  29. ^ "Un naufragio personal: The Grutatxt markup". triptico.com. Archived from the original on 2022-06-30. Retrieved 2022-06-30. +
  30. +
  31. ^ "EtText: Documentation: Using EtText". ettext.taint.org. Retrieved 2022-06-30. +
  32. +
  33. ^ "Markdown Syntax Documentation". Daring Fireball. Archived from the original on 2019-09-09. Retrieved 2018-03-09. +
  34. +
  35. ^ "GitHub Flavored Markdown Spec – Why is a spec needed?". github.github.com. Archived from the original on 2020-02-03. Retrieved 2018-05-17. +
  36. +
  37. ^ "Babelmark 2 – Compare markdown implementations". Johnmacfarlane.net. Archived from the original on 2017-07-18. Retrieved 2014-04-25. +
  38. +
  39. ^ "Babelmark 3 – Compare Markdown Implementations". github.io. Archived from the original on 2020-11-12. Retrieved 2017-12-10. +
  40. +
  41. ^ "Babelmark 2 – FAQ". Johnmacfarlane.net. Archived from the original on 2017-07-28. Retrieved 2014-04-25. +
  42. +
  43. ^ Gruber, John [@gruber] (4 September 2014). "@tobie @espadrine @comex @wycats Because different sites (and people) have different needs. No one syntax would make all happy" (Tweet) – via Twitter. +
  44. +
  45. ^ Gruber, John (19 May 2022). "Markdoc". Daring Fireball. Archived from the original on 19 May 2022. Retrieved May 19, 2022. I love their syntax extensions — very true to the spirit of Markdown. They use curly braces for their extensions; I'm not sure I ever made this clear, publicly, but I avoided using curly braces in Markdown itself — even though they are very tempting characters — to unofficially reserve them for implementation-specific extensions. Markdoc's extensive use of curly braces for its syntax is exactly the sort of thing I was thinking about. +
  46. +
  47. ^ "UTI of a CommonMark document". 12 April 2017. Archived from the original on 22 November 2018. Retrieved 29 September 2017. +
  48. +
  49. ^ "CommonMark specification". Archived from the original on 2017-08-07. Retrieved 2017-07-26. +
  50. +
  51. ^ "Markdown Community Page". GitHub. Archived from the original on 2020-10-26. Retrieved 2014-04-25. +
  52. +
  53. ^ "Standard Markdown is now Common Markdown". Jeff Atwood. 4 September 2014. Archived from the original on 2014-10-09. Retrieved 2014-10-07. +
  54. +
  55. ^ "Standard Markdown Becomes Common Markdown then CommonMark". InfoQ. Archived from the original on 2020-09-30. Retrieved 2014-10-07. +
  56. +
  57. ^ "CommonMark". Archived from the original on 12 April 2016. Retrieved 20 Jun 2018. The current version of the CommonMark spec is complete, and quite robust after a year of public feedback … but not quite final. With your help, we plan to announce a finalized 1.0 spec and test suite in 2019. +
  58. +
  59. ^ "Issues we MUST resolve before 1.0 release [6 remaining]". CommonMark Discussion. 2015-07-26. Archived from the original on 2021-04-14. Retrieved 2020-10-02. +
  60. +
  61. ^ "Markdown Variants". IANA. 2016-03-28. Archived from the original on 2020-10-27. Retrieved 2016-07-06. +
  62. +
  63. ^ "Markdown Text 101 (Chat Formatting: Bold, Italic, Underline)". Discord. 2024-10-03. Retrieved 2025-02-07. +
  64. +
  65. ^ a b "GitHub Flavored Markdown Spec". GitHub. Archived from the original on 2020-02-03. Retrieved 2020-06-11. +
  66. +
  67. ^ "Reddit markdown primer. Or, how do you do all that fancy formatting in your comments, anyway?". Reddit. Archived from the original on 2019-06-11. Retrieved 2013-03-29. +
  68. +
  69. ^ "SourceForge: Markdown Syntax Guide". SourceForge. Archived from the original on 2019-06-13. Retrieved 2013-05-10. +
  70. +
  71. ^ "Markdown Editing Help". StackOverflow.com. Archived from the original on 2014-03-28. Retrieved 2014-04-11. +
  72. +
  73. ^ "Markdown Syntax Documentation". daringfireball.net. Archived from the original on 2019-09-09. Retrieved 2021-03-01. +
  74. +
  75. ^ "Basic Syntax: Italic". The Markdown Guide. Matt Cone. Archived from the original on 26 March 2022. Retrieved 27 March 2022. To italicize text, add one asterisk or underscore before and after a word or phrase. To italicize the middle of a word for emphasis, add one asterisk without spaces around the letters. +
  76. +
  77. ^ Tom Preston-Werner. "GitHub Flavored Markdown Examples". GitHub. Archived from the original on 2021-05-13. Retrieved 2021-04-02. +
  78. +
  79. ^ "A formal spec for GitHub Flavored Markdown". GitHub Engineering. 14 March 2017. Archived from the original on 3 February 2020. Retrieved 16 Mar 2017. +
  80. +
  81. ^ Fortin, Michel (2018). "PHP Markdown Extra". Michel Fortin website. Archived from the original on 2021-01-17. Retrieved 2018-12-26. +
  82. +
  83. ^ "PHP Markdown Extra". Michel Fortin. Archived from the original on 2021-01-17. Retrieved 2018-12-26. +
  84. +
  85. ^ "Markdown editor for BUEditor". 4 December 2008. Archived from the original on 17 September 2020. Retrieved 15 January 2017. +
  86. +
  87. ^ "Plugin: wet_textfilter_markdown". Textpattern CMS plugins. 2025-04-27. +
  88. +
  89. ^ "Markdown for TYPO3 (markdown_content)". extensions.typo3.org. Archived from the original on 2021-02-01. Retrieved 2019-02-06. +
  90. +
  91. ^ "W3C Community Page of Markdown Implementations". W3C Markdown Wiki. Archived from the original on 17 September 2020. Retrieved 24 March 2016. +
  92. +
+
[edit]
+ + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+
+
+ + + +
+ + +
+
+ +
+
+
+
    + +
+
+ + + + \ No newline at end of file diff --git a/src/browser/analyze.test.ts b/src/browser/analyze.test.ts new file mode 100644 index 0000000..36f89e5 --- /dev/null +++ b/src/browser/analyze.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from 'vitest'; +import { + analyzeSite, + detectAntiBot, + classifyPattern, + findNearestAdapter, + scoreEndpointEvidence, + type PageSignals, +} from './analyze.js'; +import type { CliCommand } from '../registry.js'; + +function mkSignals(overrides: Partial = {}): PageSignals { + return { + requestedUrl: 'https://example.com/', + finalUrl: 'https://example.com/', + cookieNames: [], + networkEntries: [], + initialState: { + __INITIAL_STATE__: false, + __NUXT__: false, + __NEXT_DATA__: false, + __APOLLO_STATE__: false, + }, + title: 'Example', + ...overrides, + }; +} + +function mkCmd(site: string, name: string, domain?: string): CliCommand { + return { + site, + name, + access: 'read', + description: '', + domain, + browser: false, + args: [], + }; +} + +describe('detectAntiBot', () => { + it('flags Aliyun WAF from cookie', () => { + const v = detectAntiBot(mkSignals({ cookieNames: ['JSESSIONID', 'acw_sc__v2'] })); + expect(v.detected).toBe(true); + expect(v.vendor).toBe('aliyun_waf'); + expect(v.evidence).toContain('cookie:acw_sc__v2'); + expect(v.implication).toMatch(/browser context/i); + }); + + it('flags Aliyun WAF from challenge HTML body', () => { + const v = detectAntiBot( + mkSignals({ + networkEntries: [ + { + url: 'https://x.com/', + status: 200, + contentType: 'text/html', + bodyPreview: "var arg1 = 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6';", + }, + ], + }), + ); + expect(v.detected).toBe(true); + expect(v.vendor).toBe('aliyun_waf'); + }); + + it('flags Cloudflare from cf_clearance cookie', () => { + const v = detectAntiBot(mkSignals({ cookieNames: ['cf_clearance'] })); + expect(v.vendor).toBe('cloudflare'); + expect(v.implication).toMatch(/Cloudflare/i); + }); + + it('flags Akamai from _abck cookie', () => { + const v = detectAntiBot(mkSignals({ cookieNames: ['_abck', 'bm_sz'] })); + expect(v.vendor).toBe('akamai'); + }); + + it('returns no-match verdict with actionable fallback advice', () => { + const v = detectAntiBot(mkSignals()); + expect(v.detected).toBe(false); + expect(v.vendor).toBeNull(); + expect(v.implication).toMatch(/Node-side COOKIE fetch first/); + }); +}); + +describe('classifyPattern', () => { + it('returns A for JSON-heavy pages without SSR state', () => { + const v = classifyPattern( + mkSignals({ + networkEntries: [ + { url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{"items":[{"title":"A","id":"1"}]}' }, + { url: 'https://x.com/api/b', status: 200, contentType: 'application/json;charset=utf-8', bodyPreview: '{"data":{"results":[{"name":"B","url":"/b"}]}}' }, + ], + }), + ); + expect(v.pattern).toBe('A'); + expect(v.json_responses).toBe(2); + expect(v.real_data_candidates).toBe(2); + }); + + it('does not call analytics JSON a real API pattern', () => { + const v = classifyPattern( + mkSignals({ + networkEntries: [ + { url: 'https://x.com/analytics/collect', status: 200, contentType: 'application/json', bodyPreview: '{"event":"view","clientId":"abc","experiment":"A"}' }, + { url: 'https://x.com/personalization', status: 200, contentType: 'application/json', bodyPreview: '{"sessionId":"s1","metrics":{"latency":12}}' }, + ], + }), + ); + expect(v.pattern).toBe('C'); + expect(v.json_responses).toBe(2); + expect(v.real_data_candidates).toBe(0); + expect(v.reason).toMatch(/telemetry|side-channel/); + }); + + it('returns B when __INITIAL_STATE__ is present, beating JSON signals', () => { + const v = classifyPattern( + mkSignals({ + initialState: { __INITIAL_STATE__: true, __NUXT__: false, __NEXT_DATA__: false, __APOLLO_STATE__: false }, + networkEntries: [ + { url: 'https://x.com/api/a', status: 200, contentType: 'application/json', bodyPreview: '{}' }, + ], + }), + ); + expect(v.pattern).toBe('B'); + }); + + it('returns D when auth failures dominate', () => { + const v = classifyPattern( + mkSignals({ + networkEntries: [ + { url: 'https://x.com/api/a', status: 401, contentType: 'application/json', bodyPreview: '' }, + { url: 'https://x.com/api/b', status: 403, contentType: 'application/json', bodyPreview: '' }, + ], + }), + ); + expect(v.pattern).toBe('D'); + expect(v.auth_failures).toBe(2); + }); + + it('returns C by default for static pages', () => { + const v = classifyPattern(mkSignals()); + expect(v.pattern).toBe('C'); + }); +}); + +describe('scoreEndpointEvidence', () => { + it('scores non-empty business JSON above telemetry side-channel JSON', () => { + const data = scoreEndpointEvidence({ + url: 'https://x.com/api/search', + status: 200, + contentType: 'application/json', + bodyPreview: '{"data":{"items":[{"title":"A","price":12,"url":"/a"}],"total":1}}', + }); + const telemetry = scoreEndpointEvidence({ + url: 'https://x.com/analytics/collect', + status: 200, + contentType: 'application/json', + bodyPreview: '{"event":"view","clientId":"abc"}', + }); + + expect(data.verdict).toBe('likely_data'); + expect(data.real_data_score).toBeGreaterThan(telemetry.real_data_score); + expect(data.sample_paths).toContain('$.data.items:array(1)'); + expect(telemetry.verdict).toBe('noise'); + }); + + it('marks auth-gated JSON as blocked rather than data', () => { + const evidence = scoreEndpointEvidence({ + url: 'https://x.com/api/private', + status: 403, + contentType: 'application/json', + bodyPreview: '{"error":"forbidden"}', + }); + + expect(evidence.verdict).toBe('blocked'); + expect(evidence.real_data_score).toBeLessThan(0.1); + }); +}); + +describe('findNearestAdapter', () => { + it('matches by domain suffix', () => { + const reg = new Map([ + ['51job search', mkCmd('51job', 'search', '51job.com')], + ['51job detail', mkCmd('51job', 'detail', '51job.com')], + ['xueqiu search', mkCmd('xueqiu', 'search', 'xueqiu.com')], + ]); + const v = findNearestAdapter('https://jobs.51job.com/', reg); + expect(v?.site).toBe('51job'); + expect(v?.example_commands).toContain('51job search'); + }); + + it('falls back to site-name containment when no domain is registered', () => { + const reg = new Map([ + ['51job search', mkCmd('51job', 'search')], + ]); + const v = findNearestAdapter('https://we.51job.com/', reg); + expect(v?.site).toBe('51job'); + }); + + it('returns null when no adapter matches', () => { + const reg = new Map([ + ['xueqiu search', mkCmd('xueqiu', 'search', 'xueqiu.com')], + ]); + const v = findNearestAdapter('https://random-site.io/', reg); + expect(v).toBeNull(); + }); + + it('prefers the site with the most commands', () => { + const reg = new Map([ + ['a search', mkCmd('a', 'search', 'a.com')], + ['b search', mkCmd('b', 'search', 'a.com')], + ['b detail', mkCmd('b', 'detail', 'a.com')], + ['b company', mkCmd('b', 'company', 'a.com')], + ]); + const v = findNearestAdapter('https://jobs.a.com/', reg); + expect(v?.site).toBe('b'); + }); +}); + +describe('analyzeSite', () => { + it('recommends browser-context fetch when WAF is detected', () => { + const report = analyzeSite( + mkSignals({ cookieNames: ['acw_sc__v2'] }), + new Map(), + ); + expect(report.anti_bot.vendor).toBe('aliyun_waf'); + expect(report.recommended_next_step).toMatch(/browser context/i); + }); + + it('recommends reading SSR state when Pattern B fires', () => { + const report = analyzeSite( + mkSignals({ + initialState: { __INITIAL_STATE__: false, __NUXT__: true, __NEXT_DATA__: false, __APOLLO_STATE__: false }, + }), + new Map(), + ); + expect(report.pattern.pattern).toBe('B'); + expect(report.recommended_next_step).toMatch(/__NUXT__|__INITIAL_STATE__|__NEXT_DATA__/); + }); + + it('includes __APOLLO_STATE__ in Pattern B next-step guidance', () => { + const report = analyzeSite( + mkSignals({ + initialState: { __INITIAL_STATE__: false, __NUXT__: false, __NEXT_DATA__: false, __APOLLO_STATE__: true }, + }), + new Map(), + ); + expect(report.pattern.pattern).toBe('B'); + expect(report.recommended_next_step).toMatch(/__APOLLO_STATE__/); + }); + + it('includes nearest_adapter when the registry has a match', () => { + const reg = new Map([ + ['51job search', mkCmd('51job', 'search', '51job.com')], + ]); + const report = analyzeSite( + mkSignals({ finalUrl: 'https://we.51job.com/' }), + reg, + ); + expect(report.nearest_adapter?.site).toBe('51job'); + }); +}); diff --git a/src/browser/analyze.ts b/src/browser/analyze.ts new file mode 100644 index 0000000..d584907 --- /dev/null +++ b/src/browser/analyze.ts @@ -0,0 +1,532 @@ +/** + * `browser analyze ` — turn site-recon guesswork into deterministic CLI output. + * + * When an agent starts a new adapter, the first question is "which pattern am + * I looking at?" (A/B/C/D/E from site-recon docs) and "will Node-side fetch + * work, or will anti-bot middleware block me?". Today the agent has to open + * the page, poke `network`, try cURL, fail, guess again. This module condenses + * that into one call that returns a classification + evidence. + * + * Kept pure (no page imports) so the bulk is unit-testable; the CLI wrapper + * drives a real page, feeds the resulting signals here, and prints the verdict. + */ + +import type { CliCommand } from '../registry.js'; + +// ── Signals the CLI wrapper collects from a real page ────────────────────── + +export interface PageSignals { + /** URL we navigated to (may redirect; both fields help agents notice that). */ + requestedUrl: string; + finalUrl: string; + + /** document.cookie split into names; value not needed for detection. */ + cookieNames: string[]; + + /** + * Response bodies captured during the navigation + first few seconds. + * We only need enough body text to spot WAF markers; the CLI truncates + * per-entry before feeding us. + */ + networkEntries: Array<{ + url: string; + status: number; + contentType: string; + /** First N chars of body; null when not available. */ + bodyPreview: string | null; + }>; + + /** + * Which globals the page exposes on `window`. We don't care about the values, + * just presence — distinguishes Pattern B (SSR state) from Pattern A. + */ + initialState: { + __INITIAL_STATE__: boolean; + __NUXT__: boolean; + __NEXT_DATA__: boolean; + __APOLLO_STATE__: boolean; + }; + + /** Document title — only for the human-debug `summary` field. */ + title: string; +} + +export type EndpointEvidenceVerdict = 'likely_data' | 'maybe_data' | 'noise' | 'blocked'; + +export interface EndpointEvidence { + url: string; + status: number; + contentType: string; + real_data_score: number; + verdict: EndpointEvidenceVerdict; + reasons: string[]; + sample_paths: string[]; +} + +// ── Anti-bot detection ──────────────────────────────────────────────────── + +export type AntiBotVendor = + | 'aliyun_waf' + | 'cloudflare' + | 'akamai' + | 'geetest' + | 'unknown'; + +export interface AntiBotVerdict { + detected: boolean; + vendor: AntiBotVendor | null; + evidence: string[]; + /** One-line imperative instruction for the agent. */ + implication: string; +} + +/** + * WAF vendors we can reliably detect from cookies + response body markers + * alone. Signals are orthogonal per vendor — so when two vendors match + * simultaneously (rare), we keep all evidence and report the higher-signal + * vendor first. + */ +const WAF_SIGNATURES: Array<{ + vendor: Exclude; + cookiePatterns: RegExp[]; + bodyPatterns: RegExp[]; + implication: string; +}> = [ + { + vendor: 'aliyun_waf', + cookiePatterns: [/^acw_sc__v2$/, /^acw_tc$/, /^ssxmod_itna/], + bodyPatterns: [/arg1\s*=\s*['"][0-9A-F]{30,}/, /\/ntc_captcha\//i], + implication: + 'Direct Node-side fetch/curl will return the slider HTML. Validate the endpoint in browser context first; HTML COOKIE adapters still finish with Node-side fetch + page.getCookies.', + }, + { + vendor: 'cloudflare', + cookiePatterns: [/^__cf_bm$/, /^cf_clearance$/, /^__cfduid$/], + bodyPatterns: [/Cloudflare Ray ID/i, /Checking your browser before accessing/i, /cf-chl-/i], + implication: + 'Cloudflare bot check. Start from a real browser session; probe in browser context first. HTML COOKIE adapters still finish with Node-side fetch + page.getCookies.', + }, + { + vendor: 'akamai', + cookiePatterns: [/^_abck$/, /^bm_sz$/, /^bm_sv$/], + bodyPatterns: [/akamai/i], + implication: + 'Akamai Bot Manager. Probe in browser context first; keep final HTML COOKIE adapters on Node-side fetch + page.getCookies.', + }, + { + vendor: 'geetest', + cookiePatterns: [], + bodyPatterns: [/geetest/i, /gt_captcha/i], + implication: + 'Geetest slider/puzzle captcha. Agent cannot bypass programmatically — requires UI strategy or human-in-loop.', + }, +]; + +export function detectAntiBot(signals: PageSignals): AntiBotVerdict { + const evidence: string[] = []; + let match: typeof WAF_SIGNATURES[number] | null = null; + + for (const sig of WAF_SIGNATURES) { + const hits: string[] = []; + for (const pat of sig.cookiePatterns) { + const hit = signals.cookieNames.find((c) => pat.test(c)); + if (hit) hits.push(`cookie:${hit}`); + } + for (const pat of sig.bodyPatterns) { + for (const entry of signals.networkEntries) { + if (entry.bodyPreview && pat.test(entry.bodyPreview)) { + hits.push(`body:${entry.url}`); + break; + } + } + } + if (hits.length > 0 && !match) { + match = sig; + evidence.push(...hits); + } + } + + if (!match) { + return { + detected: false, + vendor: null, + evidence: [], + implication: 'No known anti-bot signatures. Try Node-side COOKIE fetch first; if endpoint validation is blocked, retry from browser context.', + }; + } + + return { + detected: true, + vendor: match.vendor, + evidence, + implication: match.implication, + }; +} + +// ── Pattern classification ──────────────────────────────────────────────── + +export type Pattern = 'A' | 'B' | 'C' | 'D' | 'E' | 'unknown'; + +export interface PatternVerdict { + pattern: Pattern; + reason: string; + /** How many JSON XHR/fetch responses we saw during navigation. */ + json_responses: number; + /** How many observed responses look like real business data, not telemetry. */ + real_data_candidates: number; + /** Count of non-2xx API responses — hint for token-gated (Pattern D). */ + auth_failures: number; +} + +const NOISE_URL_RE = /(?:analytics|beacon|collect|telemetry|tracking|sentry|doubleclick|google-analytics|googletagmanager|adservice|\/ads?(?:[/?#]|$)|metrics?|pixel|personalization|experiment|\/events?(?:[/?#]|$))/i; +const BUSINESS_KEY_RE = /^(?:data|items?|results?|records?|list|rows?|edges?|nodes?|timeline|users?|title|name|text|content|body|price|amount|id|url|avatar|nickname|desc|comments?|likes?|shares?|total|page|cursor|next|rank|score|date|time|author)$/i; +const TRACKING_KEY_RE = /^(?:event|events|trace|traceid|sessionid|clientid|visitorid|experiment|abtest|beacon|analytics|metrics?|pixel|log|logs)$/i; + +function clampScore(value: number): number { + return Math.max(0, Math.min(1, Math.round(value * 100) / 100)); +} + +function parseBodyPreview(preview: string | null): unknown { + if (!preview) return null; + const trimmed = preview.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function collectJsonPaths(value: unknown, prefix = '$', out: string[] = [], depth = 0): string[] { + if (depth > 4 || out.length >= 24) return out; + if (Array.isArray(value)) { + out.push(`${prefix}:array(${value.length})`); + if (value.length > 0) collectJsonPaths(value[0], `${prefix}[0]`, out, depth + 1); + return out; + } + if (!value || typeof value !== 'object') { + out.push(`${prefix}:${typeof value}`); + return out; + } + const entries = Object.entries(value as Record).slice(0, 12); + for (const [key, child] of entries) { + out.push(`${prefix}.${key}`); + if (Array.isArray(child)) out.push(`${prefix}.${key}:array(${child.length})`); + else if (child && typeof child === 'object') collectJsonPaths(child, `${prefix}.${key}`, out, depth + 1); + else out.push(`${prefix}.${key}:${typeof child}`); + } + return out; +} + +function countKeys(value: unknown, predicate: (key: string) => boolean, depth = 0): number { + if (depth > 4 || !value || typeof value !== 'object') return 0; + if (Array.isArray(value)) return value.slice(0, 3).reduce((sum, item) => sum + countKeys(item, predicate, depth + 1), 0); + return Object.entries(value as Record).reduce((sum, [key, child]) => ( + sum + (predicate(key) ? 1 : 0) + countKeys(child, predicate, depth + 1) + ), 0); +} + +function hasNonEmptyArray(value: unknown, depth = 0): boolean { + if (depth > 4 || !value || typeof value !== 'object') return false; + if (Array.isArray(value)) return value.length > 0; + return Object.values(value as Record).some((child) => hasNonEmptyArray(child, depth + 1)); +} + +export function scoreEndpointEvidence(entry: PageSignals['networkEntries'][number]): EndpointEvidence { + const reasons: string[] = []; + const body = parseBodyPreview(entry.bodyPreview); + let score = 0; + + if (entry.status >= 200 && entry.status < 300) { + score += 0.15; + reasons.push('2xx status'); + } else if (entry.status === 401 || entry.status === 403) { + return { + url: entry.url, + status: entry.status, + contentType: entry.contentType, + real_data_score: 0.05, + verdict: 'blocked', + reasons: ['auth-blocked status'], + sample_paths: [], + }; + } else { + reasons.push(`non-2xx status ${entry.status}`); + } + + if (/json/i.test(entry.contentType)) { + score += 0.2; + reasons.push('json content-type'); + } else if (/html/i.test(entry.contentType)) { + score -= 0.25; + reasons.push('html content-type'); + } else if (/javascript|text/i.test(entry.contentType)) { + score += 0.05; + reasons.push('text/script content-type'); + } + + if (NOISE_URL_RE.test(entry.url)) { + score -= 0.3; + reasons.push('telemetry-like url'); + } + + const samplePaths = collectJsonPaths(body).slice(0, 8); + if (typeof body === 'string') { + if (/^\s* 20) { + score += 0.05; + reasons.push('non-empty text body'); + } + } else if (Array.isArray(body)) { + if (body.length > 0) { + score += 0.3; + reasons.push('non-empty top-level array'); + } else { + score -= 0.15; + reasons.push('empty array'); + } + } else if (body && typeof body === 'object') { + const keys = Object.keys(body as Record); + if (keys.length === 0) { + score -= 0.15; + reasons.push('empty object'); + } else { + score += 0.12; + reasons.push('json object body'); + } + + const businessKeys = countKeys(body, (key) => BUSINESS_KEY_RE.test(key)); + if (businessKeys > 0) { + score += Math.min(0.3, businessKeys * 0.05); + reasons.push(`${businessKeys} business-like key${businessKeys === 1 ? '' : 's'}`); + } + if (hasNonEmptyArray(body)) { + score += 0.2; + reasons.push('nested non-empty array'); + } + const trackingKeys = countKeys(body, (key) => TRACKING_KEY_RE.test(key)); + if (trackingKeys > 0 && businessKeys === 0) { + score -= Math.min(0.25, trackingKeys * 0.08); + reasons.push(`${trackingKeys} tracking-like key${trackingKeys === 1 ? '' : 's'} without business keys`); + } + } + + const realDataScore = clampScore(score); + const verdict: EndpointEvidenceVerdict = realDataScore >= 0.65 + ? 'likely_data' + : realDataScore >= 0.35 + ? 'maybe_data' + : 'noise'; + + return { + url: entry.url, + status: entry.status, + contentType: entry.contentType, + real_data_score: realDataScore, + verdict, + reasons, + sample_paths: samplePaths, + }; +} + +export function scoreNetworkEvidence(signals: PageSignals): EndpointEvidence[] { + return signals.networkEntries + .map(scoreEndpointEvidence) + .filter((evidence) => evidence.verdict !== 'noise' || evidence.real_data_score > 0) + .sort((a, b) => b.real_data_score - a.real_data_score) + .slice(0, 8); +} + +/** + * Apply the decision tree from `site-recon.md` mechanically. + * + * B beats A when initial-state globals are present: even if the page fetches + * more data via XHR afterwards, the SSR payload is the highest-leverage source. + * D (token-gated) dominates when we see 401/403 on what looks like API + * endpoints — without that, an authenticated route looks identical to A. + */ +export function classifyPattern(signals: PageSignals): PatternVerdict { + const jsonEntries = signals.networkEntries.filter((e) => /json/i.test(e.contentType)); + const endpointEvidence = scoreNetworkEvidence(signals); + const realDataCandidates = endpointEvidence.filter((e) => e.verdict === 'likely_data' || e.verdict === 'maybe_data').length; + const authFailures = signals.networkEntries.filter( + (e) => e.status === 401 || e.status === 403, + ).length; + const hasInitialState = + signals.initialState.__INITIAL_STATE__ || + signals.initialState.__NUXT__ || + signals.initialState.__NEXT_DATA__ || + signals.initialState.__APOLLO_STATE__; + + if (authFailures >= 2 && jsonEntries.length >= 1) { + return { + pattern: 'D', + reason: `${authFailures} auth-failing API responses seen — endpoint is token-gated`, + json_responses: jsonEntries.length, + real_data_candidates: realDataCandidates, + auth_failures: authFailures, + }; + } + + if (hasInitialState) { + const which = Object.entries(signals.initialState) + .filter(([, v]) => v) + .map(([k]) => k); + return { + pattern: 'B', + reason: `SSR state global present: ${which.join(', ')}`, + json_responses: jsonEntries.length, + real_data_candidates: realDataCandidates, + auth_failures: authFailures, + }; + } + + if (realDataCandidates >= 1) { + return { + pattern: 'A', + reason: `${realDataCandidates} captured response${realDataCandidates === 1 ? '' : 's'} look like real data — inspect api_candidates before choosing a strategy`, + json_responses: jsonEntries.length, + real_data_candidates: realDataCandidates, + auth_failures: authFailures, + }; + } + + // No API, no SSR state — probably static HTML or a bundled SPA that lazy-loads. + // Pattern C (HTML scrape) is the default fallback; E (streaming) we can't + // reliably detect without watching WebSocket frames, so we label 'C' and + // leave the agent to upgrade to E manually if they see WS traffic. + return { + pattern: 'C', + reason: jsonEntries.length > 0 + ? `${jsonEntries.length} JSON response${jsonEntries.length === 1 ? '' : 's'} observed, but none look like target data — likely telemetry/side-channel; treat as HTML/DOM until an endpoint validates` + : 'No JSON XHR and no SSR state — HTML scrape (Pattern C); escalate to E manually if WebSocket traffic appears', + json_responses: jsonEntries.length, + real_data_candidates: realDataCandidates, + auth_failures: authFailures, + }; +} + +// ── Nearest-adapter lookup ──────────────────────────────────────────────── + +export interface NearestAdapter { + site: string; + example_commands: string[]; + reason: string; +} + +/** + * Find existing adapters that target the same site. + * + * Keep the hostname match simple — agents extend naming conventions + * differently per site, so we match on the registered `domain` field and fall + * back to site-name containment. Returning `null` is fine; agents can always + * read site-memory docs. + */ +export function findNearestAdapter( + finalUrl: string, + registry: Map, +): NearestAdapter | null { + let host: string; + try { + host = new URL(finalUrl).hostname; + } catch { + return null; + } + // Strip leading www.; 'www' as a site identifier is never what an adapter uses. + const cleanedHost = host.replace(/^www\./, ''); + // Extract apex (xx.com) and registrable parts for fuzzy match. + const parts = cleanedHost.split('.'); + const apex = parts.slice(-2).join('.'); + const siteKey = parts.length > 1 ? parts[parts.length - 2] : cleanedHost; + + const hits = new Map(); + for (const cmd of registry.values()) { + const domain = cmd.domain?.toLowerCase(); + const siteMatches = + (domain && (cleanedHost.endsWith(domain) || domain.endsWith(apex))) || + cmd.site.toLowerCase() === siteKey?.toLowerCase() || + cleanedHost.includes(cmd.site.toLowerCase()); + if (siteMatches) { + const list = hits.get(cmd.site) ?? []; + list.push(cmd); + hits.set(cmd.site, list); + } + } + if (hits.size === 0) return null; + + // Pick the site with the most commands — likely the most-developed adapter, + // and the best reference for a new command on the same host. + let best: [string, CliCommand[]] | null = null; + for (const entry of hits) { + if (!best || entry[1].length > best[1].length) best = entry; + } + if (!best) return null; + + return { + site: best[0], + example_commands: best[1].slice(0, 5).map((c) => `${c.site} ${c.name}`), + reason: `${best[1].length} existing adapter${best[1].length === 1 ? '' : 's'} target this site — reuse strategy/cookie config`, + }; +} + +// ── Top-level assembly ──────────────────────────────────────────────────── + +export interface AnalyzeReport { + requested_url: string; + final_url: string; + title: string; + pattern: PatternVerdict; + anti_bot: AntiBotVerdict; + initial_state: PageSignals['initialState']; + api_candidates: EndpointEvidence[]; + nearest_adapter: NearestAdapter | null; + recommended_next_step: string; +} + +/** + * Synthesize the verdict from collected signals + registry. + * + * The `recommended_next_step` is deliberately a single imperative + * sentence — agents act on it directly instead of re-deriving advice from + * the structured fields. + */ +export function analyzeSite( + signals: PageSignals, + registry: Map, +): AnalyzeReport { + const pattern = classifyPattern(signals); + const antiBot = detectAntiBot(signals); + const apiCandidates = scoreNetworkEvidence(signals); + const nearest = findNearestAdapter(signals.finalUrl, registry); + + let next: string; + if (antiBot.detected) { + next = antiBot.implication; + } else if (pattern.pattern === 'A') { + next = 'Inspect `api_candidates`, then replay the best endpoint and record the status/content-type/sample shape in your strategy note; do not choose API strategy from XHR count alone.'; + } else if (pattern.pattern === 'B') { + next = 'Read the SSR global via `opencli browser eval "JSON.stringify(window.__INITIAL_STATE__ ?? window.__NUXT__ ?? window.__NEXT_DATA__ ?? window.__APOLLO_STATE__)"` — no API needed.'; + } else if (pattern.pattern === 'C') { + next = 'No API visible — use SSR HTML scrape (e.g. `opencli browser extract`) against the rendered page.'; + } else if (pattern.pattern === 'D') { + next = 'Endpoints need auth. Re-open the page from a signed-in session, then retry analyze; see `field-decode-playbook` §4 for token tracing.'; + } else if (pattern.pattern === 'E') { + next = 'WebSocket stream detected — find the underlying HTTP poll/long-poll endpoint; raw WS is not supported.'; + } else { + next = 'No strong signal. Manually inspect `opencli browser network --all` and pick a pattern.'; + } + + return { + requested_url: signals.requestedUrl, + final_url: signals.finalUrl, + title: signals.title, + pattern, + anti_bot: antiBot, + initial_state: signals.initialState, + api_candidates: apiCandidates, + nearest_adapter: nearest, + recommended_next_step: next, + }; +} diff --git a/src/browser/article-extract.e2e.test.ts b/src/browser/article-extract.e2e.test.ts new file mode 100644 index 0000000..2a6376a --- /dev/null +++ b/src/browser/article-extract.e2e.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildExtractArticleJs, type ExtractArticleOptions, type ExtractedArticle } from './article-extract.js'; +import { downloadArticle } from '../download/article-download.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, '__fixtures__', 'article-extract'); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + tempDirs.length = 0; +}); + +function loadFixture(name: string): string { + return fs.readFileSync(path.join(fixturesDir, name), 'utf8'); +} + +function escapeHtml(text: string): string { + return text.replace(/[&<>]/g, ch => ({ '&': '&', '<': '<', '>': '>' }[ch]!)); +} + +function runExtract( + html: string, + url: string, + options: ExtractArticleOptions = {}, + contentType?: string, +): ExtractedArticle | null { + const dom = new JSDOM(html, { + url, + contentType: 'text/html', + pretendToBeVisual: true, + runScripts: 'outside-only', + }); + if (contentType) { + Object.defineProperty(dom.window.document, 'contentType', { + value: contentType, + configurable: true, + }); + } + return dom.window.eval(buildExtractArticleJs(options)) as ExtractedArticle | null; +} + +async function renderMarkdown( + article: ExtractedArticle, + url: string, + options: { cleanSelectors?: string[] } = {}, +): Promise { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-e2e-')); + tempDirs.push(tempDir); + const result = await downloadArticle({ + title: article.title || 'untitled', + contentHtml: article.html, + sourceUrl: url, + }, { + output: tempDir, + downloadImages: false, + cleanSelectors: options.cleanSelectors, + }); + expect(result[0].status).toBe('success'); + return fs.readFileSync(result[0].saved, 'utf8'); +} + +describe('article extract → markdown e2e fixtures', () => { + it('extracts a Wikipedia article fixture and keeps infobox/reference noise out of markdown', async () => { + const url = 'https://en.wikipedia.org/wiki/Markdown'; + const cleanSelectors = ['.infobox', '.navbox', '.reference', '.mw-editsection', '.metadata']; + const article = runExtract(loadFixture('wikipedia-markdown.html'), url, { cleanSelectors }); + expect(article?.source).toBe('readability'); + expect(article?.title).toBe('Markdown'); + if (!article) throw new Error('expected extracted article'); + + const md = await renderMarkdown(article, url, { cleanSelectors }); + expect(md).toContain('lightweight markup language'); + expect(md).toContain('John Gruber'); + expect(md).not.toContain('Syntax description'); + expect(md).not.toContain('Standard file extension'); + }); + + it('extracts a Deno blog fixture, preserves embedded iframes as markdown links, and drops page chrome', async () => { + const url = 'https://deno.com/blog/v2.0'; + const article = runExtract(loadFixture('deno-v2.html'), url); + expect(article?.source).toBe('readability'); + expect(article?.title).toBe('Announcing Deno 2 | Deno'); + if (!article) throw new Error('expected extracted article'); + + const md = await renderMarkdown(article, url); + expect(md).toContain('## Announcing Deno 2'); + expect(md).toContain('The web is humanity’s largest software platform'); + expect(md).toMatch(/\]\(https:\/\/www\.youtube(?:-nocookie)?\.com\/embed\/[^)]+\)/); + expect(md).not.toContain('Skip to main content'); + }); + + it('short-circuits non-HTML raw text pages end-to-end', async () => { + const url = 'https://raw.githubusercontent.com/openai/openai-cookbook/main/README.md'; + const text = loadFixture('openai-cookbook-readme.txt'); + const html = `OpenAI Cookbook README
${escapeHtml(text)}
`; + const article = runExtract(html, url, {}, 'text/plain'); + expect(article?.source).toBe('raw-text'); + if (!article) throw new Error('expected extracted article'); + + const md = await renderMarkdown(article, url); + expect(md).toContain('OPENAI\\_API\\_KEY'); + expect(md).toContain('Example code and guides for accomplishing common tasks'); + }); + + it('short-circuits a single-pre document end-to-end', async () => { + const url = 'https://raw.githubusercontent.com/openai/openai-cookbook/main/README.md'; + const text = loadFixture('openai-cookbook-readme.txt'); + const html = `OpenAI Cookbook README
${escapeHtml(text)}
`; + const article = runExtract(html, url); + expect(article?.source).toBe('pre'); + if (!article) throw new Error('expected extracted article'); + + const md = await renderMarkdown(article, url); + expect(md).toContain('OPENAI\\_API\\_KEY'); + expect(md).toContain('Most code examples are written in Python'); + }); +}); diff --git a/src/browser/article-extract.test.ts b/src/browser/article-extract.test.ts new file mode 100644 index 0000000..e3b8953 --- /dev/null +++ b/src/browser/article-extract.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + buildExtractArticleJs, + extractArticle, + DEFAULT_FALLBACK_SELECTORS, + type ExtractedArticle, + type PageLike, +} from './article-extract.js'; + +function fakePage(response: unknown): PageLike & { lastJs: string | null } { + const state = { lastJs: null as string | null }; + return { + lastJs: null, + async evaluate(js: string) { + state.lastJs = js; + Object.assign(this as unknown as { lastJs: string | null }, state); + return response; + }, + }; +} + +describe('buildExtractArticleJs', () => { + it('embeds Readability + Readerable sources once per evaluation', () => { + const js = buildExtractArticleJs(); + // Both libs should be inlined (matched by identifying strings from the + // upstream @mozilla/readability sources). + expect(js).toContain('function Readability(doc, options)'); + expect(js).toContain('function isProbablyReaderable'); + }); + + it('serializes caller-supplied options into the evaluated JS', () => { + const js = buildExtractArticleJs({ + cleanSelectors: ['.ads', '#banner'], + fallbackSelectors: ['article', 'body'], + force: true, + }); + expect(js).toContain('[".ads","#banner"]'); + expect(js).toContain('["article","body"]'); + expect(js).toContain('const force = true;'); + }); + + it('uses the default fallback chain when none is supplied', () => { + const js = buildExtractArticleJs(); + for (const sel of DEFAULT_FALLBACK_SELECTORS) { + expect(js).toContain(JSON.stringify(sel)); + } + }); + + it('runs fallback selection against the cleaned clone', () => { + const js = buildExtractArticleJs({ cleanSelectors: ['.noise'] }); + expect(js).toContain('el = cloneDoc.querySelector(sel);'); + expect(js).not.toContain('el = document.querySelector(sel);'); + }); + + it('produces syntactically valid JavaScript', () => { + // Parsing via the Function constructor rejects any syntax error in the + // generated code — including accidental template-literal break-outs from + // the embedded Readability sources. + expect(() => new Function(buildExtractArticleJs())).not.toThrow(); + expect(() => new Function(buildExtractArticleJs({ force: true }))).not.toThrow(); + expect(() => new Function(buildExtractArticleJs({ + cleanSelectors: ['.a', '.b'], + fallbackSelectors: ['main', 'body'], + }))).not.toThrow(); + }); +}); + +describe('extractArticle (host-side)', () => { + it('returns a normalized ExtractedArticle when the page responds with one', async () => { + const page = fakePage({ + source: 'readability', + html: '

hello

', + title: 'Hello', + byline: 'Alice', + publishedTime: '2026-04-22', + siteName: 'Example', + }); + const res = await extractArticle(page); + expect(res).toEqual({ + source: 'readability', + html: '

hello

', + title: 'Hello', + byline: 'Alice', + publishedTime: '2026-04-22', + siteName: 'Example', + }); + }); + + it('drops undefined optional fields cleanly', async () => { + const page = fakePage({ source: 'fallback', html: '
x
', title: 't' }); + const res = await extractArticle(page); + expect(res).toEqual({ source: 'fallback', html: '
x
', title: 't' }); + expect(res).not.toHaveProperty('byline'); + expect(res).not.toHaveProperty('publishedTime'); + }); + + it('returns null on a missing body or malformed payload', async () => { + expect(await extractArticle(fakePage(null))).toBeNull(); + expect(await extractArticle(fakePage('oops'))).toBeNull(); + expect(await extractArticle(fakePage({ source: 'readability' }))).toBeNull(); + expect(await extractArticle(fakePage({ html: '

x

' }))).toBeNull(); + }); + + it('defaults title to empty string when the page omits it', async () => { + const page = fakePage({ source: 'pre', html: '
x
' }); + const res = await extractArticle(page); + expect(res?.title).toBe(''); + expect(res?.source).toBe('pre'); + }); +}); diff --git a/src/browser/article-extract.ts b/src/browser/article-extract.ts new file mode 100644 index 0000000..3bc649e --- /dev/null +++ b/src/browser/article-extract.ts @@ -0,0 +1,203 @@ +/** + * Article extraction via Readability — generic `page → article HTML` pipeline. + * + * Complements `src/browser/extract.ts`: that one takes a caller-supplied + * selector. This one works with zero configuration on arbitrary article pages + * (blogs, news, docs) by running `@mozilla/readability` inside the page + * context via CDP evaluate. + * + * Pipeline: + * 1. Short-circuit non-HTML documents (`text/plain`, JSON, XML) — a page + * renderer wrapping a plain-text file would pollute the DOM pipeline. + * 2. Short-circuit the "body is a single
" case, which browsers use
+ *      when loading *.txt / *.md over file:// or raw.githubusercontent.com.
+ *   3. Deep-clone the document, apply caller-supplied `cleanSelectors` to the
+ *      clone (preserves live page state for subsequent snapshot/click).
+ *   4. Inject Readability + isProbablyReaderable sources into the page,
+ *      parse on the clone. `isProbablyReaderable` gates the parse unless
+ *      `force: true`.
+ *   5. On Readability miss, walk a fallback selector chain
+ *      (main → [role="main"] → #main-content → … → body) and return the
+ *      first root with >80 characters of text.
+ *
+ * Readability runs in the page's own window because it needs real DOM APIs
+ * (getComputedStyle, treeWalker). Running it Node-side would require jsdom —
+ * a heavy dep the rest of OpenCLI doesn't need.
+ */
+
+import * as fs from 'node:fs';
+import { createRequire } from 'node:module';
+
+const requireFromHere = createRequire(import.meta.url);
+
+let cachedSources: { readability: string; readerable: string } | null = null;
+
+function readabilitySources(): { readability: string; readerable: string } {
+  if (cachedSources) return cachedSources;
+  const readabilityPath = requireFromHere.resolve('@mozilla/readability/Readability.js');
+  const readerablePath = requireFromHere.resolve('@mozilla/readability/Readability-readerable.js');
+  cachedSources = {
+    readability: fs.readFileSync(readabilityPath, 'utf8'),
+    readerable: fs.readFileSync(readerablePath, 'utf8'),
+  };
+  return cachedSources;
+}
+
+export interface ExtractArticleOptions {
+  /** CSS selectors removed from the cloned document before Readability runs. */
+  cleanSelectors?: string[];
+  /** Fallback chain when Readability fails. Defaults to the common structural ids. */
+  fallbackSelectors?: string[];
+  /** Bypass `isProbablyReaderable` and always attempt a parse. */
+  force?: boolean;
+}
+
+export type ExtractSource = 'readability' | 'fallback' | 'raw-text' | 'pre';
+
+export interface ExtractedArticle {
+  html: string;
+  title: string;
+  byline?: string;
+  publishedTime?: string;
+  siteName?: string;
+  source: ExtractSource;
+}
+
+export const DEFAULT_FALLBACK_SELECTORS: string[] = [
+  'main',
+  '[role="main"]',
+  '#main-content',
+  '#main',
+  '#content',
+  '.content',
+  'article',
+  'body',
+];
+
+const MIN_FALLBACK_TEXT_LENGTH = 80;
+
+/**
+ * Build the JS expression evaluated in-page to extract the article. Exported
+ * for testability — callers on the host side should use `extractArticle`.
+ */
+export function buildExtractArticleJs(options: ExtractArticleOptions = {}): string {
+  const { readability, readerable } = readabilitySources();
+  const cleanSelectors = options.cleanSelectors ?? [];
+  const fallbackSelectors = options.fallbackSelectors ?? DEFAULT_FALLBACK_SELECTORS;
+  const force = !!options.force;
+
+  // Library sources contain backticks and ${...} fragments, so we embed them
+  // as JSON-encoded string literals and eval them inside a Function() scope.
+  // This isolates their var declarations from the outer IIFE without polluting
+  // window globals.
+  const readabilityLit = JSON.stringify(readability);
+  const readerableLit = JSON.stringify(readerable);
+  const cleanLit = JSON.stringify(cleanSelectors);
+  const fallbackLit = JSON.stringify(fallbackSelectors);
+  const forceLit = JSON.stringify(force);
+
+  return [
+    '(() => {',
+    '  const cleanSelectors = ' + cleanLit + ';',
+    '  const fallbackSelectors = ' + fallbackLit + ';',
+    '  const force = ' + forceLit + ';',
+    '  const minFallbackText = ' + MIN_FALLBACK_TEXT_LENGTH + ';',
+    '  const readabilitySrc = ' + readabilityLit + ';',
+    '  const readerableSrc = ' + readerableLit + ';',
+    '',
+    '  function escapeHtml(s) {',
+    '    return String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));',
+    '  }',
+    '',
+    '  // Short-circuit 1: non-HTML document',
+    '  const ct = document.contentType || "";',
+    '  if (ct && ct !== "text/html" && ct !== "application/xhtml+xml") {',
+    '    const body = document.body ? (document.body.textContent || "") : "";',
+    '    return { source: "raw-text", html: "
" + escapeHtml(body) + "
", title: document.title || "" };', + ' }', + '', + ' // Short-circuit 2: body is a single
',
+    '  if (document.body) {',
+    '    const kids = document.body.children;',
+    '    if (kids.length === 1 && kids[0] && kids[0].tagName === "PRE") {',
+    '      return { source: "pre", html: document.body.outerHTML, title: document.title || "" };',
+    '    }',
+    '  }',
+    '',
+    '  // Deep-clone + adapter-supplied dirty-node removal',
+    '  const cloneDoc = document.cloneNode(true);',
+    '  for (const sel of cleanSelectors) {',
+    '    try { for (const n of cloneDoc.querySelectorAll(sel)) n.remove(); }',
+    '    catch (e) { /* ignore invalid selector */ }',
+    '  }',
+    '',
+    '  // Inject Readability into an isolated Function scope and extract the',
+    '  // constructors we need. Library sources use their own module.exports',
+    '  // guard (if typeof module === "object"), which is falsy here.',
+    '  const libs = (new Function(',
+    '    readabilitySrc + "\\n" + readerableSrc + "\\nreturn {" +',
+    '    " Readability: typeof Readability !== \\"undefined\\" ? Readability : null," +',
+    '    " isProbablyReaderable: typeof isProbablyReaderable !== \\"undefined\\" ? isProbablyReaderable : null" +',
+    '    " };"',
+    '  ))();',
+    '  const Readability = libs.Readability;',
+    '  const isProbablyReaderable = libs.isProbablyReaderable;',
+    '',
+    '  const readerableOk = force || (typeof isProbablyReaderable === "function" ? isProbablyReaderable(cloneDoc) : true);',
+    '  let article = null;',
+    '  if (readerableOk && typeof Readability === "function") {',
+    '    try { article = new Readability(cloneDoc).parse(); } catch (e) { article = null; }',
+    '  }',
+    '  if (article && article.content) {',
+    '    return {',
+    '      source: "readability",',
+    '      html: article.content,',
+    '      title: article.title || document.title || "",',
+    '      byline: article.byline || undefined,',
+    '      publishedTime: article.publishedTime || undefined,',
+    '      siteName: article.siteName || undefined,',
+    '    };',
+    '  }',
+    '',
+    '  // Fallback chain',
+    '  for (const sel of fallbackSelectors) {',
+    '    let el = null;',
+    '    try { el = cloneDoc.querySelector(sel); } catch (e) { continue; }',
+    '    if (!el) continue;',
+    '    const text = (el.textContent || "").trim();',
+    '    if (text.length < minFallbackText) continue;',
+    '    return { source: "fallback", html: el.outerHTML, title: document.title || "" };',
+    '  }',
+    '',
+    '  return null;',
+    '})()',
+  ].join('\n');
+}
+
+export interface PageLike {
+  evaluate(js: string): Promise;
+}
+
+/**
+ * Run the extract pipeline on the given page. Returns `null` when no usable
+ * content is found (Readability miss + empty fallback chain).
+ */
+export async function extractArticle(
+  page: PageLike,
+  options: ExtractArticleOptions = {},
+): Promise {
+  const js = buildExtractArticleJs(options);
+  const raw = await page.evaluate(js);
+  if (raw == null || typeof raw !== 'object') return null;
+  const r = raw as Partial & { source?: string };
+  if (typeof r.html !== 'string' || typeof r.source !== 'string') return null;
+  const source = r.source as ExtractSource;
+  return {
+    html: r.html,
+    title: typeof r.title === 'string' ? r.title : '',
+    ...(r.byline && { byline: r.byline }),
+    ...(r.publishedTime && { publishedTime: r.publishedTime }),
+    ...(r.siteName && { siteName: r.siteName }),
+    source,
+  };
+}
diff --git a/src/browser/ax-snapshot.test.ts b/src/browser/ax-snapshot.test.ts
new file mode 100644
index 0000000..3c2ab95
--- /dev/null
+++ b/src/browser/ax-snapshot.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { buildAxSnapshot, buildAxSnapshotFromTrees, findAxRefReplacement } from './ax-snapshot.js';
+
+describe('AX snapshot prototype', () => {
+  it('builds compact refs from Accessibility.getFullAXTree output', () => {
+    const result = buildAxSnapshot({
+      nodes: [
+        { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Example' }, childIds: ['2', '3'] },
+        { nodeId: '2', role: { value: 'heading' }, name: { value: 'Expenses' }, backendDOMNodeId: 20 },
+        {
+          nodeId: '3',
+          role: { value: 'combobox' },
+          name: { value: 'Category' },
+          backendDOMNodeId: 30,
+          properties: [{ name: 'expanded', value: { value: false } }],
+        },
+      ],
+    });
+
+    expect(result.text).toContain('source: ax');
+    expect(result.text).toContain('[1]heading "Expenses"');
+    expect(result.text).toContain('[2]combobox "Category" expanded=false');
+    expect(result.refs.get('2')).toEqual({
+      ref: '2',
+      backendNodeId: 30,
+      role: 'combobox',
+      name: 'Category',
+    });
+  });
+
+  it('tracks nth only for duplicate role/name pairs', () => {
+    const result = buildAxSnapshot({
+      nodes: [
+        { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2', '3'] },
+        { nodeId: '2', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 10 },
+        { nodeId: '3', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 11 },
+      ],
+    });
+
+    expect(result.refs.get('1')).toMatchObject({ role: 'button', name: 'Save', nth: 0 });
+    expect(result.refs.get('2')).toMatchObject({ role: 'button', name: 'Save', nth: 1 });
+  });
+
+  it('finds stale ref replacements by role/name/nth', () => {
+    const replacement = findAxRefReplacement({
+      nodes: [
+        { nodeId: '1', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 10 },
+        { nodeId: '2', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 42 },
+      ],
+    }, {
+      ref: '2',
+      role: 'button',
+      name: 'Save',
+      backendNodeId: 11,
+      nth: 1,
+    });
+
+    expect(replacement).toMatchObject({ ref: '2', backendNodeId: 42, role: 'button', name: 'Save', nth: 1 });
+  });
+
+  it('combines frame AX trees while keeping ref metadata frame-scoped', () => {
+    const result = buildAxSnapshotFromTrees([
+      {
+        tree: {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 10 },
+          ],
+        },
+      },
+      {
+        frame: { frameId: 'frame-1', url: 'https://app.example/embed' },
+        tree: {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 20 },
+          ],
+        },
+      },
+    ]);
+
+    expect(result.text).toContain('[1]button "Save"');
+    expect(result.text).toContain('frame "https://app.example/embed":');
+    expect(result.text).toContain('  [2]button "Save"');
+    expect(result.refs.get('1')).toEqual({
+      ref: '1',
+      backendNodeId: 10,
+      role: 'button',
+      name: 'Save',
+    });
+    expect(result.refs.get('2')).toEqual({
+      ref: '2',
+      backendNodeId: 20,
+      role: 'button',
+      name: 'Save',
+      frame: { frameId: 'frame-1', url: 'https://app.example/embed' },
+    });
+  });
+});
diff --git a/src/browser/ax-snapshot.ts b/src/browser/ax-snapshot.ts
new file mode 100644
index 0000000..ae930dc
--- /dev/null
+++ b/src/browser/ax-snapshot.ts
@@ -0,0 +1,271 @@
+/**
+ * AX-backed browser snapshot prototype.
+ *
+ * This is intentionally additive to the current DOM snapshot. It learns from
+ * agent-browser's accessibility-tree refs without changing default `state`
+ * output until the AX path proves itself on fixtures and real SaaS workflows.
+ */
+
+export interface BrowserRef {
+  ref: string;
+  backendNodeId?: number;
+  role: string;
+  name: string;
+  nth?: number;
+  frame?: { frameId?: string; sessionId?: string; url?: string; targetUrl?: string };
+}
+
+export interface AxSnapshotTree {
+  tree: unknown;
+  frame?: BrowserRef['frame'];
+}
+
+export interface AxSnapshotBuildResult {
+  text: string;
+  refs: Map;
+}
+
+interface AxValue {
+  value?: unknown;
+}
+
+interface AxProperty {
+  name?: string;
+  value?: AxValue;
+}
+
+interface AxNode {
+  nodeId?: string;
+  ignored?: boolean;
+  role?: AxValue;
+  name?: AxValue;
+  value?: AxValue;
+  properties?: AxProperty[];
+  childIds?: string[];
+  backendDOMNodeId?: number;
+}
+
+const INTERACTIVE_ROLES = new Set([
+  'button',
+  'link',
+  'textbox',
+  'searchbox',
+  'checkbox',
+  'radio',
+  'combobox',
+  'listbox',
+  'menuitem',
+  'menuitemcheckbox',
+  'menuitemradio',
+  'option',
+  'slider',
+  'spinbutton',
+  'switch',
+  'tab',
+  'treeitem',
+]);
+
+const CONTENT_ROLES = new Set([
+  'article',
+  'cell',
+  'columnheader',
+  'gridcell',
+  'heading',
+  'listitem',
+  'main',
+  'navigation',
+  'region',
+  'rowheader',
+]);
+
+const STRUCTURAL_ROLES = new Set([
+  'generic',
+  'group',
+  'list',
+  'none',
+  'presentation',
+  'RootWebArea',
+  'WebArea',
+]);
+
+export function buildAxSnapshot(
+  axTree: unknown,
+  opts: { maxDepth?: number; interactiveOnly?: boolean } = {},
+): AxSnapshotBuildResult {
+  return buildAxSnapshotFromTrees([{ tree: axTree }], opts);
+}
+
+export function buildAxSnapshotFromTrees(
+  trees: AxSnapshotTree[],
+  opts: { maxDepth?: number; interactiveOnly?: boolean } = {},
+): AxSnapshotBuildResult {
+  const lines: string[] = ['source: ax', '---'];
+  const refs = new Map();
+  let nextRef = 1;
+
+  for (const [index, entry] of trees.entries()) {
+    if (index > 0) {
+      const label = entry.frame?.url ? JSON.stringify(entry.frame.url) : JSON.stringify(entry.frame?.frameId ?? `frame:${index}`);
+      lines.push(`frame ${label}:`);
+    }
+    nextRef = renderAxTree(entry.tree, lines, refs, nextRef, {
+      ...opts,
+      frame: entry.frame,
+      baseDepth: index > 0 ? 1 : 0,
+    });
+  }
+
+  lines.push('---');
+  lines.push(`interactive: ${refs.size}`);
+  return { text: lines.join('\n'), refs };
+}
+
+function renderAxTree(
+  axTree: unknown,
+  lines: string[],
+  refs: Map,
+  nextRef: number,
+  opts: { maxDepth?: number; interactiveOnly?: boolean; frame?: BrowserRef['frame']; baseDepth: number },
+): number {
+  const rawNodes = Array.isArray((axTree as { nodes?: unknown[] } | null)?.nodes)
+    ? ((axTree as { nodes: unknown[] }).nodes as AxNode[])
+    : [];
+  const nodes = rawNodes.filter((node) => node && !node.ignored);
+  const byId = new Map();
+  const parentIds = new Set();
+  for (const node of nodes) {
+    if (typeof node.nodeId === 'string') byId.set(node.nodeId, node);
+    for (const childId of node.childIds ?? []) parentIds.add(childId);
+  }
+
+  const roots = nodes.filter((node) => {
+    if (!node.nodeId) return false;
+    const role = axString(node.role);
+    return !parentIds.has(node.nodeId) || role === 'RootWebArea' || role === 'WebArea';
+  });
+  const root = roots[0] ?? nodes[0];
+  const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
+  const roleNameCounts = countRoleNames(nodes);
+  const roleNameSeen = new Map();
+
+  function render(node: AxNode | undefined, depth: number): boolean {
+    if (!node || depth > maxDepth) return false;
+    const role = axString(node.role) || 'generic';
+    const name = cleanText(axString(node.name));
+    const value = cleanText(axString(node.value) || propertyValue(node, 'value'));
+    const disabled = propertyValue(node, 'disabled');
+    const checked = propertyValue(node, 'checked');
+    const expanded = propertyValue(node, 'expanded');
+    const selected = propertyValue(node, 'selected');
+
+    const refEligible = shouldRef(role, name, node.backendDOMNodeId);
+    const shouldShowSelf = refEligible
+      || !!name
+      || !!value
+      || CONTENT_ROLES.has(role)
+      || (!opts.interactiveOnly && !STRUCTURAL_ROLES.has(role));
+
+    const childStart = lines.length;
+    let hasVisibleChild = false;
+    for (const childId of node.childIds ?? []) {
+      if (render(byId.get(childId), depth + 1)) hasVisibleChild = true;
+    }
+
+    if (!shouldShowSelf && !hasVisibleChild) {
+      lines.length = childStart;
+      return false;
+    }
+
+    if (shouldShowSelf) {
+      const indent = '  '.repeat(depth);
+      const parts: string[] = [];
+      let prefix = '';
+      if (refEligible) {
+        const ref = String(nextRef++);
+        prefix = `[${ref}]`;
+        const key = roleNameKey(role, name);
+        const seen = roleNameSeen.get(key) ?? 0;
+        roleNameSeen.set(key, seen + 1);
+        refs.set(ref, {
+          ref,
+          backendNodeId: node.backendDOMNodeId,
+          role,
+          name,
+          ...(roleNameCounts.get(key)! > 1 ? { nth: seen } : {}),
+          ...(opts.frame ? { frame: opts.frame } : {}),
+        });
+      }
+      if (name) parts.push(JSON.stringify(name));
+      if (value && value !== name) parts.push(`value=${JSON.stringify(value)}`);
+      if (checked) parts.push(`checked=${checked}`);
+      if (expanded) parts.push(`expanded=${expanded}`);
+      if (selected) parts.push(`selected=${selected}`);
+      if (disabled === 'true') parts.push('disabled');
+      lines.splice(childStart, 0, `${indent}${prefix}${role}${parts.length ? ` ${parts.join(' ')}` : ''}`);
+    }
+
+    return true;
+  }
+
+  render(root, opts.baseDepth);
+  return nextRef;
+}
+
+export function findAxRefReplacement(axTree: unknown, ref: BrowserRef): BrowserRef | null {
+  const nodes = Array.isArray((axTree as { nodes?: unknown[] } | null)?.nodes)
+    ? ((axTree as { nodes: unknown[] }).nodes as AxNode[])
+    : [];
+  const targetNth = ref.nth ?? 0;
+  let seen = 0;
+  for (const node of nodes) {
+    if (!node || node.ignored) continue;
+    const role = axString(node.role);
+    const name = cleanText(axString(node.name));
+    if (role !== ref.role || name !== ref.name) continue;
+    if (seen === targetNth) {
+      if (typeof node.backendDOMNodeId !== 'number') return null;
+      return { ...ref, backendNodeId: node.backendDOMNodeId };
+    }
+    seen++;
+  }
+  return null;
+}
+
+function shouldRef(role: string, name: string, backendNodeId: unknown): backendNodeId is number {
+  if (typeof backendNodeId !== 'number') return false;
+  if (INTERACTIVE_ROLES.has(role)) return true;
+  return CONTENT_ROLES.has(role) && !!name;
+}
+
+function countRoleNames(nodes: AxNode[]): Map {
+  const counts = new Map();
+  for (const node of nodes) {
+    if (!node || node.ignored) continue;
+    const role = axString(node.role);
+    const name = cleanText(axString(node.name));
+    if (!shouldRef(role, name, node.backendDOMNodeId)) continue;
+    const key = roleNameKey(role, name);
+    counts.set(key, (counts.get(key) ?? 0) + 1);
+  }
+  return counts;
+}
+
+function roleNameKey(role: string, name: string): string {
+  return `${role}\u0000${name}`;
+}
+
+function axString(value: AxValue | undefined): string {
+  const raw = value?.value;
+  if (typeof raw === 'string') return raw;
+  if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
+  return '';
+}
+
+function propertyValue(node: AxNode, name: string): string {
+  const prop = node.properties?.find((candidate) => candidate.name === name);
+  return axString(prop?.value);
+}
+
+function cleanText(value: string): string {
+  return value.replace(/\s+/g, ' ').trim().slice(0, 160);
+}
diff --git a/src/browser/base-page.test.ts b/src/browser/base-page.test.ts
new file mode 100644
index 0000000..5a32925
--- /dev/null
+++ b/src/browser/base-page.test.ts
@@ -0,0 +1,941 @@
+import { describe, expect, it, vi } from 'vitest';
+import { CliError } from '../errors.js';
+import { BasePage } from './base-page.js';
+import { TargetError } from './target-errors.js';
+import type { BrowserEvaluateFunction, ScreenshotOptions } from '../types.js';
+
+class TestPage extends BasePage {
+  result: unknown;
+  args: Record | undefined;
+
+  async goto(): Promise {}
+  async evaluate(_js: string): Promise;
+  async evaluate(_fn: BrowserEvaluateFunction, ..._args: Args): Promise>;
+  async evaluate(): Promise { return null; }
+  override async evaluateWithArgs(_js: string, args: Record): Promise {
+    this.args = args;
+    return this.result;
+  }
+  async getCookies(): Promise<[]> { return []; }
+  async screenshot(): Promise { return ''; }
+  async tabs(): Promise { return []; }
+  async selectTab(): Promise {}
+}
+
+class ActionPage extends BasePage {
+  results: unknown[] = [];
+  withArgsResults: unknown[] = [];
+  scripts: string[] = [];
+  withArgs: Record[] = [];
+  screenshotCalls: ScreenshotOptions[] = [];
+  nativeType?: (text: string) => Promise;
+  insertText?: (text: string) => Promise;
+  nativeKeyPress?: (key: string, modifiers?: string[]) => Promise;
+  nativeClick?: (x: number, y: number) => Promise;
+  setFileInput?: (files: string[], selector?: string) => Promise;
+  cdp?: (method: string, params?: Record) => Promise;
+
+  async goto(): Promise {}
+  async evaluate(js: string): Promise;
+  async evaluate(fn: BrowserEvaluateFunction, ...args: Args): Promise>;
+  async evaluate(input: string | BrowserEvaluateFunction): Promise {
+    this.scripts.push(typeof input === 'string' ? input : input.toString());
+    return this.results.shift() ?? null;
+  }
+  override async evaluateWithArgs(js: string, args: Record): Promise {
+    this.scripts.push(js);
+    this.withArgs.push(args);
+    return this.withArgsResults.shift() ?? null;
+  }
+  async getCookies(): Promise<[]> { return []; }
+  async screenshot(options: ScreenshotOptions = {}): Promise {
+    this.screenshotCalls.push(options);
+    return 'shot';
+  }
+  async tabs(): Promise { return []; }
+  async selectTab(): Promise {}
+}
+
+const resolveOk = { ok: true, matches_n: 1, match_level: 'exact' };
+
+describe('BasePage.fetchJson', () => {
+  it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
+    const page = new TestPage();
+    page.result = {
+      ok: true,
+      status: 200,
+      url: 'https://api.example.com/items',
+      contentType: 'application/json',
+      text: '{"items":[1]}',
+    };
+
+    await expect(page.fetchJson('https://api.example.com/items', {
+      method: 'POST',
+      headers: { 'X-Test': '1' },
+      body: { q: 'opencli' },
+      timeoutMs: 1234,
+    })).resolves.toEqual({ items: [1] });
+
+    expect(page.args).toEqual({
+      request: {
+        url: 'https://api.example.com/items',
+        method: 'POST',
+        headers: { 'X-Test': '1' },
+        body: { q: 'opencli' },
+        hasBody: true,
+        timeoutMs: 1234,
+      },
+    });
+  });
+
+  it('throws a CliError for non-JSON responses', async () => {
+    const page = new TestPage();
+    page.result = {
+      ok: true,
+      status: 200,
+      url: 'https://api.example.com/items',
+      contentType: 'text/html',
+      text: 'blocked',
+    };
+
+    const err = await page.fetchJson('https://api.example.com/items').catch((error: unknown) => error);
+    expect(err).toBeInstanceOf(CliError);
+    expect((err as CliError).code).toBe('FETCH_ERROR');
+    expect((err as CliError).message).toContain('Expected JSON');
+    expect((err as CliError).hint).toContain('blocked');
+  });
+
+  it('throws a CliError for browser fetch transport errors', async () => {
+    const page = new TestPage();
+    page.result = {
+      ok: false,
+      status: 0,
+      url: 'https://api.example.com/items',
+      text: '',
+      error: 'The operation was aborted.',
+    };
+
+    await expect(page.fetchJson('https://api.example.com/items')).rejects.toMatchObject({
+      code: 'FETCH_ERROR',
+      message: expect.stringContaining('The operation was aborted.'),
+    });
+  });
+});
+
+describe('BasePage annotatedScreenshot', () => {
+  it('refreshes DOM refs, captures with a temporary visual overlay, and cleans up', async () => {
+    const page = new ActionPage();
+    page.results = [
+      'snapshot',
+      '["hash"]',
+      { annotated: 1, truncated: false },
+      true,
+    ];
+
+    await expect(page.annotatedScreenshot({ path: '/tmp/opencli.png', annotate: true })).resolves.toBe('shot');
+
+    expect(page.scripts[0]).toContain('const VIEWPORT_EXPAND = 0');
+    expect(page.scripts[2]).toContain('__opencli_visual_ref_overlay');
+    expect(page.scripts[2]).toContain('[data-opencli-ref]');
+    expect(page.scripts[3]).toContain('__opencli_visual_ref_overlay');
+    expect(page.screenshotCalls).toEqual([{ path: '/tmp/opencli.png', annotate: false }]);
+  });
+});
+
+describe('BasePage native input routing', () => {
+  it('types rich-editor text via native Input.insertText when available', async () => {
+    const page = new ActionPage();
+    page.nativeType = vi.fn().mockResolvedValue(undefined);
+    page.results = [resolveOk, { ok: true, mode: 'contenteditable' }];
+
+    await expect(page.typeText('#editor', 'hello')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
+
+    expect(page.nativeType).toHaveBeenCalledWith('hello');
+    expect(page.scripts).toHaveLength(2);
+    expect(page.scripts[1]).toContain('nearestContentEditableHost');
+    expect(page.scripts.join('\n')).not.toContain("return 'typed'");
+  });
+
+  it('uses CDP DOM focus and scroll before native text insertion when available', async () => {
+    const page = new ActionPage();
+    page.nativeType = vi.fn().mockResolvedValue(undefined);
+    page.cdp = vi.fn()
+      .mockResolvedValueOnce({})
+      .mockResolvedValueOnce({ root: { nodeId: 1 } })
+      .mockResolvedValueOnce({ nodeId: 7 })
+      .mockResolvedValueOnce({})
+      .mockResolvedValueOnce({})
+      .mockResolvedValueOnce({ root: { nodeId: 1 } })
+      .mockResolvedValueOnce({ nodeId: 7 })
+      .mockResolvedValueOnce({});
+    page.results = [resolveOk, { ok: true, mode: 'input' }];
+    page.withArgsResults = [{ ok: true }, undefined, { ok: true }, undefined];
+
+    await page.typeText('#q', 'hello');
+
+    expect(page.cdp).toHaveBeenCalledWith('DOM.scrollIntoViewIfNeeded', { nodeId: 7 });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 7 });
+    expect(page.nativeType).toHaveBeenCalledWith('hello');
+    expect(page.scripts.at(-1)).toContain('if (false) el.scrollIntoView');
+    expect(page.scripts.at(-1)).toContain('if (false) {');
+  });
+
+  it('keeps the DOM setter fallback when native text insertion is unavailable', async () => {
+    const page = new ActionPage();
+    page.results = [resolveOk, 'typed'];
+
+    await page.typeText('#q', 'hello');
+
+    expect(page.scripts).toHaveLength(2);
+    expect(page.scripts[1]).toContain('document.execCommand');
+    expect(page.scripts[1]).toContain("return 'typed'");
+  });
+
+  it('falls back to DOM typing if native text insertion fails', async () => {
+    const page = new ActionPage();
+    page.nativeType = vi.fn().mockRejectedValue(new Error('native failed'));
+    page.results = [resolveOk, { ok: true, mode: 'input' }, 'typed'];
+
+    await page.typeText('#q', 'hello');
+
+    expect(page.nativeType).toHaveBeenCalledWith('hello');
+    expect(page.scripts).toHaveLength(3);
+    expect(page.scripts[2]).toContain("return 'typed'");
+  });
+
+  it('fills text through the native input path and verifies the exact value', async () => {
+    const page = new ActionPage();
+    page.nativeType = vi.fn().mockResolvedValue(undefined);
+    page.results = [
+      resolveOk,
+      { ok: true, mode: 'textarea' },
+      { ok: true, actual: 'line1\\n/ / raw', expected: 'line1\\n/ / raw', length: 14, mode: 'textarea' },
+    ];
+
+    await expect(page.fillText('#message', 'line1\\n/ / raw')).resolves.toEqual({
+      filled: true,
+      verified: true,
+      expected: 'line1\\n/ / raw',
+      actual: 'line1\\n/ / raw',
+      length: 14,
+      matches_n: 1,
+      match_level: 'exact',
+      mode: 'textarea',
+    });
+
+    expect(page.nativeType).toHaveBeenCalledWith('line1\\n/ / raw');
+    expect(page.scripts).toHaveLength(3);
+    expect(page.scripts[2]).toContain('actual ===');
+  });
+
+  it('falls back to the DOM setter when native fill insertion is unavailable', async () => {
+    const page = new ActionPage();
+    page.results = [
+      resolveOk,
+      { ok: true, mode: 'input' },
+      'typed',
+      { ok: true, actual: 'hello', expected: 'hello', length: 5, mode: 'input' },
+    ];
+
+    await expect(page.fillText('#q', 'hello')).resolves.toEqual(expect.objectContaining({
+      filled: true,
+      verified: true,
+      actual: 'hello',
+      mode: 'input',
+    }));
+
+    expect(page.scripts).toHaveLength(4);
+    expect(page.scripts[2]).toContain("return 'typed'");
+  });
+
+  it('falls back to DOM fill if native insertion does not verify', async () => {
+    const page = new ActionPage();
+    page.nativeType = vi.fn().mockResolvedValue(undefined);
+    page.results = [
+      resolveOk,
+      { ok: true, mode: 'input' },
+      { ok: false, actual: '', expected: 'hello', length: 0, mode: 'input' },
+      'typed',
+      { ok: true, actual: 'hello', expected: 'hello', length: 5, mode: 'input' },
+    ];
+
+    await expect(page.fillText('#q', 'hello')).resolves.toEqual(expect.objectContaining({
+      filled: true,
+      verified: true,
+      actual: 'hello',
+    }));
+
+    expect(page.nativeType).toHaveBeenCalledWith('hello');
+    expect(page.scripts).toHaveLength(5);
+    expect(page.scripts[3]).toContain("return 'typed'");
+  });
+
+  it('throws a structured not_editable error for non-fillable targets', async () => {
+    const page = new ActionPage();
+    page.results = [resolveOk, { ok: false, reason: 'not_editable', tag: 'button' }];
+
+    const err = await page.fillText('button', 'hello').catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_editable');
+  });
+
+  it('uses CDP DOM scrollIntoViewIfNeeded before measuring rect for click', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.cdp = vi.fn()
+      .mockResolvedValueOnce({})
+      .mockResolvedValueOnce({ root: { nodeId: 1 } })
+      .mockResolvedValueOnce({ nodeId: 9 })
+      .mockResolvedValueOnce({});
+    page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true, hit: 'target' }];
+    page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];
+
+    await page.click('#save');
+
+    expect(page.cdp).toHaveBeenCalledWith('DOM.scrollIntoViewIfNeeded', { nodeId: 9 });
+    // After CDP scroll, boundingRectResolvedJs runs with skipScroll=true.
+    expect(page.scripts.at(-1)).toContain('if (false) el.scrollIntoView');
+  });
+
+  it('clicks via CDP Input.dispatchMouseEvent when rect is visible', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.results = [resolveOk, { x: 50, y: 100, w: 200, h: 32, visible: true, hit: 'target' }];
+
+    await page.click('#category');
+
+    expect(page.nativeClick).toHaveBeenCalledWith(50, 100);
+    expect(page.nativeClick).toHaveBeenCalledTimes(1);
+    expect(page.scripts).toHaveLength(2);
+    expect(page.scripts[1]).toContain('getBoundingClientRect');
+    expect(page.scripts.join('\n')).not.toContain('el.click()');
+  });
+
+  it('clicks AX snapshot refs through backend node coordinates without DOM resolver', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.cdp = vi.fn(async (method: string) => {
+      if (method === 'Accessibility.getFullAXTree') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: 10 },
+          ],
+        };
+      }
+      if (method === 'Page.getFrameTree') {
+        return {
+          frameTree: { frame: { id: 'root', url: 'https://app.example/' } },
+        };
+      }
+      if (method === 'DOM.getBoxModel') {
+        return { model: { content: [10, 20, 50, 20, 50, 40, 10, 40] } };
+      }
+      return {};
+    });
+
+    await expect(page.snapshot({ source: 'ax' })).resolves.toContain('[1]button "Submit"');
+    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenNthCalledWith(1, 'Accessibility.enable', {});
+    expect(page.cdp).toHaveBeenNthCalledWith(2, 'Accessibility.getFullAXTree', {});
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', {});
+    expect(page.cdp).toHaveBeenCalledWith('Page.getFrameTree', {});
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
+    expect(page.nativeClick).toHaveBeenCalledWith(30, 30);
+    expect(page.scripts).toHaveLength(0);
+  });
+
+  it('adds same-origin iframe AX refs and clicks them by frame-scoped backend node', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Embedded' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: 20 },
+          ],
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'cross-frame') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Cross' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 30 },
+          ],
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
+          ],
+        };
+      }
+      if (method === 'Page.getFrameTree') {
+        return {
+          frameTree: {
+            frame: { id: 'root', url: 'https://app.example/' },
+            childFrames: [
+              { frame: { id: 'same-frame', url: 'https://app.example/embed' } },
+              { frame: { id: 'cross-frame', url: 'https://other.example/embed' } },
+            ],
+          },
+        };
+      }
+      if (method === 'DOM.getBoxModel') {
+        return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
+      }
+      return {};
+    });
+
+    const snapshot = await page.snapshot({ source: 'ax' }) as string;
+    expect(snapshot).toContain('[1]button "Main Save"');
+    expect(snapshot).toContain('frame "https://app.example/embed":');
+    expect(snapshot).toContain('[2]button "Frame Save"');
+    expect(snapshot).toContain('frame "https://other.example/embed":');
+    expect(snapshot).toContain('[3]button "Cross Save"');
+
+    await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'exact', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
+    expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
+  });
+
+  it('clicks cross-origin AX refs through a frame-target CDP route', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Page.getFrameTree') {
+        return {
+          frameTree: {
+            frame: { id: 'root', url: 'https://app.example/' },
+            childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
+          },
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId: 99 },
+          ],
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree') {
+        return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
+      }
+      if (method === 'DOM.getBoxModel') {
+        return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
+      }
+      return {};
+    });
+
+    const snapshot = await page.snapshot({ source: 'ax' }) as string;
+    expect(snapshot).toContain('[1]button "Cross Save"');
+    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
+  });
+
+  it('enables Accessibility in cross-origin frame target sessions before stale AX recovery', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    let crossAxCalls = 0;
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Page.getFrameTree') {
+        return {
+          frameTree: {
+            frame: { id: 'root', url: 'https://app.example/' },
+            childFrames: [{ frame: { id: 'cross-frame', url: 'https://other.example/embed' } }],
+          },
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree' && params?.sessionId === 'target') {
+        crossAxCalls++;
+        const backendDOMNodeId = crossAxCalls === 1 ? 99 : 100;
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Cross Save' }, backendDOMNodeId },
+          ],
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree') {
+        return { nodes: [{ nodeId: '1', role: { value: 'RootWebArea' } }] };
+      }
+      if (method === 'DOM.getBoxModel') {
+        if (params?.backendNodeId === 99) throw new Error('No node with given id found');
+        return { model: { content: [300, 400, 340, 400, 340, 420, 300, 420] } };
+      }
+      return {};
+    });
+
+    await page.snapshot({ source: 'ax' });
+    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 100, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
+    expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
+  });
+
+  it('recovers stale iframe AX refs inside the original frame', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    let iframeAxCalls = 0;
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Page.getFrameTree') {
+        return {
+          frameTree: {
+            frame: { id: 'root', url: 'https://app.example/' },
+            childFrames: [{ frame: { id: 'same-frame', url: 'https://app.example/embed' } }],
+          },
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree' && params?.frameId === 'same-frame') {
+        iframeAxCalls++;
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Frame Save' }, backendDOMNodeId: iframeAxCalls === 1 ? 20 : 42 },
+          ],
+        };
+      }
+      if (method === 'Accessibility.getFullAXTree') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Main Save' }, backendDOMNodeId: 10 },
+          ],
+        };
+      }
+      if (method === 'DOM.getBoxModel') {
+        if (params?.backendNodeId === 20) throw new Error('No node with given id found');
+        return { model: { content: [200, 300, 240, 300, 240, 320, 200, 320] } };
+      }
+      return {};
+    });
+
+    await page.snapshot({ source: 'ax' });
+    await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', {});
+    expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
+    expect(page.nativeClick).toHaveBeenCalledWith(220, 310);
+  });
+
+  it('recovers stale AX refs by role/name/nth before clicking', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    let axCalls = 0;
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Accessibility.getFullAXTree') {
+        axCalls++;
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: axCalls === 1 ? 10 : 42 },
+          ],
+        };
+      }
+      if (method === 'DOM.getBoxModel') {
+        if (params?.backendNodeId === 10) throw new Error('No node with given id found');
+        return { model: { content: [100, 200, 140, 200, 140, 220, 100, 220] } };
+      }
+      return {};
+    });
+
+    await page.snapshot({ source: 'ax' });
+    await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified', click_method: 'ax' });
+
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 10 });
+    expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 42 });
+    expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
+  });
+
+  it('recovers AX refs across 10 repeated React-style rerenders', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    let currentBackendId = 100;
+    const staleBackendIds = new Set();
+    page.cdp = vi.fn(async (method: string, params?: Record) => {
+      if (method === 'Accessibility.getFullAXTree') {
+        return {
+          nodes: [
+            { nodeId: '1', role: { value: 'RootWebArea' }, name: { value: 'Demo' }, childIds: ['2'] },
+            { nodeId: '2', role: { value: 'button' }, name: { value: 'Submit' }, backendDOMNodeId: currentBackendId },
+          ],
+        };
+      }
+      if (method === 'DOM.getBoxModel') {
+        const id = params?.backendNodeId as number;
+        if (staleBackendIds.has(id)) throw new Error('No node with given id found');
+        return { model: { content: [id, id, id + 20, id, id + 20, id + 10, id, id + 10] } };
+      }
+      return {};
+    });
+
+    await page.snapshot({ source: 'ax' });
+    for (let i = 0; i < 10; i++) {
+      staleBackendIds.add(currentBackendId);
+      currentBackendId += 1;
+      await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified', click_method: 'ax' });
+    }
+
+    expect(page.nativeClick).toHaveBeenCalledTimes(10);
+  });
+
+  it('falls back to JS el.click() when nativeClick is unavailable', async () => {
+    const page = new ActionPage();
+    page.results = [
+      resolveOk,
+      { x: 50, y: 100, w: 200, h: 32, visible: true },
+      { status: 'clicked', x: 50, y: 100 },
+    ];
+
+    await page.click('#save');
+
+    expect(page.scripts).toHaveLength(3);
+    expect(page.scripts[1]).toContain('getBoundingClientRect');
+    expect(page.scripts[2]).toContain('el.click()');
+  });
+
+  it('falls back to JS el.click() when rect is zero-area', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.results = [
+      resolveOk,
+      { x: 0, y: 0, w: 0, h: 0, visible: false },
+      { status: 'clicked', x: 0, y: 0 },
+    ];
+
+    await page.click('#hidden');
+
+    expect(page.nativeClick).not.toHaveBeenCalled();
+    expect(page.scripts).toHaveLength(3);
+    expect(page.scripts[2]).toContain('el.click()');
+  });
+
+  it('retries CDP click when JS path throws but yields coordinates', async () => {
+    const page = new ActionPage();
+    const nativeClick = vi.fn()
+      .mockRejectedValueOnce(new Error('cdp transient'))
+      .mockResolvedValueOnce(undefined);
+    page.nativeClick = nativeClick;
+    page.results = [
+      resolveOk,
+      { x: 10, y: 20, w: 100, h: 30, visible: true, hit: 'target' },
+      { status: 'js_failed', x: 10, y: 20, error: 'click intercepted' },
+    ];
+
+    await page.click('#flaky');
+
+    expect(nativeClick).toHaveBeenCalledTimes(2);
+    expect(nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
+    expect(nativeClick).toHaveBeenNthCalledWith(2, 10, 20);
+  });
+
+  it('hovers via native CDP mouseMoved when coordinates are available', async () => {
+    const page = new ActionPage();
+    page.cdp = vi.fn().mockResolvedValue({});
+    page.results = [resolveOk, { x: 70, y: 80, w: 100, h: 20, visible: true }];
+
+    await expect(page.hover('#menu')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 70, y: 80 });
+    expect(page.scripts.at(-1)).toContain('getBoundingClientRect');
+  });
+
+  it('focuses through CDP DOM.focus when available', async () => {
+    const page = new ActionPage();
+    page.cdp = vi.fn(async (method: string) => {
+      if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
+      if (method === 'DOM.querySelector') return { nodeId: 9 };
+      return {};
+    });
+    page.results = [resolveOk, true];
+    page.withArgsResults = [{ ok: true }, undefined];
+
+    await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });
+
+    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
+  });
+
+  it('verifies CDP focus and falls back to DOM focus when focus did not stick', async () => {
+    const page = new ActionPage();
+    page.cdp = vi.fn(async (method: string) => {
+      if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
+      if (method === 'DOM.querySelector') return { nodeId: 9 };
+      return {};
+    });
+    page.results = [resolveOk, false, true];
+    page.withArgsResults = [{ ok: true }, undefined];
+
+    await expect(page.focus('#email')).resolves.toEqual({ focused: true, matches_n: 1, match_level: 'exact' });
+
+    expect(page.cdp).toHaveBeenCalledWith('DOM.focus', { nodeId: 9 });
+    expect(page.scripts.at(-2)).toContain('document.activeElement === el');
+    expect(page.scripts.at(-1)).toContain('el.focus');
+  });
+
+  it('double-clicks via native CDP mouse events', async () => {
+    const page = new ActionPage();
+    page.cdp = vi.fn().mockResolvedValue({});
+    page.results = [resolveOk, { x: 20, y: 30, w: 100, h: 20, visible: true }];
+
+    await expect(page.dblClick('#row')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
+
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 20, y: 30 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 20, y: 30, button: 'left', clickCount: 2 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 20, y: 30, button: 'left', clickCount: 2 });
+  });
+
+  it('checks a checkbox only when its current state differs', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.results = [
+      resolveOk,
+      { ok: true, checked: false, disabled: false, kind: 'checkbox' },
+      resolveOk,
+      { x: 20, y: 30, w: 40, h: 20, visible: true, hit: 'target' },
+      { ok: true, checked: true, disabled: false, kind: 'checkbox' },
+    ];
+
+    await expect(page.setChecked('#agree', true)).resolves.toEqual({
+      checked: true,
+      changed: true,
+      matches_n: 1,
+      match_level: 'exact',
+      kind: 'checkbox',
+    });
+
+    expect(page.nativeClick).toHaveBeenCalledWith(20, 30);
+  });
+
+  it('does not click a checkbox that already has the requested state', async () => {
+    const page = new ActionPage();
+    page.nativeClick = vi.fn().mockResolvedValue(undefined);
+    page.results = [resolveOk, { ok: true, checked: false, disabled: false, kind: 'checkbox' }];
+
+    await expect(page.setChecked('#agree', false)).resolves.toEqual({
+      checked: false,
+      changed: false,
+      matches_n: 1,
+      match_level: 'exact',
+      kind: 'checkbox',
+    });
+
+    expect(page.nativeClick).not.toHaveBeenCalled();
+  });
+
+  it('rejects non-checkable targets with a structured error', async () => {
+    const page = new ActionPage();
+    page.results = [resolveOk, { ok: false, reason: 'not_checkable', tag: 'button' }];
+
+    const err = await page.setChecked('button', true).catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_checkable');
+  });
+
+  it('rejects attempts to uncheck a radio button directly', async () => {
+    const page = new ActionPage();
+    page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'radio' }];
+
+    const err = await page.setChecked('#radio', false).catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_checkable');
+    expect((err as TargetError).hint).toContain('Select another radio');
+  });
+
+  it('treats ARIA radio controls like radio buttons', async () => {
+    const page = new ActionPage();
+    page.results = [resolveOk, { ok: true, checked: true, disabled: false, kind: 'menuitemradio' }];
+
+    const err = await page.setChecked('[role="menuitemradio"]', false).catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_checkable');
+    expect((err as TargetError).hint).toContain('Select another radio');
+  });
+
+  it('uploads files through setFileInput using a temporary marker selector', async () => {
+    const page = new ActionPage();
+    page.setFileInput = vi.fn().mockResolvedValue(undefined);
+    page.results = [
+      resolveOk,
+      ['receipt.pdf'],
+    ];
+    page.withArgsResults = [{ ok: true, multiple: false, accept: 'application/pdf' }, undefined];
+
+    await expect(page.uploadFiles('#file', ['/tmp/receipt.pdf'])).resolves.toEqual({
+      uploaded: true,
+      files: 1,
+      file_names: ['receipt.pdf'],
+      target: '#file',
+      matches_n: 1,
+      match_level: 'exact',
+      multiple: false,
+      accept: 'application/pdf',
+    });
+
+    expect(page.setFileInput).toHaveBeenCalledWith(['/tmp/receipt.pdf'], expect.stringMatching(/data-opencli-upload-target/));
+    expect(page.withArgs.at(0)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
+    expect(page.withArgs.at(-1)).toMatchObject({ markerAttr: 'data-opencli-upload-target' });
+  });
+
+  it('rejects non-file-input upload targets with a structured error', async () => {
+    const page = new ActionPage();
+    page.setFileInput = vi.fn().mockResolvedValue(undefined);
+    page.results = [resolveOk];
+    page.withArgsResults = [{ ok: false, tag: 'button', type: '' }];
+
+    const err = await page.uploadFiles('button', ['/tmp/receipt.pdf']).catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_file_input');
+    expect(page.setFileInput).not.toHaveBeenCalled();
+  });
+
+  it('rejects multiple files for a single-file input before mutating files', async () => {
+    const page = new ActionPage();
+    page.setFileInput = vi.fn().mockResolvedValue(undefined);
+    page.results = [resolveOk];
+    page.withArgsResults = [{ ok: true, multiple: false, accept: '' }, undefined];
+
+    const err = await page.uploadFiles('#file', ['/tmp/a.pdf', '/tmp/b.pdf']).catch((error: unknown) => error);
+
+    expect(err).toBeInstanceOf(TargetError);
+    expect((err as TargetError).code).toBe('not_file_input');
+    expect(page.setFileInput).not.toHaveBeenCalled();
+  });
+
+  it('drags between two resolved element centers via native CDP mouse events', async () => {
+    const page = new ActionPage();
+    page.cdp = vi.fn().mockResolvedValue({});
+    page.results = [
+      resolveOk,
+      { x: 10, y: 20, w: 30, h: 20, visible: true },
+      { ok: true, matches_n: 2, match_level: 'stable' },
+      {
+        source: { x: 10, y: 20, w: 30, h: 20, visible: true },
+        target: { x: 110, y: 120, w: 40, h: 30, visible: true },
+      },
+    ];
+
+    await expect(page.drag('#card', '.lane', { to: { nth: 1 } })).resolves.toEqual({
+      dragged: true,
+      source: '#card',
+      target: '.lane',
+      source_matches_n: 1,
+      target_matches_n: 2,
+      source_match_level: 'exact',
+      target_match_level: 'stable',
+    });
+
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 60, y: 70, button: 'left', buttons: 1 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 110, y: 120, button: 'left', buttons: 1 });
+    expect(page.cdp).toHaveBeenCalledWith('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 110, y: 120, button: 'left', clickCount: 1 });
+  });
+
+  it('presses key chords through native CDP key events when available', async () => {
+    const page = new ActionPage();
+    page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);
+
+    await page.pressKey('Control+a');
+
+    expect(page.nativeKeyPress).toHaveBeenCalledWith('a', ['Ctrl']);
+    expect(page.scripts).toHaveLength(0);
+  });
+
+  it('falls back to synthetic keyboard events with parsed modifiers', async () => {
+    const page = new ActionPage();
+
+    await page.pressKey('Meta+N');
+
+    expect(page.scripts).toHaveLength(1);
+    expect(page.scripts[0]).toContain('key: "N"');
+    expect(page.scripts[0]).toContain('metaKey: true');
+  });
+});
+
+describe('BasePage.evaluateWithArgs scoping', () => {
+  class EvalCapturePage extends BasePage {
+    scripts: string[] = [];
+    async goto(): Promise {}
+    async evaluate(js: string): Promise;
+    async evaluate(fn: BrowserEvaluateFunction, ...args: Args): Promise>;
+    async evaluate(input: string | BrowserEvaluateFunction): Promise {
+      const code = typeof input === 'string' ? input : input.toString();
+      this.scripts.push(code);
+      if (typeof input !== 'string') return null;
+      return globalThis.eval(input);
+    }
+    async getCookies(): Promise<[]> { return []; }
+    async screenshot(): Promise { return ''; }
+    async tabs(): Promise { return []; }
+    async selectTab(): Promise {}
+  }
+
+  it('wraps declarations in a lexical block to avoid global const redeclaration', async () => {
+    const page = new EvalCapturePage();
+    const result = await page.evaluateWithArgs('(() => markerAttr)()', { markerAttr: 'test-value' });
+
+    expect(result).toBe('test-value');
+    expect(page.scripts).toHaveLength(1);
+    const code = page.scripts[0];
+    // Must be wrapped in a lexical block, not bare top-level const.
+    expect(code).toMatch(/^\{\n/);
+    expect(code).toContain('const markerAttr = "test-value"');
+  });
+
+  it('allows same arg name across multiple calls without conflict', async () => {
+    const page = new EvalCapturePage();
+    await expect(page.evaluateWithArgs('(() => x)()', { x: 'first' })).resolves.toBe('first');
+    await expect(page.evaluateWithArgs('(() => x)()', { x: 'second' })).resolves.toBe('second');
+
+    expect(page.scripts).toHaveLength(2);
+    // Both should be self-contained blocks, not polluting global scope.
+    expect(page.scripts[0]).toContain('const x = "first"');
+    expect(page.scripts[1]).toContain('const x = "second"');
+    expect(page.scripts[0]).toMatch(/^\{\n/);
+    expect(page.scripts[1]).toMatch(/^\{\n/);
+  });
+
+  it('preserves completion value semantics for statement snippets', async () => {
+    const page = new EvalCapturePage();
+
+    await expect(page.evaluateWithArgs(`
+      const local = value + 1;
+      local * 2
+    `, { value: 20 })).resolves.toBe(42);
+  });
+});
+
+describe('BasePage.sleep', () => {
+  it('is a pure client-side sleep that never evaluates page script', async () => {
+    const page = new ActionPage();
+    const evalSpy = vi.spyOn(page, 'evaluate');
+
+    await page.sleep(0);
+
+    expect(evalSpy).not.toHaveBeenCalled();
+    expect(page.scripts).toHaveLength(0);
+  });
+});
diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts
new file mode 100644
index 0000000..e77180d
--- /dev/null
+++ b/src/browser/base-page.ts
@@ -0,0 +1,1285 @@
+/**
+ * BasePage — shared IPage method implementations for DOM helpers.
+ *
+ * Both Page (daemon-backed) and CDPPage (direct CDP) execute JS the same way
+ * for DOM operations. This base class deduplicates ~200 lines of identical
+ * click/type/scroll/wait/snapshot/interceptor methods.
+ *
+ * Subclasses implement the transport-specific methods: goto, evaluate,
+ * getCookies, screenshot, tabs, etc.
+ */
+
+import type { BrowserCookie, BrowserEvaluateFunction, FetchJsonOptions, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
+import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
+import { buildAxSnapshotFromTrees, findAxRefReplacement, type AxSnapshotTree, type BrowserRef } from './ax-snapshot.js';
+import {
+  pressKeyJs,
+  waitForTextJs,
+  waitForCaptureJs,
+  waitForSelectorJs,
+  scrollJs,
+  autoScrollJs,
+  networkRequestsJs,
+  waitForDomStableJs,
+} from './dom-helpers.js';
+import {
+  resolveTargetJs,
+  boundingRectResolvedJs,
+  clickResolvedJs,
+  typeResolvedJs,
+  prepareNativeTypeResolvedJs,
+  verifyFilledResolvedJs,
+  scrollResolvedJs,
+  type FillResolvedResult,
+  type ResolveOptions,
+  type TargetMatchLevel,
+} from './target-resolver.js';
+import { TargetError, type TargetErrorCode } from './target-errors.js';
+import { CliError } from '../errors.js';
+import { formatSnapshot } from '../snapshotFormatter.js';
+import { installVisualRefOverlayJs, removeVisualRefOverlayJs } from './visual-refs.js';
+
+export interface ResolveSuccess {
+  matches_n: number;
+  /**
+   * Cascading stale-ref tier the resolver traversed. Callers surface this to
+   * agents so `stable` / `reidentified` hits are visibly distinct from a
+   * clean `exact` match — the page changed, the action still succeeded.
+   */
+  match_level: TargetMatchLevel;
+  /**
+   * Which physical click path executed: `cdp` (trusted Input.dispatchMouseEvent),
+   * `js` (el.click() fallback), or `ax` (accessibility-node click). Surfaced so
+   * agents can tell a trusted synthetic click from the untrusted JS fallback.
+   */
+  click_method?: 'cdp' | 'js' | 'ax';
+  /**
+   * Result of hit-testing the click point against the resolved element:
+   * `target` (the point lands on the element or a descendant — trustworthy),
+   * `other` (an overlay/sibling covers it — the CDP click would miss), or
+   * `none` (nothing at the point). Only set on click().
+   */
+  hit?: 'target' | 'ancestor' | 'other' | 'none';
+  /**
+   * True when the click was retargeted from the resolved element to a nearby
+   * clickable ancestor (e.g. an  icon whose click handler lives on the
+   * wrapping 
), so the handler actually fires. See issue #2071. + */ + retargeted?: boolean; +} + +export interface FillTextResult extends ResolveSuccess { + filled: boolean; + verified: boolean; + expected: string; + actual: string; + length: number; + mode?: 'input' | 'textarea' | 'contenteditable'; +} + +export interface SetCheckedResult extends ResolveSuccess { + checked: boolean; + changed: boolean; + kind?: string; +} + +export interface UploadFilesResult extends ResolveSuccess { + uploaded: boolean; + files: number; + file_names: string[]; + target: string; + multiple?: boolean; + accept?: string; +} + +export interface DragResult { + dragged: boolean; + source: string; + target: string; + source_matches_n: number; + target_matches_n: number; + source_match_level: TargetMatchLevel; + target_match_level: TargetMatchLevel; +} + +interface CdpFrameTreeNode { + frame?: { id?: string; url?: string; unreachableUrl?: string; name?: string }; + childFrames?: CdpFrameTreeNode[]; +} + +/** + * Execute `resolveTargetJs` once, throw structured `TargetError` on failure. + * Single helper so click/typeText/scrollTo share one resolution pathway, + * which is what the selector-first contract promises agents. + */ +async function runResolve( + page: { evaluate(js: string): Promise }, + ref: string, + opts: ResolveOptions = {}, +): Promise { + const resolution = (await page.evaluate(resolveTargetJs(ref, opts))) as + | { ok: true; matches_n: number; match_level: TargetMatchLevel } + | { ok: false; code: TargetErrorCode; message: string; hint: string; candidates?: string[]; matches_n?: number }; + if (!resolution.ok) { + throw new TargetError({ + code: resolution.code, + message: resolution.message, + hint: resolution.hint, + candidates: resolution.candidates, + matches_n: resolution.matches_n, + }); + } + return { matches_n: resolution.matches_n, match_level: resolution.match_level }; +} + +function previewText(text: string | undefined): string | undefined { + const preview = (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 300); + return preview ? `Response preview: ${preview}` : undefined; +} + +function parseKeyChord(rawKey: string): { key: string; modifiers: string[] } { + const parts = rawKey.split('+').map(part => part.trim()).filter(Boolean); + if (parts.length <= 1) return { key: rawKey, modifiers: [] }; + + const modifiers: string[] = []; + for (const token of parts.slice(0, -1)) { + const normalized = token.toLowerCase(); + if (normalized === 'ctrl' || normalized === 'control') modifiers.push('Ctrl'); + else if (normalized === 'cmd' || normalized === 'command' || normalized === 'meta') modifiers.push('Meta'); + else if (normalized === 'option' || normalized === 'alt') modifiers.push('Alt'); + else if (normalized === 'shift') modifiers.push('Shift'); + else return { key: rawKey, modifiers: [] }; + } + + const key = parts.at(-1); + return key ? { key, modifiers } : { key: rawKey, modifiers: [] }; +} + +export abstract class BasePage implements IPage { + protected _lastUrl: string | null = null; + /** Cached previous snapshot hashes for incremental diff marking */ + private _prevSnapshotHashes: string | null = null; + private _cdpTargetMarkerSeq = 0; + private _axRefs = new Map(); + + // ── Transport-specific methods (must be implemented by subclasses) ── + + abstract goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number; allowBoundNavigation?: boolean }): Promise; + abstract evaluate(js: string): Promise; + abstract evaluate(fn: BrowserEvaluateFunction, ...args: Args): Promise>; + + /** + * Safely evaluate JS with pre-serialized arguments. + * Each key in `args` becomes a `const` declaration with JSON-serialized value, + * wrapped in a lexical block to avoid polluting the global execution context. + * + * Why a block: Chrome's Runtime.evaluate shares a single global context per page. + * Top-level `const` declarations persist across calls, so re-declaring the same + * variable name (e.g. `markerAttr` in both click resolution and file upload) + * throws "SyntaxError: Identifier has already been declared". A block keeps + * the args scoped without forcing callers to pass expression-only code. + * + * Usage: + * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput }) + */ + async evaluateWithArgs(js: string, args: Record): Promise { + const declarations = Object.entries(args) + .map(([key, value]) => { + if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) { + throw new Error(`evaluateWithArgs: invalid key "${key}"`); + } + return `const ${key} = ${JSON.stringify(value)};`; + }) + .join('\n'); + return this.evaluate(`{\n${declarations}\n${js}\n}`); + } + + async fetchJson(url: string, opts: FetchJsonOptions = {}): Promise { + const request = { + url, + method: opts.method ?? 'GET', + headers: opts.headers ?? {}, + body: opts.body, + hasBody: opts.body !== undefined, + timeoutMs: opts.timeoutMs ?? 15_000, + }; + + const result = await this.evaluateWithArgs(` + (async () => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), request.timeoutMs); + try { + const headers = { Accept: 'application/json', ...request.headers }; + const init = { + method: request.method, + credentials: 'include', + headers, + signal: ctrl.signal, + }; + if (request.hasBody) { + if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/json'; + } + init.body = JSON.stringify(request.body); + } + const resp = await fetch(request.url, init); + const text = await resp.text(); + return { + ok: resp.ok, + status: resp.status, + statusText: resp.statusText, + url: resp.url, + contentType: resp.headers.get('content-type') || '', + text, + }; + } catch (error) { + return { + ok: false, + status: 0, + statusText: '', + url: request.url, + contentType: '', + text: '', + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timer); + } + })() + `, { request }) as { + ok?: boolean; + status?: number; + statusText?: string; + url?: string; + contentType?: string; + text?: string; + error?: string; + }; + + const targetUrl = result.url || url; + if (result.error) { + throw new CliError( + 'FETCH_ERROR', + `Browser fetch failed for ${targetUrl}: ${result.error}`, + 'Check that the page is reachable and the current browser profile has access.', + ); + } + if (!result.ok) { + throw new CliError( + 'FETCH_ERROR', + `HTTP ${result.status ?? 0}${result.statusText ? ` ${result.statusText}` : ''} from ${targetUrl}`, + previewText(result.text), + ); + } + + const text = result.text ?? ''; + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + const contentType = result.contentType ? ` (${result.contentType})` : ''; + throw new CliError( + 'FETCH_ERROR', + `Expected JSON from ${targetUrl}${contentType}`, + previewText(text), + ); + } + } + + abstract getCookies(opts?: { domain?: string; url?: string }): Promise; + abstract screenshot(options?: ScreenshotOptions): Promise; + + async annotatedScreenshot(options: ScreenshotOptions = {}): Promise { + // Refresh DOM refs first so visual labels map to immediate `browser click ` targets. + await this.snapshot({ source: 'dom', viewportExpand: 0 }); + try { + await this.evaluate(installVisualRefOverlayJs()); + return await this.screenshot({ ...options, annotate: false }); + } finally { + await this.evaluate(removeVisualRefOverlayJs()).catch(() => {}); + } + } + abstract tabs(): Promise; + abstract selectTab(target: number | string): Promise; + + // ── Shared DOM helper implementations ── + + async click(ref: string, opts: ResolveOptions = {}): Promise { + const axClick = await this.tryClickAxRef(ref); + if (axClick) return axClick; + + // Phase 1: Resolve target with fingerprint verification + const resolved = await runResolve(this, ref, opts); + const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + + // Phase 2: measure first so native click can run before DOM el.click(). + // Custom dropdowns often listen to pointer/mouse down/up; DOM el.click() + // only fires click and can silently report success without opening/selecting. + const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled, forClick: true })) as + | { x: number; y: number; w: number; h: number; visible: boolean; hit?: 'target' | 'ancestor' | 'other' | 'none'; retargeted?: boolean } + | null; + const meta = { hit: rect?.hit, retargeted: rect?.retargeted } as const; + + // Trust a CDP click when the point hit-tests onto the target, a descendant, + // or an ancestor (open shadow-DOM host / own wrapper — CDP still reaches the + // target there). Only an unrelated overlay ('other') forces the el.click() + // fallback, which dispatches straight on the node. See issues #2076/#2071. + if (rect?.visible === true && (rect.hit === 'target' || rect.hit === 'ancestor')) { + const success = await this.tryNativeClick(rect.x, rect.y); + if (success) return { ...resolved, click_method: 'cdp', ...meta }; + } + + // JS fallback: el.click() dispatches straight on __resolved, bypassing the + // occluding overlay (also covers older backends / zero-rect targets). + const result = await this.evaluate(clickResolvedJs({ skipScroll: nativeScrolled })) as + | string + | { status: string; x?: number; y?: number; w?: number; h?: number; error?: string } + | null; + + if (typeof result === 'string' || result == null) return { ...resolved, click_method: 'js', ...meta }; + + if (result.status === 'clicked') return { ...resolved, click_method: 'js', ...meta }; + + // JS click failed — try CDP native click if coordinates available + if (result.x != null && result.y != null) { + const success = await this.tryNativeClick(result.x, result.y); + if (success) return { ...resolved, click_method: 'cdp', ...meta }; + } + + throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`); + } + + /** Uses native CDP click support when the concrete page exposes it. */ + protected async tryNativeClick(x: number, y: number): Promise { + const nativeClick = (this as IPage).nativeClick; + if (typeof nativeClick !== 'function') return false; + try { + await nativeClick.call(this, x, y); + return true; + } catch { + return false; + } + } + + protected async tryNativeMouseMove(x: number, y: number): Promise { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return false; + try { + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); + return true; + } catch { + return false; + } + } + + protected async tryNativeDoubleClick(x: number, y: number): Promise { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return false; + try { + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 2 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 2 }); + return true; + } catch { + return false; + } + } + + protected async tryNativeDrag(from: { x: number; y: number }, to: { x: number; y: number }): Promise { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return false; + const midX = Math.round((from.x + to.x) / 2); + const midY = Math.round((from.y + to.y) / 2); + try { + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left', clickCount: 1 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: midX, y: midY, button: 'left', buttons: 1 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: to.x, y: to.y, button: 'left', buttons: 1 }); + await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left', clickCount: 1 }); + return true; + } catch { + return false; + } + } + + protected async tryClickAxRef(ref: string): Promise { + if (!/^\d+$/.test(ref)) return null; + const entry = this._axRefs.get(ref); + if (!entry) return null; + const nativeClick = (this as IPage).nativeClick; + if (typeof nativeClick !== 'function') return null; + + const resolved = await this.resolveAxRefPoint(entry); + if (!resolved) return null; + try { + await nativeClick.call(this, resolved.x, resolved.y); + return { matches_n: 1, match_level: resolved.matchLevel, click_method: 'ax' }; + } catch { + return null; + } + } + + private async resolveAxRefPoint(entry: BrowserRef): Promise<{ x: number; y: number; matchLevel: TargetMatchLevel } | null> { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return null; + + if (entry.backendNodeId != null) { + const point = await this.axBoxCenter(entry.backendNodeId, entry.frame).catch(() => null); + if (point) return { ...point, matchLevel: 'exact' }; + } + + await cdp.call(this, 'Accessibility.enable', axEnableParams(entry.frame)); + const axTree = await cdp.call(this, 'Accessibility.getFullAXTree', axTreeParams(entry.frame)).catch(() => null); + const recovered = findAxRefReplacement(axTree, entry); + if (!recovered?.backendNodeId) return null; + this._axRefs.set(entry.ref, recovered); + const point = await this.axBoxCenter(recovered.backendNodeId, recovered.frame).catch(() => null); + return point ? { ...point, matchLevel: 'reidentified' } : null; + } + + private async axBoxCenter(backendNodeId: number, frame?: BrowserRef['frame']): Promise<{ x: number; y: number } | null> { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return null; + const result = await cdp.call(this, 'DOM.getBoxModel', { + backendNodeId, + ...(frame?.sessionId + ? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) } + : {}), + }) as + | { model?: { content?: unknown[]; border?: unknown[] } } + | null; + const quad = Array.isArray(result?.model?.content) && result.model.content.length >= 8 + ? result.model.content + : Array.isArray(result?.model?.border) && result.model.border.length >= 8 + ? result.model.border + : null; + if (!quad) return null; + const nums = quad.slice(0, 8).map((value) => typeof value === 'number' ? value : Number(value)); + if (nums.some((value) => !Number.isFinite(value))) return null; + return { + x: Math.round((nums[0] + nums[2] + nums[4] + nums[6]) / 4), + y: Math.round((nums[1] + nums[3] + nums[5] + nums[7]) / 4), + }; + } + + /** Uses native CDP text insertion when the concrete page exposes it. */ + protected async tryNativeType(text: string): Promise { + const nativeType = (this as IPage).nativeType; + if (typeof nativeType === 'function') { + try { + await nativeType.call(this, text); + return true; + } catch { + // Fall through to the older dedicated insertText primitive if present. + } + } + + const insertText = (this as IPage).insertText; + if (typeof insertText !== 'function') return false; + try { + await insertText.call(this, text); + return true; + } catch { + return false; + } + } + + /** Uses native CDP key events when the concrete page exposes them. */ + protected async tryNativeKeyPress(key: string, modifiers: string[]): Promise { + const nativeKeyPress = (this as IPage).nativeKeyPress; + if (typeof nativeKeyPress !== 'function') return false; + try { + await nativeKeyPress.call(this, key, modifiers); + return true; + } catch { + return false; + } + } + + protected async isResolvedFocused(): Promise { + try { + return await this.evaluate(` + (() => { + const el = window.__resolved; + return !!el && (document.activeElement === el || (typeof el.matches === 'function' && el.matches(':focus'))); + })() + `) as boolean; + } catch { + return false; + } + } + + /** + * Run a DOM-domain CDP command against `window.__resolved`. + * + * CDP DOM.focus / DOM.scrollIntoViewIfNeeded need a nodeId, while our + * resolver stores the live Element in page JS. Bridge the two worlds with a + * short-lived marker attribute, then query it through CDP. + */ + protected async tryCdpOnResolvedElement(method: 'DOM.focus' | 'DOM.scrollIntoViewIfNeeded'): Promise { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') return false; + + const markerAttr = 'data-opencli-cdp-target'; + const markerValue = `${Date.now().toString(36)}-${++this._cdpTargetMarkerSeq}`; + const selector = `[${markerAttr}="${markerValue}"]`; + let marked = false; + + try { + const marker = await this.evaluateWithArgs(` + (() => { + const el = window.__resolved; + if (!el || el.nodeType !== 1 || typeof el.setAttribute !== 'function') { + return { ok: false }; + } + el.setAttribute(markerAttr, markerValue); + return { ok: true }; + })() + `, { markerAttr, markerValue }) as { ok?: boolean } | null; + marked = marker?.ok === true; + if (!marked) return false; + + await cdp.call(this, 'DOM.enable', {}).catch(() => undefined); + const doc = await cdp.call(this, 'DOM.getDocument', {}) as { root?: { nodeId?: unknown } } | null; + const rootNodeId = doc?.root?.nodeId; + if (typeof rootNodeId !== 'number') return false; + + const query = await cdp.call(this, 'DOM.querySelector', { + nodeId: rootNodeId, + selector, + }) as { nodeId?: unknown } | null; + const nodeId = query?.nodeId; + if (typeof nodeId !== 'number' || nodeId <= 0) return false; + + await cdp.call(this, method, { nodeId }); + return true; + } catch { + return false; + } finally { + if (marked) { + await this.evaluateWithArgs(` + (() => { + for (const el of document.querySelectorAll(selector)) { + el.removeAttribute(markerAttr); + } + })() + `, { selector, markerAttr }).catch(() => undefined); + } + } + } + + async typeText(ref: string, text: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + let typed = false; + let nativeScrolled = false; + let nativeFocused = false; + + if (typeof (this as IPage).nativeType === 'function' || typeof (this as IPage).insertText === 'function') { + try { + nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + nativeFocused = await this.tryCdpOnResolvedElement('DOM.focus'); + const preparation = await this.evaluate(prepareNativeTypeResolvedJs({ + skipScroll: nativeScrolled, + skipFocus: nativeFocused, + })) as + | { ok?: boolean; mode?: string; reason?: string } + | null; + typed = preparation?.ok === true && await this.tryNativeType(text); + } catch { + // Native input is a reliability upgrade, not the only path. Preserve + // the existing DOM setter fallback if preparation fails. + } + } + + if (!typed) { + await this.evaluate(typeResolvedJs(text)); + } + return resolved; + } + + async hover(ref: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled })) as + | { x: number; y: number; w: number; h: number; visible: boolean } + | null; + if (rect?.visible === true && await this.tryNativeMouseMove(rect.x, rect.y)) return resolved; + + await this.evaluate(` + (() => { + const el = window.__resolved; + if (!el) throw new Error('No resolved element'); + if (${nativeScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + const rect = el.getBoundingClientRect(); + const init = { + bubbles: true, + cancelable: true, + view: window, + clientX: Math.round(rect.left + rect.width / 2), + clientY: Math.round(rect.top + rect.height / 2), + }; + try { el.dispatchEvent(new PointerEvent('pointerover', init)); } catch (_) {} + try { el.dispatchEvent(new PointerEvent('pointermove', init)); } catch (_) {} + el.dispatchEvent(new MouseEvent('mouseover', init)); + el.dispatchEvent(new MouseEvent('mousemove', init)); + })() + `); + return resolved; + } + + async focus(ref: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + let focused = await this.tryCdpOnResolvedElement('DOM.focus') && await this.isResolvedFocused(); + if (!focused) { + focused = await this.evaluate(` + (() => { + const el = window.__resolved; + if (!el || typeof el.focus !== 'function') return false; + try { el.focus({ preventScroll: true }); } catch (_) { try { el.focus(); } catch (_) {} } + return document.activeElement === el || (typeof el.matches === 'function' && el.matches(':focus')); + })() + `) as boolean; + } + return { ...resolved, focused: !!focused }; + } + + async dblClick(ref: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled })) as + | { x: number; y: number; w: number; h: number; visible: boolean } + | null; + if (rect?.visible === true && await this.tryNativeDoubleClick(rect.x, rect.y)) return resolved; + + await this.evaluate(` + (() => { + const el = window.__resolved; + if (!el) throw new Error('No resolved element'); + if (${nativeScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + const rect = el.getBoundingClientRect(); + const init = { + bubbles: true, + cancelable: true, + view: window, + clientX: Math.round(rect.left + rect.width / 2), + clientY: Math.round(rect.top + rect.height / 2), + button: 0, + detail: 2, + }; + el.dispatchEvent(new MouseEvent('dblclick', init)); + })() + `); + return resolved; + } + + private async readCheckableState(): Promise<{ + ok?: boolean; + checked?: boolean; + disabled?: boolean; + kind?: string; + reason?: string; + tag?: string; + role?: string; + } | null> { + return await this.evaluate(` + (() => { + const el = window.__resolved; + if (!el || el.nodeType !== 1) return { ok: false, reason: 'not_checkable' }; + const tag = el.tagName.toLowerCase(); + const role = (el.getAttribute('role') || '').toLowerCase(); + const type = (el.getAttribute('type') || '').toLowerCase(); + if (tag === 'input' && (type === 'checkbox' || type === 'radio')) { + return { + ok: true, + checked: !!el.checked, + disabled: !!el.disabled, + kind: type, + }; + } + if (role === 'checkbox' || role === 'switch' || role === 'menuitemcheckbox' || role === 'radio' || role === 'menuitemradio') { + const aria = (el.getAttribute('aria-checked') || '').toLowerCase(); + return { + ok: true, + checked: aria === 'true' || aria === 'mixed', + disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'), + kind: role, + }; + } + return { ok: false, reason: 'not_checkable', tag, role }; + })() + `) as { + ok?: boolean; + checked?: boolean; + disabled?: boolean; + kind?: string; + reason?: string; + tag?: string; + role?: string; + } | null; + } + + async setChecked(ref: string, checked: boolean, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + const before = await this.readCheckableState(); + if (before?.ok !== true) { + throw new TargetError({ + code: 'not_checkable', + message: `Target "${ref}" is not a checkbox, radio, switch, or aria-checked control.`, + hint: 'Use `opencli browser state` or `browser find` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.', + }); + } + if (before.disabled) { + throw new TargetError({ + code: 'not_checkable', + message: `Target "${ref}" is disabled and cannot be ${checked ? 'checked' : 'unchecked'}.`, + hint: 'Pick an enabled control, or inspect the form state before retrying.', + }); + } + if ((before.kind === 'radio' || before.kind === 'menuitemradio') && !checked) { + throw new TargetError({ + code: 'not_checkable', + message: `Target "${ref}" is a radio button and cannot be unchecked directly.`, + hint: 'Select another radio option in the same group instead.', + }); + } + if (before.checked === checked) { + return { + ...resolved, + checked, + changed: false, + ...(before.kind ? { kind: before.kind } : {}), + }; + } + + const clicked = await this.click(ref, opts); + const after = await this.readCheckableState(); + if (after?.ok !== true || after.checked !== checked) { + throw new TargetError({ + code: 'not_checkable', + message: `Target "${ref}" did not become ${checked ? 'checked' : 'unchecked'} after click.`, + hint: 'The control may be custom, disabled by app logic, or require a different target such as its visible label.', + }); + } + return { + matches_n: clicked.matches_n, + match_level: clicked.match_level, + checked, + changed: true, + ...(after.kind ? { kind: after.kind } : {}), + }; + } + + private async setFileInputBySelector(files: string[], selector: string): Promise { + const setFileInput = (this as IPage).setFileInput; + if (typeof setFileInput === 'function') { + await setFileInput.call(this, files, selector); + return; + } + + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') { + throw new Error('File upload requires setFileInput or CDP support from the active browser backend.'); + } + await cdp.call(this, 'DOM.enable', {}).catch(() => undefined); + const doc = await cdp.call(this, 'DOM.getDocument', {}) as { root?: { nodeId?: unknown } } | null; + const rootNodeId = doc?.root?.nodeId; + if (typeof rootNodeId !== 'number') throw new Error('DOM.getDocument returned no root node.'); + const query = await cdp.call(this, 'DOM.querySelector', { nodeId: rootNodeId, selector }) as { nodeId?: unknown } | null; + const nodeId = query?.nodeId; + if (typeof nodeId !== 'number' || nodeId <= 0) throw new Error(`No element found matching selector: ${selector}`); + await cdp.call(this, 'DOM.setFileInputFiles', { files, nodeId }); + } + + async uploadFiles(ref: string, files: string[], opts: ResolveOptions = {}): Promise { + if (!Array.isArray(files) || files.length === 0) { + throw new TargetError({ + code: 'not_file_input', + message: 'No files were provided for upload.', + hint: 'Pass one or more local file paths after the target.', + }); + } + const resolved = await runResolve(this, ref, opts); + const markerAttr = 'data-opencli-upload-target'; + const markerValue = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const selector = `[${markerAttr}="${markerValue}"]`; + let marked = false; + let info: { ok?: boolean; multiple?: boolean; accept?: string; reason?: string; tag?: string; type?: string } | null = null; + + try { + info = await this.evaluateWithArgs(` + (() => { + const el = window.__resolved; + if (!el || el.nodeType !== 1) return { ok: false, reason: 'not_file_input' }; + const tag = el.tagName.toLowerCase(); + const type = (el.getAttribute('type') || '').toLowerCase(); + if (tag !== 'input' || type !== 'file') return { ok: false, reason: 'not_file_input', tag, type }; + el.setAttribute(markerAttr, markerValue); + return { + ok: true, + multiple: !!el.multiple, + accept: el.getAttribute('accept') || '', + }; + })() + `, { markerAttr, markerValue }) as { ok?: boolean; multiple?: boolean; accept?: string; reason?: string; tag?: string; type?: string } | null; + marked = info?.ok === true; + if (!marked) { + throw new TargetError({ + code: 'not_file_input', + message: `Target "${ref}" is not an input[type=file].`, + hint: 'Use `opencli browser find --css "input[type=file]"` or inspect `compound` output from browser state/find.', + }); + } + if (files.length > 1 && !info?.multiple) { + throw new TargetError({ + code: 'not_file_input', + message: `Target "${ref}" does not allow multiple files, but ${files.length} files were provided.`, + hint: 'Pass one file, or choose a file input with the multiple attribute.', + }); + } + + await this.setFileInputBySelector(files, selector); + const verification = await this.evaluate(` + (() => { + const el = window.__resolved; + const names = []; + try { + if (el && el.files) { + for (let i = 0; i < el.files.length; i++) names.push(el.files[i].name || ''); + } + } catch (_) {} + return names; + })() + `) as unknown; + const fileNames = Array.isArray(verification) + ? verification.map((value) => String(value)) + : []; + + return { + ...resolved, + uploaded: true, + files: fileNames.length || files.length, + file_names: fileNames, + target: ref, + multiple: !!info?.multiple, + ...(info?.accept ? { accept: info.accept } : {}), + }; + } finally { + if (marked) { + await this.evaluateWithArgs(` + (() => { + for (const el of document.querySelectorAll(selector)) { + el.removeAttribute(markerAttr); + } + })() + `, { selector, markerAttr }).catch(() => undefined); + } + } + } + + async drag( + source: string, + target: string, + opts: { from?: ResolveOptions; to?: ResolveOptions } = {}, + ): Promise { + const sourceResolved = await runResolve(this, source, opts.from ?? {}); + const sourceScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + const sourceRect = await this.evaluate(` + (() => { + const el = window.__resolved; + if (!el) throw new Error('No resolved drag source'); + window.__opencli_drag_source = el; + if (${sourceScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + const rect = el.getBoundingClientRect(); + const w = Math.round(rect.width); + const h = Math.round(rect.height); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + const visible = w > 0 && h > 0 && x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight; + return { x, y, w, h, visible }; + })() + `) as + | { x: number; y: number; w: number; h: number; visible: boolean } + | null; + if (sourceRect?.visible !== true) { + throw new Error(`Drag source "${source}" has no visible bounding box.`); + } + + try { + const targetResolved = await runResolve(this, target, opts.to ?? {}); + const targetScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + const endpoints = await this.evaluate(` + (() => { + const sourceEl = window.__opencli_drag_source; + const targetEl = window.__resolved; + if (!sourceEl) throw new Error('No resolved drag source'); + if (!targetEl) throw new Error('No resolved drag target'); + if (${targetScrolled ? 'false' : 'true'}) targetEl.scrollIntoView({ behavior: 'instant', block: 'center' }); + const measure = (el) => { + const rect = el.getBoundingClientRect(); + const w = Math.round(rect.width); + const h = Math.round(rect.height); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + const visible = w > 0 && h > 0 && x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight; + return { x, y, w, h, visible }; + }; + return { source: measure(sourceEl), target: measure(targetEl) }; + })() + `) as + | { + source?: { x: number; y: number; w: number; h: number; visible: boolean }; + target?: { x: number; y: number; w: number; h: number; visible: boolean }; + } + | null; + + if (endpoints?.source?.visible !== true) { + throw new Error(`Drag source "${source}" is not visible at drag time.`); + } + if (endpoints?.target?.visible !== true) { + throw new Error(`Drag target "${target}" has no visible bounding box.`); + } + + const dragged = await this.tryNativeDrag( + { x: endpoints.source.x, y: endpoints.source.y }, + { x: endpoints.target.x, y: endpoints.target.y }, + ); + if (!dragged) throw new Error('Native drag requires CDP Input.dispatchMouseEvent support.'); + + return { + dragged: true, + source, + target, + source_matches_n: sourceResolved.matches_n, + target_matches_n: targetResolved.matches_n, + source_match_level: sourceResolved.match_level, + target_match_level: targetResolved.match_level, + }; + } finally { + await this.evaluate('delete window.__opencli_drag_source').catch(() => {}); + } + } + + async fillText(ref: string, text: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + let nativeScrolled = false; + let nativeFocused = false; + + try { + nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + nativeFocused = await this.tryCdpOnResolvedElement('DOM.focus'); + } catch { + // CDP focus/scroll is best-effort; DOM preparation below remains authoritative. + } + + const preparation = await this.evaluate(prepareNativeTypeResolvedJs({ + skipScroll: nativeScrolled, + skipFocus: nativeFocused, + })) as + | { ok?: boolean; mode?: string; reason?: string; tag?: string } + | null; + + if (preparation?.ok !== true) { + throw new TargetError({ + code: 'not_editable', + message: `Target "${ref}" is not a fillable input, textarea, or contenteditable element.`, + hint: 'Use `opencli browser state` to pick an editable target, or use `browser type` for keyboard-like interactions.', + }); + } + + const usedNativeInput = await this.tryNativeType(text); + if (!usedNativeInput) { + await this.evaluate(typeResolvedJs(text)); + } + + let verification = await this.evaluate(verifyFilledResolvedJs(text)) as FillResolvedResult | null; + if (usedNativeInput && verification?.ok !== true) { + await this.evaluate(typeResolvedJs(text)); + verification = await this.evaluate(verifyFilledResolvedJs(text)) as FillResolvedResult | null; + } + const actual = verification && 'actual' in verification ? verification.actual : ''; + const mode = verification && 'mode' in verification ? verification.mode : undefined; + + return { + ...resolved, + filled: true, + verified: verification?.ok === true, + expected: text, + actual, + length: actual.length, + ...(mode ? { mode } : {}), + }; + } + + async pressKey(key: string): Promise { + const parsed = parseKeyChord(key); + if (!await this.tryNativeKeyPress(parsed.key, parsed.modifiers)) { + await this.evaluate(pressKeyJs(parsed.key, parsed.modifiers)); + } + } + + async scrollTo(ref: string, opts: ResolveOptions = {}): Promise { + const resolved = await runResolve(this, ref, opts); + const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded'); + const result = (await this.evaluate(scrollResolvedJs({ skipScroll: nativeScrolled }))) as Record | null; + // Fold match_level into the scroll payload so the user-facing envelope + // carries it the same way click / type do. + if (result && typeof result === 'object') { + return { ...result, matches_n: resolved.matches_n, match_level: resolved.match_level }; + } + return { matches_n: resolved.matches_n, match_level: resolved.match_level }; + } + + async getFormState(): Promise> { + return (await this.evaluate(getFormStateJs())) as Record; + } + + async scroll(direction: string = 'down', amount: number = 500): Promise { + await this.evaluate(scrollJs(direction, amount)); + } + + async autoScroll(options?: { times?: number; delayMs?: number }): Promise { + const times = options?.times ?? 3; + const delayMs = options?.delayMs ?? 2000; + await this.evaluate(autoScrollJs(times, delayMs)); + } + + async networkRequests(includeStatic: boolean = false): Promise { + const result = await this.evaluate(networkRequestsJs(includeStatic)); + return Array.isArray(result) ? result : []; + } + + async consoleMessages(_level: string = 'info'): Promise { + return []; + } + + /** + * Pure client-side sleep — a bare setTimeout with no page evaluation. + * + * Unlike `wait(n)`, this never installs a MutationObserver or touches the + * renderer, so poll loops that already re-check state each iteration don't + * pay for a whole-body DOM-stable probe on every tick. Use this for fixed + * poll intervals; use `wait(n)` when DOM-stable early return is desired. + */ + async sleep(seconds: number): Promise { + await new Promise(resolve => setTimeout(resolve, seconds * 1000)); + } + + async wait(options: number | WaitOptions): Promise { + if (typeof options === 'number') { + if (options >= 1) { + try { + const maxMs = options * 1000; + await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs))); + return; + } catch { + // Fallback: fixed sleep + } + } + await new Promise(resolve => setTimeout(resolve, options * 1000)); + return; + } + if (typeof options.time === 'number') { + await new Promise(resolve => setTimeout(resolve, options.time! * 1000)); + return; + } + if (options.selector) { + const timeout = (options.timeout ?? 10) * 1000; + await this.evaluate(waitForSelectorJs(options.selector, timeout)); + return; + } + if (options.text) { + const timeout = (options.timeout ?? 30) * 1000; + await this.evaluate(waitForTextJs(options.text, timeout)); + } + } + + async snapshot(opts: SnapshotOptions = {}): Promise { + if (opts.source === 'ax') { + const cdp = (this as IPage).cdp; + if (typeof cdp !== 'function') { + throw new CliError( + 'BROWSER_AX_UNAVAILABLE', + 'AX snapshot requires CDP support from the active browser backend.', + 'Use the default DOM state, or update/reload the Browser Bridge extension.', + ); + } + const axTrees = await this.collectAxSnapshotTrees(cdp); + const built = buildAxSnapshotFromTrees(axTrees, { + maxDepth: opts.maxDepth, + interactiveOnly: opts.interactive, + }); + this._axRefs = built.refs; + return built.text; + } + + this._axRefs.clear(); + const snapshotJs = generateSnapshotJs({ + viewportExpand: opts.viewportExpand ?? 2000, + maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)), + interactiveOnly: opts.interactive ?? false, + maxTextLength: opts.maxTextLength ?? 120, + includeScrollInfo: true, + bboxDedup: true, + previousHashes: this._prevSnapshotHashes, + }); + + try { + const result = await this.evaluate(snapshotJs); + // Read back the hashes stored by the snapshot for next diff + try { + const hashes = await this.evaluate('window.__opencli_prev_hashes') as string | null; + this._prevSnapshotHashes = typeof hashes === 'string' ? hashes : null; + } catch { + // Non-fatal: diff is best-effort + } + return result; + } catch (err) { + // Log snapshot failure for debugging, then fallback to basic accessibility tree + if (process.env.DEBUG_SNAPSHOT) { + process.stderr.write(`[snapshot] DOM snapshot failed, falling back to accessibility tree: ${(err as Error)?.message?.slice(0, 200)}\n`); + } + return this._basicSnapshot(opts); + } + } + + private async collectAxSnapshotTrees( + cdp: (method: string, params?: Record) => Promise, + ): Promise { + await cdp.call(this, 'Accessibility.enable', {}); + const rootTree = await cdp.call(this, 'Accessibility.getFullAXTree', {}); + const trees: AxSnapshotTree[] = [{ tree: rootTree }]; + + const frameTreeResult = await cdp.call(this, 'Page.getFrameTree', {}).catch(() => null); + const frames = collectAxFrameRefs(frameTreeResult); + for (const frame of frames) { + if (frame.sessionId) { + await cdp.call(this, 'Accessibility.enable', axEnableParams(frame)).catch(() => null); + } + const tree = await cdp.call(this, 'Accessibility.getFullAXTree', axTreeParams(frame)).catch(() => null); + if (tree) trees.push({ tree, frame }); + } + + return trees; + } + + async getCurrentUrl(): Promise { + if (this._lastUrl) return this._lastUrl; + try { + const current = await this.evaluate('window.location.href'); + if (typeof current === 'string' && current) { + this._lastUrl = current; + return current; + } + } catch { + // Best-effort + } + return null; + } + + async installInterceptor(pattern: string): Promise { + const { generateInterceptorJs } = await import('../interceptor.js'); + await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), { + arrayName: '__opencli_xhr', + patchGuard: '__opencli_interceptor_patched', + })); + } + + async getInterceptedRequests(): Promise { + const { generateReadInterceptedJs } = await import('../interceptor.js'); + const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr')); + return Array.isArray(result) ? result : []; + } + + async waitForCapture(timeout: number = 10): Promise { + const maxMs = timeout * 1000; + await this.evaluate(waitForCaptureJs(maxMs)); + } + + /** Fallback basic snapshot */ + protected async _basicSnapshot(opts: Pick = {}): Promise { + const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)); + const code = ` + (async () => { + function buildTree(node, depth) { + if (depth > ${maxDepth}) return ''; + const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic'; + const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || ''; + const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null; + + ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''} + + let indent = ' '.repeat(depth); + let line = indent + role; + if (name) line += ' "' + name.replace(/"/g, '\\\\\\"') + '"'; + if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']'; + if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']'; + + let result = line + '\\n'; + if (node.children) { + for (const child of node.children) { + result += buildTree(child, depth + 1); + } + } + return result; + } + return buildTree(document.body, 0); + })() + `; + const raw = await this.evaluate(code); + if (opts.raw) return raw; + if (typeof raw === 'string') return formatSnapshot(raw, opts); + return raw; + } +} + +function axTreeParams(frame: BrowserRef['frame'] | undefined): Record { + return frame?.frameId + ? { + frameId: frame.frameId, + ...(frame.sessionId ? { sessionId: frame.sessionId } : {}), + ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}), + } + : {}; +} + +function axEnableParams(frame: BrowserRef['frame'] | undefined): Record { + return frame?.frameId && frame.sessionId + ? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) } + : {}; +} + +function collectAxFrameRefs(frameTreeResult: unknown): Array> { + const root = (frameTreeResult as { frameTree?: CdpFrameTreeNode } | null)?.frameTree; + const rootUrl = root?.frame?.url || root?.frame?.unreachableUrl || ''; + const rootOrigin = urlOrigin(rootUrl); + if (!root || !rootOrigin) return []; + + const frames: Array> = []; + function collect(node: CdpFrameTreeNode | undefined): void { + for (const child of node?.childFrames ?? []) { + const frame = child.frame; + const frameId = frame?.id; + const frameUrl = frame?.url || frame?.unreachableUrl || ''; + const origin = urlOrigin(frameUrl); + if (!frameId) continue; + if (origin === rootOrigin) { + frames.push({ frameId, url: frameUrl }); + collect(child); + } else { + frames.push({ frameId, url: frameUrl, targetUrl: frameUrl, sessionId: 'target' }); + } + } + } + collect(root); + return frames; +} + +function urlOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} diff --git a/src/browser/bridge-readiness.test.ts b/src/browser/bridge-readiness.test.ts new file mode 100644 index 0000000..9d64db2 --- /dev/null +++ b/src/browser/bridge-readiness.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + PRE_DISPATCH_ERROR_CODES, + isPreDispatchError, + waitForBridgeReady, + type DaemonHealth, + type HealthFetcher, +} from './bridge-readiness.js'; +import type { DaemonStatus } from './daemon-transport.js'; + +function status(extensionConnected: boolean): DaemonStatus { + return { + ok: true, + pid: 1, + uptime: 0, + extensionConnected, + pending: 0, + memoryMB: 0, + port: 19825, + }; +} + +function readyHealth(): DaemonHealth { + return { state: 'ready', status: status(true) }; +} + +function notReadyHealth(state: Exclude): DaemonHealth { + if (state === 'stopped') return { state: 'stopped', status: null }; + return { state, status: status(false) }; +} + +describe('waitForBridgeReady', () => { + it('returns immediately on the first ready health', async () => { + const fetchHealth: HealthFetcher = vi.fn(async () => readyHealth()); + + const result = await waitForBridgeReady(fetchHealth, { timeoutMs: 10_000, intervalMs: 1 }); + + expect(result.state).toBe('ready'); + expect(fetchHealth).toHaveBeenCalledTimes(1); + }); + + it('polls until ready when initial reads are not ready', async () => { + const sequence: DaemonHealth[] = [ + notReadyHealth('stopped'), + notReadyHealth('no-extension'), + readyHealth(), + ]; + let i = 0; + const fetchHealth: HealthFetcher = vi.fn(async () => sequence[i++] ?? readyHealth()); + + const result = await waitForBridgeReady(fetchHealth, { timeoutMs: 10_000, intervalMs: 1 }); + + expect(result.state).toBe('ready'); + expect(fetchHealth).toHaveBeenCalledTimes(3); + }); + + it('returns the last observed non-ready health when the deadline expires', async () => { + const fetchHealth: HealthFetcher = vi.fn(async () => notReadyHealth('profile-disconnected')); + + const result = await waitForBridgeReady(fetchHealth, { timeoutMs: 25, intervalMs: 5 }); + + expect(result.state).toBe('profile-disconnected'); + expect(vi.mocked(fetchHealth).mock.calls.length).toBeGreaterThan(1); + }); +}); + +describe('isPreDispatchError', () => { + it('classifies only safe pre-dispatch daemon errors', () => { + expect(isPreDispatchError('extension_not_connected')).toBe(true); + expect(isPreDispatchError('profile_disconnected')).toBe(true); + expect(isPreDispatchError('profile_required')).toBe(false); + expect(isPreDispatchError('command_result_unknown')).toBe(false); + expect(isPreDispatchError(undefined)).toBe(false); + expect(PRE_DISPATCH_ERROR_CODES.size).toBe(2); + }); +}); diff --git a/src/browser/bridge-readiness.ts b/src/browser/bridge-readiness.ts new file mode 100644 index 0000000..f570648 --- /dev/null +++ b/src/browser/bridge-readiness.ts @@ -0,0 +1,36 @@ +import type { DaemonHealth } from './daemon-transport.js'; + +export type { DaemonHealth }; + +export type HealthFetcher = (opts?: { timeout?: number; contextId?: string }) => Promise; + +const DEFAULT_POLL_INTERVAL_MS = 200; + +export async function waitForBridgeReady( + fetchHealth: HealthFetcher, + opts: { timeoutMs: number; contextId?: string; intervalMs?: number }, +): Promise { + const interval = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + let health = await fetchHealth({ contextId: opts.contextId }); + if (health.state === 'ready') return health; + + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, interval)); + health = await fetchHealth({ contextId: opts.contextId }); + if (health.state === 'ready') return health; + } + return health; +} + +export const PRE_DISPATCH_ERROR_CODES = new Set([ + 'extension_not_connected', + 'profile_disconnected', +] as const); + +export type PreDispatchErrorCode = typeof PRE_DISPATCH_ERROR_CODES extends Set ? T : never; + +export function isPreDispatchError(errorCode: string | undefined): boolean { + if (!errorCode) return false; + return (PRE_DISPATCH_ERROR_CODES as Set).has(errorCode); +} diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts new file mode 100644 index 0000000..5eeca05 --- /dev/null +++ b/src/browser/bridge.ts @@ -0,0 +1,71 @@ +/** + * Browser session manager — auto-spawns daemon and provides IPage. + */ + +import type { ChildProcess } from 'node:child_process'; +import type { IPage } from '../types.js'; +import type { IBrowserFactory } from '../runtime.js'; +import { Page } from './page.js'; +import { profileRouteParams, resolveProfileSelection } from './profile.js'; +import { ensureBrowserBridgeReady } from './daemon-lifecycle.js'; + +const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension + +export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed'; + +/** + * Browser factory: manages daemon lifecycle and provides IPage instances. + */ +export class BrowserBridge implements IBrowserFactory { + private _state: BrowserBridgeState = 'idle'; + private _page: Page | null = null; + private _daemonProc: ChildProcess | null = null; + + get state(): BrowserBridgeState { + return this._state; + } + + async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise { + if (this._state === 'connected' && this._page) return this._page; + if (this._state === 'connecting') throw new Error('Already connecting'); + if (this._state === 'closing') throw new Error('Session is closing'); + if (this._state === 'closed') throw new Error('Session is closed'); + + this._state = 'connecting'; + + try { + // Requirement vs preference: only an explicit contextId pins daemon + // readiness and command routing to a specific profile. A preferred one + // (config default) is arbitrated by the daemon against live connections, + // so a stale default cannot make connect() wait for a dead profile. + const routing = opts.contextId || opts.preferredContextId + ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId } + : profileRouteParams(resolveProfileSelection()); + await this._ensureDaemon(opts.timeout, routing.contextId); + if (!opts.session?.trim()) throw new Error('Browser session is required'); + this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId); + this._state = 'connected'; + return this._page; + } catch (err) { + this._state = 'idle'; + throw err; + } + } + + async close(): Promise { + if (this._state === 'closed') return; + this._state = 'closing'; + // We don't kill the daemon — it's persistent. + // Just clean up our reference. + this._page = null; + this._state = 'closed'; + } + + private async _ensureDaemon(timeoutSeconds?: number, contextId?: string): Promise { + const result = await ensureBrowserBridgeReady({ + timeoutSeconds: timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000), + contextId, + }); + this._daemonProc = result.spawnedProcess; + } +} diff --git a/src/browser/cdp-click-fixture.test.ts b/src/browser/cdp-click-fixture.test.ts new file mode 100644 index 0000000..94b3afc --- /dev/null +++ b/src/browser/cdp-click-fixture.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; + +function installDom(html: string): Document { + const dom = new JSDOM(html, { pretendToBeVisual: true }); + globalThis.window = dom.window as unknown as Window & typeof globalThis; + globalThis.document = dom.window.document; + globalThis.Event = dom.window.Event; + globalThis.MouseEvent = dom.window.MouseEvent; + return dom.window.document; +} + +function dispatchNativeMouseSequence(target: Element): void { + for (const type of ['mousemove', 'pointerdown', 'mousedown', 'mouseup', 'pointerup', 'click']) { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); + } +} + +describe('CDP-primary click dropdown fixtures', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'document'); + }); + + it('captures the Radix/shadcn class of dropdowns that open on pointerdown and select on pointerup', () => { + const document = installDom(` + +
+ + `); + const trigger = document.querySelector('#trigger') as HTMLButtonElement; + const portal = document.querySelector('#portal-root')!; + const value = document.querySelector('#value')!; + + trigger.addEventListener('pointerdown', () => { + trigger.setAttribute('aria-expanded', 'true'); + portal.innerHTML = ` +
+
Meals
+
+ `; + portal.querySelector('#meals')!.addEventListener('pointerup', () => { + value.textContent = 'Meals'; + trigger.textContent = 'Meals'; + trigger.setAttribute('aria-expanded', 'false'); + }); + }); + + // Baseline: DOM el.click() dispatches click only. This is the old OpenCLI + // failure mode: the command reports success but the dropdown never opens. + trigger.click(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(portal.querySelector('[role="option"]')).toBeNull(); + expect(value.textContent).toBe(''); + + // CDP-style mouse input opens the portal and can commit the option. + dispatchNativeMouseSequence(trigger); + const option = portal.querySelector('#meals')!; + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + dispatchNativeMouseSequence(option); + expect(value.textContent).toBe('Meals'); + expect(trigger.textContent).toBe('Meals'); + }); + + it('captures the MUI autocomplete class that opens on mousedown and commits on mousedown in a popper', () => { + const document = installDom(` + + +
+ + `); + const input = document.querySelector('#category') as HTMLInputElement; + const popper = document.querySelector('#mui-popper')!; + const value = document.querySelector('#value')!; + + input.addEventListener('mousedown', () => { + popper.innerHTML = ` +
    +
  • Travel
  • +
+ `; + popper.querySelector('#travel')!.addEventListener('mousedown', () => { + input.value = 'Travel'; + value.textContent = 'Travel'; + }); + }); + + input.click(); + expect(popper.querySelector('[role="option"]')).toBeNull(); + expect(input.value).toBe(''); + + dispatchNativeMouseSequence(input); + const option = popper.querySelector('#travel')!; + dispatchNativeMouseSequence(option); + + expect(input.value).toBe('Travel'); + expect(value.textContent).toBe('Travel'); + }); +}); diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts new file mode 100644 index 0000000..6ad3b83 --- /dev/null +++ b/src/browser/cdp.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { MockWebSocket } = vi.hoisted(() => { + class MockWebSocket { + static OPEN = 1; + readyState = 1; + private handlers = new Map void>>(); + + constructor(_url: string) { + queueMicrotask(() => this.emit('open')); + } + + on(event: string, handler: (...args: unknown[]) => void): void { + const handlers = this.handlers.get(event) ?? []; + handlers.push(handler); + this.handlers.set(event, handlers); + } + + send(_message: string): void {} + + close(): void { + this.readyState = 3; + } + + private emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args); + } + } + } + + return { MockWebSocket }; +}); + +vi.mock('ws', () => ({ + WebSocket: MockWebSocket, +})); + +import { CDPBridge } from './cdp.js'; + +describe('CDPBridge cookies', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('filters cookies by actual domain match instead of substring match', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockResolvedValue({ + cookies: [ + { name: 'good', value: '1', domain: '.example.com' }, + { name: 'exact', value: '2', domain: 'example.com' }, + { name: 'bad', value: '3', domain: 'notexample.com' }, + ], + }); + + const page = await bridge.connect(); + const cookies = await page.getCookies({ domain: 'example.com' }); + + expect(cookies).toEqual([ + { name: 'good', value: '1', domain: '.example.com' }, + { name: 'exact', value: '2', domain: 'example.com' }, + ]); + }); + + it('exposes native input helpers on direct CDP pages', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + const send = vi.spyOn(bridge, 'send').mockResolvedValue({}); + + const page = await bridge.connect(); + send.mockClear(); + + expect(page.nativeType).toBeTypeOf('function'); + expect(page.nativeKeyPress).toBeTypeOf('function'); + expect(page.nativeClick).toBeTypeOf('function'); + expect(page.handleJavaScriptDialog).toBeTypeOf('function'); + expect(page.cdp).toBeTypeOf('function'); + + await page.nativeType!('hello'); + await page.nativeKeyPress!('a', ['Ctrl']); + await page.nativeClick!(10, 20); + await page.handleJavaScriptDialog!(true, 'ok'); + await page.cdp!('Page.getLayoutMetrics', {}); + + expect(send.mock.calls).toEqual([ + ['Input.insertText', { text: 'hello' }], + ['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }], + ['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }], + ['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 }], + ['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }], + ['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }], + ['Page.handleJavaScriptDialog', { accept: true, promptText: 'ok' }], + ['Page.getLayoutMetrics', {}], + ]); + }); +}); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts new file mode 100644 index 0000000..a4be15c --- /dev/null +++ b/src/browser/cdp.ts @@ -0,0 +1,585 @@ +/** + * CDP client — implements IPage by connecting directly to a Chrome/Electron CDP WebSocket. + * + * Fixes applied: + * - send() now has a 30s timeout guard (P0 #4) + * - goto() waits for Page.loadEventFired instead of hardcoded 1s sleep (P1 #3) + * - Implemented scroll, autoScroll, screenshot, networkRequests (P1 #2) + * - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5) + */ + +import { WebSocket, type RawData } from 'ws'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import type { BrowserCookie, BrowserEvaluateFunction, IPage, ScreenshotOptions } from '../types.js'; +import type { IBrowserFactory } from '../runtime.js'; +import { buildEvaluateExpression } from './utils.js'; +import { generateStealthJs } from './stealth.js'; +import { waitForDomStableJs } from './dom-helpers.js'; +import { isRecord, saveBase64ToFile } from '../utils.js'; +import { getAllElectronApps } from '../electron-apps.js'; +import { BasePage } from './base-page.js'; + +export interface CDPTarget { + type?: string; + url?: string; + title?: string; + webSocketDebuggerUrl?: string; +} + +interface RuntimeEvaluateResult { + result?: { + value?: unknown; + }; + exceptionDetails?: { + exception?: { + description?: string; + }; + }; +} + +const CDP_SEND_TIMEOUT = 30_000; + +// Memory guard for in-process capture. The 4k cap we used to apply everywhere +// silently truncated JSON so `JSON.parse` failed or gave partial objects — the +// primary agent-facing bug. Now we keep the full body up to a large cap and +// surface `responseBodyFullSize` + `responseBodyTruncated` so downstream layers +// can tell the agent what happened instead of lying about the payload. +export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; + +export class CDPBridge implements IBrowserFactory { + private _ws: WebSocket | null = null; + private _idCounter = 0; + private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>(); + private _eventListeners = new Map void>>(); + + async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' }): Promise { + if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.'); + + const endpoint = opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT; + if (!endpoint) throw new Error('CDP endpoint not provided (pass cdpEndpoint or set OPENCLI_CDP_ENDPOINT)'); + + let wsUrl = endpoint; + if (endpoint.startsWith('http')) { + const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[]; + const target = selectCDPTarget(targets); + if (!target || !target.webSocketDebuggerUrl) { + throw new Error('No inspectable targets found at CDP endpoint'); + } + wsUrl = target.webSocketDebuggerUrl; + } + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const timeoutMs = (opts?.timeout ?? 10) * 1000; + const timeout = setTimeout(() => { + this._ws = null; + ws.close(); + reject(new Error('CDP connect timeout')); + }, timeoutMs); + + ws.on('open', async () => { + clearTimeout(timeout); + this._ws = ws; + try { + await this.send('Page.enable'); + await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() }); + } catch (err) { + ws.close(); + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + resolve(new CDPPage(this)); + }); + + ws.on('error', (err: Error) => { + clearTimeout(timeout); + reject(err); + }); + + ws.on('message', (data: RawData) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.id && this._pending.has(msg.id)) { + const entry = this._pending.get(msg.id)!; + clearTimeout(entry.timer); + this._pending.delete(msg.id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + if (msg.method) { + const listeners = this._eventListeners.get(msg.method); + if (listeners) { + for (const fn of listeners) fn(msg.params); + } + } + } catch (err) { + if (process.env.OPENCLI_VERBOSE) { + // eslint-disable-next-line no-console + console.error('[cdp] Failed to parse WebSocket message:', err instanceof Error ? err.message : err); + } + } + }); + }); + } + + async close(): Promise { + if (this._ws) { + this._ws.close(); + this._ws = null; + } + for (const p of this._pending.values()) { + clearTimeout(p.timer); + p.reject(new Error('CDP connection closed')); + } + this._pending.clear(); + this._eventListeners.clear(); + } + + async send(method: string, params: Record = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { + throw new Error('CDP connection is not open'); + } + const id = ++this._idCounter; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`)); + }, timeoutMs); + this._pending.set(id, { resolve, reject, timer }); + this._ws!.send(JSON.stringify({ id, method, params })); + }); + } + + on(event: string, handler: (params: unknown) => void): void { + let set = this._eventListeners.get(event); + if (!set) { + set = new Set(); + this._eventListeners.set(event, set); + } + set.add(handler); + } + + off(event: string, handler: (params: unknown) => void): void { + this._eventListeners.get(event)?.delete(handler); + } + + waitForEvent(event: string, timeoutMs: number = 15_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.off(event, handler); + reject(new Error(`Timed out waiting for CDP event '${event}'`)); + }, timeoutMs); + const handler = (params: unknown) => { + clearTimeout(timer); + this.off(event, handler); + resolve(params); + }; + this.on(event, handler); + }); + } +} + +class CDPPage extends BasePage { + private _pageEnabled = false; + + // Network capture state (mirrors extension/src/cdp.ts NetworkCaptureEntry shape) + private _networkCapturing = false; + private _networkCapturePattern = ''; + private _networkEntries: Array<{ + url: string; method: string; responseStatus?: number; + responseContentType?: string; + responsePreview?: string; + responseBodyFullSize?: number; + responseBodyTruncated?: boolean; + timestamp: number; + }> = []; + private _pendingRequests = new Map(); // requestId → index in _networkEntries + private _pendingBodyFetches: Set> = new Set(); // track in-flight getResponseBody calls + private _consoleMessages: Array<{ type: string; text: string; timestamp: number }> = []; + private _consoleCapturing = false; + + constructor(private bridge: CDPBridge) { + super(); + } + + async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number; allowBoundNavigation?: boolean }): Promise { + if (!this._pageEnabled) { + await this.bridge.send('Page.enable'); + this._pageEnabled = true; + } + const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {}); + await this.bridge.send('Page.navigate', { url }); + await loadPromise; + this._lastUrl = url; + if (options?.waitUntil !== 'none') { + const maxMs = options?.settleMs ?? 1000; + await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs))); + } + } + + async evaluate(js: string): Promise; + async evaluate(fn: BrowserEvaluateFunction, ...args: Args): Promise>; + async evaluate(input: string | BrowserEvaluateFunction, ...args: unknown[]): Promise { + const expression = buildEvaluateExpression(input, args); + const result = await this.bridge.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) as RuntimeEvaluateResult; + if (result.exceptionDetails) { + throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception')); + } + return result.result?.value; + } + + async getCookies(opts: { domain?: string; url?: string } = {}): Promise { + const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {}); + const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : []; + const domain = opts.domain; + return domain + ? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain)) + : cookies; + } + + async screenshot(options: ScreenshotOptions = {}): Promise { + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : undefined; + // height is ignored under fullPage so the captureBeyondViewport path stays unchanged for users who pass --height alongside --full-page. + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : undefined; + const needsOverride = overrideWidth !== undefined || overrideHeight !== undefined; + + if (needsOverride) { + if (overrideWidth !== undefined && fullPage) { + await this.bridge.send('Emulation.setDeviceMetricsOverride', { + mobile: false, + width: overrideWidth, + height: 0, + deviceScaleFactor: 1, + }); + } + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await this.bridge.send('Page.getLayoutMetrics'); + const m = isRecord(metrics) ? metrics : {}; + const css = isRecord(m.cssContentSize) ? m.cssContentSize : undefined; + const fb = isRecord(m.contentSize) ? m.contentSize : undefined; + const size = css ?? fb; + if (size && typeof size.width === 'number' && typeof size.height === 'number') { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await this.bridge.send('Emulation.setDeviceMetricsOverride', { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1, + }); + } + + try { + const result = await this.bridge.send('Page.captureScreenshot', { + format: options.format ?? 'png', + quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined, + captureBeyondViewport: !needsOverride && fullPage, + }); + const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : ''; + if (options.path) { + await saveBase64ToFile(base64, options.path); + } + return base64; + } finally { + if (needsOverride) { + await this.bridge.send('Emulation.clearDeviceMetricsOverride').catch(() => {}); + } + } + } + + async startNetworkCapture(pattern: string = ''): Promise { + // Always update the filter pattern + this._networkCapturePattern = pattern; + + // Reset state only on first start; avoid wiping entries if already capturing + if (!this._networkCapturing) { + this._networkEntries = []; + this._pendingRequests.clear(); + this._pendingBodyFetches.clear(); + await this.bridge.send('Network.enable'); + + // Step 1: Record request method/url on requestWillBeSent + this.bridge.on('Network.requestWillBeSent', (params: unknown) => { + const p = params as { requestId: string; request: { method: string; url: string }; timestamp: number }; + if (!this._networkCapturePattern || p.request.url.includes(this._networkCapturePattern)) { + const idx = this._networkEntries.push({ + url: p.request.url, + method: p.request.method, + timestamp: Date.now(), + }) - 1; + this._pendingRequests.set(p.requestId, idx); + } + }); + + // Step 2: Fill in response metadata on responseReceived + this.bridge.on('Network.responseReceived', (params: unknown) => { + const p = params as { requestId: string; response: { status: number; mimeType?: string } }; + const idx = this._pendingRequests.get(p.requestId); + if (idx !== undefined) { + this._networkEntries[idx].responseStatus = p.response.status; + this._networkEntries[idx].responseContentType = p.response.mimeType || ''; + } + }); + + // Step 3: Fetch body on loadingFinished (body is only reliably available after this) + this.bridge.on('Network.loadingFinished', (params: unknown) => { + const p = params as { requestId: string }; + const idx = this._pendingRequests.get(p.requestId); + if (idx !== undefined) { + const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result: unknown) => { + const r = result as { body?: string; base64Encoded?: boolean } | undefined; + if (typeof r?.body === 'string') { + const fullSize = r.body.length; + const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; + const body = truncated ? r.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : r.body; + this._networkEntries[idx].responsePreview = r.base64Encoded ? `base64:${body}` : body; + this._networkEntries[idx].responseBodyFullSize = fullSize; + this._networkEntries[idx].responseBodyTruncated = truncated; + } + }).catch((err) => { + // Body unavailable for some requests (e.g. uploads) — non-fatal + if (process.env.OPENCLI_VERBOSE) { + // eslint-disable-next-line no-console + console.error(`[cdp] getResponseBody failed for ${p.requestId}:`, err instanceof Error ? err.message : err); + } + }).finally(() => { + this._pendingBodyFetches.delete(bodyFetch); + }); + this._pendingBodyFetches.add(bodyFetch); + this._pendingRequests.delete(p.requestId); + } + }); + + this._networkCapturing = true; + } + return true; + } + + async readNetworkCapture(): Promise { + // Await all in-flight body fetches so entries have responsePreview populated + if (this._pendingBodyFetches.size > 0) { + await Promise.all([...this._pendingBodyFetches]); + } + const entries = [...this._networkEntries]; + this._networkEntries = []; + return entries; + } + + async consoleMessages(level: string = 'all'): Promise> { + if (!this._consoleCapturing) { + await this.bridge.send('Runtime.enable'); + this.bridge.on('Runtime.consoleAPICalled', (params: unknown) => { + const p = params as { type: string; args: Array<{ value?: unknown; description?: string }>; timestamp: number }; + const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' '); + this._consoleMessages.push({ type: p.type, text, timestamp: Date.now() }); + if (this._consoleMessages.length > 500) this._consoleMessages.shift(); + }); + // Capture uncaught exceptions as error-level messages + this.bridge.on('Runtime.exceptionThrown', (params: unknown) => { + const p = params as { timestamp: number; exceptionDetails?: { exception?: { description?: string }; text?: string } }; + const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception'; + this._consoleMessages.push({ type: 'error', text: desc, timestamp: Date.now() }); + if (this._consoleMessages.length > 500) this._consoleMessages.shift(); + }); + this._consoleCapturing = true; + } + if (level === 'all') return [...this._consoleMessages]; + // 'error' level includes both console.error() and uncaught exceptions + if (level === 'error') return this._consoleMessages.filter(m => m.type === 'error' || m.type === 'warning'); + return this._consoleMessages.filter(m => m.type === level); + } + + async tabs(): Promise { + return []; + } + + async selectTab(_target: number | string): Promise { + // Not supported in direct CDP mode + } + + async cdp(method: string, params: Record = {}): Promise { + return this.bridge.send(method, params); + } + + async handleJavaScriptDialog(accept: boolean, promptText?: string): Promise { + await this.cdp('Page.handleJavaScriptDialog', { + accept, + ...(promptText !== undefined && { promptText }), + }); + } + + async nativeClick(x: number, y: number): Promise { + await this.cdp('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + }); + await this.cdp('Input.dispatchMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + clickCount: 1, + }); + await this.cdp('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + clickCount: 1, + }); + } + + async nativeType(text: string): Promise { + await this.cdp('Input.insertText', { text }); + } + + async insertText(text: string): Promise { + await this.nativeType(text); + } + + async nativeKeyPress(key: string, modifiers: string[] = []): Promise { + let modifierFlags = 0; + for (const mod of modifiers) { + if (mod === 'Alt') modifierFlags |= 1; + if (mod === 'Ctrl' || mod === 'Control') modifierFlags |= 2; + if (mod === 'Meta') modifierFlags |= 4; + if (mod === 'Shift') modifierFlags |= 8; + } + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyDown', + key, + modifiers: modifierFlags, + }); + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyUp', + key, + modifiers: modifierFlags, + }); + } +} + +function isCookie(value: unknown): value is BrowserCookie { + return isRecord(value) + && typeof value.name === 'string' + && typeof value.value === 'string' + && typeof value.domain === 'string'; +} + +function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolean { + const normalizedCookieDomain = cookieDomain.replace(/^\./, '').toLowerCase(); + const normalizedTargetDomain = targetDomain.replace(/^\./, '').toLowerCase(); + return normalizedTargetDomain === normalizedCookieDomain + || normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`); +} + +function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined { + const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); + + const ranked = targets + .map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern) })) + .filter(({ score }) => Number.isFinite(score)) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.index - b.index; + }); + + return ranked[0]?.target; +} + +function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { + if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY; + + const type = (target.type ?? '').toLowerCase(); + const url = (target.url ?? '').toLowerCase(); + const title = (target.title ?? '').toLowerCase(); + const haystack = `${title} ${url}`; + + if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY; + if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY; + if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY; + + let score = 0; + + if (preferredPattern && preferredPattern.test(haystack)) score += 1000; + + if (type === 'app') score += 120; + else if (type === 'webview') score += 100; + else if (type === 'page') score += 80; + else if (type === 'iframe') score += 20; + + if (url.startsWith('http://localhost') || url.startsWith('https://localhost')) score += 90; + if (url.startsWith('file://')) score += 60; + if (url.startsWith('http://127.0.0.1') || url.startsWith('https://127.0.0.1')) score += 50; + if (url.startsWith('about:blank')) score -= 120; + if (url === '' || url === 'about:blank') score -= 40; + + if (title && title !== 'devtools') score += 25; + + // Boost score for known Electron app names from the registry (builtin + user-defined) + const appNames = Object.values(getAllElectronApps()).map(a => (a.displayName ?? a.processName).toLowerCase()); + for (const name of appNames) { + if (title.includes(name)) { score += 120; break; } + } + for (const name of appNames) { + if (url.includes(name)) { score += 100; break; } + } + + return score; +} + +function compilePreferredPattern(raw: string | undefined): RegExp | undefined { + const value = raw?.trim(); + if (!value) return undefined; + return new RegExp(escapeRegExp(value.toLowerCase())); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export const __test__ = { + selectCDPTarget, + scoreCDPTarget, +}; + +function fetchJsonDirect(url: string): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const request = (parsed.protocol === 'https:' ? httpsRequest : httpRequest)(parsed, (res) => { + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + res.resume(); + reject(new Error(`Failed to fetch CDP targets: HTTP ${statusCode}`)); + return; + } + + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + + request.on('error', reject); + request.setTimeout(10_000, () => request.destroy(new Error('Timed out fetching CDP targets'))); + request.end(); + }); +} diff --git a/src/browser/compound.test.ts b/src/browser/compound.test.ts new file mode 100644 index 0000000..87dae0c --- /dev/null +++ b/src/browser/compound.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + COMPOUND_INFO_JS, + COMPOUND_LABEL_CAP, + COMPOUND_SELECT_OPTIONS_CAP, + type CompoundInfo, +} from './compound.js'; + +/** + * Tests run the JS source in a sandbox via `new Function`, feeding it + * minimal mock elements shaped like the DOM elements the real code sees + * at runtime. Avoids a full jsdom setup while still exercising the logic + * end-to-end instead of only snapshotting string markers. + */ +function runCompound(mockEl: unknown): CompoundInfo | null { + const fn = new Function('el', `${COMPOUND_INFO_JS}\nreturn compoundInfoOf(el);`); + return fn(mockEl) as CompoundInfo | null; +} + +function mockInput(attrs: Record, extras: Partial<{ value: string; multiple: boolean; files: { name: string }[] }> = {}) { + return { + tagName: 'INPUT', + value: extras.value, + multiple: extras.multiple, + files: extras.files, + getAttribute(name: string) { + return attrs[name] ?? null; + }, + }; +} + +function mockSelect(options: { value: string; label?: string; text?: string; selected?: boolean; disabled?: boolean }[], multiple = false) { + const opts = options.map(o => ({ ...o, selected: !!o.selected })); + return { + tagName: 'SELECT', + multiple, + options: opts, + getAttribute: () => null, + }; +} + +describe('compoundInfoOf — date-like inputs', () => { + it('returns { control, format, current } for ', () => { + const info = runCompound(mockInput({ type: 'date' }, { value: '2026-04-21' })); + expect(info).toEqual({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21' }); + }); + + it('surfaces min + max when present', () => { + const info = runCompound(mockInput({ type: 'date', min: '2026-01-01', max: '2026-12-31' }, { value: '2026-04-21' })); + expect(info).toMatchObject({ min: '2026-01-01', max: '2026-12-31' }); + }); + + it('handles time / datetime-local / month / week with correct format strings', () => { + const formats: Record = { + time: 'HH:MM', + 'datetime-local': 'YYYY-MM-DDTHH:MM', + month: 'YYYY-MM', + week: 'YYYY-W##', + }; + for (const [type, fmt] of Object.entries(formats)) { + const info = runCompound(mockInput({ type }, { value: '' })) as { format: string }; + expect(info.format).toBe(fmt); + } + }); + + it('coerces null value into empty string instead of crashing', () => { + const info = runCompound(mockInput({ type: 'date' })); + expect(info).toMatchObject({ control: 'date', current: '' }); + }); +}); + +describe('compoundInfoOf — file inputs', () => { + it('returns { control: file, multiple, current[] }', () => { + const info = runCompound(mockInput({ type: 'file' }, { + multiple: true, + files: [{ name: 'a.png' }, { name: 'b.jpg' }], + })); + expect(info).toEqual({ control: 'file', multiple: true, current: ['a.png', 'b.jpg'] }); + }); + + it('includes accept when present', () => { + const info = runCompound(mockInput({ type: 'file', accept: 'image/*' }, { multiple: false })); + expect(info).toMatchObject({ control: 'file', accept: 'image/*' }); + }); + + it('returns empty current[] when nothing uploaded', () => { + const info = runCompound(mockInput({ type: 'file' }, { multiple: false })); + expect(info).toEqual({ control: 'file', multiple: false, current: [] }); + }); + + it('caps file name at COMPOUND_LABEL_CAP', () => { + const longName = 'x'.repeat(COMPOUND_LABEL_CAP + 50); + const info = runCompound(mockInput({ type: 'file' }, { multiple: false, files: [{ name: longName }] })) as { current: string[] }; + expect(info.current[0]!.length).toBe(COMPOUND_LABEL_CAP); + }); +}); + +describe('compoundInfoOf — select', () => { + it('returns full options list with labels, values, selected flag', () => { + const info = runCompound(mockSelect([ + { value: 'us', label: 'United States', selected: true }, + { value: 'ca', label: 'Canada' }, + { value: 'fr', label: 'France' }, + ])) as { options: Array<{ label: string; value: string; selected: boolean }> }; + expect(info.options).toHaveLength(3); + expect(info.options[0]).toEqual({ label: 'United States', value: 'us', selected: true }); + expect(info.options[2]).toEqual({ label: 'France', value: 'fr', selected: false }); + }); + + it('sets current to the selected label (single-select)', () => { + const info = runCompound(mockSelect([ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Bravo', selected: true }, + ])); + expect(info).toMatchObject({ control: 'select', multiple: false, current: 'Bravo' }); + }); + + it('sets current to an array of labels when multiple=true', () => { + const info = runCompound(mockSelect([ + { value: 'a', label: 'Alpha', selected: true }, + { value: 'b', label: 'Bravo' }, + { value: 'c', label: 'Charlie', selected: true }, + ], true)); + expect(info).toMatchObject({ control: 'select', multiple: true, current: ['Alpha', 'Charlie'] }); + }); + + it('falls back from option.label to option.text', () => { + const info = runCompound(mockSelect([ + { value: 'a', text: 'FromText' }, + { value: 'b', label: '', text: 'EmptyLabelFallback' }, + ])) as { options: Array<{ label: string }> }; + expect(info.options[0]!.label).toBe('FromText'); + expect(info.options[1]!.label).toBe('EmptyLabelFallback'); + }); + + it('marks disabled options', () => { + const info = runCompound(mockSelect([ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B', disabled: true }, + ])) as { options: Array<{ disabled?: boolean }> }; + expect(info.options[0]!.disabled).toBeUndefined(); + expect(info.options[1]!.disabled).toBe(true); + }); + + it('caps options[] at COMPOUND_SELECT_OPTIONS_CAP but keeps true options_total', () => { + const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({ + value: 'v' + i, + label: 'L' + i, + })); + const info = runCompound(mockSelect(big)) as { options: unknown[]; options_total: number }; + expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP); + expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25); + }); + + it('returns "" for current on single-select with no selected option', () => { + const info = runCompound(mockSelect([ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ])); + expect(info).toMatchObject({ current: '' }); + }); + + // Regression: the previous loop stopped walking options once it hit + // COMPOUND_SELECT_OPTIONS_CAP, so a long country dropdown with the + // selected country sitting at index 80 would be reported with current="". + // Agents then thought nothing was selected and picked another country. + it('populates current even when the selected option sits past the serialization cap', () => { + const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({ + value: 'v' + i, + label: 'L' + i, + selected: i === COMPOUND_SELECT_OPTIONS_CAP + 10, + })); + const info = runCompound(mockSelect(big)) as { current: string; options: unknown[]; options_total: number }; + expect(info.current).toBe('L' + (COMPOUND_SELECT_OPTIONS_CAP + 10)); + expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP); + expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25); + }); + + it('multi-select: current[] includes labels for selected options beyond the cap', () => { + const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 10 }, (_, i) => ({ + value: 'v' + i, + label: 'L' + i, + selected: i === 3 || i === COMPOUND_SELECT_OPTIONS_CAP + 5, + })); + const info = runCompound(mockSelect(big, true)) as { current: string[] }; + expect(info.current).toEqual(['L3', 'L' + (COMPOUND_SELECT_OPTIONS_CAP + 5)]); + }); +}); + +describe('compoundInfoOf — unsupported shapes', () => { + it('returns null for plain text input', () => { + expect(runCompound(mockInput({ type: 'text' }, { value: 'hi' }))).toBeNull(); + }); + + it('returns null for non-form tags', () => { + expect(runCompound({ tagName: 'DIV', getAttribute: () => null })).toBeNull(); + }); + + it('returns null for null / missing element', () => { + expect(runCompound(null)).toBeNull(); + expect(runCompound({} as unknown)).toBeNull(); + }); +}); diff --git a/src/browser/compound.ts b/src/browser/compound.ts new file mode 100644 index 0000000..cf36a1e --- /dev/null +++ b/src/browser/compound.ts @@ -0,0 +1,149 @@ +/** + * Compound-component expansion for high-agent-failure form controls. + * + * Agents burn turns on three recurring input categories because the raw + * attribute dump from `browser state` under-specifies them: + * + * - date / time / datetime-local / month / week — agents type + * free-form strings and the browser silently ignores mismatched formats. + * - select — the snapshot caps visible options at ~6; agents don't know + * the full option set, can't match by label, and waste turns clicking + * to open the dropdown just to read options. + * - file — the snapshot shows current filenames but not `accept` or + * `multiple`; agents re-upload or pick unsupported MIME types. + * + * `compoundInfoOf(el)` returns a structured JSON summary agents can rely + * on. Included in `browser find --css` envelope so the agent gets the + * rich view without extra round-trips. + * + * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined + * into the generated evaluate scripts under find / snapshot / eval. + */ + +export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week'; + +export interface DateCompound { + control: DateLikeControl; + format: string; + current: string; + min?: string; + max?: string; +} + +export interface SelectOption { + label: string; + value: string; + selected: boolean; + disabled?: boolean; +} + +export interface SelectCompound { + control: 'select'; + multiple: boolean; + current: string | string[]; + options: SelectOption[]; + options_total: number; +} + +export interface FileCompound { + control: 'file'; + multiple: boolean; + current: string[]; + accept?: string; +} + +export type CompoundInfo = DateCompound | SelectCompound | FileCompound; + +/** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */ +export const COMPOUND_SELECT_OPTIONS_CAP = 50; + +/** Max characters per option label / file name. */ +export const COMPOUND_LABEL_CAP = 80; + +/** + * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS + * emitted by `buildFindJs` (and any other evaluate script that needs the + * rich compound view). Returns a `CompoundInfo` object or `null`. + */ +export const COMPOUND_INFO_JS = ` +function compoundInfoOf(el) { + if (!el || !el.tagName) return null; + const tag = el.tagName; + const LABEL_CAP = ${COMPOUND_LABEL_CAP}; + const OPTS_CAP = ${COMPOUND_SELECT_OPTIONS_CAP}; + if (tag === 'INPUT') { + const type = (el.getAttribute('type') || 'text').toLowerCase(); + const FORMATS = { + 'date': 'YYYY-MM-DD', + 'time': 'HH:MM', + 'datetime-local': 'YYYY-MM-DDTHH:MM', + 'month': 'YYYY-MM', + 'week': 'YYYY-W##', + }; + if (FORMATS[type]) { + const info = { + control: type, + format: FORMATS[type], + current: (el.value == null ? '' : String(el.value)), + }; + const min = el.getAttribute('min'); + if (min) info.min = min; + const max = el.getAttribute('max'); + if (max) info.max = max; + return info; + } + if (type === 'file') { + const info = { + control: 'file', + multiple: !!el.multiple, + current: [], + }; + const accept = el.getAttribute('accept'); + if (accept) info.accept = accept; + try { + if (el.files && el.files.length) { + for (let i = 0; i < el.files.length; i++) { + const name = (el.files[i].name || '').slice(0, LABEL_CAP); + info.current.push(name); + } + } + } catch (_) {} + return info; + } + return null; + } + if (tag === 'SELECT') { + const multiple = !!el.multiple; + const options = []; + const selectedLabels = []; + let total = 0; + try { + const opts = el.options || []; + total = opts.length; + // Walk ALL options so \`current\` reflects selections that sit beyond the + // serialization cap. Only the first OPTS_CAP entries get pushed into + // options[]; anything past the cap still contributes to selectedLabels + // so agents see the true current state of big dropdowns. + for (let i = 0; i < opts.length; i++) { + const o = opts[i]; + const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || ''); + const label = String(labelRaw).trim().slice(0, LABEL_CAP); + if (i < OPTS_CAP) { + const entry = { label: label, value: o.value, selected: !!o.selected }; + if (o.disabled) entry.disabled = true; + options.push(entry); + } + if (o.selected) selectedLabels.push(label); + } + } catch (_) {} + return { + control: 'select', + multiple: multiple, + current: multiple ? selectedLabels : (selectedLabels[0] || ''), + options: options, + options_total: total, + }; + } + return null; +} +`; diff --git a/src/browser/config.ts b/src/browser/config.ts new file mode 100644 index 0000000..bbef98a --- /dev/null +++ b/src/browser/config.ts @@ -0,0 +1,15 @@ +import { log } from '../logger.js'; + +function parseEnvTimeout(envVar: string, fallback: number): number { + const raw = process.env[envVar]; + if (raw === undefined) return fallback; + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + log.warn(`[runtime] Invalid ${envVar}="${raw}", using default ${fallback}s`); + return fallback; + } + return parsed; +} + +export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_CONNECT_TIMEOUT', 45); +export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_COMMAND_TIMEOUT', 60); diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts new file mode 100644 index 0000000..71b6f96 --- /dev/null +++ b/src/browser/daemon-client.test.ts @@ -0,0 +1,696 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + BrowserCommandError, + clearDaemonRunContext, + fetchDaemonStatus, + getDaemonHealth, + isUnknownOutcomeError, + requestDaemonShutdown, + sendCommand, + setDaemonCommandTimeoutSeconds, + setDaemonRunContext, +} from './daemon-client.js'; +import { SessionBusyError } from '../errors.js'; +import * as daemonLifecycle from './daemon-lifecycle.js'; + +describe('daemon-client', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + setDaemonRunContext(null); + }); + + it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => { + const status = { + ok: true, + pid: 123, + uptime: 10, + extensionConnected: true, + extensionVersion: '1.2.3', + pending: 0, + memoryMB: 32, + port: 19825, + }; + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + await expect(fetchDaemonStatus()).resolves.toEqual(status); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/status$/), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-OpenCLI': '1' }), + }), + ); + }); + + it('fetchDaemonStatus returns null on network failure', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(fetchDaemonStatus()).resolves.toBeNull(); + }); + + it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ ok: true } as Response); + + await expect(requestDaemonShutdown()).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/shutdown$/), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'X-OpenCLI': '1' }), + }), + ); + }); + + it('getDaemonHealth returns stopped when daemon is not reachable', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null }); + }); + + it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => { + const status = { + ok: true, + pid: 123, + uptime: 10, + extensionConnected: false, + pending: 0, + memoryMB: 16, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status }); + }); + + it('getDaemonHealth returns ready when daemon and extension are both connected', async () => { + const status = { + ok: true, + pid: 123, + uptime: 10, + extensionConnected: true, + extensionVersion: '1.2.3', + pending: 0, + memoryMB: 32, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status }); + }); + + it('getDaemonHealth returns profile-required when multiple profiles are connected without a selection', async () => { + const status = { + ok: true, + pid: 123, + uptime: 10, + extensionConnected: false, + profileRequired: true, + profiles: [ + { contextId: 'work', extensionConnected: true, pending: 0 }, + { contextId: 'personal', extensionConnected: true, pending: 0 }, + ], + pending: 0, + memoryMB: 32, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + await expect(getDaemonHealth()).resolves.toEqual({ state: 'profile-required', status }); + }); + + it('fetchDaemonStatus includes contextId in the status query', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + ok: true, + pid: 1, + uptime: 0, + extensionConnected: true, + pending: 0, + memoryMB: 1, + port: 19825, + }), + } as Response); + + await fetchDaemonStatus({ contextId: 'work' }); + + expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/); + }); + + it('rejects OPENCLI_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => { + vi.resetModules(); + vi.stubEnv('OPENCLI_DAEMON_PORT', '19999'); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + ok: true, + pid: 1, + uptime: 0, + extensionConnected: true, + pending: 0, + memoryMB: 1, + port: 19825, + }), + } as Response); + + const freshClient = await import('./daemon-client.js'); + await expect(freshClient.fetchDaemonStatus()).rejects.toThrow('OPENCLI_DAEMON_PORT is no longer supported'); + + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('tolerates OPENCLI_DAEMON_PORT when it equals the default port (launchers inject it, #2068)', async () => { + vi.resetModules(); + vi.stubEnv('OPENCLI_DAEMON_PORT', '19825'); + const status = { + ok: true, + pid: 1, + uptime: 0, + extensionConnected: true, + pending: 0, + memoryMB: 1, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + const freshClient = await import('./daemon-client.js'); + await expect(freshClient.fetchDaemonStatus()).resolves.toEqual(status); + }); + + it('sendCommand includes the current pid in generated command ids', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok'); + await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok'); + + const ids = vi.mocked(fetch).mock.calls.map(([, init]) => { + const body = JSON.parse(String(init?.body)) as { id: string }; + return body.id; + }); + + expect(ids).toHaveLength(2); + expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); + expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('attaches the run context (runId/command/access) to every command as a lease heartbeat', async () => { + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + setDaemonRunContext({ runId: 'run_4242_1_a', command: 'chatgpt ask', access: 'write' }); + + await sendCommand('exec', { code: '1 + 1', surface: 'adapter', session: 'site:chatgpt', siteSession: 'persistent' }); + await sendCommand('exec', { code: '2 + 2', surface: 'adapter', session: 'site:chatgpt', siteSession: 'persistent' }); + + for (const call of vi.mocked(fetch).mock.calls) { + const body = JSON.parse(String(call[1]?.body)) as { runId?: string; command?: string; access?: string }; + expect(body.runId).toBe('run_4242_1_a'); + expect(body.command).toBe('chatgpt ask'); + expect(body.access).toBe('write'); + } + }); + + it('clearDaemonRunContext only clears the context that still belongs to the runId', async () => { + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + setDaemonRunContext({ runId: 'run_4242_1_a', command: 'chatgpt ask', access: 'write' }); + + // A stale run's deferred cleanup must not strip a different owner's context. + clearDaemonRunContext('run_9999_2_b'); + await sendCommand('exec', { code: '1 + 1', surface: 'adapter', session: 'site:chatgpt', siteSession: 'persistent' }); + const kept = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { runId?: string }; + expect(kept.runId).toBe('run_4242_1_a'); + + clearDaemonRunContext('run_4242_1_a'); + await sendCommand('exec', { code: '2 + 2', surface: 'adapter', session: 'site:chatgpt', siteSession: 'persistent' }); + const cleared = JSON.parse(String(vi.mocked(fetch).mock.calls[1][1]?.body)) as { runId?: string }; + expect(cleared.runId).toBeUndefined(); + }); + + it('omits run-context fields when no run context is set (read/ephemeral commands)', async () => { + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { runId?: string; access?: string }; + expect(body.runId).toBeUndefined(); + expect(body.access).toBeUndefined(); + }); + + it('throws a terminal SessionBusyError on a session_busy response without retrying', async () => { + vi.mocked(fetch).mockResolvedValue({ + status: 409, + json: () => Promise.resolve({ + id: 'server', + ok: false, + errorCode: 'session_busy', + error: 'Session "site:chatgpt" is busy: chatgpt ask (pid 111) has been driving it for 42s.', + errorHint: 'Wait for it to finish, or stop it with `kill 111` if it is stuck. Read-only commands are not blocked.', + }), + } as Response); + + await expect( + sendCommand('exec', { code: '1 + 1', surface: 'adapter', session: 'site:chatgpt', siteSession: 'persistent' }), + ).rejects.toBeInstanceOf(SessionBusyError); + // Terminal: no ensure-bridge / re-dispatch — exactly one request went out. + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); + + it('sendCommand forwards OPENCLI_PROFILE as a hard contextId requirement', async () => { + vi.stubEnv('OPENCLI_PROFILE', 'work'); + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string }; + expect(body.contextId).toBe('work'); + expect(body.preferredContextId).toBeUndefined(); + }); + + it('sendCommand forwards the config default as a soft preferredContextId, not a requirement', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-dc-profile-')); + fs.writeFileSync( + path.join(configDir, 'browser-profiles.json'), + JSON.stringify({ version: 1, aliases: {}, defaultContextId: 'zvypsyje' }), + ); + vi.stubEnv('OPENCLI_CONFIG_DIR', configDir); + vi.stubEnv('OPENCLI_PROFILE', ''); + try { + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string }; + expect(body.preferredContextId).toBe('zvypsyje'); + expect(body.contextId).toBeUndefined(); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it('sendCommand uses explicit windowMode before OPENCLI_WINDOW env fallback', async () => { + vi.stubEnv('OPENCLI_WINDOW', 'foreground'); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1', windowMode: 'background' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { windowMode?: string }; + expect(body.windowMode).toBe('background'); + }); + + it('sendCommand retries executor-transient errors ONCE with a NEW id (re-execution is a new logical attempt)', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: false, error: 'attach failed: interference', errorCode: 'attach_failed' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }), + } as Response); + + await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const ids = fetchMock.mock.calls.map(([, init]) => { + const body = JSON.parse(String(init?.body)) as { id: string }; + return body.id; + }); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('sendCommand does NOT retry mid-execution failures (detached_mid_command) — outcome is unknown', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: false, error: 'Detached while handling command', errorCode: 'detached_mid_command' }), + } as Response); + + await expect(sendCommand('exec', { code: 'submit()' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'detached_mid_command', + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + json: () => Promise.resolve({ + id: 'server', + ok: false, + errorCode: 'command_result_unknown', + error: 'Extension disconnected after command timeout', + errorHint: 'Inspect state before retrying.', + }), + } as Response); + + await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + hint: 'Inspect state before retrying.', + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + function mockEnsureReady(extensionVersion?: string) { + return vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady').mockResolvedValue({ + health: { + state: 'ready', + status: { + ok: true, + pid: 1, + uptime: 1, + extensionConnected: true, + ...(extensionVersion && { extensionVersion }), + pending: 0, + memoryMB: 0, + port: 19825, + }, + }, + spawnedProcess: null, + }); + } + + it('sendCommand runs full bridge ensure on a pre-dispatch failure, then resends the SAME id', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_321); + const ensureSpy = mockEnsureReady('1.0.22'); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.resolve({ + ok: false, + errorCode: 'extension_not_connected', + error: 'Extension not connected.', + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 7 }), + } as Response); + + await expect(sendCommand('exec', { code: '1 + 6', contextId: 'work' })).resolves.toBe(7); + + expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({ contextId: 'work', verbose: false })); + const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); + expect(ids).toHaveLength(2); + // Transport retries keep the id stable so the executor's journal can dedupe. + expect(ids[0]).toBe(ids[1]); + }); + + it('sendCommand runs full bridge ensure on a pre-connect TypeError (ECONNREFUSED) before resending', async () => { + const ensureSpy = mockEnsureReady(); + const refused = new TypeError('fetch failed'); + (refused as { cause?: unknown }).cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:19825'), { code: 'ECONNREFUSED' }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockRejectedValueOnce(refused) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await expect(sendCommand('exec', { code: 'document.title' })).resolves.toBe('ok'); + + expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({ verbose: false })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('sendCommand retries a post-connect drop with the SAME id when the extension journals ids', async () => { + mockEnsureReady('1.0.22'); + const reset = new TypeError('fetch failed'); + (reset as { cause?: unknown }).cause = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockRejectedValueOnce(reset) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'replayed' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).resolves.toBe('replayed'); + + const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(ids[1]); + }); + + it('sendCommand does NOT resend a post-connect drop when the extension predates the journal', async () => { + const ensureSpy = mockEnsureReady('1.0.21'); + const reset = new TypeError('fetch failed'); + (reset as { cause?: unknown }).cause = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + vi.mocked(fetch).mockRejectedValueOnce(reset); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + expect(ensureSpy).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('sendCommand retries daemon_shutting_down with the same id when the extension journals ids', async () => { + mockEnsureReady('1.0.22'); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.resolve({ ok: false, errorCode: 'daemon_shutting_down', error: 'Daemon shutting down before the command completed.' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'replayed' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).resolves.toBe('replayed'); + + const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(ids[1]); + }); + + it('sendCommand does NOT resend daemon_shutting_down on a legacy extension — outcome unknown', async () => { + mockEnsureReady('1.0.21'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.resolve({ ok: false, errorCode: 'daemon_shutting_down', error: 'Daemon shutting down before the command completed.' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sendCommand does NOT resend a bare TypeError on a legacy extension', async () => { + const ensureSpy = mockEnsureReady('1.0.15'); + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')); + + await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + expect(ensureSpy).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('sendCommand does NOT wait when the bridge reports profile_required', async () => { + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + json: () => Promise.resolve({ + ok: false, + errorCode: 'profile_required', + error: 'Multiple Browser Bridge profiles are connected; choose one with --profile.', + errorHint: 'Run opencli profile list, then opencli profile use .', + }), + } as Response); + + await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'profile_required', + } satisfies Partial); + + expect(ensureSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sendCommand plumbs the default command timeout into body.timeout', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 1 }), + } as Response); + + await expect(sendCommand('exec', { code: '1' })).resolves.toBe(1); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + expect(body.timeout).toBe(120); + }); + + it('sendCommand extends body.timeout past an extension-side timeoutMs (wait-download)', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: { downloaded: true } }), + } as Response); + + await sendCommand('wait-download', { timeoutMs: 240_000 }); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + // 240s extension wait + 15s margin + expect(body.timeout).toBe(255); + }); + + it('setDaemonCommandTimeoutSeconds raises the transport deadline for the user --timeout', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 1 }), + } as Response); + + setDaemonCommandTimeoutSeconds(300); + try { + await sendCommand('exec', { code: '1' }); + } finally { + setDaemonCommandTimeoutSeconds(null); + } + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + expect(body.timeout).toBe(300); + }); + + it('client HTTP abort fires only after the daemon timer margin (timeout*1000 + 10s)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + let aborted = false; + fetchMock.mockImplementationOnce((_url, init) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' })); + }); + })); + + const pending = sendCommand('exec', { code: '1' }); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + // Just before the margin the daemon still owns the deadline — no abort. + await vi.advanceTimersByTimeAsync(120_000 + 9_999); + expect(aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(aborted).toBe(true); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it('sendCommand surfaces an AbortError as command_result_unknown without ensure or resend', async () => { + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + const abortErr = new Error('The operation was aborted'); + abortErr.name = 'AbortError'; + vi.mocked(fetch).mockRejectedValueOnce(abortErr); + + await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + expect(ensureSpy).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('isUnknownOutcomeError', () => { + it('is true for each unknown-outcome code', () => { + for (const code of ['command_result_unknown', 'command_lost', 'result_evicted']) { + expect(isUnknownOutcomeError(new BrowserCommandError('unknown', code))).toBe(true); + } + }); + + it('is false for an ordinary failure code and for a success (no error)', () => { + expect(isUnknownOutcomeError(new BrowserCommandError('boom', 'attach_failed'))).toBe(false); + expect(isUnknownOutcomeError(new Error('plain failure'))).toBe(false); + expect(isUnknownOutcomeError(undefined)).toBe(false); + expect(isUnknownOutcomeError(null)).toBe(false); + }); + + it('unwraps an unknown-outcome error nested in a cause chain', () => { + const wrapped = new Error('adapter failed', { cause: new BrowserCommandError('lost', 'command_lost') }); + expect(isUnknownOutcomeError(wrapped)).toBe(true); + }); +}); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts new file mode 100644 index 0000000..9d7bffc --- /dev/null +++ b/src/browser/daemon-client.ts @@ -0,0 +1,502 @@ +/** + * HTTP client for communicating with the opencli daemon. + * + * Provides a typed send() function that posts a Command and returns a Result. + */ + +import { sleep } from '../utils.js'; +import { BrowserConnectError, SessionBusyError } from '../errors.js'; +import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js'; +import { classifyBrowserError } from './errors.js'; +import { profileRouteParams, resolveProfileSelection } from './profile.js'; +import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js'; +import { ensureBrowserBridgeReady } from './daemon-lifecycle.js'; +import { isPreDispatchError } from './bridge-readiness.js'; +import { + fetchDaemonStatus, + getDaemonHealth, + requestDaemon, + requestDaemonShutdown, + type BrowserProfileStatus, + type DaemonHealth, + type DaemonStatus, +} from './daemon-transport.js'; + +let _idCounter = 0; + +function generateId(): string { + return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`; +} + +/** + * Id for a whole logical CLI command run. Encodes the CLI pid so the daemon can + * name/kill the holder in a busy error (see parsePidFromRunId in session-lease.ts). + */ +export function generateRunId(): string { + return `run_${process.pid}_${Date.now()}_${++_idCounter}`; +} + +/** + * Identity of the CLI command run currently driving the browser, attached to + * every command so the daemon can arbitrate the write lease. Module-level and + * one-per-invocation, matching `setDaemonCommandTimeoutSeconds`. Null for read + * and ephemeral commands, which are never lease-arbitrated. + */ +export interface DaemonRunContext { + runId: string; + command: string; + access: 'read' | 'write'; +} + +let _runContext: DaemonRunContext | null = null; + +export function setDaemonRunContext(ctx: DaemonRunContext | null): void { + _runContext = ctx; +} + +/** + * Clear the run context only if it still belongs to `runId`. Used by deferred + * cleanup (an adapter that outlived its CLI timeout settles later): by then a + * newer run may own the context, and unconditionally nulling it would strip + * that run's lease heartbeats mid-flight. + */ +export function clearDaemonRunContext(runId: string): void { + if (_runContext?.runId === runId) _runContext = null; +} + +/** + * Best-effort release of a persistent site-session lease on command completion. + * A direct one-shot POST (no bridge ensure / retry): if it never lands, the + * daemon's TTL reclaims the lease anyway, so this must never block the caller. + */ +export async function releaseSiteSessionLease(params: { + runId: string; + session: string; + surface: 'adapter'; +}): Promise { + try { + await requestDaemon('/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: generateId(), + action: 'lease-release', + runId: params.runId, + session: params.session, + surface: params.surface, + }), + timeout: 2000, + }); + } catch { + // Best-effort: TTL expiry reclaims the lease if the daemon is unreachable. + } +} + +/** + * Transport-level deadlines share one source of truth: `body.timeout` (seconds). + * The daemon arms its per-command timer from it, the extension derives its CDP + * deadline from the same value, and the client HTTP abort fires only after the + * daemon's structured timeout response should have arrived — so failures + * surface innermost-first (extension < daemon < client) with a real error + * instead of an opaque client-side AbortError. + */ +const DEFAULT_COMMAND_TIMEOUT_SECONDS = 120; +/** Headroom past an extension-side operation's own timer (e.g. wait-download). */ +const EXTENSION_OP_TIMEOUT_MARGIN_MS = 15_000; +/** Client aborts only this long after the daemon timer should have fired. */ +const HTTP_TIMEOUT_MARGIN_MS = 10_000; + +let _userCommandTimeoutSeconds: number | null = null; + +/** + * Propagate the user's `--timeout` down to the transport layer. Without this + * the daemon/HTTP deadlines stay at their defaults and a long-running command + * gets aborted mid-flight even though the user explicitly allowed more time. + */ +export function setDaemonCommandTimeoutSeconds(seconds: number | null): void { + _userCommandTimeoutSeconds = typeof seconds === 'number' && seconds > 0 ? Math.ceil(seconds) : null; +} + +function effectiveCommandTimeoutSeconds(params: Omit): number { + const base = _userCommandTimeoutSeconds ?? DEFAULT_COMMAND_TIMEOUT_SECONDS; + if (typeof params.timeoutMs === 'number' && params.timeoutMs > 0) { + return Math.max(base, Math.ceil((params.timeoutMs + EXTENSION_OP_TIMEOUT_MARGIN_MS) / 1000)); + } + return base; +} + +/** + * First extension version with the command journal (see extension/src/ + * journal.ts). From this version on, re-sending the SAME command id is safe: + * the executor replays the recorded result instead of re-executing. + */ +const MIN_JOURNAL_EXTENSION_VERSION = '1.0.22'; + +function versionAtLeast(version: string | null | undefined, min: string): boolean { + if (!version) return false; + const a = version.replace(/^v/, '').split('-')[0].split('.').map(Number); + const b = min.split('.').map(Number); + if (a.length < 3 || a.some(Number.isNaN)) return false; + for (let i = 0; i < 3; i++) { + const d = a[i] - (b[i] ?? 0); + if (d !== 0) return d > 0; + } + return true; +} + +/** Error codes meaning the executor's outcome is genuinely unknown — never auto-retry. */ +const UNKNOWN_OUTCOME_CODES = new Set(['command_result_unknown', 'command_lost', 'result_evicted']); + +/** + * True when a thrown error carries an unknown-outcome code — the browser-side + * command may still be running even though the client gave up. Callers use this + * to decide whether it is safe to release a persistent site-session lease: it is + * not, because the still-running command keeps mutating the tab. Walks the cause + * chain so a wrapped `BrowserCommandError` is still recognized. + */ +export function isUnknownOutcomeError(err: unknown): boolean { + const seen = new Set(); + let current: unknown = err; + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current); + const code = (current as { code?: unknown }).code; + if (typeof code === 'string' && UNKNOWN_OUTCOME_CODES.has(code)) return true; + current = (current as { cause?: unknown }).cause; + } + return false; +} + +/** Max transport attempts for one logical command (same id throughout). */ +const TRANSPORT_MAX_ATTEMPTS = 4; + +/** + * undici surfaces network failures as `TypeError: fetch failed` with the real + * error in `.cause` (possibly an AggregateError). Only failures that happen + * before the request could reach the daemon are safe to auto-retry — a reset + * or hang-up after connect means the daemon may have already dispatched the + * command to the browser. + */ +const PRE_CONNECT_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'UND_ERR_CONNECT_TIMEOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', +]); + +function isPreConnectFetchError(err: unknown): boolean { + const queue: unknown[] = [err]; + const seen = new Set(); + while (queue.length) { + const current = queue.pop(); + if (!current || typeof current !== 'object' || seen.has(current)) continue; + seen.add(current); + const { code, cause, errors } = current as { code?: unknown; cause?: unknown; errors?: unknown }; + if (typeof code === 'string' && PRE_CONNECT_ERROR_CODES.has(code)) return true; + if (cause) queue.push(cause); + if (Array.isArray(errors)) queue.push(...errors); + } + return false; +} + +export interface DaemonCommand { + id: string; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames' | 'lease-release'; + /** Target page identity (targetId). Cross-layer contract with the extension. */ + page?: string; + code?: string; + session?: string; + surface?: 'browser' | 'adapter'; + /** Adapter site session lifecycle. Persistent site sessions do not idle-expire. */ + siteSession?: 'ephemeral' | 'persistent'; + url?: string; + op?: string; + index?: number; + domain?: string; + format?: 'png' | 'jpeg'; + quality?: number; + fullPage?: boolean; + /** Override viewport width in CSS pixels for screenshot (0 / undefined = use current) */ + width?: number; + /** Override viewport height in CSS pixels for screenshot (0 / undefined = use current; ignored when fullPage) */ + height?: number; + + /** Local file paths for set-file-input action */ + files?: string[]; + /** CSS selector for file input element (set-file-input action) */ + selector?: string; + /** Raw text payload for insert-text action */ + text?: string; + /** URL substring filter pattern for network capture */ + pattern?: string; + /** Download wait timeout in milliseconds */ + timeoutMs?: number; + cdpMethod?: string; + cdpParams?: Record; + /** Window foreground/background policy for owned Browser Bridge containers. */ + windowMode?: 'foreground' | 'background'; + /** Custom idle timeout in seconds for this session. Overrides the default. */ + idleTimeout?: number; + /** Frame index for cross-frame operations (0-based, from 'frames' action) */ + frameIndex?: number; + /** Browser profile/context REQUIRED for this command (--profile / OPENCLI_PROFILE). Fails loud when offline. */ + contextId?: string; + /** + * Browser profile/context PREFERRED for this command (persisted config + * default). The daemon uses it when connected, and falls back to the only + * connected profile when it is not — a stale default must never veto live + * reality. Mutually exclusive with `contextId`. + */ + preferredContextId?: string; + /** + * Daemon-side command timeout in seconds. Set by the transport layer from + * the effective command deadline; kept for older daemons — new code prefers + * `deadlineAt`. + */ + timeout?: number; + /** + * Absolute command deadline (epoch ms). All hops run on one machine, so + * every layer derives its remaining budget as `deadlineAt - Date.now()`, + * absorbing queueing and service-worker wake latency. + */ + deadlineAt?: number; + /** + * Stable id for the whole logical CLI command run (NOT the per-exec `id`). + * The daemon uses it to arbitrate a write lease on the persistent site + * session: the first command acquires, same-runId execs refresh it as a + * heartbeat, a concurrent different-runId write fails fast. See session-lease.ts. + */ + runId?: string; + /** Human command name (e.g. `chatgpt ask`) surfaced in the busy error. */ + command?: string; + /** Command access level; only 'write' commands take/hold a session lease. */ + access?: 'read' | 'write'; +} + +export interface DaemonResult { + id: string; + ok: boolean; + data?: unknown; + error?: string; + errorCode?: string; + errorHint?: string; + /** Page identity (targetId) — present on page-scoped command responses */ + page?: string; +} + +export class BrowserCommandError extends Error { + constructor(message: string, readonly code?: string, readonly hint?: string) { + super(message); + this.name = 'BrowserCommandError'; + } +} + +export { + fetchDaemonStatus, + getDaemonHealth, + requestDaemonShutdown, + type BrowserProfileStatus, + type DaemonHealth, + type DaemonStatus, +}; + +/** + * Internal: send a command to the daemon and return the raw `DaemonResult`. + * + * There are exactly two retry classes, with different id semantics: + * + * TRANSPORT retries — the SAME command id, so the executor's journal replays + * an already-executed command instead of re-running it: + * - fetch failures (daemon down/replaced/crashed): run the ensure path (which + * also spawns the daemon and tells us the extension version), then resend. + * Requires a journaling extension unless the failure was pre-connect; + * - pre-dispatch bridge/profile errors and `daemon_shutting_down`; + * - a duplicate id landing on a still-pending command attaches to it in the + * daemon (no re-dispatch), so same-id resends never double-execute. + * + * SEMANTIC retry — ONE new logical attempt with a NEW id, only for executor + * errors that happened before any page code ran (`attach_failed`/`tab_gone`). + * `target_navigated` is the page layer's decision, not ours. + * + * Never retried: `command_result_unknown` / `command_lost` / `result_evicted` + * (the outcome is genuinely unknown) and client-side AbortError (the shared + * deadline is already exhausted). + */ +async function sendCommandRaw( + action: DaemonCommand['action'], + params: Omit, +): Promise { + const timeoutSeconds = effectiveCommandTimeoutSeconds(params); + const deadlineAt = Date.now() + timeoutSeconds * 1000; + const rawWindowMode = process.env.OPENCLI_WINDOW; + const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background' + ? rawWindowMode + : undefined; + // Requirement vs preference: an explicit contextId routes strictly; a + // preferred one is arbitrated by the daemon against live connections. + const routing = params.contextId || params.preferredContextId + ? { contextId: params.contextId, preferredContextId: params.preferredContextId } + : profileRouteParams(resolveProfileSelection()); + const contextId = routing.contextId; + const preferredContextId = routing.preferredContextId; + const windowMode = params.windowMode ?? envWindowMode; + + let id = generateId(); + let ensureUsed = false; + let semanticRetryUsed = false; + let executorJournaled: boolean | null = null; + + const ensureBridge = async (): Promise => { + // Bound the connect wait by the command's remaining budget so repeated + // daemon failures cannot stretch the total wall time far past --timeout. + const remainingSeconds = Math.ceil((deadlineAt - Date.now()) / 1000); + const ready = await ensureBrowserBridgeReady({ + timeoutSeconds: Math.max(1, Math.min(DEFAULT_BROWSER_CONNECT_TIMEOUT, remainingSeconds)), + // Only an explicit requirement pins readiness to a specific profile — + // waiting for a stale preferred profile to come back would hang the + // ensure path even though the daemon can already serve the command. + contextId, + verbose: false, + }); + executorJournaled = versionAtLeast(ready.health.status?.extensionVersion, MIN_JOURNAL_EXTENSION_VERSION); + }; + + for (let attempt = 1; attempt <= TRANSPORT_MAX_ATTEMPTS; attempt++) { + if (attempt > 1 && Date.now() >= deadlineAt) { + throw new BrowserCommandError( + 'Browser command deadline exhausted across transport retries.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + const remainingMs = Math.max(1000, deadlineAt - Date.now()); + const command: DaemonCommand = { + id, + action, + ...params, + timeout: timeoutSeconds, + deadlineAt, + ...(contextId && { contextId }), + ...(preferredContextId && { preferredContextId }), + ...(windowMode && { windowMode }), + // Carry the run identity so the daemon can acquire/refresh the write + // lease on the persistent site session. The same runId across every exec + // of one command is the heartbeat that keeps a long-running holder alive. + ...(_runContext && { + runId: _runContext.runId, + command: _runContext.command, + access: _runContext.access, + }), + }; + try { + const res = await requestDaemon('/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + timeout: remainingMs + HTTP_TIMEOUT_MARGIN_MS, + }); + + const result = (await res.json()) as DaemonResult; + + if (result.ok) return result; + + // A second concurrent write on the same persistent site session is + // rejected before the daemon dispatches anything — terminal, never + // retried, and surfaced as a CliError so the busy message is the output. + if (result.errorCode === 'session_busy') { + throw new SessionBusyError(result.error ?? 'The site session is busy.', result.errorHint); + } + + if (result.errorCode && UNKNOWN_OUTCOME_CODES.has(result.errorCode)) { + throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint); + } + + if (isPreDispatchError(result.errorCode) && !ensureUsed) { + // Never dispatched — resending the same id is safe on any extension. + ensureUsed = true; + await ensureBridge(); + continue; + } + + if (result.errorCode === 'daemon_shutting_down' && !ensureUsed) { + // The command WAS dispatched and the daemon died before the result + // came back. Resending the same id is only safe when the extension + // journals ids; otherwise the outcome is genuinely unknown. + ensureUsed = true; + await ensureBridge(); + if (executorJournaled) continue; + throw new BrowserCommandError( + result.error ?? 'Daemon shut down mid-command; the command may have already been applied.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + + const advice = classifyBrowserError(new BrowserCommandError(result.error ?? '', result.errorCode)); + if (advice.kind === 'extension-transient' && !semanticRetryUsed) { + semanticRetryUsed = true; + id = generateId(); + await sleep(advice.delayMs); + continue; + } + + throw new BrowserCommandError(result.error ?? 'Daemon command failed', result.errorCode, result.errorHint); + } catch (err) { + if (err instanceof BrowserCommandError || err instanceof BrowserConnectError || err instanceof SessionBusyError) throw err; + + if (err instanceof Error && err.name === 'AbortError') { + throw new BrowserCommandError( + 'Browser command timed out client-side; the page may still have applied it.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + + if (err instanceof TypeError) { + // Transport failure — the request may or may not have reached the + // daemon. Bring the bridge back up (spawns a daemon if none is + // running) and learn whether the extension journals command ids. + await ensureBridge(); + // Same-id resend is safe when the request never connected, or when + // the executor dedupes ids. Otherwise the outcome is unknown. + if (executorJournaled || isPreConnectFetchError(err)) continue; + throw new BrowserCommandError( + 'Connection to the daemon was lost mid-command; it may have already been applied.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + + throw err; + } + } + + throw new BrowserCommandError('sendCommand: max attempts exhausted', 'max_attempts_exhausted'); +} + +/** + * Send a command to the daemon and return the result data. + */ +export async function sendCommand( + action: DaemonCommand['action'], + params: Omit = {}, +): Promise { + const result = await sendCommandRaw(action, params); + return result.data; +} + +/** + * Like sendCommand, but returns both data and page identity (targetId). + * Use this for page-scoped commands where the caller needs the page identity. + */ +export async function sendCommandFull( + action: DaemonCommand['action'], + params: Omit = {}, +): Promise<{ data: unknown; page?: string }> { + const result = await sendCommandRaw(action, params); + return { data: result.data, page: result.page }; +} + +export async function bindTab(session: string, opts: { contextId?: string } = {}): Promise { + return sendCommand('bind', { session, surface: 'browser', ...opts }); +} diff --git a/src/browser/daemon-lifecycle.ts b/src/browser/daemon-lifecycle.ts new file mode 100644 index 0000000..6123199 --- /dev/null +++ b/src/browser/daemon-lifecycle.ts @@ -0,0 +1,204 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { DEFAULT_DAEMON_PORT } from '../constants.js'; +import { BrowserConnectError } from '../errors.js'; +import { PKG_VERSION } from '../version.js'; +import { waitForBridgeReady } from './bridge-readiness.js'; +import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type DaemonHealth, type DaemonStatus } from './daemon-transport.js'; + +export interface DaemonLaunchSpec { + binary: string; + args: string[]; + scriptPath: string; +} + +export interface DaemonRestartResult { + previousStatus: DaemonStatus | null; + status: DaemonStatus | null; + stopped: boolean; + spawned: boolean; +} + +export interface EnsureBrowserBridgeReadyResult { + health: DaemonHealth; + spawnedProcess: ChildProcess | null; +} + +export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const parentDir = path.resolve(__dirname, '..'); + const daemonTs = path.join(parentDir, 'daemon.ts'); + const daemonJs = path.join(parentDir, 'daemon.js'); + const isTs = fs.existsSync(daemonTs); + const scriptPath = isTs ? daemonTs : daemonJs; + return { + binary: process.execPath, + args: isTs ? ['--import', 'tsx/esm', scriptPath] : [scriptPath], + scriptPath, + }; +} + +export function spawnDaemonProcess(): ChildProcess { + const launch = resolveDaemonLaunchSpec(); + const proc = spawn(launch.binary, launch.args, { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + proc.unref(); + return proc; +} + +export async function waitForDaemonStop(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(200); + const h = await getDaemonHealth(); + if (h.state === 'stopped') return true; + } + return false; +} + +export async function waitForDaemonStatus(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await fetchDaemonStatus({ timeout: Math.min(1000, Math.max(100, deadline - Date.now())) }); + if (status) return status; + await sleep(200); + } + return null; +} + +export const daemonLifecycleHooks = { + requestDaemonShutdown, + spawnDaemonProcess, + waitForDaemonStop, +}; + +export async function restartDaemon(opts: { stopTimeoutMs?: number; startTimeoutMs?: number } = {}): Promise { + const previousStatus = await fetchDaemonStatus(); + let stopped = previousStatus === null; + if (previousStatus) { + const shutdownAccepted = await requestDaemonShutdown(); + stopped = shutdownAccepted && await waitForDaemonStop(opts.stopTimeoutMs ?? 3000); + if (!stopped) { + return { previousStatus, status: previousStatus, stopped: false, spawned: false }; + } + } + + spawnDaemonProcess(); + const status = await waitForDaemonStatus(opts.startTimeoutMs ?? 5000); + return { previousStatus, status, stopped, spawned: true }; +} + +export async function ensureBrowserBridgeReady( + opts: { timeoutSeconds?: number; contextId?: string; verbose?: boolean } = {}, +): Promise { + const timeoutSeconds = opts.timeoutSeconds && opts.timeoutSeconds > 0 ? opts.timeoutSeconds : 10; + const timeoutMs = timeoutSeconds * 1000; + const verbose = opts.verbose ?? true; + const contextId = opts.contextId; + + const health = await getDaemonHealth({ contextId }); + const daemonVersion = health.status?.daemonVersion; + const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION); + let staleDaemonReplaced = false; + let spawnedProcess: ChildProcess | null = null; + + if (isStale) { + const reason = daemonVersion + ? `v${daemonVersion} ≠ v${PKG_VERSION}` + : `pre-version daemon, CLI is v${PKG_VERSION}`; + if (verbose && (process.env.OPENCLI_VERBOSE || process.stderr.isTTY)) { + process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`); + } + const shutdownAccepted = await daemonLifecycleHooks.requestDaemonShutdown(); + let portReleased = shutdownAccepted && await daemonLifecycleHooks.waitForDaemonStop(3000); + + if (!portReleased) { + const stalePid = health.status?.pid; + if (typeof stalePid === 'number' && Number.isInteger(stalePid) && stalePid > 0) { + try { + process.kill(stalePid, 'SIGKILL'); + } catch { + // EPERM / ESRCH are both resolved by polling the fixed daemon port. + } + portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000); + } + } + + if (!portReleased) { + throw new BrowserConnectError( + 'Stale daemon could not be replaced', + `A stale daemon (${reason}) is running but did not shut down (graceful + SIGKILL both failed).\n` + + ' Run manually: opencli daemon stop', + 'daemon-not-running', + ); + } + staleDaemonReplaced = true; + } + + if (!staleDaemonReplaced && health.state === 'ready') { + return { health, spawnedProcess }; + } + + if (!staleDaemonReplaced && health.state === 'profile-required') { + throw browserConnectErrorFromHealth(health, contextId); + } + + if (staleDaemonReplaced || health.state === 'stopped') { + if (verbose && (process.env.OPENCLI_VERBOSE || process.stderr.isTTY)) { + process.stderr.write('⏳ Starting daemon...\n'); + } + spawnedProcess = daemonLifecycleHooks.spawnDaemonProcess(); + } else if (verbose && (process.env.OPENCLI_VERBOSE || process.stderr.isTTY)) { + process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n'); + process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n'); + } + + const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId }); + if (finalHealth.state === 'ready') return { health: finalHealth, spawnedProcess }; + throw browserConnectErrorFromHealth(finalHealth, contextId); +} + +function browserConnectErrorFromHealth(health: DaemonHealth, contextId?: string): BrowserConnectError { + if (health.state === 'profile-required') { + return new BrowserConnectError( + 'Multiple Browser Bridge profiles are connected', + 'Select one with --profile , OPENCLI_PROFILE=, or opencli profile use .\n' + + 'Run opencli profile list to see connected profiles.', + 'profile-required', + ); + } + if (health.state === 'profile-disconnected') { + const label = contextId ?? health.status.contextId ?? 'unknown'; + return new BrowserConnectError( + `Browser profile "${label}" is not connected`, + 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use .', + 'profile-disconnected', + ); + } + if (health.state === 'no-extension') { + return new BrowserConnectError( + 'Browser Bridge extension not connected', + 'Make sure Chrome/Chromium is open and the OpenCLI extension is enabled.\n' + + 'If not installed:\n' + + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', + 'extension-not-connected', + ); + } + return new BrowserConnectError( + 'Failed to start opencli daemon', + `Try running manually:\n node ${resolveDaemonLaunchSpec().scriptPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, + 'daemon-not-running', + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export { DEFAULT_DAEMON_PORT }; diff --git a/src/browser/daemon-transport.ts b/src/browser/daemon-transport.ts new file mode 100644 index 0000000..2fabcb8 --- /dev/null +++ b/src/browser/daemon-transport.ts @@ -0,0 +1,98 @@ +import { DEFAULT_DAEMON_PORT, isIgnorableDaemonPortEnv, unsupportedDaemonPortEnvMessage } from '../constants.js'; + +const DAEMON_PORT = DEFAULT_DAEMON_PORT; +const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; +const OPENCLI_HEADERS = { 'X-OpenCLI': '1' }; + +class UnsupportedDaemonPortEnvError extends Error { + constructor(value: string) { + super(unsupportedDaemonPortEnvMessage(value)); + this.name = 'UnsupportedDaemonPortEnvError'; + } +} + +function assertSupportedDaemonPortEnv(): void { + const value = process.env.OPENCLI_DAEMON_PORT; + if (!isIgnorableDaemonPortEnv(value)) throw new UnsupportedDaemonPortEnvError(value!); +} + +export interface DaemonStatus { + ok: boolean; + pid: number; + uptime: number; + daemonVersion?: string; + extensionConnected: boolean; + extensionVersion?: string; + extensionCompatRange?: string; + contextId?: string; + profileRequired?: boolean; + profileDisconnected?: boolean; + profiles?: BrowserProfileStatus[]; + pending: number; + commandResultUnknown?: number; + memoryMB: number; + port: number; +} + +export interface BrowserProfileStatus { + contextId: string; + extensionConnected: boolean; + extensionVersion?: string; + extensionCompatRange?: string; + pending: number; + lastSeenAt?: number; +} + +export type DaemonHealth = + | { state: 'stopped'; status: null } + | { state: 'no-extension'; status: DaemonStatus } + | { state: 'profile-required'; status: DaemonStatus } + | { state: 'profile-disconnected'; status: DaemonStatus } + | { state: 'ready'; status: DaemonStatus }; + +export async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise { + assertSupportedDaemonPortEnv(); + const { timeout = 2000, headers, ...rest } = init ?? {}; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(`${DAEMON_URL}${pathname}`, { + ...rest, + headers: { ...OPENCLI_HEADERS, ...headers }, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + +export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string }): Promise { + try { + const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : ''; + const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 }); + if (!res.ok) return null; + return await res.json() as DaemonStatus; + } catch (err) { + if (err instanceof UnsupportedDaemonPortEnvError) throw err; + return null; + } +} + +export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string }): Promise { + const status = await fetchDaemonStatus(opts); + if (!status) return { state: 'stopped', status: null }; + if (status.profileRequired) return { state: 'profile-required', status }; + if (status.profileDisconnected) return { state: 'profile-disconnected', status }; + if (!status.extensionConnected) return { state: 'no-extension', status }; + return { state: 'ready', status }; +} + +export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise { + try { + const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 }); + return res.ok; + } catch (err) { + if (err instanceof UnsupportedDaemonPortEnvError) throw err; + return false; + } +} diff --git a/src/browser/daemon-version.ts b/src/browser/daemon-version.ts new file mode 100644 index 0000000..0d941d3 --- /dev/null +++ b/src/browser/daemon-version.ts @@ -0,0 +1,15 @@ +import type { DaemonStatus } from './daemon-transport.js'; + +export function isDaemonStale(status: Pick | null | undefined, cliVersion?: string): boolean { + if (!status || !cliVersion) return false; + return !status.daemonVersion || status.daemonVersion !== cliVersion; +} + +export function formatDaemonVersion(status: Pick | null | undefined): string { + return status?.daemonVersion ? `v${status.daemonVersion}` : 'version unknown'; +} + +export function staleDaemonIssue(status: Pick | null | undefined, cliVersion: string): string { + return `Stale daemon detected: daemon ${formatDaemonVersion(status)} != CLI v${cliVersion}.\n` + + ' Run: opencli daemon restart'; +} diff --git a/src/browser/dom-helpers.test.ts b/src/browser/dom-helpers.test.ts new file mode 100644 index 0000000..6f2ce4e --- /dev/null +++ b/src/browser/dom-helpers.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { autoScrollJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js'; + +describe('autoScrollJs', () => { + it('returns early without error when document.body is null', async () => { + const g = globalThis as unknown as Record; + const origDoc = g.document; + g.document = { body: null, documentElement: {} }; + g.window = g; + const code = autoScrollJs(3, 500); + // Should resolve without throwing + await expect(eval(code)).resolves.not.toThrow(); + g.document = origDoc; + delete g.window; + }); +}); + +describe('waitForCaptureJs', () => { + it('returns a non-empty string', () => { + const code = waitForCaptureJs(1000); + expect(typeof code).toBe('string'); + expect(code.length).toBeGreaterThan(0); + expect(code).toContain('__opencli_xhr'); + expect(code).toContain('resolve'); + expect(code).toContain('reject'); + }); + + it('resolves "captured" when __opencli_xhr is populated before deadline', async () => { + const g = globalThis as unknown as Record; + const captured: unknown[] = []; + g.__opencli_xhr = captured; + g.window = g; // stub window for Node eval + const code = waitForCaptureJs(1000); + const promise = eval(code) as Promise; + captured.push({ data: 'test' }); + await expect(promise).resolves.toBe('captured'); + delete g.__opencli_xhr; + delete g.window; + }); + + it('rejects when __opencli_xhr stays empty past deadline', async () => { + const g = globalThis as unknown as Record; + g.__opencli_xhr = []; + g.window = g; + const code = waitForCaptureJs(50); // 50ms timeout + const promise = eval(code) as Promise; + await expect(promise).rejects.toThrow('No network capture within 0.05s'); + delete g.__opencli_xhr; + delete g.window; + }); + + it('resolves immediately when __opencli_xhr already has data', async () => { + const g = globalThis as unknown as Record; + g.__opencli_xhr = [{ data: 'already here' }]; + g.window = g; + const code = waitForCaptureJs(1000); + await expect(eval(code) as Promise).resolves.toBe('captured'); + delete g.__opencli_xhr; + delete g.window; + }); +}); + +describe('waitForSelectorJs', () => { + it('returns a non-empty string', () => { + const code = waitForSelectorJs('#app', 1000); + expect(typeof code).toBe('string'); + expect(code).toContain('#app'); + expect(code).toContain('querySelector'); + expect(code).toContain('MutationObserver'); + }); + + it('resolves "found" immediately when selector already present', async () => { + const g = globalThis as unknown as Record; + const fakeEl = { tagName: 'DIV' }; + g.document = { querySelector: (_: string) => fakeEl }; + const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000); + await expect(eval(code) as Promise).resolves.toBe('found'); + delete g.document; + }); + + it('resolves "found" when selector appears after DOM mutation', async () => { + const g = globalThis as unknown as Record; + let mutationCallback!: () => void; + g.MutationObserver = class { + constructor(cb: () => void) { mutationCallback = cb; } + observe() {} + disconnect() {} + }; + let calls = 0; + g.document = { + querySelector: (_: string) => (calls++ > 0 ? { tagName: 'DIV' } : null), + body: {}, + }; + const code = waitForSelectorJs('#app', 1000); + const promise = eval(code) as Promise; + mutationCallback(); // simulate DOM mutation + await expect(promise).resolves.toBe('found'); + delete g.document; + delete g.MutationObserver; + }); + + it('rejects when selector never appears within timeout', async () => { + const g = globalThis as unknown as Record; + g.MutationObserver = class { + constructor(_cb: () => void) {} + observe() {} + disconnect() {} + }; + g.document = { querySelector: (_: string) => null, body: {} }; + const code = waitForSelectorJs('#missing', 50); + await expect(eval(code) as Promise).rejects.toThrow('Selector not found: #missing'); + delete g.document; + delete g.MutationObserver; + }); +}); diff --git a/src/browser/dom-helpers.ts b/src/browser/dom-helpers.ts new file mode 100644 index 0000000..0ca688f --- /dev/null +++ b/src/browser/dom-helpers.ts @@ -0,0 +1,250 @@ +/** + * Shared DOM operation JS generators. + * + * Used by both Page (daemon mode) and CDPPage (direct CDP mode) + * to eliminate code duplication for click, type, press, wait, scroll, etc. + */ + +/** Shared element lookup JS fragment (4-strategy resolution) */ +function resolveElementJs(safeRef: string, selectorSet: string): string { + return ` + const ref = ${safeRef}; + let el = document.querySelector('[data-opencli-ref="' + ref + '"]'); + if (!el) el = document.querySelector('[data-ref="' + ref + '"]'); + if (!el && ref.match(/^[a-zA-Z#.\\[]/)) { + try { el = document.querySelector(ref); } catch {} + } + if (!el) { + const idx = parseInt(ref, 10); + if (!isNaN(idx)) { + el = document.querySelectorAll('${selectorSet}')[idx]; + } + }`; +} + +/** Generate JS to click an element by ref. + * Returns { status, x, y, w, h } for CDP fallback when JS click fails. */ +export function clickJs(ref: string): string { + const safeRef = JSON.stringify(ref); + return ` + (() => { + ${resolveElementJs(safeRef, 'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')} + if (!el) throw new Error('Element not found: ' + ref); + el.scrollIntoView({ behavior: 'instant', block: 'center' }); + const rect = el.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + try { + el.click(); + return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) }; + } catch (e) { + return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message }; + } + })() + `; +} + +/** Generate JS to type text into an element by ref. + * Uses native setter for React compat + execCommand for contenteditable. */ +export function typeTextJs(ref: string, text: string): string { + const safeRef = JSON.stringify(ref); + const safeText = JSON.stringify(text); + return ` + (() => { + ${resolveElementJs(safeRef, 'input, textarea, [contenteditable="true"]')} + if (!el) throw new Error('Element not found: ' + ref); + el.focus(); + if (el.isContentEditable) { + // Select all content + delete, then insert (supports undo, works with rich text editors) + const sel = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand('delete', false); + document.execCommand('insertText', false, ${safeText}); + el.dispatchEvent(new Event('input', { bubbles: true })); + } else { + // Use native setter for React/framework compatibility (match element type) + const proto = el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + if (nativeSetter) { + nativeSetter.call(el, ${safeText}); + } else { + el.value = ${safeText}; + } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + return 'typed'; + })() + `; +} + +/** Generate JS to press a keyboard key */ +export function pressKeyJs(key: string, modifiers: string[] = []): string { + const hasCtrl = modifiers.includes('Ctrl') || modifiers.includes('Control'); + const hasAlt = modifiers.includes('Alt'); + const hasMeta = modifiers.includes('Meta'); + const hasShift = modifiers.includes('Shift'); + return ` + (() => { + const el = document.activeElement || document.body; + const init = { + key: ${JSON.stringify(key)}, + bubbles: true, + ctrlKey: ${hasCtrl}, + altKey: ${hasAlt}, + metaKey: ${hasMeta}, + shiftKey: ${hasShift}, + }; + el.dispatchEvent(new KeyboardEvent('keydown', init)); + el.dispatchEvent(new KeyboardEvent('keyup', init)); + return 'pressed'; + })() + `; +} + +/** Generate JS to wait for text to appear in the page */ +export function waitForTextJs(text: string, timeoutMs: number): string { + return ` + new Promise((resolve, reject) => { + const deadline = Date.now() + ${timeoutMs}; + const check = () => { + if (document.body.innerText.includes(${JSON.stringify(text)})) return resolve('found'); + if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(text)})); + setTimeout(check, 200); + }; + check(); + }) + `; +} + +/** Generate JS for scroll */ +export function scrollJs(direction: string, amount: number): string { + const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0; + const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0; + return `window.scrollBy(${dx}, ${dy})`; +} + +/** Generate JS for auto-scroll with lazy-load detection */ +export function autoScrollJs(times: number, delayMs: number): string { + return ` + (async () => { + if (!document.body) return; + for (let i = 0; i < ${times}; i++) { + const lastHeight = document.body.scrollHeight; + window.scrollTo(0, lastHeight); + await new Promise(resolve => { + let timeoutId; + const observer = new MutationObserver(() => { + if (document.body.scrollHeight > lastHeight) { + clearTimeout(timeoutId); + observer.disconnect(); + setTimeout(resolve, 100); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs}); + }); + } + })() + `; +} + +/** Generate JS to read performance resource entries as network requests */ +export function networkRequestsJs(includeStatic: boolean): string { + return ` + (() => { + const entries = performance.getEntriesByType('resource'); + return entries + ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'} + .map(e => ({ + url: e.name, + type: e.initiatorType, + duration: Math.round(e.duration), + size: e.transferSize || 0, + })); + })() + `; +} + +/** + * Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`), + * with a hard cap at `maxMs`. Uses MutationObserver in the browser. + * + * Returns as soon as the page stops changing, avoiding unnecessary fixed waits. + * If document.body is not available, falls back to a fixed sleep of maxMs. + */ +export function waitForDomStableJs(maxMs: number, quietMs: number): string { + return ` + new Promise(resolve => { + if (!document.body) { + setTimeout(() => resolve('nobody'), ${maxMs}); + return; + } + let timer = null; + let cap = null; + const done = (reason) => { + clearTimeout(timer); + clearTimeout(cap); + obs.disconnect(); + resolve(reason); + }; + const resetQuiet = () => { + clearTimeout(timer); + timer = setTimeout(() => done('quiet'), ${quietMs}); + }; + const obs = new MutationObserver(resetQuiet); + obs.observe(document.body, { childList: true, subtree: true, attributes: true }); + resetQuiet(); + cap = setTimeout(() => done('capped'), ${maxMs}); + }) + `; +} + +/** + * Generate JS to wait until window.__opencli_xhr has ≥1 captured response. + * Polls every 100ms. Resolves 'captured' on success; rejects after maxMs. + * Used after installInterceptor() + goto() instead of a fixed sleep. + */ +export function waitForCaptureJs(maxMs: number): string { + return ` + new Promise((resolve, reject) => { + const deadline = Date.now() + ${maxMs}; + const check = () => { + if ((window.__opencli_xhr || []).length > 0) return resolve('captured'); + if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s')); + setTimeout(check, 100); + }; + check(); + }) + `; +} + +/** + * Generate JS to wait until document.querySelector(selector) returns a match. + * Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs. + */ +export function waitForSelectorJs(selector: string, timeoutMs: number): string { + return ` + new Promise((resolve, reject) => { + const sel = ${JSON.stringify(selector)}; + if (document.querySelector(sel)) return resolve('found'); + const cap = setTimeout(() => { + obs.disconnect(); + reject(new Error('Selector not found: ' + sel)); + }, ${timeoutMs}); + const obs = new MutationObserver(() => { + if (document.querySelector(sel)) { + clearTimeout(cap); + obs.disconnect(); + resolve('found'); + } + }); + obs.observe(document.body || document.documentElement, { childList: true, subtree: true }); + }) + `; +} diff --git a/src/browser/dom-snapshot.test.ts b/src/browser/dom-snapshot.test.ts new file mode 100644 index 0000000..f37cf2d --- /dev/null +++ b/src/browser/dom-snapshot.test.ts @@ -0,0 +1,362 @@ +/** + * Tests for dom-snapshot.ts: DOM snapshot engine. + * + * Since the engine generates JavaScript strings for in-page evaluation, + * these tests validate: + * 1. The generated code is syntactically valid JS + * 2. Options are correctly embedded + * 3. The output structure matches expected format + * 4. All features are present (Shadow DOM, iframe, table, diff, etc.) + */ + +import { describe, it, expect } from 'vitest'; +import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; + +describe('generateSnapshotJs', () => { + it('returns a non-empty string', () => { + const js = generateSnapshotJs(); + expect(typeof js).toBe('string'); + expect(js.length).toBeGreaterThan(100); + }); + + it('generates syntactically valid JS (can be parsed)', () => { + const js = generateSnapshotJs(); + expect(() => new Function(js)).not.toThrow(); + }); + + it('embeds default options correctly', () => { + const js = generateSnapshotJs(); + expect(js).toContain('VIEWPORT_EXPAND = 800'); + expect(js).toContain('MAX_DEPTH = 50'); + expect(js).toContain('INTERACTIVE_ONLY = false'); + expect(js).toContain('MAX_TEXT_LEN = 120'); + expect(js).toContain('INCLUDE_SCROLL_INFO = true'); + expect(js).toContain('BBOX_DEDUP = true'); + expect(js).toContain('INCLUDE_SHADOW_DOM = true'); + expect(js).toContain('INCLUDE_IFRAMES = true'); + expect(js).toContain('PAINT_ORDER_CHECK = true'); + expect(js).toContain('ANNOTATE_REFS = true'); + expect(js).toContain('REPORT_HIDDEN = true'); + expect(js).toContain('FILTER_ADS = true'); + expect(js).toContain('MARKDOWN_TABLES = true'); + expect(js).toContain('PREV_HASHES = null'); + }); + + it('embeds custom options correctly', () => { + const js = generateSnapshotJs({ + viewportExpand: 2000, + maxDepth: 30, + interactiveOnly: true, + maxTextLength: 200, + includeScrollInfo: false, + bboxDedup: false, + includeShadowDom: false, + includeIframes: false, + maxIframes: 3, + paintOrderCheck: false, + annotateRefs: false, + reportHidden: false, + filterAds: false, + markdownTables: false, + }); + expect(js).toContain('VIEWPORT_EXPAND = 2000'); + expect(js).toContain('MAX_DEPTH = 30'); + expect(js).toContain('INTERACTIVE_ONLY = true'); + expect(js).toContain('MAX_TEXT_LEN = 200'); + expect(js).toContain('INCLUDE_SCROLL_INFO = false'); + expect(js).toContain('BBOX_DEDUP = false'); + expect(js).toContain('INCLUDE_SHADOW_DOM = false'); + expect(js).toContain('INCLUDE_IFRAMES = false'); + expect(js).toContain('MAX_IFRAMES = 3'); + expect(js).toContain('PAINT_ORDER_CHECK = false'); + expect(js).toContain('ANNOTATE_REFS = false'); + expect(js).toContain('REPORT_HIDDEN = false'); + expect(js).toContain('FILTER_ADS = false'); + expect(js).toContain('MARKDOWN_TABLES = false'); + }); + + it('clamps maxDepth between 1 and 200', () => { + expect(generateSnapshotJs({ maxDepth: -5 })).toContain('MAX_DEPTH = 1'); + expect(generateSnapshotJs({ maxDepth: 999 })).toContain('MAX_DEPTH = 200'); + expect(generateSnapshotJs({ maxDepth: 75 })).toContain('MAX_DEPTH = 75'); + }); + + it('wraps output as an IIFE', () => { + const js = generateSnapshotJs(); + expect(js.startsWith('(() =>')).toBe(true); + expect(js.trimEnd().endsWith(')()')).toBe(true); + }); + + it('embeds previousHashes for incremental diff', () => { + const hashes = JSON.stringify(['12345', '67890']); + const js = generateSnapshotJs({ previousHashes: hashes }); + expect(js).toContain('new Set(["12345","67890"])'); + }); + + it('includes all core features in generated code', () => { + const js = generateSnapshotJs(); + + // Tag filtering + expect(js).toContain('SKIP_TAGS'); + expect(js).toContain("'script'"); + expect(js).toContain("'style'"); + + // SVG collapsing + expect(js).toContain('SVG_CHILDREN'); + + // Interactive detection + expect(js).toContain('INTERACTIVE_TAGS'); + expect(js).toContain('INTERACTIVE_ROLES'); + expect(js).toContain('isInteractive'); + + // Visibility + expect(js).toContain('isVisibleByCSS'); + expect(js).toContain('isInExpandedViewport'); + + // BBox dedup + expect(js).toContain('isContainedBy'); + expect(js).toContain('PROPAGATING_TAGS'); + expect(js).toContain('PROPAGATING_ROLES'); + expect(js).toContain('isBboxPropagator'); + expect(js).toContain('isDistinctivelyInteractive'); + + // Shadow DOM + expect(js).toContain('shadowRoot'); + expect(js).toContain('|shadow|'); + + // iframe + expect(js).toContain('walkIframe'); + expect(js).toContain('|iframe|'); + + // Paint order + expect(js).toContain('isOccludedByOverlay'); + expect(js).toContain('elementFromPoint'); + + // Ad filtering + expect(js).toContain('isAdElement'); + expect(js).toContain('AD_PATTERNS'); + + // data-ref annotation + expect(js).toContain('data-opencli-ref'); + + // Hidden elements report + expect(js).toContain('hiddenInteractives'); + expect(js).toContain('hidden_interactive'); + + // Incremental diff + expect(js).toContain('hashElement'); + expect(js).toContain('currentHashes'); + expect(js).toContain('__opencli_prev_hashes'); + + // Table serialization + expect(js).toContain('serializeTable'); + expect(js).toContain('|table|'); + + // Synthetic attributes + expect(js).toContain("'YYYY-MM-DD'"); + expect(js).toContain('value=••••'); + + // Page metadata + expect(js).toContain('location.href'); + expect(js).toContain('document.title'); + }); + + it('contains proper attribute whitelist', () => { + const js = generateSnapshotJs(); + const expectedAttrs = [ + 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected', + 'placeholder', 'href', 'role', 'data-testid', 'autocomplete', + ]; + for (const attr of expectedAttrs) { + expect(js).toContain(`'${attr}'`); + } + }); + + it('includes scroll info formatting', () => { + const js = generateSnapshotJs(); + expect(js).toContain('scrollHeight'); + expect(js).toContain('scrollTop'); + expect(js).toContain('|scroll|'); + expect(js).toContain('page_scroll'); + }); +}); + +describe('BBox 99% containment filter', () => { + it('propagates bbox for both PROPAGATING_TAGS and PROPAGATING_ROLES', () => { + const js = generateSnapshotJs(); + // Role-based propagator list covers the common wrapper-as-control patterns + // that show up as
on modern SPAs. + for (const role of ['button', 'link', 'menuitem', 'tab', 'option']) { + expect(js).toContain(`'${role}'`); + } + // propagate site uses the unified helper, not only the tag set + expect(js).toContain('isBboxPropagator(el, tag)'); + }); + + it('suppresses interactive descendants at 0.99 containment when they are not distinctive', () => { + const js = generateSnapshotJs(); + expect(js).toContain('isContainedBy(rect, parentPropagatingRect, 0.99)'); + expect(js).toContain('!isDistinctivelyInteractive(el)'); + // The suppression path flips the local interactive flag so the node is + // still emitted (for text / shape) but does not get its own [N] ref. + expect(js).toContain('interactive = false'); + }); + + it('does not suppress inputs / href-bearing anchors even when fully contained', () => { + const js = generateSnapshotJs(); + // Guards inside isDistinctivelyInteractive + expect(js).toContain("tag === 'input'"); + expect(js).toContain("tag === 'select'"); + expect(js).toContain("tag === 'textarea'"); + expect(js).toContain("tag === 'a'"); + expect(js).toContain("el.hasAttribute('href')"); + // aria-label / aria-labelledby / id / test-id / name preserve distinctness + expect(js).toContain("el.hasAttribute('aria-label')"); + expect(js).toContain("el.hasAttribute('aria-labelledby')"); + expect(js).toContain("el.id"); + expect(js).toContain("el.getAttribute('data-testid')"); + expect(js).toContain("el.hasAttribute('name')"); + }); + + it('keeps the existing 0.95 non-interactive dedup tier in place', () => { + const js = generateSnapshotJs(); + // The original non-interactive bbox filter is still present alongside the + // new interactive tier — two complementary thresholds, not a replacement. + expect(js).toContain('isContainedBy(rect, parentPropagatingRect, 0.95)'); + }); + + it('bbox containment branches are gated on BBOX_DEDUP flag', () => { + const off = generateSnapshotJs({ bboxDedup: false }); + // When the option is off, the filter becomes inert (BBOX_DEDUP = false) + // but the inlined helpers still ship — we only guard at the call sites. + expect(off).toContain('BBOX_DEDUP = false'); + expect(off).toContain('isBboxPropagator'); + expect(off).toContain('isDistinctivelyInteractive'); + }); +}); + +describe('scrollToRefJs', () => { + it('generates valid JS', () => { + const js = scrollToRefJs('42'); + expect(() => new Function(js)).not.toThrow(); + }); + + it('targets data-opencli-ref', () => { + const js = scrollToRefJs('7'); + expect(js).toContain('data-opencli-ref'); + expect(js).toContain('scrollIntoView'); + expect(js).toContain('"7"'); + }); + + it('falls back to data-ref', () => { + const js = scrollToRefJs('3'); + expect(js).toContain('data-ref'); + }); + + it('returns scrolled info', () => { + const js = scrollToRefJs('1'); + expect(js).toContain('scrolled: true'); + expect(js).toContain('tag:'); + }); +}); + +describe('getFormStateJs', () => { + it('generates valid JS', () => { + const js = getFormStateJs(); + expect(() => new Function(js)).not.toThrow(); + }); + + it('collects form elements', () => { + const js = getFormStateJs(); + expect(js).toContain('document.forms'); + expect(js).toContain('form.elements'); + }); + + it('collects orphan fields', () => { + const js = getFormStateJs(); + expect(js).toContain('orphanFields'); + expect(js).toContain('el.form'); + }); + + it('handles different input types', () => { + const js = getFormStateJs(); + expect(js).toContain('checkbox'); + expect(js).toContain('radio'); + expect(js).toContain('password'); + expect(js).toContain('contenteditable'); + }); + + it('extracts labels', () => { + const js = getFormStateJs(); + expect(js).toContain('aria-label'); + expect(js).toContain('label[for='); + expect(js).toContain('closest'); + expect(js).toContain('placeholder'); + }); + + it('masks passwords', () => { + const js = getFormStateJs(); + expect(js).toContain('••••'); + }); + + it('includes data-opencli-ref in output', () => { + const js = getFormStateJs(); + expect(js).toContain('data-opencli-ref'); + }); +}); + +describe('Search Element Detection', () => { + it('includes SEARCH_INDICATORS set', () => { + const js = generateSnapshotJs(); + expect(js).toContain('SEARCH_INDICATORS'); + expect(js).toContain('search'); + expect(js).toContain('magnify'); + expect(js).toContain('glass'); + }); + + it('includes hasFormControlDescendant function', () => { + const js = generateSnapshotJs(); + expect(js).toContain('hasFormControlDescendant'); + expect(js).toContain('input'); + expect(js).toContain('select'); + expect(js).toContain('textarea'); + }); + + it('includes isSearchElement function', () => { + const js = generateSnapshotJs(); + expect(js).toContain('isSearchElement'); + expect(js).toContain('className'); + expect(js).toContain('data-'); + }); + + it('checks label wrapper detection in isInteractive', () => { + const js = generateSnapshotJs(); + // Label elements without "for" attribute should check for form control descendants + expect(js).toContain('hasFormControlDescendant(el, 2)'); + }); + + it('checks span wrapper detection in isInteractive', () => { + const js = generateSnapshotJs(); + // Span elements should check for form control descendants + expect(js).toContain("tag === 'span'"); + }); + + it('integrates search element detection into isInteractive', () => { + const js = generateSnapshotJs(); + expect(js).toContain('isSearchElement(el)'); + }); + + // Blocker B regression: compound contract must be emitted by `browser state`, + // not only by `browser find --css`. Otherwise agents inspecting the default + // snapshot still have to round-trip `find` on every date/select/file control. + it('inlines compoundInfoOf() and attaches compound info to each interactive ref', () => { + const js = generateSnapshotJs(); + expect(js).toContain('function compoundInfoOf(el)'); + // Wiring: the walk body should call compoundInfoOf on every interactive node + expect(js).toContain('compoundInfoOf(el)'); + // And collect them into a per-ref map keyed by the same [N] index as the tree + expect(js).toContain('compoundInfos'); + // And emit a sidecar section after the tree so agents can find the JSON + expect(js).toContain("'compounds ('"); + }); +}); diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts new file mode 100644 index 0000000..e47518a --- /dev/null +++ b/src/browser/dom-snapshot.ts @@ -0,0 +1,936 @@ +/** + * DOM Snapshot Engine — Advanced DOM pruning for LLM consumption. + * + * Inspired by browser-use's multi-layer pruning pipeline, adapted for opencli's + * Chrome Extension + CDP architecture. Runs entirely in-page via Runtime.evaluate. + * + * Pipeline: + * 1. Walk DOM tree, collect visibility + layout + interactivity signals + * 2. Prune invisible, zero-area, non-content elements + * 3. SVG & decoration collapse + * 4. Shadow DOM traversal + * 5. Same-origin iframe content extraction + * 6. Bounding-box parent-child dedup (link/button wrapping children) + * 7. Paint-order occlusion detection (overlay/modal coverage) + * 8. Attribute whitelist filtering + * 9. Table-aware serialization (markdown tables) + * 10. Token-efficient serialization with interactive indices + * 11. data-ref annotation for click/type targeting + * 12. Hidden interactive element hints (scroll-to-reveal) + * 13. Incremental diff (mark new elements with *) + * + * Additional tools: + * - scrollToRefJs(ref) — scroll to a data-opencli-ref element + * - getFormStateJs() — extract all form fields as structured JSON + * + * Compound sidecar: + * After the tree, a `compounds:` section lists rich JSON for every + * date/select/file ref — format, full option list (up to cap) with + * `options_total` reflecting the true count, file `accept` + `multiple`. + * This is what the snapshot's inline attr dump cannot express and what + * agents kept blowing turns on. + */ + +import { COMPOUND_INFO_JS } from './compound.js'; + +// ─── Types ─────────────────────────────────────────────────────────── + +export interface DomSnapshotOptions { + /** Extra pixels beyond viewport to include (default 800) */ + viewportExpand?: number; + /** Maximum DOM depth to traverse (default 50) */ + maxDepth?: number; + /** Only emit interactive elements and their landmark ancestors */ + interactiveOnly?: boolean; + /** Maximum text content length per node (default 120) */ + maxTextLength?: number; + /** Include scroll position info on scrollable containers (default true) */ + includeScrollInfo?: boolean; + /** Enable bounding-box parent-child dedup (default true) */ + bboxDedup?: boolean; + /** Traverse Shadow DOM roots (default true) */ + includeShadowDom?: boolean; + /** Extract same-origin iframe content (default true) */ + includeIframes?: boolean; + /** Maximum number of iframes to process (default 5) */ + maxIframes?: number; + /** Enable paint-order occlusion detection (default true) */ + paintOrderCheck?: boolean; + /** Annotate interactive elements with data-opencli-ref (default true) */ + annotateRefs?: boolean; + /** Report hidden interactive elements outside viewport (default true) */ + reportHidden?: boolean; + /** Filter ad/noise elements (default true) */ + filterAds?: boolean; + /** Serialize tables as markdown (default true) */ + markdownTables?: boolean; + /** Previous snapshot hash set (JSON array of hashes) for diff marking (default null) */ + previousHashes?: string | null; +} + +// ─── Utility JS Generators ─────────────────────────────────────────── + +/** + * Generate JS to scroll to an element identified by data-opencli-ref. + * Completes the snapshot→action loop: snapshot identifies `[3] + * |scroll|
(0.5↑ 3.2↓) + * *[58]Result 1 + * [59]Result 2 + * + * - `[id]` — interactive element with backend index for targeting + * - `*` prefix — newly appeared element (incremental diff) + * - `|scroll|` — scrollable container with page counts + * - `|shadow|` — Shadow DOM boundary + * - `|iframe|` — iframe content + * - `|table|` — markdown table rendering + */ +export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { + const viewportExpand = opts.viewportExpand ?? 800; + const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200)); + const interactiveOnly = opts.interactiveOnly ?? false; + const maxTextLength = opts.maxTextLength ?? 120; + const includeScrollInfo = opts.includeScrollInfo ?? true; + const bboxDedup = opts.bboxDedup ?? true; + const includeShadowDom = opts.includeShadowDom ?? true; + const includeIframes = opts.includeIframes ?? true; + const maxIframes = opts.maxIframes ?? 5; + const paintOrderCheck = opts.paintOrderCheck ?? true; + const annotateRefs = opts.annotateRefs ?? true; + const reportHidden = opts.reportHidden ?? true; + const filterAds = opts.filterAds ?? true; + const markdownTables = opts.markdownTables ?? true; + const previousHashes = opts.previousHashes ?? null; + + return ` +(() => { + 'use strict'; + + ${COMPOUND_INFO_JS} + + // ── Config ───────────────────────────────────────────────────────── + const VIEWPORT_EXPAND = ${viewportExpand}; + const MAX_DEPTH = ${maxDepth}; + const INTERACTIVE_ONLY = ${interactiveOnly}; + const MAX_TEXT_LEN = ${maxTextLength}; + const INCLUDE_SCROLL_INFO = ${includeScrollInfo}; + const BBOX_DEDUP = ${bboxDedup}; + const INCLUDE_SHADOW_DOM = ${includeShadowDom}; + const INCLUDE_IFRAMES = ${includeIframes}; + const MAX_IFRAMES = ${maxIframes}; + const PAINT_ORDER_CHECK = ${paintOrderCheck}; + const ANNOTATE_REFS = ${annotateRefs}; + const REPORT_HIDDEN = ${reportHidden}; + const FILTER_ADS = ${filterAds}; + const MARKDOWN_TABLES = ${markdownTables}; + const PREV_HASHES = ${previousHashes ? `new Set(${previousHashes})` : 'null'}; + + // ── Constants ────────────────────────────────────────────────────── + + const SKIP_TAGS = new Set([ + 'script', 'style', 'noscript', 'link', 'meta', 'head', + 'template', 'br', 'wbr', 'col', 'colgroup', + ]); + + const SVG_CHILDREN = new Set([ + 'path', 'rect', 'g', 'circle', 'ellipse', 'line', 'polyline', + 'polygon', 'use', 'defs', 'clippath', 'mask', 'pattern', + 'text', 'tspan', 'lineargradient', 'radialgradient', 'stop', + 'filter', 'fegaussianblur', 'fecolormatrix', 'feblend', + 'symbol', 'marker', 'foreignobject', 'desc', 'title', + ]); + + const INTERACTIVE_TAGS = new Set([ + 'a', 'button', 'input', 'select', 'textarea', 'details', + 'summary', 'option', 'optgroup', + ]); + + const INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'menuitem', 'option', 'radio', 'checkbox', + 'tab', 'textbox', 'combobox', 'slider', 'spinbutton', + 'searchbox', 'switch', 'menuitemcheckbox', 'menuitemradio', + 'treeitem', 'gridcell', 'row', + ]); + + const LANDMARK_ROLES = new Set([ + 'main', 'navigation', 'banner', 'search', 'region', + 'complementary', 'contentinfo', 'form', 'dialog', + ]); + + const LANDMARK_TAGS = new Set([ + 'nav', 'main', 'header', 'footer', 'aside', 'form', + 'search', 'dialog', 'section', 'article', + ]); + + const ATTR_WHITELIST = new Set([ + 'id', 'name', 'type', 'value', 'placeholder', 'title', 'alt', + 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected', + 'aria-disabled', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow', + 'aria-haspopup', 'aria-live', 'aria-required', + 'href', 'src', 'action', 'method', 'for', 'checked', 'selected', + 'disabled', 'required', 'multiple', 'accept', 'min', 'max', + 'pattern', 'maxlength', 'minlength', 'data-testid', 'data-test', + 'contenteditable', 'tabindex', 'autocomplete', + ]); + + const PROPAGATING_TAGS = new Set(['a', 'button']); + + // Roles whose element wraps its own interactive descendants (icon spans + // inside a role=button, chevron inside role=link). When we see one of these, + // we propagate its bbox to children so we can suppress duplicate refs on + // undistinctive descendants that are ≥99% contained. + const PROPAGATING_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option']); + + function isBboxPropagator(el, tag) { + if (PROPAGATING_TAGS.has(tag)) return true; + const role = el.getAttribute('role'); + return !!(role && PROPAGATING_ROLES.has(role)); + } + + // True when an interactive element still deserves its own [N] ref even + // though it's visually subsumed by a propagating ancestor. Anything with + // an aria-label, aria-labelledby, id, test id, name, or its own form + // semantics is treated as distinctive — everything else (naked spans / + // divs / svgs that merely inherit click from the parent button) gets + // folded into the parent so the snapshot doesn't ship [1]', + { role: 'button', name: 'Save', testid: 'save' }, + ); + expect(result).toMatchObject({ + matches_n: 1, + entries: [ + { nth: 0, ref: 1, tag: 'button', role: 'button', attrs: { 'aria-label': 'Save expense', 'data-testid': 'save-button' } }, + ], + }); + const button = dom.window.document.querySelector('button')!; + expect(button.getAttribute('data-opencli-ref')).toBe('1'); + expect((dom.window as any).__opencli_ref_identity['1']).toMatchObject({ tag: 'button', ariaLabel: 'Save expense' }); + }); + + it('matches associated labels and placeholders for form controls', () => { + const { result } = runSemanticFind( + '', + { role: 'textbox', label: 'Category', name: 'Expense category' }, + ); + expect(result).toMatchObject({ + matches_n: 1, + entries: [ + { nth: 0, ref: 1, tag: 'input', role: 'textbox' }, + ], + }); + }); +}); diff --git a/src/browser/find.ts b/src/browser/find.ts new file mode 100644 index 0000000..4348ae0 --- /dev/null +++ b/src/browser/find.ts @@ -0,0 +1,455 @@ +/** + * `browser find --css ` — structured CSS query. + * + * Returns every match of a selector as a JSON envelope agents can read + * without parsing free-text snapshot output. Each entry carries two + * identifiers — a numeric `ref` (matching the snapshot contract) and a + * stable 0-based `nth` — so the agent can act on a specific result via + * either path: + * + * browser click // when ref is numeric + * browser click "" --nth // always works + * + * Refs are *allocated on the spot* for matched elements that were not + * tagged by a prior snapshot: `data-opencli-ref` is set on the element + * and a fingerprint is written into `window.__opencli_ref_identity` + * (same shape the snapshot uses). That makes `find` a first-class entry + * point to the ref system — agents can skip running `browser state` + * when they already know the selector. + * + * Attributes are whitelisted to keep output small and high-signal. + * Invisible elements are still returned so agents can reason about + * offscreen vs truly-missing targets. + * + * When a matched element is a compound form control (date-like input, + * select, file input), the entry gains a `compound` field with the + * rich view from `compound.ts`. This is what kills the three biggest + * agent-fail modes on form pages (wrong date format, guessed options, + * re-uploaded files) without forcing agents to probe further. + */ + +import { COMPOUND_INFO_JS, type CompoundInfo } from './compound.js'; + +/** Whitelist of attributes surfaced per entry. Keep small; agents do not need full DOM dumps. */ +export const FIND_ATTR_WHITELIST = [ + 'id', + 'class', + 'name', + 'type', + 'placeholder', + 'aria-label', + 'title', + 'href', + 'value', + 'role', + 'data-testid', +] as const; + +export interface FindEntry { + /** Zero-based position within the match set — pair with `--nth` on downstream commands. */ + nth: number; + /** + * Numeric data-opencli-ref. Find assigns one if the element was not + * tagged by a prior snapshot, so downstream `browser click ` works + * directly off the find output without requiring `browser state` first. + */ + ref: number; + tag: string; + role: string; + text: string; + attrs: Record; + visible: boolean; + /** + * Rich view for date / time / datetime-local / month / week / select / + * file inputs. Omitted (undefined) for all other element types. See + * `compound.ts` for the shape. + */ + compound?: CompoundInfo; +} + +export interface FindResult { + matches_n: number; + entries: FindEntry[]; +} + +export interface FindError { + error: { + code: 'invalid_selector' | 'selector_not_found' | 'semantic_not_found'; + message: string; + hint?: string; + }; +} + +export interface FindOptions { + /** Max entries returned. Default 50 — enough to pick from without flooding context. */ + limit?: number; + /** Max chars of trimmed text per entry. Default 120. */ + textMax?: number; +} + +export interface SemanticFindOptions extends FindOptions { + role?: string; + name?: string; + label?: string; + text?: string; + testid?: string; +} + +/** + * Build the browser-side JS that performs the CSS query and emits the + * FindResult (or FindError) envelope. Evaluated inside `page.evaluate`. + */ +export function buildFindJs(selector: string, opts: FindOptions = {}): string { + const safeSel = JSON.stringify(selector); + const limit = opts.limit ?? 50; + const textMax = opts.textMax ?? 120; + const whitelist = JSON.stringify(FIND_ATTR_WHITELIST); + + return ` + (() => { + const sel = ${safeSel}; + const LIMIT = ${limit}; + const TEXT_MAX = ${textMax}; + const ATTR_WHITELIST = ${whitelist}; + + ${COMPOUND_INFO_JS} + + let matches; + try { + matches = document.querySelectorAll(sel); + } catch (e) { + return { + error: { + code: 'invalid_selector', + message: 'Invalid CSS selector: ' + sel + ' (' + ((e && e.message) || String(e)) + ')', + hint: 'Check the selector syntax.', + }, + }; + } + + if (matches.length === 0) { + return { + error: { + code: 'selector_not_found', + message: 'CSS selector ' + sel + ' matched 0 elements', + hint: 'Use browser state to inspect the page, or try a less specific selector.', + }, + }; + } + + function pickAttrs(el) { + const out = {}; + for (const key of ATTR_WHITELIST) { + const v = el.getAttribute(key); + if (v != null && v !== '') out[key] = v; + } + return out; + } + + function isVisible(el) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + try { + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (parseFloat(style.opacity || '1') === 0) return false; + } catch (_) {} + return true; + } + + // Ref allocation: reuse \`window.__opencli_ref_identity\` (the same map + // snapshot populates) as the source of truth. For matched elements that + // don't already carry a \`data-opencli-ref\`, assign the next free numeric + // ref and write the fingerprint so the target resolver can verify it on + // downstream click/type/get calls. + const identity = (window.__opencli_ref_identity = window.__opencli_ref_identity || {}); + let maxRef = 0; + for (const k in identity) { + const n = parseInt(k, 10); + if (!isNaN(n) && n > maxRef) maxRef = n; + } + // Also walk any \`data-opencli-ref\` already in the DOM in case the identity + // map was cleared but annotations remain (e.g. soft navigation without a + // fresh snapshot). Guarantees allocated refs don't collide. + try { + const tagged = document.querySelectorAll('[data-opencli-ref]'); + for (let t = 0; t < tagged.length; t++) { + const v = tagged[t].getAttribute('data-opencli-ref'); + const n = v != null && /^\\d+$/.test(v) ? parseInt(v, 10) : NaN; + if (!isNaN(n) && n > maxRef) maxRef = n; + } + } catch (_) {} + + function fingerprintOf(el) { + return { + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role') || '', + text: (el.textContent || '').trim().slice(0, 30), + ariaLabel: el.getAttribute('aria-label') || '', + id: el.id || '', + testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '', + }; + } + + const take = Math.min(matches.length, LIMIT); + const entries = []; + for (let i = 0; i < take; i++) { + const el = matches[i]; + const refAttr = el.getAttribute('data-opencli-ref'); + let refNum = refAttr != null && /^\\d+$/.test(refAttr) ? parseInt(refAttr, 10) : null; + if (refNum === null) { + refNum = ++maxRef; + try { el.setAttribute('data-opencli-ref', '' + refNum); } catch (_) {} + identity['' + refNum] = fingerprintOf(el); + } else if (!identity['' + refNum]) { + // Ref annotation survived but identity map was cleared — repopulate so the + // target resolver's fingerprint check passes on downstream calls. + identity['' + refNum] = fingerprintOf(el); + } + const text = (el.textContent || '').trim(); + const entry = { + nth: i, + ref: refNum, + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role') || '', + text: text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text, + attrs: pickAttrs(el), + visible: isVisible(el), + }; + const compound = compoundInfoOf(el); + if (compound) entry.compound = compound; + entries.push(entry); + } + + return { + matches_n: matches.length, + entries, + }; + })() + `; +} + +export function buildSemanticFindJs(opts: SemanticFindOptions): string { + const criteria = JSON.stringify({ + role: opts.role ?? '', + name: opts.name ?? '', + label: opts.label ?? '', + text: opts.text ?? '', + testid: opts.testid ?? '', + }); + const limit = opts.limit ?? 50; + const textMax = opts.textMax ?? 120; + const whitelist = JSON.stringify(FIND_ATTR_WHITELIST); + + return ` + (() => { + const CRITERIA = ${criteria}; + const LIMIT = ${limit}; + const TEXT_MAX = ${textMax}; + const ATTR_WHITELIST = ${whitelist}; + + ${COMPOUND_INFO_JS} + + function normalize(value) { + return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + } + + function includesNeedle(value, needle) { + const n = normalize(needle); + if (!n) return true; + return normalize(value).includes(n); + } + + function nativeRole(el) { + const explicit = el.getAttribute('role'); + if (explicit) return explicit; + const tag = el.tagName.toLowerCase(); + const type = (el.getAttribute('type') || '').toLowerCase(); + if (tag === 'button') return 'button'; + if (tag === 'a' && el.getAttribute('href')) return 'link'; + if (tag === 'textarea') return 'textbox'; + if (tag === 'select') return 'combobox'; + if (tag === 'option') return 'option'; + if (tag === 'input') { + if (type === 'button' || type === 'submit' || type === 'reset') return 'button'; + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'range') return 'slider'; + if (type === 'search') return 'searchbox'; + return 'textbox'; + } + return ''; + } + + function labelText(el) { + const parts = []; + function cssEscape(value) { + try { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value); + } catch (_) {} + return String(value).replace(/["\\\\]/g, '\\\\$&'); + } + if (el.id) { + try { + const label = document.querySelector('label[for="' + cssEscape(el.id) + '"]'); + if (label) parts.push(label.textContent || ''); + } catch (_) {} + } + const parentLabel = el.closest?.('label'); + if (parentLabel) parts.push(parentLabel.textContent || ''); + return parts.join(' '); + } + + function byIdText(ids) { + if (!ids) return ''; + const parts = []; + for (const id of String(ids).split(/\\s+/)) { + if (!id) continue; + try { + const el = document.getElementById(id); + if (el) parts.push(el.textContent || ''); + } catch (_) {} + } + return parts.join(' '); + } + + function accessibleName(el) { + return [ + el.getAttribute('aria-label') || '', + byIdText(el.getAttribute('aria-labelledby')), + labelText(el), + el.getAttribute('alt') || '', + el.getAttribute('title') || '', + el.getAttribute('placeholder') || '', + el.getAttribute('value') || '', + el.textContent || '', + ].filter(Boolean).join(' '); + } + + function pickAttrs(el) { + const out = {}; + for (const key of ATTR_WHITELIST) { + const v = el.getAttribute(key); + if (v != null && v !== '') out[key] = v; + } + return out; + } + + function isVisible(el) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + try { + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (parseFloat(style.opacity || '1') === 0) return false; + } catch (_) {} + return true; + } + + function fingerprintOf(el) { + return { + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role') || '', + text: (el.textContent || '').trim().slice(0, 30), + ariaLabel: el.getAttribute('aria-label') || '', + id: el.id || '', + testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '', + }; + } + + function matches(el) { + const role = nativeRole(el); + const name = accessibleName(el); + const label = labelText(el); + const text = el.textContent || ''; + const testid = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('test-id') || ''; + if (CRITERIA.role && normalize(role) !== normalize(CRITERIA.role)) return false; + if (CRITERIA.name && !includesNeedle(name, CRITERIA.name)) return false; + if (CRITERIA.label && !includesNeedle(label, CRITERIA.label)) return false; + if (CRITERIA.text && !includesNeedle(text, CRITERIA.text)) return false; + if (CRITERIA.testid && !includesNeedle(testid, CRITERIA.testid)) return false; + return true; + } + + const candidates = Array.from(document.querySelectorAll([ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'option', + '[role]', + '[aria-label]', + '[aria-labelledby]', + '[data-testid]', + '[data-test]', + '[test-id]', + 'label', + '[contenteditable="true"]', + ].join(','))); + const matchesList = candidates.filter(matches); + + if (matchesList.length === 0) { + return { + error: { + code: 'semantic_not_found', + message: 'Semantic locator matched 0 elements', + hint: 'Try browser state, --source ax, or relax --role/--name/--label/--text/--testid.', + }, + }; + } + + const identity = (window.__opencli_ref_identity = window.__opencli_ref_identity || {}); + let maxRef = 0; + for (const k in identity) { + const n = parseInt(k, 10); + if (!isNaN(n) && n > maxRef) maxRef = n; + } + try { + const tagged = document.querySelectorAll('[data-opencli-ref]'); + for (let t = 0; t < tagged.length; t++) { + const v = tagged[t].getAttribute('data-opencli-ref'); + const n = v != null && /^\\d+$/.test(v) ? parseInt(v, 10) : NaN; + if (!isNaN(n) && n > maxRef) maxRef = n; + } + } catch (_) {} + + const take = Math.min(matchesList.length, LIMIT); + const entries = []; + for (let i = 0; i < take; i++) { + const el = matchesList[i]; + const refAttr = el.getAttribute('data-opencli-ref'); + let refNum = refAttr != null && /^\\d+$/.test(refAttr) ? parseInt(refAttr, 10) : null; + if (refNum === null) { + refNum = ++maxRef; + try { el.setAttribute('data-opencli-ref', '' + refNum); } catch (_) {} + identity['' + refNum] = fingerprintOf(el); + } else if (!identity['' + refNum]) { + identity['' + refNum] = fingerprintOf(el); + } + const text = (el.textContent || '').trim(); + const entry = { + nth: i, + ref: refNum, + tag: el.tagName.toLowerCase(), + role: nativeRole(el), + text: text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text, + attrs: pickAttrs(el), + visible: isVisible(el), + }; + const compound = compoundInfoOf(el); + if (compound) entry.compound = compound; + entries.push(entry); + } + + return { + matches_n: matchesList.length, + entries, + }; + })() + `; +} + +export function isFindError(result: unknown): result is FindError { + return !!result && typeof result === 'object' && 'error' in result; +} diff --git a/src/browser/html-tree.test.ts b/src/browser/html-tree.test.ts new file mode 100644 index 0000000..666b3d2 --- /dev/null +++ b/src/browser/html-tree.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; +import { buildHtmlTreeJs, type BuildHtmlTreeJsOptions, type HtmlTreeResult } from './html-tree.js'; + +/** + * The serializer runs in a page context via `page.evaluate`. In unit tests we + * substitute `document` with a minimal stub that mirrors the DOM surface used + * by the expression, then Function-eval the returned JS. + */ +function runTreeJs( + root: unknown, + selectorMatches: unknown[], + selector: string | null, + budgets: Omit = {}, +): HtmlTreeResult { + const js = buildHtmlTreeJs({ selector, ...budgets }); + const fakeDocument = { + querySelectorAll: () => selectorMatches, + documentElement: root, + }; + const fn = new Function('document', `return ${js};`); + return fn(fakeDocument) as HtmlTreeResult; +} + +function runTreeJsInvalid(selector: string, errorMessage: string): unknown { + const js = buildHtmlTreeJs({ selector }); + const fakeDocument = { + querySelectorAll: () => { const e = new Error(errorMessage); e.name = 'SyntaxError'; throw e; }, + documentElement: null, + }; + const fn = new Function('document', `return ${js};`); + return fn(fakeDocument); +} + +function el(tag: string, attrs: Record, children: Array, extras: Partial = {}): FakeEl { + return { + nodeType: 1, + tagName: tag.toUpperCase(), + attributes: Object.entries(attrs).map(([name, value]) => ({ name, value })), + childNodes: children, + getAttribute: (name: string) => (name in attrs ? attrs[name]! : null), + value: extras.value, + multiple: extras.multiple, + files: extras.files, + options: extras.options, + }; +} + +function txt(value: string): FakeText { return { nodeType: 3, nodeValue: value }; } + +type CompoundExtras = { + value: string; + multiple: boolean; + files: Array<{ name: string }>; + options: Array<{ value: string; label?: string; text?: string; selected?: boolean; disabled?: boolean }>; +}; +type FakeEl = { + nodeType: 1; + tagName: string; + attributes: Array<{ name: string; value: string }>; + childNodes: Array; + getAttribute: (name: string) => string | null; + value?: string; + multiple?: boolean; + files?: Array<{ name: string }>; + options?: Array<{ value: string; label?: string; text?: string; selected?: boolean; disabled?: boolean }>; +}; +type FakeText = { nodeType: 3; nodeValue: string }; +type ChildOf = FakeEl | FakeText; + +describe('buildHtmlTreeJs', () => { + it('serializes a simple element into {tag, attrs, text, children}', () => { + const root = el('div', { class: 'hero', id: 'x' }, [txt('Hello')]); + const result = runTreeJs(root, [root], null); + expect(result.selector).toBeNull(); + expect(result.matched).toBe(1); + expect(result.tree).toEqual({ + tag: 'div', + attrs: { class: 'hero', id: 'x' }, + text: 'Hello', + children: [], + }); + }); + + it('collapses whitespace in direct text content only', () => { + const root = el('p', {}, [ + txt(' line \n one '), + el('span', {}, [txt('inner text')]), + txt('\tline two\t'), + ]); + const result = runTreeJs(root, [root], null); + expect(result.tree?.text).toBe('line one line two'); + expect(result.tree?.children[0].text).toBe('inner text'); + }); + + it('recurses into element children and preserves their attrs', () => { + const root = el('ul', { role: 'list' }, [ + el('li', { 'data-id': '1' }, [txt('first')]), + el('li', { 'data-id': '2' }, [txt('second')]), + ]); + const result = runTreeJs(root, [root], null); + expect(result.tree?.children).toHaveLength(2); + expect(result.tree?.children[0]).toEqual({ + tag: 'li', + attrs: { 'data-id': '1' }, + text: 'first', + children: [], + }); + }); + + it('returns matched=N and serializes only the first match', () => { + const first = el('article', { id: 'a' }, [txt('first')]); + const second = el('article', { id: 'b' }, [txt('second')]); + const result = runTreeJs(null, [first, second], 'article'); + expect(result.matched).toBe(2); + expect(result.tree?.attrs.id).toBe('a'); + }); + + it('returns tree=null and matched=0 when selector matches nothing', () => { + const result = runTreeJs(null, [], '.nothing'); + expect(result.matched).toBe(0); + expect(result.tree).toBeNull(); + }); + + it('catches SyntaxError from querySelectorAll and returns {invalidSelector:true, reason}', () => { + const result = runTreeJsInvalid('##$@@', "'##$@@' is not a valid selector") as { + selector: string; + invalidSelector: boolean; + reason: string; + }; + expect(result.invalidSelector).toBe(true); + expect(result.selector).toBe('##$@@'); + expect(result.reason).toContain('not a valid selector'); + }); + + it('omits `truncated` when no budget is hit', () => { + const root = el('div', {}, [el('span', {}, [txt('ok')])]); + const result = runTreeJs(root, [root], null, { depth: 5, childrenMax: 10, textMax: 100 }); + expect(result.truncated).toBeUndefined(); + }); +}); + +describe('buildHtmlTreeJs budget knobs', () => { + it('caps tree at `depth` and reports truncated.depth', () => { + const deep = el('a', {}, [ + el('b', {}, [ + el('c', {}, [el('d', {}, [txt('deep')])]), + ]), + ]); + // depth=1 → root + one level of children; grandchildren should be dropped. + const result = runTreeJs(deep, [deep], null, { depth: 1 }); + expect(result.tree?.tag).toBe('a'); + expect(result.tree?.children).toHaveLength(1); + expect(result.tree?.children[0].tag).toBe('b'); + // The "b" node had element children but we hit the depth budget before + // recursing into them — children array is empty, truncated.depth is true. + expect(result.tree?.children[0].children).toEqual([]); + expect(result.truncated?.depth).toBe(true); + }); + + it('depth=0 keeps only the root', () => { + const root = el('ul', {}, [ + el('li', {}, [txt('a')]), + el('li', {}, [txt('b')]), + ]); + const result = runTreeJs(root, [root], null, { depth: 0 }); + expect(result.tree?.children).toEqual([]); + expect(result.truncated?.depth).toBe(true); + }); + + it('caps children per node at `childrenMax` and reports children_dropped count', () => { + const root = el('ul', {}, [ + el('li', {}, [txt('1')]), + el('li', {}, [txt('2')]), + el('li', {}, [txt('3')]), + el('li', {}, [txt('4')]), + el('li', {}, [txt('5')]), + ]); + const result = runTreeJs(root, [root], null, { childrenMax: 2 }); + expect(result.tree?.children).toHaveLength(2); + expect(result.truncated?.children_dropped).toBe(3); + }); + + it('caps direct text per node at `textMax` and reports text_truncated count', () => { + const root = el('p', {}, [ + txt('a'.repeat(50)), + el('span', {}, [txt('b'.repeat(50))]), + ]); + const result = runTreeJs(root, [root], null, { textMax: 10 }); + expect(result.tree?.text).toHaveLength(10); + expect(result.tree?.children[0].text).toHaveLength(10); + expect(result.truncated?.text_truncated).toBe(2); + }); + + // Blocker B regression: compound contract must ride along with the + // json tree so `browser get html --as json` surfaces the full contract + // to agents without an extra round-trip. + it('attaches compound info to date/file/select nodes and omits it elsewhere', () => { + const date = el('input', { type: 'date', min: '2026-01-01' }, [], { value: '2026-04-21' }); + const file = el('input', { type: 'file', accept: 'image/*' }, [], { multiple: true, files: [{ name: 'a.png' }] }); + const sel = el('select', { name: 'country' }, [], { + options: [ + { value: 'us', label: 'United States', selected: true }, + { value: 'ca', label: 'Canada' }, + ], + }); + const plain = el('input', { type: 'text' }, [], { value: 'hi' }); + const root = el('form', {}, [date, file, sel, plain]); + const result = runTreeJs(root, [root], null) as HtmlTreeResult & { + tree: { children: Array<{ compound?: unknown }> }; + }; + expect(result.tree?.children[0].compound).toMatchObject({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21', min: '2026-01-01' }); + expect(result.tree?.children[1].compound).toMatchObject({ control: 'file', multiple: true, current: ['a.png'], accept: 'image/*' }); + expect(result.tree?.children[2].compound).toMatchObject({ control: 'select', multiple: false, current: 'United States' }); + expect(result.tree?.children[3].compound).toBeUndefined(); + }); + + it('combines budgets and reports every hit', () => { + const root = el('ul', {}, [ + el('li', {}, [txt('x'.repeat(20)), el('em', {}, [txt('y')])]), + el('li', {}, []), + el('li', {}, []), + ]); + const result = runTreeJs(root, [root], null, { depth: 1, childrenMax: 2, textMax: 5 }); + expect(result.tree?.children).toHaveLength(2); + expect(result.truncated?.children_dropped).toBe(1); + expect(result.truncated?.text_truncated).toBe(1); + expect(result.truncated?.depth).toBe(true); + }); +}); diff --git a/src/browser/html-tree.ts b/src/browser/html-tree.ts new file mode 100644 index 0000000..4805770 --- /dev/null +++ b/src/browser/html-tree.ts @@ -0,0 +1,153 @@ +/** + * Client-side HTML → structured tree serializer. + * + * Returned as a JS string that gets passed to `page.evaluate`. The expression + * walks the DOM subtree rooted at the first selector match (or documentElement + * when no selector is given) and emits a compact `{tag, attrs, text, children}` + * tree for agents to consume instead of re-parsing raw HTML. + * + * Text handling: `text` is the concatenated text of direct text children only, + * whitespace-collapsed. Nested element text is left inside `children[].text`. + * Ordering between text and elements is not preserved — agents that need it + * should fall back to raw HTML mode. + * + * Budget knobs let the caller bound the output on large pages — previously an + * unscoped `get html --as json` could return a giant tree. Callers set any + * combination of `depth` / `childrenMax` / `textMax`; each hit is reported in + * the `truncated` envelope so agents know to narrow their selector or raise + * the budget. + * + * Compound controls (date / time / datetime-local / month / week / select / + * file) gain a `compound` field so agents inspecting the JSON tree see the + * full contract — date format, full option list (up to cap) with selections + * preserved for options beyond the cap, file `accept` and `multiple`. Without + * this wiring agents repeatedly guess values on these controls from the raw + * attributes, which is the failure mode compound.ts was built to eliminate. + */ + +import { COMPOUND_INFO_JS, type CompoundInfo } from './compound.js'; + +export interface BuildHtmlTreeJsOptions { + /** CSS selector to scope the tree; unscoped = documentElement */ + selector?: string | null; + /** Max depth below the root (0 = root only, no children). Omit = unlimited. */ + depth?: number | null; + /** Max element children per node before the rest get dropped. Omit = unlimited. */ + childrenMax?: number | null; + /** Max chars of direct text per node before truncation. Omit = unlimited. */ + textMax?: number | null; +} + +/** + * Returns a JS expression string. When evaluated in a page context the + * expression resolves to either + * `{selector, matched, tree, truncated}` on success, or + * `{selector, invalidSelector: true, reason}` when `querySelectorAll` + * throws a `SyntaxError` for an unparseable selector. + * + * Callers must branch on `invalidSelector` to convert it into the CLI's + * `invalid_selector` structured error; otherwise the browser-level exception + * would bubble out of `page.evaluate` and bypass the structured-error + * contract that agents rely on. + */ +export function buildHtmlTreeJs(opts: BuildHtmlTreeJsOptions = {}): string { + const selectorLiteral = opts.selector ? JSON.stringify(opts.selector) : 'null'; + const depthLiteral = Number.isFinite(opts.depth as number) && (opts.depth as number) >= 0 + ? String(opts.depth) + : 'null'; + const childrenMaxLiteral = Number.isFinite(opts.childrenMax as number) && (opts.childrenMax as number) >= 0 + ? String(opts.childrenMax) + : 'null'; + const textMaxLiteral = Number.isFinite(opts.textMax as number) && (opts.textMax as number) >= 0 + ? String(opts.textMax) + : 'null'; + return `(() => { + ${COMPOUND_INFO_JS} + const selector = ${selectorLiteral}; + const maxDepth = ${depthLiteral}; + const maxChildren = ${childrenMaxLiteral}; + const maxText = ${textMaxLiteral}; + let matches; + if (selector) { + try { matches = document.querySelectorAll(selector); } + catch (e) { + return { selector: selector, invalidSelector: true, reason: (e && e.message) || String(e) }; + } + } else { + matches = [document.documentElement]; + } + const matched = matches.length; + const root = matches[0] || null; + const trunc = { depth: false, children_dropped: 0, text_truncated: 0 }; + function serialize(el, depth) { + if (!el || el.nodeType !== 1) return null; + const attrs = {}; + for (const a of el.attributes) attrs[a.name] = a.value; + let text = ''; + for (const n of el.childNodes) { + if (n.nodeType === 3) text += n.nodeValue; + } + text = text.replace(/\\s+/g, ' ').trim(); + if (maxText !== null && text.length > maxText) { + text = text.slice(0, maxText); + trunc.text_truncated++; + } + const children = []; + if (maxDepth === null || depth < maxDepth) { + const childEls = []; + for (const n of el.childNodes) if (n.nodeType === 1) childEls.push(n); + const keep = maxChildren === null ? childEls.length : Math.min(childEls.length, maxChildren); + for (let i = 0; i < keep; i++) { + const child = serialize(childEls[i], depth + 1); + if (child) children.push(child); + } + if (maxChildren !== null && childEls.length > maxChildren) { + trunc.children_dropped += childEls.length - maxChildren; + } + } else { + // Budget hit: we're at max depth. Count any element children we would have visited. + for (const n of el.childNodes) if (n.nodeType === 1) { trunc.depth = true; break; } + } + const node = { tag: el.tagName.toLowerCase(), attrs, text, children }; + const compound = compoundInfoOf(el); + if (compound) node.compound = compound; + return node; + } + const tree = root ? serialize(root, 0) : null; + const truncatedOut = {}; + if (trunc.depth) truncatedOut.depth = true; + if (trunc.children_dropped > 0) truncatedOut.children_dropped = trunc.children_dropped; + if (trunc.text_truncated > 0) truncatedOut.text_truncated = trunc.text_truncated; + const envelope = { selector: selector, matched: matched, tree: tree }; + if (Object.keys(truncatedOut).length > 0) envelope.truncated = truncatedOut; + return envelope; +})()`; +} + +export interface HtmlNode { + tag: string; + attrs: Record; + text: string; + children: HtmlNode[]; + /** + * Rich view for date/select/file controls. Omitted for non-compound elements + * so agents can rely on `compound != null` as a signal. + */ + compound?: CompoundInfo; +} + +export interface HtmlTreeTruncationInfo { + /** At least one element child was dropped because depth budget was hit. */ + depth?: true; + /** Count of element children dropped across the tree due to `childrenMax`. */ + children_dropped?: number; + /** Count of nodes whose `text` was cut to `textMax`. */ + text_truncated?: number; +} + +export interface HtmlTreeResult { + selector: string | null; + matched: number; + tree: HtmlNode | null; + truncated?: HtmlTreeTruncationInfo; +} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..551d2df --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,15 @@ +/** + * Browser module — public API re-exports. + * + * This barrel replaces the former monolithic browser.ts. + * External code should import from './browser/index.js' (or './browser.js' via Node resolution). + */ + +export { Page } from './page.js'; +export { BrowserBridge } from './bridge.js'; +export { CDPBridge } from './cdp.js'; +export { getDaemonHealth } from './daemon-client.js'; +export type { DaemonHealth } from './daemon-client.js'; +export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; +export { generateStealthJs } from './stealth.js'; +export type { DomSnapshotOptions } from './dom-snapshot.js'; diff --git a/src/browser/network-cache.test.ts b/src/browser/network-cache.test.ts new file mode 100644 index 0000000..c17bbf6 --- /dev/null +++ b/src/browser/network-cache.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + DEFAULT_TTL_MS, + findEntry, + getCachePath, + loadNetworkCache, + saveNetworkCache, + type CachedNetworkEntry, + type NetworkCacheFile, +} from './network-cache.js'; + +function makeEntry(key: string, body: unknown = { ok: true }): CachedNetworkEntry { + return { key, url: `https://x.com/${key}`, method: 'GET', status: 200, size: 2, ct: 'application/json', body }; +} + +describe('network-cache', () => { + let baseDir: string; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-netcache-')); + }); + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it('sanitizes session names into safe filenames', () => { + const p = getCachePath('twitter/agent 1', baseDir); + expect(path.basename(p)).toBe('twitter_agent_1.json'); + }); + + it('round-trips entries through save + load', () => { + saveNetworkCache('ws', [makeEntry('UserTweets'), makeEntry('UserByScreenName')], baseDir); + const res = loadNetworkCache('ws', { baseDir }); + expect(res.status).toBe('ok'); + expect(res.file?.entries).toHaveLength(2); + expect(res.file?.entries[0].key).toBe('UserTweets'); + }); + + it('reports missing when cache file does not exist', () => { + expect(loadNetworkCache('nope', { baseDir }).status).toBe('missing'); + }); + + it('reports expired when the cache is older than ttl', () => { + saveNetworkCache('ws', [makeEntry('A')], baseDir); + const future = Date.now() + DEFAULT_TTL_MS + 60_000; + const res = loadNetworkCache('ws', { baseDir, now: future }); + expect(res.status).toBe('expired'); + expect(res.file?.entries).toHaveLength(1); + }); + + it('reports corrupt for malformed json', () => { + const file = getCachePath('ws', baseDir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{not json'); + expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt'); + }); + + it('reports corrupt for wrong schema version', () => { + const file = getCachePath('ws', baseDir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ version: 0, entries: [] })); + expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt'); + }); + + it('findEntry returns matching entry or null', () => { + const file: NetworkCacheFile = { + version: 1, session: 'ws', savedAt: new Date().toISOString(), + entries: [makeEntry('A'), makeEntry('B')], + }; + expect(findEntry(file, 'B')?.key).toBe('B'); + expect(findEntry(file, 'missing')).toBeNull(); + }); + + it.skipIf(process.platform === 'win32')('writes the cache file with 0o600 owner-only permissions', () => { + saveNetworkCache('ws', [makeEntry('UserTweets')], baseDir); + const target = getCachePath('ws', baseDir); + const mode = fs.statSync(target).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it.skipIf(process.platform === 'win32')('tightens an existing cache file before rewriting it', () => { + const target = getCachePath('ws', baseDir); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, '{"version":1,"session":"ws","savedAt":"old","entries":[]}', { mode: 0o644 }); + + saveNetworkCache('ws', [makeEntry('UserTweets')], baseDir); + + const mode = fs.statSync(target).mode & 0o777; + expect(mode).toBe(0o600); + const reloaded = loadNetworkCache('ws', { baseDir }); + expect(reloaded.status).toBe('ok'); + expect(reloaded.file?.entries[0].key).toBe('UserTweets'); + }); +}); diff --git a/src/browser/network-cache.ts b/src/browser/network-cache.ts new file mode 100644 index 0000000..4eb8659 --- /dev/null +++ b/src/browser/network-cache.ts @@ -0,0 +1,121 @@ +/** + * Persistent cache for browser network captures. + * + * The live capture buffer (JS interceptor / daemon ring) can be cleared + * by navigation or lost between CLI invocations. Agents still need + * stable references to request bodies after running other commands, + * so every `browser network` call snapshots its results to disk. + * + * Layout: /browser-network/.json + * Entries expire after DEFAULT_TTL_MS (24h). + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +export interface CachedNetworkEntry { + key: string; + url: string; + method: string; + status: number; + /** Full body size in chars (may exceed stored body length when truncated). */ + size: number; + ct: string; + body: unknown; + /** + * Truncation signals use snake_case so `--raw` (which emits cache entries + * verbatim) matches the agent-facing contract used by list / --detail. + */ + body_truncated?: boolean; + body_full_size?: number; + timestamp?: number; +} + +export interface NetworkCacheFile { + version: 1; + session: string; + savedAt: string; + entries: CachedNetworkEntry[]; +} + +function getDefaultCacheDir(): string { + return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache'); +} + +export function getCachePath(session: string, baseDir: string = getDefaultCacheDir()): string { + const safe = session.replace(/[^a-zA-Z0-9_-]+/g, '_'); + return path.join(baseDir, 'browser-network', `${safe}.json`); +} + +export function saveNetworkCache( + session: string, + entries: CachedNetworkEntry[], + baseDir?: string, +): void { + const target = getCachePath(session, baseDir); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const payload: NetworkCacheFile = { + version: 1, + session, + savedAt: new Date().toISOString(), + entries, + }; + // 0o600: entries can include auth tokens and PII from captured response + // bodies. fchmod before writing also tightens a pre-existing broad file. + let fd: number | undefined; + try { + fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, 0o600); + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, JSON.stringify(payload), 'utf8'); + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + +export interface LoadOptions { + baseDir?: string; + ttlMs?: number; + now?: number; +} + +export interface LoadResult { + status: 'ok' | 'missing' | 'expired' | 'corrupt'; + file?: NetworkCacheFile; + ageMs?: number; +} + +export function loadNetworkCache(session: string, opts: LoadOptions = {}): LoadResult { + const target = getCachePath(session, opts.baseDir); + let raw: string; + try { raw = fs.readFileSync(target, 'utf-8'); } + catch { return { status: 'missing' }; } + + let parsed: NetworkCacheFile; + try { + const obj = JSON.parse(raw); + if (!obj || obj.version !== 1 || !Array.isArray(obj.entries)) { + return { status: 'corrupt' }; + } + parsed = obj as NetworkCacheFile; + } catch { + return { status: 'corrupt' }; + } + + const ttl = opts.ttlMs ?? DEFAULT_TTL_MS; + const now = opts.now ?? Date.now(); + const savedAt = Date.parse(parsed.savedAt); + if (!Number.isFinite(savedAt)) return { status: 'corrupt' }; + const ageMs = now - savedAt; + if (ageMs > ttl) return { status: 'expired', file: parsed, ageMs }; + + return { status: 'ok', file: parsed, ageMs }; +} + +export function findEntry(file: NetworkCacheFile, key: string): CachedNetworkEntry | null { + return file.entries.find((e) => e.key === key) ?? null; +} diff --git a/src/browser/network-interceptor.ts b/src/browser/network-interceptor.ts new file mode 100644 index 0000000..1b3f786 --- /dev/null +++ b/src/browser/network-interceptor.ts @@ -0,0 +1,11 @@ +/** + * Injected page-side network interceptor. + * + * Used when the session-level capture channel (CDP/extension) is unavailable. + * It captures fetch/XHR response bodies while matching the CDP path's + * truncation contract: bodies above the per-entry cap are stored as a string + * prefix with `bodyTruncated: true` and `bodyFullSize` set. + * + * Keep this script dependency-free; it executes in the target page context. + */ +export const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body,timestamp:Date.now()};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`; diff --git a/src/browser/network-key.test.ts b/src/browser/network-key.test.ts new file mode 100644 index 0000000..1e56119 --- /dev/null +++ b/src/browser/network-key.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { assignKeys, deriveKey } from './network-key.js'; + +describe('deriveKey', () => { + it('extracts operationName from Twitter-style graphql URLs', () => { + expect(deriveKey({ + method: 'GET', + url: 'https://x.com/i/api/graphql/6fWQaBPK51aGyC_VC7t9GQ/UserTweets?variables=...', + })).toBe('UserTweets'); + }); + + it('handles graphql URLs without a query id', () => { + expect(deriveKey({ + method: 'POST', + url: 'https://example.com/graphql/MyOp?vars=1', + })).toBe('MyOp'); + }); + + it('uses METHOD host+pathname for REST calls', () => { + expect(deriveKey({ + method: 'get', + url: 'https://api.example.com/v1/users?page=1', + })).toBe('GET api.example.com/v1/users'); + }); + + it('falls back to truncated raw url when URL parsing fails', () => { + const key = deriveKey({ method: 'GET', url: 'not-a-valid-url' }); + expect(key.startsWith('GET ')).toBe(true); + expect(key).toContain('not-a-valid-url'); + }); +}); + +describe('assignKeys', () => { + it('disambiguates collisions with #N suffixes', () => { + const out = assignKeys([ + { url: 'https://x.com/i/api/graphql/a/UserTweets', method: 'GET' }, + { url: 'https://x.com/i/api/graphql/b/UserTweets', method: 'GET' }, + { url: 'https://api.example.com/v1/u', method: 'GET' }, + { url: 'https://api.example.com/v1/u', method: 'GET' }, + { url: 'https://api.example.com/v1/u', method: 'GET' }, + ]); + expect(out.map(o => o.key)).toEqual([ + 'UserTweets', + 'UserTweets#2', + 'GET api.example.com/v1/u', + 'GET api.example.com/v1/u#2', + 'GET api.example.com/v1/u#3', + ]); + }); + + it('preserves extra fields on each request', () => { + const out = assignKeys([{ url: 'https://a.com/x', method: 'GET', status: 200 }]); + expect(out[0]).toMatchObject({ status: 200, key: 'GET a.com/x' }); + }); +}); diff --git a/src/browser/network-key.ts b/src/browser/network-key.ts new file mode 100644 index 0000000..1ea7cca --- /dev/null +++ b/src/browser/network-key.ts @@ -0,0 +1,69 @@ +/** + * Stable keys for network capture entries. + * + * Agents reference entries by key (e.g. `UserTweets`, `GET api.x.com/1.1/home`) + * instead of array index, so the mapping survives new captures. + * + * Rules: + * GraphQL (URL contains `/graphql/`): key = operationName derived from URL path + * (the segment after a 22-char query id, or the last segment) + * Everything else: key = `METHOD host+pathname` + * + * On collision assignKeys suffixes duplicates as `base#2`, `base#3`, ... — + * the first occurrence stays bare (there is no `#1`). + */ + +export interface KeyableRequest { + url: string; + method: string; +} + +export function deriveKey(req: KeyableRequest): string { + const parsed = safeParseUrl(req.url); + if (!parsed) return `${req.method.toUpperCase()} ${truncate(req.url, 120)}`; + + const path = parsed.pathname; + if (path.includes('/graphql/')) { + const op = graphqlOperationName(path); + if (op) return op; + } + + return `${req.method.toUpperCase()} ${parsed.host}${path}`; +} + +export function assignKeys(requests: T[]): Array { + const counts = new Map(); + const out: Array = []; + for (const req of requests) { + const base = deriveKey(req); + const n = counts.get(base) ?? 0; + counts.set(base, n + 1); + const key = n === 0 ? base : `${base}#${n + 1}`; + out.push({ ...req, key }); + } + return out; +} + +function graphqlOperationName(pathname: string): string | null { + // Patterns we've seen in the wild: + // /i/api/graphql//UserTweets + // /graphql//SomeOp + // /graphql/SomeOp (rare, no id) + const segments = pathname.split('/').filter(Boolean); + const idx = segments.indexOf('graphql'); + if (idx < 0) return null; + const tail = segments.slice(idx + 1); + if (tail.length === 0) return null; + if (tail.length === 1) return tail[0]; + // tail[0] is usually a query id; the operation name is the next segment. + return tail[1] || tail[0]; +} + +function safeParseUrl(url: string): URL | null { + try { return new URL(url); } + catch { return null; } +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts new file mode 100644 index 0000000..549ea1b --- /dev/null +++ b/src/browser/page.test.ts @@ -0,0 +1,509 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({ + sendCommandMock: vi.fn(), + sendCommandFullMock: vi.fn(), +})); +const { warnMock } = vi.hoisted(() => ({ + warnMock: vi.fn(), +})); + +vi.mock('./daemon-client.js', () => ({ + sendCommand: sendCommandMock, + sendCommandFull: sendCommandFullMock, +})); +vi.mock('../logger.js', () => ({ + log: { + warn: warnMock, + }, +})); + +import { Page } from './page.js'; + +describe('Page.getCurrentUrl', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('reads the real browser URL when no local navigation cache exists', async () => { + sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live'); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + const url = await page.getCurrentUrl(); + + expect(url).toBe('https://notebooklm.google.com/notebook/nb-live'); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + session: 'notebooklm', + surface: 'adapter', + })); + }); + + it('caches the discovered browser URL for later reads', async () => { + sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live'); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live'); + expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live'); + + expect(sendCommandMock).toHaveBeenCalledTimes(1); + }); + + it('passes adapter site session lifecycle through daemon commands', async () => { + sendCommandFullMock.mockResolvedValueOnce({ page: 'page-1', data: { url: 'https://chatgpt.com/' } }); + sendCommandMock.mockResolvedValueOnce(null); + + const page = new Page('site:chatgpt', undefined, undefined, undefined, 'adapter', 'persistent'); + + await page.goto('https://chatgpt.com/', { waitUntil: 'none' }); + await page.evaluate('document.title'); + + expect(sendCommandFullMock).toHaveBeenCalledWith('navigate', expect.objectContaining({ + session: 'site:chatgpt', + surface: 'adapter', + siteSession: 'persistent', + })); + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + session: 'site:chatgpt', + surface: 'adapter', + siteSession: 'persistent', + page: 'page-1', + })); + }); +}); + +describe('Page.evaluate', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('retries once when the inspected target navigated during exec', async () => { + sendCommandMock + .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}')) + .mockResolvedValueOnce(42); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + const value = await page.evaluate('21 + 21'); + + expect(value).toBe(42); + expect(sendCommandMock).toHaveBeenCalledTimes(2); + }); + + it('serializes function-form evaluate calls with JSON args', async () => { + sendCommandMock.mockResolvedValueOnce('/opencli'); + + const page = new Page('twitter', undefined, undefined, undefined, 'adapter'); + const href = await page.evaluate((selector: string) => { + const link = document.querySelector(selector); + return link ? link.getAttribute('href') : null; + }, 'a[data-testid="AppTabBar_Profile_Link"]'); + + expect(href).toBe('/opencli'); + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + session: 'twitter', + surface: 'adapter', + code: expect.stringContaining('(...["a[data-testid=\\"AppTabBar_Profile_Link\\"]"])'), + })); + const code = sendCommandMock.mock.calls[0]?.[1]?.code as string; + expect(code).toContain('document.querySelector(selector)'); + }); + + it('rejects non-JSON-serializable evaluate args before sending to the daemon', async () => { + const page = new Page('default'); + const circular: Record = {}; + circular.self = circular; + + await expect(page.evaluate((value: unknown) => value, circular)).rejects.toThrow('JSON-serializable'); + expect(sendCommandMock).not.toHaveBeenCalled(); + }); + + it('keeps string evaluate behavior unchanged', async () => { + sendCommandMock.mockResolvedValueOnce(42); + + const page = new Page('default'); + await expect(page.evaluate('21 + 21')).resolves.toBe(42); + + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + code: '21 + 21', + })); + }); +}); + +describe('Page network capture compatibility', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('treats unknown network-capture-start as unsupported and memoizes it', async () => { + sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start')); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + + await expect(page.startNetworkCapture()).resolves.toBe(false); + await expect(page.startNetworkCapture()).resolves.toBe(false); + + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture')); + expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({ + session: 'notebooklm', + surface: 'adapter', + })); + }); + + it('returns an empty capture when network-capture-read is unsupported', async () => { + sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read')); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + + await expect(page.readNetworkCapture()).resolves.toEqual([]); + await expect(page.readNetworkCapture()).resolves.toEqual([]); + + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledTimes(1); + expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({ + session: 'notebooklm', + surface: 'adapter', + })); + }); + + it('rethrows unrelated network capture failures', async () => { + sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected')); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + + await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected'); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('warns only once even if both start and read hit the compatibility fallback', async () => { + sendCommandMock + .mockRejectedValueOnce(new Error('Unknown action: network-capture-start')) + .mockRejectedValueOnce(new Error('Unknown action: network-capture-read')); + + const page = new Page('notebooklm', undefined, undefined, undefined, 'adapter'); + + await expect(page.startNetworkCapture()).resolves.toBe(false); + await expect(page.readNetworkCapture()).resolves.toEqual([]); + + expect(warnMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('Page download waits', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('sends wait-download through the daemon with session and timeout', async () => { + sendCommandMock.mockResolvedValueOnce({ + downloaded: true, + filename: '/tmp/receipt.pdf', + state: 'complete', + elapsedMs: 5, + }); + + const page = new Page('mercury', undefined, undefined, undefined, 'adapter'); + const result = await page.waitForDownload('receipt', 1234); + + expect(result).toEqual({ + downloaded: true, + filename: '/tmp/receipt.pdf', + state: 'complete', + elapsedMs: 5, + }); + expect(sendCommandMock).toHaveBeenCalledWith('wait-download', expect.objectContaining({ + session: 'mercury', + surface: 'adapter', + pattern: 'receipt', + timeoutMs: 1234, + })); + }); +}); + +describe('Page CDP helpers', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('handles JavaScript dialogs through the CDP passthrough', async () => { + sendCommandMock.mockResolvedValueOnce({}); + + const page = new Page('default'); + await page.handleJavaScriptDialog(true, 'confirm'); + + expect(sendCommandMock).toHaveBeenCalledWith('cdp', expect.objectContaining({ + session: 'default', + surface: 'browser', + cdpMethod: 'Page.handleJavaScriptDialog', + cdpParams: { accept: true, promptText: 'confirm' }, + })); + }); +}); + +describe('Page active target tracking', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('tracks only one active page identity at a time', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' }) + .mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' }); + sendCommandMock.mockResolvedValue('ok'); + + const page = new Page('default'); + + await page.goto('https://first.example', { waitUntil: 'none' }); + expect(page.getActivePage()).toBe('page-1'); + + await page.selectTab(1); + expect(page.getActivePage()).toBe('page-2'); + + await page.evaluate('1 + 1'); + + expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({ + session: 'default', + surface: 'browser', + page: 'page-2', + })); + }); + + it('allows the caller to bind a specific active page identity explicitly', async () => { + sendCommandMock.mockResolvedValue('bound'); + + const page = new Page('default'); + page.setActivePage?.('page-explicit'); + + await page.evaluate('1 + 1'); + + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + session: 'default', + surface: 'browser', + page: 'page-explicit', + })); + }); + + // Regression: a Page instance can keep re-sending a cached targetId after the tab + // has been closed externally, so the extension throws + // "Page not found: — stale page identity" on follow-up navigation. + // goto() now drops the stale identity and retries once without it so the extension's + // session lease can resolve through to a live tab. + it('drops a stale page identity and retries navigate once', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' }) + .mockRejectedValueOnce(new Error('Page not found: deadbeef — stale page identity')) + .mockResolvedValueOnce({ data: { url: 'https://example.com/second' }, page: 'page-2' }); + + const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent'); + + await page.goto('https://example.com/first', { waitUntil: 'none' }); + expect(page.getActivePage()).toBe('page-1'); + + await page.goto('https://example.com/second', { waitUntil: 'none' }); + expect(page.getActivePage()).toBe('page-2'); + + expect(sendCommandFullMock).toHaveBeenCalledTimes(3); + // First retry attempt carried the stale page; the recovery call must drop it. + const retryCall = sendCommandFullMock.mock.calls[2]; + expect(retryCall[0]).toBe('navigate'); + expect(retryCall[1]).not.toHaveProperty('page'); + }); + + it('retries on a bare "Page not found:" error without the stale-identity suffix', async () => { + // Under concurrent adapter calls the extension can reject with just + // "Page not found: " (no "— stale page identity" suffix) when the cached + // targetId was evicted. That still means the identity is dead, so goto() must + // drop it and retry once instead of cascading failures. + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' }) + .mockRejectedValueOnce(new Error('Page not found: deadbeef')) + .mockResolvedValueOnce({ data: { url: 'https://example.com/second' }, page: 'page-2' }); + + const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent'); + + await page.goto('https://example.com/first', { waitUntil: 'none' }); + await page.goto('https://example.com/second', { waitUntil: 'none' }); + expect(page.getActivePage()).toBe('page-2'); + + expect(sendCommandFullMock).toHaveBeenCalledTimes(3); + const retryCall = sendCommandFullMock.mock.calls[2]; + expect(retryCall[0]).toBe('navigate'); + expect(retryCall[1]).not.toHaveProperty('page'); + }); + + it('does not retry stale page errors when no identity was cached', async () => { + // _page is undefined on a fresh Page — there's nothing to drop, so propagate the + // error instead of silently retrying with the same params. + sendCommandFullMock + .mockRejectedValueOnce(new Error('Page not found: deadbeef — stale page identity')); + + const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent'); + + await expect(page.goto('https://example.com', { waitUntil: 'none' })) + .rejects.toThrow('stale page identity'); + expect(sendCommandFullMock).toHaveBeenCalledTimes(1); + }); + + it('propagates non-stale navigate errors unchanged', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' }) + .mockRejectedValueOnce(new Error('Extension disconnected')); + + const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent'); + + await page.goto('https://example.com/first', { waitUntil: 'none' }); + await expect(page.goto('https://example.com/second', { waitUntil: 'none' })) + .rejects.toThrow('Extension disconnected'); + // No retry for unrelated errors — exactly two navigate calls total. + expect(sendCommandFullMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry unrelated navigate errors that only mention Page not found in details', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' }) + .mockRejectedValueOnce(new Error('Navigation failed: upstream says Page not found: /missing')); + + const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent'); + + await page.goto('https://example.com/first', { waitUntil: 'none' }); + await expect(page.goto('https://example.com/missing', { waitUntil: 'none' })) + .rejects.toThrow('Navigation failed'); + expect(sendCommandFullMock).toHaveBeenCalledTimes(2); + }); + + it('creates a new tab without changing the current active page binding', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' }) + .mockResolvedValueOnce({ + data: { url: 'https://second.example' }, + page: 'page-2', + }); + sendCommandMock.mockResolvedValue('ok'); + + const page = new Page('default'); + await page.goto('https://first.example', { waitUntil: 'none' }); + + const created = await page.newTab?.('https://second.example'); + + expect(created).toBe('page-2'); + expect(page.getActivePage()).toBe('page-1'); + await page.evaluate('1 + 1'); + expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({ + session: 'default', + surface: 'browser', + page: 'page-1', + })); + }); + + it('allows the caller to adopt a new tab explicitly after creation', async () => { + sendCommandFullMock.mockResolvedValueOnce({ + data: { url: 'https://second.example' }, + page: 'page-2', + }); + + const page = new Page('default'); + const created = await page.newTab?.('https://second.example'); + + expect(created).toBe('page-2'); + expect(page.getActivePage()).toBeUndefined(); + + page.setActivePage?.(created); + expect(page.getActivePage()).toBe('page-2'); + expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({ + op: 'new', + url: 'https://second.example', + session: 'default', + surface: 'browser', + })); + }); + + it('closes a tab by explicit page identity', async () => { + sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' }); + + const page = new Page('default'); + await page.closeTab?.('page-2'); + + expect(sendCommandMock).toHaveBeenCalledWith('tabs', expect.objectContaining({ + op: 'close', + session: 'default', + surface: 'browser', + page: 'page-2', + })); + }); + + it('clears the active page binding when closing the selected tab by numeric index', async () => { + sendCommandFullMock.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' }); + sendCommandMock + .mockResolvedValueOnce({ closed: 'page-2' }) + .mockResolvedValueOnce('ok'); + + const page = new Page('default'); + + await page.selectTab(1); + expect(page.getActivePage()).toBe('page-2'); + + await page.closeTab?.(1); + expect(page.getActivePage()).toBeUndefined(); + + await page.evaluate('1 + 1'); + + const evalCall = sendCommandMock.mock.calls.at(-1); + expect(evalCall?.[0]).toBe('exec'); + expect(evalCall?.[1]).toEqual(expect.objectContaining({ + session: 'default', + surface: 'browser', + })); + expect(evalCall?.[1]).not.toHaveProperty('page'); + }); +}); + +describe('Page.screenshot', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('forwards width / height / fullPage options to the bridge', async () => { + sendCommandMock.mockResolvedValueOnce('BASE64'); + + const page = new Page('default'); + const data = await page.screenshot({ fullPage: true, width: 1080 }); + + expect(data).toBe('BASE64'); + expect(sendCommandMock).toHaveBeenCalledWith('screenshot', expect.objectContaining({ + session: 'default', + surface: 'browser', + fullPage: true, + width: 1080, + })); + }); + + it('omits viewport overrides when none are set', async () => { + sendCommandMock.mockResolvedValueOnce('BASE64'); + + const page = new Page('default'); + await page.screenshot(); + + const call = sendCommandMock.mock.calls.at(-1); + expect(call?.[0]).toBe('screenshot'); + const args = call?.[1] as Record; + expect(args.width).toBeUndefined(); + expect(args.height).toBeUndefined(); + expect(args.fullPage).toBeUndefined(); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts new file mode 100644 index 0000000..7994ff3 --- /dev/null +++ b/src/browser/page.ts @@ -0,0 +1,471 @@ +/** + * Page abstraction — implements IPage by sending commands to the daemon. + * + * All browser operations are ultimately 'exec' (JS evaluation via CDP) + * plus a few native Chrome Extension APIs (tabs, cookies, navigate). + * + * IMPORTANT: After goto(), we remember the page identity (targetId) returned + * by the navigate action and pass it to all subsequent commands. This ensures + * page-scoped operations target the correct page without guessing. + */ + +import type { BrowserCookie, BrowserDownloadWaitResult, BrowserEvaluateFunction, ScreenshotOptions } from '../types.js'; +import { sendCommand, sendCommandFull } from './daemon-client.js'; +import { buildEvaluateExpression } from './utils.js'; +import { saveBase64ToFile } from '../utils.js'; +import { generateStealthJs } from './stealth.js'; +import { waitForDomStableJs } from './dom-helpers.js'; +import { BasePage } from './base-page.js'; +import { classifyBrowserError } from './errors.js'; +import { log } from '../logger.js'; + +function isUnsupportedNetworkCaptureError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const normalized = message.toLowerCase(); + return (normalized.includes('unknown action') && normalized.includes('network-capture')) + || (normalized.includes('network capture') && normalized.includes('not supported')); +} + +// The extension throws "Page not found: — stale page identity" when our cached +// `_page` targetId no longer maps to a live tab — e.g. the user closed the automation +// window, or a long-running script left the cache pointing at an evicted target. +// Detect that signature so goto() can drop the stale id and let resolveTab fall back +// to the session lease (or create a fresh tab). +function isStalePageIdentityError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes('stale page identity') || /^Page not found:\s*\S+\s*$/.test(message); +} + +/** + * Page — implements IPage by talking to the daemon via HTTP. + */ +export class Page extends BasePage { + private readonly _idleTimeout: number | undefined; + + constructor( + private readonly session: string, + idleTimeout?: number, + public readonly contextId?: string, + private readonly windowMode?: 'foreground' | 'background', + private readonly surface: 'browser' | 'adapter' = 'browser', + private readonly siteSession?: 'ephemeral' | 'persistent', + /** Soft profile preference (config default) — daemon arbitrates; see profileRouteParams. */ + public readonly preferredContextId?: string, + ) { + super(); + this._idleTimeout = idleTimeout; + } + + /** Active page identity (targetId), set after navigate and used in all subsequent commands */ + private _page: string | undefined; + private _networkCaptureUnsupported = false; + private _networkCaptureWarned = false; + + /** Helper: spread session into command params */ + private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + return { + session: this.session, + surface: this.surface, + ...(this.contextId && { contextId: this.contextId }), + ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), + ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), + ...(this.windowMode && { windowMode: this.windowMode }), + ...(this.siteSession && { siteSession: this.siteSession }), + }; + } + + /** Helper: spread session + page identity into command params */ + private _cmdOpts(): Record { + return { + session: this.session, + surface: this.surface, + ...(this.contextId && { contextId: this.contextId }), + ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), + ...(this._page !== undefined && { page: this._page }), + ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), + ...(this.windowMode && { windowMode: this.windowMode }), + ...(this.siteSession && { siteSession: this.siteSession }), + }; + } + + async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise { + let result: { data: unknown; page?: string }; + try { + result = await sendCommandFull('navigate', { + url, + ...this._cmdOpts(), + }); + } catch (err) { + // If our cached targetId went stale (tab closed externally, identity evicted), + // drop the dead id and retry without it — the extension will resolve through the + // session lease or open a fresh automation tab. Without this, every subsequent + // adapter call in the same process keeps re-sending the same dead targetId and + // cascades into "Page not found:" failures across concurrent calls. + if (!isStalePageIdentityError(err) || this._page === undefined) throw err; + this._page = undefined; + result = await sendCommandFull('navigate', { + url, + ...this._cmdOpts(), + }); + } + // Remember the page identity (targetId) for subsequent calls + if (result.page) { + this._page = result.page; + } + this._lastUrl = url; + // Inject stealth + settle in a single round-trip instead of two sequential exec calls. + // The stealth guard flag prevents double-injection; settle uses DOM stability detection. + if (options?.waitUntil !== 'none') { + const maxMs = options?.settleMs ?? 1000; + const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs, Math.min(500, maxMs))}`; + const combinedOpts = { + code: combinedCode, + ...this._cmdOpts(), + }; + try { + await sendCommand('exec', combinedOpts); + } catch (err) { + const advice = classifyBrowserError(err); + // Only settle-retry on target navigation (SPA client-side redirects). + // Extension/daemon errors are already retried by sendCommandRaw — + // retrying them here would silently swallow real failures. + if (advice.kind !== 'target-navigation') throw err; + try { + await new Promise((r) => setTimeout(r, advice.delayMs)); + await sendCommand('exec', combinedOpts); + } catch (retryErr) { + if (classifyBrowserError(retryErr).kind !== 'target-navigation') throw retryErr; + } + } + } else { + // Even with waitUntil='none', still inject stealth (best-effort) + try { + await sendCommand('exec', { + code: generateStealthJs(), + ...this._cmdOpts(), + }); + } catch { + // Non-fatal: stealth is best-effort + } + } + } + + /** Get the active page identity (targetId) */ + getActivePage(): string | undefined { + return this._page; + } + + /** Bind this Page instance to a specific page identity (targetId). */ + setActivePage(page?: string): void { + this._page = page; + this._lastUrl = null; + } + private _markUnsupportedNetworkCapture(): void { + this._networkCaptureUnsupported = true; + if (this._networkCaptureWarned) return; + this._networkCaptureWarned = true; + log.warn( + 'Browser Bridge extension does not support network capture; continuing without it. ' + + 'Explore output may miss API endpoints until you reload or reinstall the extension.', + ); + } + + async evaluate(js: string): Promise; + async evaluate(fn: BrowserEvaluateFunction, ...args: Args): Promise>; + async evaluate(input: string | BrowserEvaluateFunction, ...args: unknown[]): Promise { + const code = buildEvaluateExpression(input, args); + try { + return await sendCommand('exec', { code, ...this._cmdOpts() }); + } catch (err) { + const advice = classifyBrowserError(err); + if (advice.kind !== 'target-navigation') throw err; + await new Promise((resolve) => setTimeout(resolve, advice.delayMs)); + return sendCommand('exec', { code, ...this._cmdOpts() }); + } + } + + async getCookies(opts: { domain?: string; url?: string } = {}): Promise { + const result = await sendCommand('cookies', { ...this._sessionOpts(), ...opts }); + return Array.isArray(result) ? result : []; + } + + /** Release the current browser session lease in the extension */ + async closeWindow(): Promise { + try { + await sendCommand('close-window', { ...this._sessionOpts() }); + } catch { + // Window may already be closed or daemon may be down + } finally { + this._page = undefined; + this._lastUrl = null; + this._networkCaptureUnsupported = false; + this._networkCaptureWarned = false; + } + } + + async tabs(): Promise { + const result = await sendCommand('tabs', { op: 'list', ...this._sessionOpts() }); + return Array.isArray(result) ? result : []; + } + + async newTab(url?: string): Promise { + const result = await sendCommandFull('tabs', { + op: 'new', + ...(url !== undefined && { url }), + ...this._sessionOpts(), + }); + this._lastUrl = null; + return result.page; + } + + async closeTab(target?: number | string): Promise { + const params: Record = { op: 'close', ...this._sessionOpts() }; + if (typeof target === 'number') params.index = target; + else if (typeof target === 'string') params.page = target; + else if (this._page !== undefined) params.page = this._page; + + const result = await sendCommand('tabs', params) as { closed?: string } | null; + const closedPage = typeof result?.closed === 'string' ? result.closed : undefined; + + if ((closedPage && closedPage === this._page) || (!closedPage && (target === undefined || target === this._page))) { + this._page = undefined; + this._lastUrl = null; + } + } + + async selectTab(target: number | string): Promise { + const result = await sendCommandFull('tabs', { + op: 'select', + ...(typeof target === 'number' ? { index: target } : { page: target }), + ...this._sessionOpts(), + }); + if (result.page) this._page = result.page; + this._lastUrl = null; + } + + /** + * Capture a screenshot via CDP Page.captureScreenshot. + */ + async screenshot(options: ScreenshotOptions = {}): Promise { + const base64 = await sendCommand('screenshot', { + ...this._cmdOpts(), + format: options.format, + quality: options.quality, + fullPage: options.fullPage, + width: options.width, + height: options.height, + }) as string; + + if (options.path) { + await saveBase64ToFile(base64, options.path); + } + + return base64; + } + + async startNetworkCapture(pattern: string = ''): Promise { + if (this._networkCaptureUnsupported) return false; + try { + await sendCommand('network-capture-start', { + pattern, + ...this._cmdOpts(), + }); + return true; + } catch (err) { + if (!isUnsupportedNetworkCaptureError(err)) throw err; + this._markUnsupportedNetworkCapture(); + return false; + } + } + + async readNetworkCapture(): Promise { + if (this._networkCaptureUnsupported) return []; + try { + const result = await sendCommand('network-capture-read', { + ...this._cmdOpts(), + }); + return Array.isArray(result) ? result : []; + } catch (err) { + if (!isUnsupportedNetworkCaptureError(err)) throw err; + this._markUnsupportedNetworkCapture(); + return []; + } + } + + async waitForDownload(pattern: string = '', timeoutMs: number = 30_000): Promise { + const result = await sendCommand('wait-download', { + pattern, + timeoutMs, + ...this._cmdOpts(), + }); + return result as BrowserDownloadWaitResult; + } + + /** + * Set local file paths on a file input element via CDP DOM.setFileInputFiles. + * Chrome reads the files directly from the local filesystem, avoiding the + * payload size limits of base64-in-evaluate. + */ + async setFileInput(files: string[], selector?: string): Promise { + const result = await sendCommand('set-file-input', { + files, + selector, + ...this._cmdOpts(), + }) as { count?: number }; + if (!result?.count) { + throw new Error('setFileInput returned no count — command may not be supported by the extension'); + } + } + + async insertText(text: string): Promise { + const result = await sendCommand('insert-text', { + text, + ...this._cmdOpts(), + }) as { inserted?: boolean }; + if (!result?.inserted) { + throw new Error('insertText returned no inserted flag — command may not be supported by the extension'); + } + } + + async frames(): Promise> { + const result = await sendCommand('frames', { ...this._cmdOpts() }); + return Array.isArray(result) ? result : []; + } + + async evaluateInFrame(js: string, frameIndex: number): Promise { + const code = buildEvaluateExpression(js); + return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() }); + } + + async cdp(method: string, params: Record = {}): Promise { + return sendCommand('cdp', { + cdpMethod: method, + cdpParams: params, + ...this._cmdOpts(), + }); + } + + async handleJavaScriptDialog(accept: boolean, promptText?: string): Promise { + await this.cdp('Page.handleJavaScriptDialog', { + accept, + ...(promptText !== undefined && { promptText }), + }); + } + + /** CDP native click fallback — called when JS el.click() fails */ + protected override async tryNativeClick(x: number, y: number): Promise { + try { + await this.nativeClick(x, y); + return true; + } catch { + return false; + } + } + + /** Precise click using DOM.getContentQuads/getBoxModel for inline elements */ + async clickWithQuads(ref: string): Promise { + const safeRef = JSON.stringify(ref); + const cssSelector = `[data-opencli-ref="${ref.replace(/"/g, '\\"')}"]`; + + // Scroll element into view first + await this.evaluate(` + (() => { + const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]'); + if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + return !!el; + })() + `); + + try { + // Find DOM node via CDP + const doc = await this.cdp('DOM.getDocument', {}) as { root: { nodeId: number } }; + const result = await this.cdp('DOM.querySelectorAll', { + nodeId: doc.root.nodeId, + selector: cssSelector, + }) as { nodeIds: number[] }; + + if (!result.nodeIds?.length) throw new Error('DOM node not found'); + + const nodeId = result.nodeIds[0]; + + // Try getContentQuads first (precise for inline elements) + try { + const quads = await this.cdp('DOM.getContentQuads', { nodeId }) as { quads: number[][] }; + if (quads.quads?.length) { + const q = quads.quads[0]; + const cx = (q[0] + q[2] + q[4] + q[6]) / 4; + const cy = (q[1] + q[3] + q[5] + q[7]) / 4; + await this.nativeClick(Math.round(cx), Math.round(cy)); + return; + } + } catch { /* fallthrough */ } + + // Try getBoxModel + try { + const box = await this.cdp('DOM.getBoxModel', { nodeId }) as { model: { content: number[] } }; + if (box.model?.content) { + const c = box.model.content; + const cx = (c[0] + c[2] + c[4] + c[6]) / 4; + const cy = (c[1] + c[3] + c[5] + c[7]) / 4; + await this.nativeClick(Math.round(cx), Math.round(cy)); + return; + } + } catch { /* fallthrough */ } + } catch { /* fallthrough */ } + + // Final fallback: regular click + await this.evaluate(` + (() => { + const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]'); + if (!el) throw new Error('Element not found: ' + ${safeRef}); + el.click(); + return 'clicked'; + })() + `); + } + + async nativeClick(x: number, y: number): Promise { + await this.cdp('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + }); + await this.cdp('Input.dispatchMouseEvent', { + type: 'mousePressed', + x, y, + button: 'left', + clickCount: 1, + }); + await this.cdp('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, y, + button: 'left', + clickCount: 1, + }); + } + + async nativeType(text: string): Promise { + // Use Input.insertText for reliable Unicode/CJK text insertion + await this.cdp('Input.insertText', { text }); + } + + async nativeKeyPress(key: string, modifiers: string[] = []): Promise { + let modifierFlags = 0; + for (const mod of modifiers) { + if (mod === 'Alt') modifierFlags |= 1; + if (mod === 'Ctrl' || mod === 'Control') modifierFlags |= 2; + if (mod === 'Meta') modifierFlags |= 4; + if (mod === 'Shift') modifierFlags |= 8; + } + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyDown', + key, + modifiers: modifierFlags, + }); + await this.cdp('Input.dispatchKeyEvent', { + type: 'keyUp', + key, + modifiers: modifierFlags, + }); + } +} diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts new file mode 100644 index 0000000..c030af3 --- /dev/null +++ b/src/browser/profile.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { profileRouteParams, resolveProfileSelection } from './profile.js'; + +describe('profile selection (requirement vs preference)', () => { + let configDir: string; + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-profile-test-')); + vi.stubEnv('OPENCLI_CONFIG_DIR', configDir); + vi.stubEnv('OPENCLI_PROFILE', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + function writeConfig(config: object): void { + fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify(config)); + } + + it('tags an explicit --profile argument as explicit and resolves aliases', () => { + writeConfig({ version: 1, aliases: { work: 'zvypsyje' } }); + expect(resolveProfileSelection('work')).toEqual({ contextId: 'zvypsyje', source: 'explicit' }); + }); + + it('tags OPENCLI_PROFILE env as explicit', () => { + vi.stubEnv('OPENCLI_PROFILE', 'pavmrekj'); + expect(resolveProfileSelection()).toEqual({ contextId: 'pavmrekj', source: 'explicit' }); + }); + + it('tags the persisted config default as preferred, not explicit', () => { + writeConfig({ version: 1, aliases: {}, defaultContextId: 'zvypsyje' }); + expect(resolveProfileSelection()).toEqual({ contextId: 'zvypsyje', source: 'preferred' }); + }); + + it('explicit argument beats env beats config default', () => { + vi.stubEnv('OPENCLI_PROFILE', 'from-env'); + writeConfig({ version: 1, aliases: {}, defaultContextId: 'from-config' }); + expect(resolveProfileSelection('from-arg')).toEqual({ contextId: 'from-arg', source: 'explicit' }); + expect(resolveProfileSelection()).toEqual({ contextId: 'from-env', source: 'explicit' }); + }); + + it('returns undefined with no argument, env, or config default', () => { + expect(resolveProfileSelection()).toBeUndefined(); + }); + + it('profileRouteParams maps explicit → contextId and preferred → preferredContextId', () => { + expect(profileRouteParams({ contextId: 'a', source: 'explicit' })).toEqual({ contextId: 'a' }); + expect(profileRouteParams({ contextId: 'b', source: 'preferred' })).toEqual({ preferredContextId: 'b' }); + expect(profileRouteParams(undefined)).toEqual({}); + }); +}); diff --git a/src/browser/profile.ts b/src/browser/profile.ts new file mode 100644 index 0000000..46fa687 --- /dev/null +++ b/src/browser/profile.ts @@ -0,0 +1,128 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export const DEFAULT_CONTEXT_ID = 'default'; + +export type ProfileConfig = { + version: 1; + defaultContextId?: string; + aliases: Record; +}; + +function profileConfigPath(): string { + const baseDir = process.env.OPENCLI_CONFIG_DIR || path.join(os.homedir(), '.opencli'); + return path.join(baseDir, 'browser-profiles.json'); +} + +export function normalizeContextId(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +export function emptyProfileConfig(): ProfileConfig { + return { version: 1, aliases: {} }; +} + +export function loadProfileConfig(): ProfileConfig { + try { + const raw = fs.readFileSync(profileConfigPath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const aliases = parsed.aliases && typeof parsed.aliases === 'object' + ? Object.fromEntries(Object.entries(parsed.aliases).filter((entry): entry is [string, string] => { + const [key, value] = entry; + return typeof key === 'string' && key.trim().length > 0 + && typeof value === 'string' && value.trim().length > 0; + })) + : {}; + return { + version: 1, + aliases, + ...(typeof parsed.defaultContextId === 'string' && parsed.defaultContextId.trim() + ? { defaultContextId: parsed.defaultContextId.trim() } + : {}), + }; + } catch { + return emptyProfileConfig(); + } +} + +export function saveProfileConfig(config: ProfileConfig): void { + const target = profileConfigPath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} + +export type ProfileSelection = { + contextId: string; + /** + * 'explicit' — the user demanded this profile right now (--profile argument + * or OPENCLI_PROFILE env): route strictly and fail loud if it is offline. + * 'preferred' — the persisted default from browser-profiles.json. A default + * is a preference, not a requirement: its lifetime routinely exceeds the + * extension instance it names (reinstalling the extension regenerates the + * contextId), so the daemon may fall back to the only connected profile + * when the preferred one is offline. + */ + source: 'explicit' | 'preferred'; +}; + +export function resolveProfileSelection(profile?: string): ProfileSelection | undefined { + const config = loadProfileConfig(); + const explicit = normalizeContextId(profile) ?? normalizeContextId(process.env.OPENCLI_PROFILE); + if (explicit) return { contextId: config.aliases[explicit] ?? explicit, source: 'explicit' }; + const preferred = normalizeContextId(config.defaultContextId); + if (preferred) return { contextId: config.aliases[preferred] ?? preferred, source: 'preferred' }; + return undefined; +} + +/** + * Map a selection to wire/connect routing params. Exactly one of the two + * fields is set — `contextId` is a hard requirement, `preferredContextId` + * lets the daemon arbitrate against live connections. + */ +export function profileRouteParams( + selection: ProfileSelection | undefined, +): { contextId?: string; preferredContextId?: string } { + if (!selection) return {}; + return selection.source === 'explicit' + ? { contextId: selection.contextId } + : { preferredContextId: selection.contextId }; +} + +export function resolveProfileContextId(profile?: string): string | undefined { + return resolveProfileSelection(profile)?.contextId; +} + +export function aliasForContextId(config: ProfileConfig, contextId: string): string | undefined { + for (const [alias, id] of Object.entries(config.aliases)) { + if (id === contextId) return alias; + } + return undefined; +} + +export function renameProfile(contextId: string, alias: string): ProfileConfig { + const normalizedContextId = normalizeContextId(contextId); + const normalizedAlias = normalizeContextId(alias); + if (!normalizedContextId) throw new Error('profile contextId is required'); + if (!normalizedAlias) throw new Error('profile alias is required'); + + const config = loadProfileConfig(); + for (const [existingAlias, existingContextId] of Object.entries(config.aliases)) { + if (existingAlias !== normalizedAlias && existingContextId === normalizedContextId) { + delete config.aliases[existingAlias]; + } + } + config.aliases[normalizedAlias] = normalizedContextId; + saveProfileConfig(config); + return config; +} + +export function setDefaultProfile(profile: string): ProfileConfig { + const contextId = resolveProfileContextId(profile) ?? normalizeContextId(profile); + if (!contextId) throw new Error('profile is required'); + const config = loadProfileConfig(); + config.defaultContextId = contextId; + saveProfileConfig(config); + return config; +} diff --git a/src/browser/shape-filter.test.ts b/src/browser/shape-filter.test.ts new file mode 100644 index 0000000..6d7378a --- /dev/null +++ b/src/browser/shape-filter.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import type { Shape } from './shape.js'; +import { + collectShapeSegments, + extractSegments, + parseFilter, + shapeMatchesFilter, +} from './shape-filter.js'; + +describe('parseFilter', () => { + it('splits comma-separated fields, trims, and drops empty tokens', () => { + const r = parseFilter('author, text , likes'); + expect(r).toEqual({ fields: ['author', 'text', 'likes'] }); + }); + + it('dedupes while preserving first-seen order', () => { + const r = parseFilter('a,b,a,c,b'); + expect(r).toEqual({ fields: ['a', 'b', 'c'] }); + }); + + it('rejects empty string as invalid_filter', () => { + const r = parseFilter(''); + expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') }); + }); + + it('rejects whitespace-only as invalid_filter', () => { + const r = parseFilter(' '); + expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') }); + }); + + it('rejects commas-only as invalid_filter', () => { + const r = parseFilter(',,,'); + expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') }); + }); + + it('accepts a single field', () => { + expect(parseFilter('author')).toEqual({ fields: ['author'] }); + }); +}); + +describe('extractSegments', () => { + it('returns empty for root', () => { + expect(extractSegments('$')).toEqual([]); + }); + + it('splits dotted path and drops $', () => { + expect(extractSegments('$.data.user.name')).toEqual(['data', 'user', 'name']); + }); + + it('drops numeric array indices', () => { + expect(extractSegments('$.items[0].author')).toEqual(['items', 'author']); + expect(extractSegments('$.rows[0][12]')).toEqual(['rows']); + }); + + it('unwraps bracket-quoted keys', () => { + expect(extractSegments('$.data["weird key"]')).toEqual(['data', 'weird key']); + }); + + it('handles bracket-quoted keys at root', () => { + expect(extractSegments('$["123bad"]')).toEqual(['123bad']); + }); + + it('mixes bracket keys and dot segments', () => { + expect(extractSegments('$.data.user["nick name"].age')) + .toEqual(['data', 'user', 'nick name', 'age']); + }); +}); + +describe('collectShapeSegments', () => { + it('collects every segment name from every path in a shape', () => { + const shape: Shape = { + '$': 'object', + '$.data': 'object', + '$.data.items': 'array(3)', + '$.data.items[0]': 'object', + '$.data.items[0].author': 'string', + '$.data.items[0].text': 'string', + }; + const segs = collectShapeSegments(shape); + expect(segs.has('data')).toBe(true); + expect(segs.has('items')).toBe(true); + expect(segs.has('author')).toBe(true); + expect(segs.has('text')).toBe(true); + expect(segs.has('$')).toBe(false); + expect(segs.has('[0]')).toBe(false); + }); + + it('returns an empty set for an empty shape', () => { + expect(collectShapeSegments({}).size).toBe(0); + }); +}); + +describe('shapeMatchesFilter', () => { + const shape: Shape = { + '$': 'object', + '$.data': 'object', + '$.data.items': 'array(1)', + '$.data.items[0].author': 'string', + '$.data.items[0].text': 'string', + '$.data.items[0].likes': 'number', + }; + + it('returns true when every field matches some path segment (AND)', () => { + expect(shapeMatchesFilter(shape, ['author', 'text', 'likes'])).toBe(true); + }); + + it('matches nested container names, not just leaves (any-segment rule)', () => { + // `data` and `items` are container segments, not leaves; still must match. + expect(shapeMatchesFilter(shape, ['data', 'items'])).toBe(true); + }); + + it('returns false when any field is missing', () => { + expect(shapeMatchesFilter(shape, ['author', 'missing'])).toBe(false); + }); + + it('is case-sensitive', () => { + expect(shapeMatchesFilter(shape, ['Author'])).toBe(false); + expect(shapeMatchesFilter(shape, ['author'])).toBe(true); + }); + + it('empty filter list vacuously matches', () => { + expect(shapeMatchesFilter(shape, [])).toBe(true); + }); + + it('rejects requests whose shape has no body (empty shape)', () => { + expect(shapeMatchesFilter({}, ['author'])).toBe(false); + }); +}); diff --git a/src/browser/shape-filter.ts b/src/browser/shape-filter.ts new file mode 100644 index 0000000..552bf93 --- /dev/null +++ b/src/browser/shape-filter.ts @@ -0,0 +1,114 @@ +/** + * Shape-based field filter for `browser network --filter `. + * + * Agents know what fields a target request's body should contain + * (e.g. "author, text, likes") but not which of the captured requests + * carries that body. This module lets the network command filter + * entries down to those whose inferred shape exposes every requested + * field name as some path segment. + * + * Matching is "any-segment" (not last-segment-only): a field matches + * if it equals any segment name of any path in the shape map. This + * keeps nested-container fields (e.g. `legacy`, `author` used as an + * object key with further nesting) findable. + */ +import type { Shape } from './shape.js'; + +export interface ParsedFilter { + /** Deduped, order-preserving, trimmed non-empty field names. */ + fields: string[]; +} + +export interface FilterParseError { + /** `invalid_filter` structured error reason for agents. */ + reason: string; +} + +/** + * Parse `--filter` argument value. Splits on `,`, trims, drops empties, + * and dedupes (first-seen wins). Returns `FilterParseError` when the + * result is empty after cleaning — which means the caller passed only + * whitespace, commas, or an empty string. + */ +export function parseFilter(raw: string): ParsedFilter | FilterParseError { + if (typeof raw !== 'string') { + return { reason: `--filter value must be a non-empty comma-separated field list` }; + } + const parts = raw.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) { + return { reason: `--filter value must be a non-empty comma-separated field list (got "${raw}")` }; + } + const seen = new Set(); + const fields: string[] = []; + for (const p of parts) { + if (!seen.has(p)) { seen.add(p); fields.push(p); } + } + return { fields }; +} + +/** + * Extract named segments from a shape path. Drops the leading `$`, + * strips `[N]` array indices, and unwraps `["key"]` bracket-quoted + * keys back to their raw string. + * + * Examples: + * `$` → [] + * `$.data.items[0].author` → ['data','items','author'] + * `$.data.user["nick name"]` → ['data','user','nick name'] + * `$.rows[0][1]` → ['rows'] + */ +export function extractSegments(path: string): string[] { + if (!path || path === '$') return []; + const out: string[] = []; + // Start past the leading `$`; if path doesn't start with `$` treat + // it as a raw segment list (keeps us robust to unexpected input). + let i = path.startsWith('$') ? 1 : 0; + while (i < path.length) { + const c = path[i]; + if (c === '.') { i++; continue; } + if (c === '[') { + // Either `[N]` (numeric) or `["key"]` (quoted key). Handle both. + const end = path.indexOf(']', i); + if (end === -1) break; + const inner = path.slice(i + 1, end); + i = end + 1; + if (inner.length >= 2 && inner.startsWith('"') && inner.endsWith('"')) { + try { out.push(JSON.parse(inner) as string); } + catch { out.push(inner.slice(1, -1)); } + } + // numeric index: drop + continue; + } + // Bare identifier: read up to next `.` or `[` + let j = i; + while (j < path.length && path[j] !== '.' && path[j] !== '[') j++; + out.push(path.slice(i, j)); + i = j; + } + return out; +} + +/** + * Collect the set of segment names used anywhere in a shape map. + * The returned set is what we test field membership against. + */ +export function collectShapeSegments(shape: Shape): Set { + const acc = new Set(); + for (const p of Object.keys(shape)) { + for (const seg of extractSegments(p)) acc.add(seg); + } + return acc; +} + +/** + * True iff every field in `fields` equals some segment name in `shape`. + * AND semantics: all fields must be present. + */ +export function shapeMatchesFilter(shape: Shape, fields: string[]): boolean { + if (fields.length === 0) return true; + const segs = collectShapeSegments(shape); + for (const f of fields) { + if (!segs.has(f)) return false; + } + return true; +} diff --git a/src/browser/shape.test.ts b/src/browser/shape.test.ts new file mode 100644 index 0000000..e05a443 --- /dev/null +++ b/src/browser/shape.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { inferShape } from './shape.js'; + +describe('inferShape', () => { + it('describes primitives at root', () => { + expect(inferShape('hello')).toEqual({ $: 'string' }); + expect(inferShape(42)).toEqual({ $: 'number' }); + expect(inferShape(true)).toEqual({ $: 'boolean' }); + expect(inferShape(null)).toEqual({ $: 'null' }); + }); + + it('summarizes long strings with their length', () => { + const long = 'x'.repeat(200); + expect(inferShape(long, { sampleStringLen: 80 })).toEqual({ $: 'string(len=200)' }); + }); + + it('walks nested objects and emits dotted paths', () => { + const shape = inferShape({ user: { id: 1, name: 'bob' } }); + expect(shape).toEqual({ + $: 'object', + '$.user': 'object', + '$.user.id': 'number', + '$.user.name': 'string', + }); + }); + + it('quotes unsafe keys using bracket notation', () => { + const shape = inferShape({ 'weird key': 1, '123bad': 2 }); + expect(shape['$["weird key"]']).toBe('number'); + expect(shape['$["123bad"]']).toBe('number'); + }); + + it('samples the first array element and reports length', () => { + const shape = inferShape({ items: [{ a: 1 }, { a: 2 }, { a: 3 }] }); + expect(shape['$.items']).toBe('array(3)'); + expect(shape['$.items[0]']).toBe('object'); + expect(shape['$.items[0].a']).toBe('number'); + }); + + it('marks empty containers explicitly', () => { + const shape = inferShape({ arr: [], obj: {} }); + expect(shape['$.arr']).toBe('array(0)'); + expect(shape['$.obj']).toBe('object(empty)'); + }); + + it('collapses subtrees past maxDepth', () => { + const deep = { a: { b: { c: { d: { e: { f: 'too deep' } } } } } }; + const shape = inferShape(deep, { maxDepth: 2 }); + expect(shape['$.a.b']).toMatch(/^object/); + expect(shape['$.a.b.c']).toBeUndefined(); + }); + + it('truncates when the byte budget is exhausted', () => { + const wide: Record = {}; + for (let i = 0; i < 500; i++) wide[`field_${i}`] = i; + const shape = inferShape(wide, { maxBytes: 256 }); + expect(shape['(truncated)']).toMatch(/256B/); + expect(Object.keys(shape).length).toBeLessThan(500); + }); + + it('stops descending into an array once the budget is hit by its own descriptor', () => { + // Budget just large enough for `$` + one deep array descriptor, not its element. + const shape = inferShape({ items: [{ deep: 1 }] }, { maxBytes: 40 }); + expect(shape['$.items[0]']).toBeUndefined(); + expect(shape['(truncated)']).toBeDefined(); + }); + + it('handles the Twitter UserTweets payload envelope', () => { + const payload = { + data: { + user: { + result: { + rest_id: '42', + timeline_v2: { + timeline: { + instructions: [ + { type: 'TimelinePinEntry', entries: [] }, + { entries: [{ entryId: 'tweet-1', content: { entryType: 'TimelineTimelineItem' } }] }, + ], + }, + }, + }, + }, + }, + }; + const shape = inferShape(payload, { maxDepth: 10 }); + expect(shape['$.data.user.result.rest_id']).toBe('string'); + expect(shape['$.data.user.result.timeline_v2.timeline.instructions']).toBe('array(2)'); + expect(shape['$.data.user.result.timeline_v2.timeline.instructions[0]']).toBe('object'); + }); +}); diff --git a/src/browser/shape.ts b/src/browser/shape.ts new file mode 100644 index 0000000..1bd6c0d --- /dev/null +++ b/src/browser/shape.ts @@ -0,0 +1,93 @@ +/** + * JSON shape inference for browser network response previews. + * + * Produces a flat path → type descriptor map so agents can understand + * response structure without paying the token cost of the full body. + * + * Descriptors: + * string | number | boolean | null primitives + * string(len=N) strings longer than sampleStringLen + * array(0) | array(N) array at depth cap or summarized + * object | object(empty) objects at depth cap or summarized + * (truncated) output size budget exceeded + */ + +export interface InferShapeOptions { + /** Max path depth to descend into (default 6) */ + maxDepth?: number; + /** Byte budget for the serialized output; truncates when exceeded (default 2048) */ + maxBytes?: number; + /** Strings longer than this get summarized as `string(len=N)` (default 80) */ + sampleStringLen?: number; +} + +export type Shape = Record; + +const ROOT = '$'; + +export function inferShape(value: unknown, opts: InferShapeOptions = {}): Shape { + const maxDepth = opts.maxDepth ?? 6; + const maxBytes = opts.maxBytes ?? 2048; + const sampleStringLen = opts.sampleStringLen ?? 80; + + const out: Shape = {}; + let bytes = 2; // account for `{}` braces when serialized + let truncated = false; + + const add = (path: string, desc: string): boolean => { + if (truncated) return false; + const entryBytes = JSON.stringify(path).length + JSON.stringify(desc).length + 2; // ":" + "," + if (bytes + entryBytes > maxBytes) { + out['(truncated)'] = `reached ${maxBytes}B budget`; + truncated = true; + return false; + } + out[path] = desc; + bytes += entryBytes; + return true; + }; + + const walk = (node: unknown, path: string, depth: number): void => { + if (truncated) return; + + if (node === null) { add(path, 'null'); return; } + const t = typeof node; + if (t === 'string') { + const s = node as string; + add(path, s.length > sampleStringLen ? `string(len=${s.length})` : 'string'); + return; + } + if (t === 'number' || t === 'boolean') { add(path, t); return; } + if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') { + add(path, t); + return; + } + + if (Array.isArray(node)) { + if (node.length === 0) { add(path, 'array(0)'); return; } + if (depth >= maxDepth) { add(path, `array(${node.length})`); return; } + if (!add(path, `array(${node.length})`)) return; + walk(node[0], `${path}[0]`, depth + 1); + return; + } + + // plain object + const obj = node as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { add(path, 'object(empty)'); return; } + if (depth >= maxDepth) { add(path, `object(keys=${keys.length})`); return; } + if (!add(path, 'object')) return; + for (const k of keys) { + if (truncated) return; + const childPath = isSafeIdent(k) ? `${path}.${k}` : `${path}[${JSON.stringify(k)}]`; + walk(obj[k], childPath, depth + 1); + } + }; + + walk(value, ROOT, 0); + return out; +} + +function isSafeIdent(key: string): boolean { + return /^[A-Za-z_$][\w$]*$/.test(key); +} diff --git a/src/browser/stealth.test.ts b/src/browser/stealth.test.ts new file mode 100644 index 0000000..53fafeb --- /dev/null +++ b/src/browser/stealth.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { generateStealthJs } from './stealth.js'; + +/** + * Tests for the stealth anti-detection module. + * + * We test the generated JS string for expected content and structure. + * Evaluating in Node is fragile because stealth patches target browser + * globals (navigator, Performance, HTMLIFrameElement) that don't exist + * or behave differently in Node. Instead we verify the code string + * contains the right patches and is syntactically valid. + */ + +describe('generateStealthJs', () => { + it('returns a non-empty string', () => { + const code = generateStealthJs(); + expect(typeof code).toBe('string'); + expect(code.length).toBeGreaterThan(0); + }); + + it('is a valid self-contained IIFE', () => { + const code = generateStealthJs(); + // Should start/end as an IIFE + expect(code.trim()).toMatch(/^\(\(\) => \{/); + expect(code.trim()).toMatch(/\}\)\(\)$/); + }); + + it('patches navigator.webdriver', () => { + const code = generateStealthJs(); + expect(code).toContain("navigator, 'webdriver'"); + expect(code).toContain('() => false'); + }); + + it('stubs window.chrome', () => { + const code = generateStealthJs(); + expect(code).toContain('window.chrome'); + expect(code).toContain('runtime'); + expect(code).toContain('loadTimes'); + expect(code).toContain('csi'); + }); + + it('fakes navigator.plugins if empty', () => { + const code = generateStealthJs(); + expect(code).toContain('navigator.plugins'); + expect(code).toContain('PDF Viewer'); + expect(code).toContain('Chrome PDF Viewer'); + }); + + it('ensures navigator.languages is non-empty', () => { + const code = generateStealthJs(); + expect(code).toContain('navigator.languages'); + expect(code).toContain("'en-US'"); + }); + + it('normalizes Permissions.query for notifications', () => { + const code = generateStealthJs(); + expect(code).toContain('Permissions'); + expect(code).toContain('notifications'); + }); + + it('cleans automation artifacts', () => { + const code = generateStealthJs(); + expect(code).toContain('__playwright'); + expect(code).toContain('__puppeteer'); + expect(code).toContain("'cdc_'"); + expect(code).toContain("'__cdc_'"); + }); + + it('filters CDP patterns from Error.stack', () => { + const code = generateStealthJs(); + expect(code).toContain('puppeteer_evaluation_script'); + expect(code).toContain("'pptr:'"); + expect(code).toContain("'debugger://'"); + }); + + it('neutralizes debugger statement traps', () => { + const code = generateStealthJs(); + // Should patch Function constructor with new.target / Reflect.construct + expect(code).toContain('_OrigFunction'); + expect(code).toContain('_PatchedFunction'); + expect(code).toContain('new.target'); + expect(code).toContain('Reflect.construct'); + // Should patch eval + expect(code).toContain('_origEval'); + expect(code).toContain('_patchedEval'); + // Regex to strip debugger (lookbehind for statement boundaries) + expect(code).toContain('_debuggerRe'); + }); + + it('uses shared toString disguise via WeakMap', () => { + const code = generateStealthJs(); + // Shared infrastructure at the top of the IIFE + expect(code).toContain('_origToString'); + expect(code).toContain('WeakMap'); + expect(code).toContain('_disguised'); + expect(code).toContain('_disguise'); + // Should NOT have per-instance toString overrides on Function/eval + // (they go through _disguise instead) + }); + + it('defends console method fingerprinting', () => { + const code = generateStealthJs(); + expect(code).toContain('_consoleMethods'); + expect(code).toContain("'log'"); + expect(code).toContain("'warn'"); + expect(code).toContain("'error'"); + expect(code).toContain('[native code]'); + // Uses saved _origToString reference + expect(code).toContain('_origToString.call'); + }); + + it('defends window dimension detection', () => { + const code = generateStealthJs(); + expect(code).toContain('outerWidth'); + expect(code).toContain('outerHeight'); + expect(code).toContain('innerWidth'); + expect(code).toContain('innerHeight'); + }); + + it('filters Performance API entries', () => { + const code = generateStealthJs(); + expect(code).toContain('getEntries'); + expect(code).toContain('getEntriesByType'); + expect(code).toContain('getEntriesByName'); + expect(code).toContain('_suspiciousPatterns'); + }); + + it('cleans document $cdc_ properties', () => { + const code = generateStealthJs(); + expect(code).toContain("'$cdc_'"); + expect(code).toContain("'$chrome_'"); + }); + + it('patches iframe contentWindow.chrome consistency', () => { + const code = generateStealthJs(); + expect(code).toContain('contentWindow'); + expect(code).toContain('HTMLIFrameElement'); + }); + + it('uses non-enumerable guard flag on EventTarget.prototype', () => { + const code = generateStealthJs(); + expect(code).toContain('EventTarget.prototype'); + expect(code).toContain("'__lsn'"); + expect(code).toContain('enumerable: false'); + }); + + it('generates syntactically valid JavaScript', () => { + const code = generateStealthJs(); + // new Function() parses the code without executing it in a real + // browser context, catching syntax errors from template literal issues. + expect(() => new Function(code)).not.toThrow(); + }); +}); diff --git a/src/browser/stealth.ts b/src/browser/stealth.ts new file mode 100644 index 0000000..2c7da0b --- /dev/null +++ b/src/browser/stealth.ts @@ -0,0 +1,360 @@ +/** + * Stealth anti-detection module. + * + * Generates JS code that patches browser globals to hide automation + * fingerprints (e.g. navigator.webdriver, missing chrome object, empty + * plugin list). Injected before page scripts run so that websites cannot + * detect CDP / extension-based control. + * + * Inspired by puppeteer-extra-plugin-stealth. + */ + +/** + * Return a self-contained JS string that, when evaluated in a page context, + * applies all stealth patches. Safe to call multiple times — the guard flag + * ensures patches are applied only once. + * + * The generated string is pure static (no dynamic parameters), so we cache + * it after the first call to avoid re-building ~350 lines on every goto(). + */ +let _cachedStealthJs: string | undefined; + +export function generateStealthJs(): string { + if (_cachedStealthJs !== undefined) return _cachedStealthJs; + return (_cachedStealthJs = ` + (() => { + // Guard: prevent double-injection across separate CDP evaluations. + // We cannot use a closure variable (each eval is a fresh scope), and + // window properties / Symbols are discoverable by anti-bot scripts. + // Instead, stash the flag in a non-enumerable getter on a built-in + // prototype that fingerprinters are unlikely to scan. + const _gProto = EventTarget.prototype; + const _gKey = '__lsn'; // looks like an internal listener cache + if (_gProto[_gKey]) return 'skipped'; + try { + Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true }); + } catch {} + + // 1. navigator.webdriver → false + // Most common check; Playwright/Puppeteer/CDP set this to true. + // Real Chrome returns false (not undefined) — returning undefined is + // itself a detection signal for advanced fingerprinters. + try { + Object.defineProperty(navigator, 'webdriver', { + get: () => false, + configurable: true, + }); + } catch {} + + // 2. window.chrome stub + // Real Chrome exposes window.chrome with runtime, loadTimes, csi. + // Headless/automated Chrome may not have it. + try { + if (!window.chrome) { + window.chrome = { + runtime: { + onConnect: { addListener: () => {}, removeListener: () => {} }, + onMessage: { addListener: () => {}, removeListener: () => {} }, + }, + loadTimes: () => ({}), + csi: () => ({}), + }; + } + } catch {} + + // 3. navigator.plugins — fake population only if empty + // Real user browser already has plugins; only patch in automated/headless + // contexts where the list is empty (overwriting real plugins with fakes + // would be counterproductive and detectable). + try { + if (!navigator.plugins || navigator.plugins.length === 0) { + const fakePlugins = [ + { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' }, + { name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' }, + { name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' }, + { name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' }, + ]; + fakePlugins.item = (i) => fakePlugins[i] || null; + fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null; + fakePlugins.refresh = () => {}; + Object.defineProperty(navigator, 'plugins', { + get: () => fakePlugins, + configurable: true, + }); + } + } catch {} + + // 4. navigator.languages — guarantee non-empty + // Some automated contexts return undefined or empty array. + try { + if (!navigator.languages || navigator.languages.length === 0) { + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + configurable: true, + }); + } + } catch {} + + // 5. Permissions.query — normalize notification permission + // Headless Chrome throws on Permissions.query({ name: 'notifications' }). + try { + const origQuery = window.Permissions?.prototype?.query; + if (origQuery) { + window.Permissions.prototype.query = function (parameters) { + if (parameters?.name === 'notifications') { + return Promise.resolve({ state: Notification.permission, onchange: null }); + } + return origQuery.call(this, parameters); + }; + } + } catch {} + + // 6. Clean automation artifacts + // Remove properties left by Playwright, Puppeteer, or CDP injection. + try { + delete window.__playwright; + delete window.__puppeteer; + // ChromeDriver injects cdc_ prefixed globals; the suffix varies by version, + // so scan window for any matching property rather than hardcoding names. + for (const prop of Object.getOwnPropertyNames(window)) { + if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) { + try { delete window[prop]; } catch {} + } + } + } catch {} + + // 7. CDP stack trace cleanup + // Runtime.evaluate injects scripts whose source URLs appear in Error + // stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://). + // Websites detect automation by doing: new Error().stack and inspecting it. + // We override the stack property getter on Error.prototype to filter them. + // Note: Error.prepareStackTrace is V8/Node-only and not available in + // browser page context, so we use a property descriptor approach instead. + // We use generic protocol patterns instead of product-specific names to + // also catch our own injected code frames without leaking identifiers. + try { + const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack'); + const _cdpPatterns = [ + 'puppeteer_evaluation_script', + 'pptr:', + 'debugger://', + '__playwright', + '__puppeteer', + ]; + if (_origDescriptor && _origDescriptor.get) { + Object.defineProperty(Error.prototype, 'stack', { + get: function () { + const raw = _origDescriptor.get.call(this); + if (typeof raw !== 'string') return raw; + return raw.split('\\n').filter(line => + !_cdpPatterns.some(p => line.includes(p)) + ).join('\\n'); + }, + configurable: true, + }); + } + } catch {} + + // ── Shared toString disguise infrastructure ── + // Save the pristine Function.prototype.toString BEFORE any patches, + // so all subsequent disguises use the real native reference. + // Anti-bot scripts detect per-instance toString overrides via: + // Function.hasOwnProperty('toString') → true if patched + // Function.prototype.toString.call(fn) !== fn.toString() + // Instead we patch Function.prototype.toString once with a WeakMap + // lookup, making disguised functions indistinguishable from native. + const _origToString = Function.prototype.toString; + const _disguised = new WeakMap(); + try { + Object.defineProperty(Function.prototype, 'toString', { + value: function() { + const override = _disguised.get(this); + return override !== undefined ? override : _origToString.call(this); + }, + writable: true, configurable: true, + }); + } catch {} + const _disguise = (fn, name) => { + _disguised.set(fn, 'function ' + name + '() { [native code] }'); + try { Object.defineProperty(fn, 'name', { value: name, configurable: true }); } catch {} + return fn; + }; + + // 8. Anti-debugger statement trap + // Sites inject debugger statements to detect DevTools/CDP. + // When a CDP debugger is attached, the statement pauses execution + // and the site measures the time gap to confirm automation. + // We neutralize this by overriding the Function constructor and + // eval to strip debugger statements from dynamically created code. + // Note: this does NOT affect static debugger statements in parsed + // scripts — those require CDP Debugger.setBreakpointsActive(false) + // which we handle at the extension level. + // Caveat: the regex targets standalone debugger statements (preceded + // by a statement boundary) to minimise false positives inside string + // literals, but cannot perfectly distinguish all cases without a + // full parser. This is an acceptable trade-off for stealth code. + try { + const _OrigFunction = Function; + // Match standalone debugger statements preceded by a statement + // boundary (start of string, semicolon, brace, or newline). + // This avoids most false positives inside string literals like + // "use debugger mode" while still catching the anti-bot patterns. + const _debuggerRe = /(?:^|(?<=[;{}\\n\\r]))\\s*debugger\\s*;?/g; + const _cleanDebugger = (src) => typeof src === 'string' ? src.replace(_debuggerRe, '') : src; + // Patch Function constructor to strip debugger from dynamic code. + // Support both Function('code') and new Function('code') via + // new.target / Reflect.construct. + const _PatchedFunction = function(...args) { + if (args.length > 0) { + args[args.length - 1] = _cleanDebugger(args[args.length - 1]); + } + if (new.target) { + return Reflect.construct(_OrigFunction, args, new.target); + } + return _OrigFunction.apply(this, args); + }; + _PatchedFunction.prototype = _OrigFunction.prototype; + Object.setPrototypeOf(_PatchedFunction, _OrigFunction); + _disguise(_PatchedFunction, 'Function'); + try { window.Function = _PatchedFunction; } catch {} + + // Patch eval to strip debugger + const _origEval = window.eval; + const _patchedEval = function(code) { + return _origEval.call(this, _cleanDebugger(code)); + }; + _disguise(_patchedEval, 'eval'); + try { window.eval = _patchedEval; } catch {} + } catch {} + + // 9. Console method fingerprinting defense + // When CDP Runtime.enable is called, Chrome replaces console.log etc. + // with CDP-bound versions. These bound functions have a different + // toString() output: "function log() { [native code] }" becomes + // something like "function () { [native code] }" (no name) or the + // bound function signature leaks. Anti-bot scripts check: + // console.log.toString().includes('[native code]') + // console.log.name === 'log' + // We re-wrap console methods and register them via the shared + // _disguise infrastructure so Function.prototype.toString.call() + // also returns the correct native string. + try { + const _consoleMethods = ['log', 'warn', 'error', 'info', 'debug', 'table', 'trace', 'dir', 'group', 'groupEnd', 'groupCollapsed', 'clear', 'count', 'assert', 'profile', 'profileEnd', 'time', 'timeEnd', 'timeStamp']; + for (const _m of _consoleMethods) { + if (typeof console[_m] !== 'function') continue; + const _origMethod = console[_m]; + const _nativeStr = 'function ' + _m + '() { [native code] }'; + // Only patch if toString is wrong (i.e. CDP has replaced it) + try { + const _currentStr = _origToString.call(_origMethod); + if (_currentStr === _nativeStr) continue; // already looks native + } catch {} + const _wrapper = function() { return _origMethod.apply(console, arguments); }; + Object.defineProperty(_wrapper, 'length', { value: _origMethod.length || 0, configurable: true }); + _disguise(_wrapper, _m); + try { console[_m] = _wrapper; } catch {} + } + } catch {} + + // 10. window.outerWidth/outerHeight defense + // When DevTools or CDP debugger is attached, Chrome may alter the + // window dimensions. Anti-bot scripts compare outerWidth/innerWidth + // and outerHeight/innerHeight — a significant difference indicates + // DevTools is open. We freeze the relationship so the delta stays + // consistent with a normal browser window. + // Thresholds: width delta > 100px or height delta > 200px indicates + // a docked DevTools panel. When triggered, we report outerWidth + // equal to innerWidth (normal for maximised windows) and + // outerHeight as innerHeight + the captured "normal" delta (capped + // to a reasonable range), so the result is plausible across OSes. + try { + const _normalWidthDelta = window.outerWidth - window.innerWidth; + const _normalHeightDelta = window.outerHeight - window.innerHeight; + // Only patch if the delta looks suspicious (e.g. DevTools docked) + if (_normalWidthDelta > 100 || _normalHeightDelta > 200) { + Object.defineProperty(window, 'outerWidth', { + get: () => window.innerWidth, + configurable: true, + }); + // Use a clamped height offset (40-120px covers macOS ~78px, + // Windows ~40px, and Linux ~37-50px title bar heights). + const _heightOffset = Math.max(40, Math.min(120, _normalHeightDelta)); + Object.defineProperty(window, 'outerHeight', { + get: () => window.innerHeight + _heightOffset, + configurable: true, + }); + } + } catch {} + + // 11. Performance API cleanup + // CDP injects internal resources and timing entries that don't exist + // in normal browsing. Filter entries with debugger/devtools URLs. + try { + const _origGetEntries = Performance.prototype.getEntries; + const _origGetByType = Performance.prototype.getEntriesByType; + const _origGetByName = Performance.prototype.getEntriesByName; + const _suspiciousPatterns = ['debugger', 'devtools', '__puppeteer', '__playwright', 'pptr:']; + const _filterEntries = (entries) => { + if (!Array.isArray(entries)) return entries; + return entries.filter(e => { + const name = e.name || ''; + return !_suspiciousPatterns.some(p => name.includes(p)); + }); + }; + Performance.prototype.getEntries = function() { + return _filterEntries(_origGetEntries.call(this)); + }; + Performance.prototype.getEntriesByType = function(type) { + return _filterEntries(_origGetByType.call(this, type)); + }; + Performance.prototype.getEntriesByName = function(name, type) { + return _filterEntries(_origGetByName.call(this, name, type)); + }; + } catch {} + + // 12. WebDriver-related property defense + // Some anti-bot systems check additional navigator properties + // and document properties that may indicate automation. + try { + // document.$cdc_ properties (ChromeDriver specific, backup for #6) + for (const _prop of Object.getOwnPropertyNames(document)) { + if (_prop.startsWith('$cdc_') || _prop.startsWith('$chrome_')) { + try { delete document[_prop]; } catch {} + } + } + } catch {} + + // 13. Iframe contentWindow.chrome consistency + // Anti-bot scripts create iframes and check if + // iframe.contentWindow.chrome exists and matches the parent. + // CDP-controlled pages may have inconsistent iframe contexts. + try { + const _origHTMLIFrame = HTMLIFrameElement.prototype; + const _origContentWindow = Object.getOwnPropertyDescriptor(_origHTMLIFrame, 'contentWindow'); + if (_origContentWindow && _origContentWindow.get) { + Object.defineProperty(_origHTMLIFrame, 'contentWindow', { + get: function() { + const _w = _origContentWindow.get.call(this); + if (_w) { + try { + if (!_w.chrome) { + Object.defineProperty(_w, 'chrome', { + value: window.chrome, + writable: true, + configurable: true, + }); + } + } catch {} + } + return _w; + }, + configurable: true, + }); + } + } catch {} + + return 'applied'; + })() + `); +} diff --git a/src/browser/tabs.ts b/src/browser/tabs.ts new file mode 100644 index 0000000..a1c63d9 --- /dev/null +++ b/src/browser/tabs.ts @@ -0,0 +1,76 @@ +/** + * Browser tab management helpers: extract, diff, and cleanup tab state. + */ + +export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> { + if (Array.isArray(raw)) { + return raw.map((tab: Record, index: number) => ({ + index, + identity: [ + tab?.id ?? '', + tab?.url ?? '', + tab?.title ?? '', + tab?.name ?? '', + ].join('|'), + })); + } + + if (typeof raw === 'string') { + return raw + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + // Match tab list format: "- 0: (current) [title](url)" or "- 1: [title](url)" + const tabMatch = line.match(/^-\s+(\d+):\s*(.*)$/); + if (tabMatch) { + return { + index: parseInt(tabMatch[1], 10), + identity: tabMatch[2].trim() || `tab-${tabMatch[1]}`, + }; + } + // Legacy format: "Tab 0 ..." + const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/); + if (legacyMatch) { + return { + index: parseInt(legacyMatch[1], 10), + identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`, + }; + } + return null; + }) + .filter((entry): entry is { index: number; identity: string } => entry !== null); + } + + return []; +} + +export function extractTabIdentities(raw: unknown): string[] { + return extractTabEntries(raw).map(tab => tab.identity); +} + +export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] { + if (initialIdentities.length === 0 || currentTabs.length === 0) return []; + const remaining = new Map(); + for (const identity of initialIdentities) { + remaining.set(identity, (remaining.get(identity) ?? 0) + 1); + } + + const tabsToClose: number[] = []; + for (const tab of currentTabs) { + const count = remaining.get(tab.identity) ?? 0; + if (count > 0) { + remaining.set(tab.identity, count - 1); + continue; + } + tabsToClose.push(tab.index); + } + + return tabsToClose.sort((a, b) => b - a); +} + +export function appendLimited(current: string, chunk: string, limit: number): string { + const next = current + chunk; + if (next.length <= limit) return next; + return next.slice(-limit); +} diff --git a/src/browser/target-errors.test.ts b/src/browser/target-errors.test.ts new file mode 100644 index 0000000..b631998 --- /dev/null +++ b/src/browser/target-errors.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { TargetError } from './target-errors.js'; + +describe('TargetError', () => { + it('creates not_found error with code and hint', () => { + const err = new TargetError({ + code: 'not_found', + message: 'ref=99 not found in DOM', + hint: 'Re-run `opencli browser state` to get a fresh snapshot.', + }); + + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('TargetError'); + expect(err.code).toBe('not_found'); + expect(err.message).toBe('ref=99 not found in DOM'); + expect(err.hint).toContain('fresh snapshot'); + expect(err.candidates).toBeUndefined(); + }); + + it('creates selector_ambiguous error with candidates + matches_n', () => { + const err = new TargetError({ + code: 'selector_ambiguous', + message: 'CSS selector ".btn" matched 3 elements', + hint: 'Use a more specific selector, or pass --nth.', + candidates: ['', + ); + expect(md).toContain('keep'); + expect(md).not.toContain('alert'); + expect(md).not.toContain('color:red'); + expect(md).not.toContain('nojs'); + expect(md).not.toContain('click'); + // Iframe degrades to a link preserving the embedded URL. + expect(md).toContain('[Demo video](https://www.youtube.com/embed/abc)'); + }); + + it('strips SVG nodes entirely', async () => { + const md = await runAndRead( + '

before

after

', + ); + expect(md).toContain('before'); + expect(md).toContain('after'); + expect(md).not.toContain('svg'); + expect(md).not.toContain('circle'); + }); + + it('drops base64 data URI images but keeps regular images', async () => { + const md = await runAndRead( + '

inline

' + + '

keep

', + ); + expect(md).not.toContain('data:image'); + expect(md).toContain('![keep](https://example.com/a.jpg)'); + }); + + it('collapses 3+ blank lines and strips lone bullet / middle-dot residue', async () => { + const md = await runAndRead( + '

top

' + + '

-

' + + '

·

' + + '

bottom

', + ); + expect(md).not.toMatch(/\n{3,}/); + expect(md).not.toMatch(/^\s*-\s*$/m); + expect(md).not.toMatch(/^\s*·\s*$/m); + expect(md).toContain('top'); + expect(md).toContain('bottom'); + }); + + it('strips page chrome (header / footer / nav / aside)', async () => { + const md = await runAndRead( + '

page-header-text

' + + '
' + + '

article-body

' + + '' + + '

page-footer-text

', + ); + expect(md).toContain('article-body'); + expect(md).not.toContain('page-header-text'); + expect(md).not.toContain('home-link'); + expect(md).not.toContain('sidebar-text'); + expect(md).not.toContain('page-footer-text'); + }); + + it('cleanSelectors removes matching nodes before conversion', async () => { + const md = await runAndRead( + '

keep-me

' + + '
折叠卡
' + + '
赞赏栏
' + + '

also-keep

', + { cleanSelectors: ['.vote-card', '.reward-panel'] }, + ); + expect(md).toContain('keep-me'); + expect(md).toContain('also-keep'); + expect(md).not.toContain('折叠卡'); + expect(md).not.toContain('赞赏栏'); + }); + + it('cleanSelectors silently ignores invalid selectors', async () => { + const md = await runAndRead( + '

survives

and-this-too
', + { cleanSelectors: ['!!!not-a-valid-selector', '.missing'] }, + ); + expect(md).toContain('survives'); + expect(md).toContain('and-this-too'); + }); + + it('cleanSelectors keeps valid selectors active when one selector is invalid', async () => { + const md = await runAndRead( + '

keep

strip-me

also-keep

', + { cleanSelectors: ['!!!not-a-valid-selector', '.vote-card'] }, + ); + expect(md).toContain('keep'); + expect(md).toContain('also-keep'); + expect(md).not.toContain('strip-me'); + }); + + it('preserves