Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'>;
|
||||
Reference in New Issue
Block a user