@jackwener/opencli 1.7.3 → 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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  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/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -0,0 +1,208 @@
1
+ export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
2
+ export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
3
+ export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
4
+ export const MESSAGE_SELECTOR = '.ds-message';
5
+
6
+ export async function isOnDeepSeek(page) {
7
+ const url = await page.evaluate('window.location.href').catch(() => '');
8
+ if (typeof url !== 'string' || !url) return false;
9
+ try {
10
+ const h = new URL(url).hostname;
11
+ return h === 'deepseek.com' || h.endsWith('.deepseek.com');
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function ensureOnDeepSeek(page) {
18
+ if (!(await isOnDeepSeek(page))) {
19
+ await page.goto(DEEPSEEK_URL);
20
+ await page.wait(3);
21
+ }
22
+ }
23
+
24
+ export async function getPageState(page) {
25
+ return page.evaluate(`(() => {
26
+ const url = window.location.href;
27
+ const title = document.title;
28
+ const textarea = document.querySelector('${TEXTAREA_SELECTOR}');
29
+ const avatar = document.querySelector('img[src*="user-avatar"]');
30
+ return {
31
+ url,
32
+ title,
33
+ hasTextarea: !!textarea,
34
+ isLoggedIn: !!avatar,
35
+ };
36
+ })()`);
37
+ }
38
+
39
+ export async function selectModel(page, modelName) {
40
+ return page.evaluate(`(() => {
41
+ const radios = document.querySelectorAll('div[role="radio"]');
42
+ for (const radio of radios) {
43
+ const span = radio.querySelector('span');
44
+ if (span && span.textContent.trim().toLowerCase() === '${modelName}'.toLowerCase()) {
45
+ const alreadySelected = radio.getAttribute('aria-checked') === 'true';
46
+ if (!alreadySelected) radio.click();
47
+ return { ok: true, toggled: !alreadySelected };
48
+ }
49
+ }
50
+ return { ok: false };
51
+ })()`);
52
+ }
53
+
54
+ export async function setFeature(page, featureName, enabled) {
55
+ return page.evaluate(`(() => {
56
+ const btns = document.querySelectorAll('div[role="button"]');
57
+ for (const btn of btns) {
58
+ const span = btn.querySelector('span');
59
+ if (span && span.textContent.trim() === '${featureName}') {
60
+ const isActive = btn.classList.contains('ds-toggle-button--selected');
61
+ if (${enabled} !== isActive) btn.click();
62
+ return { ok: true, toggled: ${enabled} !== isActive };
63
+ }
64
+ }
65
+ return { ok: false };
66
+ })()`);
67
+ }
68
+
69
+ export async function sendMessage(page, prompt) {
70
+ const promptJson = JSON.stringify(prompt);
71
+ return page.evaluate(`(async () => {
72
+ const box = document.querySelector('${TEXTAREA_SELECTOR}');
73
+ if (!box) return { ok: false, reason: 'textarea not found' };
74
+
75
+ box.focus();
76
+ box.value = '';
77
+ document.execCommand('selectAll');
78
+ document.execCommand('insertText', false, ${promptJson});
79
+ await new Promise(r => setTimeout(r, 800));
80
+
81
+ const btns = document.querySelectorAll('div[role="button"]');
82
+ for (const btn of btns) {
83
+ if (btn.getAttribute('aria-disabled') === 'false') {
84
+ const svgs = btn.querySelectorAll('svg');
85
+ if (svgs.length > 0 && btn.closest('div')?.querySelector('textarea')) {
86
+ btn.click();
87
+ return { ok: true };
88
+ }
89
+ }
90
+ }
91
+
92
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
93
+ return { ok: true, method: 'enter' };
94
+ })()`);
95
+ }
96
+
97
+ export async function getBubbleCount(page) {
98
+ const count = await page.evaluate(`(() => {
99
+ return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
100
+ })()`);
101
+ return count || 0;
102
+ }
103
+
104
+ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
105
+ const startTime = Date.now();
106
+ let lastText = '';
107
+ let stableCount = 0;
108
+
109
+ while (Date.now() - startTime < timeoutMs) {
110
+ await page.wait(3);
111
+
112
+ let result;
113
+ try {
114
+ result = await page.evaluate(`(() => {
115
+ const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
116
+ const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
117
+ return { count: texts.length, last: texts[texts.length - 1] || '' };
118
+ })()`);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ if (!result) continue;
124
+
125
+ const candidate = result.last;
126
+ if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
127
+ if (candidate === lastText) {
128
+ stableCount++;
129
+ if (stableCount >= 3) return candidate;
130
+ } else {
131
+ stableCount = 0;
132
+ }
133
+ lastText = candidate;
134
+ }
135
+ }
136
+
137
+ return lastText || null;
138
+ }
139
+
140
+ export async function getVisibleMessages(page) {
141
+ const result = await page.evaluate(`(() => {
142
+ const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}');
143
+ return Array.from(msgs).map(m => {
144
+ // User messages carry an extra hash-class alongside ds-message
145
+ const isUser = m.className.split(/\\s+/).length > 2;
146
+ return {
147
+ Role: isUser ? 'user' : 'assistant',
148
+ Text: (m.innerText || '').trim(),
149
+ };
150
+ }).filter(m => m.Text);
151
+ })()`);
152
+ return Array.isArray(result) ? result : [];
153
+ }
154
+
155
+ export async function getConversationList(page) {
156
+ await ensureOnDeepSeek(page);
157
+ // Expand sidebar if collapsed
158
+ await page.evaluate(`(() => {
159
+ if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
160
+ const btn = document.querySelector('div[tabindex="0"][role="button"]');
161
+ if (btn) btn.click();
162
+ }
163
+ })()`);
164
+ // Poll for sidebar history links to render
165
+ for (let attempt = 0; attempt < 5; attempt++) {
166
+ await page.wait(2);
167
+ const items = await page.evaluate(`(() => {
168
+ const items = [];
169
+ const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
170
+ links.forEach((link, i) => {
171
+ const titleEl = link.querySelector('div');
172
+ const title = titleEl ? titleEl.textContent.trim() : '';
173
+ const href = link.getAttribute('href') || '';
174
+ const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
175
+ items.push({
176
+ Index: i + 1,
177
+ Id: idMatch ? idMatch[1] : href,
178
+ Title: title || '(untitled)',
179
+ Url: 'https://chat.deepseek.com' + href,
180
+ });
181
+ });
182
+ return items;
183
+ })()`);
184
+ if (Array.isArray(items) && items.length > 0) return items;
185
+ }
186
+ return [];
187
+ }
188
+
189
+ // Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
190
+ export async function withRetry(fn, retries = 2) {
191
+ for (let i = 0; i <= retries; i++) {
192
+ try {
193
+ return await fn();
194
+ } catch (err) {
195
+ const msg = String(err?.message || err);
196
+ if (i < retries && msg.includes('Promise was collected')) {
197
+ await new Promise(r => setTimeout(r, 2000));
198
+ continue;
199
+ }
200
+ throw err;
201
+ }
202
+ }
203
+ }
204
+
205
+ export function parseBoolFlag(value) {
206
+ if (typeof value === 'boolean') return value;
207
+ return String(value ?? '').trim().toLowerCase() === 'true';
208
+ }
@@ -6,6 +6,7 @@ cli({
6
6
  description: '搜索豆瓣电影、图书或音乐',
7
7
  domain: 'search.douban.com',
8
8
  strategy: Strategy.COOKIE,
9
+ navigateBefore: false,
9
10
  args: [
10
11
  { name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '搜索类型(movie=电影, book=图书, music=音乐)' },
11
12
  { name: 'keyword', required: true, positional: true, help: '搜索关键词' },
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './search.js';
4
+
5
+ describe('douban search command', () => {
6
+ it('skips default pre-navigation because the adapter handles navigation itself', () => {
7
+ const command = getRegistry().get('douban/search');
8
+ expect(command).toBeDefined();
9
+ expect(command?.navigateBefore).toBe(false);
10
+ });
11
+ });
@@ -1,18 +1,35 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { loadDoubanSubjectDetail } from './utils.js';
3
+
2
4
  cli({
3
5
  site: 'douban',
4
6
  name: 'subject',
5
- description: '获取电影详情',
7
+ description: '获取豆瓣条目详情',
6
8
  domain: 'movie.douban.com',
7
9
  strategy: Strategy.COOKIE,
8
10
  browser: true,
11
+ navigateBefore: false,
9
12
  args: [
10
- { name: 'id', required: true, positional: true, help: '电影 ID' },
13
+ { name: 'id', required: true, positional: true, help: '豆瓣条目 ID' },
14
+ { name: 'type', default: 'movie', choices: ['movie', 'book'], help: '条目类型(movie=电影, book=图书)' },
11
15
  ],
12
16
  columns: [
13
17
  'id',
18
+ 'type',
14
19
  'title',
20
+ 'subtitle',
15
21
  'originalTitle',
22
+ 'authors',
23
+ 'translators',
24
+ 'publisher',
25
+ 'publishDate',
26
+ 'publishYear',
27
+ 'pageCount',
28
+ 'binding',
29
+ 'price',
30
+ 'series',
31
+ 'isbn10',
32
+ 'isbn13',
16
33
  'year',
17
34
  'rating',
18
35
  'ratingCount',
@@ -24,95 +41,5 @@ cli({
24
41
  'summary',
25
42
  'url',
26
43
  ],
27
- pipeline: [
28
- { navigate: 'https://movie.douban.com/subject/${{ args.id }}' },
29
- { evaluate: `(async () => {
30
- const id = '\${{ args.id }}';
31
-
32
- // Wait for page to load
33
- await new Promise(r => setTimeout(r, 2000));
34
-
35
- // Extract title - v:itemreviewed contains "中文名 OriginalName"
36
- const titleEl = document.querySelector('span[property="v:itemreviewed"]');
37
- const fullTitle = titleEl?.textContent?.trim() || '';
38
-
39
- // Split title and originalTitle
40
- // Douban format: "中文名 OriginalName" - split by first space that separates CJK from non-CJK
41
- let title = fullTitle;
42
- let originalTitle = '';
43
- const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/);
44
- if (titleMatch) {
45
- title = titleMatch[1].trim();
46
- originalTitle = titleMatch[2].trim();
47
- }
48
-
49
- // Extract year
50
- const yearEl = document.querySelector('.year');
51
- const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || '';
52
-
53
- // Extract rating
54
- const ratingEl = document.querySelector('strong[property="v:average"]');
55
- const rating = parseFloat(ratingEl?.textContent || '0');
56
-
57
- // Extract rating count
58
- const ratingCountEl = document.querySelector('span[property="v:votes"]');
59
- const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10);
60
-
61
- // Extract genres
62
- const genreEls = document.querySelectorAll('span[property="v:genre"]');
63
- const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
64
-
65
- // Extract directors
66
- const directorEls = document.querySelectorAll('a[rel="v:directedBy"]');
67
- const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
68
-
69
- // Extract casts
70
- const castEls = document.querySelectorAll('a[rel="v:starring"]');
71
- const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean);
72
-
73
- // Extract info section for country and duration
74
- const infoEl = document.querySelector('#info');
75
- const infoText = infoEl?.textContent || '';
76
-
77
- // Extract country/region from #info as list
78
- let country = [];
79
- const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);
80
- if (countryMatch) {
81
- country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);
82
- }
83
-
84
- // Extract duration from #info as pure number in min
85
- const durationEl = document.querySelector('span[property="v:runtime"]');
86
- let durationRaw = durationEl?.textContent?.trim() || '';
87
- if (!durationRaw) {
88
- const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/);
89
- if (durationMatch) {
90
- durationRaw = durationMatch[1].trim();
91
- }
92
- }
93
- const durationNumMatch = durationRaw.match(/(\\d+)/);
94
- const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null;
95
-
96
- // Extract summary
97
- const summaryEl = document.querySelector('span[property="v:summary"]');
98
- const summary = summaryEl?.textContent?.trim() || '';
99
-
100
- return [{
101
- id,
102
- title,
103
- originalTitle,
104
- year,
105
- rating,
106
- ratingCount,
107
- genres,
108
- directors,
109
- casts,
110
- country,
111
- duration,
112
- summary: summary.substring(0, 200),
113
- url: \`https://movie.douban.com/subject/\${id}\`
114
- }];
115
- })()
116
- ` },
117
- ],
44
+ func: async (page, args) => [await loadDoubanSubjectDetail(page, args.id, args.type)],
118
45
  });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './subject.js';
4
+
5
+ describe('douban subject command', () => {
6
+ it('skips default pre-navigation because the adapter handles subject navigation itself', () => {
7
+ const command = getRegistry().get('douban/subject');
8
+ expect(command).toBeDefined();
9
+ expect(command?.navigateBefore).toBe(false);
10
+ });
11
+ });
@@ -7,6 +7,79 @@ const DOUBAN_PHOTO_PAGE_SIZE = 30;
7
7
  const MAX_DOUBAN_PHOTOS = 500;
8
8
  const clampLimit = (limit) => clamp(limit || 20, 1, 50);
9
9
  const clampPhotoLimit = (limit) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
10
+ const DOUBAN_SEARCH_READY_SELECTOR = '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a';
11
+ const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
12
+ function firstNonEmpty(values) {
13
+ for (const value of values) {
14
+ const normalized = normalizeText(value);
15
+ if (normalized)
16
+ return normalized;
17
+ }
18
+ return '';
19
+ }
20
+ function splitDoubanPeople(value) {
21
+ return normalizeText(value)
22
+ .split(/\s*\/\s*/)
23
+ .map((entry) => normalizeText(entry))
24
+ .filter(Boolean);
25
+ }
26
+ function parseDoubanBookInfoText(infoText) {
27
+ const lines = String(infoText || '')
28
+ .replace(/\r/g, '\n')
29
+ .split('\n')
30
+ .map((line) => normalizeText(line))
31
+ .filter(Boolean);
32
+ const map = {};
33
+ for (const line of lines) {
34
+ const match = line.match(/^([^::]+)\s*[::]\s*(.*)$/);
35
+ if (!match)
36
+ continue;
37
+ const label = normalizeText(match[1]);
38
+ const value = normalizeText(match[2]);
39
+ if (!label)
40
+ continue;
41
+ map[label] = value;
42
+ }
43
+ return map;
44
+ }
45
+ function parseDoubanRating(value) {
46
+ const normalized = normalizeText(value);
47
+ if (!normalized)
48
+ return 0;
49
+ const parsed = Number.parseFloat(normalized);
50
+ return Number.isFinite(parsed) ? parsed : 0;
51
+ }
52
+ function parseDoubanCount(value) {
53
+ const normalized = normalizeText(value).replace(/[^\d]/g, '');
54
+ if (!normalized)
55
+ return 0;
56
+ const parsed = Number.parseInt(normalized, 10);
57
+ return Number.isFinite(parsed) ? parsed : 0;
58
+ }
59
+ function parseDoubanPageCount(value) {
60
+ const match = normalizeText(value).match(/(\d+)/);
61
+ if (!match)
62
+ return null;
63
+ const parsed = Number.parseInt(match[1], 10);
64
+ return Number.isFinite(parsed) ? parsed : null;
65
+ }
66
+ function extractDoubanPublishYear(value) {
67
+ const match = normalizeText(value).match(/\b(19|20)\d{2}\b/);
68
+ return match?.[0] || '';
69
+ }
70
+ function splitDoubanTitle(fullTitle) {
71
+ const normalized = normalizeText(fullTitle);
72
+ if (!normalized)
73
+ return { title: '', originalTitle: '' };
74
+ const match = normalized.match(/^([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+(?:\s*[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef·::!?]+)*)\s+(.+)$/);
75
+ if (!match) {
76
+ return { title: normalized, originalTitle: '' };
77
+ }
78
+ return {
79
+ title: normalizeText(match[1]),
80
+ originalTitle: normalizeText(match[2]),
81
+ };
82
+ }
10
83
  async function ensureDoubanReady(page) {
11
84
  const state = await page.evaluate(`
12
85
  (() => {
@@ -20,6 +93,34 @@ async function ensureDoubanReady(page) {
20
93
  throw new CliError('AUTH_REQUIRED', 'Douban requires a logged-in browser session before these commands can load data.', 'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.');
21
94
  }
22
95
  }
96
+ function isDetachedPageError(error) {
97
+ const message = error instanceof Error ? error.message : String(error || '');
98
+ return /Detached while handling command|Debugger is not attached to the tab|Target closed|No tab with id/i.test(message);
99
+ }
100
+ async function withDetachedRetry(task, options = {}) {
101
+ const attempts = Math.max(1, options.attempts || 2);
102
+ let lastError;
103
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
104
+ try {
105
+ return await task();
106
+ }
107
+ catch (error) {
108
+ lastError = error;
109
+ if (attempt >= attempts - 1 || !isDetachedPageError(error)) {
110
+ throw error;
111
+ }
112
+ }
113
+ }
114
+ throw lastError;
115
+ }
116
+ function buildDoubanSearchUrl(type, keyword) {
117
+ const url = new URL(`https://search.douban.com/${encodeURIComponent(type)}/subject_search`);
118
+ url.searchParams.set('search_text', String(keyword || ''));
119
+ if (String(type || '').trim() === 'book') {
120
+ url.searchParams.set('cat', '1001');
121
+ }
122
+ return url.toString();
123
+ }
23
124
  export function normalizeDoubanSubjectId(subjectId) {
24
125
  const normalized = String(subjectId || '').trim();
25
126
  if (!/^\d+$/.test(normalized)) {
@@ -68,6 +169,144 @@ export function getDoubanPhotoExtension(url) {
68
169
  return ext ? ext.replace(/[?#].*$/, '') : '.jpg';
69
170
  }
70
171
  }
172
+ export function normalizeDoubanBookSubject(raw) {
173
+ const info = parseDoubanBookInfoText(raw?.infoText);
174
+ const title = firstNonEmpty([raw?.title]);
175
+ const subtitle = firstNonEmpty([raw?.subtitle, info['副标题']]);
176
+ const originalTitle = firstNonEmpty([raw?.originalTitle, info['原作名']]);
177
+ const authors = splitDoubanPeople(firstNonEmpty([info['作者']]));
178
+ const translators = splitDoubanPeople(firstNonEmpty([info['译者']]));
179
+ const publisher = firstNonEmpty([info['出版社'], info['出品方']]);
180
+ const publishDate = firstNonEmpty([info['出版年']]);
181
+ const publishYear = extractDoubanPublishYear(publishDate);
182
+ const pageCount = parseDoubanPageCount(info['页数']);
183
+ const binding = firstNonEmpty([info['装帧']]);
184
+ const price = firstNonEmpty([info['定价']]);
185
+ const series = firstNonEmpty([info['丛书']]);
186
+ const isbnRaw = firstNonEmpty([info['ISBN']]).replace(/[^\dxX]/g, '');
187
+ const isbn10 = isbnRaw.length === 10 ? isbnRaw : '';
188
+ const isbn13 = isbnRaw.length === 13 ? isbnRaw : '';
189
+ return {
190
+ id: normalizeDoubanSubjectId(raw?.id),
191
+ type: 'book',
192
+ title,
193
+ subtitle,
194
+ originalTitle,
195
+ authors,
196
+ translators,
197
+ publisher,
198
+ publishDate,
199
+ publishYear,
200
+ pageCount,
201
+ binding,
202
+ price,
203
+ series,
204
+ isbn10,
205
+ isbn13,
206
+ rating: parseDoubanRating(raw?.rating),
207
+ ratingCount: parseDoubanCount(raw?.ratingCount),
208
+ summary: normalizeText(raw?.summary),
209
+ cover: firstNonEmpty([raw?.cover]),
210
+ url: firstNonEmpty([raw?.url]),
211
+ };
212
+ }
213
+ async function loadDoubanMovieSubject(page, subjectId) {
214
+ const normalizedId = normalizeDoubanSubjectId(subjectId);
215
+ const data = await withDetachedRetry(async () => {
216
+ await page.goto(`https://movie.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 });
217
+ await ensureDoubanReady(page);
218
+ await page.wait({ selector: 'span[property="v:itemreviewed"], #info', timeout: 8 }).catch(() => { });
219
+ return page.evaluate(`
220
+ (() => {
221
+ const id = ${JSON.stringify(normalizedId)};
222
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
223
+ const { title, originalTitle } = (${splitDoubanTitle.toString()})(normalize(document.querySelector('span[property="v:itemreviewed"]')?.textContent || ''));
224
+ const year = normalize(document.querySelector('.year')?.textContent).replace(/[()()]/g, '');
225
+ const rating = parseFloat(normalize(document.querySelector('strong[property="v:average"]')?.textContent || '0')) || 0;
226
+ const ratingCount = parseInt(normalize(document.querySelector('span[property="v:votes"]')?.textContent || '0'), 10) || 0;
227
+ const genres = Array.from(document.querySelectorAll('span[property="v:genre"]'))
228
+ .map((node) => normalize(node.textContent))
229
+ .filter(Boolean)
230
+ .join(',');
231
+ const directors = Array.from(document.querySelectorAll('a[rel="v:directedBy"]'))
232
+ .map((node) => normalize(node.textContent))
233
+ .filter(Boolean)
234
+ .join(',');
235
+ const casts = Array.from(document.querySelectorAll('a[rel="v:starring"]'))
236
+ .slice(0, 5)
237
+ .map((node) => normalize(node.textContent))
238
+ .filter(Boolean);
239
+ const infoText = document.querySelector('#info')?.textContent || '';
240
+ let country = [];
241
+ const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);
242
+ if (countryMatch) {
243
+ country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);
244
+ }
245
+ const durationRaw = normalize(document.querySelector('span[property="v:runtime"]')?.textContent || '');
246
+ const durationMatch = durationRaw.match(/(\\d+)/);
247
+ const summary = normalize(document.querySelector('span[property="v:summary"]')?.textContent || '');
248
+ return {
249
+ id,
250
+ type: 'movie',
251
+ title,
252
+ originalTitle,
253
+ year,
254
+ rating,
255
+ ratingCount,
256
+ genres,
257
+ directors,
258
+ casts,
259
+ country,
260
+ duration: durationMatch ? parseInt(durationMatch[1], 10) : null,
261
+ summary: summary.slice(0, 200),
262
+ url: 'https://movie.douban.com/subject/' + id + '/',
263
+ };
264
+ })()
265
+ `);
266
+ });
267
+ return data;
268
+ }
269
+ async function loadDoubanBookSubject(page, subjectId) {
270
+ const normalizedId = normalizeDoubanSubjectId(subjectId);
271
+ const data = await withDetachedRetry(async () => {
272
+ await page.goto(`https://book.douban.com/subject/${normalizedId}/`, { waitUntil: 'load', settleMs: 1500 });
273
+ await ensureDoubanReady(page);
274
+ await page.wait({ selector: 'h1 span, #info', timeout: 8 }).catch(() => { });
275
+ return page.evaluate(`
276
+ (() => {
277
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
278
+ const pickSummary = () => {
279
+ const nodes = Array.from(document.querySelectorAll('#link-report .intro, .related_info .intro'));
280
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
281
+ const text = normalize(nodes[i]?.textContent);
282
+ if (text) return text;
283
+ }
284
+ return '';
285
+ };
286
+ return {
287
+ id: ${JSON.stringify(normalizedId)},
288
+ title: normalize(document.querySelector('h1 span')?.textContent || document.querySelector('h1')?.textContent || ''),
289
+ subtitle: '',
290
+ originalTitle: '',
291
+ infoText: document.querySelector('#info')?.innerText || document.querySelector('#info')?.textContent || '',
292
+ rating: normalize(document.querySelector('strong.rating_num, strong[property="v:average"]')?.textContent || ''),
293
+ ratingCount: normalize(document.querySelector('a.rating_people > span, span[property="v:votes"]')?.textContent || ''),
294
+ summary: pickSummary(),
295
+ cover: document.querySelector('#mainpic img')?.getAttribute('src') || '',
296
+ url: location.href,
297
+ };
298
+ })()
299
+ `);
300
+ });
301
+ return normalizeDoubanBookSubject(data);
302
+ }
303
+ export async function loadDoubanSubjectDetail(page, subjectId, subjectType = 'movie') {
304
+ const type = String(subjectType || 'movie').trim() === 'book' ? 'book' : 'movie';
305
+ if (type === 'book') {
306
+ return loadDoubanBookSubject(page, subjectId);
307
+ }
308
+ return loadDoubanMovieSubject(page, subjectId);
309
+ }
71
310
  export async function loadDoubanSubjectPhotos(page, subjectId, options = {}) {
72
311
  const normalizedId = normalizeDoubanSubjectId(subjectId);
73
312
  const type = String(options.type || 'Rb').trim() || 'Rb';
@@ -312,11 +551,13 @@ export function inferDoubanSearchResultType(searchType, item = {}) {
312
551
  }
313
552
  export async function searchDouban(page, type, keyword, limit) {
314
553
  const safeLimit = clampLimit(limit);
315
- await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
316
- await page.wait(2);
317
- await ensureDoubanReady(page);
318
554
  const inferDoubanSearchResultTypeSource = inferDoubanSearchResultType.toString();
319
- const data = await page.evaluate(`
555
+ const searchUrl = buildDoubanSearchUrl(type, keyword);
556
+ const data = await withDetachedRetry(async () => {
557
+ await page.goto(searchUrl, { waitUntil: 'load', settleMs: 1500 });
558
+ await ensureDoubanReady(page);
559
+ await page.wait({ selector: DOUBAN_SEARCH_READY_SELECTOR, timeout: 8 }).catch(() => { });
560
+ return page.evaluate(`
320
561
  (async () => {
321
562
  const type = ${JSON.stringify(type)};
322
563
  const inferDoubanSearchResultType = ${inferDoubanSearchResultTypeSource};
@@ -335,13 +576,13 @@ export async function searchDouban(page, type, keyword, limit) {
335
576
  await sleep(300);
336
577
  }
337
578
 
338
- const items = Array.from(document.querySelectorAll('.item-root'));
579
+ const items = Array.from(document.querySelectorAll('.item-root, .result-list .result-item'));
339
580
 
340
581
  const results = [];
341
582
  for (const el of items) {
342
- const titleEl = el.querySelector('.title-text, .title a, a[title]');
583
+ const titleEl = el.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]');
343
584
  const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
344
- let url = titleEl?.getAttribute('href') || '';
585
+ let url = titleEl?.getAttribute('href') || el.querySelector('a[href*="/subject/"]')?.getAttribute('href') || '';
345
586
  if (!title || !url) continue;
346
587
  if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
347
588
  if (!url.includes('/subject/') || seen.has(url)) continue;
@@ -350,7 +591,7 @@ export async function searchDouban(page, type, keyword, limit) {
350
591
  const rawItem = rawItemsById.get(id) || {};
351
592
  const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
352
593
  const abstract = normalize(
353
- el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
594
+ el.querySelector('.meta.abstract, .meta, .abstract, .subject-abstract, p')?.textContent,
354
595
  );
355
596
  results.push({
356
597
  rank: results.length + 1,
@@ -367,6 +608,7 @@ export async function searchDouban(page, type, keyword, limit) {
367
608
  return results;
368
609
  })()
369
610
  `);
611
+ });
370
612
  return Array.isArray(data) ? data : [];
371
613
  }
372
614
  /**