Skip to content

Commit c75fea9

Browse files
eeee0717jackwener
andauthored
feat(douban): add photo listing and download commands (#474)
* feat(douban): add photo listing and download commands * refactor(douban): remove unreachable empty download branch --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 6d1fb6d commit c75fea9

12 files changed

Lines changed: 671 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Run `opencli list` for the live registry.
187187
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
188188
| **steam** | `top-sellers` | Public |
189189
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
190-
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
190+
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | Browser |
191191
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
192192
| **google** | `news` `search` `suggest` `trends` | Public |
193193
| **36kr** | `news` `hot` `search` `article` | Public / Browser |
@@ -251,6 +251,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
251251
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
252252
| **bilibili** | Videos | Requires `yt-dlp` installed |
253253
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
254+
| **douban** | Images | Downloads poster / still image lists from movie subjects |
254255
| **pixiv** | Images | Downloads original-quality illustrations, supports multi-page works |
255256
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
256257
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
@@ -282,6 +283,9 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
282283
# Download single tweet media
283284
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
284285

286+
# Download Douban posters / stills
287+
opencli douban download 30382501 --output ./douban
288+
285289
# Export Zhihu article to Markdown
286290
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
287291

README.zh-CN.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ npm install -g @jackwener/opencli@latest
169169
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
170170
| **steam** | `top-sellers` | 公开 |
171171
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
172-
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
172+
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
173173
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
174174
| **google** | `news` `search` `suggest` `trends` | 公开 |
175175
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
@@ -234,6 +234,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
234234
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
235235
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
236236
| **微信公众号** | 文章(Markdown) | 导出微信公众号文章为 Markdown |
237+
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
237238

238239
### 前置依赖
239240

@@ -262,6 +263,9 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
262263
# 下载单条推文的媒体
263264
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
264265

266+
# 下载豆瓣电影海报 / 剧照
267+
opencli douban download 30382501 --output ./douban
268+
265269
# 导出知乎文章为 Markdown
266270
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
267271

SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ opencli chaoxing exams # 考试列表
286286
opencli douban search "三体" # 搜索 (query positional)
287287
opencli douban top250 # 豆瓣 Top 250
288288
opencli douban subject 1234567 # 条目详情 (id positional)
289+
opencli douban photos 30382501 # 图片列表 / 直链(默认海报)
290+
opencli douban download 30382501 # 下载海报 / 剧照
289291
opencli douban marks --limit 10 # 我的标记
290292
opencli douban reviews --limit 10 # 短评
291293

docs/adapters/browser/douban.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
1010
| `opencli douban top250` | 豆瓣电影 Top 250 |
1111
| `opencli douban subject` | 条目详情 |
12+
| `opencli douban photos` | 获取电影海报/剧照图片列表 |
13+
| `opencli douban download` | 下载电影海报/剧照图片 |
1214
| `opencli douban marks` | 我的标记 |
1315
| `opencli douban reviews` | 我的短评 |
1416
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
@@ -32,6 +34,18 @@ opencli douban top250 --limit 10
3234
# 条目详情
3335
opencli douban subject 1292052
3436

37+
# 获取海报直链(默认 type=Rb)
38+
opencli douban photos 30382501 --limit 20
39+
40+
# 下载海报到本地目录
41+
opencli douban download 30382501 --output ./douban
42+
43+
# 只下载指定 photo_id 的一张图
44+
opencli douban download 30382501 --photo-id 2913621075 --output ./douban
45+
46+
# 返回 JSON,便于上层界面直接渲染图片并右键取图
47+
opencli douban photos 30382501 -f json
48+
3549
# 电影热门
3650
opencli douban movie-hot --limit 10
3751

docs/adapters/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Run `opencli list` for the live registry.
3030
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
3131
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
3232
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
33-
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
33+
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
3434
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
3535
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
3636
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |

docs/advanced/download.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
99
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
1010
| **bilibili** | Videos | Requires `yt-dlp` installed |
1111
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
12+
| **douban** | Images | Downloads poster / still image lists from movie subjects |
1213
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
1314
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
1415

@@ -39,6 +40,9 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
3940
# Download single tweet media
4041
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
4142

43+
# Download Douban posters / stills
44+
opencli douban download 30382501 --output ./douban
45+
4246
# Export Zhihu article to Markdown
4347
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
4448

src/clis/douban/download.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { CliCommand } from '../../registry.js';
3+
import { getRegistry } from '../../registry.js';
4+
import type { IPage } from '../../types.js';
5+
6+
const { mockHttpDownload, mockLoadDoubanSubjectPhotos, mockMkdirSync } = vi.hoisted(() => ({
7+
mockHttpDownload: vi.fn(),
8+
mockLoadDoubanSubjectPhotos: vi.fn(),
9+
mockMkdirSync: vi.fn(),
10+
}));
11+
12+
vi.mock('../../download/index.js', () => ({
13+
httpDownload: mockHttpDownload,
14+
sanitizeFilename: vi.fn((value: string) => value.replace(/\s+/g, '_')),
15+
}));
16+
17+
vi.mock('./utils.js', async () => {
18+
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
19+
return {
20+
...actual,
21+
loadDoubanSubjectPhotos: mockLoadDoubanSubjectPhotos,
22+
};
23+
});
24+
25+
vi.mock('../../download/progress.js', () => ({
26+
formatBytes: vi.fn((size: number) => `${size} B`),
27+
}));
28+
29+
vi.mock('node:fs', () => ({
30+
mkdirSync: mockMkdirSync,
31+
}));
32+
33+
await import('./download.js');
34+
35+
let cmd: CliCommand;
36+
37+
beforeAll(() => {
38+
cmd = getRegistry().get('douban/download')!;
39+
expect(cmd?.func).toBeTypeOf('function');
40+
});
41+
42+
describe('douban download', () => {
43+
beforeEach(() => {
44+
mockHttpDownload.mockReset();
45+
mockLoadDoubanSubjectPhotos.mockReset();
46+
mockMkdirSync.mockReset();
47+
});
48+
49+
it('downloads douban poster images and merges metadata into the result', async () => {
50+
const page = {} as IPage;
51+
mockLoadDoubanSubjectPhotos.mockResolvedValue({
52+
subjectId: '30382501',
53+
subjectTitle: 'The Wandering Earth 2',
54+
type: 'Rb',
55+
photos: [
56+
{
57+
index: 1,
58+
photoId: '2913450214',
59+
title: 'Main poster',
60+
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
61+
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450214.webp',
62+
detailUrl: 'https://movie.douban.com/photos/photo/2913450214/',
63+
page: 1,
64+
},
65+
{
66+
index: 2,
67+
photoId: '2913450215',
68+
title: 'Character poster',
69+
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
70+
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
71+
detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
72+
page: 1,
73+
},
74+
],
75+
});
76+
77+
mockHttpDownload
78+
.mockResolvedValueOnce({ success: true, size: 1200 })
79+
.mockResolvedValueOnce({ success: true, size: 980 });
80+
81+
const result = await cmd.func!(page, {
82+
id: '30382501',
83+
type: 'Rb',
84+
limit: 20,
85+
output: '/tmp/douban-test',
86+
}) as Array<Record<string, unknown>>;
87+
88+
expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
89+
type: 'Rb',
90+
limit: 20,
91+
});
92+
expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/douban-test/30382501', { recursive: true });
93+
expect(mockHttpDownload).toHaveBeenCalledTimes(2);
94+
expect(mockHttpDownload).toHaveBeenNthCalledWith(
95+
1,
96+
'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
97+
'/tmp/douban-test/30382501/30382501_001_2913450214_Main_poster.webp',
98+
expect.objectContaining({
99+
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450214/' },
100+
timeout: 60000,
101+
}),
102+
);
103+
expect(mockHttpDownload).toHaveBeenNthCalledWith(
104+
2,
105+
'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
106+
'/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg',
107+
expect.objectContaining({
108+
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
109+
timeout: 60000,
110+
}),
111+
);
112+
113+
expect(result).toEqual([
114+
{
115+
index: 1,
116+
title: 'Main poster',
117+
photo_id: '2913450214',
118+
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
119+
detail_url: 'https://movie.douban.com/photos/photo/2913450214/',
120+
status: 'success',
121+
size: '1200 B',
122+
},
123+
{
124+
index: 2,
125+
title: 'Character poster',
126+
photo_id: '2913450215',
127+
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
128+
detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
129+
status: 'success',
130+
size: '980 B',
131+
},
132+
]);
133+
});
134+
135+
it('downloads only the requested photo when photo-id is provided', async () => {
136+
const page = {} as IPage;
137+
mockLoadDoubanSubjectPhotos.mockResolvedValue({
138+
subjectId: '30382501',
139+
subjectTitle: 'The Wandering Earth 2',
140+
type: 'Rb',
141+
photos: [
142+
{
143+
index: 2,
144+
photoId: '2913450215',
145+
title: 'Character poster',
146+
imageUrl: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
147+
thumbUrl: 'https://img1.doubanio.com/view/photo/m/public/p2913450215.jpg',
148+
detailUrl: 'https://movie.douban.com/photos/photo/2913450215/',
149+
page: 1,
150+
},
151+
],
152+
});
153+
154+
mockHttpDownload.mockResolvedValueOnce({ success: true, size: 980 });
155+
156+
const result = await cmd.func!(page, {
157+
id: '30382501',
158+
type: 'Rb',
159+
'photo-id': '2913450215',
160+
output: '/tmp/douban-test',
161+
}) as Array<Record<string, unknown>>;
162+
163+
expect(mockLoadDoubanSubjectPhotos).toHaveBeenCalledWith(page, '30382501', {
164+
type: 'Rb',
165+
targetPhotoId: '2913450215',
166+
});
167+
expect(mockHttpDownload).toHaveBeenCalledWith(
168+
'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
169+
'/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg',
170+
expect.objectContaining({
171+
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
172+
timeout: 60000,
173+
}),
174+
);
175+
176+
expect(result).toEqual([
177+
{
178+
index: 2,
179+
title: 'Character poster',
180+
photo_id: '2913450215',
181+
image_url: 'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
182+
detail_url: 'https://movie.douban.com/photos/photo/2913450215/',
183+
status: 'success',
184+
size: '980 B',
185+
},
186+
]);
187+
});
188+
189+
it('rejects invalid subject ids before attempting browser work', async () => {
190+
await expect(
191+
cmd.func!({} as IPage, { id: 'movie-30382501' }),
192+
).rejects.toThrow('Invalid Douban subject ID');
193+
194+
expect(mockHttpDownload).not.toHaveBeenCalled();
195+
});
196+
});

src/clis/douban/download.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { formatBytes } from '../../download/progress.js';
4+
import { httpDownload, sanitizeFilename } from '../../download/index.js';
5+
import { EmptyResultError } from '../../errors.js';
6+
import { cli, Strategy } from '../../registry.js';
7+
import type { DoubanSubjectPhoto, LoadDoubanSubjectPhotosOptions } from './utils.js';
8+
import { getDoubanPhotoExtension, loadDoubanSubjectPhotos, normalizeDoubanSubjectId } from './utils.js';
9+
10+
function buildDoubanPhotoFilename(subjectId: string, photo: DoubanSubjectPhoto): string {
11+
const index = String(photo.index).padStart(3, '0');
12+
const suffix = sanitizeFilename(photo.title || photo.photoId || 'photo', 80) || 'photo';
13+
return `${subjectId}_${index}_${photo.photoId || 'photo'}_${suffix}${getDoubanPhotoExtension(photo.imageUrl)}`;
14+
}
15+
16+
cli({
17+
site: 'douban',
18+
name: 'download',
19+
description: '下载电影海报/剧照图片',
20+
domain: 'movie.douban.com',
21+
strategy: Strategy.COOKIE,
22+
args: [
23+
{ name: 'id', positional: true, required: true, help: '电影 subject ID' },
24+
{ name: 'type', default: 'Rb', help: '豆瓣 photos 的 type 参数,默认 Rb(海报)' },
25+
{ name: 'limit', type: 'int', default: 120, help: '最多下载多少张图片' },
26+
{ name: 'photo-id', help: '只下载指定 photo_id 的图片' },
27+
{ name: 'output', default: './douban-downloads', help: '输出目录' },
28+
],
29+
columns: ['index', 'title', 'status', 'size'],
30+
func: async (page, kwargs) => {
31+
const subjectId = normalizeDoubanSubjectId(String(kwargs.id || ''));
32+
const output = String(kwargs.output || './douban-downloads');
33+
const requestedPhotoId = String(kwargs['photo-id'] || '').trim();
34+
const loadOptions: LoadDoubanSubjectPhotosOptions = {
35+
type: String(kwargs.type || 'Rb'),
36+
};
37+
if (requestedPhotoId) loadOptions.targetPhotoId = requestedPhotoId;
38+
else loadOptions.limit = Number(kwargs.limit) || 120;
39+
40+
const data = await loadDoubanSubjectPhotos(page, subjectId, loadOptions);
41+
42+
const photos = requestedPhotoId
43+
? data.photos.filter((photo) => photo.photoId === requestedPhotoId)
44+
: data.photos;
45+
46+
if (requestedPhotoId && !photos.length) {
47+
throw new EmptyResultError(
48+
'douban download',
49+
`Photo ID ${requestedPhotoId} was not found under subject ${subjectId}. Try "douban photos ${subjectId} -f json" first.`,
50+
);
51+
}
52+
53+
const outputDir = path.join(output, subjectId);
54+
fs.mkdirSync(outputDir, { recursive: true });
55+
56+
const results: Array<Record<string, unknown>> = [];
57+
for (const photo of photos) {
58+
const filename = buildDoubanPhotoFilename(subjectId, photo);
59+
const destPath = path.join(outputDir, filename);
60+
const result = await httpDownload(photo.imageUrl, destPath, {
61+
headers: { Referer: photo.detailUrl || `https://movie.douban.com/subject/${subjectId}/photos?type=${encodeURIComponent(String(kwargs.type || 'Rb'))}` },
62+
timeout: 60000,
63+
});
64+
65+
results.push({
66+
index: photo.index,
67+
title: photo.title,
68+
photo_id: photo.photoId,
69+
image_url: photo.imageUrl,
70+
detail_url: photo.detailUrl,
71+
status: result.success ? 'success' : 'failed',
72+
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
73+
});
74+
}
75+
76+
return results;
77+
},
78+
});

0 commit comments

Comments
 (0)