Skip to content

Add CLI hardening and crawler output features - #3

Open
07rjain wants to merge 4 commits into
Dhravya:mainfrom
07rjain:pr/webpull-improvements
Open

Add CLI hardening and crawler output features#3
07rjain wants to merge 4 commits into
Dhravya:mainfrom
07rjain:pr/webpull-improvements

Conversation

@07rjain

@07rjain 07rjain commented Apr 29, 2026

Copy link
Copy Markdown

Summary

  • add robust CLI parsing, config support, validation, filtering, resume, dry-run, and structured output flags
  • add crawler policy/output features including robots/noindex handling, manifest diffing, JSONL, single-file output, llms.txt, canonical metadata, and fallback markdown conversion
  • add tests, docs, examples, changelog, contributing notes, and CI checks

Verification

  • bun run check
  • bun run build

@vorflux vorflux Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed -- found 3 issues (all medium severity). The new features are well-structured overall, but there are a few correctness and robustness problems that could bite in production.


Review with Vorflux

Comment thread src/index.ts
pool.terminate()
process.stderr.write("\n Interrupted. Stopping workers...\n")
process.exit(130)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SIGINT handler exits before manifest/output bookkeeping can run.

process.exit(130) fires immediately after pool.terminate(), which bypasses Promise.all(writes), writeManifest(...), and the bundle/jsonl/failures writes further down. An interrupted run loses all manifest progress.

This breaks the new resume flows: after stopping a long crawl and rerunning with --changed-only, pages that were already written during the first run will be re-fetched because they were never recorded as entry.ok in the manifest. The interrupted flag on line 44 is effectively dead code since the process has already exited before it can be checked.

Suggested fix: instead of process.exit(130), set interrupted = true, call pool.terminate(), and let the normal flow fall through to write the manifest with whatever entries succeeded before the interrupt. Then exit with code 130 at the end.

Comment thread src/index.ts Outdated
Comment on lines +70 to +89
const outputPaths = new OutputPathAllocator()
const planned = urls.map((url) => ({ url, outputPath: outputPaths.allocate(url) }))
const manifestPath = config.manifestFile ?? defaultManifestPath(config.out)
const manifest = readManifest(manifestPath)
if (config.diff) {
const diff = manifestDiff(
planned.map((page) => page.url),
manifest,
)
console.log(JSON.stringify(diff, null, 2))
return
}
const skipExisting = config.resume || config.changedOnly || !config.overwrite
const skipped = skipExisting
? planned
.filter((page) => {
const entry = manifest.entries[page.url]
const fileExists = existsSync(join(config.out, page.outputPath))
if (config.changedOnly) return entry?.ok && fileExists
return fileExists

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: --resume/--changed-only is unstable for colliding URLs.

OutputPathAllocator assigns _2, _3, ... suffixes based on the current discovery order, but the skip logic here checks existence using the newly allocated page.outputPath instead of the path stored in manifest.entries[page.url].outputPath.

For URLs that collide (e.g. /foo, /foo.html, /foo?tab=js), a different discovery order on the next run can swap which URL maps to foo.md vs foo_2.md. This means --changed-only/--resume can skip the wrong file or leave stale content behind.

Suggested fix: either seed the allocator from prior manifest paths, or use manifest.entries[page.url]?.outputPath for the existence check instead of recomputing a fresh path.

Comment thread src/path.ts Outdated

export const urlToOutputPath = (url: string): string => {
const parsed = new URL(url)
let pathname = decodeURIComponent(parsed.pathname)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: decodeURIComponent can crash the entire crawl on malformed percent-encoded paths.

decodeURIComponent(parsed.pathname) throws URIError for inputs like /%E0%A4%A (incomplete sequences). new URL(...) happily accepts those URLs, so if discovery finds one broken link from a real site, the urls.map((url) => outputPaths.allocate(url)) call in src/index.ts will abort the whole run before any fetching starts.

Since this tool targets arbitrary public docs sites, malformed hrefs are realistic.

Suggested fix:

let pathname: string
try {
  pathname = decodeURIComponent(parsed.pathname)
} catch {
  pathname = parsed.pathname
}

@vorflux

vorflux Bot commented Apr 29, 2026

Copy link
Copy Markdown

Testing Results

Shipped tests: 23/23 pass

Additional tests written and run (8 files, 109 tests):

File Tests Coverage
src/pool.test.ts 11 WorkerPool dispatch, timeout/error/messageerror worker replacement, settle idempotency, terminate
src/fetcher.test.ts 8 429 retry, 503 exhaustion, 404 no-retry, network error retry, User-Agent, Accept header, custom headers
src/cli.edge.test.ts 20 Header colon-splitting, whitespace edge cases, --resume/--overwrite interaction, config file loading/overrides, malformed config
src/filter.edge.test.ts 12 globToRegex dot/+/( escaping, * wildcard, exact-match anchoring, multi-pattern include/exclude
src/path.edge.test.ts 12 Percent-encoding, .HTM/.HTML case-insensitive strip, collision suffixes _2/_3/_4, cross-directory isolation
src/convert.edge.test.ts 16 frontmatter(status: 0) guard, empty fields omitted, fallbackMarkdown <ol>/<ul>/<blockquote>/<em>
src/discover.edge.test.ts 18 parseLocs empty locs, parseRobots non-numeric/negative/zero crawl-delay, comments, extractLinks scope/dedup/assets
src/manifest.edge.test.ts 12 readManifest corrupt-JSON recovery, round-trip, writeManifest nested dir creation, all three manifestDiff categories

Grand total: 132/132 pass, 0 fail (610ms, 240 expect() calls)

E2E Smoke Tests (13/13 pass)

# Feature Assertion Result
1 --help Output contains "webpull" and "Usage:" Pass
2 --version Prints 0.1.2 Pass
3 --dry-run https://example.com/ printed to stdout Pass
4 Full crawl --json ok=1 err=0 skipped=0 total=1 Pass
4b Frontmatter title: url: status: 200 content_type: in index.md Pass
5a --single-file bundle.md written with frontmatter + content Pass
5b --llms-txt Starts # Example Domain, has Source: Pass
5c --jsonl Valid JSON line with url+title+path+contentType Pass
5d --failures File contains [] Pass
6 --diff Correct added/known/removed JSON Pass
7 --resume Second run: skipped=1 ok=0 Pass
8 --manifest (custom path) File created, version=1, 1 entry Pass
9 --changed-only Second run: skipped=1 Pass

Bug Found

WorkerPool.pullAll hangs forever on empty URL list (src/pool.ts): finish() is only reachable from inside settle(), which requires a job to complete. When urls is empty (total = 0), no jobs are dispatched, so the Promise never resolves. Currently not hit in production because src/index.ts guards with if (!pages.length) { return }, but the API contract is broken for any caller passing an empty list.

@07rjain

07rjain commented Apr 29, 2026

Copy link
Copy Markdown
Author

Addressed the review findings in 5f61663:

  • SIGINT now stops workers without immediate process exit, allowing completed writes, bundle/jsonl/failures output, and manifest bookkeeping to flush before returning exit code 130.
  • Resume/changed-only now preserves manifest output paths and reserves them before allocating new collision suffixes.
  • Malformed percent-encoded paths no longer crash output path generation.
  • WorkerPool.pullAll([]) now resolves immediately.

Verification:

  • bun run check
  • bun run build

@vorflux

vorflux Bot commented Apr 29, 2026

Copy link
Copy Markdown

Reviewed the fixes in 5f61663 -- all four issues are addressed correctly:

  • SIGINT: The interruptedPromise + Promise.race pattern lets the normal bookkeeping flow (writes, manifest, bundle/jsonl/failures) complete before setting process.exitCode = 130. Clean approach.
  • Resume stability: Reading the manifest before allocation, reserving existing paths, and falling back to entry?.outputPath in the skip check all look correct.
  • decodeURIComponent: Try/catch with raw pathname fallback is the right fix.
  • Empty URL list: Early resolve() guard is simple and correct.

No further issues found.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 6 issues across 5 rules (3 WCAG, 3 Best Practice).

Comment thread src/convert.test.ts Outdated
describe("fallbackMarkdown", () => {
test("preserves common block and inline structure", () => {
const result = fallbackMarkdown(`
<html>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WCAG 3.1.1: <html> element missing lang attribute.

The <html> element must have a lang attribute.

Details

Page text sample: "import { describe, expect, test } from "bun:test" import { extractHtmlMetadata, fallbackMarkdown, frontmatter } from "./convert" describe("frontmatter", () => { test("escapes YAML-sensitive strings", "

Screen readers use the lang attribute to determine which language rules and pronunciation to use. Without it, content may be mispronounced. Set lang to the primary language of the page (e.g., lang='en' for English, lang='es' for Spanish).

Comment thread src/discover.test.ts Outdated
describe("extractLinks", () => {
test("extracts same-host anchor hrefs and ignores script text", () => {
const html = `
<html>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WCAG 2.4.2: Document is missing a <title> element.

Documents must have a <title> element to provide users with an overview of content.

Details

Page text: "import { describe, expect, test } from "bun:test" import { extractLinks, isAllowedByRobots, parseLocs, parseRobots } from "./discover" describe("parse"

Screen reader users rely on page titles to identify and navigate between tabs/windows. Add a descriptive <title> element in <head> that summarizes the page purpose. Keep titles unique across the site, placing specific content before the site name (e.g., 'Contact Us - Acme Corp').


Best Practice: Page has no mechanism to bypass repeated content. Add a <main> landmark or skip link.

Page must have a mechanism to bypass repeated blocks of content.

Details

Missing: no landmarks (<main>, <nav>, <header>, <footer>), no skip link, no headings

Keyboard users must be able to skip repetitive content like navigation. Provide a skip link at the top of the page that links to the main content (e.g., <a href="#main">Skip to main content</a>), or use a <main> landmark. Screen readers can jump directly to landmarks, so a properly marked-up <main> element satisfies this requirement.


Best Practice: Page does not contain a level-one heading.

Page should contain a level-one heading.

Details

A level-one heading (<h1> or role='heading' with aria-level='1') helps users understand the page topic and provides a landmark for screen reader navigation. Each page should have exactly one h1 that describes the main content, typically matching or similar to the page title.


Best Practice: Page has no main landmark.

Page should have exactly one main landmark.

Details

The main landmark contains the primary content of the page. Screen readers allow users to jump directly to main content. Use a single <main> element (or role='main') to wrap the central content, excluding headers, footers, and navigation.


WCAG 3.1.1: <html> element missing lang attribute.

The <html> element must have a lang attribute.

Details

Page text sample: "import { describe, expect, test } from "bun:test" import { extractLinks, isAllowedByRobots, parseLocs, parseRobots } from "./discover" describe("parseLocs", () => { test("parses XML loc elements and d"

Screen readers use the lang attribute to determine which language rules and pronunciation to use. Without it, content may be mispronounced. Set lang to the primary language of the page (e.g., lang='en' for English, lang='es' for Spanish).

@07rjain

07rjain commented May 7, 2026

Copy link
Copy Markdown
Author

Updated this PR against the latest upstream main in f63fcf1.

What changed:

  • merged upstream SPA rendering, route extraction, sitemap host normalization, and version 0.1.3 updates
  • resolved conflicts while preserving the CLI/config/manifest/output/resume features from this PR
  • kept Playwright lazy-loaded at runtime and externalized it from the Bun build so the CLI bundles cleanly
  • adjusted test HTML fixtures for the AccessLint comments

Verification:

  • bun run check
  • bun run build

@vorflux vorflux Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the upstream merge in f63fcf1 -- found 7 issues in the new SPA rendering, route extraction, and browser integration code. 4 issues are posted as inline comments below; 3 additional issues are in a separate comment (lines not in the diff).

Comment thread src/index.ts
convertTimeoutMs: config.convertTimeoutMs,
delayMs: config.delayMs,
headers: config.headers,
jobTimeoutMs: config.timeoutMs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SPA detection uses a second, unconfigured fetch that can disagree with discovery.

discover() already fetched the base URL using configured headers, user-agent, delay, timeout, and redirect handling. This second fetch(config.url, { redirect: "follow" }) ignores all of those options, so it can get a different response (e.g. a bot-block page, login wall, or error) from what discovery saw.

If this fetch fails or returns a non-SPA page, useBrowser stays false and workers will convert SPA shells without rendering, producing empty output.

Suggested fix: have discover() return the SPA detection result as metadata, or reuse the HTML already fetched during discovery.

Comment thread src/worker.ts
const rendered = await renderPage(finalUrl, { timeout: 20000 })
if (rendered) text = rendered.html
} catch {}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Default job timeout (10s) is shorter than the SPA render timeout (20s).

Workers call renderPage(..., { timeout: 20000 }), but the CLI default --timeout is 10000ms and WorkerPool uses that as the whole-job timeout. A normal SPA render will be killed by the pool before Playwright finishes.

Suggested fix: separate the request timeout from the worker/job timeout, or increase the job timeout when useBrowser is true to account for fetch + render + conversion time.

Comment thread src/renderer.ts
let browser: BrowserLike | null = null
let context: BrowserContextLike | null = null

let browser: Browser | null = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Per-worker browser instances are not shared or gracefully closed.

The module-level browser/context singletons are per JS isolate, not shared across the worker pool. With browser mode enabled, up to workerCount independent Chromium instances can be launched (one per Bun worker). closeBrowser() in index.ts only closes the main-thread instance; it does not reach browser instances inside workers.

pool.terminate() stops the Bun worker but does not call Playwright's browser.close(), risking leaked Chromium child processes.

Suggested fix: either render in the main process with a shared browser/context, or add an explicit worker shutdown message that calls closeBrowser() before termination.

Comment thread src/write.ts
// Prevent path traversal attacks from malicious hash fragments
const full = join(outDir, outputPath)
const rel = relative(resolve(outDir), resolve(full))
if (rel.startsWith("..") || isAbsolute(rel)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Path traversal check is slightly too broad.

rel.startsWith("..") also rejects safe in-directory names like ..well-known/foo.md. While unlikely in practice, the precise check would be:

if (rel === ".." || rel.startsWith("../") || isAbsolute(rel))

@vorflux

vorflux Bot commented May 7, 2026

Copy link
Copy Markdown

Additional review findings for f63fcf1

These 3 issues could not be placed as inline comments (lines are outside the diff hunks or in files not modified in this commit).


1. src/renderer.ts:39-42 -- process.exit(1) in launchBrowser() prevents graceful error handling

If Chromium is missing, launchBrowser() hard-exits the entire process. When called from a worker or during discovery, this bypasses all cleanup (manifest writes, pending writes, browser close). Since isSPAShell() can false-positive on sparse non-SPA pages, this turns a normal crawl into a hard Playwright requirement.

Suggested fix: throw a typed error and let callers decide whether to fail the page, disable browser mode, or show a single CLI-level message.


2. src/discover.ts:254-262 -- Hash-router navigation fallback is unreachable

fullUrl is always unshifted into deduped, so unique.length > 0 is always true -- even when zero hash links were discovered. The rendered navigation fallback below is therefore dead code, and some hash-routed SPAs will discover only the starting URL.

Suggested fix: only return the hash-link list when actual hash links (beyond the starting URL) were found; otherwise fall through to rendered nav/link extraction.


3. src/routes.ts:37-41 -- JS bundle fetches have no timeout, size limit, or same-origin filtering

extractRoutesFromBundles() calls raw fetch(jsUrl) and res.text() for every <script src> URL found in HTML, including third-party hosts. This ignores configured timeout, headers, retries, and user-agent. It can hang discovery on slow CDNs or read very large third-party bundles into memory.

Suggested fix: route bundle fetches through fetchText() with the configured timeout/header options, cap response size (e.g. skip bundles > 2MB), and limit extraction to same-origin or explicitly trusted hosts.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules.

Comment thread src/discover.test.ts
<html lang="en">
<head><title>Links fixture</title></head>
<body>
<main>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best Practice: Page has multiple main landmarks.

Page should have exactly one main landmark.

Details

The main landmark contains the primary content of the page. Screen readers allow users to jump directly to main content. Use a single <main> element (or role='main') to wrap the central content, excluding headers, footers, and navigation.


Best Practice: Page has multiple main landmarks.

Page should not have more than one main landmark.

Details

Only one main landmark should exist per page. The main landmark identifies the primary content area. If you have multiple content sections, use <section> with appropriate headings instead of multiple main elements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant