deerflow-code/offline-backend-20260512/backend/skill-packages/add-audience/scripts/add_audiences.py
2026-09-07 18:24:55 +08:00

402 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
添加受众目标入库脚本(完整链路)。
链路(与前端 block-chat-audience.taskAddActionDesign / audience-content.taskActionDesignDel 一致):
1. POST {login} 登录换取 access_token(Authorization: Bearer)
2. GET {design_list}?filter=taskId&filter=actionId 查询当前行动下已有受众(查重)
3. 同名受众存在时执行删除链:
GET {pathway_relation_list}?filter=actionId 找出该受众的 pathwayId(关系表)
DELETE {pathway_account_delete}/{pathwayId} 删关系表 task-action-pathway-account
DELETE {delete_design_vulns}?designId={id} 删受众关联脆弱点
DELETE {design_delete}/{id} 删受众目标本身
4. POST {design_create} 创建受众目标(parentTargetAudience=大模型生成)
5. POST {design_account_create} ×N 逐个创建受众-账号关联
用法:
python add_audiences.py --json payload.json [--dry-run] [--config ../config/audience_api.json]
payload.json 结构:
{
"taskId": 101,
"actionId": 201,
"audienceList": [
{ "targetAudience": "群体名", // 也可用 groupName
"recReason": "推荐理由", // 会自动清洗 <n> 引用标记
"userList": [ {完整user对象} ] } // 取 id/name/platformType/dataSource/score,整个对象进 frofileInfo
]
}
输出:一段 JSON(stdout),包含每个接口的入参与调用结果(密码与 token 已脱敏),
以及分组结果与汇总,供模型如实转述给用户。
"""
import argparse
import copy
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config", "audience_api.json")
# 环境变量覆盖名
ENV_OVERRIDES = {
"base_url": "AUDIENCE_API_BASE_URL",
"username": "AUDIENCE_API_USERNAME",
"password": "AUDIENCE_API_PASSWORD",
"verify_ssl": "AUDIENCE_API_VERIFY_SSL",
}
def load_config(path: str) -> dict:
cfg = json.loads(open(path, encoding="utf-8").read())
for key, env in ENV_OVERRIDES.items():
if os.environ.get(env):
raw = os.environ[env]
if key == "verify_ssl":
cfg.setdefault("request", {})
cfg["request"]["verify_ssl"] = raw.lower() in ("1", "true", "yes")
else:
cfg[key] = raw
# 兼容 "$ENV_VAR" 占位值写法
def resolve(v):
if isinstance(v, str) and v.startswith("$") and re.fullmatch(r"\$[A-Z0-9_]+", v):
return os.environ.get(v[1:], "")
return v
def walk(o):
if isinstance(o, dict):
return {k: walk(v) for k, v in o.items()}
if isinstance(o, list):
return [walk(v) for v in o]
return resolve(o)
return walk(cfg)
def clean_reason(text) -> str:
"""复刻前端 recReason 清洗:<n> 引用标记、顿号、「引用」、空括号。"""
if not isinstance(text, str):
return ""
text = re.sub(r"<(\d+)>", "", text)
return text.replace("、", "").replace("引用", "").replace("()", "")
def platform_type_change(platform, options):
"""
平台名/别名 → 平台字典编码(写入 task-action-design-account.accountPlat)。
移植自前端 chat-data.utils.platformTypeChange:子串匹配 fb / x|witter / in / re / th → 字典 label
命中取 value;否则 label 全量小写精确匹配;仍匹配不到回退 1(facebook,与前端一致)。
options 形如 [{"label": "facebook", "value": 1}, ...],来自 sys-dict-data 的 platform 字典。
技能侧扩展:入参已是编码(int 或纯数字字符串)时原样透传;完全缺失(None/空)返回 None(不臆造)。
"""
if platform is None or (isinstance(platform, str) and not platform.strip()):
return None
if isinstance(platform, bool):
return None
if isinstance(platform, int):
return platform
if isinstance(platform, str) and platform.strip().isdigit():
return int(platform.strip())
low = str(platform).strip().lower()
def val(*labels):
for lab in labels:
for o in options:
if str(o.get("label", "")).strip().lower() == lab and o.get("value") is not None:
return o.get("value")
return None
if "fb" in low:
return val("facebook") or 1
if "x" in low or "witter" in low:
return val("x(twitter)", "x") or 1
if "in" in low:
return val("instagram") or 1
if "re" in low:
return val("reddit") or 1
if "th" in low:
return val("threads") or 1
for o in options:
if str(o.get("label", "")).strip().lower() == low and o.get("value") is not None:
return o.get("value")
return 1
class ApiClient:
def __init__(self, cfg: dict, dry_run: bool = False):
self.base = cfg["base_url"].rstrip("/")
self.cfg = cfg
self.dry_run = dry_run
self.headers = {"Content-Type": "application/json"}
self.headers.update(cfg.get("request", {}).get("extra_headers") or {})
self.timeout = cfg.get("request", {}).get("timeout_seconds", 30)
verify = cfg.get("request", {}).get("verify_ssl", True)
self.ctx = ssl.create_default_context() if verify else ssl._create_unverified_context()
self.calls = []
def _log(self, step, method, url, request, status, response):
self.calls.append({
"step": step, "method": method, "url": url,
"request": request, "response": {"status": status, "body": response},
})
def request(self, step, method, path, query=None, body=None, auth=True):
"""执行一次 HTTP 请求并记录。返回 (status, 解析后的响应)。"""
url = self.base + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
payload = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
display_body = self._redact(copy.deepcopy(body))
req_headers = dict(self.headers)
if auth:
req_headers[self.cfg["auth"]["header_name"]] = "***已附带鉴权头***"
if self.dry_run:
self._log(step, method, url, {"query": query, "body": display_body}, 0, "[dry-run] 未实际发送")
return 0, None
req = urllib.request.Request(url, data=payload, method=method)
for k, v in req_headers.items():
if v == "***已附带鉴权头***":
req.add_header(k, self._auth_header_value())
else:
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
status, text = resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
status, text = e.code, e.read().decode("utf-8", "replace")
except Exception as e: # 网络层错误
self._log(step, method, url, {"query": query, "body": display_body}, -1, str(e))
raise
try:
parsed = json.loads(text) if text else None
except ValueError:
parsed = text[:300]
self._log(step, method, url, {"query": query, "body": display_body}, status, parsed if not isinstance(parsed, str) else parsed)
return status, parsed
def _auth_header_value(self) -> str:
return self.cfg["auth"]["header_template"].format(token=getattr(self, "token", ""))
@staticmethod
def _redact(body):
if isinstance(body, dict):
for k in list(body.keys()):
if k in ("password",):
body[k] = "***"
return body
def ep(cfg, key) -> str:
return cfg["endpoints"][key]
def main():
parser = argparse.ArgumentParser(description="添加受众目标(完整链路:登录→查重→删同名及关系表→建受众→建账号关联)")
parser.add_argument("--json", required=True, help="载荷 JSON 文件路径")
parser.add_argument("--config", default=DEFAULT_CONFIG, help="接口配置文件路径")
parser.add_argument("--dry-run", action="store_true", help="只校验与打印调用计划,不实际发送")
args = parser.parse_args()
result = {
"success": False, "dry_run": args.dry_run,
"配置": {}, "入参摘要": {}, "登录": None, "groups": [], "summary": {}, "error": None,
}
try:
cfg = load_config(args.config)
except Exception as e:
result["error"] = f"配置加载失败:{e}"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
# ---- 载荷校验 ----
try:
payload = json.loads(open(args.json, encoding="utf-8").read())
except Exception as e:
result["error"] = f"载荷 JSON 读取失败:{e}"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
task_id = payload.get("taskId")
action_id = payload.get("actionId")
groups_in = payload.get("audienceList") or []
errors = []
if not isinstance(task_id, int) or not isinstance(action_id, int):
errors.append("taskId / actionId 必须为整数")
if not groups_in:
errors.append("audienceList 不能为空")
for i, g in enumerate(groups_in):
name = (g.get("targetAudience") or g.get("groupName") or "").strip()
if not name:
errors.append(f"audienceList[{i}] 缺少群体名(targetAudience/groupName)")
if not isinstance(g.get("userList"), list):
errors.append(f"audienceList[{i}] 缺少 userList 数组")
if errors:
result["error"] = "载荷校验失败:" + ";".join(errors)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
result["配置"] = {
"base_url": cfg["base_url"], "username": cfg["username"],
"verify_ssl": cfg.get("request", {}).get("verify_ssl", True),
"timeout_seconds": cfg.get("request", {}).get("timeout_seconds", 30),
}
result["入参摘要"] = {
"taskId": task_id, "actionId": action_id,
"audienceList": [
{"targetAudience": (g.get("targetAudience") or g.get("groupName", "")).strip(),
"recReason清洗后": clean_reason(g.get("recReason", "")),
"账号数": len(g.get("userList") or [])}
for g in groups_in
],
}
client = ApiClient(cfg, dry_run=args.dry_run)
# ---- 1. 登录 ----
login_status, login_resp = client.request("登录", "POST", ep(cfg, "login"),
body={"username": cfg["username"], "password": cfg["password"]}, auth=False)
if login_status not in (200, 201):
result["登录"] = f"失败(HTTP {login_status})"
result["error"] = f"登录失败:HTTP {login_status},请检查 config/audience_api.json 的 base_url 与账号密码"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
token = (login_resp or {}).get(cfg["auth"]["token_field"]) if isinstance(login_resp, dict) else None
if not token:
result["登录"] = "失败(响应中无 " + cfg["auth"]["token_field"] + ")"
result["error"] = f"登录响应缺少 {cfg['auth']['token_field']} 字段:{login_resp}"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
client.token = token
result["登录"] = f"成功(已获取 {cfg['auth']['token_field']},后续请求头 {cfg['auth']['header_name']}: {cfg['auth']['header_template']})"
# ---- 1.5 平台字典(platform 名称/别名 → 字典编码,写入 accountPlat 前映射;与前端 platformTypeChange 同款) ----
platform_options = []
dict_status, dict_resp = client.request("查询平台字典", "GET", ep(cfg, "platform_dict"),
query={"filter": ["dictType||$eq||platform"], "limit": 99999})
dict_rows = (dict_resp or {}).get("data") or [] if isinstance(dict_resp, dict) else []
if dict_status == 200:
platform_options = [
{"label": r.get("dictLabel"), "value": int(str(r["dictValue"]).strip())}
for r in dict_rows
if r.get("dictValue") is not None and str(r.get("dictValue")).strip().lstrip("-").isdigit()
]
result["平台字典"] = (
f"已加载 {len(platform_options)} 项:"
+ "、".join(f"{o['label']}={o['value']}" for o in platform_options)
)
else:
result["平台字典"] = f"查询失败(HTTP {dict_status}),平台名只能按子串规则映射且未命中回退 1,建议检查字典表"
# ---- 2. 查询已有受众(查重) ----
q = {"filter": [f"taskId||$eq||{task_id}", f"actionId||$eq||{action_id}"], "limit": 99999}
status, designs_resp = client.request("查询已有受众", "GET", ep(cfg, "design_list"), query=q)
designs = (designs_resp or {}).get("data") or [] if isinstance(designs_resp, dict) else []
if status != 200:
result["error"] = f"查询已有受众失败:HTTP {status}"
result["calls"] = client.calls
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
# 关系表(一次查出整个行动的 pathway 关联)
rel_status, rel_resp = client.request("查询受众-传播节点关系表", "GET", ep(cfg, "pathway_relation_list"),
query={"filter": [f"actionId||$eq||{action_id}"], "limit": 99999})
relations = (rel_resp or {}).get("data") or [] if isinstance(rel_resp, dict) else []
summary = {"新增受众": 0, "新增账号关联": 0, "删除同名旧受众": 0, "删除关系表记录": 0, "删除脆弱点关联": 0}
all_ok = True
for g in groups_in:
name = (g.get("targetAudience") or g.get("groupName")).strip()
group = {"targetAudience": name, "designId": None, "新增账号": 0, "deletedOld": [], "ok": False, "error": None}
# ---- 3. 同名删除链 ----
for old in [d for d in designs if str(d.get("targetAudience", "")).strip() == name]:
old_id = old.get("id")
pathway_ids = [r.get("pathwayId") for r in relations if r.get("designId") == old_id and r.get("pathwayId") is not None]
for pid in pathway_ids:
s, _ = client.request("删除关系表记录", "DELETE", f"{ep(cfg, 'pathway_account_delete')}/{pid}")
if s in (200, 204):
summary["删除关系表记录"] += 1
s, _ = client.request("删除受众关联脆弱点", "DELETE", ep(cfg, "delete_design_vulns"),
query={"designId": old_id})
if s in (200, 204):
summary["删除脆弱点关联"] += 1
s, _ = client.request("删除同名旧受众", "DELETE", f"{ep(cfg, 'design_delete')}/{old_id}")
if s in (200, 204):
summary["删除同名旧受众"] += 1
group["deletedOld"].append({"id": old_id, "删除关系表记录数": len(pathway_ids)})
# ---- 4. 创建受众目标 ----
design_body = {
"taskId": task_id, "actionId": action_id,
"parentTargetAudience": "大模型生成",
"targetAudience": name,
"recReason": clean_reason(g.get("recReason", "")),
}
s, created = client.request("创建受众目标", "POST", ep(cfg, "design_create"), body=design_body)
if s not in (200, 201) or not isinstance(created, dict) or "id" not in created:
group["error"] = f"创建受众目标失败:HTTP {s},响应:{created}"
result["groups"].append(group)
all_ok = False
continue
design_id = created["id"]
group["designId"] = design_id
summary["新增受众"] += 1
# ---- 5. 创建账号关联 ----
for user in g.get("userList") or []:
account_source = user.get("dataSource") if user.get("dataSource") is not None else user.get("score")
# 平台编码:user.platformType(编码)缺失时回退 user.platform(名称),统一经平台字典映射为编码
raw_platform = user.get("platformType") if user.get("platformType") is not None else user.get("platform")
account_plat = platform_type_change(raw_platform, platform_options)
group.setdefault("账号平台映射", []).append(
{"accountName": user.get("name"), "原始值": raw_platform, "accountPlat": account_plat}
)
account_body = {
"taskId": task_id, "actionId": action_id,
"accountId": user.get("id"),
"accountName": user.get("name"),
"accountPlat": account_plat,
"accountSource": account_source,
"frofileInfo": json.dumps(user, ensure_ascii=False),
"designId": design_id,
}
s, resp = client.request("创建受众-账号关联", "POST", ep(cfg, "design_account_create"), body=account_body)
if s in (200, 201):
group["新增账号"] += 1
summary["新增账号关联"] += 1
else:
group.setdefault("账号失败", []).append({"accountName": user.get("name"), "status": s, "response": resp})
all_ok = False
group["ok"] = "账号失败" not in group
result["groups"].append(group)
result["summary"] = summary
result["success"] = all_ok
result["calls"] = client.calls
result["人读摘要"] = (
f"{'[演练]' if args.dry_run else ''}共处理 {len(groups_in)} 个受众组:"
f"新增受众 {summary['新增受众']} 个、账号关联 {summary['新增账号关联']} 条;"
f"删除同名旧受众 {summary['删除同名旧受众']} 个(连带关系表记录 {summary['删除关系表记录']} 条、脆弱点关联 {summary['删除脆弱点关联']} 处)。"
+ ("全部成功。" if all_ok else "存在失败项,详见 groups/error 字段。")
)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if all_ok else 2)
if __name__ == "__main__":
main()