From 749954fd93431e649a24a2de262bc07c4b108454 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 19 Mar 2026 19:18:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20WeRead=20(=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E8=AF=BB=E4=B9=A6)=20adapter=20with=207=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add weread adapter for issue #82, covering search, rankings, book details, bookshelf, notebooks, highlights, and notes. Public commands (no login required): - weread search — search books - weread ranking [category] — book rankings (all/rising/category ID) Private commands (cookie auth via browser): - weread book — book details - weread shelf — personal bookshelf - weread notebooks — books with highlights/notes - weread highlights — underlines in a book - weread notes — personal notes on a book Closes #82 --- src/clis/weread/book.ts | 28 ++++++++ src/clis/weread/highlights.ts | 25 +++++++ src/clis/weread/notebooks.ts | 23 +++++++ src/clis/weread/notes.ts | 31 +++++++++ src/clis/weread/ranking.ts | 29 +++++++++ src/clis/weread/search.ts | 26 ++++++++ src/clis/weread/shelf.ts | 26 ++++++++ src/clis/weread/utils.test.ts | 104 ++++++++++++++++++++++++++++++ src/clis/weread/utils.ts | 74 +++++++++++++++++++++ tests/e2e/public-commands.test.ts | 35 +++++++++- 10 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/clis/weread/book.ts create mode 100644 src/clis/weread/highlights.ts create mode 100644 src/clis/weread/notebooks.ts create mode 100644 src/clis/weread/notes.ts create mode 100644 src/clis/weread/ranking.ts create mode 100644 src/clis/weread/search.ts create mode 100644 src/clis/weread/shelf.ts create mode 100644 src/clis/weread/utils.test.ts create mode 100644 src/clis/weread/utils.ts diff --git a/src/clis/weread/book.ts b/src/clis/weread/book.ts new file mode 100644 index 0000000..4038465 --- /dev/null +++ b/src/clis/weread/book.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { fetchWithPage } from './utils.js'; + +cli({ + site: 'weread', + name: 'book', + description: 'View book details on WeRead', + domain: 'weread.qq.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'bookId', positional: true, required: true, help: 'Book ID (numeric, from search or shelf results)' }, + ], + columns: ['title', 'author', 'publisher', 'intro', 'category', 'rating'], + func: async (page: IPage, args) => { + const data = await fetchWithPage(page, '/book/info', { bookId: args.bookId }); + // newRating is 0-1000 scale per community docs; needs runtime verification + const rating = data.newRating ? `${(data.newRating / 10).toFixed(1)}%` : '-'; + return [{ + title: data.title ?? '', + author: data.author ?? '', + publisher: data.publisher ?? '', + intro: data.intro ?? '', + category: data.category ?? '', + rating, + }]; + }, +}); diff --git a/src/clis/weread/highlights.ts b/src/clis/weread/highlights.ts new file mode 100644 index 0000000..08feac0 --- /dev/null +++ b/src/clis/weread/highlights.ts @@ -0,0 +1,25 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { fetchWithPage, formatDate } from './utils.js'; + +cli({ + site: 'weread', + name: 'highlights', + description: 'List your highlights (underlines) in a book', + domain: 'weread.qq.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, + ], + columns: ['chapter', 'text', 'createTime'], + func: async (page: IPage, args) => { + const data = await fetchWithPage(page, '/book/bookmarklist', { bookId: args.bookId }); + const items: any[] = data?.updated ?? []; + return items.slice(0, Number(args.limit)).map((item: any) => ({ + chapter: item.chapterName ?? '', + text: item.markText ?? '', + createTime: formatDate(item.createTime), + })); + }, +}); diff --git a/src/clis/weread/notebooks.ts b/src/clis/weread/notebooks.ts new file mode 100644 index 0000000..fc557cb --- /dev/null +++ b/src/clis/weread/notebooks.ts @@ -0,0 +1,23 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { fetchWithPage } from './utils.js'; + +cli({ + site: 'weread', + name: 'notebooks', + description: 'List books that have highlights or notes', + domain: 'weread.qq.com', + strategy: Strategy.COOKIE, + columns: ['title', 'author', 'noteCount', 'bookId'], + func: async (page: IPage, _args) => { + const data = await fetchWithPage(page, '/user/notebooks'); + const books: any[] = data?.books ?? []; + return books.map((item: any) => ({ + title: item.book?.title ?? '', + author: item.book?.author ?? '', + // TODO: bookmarkCount/reviewCount field names from community docs, verify with real API + noteCount: (item.bookmarkCount ?? 0) + (item.reviewCount ?? 0), + bookId: item.bookId ?? '', + })); + }, +}); diff --git a/src/clis/weread/notes.ts b/src/clis/weread/notes.ts new file mode 100644 index 0000000..b883116 --- /dev/null +++ b/src/clis/weread/notes.ts @@ -0,0 +1,31 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { fetchWithPage, formatDate } from './utils.js'; + +cli({ + site: 'weread', + name: 'notes', + description: 'List your notes (thoughts) on a book', + domain: 'weread.qq.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, + ], + columns: ['chapter', 'text', 'review', 'createTime'], + func: async (page: IPage, args) => { + const data = await fetchWithPage(page, '/review/list', { + bookId: args.bookId, + listType: '11', + mine: '1', + synckey: '0', + }); + const items: any[] = data?.reviews ?? []; + return items.slice(0, Number(args.limit)).map((item: any) => ({ + chapter: item.review?.chapterName ?? '', + text: item.review?.abstract ?? '', + review: item.review?.content ?? '', + createTime: formatDate(item.review?.createTime), + })); + }, +}); diff --git a/src/clis/weread/ranking.ts b/src/clis/weread/ranking.ts new file mode 100644 index 0000000..b6d59d1 --- /dev/null +++ b/src/clis/weread/ranking.ts @@ -0,0 +1,29 @@ +import { cli, Strategy } from '../../registry.js'; +import { fetchWebApi } from './utils.js'; + +cli({ + site: 'weread', + name: 'ranking', + description: 'WeRead book rankings by category', + domain: 'weread.qq.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'category', positional: true, default: 'all', help: 'Category: all (default), rising, or numeric category ID' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, + ], + columns: ['rank', 'title', 'author', 'category', 'readingCount', 'bookId'], + func: async (_page, args) => { + const cat = encodeURIComponent(args.category ?? 'all'); + const data = await fetchWebApi(`/bookListInCategory/${cat}`, { rank: '1' }); + const books: any[] = data?.books ?? []; + return books.slice(0, Number(args.limit)).map((item: any, i: number) => ({ + rank: i + 1, + title: item.bookInfo?.title ?? '', + author: item.bookInfo?.author ?? '', + category: item.bookInfo?.category ?? '', + readingCount: item.readingCount ?? 0, + bookId: item.bookInfo?.bookId ?? '', + })); + }, +}); diff --git a/src/clis/weread/search.ts b/src/clis/weread/search.ts new file mode 100644 index 0000000..f791834 --- /dev/null +++ b/src/clis/weread/search.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '../../registry.js'; +import { fetchWebApi } from './utils.js'; + +cli({ + site: 'weread', + name: 'search', + description: 'Search books on WeRead', + domain: 'weread.qq.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'keyword', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 10, help: 'Max results' }, + ], + columns: ['rank', 'title', 'author', 'bookId'], + func: async (_page, args) => { + const data = await fetchWebApi('/search/global', { keyword: args.keyword }); + const books: any[] = data?.books ?? []; + return books.slice(0, Number(args.limit)).map((item: any, i: number) => ({ + rank: i + 1, + title: item.bookInfo?.title ?? '', + author: item.bookInfo?.author ?? '', + bookId: item.bookInfo?.bookId ?? '', + })); + }, +}); diff --git a/src/clis/weread/shelf.ts b/src/clis/weread/shelf.ts new file mode 100644 index 0000000..93554f8 --- /dev/null +++ b/src/clis/weread/shelf.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { fetchWithPage } from './utils.js'; + +cli({ + site: 'weread', + name: 'shelf', + description: 'List books on your WeRead bookshelf', + domain: 'weread.qq.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, + ], + columns: ['title', 'author', 'progress', 'bookId'], + func: async (page: IPage, args) => { + const data = await fetchWithPage(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' }); + const books: any[] = data?.books ?? []; + return books.slice(0, Number(args.limit)).map((item: any) => ({ + title: item.bookInfo?.title ?? item.title ?? '', + author: item.bookInfo?.author ?? item.author ?? '', + // TODO: readingProgress field name from community docs, verify with real API response + progress: item.readingProgress != null ? `${item.readingProgress}%` : '-', + bookId: item.bookId ?? item.bookInfo?.bookId ?? '', + })); + }, +}); diff --git a/src/clis/weread/utils.test.ts b/src/clis/weread/utils.test.ts new file mode 100644 index 0000000..4b3b89e --- /dev/null +++ b/src/clis/weread/utils.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { formatDate, fetchWebApi, fetchWithPage } from './utils.js'; + +describe('formatDate', () => { + it('formats a typical Unix timestamp in UTC+8', () => { + // 1705276800 = 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Beijing + expect(formatDate(1705276800)).toBe('2024-01-15'); + }); + + it('handles UTC midnight edge case with UTC+8 offset', () => { + // 1705190399 = 2024-01-13 23:59:59 UTC = 2024-01-14 07:59:59 Beijing + expect(formatDate(1705190399)).toBe('2024-01-14'); + }); + + it('returns dash for zero', () => { + expect(formatDate(0)).toBe('-'); + }); + + it('returns dash for negative', () => { + expect(formatDate(-1)).toBe('-'); + }); + + it('returns dash for NaN', () => { + expect(formatDate(NaN)).toBe('-'); + }); + + it('returns dash for Infinity', () => { + expect(formatDate(Infinity)).toBe('-'); + }); + + it('returns dash for undefined', () => { + expect(formatDate(undefined)).toBe('-'); + }); + + it('returns dash for null', () => { + expect(formatDate(null)).toBe('-'); + }); +}); + +describe('fetchWebApi', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns parsed JSON for successful response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ books: [{ title: 'Test' }] }), + })); + + const result = await fetchWebApi('/search/global', { keyword: 'test' }); + expect(result).toEqual({ books: [{ title: 'Test' }] }); + }); + + it('throws CliError on HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({}), + })); + + await expect(fetchWebApi('/search/global')).rejects.toThrow('HTTP 403'); + }); + + it('throws PARSE_ERROR on non-JSON response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.reject(new SyntaxError('Unexpected token <')), + })); + + await expect(fetchWebApi('/search/global')).rejects.toThrow('Invalid JSON'); + }); +}); + +describe('fetchWithPage', () => { + it('throws AUTH_REQUIRED on errcode -2010', async () => { + const mockPage = { + evaluate: vi.fn().mockResolvedValue({ errcode: -2010, errmsg: '用户不存在' }), + } as any; + await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('Not logged in'); + }); + + it('throws API_ERROR on unknown errcode', async () => { + const mockPage = { + evaluate: vi.fn().mockResolvedValue({ errcode: -1, errmsg: 'unknown error' }), + } as any; + await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('unknown error'); + }); + + it('returns data on success (errcode 0 or absent)', async () => { + const mockPage = { + evaluate: vi.fn().mockResolvedValue({ title: 'Test Book', errcode: 0 }), + } as any; + const result = await fetchWithPage(mockPage, '/book/info'); + expect(result.title).toBe('Test Book'); + }); + + it('throws FETCH_ERROR on HTTP error', async () => { + const mockPage = { + evaluate: vi.fn().mockResolvedValue({ _httpError: '403' }), + } as any; + await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('HTTP 403'); + }); +}); diff --git a/src/clis/weread/utils.ts b/src/clis/weread/utils.ts new file mode 100644 index 0000000..efd4234 --- /dev/null +++ b/src/clis/weread/utils.ts @@ -0,0 +1,74 @@ +/** + * WeRead shared helpers: fetch wrappers and formatting. + * + * Two API domains: + * - WEB_API (weread.qq.com/web/*): public, Node.js fetch + * - API (i.weread.qq.com/*): private, browser page.evaluate with cookies + */ + +import { CliError } from '../../errors.js'; +import type { IPage } from '../../types.js'; + +const WEB_API = 'https://weread.qq.com/web'; +const API = 'https://i.weread.qq.com'; +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +/** + * Fetch a public WeRead web endpoint (Node.js direct fetch). + * Used by search and ranking commands (browser: false). + */ +export async function fetchWebApi(path: string, params?: Record): Promise { + const url = new URL(`${WEB_API}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const resp = await fetch(url.toString(), { + headers: { 'User-Agent': UA }, + }); + if (!resp.ok) { + throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable'); + } + try { + return await resp.json(); + } catch { + throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page'); + } +} + +/** + * Fetch a private WeRead API endpoint via browser page.evaluate. + * Automatically carries cookies for authenticated requests. + */ +export async function fetchWithPage(page: IPage, path: string, params?: Record): Promise { + const url = new URL(`${API}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + } + const urlStr = url.toString(); + const data = await page.evaluate(` + async () => { + const res = await fetch(${JSON.stringify(urlStr)}, { credentials: "include" }); + if (!res.ok) return { _httpError: String(res.status) }; + try { return await res.json(); } + catch { return { _httpError: 'JSON parse error (status ' + res.status + ')' }; } + } + `); + if (data?._httpError) { + throw new CliError('FETCH_ERROR', `HTTP ${data._httpError} for ${path}`, 'WeRead API may be temporarily unavailable'); + } + if (data?.errcode === -2010) { + throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'); + } + if (data?.errcode != null && data.errcode !== 0) { + throw new CliError('API_ERROR', data.errmsg ?? `WeRead API error ${data.errcode}`); + } + return data; +} + +/** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */ +export function formatDate(ts: number | undefined | null): string { + if (!Number.isFinite(ts) || (ts as number) <= 0) return '-'; + // WeRead timestamps are China-centric; offset to UTC+8 to avoid off-by-one near midnight + const d = new Date((ts as number) * 1000 + 8 * 3600_000); + return d.toISOString().slice(0, 10); +} diff --git a/tests/e2e/public-commands.test.ts b/tests/e2e/public-commands.test.ts index 5148687..b250a98 100644 --- a/tests/e2e/public-commands.test.ts +++ b/tests/e2e/public-commands.test.ts @@ -6,11 +6,14 @@ import { describe, it, expect } from 'vitest'; import { runCli, parseJsonOutput } from './helpers.js'; -function isExpectedXiaoyuzhouRestriction(code: number, stderr: string): boolean { +function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean { if (code === 0) return false; return /Error \[FETCH_ERROR\]: HTTP (403|429|451|503)\b/.test(stderr); } +// Keep old name as alias for existing tests +const isExpectedXiaoyuzhouRestriction = isExpectedChineseSiteRestriction; + describe('public commands E2E', () => { // ── hackernews ── it('hackernews top returns structured data', async () => { @@ -115,4 +118,34 @@ describe('public commands E2E', () => { expect(code).not.toBe(0); expect(stderr).toMatch(/limit must be a positive integer|Argument "limit" must be a valid number/); }, 30_000); + + // ── weread (Chinese site — may return empty on overseas CI runners) ── + it('weread search returns books', async () => { + const { stdout, stderr, code } = await runCli(['weread', 'search', 'python', '--limit', '3', '-f', 'json']); + if (isExpectedChineseSiteRestriction(code, stderr)) { + console.warn(`weread search skipped: ${stderr.trim()}`); + return; + } + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('bookId'); + }, 30_000); + + it('weread ranking returns books', async () => { + const { stdout, stderr, code } = await runCli(['weread', 'ranking', 'all', '--limit', '3', '-f', 'json']); + if (isExpectedChineseSiteRestriction(code, stderr)) { + console.warn(`weread ranking skipped: ${stderr.trim()}`); + return; + } + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('readingCount'); + expect(data[0]).toHaveProperty('bookId'); + }, 30_000); });