Release v1.0.51: precipitate ensure-web, config empty-value, and login-required standards
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -39,7 +39,7 @@ real_browser_rpa/
|
||||
|
||||
## 核心流程
|
||||
|
||||
1. **pick account** — 通过 `account_client.pick_web_account()` 获取 `profile_dir` 与租约
|
||||
1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`)获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。
|
||||
2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)`
|
||||
3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成
|
||||
4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现
|
||||
@@ -79,7 +79,8 @@ real_browser_rpa/
|
||||
|
||||
**浏览器与启动约束:**
|
||||
|
||||
- **先 Profile 再开浏览器**:必须 `pick_web_account` 拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2)
|
||||
- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web)拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2)
|
||||
- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep`(`POLICY-CONTROL-003`)
|
||||
- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态
|
||||
- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」
|
||||
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
|
||||
@@ -142,7 +143,7 @@ real_browser_rpa/
|
||||
- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用
|
||||
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
|
||||
- **日志脱敏** — 不输出完整手机号/账号
|
||||
- **租约释放** — `pick-web --lease` 后必须在 `finally` 释放
|
||||
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
|
||||
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
|
||||
|
||||
## 常见坑
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。"""
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
|
||||
|
||||
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,8 +10,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
|
||||
|
||||
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
|
||||
@@ -19,12 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
|
||||
|
||||
ACCOUNT_SETUP_MESSAGE = (
|
||||
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
|
||||
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、"
|
||||
"status=active、带 profile_dir 的账号后重新运行。"
|
||||
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
|
||||
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
|
||||
)
|
||||
|
||||
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
|
||||
LEASE_BUSY_MESSAGE = (
|
||||
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
|
||||
)
|
||||
|
||||
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
|
||||
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
|
||||
DEFAULT_START_URL = "https://www.example.com/"
|
||||
|
||||
|
||||
class AccountManagerError(Exception):
|
||||
@@ -38,13 +47,65 @@ def mask_login_id(login_id: str) -> str:
|
||||
return mask_text(login_id)
|
||||
|
||||
|
||||
def format_account_label(account: dict) -> str:
|
||||
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
|
||||
for key in ("login_id", "account_label", "label"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
for key in ("id", "account_id"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_placeholder_config(raw: str) -> bool:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return True
|
||||
if s.startswith("#"):
|
||||
return True
|
||||
low = s.lower()
|
||||
if low in ("default-account", "none", "null", "n/a", "-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""从配置解析 (account_id, login_id)。
|
||||
|
||||
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
|
||||
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
|
||||
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
|
||||
"""
|
||||
account_id: Optional[str] = None
|
||||
login_id: Optional[str] = None
|
||||
|
||||
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
|
||||
if not _is_placeholder_config(aid_raw):
|
||||
if aid_raw.isdigit():
|
||||
account_id = aid_raw
|
||||
else:
|
||||
login_id = aid_raw
|
||||
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
|
||||
|
||||
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
|
||||
if not _is_placeholder_config(lid_raw):
|
||||
login_id = lid_raw
|
||||
|
||||
if account_id:
|
||||
return account_id, None
|
||||
return None, login_id
|
||||
|
||||
|
||||
def _resolve_account_manager_main() -> str:
|
||||
"""解析 account-manager CLI 入口路径。
|
||||
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root(路径推断 / JIANGCHANG_SKILLS_ROOT)→ 开发机兜底。
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
|
||||
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
|
||||
"""
|
||||
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
|
||||
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
|
||||
|
||||
@@ -103,44 +164,66 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
message = str(err.get("message") or "")
|
||||
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
|
||||
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
|
||||
raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
|
||||
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
|
||||
)
|
||||
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if not payload.get("profile_dir"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
|
||||
return payload
|
||||
|
||||
|
||||
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
|
||||
proc = _run_argv(
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
]
|
||||
)
|
||||
def _ensure_web_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||
lid = (login_id or "").strip() or None
|
||||
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||
argv = [
|
||||
"account",
|
||||
"ensure-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--url",
|
||||
start_url,
|
||||
"--auth-strategy",
|
||||
DEFAULT_AUTH_STRATEGY,
|
||||
"--label",
|
||||
label,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if lid:
|
||||
argv.extend(["--login-id", lid])
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
"ENSURE_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "ensure-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
return _validate_pick_payload(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||
return payload
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
@@ -170,19 +253,32 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。"""
|
||||
def pick_web_account(
|
||||
platform: str,
|
||||
account_id: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。
|
||||
|
||||
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
|
||||
"""
|
||||
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||
|
||||
if account_id:
|
||||
try:
|
||||
aid = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
|
||||
return _pick_by_id(platform_key, aid)
|
||||
aid_raw = (account_id or "").strip()
|
||||
if aid_raw and not _is_placeholder_config(aid_raw):
|
||||
if not aid_raw.isdigit():
|
||||
if not (login_id or "").strip():
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id(platform_key, int(aid_raw))
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
if lid and _is_placeholder_config(lid):
|
||||
lid = None
|
||||
|
||||
try:
|
||||
return _pick_web_with_lease(platform_key)
|
||||
return _ensure_web_with_lease(platform_key, login_id=lid)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
|
||||
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@@ -11,6 +10,8 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core.activity import interruptible_sleep
|
||||
|
||||
from service.browser_session import close_browser_context, get_start_url, start_browser_session
|
||||
from service.human_verification import (
|
||||
HumanVerificationWaitResult,
|
||||
@@ -102,11 +103,11 @@ def _step_cb(cb: Optional[StepCallback], text: str) -> None:
|
||||
async def _random_delay() -> None:
|
||||
lo = int(os.getenv("RPA_STEP_DELAY_MIN_MS") or "900")
|
||||
hi = int(os.getenv("RPA_STEP_DELAY_MAX_MS") or "2600")
|
||||
await asyncio.sleep(random.uniform(lo / 1000.0, hi / 1000.0))
|
||||
await interruptible_sleep(random.uniform(lo / 1000.0, hi / 1000.0))
|
||||
|
||||
|
||||
async def _scroll_wait() -> None:
|
||||
await asyncio.sleep(random.uniform(1.2, 3.5))
|
||||
await interruptible_sleep(random.uniform(1.2, 3.5))
|
||||
|
||||
|
||||
def _headless() -> bool:
|
||||
@@ -156,7 +157,7 @@ async def _open_login_panel_if_needed(page) -> None:
|
||||
btn = page.locator(LOGGED_OUT_SELECTOR).first
|
||||
if await _visible(btn):
|
||||
await btn.click()
|
||||
await asyncio.sleep(random.uniform(1.0, 3.0))
|
||||
await interruptible_sleep(random.uniform(1.0, 3.0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -175,7 +176,7 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
|
||||
if await _is_logged_in(page):
|
||||
print("[登录] 检测到登录成功")
|
||||
return
|
||||
await asyncio.sleep(2.0)
|
||||
await interruptible_sleep(2.0)
|
||||
|
||||
raise RuntimeError(
|
||||
f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。"
|
||||
@@ -253,7 +254,7 @@ async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.8)
|
||||
await interruptible_sleep(0.8)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import asyncio
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from service.account_client import AccountManagerError, pick_web_account, release_lease
|
||||
from service.account_client import (
|
||||
AccountManagerError,
|
||||
pick_web_account,
|
||||
release_lease,
|
||||
resolve_account_selectors_from_config,
|
||||
)
|
||||
from service.task_rpa import (
|
||||
ScrapeRunResult,
|
||||
format_stop_reason_for_user,
|
||||
@@ -79,7 +84,12 @@ async def run_keyword_search_task(
|
||||
account: Optional[dict[str, Any]] = None
|
||||
|
||||
try:
|
||||
account = pick_web_account(platform, account_id)
|
||||
cfg_account_id, cfg_login_id = resolve_account_selectors_from_config()
|
||||
account = pick_web_account(
|
||||
platform,
|
||||
account_id=account_id or cfg_account_id,
|
||||
login_id=cfg_login_id,
|
||||
)
|
||||
lease_token = account.get("lease_token")
|
||||
|
||||
scrape_result: ScrapeRunResult = await run_keyword_search_async(
|
||||
|
||||
@@ -50,7 +50,7 @@ simulator_browser_rpa/
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
|
||||
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease`) |
|
||||
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
|
||||
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
|
||||
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
|
||||
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
|
||||
@@ -59,7 +59,7 @@ simulator_browser_rpa/
|
||||
|
||||
## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致)
|
||||
|
||||
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account` 拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
|
||||
- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录
|
||||
- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0`
|
||||
- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。"""
|
||||
"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。
|
||||
|
||||
业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,8 +10,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.runtime_env import get_sibling_skills_root
|
||||
|
||||
from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
|
||||
@@ -19,12 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
PLACEHOLDER_PLATFORM = TARGET_PLATFORM
|
||||
|
||||
ACCOUNT_SETUP_MESSAGE = (
|
||||
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
|
||||
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}、"
|
||||
"status=active、带 profile_dir 的账号后重新运行。"
|
||||
f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。"
|
||||
f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。"
|
||||
)
|
||||
|
||||
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
|
||||
LEASE_BUSY_MESSAGE = (
|
||||
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。"
|
||||
)
|
||||
|
||||
DEFAULT_AUTH_STRATEGY = "qr_code_manual"
|
||||
DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认"
|
||||
DEFAULT_START_URL = "https://www.example.com/"
|
||||
|
||||
|
||||
class AccountManagerError(Exception):
|
||||
@@ -38,12 +47,65 @@ def mask_login_id(login_id: str) -> str:
|
||||
return mask_text(login_id)
|
||||
|
||||
|
||||
def format_account_label(account: dict) -> str:
|
||||
"""人读账号标识:优先 login_id / 备注,最后才用数字 id。"""
|
||||
for key in ("login_id", "account_label", "label"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
for key in ("id", "account_id"):
|
||||
v = account.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_placeholder_config(raw: str) -> bool:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return True
|
||||
if s.startswith("#"):
|
||||
return True
|
||||
low = s.lower()
|
||||
if low in ("default-account", "none", "null", "n/a", "-"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""从配置解析 (account_id, login_id)。
|
||||
|
||||
- ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id
|
||||
- ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id)
|
||||
- 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web
|
||||
"""
|
||||
account_id: Optional[str] = None
|
||||
login_id: Optional[str] = None
|
||||
|
||||
aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip()
|
||||
if not _is_placeholder_config(aid_raw):
|
||||
if aid_raw.isdigit():
|
||||
account_id = aid_raw
|
||||
else:
|
||||
login_id = aid_raw
|
||||
logger.info("default_account_id_treated_as_login_id value=%s", aid_raw)
|
||||
|
||||
lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip()
|
||||
if not _is_placeholder_config(lid_raw):
|
||||
login_id = lid_raw
|
||||
|
||||
if account_id:
|
||||
return account_id, None
|
||||
return None, login_id
|
||||
|
||||
|
||||
def _resolve_account_manager_main() -> str:
|
||||
"""解析 account-manager CLI 入口路径。
|
||||
|
||||
优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
|
||||
末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。
|
||||
"""
|
||||
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
|
||||
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
|
||||
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
|
||||
|
||||
@@ -53,6 +115,7 @@ def _resolve_account_manager_main() -> str:
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
# 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录
|
||||
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
|
||||
if os.path.isfile(dev):
|
||||
return dev
|
||||
@@ -101,44 +164,66 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
code = _normalize_error_code(str(err.get("code") or ""))
|
||||
message = str(err.get("message") or "")
|
||||
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
|
||||
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
|
||||
raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE)
|
||||
if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code:
|
||||
raise AccountManagerError(
|
||||
"AMBIGUOUS_ACCOUNT",
|
||||
message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。",
|
||||
)
|
||||
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
|
||||
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
|
||||
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
|
||||
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
if not payload.get("profile_dir"):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
|
||||
return payload
|
||||
|
||||
|
||||
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
|
||||
proc = _run_argv(
|
||||
[
|
||||
"account",
|
||||
"pick-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
]
|
||||
)
|
||||
def _ensure_web_with_lease(
|
||||
platform: str,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL
|
||||
lid = (login_id or "").strip() or None
|
||||
label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL
|
||||
argv = [
|
||||
"account",
|
||||
"ensure-web",
|
||||
"--platform",
|
||||
platform,
|
||||
"--url",
|
||||
start_url,
|
||||
"--auth-strategy",
|
||||
DEFAULT_AUTH_STRATEGY,
|
||||
"--label",
|
||||
label,
|
||||
"--lease",
|
||||
"--holder",
|
||||
LEASE_HOLDER,
|
||||
"--ttl-sec",
|
||||
LEASE_TTL_SEC,
|
||||
"--purpose",
|
||||
"rpa",
|
||||
]
|
||||
if lid:
|
||||
argv.extend(["--login-id", lid])
|
||||
|
||||
proc = _run_argv(argv)
|
||||
out = proc.stdout or ""
|
||||
if proc.returncode != 0 and not out.strip():
|
||||
raise AccountManagerError(
|
||||
"PICK_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "pick-web 子进程失败",
|
||||
"ENSURE_WEB_FAILED",
|
||||
(proc.stderr or "").strip() or "ensure-web 子进程失败",
|
||||
)
|
||||
payload = _parse_last_json(out)
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
return _validate_pick_payload(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
|
||||
return payload
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
|
||||
return _validate_pick_payload(payload)
|
||||
|
||||
|
||||
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
@@ -168,19 +253,32 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。"""
|
||||
def pick_web_account(
|
||||
platform: str,
|
||||
account_id: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""获取网页账号(profile_dir + lease_token)。
|
||||
|
||||
优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。
|
||||
"""
|
||||
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
|
||||
|
||||
if account_id:
|
||||
try:
|
||||
aid = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
|
||||
return _pick_by_id(platform_key, aid)
|
||||
aid_raw = (account_id or "").strip()
|
||||
if aid_raw and not _is_placeholder_config(aid_raw):
|
||||
if not aid_raw.isdigit():
|
||||
if not (login_id or "").strip():
|
||||
login_id = aid_raw
|
||||
aid_raw = ""
|
||||
else:
|
||||
return _pick_by_id(platform_key, int(aid_raw))
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
if lid and _is_placeholder_config(lid):
|
||||
lid = None
|
||||
|
||||
try:
|
||||
return _pick_web_with_lease(platform_key)
|
||||
return _ensure_web_with_lease(platform_key, login_id=lid)
|
||||
except AccountManagerError as exc:
|
||||
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
|
||||
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
|
||||
|
||||
Reference in New Issue
Block a user