# -*- 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 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")

CHROME_EXEC = os.environ.get(
    "LENS_CHROME_PATH",
    r"D:\新建文件夹\指纹浏览器\ungoogled-chromium_136.0.7103.113-1.1_windows_x64\chrome.exe",
)
BROWSER_PROFILE = os.environ.get("LENS_BROWSER_PROFILE", r"D:\lens_rev_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

    for attempt in range(1, max_attempts + 1):
        print(f"[CAPTCHA] 第 {attempt}/{max_attempts} 次解题 ...")
        try:
            # 1. 找 reCAPTCHA anchor iframe → 点勾选框
            anchor_frame = None
            for f in page.frames:
                if "recaptcha" in (f.url or "") and "anchor" in (f.url or ""):
                    anchor_frame = f
                    break
            if not anchor_frame:
                print("[CAPTCHA] 未找到 reCAPTCHA iframe")
                return False

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

            # 2. 找挑战 iframe (bframe)
            challenge_frame = None
            for _ in range(10):
                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)
            time.sleep(4)

            # 4. 点播放按钮
            try:
                challenge_frame.click("button[aria-labelledby]", timeout=3000)
                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。

    流程:
      1. 先查缓存/环境变量
      2. 没有则启动浏览器 → Lens 上传 → 若触发 sorry 自动语音识别解 reCAPTCHA
      3. 提取 cookie 并缓存
    """
    if not force_refresh:
        cached = _load_cached_cookie()
        if cached:
            print(f"[cookie] 使用缓存 cookie (len={len(cached)})")
            return cached

    print("[cookie] 启动浏览器获取 cookie ...")

    if not os.path.exists(CHROME_EXEC):
        raise FileNotFoundError(
            f"chrome.exe 未找到: {CHROME_EXEC}\n"
            "请设置环境变量 LENS_CHROME_PATH 或手动提供 --cookie 参数"
        )

    from patchright.sync_api import sync_playwright
    os.makedirs(BROWSER_PROFILE, exist_ok=True)

    cookie_val = ""
    with sync_playwright() as p:
        ctx = p.chromium.launch_persistent_context(
            user_data_dir=BROWSER_PROFILE,
            executable_path=CHROME_EXEC,
            headless=False,
            viewport={"width": 1366, "height": 768},
            locale="zh-CN",
            timezone_id="Asia/Shanghai",
            args=["--no-default-browser-check", "--no-first-run"],
        )
        page = ctx.pages[0] if ctx.pages else ctx.new_page()

        # Step 1: google.com warmup
        page.goto("https://www.google.com/", wait_until="domcontentloaded", timeout=30000)
        time.sleep(2)

        # Step 2: lens 上传
        page.goto("https://lens.google.com/", wait_until="domcontentloaded", timeout=30000)
        time.sleep(2)

        test_img = os.environ.get(
            "LENS_TEST_IMAGE", r"C:\Users\ASUS\Downloads\snipaste20260522_103025.png"
        )
        if os.path.exists(test_img):
            file_input = page.locator('input[name="encoded_image"]')
            file_input.set_input_files(test_img)

            # 等跳转
            try:
                page.wait_for_url(
                    lambda url: "google.com/search" in url or "sorry" in url,
                    timeout=30000,
                )
            except Exception:
                pass
            time.sleep(3)

            # Step 3: 检查是否 sorry → 自动解 reCAPTCHA
            if "sorry" in page.url:
                print("[cookie] 检测到 sorry/reCAPTCHA 页面 → 自动解题 ...")
                solved = _solve_recaptcha_audio(page, max_attempts=3)
                if solved:
                    try:
                        page.wait_for_url(
                            lambda url: "google.com/search" in url and "sorry" not in url,
                            timeout=20000,
                        )
                    except Exception:
                        pass
                    time.sleep(3)
                else:
                    print("[cookie] 自动解题失败，请在浏览器里手动解 ...")
                    time.sleep(45)
            else:
                # 等搜索结果页完全加载
                try:
                    page.wait_for_function(
                        "() => /kEI\\s*[:=]\\s*['\"][A-Za-z0-9_-]{8,}['\"]/.test("
                        "document.documentElement.outerHTML)",
                        timeout=30000,
                    )
                except Exception:
                    pass
                time.sleep(3)

        # Step 4: 提取所有 Google cookies
        all_cookies = {}
        for c in ctx.cookies():
            if "google" in c.get("domain", ""):
                all_cookies[c["name"]] = c["value"]

        cookie_val = all_cookies.get("GOOGLE_ABUSE_EXEMPTION", "")

        ctx.close()

    if cookie_val:
        _save_cookie(cookie_val)
        print(f"[cookie] ABUSE_EXEMPTION 已缓存 (len={len(cookie_val)})")
    elif all_cookies:
        # 没有 ABUSE_EXEMPTION 但有其他 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)


# ===== 工具函数 =====
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": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "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 google_lens_search(image_path=None, image_url=None, hl="zh-CN",
                       abuse_cookie=None, force_refresh_cookie=False):
    if not image_path and not image_url:
        raise ValueError("需要 image_path 或 image_url")

    # Step 0: Cookie
    # 正常流程：warmup GET google.com 自动拿 NID+AEC+__Secure-STRP，不需要预设 cookie
    # 只有被拦截过/手动指定时才加载缓存
    cookie = abuse_cookie or ""
    if not cookie and not force_refresh_cookie:
        cookie = _load_cached_cookie()
    if force_refresh_cookie:
        cookie = get_abuse_exemption_cookie(force_refresh=True)

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

    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="/")
            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)
    _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 = 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")
        print(f"    → {search_url[:100]}...")
        time.sleep(1)

        r_s = session.get(
            search_url,
            headers=dict(headers, **{
                "referer": "https://lens.google.com/",
                "sec-fetch-site": "cross-site",
            }),
            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}")

    if "sorry" in html[:500].lower() or (not ei_m and lbc == 0):
        print("[!] 被拦截 — 可能 IP 被限或请求频率过高")
        # 优先纯 HTTP 重试（换 session 清掉旧 cookie，让 warmup 拿新的）
        if not abuse_cookie:
            print("[!] 纯 HTTP 重试（新 session）...")
            return google_lens_search(image_path=image_path, image_url=image_url,
                                      hl=hl, abuse_cookie="__retry__")
        # 纯 HTTP 也不行，降级到浏览器解 reCAPTCHA
        if os.path.exists(CHROME_EXEC):
            print("[!] 降级到浏览器解 reCAPTCHA ...")
            new_cookie = get_abuse_exemption_cookie(force_refresh=True)
            if new_cookie:
                return google_lens_search(image_path=image_path, image_url=image_url,
                                          hl=hl, abuse_cookie=new_cookie)
        print("[!] 返回空结果（无浏览器可用，请手动设置 cookie 或降低请求频率）")
        return {"method": "failed", "visual_matches": [], "ai_overview": {}, "visual_matches_count": 0,
                "error": "blocked_need_cookie"}

    # 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]
    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]
    fsid = (re.search(r'data-fsid="([^"]+)"', html) or [None, ""])[1]

    # Step 4: folsrch — AI 概览
    fol_html = ""
    if ei:
        print(f"[3] folsrch (AI 概览) ...")
        ved = (re.search(r'data-ved="([^"]+)"', 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"
                   + 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": "https://www.google.com/",
                    "user-agent": headers.get("user-agent", ""),
                },
                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}")

    # 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("--hl", default="zh-CN")
    args = parser.parse_args()

    if not args.image and not args.url:
        args.image = r"C:\Users\ASUS\Downloads\snipaste20260522_104715.png"

    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}")
