crawlforge-mcp-server 5.2.0 → 5.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/package.json +2 -2
- package/server.js +2 -2
- package/src/core/ChangeTracker.js +11 -32
- package/src/core/analysis/ContentAnalyzer.js +28 -5
- package/src/tools/basic/_fetch.js +5 -90
- package/src/tools/search/adapters/redditOfficialApi.js +1 -1
- package/src/tools/search/redditSearch.js +1 -1
- package/src/utils/htmlToMarkdown.js +52 -0
package/CLAUDE.md
CHANGED
|
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
62
62
|
|
|
63
63
|
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.2.
|
|
65
|
+
**Current Version:** 5.2.2
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.2",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 28 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"cheerio": "^1.1.2",
|
|
114
114
|
"commander": "^14.0.3",
|
|
115
115
|
"compromise": "^14.14.4",
|
|
116
|
-
"crawlforge-extractors": "^1.
|
|
116
|
+
"crawlforge-extractors": "^1.1.0",
|
|
117
117
|
"diff": "^9.0.0",
|
|
118
118
|
"dotenv": "^17.2.1",
|
|
119
119
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -100,7 +100,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
100
100
|
// Create the server
|
|
101
101
|
const server = new McpServer({
|
|
102
102
|
name: "crawlforge",
|
|
103
|
-
version: "5.2.
|
|
103
|
+
version: "5.2.2",
|
|
104
104
|
description: "Production-ready MCP server with 28 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
105
105
|
homepage: "https://www.crawlforge.dev",
|
|
106
106
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1402,7 +1402,7 @@ registerToolIfEnabled("localization", {
|
|
|
1402
1402
|
|
|
1403
1403
|
// Tool: scrape_template (D3.3 — pre-built site templates)
|
|
1404
1404
|
registerToolIfEnabled("scrape_template", {
|
|
1405
|
-
description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
|
|
1405
|
+
description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: shopify-product (any Shopify storefront, read from the store's own /products/<handle>.json rather than the rendered page), amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
|
|
1406
1406
|
annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1407
1407
|
inputSchema: {
|
|
1408
1408
|
template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
|
|
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import { load } from 'cheerio';
|
|
13
13
|
import { diffWords, diffLines, diffChars } from 'diff';
|
|
14
|
+
import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
|
|
14
15
|
import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
|
|
15
16
|
|
|
16
17
|
const ChangeTrackingSchema = z.object({
|
|
@@ -720,7 +721,10 @@ export class ChangeTracker extends EventEmitter {
|
|
|
720
721
|
extractStructure($, options) {
|
|
721
722
|
const structure = {
|
|
722
723
|
elements: [],
|
|
723
|
-
|
|
724
|
+
// Element count per nesting depth. This used to be an empty object that
|
|
725
|
+
// nothing ever wrote to, which made the hierarchy half of the structural
|
|
726
|
+
// score a constant.
|
|
727
|
+
hierarchy: structureSignature($).depths,
|
|
724
728
|
semanticStructure: {}
|
|
725
729
|
};
|
|
726
730
|
|
|
@@ -1094,38 +1098,13 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1094
1098
|
|
|
1095
1099
|
calculateStructuralSimilarity(baseline, current) {
|
|
1096
1100
|
if (!baseline || !current) return 0;
|
|
1097
|
-
|
|
1098
|
-
const baselineElements = baseline.elements || [];
|
|
1099
|
-
const currentElements = current.elements || [];
|
|
1100
|
-
|
|
1101
|
-
if (baselineElements.length === 0 && currentElements.length === 0) return 1;
|
|
1102
|
-
if (baselineElements.length === 0 || currentElements.length === 0) return 0;
|
|
1103
|
-
|
|
1104
|
-
const tagSimilarity = this.calculateTagSimilarity(baselineElements, currentElements);
|
|
1105
|
-
const hierarchySimilarity = this.calculateHierarchySimilarity(baseline.hierarchy, current.hierarchy);
|
|
1106
|
-
|
|
1107
|
-
// Clamp defensively — this is a 0-1 metric and must never leave that range.
|
|
1108
|
-
return Math.max(0, Math.min(1, (tagSimilarity + hierarchySimilarity) / 2));
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
calculateTagSimilarity(baselineElements, currentElements) {
|
|
1112
|
-
// Jaccard similarity: intersection and union must both operate on SETS.
|
|
1113
|
-
// The old code intersected the raw (duplicate-laden) tag list against a
|
|
1114
|
-
// set union, so repeated tags (div, p, ...) inflated the numerator and
|
|
1115
|
-
// produced impossible scores > 1 (e.g. the observed 1.05).
|
|
1116
|
-
const baselineTags = new Set(baselineElements.map(el => el.tag));
|
|
1117
|
-
const currentTags = new Set(currentElements.map(el => el.tag));
|
|
1118
1101
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
calculateHierarchySimilarity(baseline, current) {
|
|
1126
|
-
// Simple structural comparison - can be enhanced
|
|
1127
|
-
if (!baseline || !current) return 0;
|
|
1128
|
-
return Object.keys(baseline).length === Object.keys(current).length ? 1 : 0.5;
|
|
1102
|
+
// Scored in crawlforge-extractors so the REST API's track_changes reports
|
|
1103
|
+
// the same number for the same pair of pages.
|
|
1104
|
+
return structuralSimilarity(
|
|
1105
|
+
{ tags: (baseline.elements || []).map(el => el.tag), depths: baseline.hierarchy },
|
|
1106
|
+
{ tags: (current.elements || []).map(el => el.tag), depths: current.hierarchy }
|
|
1107
|
+
);
|
|
1129
1108
|
}
|
|
1130
1109
|
|
|
1131
1110
|
hammingDistance(str1, str2) {
|
|
@@ -106,24 +106,25 @@ const LANGUAGE_NAMES = {
|
|
|
106
106
|
'rus': 'Russian',
|
|
107
107
|
'jpn': 'Japanese',
|
|
108
108
|
'kor': 'Korean',
|
|
109
|
-
'
|
|
110
|
-
'
|
|
109
|
+
'cmn': 'Chinese',
|
|
110
|
+
'arb': 'Arabic',
|
|
111
111
|
'hin': 'Hindi',
|
|
112
112
|
'nld': 'Dutch',
|
|
113
113
|
'swe': 'Swedish',
|
|
114
|
-
'
|
|
114
|
+
'nob': 'Norwegian',
|
|
115
115
|
'dan': 'Danish',
|
|
116
116
|
'fin': 'Finnish',
|
|
117
117
|
'pol': 'Polish',
|
|
118
118
|
'ces': 'Czech',
|
|
119
119
|
'hun': 'Hungarian',
|
|
120
120
|
'tur': 'Turkish',
|
|
121
|
-
'
|
|
121
|
+
'ell': 'Greek',
|
|
122
122
|
'heb': 'Hebrew',
|
|
123
123
|
'tha': 'Thai',
|
|
124
124
|
'vie': 'Vietnamese',
|
|
125
125
|
'ind': 'Indonesian',
|
|
126
|
-
'
|
|
126
|
+
'zlm': 'Malay',
|
|
127
|
+
'zsm': 'Malay',
|
|
127
128
|
'tgl': 'Tagalog',
|
|
128
129
|
'ukr': 'Ukrainian',
|
|
129
130
|
'bul': 'Bulgarian',
|
|
@@ -282,6 +283,28 @@ export class ContentAnalyzer {
|
|
|
282
283
|
*/
|
|
283
284
|
async detectLanguage(text, options = {}) {
|
|
284
285
|
try {
|
|
286
|
+
// franc scores the single most common script, so a Chinese, Japanese or
|
|
287
|
+
// Korean page carrying the usual run of English product names and code
|
|
288
|
+
// samples is detected as English. Those scripts never appear in
|
|
289
|
+
// Latin-script prose, so a meaningful share of them settles the question
|
|
290
|
+
// before trigram scoring gets a say.
|
|
291
|
+
const letters = (text.match(/\p{L}/gu) || []).length;
|
|
292
|
+
if (letters > 0) {
|
|
293
|
+
const han = (text.match(/\p{Script=Han}/gu) || []).length;
|
|
294
|
+
const kana = (text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
|
|
295
|
+
const hangul = (text.match(/\p{Script=Hangul}/gu) || []).length;
|
|
296
|
+
if ((han + kana + hangul) / letters >= 0.1) {
|
|
297
|
+
const code = kana > 0 ? 'jpn' : hangul > han ? 'kor' : 'cmn';
|
|
298
|
+
return {
|
|
299
|
+
code,
|
|
300
|
+
name: LANGUAGE_NAMES[code],
|
|
301
|
+
confidence: 0.9,
|
|
302
|
+
alternative: [],
|
|
303
|
+
detectionMethod: 'script'
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
285
308
|
// Use franc for language detection
|
|
286
309
|
const detected = franc(text, {
|
|
287
310
|
minLength: 10,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Applies an AbortController timeout and a default User-Agent.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { readBody } from 'crawlforge-extractors';
|
|
6
7
|
import { config } from '../../constants/config.js';
|
|
7
8
|
import { createRequire } from 'module';
|
|
8
9
|
import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
@@ -13,35 +14,6 @@ const _require = createRequire(import.meta.url);
|
|
|
13
14
|
const _pkg = _require('../../../package.json');
|
|
14
15
|
const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
* Determine the charset to decode a response body with: Content-Type header
|
|
18
|
-
* first, then a <meta charset> sniff of the first bytes, defaulting to utf-8.
|
|
19
|
-
* @param {Response} response
|
|
20
|
-
* @param {Uint8Array} bytes
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
function detectCharset(response, bytes) {
|
|
24
|
-
const contentType = response.headers?.get?.('content-type') || '';
|
|
25
|
-
const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
|
|
26
|
-
if (headerMatch) {
|
|
27
|
-
return headerMatch[1].trim().toLowerCase();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// <meta charset> tags must appear within the first 1024 bytes per the
|
|
31
|
-
// HTML5 spec's prescan algorithm; ASCII-range bytes decode identically
|
|
32
|
-
// under latin1 regardless of the document's real encoding.
|
|
33
|
-
const sniffLength = Math.min(bytes.byteLength, 1024);
|
|
34
|
-
const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
|
|
35
|
-
const metaMatch =
|
|
36
|
-
/<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
|
|
37
|
-
/<meta[^>]+http-equiv=["']?content-type["']?[^>]*content=["'][^"']*charset=([\w-]+)/i.exec(sniffText);
|
|
38
|
-
if (metaMatch) {
|
|
39
|
-
return metaMatch[1].trim().toLowerCase();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return 'utf-8';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
17
|
/**
|
|
46
18
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
47
19
|
*
|
|
@@ -100,46 +72,11 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
100
72
|
throw error;
|
|
101
73
|
}
|
|
102
74
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// handles that case). Optional-chained so non-standard responses (e.g. test
|
|
107
|
-
// mocks) without a Headers object don't throw.
|
|
108
|
-
const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
|
|
109
|
-
if (contentLengthHeader !== null) {
|
|
110
|
-
const declared = parseInt(contentLengthHeader, 10);
|
|
111
|
-
if (!isNaN(declared) && declared > maxBodySize) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Only the streaming byte-count guard requires a readable body. Responses
|
|
119
|
-
// without a ReadableStream body (already-buffered responses, test mocks)
|
|
120
|
-
// are returned unchanged so callers' native .text()/.json() still work.
|
|
121
|
-
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
122
|
-
return Object.assign(response, { _responseTime: Date.now() - startedAt });
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Stream the body and abort if accumulated bytes exceed the cap.
|
|
126
|
-
const reader = response.body.getReader();
|
|
127
|
-
const chunks = [];
|
|
128
|
-
let totalBytes = 0;
|
|
129
|
-
|
|
75
|
+
// Reading is delegated to crawlforge-extractors so the REST API applies
|
|
76
|
+
// the same cap and the same charset handling to the same page.
|
|
77
|
+
let bodyText;
|
|
130
78
|
try {
|
|
131
|
-
|
|
132
|
-
const { done, value } = await reader.read();
|
|
133
|
-
if (done) break;
|
|
134
|
-
totalBytes += value.byteLength;
|
|
135
|
-
if (totalBytes > maxBodySize) {
|
|
136
|
-
reader.cancel();
|
|
137
|
-
throw new Error(
|
|
138
|
-
`Response body too large: exceeded limit of ${maxBodySize} bytes`
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
chunks.push(value);
|
|
142
|
-
}
|
|
79
|
+
bodyText = await readBody(response, { maxBytes: maxBodySize });
|
|
143
80
|
} catch (error) {
|
|
144
81
|
if (error.name === 'AbortError') {
|
|
145
82
|
throw new Error(`Request timeout after ${timeout}ms`);
|
|
@@ -147,29 +84,7 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
147
84
|
throw error;
|
|
148
85
|
}
|
|
149
86
|
|
|
150
|
-
// Reassemble the raw bytes in a single pass (totalBytes is already known,
|
|
151
|
-
// so this is one allocation + one copy per chunk, not the O(n^2) cost of
|
|
152
|
-
// reallocating/copying the whole buffer on every chunk), then decode using
|
|
153
|
-
// the response's actual charset (Content-Type header, falling back to a
|
|
154
|
-
// <meta charset> sniff) instead of always assuming UTF-8.
|
|
155
|
-
const mergedBytes = new Uint8Array(totalBytes);
|
|
156
|
-
let offset = 0;
|
|
157
|
-
for (const chunk of chunks) {
|
|
158
|
-
mergedBytes.set(chunk, offset);
|
|
159
|
-
offset += chunk.byteLength;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const charset = detectCharset(response, mergedBytes);
|
|
163
|
-
let bodyText;
|
|
164
|
-
try {
|
|
165
|
-
bodyText = new TextDecoder(charset).decode(mergedBytes);
|
|
166
|
-
} catch {
|
|
167
|
-
// Unrecognized charset label — fall back to UTF-8 rather than throwing.
|
|
168
|
-
bodyText = new TextDecoder().decode(mergedBytes);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
87
|
// Attach the pre-read text so callers can call .text() on the result.
|
|
172
|
-
// We wrap it in a minimal compatible object.
|
|
173
88
|
return Object.assign(response, {
|
|
174
89
|
text: () => Promise.resolve(bodyText),
|
|
175
90
|
json: () => Promise.resolve(JSON.parse(bodyText)),
|
|
@@ -27,7 +27,7 @@ const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token';
|
|
|
27
27
|
const API_BASE = 'https://oauth.reddit.com';
|
|
28
28
|
|
|
29
29
|
/** Reddit requires a descriptive, unique User-Agent; generic ones are throttled. */
|
|
30
|
-
const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.
|
|
30
|
+
const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
|
|
31
31
|
|
|
32
32
|
/** Our sort is asc/desc by post date; Reddit listings/search only go newest-first. */
|
|
33
33
|
const REDDIT_SORT = 'new';
|
|
@@ -47,7 +47,7 @@ const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
|
47
47
|
* into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
|
|
48
48
|
* 200 for the same URL); with a descriptive UA it answers instantly.
|
|
49
49
|
*/
|
|
50
|
-
const USER_AGENT = 'CrawlForge-MCP/5.2.
|
|
50
|
+
const USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
|
|
51
51
|
|
|
52
52
|
const RedditSearchSchema = z.object({
|
|
53
53
|
query: z.string().min(1).optional(),
|
|
@@ -20,6 +20,38 @@ import { stripHiddenHtml } from './hiddenContent.js';
|
|
|
20
20
|
|
|
21
21
|
let _td = null;
|
|
22
22
|
|
|
23
|
+
// Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
|
|
24
|
+
// whether it converts a table or keeps it as raw HTML.
|
|
25
|
+
function isHeadingRow(tr) {
|
|
26
|
+
const parent = tr.parentNode;
|
|
27
|
+
if (!parent) return false;
|
|
28
|
+
if (parent.nodeName === 'THEAD') return true;
|
|
29
|
+
const firstTbody =
|
|
30
|
+
parent.nodeName === 'TBODY' &&
|
|
31
|
+
(!parent.previousSibling ||
|
|
32
|
+
(parent.previousSibling.nodeName === 'THEAD' &&
|
|
33
|
+
/^\s*$/.test(parent.previousSibling.textContent)));
|
|
34
|
+
return (
|
|
35
|
+
parent.firstChild === tr &&
|
|
36
|
+
(parent.nodeName === 'TABLE' || firstTbody) &&
|
|
37
|
+
Array.prototype.every.call(tr.childNodes, n => n.nodeName === 'TH')
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isLayoutTable(node) {
|
|
42
|
+
return (
|
|
43
|
+
node.nodeName === 'TABLE' &&
|
|
44
|
+
!(node.rows && node.rows[0] && isHeadingRow(node.rows[0]))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isInLayoutTable(node) {
|
|
49
|
+
for (let p = node.parentNode; p; p = p.parentNode) {
|
|
50
|
+
if (p.nodeName === 'TABLE') return isLayoutTable(p);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
function getTurndown() {
|
|
24
56
|
if (_td === null) {
|
|
25
57
|
_td = new TurndownService({
|
|
@@ -35,6 +67,26 @@ function getTurndown() {
|
|
|
35
67
|
// Enable GFM extensions (tables, strikethrough, task lists)
|
|
36
68
|
_td.use(gfm);
|
|
37
69
|
|
|
70
|
+
// turndown-plugin-gfm only converts a table whose first row is all <th>;
|
|
71
|
+
// every other table is passed through its `keep` filter as raw HTML. Pages
|
|
72
|
+
// we scrape are full of layout tables (Hacker News, older sites), so that
|
|
73
|
+
// leaks <table> markup into a field the caller asked for as markdown.
|
|
74
|
+
// Rules added here are matched before keep filters, so these reclaim the
|
|
75
|
+
// tables the plugin skipped and flatten them to their cell content, while
|
|
76
|
+
// real data tables still reach the plugin and render as pipe tables.
|
|
77
|
+
_td.addRule('layoutTable', {
|
|
78
|
+
filter: isLayoutTable,
|
|
79
|
+
replacement: content => '\n\n' + content.replace(/\n{3,}/g, '\n\n').trim() + '\n\n'
|
|
80
|
+
});
|
|
81
|
+
_td.addRule('layoutTableCell', {
|
|
82
|
+
filter: node => (node.nodeName === 'TH' || node.nodeName === 'TD') && isInLayoutTable(node),
|
|
83
|
+
replacement: content => (content.trim() ? content.trim() + ' ' : '')
|
|
84
|
+
});
|
|
85
|
+
_td.addRule('layoutTableRow', {
|
|
86
|
+
filter: node => node.nodeName === 'TR' && isInLayoutTable(node),
|
|
87
|
+
replacement: content => (content.trim() ? content.trim() + '\n' : '')
|
|
88
|
+
});
|
|
89
|
+
|
|
38
90
|
// Remove boilerplate elements before converting
|
|
39
91
|
_td.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
|
|
40
92
|
}
|