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:
@@ -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 <plugin-id> <novel-path> -> 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 <plugin-id> <novel-path>');
|
||||||
|
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);
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
// GET /find?q=<title> -> [{source, id, name, path, url, exact}] (cached 10 min)
|
// GET /find?q=<title> -> [{source, id, name, path, url, exact}] (cached 10 min)
|
||||||
// POST /fetch {id, path, name} -> job; one download at a time
|
// POST /fetch {id, path, name} -> job; one download at a time
|
||||||
// GET /jobs -> recent jobs, newest first
|
// 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.
|
// Nightly (UPDATE_HOUR, local time) update-novels.js appends new free chapters.
|
||||||
// Env: LIBRARY (novels dir, writable), PORT, NOVEL_PLUGIN_CACHE, UPDATE_HOUR.
|
// Env: LIBRARY (novels dir, writable), PORT, NOVEL_PLUGIN_CACHE, UPDATE_HOUR.
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
@@ -20,9 +23,36 @@ const LIBRARY = process.env.LIBRARY || '/library';
|
|||||||
const PORT = Number(process.env.PORT || 8090);
|
const PORT = Number(process.env.PORT || 8090);
|
||||||
const UPDATE_HOUR = Number(process.env.UPDATE_HOUR || 4);
|
const UPDATE_HOUR = Number(process.env.UPDATE_HOUR || 4);
|
||||||
const FIND_TTL_MS = 10 * 60 * 1000;
|
const FIND_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const INFO_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const INFO_CONCURRENCY = 3;
|
||||||
const MAX_JOBS = 50;
|
const MAX_JOBS = 50;
|
||||||
|
|
||||||
const found = new Map(); // query -> {at, results}
|
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
|
const jobs = []; // newest first
|
||||||
let running = null;
|
let running = null;
|
||||||
|
|
||||||
@@ -147,6 +177,28 @@ http
|
|||||||
found.set(q.toLowerCase(), { at: Date.now(), results });
|
found.set(q.toLowerCase(), { at: Date.now(), results });
|
||||||
return send(res, 200, 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') {
|
if (req.method === 'GET' && url.pathname === '/jobs') {
|
||||||
return send(res, 200, jobs);
|
return send(res, 200, jobs);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user