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
234 lines
10 KiB
JavaScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/"/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,
|
|
}];
|
|
}
|
|
},
|
|
});
|