9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|