@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.
Files changed (126) hide show
  1. package/README.md +71 -49
  2. package/README.zh-CN.md +73 -60
  3. package/cli-manifest.json +3261 -1758
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/deepseek/ask.js +74 -0
  8. package/clis/deepseek/history.js +25 -0
  9. package/clis/deepseek/new.js +20 -0
  10. package/clis/deepseek/read.js +22 -0
  11. package/clis/deepseek/status.js +24 -0
  12. package/clis/deepseek/utils.js +208 -0
  13. package/clis/eastmoney/_secid.js +78 -0
  14. package/clis/eastmoney/announcement.js +52 -0
  15. package/clis/eastmoney/convertible.js +73 -0
  16. package/clis/eastmoney/etf.js +65 -0
  17. package/clis/eastmoney/holders.js +78 -0
  18. package/clis/eastmoney/index-board.js +96 -0
  19. package/clis/eastmoney/kline.js +87 -0
  20. package/clis/eastmoney/kuaixun.js +54 -0
  21. package/clis/eastmoney/longhu.js +67 -0
  22. package/clis/eastmoney/money-flow.js +78 -0
  23. package/clis/eastmoney/northbound.js +57 -0
  24. package/clis/eastmoney/quote.js +107 -0
  25. package/clis/eastmoney/rank.js +94 -0
  26. package/clis/eastmoney/sectors.js +76 -0
  27. package/clis/google-scholar/search.js +58 -0
  28. package/clis/google-scholar/search.test.js +23 -0
  29. package/clis/gov-law/commands.test.js +39 -0
  30. package/clis/gov-law/recent.js +22 -0
  31. package/clis/gov-law/search.js +41 -0
  32. package/clis/gov-law/shared.js +51 -0
  33. package/clis/gov-policy/commands.test.js +27 -0
  34. package/clis/gov-policy/recent.js +47 -0
  35. package/clis/gov-policy/search.js +48 -0
  36. package/clis/nowcoder/companies.js +23 -0
  37. package/clis/nowcoder/creators.js +27 -0
  38. package/clis/nowcoder/detail.js +61 -0
  39. package/clis/nowcoder/experience.js +36 -0
  40. package/clis/nowcoder/hot.js +24 -0
  41. package/clis/nowcoder/jobs.js +21 -0
  42. package/clis/nowcoder/notifications.js +29 -0
  43. package/clis/nowcoder/papers.js +40 -0
  44. package/clis/nowcoder/practice.js +37 -0
  45. package/clis/nowcoder/recommend.js +30 -0
  46. package/clis/nowcoder/referral.js +39 -0
  47. package/clis/nowcoder/salary.js +40 -0
  48. package/clis/nowcoder/search.js +49 -0
  49. package/clis/nowcoder/suggest.js +33 -0
  50. package/clis/nowcoder/topics.js +27 -0
  51. package/clis/nowcoder/trending.js +25 -0
  52. package/clis/twitter/list-add.js +337 -0
  53. package/clis/twitter/list-add.test.js +15 -0
  54. package/clis/twitter/list-remove.js +297 -0
  55. package/clis/twitter/list-remove.test.js +14 -0
  56. package/clis/twitter/list-tweets.js +185 -0
  57. package/clis/twitter/list-tweets.test.js +108 -0
  58. package/clis/twitter/lists.js +134 -47
  59. package/clis/twitter/lists.test.js +105 -38
  60. package/clis/wanfang/search.js +66 -0
  61. package/clis/wanfang/search.test.js +23 -0
  62. package/clis/web/read.js +1 -1
  63. package/clis/weixin/download.js +3 -2
  64. package/clis/xiaohongshu/publish.js +149 -28
  65. package/clis/xiaohongshu/publish.test.js +319 -6
  66. package/clis/xiaoyuzhou/download.js +8 -4
  67. package/clis/xiaoyuzhou/download.test.js +23 -13
  68. package/clis/xiaoyuzhou/episode.js +9 -4
  69. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  70. package/clis/xiaoyuzhou/podcast.js +9 -4
  71. package/clis/xiaoyuzhou/utils.js +0 -40
  72. package/clis/xiaoyuzhou/utils.test.js +15 -75
  73. package/clis/zsxq/dynamics.js +1 -1
  74. package/clis/zsxq/utils.js +6 -3
  75. package/clis/zsxq/utils.test.js +31 -0
  76. package/dist/src/browser/base-page.d.ts +1 -1
  77. package/dist/src/browser/bridge.d.ts +1 -0
  78. package/dist/src/browser/bridge.js +1 -1
  79. package/dist/src/browser/cdp.js +1 -1
  80. package/dist/src/browser/daemon-client.d.ts +6 -4
  81. package/dist/src/browser/daemon-client.js +6 -1
  82. package/dist/src/browser/daemon-client.test.js +40 -1
  83. package/dist/src/browser/dom-snapshot.js +7 -2
  84. package/dist/src/browser/page.d.ts +14 -4
  85. package/dist/src/browser/page.js +48 -7
  86. package/dist/src/browser/page.test.js +97 -0
  87. package/dist/src/cli.js +227 -150
  88. package/dist/src/cli.test.js +167 -90
  89. package/dist/src/commanderAdapter.d.ts +0 -1
  90. package/dist/src/commanderAdapter.js +2 -16
  91. package/dist/src/commanderAdapter.test.js +1 -1
  92. package/dist/src/completion-shared.js +2 -5
  93. package/dist/src/daemon.js +8 -0
  94. package/dist/src/download/article-download.d.ts +1 -0
  95. package/dist/src/download/article-download.js +3 -0
  96. package/dist/src/download/article-download.test.js +39 -0
  97. package/dist/src/plugin.d.ts +1 -8
  98. package/dist/src/plugin.js +1 -27
  99. package/dist/src/plugin.test.js +1 -59
  100. package/dist/src/registry.d.ts +1 -0
  101. package/dist/src/registry.js +3 -2
  102. package/dist/src/registry.test.js +22 -0
  103. package/dist/src/types.d.ts +14 -5
  104. package/package.json +1 -1
  105. package/clis/twitter/lists-parser.js +0 -77
  106. package/clis/twitter/lists.d.ts +0 -5
  107. package/dist/src/cascade.d.ts +0 -46
  108. package/dist/src/cascade.js +0 -135
  109. package/dist/src/explore.d.ts +0 -99
  110. package/dist/src/explore.js +0 -402
  111. package/dist/src/generate-verified.d.ts +0 -105
  112. package/dist/src/generate-verified.js +0 -696
  113. package/dist/src/generate-verified.test.js +0 -925
  114. package/dist/src/generate.d.ts +0 -46
  115. package/dist/src/generate.js +0 -117
  116. package/dist/src/record.d.ts +0 -96
  117. package/dist/src/record.js +0 -657
  118. package/dist/src/record.test.d.ts +0 -1
  119. package/dist/src/record.test.js +0 -293
  120. package/dist/src/skill-generate.d.ts +0 -30
  121. package/dist/src/skill-generate.js +0 -75
  122. package/dist/src/skill-generate.test.d.ts +0 -1
  123. package/dist/src/skill-generate.test.js +0 -173
  124. package/dist/src/synthesize.d.ts +0 -97
  125. package/dist/src/synthesize.js +0 -208
  126. /package/dist/src/{generate-verified.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -1,62 +1,149 @@
1
- import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
2
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { isEmptyListsState, parseListCards } from './lists-parser.js';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
3
 
5
- cli({
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 a user',
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
- let targetUser = kwargs.user;
19
- if (!targetUser) {
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 pageData = await page.evaluate(`() => {
34
- const cards = [];
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 (!pageData?.pageText) {
51
- throw new SelectorError('Twitter lists', 'Empty page text');
52
- }
53
- const results = parseListCards(pageData.cards);
54
- if (results.length === 0) {
55
- if (isEmptyListsState(pageData.pageText)) {
56
- return [];
57
- }
58
- throw new SelectorError('Twitter lists', `Could not parse list data`);
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
- return results.slice(0, kwargs.limit);
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 { isEmptyListsState, parseListCards } from './lists-parser.js';
2
+ import { extractListEntry, parseListsManagement } from './lists.js';
3
3
 
4
4
  describe('twitter lists parser', () => {
5
- it('parses english list cards without relying on page locale', () => {
6
- const result = parseListCards([
7
- {
8
- href: '/i/lists/123',
9
- text: `AI Researchers
10
- @jack
11
- 124 Members 3.4K Followers
12
- Private`,
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(result).toEqual([
16
- {
17
- name: 'AI Researchers',
18
- members: '124',
19
- followers: '3.4K',
20
- mode: 'private',
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('parses chinese list cards without scanning document.body.innerText', () => {
26
- const result = parseListCards([
27
- {
28
- href: '/i/lists/456',
29
- text: `AI观察
30
- @jack
31
- 321 位成员 8.8K 位关注者
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(result).toEqual([
36
- {
37
- name: 'AI观察',
38
- members: '321',
39
- followers: '8.8K',
40
- mode: 'private',
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('detects empty state text in english and chinese', () => {
46
- expect(isEmptyListsState(`@jack hasn't created any Lists yet`)).toBe(true);
47
- expect(isEmptyListsState('这个账号还没有创建任何列表')).toBe(true);
48
- expect(isEmptyListsState('AI Researchers 124 Members')).toBe(false);
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;
@@ -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({