323 lines
12 KiB
Python
Executable File
323 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
微信「联系客服解封」常规批量执行器
|
||
|
||
适用:以后任意设备、任意微信号被限制,编辑配置文件即可一键全自动解封。
|
||
|
||
链路:调用 SDK `POST /api/v3/account/unblock-customer-service`
|
||
→ 设备端 unblock_via_customer_service skill
|
||
→ 自动跑「安全保护中 → 联系客服 → 终止使用说明 → 联系专属客服 → 允许 → 微信安全专属客服 → AI 持续对话」
|
||
|
||
用法:
|
||
python3 sdk/scripts/wechat_unblock_batch.py -c sdk/config/unblock_targets.yaml
|
||
python3 sdk/scripts/wechat_unblock_batch.py -c xxx.yaml --dry-run # 只校验不执行
|
||
python3 sdk/scripts/wechat_unblock_batch.py -c xxx.yaml --only 192.168.110.80:5555
|
||
|
||
输出:
|
||
sdk/tmp/unblock_batch_{ts}/
|
||
├── summary.md 汇总表格
|
||
├── {device}_{wxid}.json 每条任务原始返回(含 turns/screenshots)
|
||
└── {device}_{wxid}.log 每条任务日志
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
LOG = logging.getLogger("unblock_batch")
|
||
|
||
|
||
# ---------- 配置加载 ----------
|
||
|
||
def _load_yaml(path: str) -> dict:
|
||
try:
|
||
import yaml # type: ignore
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return yaml.safe_load(f) or {}
|
||
except ImportError:
|
||
# 退化:把简单 yaml 当 json-like 解析(仅支持本配置示例)
|
||
import re
|
||
content = open(path, "r", encoding="utf-8").read()
|
||
content = re.sub(r"#.*$", "", content, flags=re.MULTILINE)
|
||
# 极简退化:要求用户安装 PyYAML
|
||
raise RuntimeError("未安装 PyYAML,请执行: pip install pyyaml") from None
|
||
|
||
|
||
@dataclass
|
||
class TargetTask:
|
||
device_id: str
|
||
wxid: str = ""
|
||
phone: str = ""
|
||
reason: str = ""
|
||
max_rounds: int = 18
|
||
chat_interval_sec: int = 8
|
||
use_web_search: bool = True
|
||
platform: str = "wechat"
|
||
enabled: bool = True
|
||
note: str = ""
|
||
|
||
# 运行时
|
||
started_at: float = 0
|
||
finished_at: float = 0
|
||
status: str = "pending" # pending / running / success / reject / timeout / fallback / error / skipped
|
||
final_message: str = ""
|
||
rounds: int = 0
|
||
raw_response: Optional[Dict[str, Any]] = field(default=None)
|
||
|
||
|
||
# ---------- 调用 SDK ----------
|
||
|
||
def _call_unblock(sdk_base: str, t: TargetTask, timeout: int) -> Dict[str, Any]:
|
||
qs = urllib.parse.urlencode({
|
||
"device_id": t.device_id,
|
||
"platform": t.platform,
|
||
"reason": t.reason,
|
||
"phone": t.phone,
|
||
"wxid": t.wxid,
|
||
"max_rounds": t.max_rounds,
|
||
"chat_interval_sec": t.chat_interval_sec,
|
||
"use_web_search": "true" if t.use_web_search else "false",
|
||
})
|
||
url = f"{sdk_base.rstrip('/')}/api/v3/account/unblock-customer-service?{qs}"
|
||
LOG.info(f"[{t.device_id}] POST {url}")
|
||
req = urllib.request.Request(url, method="POST", headers={"Content-Type": "application/json"})
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
body = resp.read().decode("utf-8")
|
||
try:
|
||
return json.loads(body)
|
||
except Exception:
|
||
return {"code": 599, "raw_body": body[:2000]}
|
||
|
||
|
||
# ---------- 单条任务 ----------
|
||
|
||
def _run_one(sdk_base: str, t: TargetTask, out_dir: Path,
|
||
retry_on_fallback: int) -> TargetTask:
|
||
if not t.enabled:
|
||
t.status = "skipped"
|
||
LOG.info(f"[{t.device_id}] disabled -> skip")
|
||
return t
|
||
|
||
t.started_at = time.time()
|
||
t.status = "running"
|
||
log_path = out_dir / f"{_safe(t.device_id)}_{_safe(t.wxid or 'noid')}.log"
|
||
json_path = out_dir / f"{_safe(t.device_id)}_{_safe(t.wxid or 'noid')}.json"
|
||
|
||
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
||
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||
LOG.addHandler(file_handler)
|
||
|
||
attempts = 0
|
||
response = None
|
||
try:
|
||
attempts += 1
|
||
# 单条最长耗时:max_rounds * (chat_interval + 5) + 90 兜底
|
||
timeout = t.max_rounds * (t.chat_interval_sec + 5) + 90
|
||
response = _call_unblock(sdk_base, t, timeout=timeout)
|
||
t.raw_response = response
|
||
|
||
data = response.get("data", {}) if isinstance(response, dict) else {}
|
||
session = data.get("session", {}) if isinstance(data, dict) else {}
|
||
status = data.get("status") or session.get("final_status") or "unknown"
|
||
msg = data.get("message") or data.get("error") or session.get("final_message") or ""
|
||
rounds = session.get("rounds", 0)
|
||
|
||
t.status = status
|
||
t.final_message = msg[:300]
|
||
t.rounds = int(rounds or 0)
|
||
|
||
# 链路失败时按需重试
|
||
while t.status == "fallback" and attempts <= retry_on_fallback:
|
||
LOG.warning(f"[{t.device_id}] fallback,重试 {attempts}/{retry_on_fallback}")
|
||
time.sleep(8)
|
||
attempts += 1
|
||
response = _call_unblock(sdk_base, t, timeout=timeout)
|
||
t.raw_response = response
|
||
data = response.get("data", {}) if isinstance(response, dict) else {}
|
||
session = data.get("session", {}) if isinstance(data, dict) else {}
|
||
t.status = data.get("status") or session.get("final_status") or "unknown"
|
||
t.final_message = (data.get("message") or session.get("final_message") or "")[:300]
|
||
t.rounds = int(session.get("rounds", 0) or 0)
|
||
|
||
except Exception as exc:
|
||
t.status = "error"
|
||
t.final_message = f"{type(exc).__name__}: {exc}"[:300]
|
||
LOG.exception(f"[{t.device_id}] 异常")
|
||
response = {"error": str(exc)}
|
||
finally:
|
||
t.finished_at = time.time()
|
||
try:
|
||
json_path.write_text(
|
||
json.dumps(response or {}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception:
|
||
pass
|
||
LOG.removeHandler(file_handler)
|
||
file_handler.close()
|
||
|
||
return t
|
||
|
||
|
||
def _safe(s: str) -> str:
|
||
return "".join(c if c.isalnum() else "_" for c in s)[:48]
|
||
|
||
|
||
# ---------- 主流程 ----------
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="微信联系客服解封 · 批量执行器")
|
||
parser.add_argument("-c", "--config", required=True, help="yaml 配置文件路径")
|
||
parser.add_argument("--dry-run", action="store_true", help="只校验不执行")
|
||
parser.add_argument("--only", help="只跑某个 device_id(覆盖配置)")
|
||
parser.add_argument("--out", help="输出目录(默认 sdk/tmp/unblock_batch_{ts})")
|
||
parser.add_argument("--log-level", default="INFO")
|
||
args = parser.parse_args()
|
||
|
||
logging.basicConfig(
|
||
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||
format="%(asctime)s %(levelname)s %(message)s",
|
||
)
|
||
|
||
cfg = _load_yaml(args.config)
|
||
sdk_base = cfg.get("sdk_base") or os.environ.get("SDK_BASE_URL") or "http://127.0.0.1:8899"
|
||
platform = cfg.get("platform", "wechat")
|
||
default_max = int(cfg.get("default_max_rounds", 18))
|
||
default_interval = int(cfg.get("default_chat_interval_sec", 8))
|
||
default_web = bool(cfg.get("default_use_web_search", True))
|
||
concurrency = max(1, int(cfg.get("concurrency", 1)))
|
||
retry_on_fallback = int(cfg.get("retry_on_fallback", 1))
|
||
between_sleep = int(cfg.get("between_task_sleep_sec", 6))
|
||
|
||
raw_targets = cfg.get("targets") or []
|
||
tasks: List[TargetTask] = []
|
||
for raw in raw_targets:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
dev = raw.get("device_id")
|
||
if not dev:
|
||
continue
|
||
if args.only and dev != args.only:
|
||
continue
|
||
tasks.append(TargetTask(
|
||
device_id=dev,
|
||
wxid=raw.get("wxid", "") or "",
|
||
phone=raw.get("phone", "") or "",
|
||
reason=raw.get("reason", "") or "",
|
||
max_rounds=int(raw.get("max_rounds", default_max)),
|
||
chat_interval_sec=int(raw.get("chat_interval_sec", default_interval)),
|
||
use_web_search=bool(raw.get("use_web_search", default_web)),
|
||
platform=raw.get("platform", platform),
|
||
enabled=bool(raw.get("enabled", True)),
|
||
note=raw.get("note", "") or "",
|
||
))
|
||
|
||
if not tasks:
|
||
LOG.error("配置中未找到任何 target")
|
||
return 2
|
||
|
||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
if args.out:
|
||
out_dir = Path(args.out)
|
||
else:
|
||
sdk_root = Path(__file__).resolve().parents[1]
|
||
out_dir = sdk_root / "tmp" / f"unblock_batch_{ts}"
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
LOG.info(f"配置: {args.config}")
|
||
LOG.info(f"SDK : {sdk_base}")
|
||
LOG.info(f"任务数: {len(tasks)} (concurrency={concurrency})")
|
||
LOG.info(f"输出: {out_dir}")
|
||
|
||
if args.dry_run:
|
||
LOG.info("--- DRY RUN ---")
|
||
for t in tasks:
|
||
LOG.info(f" {t.device_id} wxid={t.wxid} reason={t.reason[:30]} enabled={t.enabled}")
|
||
return 0
|
||
|
||
# 健康检查
|
||
try:
|
||
with urllib.request.urlopen(f"{sdk_base.rstrip('/')}/api/v3/connection/status", timeout=10) as resp:
|
||
conn = json.loads(resp.read().decode())
|
||
data = conn.get("data", {})
|
||
LOG.info(f"SDK 健康 ws_online={data.get('online_ws_count')} adb={data.get('adb_count')}")
|
||
except Exception as exc:
|
||
LOG.error(f"SDK 不可达: {exc}")
|
||
return 3
|
||
|
||
# 执行(串行 / 并发)
|
||
if concurrency <= 1:
|
||
for t in tasks:
|
||
_run_one(sdk_base, t, out_dir, retry_on_fallback)
|
||
if between_sleep > 0:
|
||
time.sleep(between_sleep)
|
||
else:
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||
futures = {ex.submit(_run_one, sdk_base, t, out_dir, retry_on_fallback): t for t in tasks}
|
||
for fut in concurrent.futures.as_completed(futures):
|
||
t = futures[fut]
|
||
try:
|
||
fut.result()
|
||
except Exception as exc:
|
||
LOG.exception(f"[{t.device_id}] 调度异常: {exc}")
|
||
|
||
# 汇总报告
|
||
_write_summary(tasks, out_dir, sdk_base, ts)
|
||
success = sum(1 for t in tasks if t.status == "success")
|
||
print(f"\n=== 完成 {success}/{len(tasks)} ===\n报告: {out_dir / 'summary.md'}")
|
||
return 0 if success == len(tasks) else 1
|
||
|
||
|
||
def _write_summary(tasks: List[TargetTask], out_dir: Path, sdk_base: str, ts: str) -> None:
|
||
lines = [
|
||
f"# 微信联系客服解封 · 批量执行报告 {ts}",
|
||
"",
|
||
f"- SDK: `{sdk_base}`",
|
||
f"- 任务数: {len(tasks)}",
|
||
f"- 成功: {sum(1 for t in tasks if t.status == 'success')}",
|
||
f"- 拒绝: {sum(1 for t in tasks if t.status == 'reject')}",
|
||
f"- 超时: {sum(1 for t in tasks if t.status == 'timeout')}",
|
||
f"- 链路失败: {sum(1 for t in tasks if t.status == 'fallback')}",
|
||
f"- 异常: {sum(1 for t in tasks if t.status == 'error')}",
|
||
f"- 跳过: {sum(1 for t in tasks if t.status == 'skipped')}",
|
||
"",
|
||
"| # | device | wxid | 状态 | 轮次 | 耗时(s) | 客服最终回复 |",
|
||
"|---|--------|------|------|------|---------|--------------|",
|
||
]
|
||
for i, t in enumerate(tasks, 1):
|
||
elapsed = round(t.finished_at - t.started_at, 1) if t.started_at else 0
|
||
msg = (t.final_message or "").replace("|", "/").replace("\n", " ")[:80]
|
||
lines.append(f"| {i} | `{t.device_id}` | `{t.wxid}` | **{t.status}** | {t.rounds} | {elapsed} | {msg} |")
|
||
lines += [
|
||
"",
|
||
"## 状态说明",
|
||
"- **success**:客服已解封 / 已恢复使用",
|
||
"- **reject**:客服明确拒绝解封",
|
||
"- **timeout**:达到最大轮次或客服长时间无回复",
|
||
"- **fallback**:未能进入客服小程序(路径变化)",
|
||
"- **error**:调用异常(网络 / 设备不在线等)",
|
||
"- **skipped**:配置 enabled=false",
|
||
"",
|
||
"## 详细 JSON",
|
||
"",
|
||
"每条任务详见同目录 `{device_id}_{wxid}.json`(含 turns / screenshots / web_snippets)",
|
||
]
|
||
(out_dir / "summary.md").write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|