Released as an open-source snapshot for the community to use and build on. This codebase is not actively maintained -- issues and pull requests are not monitored. Fork freely under the MIT license.
A production-grade, embeddable web crawler for TypeScript/Node.js. Plug it into any tool that needs to walk a site and get back a stream of rich page snapshots - SEO auditors, broken-link checkers, content monitors, competitive intelligence tools.
The engine has no UI, no storage, and no built-in reports. It produces PageSnapshot records as it crawls.
crawl-engine/
├── core/ @crawl/engine - pure crawl engine, no interface opinions
├── modules/
│ ├── chrome-pipe/ @crawl/chrome-pipe - Chrome as fetch pipe, crawler as extraction brain
│ ├── mcp-test/ @crawl/mcp-test - minimal 3-tool MCP server for smoke testing
│ └── mcp-server/ @crawl/mcp-server - full MCP server with crawl, fetch, and analysis tools
├── configs/
│ └── examples/ reference configs (full-site-audit, content-discovery, local-seo)
├── scratch/ file output sandbox (gitignored)
Dependency rule: core imports nothing from this repo. modules import from core only. configs are data - no imports. The arrow always points inward.
Requirements: Node.js 20+
The repo includes a .devcontainer/ config for VS Code and Cursor. Opening the repo in a dev container gives you a Linux environment with Node 20, native module compilation, and Playwright pre-installed - no local setup required.
- Install Docker Desktop
- In VS Code / Cursor: Reopen in Container (or
Dev Containers: Clone Repository in Container Volumefrom the command palette) npm installandnpx playwright installrun automatically on first build
Ports 3001 and 3002 are forwarded to localhost automatically.
# Allow deep node_modules paths (Windows has a 260-char path limit by default)
git config core.longpaths true
# Normalize file modes - Windows doesn't have Unix exec bits, so without this
# every file will appear modified after a Mac/Linux collaborator touches the repo
git config core.fileMode falseLine endings are handled automatically via .gitattributes (LF in the repo, CRLF on checkout for Windows). You don't need to set core.autocrlf manually.
npm install # installs all workspaces from the rootnpm run build -w core
npm run build -w modules/mcp-serverOr build everything at once:
npm run build --workspaces --if-presentnpm run test -w coreimport {
CrawlConfig,
CrawlEngine,
HttpClientBackend,
SsrfPolicy,
} from '@crawl/engine';
// Use HttpClientBackend for standard sites, or PlaywrightFetchBackend for JS-rendered SPAs
const backend = new HttpClientBackend(SsrfPolicy.BLOCK_PRIVATE);
const config = CrawlConfig.builder('https://example.com')
.maxDepth(3)
.workers(8)
.requestDelayMs(300)
.build();
for await (const page of new CrawlEngine(config, backend, []).crawl()) {
console.log(page.statusCode, page.url);
}const config = CrawlConfig.builder('https://example.com/blog')
.maxDepth(5)
.includePattern('/blog/') // only follow URLs containing /blog/
.excludePattern('/tag/') // skip tag archive pages
.excludePattern('?page=') // skip pagination
.requestDelayMs(500)
.jitterPct(20) // ±20% randomisation per request
.build();import type { Extractor, ParsedPage } from '@crawl/engine';
interface SchemaSignals {
jsonLdBlocks: string[];
hasProductSchema: boolean;
}
class SchemaExtractor implements Extractor<SchemaSignals> {
readonly id = 'schema.jsonld';
extract(page: ParsedPage): SchemaSignals | null {
if (!page.isHtml) return null;
const blocks = page.$('script[type="application/ld+json"]')
.map((_, el) => page.$(el).html() ?? '')
.get();
return {
jsonLdBlocks: blocks,
hasProductSchema: blocks.some(b => b.includes('"Product"')),
};
}
}
const engine = new CrawlEngine(config, backend, [new SchemaExtractor()]);
for await (const page of engine.crawl()) {
const signals = page.extraction<SchemaSignals>('schema.jsonld');
if (signals?.hasProductSchema) console.log('Product schema:', page.url);
}All settings have safe defaults for polite, scope-aware crawling. Construct via CrawlConfig.builder(seedUrl) - all validation happens at build() time.
| Builder method | Default | Description |
|---|---|---|
maxDepth(n) |
Infinity |
Maximum link hops from seed. 0 = seed only. |
crawlSubdomains(bool) |
false |
Follow links into subdomains. |
includePattern(str) |
(none) | Only crawl URLs containing this substring. Multiple calls are OR-ed. |
excludePattern(str) |
(none) | Skip URLs containing this substring. Takes precedence over include. |
checkExternalLinks(bool) |
false |
HEAD-check external links; include their status in snapshots. |
| Builder method | Default | Description |
|---|---|---|
workers(n) |
4 |
Number of concurrent fetch promises. |
| Builder method | Default | Description |
|---|---|---|
timeoutMs(ms) |
10000 |
Per-request HTTP timeout. |
maxBodyBytes(n) |
5242880 (5 MB) |
Response body cap. Larger bodies are truncated; snapshot is still emitted. |
maxRedirects(n) |
10 |
Maximum redirect hops per URL. |
userAgent(str) |
Chrome UA | User-agent string sent with every request. |
renderJs(bool) |
false |
Enable JS rendering (requires PlaywrightFetchBackend). |
postNavigationDelayMs(ms) |
0 |
Time to wait after page load before capturing DOM (useful for SPAs). |
| Builder method | Default | Description |
|---|---|---|
requestDelayMs(ms) |
500 |
Base delay between requests to the same host. 0 disables. |
jitterPct(pct) |
20 |
±% randomisation applied to every delay. |
retryDelayMs(ms) |
2000 |
Base back-off for 429 retries (doubles each attempt). |
maxRetries(n) |
5 |
Maximum 429 retries before a URL is abandoned. |
| Builder method | Default | Description |
|---|---|---|
respectRobotsTxt(bool) |
true |
Fetch and respect robots.txt for every domain. |
seedFromSitemap(bool) |
true |
Pre-load the frontier from sitemap.xml. |
detectDuplicates(bool) |
true |
Flag pages whose body matches an earlier page. |
stripSessionParams(bool) |
true |
Strip PHPSESSID, JSESSIONID, etc. from URLs. |
| Builder method | Default | Description |
|---|---|---|
ssrfPolicy(policy) |
BLOCK_PRIVATE |
Block requests to private/loopback IP ranges. |
Exposes the crawl engine as tools for Claude and any MCP-compatible client.
node modules/mcp-server/dist/index.js| Tool | Description |
|---|---|
crawl |
Crawl a site from a seed URL. Returns a manifest of page snapshots with SEO and link data. |
fetch_page |
Fetch a single URL and return its SEO data, article content, and links. |
fetch_api |
Walk an offset-paginated JSON API and return the collected results. |
parse_sitemap |
Parse a sitemap.xml and return all discovered URLs. |
search_manifest |
Search a saved crawl manifest by keyword, URL pattern, or status code. |
summarize_manifest |
Return aggregate stats for a saved manifest (page count, word counts, status distribution). |
analyze_links |
Analyze the link graph from a manifest - internal, external, broken. |
analyze_meta |
Extract canonical, robots, Open Graph, and hreflang signals from a manifest. |
analyze_headings |
Audit heading structure (H1-H6) across a manifest for SEO issues. |
analyze_images |
Audit images for missing alt text and other accessibility signals. |
analyze_schema |
Extract and summarize JSON-LD schema blocks across a manifest. |
compare_manifests |
Diff two crawl manifests to surface new, removed, and changed pages. |
find_orphans |
Identify pages with no internal inbound links. |
The MCP server ships two built-in extractors registered on every crawl:
SeoExtractor- title, meta description, H1, word count, article word count, excerptLinkExtractor- internal and external links with anchor text
A sample MCP module that inverts the usual architecture: instead of the crawler fetching pages, Claude in Chrome navigates and provides the raw HTML, and this module handles all extraction. No outbound HTTP requests are made - it is a pure extraction layer.
This pattern is useful when you need a real browser session (for JS-rendered pages, authenticated content, or bot-protected sites) but still want structured extraction output rather than raw HTML.
Claude in Chrome chrome-pipe MCP server
│ │
│ navigate to URL │
│ get_page_text() → html │
│ ─────────────────────────── >│
│ extract_page(url, html)
│ │
│ < ────────────────────────── │
│ PageSnapshot (seo, links, │
│ headings, schema, og) │
| Tool | Description |
|---|---|
extract_page |
Run the full extractor stack on a URL + HTML string. Returns SEO signals, links, headings, JSON-LD schema, and Open Graph data. |
extract_seo |
SEO signals only - title, description, canonical, robots, H1, word count, article word count, excerpt. |
extract_links |
Internal and external links with anchor text and rel attributes. |
batch_extract |
Run extraction on up to 100 pages at once. Returns a manifest array. |
{
"mcpServers": {
"chrome-pipe": {
"command": "node",
"args": ["modules/chrome-pipe/dist/index.js"]
}
}
}"Navigate to https://example.com, get the page HTML, then pass it to chrome-pipe's
extract_pagetool and show me the SEO signals and heading structure."
Claude in Chrome handles the fetch. Chrome-pipe handles the extraction. The two MCP servers never talk to each other directly - Claude is the coordinator.
CrawlEngine.crawl() AsyncIterable<PageSnapshot>
│
├── seed frontier (sitemap + seed URL)
│
└── dispatch loop
promise pool (≤ workers concurrent tasks)
each worker:
robots check
rate limit (per-host promise chain)
FetchBackend.fetch()
└── SsrfGuard.check() block private IP ranges
BodyDeduplicator.isDuplicate()
cheerio.parse() HTML only
Extractor[].extract() user-registered extractors
emit PageSnapshot
extract links → Frontier.submit()
The engine uses an explicit promise pool - the dispatch loop maintains up to workers concurrent promises and uses Promise.race to wait when the pool is full. This produces the same throughput as a thread-per-task model for I/O-bound crawling.
Each worker chains onto the previous host promise, serialising requests to the same host at the configured delay with no atomics needed - Node.js's single-threaded event loop guarantees non-interleaved await points.
On HTTP 429, the worker releases its pool slot before sleeping and re-acquires it afterwards, so a long backoff does not starve other in-flight URLs.
Before connecting, every URL's hostname is resolved to IP and checked against blocked ranges:
| Range | Description |
|---|---|
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
RFC 1918 private |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local / AWS instance metadata |
100.64.0.0/10 |
Shared address space (RFC 6598) |
::1/128, fc00::/7, fe80::/10 |
IPv6 loopback / unique-local / link-local |
The check fires at every hop of a redirect chain. To opt out for intranet crawling:
CrawlConfig.builder('http://intranet.corp/').ssrfPolicy(SsrfPolicy.ALLOW_ALL).build();Note: Playwright-based crawls (using PlaywrightFetchBackend) also enforce the same SSRF policy for all outgoing requests and sub-resources.
User-provided paths for file writes (e.g., saveToFile in MCP tools) are processed via Security.sandboxPath(). This ensures that:
- Operations are restricted to the
./scratch/directory by default. - Absolute paths and traversal components (
..) that escape the sandbox are strictly rejected.
All extractors that assign dynamic keys from untrusted HTML (Open Graph, Twitter Card, schema blocks) explicitly filter out __proto__, constructor, and prototype before assignment to prevent environment manipulation.
Response bodies are capped at maxBodyBytes (default 5 MB). Truncated bodies still produce a snapshot - fetchResult.bodyTruncated is true.
Redirect chains are capped at maxRedirects (default 10). Longer chains are treated as errors.
Before any URL is enqueued, UrlNormalizer applies these rules in order:
- Reject non-http/https or malformed URLs
- Lowercase scheme and host
- Remove default ports (
:80on http,:443on https) - Resolve dot segments
- Strip fragment (
#...) - Sort query parameters alphabetically
- Strip tracking parameters:
utm_*,fbclid,gclid,gclsrc,dclid,zanpid,mc_cid,mc_eid - Strip session parameters when
stripSessionParams=true:jsessionid,phpsessid,sid,sessionid - Remove trailing slash on non-root, extension-free paths
Normalization is idempotent.
| Package | License | Purpose |
|---|---|---|
cheerio |
MIT | HTML parsing |
robots-parser |
MIT | robots.txt parsing |
undici |
MIT | HTTP client |
playwright-core |
Apache 2.0 | Headless browser for JS rendering |
| Package | License | Purpose |
|---|---|---|
@modelcontextprotocol/sdk |
MIT | MCP server protocol |
@mozilla/readability |
Apache 2.0 | Article extraction |
got-scraping |
MIT | TLS fingerprint spoofing for bot bypass |
jsdom |
MIT | DOM simulation for readability |
express |
MIT | HTTP transport for MCP |
zod |
MIT | Schema validation |
| Package | License | Purpose |
|---|---|---|
@modelcontextprotocol/sdk |
MIT | MCP server protocol |
@mozilla/readability |
Apache 2.0 | Article extraction |
cheerio |
MIT | HTML parsing |
jsdom |
MIT | DOM simulation for readability |
zod |
MIT | Schema validation |
All dependencies are permissive open-source (MIT or Apache 2.0). All logic - frontier management, SSRF guard, URL normalizer, extractor framework, and MCP tool definitions - is original code in this repo.
MIT © Timothy Nishimura. See LICENSE.