# -*- coding: utf-8 -*-
"""纯 Python Google Lens 搜图（已验证可行）。

Cookie 机制（逆向分析结论 2026-05-28）:
    正常搜图流程只需 3 个 cookie，全部由 GET https://www.google.com/ 一个请求自动下发：
      - NID  (v531, 219字符, HttpOnly, 6个月有效) — 用户偏好/会话标识，服务端加密 opaque token
      - AEC  (59字符, HttpOnly, 6个月有效) — 反自动化检查
      - __Secure-STRP (98字符, 10分钟有效) — 安全令牌，搜索结果页会自动重新下发

    这 3 个 cookie 在 curl_cffi session.get("https://www.google.com/") 时即自动设入 cookie jar，
    后续 Lens upload → search → folsrch 全链路 session 自动携带，无需浏览器。

    GOOGLE_ABUSE_EXEMPTION 只在触发 reCAPTCHA（/sorry 页面）后才会出现，
    正常频率下搜图不会触发。浏览器仅作为被拦截时的最后降级手段。

流程：warmup(GET google.com) → upload(POST lens/v3/upload) → search(GET /search) → folsrch → parse

用法：
    python lens_pure_python.py /path/to/image.png
    python lens_pure_python.py --url "https://example.com/image.jpg"
    python lens_pure_python.py --cookie "ID=xxx:TM=xxx:C=R:IP=xxx:S=xxx" image.png
    python lens_pure_python.py --refresh-cookie image.png
"""
import os
import sys
import re
import time
import struct
import json
import random
import platform
from urllib.parse import urlparse, parse_qs, quote

sys.path.append(os.path.dirname(sys.path[0]))

from curl_cffi import requests, CurlMime

# ===== 配置 =====
LENS_UPLOAD = "https://lens.google.com/v3/upload"
FOLSRCH = "https://www.google.com/async/folsrch"
IMPERSONATE = "chrome136"
COOKIE_CACHE = os.path.join(os.path.dirname(__file__), ".lens_cookie_cache.json")

def _find_chrome():
    """自动查找 Chrome 路径（支持 Windows/Linux 云服务器）"""
    env = os.environ.get("LENS_CHROME_PATH", "")
    if env and os.path.exists(env):
        return env
    candidates = [
        r"D:\新建文件夹\指纹浏览器\ungoogled-chromium_136.0.7103.113-1.1_windows_x64\chrome.exe",
        r"C:\Program Files\Google\Chrome\Application\chrome.exe",
        r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
        "/usr/bin/google-chrome-stable",
        "/usr/bin/google-chrome",
        "/usr/bin/chromium-browser",
        "/usr/bin/chromium",
        "/snap/bin/chromium",
    ]
    for p in candidates:
        if os.path.exists(p):
            return p
    return ""

CHROME_EXEC = _find_chrome()
BROWSER_PROFILE = os.environ.get("LENS_BROWSER_PROFILE", "")

EI_PATTERN = re.compile(r"""kEI\s*[:=]\s*['"]([A-Za-z0-9_-]{8,})['"]""")


# ===== Cookie 管理 =====
# NID + AEC 由 warmup GET google.com 自动下发，缓存有效期 24h
# GOOGLE_ABUSE_EXEMPTION 仅在 reCAPTCHA 后出现，正常流程不需要
COOKIE_TTL = 86400  # 缓存 24h，NID 实际 6 个月有效但每次请求都会刷新


def _load_cached_cookie():
    """加载缓存的 Google cookies (完整 jar 或单个 ABUSE_EXEMPTION)"""
    if os.path.exists(COOKIE_CACHE):
        try:
            with open(COOKIE_CACHE, "r") as f:
                data = json.load(f)
            if data.get("cookie") and time.time() - data.get("ts", 0) < COOKIE_TTL:
                return data["cookie"]
        except Exception:
            pass
    return os.environ.get("GOOGLE_ABUSE_EXEMPTION", "")


def _save_cookie(cookie_val):
    """保存 cookie 到本地缓存"""
    with open(COOKIE_CACHE, "w") as f:
        json.dump({"cookie": cookie_val, "ts": time.time()}, f)


def _save_session_cookies(session):
    """从 curl_cffi session 中提取所有 Google cookie 并缓存"""
    all_cookies = {}
    for name, value in session.cookies.items():
        all_cookies[name] = value
    if all_cookies:
        _save_cookie(json.dumps(all_cookies))
        print(f"[cookie] 已缓存 {len(all_cookies)} 个 cookie: {list(all_cookies.keys())}")


def _solve_recaptcha_audio(page, max_attempts=3):
    """自动解 reCAPTCHA: 点勾 → 切语音 → 下载音频 → Google 语音识别 → 提交

    完整流程来自 H10_spider.py 的 activate_recaptcha + handle_audio + parse_audio + submit_recaptcha
    改用 Patchright page.frame() API 适配
    """
    import tempfile
    import requests as pyrequests

    ffmpeg_path = r"D:\开发安装\ffmpeg\bin"
    if os.path.exists(ffmpeg_path):
        os.environ["PATH"] = ffmpeg_path + ";" + os.environ.get("PATH", "")

    for attempt in range(1, max_attempts + 1):
        print(f"[CAPTCHA] 第 {attempt}/{max_attempts} 次解题 ...")
        try:
            # 1. 等待 reCAPTCHA anchor iframe 加载（Enterprise JS 是 async defer）
            anchor_frame = None
            for wait_i in range(40):
                all_frames = [(f.url or "") for f in page.frames]
                for f in page.frames:
                    furl = f.url or ""
                    if "recaptcha" in furl and "anchor" in furl:
                        anchor_frame = f
                        break
                if anchor_frame:
                    break
                if wait_i == 0:
                    print(f"[CAPTCHA] 当前页面: {page.url[:100]}")
                    print(f"[CAPTCHA] frames({len(all_frames)}): {[u[:80] for u in all_frames if u]}")
                    has_recaptcha = "recaptcha" in page.content().lower()
                    print(f"[CAPTCHA] 页面含 recaptcha: {has_recaptcha}")
                if wait_i % 10 == 0 and wait_i > 0:
                    print(f"[CAPTCHA] 等待 iframe ({wait_i}/40), frames: {[u[:60] for u in all_frames if 'recaptcha' in u.lower()]}")
                time.sleep(1)
            if not anchor_frame:
                print(f"[CAPTCHA] 40s 内未找到 reCAPTCHA iframe, 最终 frames: {[(f.url or '')[:80] for f in page.frames if f.url]}")
                return False

            print("[CAPTCHA] 点击勾选框 ...")
            anchor_frame.click(".recaptcha-checkbox-border", timeout=5000, force=True)
            time.sleep(4)

            # 2. 找挑战 iframe (bframe)
            challenge_frame = None
            for _ in range(20):
                for f in page.frames:
                    if "recaptcha" in (f.url or "") and "bframe" in (f.url or ""):
                        challenge_frame = f
                        break
                if challenge_frame:
                    break
                time.sleep(1)

            if not challenge_frame:
                print("[CAPTCHA] 勾选后无挑战弹出 — 可能直接通过了")
                return True

            # 3. 切到语音模式
            print("[CAPTCHA] 切换到语音模式 ...")
            challenge_frame.click("#recaptcha-audio-button", timeout=5000, force=True)
            time.sleep(4)

            # 4. 点播放按钮
            try:
                challenge_frame.click("button[aria-labelledby]", timeout=3000, force=True)
                time.sleep(2)
            except Exception:
                pass

            # 5. 拿音频 URL
            audio_url = challenge_frame.locator("#audio-source").get_attribute("src", timeout=5000)
            if not audio_url:
                print("[CAPTCHA] 未找到音频 URL")
                continue
            print(f"[CAPTCHA] 音频 URL: {audio_url[:80]}...")

            # 6. 下载 + 转码 mp3→wav
            mp3_path = os.path.join(tempfile.gettempdir(), "recaptcha_audio.mp3")
            wav_path = os.path.join(tempfile.gettempdir(), "recaptcha_audio.wav")
            res = pyrequests.get(audio_url, timeout=15)
            with open(mp3_path, "wb") as f:
                f.write(res.content)
            print(f"[CAPTCHA] 音频下载 {len(res.content)} bytes → 转码 wav ...")

            import pydub
            pydub.AudioSegment.from_mp3(mp3_path).export(wav_path, format="wav")

            # 7. Google 语音识别
            from speech_recognition import Recognizer, AudioFile
            recognizer = Recognizer()
            with AudioFile(wav_path) as source:
                audio = recognizer.record(source)
            answer = recognizer.recognize_google(audio, language="en-US")
            print(f"[CAPTCHA] 识别结果: '{answer}'")

            # 8. 输入答案 + 提交
            challenge_frame.fill("#audio-response", answer.lower())
            time.sleep(1)
            challenge_frame.press("#audio-response", "Enter")
            print("[CAPTCHA] 已提交答案，等待验证 ...")
            time.sleep(6)

            # 9. 检查是否通过 (URL 从 /sorry 跳走 = 成功)
            if "sorry" not in page.url:
                print("[CAPTCHA] 验证通过!")
                return True
            print(f"[CAPTCHA] 未通过 (URL 仍在 sorry)，重试 ...")

        except Exception as e:
            print(f"[CAPTCHA] 第 {attempt} 次失败: {e}")

        time.sleep(2)

    return False


def get_abuse_exemption_cookie(force_refresh=False):
    """获取 GOOGLE_ABUSE_EXEMPTION cookie (nodriver 版)。"""
    if not force_refresh:
        cached = _load_cached_cookie()
        if cached:
            print(f"[cookie] 使用缓存 cookie (len={len(cached)})")
            return cached

    print("[cookie] 用 nodriver 获取 cookie ...")
    cookies = _nodriver_get_cookies("https://www.google.com/", CHROME_EXEC)
    all_cookies = cookies
    cookie_val = all_cookies.get("GOOGLE_ABUSE_EXEMPTION", "")

    if cookie_val:
        _save_cookie(cookie_val)
        print(f"[cookie] ABUSE_EXEMPTION 已缓存 (len={len(cookie_val)})")
    elif all_cookies:
        _save_cookie(json.dumps(all_cookies))
        print(f"[cookie] 已缓存 {len(all_cookies)} 个 Google cookies: {list(all_cookies.keys())}")
    else:
        print("[cookie] 未获取到任何 cookie")

    return cookie_val or json.dumps(all_cookies)


# ===== 纯 HTTP reCAPTCHA 解题（无浏览器） =====
def _find_audio_url(text, challenge):
    """从 reload 响应提取音频 URL"""
    if challenge and isinstance(challenge, list):
        for item in challenge:
            if isinstance(item, str) and ("payload" in item or ".mp3" in item or "audio" in item):
                return item
            if isinstance(item, list):
                for sub in item:
                    if isinstance(sub, str) and ("payload" in sub or ".mp3" in sub):
                        return sub
    m = re.search(r'"(https://[^"]*(?:payload|audio)[^"]*)"', text)
    return m.group(1) if m else None


def _solve_recaptcha_http(session, sorry_html, max_attempts=3):
    """纯 HTTP 解 reCAPTCHA (protobuf + AI Vision 图片识别，无需浏览器)

    流程: sorry HTML → anchor → reload(protobuf) → 下载图片 → AI Vision → userverify → submit
    返回: (success: bool, cookie_value: str)
    """
    import html as html_mod
    from base64 import urlsafe_b64encode
    from recaptcha_image_solver import (
        encode_reload_protobuf, encode_userverify_form,
        parse_reload_response, extract_image_challenge,
        ask_vision_api, KG_ID_MAP,
    )

    sitekey_m = re.search(r'data-sitekey="([^"]+)"', sorry_html)
    data_s_m = re.search(r'data-s="([^"]+)"', sorry_html)
    q_m = (re.search(r"name='q'\s+value='([^']+)'", sorry_html) or
           re.search(r'name="q"[^>]+value="([^"]+)"', sorry_html))
    continue_m = re.search(r'name="continue"\s+value="([^"]+)"', sorry_html)

    sitekey = sitekey_m.group(1) if sitekey_m else ""
    data_s = data_s_m.group(1) if data_s_m else ""
    q_value = q_m.group(1) if q_m else ""
    continue_url = html_mod.unescape(continue_m.group(1)) if continue_m else ""

    is_enterprise = "enterprise" in sorry_html or "/recaptcha/enterprise" in sorry_html
    endpoint = "enterprise" if is_enterprise else "api2"

    if not sitekey or not data_s:
        print(f"[recaptcha] 缺少参数 sitekey={bool(sitekey)} data-s={bool(data_s)}")
        return False, ""

    print(f"[recaptcha] sitekey={sitekey[:25]}... data-s={len(data_s)} endpoint={endpoint}")

    # 获取版本号
    try:
        ejs = session.get(f"https://www.google.com/recaptcha/{endpoint}.js",
                          headers=_base_headers(), impersonate=IMPERSONATE, timeout=10)
        v_m = re.search(r"releases/([A-Za-z0-9_-]+)/recaptcha", ejs.text)
        version = v_m.group(1) if v_m else None
    except Exception:
        version = None
    if not version:
        print("[recaptcha] 无法获取版本号")
        return False, ""

    co = urlsafe_b64encode(b"https://www.google.com:443").decode().rstrip("=")
    ua = _base_headers()["user-agent"]

    # AI Vision 配置
    VISION_API_KEY = os.environ.get("LLM_GPT4OMINI_API_KEY",
                                    "sk-sDU8gSwyoqUsH9OVVTaNObQ176PZ134I34SCpJNmnBTsOcBx")
    VISION_API_BASE = os.environ.get("LLM_GPT4OMINI_BASE_URL", "https://api.tu-zi.com/v1")
    VISION_MODEL = os.environ.get("LLM_GPT4OMINI_MODEL", "gpt-4o-mini")

    def _submit_sorry(token):
        """提交 sorry 表单，返回 (success, abuse_cookie)"""
        sorry_headers = {
            "content-type": "application/x-www-form-urlencoded",
            "origin": "https://www.google.com",
            "referer": f"https://www.google.com/sorry/index?continue={quote(continue_url)}&q={quote(q_value)}",
            "user-agent": ua,
            "accept": ("text/html,application/xhtml+xml,application/xml;q=0.9,"
                       "image/avif,image/webp,image/apng,*/*;q=0.8,"
                       "application/signed-exchange;v=b3;q=0.7"),
            "accept-encoding": "gzip, deflate, br, zstd",
            "accept-language": "zh-CN,zh;q=0.9",
            "cache-control": "max-age=0",
            "sec-ch-ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"Windows"',
            "sec-fetch-dest": "document",
            "sec-fetch-mode": "navigate",
            "sec-fetch-site": "same-origin",
            "sec-fetch-user": "?1",
            "upgrade-insecure-requests": "1",
            "priority": "u=0, i",
        }
        form_data = {"q": q_value, "continue": continue_url,
                     "g-recaptcha-response": token}
        try:
            print(f"[recaptcha] 提交 sorry form (token={len(token)} chars)...")
            r_s = session.post("https://www.google.com/sorry/index",
                               data=form_data, headers=sorry_headers,
                               impersonate=IMPERSONATE, allow_redirects=True, timeout=20)
            print(f"[recaptcha] sorry 响应: status={r_s.status_code} url={r_s.url[:80]} len={len(r_s.text)}")

            abuse = ""
            for name, value in session.cookies.items():
                if name == "GOOGLE_ABUSE_EXEMPTION":
                    abuse = value
            if abuse:
                print(f"[recaptcha] SUCCESS! ABUSE_EXEMPTION len={len(abuse)}")
                _save_cookie(abuse)
                return True, abuse
            if "sorry" not in r_s.url and r_s.status_code != 429:
                print(f"[recaptcha] sorry 已跳转 → {r_s.url[:100]}")
                _save_session_cookies(session)
                return True, ""
            print(f"[recaptcha] sorry 未通过 (status={r_s.status_code})")

            # curl_cffi 失败，尝试 tls_client (不同 TLS 栈)
            try:
                import tls_client
                ts = tls_client.Session(client_identifier="chrome_131")
                for name, value in session.cookies.items():
                    ts.cookies.set(name, value, domain=".google.com", path="/")
                r_t = ts.post("https://www.google.com/sorry/index",
                              data=form_data, headers=sorry_headers, timeout_seconds=20,
                              allow_redirects=True)
                print(f"[recaptcha] tls_client sorry: status={r_t.status_code} url={r_t.url[:80]}")
                for name, value in r_t.cookies.items():
                    if name == "GOOGLE_ABUSE_EXEMPTION":
                        print(f"[recaptcha] tls_client SUCCESS! len={len(value)}")
                        _save_cookie(value)
                        return True, value
                if "sorry" not in r_t.url and r_t.status_code != 429:
                    return True, ""
            except Exception as e:
                print(f"[recaptcha] tls_client 回退异常: {e}")

            return False, ""
        except Exception as e:
            print(f"[recaptcha] sorry 提交异常: {e}")
            return False, ""

    _last_uv_token = None
    for attempt in range(1, max_attempts + 1):
        print(f"[recaptcha] 第 {attempt}/{max_attempts} 次 ...")
        try:
            # --- 0. GET sorry 页面建立 cookie session ---
            sorry_full_url = f"https://www.google.com/sorry/index?continue={quote(continue_url)}&q={quote(q_value)}"
            session.get(sorry_full_url, impersonate=IMPERSONATE, timeout=15,
                        headers={**_base_headers(), "referer": "https://www.google.com/"})

            # --- 1. anchor token ---
            anchor_params = {
                "ar": "1", "k": sitekey, "co": co, "hl": "en",
                "v": version, "size": "normal",
                "cb": "bl" + str(int(time.time() * 1000)),
            }
            if data_s:
                anchor_params["s"] = data_s
            anchor_url = (f"https://www.google.com/recaptcha/{endpoint}/anchor?"
                          + "&".join(f"{k}={quote(str(v))}" for k, v in anchor_params.items()))
            r_a = session.get(anchor_url, impersonate=IMPERSONATE, timeout=15,
                              headers={"referer": sorry_full_url,
                                       "user-agent": ua})
            tk_m = re.search(r'id="recaptcha-token"\s+value="([^"]+)"', r_a.text)
            if not tk_m:
                print("[recaptcha] anchor 无 token")
                continue
            rtoken = tk_m.group(1)
            print(f"[recaptcha] anchor token OK ({len(rtoken)} chars)")

            # --- 2. reload (protobuf，获取图片挑战，不提交 sorry) ---
            reload_url = f"https://www.google.com/recaptcha/{endpoint}/reload?k={sitekey}"
            bframe_ref = f"https://www.google.com/recaptcha/{endpoint}/bframe?hl=en&v={version}&k={sitekey}"
            proto_body = encode_reload_protobuf(version, rtoken, sitekey, reason="fi")
            r_r = session.post(reload_url, data=proto_body, impersonate=IMPERSONATE, timeout=15,
                               headers={"content-type": "application/x-protobuffer",
                                        "referer": bframe_ref, "user-agent": ua})
            arr = parse_reload_response(r_r.content)
            if not arr or not arr[1]:
                print("[recaptcha] reload null token, 重试 ...")
                continue

            new_token = arr[1]

            # --- 3. 解析图片挑战 ---
            for ri, rv in enumerate(arr):
                rs = json.dumps(rv, ensure_ascii=False) if rv else "null"
                if len(rs) > 120:
                    rs = rs[:120] + "..."
                print(f"[recaptcha]   arr[{ri}]: {rs}")
            challenge = extract_image_challenge(arr, sitekey, endpoint)
            if not challenge or not challenge.get("image_url"):
                print(f"[recaptcha] 无法解析挑战: {json.dumps(arr[:6], ensure_ascii=False)[:200]}")
                continue

            prompt_text = challenge.get("prompt", "the target object")
            grid_size = challenge.get("grid_size", 4)
            challenge_type = challenge.get("type", "")
            print(f"[recaptcha] 挑战: prompt={prompt_text}, grid={grid_size}, type={challenge_type}")

            # --- 4. 下载图片 ---
            r_img = session.get(challenge["image_url"], impersonate=IMPERSONATE, timeout=20,
                                headers={"referer": bframe_ref, "accept": "image/*,*/*",
                                         "user-agent": ua})
            if len(r_img.content) < 1000:
                print(f"[recaptcha] 图片下载失败: {len(r_img.content)} bytes")
                continue
            print(f"[recaptcha] 图片: {len(r_img.content)} bytes")

            # --- 5. AI Vision 识别 ---
            selected = ask_vision_api(
                r_img.content, prompt_text, VISION_API_KEY,
                VISION_API_BASE, grid_size, challenge_type, VISION_MODEL,
            )
            if not selected:
                print("[recaptcha] AI Vision 未识别到目标")
                continue
            print(f"[recaptcha] AI 选中: {selected}")

            # --- 6. userverify (URL-encoded form) ---
            vfy_url = f"https://www.google.com/recaptcha/{endpoint}/userverify?k={sitekey}"
            vfy_form = encode_userverify_form(version, new_token, selected)
            r_v = session.post(vfy_url, data=vfy_form, impersonate=IMPERSONATE, timeout=15,
                               headers={"content-type": "application/x-www-form-urlencoded",
                                        "referer": bframe_ref, "user-agent": ua})
            vfy_arr = parse_reload_response(r_v.content)
            if vfy_arr and vfy_arr[1]:
                _last_uv_token = vfy_arr[1]
                print(f"[recaptcha] userverify token: {len(vfy_arr[1])} chars")
                ok, cookie = _submit_sorry(vfy_arr[1])
                if ok:
                    return True, cookie
            else:
                print("[recaptcha] userverify 未通过")

        except Exception as e:
            print(f"[recaptcha] 异常: {e}")
            import traceback
            traceback.print_exc()
        time.sleep(1)

    # 纯 HTTP sorry 提交失败时，用 nodriver 访问 sorry URL 获取 cookie
    if _last_uv_token:
        print("[recaptcha] 纯HTTP sorry提交被封，用 nodriver 获取 cookie...")
        sorry_full = f"https://www.google.com/sorry/index?continue={quote(continue_url)}&q={quote(q_value)}"
        nd_cookies = _nodriver_get_cookies(sorry_full, CHROME_EXEC)
        if nd_cookies:
            abuse = nd_cookies.get("GOOGLE_ABUSE_EXEMPTION", "")
            if abuse:
                _save_cookie(abuse)
                return True, abuse
            for k, v in nd_cookies.items():
                session.cookies.set(k, v, domain=".google.com", path="/")
            _save_session_cookies(session)
            print(f"[recaptcha] nodriver 获取 {len(nd_cookies)} 个 cookie")
            return True, ""

    return False, ""


# ===== nodriver 轻量浏览器 + Cookie 预热池 =====
WARMUP_FLAG = os.path.join(os.path.dirname(__file__), ".lens_warmed_up")


def _nodriver_get_cookies(url, chrome_path=None):
    """用 nodriver (CDP) 访问 URL 并提取 Google cookies"""
    import asyncio

    async def _run():
        import nodriver as uc
        config = uc.Config()
        if chrome_path and os.path.exists(chrome_path):
            config.browser_executable_path = chrome_path
        config.headless = True
        browser = await uc.start(config=config)
        try:
            page = await browser.get(url)
            await page.sleep(7)
            cdp_cookies = await browser.cookies.get_all()
            cookies = {}
            for c in cdp_cookies:
                if "google" in (c.domain or ""):
                    cookies[c.name] = c.value
            return cookies
        finally:
            browser.stop()

    try:
        return asyncio.run(_run())
    except Exception as e:
        print(f"[nodriver] 异常: {e}")
        return {}


def warmup_cookie_pool(chrome_path=None, force=False):
    """一次性预热 cookie 池（启动时调用一次，后续全程纯 HTTP）。

    预热后 cookie 缓存有效 24h，期间不再需要浏览器。
    返回: bool (是否成功)
    """
    if not force:
        cached = _load_cached_cookie()
        if cached:
            try:
                c = json.loads(cached) if cached.startswith("{") else {}
                if c.get("DV"):
                    print(f"[warmup] cookie 池有效 (含 DV)，跳过预热")
                    return True
            except Exception:
                pass

    chrome = chrome_path or CHROME_EXEC
    print("[warmup] 预热 cookie 池 (nodriver) ...")
    cookies = _nodriver_get_cookies("https://www.google.com/search?q=hello&udm=2", chrome)
    if cookies:
        _save_cookie(json.dumps(cookies))
        has_dv = "DV" in cookies
        print(f"[warmup] 预热成功: {list(cookies.keys())} DV={'YES' if has_dv else 'NO'}")
        return True
    print("[warmup] 预热失败")
    return False


def _solve_captcha_and_retry(session, sorry_url, sorry_html, original_search_url=None):
    """纯 HTTP 解 reCAPTCHA，失败用 nodriver 获取 cookie。
    返回: (solved: bool, browser_html: str|None)
    """
    print("[captcha] 尝试纯 HTTP 解题 ...")
    success, cookie = _solve_recaptcha_http(session, sorry_html)
    if success:
        if cookie and not cookie.startswith("{"):
            session.cookies.set("GOOGLE_ABUSE_EXEMPTION", cookie,
                                domain=".google.com", path="/")
            _save_cookie(cookie)
        return True, None

    # HTTP 失败，用 nodriver 获取 cookie
    import html as html_mod
    continue_m = re.search(r'name="continue"\s+value="([^"]+)"', sorry_html)
    continue_url = html_mod.unescape(continue_m.group(1)) if continue_m else sorry_url

    print("[captcha] 纯 HTTP 失败，用 nodriver 获取 cookie ...")
    cookies = _nodriver_get_cookies(continue_url, CHROME_EXEC)
    if cookies:
        for k, v in cookies.items():
            session.cookies.set(k, v, domain=".google.com", path="/")
        _save_session_cookies(session)
        abuse = cookies.get("GOOGLE_ABUSE_EXEMPTION", "")
        if abuse:
            _save_cookie(abuse)
        print(f"[captcha] nodriver 获取 {len(cookies)} 个 cookie: {list(cookies.keys())}")
        return True, None

    print("[captcha] nodriver 也失败")
    return False, None


# ===== SG_SS Cookie 生成（纯 Python，PyMiniRacer V8 引擎） =====
def _generate_sgss_cookie(html):
    """纯 Python 执行 Google JS 壳页面生成 SG_SS cookie（无需 Node.js）"""
    try:
        from py_mini_racer import MiniRacer
    except ImportError:
        print("[sgss] py_mini_racer 未安装，回退到 Node.js")
        return _generate_sgss_cookie_nodejs(html)

    scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.DOTALL)
    if not scripts:
        print("[sgss] HTML 中无 script 标签")
        return None

    polyfill = (
        "var document = {_cookies: '', get cookie() { return this._cookies; },"
        " set cookie(v) { this._cookies = v; }};\n"
        "var window = this;\n"
        "var navigator = {userAgent: 'Mozilla/5.0 Chrome/147.0.0.0', sendBeacon: function(){}};\n"
        "var location = {href: 'https://www.google.com/search', replace: function(){}};\n"
        "var performance = {timing: {navigationStart: Date.now()-1000, responseStart: Date.now()-500}};\n"
        "var sessionStorage = {setItem: function(){}, getItem: function(){return null}};\n"
        "var Image = function(){};\n"
        "var google = {c:{cap:0}, kEXPI:'', tick:function(){}, log:function(){}};\n"
        "var N = function(){}; var O = function(){};\n"
    )

    try:
        ctx = MiniRacer()
        ctx.eval(polyfill)
        for s in scripts:
            if not s.strip():
                continue
            try:
                ctx.eval(s)
            except Exception:
                pass
        cookie = ctx.eval("document._cookies || document.cookie || ''")
        if "SG_SS=" in str(cookie):
            sgss = cookie.split("SG_SS=")[1].split(";")[0]
            print(f"[sgss] PyMiniRacer 生成成功 (len={len(sgss)})")
            return sgss
        print("[sgss] PyMiniRacer 未生成 SG_SS cookie")
    except Exception as e:
        print(f"[sgss] PyMiniRacer 异常: {e}")

    return _generate_sgss_cookie_nodejs(html)


def _generate_sgss_cookie_nodejs(html):
    """Node.js 回退方案"""
    import subprocess, tempfile
    solver = os.path.join(os.path.dirname(__file__), "lens_sgss_solver.js")
    if not os.path.exists(solver):
        return None
    tmp = os.path.join(tempfile.gettempdir(), "lens_sgss_input.html")
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(html)
    try:
        r = subprocess.run(["node", solver, tmp], capture_output=True, text=True, timeout=10)
        if r.returncode == 0 and r.stdout:
            print(f"[sgss] Node.js 回退成功 (len={len(r.stdout)})")
            return r.stdout.strip()
    except Exception:
        pass
    return None


# ===== 工具函数 =====
def _get_image_size(path):
    with open(path, "rb") as f:
        head = f.read(32)
        if head[:8] == b"\x89PNG\r\n\x1a\n":
            return struct.unpack(">II", head[16:24])
        if head[:2] == b"\xff\xd8":
            f.seek(2)
            while True:
                b = f.read(1)
                while b and b != b"\xff":
                    b = f.read(1)
                marker = f.read(1)
                while marker == b"\xff":
                    marker = f.read(1)
                if not marker:
                    break
                m = marker[0]
                if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
                    f.read(3)
                    hh, ww = struct.unpack(">HH", f.read(4))
                    return ww, hh
                seg_len = struct.unpack(">H", f.read(2))[0]
                f.read(seg_len - 2)
    raise ValueError("unsupported image format (PNG/JPEG only)")


def _base_headers():
    return {
        "sec-ch-ua": '"Google Chrome";v="136", "Not.A/Brand";v="99", "Chromium";v="136"',
        "sec-ch-ua-arch": '"x86"',
        "sec-ch-ua-bitness": '"64"',
        "sec-ch-ua-form-factors": '"Desktop"',
        "sec-ch-ua-full-version": '"136.0.7103.113"',
        "sec-ch-ua-full-version-list": '"Google Chrome";v="136.0.7103.113", "Not.A/Brand";v="99.0.0.0", "Chromium";v="136.0.7103.113"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-model": '""',
        "sec-ch-ua-platform": '"Windows"',
        "sec-ch-ua-platform-version": '"10.0.0"',
        "sec-ch-ua-wow64": "?0",
        "x-client-data": "COH7ygE=",
        "downlink": "1.5",
        "rtt": "300",
        "sec-ch-prefers-color-scheme": "light",
        "upgrade-insecure-requests": "1",
        "user-agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                       "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"),
        "accept": ("text/html,application/xhtml+xml,application/xml;q=0.9,"
                   "image/avif,image/webp,image/apng,*/*;q=0.8,"
                   "application/signed-exchange;v=b3;q=0.7"),
        "sec-fetch-site": "none",
        "sec-fetch-mode": "navigate",
        "sec-fetch-user": "?1",
        "sec-fetch-dest": "document",
        "accept-encoding": "gzip, deflate, br, zstd",
        "accept-language": "zh-CN,zh;q=0.9",
        "priority": "u=0, i",
    }


def _enrich_search_url(search_url):
    """补全搜索 URL 缺失的参数，触发 Google 下发 AI 概览组件"""
    from urllib.parse import urlencode, urlunparse
    parsed = urlparse(search_url)
    params = parse_qs(parsed.query, keep_blank_values=True)
    if 'biw' not in params:
        params['biw'] = ['1366']
    if 'bih' not in params:
        params['bih'] = ['768']
    if 'vsdim' not in params:
        params['vsdim'] = ['1366,768']
    if 'qsubts' not in params:
        params['qsubts'] = [str(int(time.time() * 1000))]
    if 'lns_vfs' not in params:
        params['lns_vfs'] = ['e']
    return urlunparse(parsed._replace(query=urlencode(params, doseq=True)))


def _extract_folsrch_url(html):
    """从搜索页 HTML 内嵌 JS 中提取预构建的完整 folsrch URL。

    Google 搜索页在 JS 中嵌入 {astyp:'folsrch', url:'/async/folsrch?...'} 对象，
    其中 url 包含服务端生成的 mlro/mlros/mrc 等参数（\\x26 编码）。
    提取后解码即可直接请求，无需手动拼接。
    """
    m = re.search(r"url:'(/async/folsrch\?[^']+)'", html)
    if not m:
        m = re.search(r'url:"(/async/folsrch\?[^"]+)"', html)
    if not m:
        return None
    raw = m.group(1)
    raw = (raw.replace('\\x26', '&').replace('\\x3d', '=')
              .replace('\\x3a', ':').replace('\\x2f', '/')
              .replace('\\x25', '%').replace('\\x3f', '?'))
    return 'https://www.google.com' + raw


def _extract_dv_from_html(html):
    """从搜索页 HTML 中提取服务端嵌入的 DV cookie 值。

    Google 在搜索页 HTML 的 JSON 数据中嵌入 "foot":{"dv":"<token>"}，
    浏览器 JS（Goa.set）读取后写入 document.cookie。
    纯 Python 直接正则提取即可，无需执行 JS。
    """
    m = re.search(r'x22dv.x22:.x22([A-Za-z0-9_/+=%-]{30,100}).x22', html)
    if m:
        return m.group(1)
    m = re.search(r'"dv"\s*:\s*"([A-Za-z0-9_/+=%-]{30,100})"', html)
    if m:
        return m.group(1)
    return None


# ===== 核心流程 =====
_captcha_retry_count = 0

def google_lens_search(image_path=None, image_url=None, hl="zh-CN",
                       abuse_cookie=None, force_refresh_cookie=False):
    global _captcha_retry_count
    if not image_path and not image_url:
        raise ValueError("需要 image_path 或 image_url")

    # Step 0: Cookie
    # 优先从缓存加载（含 DV 的预热 cookie 可直接避免 sorry）
    # 无缓存时自动触发 nodriver 预热（仅首次）
    cookie = abuse_cookie or ""
    if cookie.startswith("__"):
        cookie = _load_cached_cookie()
    if not cookie and not force_refresh_cookie:
        cookie = _load_cached_cookie()
    if not cookie and os.path.exists(CHROME_EXEC):
        print("[0] 无缓存 cookie，尝试预热 ...")
        if warmup_cookie_pool():
            cookie = _load_cached_cookie()
    if force_refresh_cookie:
        cookie = get_abuse_exemption_cookie(force_refresh=True)

    session = requests.Session(verify=False)
    headers = _base_headers()

    pre_extra_cookies = {}
    if cookie:
        if cookie.startswith("{"):
            try:
                all_c = json.loads(cookie)
                for k, v in all_c.items():
                    session.cookies.set(k, v, domain=".google.com", path="/")
                    if k not in ("NID", "AEC", "__Secure-STRP"):
                        pre_extra_cookies[k] = v
            except Exception:
                session.cookies.set("GOOGLE_ABUSE_EXEMPTION", cookie, domain=".google.com", path="/")
        else:
            session.cookies.set("GOOGLE_ABUSE_EXEMPTION", cookie, domain=".google.com", path="/")

    # warmup — 这一步自动获取 NID + AEC + __Secure-STRP（服务端 Set-Cookie 下发）
    print("[0] 预热 ...")
    session.get("https://www.google.com/", headers=headers, impersonate=IMPERSONATE, timeout=15)

    # warmup 会刷新 NID/AEC/STRP，但不能冲掉之前加载的 SG_SS/ABUSE 等额外 cookie
    for k, v in pre_extra_cookies.items():
        session.cookies.set(k, v, domain=".google.com", path="/")
    _save_session_cookies(session)
    time.sleep(1)

    # Step 1: Upload
    if image_url:
        print(f"[1] searchbyimage → {image_url[:80]}...")
        r = session.get(
            f"https://www.google.com/searchbyimage?sbisrc=cr_1_5_2&image_url={quote(image_url)}&hl={hl}",
            headers=dict(headers, **{"referer": "https://www.google.com/", "sec-fetch-site": "same-origin"}),
            impersonate=IMPERSONATE, allow_redirects=True, timeout=30,
        )
        search_url = _enrich_search_url(str(r.url))
        html = r.text
    else:
        print(f"[1] 上传 {os.path.basename(image_path)} ...")
        w, h = _get_image_size(image_path)
        mp = CurlMime()
        mp.addpart(name="encoded_image", filename=os.path.basename(image_path),
                   content_type="image/png", local_path=image_path)
        mp.addpart(name="original_width", data=str(w))
        mp.addpart(name="original_height", data=str(h))
        mp.addpart(name="processed_image_dimensions", data=f"{w},{h}")

        try:
            r_up = session.post(
                LENS_UPLOAD,
                params={"ep": "gsbubb", "st": str(int(time.time() * 1000)),
                        "authuser": "0", "hl": hl, "vpw": "1366", "vph": "768"},
                multipart=mp,
                headers=dict(headers, **{"Origin": "https://www.google.com", "Referer": "https://www.google.com/"}),
                impersonate=IMPERSONATE, allow_redirects=False, timeout=30,
            )
        finally:
            mp.close()

        if r_up.status_code not in (302, 303):
            raise RuntimeError(f"upload failed: {r_up.status_code}")

        search_url = r_up.headers.get("Location") or r_up.headers.get("location")
        search_url = _enrich_search_url(search_url)
        print(f"    → {search_url[:100]}...")
        delay = 1 + random.uniform(2, 5)
        print(f"    等待 {delay:.1f}s ...")
        time.sleep(delay)

        r_s = session.get(
            search_url,
            headers=dict(headers, **{
                "referer": "https://www.google.com/",
                "sec-fetch-site": "same-origin",
            }),
            impersonate=IMPERSONATE, timeout=30,
        )
        html = r_s.text

    # Step 2: 检查结果
    qs = parse_qs(urlparse(search_url).query)
    vsrid = (qs.get("vsrid") or [""])[0]
    vsint = (qs.get("vsint") or [""])[0]

    ei_m = EI_PATTERN.search(html)
    lbc = len(re.findall(r"LBcIee", html))
    ndng = len(re.findall(r"NDNGvf", html))
    print(f"[2] search: len={len(html)} ei={bool(ei_m)} LBcIee={lbc} NDNGvf={ndng}")

    # Step 2.5: 从 HTML 提取服务端嵌入的 DV cookie（纯 Python 自动续期）
    dv_val = _extract_dv_from_html(html)
    if dv_val:
        session.cookies.set("DV", dv_val, domain=".google.com", path="/")
        _save_session_cookies(session)
        print(f"[2.5] DV cookie 已续期 (len={len(dv_val)})")

    debug_path = os.path.join(os.path.dirname(__file__), "lens_debug.html")
    with open(debug_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"[debug] HTML 已保存: {debug_path}")

    is_sorry = ("sorry" in html[:3000].lower() or "captcha-form" in html[:3000] or
                ("recaptcha" in html[:3000].lower() and len(html) < 10000))
    if is_sorry:
        print("[!] 被拦截 (sorry/captcha 页面)")
        _captcha_retry_count += 1
        if _captcha_retry_count <= 3:
            print(f"[!] 尝试纯 HTTP 解题 (第 {_captcha_retry_count}/3 次) ...")
            solved, browser_html = _solve_captcha_and_retry(session, search_url, html, original_search_url=search_url)
            if solved:
                _save_session_cookies(session)
                if browser_html and len(browser_html) > 50000:
                    bh_ei = EI_PATTERN.search(browser_html)
                    bh_lbc = len(re.findall(r"LBcIee", browser_html))
                    if bh_ei or bh_lbc > 0:
                        html = browser_html
                        ei_m = bh_ei
                        lbc = bh_lbc
                        ndng = len(re.findall(r"NDNGvf", html))
                # captcha 通过后重新上传搜图
                if not ei_m or lbc == 0:
                    print("[!] captcha 已解决，重新上传搜图 ...")
                    time.sleep(2)
                    return google_lens_search(image_path=image_path, image_url=image_url,
                                              hl=hl, abuse_cookie="__captcha_solved__")
        if not ei_m and lbc == 0:
            print("[!] 返回空结果")
            return {"method": "failed", "visual_matches": [], "ai_overview": {}, "visual_matches_count": 0,
                    "error": "blocked_need_cookie"}

    if not ei_m and lbc == 0:
        print(f"[!] JS 壳页面 (len={len(html)})，用 Node.js 生成 SG_SS cookie ...")
        eid_m = re.search(r"var\s+eid\s*=\s*'([^']+)'", html)
        eid_val = eid_m.group(1) if eid_m else ""
        if eid_val:
            print(f"[!] 提取到 eid={eid_val}")

        sgss = _generate_sgss_cookie(html)
        if sgss:
            session.cookies.set("SG_SS", sgss, domain=".google.com", path="/")
            _save_session_cookies(session)

            # 策略: SG_SS 设好后重新上传图片（全新搜索链路），而不是重试旧 URL
            if abuse_cookie != "__sgss_retry__":
                print(f"[!] SG_SS 已设置，重新上传搜图（避免重试旧 URL 触发 sorry）...")
                time.sleep(3)
                return google_lens_search(image_path=image_path, image_url=image_url,
                                          hl=hl, abuse_cookie="__sgss_retry__")

            # 已是 SG_SS 重试仍是 JS 壳，走旧 URL 备选路径
            from urllib.parse import urlencode, urlunparse
            parsed = urlparse(search_url)
            params = parse_qs(parsed.query, keep_blank_values=True)
            params.pop("sg_ss", None)
            params.pop("sei", None)
            if eid_val:
                params["sei"] = [eid_val]
            new_query = urlencode(params, doseq=True)
            retry_url = urlunparse(parsed._replace(query=new_query))

            print(f"[!] SG_SS 重试仍是 JS 壳，尝试旧 URL ...")
            r_s2 = session.get(
                retry_url,
                headers=dict(headers, **{
                    "referer": search_url,
                    "sec-fetch-site": "same-origin",
                }),
                impersonate=IMPERSONATE, timeout=30,
            )
            html = r_s2.text
            ei_m = EI_PATTERN.search(html)
            lbc = len(re.findall(r"LBcIee", html))
            ndng = len(re.findall(r"NDNGvf", html))
            print(f"[!] 备选请求: status={r_s2.status_code} len={len(html)} ei={bool(ei_m)} LBcIee={lbc} NDNGvf={ndng}")

            if r_s2.status_code == 429 or "sorry" in html[:500].lower():
                print("[!] 备选路径也被 429，尝试纯 HTTP 解题 ...")
                _captcha_retry_count += 1
                if _captcha_retry_count <= 3:
                    solved, browser_html = _solve_captcha_and_retry(session, str(r_s2.url), html, original_search_url=search_url)
                    if solved:
                        _save_session_cookies(session)
                        if browser_html and len(browser_html) > 50000:
                            bh_ei = EI_PATTERN.search(browser_html)
                            bh_lbc = len(re.findall(r"LBcIee", browser_html))
                            if bh_ei or bh_lbc > 0:
                                html = browser_html
                                ei_m = bh_ei
                                lbc = bh_lbc
                                ndng = len(re.findall(r"NDNGvf", html))

    # Step 3: 提取参数
    ei = ei_m.group(1) if ei_m else ""
    sca_esv = (re.search(r"sca_esv=([A-Za-z0-9]+)", html) or [None, ""])[1]

    # Step 4: folsrch — AI 概览
    # 优先从 HTML JS 中提取预构建的完整 folsrch URL（含服务端生成的 mlro/mlros/mrc）
    fol_html = ""
    prebuilt_fol_url = _extract_folsrch_url(html)
    if prebuilt_fol_url:
        print(f"[3] folsrch (预构建URL) ...")
        print(f"    url={prebuilt_fol_url[:120]}...")
        try:
            r_f = session.get(
                prebuilt_fol_url,
                headers={
                    "accept": "*/*",
                    "accept-language": "zh-CN",
                    "referer": search_url,
                    "user-agent": headers.get("user-agent", ""),
                    "sec-fetch-site": "same-origin",
                    "sec-fetch-mode": "cors",
                    "sec-fetch-dest": "empty",
                },
                impersonate=IMPERSONATE, timeout=15,
            )
            if r_f.status_code == 200 and len(r_f.text) > 1000:
                fol_html = r_f.text
                print(f"    OK len={len(fol_html)}")
            else:
                print(f"    {r_f.status_code} (len={len(r_f.text)}) — 预构建URL失败")
        except Exception as e:
            print(f"    预构建URL请求失败: {e}")
    elif ei:
        print(f"[3] folsrch (手动构建，降级) ...")
        ved = (re.search(r'data-ved="([^"]+)"', html) or [None, ""])[1]
        mlro = (re.search(r'data-mlro="([^"]+)"', html) or [None, ""])[1]
        mlros = (re.search(r'data-mlros="([^"]+)"', html) or [None, ""])[1]
        mrc = (re.search(r'data-mrc="([^"]+)"', html) or [None, ""])[1]

        from urllib.parse import urlencode
        fol_url = (FOLSRCH + "?"
                   + urlencode({"vsrid": vsrid, "vsint": vsint})
                   + "&udm=26&lns_mode=un&source=lns.web.gsbubb&lns_surface=26"
                   + f"&authuser=0&hl={hl}&yv=3&cs=0&ei={ei}"
                   + "&async=_fmt:madl,_id:B2Jtyd,_mado:ed"
                   + f"&sca_esv={sca_esv or ''}&mgtyp=62&snr=1")
        if mlro:
            fol_url += f"&mlro={mlro}"
        if mlros:
            fol_url += f"&mlros={mlros}"
        if mrc:
            fol_url += f"&mrc={mrc}"
        if ved:
            fol_url += f"&ved={ved}&vet=1{ved}..i"

        try:
            r_f = session.get(
                fol_url,
                headers={
                    "accept": "*/*",
                    "accept-language": "zh-CN",
                    "referer": search_url,
                    "user-agent": headers.get("user-agent", ""),
                    "sec-fetch-site": "same-origin",
                    "sec-fetch-mode": "cors",
                    "sec-fetch-dest": "empty",
                },
                impersonate=IMPERSONATE, timeout=15,
            )
            if r_f.status_code == 200 and len(r_f.text) > 1000:
                fol_html = r_f.text
                print(f"    OK len={len(fol_html)}")
            else:
                print(f"    {r_f.status_code} (len={len(r_f.text)}) — 手动构建也失败")
        except Exception as e:
            print(f"    请求失败: {e}")
    else:
        print(f"[3] folsrch — 无 ei，跳过")

    # Step 5: 解析
    print(f"[4] 解析 ...")
    matches = _parse_visual_matches(html)
    if not matches and fol_html:
        matches = _parse_visual_matches(fol_html)
    # AI 概览：先从 search HTML 取（690KB 页面通常已包含），folsrch 做兜底
    ai = _parse_ai_overview(html)
    if not ai.get("available") and fol_html:
        ai = _parse_ai_overview(fol_html)

    result = {
        "method": "pure_http",
        "result_url": search_url,
        "vsrid": vsrid, "ei": ei, "sca_esv": sca_esv,
        "ai_overview": ai,
        "visual_matches": matches,
        "visual_matches_count": len(matches),
    }

    # 打印摘要
    print(f"\n{'='*60}")
    print(f"匹配商品数: {len(matches)}")
    if ai.get("available"):
        print(f"AI 概览: {ai['summary'][:200]}")
        for b in ai.get("bullets", [])[:5]:
            print(f"  - {b[:150]}")
    for i, m in enumerate(matches[:15], 1):
        extra = f" | {m['price']}" if m.get("price") else ""
        print(f"  {i}. {m['title'][:70]}{extra}")
        print(f"     {m['url'][:100]}")

    return result


# ===== 解析 =====
def _parse_visual_matches(html):
    if not html:
        return []
    import html as html_mod
    dimg_map = {}
    for m in re.finditer(r"sn\._setImagesSrc\('([^']+)','([^']+)'\)", html):
        dimg_map[m.group(1)] = m.group(2).replace("\\/", "/").replace("\\x3d", "=").replace("\\x26", "&")

    items, seen = [], set()
    try:
        from lxml import etree
    except ImportError:
        etree = None

    if etree:
        tree = etree.HTML(html)
        if tree is not None:
            img_by_ref = {}
            for ia in tree.xpath('//a[contains(@class, "y9pG6d")]'):
                href = html_mod.unescape(ia.get("href", ""))
                qs = parse_qs(urlparse(href).query)
                ref = (qs.get("imgrefurl") or [""])[0]
                if not ref: continue
                did = ""
                for img in ia.xpath('.//img[starts-with(@id, "dimg_")]'):
                    did = img.get("id", ""); break
                img_by_ref[ref] = {
                    "image_url": (qs.get("imgurl") or [""])[0],
                    "thumbnail": dimg_map.get(did, ""),
                }

            for a in tree.xpath('//a[contains(@class, "LBcIee")]'):
                url = a.get("href", "")
                if not url or not url.startswith("http") or url in seen: continue
                seen.add(url)
                title = " ".join(t.strip() for t in a.xpath('.//span[contains(@class, "Yt787")]//text()') if t.strip())
                domain = " ".join(t.strip() for t in a.xpath('.//div[contains(@class, "R8BTeb")]//text()') if t.strip())
                rating = rating_count = ""
                for rn in a.xpath('.//span[contains(@class, "Y0A0hc")]'):
                    lab = rn.get("aria-label", "")
                    rm = re.search(r"[\d.]+", lab)
                    if rm: rating = rm.group(0)
                    cm = re.search(r"\(([\d,]+)\)", lab)
                    if cm: rating_count = cm.group(1).replace(",", "")
                price = " ".join(t.strip() for t in a.xpath('.//span[contains(@class, "ALLCjc")]//text()') if t.strip())
                img_meta = img_by_ref.get(url, {})
                items.append({
                    "title": title[:300], "url": url, "domain": domain,
                    "image_url": img_meta.get("image_url", ""),
                    "thumbnail": img_meta.get("thumbnail", ""),
                    "rating": rating, "rating_count": rating_count,
                    "price": price,
                })

    if not items:
        for m in re.finditer(r'<a[^>]+class="[^"]*LBcIee[^"]*"[^>]+href="(https?://[^"]+)"', html):
            url = m.group(1)
            if url not in seen:
                seen.add(url)
                items.append({"title": "", "url": url, "domain": "", "image_url": "",
                              "thumbnail": "", "rating": "", "rating_count": "", "price": ""})
    return items


def _extract_madl_html(raw):
    """从 Google madl streaming response 提取纯 HTML。

    madl 格式: 连续的帧，每帧 = `<hex_len>;<payload>\n`
    payload 类型:
      - `<hex_len>;<type>;<id>_<offset>;<hex_len>; <html>` (write_to_target_document)
      - `<hex_len>;message;<data>` / `md;<data>` / `placeholder;<id>` (控制帧)
    """
    if not raw:
        return raw
    if raw.lstrip()[:1] == "<":
        return raw
    parts = []
    pos = 0
    while pos < len(raw):
        semi = raw.find(";", pos)
        if semi == -1:
            break
        try:
            frame_len = int(raw[pos:semi], 16)
        except ValueError:
            pos = semi + 1
            continue
        frame_start = semi + 1
        frame_end = frame_start + frame_len
        frame = raw[frame_start:frame_end]
        pos = frame_end
        # 跳过控制帧，只保留 HTML 内容帧
        if frame.startswith(("write_to_target_document", "message", "placeholder", "md")):
            # 内容在最后一个 `; ` 之后
            last_semi = frame.rfind("; ")
            if last_semi != -1:
                html_part = frame[last_semi + 2:]
                if "<" in html_part:
                    parts.append(html_part)
        elif "<" in frame:
            parts.append(frame)
    return "".join(parts) if parts else raw


def _parse_ai_overview(html):
    if not html:
        return {"summary": "", "bullets": [], "available": False}
    html = _extract_madl_html(html)
    try:
        from lxml import etree
    except ImportError:
        return {"summary": "", "bullets": [], "available": False}
    tree = etree.HTML(html)
    if tree is None:
        return {"summary": "", "bullets": [], "available": False}

    def _clean(node):
        parts = []
        for elem in node.iter():
            if callable(elem.tag) or elem.tag in ("style", "script", "noscript", "svg"): continue
            for t in (elem.text, elem.tail):
                if t and t.strip() and "{" not in t: parts.append(t.strip())
        return " ".join(parts)

    container = None
    for xp in ['//span[contains(@data-subtree, "aimfl")]',
               '//div[contains(@data-subtree, "aimc")]',
               '//div[contains(@class, "awi2gc")]']:
        nodes = tree.xpath(xp)
        if nodes:
            container = nodes[0]
            for _ in range(12):
                pp = container.getparent()
                if pp is None: break
                container = pp
                cls = container.get("class", "") or ""
                sub = container.get("data-subtree", "") or ""
                if "awi2gc" in cls or "aimc" in sub:
                    gp = container.getparent()
                    if gp is not None: container = gp
                    break
            break
    if container is None:
        return {"summary": "", "bullets": [], "available": False}

    # 过滤 streaming 中间状态文本
    NOISE = re.compile(
        r"无法针对此搜索生成\s*AI\s*概览|目前无法生成\s*AI\s*概要.*?重试。?|"
        r"正在思考|正在搜索|再多想一会儿|AI\s*概览|AI\s*Overview|"
        r"AI\s*的回答未必正确无误.*?核查|全部显示"
    )

    def _strip_noise(text):
        text = NOISE.sub("", text)
        return re.sub(r"\s+", " ", text).strip()

    bullets, bseen = [], set()
    for xp in ['.//li[contains(@class, "Z1qcYe")]', './/ul//li']:
        for li in container.xpath(xp):
            t = _strip_noise(_clean(li))
            if t and len(t) > 5 and t not in bseen:
                bseen.add(t)
                bullets.append(t[:500])
        if bullets:
            break
    summary = _strip_noise(_clean(container))[:2000]
    return {"summary": summary, "bullets": bullets[:10], "available": bool(summary or bullets)}


if __name__ == "__main__":
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except Exception:
        pass

    import argparse
    parser = argparse.ArgumentParser(description="纯 Python Google Lens 搜图")
    parser.add_argument("image", nargs="?", default=None)
    parser.add_argument("--url", default=None)
    parser.add_argument("--cookie", default=None, help="GOOGLE_ABUSE_EXEMPTION cookie")
    parser.add_argument("--refresh-cookie", action="store_true")
    parser.add_argument("--warmup", action="store_true", help="预热 cookie 池（仅启动一次浏览器）")
    parser.add_argument("--hl", default="zh-CN")
    args = parser.parse_args()

    if args.warmup:
        warmup_cookie_pool(force=True)
        if not args.image and not args.url:
            sys.exit(0)

    args.image = r'C:\Users\ASUS\Downloads\snipaste20260520_181727.png'
    # if not args.image and not args.url:
    #     parser.error("请提供图片路径或 --url 参数")

    result = google_lens_search(
        image_path=args.image, image_url=args.url,
        hl=args.hl, abuse_cookie=args.cookie,
        force_refresh_cookie=args.refresh_cookie,
    )

    out_path = os.path.join(os.path.dirname(__file__), "lens_pure_python_result.json")
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(f"\n结果保存: {out_path}")
