@jackwener/opencli 1.7.4 → 1.7.6
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 +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
4
|
+
|
|
5
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
|
+
const USER_TWEETS_QUERY_ID = '6fWQaBPK51aGyC_VC7t9GQ';
|
|
7
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
8
|
+
|
|
9
|
+
const USER_TWEETS_FEATURES = {
|
|
10
|
+
rweb_video_screen_enabled: false,
|
|
11
|
+
payments_enabled: false,
|
|
12
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
13
|
+
rweb_tipjar_consumption_enabled: true,
|
|
14
|
+
verified_phone_label_enabled: false,
|
|
15
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
16
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
17
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
18
|
+
premium_content_api_read_enabled: false,
|
|
19
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
20
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
21
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
22
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
23
|
+
responsive_web_jetfuel_frame: true,
|
|
24
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
25
|
+
responsive_web_grok_annotations_enabled: true,
|
|
26
|
+
articles_preview_enabled: true,
|
|
27
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
28
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
29
|
+
view_counts_everywhere_api_enabled: true,
|
|
30
|
+
longform_notetweets_consumption_enabled: true,
|
|
31
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
32
|
+
tweet_awards_web_tipping_enabled: false,
|
|
33
|
+
content_disclosure_indicator_enabled: true,
|
|
34
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
35
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
36
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
37
|
+
post_ctas_fetch_enabled: false,
|
|
38
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
39
|
+
standardized_nudges_misinfo: true,
|
|
40
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
41
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
42
|
+
longform_notetweets_inline_media_enabled: true,
|
|
43
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
44
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
45
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
46
|
+
responsive_web_enhance_cards_enabled: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const USER_BY_SCREEN_NAME_FEATURES = {
|
|
50
|
+
hidden_profile_subscriptions_enabled: true,
|
|
51
|
+
rweb_tipjar_consumption_enabled: true,
|
|
52
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
53
|
+
verified_phone_label_enabled: false,
|
|
54
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
55
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
56
|
+
highlights_tweets_tab_ui_enabled: true,
|
|
57
|
+
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
58
|
+
subscriptions_feature_can_gift_premium: true,
|
|
59
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
60
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
61
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function buildUserTweetsUrl(queryId, userId, count, cursor) {
|
|
65
|
+
const vars = {
|
|
66
|
+
userId,
|
|
67
|
+
count,
|
|
68
|
+
includePromotedContent: false,
|
|
69
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
70
|
+
withVoice: true,
|
|
71
|
+
};
|
|
72
|
+
if (cursor) vars.cursor = cursor;
|
|
73
|
+
return `/i/api/graphql/${queryId}/UserTweets`
|
|
74
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
75
|
+
+ `&features=${encodeURIComponent(JSON.stringify(USER_TWEETS_FEATURES))}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildUserByScreenNameUrl(queryId, screenName) {
|
|
79
|
+
const vars = { screen_name: screenName, withSafetyModeUserFields: true };
|
|
80
|
+
return `/i/api/graphql/${queryId}/UserByScreenName`
|
|
81
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
82
|
+
+ `&features=${encodeURIComponent(JSON.stringify(USER_BY_SCREEN_NAME_FEATURES))}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractTweet(result, seen) {
|
|
86
|
+
if (!result) return null;
|
|
87
|
+
const tw = result.tweet || result;
|
|
88
|
+
const legacy = tw.legacy || {};
|
|
89
|
+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
|
90
|
+
seen.add(tw.rest_id);
|
|
91
|
+
const user = tw.core?.user_results?.result;
|
|
92
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
93
|
+
const displayName = user?.legacy?.name || user?.core?.name || '';
|
|
94
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
95
|
+
const isRetweet = Boolean(legacy.retweeted_status_result || legacy.full_text?.startsWith('RT @'));
|
|
96
|
+
return {
|
|
97
|
+
id: tw.rest_id,
|
|
98
|
+
author: screenName,
|
|
99
|
+
name: displayName,
|
|
100
|
+
text: noteText || legacy.full_text || '',
|
|
101
|
+
likes: legacy.favorite_count || 0,
|
|
102
|
+
retweets: legacy.retweet_count || 0,
|
|
103
|
+
replies: legacy.reply_count || 0,
|
|
104
|
+
views: Number(tw.views?.count) || 0,
|
|
105
|
+
is_retweet: isRetweet,
|
|
106
|
+
created_at: legacy.created_at || '',
|
|
107
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseUserTweets(data, seen) {
|
|
112
|
+
const tweets = [];
|
|
113
|
+
let nextCursor = null;
|
|
114
|
+
const instructions = data?.data?.user?.result?.timeline_v2?.timeline?.instructions
|
|
115
|
+
|| data?.data?.user?.result?.timeline?.timeline?.instructions
|
|
116
|
+
|| [];
|
|
117
|
+
for (const inst of instructions) {
|
|
118
|
+
if (inst.type === 'TimelinePinEntry') continue;
|
|
119
|
+
for (const entry of inst.entries || []) {
|
|
120
|
+
const content = entry.content;
|
|
121
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
122
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
126
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const direct = extractTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
130
|
+
if (direct) {
|
|
131
|
+
tweets.push(direct);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const item of content?.items || []) {
|
|
135
|
+
const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
136
|
+
if (nested) tweets.push(nested);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { tweets, nextCursor };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
cli({
|
|
144
|
+
site: 'twitter',
|
|
145
|
+
name: 'tweets',
|
|
146
|
+
description: "Fetch a Twitter user's most recent tweets (chronological, excludes pinned)",
|
|
147
|
+
domain: 'x.com',
|
|
148
|
+
strategy: Strategy.COOKIE,
|
|
149
|
+
browser: true,
|
|
150
|
+
args: [
|
|
151
|
+
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
152
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
153
|
+
],
|
|
154
|
+
columns: ['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url'],
|
|
155
|
+
func: async (page, kwargs) => {
|
|
156
|
+
const limit = Math.max(1, Math.min(200, kwargs.limit || 20));
|
|
157
|
+
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
158
|
+
if (!username) throw new CommandExecutionError('username is required');
|
|
159
|
+
|
|
160
|
+
await page.goto('https://x.com');
|
|
161
|
+
await page.wait(3);
|
|
162
|
+
|
|
163
|
+
const ct0 = await page.evaluate(`() => {
|
|
164
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
165
|
+
}`);
|
|
166
|
+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
167
|
+
|
|
168
|
+
const userTweetsQueryId = await resolveTwitterQueryId(page, 'UserTweets', USER_TWEETS_QUERY_ID);
|
|
169
|
+
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
170
|
+
|
|
171
|
+
const headers = JSON.stringify({
|
|
172
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
173
|
+
'X-Csrf-Token': ct0,
|
|
174
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
175
|
+
'X-Twitter-Active-User': 'yes',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const ubsUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
|
|
179
|
+
const userId = await page.evaluate(`async () => {
|
|
180
|
+
const resp = await fetch("${ubsUrl}", { headers: ${headers}, credentials: 'include' });
|
|
181
|
+
if (!resp.ok) return null;
|
|
182
|
+
const d = await resp.json();
|
|
183
|
+
return d?.data?.user?.result?.rest_id || null;
|
|
184
|
+
}`);
|
|
185
|
+
if (!userId) throw new CommandExecutionError(`Could not resolve @${username}`);
|
|
186
|
+
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
const all = [];
|
|
189
|
+
let cursor = null;
|
|
190
|
+
for (let i = 0; i < 5 && all.length < limit; i++) {
|
|
191
|
+
const fetchCount = Math.min(100, limit - all.length + 10);
|
|
192
|
+
const url = buildUserTweetsUrl(userTweetsQueryId, userId, fetchCount, cursor);
|
|
193
|
+
const data = await page.evaluate(`async () => {
|
|
194
|
+
const r = await fetch("${url}", { headers: ${headers}, credentials: 'include' });
|
|
195
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
196
|
+
}`);
|
|
197
|
+
if (data?.error) {
|
|
198
|
+
if (all.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: UserTweets fetch failed — queryId may have expired`);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const { tweets, nextCursor } = parseUserTweets(data, seen);
|
|
202
|
+
all.push(...tweets);
|
|
203
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
204
|
+
cursor = nextCursor;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (all.length === 0) throw new EmptyResultError(`@${username} has no recent tweets`, 'Account may be private or suspended');
|
|
208
|
+
return all.slice(0, limit);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export const __test__ = {
|
|
213
|
+
sanitizeQueryId,
|
|
214
|
+
buildUserTweetsUrl,
|
|
215
|
+
buildUserByScreenNameUrl,
|
|
216
|
+
extractTweet,
|
|
217
|
+
parseUserTweets,
|
|
218
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { __test__ } from './tweets.js';
|
|
4
|
+
|
|
5
|
+
describe('twitter tweets helpers', () => {
|
|
6
|
+
it('registers is_retweet in the default columns', () => {
|
|
7
|
+
const cmd = getRegistry().get('twitter/tweets');
|
|
8
|
+
expect(cmd?.columns).toEqual(['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url']);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('falls back when queryId contains unsafe characters', () => {
|
|
12
|
+
expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
|
|
13
|
+
expect(__test__.sanitizeQueryId('bad"id', 'fallback')).toBe('fallback');
|
|
14
|
+
expect(__test__.sanitizeQueryId('bad/id', 'fallback')).toBe('fallback');
|
|
15
|
+
expect(__test__.sanitizeQueryId(null, 'fallback')).toBe('fallback');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('builds UserTweets url with cursor and features', () => {
|
|
19
|
+
const url = __test__.buildUserTweetsUrl('query123', '42', 20, 'cursor-1');
|
|
20
|
+
expect(url).toContain('/i/api/graphql/query123/UserTweets');
|
|
21
|
+
const decoded = decodeURIComponent(url);
|
|
22
|
+
expect(decoded).toContain('"userId":"42"');
|
|
23
|
+
expect(decoded).toContain('"count":20');
|
|
24
|
+
expect(decoded).toContain('"cursor":"cursor-1"');
|
|
25
|
+
expect(decoded).toContain('longform_notetweets_consumption_enabled');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('builds UserByScreenName url for the given handle', () => {
|
|
29
|
+
const url = __test__.buildUserByScreenNameUrl('uquery', 'jakevin7');
|
|
30
|
+
expect(url).toContain('/i/api/graphql/uquery/UserByScreenName');
|
|
31
|
+
expect(decodeURIComponent(url)).toContain('"screen_name":"jakevin7"');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('prefers note_tweet text over legacy.full_text for long posts', () => {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const tweet = __test__.extractTweet({
|
|
37
|
+
rest_id: '99',
|
|
38
|
+
legacy: { full_text: 'short truncated…', favorite_count: 1, retweet_count: 0, reply_count: 0, created_at: 'now' },
|
|
39
|
+
note_tweet: { note_tweet_results: { result: { text: 'full long-form body' } } },
|
|
40
|
+
core: { user_results: { result: { legacy: { screen_name: 'bob', name: 'Bob' } } } },
|
|
41
|
+
views: { count: '42' },
|
|
42
|
+
}, seen);
|
|
43
|
+
expect(tweet.text).toBe('full long-form body');
|
|
44
|
+
expect(tweet.views).toBe(42);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('flags retweets via RT prefix or retweeted_status_result', () => {
|
|
48
|
+
const a = __test__.extractTweet({
|
|
49
|
+
rest_id: '1',
|
|
50
|
+
legacy: { full_text: 'RT @foo: hi', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: '' },
|
|
51
|
+
core: { user_results: { result: { legacy: { screen_name: 'u', name: 'U' } } } },
|
|
52
|
+
}, new Set());
|
|
53
|
+
expect(a.is_retweet).toBe(true);
|
|
54
|
+
|
|
55
|
+
const b = __test__.extractTweet({
|
|
56
|
+
rest_id: '2',
|
|
57
|
+
legacy: { full_text: 'hello', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: '', retweeted_status_result: { result: {} } },
|
|
58
|
+
core: { user_results: { result: { legacy: { screen_name: 'u', name: 'U' } } } },
|
|
59
|
+
}, new Set());
|
|
60
|
+
expect(b.is_retweet).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('parses chronological tweets and skips pinned instruction', () => {
|
|
64
|
+
const chronEntry = {
|
|
65
|
+
entryId: 'tweet-1',
|
|
66
|
+
content: {
|
|
67
|
+
itemContent: {
|
|
68
|
+
tweet_results: {
|
|
69
|
+
result: {
|
|
70
|
+
rest_id: '1',
|
|
71
|
+
legacy: { full_text: 'chronological post', favorite_count: 5, retweet_count: 1, reply_count: 2, created_at: 'now' },
|
|
72
|
+
core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
|
|
73
|
+
views: { count: '100' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const cursorEntry = {
|
|
80
|
+
entryId: 'cursor-bottom-1',
|
|
81
|
+
content: { entryType: 'TimelineTimelineCursor', cursorType: 'Bottom', value: 'cursor-next' },
|
|
82
|
+
};
|
|
83
|
+
const pinnedEntry = {
|
|
84
|
+
entryId: 'tweet-pinned-999',
|
|
85
|
+
content: {
|
|
86
|
+
itemContent: {
|
|
87
|
+
tweet_results: {
|
|
88
|
+
result: {
|
|
89
|
+
rest_id: '999',
|
|
90
|
+
legacy: { full_text: 'pinned post', favorite_count: 0, retweet_count: 0, reply_count: 0, created_at: 'old' },
|
|
91
|
+
core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const payload = {
|
|
98
|
+
data: {
|
|
99
|
+
user: {
|
|
100
|
+
result: {
|
|
101
|
+
timeline_v2: {
|
|
102
|
+
timeline: {
|
|
103
|
+
instructions: [
|
|
104
|
+
{ type: 'TimelinePinEntry', entries: [pinnedEntry] },
|
|
105
|
+
{ entries: [chronEntry, cursorEntry] },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const result = __test__.parseUserTweets(payload, new Set());
|
|
114
|
+
expect(result.nextCursor).toBe('cursor-next');
|
|
115
|
+
expect(result.tweets).toHaveLength(1);
|
|
116
|
+
expect(result.tweets[0]).toMatchObject({
|
|
117
|
+
id: '1',
|
|
118
|
+
author: 'alice',
|
|
119
|
+
text: 'chronological post',
|
|
120
|
+
likes: 5,
|
|
121
|
+
views: 100,
|
|
122
|
+
url: 'https://x.com/alice/status/1',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'wanfang',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: '万方数据论文搜索',
|
|
8
|
+
domain: 's.wanfangdata.com.cn',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'authors', 'source', 'year', 'type', 'cited', 'url'],
|
|
16
|
+
navigateBefore: false,
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
19
|
+
const query = requireNonEmptyQuery(kwargs.query);
|
|
20
|
+
await page.goto(`https://s.wanfangdata.com.cn/paper?q=${encodeURIComponent(query)}`);
|
|
21
|
+
await page.wait(5);
|
|
22
|
+
const data = await page.evaluate(`
|
|
23
|
+
(async () => {
|
|
24
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
25
|
+
for (let i = 0; i < 30; i++) {
|
|
26
|
+
if (document.querySelectorAll('span.title').length > 0) break;
|
|
27
|
+
await new Promise(r => setTimeout(r, 500));
|
|
28
|
+
}
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const titleSpan of document.querySelectorAll('span.title')) {
|
|
31
|
+
const title = normalize(titleSpan.textContent);
|
|
32
|
+
if (!title || title.length < 3) continue;
|
|
33
|
+
|
|
34
|
+
let container = titleSpan.parentElement;
|
|
35
|
+
for (let i = 0; i < 6; i++) {
|
|
36
|
+
if (!container?.parentElement || container.parentElement.tagName === 'BODY') break;
|
|
37
|
+
if (container.querySelectorAll('span.title').length >= 1 && container.querySelectorAll('span.authors').length >= 1) break;
|
|
38
|
+
container = container.parentElement;
|
|
39
|
+
}
|
|
40
|
+
if (!container) continue;
|
|
41
|
+
|
|
42
|
+
const id = normalize(container.querySelector('span.title-id-hidden')?.textContent);
|
|
43
|
+
const url = id ? 'https://d.wanfangdata.com.cn/' + id : '';
|
|
44
|
+
const authors = Array.from(container.querySelectorAll('span.authors'))
|
|
45
|
+
.map((item) => normalize(item.textContent))
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join(', ')
|
|
48
|
+
.slice(0, 80);
|
|
49
|
+
const type = normalize(container.querySelector('span.essay-type')?.textContent);
|
|
50
|
+
const source = normalize(container.querySelector('span.periodical, span.source')?.textContent);
|
|
51
|
+
|
|
52
|
+
let year = normalize(container.querySelector('span.year, span.date')?.textContent);
|
|
53
|
+
if (!year) year = (container.textContent || '').match(/(19|20)\\d{2}/)?.[0] || '';
|
|
54
|
+
|
|
55
|
+
const citedText = normalize(container.querySelector('.stat-item.quote, [class*=\"quote\"]')?.textContent);
|
|
56
|
+
const cited = citedText.match(/(\\d+)/)?.[1] || '0';
|
|
57
|
+
|
|
58
|
+
results.push({ rank: results.length + 1, title, authors, source, year, type, cited, url });
|
|
59
|
+
if (results.length >= ${limit}) break;
|
|
60
|
+
}
|
|
61
|
+
return results;
|
|
62
|
+
})()
|
|
63
|
+
`);
|
|
64
|
+
return Array.isArray(data) ? data : [];
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
|
|
5
|
+
describe('wanfang search command', () => {
|
|
6
|
+
const command = getRegistry().get('wanfang/search');
|
|
7
|
+
|
|
8
|
+
it('registers as a public browser command', () => {
|
|
9
|
+
expect(command).toBeDefined();
|
|
10
|
+
expect(command.site).toBe('wanfang');
|
|
11
|
+
expect(command.strategy).toBe('public');
|
|
12
|
+
expect(command.browser).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects empty queries before browser navigation', async () => {
|
|
16
|
+
const page = { goto: vi.fn() };
|
|
17
|
+
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
18
|
+
name: 'ArgumentError',
|
|
19
|
+
code: 'ARGUMENT',
|
|
20
|
+
});
|
|
21
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
package/clis/web/read.js
CHANGED
|
@@ -27,7 +27,7 @@ cli({
|
|
|
27
27
|
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
|
28
28
|
{ name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' },
|
|
29
29
|
],
|
|
30
|
-
columns: ['title', 'author', 'publish_time', 'status', 'size'],
|
|
30
|
+
columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved'],
|
|
31
31
|
func: async (page, kwargs) => {
|
|
32
32
|
const url = kwargs.url;
|
|
33
33
|
const waitSeconds = kwargs.wait ?? 3;
|
package/clis/weixin/download.js
CHANGED
|
@@ -179,12 +179,12 @@ cli({
|
|
|
179
179
|
{ name: 'output', default: './weixin-articles', help: 'Output directory' },
|
|
180
180
|
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
|
181
181
|
],
|
|
182
|
-
columns: ['title', 'author', 'publish_time', 'status', 'size'],
|
|
182
|
+
columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved'],
|
|
183
183
|
func: async (page, kwargs) => {
|
|
184
184
|
const rawUrl = kwargs.url;
|
|
185
185
|
const url = normalizeWechatUrl(rawUrl);
|
|
186
186
|
if (!url.startsWith('https://mp.weixin.qq.com/')) {
|
|
187
|
-
return [{ title: 'Error', author: '-', publish_time: '-', status: 'invalid URL', size: '-' }];
|
|
187
|
+
return [{ title: 'Error', author: '-', publish_time: '-', status: 'invalid URL', size: '-', saved: '-' }];
|
|
188
188
|
}
|
|
189
189
|
// Navigate and wait for content to load
|
|
190
190
|
await page.goto(url);
|
|
@@ -297,6 +297,7 @@ cli({
|
|
|
297
297
|
publish_time: '-',
|
|
298
298
|
status: 'failed — verification required in WeChat browser page',
|
|
299
299
|
size: '-',
|
|
300
|
+
saved: '-',
|
|
300
301
|
}];
|
|
301
302
|
}
|
|
302
303
|
return downloadArticle({
|