From fed827e8a31617d2c85f5ee5005333187cf4df72 Mon Sep 17 00:00:00 2001 From: masi Date: Sat, 26 Sep 2026 15:50:43 -0300 Subject: [PATCH] feat(scripts): novel details (chapters, free/locked, author, status) for the request page Co-Authored-By: Claude Opus 5.5 --- scripts/novel-info.js | 28 +++++++++++++++++++++ scripts/request-server.js | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 scripts/novel-info.js diff --git a/scripts/novel-info.js b/scripts/novel-info.js new file mode 100644 index 0000000..50df4ac --- /dev/null +++ b/scripts/novel-info.js @@ -0,0 +1,28 @@ +// Details of one novel on one source, for the request page: chapter counts (free vs +// locked 🔒), latest chapter, author, status, genres, summary and cover. +// Usage: node scripts/novel-info.js -> one JSON line +import { loadPlugin, print } from './load-plugins.js'; + +const [pluginId, novelPath] = process.argv.slice(2); +if (!pluginId || !novelPath) { + console.error('Usage: node scripts/novel-info.js '); + process.exit(2); +} +const plugin = await loadPlugin(pluginId); +const novel = await plugin.parseNovel(novelPath); +const chapters = novel.chapters || []; +const firstLocked = chapters.findIndex(c => c.name.includes('🔒')); +print( + JSON.stringify({ + name: novel.name, + cover: novel.cover || null, + author: novel.author || null, + status: novel.status || null, + genres: novel.genres || null, + summary: (novel.summary || '').slice(0, 1200), + chapters: chapters.length, + free: firstLocked < 0 ? chapters.length : firstLocked, + latest: chapters.length ? chapters[chapters.length - 1].name : null, + }), +); +process.exit(0); diff --git a/scripts/request-server.js b/scripts/request-server.js index 7fe3228..3e0ead6 100644 --- a/scripts/request-server.js +++ b/scripts/request-server.js @@ -6,6 +6,9 @@ // GET /find?q= -> [{source, id, name, path, url, exact}] (cached 10 min) // POST /fetch {id, path, name} -> job; one download at a time // GET /jobs -> recent jobs, newest first +// GET /info?id=&path= -> {name, cover, author, status, genres, summary, +// chapters, free, latest} (cached 1 h, 3 at a time) +// GET /library -> titles already in the library // Nightly (UPDATE_HOUR, local time) update-novels.js appends new free chapters. // Env: LIBRARY (novels dir, writable), PORT, NOVEL_PLUGIN_CACHE, UPDATE_HOUR. import { spawn } from 'child_process'; @@ -20,9 +23,36 @@ const LIBRARY = process.env.LIBRARY || '/library'; const PORT = Number(process.env.PORT || 8090); const UPDATE_HOUR = Number(process.env.UPDATE_HOUR || 4); const FIND_TTL_MS = 10 * 60 * 1000; +const INFO_TTL_MS = 60 * 60 * 1000; +const INFO_CONCURRENCY = 3; const MAX_JOBS = 50; const found = new Map(); // query -> {at, results} +const infos = new Map(); // id\npath -> {at, info} +let infoRunning = 0; +const infoWaiting = []; + +// At most INFO_CONCURRENCY detail lookups at once: each is a child process and a request +// to the source site. +async function info(id, novelPath) { + const cacheKey = `${id}\n${novelPath}`; + const hit = infos.get(cacheKey); + if (hit && Date.now() - hit.at < INFO_TTL_MS) return hit.info; + if (infoRunning >= INFO_CONCURRENCY) + await new Promise(resolve => infoWaiting.push(resolve)); + infoRunning++; + try { + const { code, out, err } = await run('novel-info.js', [id, novelPath]); + if (code !== 0) + throw new Error(err.trim().split('\n').pop() || `exit ${code}`); + const result = JSON.parse(out.trim().split('\n').pop()); + infos.set(cacheKey, { at: Date.now(), info: result }); + return result; + } finally { + infoRunning--; + infoWaiting.shift()?.(); + } +} const jobs = []; // newest first let running = null; @@ -147,6 +177,28 @@ http found.set(q.toLowerCase(), { at: Date.now(), results }); return send(res, 200, results); } + if (req.method === 'GET' && url.pathname === '/info') { + const id = url.searchParams.get('id') || ''; + const novelPath = url.searchParams.get('path') || ''; + if (!id || !novelPath) + return send(res, 400, { error: 'id and path are required' }); + try { + return send(res, 200, await info(id, novelPath)); + } catch (e) { + return send(res, 502, { + error: String(e.message || e).slice(0, 300), + }); + } + } + if (req.method === 'GET' && url.pathname === '/library') { + const titles = fs.existsSync(LIBRARY) + ? fs + .readdirSync(LIBRARY, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + : []; + return send(res, 200, titles); + } if (req.method === 'GET' && url.pathname === '/jobs') { return send(res, 200, jobs); }