Skill: qinkaizhou/url2pdf-mk (version 1.1.1)

--- name: url2pdf-mk description: | EN: Webpage to PDF + Markdown converter. Converts any URL (especially WeChat articles) into offline-readable PDF and Markdown files with complete images, preserving original layout and styles. Auto-creates date-named folders on desktop, filenames use publish date + title. ⚠️ Security Note: Browser mode (default) reuses your Chrome profile — it can access your cookies and logged-in sessions. Use --isolated for public content, or run in a VM/sandbox. CDP proxy opens a local port and writes temp state files (cdp-proxy-<user> in your temp directory). 微信文章 / 网页抓取工具，将任意 URL 输出为含完整图片的 PDF 和 Markdown 文件。 自动在桌面按当前日期创建目录，文件名使用文章发布日期 + 标题。 当用户提到"网页转 PDF"、"保存微信文章"、"抓取网页内容"、"导出 PDF 和 Markdown"、 "url2pdf"、"网页保存"、"url2pdf-mk"、"保存链接"、"离线阅读"、"网页存档"、"批量抓取"等时触发此技能。 --- # url2pdf-mk — Webpage to PDF & Markdown Converter > One-click webpage to PDF + Markdown converter. Transforms WeChat articles and web pages into > offline-readable documents with full images, original layout and styles preserved. > 一键将网页（尤其是微信文章）转换为可离线阅读的 PDF 和 Markdown 文档，完整保留排版、图片、样式。 --- ## 📋 概述 | Overview **url2pdf-mk** is a professional webpage content scraping and conversion tool, designed specifically for WeChat public account articles and regular web pages. It can: - ✅ Convert any webpage into **PDF** and **Markdown** dual-format output / 将任意网页转换为 PDF 和 Markdown 双格式输出 - ✅ Preserve complete images, layout and styles / 完整保留网页中的图片、排版、样式 - ✅ Support both single-page and batch scraping (via xlsx file) / 支持单篇抓取和批量抓取 - ✅ Auto-create date-named folders on desktop / 自动按日期在桌面创建归档目录 - ✅ Smart detection of WeChat article titles and publish dates / 智能识别微信文章标题和发布日期 --- ## ⚙️ 工作机制 | How It Works ### 核心技术架构 | Core Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ url2pdf-mk Workflow │ │ 工作流程 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Input Sources / 输入源 │ │ ├── Single URL → scrape.py (Single Page) │ │ │ └── 单个 URL → 单篇抓取 │ │ ├── Multiple URLs → batch_scrape.py (Browser Batch) │ │ │ └── 多个 URL → 批量浏览器版 │ │ └── xlsx file → Smart Routing (Browser/HTTP) │ │ └── xlsx 文件 → 智能路由 │ │ │ │ Scraping Engines / 抓取引擎 │ │ ├── Browser (CDP) ──→ Chrome DevTools Protocol │ │ │ ├── Launch Chrome/Chromium browser │ │ │ │ └── 启动浏览器 │ │ │ ├── Control browser via CDP protocol │ │ │ │ └── CDP 协议控制 │ │ │ ├── Render full page (including JavaScript) │ │ │ │ └── 渲染完整页面 │ │ │ └── Extract DOM + screenshot to PDF │ │ │ └── 提取 DOM + 截图生成 PDF │ │ │ │ │ └── HTTP (No Browser) ──→ requests library │ │ ├── Send HTTP requests to get HTML │ │ │ └── HTTP 请求获取 HTML │ │ ├── Parse static HTML structure │ │ │ └── 解析静态 HTML │ │ └── Markdown output only (no PDF) │ │ └── 仅输出 Markdown │ │ │ │ Output Processing / 输出处理 │ │ ├── HTML → Markdown conversion (preserve image links) │ │ │ └── HTML → Markdown 转换 │ │ ├── HTML → PDF generation (reportlab + full styles) │ │ │ └── HTML → PDF 生成 │ │ └── Filename: {date}_{title}.{md/pdf} │ │ └── 文件命名：{发布日期}_{标题} │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 工作流程详解 | Workflow Details #### 1. Single Page Mode / 单篇抓取模式 (`scrape.py`) ``` URL → Launch browser → Load page → Extract content → Generate PDF + Markdown → Save to date folder URL → 启动浏览器 → 加载页面 → 提取内容 → 生成 PDF + Markdown → 保存到日期目录 ``` #### 2. Browser Batch Mode / 批量浏览器版 (`batch_scrape.py`) ``` xlsx file → Read URL list → Reuse browser instance → Scrape each → Batch output xlsx 文件 → 读取 URL 列表 → 复用浏览器实例 → 逐个抓取 → 批量输出 ``` - **Advantage / 优势**: Browser instance starts only once, high efficiency / 浏览器实例只启动一次，效率高 - **Prerequisite / 前提**: Chrome/Chromium installed / 系统已安装 Chrome/Chromium #### 3. HTTP Batch Mode / 批量 HTTP 版 (`batch_http.py`) ``` xlsx file → Read URL list → Send HTTP requests → Parse HTML → Batch output Markdown xlsx 文件 → 读取 URL 列表 → 发送 HTTP 请求 → 解析 HTML → 批量输出 Markdown ``` - **Advantage / 优势**: No browser needed, low resource usage / 无需浏览器，资源占用低 - **Limitation / 限制**: Cannot handle JavaScript-rendered content, no PDF output / 无法处理 JS 内容，无 PDF 输出 ### 智能路由逻辑 | Smart Routing Logic `main.py` serves as the unified entry point, automatically selecting the optimal approach based on input type: | Input Type / 输入类型 | Chrome Available / Chrome 可用 | Chrome Unavailable / Chrome 不可用 | |---------|------------|--------------| | Single URL / 1 个 URL | Browser mode (PDF+MD) / 浏览器版 | HTTP mode (MD only) / HTTP 版 | | Multiple URLs / ≥2 个 URL | Browser batch (PDF+MD) / 浏览器版批量 | HTTP batch (MD only) / HTTP 版批量 | | xlsx file / xlsx 文件 | Browser batch (PDF+MD) / 浏览器版批量 | HTTP batch (MD only) / HTTP 版批量 | ### CDP (Chrome DevTools Protocol) / CDP 协议说明 CDP is Chrome's debugging protocol. This tool uses it to: 1. **Launch/Connect browser / 启动/连接浏览器**: Connect to existing browser or launch new instance / 支持连接已有浏览器或启动新实例 2. **Page control / 页面控制**: Navigate, wait for load, execute JavaScript / 导航、等待加载、执行 JavaScript 3. **Content extraction / 内容提取**: Get complete DOM structure and computed styles / 获取完整的 DOM 结构和计算样式 4. **PDF generation / PDF 生成**: Use browser's native PDF print function / 调用浏览器原生 PDF 打印功能 **Security measures / 安全措施**: - CDP ports bound to `127.0.0.1` only, external network cannot access / CDP 端口仅绑定本机，外网无法访问 - WebSocket connections require Token verification / WebSocket 连接需要 Token 验证 - `--isolated` mode uses independent temporary Profile, no access to user data / `--isolated` 模式使用临时 Profile，不访问用户数据 --- ## 🖥️ 适配环境 | Supported Environments ### 操作系统支持 | Operating System Support | Operating System / 操作系统 | Status / 支持状态 | Note / 说明 | |---------|---------|------| | **Windows 10/11** | ✅ Full Support / 完全支持 | Chrome or Edge recommended / 推荐 Chrome 或 Edge | | **macOS 10.15+** | ✅ Full Support / 完全支持 | Chrome, Chromium, Edge supported / 支持 Chrome、Chromium、Edge | | **Linux** | ✅ Full Support / 完全支持 | Chrome or Chromium required / 需安装 Chrome 或 Chromium | | **Windows 7/8** | ⚠️ Limited / 有限支持 | Upgrade recommended for best experience / 建议升级以获得最佳体验 | ### 浏览器支持 | Browser Support | Browser / 浏览器 | Status / 支持状态 | Version / 版本要求 | |--------|---------|---------| | **Google Chrome** | ✅ Recommended / 推荐 | 80+ | | **Microsoft Edge (Chromium)** | ✅ Supported / 支持 | 80+ | | **Chromium** | ✅ Supported / 支持 | 80+ | | **Others / 其他浏览器** | ❌ Not supported / 不支持 | Chromium-based only / 仅支持 Chromium 内核 | ### Python 环境要求 | Python Environment Requirements | Component / 组件 | Version / 版本要求 | Purpose / 用途 | |------|---------|------| | **Python** | 3.7+ | Runtime environment / 运行时环境 | | **pip** | Latest / 最新版 | Package manager / 包管理器 | --- ## 📦 基础组件依赖 | Dependencies ### Required Dependencies / 必需依赖 (must install before running) ```bash pip install websockets openpyxl requests reportlab ``` | Package / 依赖包 | Version / 版本要求 | Purpose / 用途 | |--------|---------|------| | `websockets` | 10.0+ | CDP WebSocket communication / CDP WebSocket 通信 | | `openpyxl` | 3.0+ | Read xlsx files (batch mode) / 读取 xlsx 文件（批量模式） | | `requests` | 2.25+ | HTTP requests (HTTP mode) / HTTP 请求（HTTP 版抓取） | | `reportlab` | 3.6+ | PDF document generation / PDF 文档生成 | ### Optional Dependencies / 可选依赖 | Package / 依赖包 | Purpose / 用途 | Note / 说明 | |--------|------|------| | `Pillow` | Image processing / 图片处理 | PDF image optimization (auto-installed) / PDF 图片优化（自动安装） | | `beautifulsoup4` | HTML parsing / HTML 解析 | Enhanced content extraction (built-in) / 增强内容提取（已内置） | ### System Requirements / 系统组件要求 | Component / 组件 | Requirement / 说明 | |------|------| | **Chrome/Chromium/Edge** | Required for browser mode / 浏览器版抓取必需 | | **Network / 网络连接** | Required to access target webpages / 访问目标网页必需 | | **Disk space / 磁盘空间** | 100MB+ recommended / 建议预留 100MB+ | ### 快速环境检查 | Quick Environment Check Run the following commands to check if the environment is ready: ```bash # Check Python version python3 --version # Should show 3.7+ # 检查依赖是否已安装 python3 -c "import websockets, openpyxl, requests, reportlab; print('✅ All dependencies installed')" # Check if Chrome is available # Windows where chrome # macOS which "Google Chrome" # Linux which google-chrome || which chromium-browser ``` --- ## 🔒 安全说明（必读）| Security | Mode / 模式 | Command / 命令 | Profile | Use Case / 适用场景 | |------|------|---------|----------| | **Isolated (Recommended) / 隔离（推荐）** | `main.py --isolated <URL>` | Temporary isolated Profile, no Cookie / 临时隔离 Profile，无 Cookie | Public content / 公开内容 | | **Default / 默认** | `main.py <URL>` | Reuse Chrome real Profile / 复用 Chrome 真实 Profile | WeChat articles requiring login / 需登录态的微信文章 | ### 风险与缓解 | Risks & Mitigations | Risk / 风险项 | Description / 说明 | Mitigation / 缓解措施 | |--------|------|----------| | Profile reuse / Profile 复用 | Can access Chrome cookies/login sessions / 可访问 Chrome Cookie/登录态 | ✅ Always use `--isolated` for public content / 公开内容一律用 `--isolated` | | CDP remote debugging / CDP 远程调试 | Enables Chrome DevTools protocol / 开启 Chrome DevTools 协议 | Only listens on `127.0.0.1:9222~9232` (localhost) / 仅监听本机 | | CDP Proxy | Creates local proxy daemon / 创建本地代理守护进程 | Only bound to `127.0.0.1`, external access blocked / 仅绑定本机；WS 需 Token 验证 | | Browser launch / 浏览器启动 | May reuse existing browser window / 可能复用已有浏览器窗口 | `--isolated` always launches independent instance / 始终启动独立实例 | | sys.path | Only adds skill directory, no external path access / 仅添加本技能目录 | ✅ Already securely isolated / 已安全隔离 | | pip dependencies / pip 依赖 | No longer auto-install at runtime / 不再运行时自动安装 | ✅ Pre-installed + error prompt / 预装 + 报错提示 | ### 推荐规则 | Recommended Rules > ⚠️ **Default mode reuses your real Chrome profile — it can read cookies and logged-in sessions. Always prefer `--isolated` for public content to avoid exposing your browser session.** - **Public content / 公开内容** → **`--isolated`** (No login required, uses temporary isolated profile / 无需登录，使用临时隔离 Profile) - **Content requiring login / 需登录的内容** → Default mode (Ensure browser is logged in / 确保浏览器已登录目标网站；或在专用/沙箱环境中运行) - **Safest option / 最安全方案** → HTTP batch mode (`batch_http.py`) — no browser, no profile access / 无需浏览器，不访问 Profile ### ⚠️ 重要安全警告 | Important Security Warnings > ⭐ **For maximum safety, prefer `--isolated` mode or HTTP batch mode (`batch_http.py`) so the tool never touches your real browser profile or cookies.** 1. **Cookie and Login Access / Cookie 和登录态访问**: Default mode connects to your existing Chrome instance, allowing access to browser cookies and logged-in sessions. **Only use default mode when you trust the code and need to scrape pages requiring login.** 2. **CDP Proxy — Local Port & State Files / CDP 代理 — 本地端口与状态文件**: The CDP proxy daemon opens a local listening port (default 9223) and writes PID/state files to your temp directory (named `cdp-proxy-<username>`). After each task, **manually check and delete these temp files** if auto-cleanup failed. 3. **Temporary Files / 临时文件**: Scripts create temporary files (xlsx, proxy state, pid files) in system temp directory. These are auto-cleaned after task completion. For extra security, manually check and delete relevant files in temp directory after tasks. 4. **CDP Port Security / CDP 端口安全**: CDP debugging ports are bound to `127.0.0.1` only, external network cannot access. WebSocket connections require Token verification. 5. **Don't Run as Root / 不要以 root 运行**: Run with normal user privileges recommended / 建议以普通用户权限运行此工具。 6. **VM / Sandbox / 虚拟机 / 沙箱**: If you must run browser mode with your real profile, do so inside a VM or disposable environment to limit exposure. 7. **Extra Assurance / 如需额外保证**: Modify `browser_launcher.py` to force isolated profile only. --- ## 用法 | Usage ### 统一入口（推荐）| Unified Entry (Recommended) ```bash # Single page / 单篇 python3 scripts/main.py "https://mp.weixin.qq.com/s?__biz=..." # Batch (multiple URLs) / 批量（多个 URL） python3 scripts/main.py "https://url1" "https://url2" "https://url3" # Batch (xlsx file) / 批量（xlsx 文件） python3 scripts/main.py /path/to/urls.xlsx # Isolated mode (no login) / 隔离模式（不使用登录态） python3 scripts/main.py --isolated "https://..." python3 scripts/main.py --isolated /path/to/urls.xlsx ``` ### Smart Routing / 智能路由 | Input / 输入 | Processing / 处理方式 | |------|----------| | Single URL / 1 个 URL | Single-page full scraping (PDF + Markdown) / 单篇完整抓取 | | Multiple URLs / ≥2 个 URL | Batch scraping: Browser mode if Chrome available, otherwise HTTP mode / Chrome 可用 → 浏览器版，否则 → HTTP 版 | | xlsx file / xlsx 文件 | Auto-routed based on URL count / 根据 URL 数量自动路由 | ### xlsx 格式 | xlsx Format | Column / 列 | Content / 内容 | |----|------| | B (2nd) / B（第2列） | Article title / 文章标题 | | C (3rd) / C（第3列） | Publish date / 发布日期 | | F (6th) / F（第6列） | Article URL / 文章 URL | ### 输出 | Output ``` ~/Desktop/{current date}/ ├── 2025-04-03_article-title.md └── 2025-04-03_article-title.pdf ``` --- ## 脚本说明 | Scripts | Script / 脚本 | Purpose / 用途 | |------|------| | `main.py` | Unified entry, smart routing / 统一入口，智能路由 | | `scrape.py` | Single-page scraping, PDF + Markdown / 单篇抓取，PDF + Markdown | | `batch_scrape.py` | Browser batch (requires Chrome) / 批量浏览器版（需 Chrome） | | `batch_http.py` | HTTP batch (no browser, MD only) / 批量 HTTP 版（无浏览器，仅 Markdown） | --- ## 已知限制 | Known Limitations - **Video/Audio / 视频/音频**: PDF keeps cover, Markdown keeps links / PDF 保留封面，Markdown 保留链接 - **WeChat Mini Programs / 微信小程序**: Static content only / 仅提取静态内容 - **Anti-crawl limits / 反爬限制**: HTTP mode may fail, switch to browser mode / HTTP 版可能失败，改用浏览器版 --- ## 更新日志 | Changelog ### v1.1.1（2026-04-14）— 安全描述增强 - **SKILL.md description metadata 新增安全警告**： - 明确告知浏览器默认模式会复用 Chrome Profile，可访问 Cookie/登录态 - 提示 CDP 代理会打开本地端口并写入临时状态文件 - 推荐公开内容优先使用 `--isolated` - **推荐规则强化**： - 新增最安全方案：HTTP 批量模式（`batch_http.py`），无需浏览器，不访问 Profile - 所有推荐规则前加 ⭐ 标识 - **⚠️ 安全警告章节扩充**： - 新增 CDP 代理本地端口与 `cdp-proxy-<username>` 状态文件提示 - 新增 VM / 沙箱运行建议 - 临时文件手动清理建议 ### v1.1.0（2026-04-14）— English-first bilingual formatting - 🌐 **全面中英双语调整 / English-first throughout**: - All section titles adjusted to "English | 中文" format - All tables adjusted to English first, Chinese second - All descriptions adjusted to English first, Chinese second - Architecture diagram converted to bilingual layout - Security warnings, usage instructions, and known limitations all bilingual - description metadata: EN description placed before Chinese ### v1.0.9（2026-04-14）— 文档增强 **SKILL.md 文档大幅更新：** - 📋 **新增「概述」章节 / Overview section**: 简要说明工具核心功能定位 - ⚙️ **新增「工作机制」/ How It Works 章节**: - 核心技术架构流程图 - 工作流程详解（单篇抓取、批量浏览器版、批量 HTTP 版） - 智能路由逻辑表格 - CDP 协议技术说明 - 🖥️ **新增「适配环境」/ Supported Environments 章节**: - 操作系统支持表（Windows/macOS/Linux） - 浏览器支持表（Chrome/Edge/Chromium 版本要求） - Python 环境要求 - 📦 **新增「基础组件依赖」/ Dependencies 章节**: - 必需依赖包及版本要求（websockets、openpyxl、requests、reportlab） - 可选依赖说明 - 系统组件要求 - 快速环境检查命令 - 便于 SkillHub 平台海外用户理解工具功能 **文档结构优化：** - 将「环境」章节整合到「基础组件依赖」中，避免重复 - 统一表格格式，提升可读性 ### v1.0.8（2026-04-13）— 安全增强 - SKILL.md：新增**⚠️ 重要安全警告**章节，包含： - Cookie 和登录态访问警告 - 临时文件安全说明 - CDP 端口安全说明 - 不要以 root 运行的建议 - VM/disposable 环境运行建议 - main.py：临时文件清理添加日志输出，清理成功/失败都会提示 ### v1.0.7（2026-04-13）— 文件名修复 - 批量抓取时，文件名（PDF + Markdown）现在使用**从网页提取的真实标题和日期** - 修复了之前使用 xlsx 默认标题（如"文章1"）的问题 - `batch_scrape.py`：在提取网页内容后，用网页获取的真实标题覆盖 xlsx 中的标题 ### v1.0.5（2026-04-13）— 安全修复 - 移除 `batch_http.py` 中的硬编码 xlsx 默认路径 - 所有脚本：pip 依赖从运行时 auto-install 改为**预装检查 + 报错提示**（安全加固） - `batch_http.py` 新增系统目录路径安全校验 - SKILL.md：调整安全说明，`--isolated` 标记为公开内容首选 ### v1.0.4（2026-04-13） - 新增 `--isolated` 隔离模式参数 - 移除硬编码路径，必须提供 xlsx 路径 - 新增 `--output` 可指定输出目录 - 强化安全文档 ### v1.0.1（2026-04-12） - 新增批量抓取脚本 - 修复多项 bug

path: scripts/batch_http.py
content: # -*- coding: utf-8 -*-
"""
纯 HTTP 版批量抓取：无需浏览器，直接请求微信文章 URL
生成 Markdown 文件（PDF 需手动用浏览器打印）

适合：大量文章快速离线存档（文字内容）
注意：部分微信文章有反爬限制，此时请使用 batch_scrape.py（需浏览器）

用法：
  python3 scripts/batch_http.py [xlsx路径]
  # xlsx 列要求：col[1]=标题，col[2]=日期，col[5]=URL
"""
import sys, os, re, time, base64
from datetime import date

OUT_DIR = os.path.expanduser(f'~/Desktop/{date.today().strftime("%Y-%m-%d")}')
os.makedirs(OUT_DIR, exist_ok=True)

# ════════════════════════════════════════════════════════
# 依赖
# ════════════════════════════════════════════════════════
import importlib.util
REQUIRED = ['requests', 'openpyxl']
missing = [p for p in REQUIRED if importlib.util.find_spec(p) is None]
if missing:
    print(f"❌ 缺少依赖: {', '.join(missing)}")
    print(f"   请先安装: pip install {' '.join(missing)}")
    sys.exit(1)

import openpyxl

# ════════════════════════════════════════════════════════
# 辅助
# ════════════════════════════════════════════════════════
def sanitize(name):
    name = str(name).strip()
    name = re.sub(r'[\\/:*?"<>|]', '', name)
    name = re.sub(r'\s+', ' ', name)
    return name[:80]

def parse_date(s):
    if not s:
        return 'unknown'
    s = str(s)
    for pat in [r'(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})',
                r'(\d{4})(\d{2})(\d{2})']:
        m = re.search(pat, s)
        if m:
            return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
    return 'unknown'

def fetch_wx_article(url):
    """
    使用 requests 抓取微信文章内容（绕过部分反爬），
    提取标题、日期、正文段落和图片。
    """
    import requests
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9',
        'Referer': 'https://mp.weixin.qq.com/',
    }
    try:
        r = requests.get(url, headers=headers, timeout=20, allow_redirects=True)
        r.encoding = 'utf-8'
        html = r.text
    except Exception as e:
        return None, str(e)

    from html.parser import HTMLParser

    class WxParser(HTMLParser):
        def __init__(self):
            super().__init__()
            self.title = ''
            self.author = ''
            self.pub_date = ''
            self.body_paras = []
            self.images = []
            self.in_title = False
            self.in_content = False
            self.in_js_name = False
            self.current_tag = ''
            self.in_section = False

        def handle_starttag(self, tag, attrs):
            self.current_tag = tag
            attrs_dict = dict(attrs)

            if tag in ('h1', 'h2', 'h3'):
                self.in_title = True

            if attrs_dict.get('id') == 'js_content':
                self.in_content = True
            if attrs_dict.get('id') == 'js_name':
                self.in_js_name = True

            if tag == 'img':
                # 优先 data-src（懒加载），次取 data-wap_src，再取 src
                src = (attrs_dict.get('data-src') or
                       attrs_dict.get('data-wap_src') or
                       attrs_dict.get('src') or '')
                if src and ('mmbiz' in src or src.startswith('http')):
                    if src.startswith('//'):
                        src = 'https:' + src
                    if src not in self.images:
                        self.images.append(src)

        def handle_data(self, data):
            data = data.strip()
            if not data:
                return
            if self.in_title and not self.title:
                self.title = data
                self.in_title = False
            if self.in_js_name and not self.author:
                self.author = data
                self.in_js_name = False
            if self.in_content and self.current_tag == 'p':
                # 过滤微信运营内容
                skip_patterns = (
                    '阅读', '点赞', '在看', '分享', '收藏', '赞赏',
                    '长按', '扫码', '识别', '二维码',
                    '发表于', '来源', '更多', '往期', '相关', '热门',
                )
                if not any(data.startswith(p) for p in skip_patterns):
                    if len(data) > 3:
                        self.body_paras.append(data)

        def handle_endtag(self, tag):
            if tag in ('p', 'section'):
                self.in_section = False

    parser = WxParser()
    try:
        parser.feed(html)
    except Exception:
        pass

    # 备选：从 <title> 提取
    if not parser.title:
        m = re.search(r'<title>([^<]+)</title>', html)
        if m:
            t = m.group(1).replace(' - 复旦大学附属妇产科医院', '').strip()
            parser.title = t

    # 备选作者
    if not parser.author:
        biz_m = re.search(r'var biz = "([^"]+)"', html)
        if biz_m:
            parser.author = '复旦大学附属妇产科医院'

    # 日期备选（ct 时间戳）
    if not parser.pub_date:
        ct_m = re.search(r'ct["\s:]+(\d{10})', html)
        if ct_m:
            try:
                import datetime
                t = datetime.datetime.fromtimestamp(int(ct_m.group(1)))
                parser.pub_date = t.strftime('%Y-%m-%d')
            except Exception:
                pass

    return {
        'title':   parser.title or '未知标题',
        'author':  parser.author or '复旦大学附属妇产科医院',
        'date':    parser.pub_date or '',
        'images':  list(dict.fromkeys(parser.images)),
        'body':    parser.body_paras,
    }, None

# ════════════════════════════════════════════════════════
# Markdown 生成
# ════════════════════════════════════════════════════════
def build_markdown(article, url, pub_date):
    lines = []
    lines.append(f"# {article['title']}\n")
    lines.append(f"- **公众号**: {article['author']}")
    lines.append(f"- **发布日期**: {pub_date}")
    lines.append(f"- **原文链接**: {url}\n")
    lines.append("## 正文\n")
    for para in article['body']:
        para = para.strip()
        if not para:
            continue
        lines.append(para)
        lines.append("")
    for img in article['images'][:20]:
        lines.append(f"![图片]({img})")
        lines.append("")
    return '\n'.join(lines)

# ════════════════════════════════════════════════════════
# 主程序
# ════════════════════════════════════════════════════════
def main():
    if len(sys.argv) < 2:
        print("❌ 请提供 xlsx 文件路径")
        print("  用法: python3 scripts/batch_http.py [xlsx路径]")
        sys.exit(1)

    XLSX_PATH = os.path.abspath(sys.argv[1])

    if not os.path.exists(XLSX_PATH):
        print(f"❌ 文件不存在: {XLSX_PATH}")
        sys.exit(1)

    # 安全检查：拒绝系统目录中的文件
    xlsx_abs = os.path.abspath(XLSX_PATH)
    unsafe_dirs = ('/etc', '/usr', '/bin', '/sbin', '/sys', '/proc', '/root')
    if any(xlsx_abs.startswith(d + '/') for d in unsafe_dirs):
        print(f"❌ 拒绝不安全的路径: {XLSX_PATH}")
        sys.exit(1)

    wb = openpyxl.load_workbook(XLSX_PATH)
    ws = wb.active
    rows = list(ws.iter_rows(min_row=2, values_only=True))
    total = len(rows)
    print(f"共读取 {total} 条记录 → 输出目录: {OUT_DIR}\n")

    success, failed = 0, []

    for i, row in enumerate(rows, 1):
        title    = str(row[1]).strip() if row[1] else f'文章{i}'
        url       = str(row[5]).strip() if row[5] else ''
        date_v    = row[2]
        pub_date  = parse_date(date_v)

        if not url or 'mp.weixin.qq.com' not in url:
            print(f"[{i}/{total}] 跳过（无效URL）: {title[:40]}")
            continue

        safe = sanitize(title)
        print(f"[{i}/{total}] {'='*40}")
        print(f"  标题: {title[:50]}")
        print(f"  日期: {pub_date}")
        print(f"  URL:  {url[:80]}")

        article, err = fetch_wx_article(url)
        if err or not article:
            print(f"  ❌ HTTP 失败: {err}")
            failed.append((i, title, url))
            time.sleep(2)
            continue

        # 保存 Markdown
        md_file = f"{pub_date}_{safe}.md"
        md_path = os.path.join(OUT_DIR, md_file)
        md_content = build_markdown(article, url, pub_date)
        with open(md_path, 'w', encoding='utf-8') as f:
            f.write(md_content)
        print(f"  ✅ Markdown: {md_file} ({len(article['images'])} 张图片)")

        success += 1
        print()
        time.sleep(1)

    print(f"\n{'='*60}")
    print(f"完成！Markdown: {success} 篇")
    if failed:
        print(f"失败: {len(failed)} 篇")
        for _, t, u in failed:
            print(f"  - {t}: {u[:60]}")
    print(f"\n提示: PDF 需手动用 Chrome 打开 Markdown 中的原文链接打印")
    print(f"      或改用 batch_scrape.py（需浏览器）")
    print(f"输出目录: {OUT_DIR}")

if __name__ == '__main__':
    main()


path: scripts/batch_scrape.py
content: # -*- coding: utf-8 -*-
"""
批量抓取脚本：读取 xlsx 文件中的所有 URL，生成 PDF + Markdown
使用 OpenClaw 内置 CDP（通过 browser_launcher.py）
支持 macOS / Windows / Linux
"""
import sys, os, re, time, json, base64, subprocess, platform, importlib.util
from datetime import date

SYSTEM = platform.system()  # 'Darwin' / 'Windows' / 'Linux'

# ── 强制入口检查：禁止直接运行，必须由 main.py 启动 ─────────────────────────
if os.environ.get('URL2PDFMK_ENTRY') != 'main':
    print("❌ 禁止直接运行 batch_scrape.py，请通过 main.py 统一入口调用")
    print("   正确用法：py scripts/main.py <URL1> <URL2> ...")
    sys.exit(1)

# ── 参数解析 ──────────────────────────────────────────────────────────────
import argparse
parser = argparse.ArgumentParser(description='批量抓取网页生成 PDF + Markdown')
parser.add_argument('xlsx_path', nargs='?', help='xlsx 文件路径（包含 URL 列表）')
parser.add_argument('--isolated', action='store_true',
                    help='使用隔离浏览器 Profile（无 Cookie/登录态，适合公开内容）')
parser.add_argument('--output', '-o', help='输出目录（默认: ~/Desktop/当天日期）')
args = parser.parse_args()

# 移除硬编码路径：必须提供 xlsx 路径
if not args.xlsx_path:
    print("❌ 请提供 xlsx 文件路径")
    print("   用法: python3 batch_scrape.py <xlsx路径> [--isolated] [--output <目录>]")
    print("   示例: python3 batch_scrape.py ~/Desktop/urls.xlsx")
    sys.exit(1)

XLSX_PATH = os.path.abspath(os.path.expanduser(args.xlsx_path))
if not os.path.isfile(XLSX_PATH):
    print(f"❌ 文件不存在: {XLSX_PATH}")
    sys.exit(1)

OUT_DIR = os.path.abspath(os.path.expanduser(args.output)) if args.output else \
          os.path.expanduser(f'~/Desktop/{date.today().strftime("%Y-%m-%d")}')
os.makedirs(OUT_DIR, exist_ok=True)

# 隔离模式标志（传递给 browser_launcher）
ISOLATED_MODE = args.isolated

# ── 依赖（预装检查，不自动安装）─────────────────────────────────────────────
REQUIRED = ['openpyxl', 'websockets']
missing = [p for p in REQUIRED if importlib.util.find_spec(p) is None]
if missing:
    print(f"❌ 缺少依赖: {', '.join(missing)}")
    print(f"   请先安装: pip install {' '.join(missing)}")
    sys.exit(1)
import openpyxl

# ── CDP 模块路径 ───────────────────────────────────────────────────────────
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
LOCAL_SCRIPTS = SKILL_DIR   # 所有必要模块（cdp_proxy / cdp_client / browser_actions）均在此目录

# ── sys.path 安全收紧：仅添加本地脚本目录 ─────────────────────────────────
# ✅ 安全修复：不将任何外部目录加入 sys.path，所有模块从本地目录加载
sys.path.insert(0, LOCAL_SCRIPTS)

from browser_launcher import BrowserLauncher, BrowserNeedsCDPError

# ── Monkey-patch：修复 Windows 上 proxy daemon thread 随父进程消亡后状态文件残留的问题 ──
import json as _json, tempfile as _tempfile, getpass as _getpass
_PROXY_STATE_DIR = os.path.join(_tempfile.gettempdir(), f'cdp-proxy-{_getpass.getuser()}')
_PROXY_STATE_FILE = os.path.join(_PROXY_STATE_DIR, 'proxy.json')
_PROXY_PID_FILE   = os.path.join(_PROXY_STATE_DIR, 'proxy.pid')

def _read_proxy_state():
    if not os.path.isfile(_PROXY_STATE_FILE):
        return None
    try:
        with open(_PROXY_STATE_FILE, 'r') as f:
            return _json.load(f)
    except Exception:
        return None

def _proxy_process_alive(state):
    pid = state.get('pid')
    if not pid:
        return False
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False

def _cleanup_proxy_state():
    for f in (_PROXY_STATE_FILE, _PROXY_PID_FILE):
        try:
            os.remove(f)
        except OSError:
            pass

_orig_try_existing_proxy = None
try:
    from browser_launcher import _try_existing_proxy as _orig_try_existing_proxy
except ImportError:
    _orig_try_existing_proxy = None

def _fixed_try_existing_proxy(browser='chrome'):
    state = _read_proxy_state()
    if state and not _proxy_process_alive(state):
        _cleanup_proxy_state()
        return None
    if _orig_try_existing_proxy:
        return _orig_try_existing_proxy(browser)
    return None

import browser_launcher as _bl
_bl._try_existing_proxy = _fixed_try_existing_proxy

from cdp_client import CDPClient
from browser_actions import BrowserActions

# ── 辅助函数 ────────────────────────────────────────────────────────────────
def sanitize(name):
    return re.sub(r'[\\/:*?"<>|]', '', name).strip()[:80]

def parse_date(s):
    for pat in [r'(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})',
                r'(\d{4})(\d{2})(\d{2})']:
        m = re.search(pat, str(s))
        if m:
            return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
    return None

def scroll_and_wait(actions):
    """滚动页面触发懒加载，等待图片加载完成"""
    # 快速整页滚动
    actions.evaluate("window.scrollTo(0, document.body.scrollHeight)")
    time.sleep(1)

    # 小步逐段滚动
    scroll_step = int(actions.evaluate("window.innerHeight"))
    max_h = int(actions.evaluate("document.documentElement.scrollHeight"))
    for y in range(0, max_h, scroll_step):
        actions.evaluate(f"window.scrollTo({{top: {y}, behavior: 'instant'}})")
        time.sleep(0.5)
    actions.evaluate("window.scrollTo({top: 0, behavior: 'instant'})")
    time.sleep(1)

    # 等待图片加载
    for _ in range(15):
        result = actions.evaluate("""
            (function() {
                var imgs = document.querySelectorAll('img');
                var pending = 0;
                imgs.forEach(function(img) {
                    if (!img.complete || img.naturalWidth === 0) pending++;
                });
                return pending;
            })()
        """)
        if result == 0:
            break
        time.sleep(2)

def extract_text_with_images(actions):
    """提取正文文本和图片链接"""
    return actions.evaluate("""
(function() {
    var r = {};
    var t = document.querySelector('h1#activity-name') ||
            document.querySelector('meta[property="og:title"]') ||
            document.querySelector('title');
    r.title = t ? (t.content || t.textContent || '').trim() : '';
    var d = document.querySelector('#publish_time') ||
            document.querySelector('em#publish_time') ||
            document.querySelector('span#publish_time') ||
            document.querySelector('meta[property="article:published_time"]');
    r.date = d ? (d.content || d.textContent || '').trim() : '';
    var a = document.querySelector('#js_name') ||
            document.querySelector('a#js_name') ||
            document.querySelector('.rich_media_meta_list');
    r.author = a ? (a.textContent || '').trim() : '';

    var b = document.getElementById('js_content') ||
            document.querySelector('#js_content') ||
            document.querySelector('.rich_media_content');

    var fragments = [];
    function walk(node) {
        if (!node) return;
        if (node.nodeType === Node.TEXT_NODE) {
            var t = (node.textContent || '').replace(/[\\s\\n]+/g, ' ').trim();
            if (t.length > 1) fragments.push({type: 'text', text: t});
            return;
        }
        if (node.nodeType !== Node.ELEMENT_NODE) return;
        var tag = (node.tagName || '').toLowerCase();
        if ({'script':1,'style':1,'noscript':1,'head':1,'iframe':1,
             'audio':1,'video':1,'canvas':1,'svg':1,'form':1}.hasOwnProperty(tag)) return;

        if (tag === 'img') {
            // 优先 data-src（懒加载），再 src
            var src = node.getAttribute('data-src') ||
                      node.getAttribute('data-wap_src') ||
                      node.src || '';
            var isPh = src.startsWith('data:image/svg') ||
                       (src.length > 40000);
            if (src && !isPh && (src.startsWith('http') || src.startsWith('//'))) {
                if (src.startsWith('//')) src = 'https:' + src;
                fragments.push({type: 'img', src: src});
            }
            return;
        }
        var child = node.firstChild;
        while (child) { walk(child); child = child.nextSibling; }
        if ({'p':1,'div':1,'section':1,'h1':1,'h2':1,'h3':1,'h4':1,
             'li':1,'blockquote':1,'br':1}.hasOwnProperty(tag)) {
            fragments.push({type: 'sep'});
        }
    }
    if (b) walk(b);
    r.fragments = fragments;
    return r;
})()
""")

# ── 主程序 ─────────────────────────────────────────────────────────────────
def main():
    # 读取 Excel
    wb = openpyxl.load_workbook(XLSX_PATH)
    ws = wb.active
    rows = list(ws.iter_rows(min_row=2, values_only=True))
    total = len(rows)
    print(f"共读取 {total} 条记录")

    # ── 整个批量过程只启动一次浏览器 ──────────────────────────────────────
    mode_str = "隔离模式（无 Cookie）" if ISOLATED_MODE else "复用 Profile（可访问登录态）"
    print(f"🚀 启动浏览器（{mode_str}）...")
    launcher = BrowserLauncher()
    try:
        # 隔离模式：使用临时 Profile，不复用登录态
        cdp_url = launcher.launch(browser='chrome', reuse_profile=not ISOLATED_MODE)
    except BrowserNeedsCDPError as e:
        print(f"⚠️  {e}")
        return

    client = CDPClient(cdp_url)
    client.connect()
    actions = BrowserActions(client, None)

    success, failed = 0, []

    for i, row in enumerate(rows, 1):
        title    = str(row[1]).strip() if row[1] else f"文章{i}"
        url      = str(row[5]).strip() if row[5] else ''
        date_val = row[2]

        if not url or not url.startswith('http'):
            print(f"[{i}/{total}] 跳过（无URL）: {title[:40]}")
            continue

        safe_title  = sanitize(title)
        pub_date    = parse_date(date_val) or 'unknown'
        print(f"[{i}/{total}] 处理: {title[:40]}")

        tab = None  # 避免异常时 tab 未定义
        try:
            # 打开页面
            tab = client.create_tab(url)
            client.attach(tab['id'])
            actions.wait_for_load(timeout=20.0)
            time.sleep(3)

            # 滚动加载
            scroll_and_wait(actions)

            # 提取内容
            data = extract_text_with_images(actions)
            effective_date = parse_date(data.get('date', '')) or pub_date
            author = data.get('author', '') or '复旦大学附属妇产科医院'
            fragments = data.get('fragments', [])

            # 用网页获取的真实标题覆盖 xlsx 中的标题，用于文件名
            web_title = data.get('title', '')
            if web_title:
                title = web_title
                safe_title = sanitize(title)

            # ── Markdown ──────────────────────────────────────────────
            md_lines = [
                f"# {title}",
                "",
                f"> **公众号:** {author}",
                f"> **发布日期:** {effective_date}",
                f"> **原文链接:** {url}",
                "",
                "---",
                "",
            ]
            img_idx, prev_was_img = 0, False
            for frag in fragments:
                if frag['type'] == 'text':
                    text = frag['text'].strip()
                    if text:
                        md_lines.append(text)
                        prev_was_img = False
                elif frag['type'] == 'img':
                    src = frag.get('src', '')
                    if not src.startswith('http'):
                        continue
                    img_idx += 1
                    md_lines.append(f"**图片 {img_idx}**")
                    md_lines.append(f"![]({src})")
                    md_lines.append("")
                    prev_was_img = True
                elif frag['type'] == 'sep' and not prev_was_img:
                    md_lines.append("")
                    prev_was_img = False

            md_filename = f"{effective_date}_{safe_title}.md"
            md_path = os.path.join(OUT_DIR, md_filename)
            with open(md_path, 'w', encoding='utf-8') as f:
                f.write('\n'.join(md_lines))
            print(f"  ✅ Markdown: {md_filename} ({img_idx} 张图片)")

            # ── PDF ─────────────────────────────────────────────────
            pdf_filename = f"{effective_date}_{safe_title}.pdf"
            pdf_path = os.path.join(OUT_DIR, pdf_filename)
            result = client.send('Page.printToPDF', {
                'printBackground': True,
                'paperWidth': 21.0 / 2.54,
                'paperHeight': 29.7 / 2.54,
                'marginTop': 1.0,
                'marginBottom': 1.0,
                'marginLeft': 1.0,
                'marginRight': 1.0,
                'scale': 1.0,
                'preferCSSPageSize': False,
            }, timeout=60)
            pdf_data = base64.b64decode(result.get('data', ''))
            with open(pdf_path, 'wb') as f:
                f.write(pdf_data)
            print(f"  ✅ PDF:      {pdf_filename} ({len(pdf_data)//1024}KB)")

            # ── 只关闭标签页，保持浏览器运行（授权状态复用）──────────────
            tab_id = tab.get('id')
            if tab_id:
                try:
                    client.send('Target.closeTarget', {'targetId': tab_id})
                except Exception:
                    pass
            client.detach()
            success += 1
            print()
            time.sleep(1)

        except Exception as e:
            print(f"  ❌ 失败: {e}")
            failed.append((i, title, url))
            # 出错时也只关闭标签页，不断开连接
            if tab is not None:
                try:
                    tab_id = tab.get('id')
                    if tab_id:
                        client.send('Target.closeTarget', {'targetId': tab_id})
                except Exception:
                    pass
            try:
                client.detach()
            except Exception:
                pass
            continue

    # ── 不调用 client.close()，让浏览器保持运行 ──────────────────────────
    print(f"\n{'='*50}")
    print(f"完成！")
    print(f"  成功: {success} 篇")
    if failed:
        print(f"  失败: {len(failed)} 篇")
        for _, t, u in failed:
            print(f"    - {t}: {u[:60]}")
    print(f"输出目录: {OUT_DIR}")
    print(f"浏览器保持运行中，下次脚本会自动复用授权，无需重新授权！")

if __name__ == '__main__':
    main()


path: scripts/browser_actions.py
content: #!/usr/bin/env python3
"""
Browser Actions — 通过 CDP 实现页面交互操作。
"""

import base64, json, os, sys, time, argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import _encoding_fix  # noqa: F401
from cdp_client import CDPClient
from page_snapshot import PageSnapshot


KEY_DEFINITIONS = {
    'enter': {'key': 'Enter', 'code': 'Enter', 'keyCode': 13, 'text': '\r'},
    'return': {'key': 'Enter', 'code': 'Enter', 'keyCode': 13, 'text': '\r'},
    'tab': {'key': 'Tab', 'code': 'Tab', 'keyCode': 9},
    'escape': {'key': 'Escape', 'code': 'Escape', 'keyCode': 27},
    'esc': {'key': 'Escape', 'code': 'Escape', 'keyCode': 27},
    'backspace': {'key': 'Backspace', 'code': 'Backspace', 'keyCode': 8},
    'delete': {'key': 'Delete', 'code': 'Delete', 'keyCode': 46},
    'space': {'key': ' ', 'code': 'Space', 'keyCode': 32, 'text': ' '},
    'arrowup': {'key': 'ArrowUp', 'code': 'ArrowUp', 'keyCode': 38},
    'arrowdown': {'key': 'ArrowDown', 'code': 'ArrowDown', 'keyCode': 40},
    'arrowleft': {'key': 'ArrowLeft', 'code': 'ArrowLeft', 'keyCode': 37},
    'arrowright': {'key': 'ArrowRight', 'code': 'ArrowRight', 'keyCode': 39},
    'home': {'key': 'Home', 'code': 'Home', 'keyCode': 36},
    'end': {'key': 'End', 'code': 'End', 'keyCode': 35},
    'pageup': {'key': 'PageUp', 'code': 'PageUp', 'keyCode': 33},
    'pagedown': {'key': 'PageDown', 'code': 'PageDown', 'keyCode': 34},
    'f1': {'key': 'F1', 'code': 'F1', 'keyCode': 112},
    'f2': {'key': 'F2', 'code': 'F2', 'keyCode': 113},
    'f3': {'key': 'F3', 'code': 'F3', 'keyCode': 114},
    'f4': {'key': 'F4', 'code': 'F4', 'keyCode': 115},
    'f5': {'key': 'F5', 'code': 'F5', 'keyCode': 116},
    'f12': {'key': 'F12', 'code': 'F12', 'keyCode': 123},
}


def _resolve_key(key_name):
    lower = key_name.lower().replace(' ', '')
    if lower in KEY_DEFINITIONS:
        return KEY_DEFINITIONS[lower]
    if len(key_name) == 1:
        code = ord(key_name)
        return {'key': key_name, 'code': f'Key{key_name.upper()}' if key_name.isalpha() else '', 'keyCode': code, 'text': key_name}
    return {'key': key_name, 'code': '', 'keyCode': 0}


class BrowserActions:
    def __init__(self, client, snapshot=None):
        self._client = client
        self._snapshot = snapshot

    def navigate(self, url, wait_until='load', timeout=30.0):
        from urllib.parse import urlparse
        parsed = urlparse(url)
        ALLOWED_SCHEMES = {'http', 'https', 'about', 'chrome', 'edge'}
        if parsed.scheme and parsed.scheme.lower() not in ALLOWED_SCHEMES:
            raise ValueError(f"Blocked navigation to '{parsed.scheme}://' URL.")
        if parsed.hostname:
            import ipaddress
            try:
                ip = ipaddress.ip_address(parsed.hostname)
                if ip.is_private and parsed.hostname != '127.0.0.1':
                    raise ValueError(f"Blocked navigation to private IP: {parsed.hostname}")
            except ValueError as ve:
                if 'Blocked' in str(ve):
                    raise
                pass
            BLOCKED_HOSTS = {'metadata.google.internal', '169.254.169.254'}
            if parsed.hostname in BLOCKED_HOSTS:
                raise ValueError(f"Blocked navigation to metadata endpoint: {parsed.hostname}")
        self._client.send('Page.enable')
        result = self._client.send('Page.navigate', {'url': url})
        error_text = result.get('errorText')
        if error_text:
            raise RuntimeError(f"Navigation failed: {error_text}")
        self.wait_for_load(timeout=timeout)

    def go_back(self):
        history = self._client.send('Page.getNavigationHistory')
        current = history.get('currentIndex', 0)
        if current > 0:
            entries = history.get('entries', [])
            self._client.send('Page.navigateToHistoryEntry', {'entryId': entries[current - 1]['id']})

    def go_forward(self):
        history = self._client.send('Page.getNavigationHistory')
        current = history.get('currentIndex', 0)
        entries = history.get('entries', [])
        if current < len(entries) - 1:
            self._client.send('Page.navigateToHistoryEntry', {'entryId': entries[current + 1]['id']})

    def reload(self, ignore_cache=False):
        self._client.send('Page.reload', {'ignoreCache': ignore_cache})
        self.wait_for_load()

    def wait_for_load(self, timeout=30.0):
        try:
            self._client.send('Page.enable')
            result = self._client.send('Runtime.evaluate', {'expression': 'document.readyState', 'returnByValue': True})
            state = result.get('result', {}).get('value', '')
            if state == 'complete':
                return
            self._client.wait_for_event('Page.loadEventFired', timeout=timeout)
        except RuntimeError:
            pass

    def wait_for_selector(self, selector, timeout=10.0):
        deadline = time.time() + timeout
        while time.time() < deadline:
            result = self._client.send('Runtime.evaluate', {
                'expression': f'!!document.querySelector("{selector}")',
                'returnByValue': True,
            })
            if result.get('result', {}).get('value') is True:
                return True
            time.sleep(0.3)
        return False

    def wait_for_navigation(self, timeout=30.0):
        self._client.send('Page.enable')
        self._client.wait_for_event('Page.frameNavigated', timeout=timeout)
        self.wait_for_load(timeout=timeout)

    def wait(self, seconds):
        time.sleep(seconds)

    def click(self, x, y, button='left', click_count=1):
        self._client.send('Input.dispatchMouseEvent', {'type': 'mouseMoved', 'x': x, 'y': y})
        self._client.send('Input.dispatchMouseEvent', {'type': 'mousePressed', 'x': x, 'y': y, 'button': button, 'clickCount': click_count})
        self._client.send('Input.dispatchMouseEvent', {'type': 'mouseReleased', 'x': x, 'y': y, 'button': button, 'clickCount': click_count})

    def click_selector(self, selector, timeout=5.0):
        result = self._client.send('Runtime.evaluate', {
            'expression': f'''(function() {{const el = document.querySelector("{selector}"); if (!el) return null; const rect = el.getBoundingClientRect(); return {{x: rect.x + rect.width/2, y: rect.y + rect.height/2}};}})()''',
            'returnByValue': True,
        })
        pos = result.get('result', {}).get('value')
        if not pos:
            raise RuntimeError(f"Element not found: {selector}")
        self.click(pos['x'], pos['y'])

    def click_by_ref(self, ref):
        if not self._snapshot:
            raise RuntimeError("No PageSnapshot for ref-based interaction")
        refs = self._snapshot.refs
        if ref not in refs:
            raise RuntimeError(f"Ref '{ref}' not found. Re-run accessibility_tree().")
        node_info = refs[ref]
        role = node_info['role']
        name = node_info['name']
        backend_node_id = node_info.get('backendDOMNodeId')
        object_id = None
        if backend_node_id is not None:
            object_id, pos = self._resolve_position_and_object(backend_node_id)
            if pos:
                if object_id and self._verify_hit(object_id, pos['x'], pos['y']):
                    self.click(pos['x'], pos['y'])
                    return
                if object_id and self._dom_click(object_id):
                    return
                self.click(pos['x'], pos['y'])
                return
        if object_id:
            if self._dom_click(object_id):
                return
        if backend_node_id is not None and object_id is None:
            object_id = self._resolve_object_id(backend_node_id)
            if object_id and self._dom_click(object_id):
                return
        pos = self._resolve_position_by_js(role, name)
        if pos:
            self.click(pos['x'], pos['y'])
            return
        raise RuntimeError(f"Could not locate element for ref '{ref}' (role={role}, name={name})")

    def _resolve_object_id(self, backend_node_id):
        try:
            self._client.send('DOM.enable')
            resolve_result = self._client.send('DOM.resolveNode', {'backendNodeId': backend_node_id})
            return resolve_result.get('object', {}).get('objectId')
        except Exception:
            return None

    def _resolve_position_and_object(self, backend_node_id):
        try:
            self._client.send('DOM.enable')
            resolve_result = self._client.send('DOM.resolveNode', {'backendNodeId': backend_node_id})
            object_id = resolve_result.get('object', {}).get('objectId')
            if not object_id:
                return None, None
            fn_result = self._client.send('Runtime.callFunctionOn', {
                'objectId': object_id,
                'functionDeclaration': '''function() {const rect = this.getBoundingClientRect(); if(rect.width===0&&rect.height===0)return null; return {x:rect.x+rect.width/2, y:rect.y+rect.height/2, width:rect.width, height:rect.height};}''',
                'returnByValue': True,
            })
            pos = fn_result.get('result', {}).get('value')
            if pos and pos.get('width', 0) > 0 and pos.get('height', 0) > 0:
                return object_id, {'x': pos['x'], 'y': pos['y']}
            self._client.send('Runtime.callFunctionOn', {
                'objectId': object_id,
                'functionDeclaration': '''function() {this.scrollIntoViewIfNeeded?this.scrollIntoViewIfNeeded(true):this.scrollIntoView({block:"center",inline:"center"});}''',
            })
            time.sleep(0.3)
            fn_result = self._client.send('Runtime.callFunctionOn', {
                'objectId': object_id,
                'functionDeclaration': '''function() {const rect = this.getBoundingClientRect(); if(rect.width===0&&rect.height===0)return null; return {x:rect.x+rect.width/2, y:rect.y+rect.height/2};}''',
                'returnByValue': True,
            })
            pos = fn_result.get('result', {}).get('value')
            return object_id, pos
        except Exception:
            return None, None

    def _verify_hit(self, object_id, x, y):
        try:
            result = self._client.send('Runtime.callFunctionOn', {
                'objectId': object_id,
                'functionDeclaration': '''function(px,py){const hit=document.elementFromPoint(px,py);if(!hit)return false;return this===hit||this.contains(hit)||hit.contains(this);}''',
                'arguments': [{'value': x}, {'value': y}],
                'returnByValue': True,
            })
            return result.get('result', {}).get('value', False) is True
        except Exception:
            return True

    def _dom_click(self, object_id):
        try:
            result = self._client.send('Runtime.callFunctionOn', {
                'objectId': object_id,
                'functionDeclaration': '''function(){if(this.scrollIntoViewIfNeeded)this.scrollIntoViewIfNeeded(true);else if(this.scrollIntoView)this.scrollIntoView({block:"center",inline:"center"});if(this.focus)this.focus();this.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,cancelable:true,view:window}));this.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,cancelable:true,view:window}));this.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true,view:window}));return true;}''',
                'returnByValue': True,
            })
            return result.get('result', {}).get('value', False) is True
        except Exception:
            return False

    def _resolve_position_by_js(self, role, name):
        safe_name = name.replace('\\', '\\\\').replace('"', '\\"').replace("'", "\\'")
        js_code = f'''
(function(){{
try{{const byLabel=document.querySelectorAll('[aria-label="{safe_name}"]');for(const el of byLabel){{if(el.getAttribute('role')==='{role}'||!'{role}'){{const rect=el.getBoundingClientRect();if(rect.width>0&&rect.height>0)return{{x:rect.x+rect.width/2,y:rect.y+rect.height/2}};}}}}}}catch(e){{}}
try{{const byRole=document.querySelectorAll('[role="{role}"]');for(const el of byRole){{if(el.textContent.trim().includes("{safe_name}")||el.getAttribute('aria-label')==="{safe_name}"){{const rect=el.getBoundingClientRect();if(rect.width>0&&rect.height>0)return{{x:rect.x+rect.width/2,y:rect.y+rect.height/2}};}}}}}}catch(e){{}}
const tagMap={{'button':'button,input[type="button"],input[type="submit"]','link':'a','textbox':'input[type="text"],input[type="search"],input[type="email"],input[type="password"],input:not([type]),textarea','checkbox':'input[type="checkbox"]','radio':'input[type="radio"]','combobox':'select','searchbox':'input[type="search"]','img':'img'}};
const tagSelector=tagMap['{role}'];
if(tagSelector){{try{{const candidates=document.querySelectorAll(tagSelector);for(const el of candidates){{const elName=el.getAttribute('aria-label')||el.getAttribute('title')||el.getAttribute('placeholder')||el.textContent.trim();if(elName&&elName.includes("{safe_name}")){{const rect=el.getBoundingClientRect();if(rect.width>0&&rect.height>0)return{{x:rect.x+rect.width/2,y:rect.y+rect.height/2}};}}}}}}catch(e){{}}}}
return null;
}})()'''
        result = self._client.send('Runtime.evaluate', {'expression': js_code, 'returnByValue': True})
        return result.get('result', {}).get('value')

    def hover(self, x, y):
        self._client.send('Input.dispatchMouseEvent', {'type': 'mouseMoved', 'x': x, 'y': y})

    def drag(self, from_x, from_y, to_x, to_y, steps=10):
        self._client.send('Input.dispatchMouseEvent', {'type': 'mousePressed', 'x': from_x, 'y': from_y, 'button': 'left'})
        for i in range(1, steps + 1):
            t = i / steps
            x = from_x + (to_x - from_x) * t
            y = from_y + (to_y - from_y) * t
            self._client.send('Input.dispatchMouseEvent', {'type': 'mouseMoved', 'x': x, 'y': y})
            time.sleep(0.02)
        self._client.send('Input.dispatchMouseEvent', {'type': 'mouseReleased', 'x': to_x, 'y': to_y, 'button': 'left'})

    def scroll(self, x, y, delta_x=0, delta_y=-200):
        self._client.send('Input.dispatchMouseEvent', {'type': 'mouseWheel', 'x': x, 'y': y, 'deltaX': delta_x, 'deltaY': delta_y})

    def type_text(self, text, delay_ms=50):
        for char in text:
            self._client.send('Input.insertText', {'text': char})
            if delay_ms:
                time.sleep(delay_ms / 1000.0)

    def press_key(self, key):
        key_def = _resolve_key(key)
        params = {'type': 'keyDown', 'key': key_def.get('key', key),
                  'code': key_def.get('code', ''), 'windowsVirtualKeyCode': key_def.get('keyCode', 0)}
        if 'text' in key_def:
            params['text'] = key_def['text']
        self._client.send('Input.dispatchKeyEvent', params)
        self._client.send('Input.dispatchKeyEvent', {'type': 'keyUp', 'key': key_def.get('key', key),
                  'code': key_def.get('code', ''), 'windowsVirtualKeyCode': key_def.get('keyCode', 0)})

    def fill(self, selector, value):
        self._client.send('Runtime.evaluate', {
            'expression': f'(function(){{const el=document.querySelector("{selector}");if(el){{el.focus();el.value="";el.dispatchEvent(new Event("input",{{bubbles:true}}));}}}})()'
        })
        time.sleep(0.1)
        self.type_text(value, delay_ms=20)

    def select_option(self, selector, value):
        self._client.send('Runtime.evaluate', {
            'expression': f'(function(){{const el=document.querySelector("{selector}");if(el){{el.value="{value}";el.dispatchEvent(new Event("change",{{bubbles:true}}));}}}})()'
        })

    def screenshot(self, path='/tmp/screenshot.png', full_page=False, quality=None):
        params = {}
        if full_page:
            metrics = self._client.send('Page.getLayoutMetrics')
            cs = metrics.get('contentSize', {})
            params['clip'] = {'x': 0, 'y': 0, 'width': cs.get('width', 1920), 'height': cs.get('height', 1080), 'scale': 1}
        if path.lower().endswith(('.jpg', '.jpeg')):
            params['format'] = 'jpeg'
            if quality:
                params['quality'] = quality
        else:
            params['format'] = 'png'
        result = self._client.send('Page.captureScreenshot', params)
        data = result.get('data', '')
        img_bytes = base64.b64decode(data)
        os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
        with open(path, 'wb') as f:
            f.write(img_bytes)
        return path

    def pdf(self, path='/tmp/page.pdf', **kwargs):
        params = {'printBackground': True, 'preferCSSPageSize': True}
        params.update(kwargs)
        result = self._client.send('Page.printToPDF', params)
        data = result.get('data', '')
        pdf_bytes = base64.b64decode(data)
        os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
        with open(path, 'wb') as f:
            f.write(pdf_bytes)
        return path

    def evaluate(self, expression, return_by_value=True):
        result = self._client.send('Runtime.evaluate', {
            'expression': expression, 'returnByValue': return_by_value, 'awaitPromise': True,
        })
        remote_obj = result.get('result', {})
        exception = result.get('exceptionDetails')
        if exception:
            text = exception.get('text', '')
            desc = exception.get('exception', {}).get('description', '')
            raise RuntimeError(f"JS evaluation error: {text} — {desc}")
        return remote_obj.get('value') if return_by_value else remote_obj

    def list_tabs(self):
        return self._client.list_tabs()

    def new_tab(self, url='about:blank'):
        return self._client.create_tab(url)

    def switch_tab(self, target_id):
        self._client.detach()
        self._client.activate_tab(target_id)
        self._client.attach(target_id)

    def close_tab(self, target_id=None):
        if target_id:
            self._client.close_tab(target_id)
        else:
            self._client.send('Runtime.evaluate', {'expression': 'window.close()'})

    def get_console_messages(self, clear=True):
        events = self._client.get_events('Runtime.consoleAPICalled', clear=clear)
        messages = []
        for event in events:
            params = event.get('params', {})
            msg_type = params.get('type', 'log')
            args = params.get('args', [])
            text_parts = []
            for arg in args:
                val = arg.get('value', arg.get('description', str(arg.get('type', ''))))
                text_parts.append(str(val))
            messages.append({'type': msg_type, 'text': ' '.join(text_parts)})
        return messages

    def enable_console_capture(self):
        self._client.send('Runtime.enable')

    def get_title(self):
        result = self._client.send('Runtime.evaluate', {'expression': 'document.title', 'returnByValue': True})
        return str(result.get('result', {}).get('value', ''))

    def get_url(self):
        result = self._client.send('Runtime.evaluate', {'expression': 'window.location.href', 'returnByValue': True})
        return str(result.get('result', {}).get('value', ''))

    def get_cookies(self):
        result = self._client.send('Network.getCookies')
        return result.get('cookies', [])

    def set_viewport(self, width, height, device_scale_factor=1.0):
        self._client.send('Emulation.setDeviceMetricsOverride', {
            'width': width, 'height': height, 'deviceScaleFactor': device_scale_factor, 'mobile': False,
        })


def main():
    parser = argparse.ArgumentParser(description='Browser Actions')
    parser.add_argument('--cdp-url', default='http://localhost:9222')
    parser.add_argument('--target', default=None)
    sub = parser.add_subparsers(dest='command')
    nav_p = sub.add_parser('navigate', help='Navigate to URL')
    nav_p.add_argument('url')
    eval_p = sub.add_parser('eval', help='Evaluate JS')
    eval_p.add_argument('expression')
    sub.add_parser('tabs', help='List open tabs')
    args = parser.parse_args()
    if not args.command:
        parser.print_help()
        sys.exit(0)
    client = CDPClient(args.cdp_url)
    client.connect()
    tabs = client.list_tabs()
    if not tabs:
        print("No tabs available")
        sys.exit(1)
    target_id = args.target or tabs[0]['id']
    client.attach(target_id)
    snapshot = PageSnapshot(client)
    actions = BrowserActions(client, snapshot)
    try:
        if args.command == 'navigate':
            actions.navigate(args.url)
            print(f"Title: {actions.get_title()}")
        elif args.command == 'eval':
            result = actions.evaluate(args.expression)
            print(json.dumps(result, indent=2, ensure_ascii=False) if result is not None else 'undefined')
        elif args.command == 'tabs':
            for i, t in enumerate(actions.list_tabs()):
                print(f"  [{i}] {t['title']} — {t['url']}")
    finally:
        client.close()


if __name__ == '__main__':
    main()


path: scripts/browser_launcher.py
content: #!/usr/bin/env python3
"""
Browser Launcher — 浏览器检测、启动与 CDP 连接管理。

支持 Chrome、Edge、QQ浏览器（macOS / Windows / Linux）。
自动检测浏览器安装路径，连接或启动浏览器并开启 CDP remote-debugging 端口。

**核心策略（reuse_profile=True，默认）**：

1. **Probe** — 浏览器已启用 CDP？直接连接，零打扰。
2. **Guide** — 浏览器运行中但无 CDP？（仅 Chrome 144+）引导用户在浏览器中
   开启 ``chrome://inspect/#remote-debugging``，然后轮询等待
   用户点击 "Allow" 弹窗确认。**绝不 kill 用户进程**。
3. **Isolated** — Chrome < 144？无论是否已运行，都使用临时 profile 启动
   隔离实例（不影响正在运行的浏览器，但无法访问用户 Cookie/登录态）。
4. **Restart** — Edge / QQ浏览器运行中但无 CDP？
   提示用户关闭浏览器，用 ``--remote-debugging-port`` 命令行参数重启。
5. **Cold start** — 浏览器未运行，直接启动（带 CDP + 用户 profile）。

Usage:
    from browser_launcher import BrowserLauncher

    launcher = BrowserLauncher()
    cdp_url = launcher.launch(browser='chrome')
    # ... use cdp_url with CDPClient ...
    launcher.stop()

Run directly:
    python browser_launcher.py --browser chrome --headless --help
"""
from __future__ import annotations

import os as _os, sys as _sys  # early imports for path setup
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
import _encoding_fix  # noqa: F401 — Windows UTF-8 stdout/stderr fix

import base64
import concurrent.futures
import hashlib
import json
import os
import platform
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import time
import argparse
import shutil
from urllib.request import urlopen, Request
from urllib.error import URLError


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

DEFAULT_CDP_PORT = 9222
CDP_READY_TIMEOUT_S = 15.0
CDP_READY_POLL_INTERVAL_S = 0.2
BROWSER_STOP_TIMEOUT_S = 5.0

# Timeout for waiting user to click "Allow" in the Chrome debugging dialog
USER_ALLOW_TIMEOUT_S = 120.0
USER_ALLOW_POLL_INTERVAL_S = 1.0

SYSTEM = platform.system()  # 'Darwin', 'Windows', 'Linux'


# ---------------------------------------------------------------------------
# Default user-data-dir paths (per-platform, per-browser)
# ---------------------------------------------------------------------------

MACOS_DEFAULT_PROFILES = {
    'chrome': os.path.expanduser('~/Library/Application Support/Google/Chrome'),
    'edge': os.path.expanduser('~/Library/Application Support/Microsoft Edge'),
    'qqbrowser': os.path.expanduser('~/Library/Application Support/QQBrowser'),
}

WINDOWS_DEFAULT_PROFILES = {
    'chrome': os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google', 'Chrome', 'User Data'),
    'edge': os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Edge', 'User Data'),
    'qqbrowser': os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Tencent', 'QQBrowser', 'User Data'),
}

LINUX_DEFAULT_PROFILES = {
    'chrome': os.path.expanduser('~/.config/google-chrome'),
    'edge': os.path.expanduser('~/.config/microsoft-edge'),
    'qqbrowser': '',  # QQ浏览器不支持 Linux
}


def get_default_profile_dir(browser: str) -> str | None:
    """
    Return the default user-data-dir for *browser* on the current OS,
    or None if it does not exist.
    """
    browser = _normalize_browser_name(browser)
    if SYSTEM == 'Darwin':
        mapping = MACOS_DEFAULT_PROFILES
    elif SYSTEM == 'Windows':
        mapping = WINDOWS_DEFAULT_PROFILES
    else:
        mapping = LINUX_DEFAULT_PROFILES

    path = mapping.get(browser, '')
    if path and os.path.isdir(path):
        return path
    return None


def _normalize_browser_name(browser: str) -> str:
    """Normalize browser alias → canonical name."""
    browser = browser.lower().replace(' ', '').replace('-', '')
    if browser in ('googlechrome', 'gc'):
        return 'chrome'
    if browser in ('microsoftedge', 'msedge'):
        return 'edge'
    if browser in ('qq', 'qqbrowser', 'qq浏览器'):
        return 'qqbrowser'
    return browser


# ---------------------------------------------------------------------------
# Check whether a browser is already running
# ---------------------------------------------------------------------------

def _get_browser_pids(browser: str) -> list[int]:
    """
    Return a list of PIDs for the browser's main process.
    Only returns the *main* browser process, not Helpers/Renderers.
    """
    browser = _normalize_browser_name(browser)
    pids = []

    if SYSTEM == 'Darwin':
        # macOS: find the main process (not Helper)
        exe_patterns = {
            'chrome': 'Google Chrome.app/Contents/MacOS/Google Chrome',
            'edge': 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
            'qqbrowser': 'QQBrowser.app/Contents/MacOS/QQBrowser',
        }
        pattern = exe_patterns.get(browser)
        if pattern:
            try:
                result = subprocess.run(
                    ['pgrep', '-f', pattern],
                    capture_output=True, text=True, timeout=5,
                )
                if result.returncode == 0:
                    for line in result.stdout.strip().split('\n'):
                        line = line.strip()
                        if line.isdigit():
                            pids.append(int(line))
            except (subprocess.TimeoutExpired, FileNotFoundError):
                pass

    elif SYSTEM == 'Windows':
        exe_names = {
            'chrome': 'chrome.exe',
            'edge': 'msedge.exe',
            'qqbrowser': 'QQBrowser.exe',
        }
        exe = exe_names.get(browser)
        if exe:
            try:
                result = subprocess.run(
                    ['tasklist', '/FI', f'IMAGENAME eq {exe}', '/FO', 'CSV', '/NH'],
                    capture_output=True, text=True, timeout=5,
                )
                import csv, io
                for row in csv.reader(io.StringIO(result.stdout)):
                    if len(row) >= 2:
                        try:
                            pids.append(int(row[1]))
                        except ValueError:
                            pass
            except (subprocess.TimeoutExpired, FileNotFoundError):
                pass

    elif SYSTEM == 'Linux':
        patterns = {
            'chrome': ['google-chrome', 'chromium-browser', 'chromium'],
            'edge': ['microsoft-edge'],
            'qqbrowser': [],
        }
        for p in patterns.get(browser, []):
            try:
                result = subprocess.run(
                    ['pgrep', '-f', p],
                    capture_output=True, text=True, timeout=5,
                )
                if result.returncode == 0:
                    for line in result.stdout.strip().split('\n'):
                        line = line.strip()
                        if line.isdigit():
                            pids.append(int(line))
            except (subprocess.TimeoutExpired, FileNotFoundError):
                continue

    return pids


def is_browser_running(browser: str) -> bool:
    """
    Detect if the given browser has running processes.
    """
    return len(_get_browser_pids(browser)) > 0


# ---------------------------------------------------------------------------
# Port → process ownership: which browser is actually listening on a port?
# ---------------------------------------------------------------------------

def _get_pid_for_port(port: int) -> int | None:
    """Return the PID of the process listening on ``127.0.0.1:{port}``.

    Uses ``lsof`` on macOS/Linux.  On Windows, tries PowerShell
    ``Get-NetTCPConnection`` first (precise, available on Win10+),
    then falls back to ``netstat -ano`` (universal).
    Returns None if detection fails or the port is not in use.
    """
    import re

    try:
        if SYSTEM in ('Darwin', 'Linux'):
            # lsof -iTCP:9222 -sTCP:LISTEN -nP -t  → prints PIDs only
            result = subprocess.run(
                ['lsof', f'-iTCP:{port}', '-sTCP:LISTEN', '-nP', '-t'],
                capture_output=True, text=True, timeout=3,
            )
            if result.returncode == 0:
                for line in result.stdout.strip().split('\n'):
                    line = line.strip()
                    if line.isdigit():
                        return int(line)
        elif SYSTEM == 'Windows':
            pid = _get_pid_for_port_windows(port)
            if pid is not None:
                return pid
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass
    return None


def _get_pid_for_port_windows(port: int) -> int | None:
    """Windows-specific port→PID lookup.

    Strategy 1: PowerShell ``Get-NetTCPConnection`` (Win10+, precise).
    Strategy 2: ``netstat -ano`` with regex (universal fallback).
    """
    import re

    # --- Strategy 1: PowerShell (preferred, precise) ---
    try:
        ps_cmd = (
            f'Get-NetTCPConnection -LocalPort {port} -State Listen '
            f'-ErrorAction SilentlyContinue | '
            f'Select-Object -First 1 -ExpandProperty OwningProcess'
        )
        result = subprocess.run(
            ['powershell', '-NoProfile', '-NonInteractive',
             '-Command', ps_cmd],
            capture_output=True, text=True, timeout=5,
            creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
        )
        if result.returncode == 0:
            pid_str = result.stdout.strip()
            if pid_str.isdigit():
                return int(pid_str)
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    # --- Strategy 2: netstat -ano (fallback) ---
    try:
        result = subprocess.run(
            ['netstat', '-ano'],
            capture_output=True, text=True, timeout=5,
            creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
        )
        if result.returncode == 0:
            # Match lines like:
            #   TCP    127.0.0.1:9222    0.0.0.0:0    LISTENING    12345
            #   TCP    0.0.0.0:9222      0.0.0.0:0    LISTENING    12345
            #   TCP    [::]:9222         [::]:0       LISTENING    12345
            # Use regex to avoid false matches (e.g. port 922 matching 9222)
            pattern = re.compile(
                r'TCP\s+\S+:' + str(port) + r'\s+\S+\s+LISTENING\s+(\d+)',
                re.IGNORECASE,
            )
            for line in result.stdout.split('\n'):
                m = pattern.search(line)
                if m:
                    return int(m.group(1))
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    return None


def _pid_matches_browser(pid: int, browser_key: str) -> bool | None:
    """Check whether the given *pid* belongs to the expected browser.

    Returns ``True`` / ``False`` for a definitive answer, or ``None``
    if we couldn't determine (e.g. the process exited, or the platform
    is unsupported).

    The check is intentionally **generous**: it only returns ``False``
    when the process *positively* belongs to a *different* known browser.

    On Windows, tries multiple strategies to get the executable path:
    1. PowerShell ``Get-Process`` (works on Win10+, gives full path)
    2. ``tasklist /FI "PID eq ..."`` (universal, gives image name only)
    """
    cmd: str | None = None

    try:
        if SYSTEM == 'Darwin':
            result = subprocess.run(
                ['ps', '-p', str(pid), '-o', 'command='],
                capture_output=True, text=True, timeout=3,
            )
            if result.returncode != 0:
                return None
            cmd = result.stdout.strip().lower()
        elif SYSTEM == 'Windows':
            cmd = _get_process_path_windows(pid)
        elif SYSTEM == 'Linux':
            # /proc/<pid>/exe is a symlink to the executable
            try:
                cmd = os.readlink(f'/proc/{pid}/exe').lower()
            except OSError:
                return None
        else:
            return None
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return None

    if not cmd:
        return None

    return _match_process_to_browser(cmd, browser_key)


def _get_process_path_windows(pid: int) -> str | None:
    """Get the executable path (or at least image name) for a Windows PID.

    Strategy 1: PowerShell ``(Get-Process -Id <pid>).Path``
                Available on Win10+, returns full path like
                ``C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe``

    Strategy 2: ``tasklist /FI "PID eq <pid>" /FO CSV /NH``
                Universal (XP+), returns image name only like ``chrome.exe``
    """
    no_window = getattr(subprocess, 'CREATE_NO_WINDOW', 0)

    # --- Strategy 1: PowerShell (full path) ---
    try:
        ps_cmd = f'(Get-Process -Id {pid} -ErrorAction SilentlyContinue).Path'
        result = subprocess.run(
            ['powershell', '-NoProfile', '-NonInteractive',
             '-Command', ps_cmd],
            capture_output=True, text=True, timeout=5,
            creationflags=no_window,
        )
        if result.returncode == 0:
            path = result.stdout.strip()
            if path and path not in ('', 'None'):
                return path.lower()
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    # --- Strategy 2: tasklist (image name only) ---
    try:
        import csv
        import io
        result = subprocess.run(
            ['tasklist', '/FI', f'PID eq {pid}', '/FO', 'CSV', '/NH'],
            capture_output=True, text=True, timeout=5,
            creationflags=no_window,
        )
        if result.returncode == 0:
            for row in csv.reader(io.StringIO(result.stdout)):
                if len(row) >= 2 and row[1].strip() == str(pid):
                    return row[0].strip().lower()  # e.g. "chrome.exe"
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ImportError):
        pass

    return None


def _match_process_to_browser(cmd: str, browser_key: str) -> bool | None:
    """Given a lowercased process path/name, determine if it matches browser_key.

    The signature list is ordered from most-specific to least-specific.
    QQBrowser is checked first because its Chromium subprocess may have
    ``chrome`` in the path (e.g. ``...\\tencent\\qqbrowser\\...\\chrome.exe``).

    Key Windows paths:
      - Chrome:     ``...\\google\\chrome\\application\\chrome.exe``
      - Edge:       ``...\\microsoft\\edge\\application\\msedge.exe``
      - QQBrowser:  ``...\\tencent\\qqbrowser\\...\\qqbrowser.exe``
                    or ``...\\tencent\\qqbrowser\\...\\chrome.exe`` (subprocess)
    """
    # --- Ordered from most-specific to least-specific ---
    # Each entry: (browser_name, positive_signatures, negative_signatures)
    # A match requires ANY positive sig AND NONE of the negative sigs.
    _BROWSER_RULES: list[tuple[str, list[str], list[str]]] = [
        # QQBrowser: path contains qqbrowser or tencent\qqbrowser
        ('qqbrowser', [
            'qqbrowser',                    # macOS/Windows exe name or path
            'tencent\\qqbrowser',           # Windows install dir
            'tencent/qqbrowser',            # unlikely but safe
        ], []),
        # Edge: msedge.exe or "microsoft edge" in path
        ('edge', [
            'msedge',                       # msedge.exe on Windows
            'microsoft edge',               # macOS app name
            'microsoft\\edge',              # Windows install dir
        ], []),
        # Chrome: chrome.exe / "google chrome" — but NOT if in QQBrowser/Edge dir
        ('chrome', [
            'google chrome',                # macOS app name
            'google\\chrome',               # Windows install dir
            'google/chrome',                # Linux
            'google-chrome',                # Linux alt
            'chromium',                     # Chromium browser
        ], [
            'qqbrowser',                    # Exclude QQBrowser's chrome subprocess
            'tencent',                      # Exclude Tencent paths
            'msedge',                       # Exclude Edge
            'microsoft',                    # Exclude Microsoft paths
        ]),
    ]

    for bname, pos_sigs, neg_sigs in _BROWSER_RULES:
        has_positive = any(sig in cmd for sig in pos_sigs)
        has_negative = any(sig in cmd for sig in neg_sigs)
        if has_positive and not has_negative:
            return bname == browser_key

    # Special fallback: if the *only* info is a bare image name like
    # "chrome.exe" with no path context, we can't distinguish Chrome
    # from QQBrowser's chrome subprocess.  Return None (inconclusive).
    if 'chrome' in cmd:
        # Bare "chrome" without distinctive path — ambiguous
        return None

    return None  # Unknown process — can't judge

    return actual_browser == browser_key


def _try_probe_cdp(port: int, *, allow_ws: bool = False) -> dict | None:
    """
    Try to connect to a CDP endpoint.  Returns a version info dict on
    success, or None if unreachable / not a CDP server.

    Strategies:

    1. **HTTP JSON API** — ``/json/version`` (traditional
       ``--remote-debugging-port`` mode).  Always attempted.
    2. **WebSocket-only** — connect to ``/devtools/browser`` and send
       ``Browser.getVersion`` (``chrome://inspect`` remote debugging mode,
       Chrome 136+).  **Only attempted when ``allow_ws=True``**, because
       each new WS connection triggers Chrome's "Allow remote debugging?"
       auth dialog — calling this in a loop would spam the user with
       repeated dialogs.

    Args:
        port: TCP port to probe.
        allow_ws: If True, fall back to WebSocket probe when HTTP fails.
                  **Use sparingly** — each WS connection triggers an auth
                  dialog in ``chrome://inspect`` mode.
    """
    # --- Fast pre-check: is anything listening? ---
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(0.15)
            if sock.connect_ex(('127.0.0.1', port)) != 0:
                return None
    except OSError:
        return None

    # --- Strategy 1: HTTP /json/version ---
    # Timeout kept short (0.8 s) — loopback CDP responds in <10 ms.
    try:
        url = f'http://127.0.0.1:{port}/json/version'
        req = Request(url, headers={'Accept': 'application/json'})
        with urlopen(req, timeout=0.8) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            if 'webSocketDebuggerUrl' in data or 'Browser' in data:
                data['_ws_only'] = False
                return data
    except (URLError, OSError, json.JSONDecodeError, ValueError):
        pass

    # --- Strategy 2: WebSocket /devtools/browser (opt-in only) ---
    if allow_ws:
        ws_result = _try_probe_cdp_ws(port)
        if ws_result:
            return ws_result

    return None


def _try_probe_cdp_ws(port: int, timeout: float = 3.0) -> dict | None:
    """
    Connect to ``ws://127.0.0.1:{port}/devtools/browser`` and send
    ``Browser.getVersion``.  Returns the result dict (with
    ``'_ws_only': True``) on success, or None.

    This handles Chrome's ``chrome://inspect/#remote-debugging`` mode
    where HTTP endpoints return 404 but WebSocket works.
    """
    sock = None
    try:
        # --- WebSocket handshake ---
        ws_key = base64.b64encode(os.urandom(16)).decode()
        handshake = (
            'GET /devtools/browser HTTP/1.1\r\n'
            f'Host: 127.0.0.1:{port}\r\n'
            'Upgrade: websocket\r\n'
            'Connection: Upgrade\r\n'
            f'Sec-WebSocket-Key: {ws_key}\r\n'
            'Sec-WebSocket-Version: 13\r\n'
            '\r\n'
        )
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect(('127.0.0.1', port))
        sock.sendall(handshake.encode())

        # Read until end-of-headers
        buf = b''
        while b'\r\n\r\n' not in buf:
            chunk = sock.recv(4096)
            if not chunk:
                return None
            buf += chunk

        header_text = buf.split(b'\r\n\r\n')[0].decode('utf-8', errors='replace')
        if '101' not in header_text:
            return None

        # --- Send Browser.getVersion via WS ---
        msg = json.dumps({'id': 1, 'method': 'Browser.getVersion'}).encode()
        _ws_send_text(sock, msg)

        # --- Read response ---
        payload = _ws_recv_text(sock, timeout=timeout)
        if payload:
            data = json.loads(payload)
            if 'result' in data:
                result = data['result']
                result['_ws_only'] = True
                result['webSocketDebuggerUrl'] = f'ws://127.0.0.1:{port}/devtools/browser'
                return result

    except (OSError, json.JSONDecodeError, ValueError, socket.timeout, struct.error):
        pass
    finally:
        if sock:
            try:
                sock.close()
            except OSError:
                pass
    return None


def _ws_send_text(sock: socket.socket, payload: bytes) -> None:
    """Send a masked WebSocket text frame."""
    frame = bytearray()
    frame.append(0x81)  # FIN + text opcode
    mask_key = os.urandom(4)
    length = len(payload)
    if length < 126:
        frame.append(0x80 | length)
    elif length < 65536:
        frame.append(0x80 | 126)
        frame.extend(struct.pack('>H', length))
    else:
        frame.append(0x80 | 127)
        frame.extend(struct.pack('>Q', length))
    frame.extend(mask_key)
    frame.extend(bytearray(b ^ mask_key[i % 4] for i, b in enumerate(payload)))
    sock.sendall(bytes(frame))


def _ws_recv_text(sock: socket.socket, timeout: float = 3.0) -> str | None:
    """Receive a single WebSocket text frame.  Returns decoded text or None."""
    sock.settimeout(timeout)
    header = _ws_recv_exact(sock, 2)
    if not header:
        return None

    is_masked = (header[1] & 0x80) != 0
    length = header[1] & 0x7f
    if length == 126:
        ext = _ws_recv_exact(sock, 2)
        if not ext:
            return None
        length = struct.unpack('>H', ext)[0]
    elif length == 127:
        ext = _ws_recv_exact(sock, 8)
        if not ext:
            return None
        length = struct.unpack('>Q', ext)[0]

    if is_masked:
        mask = _ws_recv_exact(sock, 4)
        if not mask:
            return None

    data = _ws_recv_exact(sock, length)
    if not data:
        return None

    if is_masked:
        data = bytearray(b ^ mask[i % 4] for i, b in enumerate(data))

    return bytes(data).decode('utf-8', errors='replace')


def _ws_recv_exact(sock: socket.socket, n: int) -> bytes | None:
    """Receive exactly *n* bytes from *sock*, or return None on error."""
    buf = b''
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            return None
        buf += chunk
    return buf


def _trigger_and_wait_cdp_auth(
    port: int = DEFAULT_CDP_PORT,
    timeout: float = USER_ALLOW_TIMEOUT_S,
    poll_interval: float = 1.0,
    on_progress: 'callable | None' = None,
) -> 'tuple[dict, socket.socket] | tuple[None, None]':
    """
    Trigger Chrome's "Allow remote debugging?" dialog **and** wait for the
    user to click "Allow" — all using a **single** TCP connection cycle.

    **Why a single connection matters:**  In Chrome's ``chrome://inspect``
    WS-only mode, *every* new WebSocket connection triggers a new auth
    dialog.  If we use separate connections to trigger and then probe, the
    user sees multiple dialogs.

    **Strategy:**

    1. Open a TCP connection and send the WS upgrade request.  This causes
       Chrome to show the auth dialog.
    2. Wait (with a long timeout) for Chrome to respond:
       - If the user clicks "Allow", Chrome sends back ``101 Switching
         Protocols`` on this *same* connection.
       - If the user clicks "Deny" or the dialog times out, Chrome closes
         the connection (recv returns empty / connection reset).
    3. If we get ``101``, proceed to send ``Browser.getVersion`` on this
       connection to confirm CDP is working, and return the result.

    Returns:
        (version_info_dict, authenticated_socket) on success — caller MUST
        either pass the socket to CDP Proxy or close it.
        (None, None) on failure.

    **IMPORTANT**: The returned socket is an authenticated WS connection to
    Chrome.  It is intentionally NOT closed here so that the CDP Proxy can
    reuse it without triggering another auth dialog.
    """
    sock = None
    try:
        ws_key = base64.b64encode(os.urandom(16)).decode()
        handshake = (
            'GET /devtools/browser HTTP/1.1\r\n'
            f'Host: 127.0.0.1:{port}\r\n'
            'Upgrade: websocket\r\n'
            'Connection: Upgrade\r\n'
            f'Sec-WebSocket-Key: {ws_key}\r\n'
            'Sec-WebSocket-Version: 13\r\n'
            '\r\n'
        )
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(5)
        sock.connect(('127.0.0.1', port))
        sock.sendall(handshake.encode())

        # Now wait for the user to click "Allow".
        # Use a long timeout — the user may take a while.
        sock.settimeout(timeout)

        # Read response headers
        buf = b''
        try:
            while b'\r\n\r\n' not in buf:
                chunk = sock.recv(4096)
                if not chunk:
                    # Connection closed — user denied or Chrome rejected
                    return None, None
                buf += chunk
        except socket.timeout:
            return None, None

        header_text = buf.split(b'\r\n\r\n')[0].decode('utf-8', errors='replace')
        if '101' not in header_text:
            # Not a WS upgrade — user denied or wrong endpoint
            return None, None

        # Success! User clicked "Allow".  Send Browser.getVersion.
        msg = json.dumps({'id': 1, 'method': 'Browser.getVersion'}).encode()
        sock.settimeout(5)
        _ws_send_text(sock, msg)

        payload = _ws_recv_text(sock, timeout=5)
        if payload:
            data = json.loads(payload)
            if 'result' in data:
                result = data['result']
                result['_ws_only'] = True
                result['webSocketDebuggerUrl'] = f'ws://127.0.0.1:{port}/devtools/browser'
                # Return the socket along with the result — do NOT close it!
                # The caller will pass it to CDP Proxy for reuse.
                authenticated_sock = sock
                sock = None  # Prevent finally from closing it
                return result, authenticated_sock

    except (OSError, json.JSONDecodeError, ValueError, socket.timeout, struct.error):
        pass
    finally:
        if sock:
            try:
                sock.close()
            except OSError:
                pass
    return None, None


def _read_devtools_active_port(profile_dir: str) -> int | None:
    """
    Read the DevToolsActivePort file from the browser profile directory.
    Returns the port number if found and the CDP HTTP endpoint responds,
    or None otherwise.
    """
    port_file = os.path.join(profile_dir, 'DevToolsActivePort')
    if not os.path.isfile(port_file):
        return None
    try:
        with open(port_file, 'r') as f:
            first_line = f.readline().strip()
            if first_line.isdigit():
                return int(first_line)
    except (OSError, ValueError):
        pass
    return None


def _get_chrome_major_version(exe_path: str | None = None) -> int | None:
    """
    Detect the major version of a Chrome/Chromium executable.

    Tries multiple strategies in order:
      1. Run ``<exe> --version`` and parse "Google Chrome 136.0.6778.0".
      2. On Windows, read the version from the executable's file metadata
         via ``wmic`` or from the parent directory name (e.g. ``136.0.6778.0/``).
      3. On macOS, read ``Info.plist`` in the .app bundle.

    Returns the major version number (e.g. 136), or None if detection fails.
    """
    if not exe_path:
        exe_path = detect_browser('chrome')
    if not exe_path:
        return None

    # Strategy 1: --version (works on macOS / Linux, sometimes Windows)
    try:
        kwargs: dict = dict(
            capture_output=True, text=True, timeout=5,
        )
        if SYSTEM == 'Windows':
            CREATE_NO_WINDOW = 0x08000000
            kwargs['creationflags'] = CREATE_NO_WINDOW

        result = subprocess.run([exe_path, '--version'], **kwargs)
        if result.returncode == 0:
            # Output: "Google Chrome 136.0.6778.0" or "Chromium 136.0.6778.0"
            import re
            m = re.search(r'(\d+)\.', result.stdout)
            if m:
                return int(m.group(1))
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass

    # Strategy 2 (Windows): parse version from directory structure
    # Chrome installs as <base>/Application/<version>/chrome.exe
    # or <base>/Application/chrome.exe with version dirs alongside
    if SYSTEM == 'Windows':
        import re
        # Check parent directory name (e.g. "136.0.6778.0")
        parent = os.path.basename(os.path.dirname(exe_path))
        m = re.match(r'^(\d+)\.', parent)
        if m:
            return int(m.group(1))

        # Check sibling directories in the Application folder
        app_dir = os.path.dirname(exe_path)
        if os.path.isdir(app_dir):
            for name in os.listdir(app_dir):
                m = re.match(r'^(\d+)\.\d+\.\d+\.\d+$', name)
                if m:
                    return int(m.group(1))

    # Strategy 3 (macOS): Info.plist
    if SYSTEM == 'Darwin':
        try:
            # exe_path: .../Google Chrome.app/Contents/MacOS/Google Chrome
            # plist:    .../Google Chrome.app/Contents/Info.plist
            contents_dir = os.path.dirname(os.path.dirname(exe_path))
            plist_path = os.path.join(contents_dir, 'Info.plist')
            if os.path.isfile(plist_path):
                result = subprocess.run(
                    ['defaults', 'read', plist_path, 'CFBundleShortVersionString'],
                    capture_output=True, text=True, timeout=5,
                )
                if result.returncode == 0:
                    import re
                    m = re.search(r'(\d+)\.', result.stdout)
                    if m:
                        return int(m.group(1))
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            pass

    return None


# Minimum Chrome version that supports chrome://inspect/#remote-debugging
_CHROME_MIN_GUIDE_VERSION = 144


def _get_inspect_url(browser: str) -> str:
    """Return the inspect page URL for the given browser."""
    browser = _normalize_browser_name(browser)
    # Edge uses edge:// scheme, others use chrome://
    if browser == 'edge':
        return 'edge://inspect/#remote-debugging'
    return 'chrome://inspect/#remote-debugging'


def _supports_guide_mode(browser: str, exe_path: str | None = None) -> bool:
    """Return True if the browser supports chrome://inspect Guide mode.

    Only Chrome **144+** supports enabling CDP without restarting the browser
    via the ``chrome://inspect/#remote-debugging`` toggle.

    Chrome < 144, Edge, and QQ浏览器 do NOT support this — they must be
    restarted with ``--remote-debugging-port``.

    Args:
        browser: Canonical browser name.
        exe_path: Optional path to the browser executable (used to read
            the version number).  If None, will be auto-detected.
    """
    if _normalize_browser_name(browser) != 'chrome':
        return False

    version = _get_chrome_major_version(exe_path)
    if version is None:
        # Can't determine version — assume it's modern enough.
        # (Better to attempt Guide mode and fall back than to force restart.)
        return True

    return version >= _CHROME_MIN_GUIDE_VERSION


# Default CDP ports per browser (used in launch hints and cold start)
_DEFAULT_CDP_PORTS = {
    'chrome': 9222,
    'edge': 9334,
    'qqbrowser': 9333,
}


def _get_launch_hint(browser: str, exe_path: str | None = None) -> str:
    """Build a human-readable shell command to restart the browser with CDP.

    Used in error messages to guide the user when the browser needs a
    restart (Edge / QQ浏览器 / Chrome < 144).
    """
    browser_key = _normalize_browser_name(browser)
    port = _DEFAULT_CDP_PORTS.get(browser_key, 9222)

    if not exe_path:
        exe_path = detect_browser(browser_key)
    if not exe_path:
        exe_path = f'<{browser}_executable_path>'

    cmd = f'"{exe_path}" --remote-debugging-port={port}'

    # Allow CDP WebSocket connections only from localhost origins.
    # SECURITY: Never use '*' — it allows any webpage's JS to connect.
    if browser_key in ('edge', 'chrome'):
        cmd += " --remote-allow-origins=http://127.0.0.1"

    return cmd


def _open_inspect_page(browser: str) -> bool:
    """
    Try to open the browser's ``chrome://inspect/#remote-debugging`` page
    in the user's running browser so they can enable remote debugging.

    Returns True if the command was dispatched (no guarantee the page opened).
    """
    browser = _normalize_browser_name(browser)
    url = _get_inspect_url(browser)

    if SYSTEM == 'Darwin':
        app_name = {
            'chrome': 'Google Chrome',
            'edge': 'Microsoft Edge',
            'qqbrowser': 'QQBrowser',
        }.get(browser)
        if app_name:
            try:
                # Use AppleScript to open URL in existing browser
                script = f'tell application "{app_name}" to open location "{url}"'
                subprocess.run(
                    ['osascript', '-e', script],
                    capture_output=True, timeout=5,
                )
                return True
            except (subprocess.TimeoutExpired, FileNotFoundError):
                pass

    elif SYSTEM == 'Windows':
        try:
            # On Windows, 'start' will open URL in default handler,
            # but we want the specific browser
            exe_path = detect_browser(browser)
            if exe_path:
                subprocess.Popen(
                    [exe_path, url],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return True
        except (OSError, FileNotFoundError):
            pass

    else:  # Linux
        exe_path = detect_browser(browser)
        if exe_path:
            try:
                subprocess.Popen(
                    [exe_path, url],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return True
            except (OSError, FileNotFoundError):
                pass

    return False


def _is_port_listening(port: int) -> bool:
    """Return True if something is listening on ``127.0.0.1:{port}``."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(1)
        return sock.connect_ex(('127.0.0.1', port)) == 0


def _cleanup_stale_locks(profile_dir: str) -> None:
    """Remove stale lock files from a Chromium profile directory.

    After a crash or forced kill, Chromium may leave behind lock files
    (``SingletonLock``, ``SingletonSocket``, ``SingletonCookie``,
    ``lockfile``) that prevent a new instance from starting.

    On **Windows**, ``SingletonLock`` is a named kernel object (not a file),
    but ``lockfile`` is a real file.  ``SingletonSocket`` and
    ``SingletonCookie`` are Unix-only.

    This function is **only safe to call when we've already confirmed**
    that the browser is NOT running (via ``is_browser_running()``).
    """
    lock_names = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'lockfile']
    for name in lock_names:
        lock_path = os.path.join(profile_dir, name)
        if os.path.exists(lock_path):
            try:
                os.remove(lock_path)
            except OSError:
                pass  # Permission denied or still locked → ignore


def _try_probe_cdp_http_only(port: int) -> dict | None:
    """HTTP-only probe — never triggers an auth dialog.

    This is safe to call in tight polling loops because it only hits
    the ``/json/version`` HTTP endpoint.

    On **Windows**, ``urlopen(timeout=N)`` may not reliably enforce
    the connect timeout at the socket layer.  To avoid long hangs on
    ports that are not listening, we first do a quick ``socket.connect_ex``
    check (≤0.5 s) before attempting HTTP.
    """
    # --- Fast pre-check: is anything listening? ---
    # This avoids the unreliable urlopen timeout on Windows.
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(0.5)
            if sock.connect_ex(('127.0.0.1', port)) != 0:
                return None
    except OSError:
        return None

    # --- Port is open — try HTTP probe ---
    # Timeout kept short (0.8 s) because this is a loopback probe;
    # a real CDP endpoint responds in <10 ms.  Longer timeouts hurt
    # when ports are open but not CDP (e.g. Chrome WS-only mode).
    try:
        url = f'http://127.0.0.1:{port}/json/version'
        req = Request(url, headers={'Accept': 'application/json'})
        with urlopen(req, timeout=0.8) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            if 'webSocketDebuggerUrl' in data or 'Browser' in data:
                data['_ws_only'] = False
                return data
    except (URLError, OSError, json.JSONDecodeError, ValueError):
        pass
    return None


def _wait_for_cdp_with_user_action(
    browser: str,
    timeout: float = USER_ALLOW_TIMEOUT_S,
    poll_interval: float = USER_ALLOW_POLL_INTERVAL_S,
    on_progress: 'callable | None' = None,
) -> 'tuple[str, socket.socket | None] | tuple[None, None]':
    """
    Poll for a CDP endpoint to appear while the user enables remote
    debugging in their browser.

    **Full flow (Chrome 136+ ``chrome://inspect`` mode):**

    1. Poll using HTTP-only probes (safe, no dialog) — if the browser
       was started with ``--remote-debugging-port``, this will succeed.
    2. Once port 9222 starts listening (user toggled the switch),
       call ``_trigger_and_wait_cdp_auth()`` which opens **one single
       WebSocket connection** that both triggers the auth dialog AND
       waits for the user to click "Allow" on that same connection.
       This guarantees **exactly one dialog**.

    Args:
        browser: Canonical browser name.
        timeout: Max seconds to wait for the user to click "Allow".
        poll_interval: Seconds between probe attempts.
        on_progress: Optional callback(elapsed_s, timeout_s) for UI feedback.

    Returns:
        (cdp_url, authenticated_socket) on success.  The socket may be None
        if the connection was established via HTTP (non-WS-only mode).
        (None, None) on failure.
    """
    start = time.time()
    deadline = start + timeout

    browser_key = _normalize_browser_name(browser)

    while time.time() < deadline:
        # --- Build the set of candidate ports to probe ---
        # Start with the default range around 9222, then add the port
        # from DevToolsActivePort (which may be a non-standard port like
        # 59925 when chrome://inspect assigns its own port).
        probe_ports = set(range(9222, 9232))
        profile_dir = get_default_profile_dir(browser_key)
        port_from_file = None
        if profile_dir:
            port_from_file = _read_devtools_active_port(profile_dir)
            if port_from_file:
                probe_ports.add(port_from_file)

        # --- Fast path: HTTP probe (safe, no dialog) ---
        for port in sorted(probe_ports):
            info = _try_probe_cdp_http_only(port)
            if info and not info.get('_proxy', False) and _cdp_browser_matches(info, browser_key, port):
                # Found a matching CDP endpoint (not a proxy, correct browser)
                return f'http://127.0.0.1:{port}', None

        # --- Port is listening → do single-connection auth + wait ---
        # Try the DevToolsActivePort first (most reliable), then fallback
        # to DEFAULT_CDP_PORT.
        auth_port = port_from_file if port_from_file and _is_port_listening(port_from_file) else None
        if auth_port is None and _is_port_listening(DEFAULT_CDP_PORT):
            auth_port = DEFAULT_CDP_PORT
        if auth_port is not None:
            remaining = deadline - time.time()
            if remaining <= 0:
                break
            # This opens ONE WS connection, triggers the dialog,
            # and blocks until the user clicks "Allow" (or timeout).
            ws_info, auth_sock = _trigger_and_wait_cdp_auth(
                auth_port,
                timeout=remaining,
            )
            if ws_info:
                return f'http://127.0.0.1:{auth_port}', auth_sock
            # If it failed (user denied / timeout on this attempt),
            # don't retry — we'd just trigger another dialog.
            # Fall through to re-check HTTP in case user used
            # --remote-debugging-port style instead.

        if on_progress:
            on_progress(time.time() - start, timeout)

        time.sleep(poll_interval)

    return None, None


def _cdp_browser_matches(info: dict, browser_key: str,
                         port: int | None = None) -> bool:
    """Check if a CDP /json/version response matches the expected browser.

    The ``Browser`` field in the CDP response typically looks like:
      - Chrome:     "Chrome/136.0.6778.0"
      - Edge:       "Edg/136.0.2903.0"  (note: *Edg*, not *Edge*)
      - QQBrowser:  "Chrome/123.0.6312.124" (reports as Chrome!)

    QQ浏览器的 ``Browser`` 字段与 Chrome 相同，但 ``User-Agent`` 中包含
    ``QQBrowser/``，可用此区分。Edge 的 UA 中包含 ``Edg/``。

    When the CDP response alone is **ambiguous** (e.g. QQBrowser reporting
    itself as ``Chrome/*`` without a ``QQBrowser/`` UA marker), we fall
    back to **process-level verification**: look up which process is
    actually listening on the given *port* via ``lsof`` / ``netstat``,
    then compare the executable path against known browser signatures.
    This provides a definitive answer regardless of what the CDP endpoint
    reports in its ``Browser`` / ``User-Agent`` fields.

    Args:
        info: The ``/json/version`` JSON response dict.
        browser_key: Canonical browser name ('chrome', 'edge', 'qqbrowser').
        port: Optional CDP port number.  When provided, enables process-
              level verification as a fallback for ambiguous CDP responses.
    """
    browser_field = info.get('Browser', '').lower()
    ua_field = info.get('User-Agent', '').lower()
    combined = browser_field + ' ' + ua_field

    if not browser_field:
        # No info at all → fall back to process check if port is available
        if port is not None:
            pid = _get_pid_for_port(port)
            if pid is not None:
                proc_match = _pid_matches_browser(pid, browser_key)
                if proc_match is not None:
                    return proc_match
        return True  # Truly no info → optimistically accept

    # --- Layer 1: CDP response field analysis ---
    is_qqbrowser = 'qqbrowser/' in combined
    is_edge = ('edg/' in browser_field) or ('edg/' in ua_field)
    # Chrome: has "chrome/" but is NOT QQBrowser and NOT Edge
    is_chrome = ('chrome/' in browser_field) and not is_qqbrowser and not is_edge

    _ACTUAL = {
        'chrome':    is_chrome,
        'edge':      is_edge,
        'qqbrowser': is_qqbrowser,
    }

    match = _ACTUAL.get(browser_key)
    if match is None:
        return True  # Unknown browser → accept anything

    # --- Layer 2: Process-level verification for ambiguous cases ---
    # If Layer 1 says "match" but the response looks like a bare
    # "Chrome/*" without distinctive markers (could be QQBrowser or
    # a Chromium fork), verify via the actual process.
    #
    # Specifically, we do a process check when:
    #   - We think it's Chrome (is_chrome=True, requesting chrome),
    #     but there's no strong negative signal (no QQBrowser/Edg marker)
    #     — this is exactly the ambiguous case where QQBrowser hides.
    #   - port is available for lookup.
    if match and port is not None:
        # Ambiguity: the response says "Chrome/*" and we're looking for chrome,
        # but it could actually be QQBrowser (or another Chromium fork) with
        # a generic Browser field.  Only trigger process check when the
        # response is genuinely ambiguous.
        is_ambiguous = (
            browser_key == 'chrome'
            and is_chrome
            and 'chrome/' in browser_field
            and 'qqbrowser/' not in combined
            and 'edg/' not in combined
        )
        if is_ambiguous:
            pid = _get_pid_for_port(port)
            if pid is not None:
                proc_match = _pid_matches_browser(pid, browser_key)
                if proc_match is not None:
                    return proc_match
            # If process check is inconclusive, trust Layer 1

    return match


def probe_existing_cdp(
    browser: str,
    port_range: tuple[int, int] | None = None,
) -> str | None:
    """
    Try to find an already-running CDP endpoint for the given browser.

    **This function is dialog-safe** — it will NEVER trigger Chrome's
    "Allow remote debugging?" auth dialog.  It only uses HTTP probes
    and DevToolsActivePort file checks.

    Strategy:
      1. Read DevToolsActivePort from the default profile directory.
      2. Scan a **small, targeted** set of CDP ports using **HTTP-only**
         probes (safe, fast).  Uses a thread pool to probe ports
         concurrently — this is critical on Windows where ``connect_ex``
         to a closed port blocks for the full timeout duration.
      3. Skip any port that is a CDP Proxy (``_proxy: true`` in response)
         — proxy health is handled separately by ``_try_existing_proxy()``.
      4. **Verify the ``Browser`` field matches the requested browser**,
         so that e.g. an Edge probe won't accidentally connect to a
         QQBrowser CDP endpoint on a neighbouring port.

    If ``port_range`` is None (default), a small range is computed
    automatically around each browser's default CDP port
    (chrome=9222, edge=9334, qqbrowser=9333), keeping the total number
    of ports small for fast scanning on Windows.

    Returns:
        CDP HTTP base URL (e.g. 'http://127.0.0.1:9222') or None.
    """
    browser = _normalize_browser_name(browser)

    # 1. Check DevToolsActivePort — fastest path, no scanning needed
    profile_dir = get_default_profile_dir(browser)
    if profile_dir:
        port = _read_devtools_active_port(profile_dir)
        if port:
            info = _try_probe_cdp_http_only(port)
            if info and not info.get('_proxy', False) and _cdp_browser_matches(info, browser, port):
                return f'http://127.0.0.1:{port}'

    # 2. Build the port list to scan.
    if port_range is not None:
        ports = list(range(port_range[0], port_range[1] + 1))
    else:
        # Auto-compute a small set of ports centred on the browser's
        # default CDP port, plus a few neighbours.  This avoids scanning
        # 100+ ports which is very slow on Windows.
        base_port = _DEFAULT_CDP_PORTS.get(browser, 9222)
        port_set: set[int] = set()
        # The browser's own default ± 5
        for offset in range(-5, 6):
            port_set.add(base_port + offset)
        # Also always include the other browsers' default ports — in
        # case the user is running multiple browsers, we want to find
        # the right one via _cdp_browser_matches().
        for p in _DEFAULT_CDP_PORTS.values():
            for offset in range(-2, 3):
                port_set.add(p + offset)
        # Include the port from DevToolsActivePort — chrome://inspect
        # may assign a non-standard port (e.g. 59925) that falls outside
        # the default ranges above.
        if profile_dir:
            datp = _read_devtools_active_port(profile_dir)
            if datp:
                port_set.add(datp)
        ports = sorted(port_set)

    # 3. Concurrent HTTP-only scan (safe, no dialogs).
    #    On Windows, socket connect_ex to a closed port blocks for the
    #    full timeout (~0.15 s per port).  A thread pool makes it
    #    O(0.15 s) total instead of N × 0.15 s.
    def _probe_one(p: int) -> tuple[int, dict | None]:
        return (p, _try_probe_cdp_http_only(p))

    with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(ports), 20)) as pool:
        futures = {pool.submit(_probe_one, p): p for p in ports}
        results: dict[int, dict | None] = {}
        for future in concurrent.futures.as_completed(futures):
            port_num, info = future.result()
            results[port_num] = info

    # Check results in port order (prefer lower port numbers)
    for p in ports:
        info = results.get(p)
        if info and not info.get('_proxy', False) and _cdp_browser_matches(info, browser, p):
            return f'http://127.0.0.1:{p}'

    # NOTE: We intentionally do NOT do WS probes here.
    # WS probes trigger auth dialogs in chrome://inspect mode.
    # The caller (BrowserLauncher.launch) handles WS-only mode
    # via _wait_for_cdp_with_user_action() with proper user guidance.

    return None


def _try_existing_proxy(browser: str = 'chrome') -> str | None:
    """
    Check if a CDP Proxy is already running and **healthy** (upstream
    connection to Chrome is alive and responsive).

    Returns the proxy URL (e.g. 'http://127.0.0.1:9223') if available,
    or None.  This is **dialog-safe** — it only makes HTTP requests to
    the proxy, never directly to Chrome.

    Only Chrome's WS-only mode (``chrome://inspect``) uses the CDP Proxy.
    For other browsers (Edge, QQ), CDP is accessible via standard HTTP,
    so they never need a proxy — this function returns None immediately.

    Health checks (all must pass):
      1. Proxy process is alive (PID file / state file).
      2. Proxy HTTP endpoint responds to /json/version and reports itself
         as a proxy (``_proxy: true``).
      3. End-to-end CDP verification — send ``Browser.getVersion`` via
         WebSocket through the proxy.  Since only WS-only mode uses the
         proxy, this directly tests proxy → Chrome upstream connectivity.
         Unlike the previous ``/json/list`` check (which could return an
         empty ``[]`` even when upstream was dead), a successful
         ``Browser.getVersion`` proves the full round-trip is working.
    """
    # Only Chrome uses CDP Proxy (WS-only mode). Edge/QQ have standard
    # HTTP CDP endpoints and never start a proxy.
    if browser != 'chrome':
        return None

    try:
        from cdp_proxy import get_proxy_url
        proxy_url = get_proxy_url()
        if not proxy_url:
            return None

        # Extract port from URL
        port_str = proxy_url.rsplit(':', 1)[-1].rstrip('/')
        port = int(port_str) if port_str.isdigit() else 9223

        # Check 1+2: HTTP endpoint responds and is a proxy
        info = _try_probe_cdp_http_only(port)
        if not info:
            return None
        if not info.get('_proxy', False):
            return None

        # Check 3: end-to-end verification — send Browser.getVersion
        # via WS through the proxy to Chrome.  Only WS-only mode uses
        # the proxy, so we can always use _try_probe_cdp_ws here.
        # This goes through: client → proxy WS → Chrome upstream WS,
        # proving the full chain is alive.  Unlike connecting directly
        # to Chrome, connecting to the proxy does NOT trigger Chrome's
        # "Allow remote debugging?" dialog.
        ws_info = _try_probe_cdp_ws(port, timeout=5.0)
        if not ws_info:
            return None

        return proxy_url

    except (ImportError, Exception):
        pass
    return None


def _start_proxy_for_port(chrome_port: int, auth_sock: 'socket.socket | None' = None) -> str | None:
    """
    Start a CDP Proxy for the given Chrome port and return its URL.

    Args:
        chrome_port: The Chrome CDP port to proxy.
        auth_sock: An already-authenticated WS socket to Chrome.
            If provided, the proxy will reuse this socket instead of
            opening a new connection (which would trigger another auth
            dialog in ``chrome://inspect`` WS-only mode).

    Returns the proxy URL (e.g. 'http://127.0.0.1:9223') on success,
    or None if the proxy couldn't be started.
    """
    try:
        from cdp_proxy import ensure_proxy_running
        return ensure_proxy_running(chrome_port=chrome_port, auth_sock=auth_sock)
    except (ImportError, Exception) as e:
        # Proxy module not available or failed to start — not critical,
        # direct connection will still work (with dialog on each connect)
        if auth_sock:
            try:
                auth_sock.close()
            except OSError:
                pass
        return None


# ---------------------------------------------------------------------------
# Browser executable detection
# ---------------------------------------------------------------------------

# macOS bundle IDs and fallback paths
MACOS_BROWSERS = {
    'chrome': {
        'bundle_ids': ['com.google.Chrome'],
        'fallback_paths': [
            '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
        ],
    },
    'edge': {
        'bundle_ids': [
            'com.microsoft.edgemac',
            'com.microsoft.Edge',
            'com.microsoft.edgemac.Beta',
            'com.microsoft.edgemac.Dev',
            'com.microsoft.edgemac.Canary',
        ],
        'fallback_paths': [
            '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
        ],
    },
    'qqbrowser': {
        'bundle_ids': ['com.tencent.mac.qqbrowser'],
        'fallback_paths': [
            '/Applications/QQBrowser.app/Contents/MacOS/QQBrowser',
            '/Applications/QQ浏览器.app/Contents/MacOS/QQ浏览器',
        ],
    },
}

# Windows executable names and search directories
WINDOWS_BROWSERS = {
    'chrome': {
        'exe_names': ['chrome.exe'],
        'search_dirs': [
            os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Google', 'Chrome', 'Application'),
            os.path.join(os.environ.get('PROGRAMFILES', ''), 'Google', 'Chrome', 'Application'),
            os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Google', 'Chrome', 'Application'),
            r'D:\Program Files\Google\Chrome\Application',
            r'D:\Program Files (x86)\Google\Chrome\Application',
        ],
    },
    'edge': {
        'exe_names': ['msedge.exe'],
        'search_dirs': [
            os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Edge', 'Application'),
            os.path.join(os.environ.get('PROGRAMFILES', ''), 'Microsoft', 'Edge', 'Application'),
            os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Microsoft', 'Edge', 'Application'),
            r'D:\Program Files\Microsoft\Edge\Application',
            r'D:\Program Files (x86)\Microsoft\Edge\Application',
        ],
    },
    'qqbrowser': {
        'exe_names': ['QQBrowser.exe'],
        'search_dirs': [
            os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Tencent', 'QQBrowser'),
            os.path.join(os.environ.get('PROGRAMFILES', ''), 'Tencent', 'QQBrowser'),
            os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Tencent', 'QQBrowser'),
            r'D:\Program Files\Tencent\QQBrowser',
            r'D:\Program Files (x86)\Tencent\QQBrowser',
        ],
    },
}

# Linux executable names and paths
LINUX_BROWSERS = {
    'chrome': {
        'names': ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium'],
        'paths': [
            '/usr/bin/google-chrome',
            '/usr/bin/google-chrome-stable',
            '/usr/bin/chromium-browser',
            '/usr/bin/chromium',
            '/snap/bin/chromium',
        ],
    },
    'edge': {
        'names': ['microsoft-edge', 'microsoft-edge-stable'],
        'paths': [
            '/usr/bin/microsoft-edge',
            '/usr/bin/microsoft-edge-stable',
        ],
    },
    'qqbrowser': {
        'names': [],
        'paths': [],  # QQ浏览器不支持 Linux
    },
}


def _detect_macos(browser: str) -> str | None:
    """Detect browser executable on macOS."""
    info = MACOS_BROWSERS.get(browser)
    if not info:
        return None

    # Try to find via bundle ID using mdfind
    for bundle_id in info['bundle_ids']:
        try:
            result = subprocess.run(
                ['mdfind', f'kMDItemCFBundleIdentifier == "{bundle_id}"'],
                capture_output=True, text=True, timeout=5
            )
            paths = result.stdout.strip().split('\n')
            for app_path in paths:
                if app_path and os.path.isdir(app_path):
                    # Resolve the actual executable inside .app bundle
                    exe = os.path.join(app_path, 'Contents', 'MacOS')
                    if os.path.isdir(exe):
                        for f in os.listdir(exe):
                            full = os.path.join(exe, f)
                            if os.path.isfile(full) and os.access(full, os.X_OK):
                                return full
        except (subprocess.TimeoutExpired, FileNotFoundError):
            continue

    # Fallback to known paths
    for path in info['fallback_paths']:
        if os.path.isfile(path) and os.access(path, os.X_OK):
            return path

    return None


def _detect_windows(browser: str) -> str | None:
    """Detect browser executable on Windows."""
    info = WINDOWS_BROWSERS.get(browser)
    if not info:
        return None

    for search_dir in info['search_dirs']:
        if not search_dir or not os.path.isdir(search_dir):
            continue
        for exe_name in info['exe_names']:
            # First: check the directory root (the real launcher exe)
            direct = os.path.join(search_dir, exe_name)
            if os.path.isfile(direct):
                return direct
            # Then: search subdirectories (version dirs like 136.0.6778.0/)
            for root, _dirs, files in os.walk(search_dir):
                if root == search_dir:
                    continue  # already checked
                if exe_name in files:
                    return os.path.join(root, exe_name)

    # Fallback: try 'where' command
    for exe_name in info['exe_names']:
        try:
            result = subprocess.run(
                ['where', exe_name],
                capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0:
                path = result.stdout.strip().split('\n')[0]
                if path and os.path.isfile(path):
                    return path
        except (subprocess.TimeoutExpired, FileNotFoundError):
            continue

    return None


def _detect_linux(browser: str) -> str | None:
    """Detect browser executable on Linux."""
    info = LINUX_BROWSERS.get(browser)
    if not info:
        return None

    # Try 'which' for each known name
    for name in info['names']:
        try:
            result = subprocess.run(
                ['which', name],
                capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0:
                path = result.stdout.strip()
                if path and os.path.isfile(path):
                    return path
        except (subprocess.TimeoutExpired, FileNotFoundError):
            continue

    # Fallback to known paths
    for path in info['paths']:
        if os.path.isfile(path) and os.access(path, os.X_OK):
            return path

    return None


def detect_browser(browser: str) -> str | None:
    """
    Detect the executable path of a browser.

    Args:
        browser: One of 'chrome', 'edge', 'qqbrowser'

    Returns:
        Absolute path to the browser executable, or None if not found.
    """
    browser = _normalize_browser_name(browser)

    if SYSTEM == 'Darwin':
        return _detect_macos(browser)
    elif SYSTEM == 'Windows':
        return _detect_windows(browser)
    elif SYSTEM == 'Linux':
        return _detect_linux(browser)
    return None


def detect_any_browser() -> tuple[str, str] | None:
    """
    Detect any available browser, in priority order: Chrome → Edge → QQ浏览器.

    Returns:
        (browser_name, executable_path) tuple, or None.
    """
    for name in ('chrome', 'edge', 'qqbrowser'):
        path = detect_browser(name)
        if path:
            return (name, path)
    return None


# ---------------------------------------------------------------------------
# Port utilities
# ---------------------------------------------------------------------------

def is_port_available(port: int) -> bool:
    """Check if a TCP port is available."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(0.5)
        result = sock.connect_ex(('127.0.0.1', port))
        return result != 0


def find_available_port(start: int = DEFAULT_CDP_PORT, count: int = 100) -> int:
    """Find an available port starting from 'start'."""
    for port in range(start, start + count):
        if is_port_available(port):
            return port
    raise RuntimeError(f"No available port found in range {start}-{start + count - 1}")


# ---------------------------------------------------------------------------
# CDP readiness check
# ---------------------------------------------------------------------------

def wait_for_cdp_ready(
    port: int,
    timeout: float = CDP_READY_TIMEOUT_S,
    poll_interval: float = CDP_READY_POLL_INTERVAL_S,
) -> dict:
    """
    Poll the CDP /json/version endpoint until the browser is ready.

    Returns:
        The /json/version response dict.

    Raises:
        RuntimeError if timeout is exceeded.
    """
    url = f'http://127.0.0.1:{port}/json/version'
    deadline = time.time() + timeout

    while time.time() < deadline:
        # Quick TCP check before HTTP (Windows urlopen timeout unreliable)
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.settimeout(0.5)
                if sock.connect_ex(('127.0.0.1', port)) != 0:
                    time.sleep(poll_interval)
                    continue
        except OSError:
            time.sleep(poll_interval)
            continue

        try:
            req = Request(url, headers={'Accept': 'application/json'})
            with urlopen(req, timeout=3) as resp:
                data = json.loads(resp.read().decode('utf-8'))
                return data
        except (URLError, OSError, json.JSONDecodeError):
            time.sleep(poll_interval)

    raise RuntimeError(
        f"Browser CDP did not become ready on port {port} within {timeout}s"
    )


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class BrowserRunningError(RuntimeError):
    """
    Raised when the user's browser is already running and we need its
    profile directory (which Chromium locks to a single instance).

    .. deprecated:: Use BrowserNeedsCDPError instead.
    """
    pass


class BrowserNeedsCDPError(RuntimeError):
    """
    Raised when the browser is running but CDP remote debugging is not
    enabled.  The error message includes step-by-step instructions for
    the user to enable it.

    The instructions differ by browser type and version:

    * **Chrome 144+** — supports ``chrome://inspect/#remote-debugging``
      Guide mode (no restart needed).
    * **Chrome < 144** — auto-launches an isolated instance with a temp
      profile (no user action needed, no longer raises this error).
    * **Edge / QQ浏览器** — do NOT support Guide mode; must close the
      browser and restart with ``--remote-debugging-port``.

    Attributes:
        browser:        Pretty browser name (e.g. "Google Chrome").
        browser_key:    Canonical browser key (e.g. "chrome", "edge").
        inspect_url:    The ``chrome://inspect/...`` URL (Chrome only).
        waited:         True if we already waited for the user and timed out.
        timeout:        The timeout duration (seconds) if ``waited=True``.
        needs_restart:  True if the browser requires a full restart (Edge/QQ).
        launch_hint:    Suggested shell command to restart the browser (Edge/QQ).
    """

    def __init__(
        self,
        browser: str = 'Google Chrome',
        browser_key: str = 'chrome',
        inspect_url: str = 'chrome://inspect/#remote-debugging',
        waited: bool = False,
        timeout: float = 0,
        needs_restart: bool = False,
        launch_hint: str = '',
    ):
        self.browser = browser
        self.browser_key = browser_key
        self.inspect_url = inspect_url
        self.waited = waited
        self.timeout = timeout
        self.needs_restart = needs_restart
        self.launch_hint = launch_hint

        if needs_restart:
            # Edge / QQ浏览器 — must restart
            msg = (
                f"{browser} 正在运行，但未开启远程调试。\n\n"
                f"{browser} 不支持在运行中开启 CDP，需要关闭后用调试参数重新启动。\n\n"
                f"请执行以下步骤：\n"
                f"  1. 关闭 {browser}（请先保存未完成的工作）\n"
                f"  2. 用以下命令重新启动：\n"
                f"     {launch_hint}\n\n"
                f"或传入 reuse_profile=False 使用隔离 profile 启动新实例（不影响正在运行的浏览器）。"
            )
        elif waited:
            msg = (
                f"{browser} 远程调试未在 {int(timeout)} 秒内开启。\n\n"
                f"请在 {browser} 中手动操作：\n"
                f"  1. 地址栏输入 {inspect_url} 并回车\n"
                f"  2. 勾选 \"Allow remote debugging for this browser instance\"\n"
                f"  3. 等待弹出 \"要允许远程调试吗？\" 对话框，点击 \"允许\"\n\n"
                f"然后重新运行此脚本。"
            )
        else:
            msg = (
                f"{browser} 正在运行，但未开启远程调试。\n\n"
                f"请在 {browser} 中操作：\n"
                f"  1. 地址栏输入 {inspect_url} 并回车\n"
                f"  2. 勾选 \"Allow remote debugging for this browser instance\"\n"
                f"  3. 等待弹出 \"要允许远程调试吗？\" 对话框，点击 \"允许\"\n\n"
                f"或传入 reuse_profile=False 使用隔离 profile 启动新实例。"
            )
        super().__init__(msg)


# ---------------------------------------------------------------------------
# BrowserLauncher
# ---------------------------------------------------------------------------

class BrowserLauncher:
    """
    Launch and manage a Chromium-based browser process with CDP enabled.

    Supports three connection strategies (controlled by ``reuse_profile``):

    * **reuse_profile=True** (default) — try to reuse the user's real
      browser profile so that cookies, login sessions, and extensions are
      available.  The resolution order is:

      1. **Probe** — if the browser is already running with a CDP port
         open, connect directly.  Zero disruption for the user.
      2. **Guide** — if the browser is running *without* CDP, automatically
         open ``chrome://inspect/#remote-debugging`` in the user's browser
         and wait for the user to click "Allow" on the debugging dialog.
         **Never kills the user's browser process** (avoids losing
         unsaved forms, tabs, etc.).
      3. **Cold start** — browser is not running; start it with CDP
         and the real profile.

    * **reuse_profile=False** — always create a temporary isolated profile
      (no cookies, no extensions, auto-cleaned on ``stop()``).
    """

    def __init__(self):
        self._process = None
        self._cdp_port = None
        self._user_data_dir = None
        self._temp_dir = None       # non-None ⇒ we own this dir
        self._attached = False       # True if we connected to an existing process
        self._browser_name = None    # resolved canonical browser name

    def launch(
        self,
        browser: str = 'chrome',
        headless: bool = False,
        port: int = None,
        user_data_dir: str = None,
        reuse_profile: bool = True,
        executable_path: str = None,
        extra_args: list = None,
        wait_for_user: bool = True,
        user_allow_timeout: float = USER_ALLOW_TIMEOUT_S,
        on_progress: 'callable | None' = None,
    ) -> str:
        """
        Connect to an existing browser or launch a new one with CDP.

        Args:
            browser: Browser type ('chrome', 'edge', 'qqbrowser')
            headless: Run in headless mode (only used when launching new)
            port: CDP port (auto-detected if None)
            user_data_dir: Explicit profile dir (bypasses auto-resolution)
            reuse_profile: Reuse the user's real profile (default True).
                See class docstring for the full resolution strategy.
            executable_path: Override browser executable path
            extra_args: Additional command-line arguments
            wait_for_user: If True (default) and the browser needs the
                user to enable remote debugging, wait up to
                ``user_allow_timeout`` seconds for them to do so.
                If False, raise ``BrowserNeedsCDPError`` immediately.
            user_allow_timeout: Seconds to wait for the user to click
                "Allow" in the Chrome debugging dialog (default 120s).
            on_progress: Optional callback(elapsed_s, timeout_s) called
                while waiting for the user.  Useful for progress bars.

        Returns:
            CDP HTTP endpoint URL, e.g. 'http://127.0.0.1:9222'

        Raises:
            BrowserNeedsCDPError: When the browser is running without CDP
                and ``wait_for_user=False``.
            RuntimeError: If the browser cannot be started or CDP is
                unreachable after timeout.
        """
        if self._process is not None or self._attached:
            raise RuntimeError("Browser already active. Call stop() first.")

        self._browser_name = _normalize_browser_name(browser)

        # --- Strategy: connect to existing browser if possible -----------
        if reuse_profile and not user_data_dir:
            pretty_name = {
                'chrome': 'Google Chrome',
                'edge': 'Microsoft Edge',
                'qqbrowser': 'QQ浏览器',
            }.get(self._browser_name, self._browser_name)

            # Step 0: Check for a running CDP Proxy first (dialog-safe)
            # Only Chrome uses the proxy (WS-only mode); Edge/QQ skip this.
            print(f"   [1/4] 检查 CDP Proxy ...")
            proxy_url = _try_existing_proxy(self._browser_name)
            if proxy_url:
                from urllib.parse import urlparse
                parsed = urlparse(proxy_url)
                self._cdp_port = parsed.port or DEFAULT_CDP_PORT
                self._attached = True
                self._user_data_dir = get_default_profile_dir(self._browser_name)
                print(f"✅ 已通过 CDP Proxy 连接到用户的 {pretty_name} （使用用户真实 profile，含登录态和 Cookie）")
                return proxy_url

            # Step 1: Probe — check for an existing CDP endpoint (HTTP only)
            print(f"   [2/4] 探测已有 CDP 端口 ...")
            existing = probe_existing_cdp(self._browser_name)
            if existing:
                # Extract port from URL
                from urllib.parse import urlparse
                parsed = urlparse(existing)
                self._cdp_port = parsed.port or DEFAULT_CDP_PORT
                self._attached = True
                self._user_data_dir = get_default_profile_dir(self._browser_name)
                print(f"✅ 已连接到用户的 {pretty_name} （使用用户真实 profile，含登录态和 Cookie）")
                return existing

            # Step 2: Browser running without CDP
            print(f"   [3/4] 检查浏览器进程 ...")
            if is_browser_running(self._browser_name):
                pretty = {
                    'chrome': 'Google Chrome',
                    'edge': 'Microsoft Edge',
                    'qqbrowser': 'QQBrowser',
                }.get(self._browser_name, self._browser_name)
                inspect_url = _get_inspect_url(self._browser_name)

                # Resolve exe path for version detection
                resolved_exe = executable_path or detect_browser(self._browser_name)

                # ---- Edge / QQ浏览器 / Chrome < 144: must restart ----
                if not _supports_guide_mode(self._browser_name, exe_path=resolved_exe):
                    if self._browser_name == 'chrome':
                        # Chrome < 144: 无法通过 Guide 模式开启 CDP，也无法
                        # 复用已运行实例的 profile（SingletonLock 会导致新进程
                        # 静默退出）。直接用临时 profile 启动隔离实例。
                        print(f"   ⚠️  {pretty} 版本 < 144，不支持 Guide 模式")
                        print(f"   → 将使用隔离 profile 启动新实例（不影响正在运行的浏览器）")
                        self._temp_dir = tempfile.mkdtemp(prefix='browser-cdp-')
                        self._user_data_dir = self._temp_dir
                        return self._start_browser_process(
                            browser=browser,
                            headless=headless,
                            port=port,
                            executable_path=resolved_exe,
                            extra_args=extra_args,
                        )
                    else:
                        # Edge / QQ浏览器 — 仍然要求用户手动重启
                        launch_hint = _get_launch_hint(self._browser_name, exe_path=resolved_exe)
                        raise BrowserNeedsCDPError(
                            browser=pretty,
                            browser_key=self._browser_name,
                            inspect_url=inspect_url,
                            needs_restart=True,
                            launch_hint=launch_hint,
                        )

                # ---- Chrome: Guide mode (no restart needed) ----
                if not wait_for_user:
                    raise BrowserNeedsCDPError(
                        browser=pretty,
                        browser_key=self._browser_name,
                        inspect_url=inspect_url,
                    )

                # Check if a CDP Proxy is already running — if so, the user
                # already clicked "Allow" in a previous session.
                proxy_url = _try_existing_proxy()
                if proxy_url:
                    from urllib.parse import urlparse
                    parsed = urlparse(proxy_url)
                    self._cdp_port = parsed.port or DEFAULT_CDP_PORT
                    self._attached = True
                    self._user_data_dir = get_default_profile_dir(self._browser_name)
                    print(f"✅ 已通过 CDP Proxy 连接（端口 {self._cdp_port}），无需重复授权")
                    return proxy_url

                # Try to open the inspect page automatically
                _open_inspect_page(self._browser_name)

                # Print guidance for the user
                print(f"\n{'='*60}")
                print(f"🔗 {pretty} 正在运行，但尚未开启远程调试。")
                print(f"")
                print(f"已为您自动打开调试设置页面。请在 {pretty} 中：")
                print(f"  1. 确认已打开 {inspect_url}")
                print(f"  2. 勾选 \"Allow remote debugging for this browser instance\"")
                print(f"  3. 等待弹出 \"要允许远程调试吗？\" 对话框")
                print(f"  4. 点击 \"允许\"")
                print(f"")
                print(f"⏳ 等待中（最多 {int(user_allow_timeout)} 秒）...")
                print(f"   💡 开关开启后会自动触发确认弹窗")
                print(f"   💡 首次允许后，后续脚本将通过 CDP Proxy 自动复用连接")
                print(f"{'='*60}\n")

                cdp_url, auth_sock = _wait_for_cdp_with_user_action(
                    self._browser_name,
                    timeout=user_allow_timeout,
                    poll_interval=USER_ALLOW_POLL_INTERVAL_S,
                    on_progress=on_progress,
                )

                if cdp_url:
                    from urllib.parse import urlparse
                    parsed = urlparse(cdp_url)
                    chrome_port = parsed.port or DEFAULT_CDP_PORT
                    self._attached = True
                    self._user_data_dir = get_default_profile_dir(self._browser_name)
                    print(f"✅ 已成功连接到 {pretty} 的 CDP 端口 {chrome_port}")

                    # Start CDP Proxy so subsequent scripts won't trigger
                    # another auth dialog.  Pass the authenticated socket
                    # so the proxy reuses it (no second dialog).
                    proxy_url = _start_proxy_for_port(chrome_port, auth_sock=auth_sock)
                    if proxy_url:
                        parsed = urlparse(proxy_url)
                        self._cdp_port = parsed.port or chrome_port
                        print(f"🔄 CDP Proxy 已启动（端口 {self._cdp_port}），后续脚本将自动复用此连接")
                        return proxy_url
                    else:
                        self._cdp_port = chrome_port
                        return cdp_url

                # User didn't enable in time
                raise BrowserNeedsCDPError(
                    browser=pretty,
                    browser_key=self._browser_name,
                    inspect_url=inspect_url,
                    waited=True,
                    timeout=user_allow_timeout,
                )

            # Step 3: Cold start — browser not running
            print(f"   [4/4] 浏览器未运行，准备冷启动 ...")

            resolved_exe = executable_path or detect_browser(self._browser_name)

            # QQ浏览器不使用 --user-data-dir 参数，启动后自动使用内置 profile
            # （含用户登录态和 Cookie），无需解析或创建任何 profile 目录。
            if self._browser_name == 'qqbrowser':
                self._user_data_dir = None
                self._temp_dir = None

            # Chrome < 144: 在 Windows 上即使浏览器未运行，使用用户默认
            # profile + --remote-debugging-port 启动也无法正常开启 CDP 端口。
            # 一律使用隔离 profile 避免此问题。
            elif (self._browser_name == 'chrome'
                    and not _supports_guide_mode(self._browser_name, exe_path=resolved_exe)):
                print(f"   ⚠️  Chrome 版本 < 144，使用隔离 profile 冷启动")
                self._temp_dir = tempfile.mkdtemp(prefix='browser-cdp-')
                self._user_data_dir = self._temp_dir

            # Chrome ≥ 144: --remote-debugging-port 在用户真实 profile
            # 下启动后走 WS-only 模式，HTTP /json/version 不可用，导致
            # _wait_for_cdp_ready_with_process_check 超时。正确做法是
            # 先正常启动 Chrome（不带 CDP 参数），然后走 Guide 流程让
            # 用户在 chrome://inspect 开启远程调试。
            elif (self._browser_name == 'chrome'
                    and _supports_guide_mode(self._browser_name, exe_path=resolved_exe)):
                return self._cold_start_chrome_guide_mode(
                    exe_path=resolved_exe,
                    wait_for_user=wait_for_user,
                    user_allow_timeout=user_allow_timeout,
                    on_progress=on_progress,
                )

            else:
                profile_dir = get_default_profile_dir(self._browser_name)
                if profile_dir:
                    self._user_data_dir = profile_dir
                    self._temp_dir = None
                    # Clean stale lock files that may prevent Chrome from starting.
                    # These can remain after a crash or forced kill and will cause
                    # Chrome to either show a "profile in use" dialog (and block)
                    # or silently exit.
                    _cleanup_stale_locks(profile_dir)
                else:
                    # Profile dir not found → fall back to temp
                    self._temp_dir = tempfile.mkdtemp(prefix='browser-cdp-')
                    self._user_data_dir = self._temp_dir

        elif user_data_dir:
            self._user_data_dir = user_data_dir
            self._temp_dir = None
        else:
            # reuse_profile=False → isolated temp dir
            self._temp_dir = tempfile.mkdtemp(prefix='browser-cdp-')
            self._user_data_dir = self._temp_dir

        # --- Launch a new browser process --------------------------------
        return self._start_browser_process(
            browser=browser,
            headless=headless,
            port=port,
            executable_path=executable_path,
            extra_args=extra_args,
        )

    def _start_browser_process(
        self,
        browser: str,
        headless: bool,
        port: int | None,
        executable_path: str | None,
        extra_args: list | None,
    ) -> str:
        """Internal: start a fresh browser process with CDP."""
        # Resolve executable
        if executable_path:
            exe = executable_path
        else:
            exe = detect_browser(browser)
            if not exe:
                available = detect_any_browser()
                hint = f" (found {available[0]} at {available[1]})" if available else ""
                raise RuntimeError(
                    f"Browser '{browser}' not found on this system.{hint}\n"
                    f"Install it or specify executable_path manually."
                )

        # Resolve port
        if port is None:
            import random
            # Use random high port to prevent predictable scanning
            random_start = random.randint(49152, 60000)
            self._cdp_port = find_available_port(start=random_start)
        else:
            if not is_port_available(port):
                raise RuntimeError(f"Port {port} is already in use")
            self._cdp_port = port

        # Browser-specific flags
        browser_key = _normalize_browser_name(browser)

        # Build launch arguments
        args = [
            exe,
            f'--remote-debugging-port={self._cdp_port}',
        ]

        # QQ浏览器共享用户 profile，不需要 --user-data-dir 和 --disable-extensions；
        # 卡死问题的根因是 stdout PIPE 缓冲区满（已改为 DEVNULL）。
        if browser_key != 'qqbrowser':
            args.append(f'--user-data-dir={self._user_data_dir}')

        args.extend([
            '--no-first-run',
            '--no-default-browser-check',
            '--disable-sync',
            '--disable-background-networking',
            '--disable-component-update',
            '--disable-features=Translate,MediaRouter',
            '--disable-session-crashed-bubble',
            '--hide-crash-restore-bubble',
        ])

        # Allow CDP WebSocket connections only from localhost origins.
        # SECURITY: Never use '*' — it allows any webpage's JS to connect.
        if browser_key in ('edge', 'chrome'):
            args.append('--remote-allow-origins=http://127.0.0.1')

        # When restarting the user's browser, restore previous session
        if self._temp_dir is None:
            args.append('--restore-last-session')

        if headless:
            args.extend(['--headless=new', '--disable-gpu'])

        if SYSTEM == 'Linux':
            args.append('--disable-dev-shm-usage')

        if extra_args:
            args.extend(extra_args)

        # Only add about:blank for isolated mode (don't override restore)
        if self._temp_dir is not None:
            args.append('about:blank')

        # Launch process
        # NOTE: stdout/stderr must NOT be PIPE — QQ浏览器等会输出大量日志,
        # 管道缓冲区满后 write() 阻塞会导致整个浏览器进程（含 CDP handler）卡死。
        popen_kwargs: dict = dict(
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        if SYSTEM == 'Windows':
            # CREATE_NEW_PROCESS_GROUP: 避免 Ctrl+C 信号传递到浏览器
            # CREATE_NO_WINDOW: 避免闪一个黑色控制台窗口
            CREATE_NO_WINDOW = 0x08000000
            CREATE_NEW_PROCESS_GROUP = 0x00000200
            popen_kwargs['creationflags'] = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
        else:
            # macOS / Linux: start_new_session=True 让浏览器进程脱离父进程的
            # 进程组和会话，这样父 Python 进程退出（或被 kill）时，浏览器不会
            # 收到 SIGHUP 信号而意外关闭。
            popen_kwargs['start_new_session'] = True

        print(f"   启动命令: {args[0]}")
        print(f"   CDP 端口: {self._cdp_port}")
        if browser_key == 'qqbrowser':
            print(f"   Profile:  浏览器内置用户 profile（含用户登录态和 Cookie，非临时 profile）")
        elif self._temp_dir:
            print(f"   Profile:  {self._user_data_dir}（临时隔离 profile，无用户登录态）")
        else:
            print(f"   Profile:  {self._user_data_dir}（用户真实 profile，含登录态和 Cookie）")

        self._process = subprocess.Popen(args, **popen_kwargs)

        # Wait for CDP to be ready — but also watch for early process exit
        try:
            self._wait_for_cdp_ready_with_process_check(self._cdp_port)
        except RuntimeError:
            self.stop()
            raise

        # QQ浏览器共享用户 profile（无 --user-data-dir），语义上等同于
        # 连接已有浏览器：stop() 时只断开，不 kill 进程。
        if browser_key == 'qqbrowser':
            self._attached = True

        return f'http://127.0.0.1:{self._cdp_port}'

    def _cold_start_chrome_guide_mode(
        self,
        exe_path: str | None,
        wait_for_user: bool,
        user_allow_timeout: float,
        on_progress: 'callable | None',
    ) -> str:
        """Cold-start Chrome ≥ 144 using Guide mode.

        Chrome 136+ with ``--remote-debugging-port`` on a real user profile
        exposes only a **WS-only** CDP endpoint (no HTTP ``/json/version``).
        This makes ``_wait_for_cdp_ready_with_process_check()`` time out
        because it relies on HTTP probes.

        **Correct approach for Chrome ≥ 144 cold start:**

        1. Launch Chrome **normally** (without ``--remote-debugging-port``),
           opening ``chrome://inspect/#remote-debugging`` as the initial page.
           This ensures Chrome starts with the user's real profile (cookies,
           login sessions) and the inspect page is ready.
        2. Walk the user through the Guide flow: toggle the switch and click
           "Allow" on the auth dialog.
        3. Once CDP is available, start a CDP Proxy for session reuse.

        This is essentially the same as the "browser running without CDP"
        Guide flow, except we also launch the browser first.
        """
        pretty = 'Google Chrome'
        inspect_url = _get_inspect_url('chrome')

        if not exe_path:
            exe_path = detect_browser('chrome')
        if not exe_path:
            available = detect_any_browser()
            hint = f" (found {available[0]} at {available[1]})" if available else ""
            raise RuntimeError(
                f"Browser 'chrome' not found on this system.{hint}\n"
                f"Install it or specify executable_path manually."
            )

        # --- Launch Chrome without --remote-debugging-port ---
        # We start Chrome without any URL argument first, then use
        # _open_inspect_page() to navigate to the inspect page.
        #
        # Why not pass the URL as a command-line argument?
        #   - macOS: `[exe_path, url]` launches Chrome but the URL may
        #     be ignored if Chrome restores previous session or opens
        #     the default homepage.  The reliable approach on macOS is
        #     AppleScript (`tell application ... to open location ...`),
        #     which is exactly what _open_inspect_page() uses.
        #   - Windows: `[exe_path, url]` works, but splitting the logic
        #     into "launch" + "open page" keeps the code consistent
        #     across platforms.
        #
        # On Windows, passing the URL as a CLI argument opens it in the SAME
        # window — crucial to avoid spawning a second blank window.
        # On macOS we use _open_inspect_page() via AppleScript instead.
        args = [exe_path, inspect_url]

        popen_kwargs: dict = dict(
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        if SYSTEM == 'Windows':
            CREATE_NO_WINDOW = 0x08000000
            CREATE_NEW_PROCESS_GROUP = 0x00000200
            popen_kwargs['creationflags'] = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
        else:
            popen_kwargs['start_new_session'] = True

        profile_dir = get_default_profile_dir('chrome')
        print(f"   启动命令: {exe_path} {inspect_url}")
        print(f"   Profile:  {profile_dir or '(默认)'}（用户真实 profile）")
        print(f"   模式:     Guide（不使用 --remote-debugging-port，由用户在 inspect 页面开启）")

        try:
            subprocess.Popen(args, **popen_kwargs)
        except (OSError, FileNotFoundError) as e:
            raise RuntimeError(f"无法启动 Chrome: {e}")

        # Give Chrome time to initialize its main window.
        # On macOS: still need _open_inspect_page (AppleScript → existing window).
        # On Windows/Linux: URL is already in the first window via CLI arg.
        time.sleep(2.0)

        if SYSTEM == 'Darwin':
            opened = _open_inspect_page('chrome')
            if opened:
                print(f"   ✅ 已自动打开 {inspect_url}")
            else:
                print(f"   ⚠️  无法自动打开 inspect 页面，请手动访问: {inspect_url}")
        else:
            print(f"   ✅ 已打开 {inspect_url}")

        # --- Now walk through Guide mode ---
        if not wait_for_user:
            raise BrowserNeedsCDPError(
                browser=pretty,
                browser_key='chrome',
                inspect_url=inspect_url,
            )

        print(f"\n{'='*60}")
        print(f"🔗 已启动 {pretty}（冷启动），并打开了调试设置页面。")
        print(f"")
        print(f"请在 {pretty} 中：")
        print(f"  1. 确认已打开 {inspect_url}")
        print(f"  2. 勾选 \"Allow remote debugging for this browser instance\"")
        print(f"  3. 等待弹出 \"要允许远程调试吗？\" 对话框")
        print(f"  4. 点击 \"允许\"")
        print(f"")
        print(f"⏳ 等待中（最多 {int(user_allow_timeout)} 秒）...")
        print(f"   💡 开关开启后会自动触发确认弹窗")
        print(f"   💡 首次允许后，后续脚本将通过 CDP Proxy 自动复用连接")
        print(f"{'='*60}\n")

        cdp_url, auth_sock = _wait_for_cdp_with_user_action(
            'chrome',
            timeout=user_allow_timeout,
            poll_interval=USER_ALLOW_POLL_INTERVAL_S,
            on_progress=on_progress,
        )

        if cdp_url:
            from urllib.parse import urlparse
            parsed = urlparse(cdp_url)
            chrome_port = parsed.port or DEFAULT_CDP_PORT
            self._attached = True
            self._user_data_dir = profile_dir
            print(f"✅ 已成功连接到 {pretty} 的 CDP 端口 {chrome_port}")

            # Start CDP Proxy for session reuse
            proxy_url = _start_proxy_for_port(chrome_port, auth_sock=auth_sock)
            if proxy_url:
                parsed = urlparse(proxy_url)
                self._cdp_port = parsed.port or chrome_port
                print(f"🔄 CDP Proxy 已启动（端口 {self._cdp_port}），后续脚本将自动复用此连接")
                return proxy_url
            else:
                self._cdp_port = chrome_port
                return cdp_url

        # User didn't enable in time
        raise BrowserNeedsCDPError(
            browser=pretty,
            browser_key='chrome',
            inspect_url=inspect_url,
            waited=True,
            timeout=user_allow_timeout,
        )

    def _wait_for_cdp_ready_with_process_check(
        self,
        port: int,
        timeout: float = CDP_READY_TIMEOUT_S,
        poll_interval: float = CDP_READY_POLL_INTERVAL_S,
    ) -> dict:
        """Like wait_for_cdp_ready but also checks if our process died early.

        On Windows, Chrome may exit immediately if:
        - Another Chrome instance already holds the profile lock
        - The executable path is wrong or has permission issues
        - Chrome crashes on startup

        By checking process.poll() in the loop, we can fail fast with a
        descriptive error instead of waiting the full 15 seconds.
        """
        url = f'http://127.0.0.1:{port}/json/version'
        deadline = time.time() + timeout

        while time.time() < deadline:
            # Check if the process exited prematurely
            if self._process is not None and self._process.poll() is not None:
                exit_code = self._process.returncode
                # On Windows, Chrome launcher process may exit quickly
                # because it hands off to a broker/main process.  Give
                # a short grace period and check if CDP came up anyway.
                time.sleep(1.0)
                if _is_port_listening(port):
                    try:
                        req = Request(url, headers={'Accept': 'application/json'})
                        with urlopen(req, timeout=3) as resp:
                            data = json.loads(resp.read().decode('utf-8'))
                            # CDP is up — the launcher process exited normally
                            # (this is expected on Windows)
                            self._process = None  # Don't try to kill it later
                            self._attached = True  # Treat as attached
                            return data
                    except (URLError, OSError, json.JSONDecodeError):
                        pass

                # CDP not up and process is dead — real failure
                profile_lock = os.path.join(self._user_data_dir or '', 'lockfile')
                singleton_lock = os.path.join(self._user_data_dir or '', 'SingletonLock')
                has_lock = os.path.exists(profile_lock) or os.path.exists(singleton_lock)

                hint = ""
                if has_lock:
                    hint = (
                        "\n\n可能原因: 浏览器 Profile 目录被另一个实例锁定。"
                        "\n请关闭所有浏览器实例后重试，或使用 reuse_profile=False。"
                    )

                raise RuntimeError(
                    f"浏览器进程启动后立即退出 (exit code: {exit_code})。"
                    f"\nCDP 端口 {port} 在 15 秒内未就绪。{hint}"
                )

            # Quick TCP check before HTTP (Windows urlopen timeout unreliable)
            if not _is_port_listening(port):
                time.sleep(poll_interval)
                continue

            # Try HTTP probe
            try:
                req = Request(url, headers={'Accept': 'application/json'})
                with urlopen(req, timeout=3) as resp:
                    return json.loads(resp.read().decode('utf-8'))
            except (URLError, OSError, json.JSONDecodeError):
                time.sleep(poll_interval)

        raise RuntimeError(
            f"Browser CDP did not become ready on port {port} within {timeout}s"
        )

    def stop(self):
        """Detach from the browser and clean up internal state.

        This method **never** kills the browser process — the browser
        remains open for the user to continue using.  Temporary profile
        directories are also kept alive because the browser may still be
        referencing them; they will be cleaned up on the next launch or
        by the OS on reboot.
        """
        self._attached = False
        self._process = None
        self._cdp_port = None
        self._temp_dir = None

    @property
    def running(self) -> bool:
        if self._attached:
            # For attached mode, check if the CDP port is still responding.
            # Use HTTP-only probe to avoid triggering auth dialogs in
            # chrome://inspect WS-only mode.  If HTTP fails, fall back to
            # a simple TCP port check (good enough for liveness).
            if _try_probe_cdp_http_only(self._cdp_port) is not None:
                return True
            return _is_port_listening(self._cdp_port)
        return self._process is not None and self._process.poll() is None

    @property
    def attached(self) -> bool:
        """True if we connected to an existing browser (not our process)."""
        return self._attached

    @property
    def cdp_port(self) -> int | None:
        return self._cdp_port

    @property
    def cdp_url(self) -> str | None:
        if self._cdp_port:
            return f'http://127.0.0.1:{self._cdp_port}'
        return None


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description='Browser Launcher — detect, launch, and manage Chromium browsers with CDP'
    )
    sub = parser.add_subparsers(dest='command')

    # detect command
    detect_p = sub.add_parser('detect', help='Detect installed browsers')
    detect_p.add_argument('--browser', default=None, help='Specific browser to detect')

    # launch command
    launch_p = sub.add_parser('launch', help='Launch a browser with CDP')
    launch_p.add_argument('--browser', default='chrome', help='Browser type (chrome/edge/qqbrowser)')
    launch_p.add_argument('--headless', action='store_true', help='Run in headless mode')
    launch_p.add_argument('--port', type=int, default=None, help='CDP port')
    launch_p.add_argument('--executable-path', default=None, help='Browser executable path')
    launch_p.add_argument('--keep', action='store_true', help='Keep browser running (wait for Ctrl+C)')
    launch_p.add_argument(
        '--timeout', type=int, default=120,
        help='Seconds to wait for user to allow debugging (default 120)',
    )
    profile_group = launch_p.add_mutually_exclusive_group()
    profile_group.add_argument(
        '--reuse-profile', action='store_true', default=True,
        help='Reuse user\'s real browser profile for cookies & login (default)',
    )
    profile_group.add_argument(
        '--isolated', action='store_true', default=False,
        help='Use a temporary isolated profile (no cookies/login)',
    )

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(0)

    if args.command == 'detect':
        if args.browser:
            path = detect_browser(args.browser)
            if path:
                print(f"✅ {args.browser}: {path}")
            else:
                print(f"❌ {args.browser}: not found")
                sys.exit(1)
        else:
            print("Detecting installed browsers...")
            for name in ('chrome', 'edge', 'qqbrowser'):
                path = detect_browser(name)
                status = f"✅ {path}" if path else "❌ not found"
                label = {'chrome': 'Chrome', 'edge': 'Edge', 'qqbrowser': 'QQ浏览器'}[name]
                print(f"  {label}: {status}")

    elif args.command == 'launch':
        launcher = BrowserLauncher()
        try:
            cdp_url = launcher.launch(
                browser=args.browser,
                headless=args.headless,
                port=args.port,
                reuse_profile=not args.isolated,
                executable_path=args.executable_path,
                user_allow_timeout=args.timeout,
            )

            if launcher.attached:
                print(f"✅ Connected to existing browser. CDP endpoint: {cdp_url}")
                print(f"   Mode: attached (browser was already running with CDP)")
            else:
                print(f"✅ Browser launched. CDP endpoint: {cdp_url}")

            print(f"   Port: {launcher.cdp_port}")
            if launcher._temp_dir:
                print("   Profile: isolated (temp)")
            else:
                print(f"   Profile: {launcher._user_data_dir or '(unknown)'}")

            if args.keep:
                print("Press Ctrl+C to stop...")
                try:
                    while launcher.running:
                        time.sleep(1)
                except KeyboardInterrupt:
                    print("\nStopping...")
            else:
                print(f"CDP URL: {cdp_url}")
        except BrowserNeedsCDPError as e:
            print(f"⚠️  {e}", file=sys.stderr)
            sys.exit(2)
        except BrowserRunningError as e:
            print(f"⚠️  {e}", file=sys.stderr)
            sys.exit(2)
        except Exception as e:
            print(f"❌ Launch failed: {e}", file=sys.stderr)
            sys.exit(1)
        finally:
            if args.keep:
                launcher.stop()
                if launcher.attached:
                    print("Detached from browser.")
                else:
                    print("Browser stopped.")


if __name__ == '__main__':
    main()


path: scripts/cdp_client.py
content: #!/usr/bin/env python3
"""
CDP WebSocket Client — Chrome DevTools Protocol 通信核心层。
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import _encoding_fix  # noqa: F401

import json, threading, time, argparse
from urllib.request import urlopen, Request
from urllib.error import URLError

import importlib.util
if importlib.util.find_spec('websockets') is None:
    print("❌ 缺少依赖: websockets")
    print("   请先安装: pip install websockets")
    sys.exit(1)
import websockets
from websockets.sync.client import connect as ws_connect
HAS_WEBSOCKETS = True

CDP_WS_HANDSHAKE_TIMEOUT_S = 5.0
CDP_SEND_TIMEOUT_S = 30.0
CDP_HTTP_TIMEOUT_S = 5.0
CDP_CONNECT_RETRY_COUNT = 3
CDP_CONNECT_RETRY_DELAY_S = 0.5


def _http_get_json(url, timeout=CDP_HTTP_TIMEOUT_S):
    req = Request(url, headers={'Accept': 'application/json'})
    with urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode('utf-8'))


def _http_put_json(url, timeout=CDP_HTTP_TIMEOUT_S):
    req = Request(url, method='PUT', headers={'Accept': 'application/json'})
    with urlopen(req, timeout=timeout) as resp:
        body = resp.read().decode('utf-8')
        return json.loads(body) if body.strip() else {}


class CDPClient:
    def __init__(self, cdp_url):
        self.cdp_url = cdp_url.rstrip('/')
        self._ws = None; self._msg_id = 0; self._pending = {}
        self._lock = threading.Lock(); self._recv_thread = None
        self._closed = False; self._events = []; self._event_lock = threading.Lock()
        self._session_id = None; self._ws_only = False

    def connect(self):
        if not HAS_WEBSOCKETS: raise RuntimeError("Missing 'websockets'. Install: pip install websockets")
        ws_url = None
        try:
            version_info = self._get_version()
            ws_url = version_info.get('webSocketDebuggerUrl')
            if version_info.get('_proxy'): self._ws_only = False
        except Exception: pass
        if not ws_url:
            self._ws_only = True
            ws_url = self._connect_via_proxy()
        last_err = None
        for attempt in range(CDP_CONNECT_RETRY_COUNT):
            try:
                self._ws = ws_connect(ws_url, open_timeout=CDP_WS_HANDSHAKE_TIMEOUT_S, max_size=64*1024*1024)
                break
            except Exception as e:
                last_err = e
                if attempt < CDP_CONNECT_RETRY_COUNT - 1: time.sleep(CDP_CONNECT_RETRY_DELAY_S * (attempt + 1))
        if self._ws is None:
            raise RuntimeError(f"Failed to connect to CDP WebSocket after {CDP_CONNECT_RETRY_COUNT} attempts: {last_err}")
        self._closed = False
        self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True, name='cdp-recv')
        self._recv_thread.start()

    def _connect_via_proxy(self):
        from urllib.parse import urlparse
        parsed = urlparse(self.cdp_url)
        chrome_port = parsed.port or 9222
        try:
            from cdp_proxy import ensure_proxy_running, get_proxy_state
            proxy_url = ensure_proxy_running(chrome_port=chrome_port)
            self.cdp_url = proxy_url; self._ws_only = False
            proxy_parsed = urlparse(proxy_url)
            proxy_host = proxy_parsed.hostname or '127.0.0.1'
            proxy_port = proxy_parsed.port or 9223
            ws_url = f'ws://{proxy_host}:{proxy_port}/devtools/browser'
            state = get_proxy_state()
            if state and state.get('auth_token'): ws_url += f'?[REDACTED_SECRET]"auth_token"]}'
            return ws_url
        except Exception:
            host = parsed.hostname or '127.0.0.1'; port = parsed.port or 9222
            return f'ws://{host}:{port}/devtools/browser'

    def close(self):
        self._closed = True
        if self._ws:
            try: self._ws.close()
            except Exception: pass
            self._ws = None

    @property
    def connected(self): return self._ws is not None and not self._closed

    def send(self, method, params=None, timeout=CDP_SEND_TIMEOUT_S):
        if not self.connected: raise RuntimeError("Not connected to CDP")
        with self._lock: self._msg_id += 1; msg_id = self._msg_id
        msg = {'id': msg_id, 'method': method}
        if params: msg['params'] = params
        if self._session_id: msg['sessionId'] = self._session_id
        event = threading.Event(); holder = {'result': None, 'error': None}
        self._pending[msg_id] = (event, holder)
        self._ws.send(json.dumps(msg))
        if not event.wait(timeout=timeout):
            self._pending.pop(msg_id, None)
            raise RuntimeError(f"CDP command '{method}' timed out after {timeout}s (id={msg_id})")
        self._pending.pop(msg_id, None)
        if holder['error']:
            err = holder['error']
            raise RuntimeError(f"CDP error for '{method}': [{err.get('code')}] {err.get('message')}")
        return holder['result'] or {}

    def list_tabs(self):
        if not self._ws_only:
            try:
                targets = _http_get_json(f"{self.cdp_url}/json/list")
                return [{'id': t.get('id',''), 'type': t.get('type',''), 'title': t.get('title',''),
                         'url': t.get('url',''), 'webSocketDebuggerUrl': t.get('webSocketDebuggerUrl','')}
                        for t in targets if t.get('type') == 'page']
            except Exception: pass
        if not self.connected: raise RuntimeError("Not connected — call connect() first")
        old = self._session_id; self._session_id = None
        try: result = self.send('Target.getTargets')
        finally: self._session_id = old
        return [{'id': t.get('targetId',''), 'type': t.get('type',''), 'title': t.get('title',''),
                 'url': t.get('url',''), 'webSocketDebuggerUrl': ''}
                for t in result.get('targetInfos',[]) if t.get('type') == 'page']

    def create_tab(self, url='about:blank'):
        if not self._ws_only:
            try:
                from urllib.parse import quote
                endpoint = f"{self.cdp_url}/json/new?{quote(url, safe='')}"
                try: target = _http_put_json(endpoint)
                except Exception: target = _http_get_json(endpoint)
                return {'id': target.get('id',''), 'type': target.get('type',''),
                        'title': target.get('title',''), 'url': target.get('url',''),
                        'webSocketDebuggerUrl': target.get('webSocketDebuggerUrl','')}
            except Exception: pass
        if not self.connected: raise RuntimeError("Not connected — call connect() first")
        old = self._session_id; self._session_id = None
        try: result = self.send('Target.createTarget', {'url': url})
        finally: self._session_id = old
        tid = result.get('targetId', '')
        return {'id': tid, 'type': 'page', 'title': '', 'url': url, 'webSocketDebuggerUrl': ''}

    def close_tab(self, target_id):
        if not self._ws_only:
            try:
                req = Request(f"{self.cdp_url}/json/close/{target_id}", headers={'Accept': 'application/json'})
                with urlopen(req, timeout=CDP_HTTP_TIMEOUT_S) as resp: resp.read()
                return
            except Exception: pass
        if not self.connected: raise RuntimeError("Not connected — call connect() first")
        old = self._session_id; self._session_id = None
        try: self.send('Target.closeTarget', {'targetId': target_id})
        finally: self._session_id = old

    def activate_tab(self, target_id):
        if not self._ws_only:
            try: _http_get_json(f"{self.cdp_url}/json/activate/{target_id}"); return
            except Exception: pass
        if not self.connected: raise RuntimeError("Not connected — call connect() first")
        old = self._session_id; self._session_id = None
        try: self.send('Target.activateTarget', {'targetId': target_id})
        finally: self._session_id = old

    def attach(self, target_id):
        result = self.send('Target.attachToTarget', {'targetId': target_id, 'flatten': True})
        self._session_id = result.get('sessionId')
        if not self._session_id: raise RuntimeError(f"Failed to attach to target {target_id}: no sessionId")

    def detach(self):
        if self._session_id:
            try:
                old = self._session_id; self._session_id = None
                self.send('Target.detachFromTarget', {'sessionId': old})
            except Exception: pass
            self._session_id = None

    def get_events(self, method=None, clear=True):
        with self._event_lock:
            if method:
                matched = [e for e in self._events if e.get('method') == method]
                if clear: self._events = [e for e in self._events if e.get('method') != method]
            else:
                matched = list(self._events)
                if clear: self._events.clear()
        return matched

    def wait_for_event(self, method, timeout=30.0):
        deadline = time.time() + timeout
        while time.time() < deadline:
            events = self.get_events(method, clear=True)
            if events: return events[0]
            time.sleep(0.1)
        raise RuntimeError(f"Timeout waiting for CDP event '{method}' after {timeout}s")

    def get_version(self): return self._get_version()
    def _get_version(self): return _http_get_json(f"{self.cdp_url}/json/version")

    def _recv_loop(self):
        while not self._closed and self._ws:
            try: raw = self._ws.recv(timeout=1.0)
            except TimeoutError: continue
            except Exception as e:
                if not self._closed:
                    cls_name = type(e).__name__
                    if 'ConnectionClosed' not in cls_name:
                        import traceback
                        print(f"[cdp-client] _recv_loop error: {cls_name}: {e}", file=__import__('sys').stderr, flush=True)
                self._closed = True
                break
            try: msg = json.loads(raw)
            except json.JSONDecodeError: continue
            if 'id' in msg:
                pending = self._pending.get(msg['id'])
                if pending:
                    event, holder = pending
                    if 'error' in msg: holder['error'] = msg['error']
                    else: holder['result'] = msg.get('result')
                    event.set()
            elif 'method' in msg:
                with self._event_lock:
                    self._events.append(msg)
                    if len(self._events) > 1000: self._events = self._events[-500:]


def main():
    parser = argparse.ArgumentParser(description='CDP Client')
    parser.add_argument('--cdp-url', default='http://localhost:9222')
    sub = parser.add_subparsers(dest='command')
    sub.add_parser('version', help='Show browser version')
    sub.add_parser('tabs', help='List open tabs')
    open_p = sub.add_parser('open', help='Open a new tab')
    open_p.add_argument('url')
    eval_p = sub.add_parser('eval', help='Evaluate JS')
    eval_p.add_argument('expression')
    args = parser.parse_args()
    if not args.command: parser.print_help(); sys.exit(0)
    client = CDPClient(args.cdp_url)
    if args.command == 'version': print(json.dumps(client.get_version(), indent=2, ensure_ascii=False))
    elif args.command == 'tabs':
        tabs = client.list_tabs()
        for i, t in enumerate(tabs): print(f"  [{i}] {t['title']} — {t['url']}")
        if not tabs: print("  (no tabs)")
    elif args.command == 'open':
        tab = client.create_tab(args.url); print(f"Opened: {tab['id']}")
    elif args.command == 'eval':
        client.connect(); tabs = client.list_tabs()
        if not tabs: print("No tabs"); sys.exit(1)
        client.attach(tabs[0]['id'])
        result = client.send('Runtime.evaluate', {'expression': args.expression, 'returnByValue': True})
        val = result.get('result', {}).get('value')
        print(json.dumps(val, indent=2, ensure_ascii=False) if val is not None else 'undefined')
        client.close()


if __name__ == '__main__': main()


path: scripts/cdp_proxy.py
content: #!/usr/bin/env python3
"""
CDP Proxy — 常驻进程，复用单一 WebSocket 连接到 Chrome。

**解决的核心问题**：Chrome 136+ 的 ``chrome://inspect`` WS-only 模式下，
每个新的 WebSocket 连接都会触发一次 "要允许远程调试吗？" 弹窗。
CDP Proxy 保持**唯一一个** WS 连接到 Chrome，并向下游脚本暴露
标准的 HTTP + WS CDP 接口，从而让用户只需点击**一次** "允许"。
"""
from __future__ import annotations

import os, sys, argparse, atexit, base64, hashlib, json, secrets, signal, socket
import struct, tempfile, threading, time, getpass
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen, Request
from urllib.error import URLError

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _encoding_fix  # noqa: F401

DEFAULT_CHROME_PORT = 9222
DEFAULT_PROXY_PORT  = 9223

PROXY_STATE_DIR  = os.path.join(tempfile.gettempdir(), f'cdp-proxy-{getpass.getuser()}')
PROXY_PID_FILE   = os.path.join(PROXY_STATE_DIR, 'proxy.pid')
PROXY_STATE_FILE = os.path.join(PROXY_STATE_DIR, 'proxy.json')
RECONNECT_INTERVAL_S  = 2.0
RECONNECT_MAX_RETRIES = 30

WS_FIN_TEXT  = 0x81
WS_FIN_CLOSE = 0x88
WS_FIN_PING  = 0x89
WS_FIN_PONG  = 0x8A


def _log(msg: str):
    ts = time.strftime('%H:%M:%S')
    print(f"[cdp-proxy {ts}] {msg}", file=sys.stderr, flush=True)


def _ws_handshake_request(host: str, port: int, path: str):
    ws_key = base64.b64encode(os.urandom(16)).decode()
    req = (
        f'GET {path} HTTP/1.1\r\n'
        f'Host: {host}:{port}\r\n'
        'Upgrade: websocket\r\n'
        'Connection: Upgrade\r\n'
        f'Sec-WebSocket-Key: {ws_key}\r\n'
        'Sec-WebSocket-Version: 13\r\n'
        '\r\n'
    )
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(120)
    sock.connect((host, port))
    sock.sendall(req.encode())
    buf = b''
    while b'\r\n\r\n' not in buf:
        chunk = sock.recv(4096)
        if not chunk:
            sock.close()
            raise ConnectionError("Connection closed during handshake")
        buf += chunk
    header_text = buf.split(b'\r\n\r\n')[0].decode('utf-8', errors='replace')
    if '101' not in header_text:
        sock.close()
        raise ConnectionError(f"WS handshake failed: {header_text[:200]}")
    remainder = buf.split(b'\r\n\r\n', 1)[1]
    return sock, remainder


def _ws_send_frame(sock, payload, opcode=0x01, masked=True):
    frame = bytearray()
    frame.append(0x80 | opcode)
    mask_bit = 0x80 if masked else 0x00
    length = len(payload)
    if length < 126:
        frame.append(mask_bit | length)
    elif length < 65536:
        frame.append(mask_bit | 126)
        frame.extend(struct.pack('>H', length))
    else:
        frame.append(mask_bit | 127)
        frame.extend(struct.pack('>Q', length))
    if masked:
        mask_key = os.urandom(4)
        frame.extend(mask_key)
        frame.extend(bytearray(b ^ mask_key[i % 4] for i, b in enumerate(payload)))
    else:
        frame.extend(payload)
    sock.sendall(bytes(frame))


def _ws_recv_frame(sock, buf):
    def _ensure(n):
        nonlocal buf
        while len(buf) < n:
            chunk = sock.recv(65536)
            if not chunk:
                raise ConnectionError("WS connection closed")
            buf.extend(chunk)
    _ensure(2)
    b0, b1 = buf[0], buf[1]
    opcode  = b0 & 0x0F
    is_masked = (b1 & 0x80) != 0
    length  = b1 & 0x7F
    offset  = 2
    if length == 126:
        _ensure(offset + 2)
        length = struct.unpack('>H', bytes(buf[offset:offset+2]))[0]; offset += 2
    elif length == 127:
        _ensure(offset + 8)
        length = struct.unpack('>Q', bytes(buf[offset:offset+8]))[0]; offset += 8
    if is_masked:
        _ensure(offset + 4)
        mask = bytes(buf[offset:offset+4]); offset += 4
    _ensure(offset + length)
    data = bytes(buf[offset:offset+length])
    if is_masked:
        data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
    remaining = bytearray(buf[offset+length:])
    return opcode, data, remaining


class ChromeUpstream:
    def __init__(self, host, port):
        self.host = host; self.port = port
        self._sock = None; self._send_lock = threading.Lock()
        self._state_lock = threading.Lock(); self._recv_buf = bytearray()
        self._closed = False; self._recv_thread = None
        self._on_message = None; self._on_disconnect = None
        self._version_info = None
        self._pending_lock = threading.Lock(); self._pending = {}

    def connect(self, timeout=120.0):
        with self._state_lock:
            if self._sock:
                try: self._sock.close()
                except OSError: pass
                self._sock = None
        try:
            sock, remainder = _ws_handshake_request(self.host, self.port, '/devtools/browser')
            with self._state_lock:
                self._sock = sock; self._recv_buf = bytearray(remainder); self._closed = False
            self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True, name='chrome-upstream-recv')
            self._recv_thread.start()
            self._fetch_version_info()
            return True
        except (OSError, ConnectionError) as e:
            _log(f"Failed to connect to Chrome at {self.host}:{self.port}: {e}")
            return False

    def adopt_socket(self, sock):
        with self._state_lock:
            if self._sock:
                try: self._sock.close()
                except OSError: pass
            self._sock = sock; self._recv_buf = bytearray(); self._closed = False
        self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True, name='chrome-upstream-recv')
        self._recv_thread.start()
        self._fetch_version_info()
        return self._version_info is not None

    def _fetch_version_info(self):
        try:
            result = self.send_and_wait({'id': -1, 'method': 'Browser.getVersion'}, timeout=5.0)
            if result and 'result' in result:
                self._version_info = result['result']
        except Exception: pass

    @property
    def connected(self): return self._sock is not None and not self._closed
    @property
    def version_info(self): return self._version_info or {}

    def send(self, data):
        with self._send_lock:
            with self._state_lock:
                sock = self._sock; closed = self._closed
            if not sock or closed: raise ConnectionError("Not connected to Chrome")
            _ws_send_frame(sock, data, opcode=0x01, masked=True)

    def send_and_wait(self, msg, timeout=30.0):
        msg_id = msg.get('id', -1)
        event = threading.Event(); result_holder = [None]
        with self._pending_lock: self._pending[msg_id] = (event, result_holder)
        try:
            self.send(json.dumps(msg).encode())
            event.wait(timeout=timeout)
            return result_holder[0]
        finally:
            with self._pending_lock: self._pending.pop(msg_id, None)

    def set_on_message(self, cb): self._on_message = cb
    def set_on_disconnect(self, cb): self._on_disconnect = cb

    def close(self):
        self._closed = True
        with self._state_lock:
            if self._sock:
                try: self._sock.close()
                except OSError: pass
                self._sock = None

    def _dispatch_message(self, text):
        try: data = json.loads(text)
        except: self._on_message and self._on_message(text); return
        msg_id = data.get('id')
        if msg_id is not None:
            with self._pending_lock:
                pending = self._pending.get(msg_id)
            if pending:
                event, result_holder = pending
                result_holder[0] = data; event.set(); return
        if self._on_message: self._on_message(text)

    def _recv_loop(self):
        while not self._closed:
            try:
                with self._state_lock: sock = self._sock; buf = self._recv_buf
                if not sock: break
                sock.settimeout(1.0)
                try: chunk = sock.recv(65536)
                except socket.timeout: continue
                if not chunk: break
                buf.extend(chunk)
                while True:
                    try:
                        opcode, payload, remaining = _ws_recv_frame(sock, buf)
                        with self._state_lock: self._recv_buf = remaining
                        buf = remaining
                        if opcode == 0x01:
                            text = payload.decode('utf-8', errors='replace')
                            try: self._dispatch_message(text)
                            except Exception: pass
                        elif opcode == 0x08:
                            self._closed = True; return
                        elif opcode == 0x09:
                            with self._send_lock:
                                with self._state_lock: s = self._sock
                                if s: _ws_send_frame(s, payload, opcode=0x0A, masked=True)
                    except (ConnectionError, struct.error): break
                    except (TimeoutError, socket.timeout): break
            except (OSError, ConnectionError): break
        self._closed = True
        if self._on_disconnect:
            try: self._on_disconnect()
            except Exception: pass


class DownstreamClient:
    def __init__(self, sock, addr, client_id):
        self.sock = sock; self.addr = addr; self.client_id = client_id
        self._recv_buf = bytearray(); self._closed = False; self._lock = threading.Lock()

    def send_text(self, text):
        with self._lock:
            if self._closed: return
            try: _ws_send_frame(self.sock, text.encode(), opcode=0x01, masked=False)
            except (OSError, BrokenPipeError): self._closed = True

    def close(self):
        self._closed = True
        try: self.sock.close()
        except OSError: pass


class CDPProxy:
    def __init__(self, chrome_host='127.0.0.1', chrome_port=DEFAULT_CHROME_PORT, proxy_port=DEFAULT_PROXY_PORT):
        self._auth_token = secrets.token_urlsafe(32)
        self.chrome_host = chrome_host; self.chrome_port = chrome_port; self.proxy_port = proxy_port
        self._upstream = ChromeUpstream(chrome_host, chrome_port)
        self._downstream_clients = {}; self._client_counter = 0; self._lock = threading.Lock(); self._running = False
        self._id_offset = 0; self._id_map = {}
        self._session_map = {}; self._http_server = None
        self._disconnect_timer = None; self._disconnect_timeout_s = 60

    def start(self, block=True, auth_sock=None):
        _log(f"Starting CDP Proxy: Chrome={self.chrome_host}:{self.chrome_port}, Proxy=127.0.0.1:{self.proxy_port}")
        self._running = True
        if not self._connect_upstream(auth_sock=auth_sock):
            _log("Failed to connect to Chrome. Will retry in background.")
        ws_thread = threading.Thread(target=self._ws_accept_loop, daemon=True, name='proxy-ws-accept')
        ws_thread.start()
        self._write_state()
        _log(f"CDP Proxy ready on port {self.proxy_port}")
        if block:
            try:
                while self._running: time.sleep(1)
            except KeyboardInterrupt:
                _log("Interrupted, stopping...")
            finally: self.stop()

    def stop(self):
        self._running = False
        self._cancel_disconnect_timer()
        with self._lock:
            for c in self._downstream_clients.values(): c.close()
            self._downstream_clients.clear()
        self._upstream.close()
        if self._http_server:
            try: self._http_server.shutdown()
            except Exception: pass
        self._cleanup_state()
        _log("CDP Proxy stopped.")

    def _connect_upstream(self, auth_sock=None):
        if auth_sock:
            if self._upstream.adopt_socket(auth_sock):
                self._upstream.set_on_message(self._on_upstream_message)
                self._upstream.set_on_disconnect(self._on_upstream_disconnect)
                self._cancel_disconnect_timer()
                _log("Adopted authenticated socket to Chrome CDP (no new dialog).")
                return True
            else:
                _log("Failed to adopt authenticated socket, falling back to new connection.")
        for attempt in range(RECONNECT_MAX_RETRIES):
            if not self._running: return False
            if self._upstream.connect():
                self._upstream.set_on_message(self._on_upstream_message)
                self._upstream.set_on_disconnect(self._on_upstream_disconnect)
                self._cancel_disconnect_timer()
                _log("Connected to Chrome CDP.")
                return True
            if attempt < RECONNECT_MAX_RETRIES - 1: time.sleep(RECONNECT_INTERVAL_S)
        return False

    def _on_upstream_message(self, text):
        try: msg = json.loads(text)
        except: return
        if 'id' in msg:
            global_id = msg['id']
            with self._lock: mapping = self._id_map.pop(global_id, None)
            if mapping:
                client_id, original_id = mapping; msg['id'] = original_id
                with self._lock: client = self._downstream_clients.get(client_id)
                if client:
                    client.send_text(json.dumps(msg))
                    result = msg.get('result', {})
                    if 'sessionId' in result:
                        with self._lock: self._session_map[result['sessionId']] = client_id
        elif 'method' in msg:
            session_id = msg.get('sessionId')
            if session_id:
                with self._lock: client_id = self._session_map.get(session_id); client = self._downstream_clients.get(client_id) if client_id else None
                if client: client.send_text(text)
            else:
                with self._lock: clients = list(self._downstream_clients.values())
                for c in clients: c.send_text(text)

    def _ws_accept_loop(self):
        server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server_sock.settimeout(1.0)
        server_sock.bind(('127.0.0.1', self.proxy_port))
        server_sock.listen(16)
        while self._running:
            try: client_sock, addr = server_sock.accept()
            except socket.timeout: continue
            except OSError: break
            threading.Thread(target=self._handle_ws_upgrade, args=(client_sock, addr), daemon=True).start()
        server_sock.close()

    def _handle_ws_upgrade(self, sock, addr):
        client_id = -1
        try:
            sock.settimeout(10); buf = b''
            while b'\r\n\r\n' not in buf:
                chunk = sock.recv(4096)
                if not chunk: sock.close(); return
                buf += chunk
            header_text = buf.decode('utf-8', errors='replace')
            if 'upgrade: websocket' not in header_text.lower():
                self._handle_http_on_ws_port(sock, header_text); return
            for line in header_text.split('\r\n'):
                if line.lower().startswith('origin:'):
                    origin = line.split(':', 1)[1].strip().lower()
                    allowed = {'http://127.0.0.1', 'http://localhost', 'https://127.0.0.1', 'https://localhost'}
                    if origin and not any(origin.startswith(ao) for ao in allowed):
                        _log(f"Rejected WS from non-localhost origin: {origin}"); sock.close(); return
                    break
            first_line = header_text.split('\r\n')[0]
            request_path = first_line.split()[1] if len(first_line.split()) >= 2 else ''
            token_valid = False
            if '?token=' in request_path:
                from urllib.parse import urlparse, parse_qs
                parsed = urlparse(request_path)
                token_param = parse_qs(parsed.query).get('token', [None])[0]
                if token_param == self._auth_token: token_valid = True
            if not token_valid:
                _log(f"Rejected WS from {addr}: invalid or missing token")
                try: sock.sendall(b'HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
                except OSError: pass
                sock.close(); return
            ws_key = None
            for line in header_text.split('\r\n'):
                if line.lower().startswith('sec-websocket-key:'):
                    ws_key = line.split(':', 1)[1].strip(); break
            if not ws_key: sock.close(); return
            accept_key = base64.b64encode(
                hashlib.sha1((ws_key + [REDACTED_SECRET]).encode()).digest()
            ).decode()
            response = (f'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept_key}\r\n\r\n')
            sock.sendall(response.encode())
            with self._lock: self._client_counter += 1; client_id = self._client_counter
            client = DownstreamClient(sock, addr, client_id)
            with self._lock: self._downstream_clients[client_id] = client
            _log(f"Downstream client #{client_id} connected from {addr}")
            recv_buf = bytearray(buf.split(b'\r\n\r\n', 1)[1])
            sock.settimeout(1.0)
            while self._running and not client._closed:
                try: chunk = sock.recv(65536)
                except socket.timeout: continue
                except OSError: break
                if not chunk: break
                recv_buf.extend(chunk)
                while True:
                    try:
                        opcode, payload, recv_buf = _ws_recv_frame(sock, recv_buf)
                        if opcode == 0x01: self._forward_downstream_to_upstream(client_id, payload)
                        elif opcode == 0x08: break
                        elif opcode == 0x09: _ws_send_frame(sock, payload, opcode=0x0A, masked=False)
                    except (ConnectionError, struct.error): break
                    except (TimeoutError, socket.timeout): break
        except (OSError, ConnectionError): pass
        finally:
            with self._lock:
                removed = self._downstream_clients.pop(client_id, None)
                if removed:
                    to_remove = [sid for sid, cid in self._session_map.items() if cid == client_id]
                    for sid in to_remove: self._session_map.pop(sid, None)
            if removed: _log(f"Downstream client #{removed.client_id} disconnected")
            try: sock.close()
            except OSError: pass

    def _forward_downstream_to_upstream(self, client_id, payload):
        try: text = payload.decode('utf-8', errors='replace'); msg = json.loads(text)
        except: return
        if 'id' in msg:
            original_id = msg['id']
            with self._lock: self._id_offset += 1; global_id = self._id_offset; self._id_map[global_id] = (client_id, original_id)
            msg['id'] = global_id
        session_id = msg.get('sessionId')
        if session_id:
            with self._lock: self._session_map[session_id] = client_id
        try: self._upstream.send(json.dumps(msg).encode())
        except ConnectionError:
            _log("Upstream disconnected, attempting reconnect...")
            threading.Thread(target=self._reconnect_upstream, daemon=True).start()

    def _reconnect_upstream(self):
        if self._connect_upstream(): _log("Reconnected to Chrome.")
        else:
            _log("Failed to reconnect to Chrome after retries.")
            self._start_disconnect_timer()

    def _on_upstream_disconnect(self):
        if not self._running: return
        _log(f"Upstream WS disconnected. Will auto-exit in {self._disconnect_timeout_s}s unless reconnected.")
        self._start_disconnect_timer()

    def _start_disconnect_timer(self):
        self._cancel_disconnect_timer()
        timer = threading.Timer(self._disconnect_timeout_s, self._auto_exit_on_disconnect)
        timer.daemon = True; timer.name = 'proxy-disconnect-timer'
        self._disconnect_timer = timer; timer.start()

    def _cancel_disconnect_timer(self):
        t = self._disconnect_timer
        if t is not None: t.cancel(); self._disconnect_timer = None

    def _auto_exit_on_disconnect(self):
        if not self._running: return
        if self._upstream.connected:
            _log("Upstream reconnected before auto-exit timer fired. Staying alive."); return
        _log(f"Upstream WS disconnected for {self._disconnect_timeout_s}s. Auto-stopping proxy."); self.stop()

    def _handle_http_on_ws_port(self, sock, header_text):
        try:
            first_line = header_text.split('\r\n')[0]
            parts = first_line.split()
            if len(parts) < 2: sock.close(); return
            method, path = parts[0], parts[1]
            response_body = self._handle_json_endpoint(path)
            if response_body is not None:
                body_bytes = response_body.encode('utf-8')
                response = (f'HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {len(body_bytes)}\r\nConnection: close\r\n\r\n')
                sock.sendall(response.encode() + body_bytes)
            else:
                sock.sendall(b'HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
        except (OSError, BrokenPipeError): pass
        finally:
            try: sock.close()
            except OSError: pass

    def _handle_json_endpoint(self, path):
        if path == '/json/version':
            info = dict(self._upstream.version_info)
            info['webSocketDebuggerUrl'] = f'ws://127.0.0.1:{self.proxy_port}/devtools/browser?[REDACTED_SECRET]'
            info['_proxy'] = True; info['_proxy_port'] = self.proxy_port
            info['_chrome_port'] = self.chrome_port
            info['_upstream_connected'] = self._upstream.connected
            return json.dumps(info)
        elif path == '/json/list' or path == '/json':
            try:
                result = self._upstream.send_and_wait({'id': -2, 'method': 'Target.getTargets'}, timeout=5.0)
                if result and 'result' in result:
                    targets = result['result'].get('targetInfos', [])
                    tab_list = []
                    for t in targets:
                        if t.get('type') == 'page':
                            tid = t.get('targetId', '')
                            tab_list.append({
                                'id': tid, 'type': 'page', 'title': t.get('title', ''),
                                'url': t.get('url', ''),
                                'webSocketDebuggerUrl': f'ws://127.0.0.1:{self.proxy_port}/devtools/page/{tid}',
                            })
                    return json.dumps(tab_list)
            except Exception: pass
            return '[]'
        elif path.startswith('/json/new'):
            url = 'about:blank'
            if '?' in path:
                from urllib.parse import unquote; url = unquote(path.split('?', 1)[1])
            try:
                result = self._upstream.send_and_wait({'id': -3, 'method': 'Target.createTarget', 'params': {'url': url}}, timeout=10.0)
                if result and 'result' in result:
                    tid = result['result'].get('targetId', '')
                    return json.dumps({'id': tid, 'type': 'page', 'title': '', 'url': url,
                                       'webSocketDebuggerUrl': f'ws://127.0.0.1:{self.proxy_port}/devtools/page/{tid}'})
            except Exception: pass
            return None
        elif path.startswith('/json/close/'):
            tid = path.split('/json/close/', 1)[1]
            try:
                self._upstream.send_and_wait({'id': -4, 'method': 'Target.closeTarget', 'params': {'targetId': tid}}, timeout=5.0)
                return '"Target is closing"'
            except Exception: return None
        elif path.startswith('/json/activate/'):
            tid = path.split('/json/activate/', 1)[1]
            try:
                self._upstream.send_and_wait({'id': -5, 'method': 'Target.activateTarget', 'params': {'targetId': tid}}, timeout=5.0)
                return '"Target activated"'
            except Exception: return None
        return None

    def _write_state(self):
        os.makedirs(PROXY_STATE_DIR, exist_ok=True)
        os.chmod(PROXY_STATE_DIR, 0o700)
        with open(PROXY_PID_FILE, 'w') as f: f.write(str(os.getpid()))
        state = {'pid': os.getpid(), 'proxy_port': self.proxy_port,
                 'chrome_port': self.chrome_port, 'chrome_host': self.chrome_host,
                 'started_at': time.time(),
                 'proxy_url': f'http://127.0.0.1:{self.proxy_port}', 'auth_token': self._auth_token}
        with open(PROXY_STATE_FILE, 'w') as f: json.dump(state, f)

    def _cleanup_state(self):
        for f in (PROXY_PID_FILE, PROXY_STATE_FILE):
            try: os.remove(f)
            except OSError: pass


def get_proxy_state():
    if not os.path.isfile(PROXY_STATE_FILE): return None
    try:
        with open(PROXY_STATE_FILE, 'r') as f: state = json.load(f)
        pid = state.get('pid')
        if pid:
            try:
                os.kill(pid, 0)
                return state
            except (OSError, ProcessLookupError):
                _cleanup_stale_state(); return None
        return None
    except (json.JSONDecodeError, OSError):
        return None


def _cleanup_stale_state():
    for f in (PROXY_PID_FILE, PROXY_STATE_FILE):
        try: os.remove(f)
        except OSError: pass


def is_proxy_running(): return get_proxy_state() is not None

def get_proxy_url():
    state = get_proxy_state()
    return state.get('proxy_url') if state else None


def stop_proxy():
    state = get_proxy_state()
    if not state: _log("No proxy is running."); return False
    pid = state.get('pid')
    if pid:
        try:
            os.kill(pid, signal.SIGTERM)
            _log(f"Sent SIGTERM to proxy (PID {pid}).")
            for _ in range(10):
                try: os.kill(pid, 0); time.sleep(0.3)
                except (OSError, ProcessLookupError): break
            _cleanup_stale_state(); return True
        except (OSError, ProcessLookupError):
            _log(f"Proxy (PID {pid}) is not running. Cleaning up state.")
            _cleanup_stale_state(); return False
    return False


def _is_port_listening(port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1); return s.connect_ex(('127.0.0.1', port)) == 0


def ensure_proxy_running(chrome_port=DEFAULT_CHROME_PORT, proxy_port=DEFAULT_PROXY_PORT,
                          timeout=30.0, auth_sock=None):
    state = get_proxy_state()
    if state:
        existing_port = state.get('proxy_port')
        if existing_port and _is_port_listening(existing_port):
            upstream_ok = False
            try:
                req = Request(f'http://127.0.0.1:{existing_port}/json/version', headers={'Accept': 'application/json'})
                with urlopen(req, timeout=3) as resp:
                    info = json.loads(resp.read().decode('utf-8'))
                    upstream_ok = info.get('_upstream_connected', False)
            except Exception: pass
            if upstream_ok:
                if auth_sock:
                    try: auth_sock.close()
                    except OSError: pass
                return state.get('proxy_url')
            else:
                _log("Existing proxy has no upstream connection. Stopping it.")
                stop_proxy(); time.sleep(0.5)
        else:
            _cleanup_stale_state()

    if auth_sock:
        if sys.platform == 'win32':
            proxy = CDPProxy(chrome_port=chrome_port, proxy_port=proxy_port)
            proxy_thread = threading.Thread(target=proxy.start, kwargs={'block': True, 'auth_sock': auth_sock},
                                            daemon=True, name='cdp-proxy-inprocess')
            proxy_thread.start()
            proxy_url = f'http://127.0.0.1:{proxy_port}'
            deadline = time.time() + timeout
            while time.time() < deadline:
                if _is_port_listening(proxy_port): time.sleep(0.3); return proxy_url
                time.sleep(0.5)
            raise RuntimeError(f"In-process CDP Proxy did not become ready within {timeout}s")
        pid = os.fork()
        if pid == 0:
            try:
                os.setsid()
                os.makedirs(PROXY_STATE_DIR, exist_ok=True)
                log_path = os.path.join(PROXY_STATE_DIR, 'proxy.log')
                log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
                os.dup2(log_fd, 1); os.dup2(log_fd, 2); os.close(log_fd)
                devnull = os.open(os.devnull, os.O_RDONLY); os.dup2(devnull, 0); os.close(devnull)
                def _sig_handler(sig, frame): proxy.stop(); sys.exit(0)
                signal.signal(signal.SIGTERM, _sig_handler); signal.signal(signal.SIGINT, _sig_handler)
                proxy = CDPProxy(chrome_port=chrome_port, proxy_port=proxy_port)
                proxy.start(block=True, auth_sock=auth_sock)
            except Exception: pass
            finally: os._exit(0)
        else:
            try: auth_sock.close()
            except OSError: pass
            proxy_url = f'http://127.0.0.1:{proxy_port}'
            deadline = time.time() + timeout
            while time.time() < deadline:
                if _is_port_listening(proxy_port): time.sleep(0.3); return proxy_url
                try:
                    wpid, status = os.waitpid(pid, os.WNOHANG)
                    if wpid != 0: raise RuntimeError(f"CDP Proxy child (PID {pid}) exited with status {status}")
                except ChildProcessError: break
                time.sleep(0.5)
            raise RuntimeError(f"Forked CDP Proxy did not become ready within {timeout}s")

    script_path = os.path.abspath(__file__)
    cmd = [sys.executable, script_path, 'start', '--chrome-port', str(chrome_port), '--proxy-port', str(proxy_port), '--daemon']
    import subprocess
    log_path = os.path.join(PROXY_STATE_DIR, 'proxy.log')
    os.makedirs(PROXY_STATE_DIR, exist_ok=True)
    log_file = open(log_path, 'a')
    proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=log_file, start_new_session=True)
    proxy_url = f'http://127.0.0.1:{proxy_port}'
    deadline = time.time() + timeout
    while time.time() < deadline:
        if _is_port_listening(proxy_port): time.sleep(0.3); return proxy_url
        if proc.poll() is not None:
            try:
                log_file.flush()
                with open(log_path, 'r') as lf: log_content = lf.read()[-500:]
            except Exception: log_content = ''
            raise RuntimeError(f"CDP Proxy failed to start (exit code {proc.returncode}): {log_content}")
        time.sleep(0.5)
    raise RuntimeError(f"CDP Proxy did not become ready within {timeout}s")


def main():
    parser = argparse.ArgumentParser(description='CDP Proxy')
    sub = parser.add_subparsers(dest='command')
    start_p = sub.add_parser('start', help='Start proxy')
    start_p.add_argument('--chrome-port', type=int, default=DEFAULT_CHROME_PORT)
    start_p.add_argument('--proxy-port', type=int, default=DEFAULT_PROXY_PORT)
    start_p.add_argument('--daemon', action='store_true')
    sub.add_parser('status', help='Show proxy status')
    sub.add_parser('stop', help='Stop proxy')
    args = parser.parse_args()
    if not args.command: parser.print_help(); sys.exit(0)
    if args.command == 'start':
        existing = get_proxy_state()
        if existing:
            print(f"CDP Proxy already running (PID {existing['pid']}, port {existing['proxy_port']})"); sys.exit(0)
        proxy = CDPProxy(chrome_port=args.chrome_port, proxy_port=args.proxy_port)
        if args.daemon:
            def _sig_handler(sig, frame): proxy.stop(); sys.exit(0)
            signal.signal(signal.SIGTERM, _sig_handler); signal.signal(signal.SIGINT, _sig_handler)
        proxy.start(block=True)
    elif args.command == 'status':
        state = get_proxy_state()
        if state:
            age = time.time() - state.get('started_at', 0)
            print(f"CDP Proxy is running")
            print(f"   PID:       {state['pid']}")
            print(f"   Proxy URL: {state['proxy_url']}")
            print(f"   Chrome:    {state['chrome_host']}:{state['chrome_port']}")
            print(f"   Uptime:    {int(age)}s")
        else:
            print("CDP Proxy is not running"); sys.exit(1)
    elif args.command == 'stop':
        if stop_proxy(): print("CDP Proxy stopped.")
        else: print("No proxy to stop."); sys.exit(1)


if __name__ == '__main__': main()


path: scripts/page_snapshot.py
content: """
PageSnapshot stub — minimal placeholder for url2pdf-mk scripts.

The url2pdf-mk scripts don't use PageSnapshot directly (they pass None
as the snapshot argument to BrowserActions), but browser_actions.py
imports it, so we need this class present.
"""


class PageSnapshot:
    """Minimal PageSnapshot stub — not used by url2pdf-mk scrape scripts."""

    def __init__(self, client):
        self.client = client
        self.refs = {}  # empty refs dict (scrape scripts pass None anyway)

    def accessibility_tree(self):
        return {}


path: scripts/_encoding_fix.py
content: """
Windows UTF-8 encoding fix — import 即自动生效。

在 Windows 上，PowerShell / cmd 默认使用 GBK (CP936) 编码。
当 Node.js 以 UTF-8 解码 Python 子进程的 stdout/stderr 时，
中文输出会变成乱码。此模块在 import 时自动将 stdout/stderr/stdin
reconfigure 为 UTF-8，确保与 OpenClaw Node.js 宿主通信正确。

macOS / Linux 不受影响——模块检测到非 Windows 平台后直接跳过。
"""

import sys as _sys
import os as _os

if _sys.platform == 'win32':
    # 1) 影响当前进程的 stdio
    for _stream_name in ('stdout', 'stderr', 'stdin'):
        _stream = getattr(_sys, _stream_name, None)
        if _stream is not None and hasattr(_stream, 'reconfigure'):
            try:
                _stream.reconfigure(encoding='utf-8', errors='replace')
            except Exception:
                pass

    # 2) 影响后续 spawn 的子进程
    _os.environ.setdefault('PYTHONIOENCODING', 'utf-8')


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)