mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
import { cli, Strategy } from '../../registry.js';
|
|
import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
|
|
|
|
interface SearchHtmlEntry {
|
|
title: string;
|
|
author: string;
|
|
url: string;
|
|
}
|
|
|
|
function decodeHtmlText(value: string): string {
|
|
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 normalizeSearchTitle(value: string): string {
|
|
return value.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function buildSearchIdentity(title: string, author: string): string {
|
|
return `${normalizeSearchTitle(title)}\u0000${normalizeSearchTitle(author)}`;
|
|
}
|
|
|
|
function countSearchTitles(entries: Array<{ title: string }>): Map<string, number> {
|
|
const counts = new Map<string, number>();
|
|
for (const entry of entries) {
|
|
const key = normalizeSearchTitle(entry.title);
|
|
if (!key) continue;
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function countSearchIdentities(entries: Array<{ title: string; author: string }>): Map<string, number> {
|
|
const counts = new Map<string, number>();
|
|
for (const entry of entries) {
|
|
const key = buildSearchIdentity(entry.title, entry.author);
|
|
if (!normalizeSearchTitle(entry.title) || !normalizeSearchTitle(entry.author)) continue;
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function isUniqueCount(counts: Map<string, number>, key: string): boolean {
|
|
return (counts.get(key) || 0) <= 1;
|
|
}
|
|
|
|
/**
|
|
* Build exact and title-only queues separately.
|
|
* Exact title+author matches are preferred; title-only matching is used only
|
|
* when the HTML card did not expose an author field.
|
|
*/
|
|
function buildSearchUrlQueues(entries: SearchHtmlEntry[]): {
|
|
exactQueues: Map<string, string[]>;
|
|
titleOnlyQueues: Map<string, string[]>;
|
|
} {
|
|
const exactQueues = new Map<string, string[]>();
|
|
const titleOnlyQueues = new Map<string, string[]>();
|
|
for (const entry of entries) {
|
|
const titleKey = normalizeSearchTitle(entry.title);
|
|
if (!titleKey || !entry.url) continue;
|
|
const queueMap = entry.author ? exactQueues : titleOnlyQueues;
|
|
const queueKey = entry.author ? buildSearchIdentity(entry.title, entry.author) : titleKey;
|
|
const current = queueMap.get(queueKey);
|
|
if (current) {
|
|
current.push(entry.url);
|
|
continue;
|
|
}
|
|
queueMap.set(queueKey, [entry.url]);
|
|
}
|
|
return { exactQueues, titleOnlyQueues };
|
|
}
|
|
|
|
function resolveSearchResultUrl(params: {
|
|
exactQueues: Map<string, string[]>;
|
|
titleOnlyQueues: Map<string, string[]>;
|
|
apiIdentityCounts: Map<string, number>;
|
|
htmlIdentityCounts: Map<string, number>;
|
|
apiTitleCounts: Map<string, number>;
|
|
htmlTitleCounts: Map<string, number>;
|
|
title: string;
|
|
author: string;
|
|
}): string {
|
|
const {
|
|
exactQueues,
|
|
titleOnlyQueues,
|
|
apiIdentityCounts,
|
|
htmlIdentityCounts,
|
|
apiTitleCounts,
|
|
htmlTitleCounts,
|
|
title,
|
|
author,
|
|
} = params;
|
|
const identityKey = buildSearchIdentity(title, author);
|
|
if (isUniqueCount(apiIdentityCounts, identityKey) && isUniqueCount(htmlIdentityCounts, identityKey)) {
|
|
const exactUrl = exactQueues.get(identityKey)?.shift();
|
|
if (exactUrl) return exactUrl;
|
|
}
|
|
|
|
const titleKey = normalizeSearchTitle(title);
|
|
if (!isUniqueCount(apiTitleCounts, titleKey) || !isUniqueCount(htmlTitleCounts, titleKey)) {
|
|
return '';
|
|
}
|
|
|
|
return titleOnlyQueues.get(titleKey)?.shift() ?? '';
|
|
}
|
|
|
|
/**
|
|
* Extract rendered search result reader URLs from the server-rendered search page.
|
|
* The public JSON API still returns bookId, but the current web app links results
|
|
* through /web/reader/<opaque-id> rather than /web/bookDetail/<bookId>.
|
|
*/
|
|
async function loadSearchHtmlEntries(query: string): Promise<SearchHtmlEntry[]> {
|
|
const url = new URL('/web/search/books', WEREAD_WEB_ORIGIN);
|
|
url.searchParams.set('keyword', query);
|
|
|
|
let html = '';
|
|
try {
|
|
const resp = await fetch(url.toString(), {
|
|
headers: { 'User-Agent': WEREAD_UA },
|
|
});
|
|
if (!resp.ok) return [];
|
|
html = await resp.text();
|
|
} catch {
|
|
return [];
|
|
}
|
|
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] || '';
|
|
const title = decodeHtmlText(titleMatch?.[1] || '');
|
|
const author = decodeHtmlText(authorMatch?.[1] || '');
|
|
|
|
return {
|
|
author,
|
|
url: href ? new URL(href, WEREAD_WEB_ORIGIN).toString() : '',
|
|
title,
|
|
};
|
|
}).filter((item) => item.url && item.title);
|
|
}
|
|
|
|
cli({
|
|
site: 'weread',
|
|
name: 'search',
|
|
description: 'Search books on WeRead',
|
|
domain: 'weread.qq.com',
|
|
strategy: Strategy.PUBLIC,
|
|
browser: false,
|
|
args: [
|
|
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
|
|
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
|
],
|
|
columns: ['rank', 'title', 'author', 'bookId', 'url'],
|
|
func: async (_page, args) => {
|
|
const [data, htmlEntries] = await Promise.all([
|
|
fetchWebApi('/search/global', { keyword: args.query }),
|
|
loadSearchHtmlEntries(String(args.query ?? '')),
|
|
]);
|
|
const books: any[] = data?.books ?? [];
|
|
const { exactQueues, titleOnlyQueues } = buildSearchUrlQueues(htmlEntries);
|
|
const apiIdentityCounts = countSearchIdentities(
|
|
books.map((item: any) => ({
|
|
title: item.bookInfo?.title ?? '',
|
|
author: item.bookInfo?.author ?? '',
|
|
})),
|
|
);
|
|
const htmlIdentityCounts = countSearchIdentities(
|
|
htmlEntries.filter((entry) => entry.author),
|
|
);
|
|
const apiTitleCounts = countSearchTitles(
|
|
books.map((item: any) => ({ title: item.bookInfo?.title ?? '' })),
|
|
);
|
|
const htmlTitleCounts = countSearchTitles(htmlEntries);
|
|
return books.slice(0, Number(args.limit)).map((item: any, i: number) => {
|
|
const title = item.bookInfo?.title ?? '';
|
|
const author = item.bookInfo?.author ?? '';
|
|
return {
|
|
rank: i + 1,
|
|
title,
|
|
author,
|
|
bookId: item.bookInfo?.bookId ?? '',
|
|
url: resolveSearchResultUrl({
|
|
exactQueues,
|
|
titleOnlyQueues,
|
|
apiIdentityCounts,
|
|
htmlIdentityCounts,
|
|
apiTitleCounts,
|
|
htmlTitleCounts,
|
|
title,
|
|
author,
|
|
}),
|
|
};
|
|
});
|
|
},
|
|
});
|