diff --git a/README.md b/README.md index 5952b86..324a585 100644 --- a/README.md +++ b/README.md @@ -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-输出与脚本化) @@ -106,7 +106,7 @@ douban reviews 1291546 --limit 5 ## 📚 图书 -图书命令都是公开只读请求,无需登录。详情、评分、短评、书评和笔记命令均可接受豆瓣条目 ID、ISBN 或关键词。 +图书命令都是只读请求。榜单、搜索、详情、评分、短评、书评和笔记无需登录(可接受豆瓣条目 ID、ISBN 或关键词);`book user` / `book export` 读取书单时会自动复用本地登录态,以获取当前账号可见的收藏;没有登录态时使用匿名请求,结果可能较少或被拒绝。 | 命令 | 能做什么 | 常用参数 | |---|---|---| @@ -120,8 +120,8 @@ douban reviews 1291546 --limit 5 | `douban book comments ` | 热门短评 | `--page`、`--limit` | | `douban book reviews ` | 热门书评 | `--page`、`--limit` | | `douban book notes ` | 热门读书笔记 | `--page`、`--limit` | -| `douban book user ` | 公开的想读/在读/读过书单 | `--wish`、`--reading`、`--page`、`--limit` | -| `douban book export ` | 将公开书单导出到文件 | `--wish`、`--reading`、`--limit`、`--output`、`--format`、`--delay` | +| `douban book user ` | 指定用户当前可见的想读/在读/读过书单 | `--wish`、`--reading`、`--page`、`--limit` | +| `douban book export ` | 将当前可见的书单导出到文件 | `--wish`、`--reading`、`--limit`、`--output`、`--format`、`--delay` | `book chart` 和 `book new` 支持:全部、文学、小说、历史文化、社会纪实、科学新知、艺术设计、商业经管、绘本漫画。 @@ -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,10 +153,10 @@ 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 me` | 默认用户或当前登录账号的电影收藏 | 有默认用户时自动复用本地登录态、无登录态则匿名读取;否则先验证登录账号 | | `douban list` | 热门豆列推荐 | 无需登录 | ```bash @@ -243,9 +243,11 @@ douban logout | 命令 | 数据 | 登录 | 格式 | 说明 | |---|---|---|---|---| -| `douban book export ` | 指定用户的公开想读/在读/读过 | 不需要 | Markdown、CSV、JSON | 必须指定 `--output`;未传 `--format` 时按扩展名推断;最多 5000 条;`--delay` 控制分页间隔 | +| `douban book export ` | 指定用户在当前登录状态下可见的想读/在读/读过 | 自动复用;无登录态则匿名读取 | Markdown、CSV、JSON | 必须指定 `--output`;未传 `--format` 时按扩展名推断;最多 5000 条;`--delay` 控制分页间隔 | | `douban export` | 当前账号的电影看过/想看/在看 | 需要 | CSV、JSON | 必须指定 `--output`;可能包含评分、短评和用户信息;`--delay` 控制分页和状态切换间隔 | +登录后导出自己的书单可能包含匿名访问时不可见的条目,分享文件前请检查内容。 + 两种导出在后续页面请求失败时都会保留已经取得的数据,并通过 stderr 明确告警。JSON 文件还会写入 `metadata.partial` 和 `metadata.warnings`;CSV/Markdown 会在完成提示中标明结果可能不完整。 ## 🧰 批量操作 diff --git a/src/__tests__/account.test.ts b/src/__tests__/account.test.ts index 9a876d4..4d44390 100644 --- a/src/__tests__/account.test.ts +++ b/src/__tests__/account.test.ts @@ -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(() => { @@ -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(async (_url, init) => { + const signedIn = new Headers(init?.headers).get('Cookie') === 'dbcl2=secret'; + const html = signedIn + ? collectionHtml().replaceAll('movie.douban.com', `${kind}.douban.com`) + : '0-0 / 0'; + 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(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( 'Alice', diff --git a/src/__tests__/api-boundary.test.ts b/src/__tests__/api-boundary.test.ts index d44e4f1..a5caeea 100644 --- a/src/__tests__/api-boundary.test.ts +++ b/src/__tests__/api-boundary.test.ts @@ -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'); diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index c50a50d..840433f 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -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); diff --git a/src/__tests__/book-collection-command.test.ts b/src/__tests__/book-collection-command.test.ts index a2c2a47..2de14f9 100644 --- a/src/__tests__/book-collection-command.test.ts +++ b/src/__tests__/book-collection-command.test.ts @@ -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 @@ -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__/public-collection-session-boundary.test.ts b/src/__tests__/public-collection-session-boundary.test.ts new file mode 100644 index 0000000..bdb9259 --- /dev/null +++ b/src/__tests__/public-collection-session-boundary.test.ts @@ -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( + 'Alice', + { 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); +}); diff --git a/src/__tests__/user-command.test.ts b/src/__tests__/user-command.test.ts index 8949d95..842cf7b 100644 --- a/src/__tests__/user-command.test.ts +++ b/src/__tests__/user-command.test.ts @@ -5,18 +5,15 @@ import os from 'node:os'; import path from 'node:path'; const apiMocks = vi.hoisted(() => ({ - getUserCollection: vi.fn(), getUserCollectionPage: vi.fn() })); const authMocks = vi.hoisted(() => ({ - ensureAuth: vi.fn() + ensureAuth: vi.fn(), + getOptionalSessionCookies: vi.fn() })); -vi.mock('../api/collection.js', () => ({ - getUserCollectionPage: apiMocks.getUserCollectionPage -})); -vi.mock('../api/user.js', () => ({ - getUserCollection: apiMocks.getUserCollection +vi.mock('../api/account.js', () => ({ + getUserCollectionPageWithSession: apiMocks.getUserCollectionPage })); vi.mock('../auth.js', () => authMocks); vi.mock('../utils/spinner.js', () => ({ @@ -60,6 +57,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 +72,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 +95,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 +110,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 () => { @@ -142,13 +147,38 @@ describe('user command', () => { accountName: 'Alice', updatedAt: '2026-08-10T00:00:00.000Z' }); - apiMocks.getUserCollection.mockResolvedValue([]); + apiMocks.getUserCollectionPage.mockResolvedValue({ + items: [], total: 0, nextStart: null, partial: false, warnings: [] + }); vi.spyOn(console, 'log').mockImplementation(() => undefined); await createProgram().parseAsync(['node', 'douban', 'me', '--json']); expect(authMocks.ensureAuth).toHaveBeenCalledOnce(); - expect(apiMocks.getUserCollection).toHaveBeenCalledWith('alice', 'collect', 30); + expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith({ + kind: 'movie', userId: 'alice', status: 'collect', start: 0, count: 30 + }, 'dbcl2=secret'); + expect(authMocks.getOptionalSessionCookies).not.toHaveBeenCalled(); + }); + + it.each([undefined, 'dbcl2=cached-account'])('reads the configured user with the optional session %s', async (cookies) => { + const tempHome = mkdtempSync(path.join(os.tmpdir(), 'douban-config-')); + tempDirectories.push(tempHome); + process.env.HOME = tempHome; + process.env.DOUBAN_CLI_HOME = path.join(tempHome, 'storage'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await createProgram().parseAsync(['node', 'douban', 'config', '--user', 'alice', '--json']); + authMocks.getOptionalSessionCookies.mockResolvedValue(cookies); + apiMocks.getUserCollectionPage.mockResolvedValue({ + items: [], total: 0, nextStart: null, partial: false, warnings: [] + }); + + await createProgram().parseAsync(['node', 'douban', 'me', '--json']); + + expect(authMocks.ensureAuth).not.toHaveBeenCalled(); + expect(apiMocks.getUserCollectionPage).toHaveBeenCalledWith({ + kind: 'movie', userId: 'alice', status: 'collect', start: 0, count: 30 + }, cookies); }); it('stores config in a private DOUBAN_CLI_HOME directory', async () => { diff --git a/src/api/account.ts b/src/api/account.ts index 17b7619..7dacbf5 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -1,10 +1,24 @@ import { debug } from '../utils/debug.js'; import { cleanText, fetchHtml, fetchHtmlPage } from './common.js'; import { + getUserCollectionPage, loadAuthenticatedUserCollectionPage, - type CollectionStatus + type CollectionStatus, + type UserCollectionPage, + type UserCollectionPageOptions } from './collection.js'; +/** Read with an explicitly supplied session, retaining anonymous access when absent. */ +export function getUserCollectionPageWithSession( + options: UserCollectionPageOptions, + cookies?: string +): Promise { + const cookieHeader = cookies?.trim(); + return cookieHeader + ? loadAuthenticatedUserCollectionPage(options, cookieHeader) + : getUserCollectionPage(options); +} + interface ProfileMatch { id: string; name?: string; diff --git a/src/auth.ts b/src/auth.ts index 8d43d8a..b50817f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1087,6 +1087,21 @@ export async function detectAuthSession(): Promise { return readAuthCache(); } +/** + * Cookie header of the local login session, or `undefined` when no session is cached. + * + * Never throws or scans browsers. Commands can reuse a cached session for collections + * that reject anonymous access or expose fewer entries while signed out. + */ +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..8d21f8f 100644 --- a/src/commands/book-collection.ts +++ b/src/commands/book-collection.ts @@ -1,11 +1,12 @@ import path from 'node:path'; import { Command } from 'commander'; -import { - getUserCollectionPage, - type CollectionStatus, - type UserCollectionItem, - type UserCollectionPage +import type { + CollectionStatus, + UserCollectionItem, + UserCollectionPage } from '../api/collection.js'; +import { getOptionalSessionCookies } from '../auth.js'; +import { getUserCollectionPageWithSession } from '../api/account.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 getUserCollectionPageWithSession( + { + 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); + // Reuse the local session to include collections visible only when signed in. + const cookies = await getOptionalSessionCookies(); if (opts.json) { const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}书单...`, - () => getUserCollectionPage({ kind: 'book', userId, status, start, count: limit }), + () => getUserCollectionPageWithSession({ 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 - }), + () => getUserCollectionPageWithSession( + { + 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); + // Reuse the same session across all export pages. + 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..4586d4c 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,9 +1,8 @@ import path from 'node:path'; import { chmodSync, lstatSync, readFileSync, unlinkSync } from 'node:fs'; import { Command } from 'commander'; -import { ensureAuth } from '../auth.js'; -import { getUserCollectionPage } from '../api/collection.js'; -import { getUserCollection } from '../api/user.js'; +import { ensureAuth, getOptionalSessionCookies } from '../auth.js'; +import { getUserCollectionPageWithSession } from '../api/account.js'; import { withErrorHandler } from '../utils/error.js'; import { pageToOffset, parsePositiveInt } from '../utils/parsing.js'; import { withSpinner } from '../utils/spinner.js'; @@ -118,17 +117,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); + // Reuse the local session to include collections visible only when signed in. + const cookies = await getOptionalSessionCookies(); if (opts.json) { const result = await withSpinner( `正在获取用户 ${userId} 的${statusLabel}片单...`, - () => getUserCollectionPage({ - kind: 'movie', - userId, - status, - start: startForPage, - count: limit - }), + () => getUserCollectionPageWithSession( + { + kind: 'movie', + userId, + status, + start: startForPage, + count: limit + }, + cookies + ), false ); result.warnings.forEach((warning) => console.error(`⚠️ ${warning}`)); @@ -145,13 +149,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 - }), + () => getUserCollectionPageWithSession( + { + kind: 'movie', + userId, + status, + start, + count: limit + }, + cookies + ), true ); warningsByPage.set(currentPage, result.warnings); @@ -237,11 +244,13 @@ export function registerUserCommands(program: Command): void { const { status, label: statusLabel } = resolveCollectionStatus(opts); const config = readConfig(); let userId = config.user?.trim(); + let cookies: string | undefined; if (!userId) { try { const auth = await withSpinner('正在识别当前登录用户...', () => ensureAuth(), !opts.json); userId = auth.accountId; + cookies = auth.cookies; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`本地登录态无法识别当前账号:${message}。请重新运行 douban login 或配置 --user`, { @@ -255,11 +264,16 @@ export function registerUserCommands(program: Command): void { throw new Error('无法识别当前用户,请先运行 douban login 或 douban config --user '); } - const items = await withSpinner( + cookies ??= await getOptionalSessionCookies(); + const result = await withSpinner( `正在获取我的${statusLabel}片单...`, - () => getUserCollection(userId, status, limit), + () => getUserCollectionPageWithSession({ + kind: 'movie', userId, status, start: 0, count: limit + }, cookies), !opts.json ); + result.warnings.forEach((warning) => console.error(`⚠️ ${warning}`)); + const items = result.items; if (opts.json) { console.log(JSON.stringify(items, null, 2));