diff --git a/README.md b/README.md index 5952b86..f74b9e4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unofficial Douban CLI for movies, TV shows, books, collections, and authenticate `douban-cli` 不只是一个搜索命令:它覆盖影视发现、Top250 与口碑榜、电影和图书详情、评分分布、短评/长评/读书笔记、公开片单与书单、交互翻页、JSON 输出、批量标记和数据导出。 -> 公开电影、图书、豆列和用户收藏查询无需登录。只有账号身份、电影标记/评分/评论、个人统计与私有导出需要显式运行 `douban login`。 +> 电影、图书、豆列等公开查询无需登录。**用户收藏(片单/书单)在豆瓣侧已改为要求登录**,CLI 会自动复用本地登录态;没有登录态时仍按匿名请求,并如实返回上游错误。只有账号身份、电影标记/评分/评论、个人统计与私有导出需要显式运行 `douban login`。 > > 当前登录后的写操作仅支持电影;图书功能为公开查询、阅读内容和公开书单导出。 @@ -106,7 +106,7 @@ douban reviews 1291546 --limit 5 ## 📚 图书 -图书命令都是公开只读请求,无需登录。详情、评分、短评、书评和笔记命令均可接受豆瓣条目 ID、ISBN 或关键词。 +图书命令都是只读请求。榜单、搜索、详情、评分、短评、书评和笔记无需登录(可接受豆瓣条目 ID、ISBN 或关键词);`book user` / `book export` 读取书单时会自动复用本地登录态,因为豆瓣已不允许匿名读取收藏页。 | 命令 | 能做什么 | 常用参数 | |---|---|---| @@ -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 @@ -153,8 +153,8 @@ douban book export USER_ID --reading -f csv -o reading.csv | 命令 | 能做什么 | 登录要求 | |---|---|---| -| `douban user ` | 指定用户的电影看过/想看/在看 | 无需登录;默认看过,使用 `--wish` 或 `--doing` 切换 | -| `douban book user ` | 指定用户的图书读过/想读/在读 | 无需登录;默认读过,使用 `--wish` 或 `--reading` 切换 | +| `douban user ` | 指定用户的电影看过/想看/在看 | 自动复用本地登录态;默认看过,使用 `--wish` 或 `--doing` 切换 | +| `douban book user ` | 指定用户的图书读过/想读/在读 | 自动复用本地登录态;默认读过,使用 `--wish` 或 `--reading` 切换 | | `douban config` | 查看本地配置;`--user ` 设置 `me` 的默认用户 | 无需登录 | | `douban me` | 默认用户或当前登录账号的电影收藏 | 配置过用户 ID 时无需登录,否则使用缓存登录态 | | `douban list` | 热门豆列推荐 | 无需登录 | diff --git a/src/__tests__/book-collection-command.test.ts b/src/__tests__/book-collection-command.test.ts index a2c2a47..e77fa7a 100644 --- a/src/__tests__/book-collection-command.test.ts +++ b/src/__tests__/book-collection-command.test.ts @@ -28,6 +28,10 @@ const mocks = vi.hoisted(() => ({ }) })); +const authMocks = vi.hoisted(() => ({ + getOptionalSessionCookies: vi.fn() +})); + vi.mock('../api/collection.js', () => ({ getUserCollectionPage: mocks.getUserCollectionPage })); @@ -46,6 +50,7 @@ vi.mock('../utils/timing.js', () => ({ )), sleep: mocks.sleep })); +vi.mock('../auth.js', () => authMocks); import { registerBookCollectionCommands } from '../commands/book-collection.ts'; @@ -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); @@ -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: [ @@ -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); diff --git a/src/__tests__/user-command.test.ts b/src/__tests__/user-command.test.ts index 8949d95..a06f3c5 100644 --- a/src/__tests__/user-command.test.ts +++ b/src/__tests__/user-command.test.ts @@ -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', () => ({ @@ -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([ @@ -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' } ]); @@ -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([ @@ -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 () => { diff --git a/src/api/collection.ts b/src/api/collection.ts index f4f1137..fe507ee 100644 --- a/src/api/collection.ts +++ b/src/api/collection.ts @@ -384,8 +384,11 @@ async function loadUserCollectionPage( }; } -export function getUserCollectionPage(options: UserCollectionPageOptions): Promise { - return loadUserCollectionPage(options); +export function getUserCollectionPage( + options: UserCollectionPageOptions, + cookies?: string +): Promise { + return loadUserCollectionPage(options, cookies?.trim() || undefined); } export function loadAuthenticatedUserCollectionPage( diff --git a/src/api/user.ts b/src/api/user.ts index 9644ff0..a8a3fcd 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,4 +1,5 @@ import { debug } from '../utils/debug.js'; +import { getOptionalSessionCookies } from '../auth.js'; import { getUserCollectionPage, type CollectionStatus, @@ -16,13 +17,16 @@ export async function getUserCollection( limit = 50 ): Promise { 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('; ')}`); diff --git a/src/auth.ts b/src/auth.ts index 8d43d8a..bef41d7 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1087,6 +1087,22 @@ export async function detectAuthSession(): Promise { 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 { + try { + const session = readAuthCache(); + return session?.cookies.trim() || undefined; + } catch { + return undefined; + } +} + export async function ensureAuth(): Promise { const cached = readAuthCache(); if (!cached) { diff --git a/src/commands/book-collection.ts b/src/commands/book-collection.ts index 96c215b..819d491 100644 --- a/src/commands/book-collection.ts +++ b/src/commands/book-collection.ts @@ -6,6 +6,7 @@ import { type UserCollectionItem, type UserCollectionPage } from '../api/collection.js'; +import { getOptionalSessionCookies } from '../auth.js'; import { secureAtomicWriteFile } from '../storage/secure-file.js'; import { withErrorHandler } from '../utils/error.js'; import { pageToOffset, parsePositiveInt } from '../utils/parsing.js'; @@ -171,7 +172,8 @@ async function fetchBookCollectionExport( userId: string, status: CollectionStatus, limit: number, - pageDelayMs: number + pageDelayMs: number, + cookies?: string ): Promise { const items: UserCollectionItem[] = []; const seenItems = new Set(); @@ -197,14 +199,17 @@ async function fetchBookCollectionExport( let result: UserCollectionPage; try { - result = await getUserCollectionPage({ - kind: 'book', - userId, - status, - start: nextStart, - count: Math.min(EXPORT_BATCH_SIZE, limit - items.length), - pageDelayMs - }); + result = await getUserCollectionPage( + { + kind: 'book', + userId, + status, + start: nextStart, + count: Math.min(EXPORT_BATCH_SIZE, limit - items.length), + pageDelayMs + }, + cookies + ); } catch (error) { if (items.length === 0) throw error; partial = true; @@ -272,11 +277,13 @@ export function registerBookCollectionCommands(book: Command): void { const page = parsePositiveInt(opts.page, '--page', 1); const limit = parsePositiveInt(opts.limit, '--limit', DEFAULT_PAGE_SIZE); const start = pageToOffset(page, limit); + // Douban requires a login session for collection pages; reuse the local one when present. + const cookies = await getOptionalSessionCookies(); if (opts.json) { const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}书单...`, - () => getUserCollectionPage({ kind: 'book', userId, status, start, count: limit }), + () => getUserCollectionPage({ kind: 'book', userId, status, start, count: limit }, cookies), false ); emitWarnings(result); @@ -291,13 +298,16 @@ export function registerBookCollectionCommands(book: Command): void { const currentStart = pageToOffset(currentPage, limit); const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}书单...`, - () => getUserCollectionPage({ - kind: 'book', - userId, - status, - start: currentStart, - count: limit - }), + () => getUserCollectionPage( + { + kind: 'book', + userId, + status, + start: currentStart, + count: limit + }, + cookies + ), true ); emitWarnings(result); @@ -333,9 +343,11 @@ export function registerBookCollectionCommands(book: Command): void { const pageDelayMs = Math.round(delaySeconds * 1000); const outputPath = path.resolve(String(opts.output)); const format = resolveExportFormat(opts.format, outputPath); + // Collection pages now require a login session on Douban's side; reuse the local one when present. + const cookies = await getOptionalSessionCookies(); const result = await withSpinner( `正在导出用户 ${userId} 的${statusLabel}书单...`, - () => fetchBookCollectionExport(userId, status, limit, pageDelayMs), + () => fetchBookCollectionExport(userId, status, limit, pageDelayMs, cookies), true ); emitWarnings(result); diff --git a/src/commands/user.ts b/src/commands/user.ts index f199f2a..2b9faeb 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { chmodSync, lstatSync, readFileSync, unlinkSync } from 'node:fs'; import { Command } from 'commander'; -import { ensureAuth } from '../auth.js'; +import { ensureAuth, getOptionalSessionCookies } from '../auth.js'; import { getUserCollectionPage } from '../api/collection.js'; import { getUserCollection } from '../api/user.js'; import { withErrorHandler } from '../utils/error.js'; @@ -118,17 +118,22 @@ export function registerUserCommands(program: Command): void { const limit = parsePositiveInt(opts.limit, '--limit', 15); const page = parsePositiveInt(opts.page, '--page', 1); const startForPage = pageToOffset(page, limit); + // Douban requires a login session for collection pages; reuse the local one when present. + const cookies = await getOptionalSessionCookies(); if (opts.json) { const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}片单...`, - () => getUserCollectionPage({ - kind: 'movie', - userId, - status, - start: startForPage, - count: limit - }), + () => getUserCollectionPage( + { + kind: 'movie', + userId, + status, + start: startForPage, + count: limit + }, + cookies + ), false ); result.warnings.forEach((warning) => console.error(`⚠️ ${warning}`)); @@ -145,13 +150,16 @@ export function registerUserCommands(program: Command): void { const start = pageToOffset(currentPage, limit); const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}片单...`, - () => getUserCollectionPage({ - kind: 'movie', - userId, - status, - start, - count: limit - }), + () => getUserCollectionPage( + { + kind: 'movie', + userId, + status, + start, + count: limit + }, + cookies + ), true ); warningsByPage.set(currentPage, result.warnings);