Replace the abandoned llms-txt-mcp/Chroma docs backend with an in-repo MCP service: SQLite WAL + FTS5 + sentence-transformer embeddings, transactional source replacement, persisted state across restarts, singleflight refresh with conditional requests, hybrid lexical/semantic ranking with exact-duplicate collapse, source/host filters, and explicit-by-default content retrieval. Add docs_rebuild and a docs-rebuild CLI command. Add deterministic llms-full.txt snapshot generation for machine-local menus with hash-validated provenance manifests; lifecycle commands promote a local menu to its snapshot only when the manifest validates. Switch public source profiles to content-bearing llms-full.txt feeds. Improve web search: bounded provider fallback with per-attempt diagnostics and cancellation, an optional Brave Search API provider, strict SearXNG engine selection, capped link/media extraction, and a real engine=browser renderer that routes every request through the existing SSRF vetting while blocking WebSockets, non-GET traffic, and private destinations. Extend release checks with offline unit suites and isolated candidate container tests for both images.
119 lines
4.4 KiB
JavaScript
119 lines
4.4 KiB
JavaScript
import { PUPPETEER_TIMEOUT } from "../constants.js";
|
|
import { browserPool } from "../utils/browser-pool.js";
|
|
import { getAcceptLanguageHeader, getMarketFromLang } from "../utils/user-agent.js";
|
|
import { searchCache, createCacheKey } from "../utils/cache.js";
|
|
|
|
// Context Kit override for @zhafron/mcp-web-search 1.3.0.
|
|
// The upstream provider can read Bing before result cards render and return an
|
|
// empty fallback. Keep this as a direct provider replacement until upstream
|
|
// waits for cards and decodes current /ck/a redirects reliably.
|
|
const DEFAULT_BROWSER_SEARCH_USER_AGENT = process.env.BROWSER_SEARCH_USER_AGENT ||
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
|
|
function decodeBase64Url(value) {
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
|
|
return Buffer.from(padded, "base64").toString("utf-8");
|
|
}
|
|
|
|
export class BingProvider {
|
|
name = "bing";
|
|
|
|
decodeBingRedirect(href) {
|
|
try {
|
|
const url = new URL(href, "https://www.bing.com/");
|
|
if (url.hostname === "www.bing.com" && url.pathname === "/ck/a") {
|
|
const encoded = url.searchParams.get("u");
|
|
if (encoded) {
|
|
const candidates = [encoded];
|
|
if (/^[a-z][0-9]/i.test(encoded)) candidates.push(encoded.slice(2));
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const decoded = decodeBase64Url(candidate);
|
|
if (/^https?:\/\//i.test(decoded)) return decoded;
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
}
|
|
return url.toString();
|
|
}
|
|
catch {
|
|
return href;
|
|
}
|
|
}
|
|
|
|
async search(q, limit, lang, signal) {
|
|
const cacheKey = createCacheKey("bing", q, limit, lang);
|
|
const cached = searchCache.get(cacheKey);
|
|
if (cached)
|
|
return cached;
|
|
const market = getMarketFromLang(lang);
|
|
const results = await browserPool.withBrowser(async (browser) => {
|
|
const page = await browser.newPage();
|
|
const abort = () => void page.close().catch(() => undefined);
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
try {
|
|
signal?.throwIfAborted();
|
|
await page.setViewport({ width: 1365, height: 768 });
|
|
await page.setUserAgent(DEFAULT_BROWSER_SEARCH_USER_AGENT);
|
|
await page.setExtraHTTPHeaders(getAcceptLanguageHeader(lang));
|
|
const url = new URL("https://www.bing.com/search");
|
|
url.searchParams.set("q", q);
|
|
url.searchParams.set("mkt", market);
|
|
const response = await page.goto(url.toString(), {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: PUPPETEER_TIMEOUT
|
|
});
|
|
if (response && response.status() >= 400) {
|
|
throw new Error(`Bing HTTP ${response.status()}`);
|
|
}
|
|
await page.waitForSelector("li.b_algo h2 a[href], li.b_algo a[href]", { timeout: 10000 }).catch(() => undefined);
|
|
const items = await page.evaluate(maxResults => {
|
|
const parsed = [];
|
|
for (const card of Array.from(document.querySelectorAll("li.b_algo"))) {
|
|
const anchor = card.querySelector("h2 a[href]") || card.querySelector("a[href]");
|
|
const title = anchor?.textContent?.trim() || "";
|
|
const href = anchor?.getAttribute("href") || "";
|
|
if (!title || !href)
|
|
continue;
|
|
const snippetElement = card.querySelector("div.b_caption p, div.b_snippet, p");
|
|
const snippet = snippetElement?.textContent?.trim() || undefined;
|
|
parsed.push({ title, url: href, snippet });
|
|
if (parsed.length >= maxResults)
|
|
break;
|
|
}
|
|
return parsed;
|
|
}, limit);
|
|
return items.flatMap(result => {
|
|
try {
|
|
const absolute = new URL(result.url, "https://www.bing.com/").toString();
|
|
const decoded = this.decodeBingRedirect(absolute);
|
|
new URL(decoded);
|
|
return [{ ...result, url: decoded, source: "bing" }];
|
|
}
|
|
catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
finally {
|
|
signal?.removeEventListener("abort", abort);
|
|
if (!page.isClosed()) await page.close();
|
|
}
|
|
});
|
|
searchCache.set(cacheKey, results);
|
|
return results;
|
|
}
|
|
|
|
async isAvailable() {
|
|
try {
|
|
await browserPool.getBrowser();
|
|
return true;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|