@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.
- package/README.md +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /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
|
+
}
|
package/clis/douban/search.js
CHANGED
|
@@ -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
|
+
});
|
package/clis/douban/subject.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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
|
+
});
|
package/clis/douban/utils.js
CHANGED
|
@@ -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
|
|
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
|
/**
|