Forked from bakit/crawler
Forked from bakit/crawler
src / search / providers / DuckDuckGoProvider.ts
src / search / providers / DuckDuckGoProvider.ts
import * as cheerio from "cheerio";
import { SearchResult, SearchProvider } from "./SearchProvider";
import { RANDOM_UA_POOL } from "../../utils/userAgents";
export class DuckDuckGoProvider implements SearchProvider {
readonly name = "DuckDuckGo";
private lastRequestTime = 0;
private async rateLimit() {
const now = Date.now();
const elapsed = now - this.lastRequestTime;
const minDelay = 800 + Math.random() * 1200; // 800–2000ms jitter
if (elapsed < minDelay) {
await new Promise(resolve => setTimeout(resolve, minDelay - elapsed));
}
this.lastRequestTime = Date.now();
}
async search(query: string, maxResults: number = 10): Promise<SearchResult[]> {
let lastError: Error | null = null;
// Run both methods in parallel for faster results
const htmlPromise = (async () => {
try { await this.rateLimit(); return this.searchHtml(query, maxResults); }
catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); return []; }
})();
const litePromise = (async () => {
try { await this.rateLimit(); return this.searchLite(query, maxResults); }
catch (err) { if (!lastError) lastError = err instanceof Error ? err : new Error(String(err)); return []; }
})();
const [htmlResults, liteResults] = await Promise.all([htmlPromise, litePromise]);
let combined = [...htmlResults, ...liteResults];
// Deduplicate by URL, prefer non-empty titles
const seen = new Set<string>();
combined = combined.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
});
// If no results with titles found, try a generic fallback on the raw HTML
if (!combined.length || combined.every(r => !r.title?.length)) {
const html = await this.fetchHtml(query);
if (html) {
combined = this.extractGenericResults(html, maxResults);
}
}
return combined;
}
private async fetchHtml(query: string): Promise<string | null> {
try {
const url = new URL("https://html.duckduckgo.com/html/");
url.searchParams.set("q", query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url.toString(), {
signal: controller.signal,
redirect: "follow",
headers: { "User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)] },
});
clearTimeout(timeout);
return response.ok ? await response.text() : null;
} catch {
clearTimeout(timeout);
return null;
}
} catch {
return null;
}
}
private async searchHtml(query: string, maxResults: number): Promise<SearchResult[]> {
const html = await this.fetchHtml(query);
if (!html) return [];
if (html.includes("anomaly.js") || html.includes("challenge-form")) return [];
return this.extractResults(html, maxResults);
}
private async searchLite(query: string, maxResults: number): Promise<SearchResult[]> {
try {
await this.rateLimit();
const url = new URL("https://lite.duckduckgo.com/lite/");
url.searchParams.set("q", query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url.toString(), {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)],
"Accept": "text/html",
},
});
if (!response.ok) return [];
const html = await response.text();
clearTimeout(timeout);
if (html.includes("anomaly.js") || html.includes("challenge-form")) return [];
return this.extractLiteResults(html, maxResults);
} catch {
clearTimeout(timeout);
return [];
}
} catch {
return [];
}
}
private extractResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
// Primary selector: h2.result__title > a.result__a
$("h2.result__title > a.result__a").each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
const title = $el.text().trim();
if (href.includes("duckduckgo.com/l/?uddg=")) {
const uddgMatch = href.match(/uddg=([^&]+)/);
if (uddgMatch) href = decodeURIComponent(uddgMatch[1]);
}
if (href && !seen.has(href)) {
seen.add(href);
results.push({ url: href, title: title || extractFallbackTitle($el), content: "", score: 70 });
}
});
return results;
}
private extractLiteResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
$("a.result-link").each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
const title = $el.text().trim();
if (href.includes("duckduckgo.com/l/?uddg=")) {
const uddgMatch = href.match(/uddg=([^&]+)/);
if (uddgMatch) href = decodeURIComponent(uddgMatch[1]);
}
if (href && !seen.has(href)) {
seen.add(href);
results.push({ url: href, title: title || extractFallbackTitle($el), content: "", score: 65 });
}
});
return results;
}
/**
* Generic fallback extraction when DDG-specific selectors don't match.
* Looks for any anchor with a redirect URL pattern and extracts the next text node as title.
*/
private extractGenericResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
// Try multiple selector strategies for DDG HTML results
const selectors = [
"h2.result__title > a",
"a[href*='/l/?uddg=']",
"div.result--more a",
];
for (const sel of selectors) {
if (results.length >= maxResults) break;
$(sel).each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
// Resolve uddg redirect
if (href.includes("/l/?uddg=")) {
const m = href.match(/uddg=([^&]+)/);
if (m) href = decodeURIComponent(m[1]);
} else if (href.startsWith("/l/")) {
// Relative redirect URL
try {
const parsed = new URL(href, "https://html.duckduckgo.com");
href = parsed.searchParams.get("uddg") || "";
} catch {}
}
if (!href || seen.has(href)) return true;
// Extract title from text content or sibling elements
const title = ($el.text().trim()) || ($el.closest("h2, div.result").find("a, strong").first().text().trim());
if (title) {
seen.add(href);
results.push({ url: href, title: title.substring(0, 200), content: "", score: 60 });
}
});
}
return results;
}
}
function extractFallbackTitle($el: cheerio.Cheerio<cheerio.Element>): string {
// Try to get text from parent or sibling elements
const $parent = $el.parent();
if ($parent) {
const text = $parent.contents().filter((_, node) => node.type === "text").first().text().trim();
if (text) return text;
}
return "";
}
import * as cheerio from "cheerio";
import { SearchResult, SearchProvider } from "./SearchProvider";
import { RANDOM_UA_POOL } from "../../utils/userAgents";
export class DuckDuckGoProvider implements SearchProvider {
readonly name = "DuckDuckGo";
private lastRequestTime = 0;
private async rateLimit() {
const now = Date.now();
const elapsed = now - this.lastRequestTime;
const minDelay = 800 + Math.random() * 1200; // 800–2000ms jitter
if (elapsed < minDelay) {
await new Promise(resolve => setTimeout(resolve, minDelay - elapsed));
}
this.lastRequestTime = Date.now();
}
async search(query: string, maxResults: number = 10): Promise<SearchResult[]> {
let lastError: Error | null = null;
// Run both methods in parallel for faster results
const htmlPromise = (async () => {
try { await this.rateLimit(); return this.searchHtml(query, maxResults); }
catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); return []; }
})();
const litePromise = (async () => {
try { await this.rateLimit(); return this.searchLite(query, maxResults); }
catch (err) { if (!lastError) lastError = err instanceof Error ? err : new Error(String(err)); return []; }
})();
const [htmlResults, liteResults] = await Promise.all([htmlPromise, litePromise]);
let combined = [...htmlResults, ...liteResults];
// Deduplicate by URL, prefer non-empty titles
const seen = new Set<string>();
combined = combined.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
});
// If no results with titles found, try a generic fallback on the raw HTML
if (!combined.length || combined.every(r => !r.title?.length)) {
const html = await this.fetchHtml(query);
if (html) {
combined = this.extractGenericResults(html, maxResults);
}
}
return combined;
}
private async fetchHtml(query: string): Promise<string | null> {
try {
const url = new URL("https://html.duckduckgo.com/html/");
url.searchParams.set("q", query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url.toString(), {
signal: controller.signal,
redirect: "follow",
headers: { "User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)] },
});
clearTimeout(timeout);
return response.ok ? await response.text() : null;
} catch {
clearTimeout(timeout);
return null;
}
} catch {
return null;
}
}
private async searchHtml(query: string, maxResults: number): Promise<SearchResult[]> {
const html = await this.fetchHtml(query);
if (!html) return [];
if (html.includes("anomaly.js") || html.includes("challenge-form")) return [];
return this.extractResults(html, maxResults);
}
private async searchLite(query: string, maxResults: number): Promise<SearchResult[]> {
try {
await this.rateLimit();
const url = new URL("https://lite.duckduckgo.com/lite/");
url.searchParams.set("q", query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url.toString(), {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": RANDOM_UA_POOL[Math.floor(Math.random() * RANDOM_UA_POOL.length)],
"Accept": "text/html",
},
});
if (!response.ok) return [];
const html = await response.text();
clearTimeout(timeout);
if (html.includes("anomaly.js") || html.includes("challenge-form")) return [];
return this.extractLiteResults(html, maxResults);
} catch {
clearTimeout(timeout);
return [];
}
} catch {
return [];
}
}
private extractResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
// Primary selector: h2.result__title > a.result__a
$("h2.result__title > a.result__a").each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
const title = $el.text().trim();
if (href.includes("duckduckgo.com/l/?uddg=")) {
const uddgMatch = href.match(/uddg=([^&]+)/);
if (uddgMatch) href = decodeURIComponent(uddgMatch[1]);
}
if (href && !seen.has(href)) {
seen.add(href);
results.push({ url: href, title: title || extractFallbackTitle($el), content: "", score: 70 });
}
});
return results;
}
private extractLiteResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
$("a.result-link").each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
const title = $el.text().trim();
if (href.includes("duckduckgo.com/l/?uddg=")) {
const uddgMatch = href.match(/uddg=([^&]+)/);
if (uddgMatch) href = decodeURIComponent(uddgMatch[1]);
}
if (href && !seen.has(href)) {
seen.add(href);
results.push({ url: href, title: title || extractFallbackTitle($el), content: "", score: 65 });
}
});
return results;
}
/**
* Generic fallback extraction when DDG-specific selectors don't match.
* Looks for any anchor with a redirect URL pattern and extracts the next text node as title.
*/
private extractGenericResults(html: string, maxResults: number): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
const seen = new Set<string>();
// Try multiple selector strategies for DDG HTML results
const selectors = [
"h2.result__title > a",
"a[href*='/l/?uddg=']",
"div.result--more a",
];
for (const sel of selectors) {
if (results.length >= maxResults) break;
$(sel).each(function(_, el) {
if (results.length >= maxResults) return false;
const $el = $(el);
let href = $el.attr("href") || "";
// Resolve uddg redirect
if (href.includes("/l/?uddg=")) {
const m = href.match(/uddg=([^&]+)/);
if (m) href = decodeURIComponent(m[1]);
} else if (href.startsWith("/l/")) {
// Relative redirect URL
try {
const parsed = new URL(href, "https://html.duckduckgo.com");
href = parsed.searchParams.get("uddg") || "";
} catch {}
}
if (!href || seen.has(href)) return true;
// Extract title from text content or sibling elements
const title = ($el.text().trim()) || ($el.closest("h2, div.result").find("a, strong").first().text().trim());
if (title) {
seen.add(href);
results.push({ url: href, title: title.substring(0, 200), content: "", score: 60 });
}
});
}
return results;
}
}
function extractFallbackTitle($el: cheerio.Cheerio<cheerio.Element>): string {
// Try to get text from parent or sibling elements
const $parent = $el.parent();
if ($parent) {
const text = $parent.contents().filter((_, node) => node.type === "text").first().text().trim();
if (text) return text;
}
return "";
}