@alisio/core 0.1.0-alpha.11 → 0.1.0-alpha.12

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/dist/config.d.ts CHANGED
@@ -155,6 +155,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
155
155
  websearch: z.ZodDefault<z.ZodObject<{
156
156
  provider: z.ZodOptional<z.ZodEnum<{
157
157
  brave: "brave";
158
+ "duckduckgo-html": "duckduckgo-html";
158
159
  "duckduckgo-instant": "duckduckgo-instant";
159
160
  native: "native";
160
161
  searxng: "searxng";
@@ -215,7 +216,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
215
216
  maxOutputTokens: number;
216
217
  };
217
218
  websearch: {
218
- provider?: "brave" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
219
+ provider?: "brave" | "duckduckgo-html" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
219
220
  searxngUrl?: string | undefined;
220
221
  apiKeyEnv?: string | undefined;
221
222
  nativeToolType: string;
@@ -353,7 +354,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
353
354
  maxOutputTokens: number;
354
355
  };
355
356
  websearch: {
356
- provider?: "brave" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
357
+ provider?: "brave" | "duckduckgo-html" | "duckduckgo-instant" | "native" | "searxng" | "serpapi" | "tavily" | undefined;
357
358
  searxngUrl?: string | undefined;
358
359
  apiKeyEnv?: string | undefined;
359
360
  nativeToolType: string;
@@ -444,6 +445,7 @@ declare const SETTABLE_KEYS: {
444
445
  readonly "tui.skillSlashCommands": z.ZodDefault<z.ZodBoolean>;
445
446
  readonly "websearch.provider": z.ZodOptional<z.ZodEnum<{
446
447
  brave: "brave";
448
+ "duckduckgo-html": "duckduckgo-html";
447
449
  "duckduckgo-instant": "duckduckgo-instant";
448
450
  native: "native";
449
451
  searxng: "searxng";
package/dist/config.js CHANGED
@@ -140,7 +140,15 @@ const configObjectSchema = z
140
140
  websearch: z
141
141
  .object({
142
142
  provider: z
143
- .enum(["searxng", "duckduckgo-instant", "tavily", "brave", "serpapi", "native"])
143
+ .enum([
144
+ "searxng",
145
+ "duckduckgo-instant",
146
+ "duckduckgo-html",
147
+ "tavily",
148
+ "brave",
149
+ "serpapi",
150
+ "native",
151
+ ])
144
152
  .optional(),
145
153
  searxngUrl: z.string().optional(),
146
154
  apiKeyEnv: z.string().optional(),
@@ -8,8 +8,8 @@
8
8
  * A throwing plugin provider falls back to the built-in chain with a diagnostic, never a hard
9
9
  * failure of the tool call.
10
10
  * 2. `websearch.provider` from config, when set: `"searxng"` (with a custom `searxngUrl`),
11
- * `"duckduckgo-instant"`, `"tavily"`, `"brave"` or `"serpapi"` (the last three need an API key
12
- * env var).
11
+ * `"duckduckgo-instant"`, `"duckduckgo-html"`, `"tavily"`, `"brave"` or `"serpapi"` (the last
12
+ * three need an API key env var).
13
13
  * 3. Nothing configured: a public SearXNG instance (see `DEFAULT_SEARXNG_URL`) — genuinely free
14
14
  * and keyless, but empirically unreliable: while building this tool, nearly every public
15
15
  * instance tried (searx.be and several others from https://searx.space) rate-limited or
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import type { SearchProvider, SearchResult } from "@alisio/sdk";
25
25
  export interface WebsearchConfig {
26
- provider?: "searxng" | "duckduckgo-instant" | "tavily" | "brave" | "serpapi" | "native";
26
+ provider?: "searxng" | "duckduckgo-instant" | "duckduckgo-html" | "tavily" | "brave" | "serpapi" | "native";
27
27
  /** Overrides the default public instance; self-hosted or another public instance. */
28
28
  searxngUrl?: string;
29
29
  /** Environment variable holding the API key for `tavily`, `brave` or `serpapi`. */
@@ -50,6 +50,20 @@ export declare function searxngProvider(baseUrl: string): SearchProvider;
50
50
  * search engine. Callers must not treat an empty result as "nothing found on the web".
51
51
  */
52
52
  export declare function duckDuckGoInstantProvider(): SearchProvider;
53
+ /**
54
+ * Parses DuckDuckGo lite HTML into `SearchResult`s (exported so tests can exercise it without the
55
+ * network). Ignores every anchor that is not a `result-link` whose href starts with
56
+ * `//duckduckgo.com/l/?uddg=` — ads and related-search links never count. Snippets are taken from
57
+ * the `result-snippet` cell that follows each result and precedes the next one. Returns [] for a
58
+ * lite page that legitimately has no results.
59
+ */
60
+ export declare function parseDuckDuckGoLiteHtml(html: string): SearchResult[];
61
+ /**
62
+ * DuckDuckGo's keyless HTML "lite" endpoint: a real general web search (unlike the Instant Answer
63
+ * API), scraped without an API key or cookie machinery. Works when the default public SearXNG
64
+ * instance is bot-blocked. See `parseDuckDuckGoLiteHtml` for the HTML parsing contract.
65
+ */
66
+ export declare function duckDuckGoHtmlProvider(): SearchProvider;
53
67
  export declare function tavilyProvider(apiKey: string): SearchProvider;
54
68
  export declare function braveProvider(apiKey: string): SearchProvider;
55
69
  export declare function serpApiProvider(apiKey: string): SearchProvider;
@@ -116,6 +116,150 @@ export function duckDuckGoInstantProvider() {
116
116
  },
117
117
  };
118
118
  }
119
+ // --- DuckDuckGo lite (HTML) ---
120
+ // The keyless `lite.duckduckgo.com/lite/` page answers a plain GET with real, general web-search
121
+ // results (unlike the Instant Answer API). It is a scrape of HTML that DuckDuckGo may change at any
122
+ // time, so the parser below is deliberately tolerant: only `result-link` anchors whose href goes
123
+ // through the `//duckduckgo.com/l/?uddg=` redirector are trusted as results; ads/related links and
124
+ // the surrounding table/div markup are ignored. Verified live (2026-09): the full
125
+ // `html.duckduckgo.com/html/` endpoint returns HTTP 202 with a captcha challenge for automated
126
+ // requests, while `lite.duckduckgo.com/lite/` answers HTTP 200 with results and no key.
127
+ const DUCKDUCKGO_LITE_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0";
128
+ const DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/";
129
+ /** Minimal local HTML-entity decoder (no dependency): the common named ones plus numeric forms. */
130
+ function decodeHtmlEntities(text) {
131
+ return text.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos|nbsp);/g, (raw, entity) => {
132
+ const lower = entity.toLowerCase();
133
+ if (lower === "amp")
134
+ return "&";
135
+ if (lower === "lt")
136
+ return "<";
137
+ if (lower === "gt")
138
+ return ">";
139
+ if (lower === "quot")
140
+ return '"';
141
+ if (lower === "apos")
142
+ return "'";
143
+ if (lower === "nbsp")
144
+ return "\u00a0";
145
+ try {
146
+ const code = lower.startsWith("#x")
147
+ ? parseInt(lower.slice(2), 16)
148
+ : lower.startsWith("#")
149
+ ? parseInt(lower.slice(1), 10)
150
+ : NaN;
151
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
152
+ ? String.fromCodePoint(code)
153
+ : raw;
154
+ }
155
+ catch {
156
+ return raw;
157
+ }
158
+ });
159
+ }
160
+ /** Strips tags and collapses whitespace, keeping the text usable as a title or snippet. */
161
+ function htmlToText(fragment) {
162
+ return decodeHtmlEntities(fragment.replace(/<[^>]*>/g, ""))
163
+ .replace(/\s+/g, " ")
164
+ .trim();
165
+ }
166
+ const RESULT_LINK = /<a\b[^>]*class=['"]result-link['"][^>]*>([\s\S]*?)<\/a>/gi;
167
+ const HREF_ATTR = /href\s*=\s*"([^"]*)"/i;
168
+ const SNIPPET_CELL = /class=['"]result-snippet['"]>([\s\S]*?)<\/td>/gi;
169
+ const UDDG_PARAM = /uddg=([^&"]+)/i;
170
+ /**
171
+ * Parses DuckDuckGo lite HTML into `SearchResult`s (exported so tests can exercise it without the
172
+ * network). Ignores every anchor that is not a `result-link` whose href starts with
173
+ * `//duckduckgo.com/l/?uddg=` — ads and related-search links never count. Snippets are taken from
174
+ * the `result-snippet` cell that follows each result and precedes the next one. Returns [] for a
175
+ * lite page that legitimately has no results.
176
+ */
177
+ export function parseDuckDuckGoLiteHtml(html) {
178
+ const anchors = [...html.matchAll(RESULT_LINK)];
179
+ const snippets = [...html.matchAll(SNIPPET_CELL)];
180
+ const results = [];
181
+ for (let i = 0; i < anchors.length; i++) {
182
+ const anchor = anchors[i];
183
+ const tag = anchor[0];
184
+ const href = HREF_ATTR.exec(tag)?.[1] ?? "";
185
+ if (!href.startsWith("//duckduckgo.com/l/?uddg="))
186
+ continue;
187
+ const raw = UDDG_PARAM.exec(href)?.[1];
188
+ let url = "";
189
+ if (raw) {
190
+ try {
191
+ url = decodeURIComponent(raw);
192
+ }
193
+ catch {
194
+ url = "";
195
+ }
196
+ }
197
+ if (!url)
198
+ continue;
199
+ const title = htmlToText(anchor[1] ?? "");
200
+ if (!title)
201
+ continue;
202
+ let snippet = "";
203
+ const limit = anchors[i + 1] ? anchors[i + 1].index : html.length;
204
+ for (const cell of snippets) {
205
+ if (cell.index !== undefined && cell.index > (anchor.index ?? 0) && cell.index < limit) {
206
+ snippet = htmlToText(cell[1] ?? "");
207
+ break;
208
+ }
209
+ }
210
+ results.push({ title, url, snippet });
211
+ }
212
+ return results;
213
+ }
214
+ /**
215
+ * DuckDuckGo's keyless HTML "lite" endpoint: a real general web search (unlike the Instant Answer
216
+ * API), scraped without an API key or cookie machinery. Works when the default public SearXNG
217
+ * instance is bot-blocked. See `parseDuckDuckGoLiteHtml` for the HTML parsing contract.
218
+ */
219
+ export function duckDuckGoHtmlProvider() {
220
+ return {
221
+ id: "duckduckgo-html",
222
+ async search(query, options) {
223
+ const url = new URL(DUCKDUCKGO_LITE_URL);
224
+ url.searchParams.set("q", query);
225
+ url.searchParams.set("kl", "us-en");
226
+ let res;
227
+ try {
228
+ res = await fetch(url, {
229
+ headers: { "User-Agent": DUCKDUCKGO_LITE_UA, Accept: "text/html" },
230
+ // Never follow a redirect blindly: on this endpoint a redirect is a bot-check detour,
231
+ // not a moved page, and following it would just land on a captcha.
232
+ redirect: "error",
233
+ signal: options?.signal,
234
+ });
235
+ }
236
+ catch (error) {
237
+ throw new Error(`DuckDuckGo lite search request failed: ${error instanceof Error ? error.message : String(error)}. The provider is duckduckgo-html (${DUCKDUCKGO_LITE_URL}); possible causes: a network ` +
238
+ "failure, a redirect (treated as a bot-check detour), or rate limiting. Try again later " +
239
+ "or switch websearch.provider via /settings.");
240
+ }
241
+ if (res.status !== 200) {
242
+ throw new Error(`DuckDuckGo lite search failed: HTTP ${res.status}. The endpoint may be showing a ` +
243
+ "bot-check/captcha challenge instead of results (the full html.duckduckgo.com endpoint " +
244
+ "returns HTTP 202 for automated requests). Try again later, or switch websearch.provider " +
245
+ "via /settings (e.g. searxng with your own instance).");
246
+ }
247
+ const html = await res.text();
248
+ if (html.length <= 200) {
249
+ throw new Error("DuckDuckGo lite search returned an unexpectedly empty response (no HTML to parse); the " +
250
+ "endpoint may be bot-blocked. Try again later or switch websearch.provider via /settings.");
251
+ }
252
+ if (!html.includes("result-link") &&
253
+ !html.includes("result-snippet") &&
254
+ !/action=['"]\/lite\/['"]/.test(html)) {
255
+ throw new Error("DuckDuckGo lite did not return recognizable DuckDuckGo lite HTML results; it may be " +
256
+ "showing a bot-check/captcha challenge instead. Try again later or switch " +
257
+ "websearch.provider via /settings.");
258
+ }
259
+ return parseDuckDuckGoLiteHtml(html).slice(0, 10);
260
+ },
261
+ };
262
+ }
119
263
  export function tavilyProvider(apiKey) {
120
264
  return {
121
265
  id: "tavily",
@@ -197,6 +341,15 @@ function resolveBuiltin(config) {
197
341
  limitation: "DuckDuckGo's Instant Answer API only answers direct factual/infobox-style queries, not " +
198
342
  "general web search. An empty result here does not mean nothing exists on the web.",
199
343
  };
344
+ if (provider === "duckduckgo-html")
345
+ return {
346
+ provider: duckDuckGoHtmlProvider(),
347
+ source: "duckduckgo-html",
348
+ limitation: "DuckDuckGo lite HTML provider: keyless and free, but it scrapes HTML DuckDuckGo may " +
349
+ "change at any time, and heavy automation can be bot-checked. Use it as a keyless fallback " +
350
+ "when the default public SearXNG instance is bot-blocked; self-hosted SearXNG remains the " +
351
+ "most reliable option.",
352
+ };
200
353
  if (provider === "native")
201
354
  throw new Error('websearch.provider is "native": search runs server-side through the model provider; ' +
202
355
  "this tool should never be registered or called in that mode.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.12",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",