feat(scripts): novel details (chapters, free/locked, author, status) for the request page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
masi
2026-09-26 15:50:43 -03:00
parent 0fd7b27d08
commit fed827e8a3
2 changed files with 80 additions and 0 deletions
+52
View File
@@ -6,6 +6,9 @@
// GET /find?q=<title> -> [{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);
}