From 053aa91b0233b76760edc3a780dc3279aa7c1e1e Mon Sep 17 00:00:00 2001 From: masi Date: Fri, 25 Sep 2026 12:31:47 -0300 Subject: [PATCH] feat: find-novel search across all sources; fix WeTriedTLS apostrophe search Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- plugins/english/wetriedtls.ts | 13 +++- scripts/find-novel.js | 122 ++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 scripts/find-novel.js diff --git a/package.json b/package.json index c9b8a5f..91b0986 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "check:sites": "node scripts/check-plugin-sites.js", "check:plugin": "node scripts/live-check-plugin.js", "prepare": "husky", - "publish:gitea": "./scripts/publish-gitea.sh" + "publish:gitea": "./scripts/publish-gitea.sh", + "find": "node scripts/find-novel.js" }, "author": "LNReader", "license": "MIT", diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts index 8b3f492..bddac83 100644 --- a/plugins/english/wetriedtls.ts +++ b/plugins/english/wetriedtls.ts @@ -42,7 +42,7 @@ class WeTriedTLS implements Plugin.PluginBase { name = 'WeTriedTLS'; icon = 'src/en/wetriedtls/icon.png'; site = 'https://wetriedtls.com'; - version = '1.0.0'; + version = '1.0.1'; async api(path: string): Promise { const res = await fetchApi(API + path, { headers: { Referer: this.site } }); @@ -72,7 +72,16 @@ class WeTriedTLS implements Plugin.PluginBase { } searchNovels(searchTerm: string, pageNo: number) { - return this.query(`query_string=${encodeURIComponent(searchTerm)}`, pageNo); + // The API matches literally and titles use curly apostrophes, so search + // with the longest apostrophe-free run of words. + const query = searchTerm + .split(/\S*['’]\S*/) + .map(part => part.trim()) + .reduce((a, b) => (b.length > a.length ? b : a), ''); + return this.query( + `query_string=${encodeURIComponent(query || searchTerm)}`, + pageNo, + ); } async parseNovel(novelPath: string): Promise { diff --git a/scripts/find-novel.js b/scripts/find-novel.js new file mode 100644 index 0000000..525cf33 --- /dev/null +++ b/scripts/find-novel.js @@ -0,0 +1,122 @@ +// Search every plugin of the given languages for a title and list the sources +// that carry it. Usage: node scripts/find-novel.js "" [lang...] +// Languages are plugin folder names (default: english multi). +import * as esbuild from 'esbuild'; +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import path, { dirname } from 'path'; +import fs from 'fs'; +import os from 'os'; + +const require = createRequire(import.meta.url); +const ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const TIMEOUT_MS = 20_000; +const CONCURRENCY = 16; +const PER_SOURCE = 3; + +const [term, ...langArgs] = process.argv.slice(2); +if (!term) { + console.error('Usage: node scripts/find-novel.js "<title>" [lang...]'); + process.exit(2); +} +const langs = langArgs.length ? langArgs : ['english', 'multi']; + +// Multisrc plugins (Madara, NovelFire, ...) are generated, not committed. +execFileSync('node', ['plugins/multisrc/generate-multisrc-plugins.js'], { + cwd: ROOT, + stdio: 'ignore', +}); + +const entryPoints = langs.flatMap(lang => { + const dir = path.join(ROOT, 'plugins', lang); + if (!fs.existsSync(dir)) throw new Error(`No plugin folder: plugins/${lang}`); + return fs + .readdirSync(dir) + .filter(f => f.endsWith('.ts')) + .map(f => path.join(dir, f)); +}); + +const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'find-novel-')); +try { + const result = await esbuild.build({ + entryPoints, + outdir, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + outExtension: { '.js': '.cjs' }, + logLevel: 'silent', + alias: { + '@libs': path.join(ROOT, 'src/libs'), + '@': path.join(ROOT, 'src'), + }, + }); + if (result.errors.length) throw new Error('Plugin bundling failed'); + + const bundles = fs + .readdirSync(outdir, { recursive: true }) + .filter(f => f.endsWith('.cjs')) + .map(f => path.join(outdir, f)); + process.stderr.write( + `Searching ${bundles.length} sources for "${term}"...\n`, + ); + + const hits = []; + let failed = 0; + const queue = [...bundles]; + const worker = async () => { + for (let file; (file = queue.shift());) { + let plugin; + try { + const mod = require(file); + plugin = mod.default ?? mod; + const found = await Promise.race([ + plugin.searchNovels(term, 1), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS), + ), + ]); + for (const novel of (found || []).slice(0, PER_SOURCE)) { + const url = plugin.resolveUrl + ? plugin.resolveUrl(novel.path, true) + : new URL(novel.path, plugin.site).href; + hits.push({ source: plugin.name, name: novel.name, url }); + } + } catch { + failed++; + } + } + }; + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + + // Keep titles containing every word of the query (quotes/punctuation ignored). + const norm = t => + t + .toLowerCase() + .replace(/[’']/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + const words = norm(term).split(' '); + const exact = norm(term); + const matches = hits.filter(h => + words.every(w => norm(h.name).split(' ').includes(w)), + ); + const rank = h => (norm(h.name) === exact ? 0 : 1); + matches.sort((a, b) => rank(a) - rank(b) || a.source.localeCompare(b.source)); + + for (const h of matches) { + console.log( + `${rank(h) === 0 ? '*' : ' '} ${h.source.padEnd(28)} ${h.name}\n ${''.padEnd(28)} ${h.url}`, + ); + } + process.stderr.write( + `${matches.length} matches (* = exact title), ${hits.length - matches.length} loose results hidden; ` + + `${bundles.length - failed}/${bundles.length} sources answered.\n`, + ); +} finally { + fs.rmSync(outdir, { recursive: true, force: true }); +} +// Keep-alive sockets can hold the event loop open; results are already printed. +process.exit(0);