Spaces:
Running
Running
File size: 5,124 Bytes
99c7c9f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | /**
* Pulpie WebUI - Main Content Reconstructor
*
* Reconstructs main-content HTML from classifier predictions and converts
* to clean Markdown using Turndown with image link retention and tracking-pixel removal.
*/
import TurndownService from 'turndown';
import { ITEM_ID_ATTR } from './html-simplifier.js';
// Matches tracking pixels, 1x1 gifs, and empty spacer images
const SPACER_IMG_REGEX =
/<img\b[^>]*?(?:(?:width|height)\s*=\s*["']?\s*1\b|src\s*=\s*["'][^"']*(?:trans(?:parent)?|spacer|blank|pixel|1x1|clear)[^"']*["']|src\s*=\s*["']data:image[^"']*["'])[^>]*>/gi;
/**
* Initialize and configure TurndownService for clean GitHub-style Markdown.
* @returns {TurndownService}
*/
function createTurndownService() {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
emDelimiter: '*',
});
// Keep pre/code formatting
turndown.addRule('fencedCodeBlock', {
filter: ['pre'],
replacement: function (content, node) {
const code = node.querySelector('code');
const language = code?.className?.match(/(?:lang|language)-(\S+)/)?.[1] || '';
const text = (code || node).textContent.replace(/\n+$/, '');
return `\n\n\`\`\`${language}\n${text}\n\`\`\`\n\n`;
},
});
// Preserve tables
turndown.addRule('tables', {
filter: ['table'],
replacement: function (content, node) {
// Basic markdown table reconstruction
const rows = Array.from(node.querySelectorAll('tr'));
if (rows.length === 0) return '';
const tableLines = [];
let isHeader = true;
for (const row of rows) {
const cells = Array.from(row.querySelectorAll('th, td')).map((c) =>
c.textContent.trim().replace(/\|/g, '\\|')
);
if (cells.length === 0) continue;
tableLines.push(`| ${cells.join(' | ')} |`);
if (isHeader) {
tableLines.push(`| ${cells.map(() => '---').join(' | ')} |`);
isHeader = false;
}
}
return `\n\n${tableLines.join('\n')}\n\n`;
},
});
// Clean images
turndown.addRule('imagesWithAlts', {
filter: 'img',
replacement: function (content, node) {
const alt = node.getAttribute('alt') || '';
const src = node.getAttribute('src') || '';
if (!src) return '';
return ``;
},
});
return turndown;
}
const _turndownInstance = createTurndownService();
/**
* Strip invisible tracking pixels and spacer images.
* @param {string} html
* @returns {string}
*/
export function stripSpacerImages(html) {
return html.replace(SPACER_IMG_REGEX, '');
}
/**
* Reconstruct main HTML and convert to Markdown based on model predictions.
*
* @param {Document} doc - The DOM containing _item_id markers
* @param {Record<string, 'main' | 'other'>} labels - Map of item_id -> label
* @returns {{ html: string, markdown: string }}
*/
export function reconstructMainContent(doc, labels) {
// Clone the document body to prevent altering the original state
const clonedBody = doc.body.cloneNode(true);
// Map each _item_id to its element
const idToElement = new Map();
const allTagged = clonedBody.querySelectorAll(`[${ITEM_ID_ATTR}]`);
for (const el of allTagged) {
const id = el.getAttribute(ITEM_ID_ATTR);
if (id && !idToElement.has(id)) {
idToElement.set(id, el);
}
}
// Set of all elements to preserve (main content + ancestors + descendants)
const elementsToRemain = new Set();
for (const [itemId, label] of Object.entries(labels)) {
if (label !== 'main') continue;
const el = idToElement.get(itemId);
if (!el) continue;
// Add element and all its descendants
elementsToRemain.add(el);
const descendants = el.querySelectorAll('*');
for (const d of descendants) {
elementsToRemain.add(d);
}
// Add ancestors up to body
let ancestor = el.parentElement;
while (ancestor && ancestor !== clonedBody.parentElement) {
elementsToRemain.add(ancestor);
ancestor = ancestor.parentElement;
}
}
// If no elements were kept (e.g. all classified as boilerplate), retain fallback
if (elementsToRemain.size === 0) {
return {
html: '<p><em>No main content identified.</em></p>',
markdown: '*No main content identified.*',
};
}
// Prune nodes that are not in elementsToRemain
function pruneNode(node) {
const children = Array.from(node.children);
for (const child of children) {
if (!elementsToRemain.has(child)) {
child.remove();
} else {
pruneNode(child);
}
}
}
pruneNode(clonedBody);
// Clean _item_id attributes from final output
const remainingTagged = clonedBody.querySelectorAll(`[${ITEM_ID_ATTR}]`);
for (const el of remainingTagged) {
el.removeAttribute(ITEM_ID_ATTR);
}
const rawExtractedHtml = clonedBody.innerHTML.trim();
const cleanedHtml = stripSpacerImages(rawExtractedHtml);
const markdown = _turndownInstance.turndown(cleanedHtml).trim();
return {
html: cleanedHtml,
markdown,
};
}
|