Skip to content

Commit 0e38fd8

Browse files
authored
Feat/douban book subject (#993)
* chore: ignore local worktrees * feat(douban): support book subject details
1 parent cd48917 commit 0e38fd8

11 files changed

Lines changed: 563 additions & 111 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dist/
33
!extension/dist/
44
*.tsbuildinfo
55
.opencli/
6+
.worktrees/
67
.mcp.json
78
*.log
89
.DS_Store

cli-manifest.json

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4150,12 +4150,13 @@
41504150
],
41514151
"type": "js",
41524152
"modulePath": "douban/search.js",
4153-
"sourceFile": "douban/search.js"
4153+
"sourceFile": "douban/search.js",
4154+
"navigateBefore": false
41544155
},
41554156
{
41564157
"site": "douban",
41574158
"name": "subject",
4158-
"description": "获取电影详情",
4159+
"description": "获取豆瓣条目详情",
41594160
"domain": "movie.douban.com",
41604161
"strategy": "cookie",
41614162
"browser": true,
@@ -4165,13 +4166,37 @@
41654166
"type": "str",
41664167
"required": true,
41674168
"positional": true,
4168-
"help": "电影 ID"
4169+
"help": "豆瓣条目 ID"
4170+
},
4171+
{
4172+
"name": "type",
4173+
"type": "str",
4174+
"default": "movie",
4175+
"required": false,
4176+
"help": "条目类型(movie=电影, book=图书)",
4177+
"choices": [
4178+
"movie",
4179+
"book"
4180+
]
41694181
}
41704182
],
41714183
"columns": [
41724184
"id",
4185+
"type",
41734186
"title",
4187+
"subtitle",
41744188
"originalTitle",
4189+
"authors",
4190+
"translators",
4191+
"publisher",
4192+
"publishDate",
4193+
"publishYear",
4194+
"pageCount",
4195+
"binding",
4196+
"price",
4197+
"series",
4198+
"isbn10",
4199+
"isbn13",
41754200
"year",
41764201
"rating",
41774202
"ratingCount",
@@ -4185,7 +4210,8 @@
41854210
],
41864211
"type": "js",
41874212
"modulePath": "douban/subject.js",
4188-
"sourceFile": "douban/subject.js"
4213+
"sourceFile": "douban/subject.js",
4214+
"navigateBefore": false
41894215
},
41904216
{
41914217
"site": "douban",

clis/douban/search.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ cli({
66
description: '搜索豆瓣电影、图书或音乐',
77
domain: 'search.douban.com',
88
strategy: Strategy.COOKIE,
9+
navigateBefore: false,
910
args: [
1011
{ name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '搜索类型(movie=电影, book=图书, music=音乐)' },
1112
{ name: 'keyword', required: true, positional: true, help: '搜索关键词' },

clis/douban/search.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

clis/douban/subject.js

Lines changed: 20 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { loadDoubanSubjectDetail } from './utils.js';
3+
24
cli({
35
site: 'douban',
46
name: 'subject',
5-
description: '获取电影详情',
7+
description: '获取豆瓣条目详情',
68
domain: 'movie.douban.com',
79
strategy: Strategy.COOKIE,
810
browser: true,
11+
navigateBefore: false,
912
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=图书)' },
1115
],
1216
columns: [
1317
'id',
18+
'type',
1419
'title',
20+
'subtitle',
1521
'originalTitle',
22+
'authors',
23+
'translators',
24+
'publisher',
25+
'publishDate',
26+
'publishYear',
27+
'pageCount',
28+
'binding',
29+
'price',
30+
'series',
31+
'isbn10',
32+
'isbn13',
1633
'year',
1734
'rating',
1835
'ratingCount',
@@ -24,95 +41,5 @@ cli({
2441
'summary',
2542
'url',
2643
],
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)],
11845
});

clis/douban/subject.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)