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:
1 parent
1f270397f6
commit
edb21ca67b
10 files changed
+400
-1
No files matched your search
@@ -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,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user