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 `![${alt}](${src})`;
    },
  });

  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,
  };
}