BoxLang 🚀 A New JVM Dynamic Language Learn More...
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.
| Requirement | Supported |
|---|---|
| ColdBox | 8.x |
| Adobe ColdFusion | 2023+ |
| Lucee | 5, 6 |
| BoxLang | 1 (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.
box install sitemap-spider
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" ]
);
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
create() returns a struct with these keys:
| Key | Type | Description |
|---|---|---|
pages
| array | Crawled page structs. The array keeps
case-sensitive paths such as /Page and
/page separate. |
sitemap
| string | The 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
| array | Child sitemaps when type is
"index". Each contains
filename, xml, and an optional
filePath. Empty for one sitemap. |
sitemapCount
| number | Number of sitemap files: 1
for a single sitemap, otherwise the child count. |
duration
| number | Total crawl + generate time in milliseconds. |
stats
| struct | Counts 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
| array | Every URL the crawler visited. |
badUrls
| struct | URLs that failed to fetch or check, keyed by URL. Each value contains the failure details described in the report reference. |
ignored
| array | Skipped URLs as { url, reason
}. Reasons are "nofollow",
"excluded",
"disallowed",
"noindex", and
"notAllowed". Rejected start and seed
URLs also appear here. |
redirects
| array | One 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
| boolean | Whether this crawl used parallel workers. |
filePath
| string | The path passed as filePath,
or empty when nothing was saved. |
saved
| boolean | true when the sitemap was
written to filePath. |
metadataPath
| string | Where the metadata sidecar was written, or
empty when none was. See writeMetadata. |
metadataSaved
| boolean | true when the metadata
sidecar was written. |
linkReportPath
| string | Where the link report was written, or
empty when none was. See writeLinkReport. |
linkReportSaved
| boolean | true 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.
}
Override any of these in your app's config/ColdBox.cfc
under moduleSettings.sitemap-spider. Defaults come from ModuleConfig.cfc.
| 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. |
| 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.xml
→ sitemap.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.xml →
sitemap.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. |
| 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. |
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. |
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. |
| Playwright | Playwright@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.
Playwright renders JavaScript in headless Chromium. Set it up as follows:
Install the module:
box install cbPlaywright
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
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" );
}
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 = trueto a single-threaded crawl and logs the change. Stop the server cleanly so the crawl can close Chromium. A hard JVM kill can leaveheadless_shellprocesses 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
maxConcurrentJobslow when several jobs use this backend.
<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 Graph | — | og:video
| the page's |
<video>
element | src 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 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.
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.
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" } ).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 |
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 memory | A scheduled
task finds jobs with heartbeats older than
jobStaleSeconds and marks them interrupted
|
| App restarted with a durable store | Startup 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 sweep | Once, shortly after every boot | Marks jobs left running by this
server's previous start as interrupted, matching on
boot id |
| Heartbeat | Every jobHeartbeatSeconds for
shared stores | Writes each running job's counters and heartbeat |
| Dead-job check | Every
jobReaperIntervalSeconds for shared
stores | Marks 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.
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.
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.
The complete source code is publicly available under the PolyForm Perimeter License 1.0.1.
In plain terms:
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.
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.
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.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.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.First stable release. The module crawls a site breadth-first from one or more start URLs and generates a sitemaps.org XML sitemap.
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.
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.
?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.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.writeDump debug output that corrupted responses and test runs.reMatch arguments.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