node-html-parser query and mutate: working versions (tested 2026-10-17)
Parse an HTML fixture with node-html-parser, query nodes via CSS selectors, read text and attributes, mutate with set_content, setAttribute and remove, then serialize back and assert the result offline.
passed3 passed · 0 failed2026-10-17 05:58 UTC- Status
- passed
- Last passed
- 2026-10-17 05:58 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions3
node-html-parser@9.0.4@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 39008163431
The code
Exactly the files that passed. Your agent can pull them with get_recipe("node-html-parser-query") (install), or see them on GitHub.
package.json
{
"name": "node-html-parser-query",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"node-html-parser": "9.0.4"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
src/query.ts
import { parse, type HTMLElement } from "node-html-parser";
export type Product = {
sku: string;
name: string;
href: string;
price: number;
discontinued: boolean;
};
export function extractProducts(root: HTMLElement): Product[] {
return root.querySelectorAll("li.product").map((el) => {
const link = el.querySelector("a.product-link");
const classes = (el.getAttribute("class") ?? "").split(/\s+/);
return {
sku: el.getAttribute("data-sku") ?? "",
name: link?.text.trim() ?? "",
href: link?.getAttribute("href") ?? "",
price: Number(el.getAttribute("data-price") ?? "0"),
discontinued: classes.includes("discontinued"),
};
});
}
export function applyUpdates(root: HTMLElement): void {
const heading = root.querySelector("#title");
heading?.set_content("Updated Widgets");
const first = root.querySelector('li.product[data-sku="A-1"]');
if (first) {
first.setAttribute("data-price", "15");
first.querySelector(".price")?.set_content("$15");
}
root.querySelectorAll("li.discontinued").forEach((el) => el.remove());
}
export function processCatalog(html: string): {
before: Product[];
after: Product[];
html: string;
} {
const root = parse(html);
const before = extractProducts(root);
applyUpdates(root);
const after = extractProducts(root);
return { before, after, html: root.toString() };
}
test/query.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parse } from "node-html-parser";
import { extractProducts, applyUpdates, processCatalog } from "../src/query.ts";
const fixture = readFileSync(
fileURLToPath(new URL("../fixtures/page.html", import.meta.url)),
"utf8",
);
test("querySelectorAll extracts product data with attributes and text", () => {
const root = parse(fixture);
const products = extractProducts(root);
assert.equal(products.length, 3);
assert.deepEqual(products[0], {
sku: "A-1",
name: "Widget A",
href: "/p/a1",
price: 10,
discontinued: false,
});
assert.deepEqual(products[2], {
sku: "C-3",
name: "Widget C",
href: "/p/c3",
price: 30,
discontinued: true,
});
});
test("set_content and setAttribute modify nodes", () => {
const root = parse(fixture);
assert.equal(root.querySelector("#title")?.text, "Widgets");
applyUpdates(root);
assert.equal(root.querySelector("#title")?.text, "Updated Widgets");
const first = root.querySelector('li.product[data-sku="A-1"]');
assert.equal(first?.getAttribute("data-price"), "15");
assert.equal(first?.querySelector(".price")?.text, "$15");
});
test("remove drops discontinued nodes and serializes back", () => {
const { before, after, html } = processCatalog(fixture);
assert.equal(before.length, 3);
assert.equal(after.length, 2);
assert.deepEqual(
after.map((p) => p.sku),
["A-1", "B-2"],
);
assert.ok(!html.includes("C-3"));
assert.ok(html.includes('data-price="15"'));
assert.ok(html.includes("Updated Widgets"));
assert.ok(html.includes("<title>ProvenPin Demo</title>"));
});
fixtures/page.html
<!DOCTYPE html>
<html>
<head>
<title>ProvenPin Demo</title>
</head>
<body>
<header class="site-header">
<h1 id="title">Widgets</h1>
<p class="intro">A small catalog.</p>
</header>
<ul id="products" class="products">
<li class="product" data-sku="A-1" data-price="10">
<a href="/p/a1" class="product-link">Widget A</a>
<span class="price">$10</span>
</li>
<li class="product" data-sku="B-2" data-price="20">
<a href="/p/b2" class="product-link">Widget B</a>
<span class="price">$20</span>
</li>
<li class="product discontinued" data-sku="C-3" data-price="30">
<a href="/p/c3" class="product-link">Widget C</a>
<span class="price">$30</span>
</li>
</ul>
<footer><p class="note">Prices in USD.</p></footer>
</body>
</html>
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.