feat: add WeRead (微信读书) adapter with 7 commands (#89)

Add weread adapter for issue #82, covering search, rankings, book details,
bookshelf, notebooks, highlights, and notes.

Public commands (no login required):
- weread search <keyword> — search books
- weread ranking [category] — book rankings (all/rising/category ID)

Private commands (cookie auth via browser):
- weread book <bookId> — book details
- weread shelf — personal bookshelf
- weread notebooks — books with highlights/notes
- weread highlights <bookId> — underlines in a book
- weread notes <bookId> — personal notes on a book

Closes #82
This commit is contained in:
AstroHan authored and GitHub committed 2026-03-19 21:03:20 +08:00
1 parent 1f270397f6
commit edb21ca67b
10 files changed
+400 -1

No files matched your search

+28
View File
@@ -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,
}];
},
});
+25
View File
@@ -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),
}));
},
});
+23
View File
@@ -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 ?? '',
}));
},
});
+31
View File
@@ -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),
}));
},
});
+29
View File
@@ -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 ?? '',
}));
},
});
+26
View File
@@ -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 ?? '',
}));
},
});
+26
View File
@@ -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 ?? '',
}));
},
});
+104
View File
@@ -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');
});
});
+74
View File
@@ -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<string, string>): Promise<any> {
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<string, string>): Promise<any> {
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);
}
+34 -1
View File
@@ -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', () => {
// ── apple-podcasts ──
it('apple-podcasts search returns structured podcast results', async () => {
@@ -149,4 +152,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);
});