Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

234 lines
10 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { fetchPrivateApi, fetchWebApi, resolveShelfReader, WEREAD_UA, WEREAD_WEB_ORIGIN, } from './utils.js';
function decodeHtmlText(value) {
return value
.replace(/<[^>]+>/g, '')
.replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.trim();
}
function normalizeSearchText(value) {
return value.replace(/\s+/g, ' ').trim();
}
function buildSearchIdentity(title, author) {
return `${normalizeSearchText(title)}\u0000${normalizeSearchText(author)}`;
}
function countSearchTitles(entries) {
const counts = new Map();
for (const entry of entries) {
const key = normalizeSearchText(entry.title);
if (!key)
continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}
function countSearchIdentities(entries) {
const counts = new Map();
for (const entry of entries) {
const key = buildSearchIdentity(entry.title, entry.author);
if (!normalizeSearchText(entry.title) || !normalizeSearchText(entry.author))
continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}
export function strictTitleFromWereadDocumentTitle(rawTitle) {
const suffix = ' - 微信读书';
const normalized = String(rawTitle || '').trim();
if (!normalized.endsWith(suffix))
return '';
const base = normalized.slice(0, -suffix.length).trim();
// Only accept the title when WeRead exposes the strict "<title> - 微信读书"
// shape. If extra separators remain, the page title is ambiguous.
return base.includes(' - ') ? '' : base;
}
export function extractReaderFallbackMetadata(doc) {
const text = (node) => node?.textContent?.trim() || '';
const firstText = (...sels) => { for (const s of sels) {
const v = text(doc.querySelector(s));
if (v)
return v;
} return ''; };
const bodyText = doc.body?.innerText?.replace(/\s+/g, ' ').trim() || '';
const extractRating = () => {
const match = bodyText.match(/微信读书推荐值\s*([0-9.]+%)/);
return match ? match[1] : '';
};
const extractPublisher = () => {
const direct = text(doc.querySelector('.introDialog_content_pub_line'));
return direct.startsWith('出版社') ? direct.replace(/^出版社\s*/, '').trim() : '';
};
const extractIntro = () => {
const selectors = [
'.horizontalReaderCoverPage_content_bookInfo_intro',
'.wr_flyleaf_page_bookIntro_content',
'.introDialog_content_intro_para',
];
for (const selector of selectors) {
const value = text(doc.querySelector(selector));
if (value)
return value;
}
return '';
};
const categorySource = Array.from(doc.scripts || [])
.map((script) => script.textContent || '')
.find((scriptText) => scriptText.includes('"category"')) || '';
const categoryMatch = categorySource.match(/"category"\s*:\s*"([^"]+)"/);
const title = firstText('.horizontalReaderCoverPage_content_bookTitle', '.wr_flyleaf_page_bookInfo_bookTitle', '.outline_book_detail_header_title', '.readerTopBar_title_link') || strictTitleFromWereadDocumentTitle(doc.title || '');
const author = firstText('.horizontalReaderCoverPage_content_author', '.wr_flyleaf_page_bookInfo_author', '.outline_book_detail_header_author');
return {
title,
author,
publisher: extractPublisher(),
intro: extractIntro(),
category: categoryMatch ? categoryMatch[1].trim() : '',
rating: extractRating(),
metadataReady: Boolean(title || author),
};
}
/**
* Reuse the public search page as a last-resort reader URL source when the
* cached shelf page cannot provide a trustworthy bookId-to-reader mapping.
*/
async function resolveSearchReaderUrl(title, author) {
const normalizedTitle = normalizeSearchText(title);
const normalizedAuthor = normalizeSearchText(author);
if (!normalizedTitle)
return '';
try {
const [data, htmlEntries] = await Promise.all([
fetchWebApi('/search/global', { keyword: normalizedTitle }),
(async () => {
const url = new URL('/web/search/books', WEREAD_WEB_ORIGIN);
url.searchParams.set('keyword', normalizedTitle);
const resp = await fetch(url.toString(), {
headers: { 'User-Agent': WEREAD_UA },
});
if (!resp.ok)
return [];
const html = await resp.text();
const items = Array.from(html.matchAll(/<li[^>]*class="wr_bookList_item"[^>]*>([\s\S]*?)<\/li>/g));
return items.map((match) => {
const chunk = match[1];
const hrefMatch = chunk.match(/<a[^>]*href="([^"]+)"[^>]*class="wr_bookList_item_link"[^>]*>|<a[^>]*class="wr_bookList_item_link"[^>]*href="([^"]+)"[^>]*>/);
const titleMatch = chunk.match(/<p[^>]*class="wr_bookList_item_title"[^>]*>([\s\S]*?)<\/p>/);
const authorMatch = chunk.match(/<p[^>]*class="wr_bookList_item_author"[^>]*>([\s\S]*?)<\/p>/);
const href = hrefMatch?.[1] || hrefMatch?.[2] || '';
return {
title: decodeHtmlText(titleMatch?.[1] || ''),
author: decodeHtmlText(authorMatch?.[1] || ''),
url: href ? new URL(href, WEREAD_WEB_ORIGIN).toString() : '',
};
}).filter((entry) => entry.title && entry.url);
})(),
]);
const books = Array.isArray(data?.books) ? data.books : [];
const apiIdentityCounts = countSearchIdentities(books.map((item) => ({
title: item.bookInfo?.title ?? '',
author: item.bookInfo?.author ?? '',
})));
const htmlIdentityCounts = countSearchIdentities(htmlEntries.filter((entry) => entry.author));
const identityKey = buildSearchIdentity(normalizedTitle, normalizedAuthor);
if (normalizedAuthor &&
(apiIdentityCounts.get(identityKey) || 0) === 1 &&
(htmlIdentityCounts.get(identityKey) || 0) === 1) {
const exactMatch = htmlEntries.find((entry) => buildSearchIdentity(entry.title, entry.author) === identityKey);
if (exactMatch?.url)
return exactMatch.url;
}
const sameTitleHtmlEntries = htmlEntries.filter((entry) => normalizeSearchText(entry.title) === normalizedTitle);
if (normalizedAuthor && sameTitleHtmlEntries.some((entry) => normalizeSearchText(entry.author))) {
return '';
}
const apiTitleCounts = countSearchTitles(books.map((item) => ({ title: item.bookInfo?.title ?? '' })));
const htmlTitleCounts = countSearchTitles(htmlEntries);
if ((apiTitleCounts.get(normalizedTitle) || 0) !== 1 || (htmlTitleCounts.get(normalizedTitle) || 0) !== 1) {
return '';
}
return htmlEntries.find((entry) => normalizeSearchText(entry.title) === normalizedTitle)?.url || '';
}
catch {
return '';
}
}
/**
* Read visible book metadata from the web reader cover/flyleaf page.
* This path is used as a fallback when the private API session has expired.
*/
async function loadReaderFallbackResult(page, readerUrl) {
await page.goto(readerUrl);
await page.wait({ selector: '.horizontalReaderCoverPage_content_bookTitle, .wr_flyleaf_page_bookInfo_bookTitle, .readerTopBar_title_link', timeout: 10 });
const result = await page.evaluate(`
(${extractReaderFallbackMetadata.toString()})(document)
`);
return {
title: String(result?.title || '').trim(),
author: String(result?.author || '').trim(),
publisher: String(result?.publisher || '').trim(),
intro: String(result?.intro || '').trim(),
category: String(result?.category || '').trim(),
rating: String(result?.rating || '').trim(),
metadataReady: result?.metadataReady === true,
};
}
cli({
site: 'weread',
name: 'book',
access: 'read',
description: 'View book details on WeRead',
domain: 'weread.qq.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'book-id', positional: true, required: true, help: 'Book ID from search or shelf results' },
],
columns: ['title', 'author', 'publisher', 'intro', 'category', 'rating'],
func: async (page, args) => {
const bookId = String(args['book-id'] || '').trim();
try {
const data = await fetchPrivateApi(page, '/book/info', { 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,
}];
}
catch (error) {
if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
throw error;
}
const { readerUrl: resolvedReaderUrl, snapshot } = await resolveShelfReader(page, bookId);
let readerUrl = resolvedReaderUrl;
if (!readerUrl) {
const cachedBook = snapshot.rawBooks.find((book) => String(book?.bookId || '').trim() === bookId);
readerUrl = await resolveSearchReaderUrl(String(cachedBook?.title || ''), String(cachedBook?.author || ''));
}
if (!readerUrl) {
throw error;
}
const data = await loadReaderFallbackResult(page, readerUrl);
if (!data.metadataReady || !data.title) {
throw error;
}
return [{
title: data.title,
author: data.author,
publisher: data.publisher,
intro: data.intro,
category: data.category,
rating: data.rating,
}];
}
},
});