Skip to content

Commit c9edd2b

Browse files
authored
Add number class annotation feature to HTML to Markdown conversion (#74)
This update introduces the `annotateNumberClasses` option, which, when enabled, appends the CSS class name of number-bearing elements next to the number in the markdown output. This preserves semantic meaning for numbers, such as prices and ratings, while ensuring that nested numeric parts do not add noise. The feature is accompanied by tests to verify its functionality and is documented in the README.
1 parent d9a27c5 commit c9edd2b

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ Main function to extract structured data from content.
441441
| `extractMainHtml` | `boolean` | When enabled for HTML content, attempts to extract the main content area, removing navigation bars, headers, footers, sidebars etc. using heuristics. Should be kept off when extracting details about a single item. | `false` |
442442
| `includeImages` | `boolean` | When enabled, images in the HTML will be included in the markdown output. Enable this when you need to extract image URLs or related content. | `false` |
443443
| `cleanUrls` | `boolean` | When enabled, removes tracking parameters and unnecessary URL components to produce cleaner links. Currently supports cleaning Amazon product URLs by removing `/ref=` parameters and everything after. This helps produce more readable URLs in the markdown output. | `false` |
444+
| `annotateNumberClasses` | `boolean` | When enabled, appends the CSS class name of number-bearing elements next to the number in the markdown (e.g. `22,99 {price-box__price__amount}`). Plain markdown discards the semantic meaning encoded in class names; this preserves whether a number is a price, rating, review count, etc. Only the outermost element whose text is purely a number is annotated, so nested integer/decimal parts don't add noise. | `false` |
444445

445446
#### Return Value
446447

src/converters.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ function extractMainHtml(html: string): string {
6969
}
7070
}
7171

72+
/**
73+
* Matches a string that is "just a number" once whitespace is removed.
74+
* Tolerates a leading currency symbol / sign, thousands and decimal
75+
* separators (",", "."), and a trailing percent sign — e.g. "22,99",
76+
* "$1,234.56", "4.5", "275", "99%".
77+
*/
78+
const NUMBER_LIKE_RE = /^[$£¥+\-]?[\d.,]*\d[\d.,]*%?$/;
79+
80+
/**
81+
* Returns the whitespace-collapsed text when it represents a single number
82+
* token (e.g. "22,99", "$1,234.56", "275"), otherwise an empty string. Used
83+
* to decide whether an element's class name carries semantic meaning worth
84+
* annotating onto the number, and to compare numbers across nested elements.
85+
*/
86+
function normalizeNumberText(text: string | null | undefined): string {
87+
if (!text) return "";
88+
const normalized = text.replace(/\s+/g, "");
89+
if (!normalized || !/\d/.test(normalized)) return "";
90+
return NUMBER_LIKE_RE.test(normalized) ? normalized : "";
91+
}
92+
7293
/**
7394
* Convert HTML to Markdown
7495
*/
@@ -114,6 +135,65 @@ export function htmlToMarkdown(
114135
replacement: () => "",
115136
});
116137

138+
if (options?.annotateNumberClasses) {
139+
// Append the class name of number-bearing elements next to the number so
140+
// the downstream LLM keeps the semantic meaning that plain Markdown drops
141+
// (e.g. "22,99 {price-box__price__amount}").
142+
//
143+
// We pick the single element that best represents the number, navigating
144+
// two opposing nesting patterns:
145+
// 1. Composed numbers — the number is assembled from fragment children
146+
// (e.g. <span int>22</span><span decSep>,</span><sup dec>99</sup>).
147+
// None of the children holds the whole number, so we annotate the
148+
// parent where it comes together and skip the fragments.
149+
// 2. Wrapped numbers — the same full number is duplicated through
150+
// single-purpose wrappers (e.g. layout div <div col-xs-6>
151+
// <div specs__value>125</div></div>). We annotate the innermost
152+
// element and skip the redundant outer wrappers, since the inner
153+
// class (page-product__specs__value) carries the meaning, not the
154+
// layout class (col-xs-6).
155+
turnDownService.addRule("annotate-number-classes", {
156+
filter: function (node: any) {
157+
if (typeof node.getAttribute !== "function") return false;
158+
const className = node.getAttribute("class");
159+
if (!className || !className.trim()) return false;
160+
const selfNumber = normalizeNumberText(node.textContent);
161+
if (!selfNumber) return false;
162+
163+
// Wrapped-number case: if a child element already carries the same
164+
// full number, that child is more specific — let it be annotated.
165+
const childNodes = node.childNodes || [];
166+
for (let i = 0; i < childNodes.length; i++) {
167+
const child = childNodes[i];
168+
if (
169+
child.nodeType === 1 &&
170+
normalizeNumberText(child.textContent) === selfNumber
171+
) {
172+
return false;
173+
}
174+
}
175+
176+
// Composed-number case: if the parent is numeric but represents a
177+
// different (larger) number, this element is only a fragment of it —
178+
// let the parent be annotated instead.
179+
const parent = node.parentNode;
180+
if (parent && parent.nodeType === 1) {
181+
const parentNumber = normalizeNumberText(parent.textContent);
182+
if (parentNumber && parentNumber !== selfNumber) {
183+
return false;
184+
}
185+
}
186+
187+
return true;
188+
},
189+
replacement: function (_content: string, node: any) {
190+
const number = normalizeNumberText(node.textContent);
191+
const className = node.getAttribute("class").trim().replace(/\s+/g, " ");
192+
return `${number} {${className}}`;
193+
},
194+
});
195+
}
196+
117197
turnDownService.addRule("title-as-h1", {
118198
filter: ["title"],
119199
replacement: (innerText: string) => `${innerText}\n===============\n`,

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ export interface HTMLExtractionOptions {
4141
* Disabled by default to preserve original URLs.
4242
*/
4343
cleanUrls?: boolean;
44+
45+
/**
46+
* When enabled, appends the CSS class name of number-bearing elements next to
47+
* the number in the markdown output (e.g. `22,99 {price-box__price__amount}`).
48+
*
49+
* Plain markdown discards the semantic meaning encoded in class names, making
50+
* it hard for the LLM to tell whether a number is a price, rating, review
51+
* count, etc. This annotates the outermost element whose text is purely a
52+
* number, preserving that signal while keeping nested numeric parts
53+
* (integer / decimal / separator spans) from adding noise.
54+
*
55+
* Disabled by default.
56+
*/
57+
annotateNumberClasses?: boolean;
4458
}
4559

4660
/**

tests/unit/converters.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,95 @@ describe("HTML to Markdown converter", () => {
139139
expect(markdownWithoutExtraction).toContain("Footer content");
140140
});
141141

142+
describe("number class annotation", () => {
143+
const priceHtml = `
144+
<span class="price-box__price large ">
145+
<span class="price-box__price__amount">
146+
<span class="price-box__price__amount__integer">22</span>
147+
<span class="price-box__price__amount__decSep">,</span>
148+
<sup class="price-box__price__amount__decimal">99</sup>
149+
</span>
150+
<span class="price-box__price__spec">Chacun</span>
151+
</span>`;
152+
153+
test("does not annotate numbers by default", () => {
154+
const markdown = htmlToMarkdown(priceHtml);
155+
expect(markdown).not.toContain("{");
156+
expect(markdown).toContain("Chacun");
157+
});
158+
159+
test("annotates the outermost numeric element with its class", () => {
160+
const markdown = htmlToMarkdown(priceHtml, {
161+
annotateNumberClasses: true,
162+
});
163+
164+
// The fully-numeric amount span is annotated with its class...
165+
expect(markdown).toContain("22,99 {price-box__price__amount}");
166+
// ...while the nested integer / decimal parts are NOT annotated.
167+
expect(markdown).not.toContain("__integer}");
168+
expect(markdown).not.toContain("__decimal}");
169+
// Non-numeric siblings are left untouched.
170+
expect(markdown).toContain("Chacun");
171+
});
172+
173+
test("annotates standalone numeric fields", () => {
174+
const html = `
175+
<div class="product">
176+
<span class="rating">4.5</span>
177+
<span class="review-count">275</span>
178+
<div class="price">$1,234.56</div>
179+
</div>`;
180+
const markdown = htmlToMarkdown(html, { annotateNumberClasses: true });
181+
182+
expect(markdown).toContain("4.5 {rating}");
183+
expect(markdown).toContain("275 {review-count}");
184+
expect(markdown).toContain("$1,234.56 {price}");
185+
});
186+
187+
test("annotates the innermost element for same-number wrappers", () => {
188+
// The number is duplicated through a layout wrapper (col-xs-6) whose
189+
// only content is the same value. The meaningful class lives on the
190+
// inner element, not the grid wrapper.
191+
const html = `
192+
<div class="row">
193+
<div class="col-xs-6">
194+
<div class="page-product__specs__label">Weight</div>
195+
</div>
196+
<div class="col-xs-6">
197+
<div class="page-product__specs__value">125</div>
198+
</div>
199+
</div>`;
200+
const markdown = htmlToMarkdown(html, { annotateNumberClasses: true });
201+
202+
expect(markdown).toContain("125 {page-product__specs__value}");
203+
expect(markdown).not.toContain("{col-xs-6}");
204+
});
205+
206+
test("annotates the innermost element through deeply nested wrappers", () => {
207+
const html = `
208+
<section>
209+
<div class="a"><div class="b"><div class="c">125</div></div></div>
210+
</section>`;
211+
const markdown = htmlToMarkdown(html, { annotateNumberClasses: true });
212+
213+
expect(markdown).toContain("125 {c}");
214+
expect(markdown).not.toContain("{a}");
215+
expect(markdown).not.toContain("{b}");
216+
});
217+
218+
test("does not annotate non-numeric or classless elements", () => {
219+
const html = `
220+
<span class="label">In stock</span>
221+
<span>42</span>`;
222+
const markdown = htmlToMarkdown(html, { annotateNumberClasses: true });
223+
224+
// Text content is not a number → no annotation.
225+
expect(markdown).not.toContain("{label}");
226+
// Numeric but no class → no annotation.
227+
expect(markdown).not.toContain("{");
228+
});
229+
});
230+
142231
describe("URL handling", () => {
143232
test("should convert relative URLs to absolute URLs when sourceUrl is provided", () => {
144233
const html = `

0 commit comments

Comments
 (0)