/** * 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, Node.js fetch with cookies from browser */ import { CliError } from '@jackwener/opencli/errors'; export const WEREAD_DOMAIN = 'weread.qq.com'; export const WEREAD_WEB_ORIGIN = `https://${WEREAD_DOMAIN}`; export const WEREAD_SHELF_URL = `${WEREAD_WEB_ORIGIN}/web/shelf`; const WEB_API = `${WEREAD_WEB_ORIGIN}/web`; const API = `https://i.${WEREAD_DOMAIN}`; export const WEREAD_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'; const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]); function buildCookieHeader(cookies) { return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; '); } function isAuthErrorResponse(resp, data) { return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode)); } function getCurrentVid(cookies) { return String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim(); } function getWebShelfStorageKeys(currentVid) { return { rawBooksKey: `shelf:rawBooks:${currentVid}`, shelfIndexesKey: `shelf:shelfIndexes:${currentVid}`, }; } function normalizeWebShelfSnapshot(value) { return { cacheFound: value?.cacheFound === true, rawBooks: Array.isArray(value?.rawBooks) ? value.rawBooks : [], shelfIndexes: Array.isArray(value?.shelfIndexes) ? value.shelfIndexes : [], }; } function buildShelfSnapshotPollScript(storageKeys, requireTrustedIndexes) { return ` (() => new Promise((resolve) => { const deadline = Date.now() + 5000; const rawBooksKey = ${JSON.stringify(storageKeys.rawBooksKey)}; const shelfIndexesKey = ${JSON.stringify(storageKeys.shelfIndexesKey)}; const requireTrustedIndexes = ${JSON.stringify(requireTrustedIndexes)}; const readJson = (raw) => { if (typeof raw !== 'string') return null; try { return JSON.parse(raw); } catch { return null; } }; const collectBookIds = (items) => Array.isArray(items) ? Array.from(new Set(items.map((item) => String(item?.bookId || '').trim()).filter(Boolean))) : []; // Mirror of getTrustedIndexedBookIds in Node.js — keep in sync const hasTrustedIndexes = (rawBooks, shelfIndexes) => { const rawBookIds = collectBookIds(rawBooks); if (rawBookIds.length === 0) return false; const rawBookIdSet = new Set(rawBookIds); const projectedIndexedBookIds = Array.isArray(shelfIndexes) ? Array.from(new Set( shelfIndexes .filter((entry) => Number.isFinite(entry?.idx)) .sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER)) .map((entry) => String(entry?.bookId || '').trim()) .filter((bookId) => rawBookIdSet.has(bookId)), )) : []; return projectedIndexedBookIds.length === rawBookIds.length; }; const poll = () => { const rawBooks = readJson(localStorage.getItem(rawBooksKey)); const shelfIndexes = readJson(localStorage.getItem(shelfIndexesKey)); const cacheFound = Array.isArray(rawBooks); const ready = cacheFound && (!requireTrustedIndexes || hasTrustedIndexes(rawBooks, shelfIndexes)); if (ready || Date.now() >= deadline) { resolve({ cacheFound, rawBooks: Array.isArray(rawBooks) ? rawBooks : [], shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [], }); return; } setTimeout(poll, 100); }; poll(); })) `; } /** * Fetch a public WeRead web endpoint (Node.js direct fetch). * Used by search and ranking commands (browser: false). */ export async function fetchWebApi(path, params) { 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': WEREAD_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 with cookies extracted from the browser. * The HTTP request itself runs in Node.js to avoid page-context CORS failures. * * Cookies are collected from both the API subdomain (i.weread.qq.com) and the * main domain (weread.qq.com). WeRead may set auth cookies as host-only on * weread.qq.com, which won't match i.weread.qq.com in a URL-based lookup. */ export async function fetchPrivateApi(page, path, params) { 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(); // Merge cookies from both domains; API-domain cookies take precedence on name collision const [apiCookies, domainCookies] = await Promise.all([ page.getCookies({ url: urlStr }), page.getCookies({ domain: WEREAD_DOMAIN }), ]); const merged = new Map(); for (const c of domainCookies) merged.set(c.name, c); for (const c of apiCookies) merged.set(c.name, c); const cookieHeader = buildCookieHeader(Array.from(merged.values())); let resp; try { resp = await fetch(urlStr, { headers: { 'User-Agent': WEREAD_UA, 'Origin': 'https://weread.qq.com', 'Referer': 'https://weread.qq.com/', ...(cookieHeader ? { 'Cookie': cookieHeader } : {}), }, }); } catch (error) { throw new CliError('FETCH_ERROR', `Failed to fetch ${path}: ${error instanceof Error ? error.message : String(error)}`, 'WeRead API may be temporarily unavailable'); } let data; try { data = await resp.json(); } catch { throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page'); } if (isAuthErrorResponse(resp, data)) { throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'); } if (!resp.ok) { throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable'); } if (data?.errcode != null && data.errcode !== 0) { throw new CliError('API_ERROR', data.errmsg ?? `WeRead API error ${data.errcode}`); } return data; } function getUniqueRawBookIds(snapshot) { return Array.from(new Set(snapshot.rawBooks .map((book) => String(book?.bookId || '').trim()) .filter(Boolean))); } /** Mirror of hasTrustedIndexes in buildShelfSnapshotPollScript — keep in sync */ function getTrustedIndexedBookIds(snapshot) { const rawBookIds = getUniqueRawBookIds(snapshot); if (rawBookIds.length === 0) return []; const rawBookIdSet = new Set(rawBookIds); const projectedIndexedBookIds = Array.from(new Set(snapshot.shelfIndexes .filter((entry) => Number.isFinite(entry?.idx)) .sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER)) .map((entry) => String(entry?.bookId || '').trim()) .filter((bookId) => rawBookIdSet.has(bookId)))); return projectedIndexedBookIds.length === rawBookIds.length ? projectedIndexedBookIds : []; } /** * Build stable shelf records from the web cache plus optional rendered reader URLs. * We only trust shelfIndexes when it fully covers the same bookId set as rawBooks; * otherwise we keep rawBooks order to avoid partial hydration reordering entries. */ export function buildWebShelfEntries(snapshot, readerUrls = []) { const rawBookIds = getUniqueRawBookIds(snapshot); const trustedIndexedBookIds = getTrustedIndexedBookIds(snapshot); const orderedBookIds = trustedIndexedBookIds.length > 0 ? trustedIndexedBookIds : rawBookIds; const rawBookById = new Map(); for (const book of snapshot.rawBooks) { const bookId = String(book?.bookId || '').trim(); if (!bookId || rawBookById.has(bookId)) continue; rawBookById.set(bookId, book); } return orderedBookIds.map((bookId, index) => { const book = rawBookById.get(bookId); return { bookId, title: String(book?.title || '').trim(), author: String(book?.author || '').trim(), readerUrl: String(readerUrls[index] || '').trim(), }; }); } /** * Internal: load shelf snapshot and return the currentVid alongside it, * so callers like resolveShelfReaderUrl can reuse it without a second getCookies. */ async function loadWebShelfSnapshotWithVid(page) { await page.goto(WEREAD_SHELF_URL); const cookies = await page.getCookies({ domain: WEREAD_DOMAIN }); const currentVid = getCurrentVid(cookies); if (!currentVid) { return { snapshot: { cacheFound: false, rawBooks: [], shelfIndexes: [] }, currentVid: '' }; } const result = await page.evaluate(buildShelfSnapshotPollScript(getWebShelfStorageKeys(currentVid), false)); return { snapshot: normalizeWebShelfSnapshot(result), currentVid, }; } /** * Read the structured shelf cache from the WeRead shelf page. * The page hydrates localStorage asynchronously, so we poll briefly before * giving up and treating the cache as unavailable for the current session. */ export async function loadWebShelfSnapshot(page) { const { snapshot } = await loadWebShelfSnapshotWithVid(page); return snapshot; } /** * `book` needs a trustworthy `bookId -> readerUrl` mapping, which may lag behind * the first rawBooks cache hydration. Keep the fast shelf fallback path separate * and only wait here, with a bounded poll, when resolving reader URLs. */ async function waitForTrustedWebShelfSnapshot(page, snapshot, currentVid) { // Cache not available; nothing to wait for if (!snapshot.cacheFound) return snapshot; // Indexes already fully cover rawBooks; no need to re-poll if (getTrustedIndexedBookIds(snapshot).length > 0) return snapshot; if (!currentVid) return snapshot; const result = await page.evaluate(buildShelfSnapshotPollScript(getWebShelfStorageKeys(currentVid), true)); return normalizeWebShelfSnapshot(result); } /** * Resolve a shelf bookId to the current web reader URL by pairing structured * shelf cache order with the visible shelf links rendered on the page. */ export async function resolveShelfReaderUrl(page, bookId) { const resolution = await resolveShelfReader(page, bookId); return resolution.readerUrl; } /** * Resolve the current reader URL for a shelf entry and return the parsed shelf * snapshot used during resolution, so callers can reuse cached title/author * metadata without loading the shelf page twice. */ export async function resolveShelfReader(page, bookId) { const { snapshot: initialSnapshot, currentVid } = await loadWebShelfSnapshotWithVid(page); const snapshot = await waitForTrustedWebShelfSnapshot(page, initialSnapshot, currentVid); if (!snapshot.cacheFound) { return { snapshot, readerUrl: null }; } const rawBookIds = getUniqueRawBookIds(snapshot); const trustedIndexedBookIds = getTrustedIndexedBookIds(snapshot); const canUseRawOrderFallback = trustedIndexedBookIds.length === 0 && rawBookIds.length > 0 && snapshot.shelfIndexes.length === 0; if (trustedIndexedBookIds.length === 0 && !canUseRawOrderFallback) { return { snapshot, readerUrl: null }; } const readerUrls = await page.evaluate(` (() => Array.from(document.querySelectorAll('a.shelfBook[href]')) .map((anchor) => { const href = anchor.getAttribute('href') || ''; return href ? new URL(href, location.origin).toString() : ''; }) .filter(Boolean)) `); const expectedEntryCount = trustedIndexedBookIds.length > 0 ? trustedIndexedBookIds.length : rawBookIds.length; if (readerUrls.length !== expectedEntryCount) { return { snapshot, readerUrl: null }; } const entries = buildWebShelfEntries(snapshot, readerUrls); const entry = entries.find((candidate) => candidate.bookId === bookId); return { snapshot, readerUrl: entry?.readerUrl || null, }; } /** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */ export function formatDate(ts) { if (!Number.isFinite(ts) || ts <= 0) return '-'; // WeRead timestamps are China-centric; offset to UTC+8 to avoid off-by-one near midnight const d = new Date(ts * 1000 + 8 * 3600_000); return d.toISOString().slice(0, 10); }