diff --git a/app/chrome.py b/app/chrome.py new file mode 100644 index 0000000..12983fa --- /dev/null +++ b/app/chrome.py @@ -0,0 +1,115 @@ +"""Chrome launcher helpers for per-account CDP sessions.""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +import urllib.error +import urllib.request + +from . import appconfig +from . import config as account_config + + +CDP_HOST = "127.0.0.1" + + +class ChromeLaunchError(RuntimeError): + """Raised when Chrome launch configuration is invalid.""" + + +def _account_value(account, field, default=None): + if isinstance(account, dict): + return account.get(field, default) + return getattr(account, field, default) + + +def _required_account_value(account, field): + value = _account_value(account, field) + if value is None or str(value).strip() == "": + raise ChromeLaunchError(f"账号缺少字段: {field}") + return value + + +def _debug_port(account) -> int: + value = int(_required_account_value(account, "debug_port")) + if value <= 0 or value > 65535: + raise ChromeLaunchError("debug_port 必须在 1-65535 范围内") + return value + + +def _user_data_dir(account, config=None) -> str: + existing = _account_value(account, "user_data_dir") + if existing: + path = os.path.abspath(str(existing)) + os.makedirs(path, exist_ok=True) + return path + + slug = _account_value(account, "slug") + if not slug: + slug = account_config.make_slug(_required_account_value(account, "alias")) + return account_config.ensure_user_data_dir(slug, config=config) + + +def build_launch_args(account, config=None) -> list: + """Build Chrome command arguments for one account.""" + + chrome_path = appconfig.chrome_path(config) + if not chrome_path: + raise ChromeLaunchError("chrome_path 不能为空") + port = _debug_port(account) + user_data_dir = _user_data_dir(account, config=config) + return [ + chrome_path, + f"--remote-debugging-port={port}", + "--remote-allow-origins=*", + f"--user-data-dir={user_data_dir}", + ] + + +def launch_chrome(account, config=None) -> subprocess.Popen: + """Launch Chrome for one account and return the process handle.""" + + args = build_launch_args(account, config=config) + try: + return subprocess.Popen(args) + except OSError as exc: + raise ChromeLaunchError(f"启动 Chrome 失败: {exc}") from exc + + +def _json_version_url(port, host=CDP_HOST): + return f"http://{host}:{int(port)}/json/version" + + +def _fetch_json_version(port, host=CDP_HOST, timeout=1.0): + request = urllib.request.Request(_json_version_url(port, host=host), method="GET") + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=timeout) as response: + if response.status != 200: + raise ChromeLaunchError(f"CDP 返回状态码: {response.status}") + body = response.read() + return json.loads(body.decode("utf-8")) + + +def is_running(port, host=CDP_HOST) -> bool: + """Return whether a Chrome CDP endpoint responds on /json/version.""" + + try: + _fetch_json_version(port, host=host, timeout=1.0) + return True + except (OSError, urllib.error.URLError, json.JSONDecodeError, ChromeLaunchError): + return False + + +def wait_debug_ready(port, timeout=60, host=CDP_HOST) -> bool: + """Poll until the Chrome CDP endpoint is ready or timeout expires.""" + + deadline = time.monotonic() + float(timeout) + while True: + if is_running(port, host=host): + return True + if time.monotonic() >= deadline: + return False + time.sleep(0.25) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 3751521..23d1008 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -36,7 +36,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | DONE | -| T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | TODO | +| T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | DONE | | T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | TODO | | T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | TODO | | T-104b | PySide6 worker 基类与线程启动工具(`BaseWorker` + `QThread` 包装) | T-104 | signals: progress/log/row_updated/failed/finished/cancelled;取消标记可用;worker 不直接操作 QWidget | TODO | diff --git a/docs/api.md b/docs/api.md index b16beb9..d5f5a5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -121,16 +121,19 @@ ensure_user_data_dir(slug, root=None, config=None) -> str `make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_`。`ensure_user_data_dir()` 拒绝非 `[a-z0-9_]` slug,防止路径穿越。 -## chrome 模块(`app/chrome.py`,待建) +## chrome 模块(`app/chrome.py`,已建) ```python -build_launch_args(account) -> list[str] # chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir -launch_chrome(account) -> subprocess.Popen -wait_debug_ready(port, timeout=60) -> bool -is_running(port) -> bool -create_shortcut(account, dest_dir=None) -> str # 可选 .lnk,PowerShell WScript.Shell +class ChromeLaunchError(RuntimeError): ... +build_launch_args(account, config=None) -> list[str] +# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir +launch_chrome(account, config=None) -> subprocess.Popen +wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool +is_running(port, host="127.0.0.1") -> bool ``` +`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。端口探测访问 `/json/version`,显式禁用环境代理。`.lnk` 快捷方式留给 T-106。 + ## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入) ```python diff --git a/docs/current-state.md b/docs/current-state.md index 407cafa..5197c66 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/chrome.py` 已实现 Chrome 参数拼装、启动与 CDP 端口探测;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/chrome,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -37,8 +37,9 @@ | `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 | | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | +| `app/chrome.py` | 已有 | T-102 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测;快捷方式待 T-106 | | `tests/` | 已有 | T-006 产出:stdlib unittest 基座;appconfig/db 单元测试;excel/prompts 模块契约占位测试 | -| `app/excel.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 1-3 产出 | +| `app/excel.py` / `app/workers.py` | 待建 | Phase 1-3 产出 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | ## 已验证能力(单账号) @@ -53,9 +54,9 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)。 - 正在进行:无。 -- 下一个可领取任务:**T-102(`app/chrome.py` 启动器)**。 +- 下一个可领取任务:**T-103(首次登录保活 + 登录检测)**。 ## 当前可运行内容 @@ -78,6 +79,9 @@ py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDir # config slug 与 user-data-dir 临时目录检查 py -3 -c "import tempfile; from app import config; d=tempfile.TemporaryDirectory(dir='.'); print(config.ensure_user_data_dir(config.make_slug('alias'), root=d.name))" +# chrome 参数拼装 / 端口探测由 tests/test_chrome.py 覆盖 +python -m unittest discover -s tests + # 当前入口占位 python main.py py -3 -m app diff --git a/progress.md b/progress.md index 1eac096..379b99a 100644 --- a/progress.md +++ b/progress.md @@ -303,3 +303,12 @@ - 规则:slug = 可读 ASCII 前缀 + 8 位 SHA1 后缀;非 ASCII 别名使用 `account_`;`ensure_user_data_dir` 只接受 `[a-z0-9_]`,防止路径穿越。 - 验证:`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(12 tests,skipped=2);`python -m unittest discover -s tests` 通过(12 tests,skipped=2);测试临时目录已清理。 - 下一步:按任务看板领取 T-102。 + +## 【2026-06-27】T-102 Chrome 启动器 + +- 状态:DONE +- 变更:新增 `app/chrome.py`,实现 `build_launch_args`、`launch_chrome`、`is_running`、`wait_debug_ready`;启动参数包含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir`;新增 `tests/test_chrome.py` 覆盖参数拼装、Popen 调用模拟、本地 `/json/version` 端口探测;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。 +- 细节:端口探测使用 stdlib `urllib` 访问 `127.0.0.1:/json/version`,显式禁用环境代理,避免 localhost/CDP 被代理干扰。 +- 范围:本轮不实现 `.lnk` 快捷方式,留给 T-106。 +- 验证:`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(17 tests,skipped=2);`python -m unittest discover -s tests` 通过(17 tests,skipped=2)。 +- 下一步:按任务看板领取 T-103。 diff --git a/tests/test_chrome.py b/tests/test_chrome.py new file mode 100644 index 0000000..11085b4 --- /dev/null +++ b/tests/test_chrome.py @@ -0,0 +1,123 @@ +import json +import os +import socket +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import TempDirMixin + +from app import chrome +from app import config as account_config + + +class JsonVersionHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/json/version": + self.send_response(404) + self.end_headers() + return + body = json.dumps({"Browser": "Chrome/Test"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + +class ChromeTests(TempDirMixin, unittest.TestCase): + def start_json_version_server(self): + server = HTTPServer(("127.0.0.1", 0), JsonVersionHandler) + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + self.addCleanup(thread.join, 2) + return server.server_address[1] + + def unused_port(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + def test_build_launch_args_includes_required_flags(self): + with self.make_temp_dir() as temp_dir: + cfg = { + "chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe", + "user_data_root": temp_dir, + } + account = {"alias": "alias", "debug_port": "9222"} + + args = chrome.build_launch_args(account, config=cfg) + slug = account_config.make_slug("alias") + user_data_dir = os.path.abspath(os.path.join(temp_dir, slug)) + + self.assertEqual(cfg["chrome_path"], args[0]) + self.assertIn("--remote-debugging-port=9222", args) + self.assertIn("--remote-allow-origins=*", args) + self.assertIn(f"--user-data-dir={user_data_dir}", args) + self.assertTrue(os.path.isdir(user_data_dir)) + + self.assert_removed(temp_dir) + + def test_build_launch_args_accepts_account_object_user_data_dir(self): + with self.make_temp_dir() as temp_dir: + user_data_dir = os.path.join(temp_dir, "custom") + account = SimpleNamespace(debug_port=9333, user_data_dir=user_data_dir) + args = chrome.build_launch_args( + account, + config={"chrome_path": "chrome.exe"}, + ) + + self.assertIn("--remote-debugging-port=9333", args) + self.assertIn(f"--user-data-dir={os.path.abspath(user_data_dir)}", args) + self.assertTrue(os.path.isdir(user_data_dir)) + + self.assert_removed(temp_dir) + + def test_build_launch_args_rejects_invalid_account(self): + with self.assertRaises(chrome.ChromeLaunchError): + chrome.build_launch_args( + {"alias": "alias", "debug_port": 0}, + config={"chrome_path": "chrome.exe"}, + ) + + def test_launch_chrome_uses_subprocess_popen(self): + with self.make_temp_dir() as temp_dir: + account = {"alias": "alias", "debug_port": 9222} + cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir} + process = object() + + with mock.patch("app.chrome.subprocess.Popen", return_value=process) as popen: + result = chrome.launch_chrome(account, config=cfg) + + self.assertIs(process, result) + popen.assert_called_once() + args = popen.call_args[0][0] + self.assertEqual("chrome.exe", args[0]) + self.assertIn("--remote-debugging-port=9222", args) + self.assertIn("--remote-allow-origins=*", args) + + self.assert_removed(temp_dir) + + def test_is_running_and_wait_debug_ready(self): + port = self.start_json_version_server() + + self.assertTrue(chrome.is_running(port)) + self.assertTrue(chrome.wait_debug_ready(port, timeout=1)) + self.assertFalse(chrome.is_running(self.unused_port())) + + +if __name__ == "__main__": + unittest.main()