add: post-to-xhs skills
This commit is contained in:
296
skills/post-to-xhs/scripts/chrome_launcher.py
Normal file
296
skills/post-to-xhs/scripts/chrome_launcher.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user