Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/clis/weread/book.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions src/clis/weread/highlights.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions src/clis/weread/notebooks.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions src/clis/weread/notes.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions src/clis/weread/ranking.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions src/clis/weread/search.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions src/clis/weread/shelf.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 104 additions & 0 deletions src/clis/weread/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 74 additions & 0 deletions src/clis/weread/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading