Skip to main content

Script Hooks

Script hooks let you inject custom JavaScript into the scrape pipeline at two points — before and after the HTML-to-Markdown conversion. Use them when the config options alone can't express what you need: DOM cleanup, custom MDX component generation, or post-conversion string transformations.

Setup

Add a scripts key inside scrape in your config.json:

{
"scrape": {
"scripts": {
"pre": "./scripts/pre.js",
"post": ["./scripts/normalize.js", "./scripts/fix-links.js"]
}
}
}

Both pre and post accept a single path string or an array of paths. Multiple scripts run in the order listed. Paths are resolved relative to your working directory.

Where Each Hook Runs

HTML fetched

Elements removed (elements_to_remove)

► PRE-PROCESS scripts run here (DOM is live)

Components converted (scrape.components)

HTML preserved (tables, iframes)

Images processed

Turndown converts HTML → Markdown

Component placeholders restored

Built-in cleanup (spacing, anchors, blank lines)

► POST-PROCESS scripts run here (final markdown string)

Frontmatter added → MDX file written

Error Handling

  • Load errors (file not found, syntax error) — stop the scrape immediately. Silent failures would mask misconfiguration.
  • Runtime errors on a specific page — fail that page and continue with others, matching the behavior of HTTP errors.

Pre-process Scripts

Runs while the Cheerio DOM is still live, before Turndown. You can read, add, remove, or restructure any element. Mutations to $ are reflected in the final output.

Signature

export default function($, pageUrl, config, { pm }) { ... }
// async is also supported:
export default async function($, pageUrl, config, { pm }) { ... }
ArgumentTypeDescription
$Cheerio instanceThe content DOM, already scoped to content_selector — mutate it directly
pageUrlstringURL of the page being scraped
configobjectFull scrape config section from config.json
{ pm }objectContext object — contains the PlaceholderManager (see below)

DOM Cleanup

The simplest use case: remove elements and fix attributes before conversion.

// scripts/pre.js
export default function($, pageUrl) {
// remove chrome that shouldn't be in the output
$(".page-nav, .feedback-widget, .last-updated, .edit-on-github").remove();

// rewrite legacy internal links
$("a[href^='/old-docs']").each((_, el) => {
$(el).attr("href", $(el).attr("href").replace("/old-docs", "/docs"));
});

// unwrap unnecessary container divs
$(".content-wrapper > div.inner").each((_, el) => {
$(el).replaceWith($(el).html());
});
}

Conditional Logic by URL

Scripts receive the page URL, so you can apply different transformations per section of the site.

export default function($, pageUrl) {
if (pageUrl.includes("/api-reference/")) {
$(".param-table th:last-child").remove(); // strip "Required" column on API pages
$(".param-table td:last-child").remove();
}

if (pageUrl.includes("/changelog/")) {
$("time.release-date").each((_, el) => {
$(el).replaceWith(`**${$(el).text()}**`);
});
}
}

Custom MDX Conversions with the PlaceholderManager

The pm argument is the key to converting HTML patterns into arbitrary MDX components. Without it, any text you write into the DOM would get processed by Turndown and potentially escaped or restructured. With it, you can store MDX content as an opaque token that Turndown ignores entirely.

How it works

$el.replaceWith(pm.store("<Steps>...</Steps>", "CUSTOM"))

Turndown sees: ||CUSTOM|0|a1b2|| ← opaque token, passed through unchanged

pm.restore() runs after all conversion steps

Output contains: <Steps>...</Steps> ← exactly as written

pm.store(content, label) accepts any string — valid MDX, raw HTML, or a mix. The label is just a human-readable tag used in the token; it doesn't affect behavior.

Example: <Steps> / <Step>

// scripts/pre.js
export default function($, pageUrl, config, { pm }) {
$("div.procedure, ol.steps").each((_, el) => {
const $el = $(el);

const steps = $el.find("> li, > .step").map((i, step) => {
const $step = $(step);
const title = $step.find(".step-title, > strong").first().text().trim();
$step.find(".step-title, > strong").first().remove();
const body = $step.html()?.trim() || "";
return `<Step title="${title}">\n\n${body}\n\n</Step>`;
}).get().join("\n\n");

$el.replaceWith(pm.store(`<Steps>\n\n${steps}\n\n</Steps>`, "STEPS"));
});
}

Example: <Tabs> from a custom pattern

export default function($, pageUrl, config, { pm }) {
$(".platform-switcher").each((_, el) => {
const $el = $(el);

const tabs = $el.find(".platform-tab").map((_, tab) => {
const $tab = $(tab);
const title = $tab.attr("data-platform") || "Tab";
const body = $tab.html()?.trim() || "";
return `<Tab title="${title}">\n\n${body}\n\n</Tab>`;
}).get().join("\n\n");

$el.replaceWith(pm.store(`<Tabs>\n\n${tabs}\n\n</Tabs>`, "TABS"));
});
}

Example: preserve a raw HTML block unchanged

export default function($, pageUrl, config, { pm }) {
// keep complex SVG diagrams as raw HTML — Turndown would strip them
$("figure.diagram").each((_, el) => {
const $el = $(el);
$el.replaceWith(pm.store($.html($el), "SVG"));
});
}

Post-process Scripts

Runs on the final markdown string, after Turndown, component replacement, HTML restoration, and all built-in cleanup. The input is what will be written to the MDX file (minus frontmatter). You must return the string.

Signature

export default function(markdown, pageUrl, config) { return markdown; }
// async is also supported:
export default async function(markdown, pageUrl, config) { return markdown; }
ArgumentTypeDescription
markdownstringFully converted Markdown/MDX content (no frontmatter)
pageUrlstringURL of the page being scraped
configobjectFull scrape config section from config.json

If a script returns a non-string value, a warning is printed and the original markdown is kept.

Pattern Replacement

Good for cleaning up artifacts left by the conversion — legacy shortcodes, misformatted syntax, or recurring structural issues.

// scripts/post.js
export default function(markdown, pageUrl) {
// replace Hugo shortcodes not caught by the HTML converter
markdown = markdown.replace(/\{\{<\s*note\s*>\}\}/g, "<Note>");
markdown = markdown.replace(/\{\{<\s*\/note\s*>\}\}/g, "</Note>");

// fix double-escaped backticks that Turndown sometimes produces
markdown = markdown.replace(/\\`\\`\\`/g, "```");

return markdown;
}

Per-page Logic by URL

export default function(markdown, pageUrl) {
// strip auto-generated boilerplate intro on API pages
if (pageUrl.includes("/api-reference/")) {
markdown = markdown.replace(/^> This page was auto-generated.*?\n\n/m, "");
}

// add a deprecation notice to legacy pages
if (pageUrl.includes("/v1/")) {
markdown = `:::warning\nThis page documents a deprecated API version.\n:::\n\n` + markdown;
}

return markdown;
}

Chaining Multiple Scripts

When post is an array, scripts run in order — each receives the output of the previous.

{
"scripts": {
"post": [
"./scripts/fix-shortcodes.js",
"./scripts/normalize-links.js",
"./scripts/add-notices.js"
]
}
}

This lets you keep each script focused on one concern rather than combining everything in one file.