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.
Files changed (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. 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 ![alt](src) 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 bodyText = await page.evaluate(() => {
563
- const body = document.body;
564
- if (!body) return '';
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
- bodyText,
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 forwarded = req.headers?.['x-forwarded-for'];
32
- if (typeof forwarded === 'string' && forwarded.trim()) {
33
- return forwarded.split(',')[0].trim();
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 req.socket?.remoteAddress || 'unknown';
38
+ return directPeer;
36
39
  }
37
40
 
38
41
  function createUpgradeThrottleObserver() {