@jackwener/opencli 1.6.1 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1 -1
- package/README.md +27 -45
- package/README.zh-CN.md +32 -34
- package/autoresearch/browse-tasks.json +18 -20
- package/autoresearch/commands/debug.ts +163 -0
- package/autoresearch/commands/fix.ts +145 -0
- package/autoresearch/commands/plan.ts +88 -0
- package/autoresearch/commands/run.ts +138 -0
- package/autoresearch/config.ts +82 -0
- package/autoresearch/engine.ts +359 -0
- package/autoresearch/eval-all.ts +127 -0
- package/autoresearch/eval-browse.ts +1 -1
- package/autoresearch/eval-publish.ts +238 -0
- package/autoresearch/eval-save.ts +249 -0
- package/autoresearch/eval-skill.ts +14 -8
- package/autoresearch/eval-v2ex.ts +220 -0
- package/autoresearch/eval-zhihu.ts +230 -0
- package/autoresearch/logger.ts +69 -0
- package/autoresearch/presets/combined-reliability.ts +27 -0
- package/autoresearch/presets/index.ts +23 -0
- package/autoresearch/presets/operate-reliability.ts +24 -0
- package/autoresearch/presets/save-reliability.ts +26 -0
- package/autoresearch/presets/skill-quality.ts +20 -0
- package/autoresearch/presets/v2ex-reliability.ts +24 -0
- package/autoresearch/presets/zhihu-reliability.ts +25 -0
- package/autoresearch/publish-tasks.json +345 -0
- package/autoresearch/run-save.sh +11 -0
- package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
- package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
- package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
- package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
- package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
- package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
- package/autoresearch/save-tasks.json +281 -0
- package/autoresearch/v2ex-tasks.json +899 -0
- package/autoresearch/zhihu-tasks.json +848 -0
- package/dist/browser/base-page.d.ts +4 -2
- package/dist/browser/base-page.js +37 -4
- package/dist/browser/bridge.js +10 -8
- package/dist/browser/cdp.js +2 -6
- package/dist/browser/daemon-client.d.ts +11 -1
- package/dist/browser/daemon-client.js +3 -0
- package/dist/browser/dom-helpers.d.ts +4 -2
- package/dist/browser/dom-helpers.js +42 -31
- package/dist/browser/dom-snapshot.js +23 -1
- package/dist/browser/page.d.ts +7 -2
- package/dist/browser/page.js +112 -30
- package/dist/browser.test.js +1 -1
- package/dist/build-manifest.d.ts +1 -0
- package/dist/build-manifest.js +1 -0
- package/dist/cli-manifest.json +1135 -184
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +48 -7
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +88 -0
- package/dist/clis/1688/item.d.ts +70 -0
- package/dist/clis/1688/item.js +187 -0
- package/dist/clis/1688/item.test.d.ts +1 -0
- package/dist/clis/1688/item.test.js +67 -0
- package/dist/clis/1688/search.d.ts +56 -0
- package/dist/clis/1688/search.js +309 -0
- package/dist/clis/1688/search.test.d.ts +1 -0
- package/dist/clis/1688/search.test.js +75 -0
- package/dist/clis/1688/shared.d.ts +112 -0
- package/dist/clis/1688/shared.js +514 -0
- package/dist/clis/1688/shared.test.d.ts +1 -0
- package/dist/clis/1688/shared.test.js +57 -0
- package/dist/clis/1688/store.d.ts +45 -0
- package/dist/clis/1688/store.js +226 -0
- package/dist/clis/1688/store.test.d.ts +1 -0
- package/dist/clis/1688/store.test.js +62 -0
- package/dist/clis/amazon/bestsellers.d.ts +0 -20
- package/dist/clis/amazon/bestsellers.js +6 -129
- package/dist/clis/amazon/bestsellers.test.js +12 -3
- package/dist/clis/amazon/movers-shakers.d.ts +1 -0
- package/dist/clis/amazon/movers-shakers.js +7 -0
- package/dist/clis/amazon/new-releases.d.ts +1 -0
- package/dist/clis/amazon/new-releases.js +7 -0
- package/dist/clis/amazon/rankings.d.ts +59 -0
- package/dist/clis/amazon/rankings.js +226 -0
- package/dist/clis/amazon/rankings.test.d.ts +1 -0
- package/dist/clis/amazon/rankings.test.js +41 -0
- package/dist/clis/amazon/shared.d.ts +11 -0
- package/dist/clis/amazon/shared.js +121 -11
- package/dist/clis/amazon/shared.test.js +11 -0
- package/dist/clis/bilibili/comments.js +2 -2
- package/dist/clis/bilibili/comments.test.js +3 -2
- package/dist/clis/bilibili/download.js +2 -1
- package/dist/clis/bilibili/subtitle.js +4 -3
- package/dist/clis/bilibili/subtitle.test.js +2 -1
- package/dist/clis/bilibili/utils.d.ts +5 -0
- package/dist/clis/bilibili/utils.js +30 -0
- package/dist/clis/bilibili/utils.test.d.ts +1 -0
- package/dist/clis/bilibili/utils.test.js +17 -0
- package/dist/clis/douban/marks.js +1 -1
- package/dist/clis/douban/subject.yaml +50 -19
- package/dist/clis/doubao/utils.js +32 -12
- package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
- package/dist/clis/douyin/_shared/transcode.test.js +0 -2
- package/dist/clis/douyin/draft.test.js +0 -2
- package/dist/clis/facebook/search.test.js +0 -2
- package/dist/clis/gemini/ask.js +9 -3
- package/dist/clis/gemini/ask.test.d.ts +1 -0
- package/dist/clis/gemini/ask.test.js +100 -0
- package/dist/clis/gemini/reply-state.test.d.ts +1 -0
- package/dist/clis/gemini/reply-state.test.js +641 -0
- package/dist/clis/gemini/utils.d.ts +44 -1
- package/dist/clis/gemini/utils.js +528 -61
- package/dist/clis/gemini/utils.test.js +149 -2
- package/dist/clis/hupu/detail.d.ts +1 -0
- package/dist/clis/hupu/detail.js +72 -0
- package/dist/clis/hupu/hot.yaml +43 -0
- package/dist/clis/hupu/like.d.ts +1 -0
- package/dist/clis/hupu/like.js +75 -0
- package/dist/clis/hupu/reply.d.ts +1 -0
- package/dist/clis/hupu/reply.js +71 -0
- package/dist/clis/hupu/search.d.ts +1 -0
- package/dist/clis/hupu/search.js +59 -0
- package/dist/clis/hupu/unlike.d.ts +1 -0
- package/dist/clis/hupu/unlike.js +75 -0
- package/dist/clis/hupu/utils.d.ts +20 -0
- package/dist/clis/hupu/utils.js +319 -0
- package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
- package/dist/clis/instagram/_shared/private-publish.js +1030 -0
- package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
- package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
- package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
- package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
- package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
- package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
- package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
- package/dist/clis/instagram/_shared/runtime-info.js +81 -0
- package/dist/clis/instagram/note.d.ts +1 -0
- package/dist/clis/instagram/note.js +222 -0
- package/dist/clis/instagram/note.test.d.ts +1 -0
- package/dist/clis/instagram/note.test.js +81 -0
- package/dist/clis/instagram/post.d.ts +4 -0
- package/dist/clis/instagram/post.js +1496 -0
- package/dist/clis/instagram/post.test.d.ts +1 -0
- package/dist/clis/instagram/post.test.js +1647 -0
- package/dist/clis/instagram/reel.d.ts +1 -0
- package/dist/clis/instagram/reel.js +826 -0
- package/dist/clis/instagram/reel.test.d.ts +1 -0
- package/dist/clis/instagram/reel.test.js +167 -0
- package/dist/clis/instagram/story.d.ts +1 -0
- package/dist/clis/instagram/story.js +115 -0
- package/dist/clis/instagram/story.test.d.ts +1 -0
- package/dist/clis/instagram/story.test.js +167 -0
- package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
- package/dist/clis/sinafinance/stock-rank.js +65 -0
- package/dist/clis/substack/utils.test.js +0 -2
- package/dist/clis/twitter/post.js +72 -45
- package/dist/clis/twitter/post.test.d.ts +1 -0
- package/dist/clis/twitter/post.test.js +116 -0
- package/dist/clis/twitter/reply.d.ts +12 -0
- package/dist/clis/twitter/reply.js +257 -35
- package/dist/clis/twitter/reply.test.d.ts +1 -0
- package/dist/clis/twitter/reply.test.js +151 -0
- package/dist/clis/xianyu/chat.d.ts +7 -0
- package/dist/clis/xianyu/chat.js +146 -0
- package/dist/clis/xianyu/chat.test.d.ts +1 -0
- package/dist/clis/xianyu/chat.test.js +15 -0
- package/dist/clis/xianyu/item.d.ts +7 -0
- package/dist/clis/xianyu/item.js +152 -0
- package/dist/clis/xianyu/item.test.d.ts +1 -0
- package/dist/clis/xianyu/item.test.js +56 -0
- package/dist/clis/xianyu/search.d.ts +10 -0
- package/dist/clis/xianyu/search.js +134 -0
- package/dist/clis/xianyu/search.test.d.ts +1 -0
- package/dist/clis/xianyu/search.test.js +17 -0
- package/dist/clis/xianyu/utils.d.ts +1 -0
- package/dist/clis/xianyu/utils.js +8 -0
- package/dist/clis/xiaoe/catalog.yaml +129 -0
- package/dist/clis/xiaoe/content.yaml +43 -0
- package/dist/clis/xiaoe/courses.yaml +73 -0
- package/dist/clis/xiaoe/detail.yaml +39 -0
- package/dist/clis/xiaoe/play-url.yaml +124 -0
- package/dist/clis/xiaohongshu/comments.test.js +0 -2
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
- package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
- package/dist/clis/xiaohongshu/download.test.js +0 -2
- package/dist/clis/xiaohongshu/note.test.js +0 -2
- package/dist/clis/xiaohongshu/publish.test.js +0 -2
- package/dist/clis/xiaohongshu/search.js +29 -20
- package/dist/clis/xiaohongshu/search.test.js +56 -48
- package/dist/clis/yuanbao/ask.d.ts +21 -0
- package/dist/clis/yuanbao/ask.js +427 -0
- package/dist/clis/yuanbao/ask.test.d.ts +1 -0
- package/dist/clis/yuanbao/ask.test.js +124 -0
- package/dist/clis/yuanbao/new.d.ts +1 -0
- package/dist/clis/yuanbao/new.js +70 -0
- package/dist/clis/yuanbao/new.test.d.ts +1 -0
- package/dist/clis/yuanbao/new.test.js +30 -0
- package/dist/clis/yuanbao/shared.d.ts +13 -0
- package/dist/clis/yuanbao/shared.js +49 -0
- package/dist/clis/zhihu/question.js +30 -19
- package/dist/clis/zhihu/question.test.js +34 -16
- package/dist/commanderAdapter.js +8 -4
- package/dist/commanderAdapter.test.js +42 -0
- package/dist/completion.js +3 -1
- package/dist/completion.test.d.ts +1 -0
- package/dist/completion.test.js +23 -0
- package/dist/doctor.js +1 -1
- package/dist/electron-apps.d.ts +2 -0
- package/dist/electron-apps.js +7 -1
- package/dist/errors.js +1 -1
- package/dist/execution.js +25 -35
- package/dist/explore.js +1 -1
- package/dist/launcher.d.ts +4 -0
- package/dist/launcher.js +64 -8
- package/dist/launcher.test.js +88 -7
- package/dist/output.d.ts +2 -0
- package/dist/output.js +10 -1
- package/dist/output.test.d.ts +0 -3
- package/dist/output.test.js +59 -92
- package/dist/pipeline/executor.test.js +0 -2
- package/dist/pipeline/steps/download.test.js +0 -2
- package/dist/registry.d.ts +2 -0
- package/dist/serialization.d.ts +1 -0
- package/dist/serialization.js +1 -0
- package/dist/types.d.ts +9 -2
- package/docs/.vitepress/config.mts +4 -0
- package/docs/adapters/browser/1688.md +52 -0
- package/docs/adapters/browser/36kr.md +2 -1
- package/docs/adapters/browser/doubao.md +5 -1
- package/docs/adapters/browser/hupu.md +53 -0
- package/docs/adapters/browser/sinafinance.md +32 -2
- package/docs/adapters/browser/weibo.md +6 -1
- package/docs/adapters/browser/wikipedia.md +2 -0
- package/docs/adapters/browser/xianyu.md +42 -0
- package/docs/adapters/browser/xiaoe.md +44 -0
- package/docs/adapters/browser/yuanbao.md +64 -0
- package/docs/adapters/index.md +14 -5
- package/docs/comparison.md +1 -1
- package/docs/developer/ai-workflow.md +2 -2
- package/docs/developer/contributing.md +1 -1
- package/docs/developer/testing.md +2 -0
- package/docs/guide/plugins.md +1 -0
- package/docs/guide/troubleshooting.md +11 -0
- package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
- package/docs/zh/guide/plugins.md +1 -0
- package/extension/dist/background.js +1127 -0
- package/extension/src/background.test.ts +39 -0
- package/extension/src/background.ts +223 -34
- package/extension/src/cdp.ts +194 -4
- package/extension/src/protocol.ts +22 -1
- package/package.json +3 -2
- package/scripts/postinstall.js +1 -1
- package/skills/opencli-explorer/SKILL.md +1 -1
- package/skills/opencli-oneshot/SKILL.md +2 -2
- package/skills/opencli-operate/SKILL.md +120 -27
- package/skills/opencli-usage/SKILL.md +31 -20
- package/skills/opencli-usage/browser.md +114 -16
- package/skills/opencli-usage/public-api.md +32 -3
- package/skills/smart-search/SKILL.md +156 -0
- package/skills/smart-search/references/sources-ai.md +74 -0
- package/skills/smart-search/references/sources-info.md +43 -0
- package/skills/smart-search/references/sources-media.md +50 -0
- package/skills/smart-search/references/sources-other.md +42 -0
- package/skills/smart-search/references/sources-shopping.md +31 -0
- package/skills/smart-search/references/sources-social.md +51 -0
- package/skills/smart-search/references/sources-tech.md +42 -0
- package/skills/smart-search/references/sources-travel.md +20 -0
- package/src/browser/base-page.ts +41 -6
- package/src/browser/bridge.ts +11 -8
- package/src/browser/cdp.ts +1 -8
- package/src/browser/daemon-client.ts +11 -1
- package/src/browser/dom-helpers.ts +43 -31
- package/src/browser/dom-snapshot.ts +23 -1
- package/src/browser/page.ts +115 -31
- package/src/browser.test.ts +1 -1
- package/src/build-manifest.ts +2 -0
- package/src/cli.test.ts +133 -0
- package/src/cli.ts +73 -11
- package/src/clis/1688/item.test.ts +69 -0
- package/src/clis/1688/item.ts +282 -0
- package/src/clis/1688/search.test.ts +81 -0
- package/src/clis/1688/search.ts +402 -0
- package/src/clis/1688/shared.test.ts +75 -0
- package/src/clis/1688/shared.ts +623 -0
- package/src/clis/1688/store.test.ts +69 -0
- package/src/clis/1688/store.ts +300 -0
- package/src/clis/amazon/bestsellers.test.ts +12 -3
- package/src/clis/amazon/bestsellers.ts +6 -178
- package/src/clis/amazon/movers-shakers.ts +8 -0
- package/src/clis/amazon/new-releases.ts +8 -0
- package/src/clis/amazon/rankings.test.ts +47 -0
- package/src/clis/amazon/rankings.ts +312 -0
- package/src/clis/amazon/shared.test.ts +16 -0
- package/src/clis/amazon/shared.ts +134 -12
- package/src/clis/bilibili/comments.test.ts +4 -3
- package/src/clis/bilibili/comments.ts +2 -2
- package/src/clis/bilibili/download.ts +2 -1
- package/src/clis/bilibili/subtitle.test.ts +2 -1
- package/src/clis/bilibili/subtitle.ts +4 -3
- package/src/clis/bilibili/utils.test.ts +21 -0
- package/src/clis/bilibili/utils.ts +27 -0
- package/src/clis/douban/marks.ts +1 -1
- package/src/clis/douban/subject.yaml +50 -19
- package/src/clis/doubao/utils.ts +32 -12
- package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
- package/src/clis/douyin/_shared/transcode.test.ts +0 -2
- package/src/clis/douyin/draft.test.ts +0 -2
- package/src/clis/facebook/search.test.ts +0 -2
- package/src/clis/gemini/ask.test.ts +116 -0
- package/src/clis/gemini/ask.ts +10 -3
- package/src/clis/gemini/reply-state.test.ts +708 -0
- package/src/clis/gemini/utils.test.ts +184 -2
- package/src/clis/gemini/utils.ts +588 -60
- package/src/clis/hupu/detail.ts +126 -0
- package/src/clis/hupu/hot.yaml +43 -0
- package/src/clis/hupu/like.ts +76 -0
- package/src/clis/hupu/reply.ts +76 -0
- package/src/clis/hupu/search.ts +95 -0
- package/src/clis/hupu/unlike.ts +76 -0
- package/src/clis/hupu/utils.ts +381 -0
- package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
- package/src/clis/instagram/_shared/private-publish.ts +1303 -0
- package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
- package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
- package/src/clis/instagram/_shared/runtime-info.ts +91 -0
- package/src/clis/instagram/note.test.ts +96 -0
- package/src/clis/instagram/note.ts +254 -0
- package/src/clis/instagram/post.test.ts +1716 -0
- package/src/clis/instagram/post.ts +1620 -0
- package/src/clis/instagram/reel.test.ts +191 -0
- package/src/clis/instagram/reel.ts +886 -0
- package/src/clis/instagram/story.test.ts +191 -0
- package/src/clis/instagram/story.ts +151 -0
- package/src/clis/sinafinance/stock-rank.ts +68 -0
- package/src/clis/substack/utils.test.ts +0 -2
- package/src/clis/twitter/post.test.ts +157 -0
- package/src/clis/twitter/post.ts +82 -48
- package/src/clis/twitter/reply.test.ts +177 -0
- package/src/clis/twitter/reply.ts +285 -39
- package/src/clis/xianyu/chat.test.ts +20 -0
- package/src/clis/xianyu/chat.ts +175 -0
- package/src/clis/xianyu/item.test.ts +67 -0
- package/src/clis/xianyu/item.ts +172 -0
- package/src/clis/xianyu/search.test.ts +22 -0
- package/src/clis/xianyu/search.ts +151 -0
- package/src/clis/xianyu/utils.ts +9 -0
- package/src/clis/xiaoe/catalog.yaml +129 -0
- package/src/clis/xiaoe/content.yaml +43 -0
- package/src/clis/xiaoe/courses.yaml +73 -0
- package/src/clis/xiaoe/detail.yaml +39 -0
- package/src/clis/xiaoe/play-url.yaml +124 -0
- package/src/clis/xiaohongshu/comments.test.ts +0 -2
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
- package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
- package/src/clis/xiaohongshu/download.test.ts +0 -2
- package/src/clis/xiaohongshu/note.test.ts +0 -2
- package/src/clis/xiaohongshu/publish.test.ts +0 -2
- package/src/clis/xiaohongshu/search.test.ts +59 -48
- package/src/clis/xiaohongshu/search.ts +31 -21
- package/src/clis/yuanbao/ask.test.ts +156 -0
- package/src/clis/yuanbao/ask.ts +522 -0
- package/src/clis/yuanbao/new.test.ts +36 -0
- package/src/clis/yuanbao/new.ts +81 -0
- package/src/clis/yuanbao/shared.ts +57 -0
- package/src/clis/zhihu/question.test.ts +42 -17
- package/src/clis/zhihu/question.ts +31 -26
- package/src/commanderAdapter.test.ts +51 -0
- package/src/commanderAdapter.ts +8 -4
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +3 -1
- package/src/doctor.ts +1 -1
- package/src/electron-apps.ts +9 -1
- package/src/errors.ts +1 -1
- package/src/execution.ts +26 -30
- package/src/explore.ts +1 -1
- package/src/launcher.test.ts +121 -7
- package/src/launcher.ts +87 -9
- package/src/output.test.ts +50 -90
- package/src/output.ts +10 -1
- package/src/pipeline/executor.test.ts +0 -2
- package/src/pipeline/steps/download.test.ts +0 -2
- package/src/registry.ts +2 -0
- package/src/serialization.ts +2 -0
- package/src/types.ts +9 -2
- package/tests/e2e/browser-auth.test.ts +9 -0
- package/CLI-EXPLORER.md +0 -724
- package/CLI-ONESHOT.md +0 -216
- package/SKILL.md +0 -59
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
1
3
|
import { cli, Strategy } from '../../registry.js';
|
|
2
4
|
import { CommandExecutionError } from '../../errors.js';
|
|
5
|
+
const MAX_IMAGES = 4;
|
|
6
|
+
const UPLOAD_POLL_MS = 500;
|
|
7
|
+
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
8
|
+
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
9
|
+
function validateImagePaths(raw) {
|
|
10
|
+
const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
11
|
+
if (paths.length > MAX_IMAGES) {
|
|
12
|
+
throw new CommandExecutionError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
|
|
13
|
+
}
|
|
14
|
+
return paths.map(p => {
|
|
15
|
+
const absPath = path.resolve(p);
|
|
16
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
17
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
18
|
+
throw new CommandExecutionError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
|
|
19
|
+
}
|
|
20
|
+
const stat = fs.statSync(absPath, { throwIfNoEntry: false });
|
|
21
|
+
if (!stat || !stat.isFile()) {
|
|
22
|
+
throw new CommandExecutionError(`Not a valid file: ${absPath}`);
|
|
23
|
+
}
|
|
24
|
+
return absPath;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
3
27
|
cli({
|
|
4
28
|
site: 'twitter',
|
|
5
29
|
name: 'post',
|
|
@@ -9,63 +33,66 @@ cli({
|
|
|
9
33
|
browser: true,
|
|
10
34
|
args: [
|
|
11
35
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
|
|
36
|
+
{ name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
|
|
12
37
|
],
|
|
13
38
|
columns: ['status', 'message', 'text'],
|
|
14
39
|
func: async (page, kwargs) => {
|
|
15
40
|
if (!page)
|
|
16
41
|
throw new CommandExecutionError('Browser session required for twitter post');
|
|
17
|
-
//
|
|
42
|
+
// Validate images upfront before any browser interaction
|
|
43
|
+
const absPaths = kwargs.images ? validateImagePaths(String(kwargs.images)) : [];
|
|
44
|
+
// 1. Navigate to compose modal
|
|
18
45
|
await page.goto('https://x.com/compose/tweet');
|
|
19
|
-
await page.wait(3);
|
|
20
|
-
// 2.
|
|
21
|
-
const
|
|
46
|
+
await page.wait(3);
|
|
47
|
+
// 2. Type the text via clipboard paste (handles newlines in Draft.js)
|
|
48
|
+
const typeResult = await page.evaluate(`(async () => {
|
|
22
49
|
try {
|
|
23
|
-
// Find the active text area
|
|
24
50
|
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
|
25
|
-
if (box) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
if (!box) return { ok: false, message: 'Could not find the tweet composer text area.' };
|
|
52
|
+
box.focus();
|
|
53
|
+
const dt = new DataTransfer();
|
|
54
|
+
dt.setData('text/plain', ${JSON.stringify(kwargs.text)});
|
|
55
|
+
box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
56
|
+
return { ok: true };
|
|
57
|
+
} catch (e) { return { ok: false, message: String(e) }; }
|
|
58
|
+
})()`);
|
|
59
|
+
if (!typeResult.ok) {
|
|
60
|
+
return [{ status: 'failed', message: typeResult.message, text: kwargs.text }];
|
|
61
|
+
}
|
|
62
|
+
// 3. Attach images if provided
|
|
63
|
+
if (absPaths.length > 0) {
|
|
64
|
+
if (!page.setFileInput) {
|
|
65
|
+
throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
|
|
38
66
|
}
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
67
|
+
await page.setFileInput(absPaths, 'input[data-testid="fileInput"]');
|
|
68
|
+
// Poll until attachments render and tweet button is enabled
|
|
69
|
+
const pollIterations = Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS);
|
|
70
|
+
const uploaded = await page.evaluate(`(async () => {
|
|
71
|
+
for (let i = 0; i < ${JSON.stringify(pollIterations)}; i++) {
|
|
72
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)}));
|
|
73
|
+
const container = document.querySelector('[data-testid="attachments"]');
|
|
74
|
+
if (!container) continue;
|
|
75
|
+
if (container.querySelectorAll('[role="group"]').length !== ${JSON.stringify(absPaths.length)}) continue;
|
|
76
|
+
const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
|
|
77
|
+
if (btn && !btn.disabled) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
})()`);
|
|
81
|
+
if (!uploaded) {
|
|
82
|
+
return [{ status: 'failed', message: `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text: kwargs.text }];
|
|
56
83
|
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
return { ok: false, message: e.toString() };
|
|
59
84
|
}
|
|
85
|
+
// 4. Click the post button
|
|
86
|
+
await page.wait(1);
|
|
87
|
+
const result = await page.evaluate(`(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
|
|
90
|
+
if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; }
|
|
91
|
+
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
92
|
+
} catch (e) { return { ok: false, message: String(e) }; }
|
|
60
93
|
})()`);
|
|
61
|
-
|
|
62
|
-
if (result.ok) {
|
|
94
|
+
if (result.ok)
|
|
63
95
|
await page.wait(3);
|
|
64
|
-
}
|
|
65
|
-
return [{
|
|
66
|
-
status: result.ok ? 'success' : 'failed',
|
|
67
|
-
message: result.message,
|
|
68
|
-
text: kwargs.text
|
|
69
|
-
}];
|
|
96
|
+
return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text }];
|
|
70
97
|
}
|
|
71
98
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './post.js';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './post.js';
|
|
4
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
statSync: vi.fn((p, _opts) => {
|
|
9
|
+
if (String(p).includes('missing'))
|
|
10
|
+
return undefined;
|
|
11
|
+
return { isFile: () => true };
|
|
12
|
+
}),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
vi.mock('node:path', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
resolve: vi.fn((p) => `/abs/${p}`),
|
|
20
|
+
extname: vi.fn((p) => {
|
|
21
|
+
const m = p.match(/\.[^.]+$/);
|
|
22
|
+
return m ? m[0] : '';
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
function makePage(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
evaluate: vi.fn().mockResolvedValue({ ok: true }),
|
|
31
|
+
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
describe('twitter post command', () => {
|
|
36
|
+
const getCommand = () => getRegistry().get('twitter/post');
|
|
37
|
+
it('posts text-only tweet successfully', async () => {
|
|
38
|
+
const command = getCommand();
|
|
39
|
+
const page = makePage({
|
|
40
|
+
evaluate: vi.fn()
|
|
41
|
+
.mockResolvedValueOnce({ ok: true })
|
|
42
|
+
.mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }),
|
|
43
|
+
});
|
|
44
|
+
const result = await command.func(page, { text: 'hello world' });
|
|
45
|
+
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hello world' }]);
|
|
46
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/tweet');
|
|
47
|
+
});
|
|
48
|
+
it('returns failed when text area not found', async () => {
|
|
49
|
+
const command = getCommand();
|
|
50
|
+
const page = makePage({
|
|
51
|
+
evaluate: vi.fn()
|
|
52
|
+
.mockResolvedValueOnce({ ok: false, message: 'Could not find the tweet composer text area.' }),
|
|
53
|
+
});
|
|
54
|
+
const result = await command.func(page, { text: 'hello' });
|
|
55
|
+
expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area.', text: 'hello' }]);
|
|
56
|
+
});
|
|
57
|
+
it('throws when more than 4 images', async () => {
|
|
58
|
+
const command = getCommand();
|
|
59
|
+
const page = makePage();
|
|
60
|
+
await expect(command.func(page, { text: 'hi', images: 'a.png,b.png,c.png,d.png,e.png' })).rejects.toThrow('Too many images: 5 (max 4)');
|
|
61
|
+
});
|
|
62
|
+
it('throws when image file does not exist', async () => {
|
|
63
|
+
const command = getCommand();
|
|
64
|
+
const page = makePage();
|
|
65
|
+
await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
|
|
66
|
+
});
|
|
67
|
+
it('throws on unsupported image format', async () => {
|
|
68
|
+
const command = getCommand();
|
|
69
|
+
const page = makePage();
|
|
70
|
+
await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
|
|
71
|
+
});
|
|
72
|
+
it('throws when page.setFileInput is not available', async () => {
|
|
73
|
+
const command = getCommand();
|
|
74
|
+
const page = makePage({
|
|
75
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
|
|
76
|
+
setFileInput: undefined,
|
|
77
|
+
});
|
|
78
|
+
await expect(command.func(page, { text: 'hi', images: 'a.png' })).rejects.toThrow('Browser extension does not support file upload');
|
|
79
|
+
});
|
|
80
|
+
it('posts with images when upload completes', async () => {
|
|
81
|
+
const command = getCommand();
|
|
82
|
+
const page = makePage({
|
|
83
|
+
evaluate: vi.fn()
|
|
84
|
+
.mockResolvedValueOnce({ ok: true }) // type text
|
|
85
|
+
.mockResolvedValueOnce(true) // upload polling returns true
|
|
86
|
+
.mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post
|
|
87
|
+
});
|
|
88
|
+
const result = await command.func(page, { text: 'with images', images: 'a.png,b.png' });
|
|
89
|
+
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with images' }]);
|
|
90
|
+
expect(page.setFileInput).toHaveBeenCalled();
|
|
91
|
+
const uploadScript = page.evaluate.mock.calls[1][0];
|
|
92
|
+
expect(uploadScript).toContain('[data-testid="attachments"]');
|
|
93
|
+
expect(uploadScript).toContain('[role="group"]');
|
|
94
|
+
});
|
|
95
|
+
it('returns failed when image upload times out', async () => {
|
|
96
|
+
const command = getCommand();
|
|
97
|
+
const page = makePage({
|
|
98
|
+
evaluate: vi.fn()
|
|
99
|
+
.mockResolvedValueOnce({ ok: true })
|
|
100
|
+
.mockResolvedValueOnce(false),
|
|
101
|
+
});
|
|
102
|
+
const result = await command.func(page, { text: 'timeout', images: 'a.png' });
|
|
103
|
+
expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]);
|
|
104
|
+
});
|
|
105
|
+
it('validates images before navigating to compose page', async () => {
|
|
106
|
+
const command = getCommand();
|
|
107
|
+
const page = makePage();
|
|
108
|
+
await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
|
|
109
|
+
// Should NOT have navigated since validation happens first
|
|
110
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
it('throws when no browser session', async () => {
|
|
113
|
+
const command = getCommand();
|
|
114
|
+
await expect(command.func(null, { text: 'hi' })).rejects.toThrow('Browser session required for twitter post');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1 +1,13 @@
|
|
|
1
|
+
declare function resolveImagePath(imagePath: string): string;
|
|
2
|
+
declare function extractTweetId(url: string): string;
|
|
3
|
+
declare function buildReplyComposerUrl(url: string): string;
|
|
4
|
+
declare function resolveImageExtension(url: string, contentType: string | null): string;
|
|
5
|
+
declare function downloadRemoteImage(imageUrl: string): Promise<string>;
|
|
6
|
+
export declare const __test__: {
|
|
7
|
+
buildReplyComposerUrl: typeof buildReplyComposerUrl;
|
|
8
|
+
downloadRemoteImage: typeof downloadRemoteImage;
|
|
9
|
+
extractTweetId: typeof extractTweetId;
|
|
10
|
+
resolveImageExtension: typeof resolveImageExtension;
|
|
11
|
+
resolveImagePath: typeof resolveImagePath;
|
|
12
|
+
};
|
|
1
13
|
export {};
|
|
@@ -1,58 +1,280 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
1
4
|
import { CommandExecutionError } from '../../errors.js';
|
|
2
5
|
import { cli, Strategy } from '../../registry.js';
|
|
6
|
+
const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
|
|
7
|
+
const SUPPORTED_IMAGE_EXTENSIONS = new Set([
|
|
8
|
+
'.jpg',
|
|
9
|
+
'.jpeg',
|
|
10
|
+
'.png',
|
|
11
|
+
'.gif',
|
|
12
|
+
'.webp',
|
|
13
|
+
]);
|
|
14
|
+
const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
|
|
15
|
+
const CONTENT_TYPE_TO_EXTENSION = {
|
|
16
|
+
'image/jpeg': '.jpg',
|
|
17
|
+
'image/jpg': '.jpg',
|
|
18
|
+
'image/png': '.png',
|
|
19
|
+
'image/gif': '.gif',
|
|
20
|
+
'image/webp': '.webp',
|
|
21
|
+
};
|
|
22
|
+
function resolveImagePath(imagePath) {
|
|
23
|
+
const absPath = path.resolve(imagePath);
|
|
24
|
+
if (!fs.existsSync(absPath)) {
|
|
25
|
+
throw new Error(`Image file not found: ${absPath}`);
|
|
26
|
+
}
|
|
27
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
28
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
|
29
|
+
throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
|
|
30
|
+
}
|
|
31
|
+
const stat = fs.statSync(absPath);
|
|
32
|
+
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
33
|
+
throw new Error(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
34
|
+
}
|
|
35
|
+
return absPath;
|
|
36
|
+
}
|
|
37
|
+
function extractTweetId(url) {
|
|
38
|
+
let pathname = '';
|
|
39
|
+
try {
|
|
40
|
+
pathname = new URL(url).pathname;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Invalid tweet URL: ${url}`);
|
|
44
|
+
}
|
|
45
|
+
const match = pathname.match(/\/status\/(\d+)/);
|
|
46
|
+
if (!match?.[1]) {
|
|
47
|
+
throw new Error(`Could not extract tweet ID from URL: ${url}`);
|
|
48
|
+
}
|
|
49
|
+
return match[1];
|
|
50
|
+
}
|
|
51
|
+
function buildReplyComposerUrl(url) {
|
|
52
|
+
return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
|
|
53
|
+
}
|
|
54
|
+
function resolveImageExtension(url, contentType) {
|
|
55
|
+
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
|
|
56
|
+
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
|
|
57
|
+
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const pathname = new URL(url).pathname;
|
|
61
|
+
const ext = path.extname(pathname).toLowerCase();
|
|
62
|
+
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
|
|
63
|
+
return ext;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Fall through to the final error below.
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
|
|
69
|
+
'Supported: jpg, jpeg, png, gif, webp');
|
|
70
|
+
}
|
|
71
|
+
async function downloadRemoteImage(imageUrl) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(imageUrl);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error(`Invalid image URL: ${imageUrl}`);
|
|
78
|
+
}
|
|
79
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
80
|
+
throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
|
|
81
|
+
}
|
|
82
|
+
const response = await fetch(imageUrl);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`Image download failed: HTTP ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
const contentLength = Number(response.headers.get('content-length') || '0');
|
|
87
|
+
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
|
|
88
|
+
throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
89
|
+
}
|
|
90
|
+
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
|
|
91
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
|
|
92
|
+
const tmpPath = path.join(tmpDir, `image${ext}`);
|
|
93
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
94
|
+
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
|
|
95
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
96
|
+
throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
|
|
97
|
+
}
|
|
98
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
99
|
+
return tmpPath;
|
|
100
|
+
}
|
|
101
|
+
async function attachReplyImage(page, absImagePath) {
|
|
102
|
+
let uploaded = false;
|
|
103
|
+
if (page.setFileInput) {
|
|
104
|
+
try {
|
|
105
|
+
await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
|
|
106
|
+
uploaded = true;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
|
|
111
|
+
throw new Error(`Image upload failed: ${msg}`);
|
|
112
|
+
}
|
|
113
|
+
// setFileInput not supported by extension — fall through to base64 fallback
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!uploaded) {
|
|
117
|
+
const ext = path.extname(absImagePath).toLowerCase();
|
|
118
|
+
const mimeType = ext === '.png'
|
|
119
|
+
? 'image/png'
|
|
120
|
+
: ext === '.gif'
|
|
121
|
+
? 'image/gif'
|
|
122
|
+
: ext === '.webp'
|
|
123
|
+
? 'image/webp'
|
|
124
|
+
: 'image/jpeg';
|
|
125
|
+
const base64 = fs.readFileSync(absImagePath).toString('base64');
|
|
126
|
+
if (base64.length > 500_000) {
|
|
127
|
+
console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
|
|
128
|
+
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
|
|
129
|
+
'or compress the image before attaching.');
|
|
130
|
+
}
|
|
131
|
+
const upload = await page.evaluate(`
|
|
132
|
+
(() => {
|
|
133
|
+
const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
|
|
134
|
+
if (!input) return { ok: false, error: 'No file input found on page' };
|
|
135
|
+
|
|
136
|
+
const binary = atob(${JSON.stringify(base64)});
|
|
137
|
+
const bytes = new Uint8Array(binary.length);
|
|
138
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
139
|
+
|
|
140
|
+
const dt = new DataTransfer();
|
|
141
|
+
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
|
|
142
|
+
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
|
|
143
|
+
|
|
144
|
+
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
|
|
145
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
146
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
147
|
+
return { ok: true };
|
|
148
|
+
})()
|
|
149
|
+
`);
|
|
150
|
+
if (!upload?.ok) {
|
|
151
|
+
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await page.wait(2);
|
|
155
|
+
const uploadState = await page.evaluate(`
|
|
156
|
+
(() => {
|
|
157
|
+
const previewCount = document.querySelectorAll(
|
|
158
|
+
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
|
|
159
|
+
).length;
|
|
160
|
+
const hasMedia = previewCount > 0
|
|
161
|
+
|| !!document.querySelector('[data-testid="attachments"]')
|
|
162
|
+
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
|
|
163
|
+
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
|
|
164
|
+
);
|
|
165
|
+
return { ok: hasMedia, previewCount };
|
|
166
|
+
})()
|
|
167
|
+
`);
|
|
168
|
+
if (!uploadState?.ok) {
|
|
169
|
+
throw new Error('Image upload failed: preview did not appear.');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function submitReply(page, text) {
|
|
173
|
+
return page.evaluate(`(async () => {
|
|
174
|
+
try {
|
|
175
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
176
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
177
|
+
const box = boxes.find(visible) || boxes[0];
|
|
178
|
+
if (!box) {
|
|
179
|
+
return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
box.focus();
|
|
183
|
+
const textToInsert = ${JSON.stringify(text)};
|
|
184
|
+
// execCommand('insertText') is more reliable with Twitter's Draft.js editor
|
|
185
|
+
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
186
|
+
// Fallback to paste event if execCommand fails
|
|
187
|
+
const dataTransfer = new DataTransfer();
|
|
188
|
+
dataTransfer.setData('text/plain', textToInsert);
|
|
189
|
+
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
190
|
+
clipboardData: dataTransfer,
|
|
191
|
+
bubbles: true,
|
|
192
|
+
cancelable: true
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
197
|
+
|
|
198
|
+
const buttons = Array.from(
|
|
199
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
200
|
+
);
|
|
201
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled);
|
|
202
|
+
if (!btn) {
|
|
203
|
+
return { ok: false, message: 'Reply button is disabled or not found.' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
btn.click();
|
|
207
|
+
return { ok: true, message: 'Reply posted successfully.' };
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return { ok: false, message: e.toString() };
|
|
210
|
+
}
|
|
211
|
+
})()`);
|
|
212
|
+
}
|
|
3
213
|
cli({
|
|
4
214
|
site: 'twitter',
|
|
5
215
|
name: 'reply',
|
|
6
|
-
description: 'Reply to a specific tweet',
|
|
216
|
+
description: 'Reply to a specific tweet, optionally with a local or remote image',
|
|
7
217
|
domain: 'x.com',
|
|
8
218
|
strategy: Strategy.UI, // Uses the UI directly to input and click post
|
|
9
219
|
browser: true,
|
|
10
220
|
args: [
|
|
11
221
|
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' },
|
|
12
222
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' },
|
|
223
|
+
{ name: 'image', help: 'Optional local image path to attach to the reply' },
|
|
224
|
+
{ name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
|
|
13
225
|
],
|
|
14
226
|
columns: ['status', 'message', 'text'],
|
|
15
227
|
func: async (page, kwargs) => {
|
|
16
228
|
if (!page)
|
|
17
229
|
throw new CommandExecutionError('Browser session required for twitter reply');
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
230
|
+
if (kwargs.image && kwargs['image-url']) {
|
|
231
|
+
throw new Error('Use either --image or --image-url, not both.');
|
|
232
|
+
}
|
|
233
|
+
let localImagePath;
|
|
234
|
+
let cleanupDir;
|
|
23
235
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} else {
|
|
31
|
-
return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
|
|
236
|
+
if (kwargs.image) {
|
|
237
|
+
localImagePath = resolveImagePath(kwargs.image);
|
|
238
|
+
}
|
|
239
|
+
else if (kwargs['image-url']) {
|
|
240
|
+
localImagePath = await downloadRemoteImage(kwargs['image-url']);
|
|
241
|
+
cleanupDir = path.dirname(localImagePath);
|
|
32
242
|
}
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
btn.click();
|
|
41
|
-
return { ok: true, message: 'Reply posted successfully.' };
|
|
42
|
-
} else {
|
|
43
|
-
return { ok: false, message: 'Reply button is disabled or not found.' };
|
|
243
|
+
// Dedicated composer is more reliable for image replies because the media
|
|
244
|
+
// toolbar and file input are consistently present there.
|
|
245
|
+
if (localImagePath) {
|
|
246
|
+
await page.goto(buildReplyComposerUrl(kwargs.url));
|
|
247
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
|
|
248
|
+
await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
249
|
+
await attachReplyImage(page, localImagePath);
|
|
44
250
|
}
|
|
45
|
-
|
|
46
|
-
|
|
251
|
+
else {
|
|
252
|
+
await page.goto(kwargs.url);
|
|
253
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
254
|
+
}
|
|
255
|
+
const result = await submitReply(page, kwargs.text);
|
|
256
|
+
if (result.ok) {
|
|
257
|
+
await page.wait(3); // Wait for network submission to complete
|
|
258
|
+
}
|
|
259
|
+
return [{
|
|
260
|
+
status: result.ok ? 'success' : 'failed',
|
|
261
|
+
message: result.message,
|
|
262
|
+
text: kwargs.text,
|
|
263
|
+
...(kwargs.image ? { image: kwargs.image } : {}),
|
|
264
|
+
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
|
|
265
|
+
}];
|
|
47
266
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
267
|
+
finally {
|
|
268
|
+
if (cleanupDir) {
|
|
269
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
51
271
|
}
|
|
52
|
-
return [{
|
|
53
|
-
status: result.ok ? 'success' : 'failed',
|
|
54
|
-
message: result.message,
|
|
55
|
-
text: kwargs.text
|
|
56
|
-
}];
|
|
57
272
|
}
|
|
58
273
|
});
|
|
274
|
+
export const __test__ = {
|
|
275
|
+
buildReplyComposerUrl,
|
|
276
|
+
downloadRemoteImage,
|
|
277
|
+
extractTweetId,
|
|
278
|
+
resolveImageExtension,
|
|
279
|
+
resolveImagePath,
|
|
280
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|