@jackwener/opencli 1.7.3 → 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 (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -3,27 +3,32 @@ import { apiGet, payloadData, getSelfUid } from './utils.js';
3
3
  cli({
4
4
  site: 'bilibili',
5
5
  name: 'favorite',
6
- description: '我的默认收藏夹',
6
+ description: '我的收藏夹',
7
7
  domain: 'www.bilibili.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  args: [
10
+ { name: 'fid', type: 'int', required: false, help: 'Favorite folder ID (defaults to first folder)' },
10
11
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
11
12
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
12
13
  ],
13
14
  columns: ['rank', 'title', 'author', 'plays', 'url'],
14
15
  func: async (page, kwargs) => {
15
- const { limit = 20, page: pageNum = 1 } = kwargs;
16
- // Get current user's UID
17
- const uid = await getSelfUid(page);
18
- // Get default favorite folder ID
19
- const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
20
- params: { up_mid: uid },
21
- signed: true,
22
- });
23
- const folders = payloadData(foldersPayload)?.list ?? [];
24
- if (!folders.length)
25
- return [];
26
- const fid = folders[0].id;
16
+ const { fid: favoriteId, limit = 20, page: pageNum = 1 } = kwargs;
17
+ let fid;
18
+ if (favoriteId) {
19
+ fid = Number(favoriteId);
20
+ } else {
21
+ // Fall back to the default (first) favorite folder
22
+ const uid = await getSelfUid(page);
23
+ const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
24
+ params: { up_mid: uid },
25
+ signed: true,
26
+ });
27
+ const folders = payloadData(foldersPayload)?.list ?? [];
28
+ if (!folders.length)
29
+ return [];
30
+ fid = folders[0].id;
31
+ }
27
32
  // Fetch favorite items
28
33
  const payload = await apiGet(page, '/x/v3/fav/resource/list', {
29
34
  params: { media_id: fid, pn: pageNum, ps: Math.min(Number(limit), 40) },
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  cli({
4
4
  site: 'binance',
5
5
  name: 'depth',
6
- description: 'Order book bid prices for a trading pair',
6
+ description: 'Order book bid and ask prices for a trading pair',
7
7
  domain: 'data-api.binance.vision',
8
8
  strategy: Strategy.PUBLIC,
9
9
  browser: false,
@@ -11,11 +11,10 @@ cli({
11
11
  { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
13
13
  ],
14
- columns: ['rank', 'bid_price', 'bid_qty'],
14
+ columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'],
15
15
  pipeline: [
16
16
  { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
17
- { select: 'bids' },
18
- { map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } },
17
+ { map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } },
19
18
  { limit: '${{ args.limit }}' },
20
19
  ],
21
20
  });
@@ -214,11 +214,10 @@ export async function typeAndSendMessage(page, text) {
214
214
  return true;
215
215
  }
216
216
  /**
217
- * Verbose log helper — prints when OPENCLI_VERBOSE is set, with DEBUG=opencli
218
- * kept as a compatibility fallback.
217
+ * Verbose log helper — prints when OPENCLI_VERBOSE is set.
219
218
  */
220
219
  export function verbose(msg) {
221
- if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
220
+ if (process.env.OPENCLI_VERBOSE) {
222
221
  console.error(`[opencli:boss] ${msg}`);
223
222
  }
224
223
  }
@@ -121,11 +121,14 @@ let args = CommandLine.arguments
121
121
  let target = args.count > 1 ? args[1] : ""
122
122
  let needsLegacy = args.count > 2 && args[2] == "legacy"
123
123
 
124
- // Step 1: Click the "Options" button to open the popover
125
- guard let optionsBtn = findByDesc(win, "Options") else {
124
+ // Step 1: Click the "Options" button to open the popover (support both English and Chinese UI)
125
+ var optionsBtn: AXUIElement? = nil
126
+ if let btn = findByDesc(win, "Options") { optionsBtn = btn }
127
+ else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
128
+ guard let options = optionsBtn else {
126
129
  fputs("Could not find Options button\\n", stderr); exit(1)
127
130
  }
128
- press(optionsBtn)
131
+ press(options)
129
132
  Thread.sleep(forTimeInterval: 0.8)
130
133
 
131
134
  // Step 2: Find the popover that appeared, search ONLY within it
@@ -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
  /**