Files
hey-search/backend/app/engines/google.py
T
junvandCopilot 7a2126ddb1 Open source: add AGPL-3.0 license, NOTICE, and copyright headers
- Add LICENSE (GNU Affero General Public License v3.0)
- Add NOTICE file with SearXNG attribution for borrowed scraping patterns
  in google.py (arc_id, GSA user-agent, HTML selectors) and bing.py
  (PERE pagination, base64 redirect decoding, CAPTCHA detection)
- Add CONTRIBUTING.md with contribution guidelines and license info
- Add SPDX-License-Identifier headers to all Python source files
- Add AGPL-3.0 license badge and License section to README.md

SearXNG is licensed under AGPL-3.0-or-later; this project follows suit
as it incorporates techniques derived from SearXNG's search engines.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-07 10:18:05 +11:00

294 lines
10 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 Junwei Zhao
# Portions derived from SearXNG <https://github.com/searxng/searxng>
# Copyright (C) 2021-2024 SearXNG Contributors
"""Google Search engine — using SearXNG's async progressive response approach."""
from __future__ import annotations
import json
import logging
import random
import re
import string
import time
from urllib.parse import urlencode, unquote
from lxml import html as lxml_html
from app.models import WebResult, ImageResult
from app.engines.base import SearchEngine, get_http_client
from app.engines.date_utils import parse_date_from_text
logger = logging.getLogger(__name__)
# Characters used to generate random arc_id (matches SearXNG)
_ARCID_CHARS = string.ascii_letters + string.digits + "_-"
_ARCID_LEN = 23
# Cached arc_id prefix refreshed every hour
_arc_id_cache: tuple[float, str] = (0.0, "")
def _get_arc_id() -> str:
"""Generate a random arc_id prefix, refreshed hourly like SearXNG."""
global _arc_id_cache
now = time.time()
if now - _arc_id_cache[0] > 3600:
_arc_id_cache = (now, "".join(random.choices(_ARCID_CHARS, k=_ARCID_LEN)))
return _arc_id_cache[1]
def _build_async_param(start: int) -> str:
"""Build the `async` parameter for Google's progressive response API."""
arc_id = _get_arc_id()
return f"arc_id:srp_{arc_id}_1{start},use_ac:true,_fmt:prog"
# Google Search App user agent (matches SearXNG's gen_gsa_useragent)
_GSA_UA = "GSA/14.46.1 (Linux; U; Android 14; Pixel 8a)"
_GOOGLE_HEADERS = {
"User-Agent": _GSA_UA,
"Accept": "*/*",
}
def _extract_url(raw: str) -> str:
"""Extract real URL from Google's /url?q= redirect wrapper."""
if raw.startswith("/url?"):
# Format: /url?q=<encoded_url>&sa=U&...
url = unquote(raw[7:].split("&sa=")[0])
return url
if raw.startswith("http") and "google.com" not in raw:
return raw
return ""
class GoogleEngine(SearchEngine):
name = "google"
display_name = "Google"
supports_web = True
supports_images = True
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
start = (page - 1) * 10
params = {
"q": query,
"hl": "en",
"ie": "utf8",
"oe": "utf8",
"start": start,
"filter": "0",
"asearch": "arc",
"async": _build_async_param(start),
}
client = get_http_client()
resp = await client.get(
f"https://www.google.com/search?{urlencode(params)}",
headers=_GOOGLE_HEADERS,
cookies={"CONSENT": "YES+"},
)
resp.raise_for_status()
# Detect captcha
url_str = str(resp.url)
if "sorry.google.com" in url_str or "/sorry/" in url_str:
logger.warning("Google CAPTCHA detected — results blocked")
return []
dom = lxml_html.fromstring(resp.text)
results: list[WebResult] = []
for container in dom.xpath('.//div[contains(@class, "MjjYud")]'):
# Extract title from role="link" div (SearXNG pattern)
title_el = container.xpath('.//div[contains(@role, "link")]')
if not title_el:
title_el = container.xpath(".//h3")
if not title_el:
continue
title = title_el[0].text_content().strip()
if not title:
continue
# Extract URL from first valid anchor
url = ""
for a in container.xpath(".//a/@href"):
extracted = _extract_url(a)
if extracted:
url = extracted
break
if not url:
continue
# Extract content/description (data-sncf="1" is SearXNG's selector)
content = ""
content_els = container.xpath('.//div[contains(@data-sncf, "1")]')
if content_els:
# Remove script tags before extracting text
for script in content_els[0].xpath(".//script"):
script.getparent().remove(script)
content = content_els[0].text_content().strip()
if not content:
# Fallback: look for any description-like paragraph
for p in container.xpath(".//span"):
txt = p.text_content().strip()
if len(txt) > 40:
content = txt
break
results.append(WebResult(title=title, url=url, content=content, engine=self.name, published_date=parse_date_from_text(content)))
if not results:
logger.warning("Google returned no parseable results for '%s'", query)
return results
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
start = (page - 1) * 50
params: dict[str, str | int] = {
"q": query,
"tbm": "isch",
"hl": "en",
"ie": "utf8",
"oe": "utf8",
"asearch": "arc",
"async": _build_async_param(start),
}
# Google size filter: tbs=isz:l (large), isz:m (medium), isz:s (small)
size_map = {"large": "isz:l", "medium": "isz:m", "small": "isz:s"}
if image_size in size_map:
params["tbs"] = size_map[image_size]
client = get_http_client()
resp = await client.get(
f"https://www.google.com/search?{urlencode(params)}",
headers=_GOOGLE_HEADERS,
cookies={"CONSENT": "YES+"},
)
resp.raise_for_status()
url_str = str(resp.url)
if "sorry.google.com" in url_str or "/sorry/" in url_str:
logger.warning("Google CAPTCHA detected for image search")
return []
dom = lxml_html.fromstring(resp.text)
text = resp.text
# Build thumbnail map from base64 data embedded in response
# Pattern: dimg_<id>","0"]<digits>;data:image/<type>;base64,<data>
thumb_map: dict[str, str] = {}
b64_matches = re.findall(
r'(dimg_[^"]+)","0"\]\d+;(data:image/[^;]+;base64,[A-Za-z0-9+/=]+)', text
)
for img_id, data_uri in b64_matches:
thumb_map[img_id] = data_uri
# Also collect encrypted-tbn thumbnail URLs
tbn_urls: list[str] = []
raw_text = text.replace("\\\\u003d", "=").replace('\\"', '"')
tbn_urls = re.findall(
r"https://encrypted-tbn\d\.gstatic\.com/images\?q[^\"\\&\s]+", raw_text
)
results: list[ImageResult] = []
seen: set[str] = set()
# Parse image items from ivg-i containers (image grid items)
for item in dom.xpath('.//div[contains(@class, "ivg-i")]'):
img = item.xpath(".//img[@alt]")
if not img:
continue
alt = img[0].get("alt", "").strip()
if not alt:
continue
# Find source page URL
source_url = ""
for a in item.xpath(".//a/@href"):
extracted = _extract_url(a)
if extracted:
source_url = extracted
break
# Skip Google-internal URLs
if not source_url or source_url in seen:
continue
seen.add(source_url)
# Get thumbnail: prefer base64 from thumb_map, else encrypted-tbn
img_id = img[0].get("id", "")
thumb = thumb_map.get(img_id, "")
if not thumb and tbn_urls:
thumb = tbn_urls.pop(0)
results.append(
ImageResult(
title=alt,
url=source_url,
img_src=thumb or "",
thumbnail_src=thumb or "",
source="Google",
engine=self.name,
)
)
# Fallback: if no ivg-i items, try imgs with alt inside MjjYud
if not results:
for img_el in dom.xpath('//img[@alt and @alt!=""]'):
alt = img_el.get("alt", "").strip()
if not alt or len(alt) < 3:
continue
el = img_el
source_url = ""
for _ in range(10):
el = el.getparent()
if el is None:
break
if el.tag == "a":
extracted = _extract_url(el.get("href", ""))
if extracted:
source_url = extracted
break
if source_url and source_url not in seen:
seen.add(source_url)
img_id = img_el.get("id", "")
thumb = thumb_map.get(img_id, "")
if not thumb and tbn_urls:
thumb = tbn_urls.pop(0)
results.append(
ImageResult(
title=alt,
url=source_url,
img_src=thumb or "",
thumbnail_src=thumb or "",
source="Google",
engine=self.name,
)
)
if len(results) >= 50:
break
return results
async def autocomplete(self, query: str) -> list[str]:
client = get_http_client()
resp = await client.get(
f"https://www.google.com/complete/search?{urlencode({'q': query, 'client': 'gws-wiz', 'hl': 'en'})}",
)
if resp.is_success:
text = resp.text
try:
json_txt = text[text.index("[") : text.rindex("]", -5) + 1]
data = json.loads(json_txt)
suggestions = []
for item in data[0]:
raw = item[0] if isinstance(item, list) else str(item)
clean = lxml_html.fromstring(raw).text_content()
suggestions.append(clean)
return suggestions[:10]
except (ValueError, json.JSONDecodeError, IndexError):
pass
return []