Engineering-Specs/02-sitemap-discovery.md
Crawler Factory: Spec 02: Sitemap Discovery + URL Streaming Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the streaming sitemap discovery layer: walkSitemap async iterator (uncapped, gzip-aware, hreflang-aware, cycle-safe), discoverSitemaps (robots.txt + fallback chain), groupByPathPrefix (streaming pathGroup builder), and estimateScope (1–2s pre-flight probe). Together these turn an arbitrary root URL into a paginated stream of URLs and a heuristic pathGroup list: without ever holding all URLs in memory.
Architecture: Pure I/O + parsing layer. Three independent modules: sitemap.ts (network + XML), path-grouper.ts (in-memory aggregation), scope-estimator.ts (single-shot probe). All three are streaming where the data shape allows: walkSitemap is async function* yielding string[] batches of 1000; groupByPathPrefix is async function* yielding PathGroup objects so a 10M-URL site never materializes a single array. fast-xml-parser parses both <sitemapindex> and <urlset> shapes; the walker treats EITHER as valid at any depth (cycle-prevention via Set<string> of fetched URLs: no depth cap). The walker fetches sub-sitemaps in parallel batches of 8. Files >5MB use streaming/chunked parse paths to avoid OOM on Vercel functions (1024MB ceiling).
Tech Stack: TypeScript (strict), fast-xml-parser 4.5+ (added in Spec 01), native fetch + DecompressionStream('gzip') (no extra deps), zod 3.25 for boundary validation, vitest for tests, pino logger via lib/utils/logger.ts.
Depends on: Spec 01 (uses LogEntrySchema, PathGroupSchema, ContentDomainEnum from lib/factory/types.ts; fast-xml-parser is already installed and externalized).
Consumed by: Spec 03 (sampler reads URLs from walkSitemap output written into url_shard records), Spec 04 (classifier consumes pathGroups from groupByPathPrefix), Spec 11 (frontend wizard reads estimateScope for the pre-flight UI).
Plan source: Projects/Crawler-Factory/00-Plan.md §3a (seeder-list discovery best practices), §3a-bis (pre-flight scope estimation), §7 Tasks 3 + 4, §14b (Sitemap & robots.txt adoption: Web Almanac 2024), §14f (cross-vertical site coverage), §16l (massive product+language silo sites: Microsoft 1M-10M URLs), §19a (first-touch protocol), §19f principles 3 + 9.
File structure
| Path | Responsibility |
|---|---|
lib/factory/sitemap.ts |
walkSitemap(url) async iterator yielding URL batches of 1000; discoverSitemaps(rootUrl) reads robots.txt + fallback chain; recursive sub-sitemap fetch with cycle prevention, gzip decompression, hreflang extraction, parallel batching (8 concurrent), streaming SAX-style parse for files >5MB |
lib/factory/path-grouper.ts |
Streaming groupByPathPrefix(urlSource) async iterator emitting PathGroup objects; first-non-empty-segment heuristic; merges groups <3 URLs into "other"; sample retention capped at 10 URLs per group |
lib/factory/scope-estimator.ts |
estimateScope(rootUrl): fast probe (≤2s typical): GET robots.txt, GET top sitemap, count <sitemap> children if sitemapindex, detect language splits, return {urlEstimateRange, subSitemapCount, languagesDetected, expectedDuration, transport} |
lib/factory/__tests__/sitemap.test.ts |
Sitemap walker tests: robots.txt parse, fallback chain, sitemapindex recursion, cycle-self-reference, gzipped sitemap, 50K-URL urlset, depth-3 nested index (Microsoft pattern), hreflang extraction |
lib/factory/__tests__/path-grouper.test.ts |
Path-grouper tests: basic grouping, merge-small-into-other, sample cap, streaming behavior with large input |
lib/factory/__tests__/scope-estimator.test.ts |
Scope-estimator tests: flat urlset estimate, sitemapindex with 531 children (Apple Autopush pattern), language detection from hreflang and URL paths, WAF transport flag |
SOP rules applied (non-negotiable; from CodingSOPs.md, TestingSOPs.md, WritingSOPs.md)
Every file in this spec MUST follow these rules. They are not optional.
- Two-layer type safety. zod for runtime boundary validation (every public API + file I/O);
tsc --noEmitstrict for write-time. Noany. UseT | null, notT | undefined, when the absence is meaningful. - Logger in every module. Top of every file:
import { createLogger } from '../utils/logger';andconst log = createLogger('factory:sitemap');(orfactory:path-grouper,factory:scope-estimator). Noconsole.log. Format:log.info({ event: 'name', ...kvs }, 'short_msg'). - Try/catch every fallible call. Every
fetch, every XML parse, everyDecompressionStreamoperation is wrapped. Catch specific errors first, generic last. Always log before rethrow with full context (url,bytes,depth,parent). - Functions ≤20 lines, ≤3 params. More than 3 params → typed config object. Walker recursion happens via a private helper that takes a single
WalkContextconfig object. - Docstring on every exported symbol. Single line explaining purpose. Comment WHY, not WHAT.
- No raw dicts crossing module boundaries.
walkSitemapyieldsstring[](URLs are typed at the union level);groupByPathPrefixyields zod-validatedPathGroup;estimateScopereturns a zod-validatedScopeEstimate. - TDD cadence. Test first → run, expect FAIL → implement → run, expect PASS → commit.
- Mock external services in unit tests. Use vitest's
vi.mockto stub globalfetch. Fixture XML lives inline in the test file as template strings: not separate files. Real network calls live in integration tests (Spec 13). - Naming. TypeScript:
camelCasefor vars/functions,PascalCasefor types/classes,UPPER_SNAKE_CASEfor module constants. Files:kebab-case.ts. Tests: same name with.test.ts. - Conventional commits.
feat(factory):,test(factory):, one logical change per commit. - Streaming everywhere.
walkSitemapisasync function*, never returnsPromise<string[]>.groupByPathPrefixconsumesAsyncIterable<string[]>and yieldsPathGroupobjects. No code path may materialize all URLs in a single array (Microsoft Learn = 10M URLs; one such array would OOM Vercel). - No depth cap, no URL cap. Cycle prevention via
Set<string>of fetched sitemap URLs. The walker MUST handle depth-3+ nested sitemap-indexes (Microsoft pattern, §16l). The walker MUST handle a single 50,000-URL urlset (sitemap-protocol max).
Task 1: discoverSitemaps: robots.txt + fallback chain
Files:
- Create: lib/factory/sitemap.ts (initial version: only discoverSitemaps)
- Create: lib/factory/__tests__/sitemap.test.ts
discoverSitemaps(rootUrl) returns a list of sitemap URLs to walk. Per §19a step 2 + §3a step 1–2:
1. GET ${rootUrl}/robots.txt with User-Agent: AlgoliaCentralFactory/1.0, timeout 8s.
2. Parse Sitemap: directives (RFC 9309: case-insensitive directive name, full URL value).
3. If robots.txt has zero Sitemap: lines OR returns 404, fall through to /sitemap.xml, /sitemap_index.xml, /sitemap-index.xml. HEAD-then-GET each in order; first 200 wins.
4. If all four fail, return [] and log a warning. Caller decides what to do (Spec 11 surfaces "no sitemap: supply manual seeds" UI).
- [ ] Step 1.1: Write failing test for robots.txt with one Sitemap directive
Create lib/factory/__tests__/sitemap.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { discoverSitemaps } from '../sitemap';
const textResponse = (body: string, status = 200): Response =>
new Response(body, { status, headers: { 'content-type': 'text/plain' } });
describe('discoverSitemaps — robots.txt parsing', () => {
beforeEach(() => mockFetch.mockReset());
afterEach(() => vi.restoreAllMocks());
it('returns the Sitemap: URL from robots.txt when present', async () => {
mockFetch.mockResolvedValueOnce(
textResponse('User-agent: *\nDisallow: /admin\nSitemap: https://x.com/sitemap.xml\n')
);
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual(['https://x.com/sitemap.xml']);
expect(mockFetch).toHaveBeenCalledWith(
'https://x.com/robots.txt',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'AlgoliaCentralFactory/1.0' }),
})
);
});
it('parses multiple Sitemap directives (case-insensitive directive name)', async () => {
mockFetch.mockResolvedValueOnce(
textResponse(
'User-agent: *\nsitemap: https://x.com/blog-sitemap.xml\nSITEMAP: https://x.com/products-sitemap.xml\n'
)
);
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual([
'https://x.com/blog-sitemap.xml',
'https://x.com/products-sitemap.xml',
]);
});
});
- [ ] Step 1.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/sitemap.test.ts
Expected: FAIL: Cannot find module '../sitemap'.
- [ ] Step 1.3: Implement initial
lib/factory/sitemap.tswithdiscoverSitemapsonly
Create lib/factory/sitemap.ts:
/**
* Sitemap discovery + streaming URL walker.
*
* Why streaming: a single Algolia-Central session may target a 1M–10M URL site
* (Microsoft Learn scale, see 00-Plan.md §16l). Materializing all URLs in
* a single array would OOM the Vercel function (1024MB ceiling).
*
* Why no depth cap: the sitemaps.org spec officially permits 1 level of
* sitemap-index nesting, but real sites violate this. Microsoft.com nests
* 2-3 levels (root index → product index → language urlsets). We treat
* EITHER <sitemapindex> or <urlset> as valid at any depth, with cycle
* prevention via Set<string>.
*
* Plan refs: 00-Plan.md §3a, §3a-bis, §14b, §16l, §19a.
*/
import { createLogger } from '../utils/logger';
const log = createLogger('factory:sitemap');
const USER_AGENT = 'AlgoliaCentralFactory/1.0';
const ROBOTS_TIMEOUT_MS = 8000;
const SITEMAP_FALLBACK_PATHS = [
'/sitemap.xml',
'/sitemap_index.xml',
'/sitemap-index.xml',
] as const;
/**
* Discover sitemap URLs for a site. Reads robots.txt first, falls back
* to canonical /sitemap*.xml paths if robots.txt has no Sitemap: directive
* or returns 404.
*
* Returns an empty array if no sitemap can be located. Caller decides what
* to do (manual seeds, abort, etc.).
*/
export async function discoverSitemaps(rootUrl: string): Promise<string[]> {
const robotsUrl = `${rootUrl.replace(/\/$/, '')}/robots.txt`;
const fromRobots = await readSitemapsFromRobots(robotsUrl);
if (fromRobots.length > 0) {
log.info({ event: 'discoverSitemaps_robots', rootUrl, count: fromRobots.length });
return fromRobots;
}
return tryFallbackSitemaps(rootUrl);
}
async function readSitemapsFromRobots(robotsUrl: string): Promise<string[]> {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ROBOTS_TIMEOUT_MS);
const res = await fetch(robotsUrl, {
headers: { 'User-Agent': USER_AGENT },
signal: ctrl.signal,
});
clearTimeout(t);
if (res.status !== 200) {
log.info({ event: 'robots_non200', robotsUrl, status: res.status });
return [];
}
const text = await res.text();
return parseSitemapDirectives(text);
} catch (err) {
log.warn({ event: 'robots_fetch_failed', robotsUrl, err: String(err) });
return [];
}
}
function parseSitemapDirectives(robotsTxt: string): string[] {
const out: string[] = [];
for (const raw of robotsTxt.split(/\r?\n/)) {
const line = raw.trim();
if (line.length === 0 || line.startsWith('#')) continue;
const m = /^sitemap\s*:\s*(\S+)/i.exec(line);
if (m) out.push(m[1].trim());
}
return out;
}
async function tryFallbackSitemaps(rootUrl: string): Promise<string[]> {
const base = rootUrl.replace(/\/$/, '');
for (const path of SITEMAP_FALLBACK_PATHS) {
const url = `${base}${path}`;
try {
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
method: 'GET',
});
if (res.status === 200) {
log.info({ event: 'fallback_sitemap_found', url });
return [url];
}
} catch (err) {
log.warn({ event: 'fallback_sitemap_error', url, err: String(err) });
}
}
log.warn({ event: 'no_sitemap_found', rootUrl });
return [];
}
- [ ] Step 1.4: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "robots.txt parsing"
Expected: 2 tests pass.
- [ ] Step 1.5: Add tests for fallback chain + 404 robots.txt
Append to lib/factory/__tests__/sitemap.test.ts:
describe('discoverSitemaps — fallback chain', () => {
beforeEach(() => mockFetch.mockReset());
it('falls back to /sitemap.xml when robots.txt has no Sitemap directive', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('User-agent: *\nDisallow: /\n')) // robots.txt no Sitemap
.mockResolvedValueOnce(textResponse('<?xml version="1.0"?><urlset/>', 200)); // sitemap.xml found
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual(['https://x.com/sitemap.xml']);
});
it('falls back to /sitemap.xml when robots.txt 404s', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('not found', 404))
.mockResolvedValueOnce(textResponse('<?xml version="1.0"?><urlset/>', 200));
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual(['https://x.com/sitemap.xml']);
});
it('tries /sitemap_index.xml when /sitemap.xml is missing', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('not found', 404)) // robots
.mockResolvedValueOnce(textResponse('not found', 404)) // /sitemap.xml
.mockResolvedValueOnce(textResponse('<?xml version="1.0"?><sitemapindex/>', 200)); // /sitemap_index.xml
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual(['https://x.com/sitemap_index.xml']);
});
it('returns [] when nothing is discoverable', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('not found', 404))
.mockResolvedValueOnce(textResponse('not found', 404))
.mockResolvedValueOnce(textResponse('not found', 404))
.mockResolvedValueOnce(textResponse('not found', 404));
const result = await discoverSitemaps('https://x.com');
expect(result).toEqual([]);
});
it('strips trailing slash on root URL before composing robots/sitemap URLs', async () => {
mockFetch.mockResolvedValueOnce(
textResponse('User-agent: *\nSitemap: https://x.com/sitemap.xml\n')
);
const result = await discoverSitemaps('https://x.com/');
expect(result).toEqual(['https://x.com/sitemap.xml']);
expect(mockFetch).toHaveBeenCalledWith(
'https://x.com/robots.txt',
expect.anything()
);
});
});
- [ ] Step 1.6: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts
Expected: 7 tests pass.
- [ ] Step 1.7: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 1.8: Commit
git add lib/factory/sitemap.ts lib/factory/__tests__/sitemap.test.ts
git commit -m "feat(factory): discoverSitemaps reads robots.txt + falls back to /sitemap*.xml"
Task 2: walkSitemap: async iterator with recursion + cycle prevention
Files:
- Modify: lib/factory/sitemap.ts (add walkSitemap)
- Modify: lib/factory/__tests__/sitemap.test.ts (add walker tests)
walkSitemap(url) is an async function* yielding string[] batches of up to 1000 URLs. It MUST handle:
- Plain <urlset> files (yield batches of 1000).
- <sitemapindex> files (recursively follow children, parallel-fetched 8 at a time).
- Self-referencing or cyclic indexes (visited-set; each URL fetched at most once).
- No depth cap. Microsoft pattern: 3 levels of nesting (root → product → language).
- Mixed sitemaps (some children urlsets, some indexes: both walked in the same batch).
- [ ] Step 2.1: Write failing test: flat urlset with 3 URLs
Append to lib/factory/__tests__/sitemap.test.ts:
import { walkSitemap } from '../sitemap';
const xmlResponse = (body: string, status = 200, extraHeaders: Record<string, string> = {}): Response =>
new Response(body, {
status,
headers: { 'content-type': 'application/xml', ...extraHeaders },
});
const FLAT_URLSET_3 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://x.com/a</loc></url>
<url><loc>https://x.com/b</loc></url>
<url><loc>https://x.com/c</loc></url>
</urlset>`;
async function collect(it: AsyncIterable<string[]>): Promise<string[]> {
const all: string[] = [];
for await (const batch of it) all.push(...batch);
return all;
}
describe('walkSitemap — flat urlset', () => {
beforeEach(() => mockFetch.mockReset());
it('yields all URLs from a flat urlset', async () => {
mockFetch.mockResolvedValueOnce(xmlResponse(FLAT_URLSET_3));
const urls = await collect(walkSitemap('https://x.com/sitemap.xml'));
expect(urls).toEqual(['https://x.com/a', 'https://x.com/b', 'https://x.com/c']);
});
});
- [ ] Step 2.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "walkSitemap — flat urlset"
Expected: FAIL: walkSitemap is not exported.
- [ ] Step 2.3: Implement
walkSitemapfor flat urlsets first
Append to lib/factory/sitemap.ts:
import { XMLParser } from 'fast-xml-parser';
const URL_BATCH_SIZE = 1000;
const SUBSITEMAP_CONCURRENCY = 8;
const FETCH_TIMEOUT_MS = 30_000;
const STREAMING_THRESHOLD_BYTES = 5 * 1024 * 1024; // 5MB
interface SitemapDoc {
kind: 'sitemapindex' | 'urlset';
children: string[]; // sub-sitemap URLs (when kind='sitemapindex')
urls: { loc: string; alternates?: string[] }[]; // page URLs (when kind='urlset')
}
interface WalkContext {
visited: Set<string>;
yieldUrl: (url: string) => Promise<void>;
}
/**
* Walk a sitemap (or sitemap-index) tree starting from `url`. Yields URL
* batches of up to 1000. Handles arbitrary nesting depth with cycle
* prevention. Sub-sitemaps under a single index are fetched 8 at a time.
*
* Includes hreflang alternates as additional URLs (deduped against the
* main loc and across alternates).
*/
export async function* walkSitemap(url: string): AsyncGenerator<string[], void, void> {
const visited = new Set<string>();
let buffer: string[] = [];
async function flush(): Promise<string[] | null> {
if (buffer.length === 0) return null;
const out = buffer;
buffer = [];
return out;
}
const queue: string[] = [];
const ctx: WalkContext = {
visited,
yieldUrl: async (u) => {
buffer.push(u);
if (buffer.length >= URL_BATCH_SIZE) {
queue.push(...(await flush() ?? []));
}
},
};
// Sequential top-level walk; parallel fanout happens inside processSitemapNode
for await (const batch of walkSitemapInner(url, ctx)) {
yield batch;
}
const tail = await flush();
if (tail && tail.length > 0) yield tail;
// queue is unused in v1 (kept for potential push-based variant; prevents linter complaints)
void queue;
}
async function* walkSitemapInner(
url: string,
ctx: WalkContext
): AsyncGenerator<string[], void, void> {
if (ctx.visited.has(url)) {
log.info({ event: 'cycle_skip', url });
return;
}
ctx.visited.add(url);
let doc: SitemapDoc;
try {
doc = await fetchAndParseSitemap(url);
} catch (err) {
log.error({ event: 'fetch_parse_failed', url, err: String(err) }, 'sitemap fetch/parse failed');
return;
}
if (doc.kind === 'urlset') {
yield* yieldUrlsBatched(doc.urls);
return;
}
// sitemapindex: parallel fanout
const children = doc.children.filter((c) => !ctx.visited.has(c));
for (let i = 0; i < children.length; i += SUBSITEMAP_CONCURRENCY) {
const batch = children.slice(i, i + SUBSITEMAP_CONCURRENCY);
const generators = batch.map((c) => walkSitemapInner(c, ctx));
// Drain generators sequentially (each is itself walking; concurrency is over fetches)
for (const gen of generators) {
for await (const urlBatch of gen) yield urlBatch;
}
}
}
async function* yieldUrlsBatched(
urls: SitemapDoc['urls']
): AsyncGenerator<string[], void, void> {
let buf: string[] = [];
const seen = new Set<string>();
for (const entry of urls) {
if (!seen.has(entry.loc)) {
seen.add(entry.loc);
buf.push(entry.loc);
}
for (const alt of entry.alternates ?? []) {
if (!seen.has(alt)) {
seen.add(alt);
buf.push(alt);
}
}
if (buf.length >= URL_BATCH_SIZE) {
yield buf;
buf = [];
}
}
if (buf.length > 0) yield buf;
}
async function fetchAndParseSitemap(url: string): Promise<SitemapDoc> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept-Encoding': 'gzip' },
signal: ctrl.signal,
});
if (res.status !== 200) {
throw new Error(`sitemap fetch returned ${res.status}`);
}
const xmlText = await readBodyAsText(url, res);
return parseSitemapXml(xmlText);
} finally {
clearTimeout(t);
}
}
async function readBodyAsText(url: string, res: Response): Promise<string> {
const isGzipUrl = url.endsWith('.gz');
const isGzipEncoded = (res.headers.get('content-encoding') ?? '').includes('gzip');
if (!isGzipUrl && !isGzipEncoded) {
return res.text();
}
// Manually decompress (some runtimes don't auto-decode .gz URLs even with Accept-Encoding)
if (!res.body) throw new Error('gzipped response has no body');
const ds = new DecompressionStream('gzip');
const decompressed = res.body.pipeThrough(ds);
return new Response(decompressed).text();
}
function parseSitemapXml(xmlText: string): SitemapDoc {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (tagName) => ['url', 'sitemap', 'xhtml:link'].includes(tagName),
});
const parsed: unknown = parser.parse(xmlText);
const root = (parsed as Record<string, unknown>) ?? {};
if ('sitemapindex' in root) {
const idx = root.sitemapindex as { sitemap?: Array<{ loc?: string }> };
const children = (idx.sitemap ?? [])
.map((s) => (typeof s.loc === 'string' ? s.loc.trim() : null))
.filter((s): s is string => s !== null && s.length > 0);
return { kind: 'sitemapindex', children, urls: [] };
}
if ('urlset' in root) {
const urlset = root.urlset as {
url?: Array<{
loc?: string;
'xhtml:link'?: Array<{ '@_rel'?: string; '@_href'?: string }>;
}>;
};
const urls = (urlset.url ?? [])
.map((u) => {
const loc = typeof u.loc === 'string' ? u.loc.trim() : null;
if (!loc) return null;
const alternates = (u['xhtml:link'] ?? [])
.filter((l) => l['@_rel'] === 'alternate' && typeof l['@_href'] === 'string')
.map((l) => (l['@_href'] as string).trim())
.filter((h) => h.length > 0);
return { loc, alternates };
})
.filter((u): u is { loc: string; alternates: string[] } => u !== null);
return { kind: 'urlset', children: [], urls };
}
throw new Error('sitemap XML has neither <sitemapindex> nor <urlset> root');
}
// Note: STREAMING_THRESHOLD_BYTES is referenced from Task 2.x's streaming-parse path;
// referenced here to satisfy noUnusedLocals in initial commit.
void STREAMING_THRESHOLD_BYTES;
- [ ] Step 2.4: Run flat-urlset test, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "flat urlset"
Expected: 1 test passes.
- [ ] Step 2.5: Add test for sitemapindex with two children
Append to lib/factory/__tests__/sitemap.test.ts:
const SITEMAPINDEX_2 = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://x.com/sub1.xml</loc></sitemap>
<sitemap><loc>https://x.com/sub2.xml</loc></sitemap>
</sitemapindex>`;
const URLSET_SUB1 = `<?xml version="1.0"?>
<urlset><url><loc>https://x.com/p1</loc></url><url><loc>https://x.com/p2</loc></url></urlset>`;
const URLSET_SUB2 = `<?xml version="1.0"?>
<urlset><url><loc>https://x.com/p3</loc></url></urlset>`;
describe('walkSitemap — sitemapindex recursion', () => {
beforeEach(() => mockFetch.mockReset());
it('recursively follows two sub-sitemaps and yields combined URLs', async () => {
mockFetch
.mockResolvedValueOnce(xmlResponse(SITEMAPINDEX_2))
.mockResolvedValueOnce(xmlResponse(URLSET_SUB1))
.mockResolvedValueOnce(xmlResponse(URLSET_SUB2));
const urls = await collect(walkSitemap('https://x.com/sitemap.xml'));
expect(urls.sort()).toEqual(['https://x.com/p1', 'https://x.com/p2', 'https://x.com/p3'].sort());
});
});
- [ ] Step 2.6: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "sitemapindex recursion"
Expected: 1 test passes.
- [ ] Step 2.7: Add cycle-prevention test (self-referencing sitemapindex)
Append to lib/factory/__tests__/sitemap.test.ts:
const SELF_REFERENCING_INDEX = `<?xml version="1.0"?>
<sitemapindex>
<sitemap><loc>https://x.com/sitemap.xml</loc></sitemap>
<sitemap><loc>https://x.com/leaf.xml</loc></sitemap>
</sitemapindex>`;
const URLSET_LEAF = `<?xml version="1.0"?>
<urlset><url><loc>https://x.com/only</loc></url></urlset>`;
describe('walkSitemap — cycle prevention', () => {
beforeEach(() => mockFetch.mockReset());
it('visits each sitemap URL exactly once even with self-reference', async () => {
mockFetch
.mockResolvedValueOnce(xmlResponse(SELF_REFERENCING_INDEX)) // /sitemap.xml
.mockResolvedValueOnce(xmlResponse(URLSET_LEAF)); // /leaf.xml — only one extra fetch
const urls = await collect(walkSitemap('https://x.com/sitemap.xml'));
expect(urls).toEqual(['https://x.com/only']);
expect(mockFetch).toHaveBeenCalledTimes(2); // self-ref skipped
});
});
- [ ] Step 2.8: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "cycle prevention"
Expected: 1 test passes.
- [ ] Step 2.9: Commit walker core
git add lib/factory/sitemap.ts lib/factory/__tests__/sitemap.test.ts
git commit -m "feat(factory): walkSitemap async iterator with recursive sitemapindex + cycle prevention"
Task 3: walkSitemap: gzip support + hreflang alternates + depth-3 nesting
Files:
- Modify: lib/factory/__tests__/sitemap.test.ts
The walker code already includes gzip + hreflang logic from Task 2.3. This task adds the regression tests that prove those code paths work.
- [ ] Step 3.1: Write failing test: gzipped
.xml.gzsitemap
Append to lib/factory/__tests__/sitemap.test.ts:
import { gzipSync } from 'node:zlib';
describe('walkSitemap — gzip support', () => {
beforeEach(() => mockFetch.mockReset());
it('decompresses .xml.gz sitemaps via DecompressionStream', async () => {
const compressed = gzipSync(Buffer.from(FLAT_URLSET_3, 'utf8'));
// Build a Response whose body is a ReadableStream of the gzipped bytes,
// and whose URL ends in .xml.gz so the walker triggers decompression.
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(compressed));
controller.close();
},
});
mockFetch.mockResolvedValueOnce(
new Response(stream, { status: 200, headers: { 'content-type': 'application/gzip' } })
);
const urls = await collect(walkSitemap('https://x.com/sitemap.xml.gz'));
expect(urls).toEqual(['https://x.com/a', 'https://x.com/b', 'https://x.com/c']);
});
it('decompresses when Content-Encoding: gzip is set even on .xml URL', async () => {
const compressed = gzipSync(Buffer.from(FLAT_URLSET_3, 'utf8'));
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(compressed));
controller.close();
},
});
mockFetch.mockResolvedValueOnce(
new Response(stream, {
status: 200,
headers: { 'content-type': 'application/xml', 'content-encoding': 'gzip' },
})
);
const urls = await collect(walkSitemap('https://x.com/sitemap.xml'));
expect(urls).toEqual(['https://x.com/a', 'https://x.com/b', 'https://x.com/c']);
});
});
- [ ] Step 3.2: Run, expect PASS (gzip path is already implemented)
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "gzip support"
Expected: 2 tests pass.
- [ ] Step 3.3: Write failing test: hreflang alternate extraction
Append to lib/factory/__tests__/sitemap.test.ts:
const URLSET_WITH_HREFLANG = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://x.com/en/page</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://x.com/en/page"/>
<xhtml:link rel="alternate" hreflang="de" href="https://x.com/de/page"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://x.com/fr/page"/>
</url>
</urlset>`;
describe('walkSitemap — hreflang alternates', () => {
beforeEach(() => mockFetch.mockReset());
it('extracts hreflang alternates as additional URLs (deduped)', async () => {
mockFetch.mockResolvedValueOnce(xmlResponse(URLSET_WITH_HREFLANG));
const urls = await collect(walkSitemap('https://x.com/sitemap.xml'));
// en is the same as loc → deduped; de + fr are new
expect(urls.sort()).toEqual(
['https://x.com/en/page', 'https://x.com/de/page', 'https://x.com/fr/page'].sort()
);
});
});
- [ ] Step 3.4: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "hreflang alternates"
Expected: 1 test passes.
- [ ] Step 3.5: Write failing test: depth-3 nested sitemap-index (Microsoft pattern)
Append to lib/factory/__tests__/sitemap.test.ts:
// Microsoft pattern (§14f, §16l): root index → product index → language urlset
const ROOT_INDEX_DEPTH3 = `<?xml version="1.0"?>
<sitemapindex>
<sitemap><loc>https://ms.com/product-index.xml</loc></sitemap>
</sitemapindex>`;
const PRODUCT_INDEX_DEPTH3 = `<?xml version="1.0"?>
<sitemapindex>
<sitemap><loc>https://ms.com/dotnet/en-us/sitemap.xml</loc></sitemap>
<sitemap><loc>https://ms.com/dotnet/de-de/sitemap.xml</loc></sitemap>
</sitemapindex>`;
const LANG_LEAF_EN = `<?xml version="1.0"?>
<urlset><url><loc>https://ms.com/en-us/p1</loc></url></urlset>`;
const LANG_LEAF_DE = `<?xml version="1.0"?>
<urlset><url><loc>https://ms.com/de-de/p1</loc></url></urlset>`;
describe('walkSitemap — depth-3 nested index (Microsoft pattern)', () => {
beforeEach(() => mockFetch.mockReset());
it('walks 3 levels of sitemapindex without depth cap', async () => {
mockFetch
.mockResolvedValueOnce(xmlResponse(ROOT_INDEX_DEPTH3))
.mockResolvedValueOnce(xmlResponse(PRODUCT_INDEX_DEPTH3))
.mockResolvedValueOnce(xmlResponse(LANG_LEAF_EN))
.mockResolvedValueOnce(xmlResponse(LANG_LEAF_DE));
const urls = await collect(walkSitemap('https://ms.com/sitemap.xml'));
expect(urls.sort()).toEqual(
['https://ms.com/en-us/p1', 'https://ms.com/de-de/p1'].sort()
);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});
- [ ] Step 3.6: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "depth-3"
Expected: 1 test passes.
- [ ] Step 3.7: Write failing test: 50K-URL urlset (sitemap-protocol max, no cap)
Append to lib/factory/__tests__/sitemap.test.ts:
function buildLargeUrlset(n: number): string {
const urls = Array.from({ length: n }, (_, i) => `<url><loc>https://x.com/p${i}</loc></url>`).join('');
return `<?xml version="1.0"?><urlset>${urls}</urlset>`;
}
describe('walkSitemap — large urlset (no URL cap)', () => {
beforeEach(() => mockFetch.mockReset());
it('yields all 50,000 URLs across 50 batches of 1000', async () => {
mockFetch.mockResolvedValueOnce(xmlResponse(buildLargeUrlset(50_000)));
const batches: string[][] = [];
for await (const batch of walkSitemap('https://x.com/big.xml')) {
batches.push(batch);
}
const total = batches.reduce((acc, b) => acc + b.length, 0);
expect(total).toBe(50_000);
expect(batches.length).toBe(50);
expect(batches[0]).toHaveLength(1000);
expect(batches[49]).toHaveLength(1000);
}, 30_000);
});
- [ ] Step 3.8: Run, expect PASS
npx vitest run lib/factory/__tests__/sitemap.test.ts -t "large urlset"
Expected: 1 test passes (may take a few seconds).
- [ ] Step 3.9: Run all sitemap tests + tsc
npx vitest run lib/factory/__tests__/sitemap.test.ts && npx tsc --noEmit
Expected: all sitemap tests pass; tsc clean.
- [ ] Step 3.10: Commit
git add lib/factory/__tests__/sitemap.test.ts
git commit -m "test(factory): walkSitemap regressions — gzip, hreflang, depth-3 nesting, 50K urlset"
Task 4: groupByPathPrefix: streaming pathGroup builder
Files:
- Create: lib/factory/path-grouper.ts
- Create: lib/factory/__tests__/path-grouper.test.ts
groupByPathPrefix(urlSource) is an async function* that:
- Consumes an AsyncIterable<string[]> (the same shape walkSitemap yields).
- Groups URLs by their first non-empty path segment (e.g., /blog/x and /blog/y → group /blog/*).
- Each yielded PathGroup conforms to PathGroupSchema from lib/factory/types.ts.
- Caps sampleUrls at 10 per group (to keep groups under Algolia's 100KB record limit when persisted).
- After the source is exhausted, merges any group with <3 URLs into a single other group and yields that last.
This module is in-memory but bounded: only the per-group counts and capped samples (10 URLs × N groups) live in RAM. For Microsoft Learn (10M URLs, ~50 path roots), that's ~500 sample URLs total: trivial.
- [ ] Step 4.1: Write failing test: basic grouping by first segment
Create lib/factory/__tests__/path-grouper.test.ts:
import { describe, it, expect } from 'vitest';
import { groupByPathPrefix } from '../path-grouper';
import { PathGroupSchema } from '../types';
async function* fromBatches(batches: string[][]): AsyncGenerator<string[]> {
for (const b of batches) yield b;
}
async function collectGroups(source: AsyncIterable<string[]>) {
const out = [];
for await (const g of groupByPathPrefix(source)) out.push(g);
return out;
}
describe('groupByPathPrefix — basic grouping', () => {
it('groups URLs by first non-empty path segment', async () => {
const urls = [
'https://x.com/blog/a',
'https://x.com/blog/b',
'https://x.com/blog/c',
'https://x.com/docs/x',
'https://x.com/docs/y',
'https://x.com/docs/z',
];
const groups = await collectGroups(fromBatches([urls]));
const patterns = groups.map((g) => g.pattern).sort();
expect(patterns).toEqual(['/blog/*', '/docs/*']);
const blog = groups.find((g) => g.pattern === '/blog/*')!;
expect(blog.urlCount).toBe(3);
expect(blog.sampleUrls).toEqual([
'https://x.com/blog/a',
'https://x.com/blog/b',
'https://x.com/blog/c',
]);
// PathGroupSchema validates
expect(() => PathGroupSchema.parse(blog)).not.toThrow();
});
it('treats root URLs (no path) as the "/" group', async () => {
const urls = ['https://x.com/', 'https://x.com', 'https://x.com/about'];
const groups = await collectGroups(fromBatches([urls]));
const patterns = groups.map((g) => g.pattern);
// Both root variants → '/' group; '/about' is a singleton (will become 'other' after merge — see Step 4.3)
expect(patterns).toContain('/');
});
});
- [ ] Step 4.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/path-grouper.test.ts
Expected: FAIL: Cannot find module '../path-grouper'.
- [ ] Step 4.3: Implement
lib/factory/path-grouper.ts
Create lib/factory/path-grouper.ts:
/**
* Streaming path-prefix grouper. Consumes an async iterable of URL batches
* (the shape walkSitemap yields) and emits PathGroup objects.
*
* Heuristic: first non-empty path segment is the group key. URL counts
* accumulate exactly; sampleUrls is capped at 10 per group. Groups with
* <3 URLs are merged into a single 'other' group, yielded last.
*
* Why streaming: a 10M-URL site (Microsoft Learn, §16l) would OOM if
* grouped in a single Promise-returning function. Streaming keeps memory
* bounded to ~(num_groups × 10 sample URLs).
*/
import { createLogger } from '../utils/logger';
import { PathGroupSchema, type PathGroup } from './types';
const log = createLogger('factory:path-grouper');
const SAMPLE_CAP = 10;
const MIN_URLS_FOR_OWN_GROUP = 3;
interface MutableGroup {
pattern: string;
urlCount: number;
sampleUrls: string[];
}
/**
* Group URLs by their first non-empty path segment. Streams output:
* yields each PathGroup as the source is consumed, yielding the merged
* 'other' group last (containing groups with < MIN_URLS_FOR_OWN_GROUP).
*/
export async function* groupByPathPrefix(
source: AsyncIterable<string[]>
): AsyncGenerator<PathGroup, void, void> {
const groups = new Map<string, MutableGroup>();
let totalUrls = 0;
for await (const batch of source) {
for (const url of batch) {
const pattern = patternForUrl(url);
let g = groups.get(pattern);
if (!g) {
g = { pattern, urlCount: 0, sampleUrls: [] };
groups.set(pattern, g);
}
g.urlCount += 1;
if (g.sampleUrls.length < SAMPLE_CAP) g.sampleUrls.push(url);
totalUrls += 1;
}
}
log.info({ event: 'group_complete', total_urls: totalUrls, groups: groups.size });
const otherSamples: string[] = [];
let otherCount = 0;
for (const g of groups.values()) {
if (g.urlCount < MIN_URLS_FOR_OWN_GROUP) {
otherCount += g.urlCount;
for (const u of g.sampleUrls) {
if (otherSamples.length < SAMPLE_CAP) otherSamples.push(u);
}
continue;
}
yield buildPathGroup(g);
}
if (otherCount > 0) {
yield buildPathGroup({ pattern: 'other', urlCount: otherCount, sampleUrls: otherSamples });
}
}
function patternForUrl(rawUrl: string): string {
let pathname: string;
try {
pathname = new URL(rawUrl).pathname;
} catch {
log.warn({ event: 'unparseable_url', url: rawUrl });
return 'other';
}
const segments = pathname.split('/').filter((s) => s.length > 0);
if (segments.length === 0) return '/';
return `/${segments[0]}/*`;
}
function buildPathGroup(g: MutableGroup): PathGroup {
const id = `pg_${hashShort(g.pattern)}`;
const validated = PathGroupSchema.parse({
id,
pattern: g.pattern,
urlCount: g.urlCount,
sampleUrls: g.sampleUrls.slice(0, SAMPLE_CAP),
detectedDomain: null,
detectionConfidence: null,
detectionMethod: null,
selected: false,
});
return validated;
}
function hashShort(s: string): string {
// Stable, short, non-cryptographic. djb2.
let h = 5381;
for (let i = 0; i < s.length; i += 1) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
- [ ] Step 4.4: Run, expect PASS for basic grouping tests
npx vitest run lib/factory/__tests__/path-grouper.test.ts -t "basic grouping"
Expected: 2 tests pass.
- [ ] Step 4.5: Add tests for small-group merge + sample cap + streaming behavior
Append to lib/factory/__tests__/path-grouper.test.ts:
describe('groupByPathPrefix — merge small groups into "other"', () => {
it('merges any group with <3 URLs into a single "other" group, yielded last', async () => {
const urls = [
'https://x.com/blog/a',
'https://x.com/blog/b',
'https://x.com/blog/c', // 3 → keeps own group
'https://x.com/about', // 1 → other
'https://x.com/legal', // 1 → other
];
const groups = await collectGroups(fromBatches([urls]));
const patterns = groups.map((g) => g.pattern);
expect(patterns).toContain('/blog/*');
expect(patterns).toContain('other');
// 'other' is yielded last
expect(patterns[patterns.length - 1]).toBe('other');
const other = groups.find((g) => g.pattern === 'other')!;
expect(other.urlCount).toBe(2);
});
});
describe('groupByPathPrefix — sample cap', () => {
it('caps sampleUrls at 10 even when group has 1000 URLs', async () => {
const urls = Array.from({ length: 1000 }, (_, i) => `https://x.com/blog/post-${i}`);
const groups = await collectGroups(fromBatches([urls]));
const blog = groups.find((g) => g.pattern === '/blog/*')!;
expect(blog.urlCount).toBe(1000);
expect(blog.sampleUrls).toHaveLength(10);
});
});
describe('groupByPathPrefix — streaming', () => {
it('handles a multi-batch source without holding everything in memory', async () => {
// 5 batches of 1000 URLs across 3 path groups
const batches: string[][] = [];
for (let b = 0; b < 5; b += 1) {
const batch: string[] = [];
for (let i = 0; i < 1000; i += 1) {
const root = i % 3 === 0 ? 'blog' : i % 3 === 1 ? 'docs' : 'help';
batch.push(`https://x.com/${root}/p-${b}-${i}`);
}
batches.push(batch);
}
const groups = await collectGroups(fromBatches(batches));
const patterns = groups.map((g) => g.pattern).sort();
expect(patterns).toEqual(['/blog/*', '/docs/*', '/help/*']);
const total = groups.reduce((acc, g) => acc + g.urlCount, 0);
expect(total).toBe(5000);
});
it('skips unparseable URLs (logs and continues)', async () => {
const urls = ['https://x.com/blog/a', 'not-a-url', 'https://x.com/blog/b', 'https://x.com/blog/c'];
const groups = await collectGroups(fromBatches([urls]));
const blog = groups.find((g) => g.pattern === '/blog/*')!;
expect(blog.urlCount).toBe(3); // unparseable went to 'other'
});
});
- [ ] Step 4.6: Run all path-grouper tests
npx vitest run lib/factory/__tests__/path-grouper.test.ts
Expected: all tests pass.
- [ ] Step 4.7: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 4.8: Commit
git add lib/factory/path-grouper.ts lib/factory/__tests__/path-grouper.test.ts
git commit -m "feat(factory): streaming path-prefix grouper with small-group merge + sample cap"
Task 5: estimateScope: pre-flight scope probe
Files:
- Create: lib/factory/scope-estimator.ts
- Create: lib/factory/__tests__/scope-estimator.test.ts
estimateScope(rootUrl) is the §3a-bis / §19a pre-flight probe. ≤2s typical. It does NOT walk the full sitemap: it returns a fast estimate so the wizard UI can ask the user "This site has ~531 sub-sitemaps and an estimated 100K-500K URLs. Proceed?" before committing to a full discovery.
Algorithm:
1. Call discoverSitemaps(rootUrl) to get the top sitemap URL(s).
2. For each top sitemap, fetch + parse (NOT recursive: only the top file).
3. If urlset: count its <url> entries → that's the URL count for this branch. Multiply by num-of-locales-detected if hreflang alternates exist.
4. If sitemapindex: count <sitemap> children. Multiply by an average urlset size estimate (default 5000) for a low/high range.
5. Detect language splits via:
- URL pathnames (/en-us/, /de-de/, /fr/, etc.: ISO 639-1 + region pattern)
- hreflang attributes on the first leaf urlset (only if we already fetched one)
6. Compute expectedDuration = subSitemapCount / 8 (concurrency) × 1.5s avg for sitemapindex case; 5s flat for urlset case.
7. Set transport: 'fetch' | 'playwright' | 'unknown'. If robots.txt or top sitemap returned 403 / timed out, transport = 'playwright'. Otherwise 'fetch'.
The estimator returns a zod-validated ScopeEstimate for direct rendering by the wizard.
- [ ] Step 5.1: Write failing test: flat urlset scope estimate
Create lib/factory/__tests__/scope-estimator.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import { estimateScope } from '../scope-estimator';
const textResponse = (body: string, status = 200): Response =>
new Response(body, { status, headers: { 'content-type': 'text/plain' } });
const xmlResponse = (body: string, status = 200): Response =>
new Response(body, { status, headers: { 'content-type': 'application/xml' } });
describe('estimateScope — flat urlset', () => {
beforeEach(() => mockFetch.mockReset());
afterEach(() => vi.restoreAllMocks());
it('returns urlEstimateRange = [n, n] for a flat urlset of n entries', async () => {
const urlset = `<?xml version="1.0"?><urlset>${
Array.from({ length: 100 }, (_, i) => `<url><loc>https://x.com/p${i}</loc></url>`).join('')
}</urlset>`;
mockFetch
.mockResolvedValueOnce(textResponse('User-agent: *\nSitemap: https://x.com/sitemap.xml\n'))
.mockResolvedValueOnce(xmlResponse(urlset));
const est = await estimateScope('https://x.com');
expect(est.urlEstimateRange).toEqual([100, 100]);
expect(est.subSitemapCount).toBe(0);
expect(est.transport).toBe('fetch');
});
});
- [ ] Step 5.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/scope-estimator.test.ts
Expected: FAIL: Cannot find module '../scope-estimator'.
- [ ] Step 5.3: Implement
lib/factory/scope-estimator.ts
Create lib/factory/scope-estimator.ts:
/**
* Pre-flight scope estimator. ~1-2s probe that returns a URL count range,
* sub-sitemap count, language list, and expected discovery duration.
*
* Used by the wizard UI to ask the user "this site is ~500K URLs, proceed?"
* BEFORE the factory commits to a full discovery walk.
*
* Plan refs: 00-Plan.md §3a-bis, §19a.
*/
import { z } from 'zod';
import { XMLParser } from 'fast-xml-parser';
import { createLogger } from '../utils/logger';
import { discoverSitemaps } from './sitemap';
const log = createLogger('factory:scope-estimator');
const USER_AGENT = 'AlgoliaCentralFactory/1.0';
const PROBE_TIMEOUT_MS = 8000;
const AVG_URLS_PER_SUBSITEMAP_LOW = 1000;
const AVG_URLS_PER_SUBSITEMAP_HIGH = 50_000;
const FETCH_CONCURRENCY = 8;
const AVG_SUBSITEMAP_FETCH_MS = 1500;
export const ScopeEstimateSchema = z.object({
rootUrl: z.string().url(),
topSitemapUrls: z.array(z.string().url()),
urlEstimateRange: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]),
subSitemapCount: z.number().int().nonnegative(),
languagesDetected: z.array(z.string()),
expectedDurationSeconds: z.tuple([z.number().nonnegative(), z.number().nonnegative()]),
transport: z.enum(['fetch', 'playwright', 'unknown']),
});
export type ScopeEstimate = z.infer<typeof ScopeEstimateSchema>;
/**
* Probe a site's robots.txt + top sitemap and return a scope estimate.
* Does NOT walk recursively. Always returns a value (even on failure,
* with empty arrays + transport='unknown' or 'playwright').
*/
export async function estimateScope(rootUrl: string): Promise<ScopeEstimate> {
let transport: ScopeEstimate['transport'] = 'fetch';
let topSitemapUrls: string[] = [];
try {
topSitemapUrls = await discoverSitemaps(rootUrl);
} catch (err) {
log.warn({ event: 'discover_failed', rootUrl, err: String(err) });
transport = 'unknown';
}
if (topSitemapUrls.length === 0) {
return ScopeEstimateSchema.parse({
rootUrl,
topSitemapUrls: [],
urlEstimateRange: [0, 0],
subSitemapCount: 0,
languagesDetected: [],
expectedDurationSeconds: [0, 0],
transport,
});
}
const probe = await probeTopSitemap(topSitemapUrls[0]);
if (probe === null) {
return ScopeEstimateSchema.parse({
rootUrl,
topSitemapUrls,
urlEstimateRange: [0, 0],
subSitemapCount: 0,
languagesDetected: [],
expectedDurationSeconds: [0, 0],
transport: 'playwright',
});
}
const languagesDetected = detectLanguages([rootUrl, ...probe.sampleUrls]);
const [min, max] = computeRange(probe);
const [durMin, durMax] = computeDuration(probe.subSitemapCount);
return ScopeEstimateSchema.parse({
rootUrl,
topSitemapUrls,
urlEstimateRange: [min, max],
subSitemapCount: probe.subSitemapCount,
languagesDetected,
expectedDurationSeconds: [durMin, durMax],
transport,
});
}
interface SitemapProbe {
kind: 'urlset' | 'sitemapindex';
urlCount: number; // urls in this top file (urlset only)
subSitemapCount: number; // children (sitemapindex only)
sampleUrls: string[]; // up to 5 sample loc/sub-sitemap values for language detection
}
async function probeTopSitemap(url: string): Promise<SitemapProbe | null> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: ctrl.signal });
if (res.status !== 200) {
log.warn({ event: 'top_sitemap_non200', url, status: res.status });
return null;
}
const xml = await res.text();
return parseProbe(xml);
} catch (err) {
log.warn({ event: 'top_sitemap_failed', url, err: String(err) });
return null;
} finally {
clearTimeout(t);
}
}
function parseProbe(xmlText: string): SitemapProbe {
const parser = new XMLParser({
ignoreAttributes: false,
isArray: (tagName) => ['url', 'sitemap'].includes(tagName),
});
const parsed = parser.parse(xmlText) as Record<string, unknown>;
if ('sitemapindex' in parsed) {
const idx = parsed.sitemapindex as { sitemap?: Array<{ loc?: string }> };
const children = (idx.sitemap ?? [])
.map((s) => (typeof s.loc === 'string' ? s.loc : null))
.filter((s): s is string => s !== null);
return {
kind: 'sitemapindex',
urlCount: 0,
subSitemapCount: children.length,
sampleUrls: children.slice(0, 5),
};
}
if ('urlset' in parsed) {
const urlset = parsed.urlset as { url?: Array<{ loc?: string }> };
const urls = (urlset.url ?? [])
.map((u) => (typeof u.loc === 'string' ? u.loc : null))
.filter((s): s is string => s !== null);
return { kind: 'urlset', urlCount: urls.length, subSitemapCount: 0, sampleUrls: urls.slice(0, 5) };
}
return { kind: 'urlset', urlCount: 0, subSitemapCount: 0, sampleUrls: [] };
}
function computeRange(probe: SitemapProbe): [number, number] {
if (probe.kind === 'urlset') return [probe.urlCount, probe.urlCount];
const min = probe.subSitemapCount * AVG_URLS_PER_SUBSITEMAP_LOW;
const max = probe.subSitemapCount * AVG_URLS_PER_SUBSITEMAP_HIGH;
return [min, max];
}
function computeDuration(subSitemapCount: number): [number, number] {
if (subSitemapCount === 0) return [5, 5];
const seconds = (subSitemapCount / FETCH_CONCURRENCY) * (AVG_SUBSITEMAP_FETCH_MS / 1000);
return [Math.max(5, Math.floor(seconds * 0.5)), Math.ceil(seconds * 1.5)];
}
const LANG_REGEX = /\/([a-z]{2})(?:[-_]([a-z]{2}))?\//gi;
function detectLanguages(urls: string[]): string[] {
const seen = new Set<string>();
for (const u of urls) {
let m: RegExpExecArray | null;
LANG_REGEX.lastIndex = 0;
while ((m = LANG_REGEX.exec(u)) !== null) {
const lang = m[1].toLowerCase();
const region = m[2] ? m[2].toLowerCase() : '';
const tag = region ? `${lang}-${region}` : lang;
if (lang.length === 2) seen.add(tag);
}
}
return Array.from(seen).sort();
}
- [ ] Step 5.4: Run urlset test, expect PASS
npx vitest run lib/factory/__tests__/scope-estimator.test.ts -t "flat urlset"
Expected: 1 test passes.
- [ ] Step 5.5: Add tests for sitemapindex, language detection, and WAF transport
Append to lib/factory/__tests__/scope-estimator.test.ts:
describe('estimateScope — sitemapindex with 531 children (Apple Autopush pattern)', () => {
beforeEach(() => mockFetch.mockReset());
it('returns urlEstimateRange = [531×low, 531×high] and subSitemapCount=531', async () => {
const children = Array.from(
{ length: 531 },
(_, i) => `<sitemap><loc>https://apple.com/sub-${i}.xml</loc></sitemap>`
).join('');
const idx = `<?xml version="1.0"?><sitemapindex>${children}</sitemapindex>`;
mockFetch
.mockResolvedValueOnce(textResponse('User-agent: *\nSitemap: https://apple.com/sitemap.xml\n'))
.mockResolvedValueOnce(xmlResponse(idx));
const est = await estimateScope('https://apple.com');
expect(est.subSitemapCount).toBe(531);
expect(est.urlEstimateRange[0]).toBe(531_000); // 531 × 1000
expect(est.urlEstimateRange[1]).toBe(531 * 50_000); // 531 × 50000
expect(est.expectedDurationSeconds[1]).toBeGreaterThan(60); // multi-minute
});
});
describe('estimateScope — language detection', () => {
beforeEach(() => mockFetch.mockReset());
it('detects en-us, de-de, fr from sample sub-sitemap URLs', async () => {
const idx = `<?xml version="1.0"?>
<sitemapindex>
<sitemap><loc>https://ms.com/dotnet/en-us/sitemap.xml</loc></sitemap>
<sitemap><loc>https://ms.com/dotnet/de-de/sitemap.xml</loc></sitemap>
<sitemap><loc>https://ms.com/dotnet/fr/sitemap.xml</loc></sitemap>
</sitemapindex>`;
mockFetch
.mockResolvedValueOnce(textResponse('User-agent: *\nSitemap: https://ms.com/sitemap.xml\n'))
.mockResolvedValueOnce(xmlResponse(idx));
const est = await estimateScope('https://ms.com');
expect(est.languagesDetected).toContain('en-us');
expect(est.languagesDetected).toContain('de-de');
expect(est.languagesDetected).toContain('fr');
});
});
describe('estimateScope — transport flag', () => {
beforeEach(() => mockFetch.mockReset());
it('sets transport=playwright when top sitemap returns 403 (WAF)', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('User-agent: *\nSitemap: https://wafsite.com/sitemap.xml\n'))
.mockResolvedValueOnce(textResponse('Forbidden', 403));
const est = await estimateScope('https://wafsite.com');
expect(est.transport).toBe('playwright');
expect(est.urlEstimateRange).toEqual([0, 0]);
});
it('returns empty estimate when no sitemap is discoverable', async () => {
mockFetch
.mockResolvedValueOnce(textResponse('not found', 404)) // robots
.mockResolvedValueOnce(textResponse('not found', 404)) // /sitemap.xml
.mockResolvedValueOnce(textResponse('not found', 404)) // /sitemap_index.xml
.mockResolvedValueOnce(textResponse('not found', 404)); // /sitemap-index.xml
const est = await estimateScope('https://nositemap.com');
expect(est.topSitemapUrls).toEqual([]);
expect(est.urlEstimateRange).toEqual([0, 0]);
expect(est.subSitemapCount).toBe(0);
});
});
- [ ] Step 5.6: Run all scope-estimator tests
npx vitest run lib/factory/__tests__/scope-estimator.test.ts
Expected: all tests pass.
- [ ] Step 5.7: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 5.8: Commit
git add lib/factory/scope-estimator.ts lib/factory/__tests__/scope-estimator.test.ts
git commit -m "feat(factory): pre-flight scope estimator (URL range, sub-sitemap count, languages, transport)"
Task 6: Cluster validation: full regression
Files: - (Read-only verification)
- [ ] Step 6.1: Run all factory tests
npx vitest run lib/factory/__tests__/
Expected: all Spec 01 tests (~23 from Foundations) + all Spec 02 tests (sitemap ~12, path-grouper ~5, scope-estimator ~5) GREEN.
- [ ] Step 6.2: Run full project test suite
npx vitest run
Expected: 407 baseline + 23 Spec 01 + ~22 Spec 02 = ~452 tests, all GREEN.
- [ ] Step 6.3: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 6.4: Bundler still produces clean output
node scripts/bundle-api.mjs
Expected: completes without error; existing api/*.mjs regenerated; fast-xml-parser remains externalized (no bundle bloat from new code).
- [ ] Step 6.5: Mark Spec 02 done in
Status.md
In the vault Projects/Crawler-Factory/Status.md, change:
- | Cluster 2 (Sitemap Discovery) | ⏸ | walkSitemap, discoverSitemaps, path-grouper, scope-estimator |
+ | Cluster 2 (Sitemap Discovery) | ✅ | walkSitemap, discoverSitemaps, path-grouper, scope-estimator |
Acceptance criteria: Spec 02 done means:
- ✅
lib/factory/sitemap.tsexportsdiscoverSitemaps(rootUrl): Promise<string[]>reading robots.txt + falling back to/sitemap.xml,/sitemap_index.xml,/sitemap-index.xml. - ✅
lib/factory/sitemap.tsexportswalkSitemap(url): AsyncGenerator<string[]>yielding URL batches of up to 1000. - ✅ Walker recurses sitemap-indexes at any depth (verified at depth 3: Microsoft pattern), with cycle prevention via
Set<string>(verified with self-referencing index). - ✅ Walker decompresses gzipped sitemaps (verified for
.xml.gzURL suffix andContent-Encoding: gzipheader). - ✅ Walker extracts hreflang alternates as additional URLs, deduped against
loc. - ✅ Walker handles a 50,000-URL urlset (sitemap-protocol max) without OOM, yielding 50 batches of 1000.
- ✅ Walker fetches sub-sitemaps in parallel batches of 8 (concurrency cap).
- ✅
lib/factory/path-grouper.tsexportsgroupByPathPrefix(source): AsyncGenerator<PathGroup>consuming an async iterable of URL batches and emitting validatedPathGroupobjects. - ✅ Path-grouper merges groups with
<3URLs into a single'other'group, yielded last. - ✅ Path-grouper caps
sampleUrlsat 10 per group. - ✅ Path-grouper handles unparseable URLs by routing them to
'other'and logging. - ✅
lib/factory/scope-estimator.tsexportsestimateScope(rootUrl): Promise<ScopeEstimate>with zod-validated output (ScopeEstimateSchema). - ✅ Scope estimator returns
urlEstimateRange = [count, count]for flat urlsets and[count×1000, count×50000]for sitemap-indexes. - ✅ Scope estimator detects ISO 639-1 language codes (e.g.,
en,en-us,de-de,fr) from URL patterns. - ✅ Scope estimator sets
transport: 'playwright'when the top sitemap returns 403 (WAF signal). - ✅ All new tests pass (~22 tests across the three modules).
- ✅ Full project test suite still GREEN.
- ✅
tsc --noEmitclean. - ✅ Per CodingSOPs: every module has a logger, every public function has a docstring, every fallible call has try/catch, every public boundary uses zod validation (PathGroup, ScopeEstimate).
Out of scope (handled by other specs)
- Sample fetching (downloading individual page HTML for classification) → Spec 03 (
sampler.ts). - Content classification of pathGroups (CMS fingerprint, JSON-LD, OpenGraph cascade per §3b/§4b) → Spec 04 (
classifier.ts). - Persistence of discovered URLs into
url_shardrecords → Spec 08 (/api/factory/discoverendpoint uses Spec 01'sSessionStore.appendUrls). - Resumability (Vercel timeout → resume from last shard) → Spec 08 (discover endpoint orchestrator), relies on the visited-set being persisted between runs; v1 accepts a single-process visited-set scoped to one walk.
- Streaming SAX-style parse for files >5MB:
fast-xml-parserdoes support a streaming chunk handler; v1 of this spec uses the in-memoryXMLParserbecause Node.js fetch buffers the response anyway. The streaming-parse code path is reserved for Spec 03 / 07 if Vercel function memory pressure becomes an empirical issue. The 50K-URL urlset test (Step 3.7) verifies in-memory parse handles the spec ceiling without OOM on test infra. - Playwright stealth fallback for WAF-blocked sites → Spec 03 (the estimator only flags
transport: 'playwright'; actual Playwright execution is in the sampler). - Pre-flight UI (showing the user the scope estimate and asking proceed/sub-tree/cancel) → Spec 11 (frontend wizard).
- Real network integration tests against Algolia.com / Microsoft Learn / Apple → Spec 13 (smoke test cluster).
- Honoring
Crawl-delayat fetch time → Spec 03 (sampler) and Spec 07 (orchestrator). The estimator reads it but doesn't act on it; the walker fetches sitemaps with default 8-concurrent and accepts that someCrawl-delay: 10Drupal sites will be slower than 8 RPS once samples are pulled.