@jackwener/opencli 1.7.22 → 1.8.1
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 +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
package/clis/twitter/quote.js
CHANGED
|
@@ -62,10 +62,15 @@ async function submitQuote(page, text, tweetId) {
|
|
|
62
62
|
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
let btn = null;
|
|
66
|
+
for (let i = 0; i < 30; i++) {
|
|
67
|
+
const buttons = Array.from(
|
|
68
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
69
|
+
);
|
|
70
|
+
btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
71
|
+
if (btn) break;
|
|
72
|
+
await new Promise(r => setTimeout(r, 500));
|
|
73
|
+
}
|
|
69
74
|
if (!btn) {
|
|
70
75
|
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
71
76
|
}
|
package/clis/twitter/reply.js
CHANGED
|
@@ -90,19 +90,22 @@ async function insertReplyText(page, text) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
async function clickReplyButton(page) {
|
|
93
|
-
|
|
93
|
+
const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
|
|
94
|
+
return page.evaluate(`(async () => {
|
|
94
95
|
try {
|
|
95
96
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
|
|
98
|
+
const buttons = Array.from(
|
|
99
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
100
|
+
);
|
|
101
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
102
|
+
if (btn) {
|
|
103
|
+
btn.click();
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
|
|
102
107
|
}
|
|
103
|
-
|
|
104
|
-
btn.click();
|
|
105
|
-
return { ok: true };
|
|
108
|
+
return { ok: false, message: 'Reply button is disabled or not found.' };
|
|
106
109
|
} catch (e) {
|
|
107
110
|
return { ok: false, message: e.toString() };
|
|
108
111
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
+
import { JSDOM } from 'jsdom';
|
|
4
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
6
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
6
7
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
@@ -193,4 +194,44 @@ describe('twitter image helpers (utils.js)', () => {
|
|
|
193
194
|
expect(utilsTest.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
|
|
194
195
|
expect(utilsTest.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
|
|
195
196
|
});
|
|
197
|
+
|
|
198
|
+
it('classifies CDP NotAllowed file-input failures as recoverable', () => {
|
|
199
|
+
expect(utilsTest.isRecoverableFileInputError(new Error('NotAllowedError: Not allowed'))).toBe(true);
|
|
200
|
+
expect(utilsTest.isRecoverableFileInputError(new Error('ProtocolError: not-allowed'))).toBe(true);
|
|
201
|
+
expect(utilsTest.isRecoverableFileInputError(new Error('Permission denied'))).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('fails closed when a composer image preview never appears', async () => {
|
|
205
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-helper-'));
|
|
206
|
+
const imagePath = path.join(tempDir, 'missing-preview.png');
|
|
207
|
+
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
208
|
+
const page = createPageMock([{ ok: false, message: 'Image upload timed out (30s).' }], {
|
|
209
|
+
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await expect(utilsTest.attachComposerImage(page, imagePath)).rejects.toThrow('Image upload timed out');
|
|
213
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('does not treat an empty attachments container as uploaded media', async () => {
|
|
217
|
+
const runMediaReadyProbe = async (html) => {
|
|
218
|
+
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, {
|
|
219
|
+
url: 'https://x.com/compose/post',
|
|
220
|
+
runScripts: 'outside-only',
|
|
221
|
+
});
|
|
222
|
+
dom.window.setTimeout = (callback) => {
|
|
223
|
+
callback();
|
|
224
|
+
return 0;
|
|
225
|
+
};
|
|
226
|
+
const page = {
|
|
227
|
+
evaluate: vi.fn(async (script) => dom.window.eval(script)),
|
|
228
|
+
};
|
|
229
|
+
return utilsTest.waitForComposerMediaReady(page, 1);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await expect(runMediaReadyProbe('<div data-testid="attachments"></div>'))
|
|
233
|
+
.resolves.toMatchObject({ ok: false });
|
|
234
|
+
await expect(runMediaReadyProbe('<div data-testid="attachments"><img src="blob:https://x.com/1"></div>'))
|
|
235
|
+
.resolves.toMatchObject({ ok: true, previewCount: 1 });
|
|
236
|
+
});
|
|
196
237
|
});
|
package/clis/twitter/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { extractMedia, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
|
|
3
|
+
import { extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
|
|
4
4
|
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// ── Public-search operator surface ─────────────────────────────────────
|
|
@@ -212,15 +212,19 @@ function tweetToRow(result, seen) {
|
|
|
212
212
|
if (!tweet?.rest_id || seen.has(tweet.rest_id)) return null;
|
|
213
213
|
seen.add(tweet.rest_id);
|
|
214
214
|
const tweetUser = tweet.core?.user_results?.result;
|
|
215
|
+
const bio = tweetUser?.legacy?.description || '';
|
|
215
216
|
return {
|
|
216
217
|
id: tweet.rest_id,
|
|
217
|
-
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '
|
|
218
|
+
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '',
|
|
219
|
+
bio,
|
|
218
220
|
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
|
219
221
|
created_at: tweet.legacy?.created_at || '',
|
|
220
222
|
likes: tweet.legacy?.favorite_count || 0,
|
|
221
223
|
views: tweet.views?.count || '0',
|
|
222
224
|
url: `https://x.com/i/status/${tweet.rest_id}`,
|
|
223
225
|
...extractMedia(tweet.legacy),
|
|
226
|
+
card: extractCard(tweet),
|
|
227
|
+
quoted_tweet: extractQuotedTweet(tweet),
|
|
224
228
|
};
|
|
225
229
|
}
|
|
226
230
|
|
|
@@ -271,7 +275,7 @@ cli({
|
|
|
271
275
|
{ name: 'limit', type: 'int', default: 15, help: 'Maximum number of tweets to return (default 15). Result count after server-side filtering.' },
|
|
272
276
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
|
|
273
277
|
],
|
|
274
|
-
columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls'],
|
|
278
|
+
columns: ['id', 'author', 'bio', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
|
|
275
279
|
func: async (page, kwargs) => {
|
|
276
280
|
const finalQuery = buildSearchQuery(kwargs.query, kwargs);
|
|
277
281
|
if (!finalQuery) {
|
|
@@ -44,6 +44,9 @@ describe('twitter search command', () => {
|
|
|
44
44
|
core: {
|
|
45
45
|
screen_name: 'alice',
|
|
46
46
|
},
|
|
47
|
+
legacy: {
|
|
48
|
+
description: 'Search author bio',
|
|
49
|
+
},
|
|
47
50
|
},
|
|
48
51
|
},
|
|
49
52
|
},
|
|
@@ -68,6 +71,7 @@ describe('twitter search command', () => {
|
|
|
68
71
|
{
|
|
69
72
|
id: '1',
|
|
70
73
|
author: 'alice',
|
|
74
|
+
bio: 'Search author bio',
|
|
71
75
|
text: 'hello world',
|
|
72
76
|
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
|
|
73
77
|
likes: 7,
|
|
@@ -75,6 +79,8 @@ describe('twitter search command', () => {
|
|
|
75
79
|
url: 'https://x.com/i/status/1',
|
|
76
80
|
has_media: false,
|
|
77
81
|
media_urls: [],
|
|
82
|
+
card: null,
|
|
83
|
+
quoted_tweet: null,
|
|
78
84
|
},
|
|
79
85
|
]);
|
|
80
86
|
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
@@ -148,6 +154,40 @@ describe('twitter search command', () => {
|
|
|
148
154
|
expect(result.map((row) => row.id)).toEqual(['1', '2', '3', '4', '5', '6', '7']);
|
|
149
155
|
expect(page.evaluate).toHaveBeenCalledTimes(8);
|
|
150
156
|
});
|
|
157
|
+
|
|
158
|
+
it('surfaces empty author when the tweet has no user screen_name', () => {
|
|
159
|
+
const payload = {
|
|
160
|
+
data: {
|
|
161
|
+
search_by_raw_query: {
|
|
162
|
+
search_timeline: {
|
|
163
|
+
timeline: {
|
|
164
|
+
instructions: [{
|
|
165
|
+
type: 'TimelineAddEntries',
|
|
166
|
+
entries: [{
|
|
167
|
+
entryId: 'tweet-2',
|
|
168
|
+
content: {
|
|
169
|
+
itemContent: {
|
|
170
|
+
tweet_results: {
|
|
171
|
+
result: {
|
|
172
|
+
rest_id: '2',
|
|
173
|
+
legacy: { full_text: 'no author here', favorite_count: 0, created_at: '' },
|
|
174
|
+
core: { user_results: { result: {} } },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}],
|
|
180
|
+
}],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
const { rows } = parseSearchTimeline(payload, new Set());
|
|
187
|
+
expect(rows).toHaveLength(1);
|
|
188
|
+
expect(rows[0].author).toBe('');
|
|
189
|
+
expect(rows[0].id).toBe('2');
|
|
190
|
+
});
|
|
151
191
|
});
|
|
152
192
|
|
|
153
193
|
describe('twitter search filter helpers', () => {
|
|
@@ -340,4 +380,5 @@ describe('twitter search end-to-end with new filters', () => {
|
|
|
340
380
|
const searchFetch = evaluate.mock.calls[1][0];
|
|
341
381
|
expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
|
|
342
382
|
});
|
|
383
|
+
|
|
343
384
|
});
|
package/clis/twitter/shared.js
CHANGED
|
@@ -155,6 +155,16 @@ export function unwrapBrowserResult(value) {
|
|
|
155
155
|
return value;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function isEmptyObject(value) {
|
|
159
|
+
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function looksLikePrivateTwitterTimeline(data) {
|
|
163
|
+
const result = data?.data?.user?.result;
|
|
164
|
+
if (!result || typeof result !== 'object') return false;
|
|
165
|
+
return Boolean(isEmptyObject(result.timeline) || isEmptyObject(result.timeline_v2?.timeline));
|
|
166
|
+
}
|
|
167
|
+
|
|
158
168
|
export function normalizeTwitterGraphqlPayload(value) {
|
|
159
169
|
const unwrapped = unwrapBrowserResult(value);
|
|
160
170
|
if (unwrapped?.data && typeof unwrapped.data === 'object') return unwrapped;
|
|
@@ -288,6 +298,148 @@ export function extractMedia(legacy) {
|
|
|
288
298
|
}
|
|
289
299
|
return { has_media: urls.length > 0, media_urls: urls };
|
|
290
300
|
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Extract the link-preview card from a tweet's GraphQL response.
|
|
304
|
+
*
|
|
305
|
+
* Reads `tweet.card.legacy.{name, binding_values}` plus the expanded URL from
|
|
306
|
+
* the `tweet.legacy.entities.urls` entry matching the card's t.co URL.
|
|
307
|
+
* `binding_values` is an array of `{ key, value: { type, string_value, image_value: { url } } }`.
|
|
308
|
+
*
|
|
309
|
+
* Returns `null` when:
|
|
310
|
+
* - the tweet has no card, OR
|
|
311
|
+
* - the card is structurally empty (no landing URL AND no title/description),
|
|
312
|
+
* which would be useless to downstream renderers.
|
|
313
|
+
*
|
|
314
|
+
* Otherwise returns a partial card object — missing fields are simply omitted
|
|
315
|
+
* (no `undefined` values in the output) so JSON consumers see a clean shape.
|
|
316
|
+
*/
|
|
317
|
+
export function extractCard(tweet) {
|
|
318
|
+
const cardLegacy = tweet?.card?.legacy;
|
|
319
|
+
if (!cardLegacy) return null;
|
|
320
|
+
const bindings = Array.isArray(cardLegacy.binding_values) ? cardLegacy.binding_values : [];
|
|
321
|
+
const byKey = new Map();
|
|
322
|
+
for (const b of bindings) {
|
|
323
|
+
if (b && typeof b.key === 'string') byKey.set(b.key, b.value);
|
|
324
|
+
}
|
|
325
|
+
const str = (key) => {
|
|
326
|
+
const v = byKey.get(key);
|
|
327
|
+
return typeof v?.string_value === 'string' && v.string_value.length > 0 ? v.string_value : undefined;
|
|
328
|
+
};
|
|
329
|
+
const img = (key) => {
|
|
330
|
+
const v = byKey.get(key);
|
|
331
|
+
const u = v?.image_value?.url;
|
|
332
|
+
return typeof u === 'string' && u.length > 0 ? u : undefined;
|
|
333
|
+
};
|
|
334
|
+
const title = str('title');
|
|
335
|
+
const description = str('description');
|
|
336
|
+
const domainBinding = str('domain');
|
|
337
|
+
const cardUrlBinding = str('card_url');
|
|
338
|
+
const image_url = img('thumbnail_image_large') || img('photo_image_full_size_large') || img('summary_photo_image_large');
|
|
339
|
+
const urlEntities = Array.isArray(tweet?.legacy?.entities?.urls)
|
|
340
|
+
? tweet.legacy.entities.urls
|
|
341
|
+
: [];
|
|
342
|
+
const matchingEntity = cardUrlBinding
|
|
343
|
+
? urlEntities.find((entity) => entity?.url === cardUrlBinding || entity?.expanded_url === cardUrlBinding)
|
|
344
|
+
: undefined;
|
|
345
|
+
const matchedExpandedUrl = matchingEntity?.expanded_url;
|
|
346
|
+
const url = (typeof matchedExpandedUrl === 'string' && matchedExpandedUrl.length > 0)
|
|
347
|
+
? matchedExpandedUrl
|
|
348
|
+
: cardUrlBinding;
|
|
349
|
+
let domain = domainBinding;
|
|
350
|
+
if (!domain && url) {
|
|
351
|
+
try { domain = new URL(url).hostname; }
|
|
352
|
+
catch { /* malformed url — domain stays undefined */ }
|
|
353
|
+
}
|
|
354
|
+
if (!url && !title && !description) return null;
|
|
355
|
+
const out = { name: cardLegacy.name };
|
|
356
|
+
if (title) out.title = title;
|
|
357
|
+
if (description) out.description = description;
|
|
358
|
+
if (image_url) out.image_url = image_url;
|
|
359
|
+
if (url) out.url = url;
|
|
360
|
+
if (domain) out.domain = domain;
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Extract the quoted tweet from a tweet's GraphQL response.
|
|
366
|
+
*
|
|
367
|
+
* A quote tweet is a tweet that embeds and comments on another tweet (distinct
|
|
368
|
+
* from a reply or retweet). The author writes new commentary and the embedded
|
|
369
|
+
* tweet renders as a card-like preview under the new tweet.
|
|
370
|
+
*
|
|
371
|
+
* GraphQL surfaces this as `tweet.quoted_status_result.result`, which contains
|
|
372
|
+
* the same `legacy / core / card / note_tweet` shape as the outer tweet — so
|
|
373
|
+
* we reuse `extractMedia` / `extractCard` on the nested object. Detection is
|
|
374
|
+
* gated by `legacy.is_quote_status === true` (plus the presence of the nested
|
|
375
|
+
* result) so we don't return junk on plain replies that share field shapes.
|
|
376
|
+
*
|
|
377
|
+
* Returns `null` when:
|
|
378
|
+
* - the tweet is not a quote, OR
|
|
379
|
+
* - the nested `quoted_status_result.result` is missing/empty/tombstoned.
|
|
380
|
+
*
|
|
381
|
+
* Only goes ONE level deep — a quote-of-a-quote returns its level-1 quoted
|
|
382
|
+
* tweet without further nesting. Recursing would explode payload size on
|
|
383
|
+
* threads where every reply re-quotes the original.
|
|
384
|
+
*
|
|
385
|
+
* The output shape is a deliberately small subset of the main tweet shape
|
|
386
|
+
* (id/author/name/text/created_at/url + media + card). Consumers that need
|
|
387
|
+
* counts or full author bio of the quoted tweet can re-fetch the quoted id
|
|
388
|
+
* via `twitter thread <id>` — keeping this slim avoids ballooning every
|
|
389
|
+
* timeline/list/search response by 2-3x.
|
|
390
|
+
*/
|
|
391
|
+
export function extractQuotedTweet(tweet) {
|
|
392
|
+
const legacy = tweet?.legacy;
|
|
393
|
+
if (!legacy?.is_quote_status) return null;
|
|
394
|
+
const q = tweet?.quoted_status_result?.result
|
|
395
|
+
?? tweet?.legacy?.quoted_status_result?.result;
|
|
396
|
+
// `result` can be a tombstone (`__typename: 'TweetTombstone'`) or
|
|
397
|
+
// `'TweetUnavailable'` when the quoted tweet was deleted / privacy-restricted.
|
|
398
|
+
if (!q) return null;
|
|
399
|
+
// Nested `tweet` wrapper appears on TweetWithVisibilityResults — same
|
|
400
|
+
// shim that callers already do at the top level (`tw.tweet || tw`).
|
|
401
|
+
const qTw = q.tweet || q;
|
|
402
|
+
if (!qTw || typeof qTw !== 'object') return null;
|
|
403
|
+
const qLegacy = qTw.legacy && typeof qTw.legacy === 'object' ? qTw.legacy : {};
|
|
404
|
+
// `rest_id` is required — tombstoned / unavailable wrappers have neither
|
|
405
|
+
// rest_id nor legacy. Don't fall back to outer `legacy.quoted_status_id_str`:
|
|
406
|
+
// the id alone can't substitute for missing content (author/text/media all
|
|
407
|
+
// empty), so emitting a stub object would mislead downstream renderers into
|
|
408
|
+
// drawing an empty "quoted tweet" preview.
|
|
409
|
+
if (typeof qTw.rest_id !== 'string' || !qTw.rest_id.trim()) return null;
|
|
410
|
+
const qUser = qTw.core?.user_results?.result;
|
|
411
|
+
const qLegacyScreenName = qUser?.legacy?.screen_name;
|
|
412
|
+
const qCoreScreenName = qUser?.core?.screen_name;
|
|
413
|
+
const qScreenName = typeof qLegacyScreenName === 'string' && qLegacyScreenName.trim()
|
|
414
|
+
? qLegacyScreenName.trim()
|
|
415
|
+
: (typeof qCoreScreenName === 'string' && qCoreScreenName.trim() ? qCoreScreenName.trim() : '');
|
|
416
|
+
if (!SCREEN_NAME_PATTERN.test(qScreenName)) return null;
|
|
417
|
+
const qLegacyDisplayName = qUser?.legacy?.name;
|
|
418
|
+
const qCoreDisplayName = qUser?.core?.name;
|
|
419
|
+
const qDisplayName = typeof qLegacyDisplayName === 'string'
|
|
420
|
+
? qLegacyDisplayName
|
|
421
|
+
: (typeof qCoreDisplayName === 'string' ? qCoreDisplayName : '');
|
|
422
|
+
const qNoteText = qTw.note_tweet?.note_tweet_results?.result?.text;
|
|
423
|
+
const qText = (typeof qNoteText === 'string' && qNoteText.length > 0)
|
|
424
|
+
? qNoteText
|
|
425
|
+
: (typeof qLegacy.full_text === 'string' ? qLegacy.full_text : '');
|
|
426
|
+
const qMedia = extractMedia(qLegacy);
|
|
427
|
+
const qCard = extractCard(qTw);
|
|
428
|
+
if (!qText && !qMedia.has_media && !qCard) return null;
|
|
429
|
+
const out = {
|
|
430
|
+
id: qTw.rest_id,
|
|
431
|
+
author: qScreenName,
|
|
432
|
+
name: qDisplayName,
|
|
433
|
+
text: qText,
|
|
434
|
+
created_at: typeof qLegacy.created_at === 'string' ? qLegacy.created_at : '',
|
|
435
|
+
url: `https://x.com/${qScreenName}/status/${qTw.rest_id}`,
|
|
436
|
+
has_media: qMedia.has_media,
|
|
437
|
+
media_urls: qMedia.media_urls,
|
|
438
|
+
};
|
|
439
|
+
if (qCard) out.card = qCard;
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
291
443
|
export const __test__ = {
|
|
292
444
|
sanitizeQueryId,
|
|
293
445
|
sanitizeTwitterOperationMetadata,
|
|
@@ -295,6 +447,9 @@ export const __test__ = {
|
|
|
295
447
|
normalizeTwitterGraphqlPayload,
|
|
296
448
|
normalizeTwitterScreenName,
|
|
297
449
|
extractMedia,
|
|
450
|
+
extractCard,
|
|
451
|
+
extractQuotedTweet,
|
|
298
452
|
parseTweetUrl,
|
|
299
453
|
buildTwitterArticleScopeSource,
|
|
454
|
+
looksLikePrivateTwitterTimeline,
|
|
300
455
|
};
|