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

310 lines
9.4 KiB
Python

"""
Multi-account manager for Xiaohongshu publishing.
Manages multiple Xiaohongshu accounts with separate Chrome profiles:
- Each account has its own user-data-dir for cookie isolation
- Accounts are stored in a JSON config file
- Supports add/remove/list/switch operations
Usage:
python account_manager.py list
python account_manager.py add <name> [--alias <alias>]
python account_manager.py remove <name>
python account_manager.py info <name>
python account_manager.py set-default <name>
"""
import json
import os
import sys
import shutil
from typing import Optional
# Config file location
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
ACCOUNTS_FILE = os.path.join(CONFIG_DIR, "accounts.json")
# Base directory for account profiles
PROFILES_BASE = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"Google", "Chrome", "XiaohongshuProfiles")
# Default account name (for backward compatibility)
DEFAULT_PROFILE_NAME = "default"
def _ensure_config_dir():
"""Ensure the config directory exists."""
os.makedirs(CONFIG_DIR, exist_ok=True)
def _load_accounts() -> dict:
"""Load accounts from config file."""
_ensure_config_dir()
if os.path.exists(ACCOUNTS_FILE):
try:
with open(ACCOUNTS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
# Default structure
return {
"default_account": DEFAULT_PROFILE_NAME,
"accounts": {
DEFAULT_PROFILE_NAME: {
"alias": "默认账号",
"profile_dir": os.path.join(PROFILES_BASE, DEFAULT_PROFILE_NAME),
"created_at": None,
}
}
}
def _save_accounts(data: dict):
"""Save accounts to config file."""
_ensure_config_dir()
with open(ACCOUNTS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_profile_dir(account_name: Optional[str] = None) -> str:
"""
Get the Chrome profile directory for a given account.
Args:
account_name: Account name. If None, uses the default account.
Returns:
Path to the Chrome user-data-dir for this account.
"""
data = _load_accounts()
if account_name is None:
account_name = data.get("default_account", DEFAULT_PROFILE_NAME)
if account_name not in data["accounts"]:
# Fallback to default
account_name = DEFAULT_PROFILE_NAME
if account_name not in data["accounts"]:
# Create default account entry
data["accounts"][account_name] = {
"alias": "默认账号",
"profile_dir": os.path.join(PROFILES_BASE, account_name),
"created_at": None,
}
_save_accounts(data)
return data["accounts"][account_name]["profile_dir"]
def get_default_account() -> str:
"""Get the name of the default account."""
data = _load_accounts()
return data.get("default_account", DEFAULT_PROFILE_NAME)
def set_default_account(account_name: str) -> bool:
"""
Set the default account.
Returns True if successful, False if account doesn't exist.
"""
data = _load_accounts()
if account_name not in data["accounts"]:
return False
data["default_account"] = account_name
_save_accounts(data)
return True
def list_accounts() -> list[dict]:
"""
List all registered accounts.
Returns a list of dicts with account info.
"""
data = _load_accounts()
default = data.get("default_account", DEFAULT_PROFILE_NAME)
result = []
for name, info in data["accounts"].items():
result.append({
"name": name,
"alias": info.get("alias", ""),
"profile_dir": info.get("profile_dir", ""),
"is_default": name == default,
})
return result
def add_account(name: str, alias: Optional[str] = None) -> bool:
"""
Add a new account.
Args:
name: Unique account name (used as identifier)
alias: Display name / description
Returns True if added, False if name already exists.
"""
data = _load_accounts()
if name in data["accounts"]:
return False
from datetime import datetime
profile_dir = os.path.join(PROFILES_BASE, name)
os.makedirs(profile_dir, exist_ok=True)
data["accounts"][name] = {
"alias": alias or name,
"profile_dir": profile_dir,
"created_at": datetime.now().isoformat(),
}
_save_accounts(data)
return True
def remove_account(name: str, delete_profile: bool = False) -> bool:
"""
Remove an account.
Args:
name: Account name to remove
delete_profile: If True, also delete the Chrome profile directory
Returns True if removed, False if not found or is default.
"""
data = _load_accounts()
if name not in data["accounts"]:
return False
# Don't allow removing the default account if it's the only one
if name == data.get("default_account") and len(data["accounts"]) == 1:
return False
profile_dir = data["accounts"][name].get("profile_dir", "")
del data["accounts"][name]
# If we removed the default, set a new default
if name == data.get("default_account"):
data["default_account"] = next(iter(data["accounts"].keys()))
_save_accounts(data)
# Optionally delete the profile directory
if delete_profile and profile_dir and os.path.isdir(profile_dir):
try:
shutil.rmtree(profile_dir)
except Exception:
pass
return True
def get_account_info(name: str) -> Optional[dict]:
"""Get info for a specific account."""
data = _load_accounts()
if name not in data["accounts"]:
return None
info = data["accounts"][name].copy()
info["name"] = name
info["is_default"] = name == data.get("default_account")
return info
def account_exists(name: str) -> bool:
"""Check if an account exists."""
data = _load_accounts()
return name in data["accounts"]
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
import argparse
parser = argparse.ArgumentParser(description="Xiaohongshu Account Manager")
sub = parser.add_subparsers(dest="command", required=True)
# list
sub.add_parser("list", help="List all accounts")
# add
p_add = sub.add_parser("add", help="Add a new account")
p_add.add_argument("name", help="Account name (unique identifier)")
p_add.add_argument("--alias", help="Display name / description")
# remove
p_rm = sub.add_parser("remove", help="Remove an account")
p_rm.add_argument("name", help="Account name to remove")
p_rm.add_argument("--delete-profile", action="store_true",
help="Also delete the Chrome profile directory")
# info
p_info = sub.add_parser("info", help="Show account info")
p_info.add_argument("name", help="Account name")
# set-default
p_def = sub.add_parser("set-default", help="Set the default account")
p_def.add_argument("name", help="Account name to set as default")
# get-profile-dir (for internal use)
p_dir = sub.add_parser("get-profile-dir", help="Get profile directory for an account")
p_dir.add_argument("--account", help="Account name (default: default account)")
args = parser.parse_args()
if args.command == "list":
accounts = list_accounts()
if not accounts:
print("No accounts configured.")
return
print(f"{'Name':<20} {'Alias':<20} {'Default':<10}")
print("-" * 50)
for acc in accounts:
default_mark = "*" if acc["is_default"] else ""
print(f"{acc['name']:<20} {acc['alias']:<20} {default_mark:<10}")
elif args.command == "add":
if add_account(args.name, args.alias):
print(f"Account '{args.name}' added.")
print(f"Profile dir: {get_profile_dir(args.name)}")
print("\nTo log in to this account, run:")
print(f" python cdp_publish.py --account {args.name} login")
else:
print(f"Error: Account '{args.name}' already exists.", file=sys.stderr)
sys.exit(1)
elif args.command == "remove":
if remove_account(args.name, args.delete_profile):
print(f"Account '{args.name}' removed.")
else:
print(f"Error: Cannot remove account '{args.name}'.", file=sys.stderr)
sys.exit(1)
elif args.command == "info":
info = get_account_info(args.name)
if info:
print(f"Name: {info['name']}")
print(f"Alias: {info.get('alias', '')}")
print(f"Profile dir: {info.get('profile_dir', '')}")
print(f"Default: {'Yes' if info.get('is_default') else 'No'}")
print(f"Created: {info.get('created_at', 'Unknown')}")
else:
print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
sys.exit(1)
elif args.command == "set-default":
if set_default_account(args.name):
print(f"Default account set to '{args.name}'.")
else:
print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
sys.exit(1)
elif args.command == "get-profile-dir":
print(get_profile_dir(args.account))
if __name__ == "__main__":
main()