diff --git a/src/config/types.ts b/src/config/types.ts index d9dca0c..772cc45 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -46,8 +46,10 @@ export interface NovelDefinition { slug: string; type: 'epub' | 'web-scraped'; source: { - url?: string; - urls?: string[]; + url?: string; // web-scraped TOC strategy: the table-of-contents page URL + 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?: { toc?: TocSelector; diff --git a/src/routes/novel.ts b/src/routes/novel.ts index 40c1796..699fa60 100644 --- a/src/routes/novel.ts +++ b/src/routes/novel.ts @@ -145,7 +145,7 @@ export function registerNovelFromConfig( cache: Cache, appConfig: AppConfig, ): 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'); let registeredId = -1; @@ -162,7 +162,27 @@ export function registerNovelFromConfig( 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 { + // TOC strategy: scrape the table-of-contents page to discover all chapter URLs. registeredId = registry.register({ hash, slug: def.slug, diff --git a/tests/routes.test.ts b/tests/routes.test.ts index 8870a76..6d63e19 100644 --- a/tests/routes.test.ts +++ b/tests/routes.test.ts @@ -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 { 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 { 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 const stubCache = {} as import('../src/services/cache.ts').Cache; @@ -173,3 +177,76 @@ describe('POST /qa/requests/novel/new', () => { 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('
Chapter text content here.
', { 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('
Cached chapter content.
', { status: 200 }), + ), + ); + + await incRegistry.get(id).fetch(1, 2); + spy.mockReset(); + + await incRegistry.get(id).fetch(1, 2); + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); +});