Added new novel strategy
This commit is contained in:
+4
-2
@@ -46,8 +46,10 @@ export interface NovelDefinition {
|
|||||||
slug: string;
|
slug: string;
|
||||||
type: 'epub' | 'web-scraped';
|
type: 'epub' | 'web-scraped';
|
||||||
source: {
|
source: {
|
||||||
url?: string;
|
url?: string; // web-scraped TOC strategy: the table-of-contents page URL
|
||||||
urls?: string[];
|
urls?: string[]; // epub: download URL(s)
|
||||||
|
urlTemplate?: string; // web-scraped increment strategy: template with {n} placeholder, e.g. "https://example.com/chapter-{n}"
|
||||||
|
startIndex?: number; // increment: chapter number of the first {n} value (default 1)
|
||||||
};
|
};
|
||||||
selectors?: {
|
selectors?: {
|
||||||
toc?: TocSelector;
|
toc?: TocSelector;
|
||||||
|
|||||||
+21
-1
@@ -145,7 +145,7 @@ export function registerNovelFromConfig(
|
|||||||
cache: Cache,
|
cache: Cache,
|
||||||
appConfig: AppConfig,
|
appConfig: AppConfig,
|
||||||
): number {
|
): number {
|
||||||
const sourceUrl = def.source.url ?? def.source.urls?.[0] ?? '';
|
const sourceUrl = def.source.url ?? def.source.urls?.[0] ?? def.source.urlTemplate ?? '';
|
||||||
const hash = createHash('sha256').update(def.slug + sourceUrl).digest('hex');
|
const hash = createHash('sha256').update(def.slug + sourceUrl).digest('hex');
|
||||||
|
|
||||||
let registeredId = -1;
|
let registeredId = -1;
|
||||||
@@ -162,7 +162,27 @@ export function registerNovelFromConfig(
|
|||||||
registry.updateRange(registeredId, start, end),
|
registry.updateRange(registeredId, start, end),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
} else if (def.source.urlTemplate) {
|
||||||
|
// Increment strategy: chapter URLs follow a predictable pattern — no TOC fetch needed.
|
||||||
|
// {n} is replaced with the chapter number directly; endsAt is unknown so set to MAX_SAFE_INTEGER.
|
||||||
|
const startIndex = def.source.startIndex ?? 1;
|
||||||
|
const template = def.source.urlTemplate;
|
||||||
|
registeredId = registry.register({
|
||||||
|
hash,
|
||||||
|
slug: def.slug,
|
||||||
|
startsAt: startIndex,
|
||||||
|
endsAt: Number.MAX_SAFE_INTEGER,
|
||||||
|
url: template,
|
||||||
|
fetch: async (from, to) => {
|
||||||
|
const urls = Array.from(
|
||||||
|
{ length: to - from + 1 },
|
||||||
|
(_, i) => template.replace('{n}', String(from + i)),
|
||||||
|
);
|
||||||
|
return fetchChapters(def, registeredId, from, urls, cache);
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// TOC strategy: scrape the table-of-contents page to discover all chapter URLs.
|
||||||
registeredId = registry.register({
|
registeredId = registry.register({
|
||||||
hash,
|
hash,
|
||||||
slug: def.slug,
|
slug: def.slug,
|
||||||
|
|||||||
+80
-3
@@ -1,9 +1,13 @@
|
|||||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { ResourceRegistry } from '../src/services/registry.ts';
|
import { ResourceRegistry } from '../src/services/registry.ts';
|
||||||
import { createNovelRouter } from '../src/routes/novel.ts';
|
import { Cache } from '../src/services/cache.ts';
|
||||||
|
import { createNovelRouter, registerNovelFromConfig } from '../src/routes/novel.ts';
|
||||||
import type { ChapterView } from '../src/types.ts';
|
import type { ChapterView } from '../src/types.ts';
|
||||||
import type { AppConfig } from '../src/config/types.ts';
|
import type { AppConfig, NovelDefinition } from '../src/config/types.ts';
|
||||||
|
|
||||||
// Cache is not exercised by route tests — pass a stub that satisfies the type
|
// Cache is not exercised by route tests — pass a stub that satisfies the type
|
||||||
const stubCache = {} as import('../src/services/cache.ts').Cache;
|
const stubCache = {} as import('../src/services/cache.ts').Cache;
|
||||||
@@ -173,3 +177,76 @@ describe('POST /qa/requests/novel/new', () => {
|
|||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('registerNovelFromConfig — increment strategy', () => {
|
||||||
|
const DEF: NovelDefinition = {
|
||||||
|
slug: 'increment-novel',
|
||||||
|
type: 'web-scraped',
|
||||||
|
source: {
|
||||||
|
urlTemplate: 'https://example.com/chapter-{n}',
|
||||||
|
startIndex: 1,
|
||||||
|
},
|
||||||
|
selectors: { content: { scope: '#content' } },
|
||||||
|
download: { parallelism: 1, minContentBytes: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let incRegistry: ResourceRegistry;
|
||||||
|
let incCache: Cache;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'qa-inc-test-'));
|
||||||
|
incRegistry = new ResourceRegistry();
|
||||||
|
incCache = new Cache(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers with startsAt = startIndex and endsAt = MAX_SAFE_INTEGER', () => {
|
||||||
|
const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig);
|
||||||
|
const res = incRegistry.get(id);
|
||||||
|
expect(res.startsAt).toBe(1);
|
||||||
|
expect(res.endsAt).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(res.slug).toBe('increment-novel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch generates URLs from template and downloads chapters', async () => {
|
||||||
|
const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig);
|
||||||
|
const spy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response('<html><body><div id="content">Chapter text content here.</div></body></html>', { status: 200 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = await incRegistry.get(id).fetch(1, 3);
|
||||||
|
expect(view.min).toBe(1);
|
||||||
|
expect(view.max).toBe(3);
|
||||||
|
expect(view.chapters).toHaveLength(3);
|
||||||
|
|
||||||
|
const urls = spy.mock.calls.map(c => c[0] as string);
|
||||||
|
expect(urls).toContain('https://example.com/chapter-1');
|
||||||
|
expect(urls).toContain('https://example.com/chapter-2');
|
||||||
|
expect(urls).toContain('https://example.com/chapter-3');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch result is served from cache on second call', async () => {
|
||||||
|
const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig);
|
||||||
|
const spy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response('<html><body><div id="content">Cached chapter content.</div></body></html>', { status: 200 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await incRegistry.get(id).fetch(1, 2);
|
||||||
|
spy.mockReset();
|
||||||
|
|
||||||
|
await incRegistry.get(id).fetch(1, 2);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user