@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.
- package/README.md +16 -16
- package/README.zh-CN.md +28 -15
- package/cli-manifest.json +547 -10
- 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/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/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -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/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/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/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- 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/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -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.d.ts +1 -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.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -37
- 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/daemon.js +2 -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/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 +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/types.d.ts +1 -1
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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) },
|
package/clis/binance/depth.js
CHANGED
|
@@ -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
|
});
|
package/clis/boss/utils.js
CHANGED
|
@@ -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
|
|
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
|
|
220
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
222
221
|
console.error(`[opencli:boss] ${msg}`);
|
|
223
222
|
}
|
|
224
223
|
}
|
package/clis/chatgpt-app/ax.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
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
|
/**
|