Files
xiaohongshu-mcp/skills/post-to-xhs/scripts/chrome_launcher.py
2026-02-27 16:27:16 +08:00

297 lines
9.8 KiB
Python

"""
Chrome launcher with CDP remote debugging support.
Manages a dedicated Chrome instance for Xiaohongshu publishing:
- Detects if Chrome is already listening on the debug port
- Launches Chrome with a dedicated user-data-dir for login persistence
- Waits for the debug port to become available
- Supports headless mode for automated publishing without GUI
- Supports switching between headless and headed mode (e.g. for login)
- Supports multiple accounts with separate profile directories
"""
import os
import sys
import time
import socket
import subprocess
import platform
import signal
from typing import Optional
CDP_PORT = 9222
PROFILE_DIR_NAME = "XiaohongshuProfile"
STARTUP_TIMEOUT = 15 # seconds to wait for Chrome to start
# Track the Chrome process we launched so we can kill it later
_chrome_process: subprocess.Popen | None = None
# Track the current account being used
_current_account: Optional[str] = None
def get_chrome_path() -> str:
"""Find Chrome executable on Windows."""
candidates = []
# Standard install locations
for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
base = os.environ.get(env_var, "")
if base:
candidates.append(os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"))
for path in candidates:
if os.path.isfile(path):
return path
# Fallback: check PATH
import shutil
found = shutil.which("chrome") or shutil.which("chrome.exe")
if found:
return found
raise FileNotFoundError(
"Chrome not found. Please install Google Chrome or set its path manually."
)
def get_user_data_dir(account: Optional[str] = None) -> str:
"""
Return the Chrome profile directory path for a given account.
Args:
account: Account name. If None, uses the default account from account_manager.
Returns:
Path to the Chrome user-data-dir for this account.
"""
try:
from account_manager import get_profile_dir
return get_profile_dir(account)
except ImportError:
# Fallback if account_manager not available
local_app_data = os.environ.get("LOCALAPPDATA", "")
if not local_app_data:
local_app_data = os.path.expanduser("~")
return os.path.join(local_app_data, "Google", "Chrome", PROFILE_DIR_NAME)
def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
"""Check if a TCP port is accepting connections."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
try:
s.connect((host, port))
return True
except (ConnectionRefusedError, socket.timeout, OSError):
return False
def launch_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> subprocess.Popen | None:
"""
Launch Chrome with remote debugging enabled.
Args:
port: CDP remote debugging port.
headless: If True, launch Chrome in headless mode (no GUI window).
account: Account name to use. If None, uses the default account.
Returns the Popen object if a new process was started, or None if Chrome
was already running on the target port.
"""
global _chrome_process, _current_account
if is_port_open(port):
print(f"[chrome_launcher] Chrome already running on port {port}.")
return None
chrome_path = get_chrome_path()
user_data_dir = get_user_data_dir(account)
_current_account = account
cmd = [
chrome_path,
f"--remote-debugging-port={port}",
f"--user-data-dir={user_data_dir}",
"--no-first-run",
"--no-default-browser-check",
]
if headless:
cmd.append("--headless=new")
mode_label = "headless" if headless else "headed"
account_label = account or "default"
print(f"[chrome_launcher] Launching Chrome ({mode_label}, account: {account_label})...")
print(f" executable : {chrome_path}")
print(f" profile dir: {user_data_dir}")
print(f" debug port : {port}")
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_chrome_process = proc
# Wait for the debug port to become available
deadline = time.time() + STARTUP_TIMEOUT
while time.time() < deadline:
if is_port_open(port):
print(f"[chrome_launcher] Chrome is ready on port {port}.")
return proc
time.sleep(0.5)
print(
f"[chrome_launcher] WARNING: Chrome started but port {port} not responding "
f"after {STARTUP_TIMEOUT}s. It may still be initializing.",
file=sys.stderr,
)
return proc
def kill_chrome(port: int = CDP_PORT):
"""
Kill the Chrome instance on the given debug port.
Tries multiple strategies:
1. Send CDP Browser.close command via HTTP
2. Terminate the tracked subprocess
3. Kill by port on Windows (taskkill)
"""
global _chrome_process
# Strategy 1: CDP Browser.close
try:
import requests
resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
if resp.ok:
ws_url = resp.json().get("webSocketDebuggerUrl")
if ws_url:
import websockets.sync.client as ws_client
ws = ws_client.connect(ws_url)
ws.send('{"id":1,"method":"Browser.close"}')
try:
ws.recv(timeout=2)
except Exception:
pass
ws.close()
print("[chrome_launcher] Sent Browser.close via CDP.")
except Exception:
pass
# Wait briefly for Chrome to shut down
time.sleep(1)
# Strategy 2: Terminate tracked subprocess
if _chrome_process and _chrome_process.poll() is None:
try:
_chrome_process.terminate()
_chrome_process.wait(timeout=5)
print("[chrome_launcher] Terminated tracked Chrome process.")
except Exception:
try:
_chrome_process.kill()
except Exception:
pass
_chrome_process = None
# Strategy 3: Windows taskkill by port (fallback)
if sys.platform == "win32" and is_port_open(port):
try:
result = subprocess.run(
["netstat", "-ano"],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
if f":{port}" in line and "LISTENING" in line:
pid = line.strip().split()[-1]
subprocess.run(
["taskkill", "/F", "/PID", pid],
capture_output=True, timeout=5
)
print(f"[chrome_launcher] Killed process {pid} via taskkill.")
break
except Exception:
pass
# Wait for port to be released
deadline = time.time() + 5
while time.time() < deadline:
if not is_port_open(port):
return
time.sleep(0.5)
if is_port_open(port):
print(f"[chrome_launcher] WARNING: port {port} still open after kill attempt.",
file=sys.stderr)
def restart_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> subprocess.Popen | None:
"""
Kill the current Chrome instance and relaunch with the specified mode.
Useful for switching between headless and headed mode (e.g. when login
is needed during a headless session), or switching accounts.
Args:
port: CDP remote debugging port.
headless: If True, relaunch in headless mode.
account: Account name to use. If None, uses the default account.
Returns the Popen object for the new Chrome process.
"""
account_label = account or "default"
print(f"[chrome_launcher] Restarting Chrome ({'headless' if headless else 'headed'}, account: {account_label})...")
kill_chrome(port)
time.sleep(1)
return launch_chrome(port, headless=headless, account=account)
def ensure_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> bool:
"""
Ensure Chrome is running with remote debugging on the given port.
Args:
port: CDP remote debugging port.
headless: If True, launch in headless mode when starting a new instance.
If Chrome is already running, this parameter is ignored.
account: Account name to use. If None, uses the default account.
Returns True if Chrome is available, False otherwise.
"""
if is_port_open(port):
return True
try:
launch_chrome(port, headless=headless, account=account)
return is_port_open(port)
except FileNotFoundError as e:
print(f"[chrome_launcher] Error: {e}", file=sys.stderr)
return False
def get_current_account() -> Optional[str]:
"""Get the name of the currently active account."""
return _current_account
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Chrome Launcher for CDP")
parser.add_argument("--headless", action="store_true", help="Launch in headless mode")
parser.add_argument("--kill", action="store_true", help="Kill the running Chrome instance")
parser.add_argument("--restart", action="store_true", help="Restart Chrome")
parser.add_argument("--account", help="Account name to use (default: default account)")
args = parser.parse_args()
if args.kill:
kill_chrome()
print("[chrome_launcher] Chrome killed.")
elif args.restart:
restart_chrome(headless=args.headless, account=args.account)
print("[chrome_launcher] Chrome restarted.")
elif ensure_chrome(headless=args.headless, account=args.account):
print("[chrome_launcher] Chrome is ready for CDP connections.")
else:
print("[chrome_launcher] Failed to start Chrome.", file=sys.stderr)
sys.exit(1)