Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Unofficial Douban CLI for movies, TV shows, books, collections, and authenticate

`douban-cli` 不只是一个搜索命令:它覆盖影视发现、Top250 与口碑榜、电影和图书详情、评分分布、短评/长评/读书笔记、公开片单与书单、交互翻页、JSON 输出、批量标记和数据导出。

> 公开电影、图书、豆列和用户收藏查询无需登录。只有账号身份、电影标记/评分/评论、个人统计与私有导出需要显式运行 `douban login`。
> 电影、图书、豆列等公开查询无需登录。**用户收藏(片单/书单)的匿名结果可能不完整,部分请求也可能被豆瓣拒绝**,CLI 会自动复用已有的本地登录态;没有登录态时仍按匿名请求。可先运行 `douban login`,以当前账号的可见范围查询收藏。账号身份、电影标记/评分/评论、个人统计与私有导出需要登录。
>
> 当前登录后的写操作仅支持电影;图书功能为公开查询、阅读内容和公开书单导出。
> 当前登录后的写操作仅支持电影;图书功能均为只读查询与导出。

**导航:** [快速开始](#-快速开始) · [电影与剧集](#-电影与剧集) · [图书](#-图书) · [用户收藏](#-用户收藏豆列与配置) · [账号操作](#-登录个人数据与账号操作) · [批量操作](#-批量操作) · [JSON](#-json-输出与脚本化)

Expand Down Expand Up @@ -106,7 +106,7 @@ douban reviews 1291546 --limit 5

## 📚 图书

图书命令都是公开只读请求,无需登录。详情、评分、短评、书评和笔记命令均可接受豆瓣条目 ID、ISBN 或关键词。
图书命令都是只读请求。榜单、搜索、详情、评分、短评、书评和笔记无需登录(可接受豆瓣条目 ID、ISBN 或关键词);`book user` / `book export` 读取书单时会自动复用本地登录态,以获取当前账号可见的收藏;没有登录态时使用匿名请求,结果可能较少或被拒绝。

| 命令 | 能做什么 | 常用参数 |
|---|---|---|
Expand All @@ -120,8 +120,8 @@ douban reviews 1291546 --limit 5
| `douban book comments <query>` | 热门短评 | `--page`、`--limit` |
| `douban book reviews <query>` | 热门书评 | `--page`、`--limit` |
| `douban book notes <query>` | 热门读书笔记 | `--page`、`--limit` |
| `douban book user <userId>` | 公开的想读/在读/读过书单 | `--wish`、`--reading`、`--page`、`--limit` |
| `douban book export <userId>` | 将公开书单导出到文件 | `--wish`、`--reading`、`--limit`、`--output`、`--format`、`--delay` |
| `douban book user <userId>` | 指定用户当前可见的想读/在读/读过书单 | `--wish`、`--reading`、`--page`、`--limit` |
| `douban book export <userId>` | 将当前可见的书单导出到文件 | `--wish`、`--reading`、`--limit`、`--output`、`--format`、`--delay` |

`book chart` 和 `book new` 支持:全部、文学、小说、历史文化、社会纪实、科学新知、艺术设计、商业经管、绘本漫画。

Expand All @@ -140,7 +140,7 @@ douban book comments 三体
douban book reviews 三体 --limit 5
douban book notes 三体 --limit 5

# 公开书单
# 书单(自动复用本地登录态)
douban book user USER_ID --wish --page 2
douban book user USER_ID --reading
douban book export USER_ID --wish --limit 100 --delay 2 -o books.md
Expand All @@ -153,10 +153,10 @@ douban book export USER_ID --reading -f csv -o reading.csv

| 命令 | 能做什么 | 登录要求 |
|---|---|---|
| `douban user <userId>` | 指定用户的电影看过/想看/在看 | 无需登录;默认看过,使用 `--wish` 或 `--doing` 切换 |
| `douban book user <userId>` | 指定用户的图书读过/想读/在读 | 无需登录;默认读过,使用 `--wish` 或 `--reading` 切换 |
| `douban user <userId>` | 指定用户的电影看过/想看/在看 | 自动复用本地登录态;默认看过,使用 `--wish` 或 `--doing` 切换 |
| `douban book user <userId>` | 指定用户当前可见的图书读过/想读/在读 | 自动复用本地登录态;默认读过,使用 `--wish` 或 `--reading` 切换 |
| `douban config` | 查看本地配置;`--user <id>` 设置 `me` 的默认用户 | 无需登录 |
| `douban me` | 默认用户或当前登录账号的电影收藏 | 配置过用户 ID 时无需登录,否则使用缓存登录态 |
| `douban me` | 默认用户或当前登录账号的电影收藏 | 有默认用户时自动复用本地登录态、无登录态则匿名读取;否则先验证登录账号 |
| `douban list` | 热门豆列推荐 | 无需登录 |

```bash
Expand Down Expand Up @@ -243,9 +243,11 @@ douban logout

| 命令 | 数据 | 登录 | 格式 | 说明 |
|---|---|---|---|---|
| `douban book export <userId>` | 指定用户的公开想读/在读/读过 | 不需要 | Markdown、CSV、JSON | 必须指定 `--output`;未传 `--format` 时按扩展名推断;最多 5000 条;`--delay` 控制分页间隔 |
| `douban book export <userId>` | 指定用户在当前登录状态下可见的想读/在读/读过 | 自动复用;无登录态则匿名读取 | Markdown、CSV、JSON | 必须指定 `--output`;未传 `--format` 时按扩展名推断;最多 5000 条;`--delay` 控制分页间隔 |
| `douban export` | 当前账号的电影看过/想看/在看 | 需要 | CSV、JSON | 必须指定 `--output`;可能包含评分、短评和用户信息;`--delay` 控制分页和状态切换间隔 |

登录后导出自己的书单可能包含匿名访问时不可见的条目,分享文件前请检查内容。

两种导出在后续页面请求失败时都会保留已经取得的数据,并通过 stderr 明确告警。JSON 文件还会写入 `metadata.partial` 和 `metadata.warnings`;CSV/Markdown 会在完成提示中标明结果可能不完整。

## 🧰 批量操作
Expand Down
33 changes: 32 additions & 1 deletion src/__tests__/account.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCollectionRecords, getCurrentUserProfile } from '../api/account.ts';
import { getCollectionRecords, getCurrentUserProfile, getUserCollectionPageWithSession } from '../api/account.ts';
import { getUserCollectionPage } from '../api/collection.ts';

afterEach(() => {
Expand Down Expand Up @@ -27,6 +27,37 @@ function htmlResponseAt(html: string, url: string): Response {
}

describe('authenticated account reads', () => {
it.each(['book', 'movie'] as const)('includes signed-in-only %s entries when a session is supplied', async (kind) => {
const fetchMock = vi.fn<typeof fetch>(async (_url, init) => {
const signedIn = new Headers(init?.headers).get('Cookie') === 'dbcl2=secret';
const html = signedIn
? collectionHtml().replaceAll('movie.douban.com', `${kind}.douban.com`)
: '<span class="subject-num">0-0 / 0</span>';
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
});
vi.stubGlobal('fetch', fetchMock);
const options = { kind, userId: 'alice', status: 'collect' as const, count: 15 };

const anonymous = await getUserCollectionPageWithSession(options);
expect(anonymous.items).toHaveLength(0);
expect(anonymous.total).toBe(0);
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).has('Cookie')).toBe(false);

const authenticated = await getUserCollectionPageWithSession(options, ' dbcl2=secret ');
expect(authenticated.items.map((item) => item.id)).toEqual(['42']);
expect(authenticated.total).toBe(1);
expect(authenticated.partial).toBe(false);
});

it('uses anonymous access for a blank optional session', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(collectionHtml()));
vi.stubGlobal('fetch', fetchMock);
await getUserCollectionPageWithSession({
kind: 'movie', userId: 'alice', status: 'collect', count: 1
}, ' ');
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).has('Cookie')).toBe(false);
});

it('loads the current profile from the authenticated mine page', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
'<a class="lnk-mine" href="https://www.douban.com/people/alice/">Alice</a>',
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/api-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('public API boundary', () => {
expect(publicApi).not.toHaveProperty('fetchHtml');
expect(publicApi).not.toHaveProperty('getCurrentUserProfile');
expect(publicApi).not.toHaveProperty('getCollectionRecords');
expect(publicApi).not.toHaveProperty('getUserCollectionPageWithSession');
expect(publicApi).not.toHaveProperty('loadAuthenticatedUserCollectionPage');
expect(publicApi).not.toHaveProperty('resolveCk');
expect(publicApi).not.toHaveProperty('updateSubjectInterest');
Expand Down
51 changes: 51 additions & 0 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,57 @@ function netscapeCookieLine(name: string, value: string, options: {
}

describe.sequential('authentication safety', () => {
it('optional session lookup stays offline when no cache exists', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const auth = await import('../auth.ts');
await expect(auth.getOptionalSessionCookies()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it('optional session lookup reuses a valid cache without verifying it again', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(profileHtml('alice', 'Alice')));
vi.stubGlobal('fetch', fetchMock);
const auth = await import('../auth.ts');
await auth.loginFromCookieStdin(cookieStdin('dbcl2=mock-account; ck=mock-csrf'));
fetchMock.mockClear();

await expect(auth.getOptionalSessionCookies()).resolves.toBe('dbcl2=mock-account; ck=mock-csrf');
expect(fetchMock).not.toHaveBeenCalled();
});

it('optional session lookup ignores malformed caches and invalid storage settings', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
mkdirSync(path.dirname(authCachePath()), { recursive: true });
writeFileSync(authCachePath(), '{broken-cache', { mode: 0o600 });
const auth = await import('../auth.ts');

await expect(auth.getOptionalSessionCookies()).resolves.toBeUndefined();
process.env.DOUBAN_CLI_HOME = 'invalid-relative-path';
await expect(auth.getOptionalSessionCookies()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it('optional session lookup ignores unreadable cache paths', async () => {
mkdirSync(authCachePath(), { recursive: true });
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const auth = await import('../auth.ts');
await expect(auth.getOptionalSessionCookies()).resolves.toBeUndefined();
expect(lstatSync(authCachePath()).isDirectory()).toBe(true);
});

it('optional session lookup does not reuse an expired cache', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(profileHtml('alice', 'Alice'))));
const auth = await import('../auth.ts');
await auth.loginFromCookieStdin(cookieStdin('dbcl2=mock-account'));
vi.setSystemTime(new Date('2026-02-01T00:00:01.000Z'));
await expect(auth.getOptionalSessionCookies()).resolves.toBeUndefined();
});

it('ensureAuth uses only the local cache and never scans browsers implicitly', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
Expand Down
94 changes: 69 additions & 25 deletions src/__tests__/book-collection-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ const mocks = vi.hoisted(() => ({
})
}));

vi.mock('../api/collection.js', () => ({
getUserCollectionPage: mocks.getUserCollectionPage
const authMocks = vi.hoisted(() => ({
getOptionalSessionCookies: vi.fn()
}));

vi.mock('../api/account.js', () => ({
getUserCollectionPageWithSession: mocks.getUserCollectionPage
}));
vi.mock('../storage/secure-file.js', () => ({
secureAtomicWriteFile: mocks.secureAtomicWriteFile
Expand All @@ -46,6 +50,7 @@ vi.mock('../utils/timing.js', () => ({
)),
sleep: mocks.sleep
}));
vi.mock('../auth.js', () => authMocks);

import { registerBookCollectionCommands } from '../commands/book-collection.ts';

Expand Down Expand Up @@ -94,21 +99,51 @@ describe('book collection commands', () => {
partial: false,
warnings: []
});
authMocks.getOptionalSessionCookies.mockResolvedValue('dbcl2=alice; ck=token');
const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined);

await run(['book', 'user', 'alice', '--reading', '--page', '3', '--limit', '10']);

expect(mocks.getUserCollectionPage).toHaveBeenCalledWith({
kind: 'book',
userId: 'alice',
status: 'do',
start: 20,
count: 10
});
expect(mocks.getUserCollectionPage).toHaveBeenCalledWith(
{
kind: 'book',
userId: 'alice',
status: 'do',
start: 20,
count: 10
},
'dbcl2=alice; ck=token'
);
expect(mocks.withPagination).toHaveBeenCalledOnce();
expect(stdout.mock.calls.flat().join('\n')).toContain('ID: 42');
});

it('falls back to an anonymous read when no local session exists', async () => {
authMocks.getOptionalSessionCookies.mockResolvedValue(undefined);
mocks.getUserCollectionPage.mockResolvedValue({
items: [collectionItem('7')],
total: 1,
nextStart: null,
partial: false,
warnings: []
});
const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined);

await run(['book', 'user', 'alice', '--limit', '10', '--json']);

expect(mocks.getUserCollectionPage).toHaveBeenCalledWith(
{
kind: 'book',
userId: 'alice',
status: 'collect',
start: 0,
count: 10
},
undefined
);
expect(stdout.mock.calls.flat().join('\n')).toContain('Book 7');
});

it('rejects conflicting book collection status flags before fetching', async () => {
const stderr = vi.spyOn(console, 'error').mockImplementation(() => undefined);

Expand All @@ -121,6 +156,7 @@ describe('book collection commands', () => {
});

it('follows nextStart while exporting a shareable Markdown list', async () => {
authMocks.getOptionalSessionCookies.mockResolvedValue('dbcl2=alice; ck=token');
mocks.getUserCollectionPage
.mockResolvedValueOnce({
items: [
Expand Down Expand Up @@ -154,22 +190,30 @@ describe('book collection commands', () => {
'2'
]);

expect(mocks.getUserCollectionPage).toHaveBeenNthCalledWith(1, {
kind: 'book',
userId: 'alice',
status: 'wish',
start: 0,
count: 3,
pageDelayMs: 2000
});
expect(mocks.getUserCollectionPage).toHaveBeenNthCalledWith(2, {
kind: 'book',
userId: 'alice',
status: 'wish',
start: 2,
count: 1,
pageDelayMs: 2000
});
expect(mocks.getUserCollectionPage).toHaveBeenNthCalledWith(
1,
{
kind: 'book',
userId: 'alice',
status: 'wish',
start: 0,
count: 3,
pageDelayMs: 2000
},
'dbcl2=alice; ck=token'
);
expect(mocks.getUserCollectionPage).toHaveBeenNthCalledWith(
2,
{
kind: 'book',
userId: 'alice',
status: 'wish',
start: 2,
count: 1,
pageDelayMs: 2000
},
'dbcl2=alice; ck=token'
);
expect(mocks.sleep).toHaveBeenCalledOnce();
expect(mocks.sleep).toHaveBeenCalledWith(2000);

Expand Down
56 changes: 56 additions & 0 deletions src/__tests__/public-collection-session-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';

let directory: string;
let originalCliHome: string | undefined;
const fixture = readFileSync(new URL('./fixtures/movie-collection-page.html', import.meta.url), 'utf8');

beforeEach(() => {
directory = mkdtempSync(path.join(os.tmpdir(), 'douban-pr3-review-'));
originalCliHome = process.env.DOUBAN_CLI_HOME;
process.env.DOUBAN_CLI_HOME = path.join(directory, 'storage');
vi.spyOn(os, 'homedir').mockReturnValue(directory);
vi.resetModules();
});

afterEach(() => {
if (originalCliHome === undefined) delete process.env.DOUBAN_CLI_HOME;
else process.env.DOUBAN_CLI_HOME = originalCliHome;
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.resetModules();
rmSync(directory, { recursive: true, force: true });
});

it('public getUserCollection stays anonymous even with a cached CLI session', async () => {
const fetchMock = vi.fn().mockImplementation(async () => new Response(
'<a class="lnk-mine" href="https://www.douban.com/people/alice/">Alice</a>',
{ headers: { 'Content-Type': 'text/html' } }
));
vi.stubGlobal('fetch', fetchMock);
const auth = await import('../auth.ts');
await auth.loginFromCookieStdin(Readable.from(['dbcl2=REVIEW_FAKE_ACCOUNT; ck=REVIEW_FAKE_CSRF']));
fetchMock.mockClear();
fetchMock.mockImplementation(async () => new Response(fixture, { headers: { 'Content-Type': 'text/html' } }));
const api = await import('../api/index.ts');
const items = await api.getUserCollection('someone-else', 'collect', 2);
expect(items).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledOnce();
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get('Cookie')).toBeNull();
});

it('public getUserCollection does not modify a malformed CLI auth cache', async () => {
const storage = path.join(directory, 'storage');
mkdirSync(storage, { mode: 0o700 });
const cache = path.join(storage, 'auth.json');
writeFileSync(cache, '{malformed-cache', { mode: 0o600 });
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response(fixture, {
headers: { 'Content-Type': 'text/html' }
})));
const api = await import('../api/index.ts');
await api.getUserCollection('someone-else', 'collect', 2);
expect(existsSync(cache)).toBe(true);
});
Loading