Skip to content
Open
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Unofficial Douban CLI for movies, TV shows, books, collections, and authenticate

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

> 公开电影、图书、豆列和用户收藏查询无需登录。只有账号身份、电影标记/评分/评论、个人统计与私有导出需要显式运行 `douban login`。
> 电影、图书、豆列等公开查询无需登录。**用户收藏(片单/书单)在豆瓣侧已改为要求登录**,CLI 会自动复用本地登录态;没有登录态时仍按匿名请求,并如实返回上游错误。只有账号身份、电影标记/评分/评论、个人统计与私有导出需要显式运行 `douban login`。
>
> 当前登录后的写操作仅支持电影;图书功能为公开查询、阅读内容和公开书单导出。

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

## 📚 图书

图书命令都是公开只读请求,无需登录。详情、评分、短评、书评和笔记命令均可接受豆瓣条目 ID、ISBN 或关键词。
图书命令都是只读请求。榜单、搜索、详情、评分、短评、书评和笔记无需登录(可接受豆瓣条目 ID、ISBN 或关键词);`book user` / `book export` 读取书单时会自动复用本地登录态,因为豆瓣已不允许匿名读取收藏页。

| 命令 | 能做什么 | 常用参数 |
|---|---|---|
Expand Down Expand Up @@ -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,8 +153,8 @@ 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 list` | 热门豆列推荐 | 无需登录 |
Expand Down
90 changes: 67 additions & 23 deletions src/__tests__/book-collection-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const mocks = vi.hoisted(() => ({
})
}));

const authMocks = vi.hoisted(() => ({
getOptionalSessionCookies: vi.fn()
}));

vi.mock('../api/collection.js', () => ({
getUserCollectionPage: mocks.getUserCollectionPage
}));
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
39 changes: 24 additions & 15 deletions src/__tests__/user-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const apiMocks = vi.hoisted(() => ({
getUserCollectionPage: vi.fn()
}));
const authMocks = vi.hoisted(() => ({
ensureAuth: vi.fn()
ensureAuth: vi.fn(),
getOptionalSessionCookies: vi.fn()
}));

vi.mock('../api/collection.js', () => ({
Expand Down Expand Up @@ -60,6 +61,7 @@ describe('user command', () => {
partial: false,
warnings: []
});
authMocks.getOptionalSessionCookies.mockResolvedValue('dbcl2=alice; ck=token');
const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined);

await createProgram().parseAsync([
Expand All @@ -74,13 +76,16 @@ describe('user command', () => {
'--json'
]);

expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith({
kind: 'movie',
userId: 'alice',
status: 'collect',
start: 20,
count: 10
});
expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith(
{
kind: 'movie',
userId: 'alice',
status: 'collect',
start: 20,
count: 10
},
'dbcl2=alice; ck=token'
);
expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toEqual([
{ kind: 'movie', id: '42', title: 'Example' }
]);
Expand All @@ -94,6 +99,7 @@ describe('user command', () => {
partial: false,
warnings: []
});
authMocks.getOptionalSessionCookies.mockResolvedValue('dbcl2=alice; ck=token');
vi.spyOn(console, 'log').mockImplementation(() => undefined);

await createProgram().parseAsync([
Expand All @@ -108,13 +114,16 @@ describe('user command', () => {
]);

expect(apiMocks.getUserCollectionPage).toHaveBeenCalledOnce();
expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith({
kind: 'movie',
userId: 'alice',
status: 'collect',
start: 20,
count: 10
});
expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith(
{
kind: 'movie',
userId: 'alice',
status: 'collect',
start: 20,
count: 10
},
'dbcl2=alice; ck=token'
);
});

it('rejects conflicting collection status flags before fetching', async () => {
Expand Down
7 changes: 5 additions & 2 deletions src/api/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,11 @@ async function loadUserCollectionPage(
};
}

export function getUserCollectionPage(options: UserCollectionPageOptions): Promise<UserCollectionPage> {
return loadUserCollectionPage(options);
export function getUserCollectionPage(
options: UserCollectionPageOptions,
cookies?: string
): Promise<UserCollectionPage> {
return loadUserCollectionPage(options, cookies?.trim() || undefined);
Comment on lines +387 to +391
}

export function loadAuthenticatedUserCollectionPage(
Expand Down
18 changes: 11 additions & 7 deletions src/api/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { debug } from '../utils/debug.js';
import { getOptionalSessionCookies } from '../auth.js';
Comment on lines 1 to +2
import {
getUserCollectionPage,
type CollectionStatus,
Expand All @@ -16,13 +17,16 @@ export async function getUserCollection(
limit = 50
): Promise<UserCollectionItem[]> {
if (!Number.isInteger(limit) || limit <= 0) return [];
const page: UserCollectionPage = await getUserCollectionPage({
kind: 'movie',
userId,
status,
start: 0,
count: limit
} satisfies UserCollectionPageOptions);
const page: UserCollectionPage = await getUserCollectionPage(
{
kind: 'movie',
userId,
status,
start: 0,
count: limit
} satisfies UserCollectionPageOptions,
await getOptionalSessionCookies()
);

if (page.partial) {
debug('user', `用户片单仅返回部分结果: ${page.warnings.join('; ')}`);
Expand Down
16 changes: 16 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,22 @@ export async function detectAuthSession(): Promise<AuthSession | null> {
return readAuthCache();
}

/**
* Cookie header of the local login session, or `undefined` when no session is cached.
*
* Never throws. Collection pages used to be readable anonymously, but Douban now redirects
* those requests to `sec.douban.com` (HTTP 403), so readers should reuse the local session
* when one exists — while still degrading to an anonymous read (and its own error) when none does.
*/
export async function getOptionalSessionCookies(): Promise<string | undefined> {
try {
const session = readAuthCache();
return session?.cookies.trim() || undefined;
} catch {
Comment on lines +1097 to +1101
return undefined;
}
}

export async function ensureAuth(): Promise<VerifiedAuthSession> {
const cached = readAuthCache();
if (!cached) {
Expand Down
Loading