@agentprojectcontext/apx 1.30.2 → 1.31.0
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/package.json +1 -1
- package/src/core/agent/prompt-builder.js +6 -0
- package/src/core/agent/run-agent.js +21 -0
- package/src/core/tools/browser.js +169 -75
- package/src/core/tools/registry.js +6 -1
- package/src/core/tools/search.js +35 -7
- package/src/host/daemon/super-agent-tools/index.js +232 -43
- package/src/host/daemon/super-agent-tools/registry-bridge.js +30 -1
- package/src/host/daemon/super-agent-tools/tools/discover-tools.js +67 -0
- package/src/host/daemon/super-agent.js +15 -17
- package/src/interfaces/web/package-lock.json +100 -211
package/package.json
CHANGED
|
@@ -271,6 +271,11 @@ export function buildSuperAgentSystem({
|
|
|
271
271
|
// Pre-rendered "# Hilos activos en otros canales" block (recency-based
|
|
272
272
|
// cross-channel awareness; see core/memory/active-threads.js). "" → omitted.
|
|
273
273
|
activeThreadsBlock = "",
|
|
274
|
+
// Compact "# Tools adicionales (activación on-demand)" block: instructions +
|
|
275
|
+
// the NAMES (no schemas) of tools that exist but aren't loaded on this
|
|
276
|
+
// channel, so the model knows they're reachable via discover_tools without
|
|
277
|
+
// paying for their schemas. "" → omitted (full channels load everything).
|
|
278
|
+
lazyToolsBlock = "",
|
|
274
279
|
}) {
|
|
275
280
|
const sa = globalConfig.super_agent;
|
|
276
281
|
const projectIndex = projects
|
|
@@ -305,6 +310,7 @@ export function buildSuperAgentSystem({
|
|
|
305
310
|
projectIndex || "(no projects registered)",
|
|
306
311
|
buildProjectAgentsBlock(channelMeta?.projectPath),
|
|
307
312
|
buildSkillsCatalog(listSkills),
|
|
313
|
+
lazyToolsBlock,
|
|
308
314
|
voiceModeBlock,
|
|
309
315
|
systemSuffix,
|
|
310
316
|
]
|
|
@@ -178,6 +178,25 @@ export async function runAgent({
|
|
|
178
178
|
})
|
|
179
179
|
: rawHandlers;
|
|
180
180
|
|
|
181
|
+
// Lazy tools: when the super-agent runs a `discover_tools` activation, its
|
|
182
|
+
// handler pushes the newly-revealed schemas onto session.pending. We drain
|
|
183
|
+
// that queue into effectiveSchemas at the top of each iteration, so tools
|
|
184
|
+
// activated on step N are callable from step N+1. No session → no-op.
|
|
185
|
+
const toolSession = toolHandlerCtx?.toolSession || null;
|
|
186
|
+
const drainPendingTools = () => {
|
|
187
|
+
if (!toolSession || toolSession.pending.length === 0) return;
|
|
188
|
+
const seen = new Set(
|
|
189
|
+
effectiveSchemas.map((s) => s?.function?.name || s?.name)
|
|
190
|
+
);
|
|
191
|
+
const additions = [];
|
|
192
|
+
for (const sc of toolSession.pending) {
|
|
193
|
+
const n = sc?.function?.name || sc?.name;
|
|
194
|
+
if (n && !seen.has(n)) { additions.push(sc); seen.add(n); }
|
|
195
|
+
}
|
|
196
|
+
toolSession.pending = [];
|
|
197
|
+
if (additions.length > 0) effectiveSchemas = effectiveSchemas.concat(additions);
|
|
198
|
+
};
|
|
199
|
+
|
|
181
200
|
const conversation = [...previousMessages, { role: "user", content: prompt }];
|
|
182
201
|
const trace = [];
|
|
183
202
|
let totalUsage = { input_tokens: 0, output_tokens: 0 };
|
|
@@ -236,6 +255,8 @@ export async function runAgent({
|
|
|
236
255
|
};
|
|
237
256
|
|
|
238
257
|
for (let iter = 0; iter < maxIters; iter++) {
|
|
258
|
+
// Merge any tools activated via discover_tools on the previous iteration.
|
|
259
|
+
drainPendingTools();
|
|
239
260
|
await emitProgress(onEvent, { type: "model_start", iteration: iter + 1, model: activeModel });
|
|
240
261
|
// Force a tool call on iter 0 ONLY when the user message looks like a real
|
|
241
262
|
// action request ("listame…", "mandá…", "buscá…"). For chit-chat ("hola",
|
|
@@ -181,19 +181,104 @@ async function ensureBrowser({ launch_options, allow_dangerous } = {}) {
|
|
|
181
181
|
return _page;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Context-destruction resilience
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
189
|
+
|
|
190
|
+
// Puppeteer throws this family of errors when an action (evaluate / get_text /
|
|
191
|
+
// click / …) runs while the page is navigating, redirecting, or reloading —
|
|
192
|
+
// the frame's JS execution context is torn down mid-call. Redirect-heavy sites
|
|
193
|
+
// (ESPN geo/consent hops, login walls) trigger it constantly. These are
|
|
194
|
+
// transient: waiting for the navigation to settle and retrying succeeds.
|
|
195
|
+
const CONTEXT_DESTROYED_RE =
|
|
196
|
+
/Execution context was destroyed|Cannot find context|Execution context is not available|detached frame|frame (?:was|got) detached|Target closed|Session closed|Protocol error.*(?:Runtime|Page)\./i;
|
|
197
|
+
|
|
198
|
+
export function isContextDestroyed(err) {
|
|
199
|
+
return CONTEXT_DESTROYED_RE.test(String(err?.message || err));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Let any in-flight navigation finish so the next action sees a stable context.
|
|
203
|
+
async function settlePage(page, { timeout = 5000 } = {}) {
|
|
204
|
+
if (!page || page.isClosed()) return;
|
|
205
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout }).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Run a page action, retrying on a transient "Execution context was destroyed"
|
|
209
|
+
// (and friends): wait `delayMs`, let the page settle, try again — up to
|
|
210
|
+
// `retries` extra attempts. Non-context errors bubble immediately.
|
|
211
|
+
export async function withContextRetry(fn, { retries = 2, delayMs = 1500 } = {}) {
|
|
212
|
+
let lastErr;
|
|
213
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
214
|
+
try {
|
|
215
|
+
return await fn();
|
|
216
|
+
} catch (e) {
|
|
217
|
+
lastErr = e;
|
|
218
|
+
if (!isContextDestroyed(e) || attempt === retries) throw e;
|
|
219
|
+
await sleep(delayMs);
|
|
220
|
+
await settlePage(_page);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
throw lastErr;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Convenience: ensure the browser/page, then run an action under context-retry.
|
|
227
|
+
async function onPage(fn) {
|
|
228
|
+
const page = await ensureBrowser();
|
|
229
|
+
return withContextRetry(() => fn(page));
|
|
230
|
+
}
|
|
231
|
+
|
|
184
232
|
// ---------------------------------------------------------------------------
|
|
185
233
|
// Tool implementations
|
|
186
234
|
// ---------------------------------------------------------------------------
|
|
187
235
|
|
|
188
|
-
export async function browser_navigate({ url, launch_options, allow_dangerous } = {}) {
|
|
236
|
+
export async function browser_navigate({ url, launch_options, allow_dangerous, wait_until } = {}) {
|
|
189
237
|
if (!url) throw new Error("url required");
|
|
190
238
|
const page = await ensureBrowser({ launch_options, allow_dangerous });
|
|
191
|
-
|
|
239
|
+
|
|
240
|
+
const go = async (waitUntil) => {
|
|
241
|
+
const response = await page.goto(url, { waitUntil, timeout: 30000 });
|
|
242
|
+
// Some sites fire a client-side redirect/reload right after the initial
|
|
243
|
+
// load. Give it a beat to settle so the execution context is stable for
|
|
244
|
+
// the caller's NEXT tool (get_text/evaluate) instead of being destroyed
|
|
245
|
+
// out from under it.
|
|
246
|
+
await settlePage(page, { timeout: 3000 });
|
|
247
|
+
return response;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Preferred wait strategy: networkidle2 (or caller override). On a
|
|
251
|
+
// context-destroyed / timeout / navigation error, fall back to the much more
|
|
252
|
+
// permissive "domcontentloaded" which resolves as soon as the DOM is parsed,
|
|
253
|
+
// before late redirects/XHR can tear the context down.
|
|
254
|
+
const preferred = wait_until || "networkidle2";
|
|
255
|
+
let response;
|
|
256
|
+
try {
|
|
257
|
+
response = await go(preferred);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const recoverable =
|
|
260
|
+
isContextDestroyed(e) ||
|
|
261
|
+
/TimeoutError|Navigation timeout|net::ERR_ABORTED|frame was detached/i.test(String(e?.message || e));
|
|
262
|
+
if (!recoverable || preferred === "domcontentloaded") throw e;
|
|
263
|
+
await sleep(1500);
|
|
264
|
+
response = await go("domcontentloaded");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// title() evaluates in-page, so it can itself throw if a redirect is still
|
|
268
|
+
// in flight — read it defensively (url() is sync and always safe).
|
|
269
|
+
let title = "";
|
|
270
|
+
try {
|
|
271
|
+
title = await withContextRetry(() => page.title(), { retries: 1, delayMs: 1000 });
|
|
272
|
+
} catch {
|
|
273
|
+
title = "";
|
|
274
|
+
}
|
|
275
|
+
|
|
192
276
|
return {
|
|
193
277
|
ok: true,
|
|
194
278
|
url: page.url(),
|
|
195
279
|
status: response?.status() ?? null,
|
|
196
|
-
title
|
|
280
|
+
title,
|
|
281
|
+
wait_until: response ? (preferred) : null,
|
|
197
282
|
};
|
|
198
283
|
}
|
|
199
284
|
|
|
@@ -206,12 +291,13 @@ export async function browser_screenshot({ selector, full_page = false, width, h
|
|
|
206
291
|
});
|
|
207
292
|
}
|
|
208
293
|
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
294
|
+
const buf = await withContextRetry(async () => {
|
|
295
|
+
const target = selector ? await page.$(selector) : null;
|
|
296
|
+
if (selector && !target) throw new Error(`Element not found: ${selector}`);
|
|
297
|
+
return target
|
|
298
|
+
? await target.screenshot({ type: "png", encoding: "base64" })
|
|
299
|
+
: await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
|
|
300
|
+
});
|
|
215
301
|
|
|
216
302
|
const size = Buffer.from(String(buf), "base64").length;
|
|
217
303
|
if (size > MAX_SCREENSHOT_BYTES) {
|
|
@@ -248,50 +334,56 @@ export async function browser_screenshot({ selector, full_page = false, width, h
|
|
|
248
334
|
|
|
249
335
|
export async function browser_click({ selector } = {}) {
|
|
250
336
|
if (!selector) throw new Error("selector required");
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
337
|
+
return onPage(async (page) => {
|
|
338
|
+
await page.waitForSelector(selector, { timeout: 10000 });
|
|
339
|
+
await page.click(selector);
|
|
340
|
+
await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
|
|
341
|
+
return { ok: true, selector, url: page.url() };
|
|
342
|
+
});
|
|
256
343
|
}
|
|
257
344
|
|
|
258
345
|
export async function browser_type({ selector, text, clear = true } = {}) {
|
|
259
346
|
if (!selector) throw new Error("selector required");
|
|
260
347
|
if (text === undefined) throw new Error("text required");
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
348
|
+
return onPage(async (page) => {
|
|
349
|
+
await page.waitForSelector(selector, { timeout: 10000 });
|
|
350
|
+
await page.focus(selector);
|
|
351
|
+
if (clear) {
|
|
352
|
+
await page.keyboard.down("Control");
|
|
353
|
+
await page.keyboard.press("KeyA");
|
|
354
|
+
await page.keyboard.up("Control");
|
|
355
|
+
await page.keyboard.press("Backspace");
|
|
356
|
+
}
|
|
357
|
+
await page.type(selector, String(text), { delay: 20 });
|
|
358
|
+
return { ok: true, selector, typed: String(text).length };
|
|
359
|
+
});
|
|
272
360
|
}
|
|
273
361
|
|
|
274
362
|
export async function browser_select({ selector, value } = {}) {
|
|
275
363
|
if (!selector) throw new Error("selector required");
|
|
276
364
|
if (value === undefined) throw new Error("value required");
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
365
|
+
return onPage(async (page) => {
|
|
366
|
+
await page.waitForSelector(selector, { timeout: 10000 });
|
|
367
|
+
await page.select(selector, String(value));
|
|
368
|
+
return { ok: true, selector, value };
|
|
369
|
+
});
|
|
281
370
|
}
|
|
282
371
|
|
|
283
372
|
export async function browser_hover({ selector } = {}) {
|
|
284
373
|
if (!selector) throw new Error("selector required");
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
374
|
+
return onPage(async (page) => {
|
|
375
|
+
await page.waitForSelector(selector, { timeout: 10000 });
|
|
376
|
+
await page.hover(selector);
|
|
377
|
+
return { ok: true, selector };
|
|
378
|
+
});
|
|
289
379
|
}
|
|
290
380
|
|
|
291
381
|
export async function browser_evaluate({ code } = {}) {
|
|
292
382
|
if (!code) throw new Error("code required");
|
|
293
|
-
|
|
383
|
+
return onPage((page) => evaluateOnPage(page, code));
|
|
384
|
+
}
|
|
294
385
|
|
|
386
|
+
async function evaluateOnPage(page, code) {
|
|
295
387
|
// Install in-page console capture so evaluated code's logs come back.
|
|
296
388
|
await page.evaluate(() => {
|
|
297
389
|
window.__apxHelper = { logs: [], orig: { ...console } };
|
|
@@ -325,54 +417,56 @@ export async function browser_evaluate({ code } = {}) {
|
|
|
325
417
|
}
|
|
326
418
|
|
|
327
419
|
export async function browser_get_text({ selector } = {}) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
420
|
+
return onPage(async (page) => {
|
|
421
|
+
const text = await page.evaluate((sel) => {
|
|
422
|
+
const root = sel ? document.querySelector(sel) : document.body;
|
|
423
|
+
if (!root) return null;
|
|
424
|
+
const clone = root.cloneNode(true);
|
|
425
|
+
for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
|
|
426
|
+
for (const el of clone.querySelectorAll(tag)) el.remove();
|
|
427
|
+
}
|
|
428
|
+
return clone.innerText || clone.textContent || "";
|
|
429
|
+
}, selector ?? null);
|
|
430
|
+
if (text === null) throw new Error(`Element not found: ${selector}`);
|
|
431
|
+
const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
432
|
+
let title = "";
|
|
433
|
+
try { title = await page.title(); } catch { title = ""; }
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
url: page.url(),
|
|
437
|
+
title,
|
|
438
|
+
text: cleaned,
|
|
439
|
+
chars: cleaned.length,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
347
442
|
}
|
|
348
443
|
|
|
349
444
|
export async function browser_get_content({ selector } = {}) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
445
|
+
return onPage(async (page) => {
|
|
446
|
+
let content = selector
|
|
447
|
+
? await page.$eval(selector, el => el.innerHTML).catch(() => null)
|
|
448
|
+
: await page.content();
|
|
449
|
+
if (content === null) throw new Error(`Element not found: ${selector}`);
|
|
450
|
+
|
|
451
|
+
let truncated = false;
|
|
452
|
+
if (content.length > MAX_CONTENT_CHARS) {
|
|
453
|
+
content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
|
|
454
|
+
truncated = true;
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
url: page.url(),
|
|
459
|
+
selector: selector ?? null,
|
|
460
|
+
chars: content.length,
|
|
461
|
+
truncated,
|
|
462
|
+
html: content,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
369
465
|
}
|
|
370
466
|
|
|
371
467
|
export async function browser_wait_for_selector({ selector, timeout = 30000 } = {}) {
|
|
372
468
|
if (!selector) throw new Error("selector required");
|
|
373
|
-
|
|
374
|
-
await page.waitForSelector(selector, { timeout });
|
|
375
|
-
return { ok: true, selector };
|
|
469
|
+
return onPage((page) => page.waitForSelector(selector, { timeout }).then(() => ({ ok: true, selector })));
|
|
376
470
|
}
|
|
377
471
|
|
|
378
472
|
export async function browser_close() {
|
|
@@ -350,12 +350,17 @@ const TOOL_DEFINITIONS = [
|
|
|
350
350
|
{
|
|
351
351
|
name: "browser_navigate",
|
|
352
352
|
category: "browser",
|
|
353
|
-
description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call.",
|
|
353
|
+
description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call. Auto-retries and falls back to a more permissive wait strategy on redirect-heavy sites.",
|
|
354
354
|
endpoint: { method: "POST", path: "/tools/browser/navigate" },
|
|
355
355
|
parameters: {
|
|
356
356
|
type: "object",
|
|
357
357
|
properties: {
|
|
358
358
|
url: { type: "string" },
|
|
359
|
+
wait_until: {
|
|
360
|
+
type: "string",
|
|
361
|
+
enum: ["load", "domcontentloaded", "networkidle0", "networkidle2"],
|
|
362
|
+
description: "Puppeteer wait strategy (default networkidle2). Use 'domcontentloaded' for slow/redirect-heavy sites; navigate auto-falls back to it on failure anyway.",
|
|
363
|
+
},
|
|
359
364
|
launch_options: { type: "object", description: "Puppeteer launch overrides (headless, args, defaultViewport, etc.)." },
|
|
360
365
|
allow_dangerous: { type: "boolean", description: "Allow dangerous launch args (--no-sandbox, --single-process, etc.)." },
|
|
361
366
|
},
|
package/src/core/tools/search.js
CHANGED
|
@@ -45,26 +45,54 @@ function extractText(html) {
|
|
|
45
45
|
.replace(/</g, "<")
|
|
46
46
|
.replace(/>/g, ">")
|
|
47
47
|
.replace(/"/g, '"')
|
|
48
|
-
.replace(/&#
|
|
48
|
+
.replace(/�?39;/g, "'")
|
|
49
49
|
.replace(/ /g, " ")
|
|
50
|
+
// Generic numeric entities (decimal \ and hex ') DDG sprinkles into
|
|
51
|
+
// titles/snippets — decode so results read cleanly.
|
|
52
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
53
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
50
54
|
.replace(/\s{2,}/g, " ")
|
|
51
55
|
.trim();
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Unwrap DuckDuckGo's result redirect. DDG no longer exposes the target URL
|
|
60
|
+
* directly: every result href is `//duckduckgo.com/l/?uddg=<urlencoded real
|
|
61
|
+
* url>&rut=...`. We pull the `uddg` param out and decode it back to the real
|
|
62
|
+
* destination. Plain/protocol-relative URLs are normalized to https.
|
|
63
|
+
*/
|
|
64
|
+
export function unwrapDdgUrl(href) {
|
|
65
|
+
if (!href) return href;
|
|
66
|
+
const m = href.match(/[?&]uddg=([^&]+)/);
|
|
67
|
+
if (m) {
|
|
68
|
+
try {
|
|
69
|
+
return decodeURIComponent(m[1].replace(/&/g, "&"));
|
|
70
|
+
} catch {
|
|
71
|
+
/* fall through to raw href */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (href.startsWith("//")) return "https:" + href;
|
|
75
|
+
return href;
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
/** Parse DuckDuckGo HTML results */
|
|
55
|
-
function parseDdgResults(html, limit) {
|
|
79
|
+
export function parseDdgResults(html, limit) {
|
|
56
80
|
const results = [];
|
|
57
|
-
// Match result blocks: each has a link (.result__a) and snippet (.result__snippet)
|
|
58
|
-
|
|
81
|
+
// Match result blocks: each has a link (.result__a) and snippet (.result__snippet).
|
|
82
|
+
// Attribute order varies (rel/class/href), so don't assume class precedes href.
|
|
83
|
+
const blockRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
59
84
|
const snippetRe = /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
60
85
|
|
|
61
86
|
const links = [];
|
|
62
87
|
let m;
|
|
63
88
|
while ((m = blockRe.exec(html)) !== null && links.length < limit * 2) {
|
|
64
|
-
|
|
89
|
+
// DDG wraps every external link in a //duckduckgo.com/l/?uddg= redirect —
|
|
90
|
+
// decode it to the real target instead of discarding it (the old code
|
|
91
|
+
// dropped everything containing "duckduckgo.com", yielding zero results).
|
|
92
|
+
const url = unwrapDdgUrl(m[1]);
|
|
65
93
|
const title = extractText(m[2]).trim();
|
|
66
|
-
if (
|
|
67
|
-
links.push({ url
|
|
94
|
+
if (url && title && !/^https?:\/\/(?:[a-z]+\.)?duckduckgo\.com\//i.test(url) && !url.startsWith("//duckduckgo")) {
|
|
95
|
+
links.push({ url, title });
|
|
68
96
|
}
|
|
69
97
|
}
|
|
70
98
|
|