Files
jackwener__opencli/clis/weread/search.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* 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
2026-04-05 01:46:36 +08:00

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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/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,
}),
};
});
},
});