402 lines
18 KiB
Python
402 lines
18 KiB
Python
# -*- 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()
|