@jackwener/opencli 1.7.4 → 1.7.5
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 +71 -49
- package/README.zh-CN.md +73 -60
- package/cli-manifest.json +3261 -1758
- 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/deepseek/ask.js +74 -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 +208 -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/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/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/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 +1 -1
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.js +1 -1
- 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.js +7 -2
- 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/cli.js +227 -150
- package/dist/src/cli.test.js +167 -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.js +39 -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 +14 -5
- 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.d.ts +0 -1
- 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.d.ts +0 -1
- 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 → download/article-download.test.d.ts} +0 -0
package/clis/twitter/lists.js
CHANGED
|
@@ -1,62 +1,149 @@
|
|
|
1
|
-
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
|
|
2
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import {
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
|
+
const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
6
|
+
const OPERATION_NAME = 'ListsManagementPageTimeline';
|
|
7
|
+
|
|
8
|
+
const FEATURES = {
|
|
9
|
+
rweb_video_screen_enabled: false,
|
|
10
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
11
|
+
rweb_tipjar_consumption_enabled: true,
|
|
12
|
+
verified_phone_label_enabled: false,
|
|
13
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
14
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
15
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
16
|
+
premium_content_api_read_enabled: false,
|
|
17
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
18
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
19
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
20
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
21
|
+
responsive_web_jetfuel_frame: false,
|
|
22
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
23
|
+
articles_preview_enabled: true,
|
|
24
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
25
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
26
|
+
view_counts_everywhere_api_enabled: true,
|
|
27
|
+
longform_notetweets_consumption_enabled: true,
|
|
28
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
29
|
+
tweet_awards_web_tipping_enabled: false,
|
|
30
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
31
|
+
responsive_web_grok_analysis_button_from_backend: false,
|
|
32
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
33
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
34
|
+
standardized_nudges_misinfo: true,
|
|
35
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
36
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
37
|
+
longform_notetweets_inline_media_enabled: true,
|
|
38
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
39
|
+
responsive_web_enhance_cards_enabled: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function buildUrl(queryId) {
|
|
43
|
+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
|
|
44
|
+
+ `?features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function extractListEntry(entry, seen) {
|
|
48
|
+
const list = entry?.content?.itemContent?.list
|
|
49
|
+
|| entry?.content?.list
|
|
50
|
+
|| entry?.item?.itemContent?.list;
|
|
51
|
+
if (!list) return null;
|
|
52
|
+
const id = list.id_str || list.id || '';
|
|
53
|
+
if (!id || seen.has(id)) return null;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
const mode = typeof list.mode === 'string' && /private/i.test(list.mode) ? 'private' : 'public';
|
|
56
|
+
return {
|
|
57
|
+
id: String(id),
|
|
58
|
+
name: list.name || '',
|
|
59
|
+
members: String(list.member_count ?? 0),
|
|
60
|
+
followers: String(list.subscriber_count ?? 0),
|
|
61
|
+
mode,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseListsManagement(data, seen) {
|
|
66
|
+
const lists = [];
|
|
67
|
+
const instructions = data?.data?.viewer?.list_management_timeline?.timeline?.instructions
|
|
68
|
+
|| data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
|
|
69
|
+
|| data?.data?.list_management_timeline?.timeline?.instructions
|
|
70
|
+
|| [];
|
|
71
|
+
for (const inst of instructions) {
|
|
72
|
+
for (const entry of inst.entries || []) {
|
|
73
|
+
const direct = extractListEntry(entry, seen);
|
|
74
|
+
if (direct) {
|
|
75
|
+
lists.push(direct);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const item of entry?.content?.items || []) {
|
|
79
|
+
const nested = extractListEntry(item, seen);
|
|
80
|
+
if (nested) lists.push(nested);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lists;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const command = cli({
|
|
6
88
|
site: 'twitter',
|
|
7
89
|
name: 'lists',
|
|
8
|
-
description: 'Get Twitter/X lists for
|
|
90
|
+
description: 'Get Twitter/X lists for the logged-in user (owned + subscribed)',
|
|
9
91
|
domain: 'x.com',
|
|
10
92
|
strategy: Strategy.COOKIE,
|
|
11
93
|
browser: true,
|
|
12
94
|
args: [
|
|
13
|
-
{ name: 'user', positional: true, type: 'string', required: false },
|
|
14
95
|
{ name: 'limit', type: 'int', default: 50 },
|
|
15
96
|
],
|
|
16
|
-
columns: ['name', 'members', 'followers', 'mode'],
|
|
97
|
+
columns: ['id', 'name', 'members', 'followers', 'mode'],
|
|
17
98
|
func: async (page, kwargs) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
await page.goto('https://x.com/home');
|
|
21
|
-
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
|
-
const href = await page.evaluate(`() => {
|
|
23
|
-
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
24
|
-
return link ? link.getAttribute('href') : null;
|
|
25
|
-
}`);
|
|
26
|
-
if (!href) {
|
|
27
|
-
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
28
|
-
}
|
|
29
|
-
targetUser = href.replace('/', '');
|
|
30
|
-
}
|
|
31
|
-
await page.goto(`https://x.com/${targetUser}/lists`);
|
|
99
|
+
const limit = kwargs.limit || 50;
|
|
100
|
+
await page.goto('https://x.com');
|
|
32
101
|
await page.wait(3);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
for (const anchor of Array.from(document.querySelectorAll('a[href*="/i/lists/"]'))) {
|
|
37
|
-
const href = anchor.getAttribute('href') || '';
|
|
38
|
-
if (!/\\/i\\/lists\\/\\d+/.test(href) || seen.has(href)) continue;
|
|
39
|
-
seen.add(href);
|
|
40
|
-
const container = anchor.closest('[data-testid="cellInnerDiv"]') || anchor;
|
|
41
|
-
const text = (container.innerText || anchor.innerText || '').trim();
|
|
42
|
-
if (!text) continue;
|
|
43
|
-
cards.push({ href, text });
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
cards,
|
|
47
|
-
pageText: document.body.innerText || '',
|
|
48
|
-
};
|
|
102
|
+
const ct0 = await page.evaluate(`() => {
|
|
103
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
49
104
|
}`);
|
|
50
|
-
if (!
|
|
51
|
-
throw new
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
if (!ct0)
|
|
106
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
107
|
+
const queryId = await page.evaluate(`async () => {
|
|
108
|
+
try {
|
|
109
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
110
|
+
if (ghResp.ok) {
|
|
111
|
+
const data = await ghResp.json();
|
|
112
|
+
const entry = data['${OPERATION_NAME}'];
|
|
113
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
try {
|
|
117
|
+
const scripts = performance.getEntriesByType('resource')
|
|
118
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
119
|
+
.map(r => r.name);
|
|
120
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
121
|
+
try {
|
|
122
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
123
|
+
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"${OPERATION_NAME}"/;
|
|
124
|
+
const m = text.match(re);
|
|
125
|
+
if (m) return m[1];
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
return null;
|
|
130
|
+
}`) || LISTS_QUERY_ID;
|
|
131
|
+
const headers = JSON.stringify({
|
|
132
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
133
|
+
'X-Csrf-Token': ct0,
|
|
134
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
135
|
+
'X-Twitter-Active-User': 'yes',
|
|
136
|
+
});
|
|
137
|
+
const apiUrl = buildUrl(queryId);
|
|
138
|
+
const data = await page.evaluate(`async () => {
|
|
139
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
140
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
141
|
+
}`);
|
|
142
|
+
if (data?.error) {
|
|
143
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch lists. queryId may have expired.`);
|
|
59
144
|
}
|
|
60
|
-
|
|
61
|
-
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const lists = parseListsManagement(data, seen);
|
|
147
|
+
return lists.slice(0, limit);
|
|
148
|
+
},
|
|
62
149
|
});
|
|
@@ -1,50 +1,117 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { extractListEntry, parseListsManagement } from './lists.js';
|
|
3
3
|
|
|
4
4
|
describe('twitter lists parser', () => {
|
|
5
|
-
it('
|
|
6
|
-
const
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
it('extracts a list entry with full metadata', () => {
|
|
6
|
+
const entry = {
|
|
7
|
+
content: {
|
|
8
|
+
itemContent: {
|
|
9
|
+
list: {
|
|
10
|
+
id_str: '1597593475389984769',
|
|
11
|
+
name: 'Crypto',
|
|
12
|
+
member_count: 44,
|
|
13
|
+
subscriber_count: 8747,
|
|
14
|
+
mode: 'Public',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
13
17
|
},
|
|
14
|
-
|
|
15
|
-
expect(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
]);
|
|
18
|
+
};
|
|
19
|
+
expect(extractListEntry(entry, new Set())).toEqual({
|
|
20
|
+
id: '1597593475389984769',
|
|
21
|
+
name: 'Crypto',
|
|
22
|
+
members: '44',
|
|
23
|
+
followers: '8747',
|
|
24
|
+
mode: 'public',
|
|
25
|
+
});
|
|
23
26
|
});
|
|
24
27
|
|
|
25
|
-
it('
|
|
26
|
-
const
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
it('maps Private mode to private', () => {
|
|
29
|
+
const entry = {
|
|
30
|
+
content: {
|
|
31
|
+
itemContent: {
|
|
32
|
+
list: {
|
|
33
|
+
id_str: '2044679538156912976',
|
|
34
|
+
name: 'AI & Agents',
|
|
35
|
+
member_count: 15,
|
|
36
|
+
subscriber_count: 0,
|
|
37
|
+
mode: 'Private',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
33
40
|
},
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
};
|
|
42
|
+
expect(extractListEntry(entry, new Set())?.mode).toBe('private');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('deduplicates by list id', () => {
|
|
46
|
+
const entry = {
|
|
47
|
+
content: { itemContent: { list: { id_str: '1', name: 'X' } } },
|
|
48
|
+
};
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
expect(extractListEntry(entry, seen)).not.toBeNull();
|
|
51
|
+
expect(extractListEntry(entry, seen)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null when no list payload is present', () => {
|
|
55
|
+
expect(extractListEntry({}, new Set())).toBeNull();
|
|
56
|
+
expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses ListsManagementPageTimeline payload instructions', () => {
|
|
60
|
+
const payload = {
|
|
61
|
+
data: {
|
|
62
|
+
viewer: {
|
|
63
|
+
list_management_timeline: {
|
|
64
|
+
timeline: {
|
|
65
|
+
instructions: [
|
|
66
|
+
{
|
|
67
|
+
entries: [
|
|
68
|
+
{
|
|
69
|
+
entryId: 'owned-list-1',
|
|
70
|
+
content: {
|
|
71
|
+
itemContent: {
|
|
72
|
+
list: { id_str: '1', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
entryId: 'subscribed-list-2',
|
|
78
|
+
content: {
|
|
79
|
+
itemContent: {
|
|
80
|
+
list: { id_str: '2', name: 'AI', member_count: 15, subscriber_count: 0, mode: 'Private' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
41
90
|
},
|
|
42
|
-
|
|
91
|
+
};
|
|
92
|
+
const result = parseListsManagement(payload, new Set());
|
|
93
|
+
expect(result).toHaveLength(2);
|
|
94
|
+
expect(result[0]).toMatchObject({ id: '1', name: 'Crypto', mode: 'public' });
|
|
95
|
+
expect(result[1]).toMatchObject({ id: '2', name: 'AI', mode: 'private' });
|
|
43
96
|
});
|
|
44
97
|
|
|
45
|
-
it('
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
48
|
-
|
|
98
|
+
it('returns empty list for malformed payload', () => {
|
|
99
|
+
expect(parseListsManagement({}, new Set())).toEqual([]);
|
|
100
|
+
expect(parseListsManagement({ data: {} }, new Set())).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('dedupes across repeated entries', () => {
|
|
104
|
+
const entryA = { content: { itemContent: { list: { id_str: '1', name: 'A' } } } };
|
|
105
|
+
const payload = {
|
|
106
|
+
data: {
|
|
107
|
+
viewer: {
|
|
108
|
+
list_management_timeline: {
|
|
109
|
+
timeline: { instructions: [{ entries: [entryA, entryA] }] },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const result = parseListsManagement(payload, new Set());
|
|
115
|
+
expect(result).toHaveLength(1);
|
|
49
116
|
});
|
|
50
117
|
});
|
|
@@ -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({
|