297 lines
9.8 KiB
Python
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)
|