neoagent 3.0.1-beta.2 → 3.0.1-beta.21
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/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- package/server/services/workspace/manager.js +37 -3
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cheerio = require('cheerio');
|
|
4
|
+
|
|
5
|
+
// ─── Noise removal ───────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const NOISE_TAGS = new Set([
|
|
8
|
+
'script', 'style', 'noscript', 'nav', 'header', 'footer',
|
|
9
|
+
'aside', 'iframe', 'dialog', 'template',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const NOISE_ARIA_ROLES = new Set([
|
|
13
|
+
'navigation', 'banner', 'contentinfo', 'complementary',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Matched against individual whitespace-separated class/id tokens (exact token match).
|
|
17
|
+
// This avoids false positives: "modal" matches class="modal" but not class="free-modal-container".
|
|
18
|
+
const NOISE_TOKENS = new Set([
|
|
19
|
+
'sidebar', 'nav', 'navbar', 'navigation', 'modal', 'popup', 'cookie',
|
|
20
|
+
'newsletter', 'menu', 'breadcrumb', 'breadcrumbs', 'social', 'share',
|
|
21
|
+
'widget', 'promo', 'banner', 'ad', 'ads', 'advertisement', 'overlay',
|
|
22
|
+
'toast', 'tooltip', 'flyout', 'drawer', 'offcanvas', 'skip-link',
|
|
23
|
+
'toolbar', 'ribbon', 'announcement', 'notice',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function hasNoiseToken(attr) {
|
|
27
|
+
if (!attr) return false;
|
|
28
|
+
for (const token of attr.split(/\s+/)) {
|
|
29
|
+
if (NOISE_TOKENS.has(token.toLowerCase())) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function removeNoise($) {
|
|
35
|
+
// Remove by tag (keep <form role="search">)
|
|
36
|
+
NOISE_TAGS.forEach((tag) => {
|
|
37
|
+
if (tag === 'form') {
|
|
38
|
+
$('form').filter((_, el) => $(el).attr('role') !== 'search').remove();
|
|
39
|
+
} else {
|
|
40
|
+
$(tag).remove();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Remove by ARIA role
|
|
45
|
+
$('[role]').each((_, el) => {
|
|
46
|
+
const role = ($(el).attr('role') || '').toLowerCase();
|
|
47
|
+
if (NOISE_ARIA_ROLES.has(role)) $(el).remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Remove by class/id token
|
|
51
|
+
$('*').each((_, el) => {
|
|
52
|
+
const $el = $(el);
|
|
53
|
+
if (hasNoiseToken($el.attr('class')) || hasNoiseToken($el.attr('id'))) {
|
|
54
|
+
$el.remove();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Metadata extraction ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function extractMetadata($, baseUrl) {
|
|
62
|
+
const meta = (name) => $(`meta[name="${name}"]`).attr('content')
|
|
63
|
+
|| $(`meta[property="${name}"]`).attr('content') || '';
|
|
64
|
+
|
|
65
|
+
const title = meta('og:title') || meta('twitter:title') || $('title').text().trim() || '';
|
|
66
|
+
const description = meta('og:description') || meta('twitter:description') || meta('description') || '';
|
|
67
|
+
const author = meta('author') || '';
|
|
68
|
+
const url = meta('og:url') || baseUrl || '';
|
|
69
|
+
|
|
70
|
+
return { title, description, author, url };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Readability-style content scoring ───────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SCORE_BOOST_TAGS = new Set(['article', 'main']);
|
|
76
|
+
const SCORE_PENALTY_TAGS = new Set(['nav', 'aside', 'footer', 'header']);
|
|
77
|
+
const CANDIDATE_TAGS = ['article', 'main', '[role="main"]', 'div', 'section', 'td'];
|
|
78
|
+
|
|
79
|
+
function textLength($el) {
|
|
80
|
+
return $el.text().replace(/\s+/g, ' ').trim().length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function wordCount(text) {
|
|
84
|
+
return text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean).length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function linkDensity($, $el) {
|
|
88
|
+
const total = textLength($el);
|
|
89
|
+
if (!total) return 0;
|
|
90
|
+
let linkText = 0;
|
|
91
|
+
$el.find('a').each((_, a) => { linkText += $(a).text().trim().length; });
|
|
92
|
+
return linkText / total;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scoreElement($, el) {
|
|
96
|
+
const $el = $(el);
|
|
97
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
98
|
+
const text = textLength($el);
|
|
99
|
+
if (text < 20) return 0;
|
|
100
|
+
|
|
101
|
+
const outerLength = $.html($el).length;
|
|
102
|
+
let score = outerLength > 0 ? text / outerLength : 0;
|
|
103
|
+
|
|
104
|
+
// Penalise link-heavy elements (navigation-like)
|
|
105
|
+
const ld = linkDensity($, $el);
|
|
106
|
+
if (ld > 0.3) score *= (1 - ld);
|
|
107
|
+
|
|
108
|
+
if (SCORE_BOOST_TAGS.has(tag)) score *= 1.25;
|
|
109
|
+
if (SCORE_PENALTY_TAGS.has(tag)) score *= 0.75;
|
|
110
|
+
|
|
111
|
+
return score;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function selectMainContent($) {
|
|
115
|
+
// Fast path: semantic landmark elements
|
|
116
|
+
for (const sel of ['article', 'main', '[role="main"]']) {
|
|
117
|
+
const $el = $(sel).first();
|
|
118
|
+
if ($el.length && wordCount($el.text()) > 100) return $el;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Scoring path across likely containers
|
|
122
|
+
let best = null;
|
|
123
|
+
let bestScore = 0;
|
|
124
|
+
|
|
125
|
+
$(CANDIDATE_TAGS.join(',')).each((_, el) => {
|
|
126
|
+
const s = scoreElement($, el);
|
|
127
|
+
if (s > bestScore) { bestScore = s; best = el; }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (best) return $(best);
|
|
131
|
+
|
|
132
|
+
// Body fallback
|
|
133
|
+
return $('body');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Structured data / data islands ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const SCHEMA_CONTENT_TYPES = new Set([
|
|
139
|
+
'Article', 'NewsArticle', 'BlogPosting', 'Product', 'Recipe',
|
|
140
|
+
'FAQPage', 'HowTo', 'Event', 'Review', 'Course',
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const SCHEMA_SKIP_TYPES = new Set([
|
|
144
|
+
'WebSite', 'WebPage', 'SiteNavigationElement', 'BreadcrumbList',
|
|
145
|
+
'SearchAction',
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
const SCHEMA_LONG_FIELDS = new Set(['articleBody', 'body', 'text', 'description']);
|
|
149
|
+
|
|
150
|
+
function cleanSchemaItem(item) {
|
|
151
|
+
if (!item || typeof item !== 'object') return item;
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const [k, v] of Object.entries(item)) {
|
|
154
|
+
if (SCHEMA_LONG_FIELDS.has(k) && typeof v === 'string' && v.length > 500) continue;
|
|
155
|
+
out[k] = v;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractStructuredData($) {
|
|
161
|
+
const items = [];
|
|
162
|
+
let totalSize = 0;
|
|
163
|
+
const SIZE_CAP = 16 * 1024;
|
|
164
|
+
|
|
165
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
166
|
+
if (totalSize >= SIZE_CAP) return;
|
|
167
|
+
try {
|
|
168
|
+
const raw = $(el).html() || '';
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
171
|
+
for (const item of arr) {
|
|
172
|
+
const type = item['@type'];
|
|
173
|
+
if (!type) continue;
|
|
174
|
+
if (SCHEMA_SKIP_TYPES.has(type)) continue;
|
|
175
|
+
if (!SCHEMA_CONTENT_TYPES.has(type)) continue;
|
|
176
|
+
const cleaned = cleanSchemaItem(item);
|
|
177
|
+
const chunk = JSON.stringify(cleaned);
|
|
178
|
+
if (totalSize + chunk.length > SIZE_CAP) continue;
|
|
179
|
+
items.push(cleaned);
|
|
180
|
+
totalSize += chunk.length;
|
|
181
|
+
}
|
|
182
|
+
} catch { /* malformed JSON-LD — skip */ }
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return items;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractNextData($) {
|
|
189
|
+
try {
|
|
190
|
+
const raw = $('#__NEXT_DATA__').html() || $('script[id="__NEXT_DATA__"]').html();
|
|
191
|
+
if (!raw) return null;
|
|
192
|
+
const parsed = JSON.parse(raw);
|
|
193
|
+
const pageProps = parsed?.props?.pageProps;
|
|
194
|
+
if (!pageProps) return null;
|
|
195
|
+
// Drop framework internals
|
|
196
|
+
const { buildId, isFallback, ...rest } = pageProps; // eslint-disable-line no-unused-vars
|
|
197
|
+
return Object.keys(rest).length ? rest : null;
|
|
198
|
+
} catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── HTML → Markdown conversion ───────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
const BLOCK_TAGS = new Set([
|
|
204
|
+
'p', 'div', 'section', 'article', 'blockquote', 'pre',
|
|
205
|
+
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
206
|
+
'table', 'tr', 'td', 'th', 'thead', 'tbody', 'tfoot',
|
|
207
|
+
'figure', 'figcaption', 'details', 'summary',
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
function resolveUrl(href, base) {
|
|
211
|
+
if (!href || href.startsWith('javascript:') || href.startsWith('#')) return null;
|
|
212
|
+
try {
|
|
213
|
+
return new URL(href, base).toString();
|
|
214
|
+
} catch { return href; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function langFromClass(cls) {
|
|
218
|
+
if (!cls) return '';
|
|
219
|
+
const m = cls.match(/language-(\S+)/);
|
|
220
|
+
return m ? m[1] : '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function bestImgSrc(el) {
|
|
224
|
+
const candidates = ['data-src', 'data-lazy-src', 'data-original', 'src'];
|
|
225
|
+
for (const attr of candidates) {
|
|
226
|
+
const v = el.attribs?.[attr] || '';
|
|
227
|
+
if (v && !v.startsWith('data:') && !v.startsWith('blob:')) return v;
|
|
228
|
+
}
|
|
229
|
+
return '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const GENERIC_ALT = new Set(['logo', 'icon', 'image', 'img', 'photo', 'picture', 'banner', 'thumbnail']);
|
|
233
|
+
|
|
234
|
+
function isMeaningfulAlt(alt) {
|
|
235
|
+
if (!alt || alt.length <= 10) return false;
|
|
236
|
+
const lower = alt.toLowerCase().trim();
|
|
237
|
+
return !GENERIC_ALT.has(lower);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Collected links during conversion (per-call state passed via context)
|
|
241
|
+
function nodeToMd(node, ctx, depth = 0) {
|
|
242
|
+
if (depth > 100) return node.type === 'text' ? (node.data || '') : '';
|
|
243
|
+
|
|
244
|
+
if (node.type === 'text') {
|
|
245
|
+
return node.data || '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (node.type !== 'tag') return '';
|
|
249
|
+
|
|
250
|
+
const tag = (node.tagName || '').toLowerCase();
|
|
251
|
+
const $el = ctx.$(node);
|
|
252
|
+
const children = () => node.children
|
|
253
|
+
.map((c) => nodeToMd(c, ctx, depth + 1))
|
|
254
|
+
.join('');
|
|
255
|
+
|
|
256
|
+
// Headings
|
|
257
|
+
const headingMatch = tag.match(/^h([1-6])$/);
|
|
258
|
+
if (headingMatch) {
|
|
259
|
+
const hashes = '#'.repeat(parseInt(headingMatch[1], 10));
|
|
260
|
+
const text = $el.text().trim();
|
|
261
|
+
if (!text) return '';
|
|
262
|
+
return `\n\n${hashes} ${text}\n\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Paragraphs and generic blocks
|
|
266
|
+
if (tag === 'p') {
|
|
267
|
+
const inner = children().trim();
|
|
268
|
+
return inner ? `\n\n${inner}\n\n` : '';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (tag === 'br') return '\n';
|
|
272
|
+
|
|
273
|
+
if (tag === 'hr') return '\n\n---\n\n';
|
|
274
|
+
|
|
275
|
+
// Blockquote
|
|
276
|
+
if (tag === 'blockquote') {
|
|
277
|
+
const inner = children().trim();
|
|
278
|
+
if (!inner) return '';
|
|
279
|
+
return '\n\n' + inner.split('\n').map((l) => `> ${l}`).join('\n') + '\n\n';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Code blocks
|
|
283
|
+
if (tag === 'pre') {
|
|
284
|
+
const codeEl = $el.find('code').first();
|
|
285
|
+
const lang = langFromClass(codeEl.attr('class') || $el.attr('class') || '');
|
|
286
|
+
const code = (codeEl.length ? codeEl.text() : $el.text()).trim();
|
|
287
|
+
ctx.inCode = true;
|
|
288
|
+
const result = `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
|
|
289
|
+
ctx.inCode = false;
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (tag === 'code' && !ctx.inCode) {
|
|
294
|
+
const text = $el.text();
|
|
295
|
+
return `\`${text}\``;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Inline emphasis
|
|
299
|
+
if (tag === 'strong' || tag === 'b') {
|
|
300
|
+
const inner = children().trim();
|
|
301
|
+
return inner ? `**${inner}**` : '';
|
|
302
|
+
}
|
|
303
|
+
if (tag === 'em' || tag === 'i') {
|
|
304
|
+
const inner = children().trim();
|
|
305
|
+
return inner ? `*${inner}*` : '';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Links
|
|
309
|
+
if (tag === 'a') {
|
|
310
|
+
const href = resolveUrl(node.attribs?.href || '', ctx.baseUrl);
|
|
311
|
+
const text = children().trim() || $el.text().trim();
|
|
312
|
+
if (!text) return '';
|
|
313
|
+
if (href) ctx.links.push({ text, href });
|
|
314
|
+
return text;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Images
|
|
318
|
+
if (tag === 'img') {
|
|
319
|
+
const alt = (node.attribs?.alt || '').trim();
|
|
320
|
+
if (isMeaningfulAlt(alt)) return alt;
|
|
321
|
+
// Track decorative logos for collapsing
|
|
322
|
+
if (alt && GENERIC_ALT.has(alt.toLowerCase())) {
|
|
323
|
+
ctx.consecutiveLogos.push(alt);
|
|
324
|
+
}
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Lists
|
|
329
|
+
if (tag === 'ul' || tag === 'ol') {
|
|
330
|
+
ctx.listStack.push(tag);
|
|
331
|
+
const inner = children().trim();
|
|
332
|
+
ctx.listStack.pop();
|
|
333
|
+
return inner ? `\n\n${inner}\n\n` : '';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (tag === 'li') {
|
|
337
|
+
const isOrdered = ctx.listStack[ctx.listStack.length - 1] === 'ol';
|
|
338
|
+
const inner = children().trim();
|
|
339
|
+
if (!inner) return '';
|
|
340
|
+
if (isOrdered) {
|
|
341
|
+
ctx.listIndex = (ctx.listIndex || 0) + 1;
|
|
342
|
+
return `${ctx.listIndex}. ${inner}\n`;
|
|
343
|
+
}
|
|
344
|
+
return `- ${inner}\n`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Tables — render as pipe tables or just extract text
|
|
348
|
+
if (tag === 'table') {
|
|
349
|
+
return `\n\n${$el.text().replace(/\s+/g, ' ').trim()}\n\n`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Details/summary
|
|
353
|
+
if (tag === 'summary') {
|
|
354
|
+
return `**${children().trim()}**\n`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Figcaption
|
|
358
|
+
if (tag === 'figcaption') {
|
|
359
|
+
const inner = children().trim();
|
|
360
|
+
return inner ? `\n*${inner}*\n` : '';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Generic block-level elements: add newlines around children
|
|
364
|
+
if (BLOCK_TAGS.has(tag)) {
|
|
365
|
+
const inner = children();
|
|
366
|
+
return inner ? `\n${inner}\n` : '';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return children();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function htmlToMarkdown($, $root, baseUrl) {
|
|
373
|
+
const ctx = {
|
|
374
|
+
$,
|
|
375
|
+
baseUrl,
|
|
376
|
+
links: [],
|
|
377
|
+
listStack: [],
|
|
378
|
+
listIndex: 0,
|
|
379
|
+
inCode: false,
|
|
380
|
+
consecutiveLogos: [],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
let md = '';
|
|
384
|
+
$root.contents().each((_, node) => {
|
|
385
|
+
md += nodeToMd(node, ctx, 0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { md, links: ctx.links, logoAlts: ctx.consecutiveLogos };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── LLM optimization pipeline ───────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const CSS_TOKEN_RE = /^[a-z-]+(-[a-z0-9]+)*$/;
|
|
394
|
+
const PROSE_SIGNAL_RE = /[A-Z]|[.!?,;:]|[0-9]{4}|\s[a-z]{4,}\s/;
|
|
395
|
+
|
|
396
|
+
function isCssClassLine(line) {
|
|
397
|
+
const tokens = line.trim().split(/\s+/);
|
|
398
|
+
if (tokens.length < 3) return false;
|
|
399
|
+
const allCss = tokens.every((t) => CSS_TOKEN_RE.test(t));
|
|
400
|
+
if (!allCss) return false;
|
|
401
|
+
// Keep if it looks like prose mixed with class-style words
|
|
402
|
+
return !PROSE_SIGNAL_RE.test(line);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const STAT_ONLY_RE = /^[\d,.+%$€£¥km bBMKGT/×x-]+$/i;
|
|
406
|
+
|
|
407
|
+
function mergeStats(lines) {
|
|
408
|
+
const out = [];
|
|
409
|
+
for (let i = 0; i < lines.length; i++) {
|
|
410
|
+
const line = lines[i];
|
|
411
|
+
const next = lines[i + 1];
|
|
412
|
+
if (STAT_ONLY_RE.test(line.trim()) && next && next.trim().length > 0 && next.trim().split(' ').length <= 4) {
|
|
413
|
+
out.push(`${line.trim()} ${next.trim()}`);
|
|
414
|
+
i++; // skip next
|
|
415
|
+
} else {
|
|
416
|
+
out.push(line);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function stripEmphasis(text) {
|
|
423
|
+
let inCode = false;
|
|
424
|
+
return text.replace(/(`{1,3})([\s\S]*?)\1/g, (m) => { inCode = !inCode; return m; })
|
|
425
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
426
|
+
.replace(/\*([^*]+)\*/g, '$1');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Actually do it line-aware to protect code blocks
|
|
430
|
+
function stripEmphasisSafe(text) {
|
|
431
|
+
const lines = text.split('\n');
|
|
432
|
+
const out = [];
|
|
433
|
+
let inFence = false;
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
if (/^```/.test(line.trim())) { inFence = !inFence; out.push(line); continue; }
|
|
436
|
+
if (inFence) { out.push(line); continue; }
|
|
437
|
+
out.push(line.replace(/\*\*([^*\n]+)\*\*/g, '$1').replace(/\*([^*\n]+)\*/g, '$1'));
|
|
438
|
+
}
|
|
439
|
+
return out.join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function deduplicateParagraphs(text) {
|
|
443
|
+
const paragraphs = text.split(/\n{2,}/);
|
|
444
|
+
const seen = new Set();
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const para of paragraphs) {
|
|
447
|
+
const normalized = para.replace(/\s+/g, ' ').trim();
|
|
448
|
+
if (!normalized) { out.push(para); continue; }
|
|
449
|
+
// Near-duplicate: first 60 chars prefix
|
|
450
|
+
const prefix = normalized.slice(0, 60);
|
|
451
|
+
if (seen.has(prefix)) continue;
|
|
452
|
+
seen.add(prefix);
|
|
453
|
+
out.push(para);
|
|
454
|
+
}
|
|
455
|
+
return out.join('\n\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function filterCssLines(text) {
|
|
459
|
+
const lines = text.split('\n');
|
|
460
|
+
const out = [];
|
|
461
|
+
let inFence = false;
|
|
462
|
+
for (const line of lines) {
|
|
463
|
+
if (/^```/.test(line.trim())) { inFence = !inFence; out.push(line); continue; }
|
|
464
|
+
if (inFence) { out.push(line); continue; }
|
|
465
|
+
if (isCssClassLine(line)) continue;
|
|
466
|
+
out.push(line);
|
|
467
|
+
}
|
|
468
|
+
return out.join('\n');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function buildLinksSection(links) {
|
|
472
|
+
const seen = new Set();
|
|
473
|
+
const unique = [];
|
|
474
|
+
for (const { text, href } of links) {
|
|
475
|
+
if (!href || seen.has(href)) continue;
|
|
476
|
+
if (href.startsWith('#') || href.startsWith('javascript:')) continue;
|
|
477
|
+
seen.add(href);
|
|
478
|
+
unique.push(`- ${text}: ${href}`);
|
|
479
|
+
}
|
|
480
|
+
if (!unique.length) return '';
|
|
481
|
+
return `\n\n## Links\n${unique.join('\n')}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function buildStructuredDataSection(items) {
|
|
485
|
+
if (!items.length) return '';
|
|
486
|
+
return `\n\n## Structured Data\n\`\`\`json\n${JSON.stringify(items, null, 2)}\n\`\`\``;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function optimizeForLLM(rawMd, { metadata, links, structuredData, logoAlts }) {
|
|
490
|
+
// Step 1: metadata header
|
|
491
|
+
const headerLines = [];
|
|
492
|
+
if (metadata.url) headerLines.push(`> URL: ${metadata.url}`);
|
|
493
|
+
if (metadata.title) headerLines.push(`> Title: ${metadata.title}`);
|
|
494
|
+
if (metadata.description) headerLines.push(`> Description: ${metadata.description}`);
|
|
495
|
+
if (metadata.author) headerLines.push(`> Author: ${metadata.author}`);
|
|
496
|
+
const wc = wordCount(rawMd);
|
|
497
|
+
headerLines.push(`> Word count: ${wc}`);
|
|
498
|
+
const header = headerLines.join('\n');
|
|
499
|
+
|
|
500
|
+
let body = rawMd;
|
|
501
|
+
|
|
502
|
+
// Step 2: image stripping — inline  already resolved during conversion
|
|
503
|
+
// (we output alt text directly from nodeToMd — nothing left to strip)
|
|
504
|
+
// Collapse consecutive decorative logo alts if any leaked in
|
|
505
|
+
if (logoAlts.length > 1) {
|
|
506
|
+
body = `${logoAlts.join(', ')}\n\n${body}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Step 3: emphasis stripping
|
|
510
|
+
body = stripEmphasisSafe(body);
|
|
511
|
+
|
|
512
|
+
// Step 4: link normalization — links already extracted, body has plain text from nodeToMd
|
|
513
|
+
|
|
514
|
+
// Step 5: deduplication
|
|
515
|
+
body = deduplicateParagraphs(body);
|
|
516
|
+
|
|
517
|
+
// Step 6: stat merging
|
|
518
|
+
const statLines = mergeStats(body.split('\n'));
|
|
519
|
+
body = statLines.join('\n');
|
|
520
|
+
|
|
521
|
+
// Step 7: whitespace collapse
|
|
522
|
+
body = body
|
|
523
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
524
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
525
|
+
.replace(/\t/g, ' ')
|
|
526
|
+
.trim();
|
|
527
|
+
|
|
528
|
+
// Step 8: CSS class line filtering
|
|
529
|
+
body = filterCssLines(body);
|
|
530
|
+
|
|
531
|
+
// Step 9: structured data section
|
|
532
|
+
const linksSection = buildLinksSection(links);
|
|
533
|
+
const structuredSection = buildStructuredDataSection(structuredData);
|
|
534
|
+
|
|
535
|
+
return `${header}\n\n${body}${linksSection}${structuredSection}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── H1 recovery ─────────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
function recoverH1(allH1Text, extractedMd) {
|
|
541
|
+
if (!allH1Text) return extractedMd;
|
|
542
|
+
if (extractedMd.includes(allH1Text.trim())) return extractedMd;
|
|
543
|
+
return `# ${allH1Text.trim()}\n\n${extractedMd}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
function extractForLLM(html, options = {}) {
|
|
549
|
+
const { url = '' } = options;
|
|
550
|
+
const strategies = [];
|
|
551
|
+
|
|
552
|
+
let $;
|
|
553
|
+
try {
|
|
554
|
+
$ = cheerio.load(html || '', { decodeEntities: true });
|
|
555
|
+
} catch {
|
|
556
|
+
return { markdown: String(html || '').slice(0, 4000), metadata: {}, wordCount: 0, strategies: ['fallback_raw'] };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Capture H1 before noise removal (it might be in a hero <header>)
|
|
560
|
+
const originalH1 = $('h1').first().text().trim();
|
|
561
|
+
|
|
562
|
+
// Capture structured data and Next.js data islands before noise removal
|
|
563
|
+
const structuredData = extractStructuredData($);
|
|
564
|
+
const nextData = extractNextData($);
|
|
565
|
+
|
|
566
|
+
// Metadata
|
|
567
|
+
const metadata = extractMetadata($, url);
|
|
568
|
+
|
|
569
|
+
// Phase 1: noise removal
|
|
570
|
+
removeNoise($);
|
|
571
|
+
|
|
572
|
+
// Phase 2: main content selection
|
|
573
|
+
let $content = selectMainContent($);
|
|
574
|
+
let extractedMd;
|
|
575
|
+
let conversionResult;
|
|
576
|
+
|
|
577
|
+
conversionResult = htmlToMarkdown($, $content, url);
|
|
578
|
+
extractedMd = conversionResult.md;
|
|
579
|
+
const wc = wordCount(extractedMd);
|
|
580
|
+
|
|
581
|
+
if (wc < 100) {
|
|
582
|
+
// Sparse recovery: retry against full body
|
|
583
|
+
$content = $('body');
|
|
584
|
+
conversionResult = htmlToMarkdown($, $content, url);
|
|
585
|
+
extractedMd = conversionResult.md;
|
|
586
|
+
strategies.push('sparse_recovery');
|
|
587
|
+
} else {
|
|
588
|
+
strategies.push('readability_score');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// H1 recovery
|
|
592
|
+
extractedMd = recoverH1(originalH1, extractedMd);
|
|
593
|
+
|
|
594
|
+
// Data island fallback when content is still sparse
|
|
595
|
+
const finalWc = wordCount(extractedMd);
|
|
596
|
+
let islandData = [];
|
|
597
|
+
if (finalWc < 200) {
|
|
598
|
+
if (structuredData.length) {
|
|
599
|
+
islandData = structuredData;
|
|
600
|
+
strategies.push('data_island_schema');
|
|
601
|
+
}
|
|
602
|
+
if (nextData) {
|
|
603
|
+
try {
|
|
604
|
+
const nextStr = JSON.stringify(nextData, null, 2);
|
|
605
|
+
if (nextStr.length < 8000) {
|
|
606
|
+
extractedMd += `\n\n${nextStr}`;
|
|
607
|
+
strategies.push('data_island_next');
|
|
608
|
+
}
|
|
609
|
+
} catch { /* skip */ }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Phase 6: LLM optimization pipeline
|
|
614
|
+
const optimized = optimizeForLLM(extractedMd, {
|
|
615
|
+
metadata,
|
|
616
|
+
links: conversionResult.links,
|
|
617
|
+
structuredData: islandData.length ? islandData : structuredData,
|
|
618
|
+
logoAlts: conversionResult.logoAlts,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
markdown: optimized,
|
|
623
|
+
metadata,
|
|
624
|
+
wordCount: finalWc,
|
|
625
|
+
strategies,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
module.exports = { extractForLLM };
|
|
@@ -559,19 +559,15 @@ class BrowserController {
|
|
|
559
559
|
screenshot = await this.takeScreenshot({ fullPage: options.fullPage });
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const clone = body.cloneNode(true);
|
|
566
|
-
clone.querySelectorAll('script, style, noscript').forEach(s => s.remove());
|
|
567
|
-
return clone.innerText.slice(0, 10000);
|
|
568
|
-
});
|
|
562
|
+
const rawHtml = await page.content();
|
|
563
|
+
const { extractForLLM } = require('./contentExtractor');
|
|
564
|
+
const extraction = extractForLLM(rawHtml, { url: currentUrl });
|
|
569
565
|
|
|
570
566
|
return {
|
|
571
567
|
title,
|
|
572
568
|
url: currentUrl,
|
|
573
569
|
status: response?.status() || 0,
|
|
574
|
-
|
|
570
|
+
pageContent: extraction.markdown,
|
|
575
571
|
screenshotPath: screenshot?.screenshotPath || null,
|
|
576
572
|
artifactId: screenshot?.artifactId || null,
|
|
577
573
|
fullPath: screenshot?.fullPath || null
|
|
@@ -28,11 +28,14 @@ function rejectUpgrade(socket, statusCode, message) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function remoteAddressFromRequest(req) {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
31
|
+
const directPeer = req.socket?.remoteAddress || 'unknown';
|
|
32
|
+
if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
|
|
33
|
+
const forwarded = req.headers?.['x-forwarded-for'];
|
|
34
|
+
if (typeof forwarded === 'string' && forwarded.trim()) {
|
|
35
|
+
return forwarded.split(',')[0].trim();
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
|
-
return
|
|
38
|
+
return directPeer;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
function createUpgradeThrottleObserver() {
|