BoxLang 🚀 A New JVM Dynamic Language Learn More...

sitemap-spider

v1.1.0 Modules

sitemap-spider

Sitemap Spider Logo

A ColdBox module that crawls a website and generates an XML sitemap.

It starts from one or more URLs and follows links breadth-first. It honors robots.txt, follows redirects, and writes a sitemaps.org<urlset> (or a <sitemapindex> for very large sites). By default it fetches static HTML with jsoup. For pages whose links or content are rendered by JavaScript, the optional cbPlaywright backend uses a headless browser.

Source-available under the PolyForm Perimeter License 1.0.1: free to use in your own sites and client projects, commercial included — just not to build a competing sitemap product. See License.

Requirements

Requirement Supported
ColdBox8.x
Adobe ColdFusion2023+
Lucee5, 6
BoxLang1 (CFML compatibility mode)

The only runtime dependency is cbjavaloader, which loads the bundled lib/jsoup-1.21.2.jar at module load. jsoup ships inside the package, so no extra download is needed for the default backend.

The full test suite, including Playwright specs, passes on every supported engine.

Install

box install sitemap-spider

Quickstart

Call create() to crawl a site. It returns the pages, sitemap XML, and timing.

property name="sitemapService" inject="SitemapService@sitemap-spider";

var result = sitemapService.create( "https://example.com/" );

writeOutput( result.sitemap );

Start from several URLs, exclude some, and save the XML to disk in one call:

var result = sitemapService.create(
    url         = [ "https://example.com/", "https://example.com/blog/" ],
    excludeUrls = [ "https://example.com/private/" ],
    filePath    = expandPath( "/public/sitemap.xml" )
);

// result.saved is true and result.filePath contains the saved path.

Use seedUrls for pages that other pages do not link to. The url argument still sets the host, robots.txt base, and split-file base URL. Every seed must use that host.

var result = sitemapService.create(
    url      = "https://example.com/",
    seedUrls = [ "https://example.com/orphan-landing.cfm" ]
);

Publish the sitemap

Save the sitemap at your site's public root so search engines can request /sitemap.xml:

var result = sitemapService.create(
    url      = "https://example.com/",
    filePath = expandPath( "/sitemap.xml" )
);

Most web servers serve this file directly without a ColdBox route. Add the generated file to .gitignore.

Then add its public URL to robots.txt:

Sitemap: https://example.com/sitemap.xml

The return struct

create() returns a struct with these keys:

Key Type Description
pages arrayCrawled page structs. The array keeps case-sensitive paths such as /Page and /page separate.
sitemap stringThe primary sitemap XML. A <urlset> for a normal crawl, or a <sitemapindex> when the crawl is split.
type string"single" for one file, "index" when the output was split into a sitemap index plus child files.
sitemaps arrayChild sitemaps when type is "index". Each contains filename, xml, and an optional filePath. Empty for one sitemap.
sitemapCount numberNumber of sitemap files: 1 for a single sitemap, otherwise the child count.
duration numberTotal crawl + generate time in milliseconds.
stats structCounts for dashboards and job records: { generatedAt, durationMs, urlCount, sitemapCount, type, badUrlCount, ignoredCount, redirectCount, assetsCheckedCount, assetsBrokenCount }. generatedAt is an ISO-8601 timestamp with the server's UTC offset. durationMs equals duration.
processedUrls arrayEvery URL the crawler visited.
badUrls structURLs that failed to fetch or check, keyed by URL. Each value contains the failure details described in the report reference.
ignored arraySkipped URLs as { url, reason }. Reasons are "nofollow", "excluded", "disallowed", "noindex", and "notAllowed". Rejected start and seed URLs also appear here.
redirects arrayOne entry per fetched URL that followed an HTTP redirect: { from, to, status, chain, foundOn, foundOnTruncated }. from is the requested URL, to is the final URL, status is the first hop's status, and chain is the hop list, each { url, status }.
runAsync booleanWhether this crawl used parallel workers.
filePath stringThe path passed as filePath, or empty when nothing was saved.
saved booleantrue when the sitemap was written to filePath.
metadataPath stringWhere the metadata sidecar was written, or empty when none was. See writeMetadata.
metadataSaved booleantrue when the metadata sidecar was written.
linkReportPath stringWhere the link report was written, or empty when none was. See writeLinkReport.
linkReportSaved booleantrue when the link report was written.

Each entry in pages looks like:

{
    url          : "https://example.com/about/",
    lastModified : "2026-07-01",  // Empty when unknown. See lastModFallback.
    priority     : 0.9,           // Starts at 1.0 and decreases with depth.
    depth        : 1,             // Link distance from the start URL.
    images       : [],            // Filled when includeImages is true.
    alternates   : [],            // Filled when includeHreflang is true.
    videos       : []             // Filled when includeVideos is true.
}

Each entry in videos looks like:

{
    title        : "Product tour",
    description  : "A two minute tour.",
    thumbnailLoc : "https://example.com/thumb.jpg",
    contentLoc   : "https://example.com/tour.mp4",   // Media file, or empty.
    playerLoc    : "https://player.example.com/42",  // Player page, or empty.
    duration     : 93                                // Seconds, or 0 when unknown.
}

Settings

Override any of these in your app's config/ColdBox.cfc under moduleSettings.sitemap-spider. Defaults come from ModuleConfig.cfc.

Common crawl settings

Setting Default Effect
browserDsl "Jsoup@sitemap-spider" Which browser backend fetches each URL. Set to "Playwright@sitemap-spider" for JavaScript rendering.
maxDepth 10 Maximum link distance from a start URL to crawl.
maxPages 1000 Maximum number of pages to record in one crawl.
runAsync false Uses asyncMaxThreads workers when the backend supports parallel fetches. Workers share the robots.txt crawl delay. The create() argument overrides this setting for one crawl.
asyncMaxThreads 10 Worker thread count for a parallel crawl. Only used when runAsync is on.
respectRobotsTxt true Honors robots.txt Disallow, Allow, and Crawl-delay. Rules use prefix matching, so /admin/ does not match /admin.
respectNoIndex true Omits pages marked noindex by a robots meta tag or X-Robots-Tag header. Their links are still followed. An agent-scoped header such as googlebot: noindex also counts.
userAgent "sitemap-spider" Sent on every fetch, and matched against robots.txt User-agent groups.
maxCrawlDelay 10 Maximum robots.txt crawl delay in seconds. Parallel workers share this delay between fetches.
notAllowedPattern \.(png\|webp\|svg\|gif\|js\|css\|jpg\|jpeg)$\|javascript:\|mailto:\|tel: Links matching this regex are never crawled (asset files and non-HTTP schemes).
excludePattern "" (empty)Case-insensitive regex matched against the full URL. A match adds reason "excluded" to ignored. excludeUrls handles exact URLs. The create() argument overrides this setting for one crawl.
sessionParams cfid,cftoken,jsessionid,utm_source,... Query-param and ;jsessionid path-param names stripped during URL normalization, so session tokens and tracking params never reach dedup keys or the sitemap. Matched case-insensitively. The full default list also covers utm_medium, utm_campaign, utm_term, utm_content, fbclid, and gclid.
priority 1.0 <priority> assigned to the start URL.
priorityDecrement 0.1 How much <priority> drops per level of depth.
lastModFormat "date" Format of <lastmod>: "date" writes YYYY-MM-DD; "datetime" writes the full W3C timestamp in the server's local timezone.
lastModFallback "omit" "omit" leaves out an unknown <lastmod>; "crawlTime" uses the crawl timestamp.
requestTimeout 10000 Per-request timeout in milliseconds.
maxBodySize 5242880 Maximum response body downloaded by jsoup (5 MB).
maxRedirects 20 Maximum redirect hops for jsoup. Playwright uses its browser limit.
htmlContentTypePattern ^(text/html\|application/xhtml\+xml)(;.*)?$ Content types that are parsed for links and canonical URLs.

Output and report settings

Setting Default Effect
maxUrlsPerSitemap 50000 sitemaps.org per-file URL limit. Above this the output splits into a sitemap index.
maxSitemapBytes 52428800 sitemaps.org per-file byte limit (50 MiB, uncompressed). Above this the output splits.
gzipOutput false Saves compressed files with a .gz suffix. Child filenames and index locations also include .gz. See Output and splitting.
writeMetadata false When true, writes a JSON sidecar holding the stats struct and crawl options. It uses metadataPath when configured, otherwise it is written next to filePath (sitemap.xmlsitemap.xml.meta.json). Read it later with readMetadata() — see Host app integration.
metadataPath "" (empty)Default metadata filename. Set it outside the webroot to keep it private. An omitted method argument uses this setting. A non-empty argument replaces it. An explicit empty argument saves beside the sitemap. Use a distinct path for each crawl or job.
metadataIncludeUrls false Adds full badUrls and ignored lists to metadata. Store this file outside the webroot or block *.meta.json at the web server.
writeLinkReport false When true, writes a JSON report of broken links, redirects, and skipped URLs. It uses linkReportPath when configured, otherwise it is written next to filePath (sitemap.xmlsitemap.xml.links.json). Read it later with readLinkReport(). See Broken link reports.
linkReportPath "" (empty)Default link report filename. Set it outside the webroot to keep it private. An omitted method argument uses this setting. A non-empty argument replaces it. An explicit empty argument saves beside the sitemap. Use a distinct path for each crawl or job.
trackInboundLinks true Records which pages link to each URL, so a broken link can name the pages that need fixing. Referrers are dropped as URLs are fetched successfully, so memory tracks the queue and the failures rather than every link on the site.
maxInboundLinks 10 How many linking pages to keep per URL. Past this the report sets foundOnTruncated.
checkAssets false Requests the status of on-host images, stylesheets, scripts, and linked files that notAllowedPattern blocks. These are never crawled as pages and never reach the sitemap. This is the only part of the report that makes extra HTTP requests. See Broken link reports.
maxAssetChecks 5000 Maximum unique asset URLs checked in one crawl. Reaching it logs a warning and stops collecting.
includeImages false Adds <img src> URLs as <image:image>. Keeps off-host CDN images and limits each page to 1000 images. The create() argument overrides this setting for one crawl.
includeHreflang false Adds alternate links as <xhtml:link>. Keeps off-host targets and x-default, with a limit of 1000 per page. The create() argument overrides this setting for one crawl.
includeVideos false Adds <video:video> from JSON-LD, Open Graph, and <video> elements. Omits videos missing a thumbnail, title, description, or media/player URL. Limits each page to 100 videos. The create() argument overrides this setting for one crawl.

Playwright and runtime settings

Setting Default Effect
waitStrategy "networkidle" Playwright backend only. Page load state to wait for after navigation ("load" or "networkidle").
waitMs 0 Playwright only. Fixed wait after every navigation. Prefer waitForSelector for a known element.
waitForSelector "" (empty)Playwright only. Waits up to requestTimeout for a CSS selector. A timeout logs a warning and continues.
libPath <modulePath>/lib Directory cbjavaloader loads the jsoup jar from.

Background job settings

These settings apply only to background jobs. See Background jobs.

Setting Default Effect
maxConcurrentJobs 3 Jobs that can run at once. Other jobs remain queued. Each Playwright job starts a browser process.
maxRetainedJobs 100 How many finished job records to keep. The oldest are dropped past this. 0 or less keeps everything.
jobStoreDsl "InMemoryJobStore@sitemap-spider" Where job records are kept. The default keeps them in memory, so they are lost on a restart. Point it at your own IJobStore to keep them.
jobNodeId "" (empty)Names this app server in job records. Empty uses the machine's host name. Only matters when several servers share one job store.

The next four settings apply only when IJobStore.isShared() returns true. The default in-memory store does not schedule these tasks. See Background upkeep.

Setting Default Effect
jobHeartbeatSeconds 30 How often a running job writes its counters and a "still alive" timestamp to the store.
jobStaleSeconds 180 How long a job can go without reporting progress before it is treated as dead and marked interrupted. Keep it several times jobHeartbeatSeconds so a slow garbage-collection pause never kills a healthy job.
jobReaperEnabled true Enables stale-job recovery for a shared store.
jobReaperIntervalSeconds 120 How often that task runs. Worst case time to notice a dead job is this plus jobStaleSeconds.

Browser backends

The browserDsl setting selects how each URL is fetched. Both backends share the IBrowser interface in models/browsers/.

Backend browserDsl Use it for
jsoup (default)Jsoup@sitemap-spider Static HTML. Fast, no extra dependency. Links and content already present in the server-rendered HTML.
PlaywrightPlaywright@sitemap-spider Pages whose links or content are added by JavaScript in the browser. Requires the optional cbPlaywright module.

The browserDsl argument to create() or SitemapJobRegistry.queue() overrides the setting for one crawl. Each Playwright crawl starts a browser process, so use it only for sites that need JavaScript.

Custom browser implementations must follow the IBrowser interface. See Custom browser backends for the failure details required by link reports.

cbPlaywright setup (optional)

Playwright renders JavaScript in headless Chromium. Set it up as follows:

  1. Install the module:

    box install cbPlaywright
    
  2. Install the matching browser. cbPlaywright requires its exact Chromium revision. A different revision causes Executable doesn't exist at .... Use the driver's Node CLI:

    <driver>/node.exe cli.js install chromium-headless-shell
    
  3. Add the cbPlaywright jars to the CF classpath in Application.cfc. Also add them to tests/Application.cfc when running specs because TestBox uses a separate application. Missing test jars cause Class not found: PlaywrightImpl.

    this.cbPlaywrightLib = expandPath( "/modules/cbPlaywright/lib" );
    if ( directoryExists( this.cbPlaywrightLib ) ) {
        this.javaSettings = {
            loadPaths               : directoryList( this.cbPlaywrightLib, true, "array", "*.jar" ),
            loadColdFusionClassPath : true,
            reloadOnChange          : false
        };
        // Let Playwright.cfc include cbPlaywright helpers.
        this.mappings[ "/cbPlaywright" ] = expandPath( "/modules/cbPlaywright" );
    }
    
  4. Choose a wait. waitStrategy selects "load" or "networkidle". waitForSelector returns when a known element appears. Use waitMs only when code adds content later and no selector can identify it. waitMs slows every page.

Playwright must use the thread that created its browser. The backend therefore changes runAsync = true to a single-threaded crawl and logs the change. Stop the server cleanly so the crawl can close Chromium. A hard JVM kill can leave headless_shell processes behind. Containers remove these processes when the container stops. ColdBox reinit cancels the crawl and closes its browser.

Each Playwright crawl starts a browser. Keep maxConcurrentJobs low when several jobs use this backend.

Output and splitting

  • <lastmod> is written date-only (YYYY-MM-DD), which is valid per sitemaps.org. When a page has no parseable Last-Modified, lastModFallback decides whether the element is omitted or filled with the crawl time.

  • Splitting into a <sitemapindex> plus numbered child sitemaps happens only when a crawl exceeds maxUrlsPerSitemap (50000) or maxSitemapBytes (50 MiB). Because the default maxPages is 1000, a default crawl never splits — raise maxPages well past 50000 to reach the limit.

  • filePath saves the primary sitemap there (creating the directory if needed). When the output splits, that path receives the <sitemapindex> and each child sitemap is written beside it. A write failure throws sitemap-spider.SaveFailed.

  • publicBaseUrl is the absolute URL prefix the child sitemaps are served from, used for the <sitemapindex> <loc> entries. When omitted, it is derived from the first start URL's directory.

  • Gzip (gzipOutput = true) writes each file compressed with a .gz name. Your web server must serve these .gz files with the Content-Encoding: gzip header (or as an already-gzipped body a crawler will decompress) — otherwise a search engine fetching the URL sees raw binary. The module writes the files; it does not configure your server.

  • Full timestamps (lastModFormat = "datetime") write YYYY-MM-DDThh:mm: ss+HH:MM in the server's local timezone. A page whose date is known only to the day (no time) renders at midnight local (T00:00: 00).

  • Image sitemaps (includeImages = true) add <image:image> entries per page from its <img src> tags, plus the xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" namespace on the <urlset>.

  • hreflang alternates (includeHreflang = true) add an <xhtml:link rel="alternate" hreflang="..." href="..."/> per declared alternate on each <url>, plus the xmlns:xhtml="http://www.w3.org/1999/xhtml" namespace on the <urlset>.

  • Video sitemaps (includeVideos = true) add <video:video> blocks per page (thumbnail, title, description, then a content and/or player URL, then duration in seconds when known), plus the xmlns:video="http://www.google.com/schemas/sitemap-video/1.1" namespace on the <urlset>. Videos come from three sources, read in this order:

    Source Media URL Player URL Title and description
    JSON-LD VideoObject contentUrl embedUrl its own name and description
    Open Graphog:video the page's
    <video> elementsrc or <source src> the page's

    JSON-LD is read first. Its title, description, and thumbnailUrl replace page-level fallbacks. It can also provide both media and player URLs. When sources contain the same URL, the first source wins. VideoObject can appear at the top level, in an array, under @graph, or inside another type. Invalid JSON is skipped.

    <video:duration> comes from the JSON-LD duration field, converted from ISO 8601 (PT1M33S) to the whole seconds the sitemap protocol wants. A value that cannot be read, or that falls outside Google's 1–28800 second range, is left out. The other two sources carry no duration.

  • Per-crawl extension flags. create() and SitemapJobRegistry.queue() accept includeImages, includeHreflang, and includeVideos. Each argument overrides its setting for one crawl. Omit it to use the setting.

    sitemapService.create( url = "https://example.com/", includeVideos = true );
    

Enable writeLinkReport to save broken pages, redirects, and skipped URLs as JSON beside the sitemap:

var result = sitemapService.create(
    url             = "https://example.com/",
    filePath        = expandPath( "/sitemap.xml" ),
    writeLinkReport = true
);

// result.linkReportPath -> /path/to/sitemap.xml.links.json
var report = sitemapService.readLinkReport( result.linkReportPath );

Broken-page reporting adds no HTTP requests because the crawler already visits those URLs. Enable checkAssets to make additional checks for on-host images, stylesheets, scripts, and linked files:

moduleSettings = {
    "sitemap-spider" : { checkAssets : true, writeLinkReport : true }
};

Store link reports outside the webroot or block *.links.json at the web server because they can reveal failed and deliberately skipped URLs.

See Broken link report reference for the JSON format, failure reasons, asset behavior, and custom browser requirements.

Host app integration

Host applications usually save the sitemap in the webroot, list it in robots.txt, and save generation statistics.

Keep crawl scope deliberate. robots.txt paths are prefix matches, and a trailing slash is significant. Disallow: /admin/ blocks descendants such as /admin/users, but it does not block a link to /admin. A slashless Disallow: /admin blocks both, but also blocks /administrator. To target only the exact route and its descendants, use:

User-agent: *
Disallow: /admin$
Disallow: /admin/

For a sitemap-specific safety net independent of robots.txt, configure excludePattern = "/admin(?:/|\?|$)".

URL paths also remain distinct after normalization: repeated slashes such as //about/ collapse to /about/, but /about and /about/ are not merged. Sites that serve the same page at both URLs should emit one consistent link form and redirect the other, or declare a common rel="canonical".

Save stats without job records. Use a private path so URL details cannot become public:

moduleSettings = {
    "sitemap-spider" : {
        metadataPath : expandPath( "/../private/sitemap.xml.meta.json" )
    }
};

With writeMetadata enabled, generation writes the sidecar to metadataPath. An empty path writes sitemap.xml.meta.json beside the sitemap. The file remains available after in-memory job records disappear.

var metadata = sitemapService.readMetadata( expandPath( "/sitemap.xml" ) );
if ( metadata.exists ) {
    // Read metadata.stats and metadata.options.
}

readMetadata() accepts a sitemap or sidecar path. It returns { exists : false } for a missing or invalid file. A sitemap path uses the configured metadataPath; a .meta.json path is read directly. When one crawl overrides the setting, read its returned result.metadataPath. Keep metadataIncludeUrls disabled unless the file is private.

Add a non-blocking generate button. create() waits for the crawl. For an admin action, queue a job and poll it or listen for its completion event.

property name="jobs" inject="SitemapJobRegistry@sitemap-spider";

// Returns a job id immediately; the crawl runs on a pool thread.
var jobId = jobs.queue(
    url           = "https://example.com/",
    filePath      = expandPath( "/sitemap.xml" ),
    writeMetadata = true
);

// Poll jobs.getJob( jobId ).progress or listen for completion events.

See Background jobs for the full job API, and Keeping job records if you want job history to survive restarts too.

Background jobs

SitemapService.create() waits for the crawl to finish. SitemapJobRegistry.queue() returns a job ID and runs the crawl on a background thread pool. Use it for concurrent crawls, progress, or cancellation.

property name="jobs" inject="SitemapJobRegistry@sitemap-spider";

// filePath is required because the background job must save its output.
var jobId = jobs.queue(
    url      = "https://example.com/",
    filePath = expandPath( "/sitemaps/example.xml" )
);

var job = jobs.getJob( jobId );
// job.status   -> queued | running | completed | failed | canceled | interrupted
// job.progress -> { pagesFound, urlsProcessed, badUrls, remaining, elapsedMs, ... }
// job.result   -> { saved, filePath, type, sitemapCount, stats, metadataPath,
//                   metadataSaved, linkReportPath, linkReportSaved }
//                 once it completes

jobs.listJobs();            // every job, newest first
jobs.cancel( jobId );       // stops after the page it is on
jobs.remove( jobId );       // drops the record

maxConcurrentJobs sets the running-job limit. Other jobs remain queued. Each job is single-threaded unless it receives runAsync = true. The maximum crawl worker count is then maxConcurrentJobs × asyncMaxThreads; keep it within the engine's thread limit.

Per-site options

queue() takes the same crawl arguments as create(), plus:

  • browserDsl selects the backend. Empty uses the module setting. create() also accepts this argument. Each Playwright job starts a browser.
  • includeImages, includeHreflang, includeVideos override module settings for that job. Values are saved when the job is queued, so later setting changes do not affect it.
  • writeMetadata, metadataPath, metadataIncludeUrls control the job's JSON sidecar. These values are also saved at queue time. An explicit empty path saves beside the sitemap. The result includes stats, metadataPath, and metadataSaved.
  • writeLinkReport, linkReportPath control the job's link report, and are saved at queue time the same way. The result includes linkReportPath and linkReportSaved, so a background crawl can point at its report. See Broken link reports.
  • meta stores your own values without interpreting them. Filter these values with jobs.listJobs( { customerId : "acme" } ). You can also filter status with jobs.listJobs( { status : "running" } ).

Reacting to jobs

The module announces these ColdBox interception points with { jobId, record }. Listeners can upload a file, send a notification, or choose whether to retry.

Point When
onSitemapJobQueued A job was accepted
onSitemapJobStarted A worker picked it up
onSitemapJobCompleted The crawl finished and the file was written
onSitemapJobFailed The crawl threw
onSitemapJobInterrupted It was canceled, or its process died

What happens when the app restarts or crashes

Crawls cannot survive a stopped JVM or resume partway through. The registry marks stopped jobs interrupted so callers can decide whether to run them again.

What happened How it is handled
ColdBox reinit (?fwreinit=1)The module cancels its running crawls and records them as interrupted before the framework rebuilds
Server stopped, killed, or out of memoryA scheduled task finds jobs with heartbeats older than jobStaleSeconds and marks them interrupted
App restarted with a durable storeStartup recovery uses the boot ID to mark jobs from the earlier application start interrupted

config/Scheduler.cfc registers three recovery tasks:

Task When it runs What it does
Startup sweepOnce, shortly after every bootMarks jobs left running by this server's previous start as interrupted, matching on boot id
HeartbeatEvery jobHeartbeatSeconds for shared storesWrites each running job's counters and heartbeat
Dead-job checkEvery jobReaperIntervalSeconds for shared storesMarks jobs with stale heartbeats interrupted

isShared() must return true only when multiple app servers use the same records. Otherwise, the heartbeat and dead-job tasks are disabled before they reach the scheduler. A single server reads its live counters from memory, and startup recovery handles records from its earlier application start. Changing jobStoreDsl requires a reinit because the scheduler checks the store at load.

Keeping job records

By default records live in memory (InMemoryJobStore) and are lost on a restart. The saved sitemap files are not affected. To keep records, write a component implementing IJobStore (see models/jobs/IJobStore.cfc) and point the module at it:

moduleSettings = {
    "sitemap-spider" : { jobStoreDsl : "MyDbJobStore@myapp" }
};

The store saves status changes and heartbeats, not every counter change. Three methods have important requirements:

  • isShared() must return true only when multiple app servers use the same records. Return false for a single-server durable store such as SQLite. A false positive schedules unnecessary heartbeat writes and stale-job queries. A false negative leaves another crashed server's job marked running.
  • claim() must atomically move one job from queued to running for one caller. In a database, use a conditional update and check that one row changed. This prevents two servers from running the same job.
  • save() with expectedOwnerId must write only when that owner still owns the job. This rejects a late write from a crawl thread that survived reinit.

With a durable store, your application must requeue jobs left queued after a stop. It must also decide whether to retry interrupted jobs. Use attempts to limit retries.

Third-party software

This package bundles jsoup (lib/jsoup-1.21.2.jar), copyright Jonathan Hedley, used under the MIT License. jsoup's own terms apply to it, not the terms below.

License

The complete source code is publicly available under the PolyForm Perimeter License 1.0.1.

In plain terms:

  • You may use this module in personal and commercial website projects, including client work. You may modify it and redistribute it.
  • You may not use it to provide a competing sitemap generation product or hosted sitemap generation service.

This summary is not the license. LICENSE.md governs use.

sitemap-spider is source-available, not open source. PolyForm Perimeter restricts using it to compete with this module.

Copyright Angry Sam Productions, Inc.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.


[Unreleased]

[1.1.0] - 2026-07-30

Added

  • A broken link report, saved as JSON beside the sitemap. A crawl already visits every page, so it already knows which links are dead; the report saves that instead of discarding it. writeLinkReport (setting and per-crawl argument, default off) writes broken, redirects, and skipped lists plus a summary of counts. The new linkReportPath setting supplies a private default location; when empty, <filePath minus .gz>.links.json is written beside the sitemap, following the same rules as the metadata sidecar. Read it back with SitemapService.readLinkReport( path ), which accepts either path and returns { exists : false } when the file is missing or unreadable. A failed write throws the typed sitemap-spider.LinkReportSaveFailed. The result struct gained linkReportPath and linkReportSaved; queue() passes both arguments through, stores them on the job record, and returns them in result. Reporting broken pages costs no extra HTTP requests.
  • models/LinkReportGenerator.cfc, a stateless singleton that turns a crawl result into the report struct. It sorts broken with server errors first, then missing pages, then transport failures, and marks a redirects entry permanent for a 301 or 308.
  • Failure detail on every badUrls entry. Alongside the existing message, each entry now carries status (the HTTP status, or 0 when the request never got a response), reason, kind ("page" or "asset"), redirectChain, foundOn, and foundOnTruncated. reason is one of notFound, serverError, clientError, redirectError, tooManyRedirects, timeout, connectionFailed, or unknown. Previously a 404, a 500, a timeout, and a redirect loop were indistinguishable without parsing English out of message.
  • foundOn names the pages that link to a broken or redirected URL, which is what makes a report actionable. trackInboundLinks (default on) and maxInboundLinks (default 10) control it. Referrers are dropped as URLs are fetched successfully, so memory tracks the queue and the failures rather than every link on the site. A redirected URL keeps its referrers, because those are the links worth updating.
  • checkAssets setting (default off) requests the status of on-host images, stylesheets, scripts, and links to files that notAllowedPattern blocks — broken images were previously invisible. Checks run after the page crawl, so each unique asset is requested once no matter how many pages use it. They use HEAD, falling back to a one-byte GET on a 405 or 501, never enter pages or maxPages or the sitemap XML, and ignore off-host URLs. maxAssetChecks (default 5000) caps them. This is the only part of the report that makes extra requests.
  • stats gained assetsCheckedCount and assetsBrokenCount. The crawl result gained assetsChecked.
  • redirects entries gained status (the first hop's status), foundOn, and foundOnTruncated.

Changed

  • Breaking for custom browser backends. IBrowser now requires checkUrl( url ), which returns { ok, status, url, redirectChain, error } and must never throw. A backend extending BaseBrowser inherits a working implementation that asks the jsoup backend for the status, so no change is needed there. A backend implementing IBrowser directly must add the method.
  • fetchUrl() implementations should now set errorCode (the HTTP status) and extendedInfo (JSON with status, url, and chain) on a thrown StatusCodeException. Both bundled backends do. A backend that omits them still crawls correctly; its failures report status: 0 and reason: "unknown".
  • Parser.getLinks() returns a third array, assets: on-host links rejected only by notAllowedPattern. Off-host links are still dropped. Added Parser.getAssetLinks() for embedded images, stylesheets, and scripts, and Parser.isSameHost(), which isUrlAllowed() now builds on.
  • The Playwright backend now builds its redirect chain before checking the status, so a failed navigation reports the hops that led to it.

[1.0.0] - 2026-07-30

First stable release. The module crawls a site breadth-first from one or more start URLs and generates a sitemaps.org XML sitemap.

Added

  • A pre-computed stats struct on the create() result and on completed job records (result.stats): generatedAt (ISO-8601 with UTC offset), durationMs, urlCount, sitemapCount, type, badUrlCount, ignoredCount, and redirectCount. Saves every caller from deriving counts out of the raw pages / badUrls / ignored / redirects collections.

  • A JSON metadata "sidecar" file, for admin screens that show "last generated" stats without keeping job records. writeMetadata (setting and per-crawl argument, default off) writes the stats struct, crawl options, and module version. The new module-wide metadataPath setting supplies a private default location; when empty, <filePath minus .gz>.meta.json is written beside the sitemap. A non-empty per-crawl argument overrides the setting, while an explicitly empty argument forces adjacent-file derivation. This distinction is snapshotted on queued jobs. Move the sidecar outside the webroot before enabling metadataIncludeUrls, which adds the full badUrls and ignored lists. Read it back with the new SitemapService.readMetadata( path ), which accepts the sitemap path or the sidecar path and returns { exists : false } instead of throwing when the file is missing or unreadable. A sitemap path honors the module setting; an explicit sidecar path is read literally. A failed sidecar write throws the typed sitemap-spider.MetadataSaveFailed. The result struct gained metadataPath and metadataSaved keys; queue() passes all three arguments through and stores them on the job record.

  • respectNoIndex setting (default true). A page carrying <meta name="robots" content="noindex"> (or none) or an X-Robots-Tag: noindex response header is left out of the sitemap and reported in ignored with the new reason "noindex". Its links are still followed, because noindex does not mean nofollow. An agent-scoped header directive (googlebot: noindex) counts as global on purpose. Set the flag to false to list such pages anyway.

  • JSON-LD videos. includeVideos now reads schema.org VideoObject entries out of <script type="application/ld+json"> blocks, on top of the Open Graph tags and <video> elements it already read. Entries are found wherever they sit in the block: at the top level, in an array, under @graph, or nested inside another type. contentUrl becomes <video:content_loc>, embedUrl becomes <video:player_loc>, and the block's own name, description and thumbnailUrl are used instead of the page-level fallbacks. A block that is not valid JSON is skipped and the crawl carries on. JSON-LD is read before the other two sources, so when two of them name the same URL the JSON-LD entry is the one that survives.

  • <video:duration>, converted from the JSON-LD duration field's ISO 8601 form (PT1M33S) to the whole seconds the sitemap protocol wants. Video structs on the crawl result gained a matching duration key. A value that cannot be read, or that falls outside Google's 1–28800 second range, is left out.

  • includeImages, includeHreflang and includeVideos arguments on SitemapService.create(), Crawler.crawl() and SitemapJobRegistry.queue(). Each overrides the module setting of the same name for one crawl, in either direction; omitting the argument uses the setting. A host generating sitemaps for many sites needs this, because the right extensions differ per site. Job records gained includeImages, includeHreflang and includeVideos keys; a record written before they existed still runs, falling back to the settings.

  • Background sitemap jobs, so a host app can crawl several sites at once, watch each one's progress, and cancel one. SitemapJobRegistry@sitemap-spider provides queue() (returns a job id right away), getJob(), listJobs(), cancel(), and remove(). maxConcurrentJobs (default 3) sets how many run at once; the rest wait as queued and start as slots free up. A queued job must be given a filePath, because a background job has no response to write to. Calling SitemapService.create() directly is unchanged and still blocks.

  • Live crawl progress and cancellation. SitemapService.create() and Crawler.crawl() take an optional progress argument (a CrawlProgress) reporting pages found, URLs processed, bad URLs, how many are left, and elapsed time. Cancelling it stops the crawl after the page it is on. Omitting the argument leaves behavior exactly as before.

  • browserDsl argument on SitemapService.create(), Crawler.crawl(), and SitemapJobRegistry.queue(), choosing the browser backend for one crawl. Empty uses the browserDsl setting. This is for hosts crawling many sites where only some need JavaScript rendering.

  • Pluggable job storage. IJobStore defines where job records live, and InMemoryJobStore (the default) keeps them in memory. Point the new jobStoreDsl setting at your own implementation to keep records across restarts or to share them between app servers. The counters that change during a crawl never reach the store — it only sees status changes and a periodic heartbeat.

  • Recovery for jobs that stop unexpectedly, so none are left looking like they are still running. A ColdBox reinit cancels running crawls and records them as interrupted; a background task marks jobs that stop reporting progress (jobStaleSeconds); and with a durable store, jobs left running by a previous start of the app are marked at startup. The upkeep ships with the module in config/Scheduler.cfc and needs no wiring. New settings: jobNodeId, jobHeartbeatSeconds, jobStaleSeconds, jobReaperEnabled, jobReaperIntervalSeconds, maxRetainedJobs.

  • Job events a host app can listen to: onSitemapJobQueued, onSitemapJobStarted, onSitemapJobCompleted, onSitemapJobFailed, and onSitemapJobInterrupted, each with { jobId, record }. Use them to upload the finished file, notify someone, or decide whether to retry. Announcing an event nobody listens for does nothing.

  • meta argument on SitemapJobRegistry.queue(): a struct of the host's own values (site id, customer id, and so on) stored with the job, returned on every read, and usable as a filter in listJobs(). This module never reads it.

  • gzipOutput module setting: when true and a filePath is given, the sitemap files are written gzip-compressed with a .gz suffix (e.g. sitemap.xml.gz). For a split set the child filenames and the <sitemapindex> <loc> entries carry .gz too, and result.filePath reports the .gz path. Defaults to false (plain XML).

  • lastModFormat module setting: "date" (default) writes the date-only <lastmod> (YYYY-MM-DD); "datetime" writes the full W3C timestamp (YYYY-MM-DDThh:mm: ss+HH:MM) in the server's local timezone.

  • includeImages module setting: when true, each crawled page's <img src> images are collected and emitted as <image:image> entries, and the <urlset> gains the image namespace. Images are not host-filtered (CDN images are kept), and each page's images array is included in the crawl result. Defaults to false (no image entries, output unchanged).

  • includeHreflang module setting: when true, each crawled page's <link rel="alternate" hreflang="..."> tags are collected and emitted as <xhtml:link> entries, and the <urlset> gains the xhtml namespace. Alternates are emitted exactly as the page declared them — off-host targets are kept and x-default is allowed — and each page's alternates array is included in the crawl result. Defaults to false (output unchanged).

  • includeVideos module setting: when true, each crawled page's videos are collected and emitted as <video:video> blocks, and the <urlset> gains the video namespace. Videos are read from Open Graph tags (og:video becomes <video:player_loc>) and <video> elements (src or a <source src> child becomes <video:content_loc>, poster the thumbnail), with the page title, meta description, and og:image as fallbacks for the required fields. A video still missing a required field is dropped. Each page's videos array is included in the crawl result. Defaults to false (output unchanged).

  • seedUrls argument on SitemapService.create(): an array of extra start URLs for orphan pages that no reachable page links to. They are crawled alongside url; the host, robots.txt base, and split-file base URL still come from the first url, so all seeds must share its host.

  • ignored key in the crawl result: an array of { url, reason } structs for URLs the crawl dropped, so you can see what was skipped and why. Reasons are "nofollow" (a rel="nofollow" link), "excluded" (matched excludeUrls or excludePattern), "disallowed" (blocked by robots.txt), and "notAllowed" (a rejected start/seed URL that is off-host, the wrong scheme, or an asset URL). A rejected start or seed URL is reported here too.

  • excludePattern module setting: a regex matched case-insensitively against the full URL for whole-section excludes (e.g. /admin/). A match skips the URL and reports it in ignored with reason "excluded". This is separate from the per-crawl exact-URL excludeUrls argument. Defaults to empty (no pattern exclusion).

  • excludePattern argument on SitemapService.create(): the same section-exclude regex, per crawl. When passed non-empty it overrides the excludePattern module setting for that crawl only; empty falls back to the setting. Lets one crawl exclude a section without changing global config.

  • waitForSelector module setting (Playwright backend): a CSS selector to wait for after navigation, so you can wait for a known JS-injected element instead of a large blanket waitMs. It returns as soon as the element appears (bounded by requestTimeout); a selector that never appears logs a warning and the fetch continues. Defaults to empty (disabled); combines with waitMs.

  • maxRedirects module setting: the most HTTP redirect hops the Jsoup backend follows for one URL before recording it as bad. It now follows redirects itself so it can enforce this limit and report the hop chain. Defaults to 20 (matching jsoup's own cap, so normal sites are unaffected).

  • redirects key in the crawl result: one { from, to, chain } entry per fetched URL that followed an HTTP redirect. from is the requested URL, to is the final URL, and chain lists each hop as { url, status }.

  • Parallel crawling. runAsync (setting and per-crawl argument, default false) fetches URLs on asyncMaxThreads worker threads (default 10) when the browser backend is parallel-safe. A robots.txt Crawl-delay is still honored by spacing fetches across the workers.

  • Breadth-first crawler with configurable maxDepth and maxPages.

  • robots.txt support: fetches and honors Disallow / Allow rules, selects the matching User-agent group, and applies Crawl-delay up to maxCrawlDelay.

  • Redirect handling for HTTP redirects and <meta> refresh, with the final URL recorded once.

  • Configurable browser backend selected by the browserDsl setting: jsoup (default, static HTML) or cbPlaywright (Playwright@sitemap-spider) for JavaScript-rendered pages, with waitStrategy / waitMs controls.

  • W3C <lastmod> output (date-only), with a lastModFallback setting to omit the element or use the crawl time when a page has no Last-Modified.

  • Save-to-file via create( ..., filePath = ), creating the directory as needed and throwing sitemap-spider.SaveFailed on a write error.

  • Sitemap index splitting: when a crawl exceeds maxUrlsPerSitemap (50000) or maxSitemapBytes (50 MiB), the output becomes a <sitemapindex> plus numbered child <urlset> files, with an optional publicBaseUrl for the index entries.

  • Unit, integration, and contract test coverage under test-harness.

Changed

  • Breaking: IJobStore gained an isShared() method. A custom job store must add boolean function isShared() or it will no longer instantiate. Return false for a store only one app server reaches, even a durable one; return true when several app servers read the same records. The answer decides whether the module's two background timer tasks run at all.

  • The heartbeat and dead-job tasks now only apply to a store that reports isShared() as true, and when it does not they are never registered with the scheduler: the module asks the store once while it loads, and skips handing either task to the executor. Nothing is scheduled and no thread wakes on an interval. Neither task could achieve anything otherwise: a single server reads its own live counters straight out of memory rather than from the store, and the once-per-boot startup sweep already clears rows its own previous start left behind. With the default InMemoryJobStore the dead-job check could never have found anything at all, because a record only goes stale when the heartbeat task stops, and both tasks share one executor. So a default install now does no background work. The startup sweep still always runs, which is what a durable single-server store needs. Because the store is asked while the module loads, changing jobStoreDsl needs a reinit to take effect.

  • Default jobHeartbeatSeconds is now 30 (was 15), jobStaleSeconds 180 (was 90), and jobReaperIntervalSeconds 120 (was 60). A crawl runs for minutes, so the old intervals were tuned tighter than anything needed. These only apply to a shared store now.

  • jobReaperEnabled is now a second off-switch on top of the store's own isShared() answer, rather than the only one. Setting it false still stops the dead-job task on a shared store; setting it true no longer switches that task on for an unshared store.

  • License changed from MIT to the PolyForm Perimeter License 1.0.1. You can still use sitemap-spider in personal and commercial website projects, including client work, and you can still modify and redistribute it. You cannot use it to provide a competing sitemap generation product or hosted sitemap generation service. The license file is now LICENSE.md instead of LICENSE, and the readme has a section explaining the terms in plain English.

  • SitemapService.create() now builds a fresh Crawler for each call instead of reusing one injected instance. A crawler keeps the whole crawl in its own variables scope, so two crawls running at the same time used to overwrite each other's state. Each crawl now gets its own crawler and its own browser. Single crawls behave exactly as before.

  • A crawl now takes its own copy of the module settings when it starts. Settings are one shared struct, so without this a second crawl running at the same time, or a host changing a setting mid-crawl, could alter a crawl already underway. Changing a setting now affects crawls that start afterwards.

  • Parser.getLinks() now returns a struct { links, ignored } instead of a plain array of link strings. links is the same crawlable-URL array as before; ignored lists the page's rel="nofollow" drops as { url, reason }.

  • respectRobotsTxt still defaults to true. Most users crawl their own site and may prefer to ignore robots.txt; set respectRobotsTxt = false in the module settings to do so.

  • A robots.txt Crawl-delay no longer forces a single-threaded crawl. A parallel crawl (runAsync = true) now honors the delay by spacing fetches Crawl-delay seconds apart across the workers, so a delayed site can still be crawled in parallel. A crawl still runs single-threaded only when the browser backend is not parallel-safe (e.g. Playwright).

  • Breaking: the crawl result pages is now an array of page structs (each { url, lastModified, priority, depth, images }), not a struct keyed by URL. A CFML struct's keys are case-insensitive, so keying pages by URL merged two URLs that differ only in path case (/Page vs /page) even though the crawl visits them separately; an array keeps them distinct. SitemapGenerator's generate() and generateSet() now take pages as an array too.

  • A rejected start or seed URL is now reported in ignored with its reason ("excluded", "disallowed", or the new "notAllowed"), instead of only being logged.

  • Consolidated the fetch layer behind the IBrowser interface and removed the old cfhttp backend (jsoup covers static fetching).

  • Refactored the crawler's bookkeeping to O(1) struct lookups, replacing the previous O(n) scans.

Fixed

  • Two stacktraces on every ?fwreinit=true, reading has no accessible Member with name [PROGRESSMAP] or [STORE]. WireBox puts a singleton in its cache before wiring it, so that circular dependencies can resolve, which meant a scheduled task firing during a reinit could fetch a SitemapJobRegistry whose onDiComplete() had not run yet. Both timer tasks fired at once because neither had a startup delay. SitemapJobRegistry is now marked threadsafe, so WireBox only publishes it once wiring has finished; its background methods return 0 rather than throwing if they are somehow called earlier; and the timer tasks are staggered so neither starts during the reinit. It self-healed on the next tick before, but put two stacktraces in the log each time.
  • A failing scheduled task logged a second, misleading stacktrace (No matching function [ERR] found) instead of the intended one-line message. The failure handlers in config/Scheduler.cfc called err(), and the startup and dead-job tasks called out(), but both belong to ScheduledTask rather than to a scheduler, so neither resolved. out() could never have worked at all, because ColdBox calls a task closure with no arguments. They now use the LogBox logger ColdBox injects into the scheduler as log. This affected every engine, not just Lucee.
  • Removed leftover writeDump debug output that corrupted responses and test runs.
  • Corrected the URL filter's inverted reMatch arguments.
  • Reset crawler state at the start of each crawl so a reused (cached) instance no longer carries pages over from a previous crawl.

Removed

  • Breaking: the disallowedUrls array is gone from the crawl result. URLs skipped by robots.txt are reported in ignored with reason "disallowed", which already carried them, so the separate array was redundant.

$ box install sitemap-spider

No collaborators yet.
     
  • {{ getFullDate("2026-07-30T21:17:53Z") }}
  • {{ getFullDate("2026-07-31T00:18:32Z") }}
  • 13
  • 3