Angiin

chore: Chrome 启动器增强、运行锁修复、长文发布和类型优化

@@ -2,14 +2,17 @@ @@ -2,14 +2,17 @@
2 2
3 from __future__ import annotations 3 from __future__ import annotations
4 4
  5 +import contextlib
5 import json 6 import json
6 import logging 7 import logging
7 import os 8 import os
8 import platform 9 import platform
9 import shutil 10 import shutil
10 -import signal 11 +import socket
11 import subprocess 12 import subprocess
  13 +import sys
12 import time 14 import time
  15 +from pathlib import Path
13 16
14 from xhs.stealth import STEALTH_ARGS 17 from xhs.stealth import STEALTH_ARGS
15 18
@@ -18,6 +21,9 @@ logger = logging.getLogger(__name__) @@ -18,6 +21,9 @@ logger = logging.getLogger(__name__)
18 # 默认远程调试端口 21 # 默认远程调试端口
19 DEFAULT_PORT = 9222 22 DEFAULT_PORT = 9222
20 23
  24 +# 全局进程追踪
  25 +_chrome_process: subprocess.Popen | None = None
  26 +
21 # 各平台 Chrome 默认路径 27 # 各平台 Chrome 默认路径
22 _CHROME_PATHS: dict[str, list[str]] = { 28 _CHROME_PATHS: dict[str, list[str]] = {
23 "Darwin": [ 29 "Darwin": [
@@ -38,6 +44,22 @@ _CHROME_PATHS: dict[str, list[str]] = { @@ -38,6 +44,22 @@ _CHROME_PATHS: dict[str, list[str]] = {
38 } 44 }
39 45
40 46
  47 +def _get_default_data_dir() -> str:
  48 + """返回默认 Chrome Profile 目录路径。"""
  49 + return str(Path.home() / ".xhs" / "chrome-profile")
  50 +
  51 +
  52 +def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
  53 + """TCP socket 级端口检测(秒级响应)。"""
  54 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  55 + s.settimeout(1)
  56 + try:
  57 + s.connect((host, port))
  58 + return True
  59 + except (ConnectionRefusedError, TimeoutError, OSError):
  60 + return False
  61 +
  62 +
41 def find_chrome() -> str | None: 63 def find_chrome() -> str | None:
42 """查找 Chrome 可执行文件路径。""" 64 """查找 Chrome 可执行文件路径。"""
43 # 环境变量优先 65 # 环境变量优先
@@ -45,13 +67,28 @@ def find_chrome() -> str | None: @@ -45,13 +67,28 @@ def find_chrome() -> str | None:
45 if env_path and os.path.isfile(env_path): 67 if env_path and os.path.isfile(env_path):
46 return env_path 68 return env_path
47 69
48 - # which/where 查找  
49 - chrome = shutil.which("google-chrome") or shutil.which("chromium") 70 + # which/where 查找(含 Windows chrome.exe)
  71 + chrome = (
  72 + shutil.which("google-chrome")
  73 + or shutil.which("chromium")
  74 + or shutil.which("chrome")
  75 + or shutil.which("chrome.exe")
  76 + )
50 if chrome: 77 if chrome:
51 return chrome 78 return chrome
52 79
53 # 平台默认路径 80 # 平台默认路径
54 system = platform.system() 81 system = platform.system()
  82 +
  83 + # Windows: 额外检查环境变量路径
  84 + if system == "Windows":
  85 + for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
  86 + base = os.environ.get(env_var, "")
  87 + if base:
  88 + candidate = os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")
  89 + if os.path.isfile(candidate):
  90 + return candidate
  91 +
55 for path in _CHROME_PATHS.get(system, []): 92 for path in _CHROME_PATHS.get(system, []):
56 if os.path.isfile(path): 93 if os.path.isfile(path):
57 return path 94 return path
@@ -59,55 +96,70 @@ def find_chrome() -> str | None: @@ -59,55 +96,70 @@ def find_chrome() -> str | None:
59 return None 96 return None
60 97
61 98
  99 +def is_chrome_running(port: int = DEFAULT_PORT) -> bool:
  100 + """检查指定端口的 Chrome 是否在运行(TCP 级检测)。"""
  101 + return is_port_open(port)
  102 +
  103 +
62 def launch_chrome( 104 def launch_chrome(
63 port: int = DEFAULT_PORT, 105 port: int = DEFAULT_PORT,
64 headless: bool = False, 106 headless: bool = False,
65 user_data_dir: str | None = None, 107 user_data_dir: str | None = None,
66 chrome_bin: str | None = None, 108 chrome_bin: str | None = None,
67 -) -> subprocess.Popen: 109 +) -> subprocess.Popen | None:
68 """启动 Chrome 进程(带远程调试端口)。 110 """启动 Chrome 进程(带远程调试端口)。
69 111
70 Args: 112 Args:
71 port: 远程调试端口。 113 port: 远程调试端口。
72 headless: 是否无头模式。 114 headless: 是否无头模式。
73 - user_data_dir: 用户数据目录(Profile 隔离)。 115 + user_data_dir: 用户数据目录(Profile 隔离),默认 ~/.xhs/chrome-profile
74 chrome_bin: Chrome 可执行文件路径。 116 chrome_bin: Chrome 可执行文件路径。
75 117
76 Returns: 118 Returns:
77 - Chrome 子进程。 119 + Chrome 子进程,若已在运行则返回 None
78 120
79 Raises: 121 Raises:
80 FileNotFoundError: 未找到 Chrome。 122 FileNotFoundError: 未找到 Chrome。
81 """ 123 """
  124 + global _chrome_process
  125 +
  126 + # 已在运行则跳过
  127 + if is_port_open(port):
  128 + logger.info("Chrome 已在运行 (port=%d),跳过启动", port)
  129 + return None
  130 +
82 if not chrome_bin: 131 if not chrome_bin:
83 chrome_bin = find_chrome() 132 chrome_bin = find_chrome()
84 if not chrome_bin: 133 if not chrome_bin:
85 raise FileNotFoundError("未找到 Chrome,请设置 CHROME_BIN 环境变量或安装 Chrome") 134 raise FileNotFoundError("未找到 Chrome,请设置 CHROME_BIN 环境变量或安装 Chrome")
86 135
  136 + # 默认 user-data-dir
  137 + if not user_data_dir:
  138 + user_data_dir = _get_default_data_dir()
  139 +
87 args = [ 140 args = [
88 chrome_bin, 141 chrome_bin,
89 f"--remote-debugging-port={port}", 142 f"--remote-debugging-port={port}",
  143 + f"--user-data-dir={user_data_dir}",
90 *STEALTH_ARGS, 144 *STEALTH_ARGS,
91 ] 145 ]
92 146
93 if headless: 147 if headless:
94 args.append("--headless=new") 148 args.append("--headless=new")
95 149
96 - if user_data_dir:  
97 - args.append(f"--user-data-dir={user_data_dir}")  
98 -  
99 # 代理 150 # 代理
100 proxy = os.getenv("XHS_PROXY") 151 proxy = os.getenv("XHS_PROXY")
101 if proxy: 152 if proxy:
102 args.append(f"--proxy-server={proxy}") 153 args.append(f"--proxy-server={proxy}")
103 logger.info("使用代理: %s", _mask_proxy(proxy)) 154 logger.info("使用代理: %s", _mask_proxy(proxy))
104 155
105 - logger.info("启动 Chrome: port=%d, headless=%s", port, headless) 156 + logger.info("启动 Chrome: port=%d, headless=%s, profile=%s", port, headless, user_data_dir)
106 process = subprocess.Popen( 157 process = subprocess.Popen(
107 args, 158 args,
108 stdout=subprocess.DEVNULL, 159 stdout=subprocess.DEVNULL,
109 stderr=subprocess.DEVNULL, 160 stderr=subprocess.DEVNULL,
110 ) 161 )
  162 + _chrome_process = process
111 163
112 # 等待 Chrome 准备就绪 164 # 等待 Chrome 准备就绪
113 _wait_for_chrome(port) 165 _wait_for_chrome(port)
@@ -120,7 +172,7 @@ def close_chrome(process: subprocess.Popen) -> None: @@ -120,7 +172,7 @@ def close_chrome(process: subprocess.Popen) -> None:
120 return 172 return
121 173
122 try: 174 try:
123 - process.send_signal(signal.SIGTERM) 175 + process.terminate()
124 process.wait(timeout=5) 176 process.wait(timeout=5)
125 except (subprocess.TimeoutExpired, OSError): 177 except (subprocess.TimeoutExpired, OSError):
126 process.kill() 178 process.kill()
@@ -129,29 +181,20 @@ def close_chrome(process: subprocess.Popen) -> None: @@ -129,29 +181,20 @@ def close_chrome(process: subprocess.Popen) -> None:
129 logger.info("Chrome 进程已关闭") 181 logger.info("Chrome 进程已关闭")
130 182
131 183
132 -def is_chrome_running(port: int = DEFAULT_PORT) -> bool:  
133 - """检查指定端口的 Chrome 是否在运行。"""  
134 - import requests  
135 -  
136 - try:  
137 - resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)  
138 - return resp.status_code == 200  
139 - except (requests.ConnectionError, requests.Timeout):  
140 - return False  
141 -  
142 -  
143 def kill_chrome(port: int = DEFAULT_PORT) -> None: 184 def kill_chrome(port: int = DEFAULT_PORT) -> None:
144 """关闭指定端口的 Chrome 实例。 185 """关闭指定端口的 Chrome 实例。
145 186
146 - 尝试通过 CDP Browser.close 命令关闭,失败则使用进程信号 187 + 策略: CDP Browser.close → terminate 追踪进程 → 端口查找终止进程
147 188
148 Args: 189 Args:
149 port: Chrome 调试端口。 190 port: Chrome 调试端口。
150 """ 191 """
151 - import requests 192 + global _chrome_process
152 193
153 # 策略1: 通过 CDP 关闭 194 # 策略1: 通过 CDP 关闭
154 try: 195 try:
  196 + import requests
  197 +
155 resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2) 198 resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
156 if resp.status_code == 200: 199 if resp.status_code == 200:
157 ws_url = resp.json().get("webSocketDebuggerUrl") 200 ws_url = resp.json().get("webSocketDebuggerUrl")
@@ -163,32 +206,70 @@ def kill_chrome(port: int = DEFAULT_PORT) -> None: @@ -163,32 +206,70 @@ def kill_chrome(port: int = DEFAULT_PORT) -> None:
163 ws.close() 206 ws.close()
164 logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port) 207 logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port)
165 time.sleep(1) 208 time.sleep(1)
166 - return  
167 except Exception: 209 except Exception:
168 pass 210 pass
169 211
170 - # 策略2: 通过 lsof 查找并 kill 进程 212 + # 策略2: terminate 追踪的子进程
  213 + if _chrome_process and _chrome_process.poll() is None:
171 try: 214 try:
172 - result = subprocess.run(  
173 - ["lsof", "-ti", f":{port}"],  
174 - capture_output=True,  
175 - text=True,  
176 - timeout=5,  
177 - )  
178 - if result.returncode == 0 and result.stdout.strip():  
179 - import contextlib  
180 -  
181 - pids = result.stdout.strip().split("\n") 215 + _chrome_process.terminate()
  216 + _chrome_process.wait(timeout=5)
  217 + logger.info("通过 terminate 关闭追踪的 Chrome 进程")
  218 + except Exception:
  219 + with contextlib.suppress(Exception):
  220 + _chrome_process.kill()
  221 + _chrome_process = None
  222 +
  223 + # 策略3: 通过端口查找并终止进程(跨平台)
  224 + if is_port_open(port):
  225 + pids = _find_pids_by_port(port)
  226 + if pids:
182 for pid in pids: 227 for pid in pids:
183 - with contextlib.suppress(OSError, ValueError):  
184 - os.kill(int(pid), signal.SIGTERM)  
185 - logger.info("通过 SIGTERM 关闭 Chrome 进程 (port=%d)", port)  
186 - time.sleep(1) 228 + _kill_pid(pid)
  229 + logger.info("通过进程终止关闭 Chrome (port=%d)", port)
  230 +
  231 + # 等待端口释放(最多 5s)
  232 + deadline = time.monotonic() + 5
  233 + while time.monotonic() < deadline:
  234 + if not is_port_open(port):
187 return 235 return
188 - except Exception:  
189 - pass 236 + time.sleep(0.5)
  237 +
  238 + if is_port_open(port):
  239 + logger.warning("端口 %d 仍被占用,kill 可能未完全生效", port)
  240 +
  241 +
  242 +def ensure_chrome(
  243 + port: int = DEFAULT_PORT,
  244 + headless: bool = False,
  245 + user_data_dir: str | None = None,
  246 + chrome_bin: str | None = None,
  247 +) -> bool:
  248 + """确保 Chrome 在指定端口可用(一站式入口)。
  249 +
  250 + 如果 Chrome 已在运行,直接返回 True。
  251 + 否则尝试启动 Chrome 并等待端口就绪。
  252 +
  253 + Args:
  254 + port: 远程调试端口。
  255 + headless: 是否无头模式(仅新启动时生效)。
  256 + user_data_dir: 用户数据目录。
  257 + chrome_bin: Chrome 可执行文件路径。
190 258
191 - logger.warning("未能关闭 Chrome (port=%d)", port) 259 + Returns:
  260 + True 表示 Chrome 可用,False 表示启动失败。
  261 + """
  262 + if is_port_open(port):
  263 + return True
  264 +
  265 + try:
  266 + launch_chrome(
  267 + port=port, headless=headless, user_data_dir=user_data_dir, chrome_bin=chrome_bin,
  268 + )
  269 + return is_port_open(port)
  270 + except FileNotFoundError as e:
  271 + logger.error("启动 Chrome 失败: %s", e)
  272 + return False
192 273
193 274
194 def restart_chrome( 275 def restart_chrome(
@@ -196,7 +277,7 @@ def restart_chrome( @@ -196,7 +277,7 @@ def restart_chrome(
196 headless: bool = False, 277 headless: bool = False,
197 user_data_dir: str | None = None, 278 user_data_dir: str | None = None,
198 chrome_bin: str | None = None, 279 chrome_bin: str | None = None,
199 -) -> subprocess.Popen: 280 +) -> subprocess.Popen | None:
200 """重启 Chrome:关闭当前实例后以新模式重新启动。 281 """重启 Chrome:关闭当前实例后以新模式重新启动。
201 282
202 Args: 283 Args:
@@ -206,7 +287,7 @@ def restart_chrome( @@ -206,7 +287,7 @@ def restart_chrome(
206 chrome_bin: Chrome 可执行文件路径。 287 chrome_bin: Chrome 可执行文件路径。
207 288
208 Returns: 289 Returns:
209 - 新的 Chrome 子进程。 290 + 新的 Chrome 子进程,或 None
210 """ 291 """
211 logger.info("重启 Chrome: port=%d, headless=%s", port, headless) 292 logger.info("重启 Chrome: port=%d, headless=%s", port, headless)
212 kill_chrome(port) 293 kill_chrome(port)
@@ -220,16 +301,70 @@ def restart_chrome( @@ -220,16 +301,70 @@ def restart_chrome(
220 301
221 302
222 def _wait_for_chrome(port: int, timeout: float = 15.0) -> None: 303 def _wait_for_chrome(port: int, timeout: float = 15.0) -> None:
223 - """等待 Chrome 调试端口就绪。""" 304 + """等待 Chrome 调试端口就绪(TCP 级检测)。"""
224 deadline = time.monotonic() + timeout 305 deadline = time.monotonic() + timeout
225 while time.monotonic() < deadline: 306 while time.monotonic() < deadline:
226 - if is_chrome_running(port): 307 + if is_port_open(port):
227 logger.info("Chrome 已就绪 (port=%d)", port) 308 logger.info("Chrome 已就绪 (port=%d)", port)
228 return 309 return
229 time.sleep(0.5) 310 time.sleep(0.5)
230 logger.warning("等待 Chrome 就绪超时 (port=%d)", port) 311 logger.warning("等待 Chrome 就绪超时 (port=%d)", port)
231 312
232 313
  314 +def _find_pids_by_port(port: int) -> list[int]:
  315 + """查找占用指定端口的进程 PID(跨平台)。"""
  316 + try:
  317 + if sys.platform == "win32":
  318 + result = subprocess.run(
  319 + ["netstat", "-ano", "-p", "TCP"],
  320 + capture_output=True,
  321 + text=True,
  322 + timeout=5,
  323 + )
  324 + if result.returncode != 0:
  325 + return []
  326 + pids: list[int] = []
  327 + for line in result.stdout.splitlines():
  328 + if f":{port}" in line and "LISTENING" in line:
  329 + parts = line.split()
  330 + with contextlib.suppress(ValueError, IndexError):
  331 + pids.append(int(parts[-1]))
  332 + return list(set(pids))
  333 + else:
  334 + result = subprocess.run(
  335 + ["lsof", "-ti", f":{port}"],
  336 + capture_output=True,
  337 + text=True,
  338 + timeout=5,
  339 + )
  340 + if result.returncode != 0 or not result.stdout.strip():
  341 + return []
  342 + pids = []
  343 + for p in result.stdout.strip().split("\n"):
  344 + with contextlib.suppress(ValueError):
  345 + pids.append(int(p))
  346 + return pids
  347 + except Exception:
  348 + return []
  349 +
  350 +
  351 +def _kill_pid(pid: int) -> None:
  352 + """终止指定 PID 的进程(跨平台)。"""
  353 + try:
  354 + if sys.platform == "win32":
  355 + subprocess.run(
  356 + ["taskkill", "/PID", str(pid), "/F"],
  357 + capture_output=True,
  358 + timeout=5,
  359 + )
  360 + else:
  361 + import signal
  362 +
  363 + os.kill(pid, signal.SIGTERM)
  364 + except Exception:
  365 + logger.debug("终止进程 %d 失败", pid)
  366 +
  367 +
233 def _mask_proxy(proxy_url: str) -> str: 368 def _mask_proxy(proxy_url: str) -> str:
234 """隐藏代理 URL 中的敏感信息。""" 369 """隐藏代理 URL 中的敏感信息。"""
235 from urllib.parse import urlparse 370 from urllib.parse import urlparse
@@ -71,7 +71,7 @@ class RunLock: @@ -71,7 +71,7 @@ class RunLock:
71 # 检查进程是否存在 71 # 检查进程是否存在
72 os.kill(pid, 0) 72 os.kill(pid, 0)
73 return False 73 return False
74 - except (FileNotFoundError, ValueError, ProcessLookupError, PermissionError): 74 + except (ValueError, OSError):
75 return True 75 return True
76 76
77 def _force_release(self) -> None: 77 def _force_release(self) -> None:
@@ -5,6 +5,7 @@ from __future__ import annotations @@ -5,6 +5,7 @@ from __future__ import annotations
5 import json 5 import json
6 import logging 6 import logging
7 import time 7 import time
  8 +from pathlib import Path
8 9
9 from .cdp import Page 10 from .cdp import Page
10 from .errors import PublishError 11 from .errors import PublishError
@@ -217,14 +218,14 @@ def _fill_long_content(page: Page, content: str) -> None: @@ -217,14 +218,14 @@ def _fill_long_content(page: Page, content: str) -> None:
217 def _insert_images_to_editor(page: Page, image_paths: list[str]) -> None: 218 def _insert_images_to_editor(page: Page, image_paths: list[str]) -> None:
218 """将图片插入到编辑器中。""" 219 """将图片插入到编辑器中。"""
219 for img_path in image_paths: 220 for img_path in image_paths:
220 - normalized = img_path.replace("\\", "/") 221 + file_uri = Path(img_path).resolve().as_uri()
221 page.evaluate( 222 page.evaluate(
222 f""" 223 f"""
223 (() => {{ 224 (() => {{
224 const editor = document.querySelector({json.dumps(CONTENT_EDITOR)}); 225 const editor = document.querySelector({json.dumps(CONTENT_EDITOR)});
225 if (!editor) return false; 226 if (!editor) return false;
226 const img = document.createElement('img'); 227 const img = document.createElement('img');
227 - img.src = 'file:///' + {json.dumps(normalized)}; 228 + img.src = {json.dumps(file_uri)};
228 editor.appendChild(img); 229 editor.appendChild(img);
229 editor.dispatchEvent(new Event('input', {{ bubbles: true }})); 230 editor.dispatchEvent(new Event('input', {{ bubbles: true }}));
230 return true; 231 return true;
@@ -159,6 +159,9 @@ class Feed: @@ -159,6 +159,9 @@ class Feed:
159 "sharedCount": self.note_card.interact_info.shared_count, 159 "sharedCount": self.note_card.interact_info.shared_count,
160 }, 160 },
161 } 161 }
  162 + cover = self.note_card.cover
  163 + if cover.url or cover.url_default:
  164 + result["cover"] = cover.url or cover.url_default
162 if self.note_card.video: 165 if self.note_card.video:
163 result["video"] = {"duration": self.note_card.video.capa.duration} 166 result["video"] = {"duration": self.note_card.video.capa.duration}
164 return result 167 return result