@jackwener/opencli 1.7.2 → 1.7.4

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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -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';
@@ -293,42 +532,71 @@ export async function loadDoubanMovieHot(page, limit) {
293
532
  `);
294
533
  return Array.isArray(data) ? data : [];
295
534
  }
535
+ export function inferDoubanSearchResultType(searchType, item = {}) {
536
+ const fallbackType = String(searchType || '').trim() || 'movie';
537
+ if (fallbackType !== 'movie') {
538
+ return fallbackType;
539
+ }
540
+ const moreUrl = String(item.moreUrl || item.more_url || '').trim();
541
+ const isTv = moreUrl.match(/is_tv:\s*['"]?([01])['"]?/)?.[1] || '';
542
+ if (isTv === '1') {
543
+ return 'tvshow';
544
+ }
545
+ const labels = Array.isArray(item.labels)
546
+ ? item.labels
547
+ .map((label) => typeof label === 'string' ? label.trim() : String(label?.text || '').trim())
548
+ .filter(Boolean)
549
+ : [];
550
+ return labels.includes('剧集') ? 'tvshow' : fallbackType;
551
+ }
296
552
  export async function searchDouban(page, type, keyword, limit) {
297
553
  const safeLimit = clampLimit(limit);
298
- await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
299
- await page.wait(2);
300
- await ensureDoubanReady(page);
301
- const data = await page.evaluate(`
554
+ const inferDoubanSearchResultTypeSource = inferDoubanSearchResultType.toString();
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(`
302
561
  (async () => {
303
562
  const type = ${JSON.stringify(type)};
563
+ const inferDoubanSearchResultType = ${inferDoubanSearchResultTypeSource};
304
564
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
305
565
  const seen = new Set();
306
566
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
567
+ const rawItems = Array.isArray(window.__DATA__?.items) ? window.__DATA__.items : [];
568
+ const rawItemsById = new Map(
569
+ rawItems
570
+ .map((item) => [String(item?.id || '').trim(), item])
571
+ .filter(([id]) => id),
572
+ );
307
573
 
308
574
  for (let i = 0; i < 20; i += 1) {
309
575
  if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
310
576
  await sleep(300);
311
577
  }
312
578
 
313
- const items = Array.from(document.querySelectorAll('.item-root'));
579
+ const items = Array.from(document.querySelectorAll('.item-root, .result-list .result-item'));
314
580
 
315
581
  const results = [];
316
582
  for (const el of items) {
317
- 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]');
318
584
  const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
319
- let url = titleEl?.getAttribute('href') || '';
585
+ let url = titleEl?.getAttribute('href') || el.querySelector('a[href*="/subject/"]')?.getAttribute('href') || '';
320
586
  if (!title || !url) continue;
321
587
  if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
322
588
  if (!url.includes('/subject/') || seen.has(url)) continue;
323
589
  seen.add(url);
590
+ const id = url.match(/subject\\/(\\d+)/)?.[1] || '';
591
+ const rawItem = rawItemsById.get(id) || {};
324
592
  const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
325
593
  const abstract = normalize(
326
- el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
594
+ el.querySelector('.meta.abstract, .meta, .abstract, .subject-abstract, p')?.textContent,
327
595
  );
328
596
  results.push({
329
597
  rank: results.length + 1,
330
- id: url.match(/subject\\/(\\d+)/)?.[1] || '',
331
- type,
598
+ id,
599
+ type: inferDoubanSearchResultType(type, rawItem),
332
600
  title,
333
601
  rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
334
602
  abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
@@ -340,6 +608,7 @@ export async function searchDouban(page, type, keyword, limit) {
340
608
  return results;
341
609
  })()
342
610
  `);
611
+ });
343
612
  return Array.isArray(data) ? data : [];
344
613
  }
345
614
  /**
@@ -1,19 +1,105 @@
1
+ import vm from 'node:vm';
1
2
  import { describe, expect, it, vi } from 'vitest';
2
- import { getDoubanPhotoExtension, loadDoubanSubjectPhotos, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, } from './utils.js';
3
+ import {
4
+ getDoubanPhotoExtension,
5
+ inferDoubanSearchResultType,
6
+ loadDoubanSubjectDetail,
7
+ loadDoubanSubjectPhotos,
8
+ normalizeDoubanBookSubject,
9
+ normalizeDoubanSubjectId,
10
+ promoteDoubanPhotoUrl,
11
+ resolveDoubanPhotoAssetUrl,
12
+ searchDouban,
13
+ } from './utils.js';
14
+
15
+ function createFakeNode(text = '', attrs = {}) {
16
+ return {
17
+ textContent: text,
18
+ getAttribute(name) {
19
+ return attrs[name] || '';
20
+ },
21
+ };
22
+ }
23
+
24
+ function createFakeSearchItem({ title, url, rating, abstract, cover }) {
25
+ return {
26
+ querySelector(selector) {
27
+ if (selector === '.title-text, .title a, .title h3 a, h3 a, a[title]') {
28
+ return createFakeNode(title, { href: url, title });
29
+ }
30
+ if (selector === '.rating_nums') {
31
+ return createFakeNode(rating);
32
+ }
33
+ if (selector === '.meta.abstract, .meta, .abstract, .subject-abstract, p') {
34
+ return createFakeNode(abstract);
35
+ }
36
+ if (selector === 'a[href*="/subject/"]') {
37
+ return createFakeNode('', { href: url });
38
+ }
39
+ if (selector === 'img') {
40
+ return createFakeNode('', { src: cover });
41
+ }
42
+ return null;
43
+ },
44
+ };
45
+ }
46
+
47
+ async function runSearchEvaluate(script, rawItems, domItems) {
48
+ const document = {
49
+ querySelector(selector) {
50
+ if (selector === '.item-root .title-text, .item-root .title a') {
51
+ return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null;
52
+ }
53
+ if (selector === '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a') {
54
+ return domItems[0]?.querySelector('.title-text, .title a, .title h3 a, h3 a, a[title]') || null;
55
+ }
56
+ return null;
57
+ },
58
+ querySelectorAll(selector) {
59
+ if (selector === '.item-root') {
60
+ return domItems;
61
+ }
62
+ if (selector === '.item-root, .result-list .result-item') {
63
+ return domItems;
64
+ }
65
+ return [];
66
+ },
67
+ };
68
+
69
+ return vm.runInNewContext(script, {
70
+ Map,
71
+ Promise,
72
+ document,
73
+ window: { __DATA__: { items: rawItems } },
74
+ location: {
75
+ href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0',
76
+ origin: 'https://search.douban.com',
77
+ },
78
+ setTimeout(fn) {
79
+ fn();
80
+ return 0;
81
+ },
82
+ });
83
+ }
84
+
3
85
  describe('douban utils', () => {
4
86
  it('normalizes valid subject ids', () => {
5
87
  expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501');
6
88
  });
89
+
7
90
  it('rejects invalid subject ids', () => {
8
91
  expect(() => normalizeDoubanSubjectId('tt30382501')).toThrow('Invalid Douban subject ID');
9
92
  });
93
+
10
94
  it('promotes thumbnail urls to large photo urls', () => {
11
95
  expect(promoteDoubanPhotoUrl('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp')).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
12
96
  expect(promoteDoubanPhotoUrl('https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2578474613.jpg')).toBe('https://img9.doubanio.com/view/photo/l/public/p2578474613.jpg');
13
97
  });
98
+
14
99
  it('rejects non-http photo urls during promotion', () => {
15
100
  expect(promoteDoubanPhotoUrl('data:image/gif;base64,abc')).toBe('');
16
101
  });
102
+
17
103
  it('prefers lazy-loaded photo urls over data placeholders', () => {
18
104
  expect(resolveDoubanPhotoAssetUrl([
19
105
  '',
@@ -21,9 +107,11 @@ describe('douban utils', () => {
21
107
  'data:image/gif;base64,abc',
22
108
  ], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe('https://img1.doubanio.com/view/photo/m/public/p2913450214.webp');
23
109
  });
110
+
24
111
  it('drops unsupported non-http photo urls when no real image url exists', () => {
25
112
  expect(resolveDoubanPhotoAssetUrl(['data:image/gif;base64,abc', 'blob:https://movie.douban.com/example'], 'https://movie.douban.com/subject/30382501/photos?type=Rb')).toBe('');
26
113
  });
114
+
27
115
  it('removes the default photo cap when scanning for an exact photo id', async () => {
28
116
  const evaluate = vi.fn()
29
117
  .mockResolvedValueOnce({ blocked: false, title: 'Some Movie', href: 'https://movie.douban.com/subject/30382501/photos?type=Rb' })
@@ -57,8 +145,215 @@ describe('douban utils', () => {
57
145
  expect(scanScript).toContain(`const limit = ${Number.MAX_SAFE_INTEGER};`);
58
146
  expect(scanScript).toContain('for (let pageIndex = 0; photos.length < limit; pageIndex += 1)');
59
147
  });
148
+
60
149
  it('keeps image extensions when download urls contain query params', () => {
61
150
  expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1')).toBe('.webp');
62
151
  expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg')).toBe('.jpeg');
63
152
  });
153
+
154
+ it('maps tv series results to tvshow in searchDouban output', async () => {
155
+ const domItems = [
156
+ createFakeSearchItem({
157
+ title: '射雕英雄传‎ (2017)',
158
+ url: 'https://movie.douban.com/subject/26663086/',
159
+ rating: '7.9',
160
+ abstract: '中国大陆 / 剧情 / 武侠 / 古装 / 45分钟',
161
+ cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2411844029.webp',
162
+ }),
163
+ createFakeSearchItem({
164
+ title: '射雕英雄传:侠之大者‎ (2025)',
165
+ url: 'https://movie.douban.com/subject/36289423/',
166
+ rating: '5.2',
167
+ abstract: '中国大陆 / 武侠 / 146分钟',
168
+ cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2917502509.webp',
169
+ }),
170
+ ];
171
+ const rawItems = [
172
+ {
173
+ id: 26663086,
174
+ labels: [{ text: '剧集' }, { text: '可播放' }],
175
+ more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'26663086',is_tv:'1'})\"",
176
+ },
177
+ {
178
+ id: 36289423,
179
+ labels: [{ text: '可播放' }],
180
+ more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'36289423',is_tv:'0'})\"",
181
+ },
182
+ ];
183
+ const page = {
184
+ goto: vi.fn().mockResolvedValue(undefined),
185
+ wait: vi.fn().mockResolvedValue(undefined),
186
+ evaluate: vi.fn()
187
+ .mockResolvedValueOnce({ blocked: false, title: '射雕英雄传 - 电影 - 豆瓣搜索', href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0' })
188
+ .mockImplementationOnce((script) => runSearchEvaluate(script, rawItems, domItems)),
189
+ };
190
+ await expect(searchDouban(page, 'movie', '射雕英雄传', 20)).resolves.toMatchObject([
191
+ { id: '26663086', type: 'tvshow', title: '射雕英雄传‎ (2017)' },
192
+ { id: '36289423', type: 'movie', title: '射雕英雄传:侠之大者‎ (2025)' },
193
+ ]);
194
+ });
195
+
196
+ it('normalizes douban book subject raw data into structured fields', () => {
197
+ const normalized = normalizeDoubanBookSubject({
198
+ id: '2567698',
199
+ title: '小狗钱钱',
200
+ subtitle: '让孩子和家长共同成长的财商童话',
201
+ originalTitle: 'Ein Hund namens Money',
202
+ infoText: `
203
+ 作者: [德] 博多·舍费尔
204
+ 出版社: 南海出版公司
205
+ 副标题: 让孩子和家长共同成长的财商童话
206
+ 原作名: Ein Hund namens Money
207
+ 译者: 王钟欣 / 余茜
208
+ 出版年: 2014-1-1
209
+ 页数: 208
210
+ 定价: 26.00元
211
+ 装帧: 平装
212
+ 丛书: 新经典文库·爱心树童书
213
+ ISBN: 9787544270871
214
+ `,
215
+ rating: '8.9',
216
+ ratingCount: '12345',
217
+ summary: '理财启蒙故事',
218
+ cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
219
+ url: 'https://book.douban.com/subject/2567698/',
220
+ });
221
+ expect(normalized).toMatchObject({
222
+ id: '2567698',
223
+ type: 'book',
224
+ title: '小狗钱钱',
225
+ subtitle: '让孩子和家长共同成长的财商童话',
226
+ originalTitle: 'Ein Hund namens Money',
227
+ authors: ['[德] 博多·舍费尔'],
228
+ translators: ['王钟欣', '余茜'],
229
+ publisher: '南海出版公司',
230
+ publishDate: '2014-1-1',
231
+ publishYear: '2014',
232
+ pageCount: 208,
233
+ binding: '平装',
234
+ price: '26.00元',
235
+ series: '新经典文库·爱心树童书',
236
+ isbn13: '9787544270871',
237
+ rating: 8.9,
238
+ ratingCount: 12345,
239
+ summary: '理财启蒙故事',
240
+ cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
241
+ url: 'https://book.douban.com/subject/2567698/',
242
+ });
243
+ });
244
+
245
+ it('loads book subject details from book.douban.com when type=book', async () => {
246
+ const page = {
247
+ goto: vi.fn().mockResolvedValue(undefined),
248
+ wait: vi.fn().mockResolvedValue(undefined),
249
+ evaluate: vi.fn()
250
+ .mockResolvedValueOnce({ blocked: false, title: '小狗钱钱 (豆瓣)', href: 'https://book.douban.com/subject/2567698/' })
251
+ .mockResolvedValueOnce({
252
+ id: '2567698',
253
+ title: '小狗钱钱',
254
+ subtitle: '',
255
+ originalTitle: '',
256
+ infoText: `
257
+ 作者: [德] 博多·舍费尔
258
+ 出版社: 南海出版公司
259
+ 出版年: 2014-1-1
260
+ ISBN: 9787544270871
261
+ `,
262
+ rating: '8.9',
263
+ ratingCount: '12345',
264
+ summary: '理财启蒙故事',
265
+ cover: 'https://img9.doubanio.com/view/subject/l/public/s29618581.jpg',
266
+ url: 'https://book.douban.com/subject/2567698/',
267
+ }),
268
+ };
269
+ const detail = await loadDoubanSubjectDetail(page, '2567698', 'book');
270
+ expect(page.goto).toHaveBeenCalledWith('https://book.douban.com/subject/2567698/', {
271
+ waitUntil: 'load',
272
+ settleMs: 1500,
273
+ });
274
+ expect(page.wait).toHaveBeenCalledWith({ selector: 'h1 span, #info', timeout: 8 });
275
+ expect(detail).toMatchObject({
276
+ id: '2567698',
277
+ type: 'book',
278
+ title: '小狗钱钱',
279
+ authors: ['[德] 博多·舍费尔'],
280
+ publisher: '南海出版公司',
281
+ isbn13: '9787544270871',
282
+ rating: 8.9,
283
+ ratingCount: 12345,
284
+ url: 'https://book.douban.com/subject/2567698/',
285
+ });
286
+ });
287
+
288
+ it('retries transient detached navigation errors when loading douban search results', async () => {
289
+ const page = {
290
+ goto: vi.fn()
291
+ .mockRejectedValueOnce(new Error('Detached while handling command'))
292
+ .mockResolvedValueOnce(undefined),
293
+ wait: vi.fn().mockResolvedValue(undefined),
294
+ evaluate: vi.fn()
295
+ .mockResolvedValueOnce({
296
+ blocked: false,
297
+ title: '经济学思维 - 豆瓣搜索',
298
+ href: 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001',
299
+ })
300
+ .mockResolvedValueOnce([
301
+ {
302
+ rank: 1,
303
+ id: '26895402',
304
+ type: 'book',
305
+ title: '经济学思维',
306
+ rating: 7.9,
307
+ abstract: '李子畅 / 中信出版社 / 2016-7',
308
+ url: 'https://book.douban.com/subject/26895402/',
309
+ cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg',
310
+ },
311
+ ]),
312
+ };
313
+ const results = await searchDouban(page, 'book', '经济学思维', 3);
314
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://search.douban.com/book/subject_search?search_text=%E7%BB%8F%E6%B5%8E%E5%AD%A6%E6%80%9D%E7%BB%B4&cat=1001', {
315
+ waitUntil: 'load',
316
+ settleMs: 1500,
317
+ });
318
+ expect(page.goto).toHaveBeenCalledTimes(2);
319
+ expect(page.wait).toHaveBeenCalledWith({
320
+ selector: '.item-root .title-text, .item-root .title a, .result-list .result-item h3 a',
321
+ timeout: 8,
322
+ });
323
+ expect(results).toEqual([
324
+ {
325
+ rank: 1,
326
+ id: '26895402',
327
+ type: 'book',
328
+ title: '经济学思维',
329
+ rating: 7.9,
330
+ abstract: '李子畅 / 中信出版社 / 2016-7',
331
+ url: 'https://book.douban.com/subject/26895402/',
332
+ cover: 'https://img1.doubanio.com/view/subject/m/public/s29000000.jpg',
333
+ },
334
+ ]);
335
+ });
336
+ });
337
+
338
+ describe('inferDoubanSearchResultType', () => {
339
+ it('returns tvshow for movie search results marked as TV', () => {
340
+ expect(inferDoubanSearchResultType('movie', {
341
+ moreUrl: "onclick=\"moreurl(this,{is_tv:'1'})\"",
342
+ labels: [{ text: '剧集' }],
343
+ })).toBe('tvshow');
344
+ });
345
+
346
+ it('returns movie when a movie search result has no TV signal', () => {
347
+ expect(inferDoubanSearchResultType('movie', {
348
+ moreUrl: "onclick=\"moreurl(this,{is_tv:'0'})\"",
349
+ labels: [{ text: '可播放' }],
350
+ })).toBe('movie');
351
+ });
352
+
353
+ it('preserves non-movie search types', () => {
354
+ expect(inferDoubanSearchResultType('book', {
355
+ moreUrl: '',
356
+ labels: [{ text: '图书' }],
357
+ })).toBe('book');
358
+ });
64
359
  });