Spaces:
Runtime error
Runtime error
Download app.py from broadfield/huggingbot-browser: direct link, hf CLI and curl.
- Browser
- Download file 16.6 kB
-
https://huggingface.co/spaces/broadfield/huggingbot-browser/resolve/main/app.py
- Command line
-
hf download hf://spaces/broadfield/huggingbot-browser/app.py
-
curl -L -o app.py https://huggingface.co/spaces/broadfield/huggingbot-browser/resolve/main/app.py
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=["*"], | |
| ) | |
| 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 | |
| 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"), | |
| } | |
| 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) | |
| 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) |