broadfield's picture
Deploy app via HuggingBot
1d8970d verified
Raw History Blame Contribute Delete
16.6 kB
"""
HuggingBot Browser API
----------------------
Server-side web search + page scraping for HuggingBot, run as a HuggingFace Space.
Endpoints (CORS-enabled):
POST/GET /search {query, max_results, source} -> {status, results:[{title,snippet,url,source}]}
POST/GET /scrape {url, max_length} -> {status, page_title, final_url, http_status, markdown_content}
POST /web_browse {action:"Search"|"Scrape", query, ...} (compat with the old broadfield-dev/browser-api shape)
GET /health
"""
import json
import re
import urllib.parse
import requests
from bs4 import BeautifulSoup
# ----------------------------------------------------------------------------
# HTTP helpers
# ----------------------------------------------------------------------------
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
def http_get(url, params=None, timeout=25):
return requests.get(url, params=params, headers=HEADERS, timeout=timeout, allow_redirects=True)
def clean_snippet(text, limit=320):
text = re.sub(r"\s+", " ", text or "").strip()
return text[:limit]
# ----------------------------------------------------------------------------
# SEARCH ENGINES (server-side -> no CORS problems)
# ----------------------------------------------------------------------------
def parse_ddg_html(text):
soup = BeautifulSoup(text, "html.parser")
results = []
for a in soup.select("a.result__a"):
title = a.get_text(" ", strip=True)
if not title:
continue
href = a.get("href", "")
m = re.search(r"uddg=([^&]+)", href)
if m:
href = urllib.parse.unquote(m.group(1))
parent = a.find_parent(("div", "article"))
snippet = ""
if parent is not None:
node = parent.select_one(".result__snippet")
if node is not None:
snippet = node.get_text(" ", strip=True)
results.append({"title": title, "url": href, "snippet": clean_snippet(snippet)})
return results
def search_ddg(query, n=5):
r = http_get("https://html.duckduckgo.com/html/", params={"q": query})
r.raise_for_status()
return parse_ddg_html(r.text)[:n]
def search_bing(query, n=5):
r = http_get("https://www.bing.com/search", params={"q": query})
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
results = []
for li in soup.select("li.b_algo"):
a = li.select_one("h2 a")
if a is None:
continue
href = a.get("href", "")
if not href or not href.startswith("http"):
continue
p = li.select_one("p, .b_caption p")
snippet = p.get_text(" ", strip=True) if p is not None else ""
results.append({
"title": a.get_text(" ", strip=True),
"url": href,
"snippet": clean_snippet(snippet),
})
return results[:n]
def search_bing_news(query, n=5):
r = http_get("https://www.bing.com/news/search", params={"q": query})
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
results = []
for card in soup.select(".news-card, .newsitem"):
a = card.select_one("a.title, h3 a, a")
if a is None:
continue
href = a.get("href", "")
title = a.get_text(" ", strip=True)
if not title or not href:
continue
sn = card.select_one(".snippet")
snippet = sn.get_text(" ", strip=True) if sn is not None else ""
results.append({"title": title, "url": href, "snippet": clean_snippet(snippet)})
return results[:n]
def search_wikipedia(query, n=5):
r = http_get("https://en.wikipedia.org/w/api.php", params={
"action": "query", "list": "search", "srsearch": query,
"format": "json", "srlimit": n,
})
r.raise_for_status()
data = r.json()
results = []
for item in data.get("query", {}).get("search", []):
title = item.get("title", "")
if not title:
continue
snippet = re.sub("<[^>]+>", "", item.get("snippet", "")).strip()
results.append({
"title": title,
"url": "https://en.wikipedia.org/wiki/" + urllib.parse.quote(title.replace(" ", "_")),
"snippet": clean_snippet(snippet),
})
return results
def search_web(query, max_results=5, source="auto"):
source = (source or "auto").lower()
try:
n = max(1, min(int(max_results), 10))
except (TypeError, ValueError):
n = 5
attempts = []
if source in ("wikipedia", "wiki"):
attempts.append(("wikipedia", lambda: search_wikipedia(query, n)))
elif source == "news":
attempts.append(("bing-news", lambda: search_bing_news(query, n)))
elif source == "reddit":
attempts.append(("duckduckgo", lambda: search_ddg("site:reddit.com " + query, n)))
attempts.append(("bing", lambda: search_bing(query + " reddit", n)))
else: # auto / web
attempts.append(("duckduckgo", lambda: search_ddg(query, n)))
attempts.append(("bing", lambda: search_bing(query, n)))
attempts.append(("wikipedia", lambda: search_wikipedia(query, n)))
last_error = "no results found"
for name, fn in attempts:
try:
results = fn()
if results:
return {"engine": name, "results": results}
except Exception as e: # noqa: BLE001
last_error = "%s: %s" % (name, e)
return {"engine": "none", "results": [], "error": last_error}
# ----------------------------------------------------------------------------
# SCRAPING
# ----------------------------------------------------------------------------
REMOVE_SELECTORS = [
"script", "style", "noscript", "svg", "canvas", "iframe", "form", "button",
"nav", "footer", "aside",
"[role='navigation']", "[role='search']", "[role='banner']", "[aria-hidden='true']",
".nav", ".navbar", ".menu", ".sidebar", ".header", ".footer",
".ads", ".advertisement", ".cookie", ".popup", ".modal", ".share", ".social",
]
def html_to_markdown(node):
lines = []
for el in node.find_all(["h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "pre", "blockquote", "hr"]):
tag = el.name
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
level = int(tag[1])
text = el.get_text(" ", strip=True)
if text:
lines.append("\n" + "#" * level + " " + text + "\n")
elif tag == "p":
text = el.get_text(" ", strip=True)
if text:
lines.append(text + "\n")
elif tag == "li":
text = el.get_text(" ", strip=True)
if text:
lines.append("* " + text)
elif tag == "pre":
lines.append("\n```\n" + el.get_text("\n", strip=True) + "\n```\n")
elif tag == "blockquote":
text = el.get_text(" ", strip=True)
if text:
lines.append("> " + text + "\n")
elif tag == "hr":
lines.append("\n---\n")
md = re.sub(r"\n{3,}", "\n\n", "\n".join(lines))
return md.strip()
def scrape_url(url, max_len=50000):
try:
max_len = max(100, min(int(max_len), 100000))
except (TypeError, ValueError):
max_len = 50000
url = (url or "").strip()
if not url:
return {"status": "error", "error_message": "Missing 'url'"}
if not url.lower().startswith(("http://", "https://")):
url = "https://" + url
try:
r = http_get(url, timeout=35)
except Exception as e: # noqa: BLE001
return {"status": "error", "url": url, "error_message": str(e)}
final_url = r.url or url
status = r.status_code
ctype = r.headers.get("content-type", "").lower()
base = {"status": "success", "url": url, "final_url": final_url, "http_status": status}
if status >= 400:
base["status"] = "error"
base["error_message"] = "HTTP %d: the page could not be fetched" % status
return base
raw = r.text or ""
# JSON responses -> return pretty JSON
if "json" in ctype:
try:
content = json.dumps(r.json(), ensure_ascii=False, indent=2)
if len(content) > max_len:
content = content[:max_len] + "\n\n...[truncated]"
base.update({"page_title": "JSON response", "description": "", "content": content,
"markdown_content": content, "links": []})
return base
except Exception: # noqa: BLE001
pass
soup = BeautifulSoup(raw, "html.parser")
for selector in REMOVE_SELECTORS:
for el in soup.select(selector):
el.decompose()
title = soup.title.get_text(" ", strip=True) if soup.title else ""
desc = ""
og = soup.select_one('meta[property="og:description"]')
if og is not None:
desc = (og.get("content") or "").strip()
meta = soup.select_one('meta[name="description"]')
if not desc and meta is not None:
desc = (meta.get("content") or "").strip()
node = soup.find("main") or soup.find("article") or soup.find("body") or soup
md = html_to_markdown(node)
# collect links
links, seen = [], set()
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.startswith("javascript:"):
continue
full = urllib.parse.urljoin(final_url, href)
if full in seen:
continue
seen.add(full)
text = a.get_text(" ", strip=True)[:80]
links.append({"url": full, "title": text})
if len(links) >= 20:
break
if links:
md += "\n\n**Links:**\n" + "\n".join(
"- [%s](%s)" % (l["title"] or l["url"], l["url"]) for l in links)
if len(md) > max_len:
md = md[:max_len] + "\n\n...[truncated]"
base.update({
"page_title": title,
"description": desc,
"content": md,
"markdown_content": md,
"links": links,
})
return base
# ----------------------------------------------------------------------------
# FastAPI app + CORS + Gradio UI
# ----------------------------------------------------------------------------
import uvicorn
import gradio as gr
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="HuggingBot Browser API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {"status": "ok", "service": "huggingbot-browser"}
def _search_args(body, params):
if body:
q = body.get("query") or body.get("q")
n = body.get("max_results") or body.get("max") or 5
s = body.get("source") or body.get("search_engine") or "auto"
else:
q = params.get("q") or params.get("query")
n = params.get("max_results") or params.get("max") or 5
s = params.get("source") or "auto"
return q, n, s
@app.get("/search")
@app.post("/search")
async def endpoint_search(request: Request):
body = None
if request.method == "POST":
try:
body = await request.json()
except Exception: # noqa: BLE001
body = None
q, n, s = _search_args(body, request.query_params)
if not q:
return {"status": "error", "error_message": "Missing required parameter: query"}
data = search_web(q, n, s)
ok = bool(data.get("results"))
return {
"status": "success" if ok else "error",
"query": q,
"source": s,
"engine": data.get("engine", "none"),
"results": data.get("results", []),
"error_message": None if ok else data.get("error"),
}
@app.get("/scrape")
@app.post("/scrape")
async def endpoint_scrape(request: Request):
body = None
if request.method == "POST":
try:
body = await request.json()
except Exception: # noqa: BLE001
body = None
if body:
url = body.get("url") or body.get("query")
mlen = body.get("max_length") or body.get("max_len") or 50000
else:
url = request.query_params.get("url") or request.query_params.get("q")
mlen = request.query_params.get("max_length") or request.query_params.get("max_len") or 50000
if not url:
return {"status": "error", "error_message": "Missing required parameter: url"}
return scrape_url(url, mlen)
@app.post("/web_browse")
async def endpoint_web_browse(request: Request):
"""Compatibility endpoint mirroring the old broadfield-dev/browser-api shape."""
try:
body = await request.json()
except Exception: # noqa: BLE001
return {"status": "error", "error_message": "Expected JSON body"}
action = (body.get("action") or "").strip().lower()
query = body.get("query") or body.get("url") or body.get("q")
if not query:
return {"status": "error", "error_message": "Missing 'query'"}
if action in ("search", "scrape url", "scrape") or action == "search":
q_action = "search" if "search" in action else "scrape"
else:
return {"status": "error", "error_message": "action must be 'Search' or 'Scrape'"}
if action == "search":
src = body.get("search_engine_name") or body.get("search_engine") or "auto"
data = search_web(query, body.get("max_results") or 5, src)
results = data.get("results", [])
lines = ['## Search results for "%s"' % query, ""]
for i, r in enumerate(results, 1):
lines.append("### %d. %s" % (i, r["title"]))
if r.get("snippet"):
lines.append("> " + r["snippet"])
if r.get("url"):
lines.append("πŸ”— " + r["url"])
lines.append("")
return {
"status": "success" if results else "error",
"query": query,
"action": "Search",
"final_url": "",
"page_title": "Search results",
"http_status": 200,
"markdown_content": "\n".join(lines),
"results": results,
"error_message": None if results else data.get("error"),
}
else:
mlen = body.get("max_length") or body.get("max_len") or 50000
data = scrape_url(query, mlen)
data["query"] = query
data["action"] = "Scrape"
data["status"] = data.get("status")
return data
# Minimal Gradio UI so the Space behaves like a proper Gradio app too
def ui_search(q, source, n):
data = search_web(q, n, source)
return json.dumps({k: v for k, v in data.items()}, ensure_ascii=False, indent=2)
def ui_scrape(url, mlen):
data = scrape_url(url, mlen)
out = data.get("markdown_content") or data.get("error_message") or "no content"
return "# %s\n\n%s\n" % (data.get("page_title", "Page"), out)
demo = gr.Blocks(title="HuggingBot Browser API")
with demo:
gr.Markdown("# HuggingBot Browser API\n\n"
"Server-side **search** + **scrape** for HuggingBot.\n\n"
"- `POST /search` β†’ `{query, max_results, source}`\n"
"- `POST /scrape` β†’ `{url, max_length}`\n"
"- `POST /web_browse` β†’ legacy-compatible shape\n"
"- `GET /health`")
with gr.Tab("Search"):
with gr.Row():
q = gr.Textbox(label="Query", value="latest AI news 2025", scale=3)
src = gr.Dropdown(["auto", "web", "wikipedia", "news", "reddit"], value="auto", label="Source")
n = gr.Slider(1, 10, value=5, step=1, label="Max results")
btn = gr.Button("Search")
out = gr.Code(label="JSON", language="json")
btn.click(ui_search, inputs=[q, src, n], outputs=out)
with gr.Tab("Scrape"):
with gr.Row():
u = gr.Textbox(label="URL", value="https://en.wikipedia.org/wiki/Web_scraping", scale=3)
ml = gr.Slider(1000, 50000, value=5000, step=500, label="Max length")
btn2 = gr.Button("Scrape")
out2 = gr.Markdown()
btn2.click(ui_scrape, inputs=[u, ml], outputs=out2)
app.mount("/", demo.app)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860)