@jackwener/opencli 1.7.12 → 1.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +12128 -6665
- package/clis/1point3acres/digest.js +35 -0
- package/clis/1point3acres/forum.js +51 -0
- package/clis/1point3acres/forums.js +44 -0
- package/clis/1point3acres/hot.js +35 -0
- package/clis/1point3acres/latest.js +35 -0
- package/clis/1point3acres/notifications.js +64 -0
- package/clis/1point3acres/search.js +71 -0
- package/clis/1point3acres/thread.js +117 -0
- package/clis/1point3acres/user.js +77 -0
- package/clis/1point3acres/utils.js +247 -0
- package/clis/_shared/desktop-commands.js +4 -0
- package/clis/aibase/news.js +110 -0
- package/clis/aibase/news.test.js +59 -0
- package/clis/amazon/discussion.test.js +1 -28
- package/clis/antigravity/watch.js +3 -2
- package/clis/arxiv/author.js +44 -0
- package/clis/baidu-scholar/search.js +0 -1
- package/clis/bbc/topic.js +57 -0
- package/clis/bbc/utils.js +79 -0
- package/clis/chaoxing/assignments.js +1 -1
- package/clis/chaoxing/exams.js +1 -1
- package/clis/chatgpt/ask.js +57 -0
- package/clis/chatgpt/commands.test.js +45 -0
- package/clis/chatgpt/detail.js +46 -0
- package/clis/chatgpt/history.js +39 -0
- package/clis/chatgpt/image.js +12 -11
- package/clis/chatgpt/image.test.js +23 -0
- package/clis/chatgpt/new.js +25 -0
- package/clis/chatgpt/read.js +43 -0
- package/clis/chatgpt/send.js +46 -0
- package/clis/chatgpt/status.js +29 -0
- package/clis/chatgpt/utils.js +294 -4
- package/clis/chatgpt/utils.test.js +13 -0
- package/clis/chatgpt-app/ask.js +6 -3
- package/clis/chatwise/ask.js +16 -43
- package/clis/chatwise/composer.test.js +186 -0
- package/clis/chatwise/send.js +2 -24
- package/clis/chatwise/utils.js +143 -0
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -0
- package/clis/claude/history.js +1 -0
- package/clis/claude/new.js +1 -0
- package/clis/claude/read.js +1 -0
- package/clis/claude/send.js +1 -0
- package/clis/claude/status.js +1 -0
- package/clis/codex/ask.js +15 -9
- package/clis/codex/history.js +16 -33
- package/clis/codex/projects.js +28 -0
- package/clis/codex/read.js +10 -4
- package/clis/codex/send.js +10 -3
- package/clis/codex/sidebar.js +356 -0
- package/clis/codex/sidebar.test.js +329 -0
- package/clis/coingecko/categories.js +75 -0
- package/clis/coingecko/coin.js +107 -0
- package/clis/coingecko/coingecko.test.js +109 -0
- package/clis/coingecko/derivatives.js +84 -0
- package/clis/coingecko/exchanges.js +74 -0
- package/clis/coingecko/global.js +71 -0
- package/clis/coingecko/top.js +64 -0
- package/clis/coingecko/trending.js +55 -0
- package/clis/coupang/add-to-cart.js +21 -13
- package/clis/coupang/coupang.test.js +159 -0
- package/clis/coupang/product.js +257 -0
- package/clis/coupang/search.js +38 -16
- package/clis/coupang/utils.js +55 -1
- package/clis/crates/crate.js +62 -0
- package/clis/crates/search.js +44 -0
- package/clis/crates/utils.js +72 -0
- package/clis/ctrip/ctrip.test.js +234 -0
- package/clis/ctrip/hotel-suggest.js +45 -0
- package/clis/ctrip/search.js +22 -68
- package/clis/ctrip/utils.js +175 -0
- package/clis/cursor/ask.js +6 -3
- package/clis/dblp/author.js +133 -0
- package/clis/dblp/venue.js +64 -0
- package/clis/deepseek/ask.js +12 -7
- package/clis/deepseek/ask.test.js +13 -13
- package/clis/deepseek/detail.js +38 -0
- package/clis/deepseek/detail.test.js +81 -0
- package/clis/deepseek/history.js +1 -0
- package/clis/deepseek/new.js +1 -0
- package/clis/deepseek/read.js +1 -0
- package/clis/deepseek/send.js +140 -0
- package/clis/deepseek/send.test.js +107 -0
- package/clis/deepseek/status.js +1 -0
- package/clis/deepseek/utils.js +66 -0
- package/clis/deepseek/utils.test.js +107 -1
- package/clis/defillama/defillama.test.js +99 -0
- package/clis/defillama/protocol.js +84 -0
- package/clis/defillama/protocols.js +55 -0
- package/clis/defillama/utils.js +99 -0
- package/clis/devto/latest.js +74 -0
- package/clis/dockerhub/image.js +52 -0
- package/clis/dockerhub/search.js +47 -0
- package/clis/dockerhub/utils.js +100 -0
- package/clis/doubao/ask.js +7 -3
- package/clis/doubao/detail.js +1 -0
- package/clis/doubao/history.js +1 -0
- package/clis/doubao/meeting-summary.js +1 -0
- package/clis/doubao/meeting-transcript.js +1 -0
- package/clis/doubao/new.js +1 -0
- package/clis/doubao/read.js +1 -0
- package/clis/doubao/send.js +1 -0
- package/clis/doubao/status.js +1 -0
- package/clis/douyin/draft.test.js +1 -30
- package/clis/endoflife/endoflife.test.js +51 -0
- package/clis/endoflife/product.js +55 -0
- package/clis/endoflife/utils.js +89 -0
- package/clis/facebook/__fixtures__/notifications-page.html +13 -0
- package/clis/facebook/notifications.js +326 -30
- package/clis/facebook/notifications.test.js +458 -0
- package/clis/flathub/app.js +71 -0
- package/clis/flathub/flathub.test.js +90 -0
- package/clis/flathub/search.js +80 -0
- package/clis/flathub/utils.js +114 -0
- package/clis/gemini/ask.js +7 -3
- package/clis/gemini/ask.test.js +2 -2
- package/clis/gemini/deep-research-result.js +6 -2
- package/clis/gemini/deep-research-result.test.js +15 -14
- package/clis/gemini/deep-research.js +8 -4
- package/clis/gemini/deep-research.test.js +15 -18
- package/clis/gemini/image.js +7 -2
- package/clis/gemini/new.js +1 -0
- package/clis/gemini/utils.js +0 -4
- package/clis/google-scholar/cite.js +0 -1
- package/clis/google-scholar/profile.js +0 -1
- package/clis/google-scholar/search.js +0 -1
- package/clis/goproxy/goproxy.test.js +103 -0
- package/clis/goproxy/module.js +47 -0
- package/clis/goproxy/utils.js +165 -0
- package/clis/goproxy/versions.js +59 -0
- package/clis/gov-law/recent.js +0 -1
- package/clis/gov-law/search.js +0 -1
- package/clis/gov-policy/__fixtures__/recent.html +16 -0
- package/clis/gov-policy/__fixtures__/search.html +41 -0
- package/clis/gov-policy/gov-policy.test.js +224 -0
- package/clis/gov-policy/recent.js +66 -24
- package/clis/gov-policy/search.js +65 -23
- package/clis/gov-policy/utils.js +54 -0
- package/clis/grok/ask.js +49 -265
- package/clis/grok/ask.test.js +21 -46
- package/clis/grok/detail.js +60 -0
- package/clis/grok/history.js +48 -0
- package/clis/grok/{image.ts → image.js} +56 -70
- package/clis/grok/image.test.ts +20 -0
- package/clis/grok/new.js +20 -0
- package/clis/grok/read.js +39 -0
- package/clis/grok/send.js +50 -0
- package/clis/grok/status.js +41 -0
- package/clis/grok/utils.js +326 -0
- package/clis/grok/utils.test.js +103 -0
- package/clis/hf/datasets.js +88 -0
- package/clis/hf/hf.test.js +16 -0
- package/clis/hf/models.js +91 -0
- package/clis/hf/paper.js +79 -0
- package/clis/hf/spaces.js +101 -0
- package/clis/hf/top.js +1 -0
- package/clis/homebrew/cask.js +39 -0
- package/clis/homebrew/formula.js +41 -0
- package/clis/homebrew/popular.js +54 -0
- package/clis/homebrew/utils.js +100 -0
- package/clis/hupu/__fixtures__/hot-home.html +64 -0
- package/clis/hupu/detail.js +0 -1
- package/clis/hupu/hot.js +156 -35
- package/clis/hupu/hot.test.js +224 -0
- package/clis/hupu/search.js +0 -1
- package/clis/instagram/note.js +1 -1
- package/clis/instagram/note.test.js +1 -29
- package/clis/instagram/post.js +1 -1
- package/clis/instagram/post.test.js +1 -1
- package/clis/instagram/reel.js +1 -1
- package/clis/instagram/story.js +1 -1
- package/clis/instagram/story.test.js +1 -34
- package/clis/jd/commands.test.js +1 -24
- package/clis/lichess/lichess.test.js +85 -0
- package/clis/lichess/top.js +46 -0
- package/clis/lichess/user.js +91 -0
- package/clis/lichess/utils.js +97 -0
- package/clis/linkedin/search.js +107 -10
- package/clis/linkedin/search.test.js +222 -0
- package/clis/linux-do/feed.js +2 -5
- package/clis/linux-do/feed.test.js +35 -0
- package/clis/lobsters/domain.js +92 -0
- package/clis/maven/artifact.js +49 -0
- package/clis/maven/search.js +51 -0
- package/clis/maven/utils.js +110 -0
- package/clis/mdn/search.js +97 -0
- package/clis/medium/tag.js +135 -0
- package/clis/npm/downloads.js +59 -0
- package/clis/npm/package.js +70 -0
- package/clis/npm/search.js +49 -0
- package/clis/npm/utils.js +76 -0
- package/clis/nuget/nuget.test.js +111 -0
- package/clis/nuget/package.js +101 -0
- package/clis/nuget/search.js +69 -0
- package/clis/nuget/utils.js +87 -0
- package/clis/nvd/cve.js +121 -0
- package/clis/oeis/oeis.test.js +88 -0
- package/clis/oeis/search.js +63 -0
- package/clis/oeis/sequence.js +71 -0
- package/clis/oeis/utils.js +88 -0
- package/clis/openalex/search.js +69 -0
- package/clis/openalex/utils.js +160 -0
- package/clis/openalex/work.js +65 -0
- package/clis/openfda/drug-label.js +74 -0
- package/clis/openfda/food-recall.js +65 -0
- package/clis/openfda/openfda.test.js +114 -0
- package/clis/openfda/utils.js +67 -0
- package/clis/osv/osv.test.js +97 -0
- package/clis/osv/query.js +72 -0
- package/clis/osv/utils.js +169 -0
- package/clis/osv/vulnerability.js +54 -0
- package/clis/packagist/package.js +49 -0
- package/clis/packagist/search.js +43 -0
- package/clis/packagist/utils.js +113 -0
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/pixiv/download.test.js +1 -1
- package/clis/pixiv/illusts.test.js +1 -1
- package/clis/pixiv/search.test.js +1 -1
- package/clis/pubmed/article.js +50 -0
- package/clis/pubmed/author.js +64 -0
- package/clis/pubmed/citations.js +36 -0
- package/clis/pubmed/pubmed.test.js +276 -0
- package/clis/pubmed/related.js +45 -0
- package/clis/pubmed/search.js +75 -0
- package/clis/pubmed/utils.js +309 -0
- package/clis/pypi/downloads.js +66 -0
- package/clis/pypi/package.js +79 -0
- package/clis/pypi/utils.js +55 -0
- package/clis/quark/mv.js +1 -1
- package/clis/quark/save.js +1 -1
- package/clis/qwen/ask.js +85 -0
- package/clis/qwen/detail.js +62 -0
- package/clis/qwen/history.js +61 -0
- package/clis/qwen/image.js +179 -0
- package/clis/qwen/new.js +23 -0
- package/clis/qwen/read.js +41 -0
- package/clis/qwen/send.js +55 -0
- package/clis/qwen/status.js +37 -0
- package/clis/qwen/utils.js +409 -0
- package/clis/qwen/utils.test.js +45 -0
- package/clis/rest-countries/country.js +65 -0
- package/clis/rest-countries/region.js +64 -0
- package/clis/rest-countries/rest-countries.test.js +83 -0
- package/clis/rest-countries/utils.js +126 -0
- package/clis/reuters/article-detail.js +53 -0
- package/clis/reuters/reuters.test.js +299 -0
- package/clis/reuters/search.js +45 -34
- package/clis/reuters/utils.js +159 -0
- package/clis/rfc/rfc.js +52 -0
- package/clis/rfc/rfc.test.js +74 -0
- package/clis/rfc/utils.js +72 -0
- package/clis/rubygems/gem.js +42 -0
- package/clis/rubygems/search.js +47 -0
- package/clis/rubygems/utils.js +86 -0
- package/clis/stackoverflow/related.js +66 -0
- package/clis/stackoverflow/stackoverflow.test.js +58 -0
- package/clis/stackoverflow/tag.js +60 -0
- package/clis/stackoverflow/user.js +50 -0
- package/clis/stackoverflow/utils.js +118 -0
- package/clis/steam/app.js +67 -0
- package/clis/steam/search.js +58 -0
- package/clis/steam/steam.test.js +46 -0
- package/clis/steam/utils.js +107 -0
- package/clis/taobao/commands.test.js +1 -24
- package/clis/test-utils.js +61 -0
- package/clis/tieba/hot.js +0 -1
- package/clis/tiktok/comment.js +128 -41
- package/clis/tiktok/creator-videos.js +270 -0
- package/clis/tiktok/creator-videos.test.js +113 -0
- package/clis/tiktok/explore.js +137 -29
- package/clis/tiktok/follow.js +115 -33
- package/clis/tiktok/following.js +157 -36
- package/clis/tiktok/friends.js +139 -37
- package/clis/tiktok/live.js +137 -41
- package/clis/tiktok/notifications.js +141 -38
- package/clis/tiktok/refactor.test.js +389 -0
- package/clis/tiktok/unfollow.js +124 -38
- package/clis/tiktok/user.js +203 -29
- package/clis/tiktok/utils.js +505 -0
- package/clis/tiktok/write-refactor.test.js +370 -0
- package/clis/toutiao/articles.js +36 -62
- package/clis/toutiao/hot.js +63 -0
- package/clis/toutiao/toutiao.test.js +378 -0
- package/clis/toutiao/utils.js +161 -0
- package/clis/tvmaze/search.js +61 -0
- package/clis/tvmaze/show.js +60 -0
- package/clis/tvmaze/tvmaze.test.js +93 -0
- package/clis/tvmaze/utils.js +110 -0
- package/clis/twitter/accept.js +1 -1
- package/clis/twitter/followers.js +134 -69
- package/clis/twitter/quote.js +139 -0
- package/clis/twitter/quote.test.js +106 -0
- package/clis/twitter/reply-dm.js +1 -1
- package/clis/twitter/reply.test.js +1 -29
- package/clis/twitter/retweet.js +99 -0
- package/clis/twitter/retweet.test.js +69 -0
- package/clis/twitter/shared.js +38 -0
- package/clis/twitter/shared.test.js +28 -1
- package/clis/twitter/unlike.js +87 -0
- package/clis/twitter/unlike.test.js +72 -0
- package/clis/twitter/unretweet.js +99 -0
- package/clis/twitter/unretweet.test.js +69 -0
- package/clis/uisdc/news.js +105 -0
- package/clis/uisdc/news.test.js +66 -0
- package/clis/wanfang/search.js +0 -1
- package/clis/web/read.js +47 -17
- package/clis/web/read.test.js +101 -1
- package/clis/weixin/create-draft.js +1 -1
- package/clis/weixin/drafts.js +1 -1
- package/clis/weixin/drafts.test.js +5 -1
- package/clis/weixin/search.js +157 -0
- package/clis/weixin/search.test.js +227 -0
- package/clis/wikidata/entity.js +60 -0
- package/clis/wikidata/search.js +50 -0
- package/clis/wikidata/utils.js +117 -0
- package/clis/wikidata/wikidata.test.js +83 -0
- package/clis/wikipedia/page.js +95 -0
- package/clis/wttr/current.js +63 -0
- package/clis/wttr/forecast.js +71 -0
- package/clis/wttr/utils.js +50 -0
- package/clis/wttr/wttr.test.js +84 -0
- package/clis/xianyu/chat.js +16 -4
- package/clis/xianyu/chat.test.js +64 -0
- package/clis/xianyu/publish.js +485 -0
- package/clis/xianyu/publish.test.js +220 -0
- package/clis/xiaoe/catalog.js +105 -40
- package/clis/xiaoe/content.js +164 -29
- package/clis/xiaoe/courses.js +86 -29
- package/clis/xiaoe/xiaoe.test.js +486 -0
- package/clis/xiaohongshu/creator-notes-summary.js +1 -1
- package/clis/xiaohongshu/publish.js +16 -3
- package/clis/xiaohongshu/publish.test.js +46 -1
- package/clis/youtube/transcript.js +13 -19
- package/clis/youtube/transcript.test.js +17 -0
- package/clis/yuanbao/ask.js +17 -66
- package/clis/yuanbao/ask.test.js +5 -5
- package/clis/yuanbao/detail.js +65 -0
- package/clis/yuanbao/history.js +51 -0
- package/clis/yuanbao/new.js +1 -0
- package/clis/yuanbao/read.js +38 -0
- package/clis/yuanbao/send.js +57 -0
- package/clis/yuanbao/shared.js +297 -5
- package/clis/yuanbao/shared.test.js +80 -0
- package/clis/yuanbao/status.js +44 -0
- package/clis/zlibrary/commands.test.js +1 -11
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -1
- package/dist/src/browser/base-page.test.js +66 -0
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +51 -9
- package/dist/src/browser/daemon-client.d.ts +4 -0
- package/dist/src/browser/errors.js +1 -1
- package/dist/src/browser/page.d.ts +1 -1
- package/dist/src/browser/page.js +3 -1
- package/dist/src/browser/page.test.js +29 -0
- package/dist/src/browser/target-errors.d.ts +2 -1
- package/dist/src/browser/target-errors.js +1 -0
- package/dist/src/browser/target-resolver.d.ts +25 -0
- package/dist/src/browser/target-resolver.js +43 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.js +9 -4
- package/dist/src/build-manifest.test.js +2 -8
- package/dist/src/capabilityRouting.d.ts +16 -1
- package/dist/src/capabilityRouting.js +24 -1
- package/dist/src/capabilityRouting.test.js +19 -1
- package/dist/src/cli.js +76 -11
- package/dist/src/cli.test.js +241 -1
- package/dist/src/commanderAdapter.js +23 -9
- package/dist/src/commanderAdapter.test.js +0 -1
- package/dist/src/discovery.js +2 -5
- package/dist/src/errors.js +1 -1
- package/dist/src/execution.d.ts +1 -1
- package/dist/src/execution.js +111 -27
- package/dist/src/execution.test.js +326 -17
- package/dist/src/help.d.ts +27 -2
- package/dist/src/help.js +196 -23
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +54 -0
- package/dist/src/main.js +14 -1
- package/dist/src/manifest-types.d.ts +5 -3
- package/dist/src/pipeline/executor.js +1 -1
- package/dist/src/pipeline/executor.test.js +8 -0
- package/dist/src/pipeline/registry.d.ts +9 -0
- package/dist/src/pipeline/registry.js +13 -1
- package/dist/src/pipeline/steps/browser.d.ts +1 -0
- package/dist/src/pipeline/steps/browser.js +10 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +12 -11
- package/dist/src/registry.js +16 -6
- package/dist/src/registry.test.js +2 -2
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/serialization.d.ts +2 -2
- package/dist/src/serialization.js +4 -6
- package/dist/src/serialization.test.js +17 -0
- package/dist/src/types.d.ts +17 -0
- package/dist/src/validate.js +15 -11
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +1 -1
- package/scripts/typed-error-lint-baseline.json +5 -77
- package/clis/ctrip/search.test.js +0 -64
- package/clis/gov-policy/commands.test.js +0 -27
- package/clis/linux-do/category.js +0 -37
- package/clis/linux-do/hot.js +0 -26
- package/clis/linux-do/latest.js +0 -19
- package/clis/pixiv/test-utils.js +0 -23
- package/clis/toutiao/articles.test.js +0 -30
- package/dist/src/analysis.d.ts +0 -40
- package/dist/src/analysis.js +0 -172
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './search.js';
|
|
5
|
+
import './show.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('tvmaze search adapter', () => {
|
|
13
|
+
const cmd = getRegistry().get('tvmaze/search');
|
|
14
|
+
|
|
15
|
+
it('rejects empty query and bad limit before fetching', async () => {
|
|
16
|
+
const fetchMock = vi.fn();
|
|
17
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
18
|
+
|
|
19
|
+
await expect(cmd.func({ query: '', limit: 5 })).rejects.toThrow(ArgumentError);
|
|
20
|
+
await expect(cmd.func({ query: 'foo', limit: 0 })).rejects.toThrow(ArgumentError);
|
|
21
|
+
await expect(cmd.func({ query: 'foo', limit: 100 })).rejects.toThrow(ArgumentError);
|
|
22
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('maps HTTP 429 to CommandExecutionError', async () => {
|
|
26
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
|
|
27
|
+
await expect(cmd.func({ query: 'foo', limit: 5 })).rejects.toThrow(CommandExecutionError);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws EmptyResultError when no shows match', async () => {
|
|
31
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 })));
|
|
32
|
+
await expect(cmd.func({ query: 'asdfghjkl', limit: 5 })).rejects.toThrow(EmptyResultError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('strips HTML from summary and ids round-trip into tvmaze show <id>', async () => {
|
|
36
|
+
const list = [{
|
|
37
|
+
score: 1.2,
|
|
38
|
+
show: {
|
|
39
|
+
id: 169, name: 'Breaking Bad', type: 'Scripted', language: 'English',
|
|
40
|
+
genres: ['Drama'], status: 'Ended', premiered: '2008-01-20', ended: '2019-10-11',
|
|
41
|
+
network: { name: 'AMC' }, rating: { average: 9.2 },
|
|
42
|
+
summary: '<p><b>Breaking Bad</b> is a show with & entities 'here', 'hex', and ….</p>',
|
|
43
|
+
url: 'https://www.tvmaze.com/shows/169/breaking-bad',
|
|
44
|
+
},
|
|
45
|
+
}];
|
|
46
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
|
|
47
|
+
|
|
48
|
+
const rows = await cmd.func({ query: 'breaking bad', limit: 5 });
|
|
49
|
+
expect(rows[0]).toMatchObject({
|
|
50
|
+
rank: 1, id: 169, name: 'Breaking Bad', network: 'AMC', rating: 9.2, matchScore: 1.2,
|
|
51
|
+
});
|
|
52
|
+
expect(rows[0].summary).toBe("Breaking Bad is a show with & entities 'here', 'hex', and ….");
|
|
53
|
+
// id round-trip
|
|
54
|
+
expect(typeof rows[0].id).toBe('number');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('tvmaze show adapter', () => {
|
|
59
|
+
const cmd = getRegistry().get('tvmaze/show');
|
|
60
|
+
|
|
61
|
+
it('rejects non-positive id before fetching', async () => {
|
|
62
|
+
const fetchMock = vi.fn();
|
|
63
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
64
|
+
|
|
65
|
+
await expect(cmd.func({ id: 0 })).rejects.toThrow(ArgumentError);
|
|
66
|
+
await expect(cmd.func({ id: -5 })).rejects.toThrow(ArgumentError);
|
|
67
|
+
await expect(cmd.func({ id: 'abc' })).rejects.toThrow(ArgumentError);
|
|
68
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('maps HTTP 404 to EmptyResultError', async () => {
|
|
72
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not found', { status: 404 })));
|
|
73
|
+
await expect(cmd.func({ id: 99999999 })).rejects.toThrow(EmptyResultError);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns full show detail with externals + schedule', async () => {
|
|
77
|
+
const show = {
|
|
78
|
+
id: 169, name: 'Breaking Bad', type: 'Scripted', language: 'English', genres: ['Drama'],
|
|
79
|
+
status: 'Ended', premiered: '2008-01-20', ended: '2019-10-11', runtime: 60, averageRuntime: 60,
|
|
80
|
+
network: { name: 'AMC', country: { name: 'United States' } }, schedule: { time: '22:00', days: ['Sunday'] },
|
|
81
|
+
rating: { average: 9.2 }, externals: { imdb: 'tt0903747', thetvdb: 81189 },
|
|
82
|
+
officialSite: 'http://www.amc.com/shows/breaking-bad',
|
|
83
|
+
summary: '<p>Plain.</p>', url: 'https://www.tvmaze.com/shows/169/breaking-bad',
|
|
84
|
+
};
|
|
85
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(show), { status: 200 })));
|
|
86
|
+
|
|
87
|
+
const rows = await cmd.func({ id: 169 });
|
|
88
|
+
expect(rows[0]).toMatchObject({
|
|
89
|
+
id: 169, name: 'Breaking Bad', country: 'United States', schedule: 'Sunday 22:00',
|
|
90
|
+
imdb: 'tt0903747', thetvdb: 81189, rating: 9.2, summary: 'Plain.',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Shared helpers for the TVmaze adapters.
|
|
2
|
+
//
|
|
3
|
+
// TVmaze publishes a free, unauthenticated REST API at https://api.tvmaze.com.
|
|
4
|
+
// Docs: https://www.tvmaze.com/api
|
|
5
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
6
|
+
|
|
7
|
+
export const TVMAZE_BASE = 'https://api.tvmaze.com';
|
|
8
|
+
const UA = 'opencli-tvmaze-adapter (+https://github.com/jackwener/opencli)';
|
|
9
|
+
|
|
10
|
+
export function requireString(value, label) {
|
|
11
|
+
const s = String(value ?? '').trim();
|
|
12
|
+
if (!s) throw new ArgumentError(`tvmaze ${label} cannot be empty`);
|
|
13
|
+
return s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requireShowId(value) {
|
|
17
|
+
const raw = value;
|
|
18
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw ?? '').trim());
|
|
19
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
20
|
+
throw new ArgumentError(
|
|
21
|
+
'tvmaze show id is required and must be a positive integer',
|
|
22
|
+
'TVmaze show ids appear in the URL: https://www.tvmaze.com/shows/<id>/<slug>.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
29
|
+
const raw = value ?? defaultValue;
|
|
30
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
31
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
32
|
+
throw new ArgumentError(`tvmaze ${label} must be a positive integer`);
|
|
33
|
+
}
|
|
34
|
+
if (n > maxValue) {
|
|
35
|
+
throw new ArgumentError(`tvmaze ${label} must be <= ${maxValue}`);
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function tvmazeFetch(url, label) {
|
|
41
|
+
let resp;
|
|
42
|
+
try {
|
|
43
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new CommandExecutionError(
|
|
47
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
48
|
+
'Check that api.tvmaze.com is reachable from this network.',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (resp.status === 404) {
|
|
52
|
+
throw new EmptyResultError(label, `TVmaze returned 404 for ${url}.`);
|
|
53
|
+
}
|
|
54
|
+
if (resp.status === 429) {
|
|
55
|
+
throw new CommandExecutionError(
|
|
56
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
57
|
+
'TVmaze caps unauthenticated traffic at ~20 req/10s; wait a few seconds and retry.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!resp.ok) {
|
|
61
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
62
|
+
}
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = await resp.json();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
69
|
+
}
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HTML_ENTITY_MAP = {
|
|
74
|
+
amp: '&',
|
|
75
|
+
lt: '<',
|
|
76
|
+
gt: '>',
|
|
77
|
+
quot: '"',
|
|
78
|
+
apos: "'",
|
|
79
|
+
nbsp: ' ',
|
|
80
|
+
rsquo: '’',
|
|
81
|
+
lsquo: '‘',
|
|
82
|
+
rdquo: '”',
|
|
83
|
+
ldquo: '“',
|
|
84
|
+
hellip: '…',
|
|
85
|
+
ndash: '–',
|
|
86
|
+
mdash: '—',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// TVmaze ships HTML in `summary` ("<p><b>...</b> ...</p>"). Strip tags + decode
|
|
90
|
+
// named and numeric entities so output is plain text.
|
|
91
|
+
export function stripHtml(html) {
|
|
92
|
+
if (html == null) return '';
|
|
93
|
+
let s = String(html);
|
|
94
|
+
s = s.replace(/<[^>]+>/g, '');
|
|
95
|
+
s = s
|
|
96
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
|
97
|
+
const code = parseInt(hex, 16);
|
|
98
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : '';
|
|
99
|
+
})
|
|
100
|
+
.replace(/&#(\d+);/g, (_, dec) => {
|
|
101
|
+
const code = parseInt(dec, 10);
|
|
102
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : '';
|
|
103
|
+
})
|
|
104
|
+
.replace(/&([a-zA-Z]+);/g, (match, name) => HTML_ENTITY_MAP[name] ?? match);
|
|
105
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function joinList(value) {
|
|
109
|
+
return Array.isArray(value) ? value.filter(Boolean).join(', ') : '';
|
|
110
|
+
}
|
package/clis/twitter/accept.js
CHANGED
|
@@ -8,10 +8,10 @@ cli({
|
|
|
8
8
|
domain: 'x.com',
|
|
9
9
|
strategy: Strategy.UI,
|
|
10
10
|
browser: true,
|
|
11
|
-
timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
|
|
12
11
|
args: [
|
|
13
12
|
{ name: 'query', type: 'string', required: true, positional: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
|
|
14
13
|
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
|
|
14
|
+
{ name: 'timeout', type: 'int', required: false, default: 600, help: 'Max seconds for the overall command (default: 600 — batch op)' },
|
|
15
15
|
],
|
|
16
16
|
columns: ['index', 'status', 'user', 'message'],
|
|
17
17
|
func: async (page, kwargs) => {
|
|
@@ -1,103 +1,168 @@
|
|
|
1
|
-
import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract follower rows from Twitter/X follower-list SPA cells.
|
|
6
|
+
*
|
|
7
|
+
* Verified DOM shape on x.com followers list (confirmed live 2026-05-05):
|
|
8
|
+
* - `[data-testid="UserCell"]` per follower
|
|
9
|
+
* - `[data-testid="UserAvatar-Container-<handle>"]` — stable handle source
|
|
10
|
+
* - `[data-testid="<userId>-follow"]` — follow button (i18n-independent)
|
|
11
|
+
* - `[data-testid="userFollowIndicator"]` — "Follows you" badge when present
|
|
12
|
+
* - the bio (when present) is rendered into `cell.innerText` but has NO
|
|
13
|
+
* dedicated testid in the list view (`UserDescription` only appears on
|
|
14
|
+
* the standalone profile page, NOT inside follower-list cells).
|
|
15
|
+
*
|
|
16
|
+
* Strategy: subtract the i18n-variable button / badge texts from the lines of
|
|
17
|
+
* `cell.innerText`, treat the first remaining `@…` line as the handle, the
|
|
18
|
+
* first non-handle line as display name, and the rest as bio. We avoid
|
|
19
|
+
* locale-coupled string matching (`"Follow"` / `"关注"` / `"Folgen"`).
|
|
20
|
+
*
|
|
21
|
+
* Note: Twitter does NOT render follower COUNTS in the list view, so the
|
|
22
|
+
* `followers` column is omitted from the output schema. Use
|
|
23
|
+
* `opencli twitter profile <user>` to read a per-user follower count.
|
|
24
|
+
*/
|
|
25
|
+
async function extractFollowersFromDOM(page) {
|
|
26
|
+
const script = `() => {
|
|
27
|
+
const cells = document.querySelectorAll('[data-testid="UserCell"]');
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const cell of cells) {
|
|
30
|
+
// Collect i18n-variable UI strings to strip from the cell text.
|
|
31
|
+
const stripTexts = new Set();
|
|
32
|
+
const buttons = cell.querySelectorAll(
|
|
33
|
+
'[data-testid$="-follow"],[data-testid$="-unfollow"],[data-testid="userFollowIndicator"]'
|
|
34
|
+
);
|
|
35
|
+
for (const el of buttons) {
|
|
36
|
+
const t = (el.innerText || '').trim();
|
|
37
|
+
if (t) stripTexts.add(t);
|
|
38
|
+
}
|
|
39
|
+
const lines = (cell.innerText || '')
|
|
40
|
+
.split('\\n')
|
|
41
|
+
.map(s => s.trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.filter(l => !stripTexts.has(l));
|
|
44
|
+
// Pull the @handle line; fall back to UserAvatar-Container-<handle>.
|
|
45
|
+
let screen_name = '';
|
|
46
|
+
const remaining = [];
|
|
47
|
+
for (const l of lines) {
|
|
48
|
+
if (!screen_name && l.startsWith('@')) {
|
|
49
|
+
screen_name = l.slice(1).split(/\\s/)[0];
|
|
50
|
+
} else {
|
|
51
|
+
remaining.push(l);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!screen_name) {
|
|
55
|
+
const av = cell.querySelector('[data-testid^="UserAvatar-Container-"]');
|
|
56
|
+
const tid = av ? av.getAttribute('data-testid') || '' : '';
|
|
57
|
+
if (tid.startsWith('UserAvatar-Container-')) {
|
|
58
|
+
screen_name = tid.slice('UserAvatar-Container-'.length);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// First non-handle line is display name (may equal handle when the user hasn't set one).
|
|
62
|
+
const name = remaining[0] || screen_name;
|
|
63
|
+
// Lines past the display name form the bio.
|
|
64
|
+
const bio = remaining.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
|
|
65
|
+
if (screen_name) {
|
|
66
|
+
out.push({ screen_name, name, bio });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}`;
|
|
71
|
+
return page.evaluate(script);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeScreenName(value) {
|
|
75
|
+
return String(value ?? '').trim().replace(/^\/+/, '').replace(/^@+/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
3
78
|
cli({
|
|
4
79
|
site: 'twitter',
|
|
5
80
|
name: 'followers',
|
|
6
81
|
access: 'read',
|
|
7
82
|
description: 'Get accounts following a Twitter/X user',
|
|
8
83
|
domain: 'x.com',
|
|
9
|
-
strategy: Strategy.
|
|
84
|
+
strategy: Strategy.UI,
|
|
10
85
|
browser: true,
|
|
11
86
|
args: [
|
|
12
87
|
{ name: 'user', positional: true, type: 'string', required: false },
|
|
13
88
|
{ name: 'limit', type: 'int', default: 50 },
|
|
14
89
|
],
|
|
15
|
-
|
|
90
|
+
// `followers` (count) is NOT exposed: the SPA followers-list view does not
|
|
91
|
+
// render it. Use `twitter profile <user>` for per-user follower counts.
|
|
92
|
+
columns: ['screen_name', 'name', 'bio'],
|
|
16
93
|
func: async (page, kwargs) => {
|
|
17
|
-
|
|
18
|
-
|
|
94
|
+
const limit = kwargs.limit;
|
|
95
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
96
|
+
throw new ArgumentError('limit must be a positive integer');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let targetUser = normalizeScreenName(kwargs.user);
|
|
19
100
|
if (!targetUser) {
|
|
20
101
|
await page.goto('https://x.com/home');
|
|
21
102
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
103
|
const href = await page.evaluate(`() => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
104
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
105
|
+
return link ? link.getAttribute('href') : null;
|
|
106
|
+
}`);
|
|
26
107
|
if (!href) {
|
|
27
108
|
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
28
109
|
}
|
|
29
|
-
targetUser = href
|
|
110
|
+
targetUser = normalizeScreenName(href);
|
|
30
111
|
}
|
|
112
|
+
if (!targetUser) {
|
|
113
|
+
throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
|
|
114
|
+
}
|
|
115
|
+
|
|
31
116
|
// 1. Navigate to profile page
|
|
32
117
|
await page.goto(`https://x.com/${targetUser}`);
|
|
33
118
|
await page.wait(3);
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
// Twitter uses /verified_followers instead of /followers now.
|
|
119
|
+
|
|
120
|
+
// 2. Click the followers tab via SPA navigation (preserves session/state).
|
|
121
|
+
// Twitter sometimes only renders /verified_followers on profiles with
|
|
122
|
+
// badge filtering enabled; try the canonical link first, fall back.
|
|
39
123
|
const safeUser = JSON.stringify(targetUser);
|
|
40
124
|
const clicked = await page.evaluate(`() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
125
|
+
const target = ${safeUser};
|
|
126
|
+
const selectors = [
|
|
127
|
+
'a[href="/' + target + '/followers"]',
|
|
128
|
+
'a[href="/' + target + '/verified_followers"]',
|
|
129
|
+
];
|
|
130
|
+
for (const sel of selectors) {
|
|
131
|
+
const link = document.querySelector(sel);
|
|
132
|
+
if (link) { link.click(); return true; }
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}`);
|
|
52
136
|
if (!clicked) {
|
|
53
137
|
throw selectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
|
54
138
|
}
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
await page.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!instructions)
|
|
70
|
-
continue;
|
|
71
|
-
let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
|
|
72
|
-
if (!addEntries) {
|
|
73
|
-
addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
|
|
74
|
-
}
|
|
75
|
-
if (!addEntries)
|
|
76
|
-
continue;
|
|
77
|
-
for (const entry of addEntries.entries) {
|
|
78
|
-
if (!entry.entryId.startsWith('user-'))
|
|
79
|
-
continue;
|
|
80
|
-
const item = entry.content?.itemContent?.user_results?.result;
|
|
81
|
-
if (!item || item.__typename !== 'User')
|
|
82
|
-
continue;
|
|
83
|
-
const core = item.core || {};
|
|
84
|
-
const legacy = item.legacy || {};
|
|
85
|
-
results.push({
|
|
86
|
-
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
|
87
|
-
name: core.name || legacy.name || 'unknown',
|
|
88
|
-
bio: legacy.description || item.profile_bio?.description || '',
|
|
89
|
-
followers: legacy.followers_count || legacy.normal_followers_count || 0
|
|
90
|
-
});
|
|
91
|
-
}
|
|
139
|
+
|
|
140
|
+
// 3. Wait for follower cells to appear
|
|
141
|
+
await page.wait({ selector: '[data-testid="UserCell"]', timeout: 10000 });
|
|
142
|
+
|
|
143
|
+
// 4. Extract from DOM, scroll to load more, dedupe by screen_name
|
|
144
|
+
const allFollowers = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
let sameCount = 0;
|
|
147
|
+
while (allFollowers.length < limit && sameCount < 3) {
|
|
148
|
+
const followers = await extractFollowersFromDOM(page);
|
|
149
|
+
const newFollowers = followers.filter(f => !seen.has(f.screen_name));
|
|
150
|
+
for (const f of newFollowers) {
|
|
151
|
+
seen.add(f.screen_name);
|
|
152
|
+
allFollowers.push(f);
|
|
92
153
|
}
|
|
93
|
-
|
|
94
|
-
|
|
154
|
+
if (newFollowers.length === 0) {
|
|
155
|
+
sameCount++;
|
|
156
|
+
} else {
|
|
157
|
+
sameCount = 0;
|
|
95
158
|
}
|
|
159
|
+
if (allFollowers.length >= limit) break;
|
|
160
|
+
await page.autoScroll({ times: 1, delayMs: 500 });
|
|
161
|
+
await page.wait(2);
|
|
162
|
+
}
|
|
163
|
+
if (allFollowers.length === 0) {
|
|
164
|
+
throw new EmptyResultError('twitter followers', `No followers found for @${targetUser}`);
|
|
96
165
|
}
|
|
97
|
-
|
|
98
|
-
const unique = new Map();
|
|
99
|
-
results.forEach(r => unique.set(r.screen_name, r));
|
|
100
|
-
const deduplicatedResults = Array.from(unique.values());
|
|
101
|
-
return deduplicatedResults.slice(0, kwargs.limit);
|
|
166
|
+
return allFollowers.slice(0, limit);
|
|
102
167
|
}
|
|
103
168
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
function extractTweetId(url) {
|
|
6
|
+
return parseTweetUrl(url).id;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildQuoteComposerUrl(url) {
|
|
10
|
+
// Twitter/X quote-tweet compose URL: the `url` param attaches the source
|
|
11
|
+
// tweet as a quoted card. Validating tweet-id shape early surfaces obvious
|
|
12
|
+
// typos before any browser interaction.
|
|
13
|
+
const parsed = parseTweetUrl(url);
|
|
14
|
+
return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function submitQuote(page, text, tweetId) {
|
|
18
|
+
return page.evaluate(`(async () => {
|
|
19
|
+
try {
|
|
20
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
21
|
+
const getStatusId = (href) => {
|
|
22
|
+
try {
|
|
23
|
+
const match = new URL(href, window.location.origin).pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/);
|
|
24
|
+
return match?.[1] || null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
30
|
+
const box = boxes.find(visible) || boxes[0];
|
|
31
|
+
if (!box) {
|
|
32
|
+
return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
box.focus();
|
|
36
|
+
const textToInsert = ${JSON.stringify(text)};
|
|
37
|
+
const tweetId = ${JSON.stringify(tweetId)};
|
|
38
|
+
// execCommand('insertText') is more reliable with Twitter's Draft.js editor.
|
|
39
|
+
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
40
|
+
// Fallback to paste event if execCommand fails.
|
|
41
|
+
const dataTransfer = new DataTransfer();
|
|
42
|
+
dataTransfer.setData('text/plain', textToInsert);
|
|
43
|
+
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
44
|
+
clipboardData: dataTransfer,
|
|
45
|
+
bubbles: true,
|
|
46
|
+
cancelable: true,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
51
|
+
|
|
52
|
+
// Confirm the quoted card is rendered before submitting; otherwise we may
|
|
53
|
+
// accidentally post a plain tweet without the quote attachment.
|
|
54
|
+
let cardAttempts = 0;
|
|
55
|
+
let hasQuoteCard = false;
|
|
56
|
+
while (cardAttempts < 20) {
|
|
57
|
+
hasQuoteCard = Array.from(document.querySelectorAll('a[href*="/status/"]'))
|
|
58
|
+
.some((link) => getStatusId(link.href) === tweetId);
|
|
59
|
+
if (hasQuoteCard) break;
|
|
60
|
+
await new Promise(r => setTimeout(r, 250));
|
|
61
|
+
cardAttempts++;
|
|
62
|
+
}
|
|
63
|
+
if (!hasQuoteCard) {
|
|
64
|
+
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const buttons = Array.from(
|
|
68
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
69
|
+
);
|
|
70
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
71
|
+
if (!btn) {
|
|
72
|
+
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
btn.click();
|
|
76
|
+
|
|
77
|
+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
78
|
+
const expectedText = normalize(textToInsert);
|
|
79
|
+
for (let i = 0; i < 30; i++) {
|
|
80
|
+
await new Promise(r => setTimeout(r, 500));
|
|
81
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
82
|
+
.filter((el) => visible(el));
|
|
83
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
84
|
+
if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
85
|
+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
86
|
+
if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
|
|
87
|
+
|
|
88
|
+
const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
89
|
+
const composerStillHasText = visibleBoxes.some((box) =>
|
|
90
|
+
normalize(box.innerText || box.textContent || '').includes(expectedText)
|
|
91
|
+
);
|
|
92
|
+
if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
93
|
+
}
|
|
94
|
+
return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, message: e.toString() };
|
|
97
|
+
}
|
|
98
|
+
})()`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cli({
|
|
102
|
+
site: 'twitter',
|
|
103
|
+
name: 'quote',
|
|
104
|
+
access: 'write',
|
|
105
|
+
description: 'Quote-tweet a specific tweet with your own text',
|
|
106
|
+
domain: 'x.com',
|
|
107
|
+
strategy: Strategy.UI,
|
|
108
|
+
browser: true,
|
|
109
|
+
args: [
|
|
110
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
|
|
111
|
+
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
|
|
112
|
+
],
|
|
113
|
+
columns: ['status', 'message', 'text'],
|
|
114
|
+
func: async (page, kwargs) => {
|
|
115
|
+
if (!page)
|
|
116
|
+
throw new CommandExecutionError('Browser session required for twitter quote');
|
|
117
|
+
|
|
118
|
+
// Dedicated composer is more reliable than the inline quote-tweet button.
|
|
119
|
+
const target = parseTweetUrl(kwargs.url);
|
|
120
|
+
await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
|
|
121
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
122
|
+
|
|
123
|
+
const result = await submitQuote(page, kwargs.text, target.id);
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
// Wait for network submission to complete
|
|
126
|
+
await page.wait(3);
|
|
127
|
+
}
|
|
128
|
+
return [{
|
|
129
|
+
status: result.ok ? 'success' : 'failed',
|
|
130
|
+
message: result.message,
|
|
131
|
+
text: kwargs.text,
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const __test__ = {
|
|
137
|
+
buildQuoteComposerUrl,
|
|
138
|
+
extractTweetId,
|
|
139
|
+
};
|