mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
123 lines
4.6 KiB
Python
123 lines
4.6 KiB
Python
"""DuckDuckGo web search: html endpoint primary, lite endpoint fallback.
|
|
|
|
The html endpoint is the richer parse; lite (see search_ddg_lite) covers the two
|
|
ways html dies: a 202 throttle and silent markup drift. Only both endpoints
|
|
throttling raises DDGRateLimited, so free search no longer has a single point
|
|
of failure (the outage class that stranded subscription-only users on
|
|
"No search backend is configured")."""
|
|
|
|
import html
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from backend.apps.agents.tools.search_ddg_lite import search_ddg_lite
|
|
|
|
HTTP_TIMEOUT = 30
|
|
USER_AGENT = (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
|
|
class DDGRateLimited(Exception):
|
|
"""Both DuckDuckGo endpoints answered with the throttle challenge (HTTP 202).
|
|
|
|
Distinct from 'genuinely zero hits' so the caller can fail over to another
|
|
backend instead of reporting an empty search to the user. The throttle is
|
|
per-IP and burst-triggered; once BOTH html and lite serve it, the only cure
|
|
is a different backend or waiting it out."""
|
|
|
|
|
|
def strip_html(raw_html: str) -> str:
|
|
"""Naive but effective HTML to plain-text conversion."""
|
|
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = html.unescape(text)
|
|
text = re.sub(r"[ \t]+", " ", text)
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
return text.strip()
|
|
|
|
|
|
async def search_ddg(query: str, num_results: int) -> str:
|
|
"""Query DuckDuckGo's html endpoint and parse results; lite is the free fallback."""
|
|
async with httpx.AsyncClient(
|
|
timeout=HTTP_TIMEOUT,
|
|
follow_redirects=True,
|
|
headers={"User-Agent": USER_AGENT},
|
|
) as client:
|
|
resp = await client.post(
|
|
"https://html.duckduckgo.com/html/",
|
|
data={"q": query},
|
|
)
|
|
# DDG serves its throttle challenge as 202 (a ~14KB no-results page), which is a 2xx so raise_for_status() sails right past it. Before declaring rate-limited, try the lite frontend; only when BOTH throttle is free search actually dead.
|
|
if resp.status_code == 202:
|
|
lite = await search_ddg_lite(query, num_results)
|
|
if lite is None:
|
|
raise DDGRateLimited(query)
|
|
return lite
|
|
resp.raise_for_status()
|
|
|
|
body = resp.text
|
|
|
|
result_blocks = re.findall(
|
|
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
|
|
body,
|
|
flags=re.DOTALL,
|
|
)
|
|
|
|
entries: list[str] = []
|
|
for block in result_blocks:
|
|
if len(entries) >= num_results:
|
|
break
|
|
|
|
# Handle both class-before-href and href-before-class attribute orders.
|
|
link_match = re.search(
|
|
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
|
block,
|
|
flags=re.DOTALL,
|
|
)
|
|
if not link_match:
|
|
link_match = re.search(
|
|
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
|
|
block,
|
|
flags=re.DOTALL,
|
|
)
|
|
if not link_match:
|
|
continue
|
|
|
|
raw_url = html.unescape(link_match.group(1))
|
|
|
|
# Drop sponsored rows: DDG ads point at its own y.js click-tracker (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd otherwise show up as junk "duckduckgo.com/y.js?ad_..." results.
|
|
if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url:
|
|
continue
|
|
|
|
title = strip_html(link_match.group(2)).strip()
|
|
|
|
snippet_match = re.search(
|
|
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
|
|
block,
|
|
flags=re.DOTALL,
|
|
)
|
|
snippet = strip_html(snippet_match.group(1)).strip() if snippet_match else ""
|
|
|
|
# DDG wraps URLs in a redirect; extract the real one.
|
|
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
|
|
if real_url_match:
|
|
from urllib.parse import unquote
|
|
url = unquote(real_url_match.group(1))
|
|
else:
|
|
url = raw_url
|
|
|
|
entry = f"[{len(entries) + 1}] {title}\n {url}"
|
|
if snippet:
|
|
entry += f"\n {snippet}"
|
|
entries.append(entry)
|
|
|
|
# 200 with zero parsed entries usually means DDG changed its markup out from under the regexes (it has before), not a genuine no-hits; lite's simpler shape is the safety net.
|
|
if not entries:
|
|
lite = await search_ddg_lite(query, num_results)
|
|
if lite:
|
|
return lite
|
|
return "\n\n".join(entries)
|