Initial commit

This commit is contained in:
qwsdcvghyu89
2026-06-17 07:19:25 +10:00
commit 363d894c79
26 changed files with 1805 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from 'fs';
import { parse } from 'yaml';
import path from 'path';
import type { AppConfig, NovelsConfig } from './types.ts';
const CONFIG_DIR = path.resolve(process.cwd(), 'config');
export function loadAppConfig(): AppConfig {
const raw = readFileSync(path.join(CONFIG_DIR, '.config.yaml'), 'utf-8');
const config = parse(raw) as AppConfig;
if (!config.server?.port) throw new Error('Config missing server.port');
if (!config.selenium?.remoteUrl) throw new Error('Config missing selenium.remoteUrl');
if (!config.cache?.basePath) throw new Error('Config missing cache.basePath');
if (!config.addons?.directory) throw new Error('Config missing addons.directory');
return config;
}
export function loadNovelsConfig(): NovelsConfig {
const raw = readFileSync(path.join(CONFIG_DIR, 'novels.yaml'), 'utf-8');
const config = parse(raw) as NovelsConfig;
if (!Array.isArray(config.novels)) throw new Error('novels.yaml must have a top-level "novels" array');
for (const novel of config.novels) {
if (!novel.slug) throw new Error(`Novel entry missing slug: ${JSON.stringify(novel)}`);
if (!novel.type) throw new Error(`Novel "${novel.slug}" missing type`);
if (novel.type !== 'epub' && novel.type !== 'web-scraped') {
throw new Error(`Novel "${novel.slug}" has unknown type "${novel.type}"`);
}
if (!novel.source?.url && !novel.source?.urls?.length) {
throw new Error(`Novel "${novel.slug}" missing source.url or source.urls`);
}
}
return config;
}
+54
View File
@@ -0,0 +1,54 @@
export interface AppConfig {
server: {
port: number;
};
selenium: {
remoteUrl: string;
browser: string;
};
cache: {
basePath: string;
};
addons: {
directory: string;
};
}
export interface TocSelector {
scope: string;
linkPattern: string;
}
export interface ContentSelector {
scope: string;
}
export interface StealthConfig {
enabled: boolean;
waitMs: number;
addons: string[];
}
export interface DownloadConfig {
parallelism: number;
minContentBytes: number;
}
export interface NovelDefinition {
slug: string;
type: 'epub' | 'web-scraped';
source: {
url?: string;
urls?: string[];
};
selectors?: {
toc?: TocSelector;
content?: ContentSelector;
};
stealth?: StealthConfig;
download?: DownloadConfig;
}
export interface NovelsConfig {
novels: NovelDefinition[];
}
+30
View File
@@ -0,0 +1,30 @@
import { Hono } from 'hono';
import { loadAppConfig, loadNovelsConfig } from './config/loader.ts';
import { ResourceRegistry } from './services/registry.ts';
import { Cache } from './services/cache.ts';
import { createNovelRouter, registerNovelFromConfig } from './routes/novel.ts';
const appConfig = loadAppConfig();
const novelsConfig = loadNovelsConfig();
const registry = new ResourceRegistry();
const cache = new Cache(appConfig.cache.basePath);
for (const def of novelsConfig.novels) {
try {
const id = registerNovelFromConfig(def, registry, cache, appConfig);
console.log(`[init] Registered novel "${def.slug}" as id=${id}`);
} catch (err) {
console.error(`[init] Failed to register novel "${def.slug}": ${err instanceof Error ? err.message : err}`);
}
}
const app = new Hono();
app.route('/qa/requests/novel', createNovelRouter(registry, cache, appConfig));
console.log(`[init] Listening on port ${appConfig.server.port}`);
export default {
port: appConfig.server.port,
fetch: app.fetch,
};
+62
View File
@@ -0,0 +1,62 @@
import { readFileSync } from 'fs';
import path from 'path';
import type { ChapterView } from '../types.ts';
const TEMPLATE_PATH = path.resolve(process.cwd(), 'templates', 'book.template.html');
let templateCache: string | null = null;
function getTemplate(): string {
if (!templateCache) templateCache = readFileSync(TEMPLATE_PATH, 'utf-8');
return templateCache;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function readChapterLines(filePath: string): string[] {
try {
const raw = readFileSync(filePath, 'utf-8');
// Both plain-text and JSON chapters are read as plain text for HTML output,
// matching the C# SomeBasicFileView.GetAsHTML() behaviour.
try {
const fragments = JSON.parse(raw) as Array<{ body: string }>;
if (Array.isArray(fragments) && fragments.length > 0 && 'body' in (fragments[0] ?? {})) {
return fragments.map(f => f.body).filter(Boolean);
}
} catch {
// not JSON — fall through to plain text
}
return raw.split(/[\n\r]+/).map(l => l.trim()).filter(Boolean);
} catch {
return [];
}
}
export function renderHtml(view: ChapterView): string {
const template = getTemplate();
const content = view.chapters
.map((chapter, i) => {
const chapNum = view.min + i;
const lines = readChapterLines(chapter.path);
if (lines.length === 0) return '';
const paragraphs = lines
.map((line, j) => ` <p id="para${chapNum}-${j}">${escapeHtml(line)}</p>`)
.join('\n');
return `<div id="chap${chapNum}">\n${paragraphs}\n</div>`;
})
.filter(Boolean)
.join('\n');
return template
.replace('{id}', String(view.id))
.replace('{min}', String(view.min))
.replace('{max}', String(view.max))
.replace('{content}', content);
}
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from 'fs';
import type { ChapterView, TextFragment } from '../types.ts';
interface ChapterRow {
chapter: number;
contents: string[] | TextFragment[];
}
function readChapterContents(filePath: string, hasStyle: boolean): string[] | TextFragment[] {
try {
const raw = readFileSync(filePath, 'utf-8');
if (hasStyle) {
return JSON.parse(raw) as TextFragment[];
}
return raw.split(/[\n\r]+/).map(l => l.trim()).filter(Boolean);
} catch {
return [];
}
}
export function renderJson(view: ChapterView): string {
const rows: ChapterRow[] = view.chapters.map((chapter, i) => ({
chapter: view.min + i,
contents: readChapterContents(chapter.path, chapter.hasStyle),
}));
return JSON.stringify(rows);
}
+180
View File
@@ -0,0 +1,180 @@
import { Hono } from 'hono';
import { createHash } from 'crypto';
import { ResourceRegistry, ResourceConflictError, ResourceNotFoundError } from '../services/registry.ts';
import { Cache } from '../services/cache.ts';
import { fetchEpub } from '../services/epub.ts';
import { fetchToc, fetchChapters } from '../services/scraper.ts';
import { renderHtml } from '../renderers/html.ts';
import { renderJson } from '../renderers/json.ts';
import type { AppConfig } from '../config/types.ts';
export function createNovelRouter(registry: ResourceRegistry, cache: Cache, appConfig: AppConfig) {
const app = new Hono();
// /available MUST be registered before /:novel — otherwise Hono matches "available" as an ID
app.get('/available', c => {
return c.json(registry.getAll());
});
app.get('/:novel', async c => {
const novelParam = c.req.param('novel');
const novelId = parseInt(novelParam, 10);
if (isNaN(novelId)) {
return c.json({ error: 'Invalid novel ID', detail: 'Novel ID must be a non-negative integer' }, 400);
}
const rangeString = c.req.query('r');
if (!rangeString) {
return c.json(
{ error: 'Missing required query parameter', detail: '?r=min,max is required, e.g. ?r=0,10' },
400,
);
}
const parts = rangeString.split(',');
const min = parseInt(parts[0] ?? '', 10);
const max = parseInt(parts[1] ?? '', 10);
if (parts.length !== 2 || isNaN(min) || isNaN(max)) {
return c.json(
{
error: 'Invalid range format',
detail: `Expected ?r=min,max with integer values, got ?r=${rangeString}`,
},
400,
);
}
if (min > max) {
return c.json({ error: 'Invalid range', detail: 'min must be less than or equal to max' }, 400);
}
const format = c.req.query('f') ?? 'html';
let resource;
try {
resource = registry.get(novelId);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return c.json({ error: 'Unknown novel ID', detail: err.message }, 422);
}
throw err;
}
try {
const view = await resource.fetch(min, max);
if (format === 'json') {
return c.body(renderJson(view), 200, { 'Content-Type': 'application/json' });
}
return c.html(renderHtml(view));
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error(`[route] Fetch failed for novel ${novelId} range ${min}-${max}: ${detail}`);
return c.json({ error: 'Failed to fetch or render content', detail }, 500);
}
});
app.post('/new', async c => {
let body: { type?: unknown; slug?: unknown; urls?: unknown };
try {
body = await c.req.json<typeof body>();
} catch {
return c.json({ error: 'Invalid request body', detail: 'Body must be valid JSON' }, 400);
}
if (typeof body.type !== 'string' || !body.type) {
return c.json({ error: 'Missing field', detail: '"type" is required and must be a string' }, 400);
}
if (typeof body.slug !== 'string' || !body.slug) {
return c.json({ error: 'Missing field', detail: '"slug" is required and must be a string' }, 400);
}
if (!Array.isArray(body.urls) || body.urls.length === 0 || typeof body.urls[0] !== 'string') {
return c.json(
{ error: 'Missing field', detail: '"urls" must be a non-empty array of strings' },
400,
);
}
const { type, slug } = body;
const urls = body.urls as string[];
if (type !== 'epub') {
return c.json(
{
error: 'Unsupported type',
detail: `Type "${type}" is not supported. Supported types: epub`,
},
400,
);
}
const hash = createHash('sha256').update(type + urls.join(';')).digest('hex');
const url = urls[0]!;
let registeredId = -1;
try {
registeredId = registry.register({
hash,
slug,
startsAt: 0,
endsAt: -1,
url,
fetch: (from, to) =>
fetchEpub(url, registeredId, from, to, cache, (start, end) =>
registry.updateRange(registeredId, start, end),
),
});
} catch (err) {
if (err instanceof ResourceConflictError) {
return c.json(
{ error: 'Conflict', detail: 'A resource with those parameters is already registered' },
409,
);
}
const detail = err instanceof Error ? err.message : String(err);
return c.json({ error: 'Registration failed', detail }, 500);
}
return c.text(String(registeredId));
});
return app;
}
export function registerNovelFromConfig(
def: import('../config/types.ts').NovelDefinition,
registry: ResourceRegistry,
cache: Cache,
appConfig: AppConfig,
): number {
const sourceUrl = def.source.url ?? def.source.urls?.[0] ?? '';
const hash = createHash('sha256').update(def.slug + sourceUrl).digest('hex');
let registeredId = -1;
if (def.type === 'epub') {
registeredId = registry.register({
hash,
slug: def.slug,
startsAt: 0,
endsAt: -1,
url: sourceUrl,
fetch: (from, to) =>
fetchEpub(sourceUrl, registeredId, from, to, cache, (start, end) =>
registry.updateRange(registeredId, start, end),
),
});
} else {
registeredId = registry.register({
hash,
slug: def.slug,
startsAt: 0,
endsAt: -1,
url: sourceUrl,
fetch: async (from, to) => {
const toc = await fetchToc(def, registeredId, cache, appConfig);
return fetchChapters(def, registeredId, from, toc.slice(from, to + 1), cache);
},
});
}
return registeredId;
}
+81
View File
@@ -0,0 +1,81 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import path from 'path';
import os from 'os';
import type { ChapterFile, ChapterView, TextFragment } from '../types.ts';
const TOC_FILENAME = 'toc.links';
function looksLikeFragments(content: string): boolean {
try {
const parsed = JSON.parse(content);
return (
Array.isArray(parsed) &&
parsed.length > 0 &&
typeof parsed[0] === 'object' &&
parsed[0] !== null &&
'body' in (parsed[0] as object)
);
} catch {
return false;
}
}
export class Cache {
private readonly base: string;
constructor(basePath: string) {
this.base = basePath.replace(/^~/, os.homedir());
}
private chapterPath(id: number, index: number): string {
return path.join(this.base, String(id), String(index));
}
private tocPath(id: number): string {
return path.join(this.base, String(id), TOC_FILENAME);
}
private ensureDir(id: number): void {
const dir = path.join(this.base, String(id));
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
tryGetChapters(id: number, from: number, to: number): ChapterView | null {
if (from > to) return null;
const chapters: ChapterFile[] = [];
for (let i = from; i <= to; i++) {
const p = this.chapterPath(id, i);
if (!existsSync(p)) return null;
const content = readFileSync(p, 'utf-8');
if (content.length < 10) return null;
chapters.push({ path: p, hasStyle: looksLikeFragments(content) });
}
return { id, min: from, max: to, chapters };
}
tryGetToc(id: number): string[] | null {
const p = this.tocPath(id);
if (!existsSync(p)) return null;
const lines = readFileSync(p, 'utf-8')
.split('\n')
.map(l => l.trim())
.filter(Boolean);
if (lines.length === 0) return null;
return lines;
}
saveChapters(id: number, chapters: { index: number; content: string }[]): ChapterFile[] {
this.ensureDir(id);
return chapters.map(({ index, content }) => {
const p = this.chapterPath(id, index);
writeFileSync(p, content, 'utf-8');
return { path: p, hasStyle: looksLikeFragments(content) };
});
}
saveToc(id: number, urls: string[]): void {
this.ensureDir(id);
writeFileSync(this.tocPath(id), urls.join('\n'), 'utf-8');
}
}
+107
View File
@@ -0,0 +1,107 @@
import { unzipSync } from 'fflate';
import * as cheerio from 'cheerio';
import type { Cache } from './cache.ts';
import type { ChapterView } from '../types.ts';
const dec = new TextDecoder();
function parseContainerXml(xml: string): string {
const $ = cheerio.load(xml, { xmlMode: true });
const opfPath = $('rootfile').attr('full-path');
if (!opfPath) throw new Error('EPUB container.xml is missing full-path attribute');
return opfPath;
}
function parseOpfSpine(xml: string): string[] {
const $ = cheerio.load(xml, { xmlMode: true });
const manifest: Record<string, string> = {};
$('manifest item').each((_, el) => {
const id = $(el).attr('id');
const href = $(el).attr('href');
if (id && href) manifest[id] = href;
});
const spine: string[] = [];
$('spine itemref').each((_, el) => {
const idref = $(el).attr('idref');
if (idref) {
const href = manifest[idref];
if (href) spine.push(href);
}
});
return spine;
}
function resolveInZip(opfPath: string, relative: string): string {
const dir = opfPath.includes('/')
? opfPath.slice(0, opfPath.lastIndexOf('/') + 1)
: '';
return dir + decodeURIComponent(relative);
}
function extractDivText(xhtml: string): string {
const $ = cheerio.load(xhtml);
const lines: string[] = [];
$('div').each((_, el) => {
const text = $(el).text().trim();
if (text) lines.push(text);
});
return lines.join('\n');
}
export async function fetchEpub(
url: string,
id: number,
from: number,
to: number,
cache: Cache,
onRangeKnown: (startsAt: number, endsAt: number) => void,
): Promise<ChapterView> {
const cached = cache.tryGetChapters(id, from, to);
if (cached) return cached;
const response = await fetch(url, { redirect: 'follow' });
if (!response.ok) {
throw new Error(`Failed to download EPUB from ${url}: HTTP ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const zip = unzipSync(new Uint8Array(buffer));
const containerEntry = zip['META-INF/container.xml'];
if (!containerEntry) throw new Error('Invalid EPUB: missing META-INF/container.xml');
const opfPath = parseContainerXml(dec.decode(containerEntry));
const opfEntry = zip[opfPath];
if (!opfEntry) throw new Error(`Invalid EPUB: OPF not found at "${opfPath}"`);
const spineHrefs = parseOpfSpine(dec.decode(opfEntry));
if (spineHrefs.length === 0) throw new Error('EPUB spine is empty — no readable chapters found');
const toSave: { index: number; content: string }[] = [];
let chapterIndex = 0;
for (const href of spineHrefs) {
const zipKey = resolveInZip(opfPath, href);
const entry = zip[zipKey];
if (!entry) continue;
const text = extractDivText(dec.decode(entry));
if (!text.trim()) continue;
toSave.push({ index: chapterIndex, content: text });
chapterIndex++;
}
if (toSave.length === 0) throw new Error('EPUB parsed but all chapters were empty');
cache.saveChapters(id, toSave);
onRangeKnown(0, chapterIndex - 1);
const effectiveTo = Math.min(to, chapterIndex - 1);
const view = cache.tryGetChapters(id, from, effectiveTo);
if (!view) throw new Error('Cache read failed immediately after EPUB save — this is a bug');
return view;
}
+52
View File
@@ -0,0 +1,52 @@
import type { Resource, ResourceMeta } from '../types.ts';
export class ResourceConflictError extends Error {
constructor() {
super('A resource with that hash is already registered');
}
}
export class ResourceNotFoundError extends Error {
constructor(id: number) {
super(`No resource found with id ${id}`);
}
}
export class ResourceRegistry {
private readonly byId = new Map<number, Resource>();
private readonly hashes = new Set<string>();
register(resource: Omit<Resource, 'id'>): number {
if (this.hashes.has(resource.hash)) throw new ResourceConflictError();
let id = 0;
while (this.byId.has(id)) id++;
const full: Resource = { ...resource, id };
this.byId.set(id, full);
this.hashes.add(resource.hash);
return id;
}
get(id: number): Resource {
const r = this.byId.get(id);
if (!r) throw new ResourceNotFoundError(id);
return r;
}
getAll(): Record<number, ResourceMeta> {
const out: Record<number, ResourceMeta> = {};
for (const [id, r] of this.byId) {
const { fetch: _fetch, ...meta } = r;
out[id] = meta;
}
return out;
}
updateRange(id: number, startsAt: number, endsAt: number): void {
const r = this.byId.get(id);
if (!r) throw new ResourceNotFoundError(id);
r.startsAt = startsAt;
r.endsAt = endsAt;
}
}
+186
View File
@@ -0,0 +1,186 @@
import { Builder, type WebDriver } from 'selenium-webdriver';
import { Options as FirefoxOptions } from 'selenium-webdriver/firefox.js';
import * as cheerio from 'cheerio';
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import { readFileSync } from 'fs';
import path from 'path';
import type { AppConfig, NovelDefinition } from '../config/types.ts';
import type { Cache } from './cache.ts';
import type { ChapterView, TextFragment, Style } from '../types.ts';
export type DriverFactory = () => Promise<WebDriver>;
function buildDefaultDriverFactory(def: NovelDefinition, appConfig: AppConfig): DriverFactory {
return async () => {
const options = new FirefoxOptions();
for (const addonName of (def.stealth?.addons ?? [])) {
const addonPath = path.resolve(appConfig.addons.directory, addonName);
const addonData = readFileSync(addonPath);
options.addExtensions(addonData);
}
return new Builder()
.usingServer(appConfig.selenium.remoteUrl)
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
};
}
export async function fetchToc(
def: NovelDefinition,
id: number,
cache: Cache,
appConfig: AppConfig,
driverFactory?: DriverFactory,
): Promise<string[]> {
const cached = cache.tryGetToc(id);
if (cached) return cached;
const sourceUrl = def.source.url;
if (!sourceUrl) throw new Error(`Novel "${def.slug}" is missing source.url for TOC fetch`);
const tocCfg = def.selectors?.toc;
if (!tocCfg) throw new Error(`Novel "${def.slug}" is missing selectors.toc`);
if (!def.stealth?.enabled) {
throw new Error(
`Novel "${def.slug}" requires stealth: true — the TOC page cannot be fetched without a real browser and uBlock`,
);
}
const createDriver = driverFactory ?? buildDefaultDriverFactory(def, appConfig);
const driver = await createDriver();
try {
const urls = await pRetry(
async () => {
await driver.get(sourceUrl);
await driver.sleep(def.stealth!.waitMs);
// Get the live rendered DOM (post-JS, post-uBlock)
const html = (await driver.executeScript(
'return document.documentElement.outerHTML',
)) as string;
const $ = cheerio.load(html);
const container = $(tocCfg.scope);
if (!container.length) {
throw new Error(
`TOC container "${tocCfg.scope}" not found on page — page may not have loaded correctly`,
);
}
const pattern = new RegExp(tocCfg.linkPattern);
const found: string[] = [];
container.find('a').each((_, el) => {
const href = $(el).attr('href');
if (href && pattern.test(href)) found.push(href);
});
if (found.length === 0) {
throw new Error(
`No TOC links matching "${tocCfg.linkPattern}" found within "${tocCfg.scope}"`,
);
}
return found;
},
{
retries: 3,
onFailedAttempt: err =>
console.warn(
`[scraper] TOC fetch attempt ${err.attemptNumber}/${err.attemptNumber + err.retriesLeft} failed: ${err.message}`,
),
},
);
cache.saveToc(id, urls);
return urls;
} finally {
await driver.quit();
}
}
const errorStyle: Style = {
display: 'inline',
fontFamily: 'sans-serif',
fontSize: '12pt',
fontStyle: 'regular',
fontWeight: 'bold',
color: 'red',
};
export async function fetchChapters(
def: NovelDefinition,
id: number,
from: number,
tocUrls: string[],
cache: Cache,
): Promise<ChapterView> {
const to = from + tocUrls.length - 1;
const cached = cache.tryGetChapters(id, from, to);
if (cached) return cached;
const contentScope = def.selectors?.content?.scope ?? '#chr-content';
const minBytes = def.download?.minContentBytes ?? 10;
const parallelism = def.download?.parallelism ?? 4;
const limit = pLimit(parallelism);
const chapterResults = await Promise.all(
tocUrls.map((url, i) =>
limit(async (): Promise<{ index: number; content: string }> => {
const index = from + i;
try {
return await pRetry(
async () => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
const html = await response.text();
const $ = cheerio.load(html);
const rawText = $(contentScope).text().trim();
if (rawText.length < minBytes) {
throw new Error(
`Content at "${url}" is too short (${rawText.length} bytes, minimum ${minBytes})`,
);
}
const fragments: TextFragment[] = rawText
.split(/[\n\r]+/)
.map(line => line.trim())
.filter(Boolean)
.map(body => ({ body }));
return { index, content: JSON.stringify(fragments) };
},
{
retries: 2,
onFailedAttempt: err =>
console.warn(
`[scraper] Chapter ${index} attempt ${err.attemptNumber} failed: ${err.message}`,
),
},
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[scraper] Chapter ${index} permanently failed: ${message}`);
const errorFragments: TextFragment[] = [
{ body: `Chapter ${index} failed to download`, style: errorStyle },
{ body: message, style: { ...errorStyle, fontWeight: 'light' } },
];
return { index, content: JSON.stringify(errorFragments) };
}
}),
),
);
const files = cache.saveChapters(id, chapterResults);
return { id, min: from, max: to, chapters: files };
}
+46
View File
@@ -0,0 +1,46 @@
export interface Style {
display: string;
fontFamily: string;
fontSize: string;
fontStyle: string;
fontWeight: string;
color: string;
}
export const defaultStyle: Style = {
display: 'inline-block',
fontFamily: 'sans-serif',
fontSize: '12pt',
fontStyle: 'regular',
fontWeight: 'light',
color: 'white',
};
export interface TextFragment {
body: string;
style?: Style;
}
export interface ChapterFile {
path: string;
hasStyle: boolean;
}
export interface ChapterView {
id: number;
min: number;
max: number;
chapters: ChapterFile[];
}
export interface Resource {
id: number;
hash: string;
slug: string;
startsAt: number;
endsAt: number;
url?: string;
fetch: (from: number, to: number) => Promise<ChapterView>;
}
export type ResourceMeta = Omit<Resource, 'fetch'>;