run_dealer_pipeline.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import subprocess
  5. import sys
  6. from datetime import datetime
  7. from pathlib import Path
  8. from typing import Any, Dict, List, Optional, Sequence
  9. SCRIPT_DIR = Path(__file__).resolve().parent
  10. SKILL_ROOT = SCRIPT_DIR.parents[1]
  11. COMMON_DIR = SKILL_ROOT / "scripts" / "common"
  12. if str(COMMON_DIR) not in sys.path:
  13. sys.path.insert(0, str(COMMON_DIR))
  14. from artifact_manager import new_run_id, resolve_artifact_path, run_dir, write_json # type: ignore # noqa: E402
  15. from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
  16. SUMMARY_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
  17. DEFAULT_CONFIG_NAME = "workflow_config.json"
  18. FEISHU_CONFIG_NAME = "feishu_sync_config.json"
  19. DEFAULT_CONFIG: Dict[str, Any] = {
  20. "enabled": True,
  21. "country": "Morocco",
  22. "profile_id": "",
  23. "excel": "",
  24. "summary_sheet_name": SUMMARY_SHEET,
  25. "daily_outreach_target": 20,
  26. "default_stages": ["summary", "dashboard", "feishu_check"],
  27. "refresh_summary": True,
  28. "refresh_dashboard": True,
  29. "dashboard_latest_dir": "dashboards/latest",
  30. "sync_feishu": True,
  31. "prepare_email_preview": False,
  32. "prepare_facebook_outreach": False,
  33. "collect_facebook_replies": False,
  34. "send_email": False,
  35. "send_facebook_dm": False,
  36. "human_confirmation_required": [
  37. "send_email",
  38. "send_facebook_dm",
  39. "linkedin_outreach",
  40. "write_facebook_conversations",
  41. ],
  42. }
  43. def read_json(path: Path) -> Dict[str, Any]:
  44. with path.open("r", encoding="utf-8") as fh:
  45. data = json.load(fh)
  46. if not isinstance(data, dict):
  47. raise ValueError(f"Config must be a JSON object: {path}")
  48. return data
  49. def load_config(raw_path: str) -> Dict[str, Any]:
  50. config = dict(DEFAULT_CONFIG)
  51. root_config = Path.cwd() / DEFAULT_CONFIG_NAME
  52. if raw_path:
  53. path = Path(raw_path).expanduser()
  54. if not path.is_absolute():
  55. path = (Path.cwd() / path).resolve()
  56. elif root_config.exists():
  57. path = root_config.resolve()
  58. else:
  59. example = SKILL_ROOT / "assets" / "workflow_config.example.json"
  60. path = example.resolve() if example.exists() else root_config.resolve()
  61. loaded = read_json(path) if path.exists() else {}
  62. config.update(loaded)
  63. config["_config_path"] = str(path) if path.exists() else ""
  64. config["_config_found"] = path.exists()
  65. return config
  66. def parse_stages(raw: str, config: Dict[str, Any]) -> List[str]:
  67. if raw:
  68. return [item.strip() for item in raw.split(",") if item.strip()]
  69. stages = config.get("default_stages") or DEFAULT_CONFIG["default_stages"]
  70. return [str(item).strip() for item in stages if str(item).strip()]
  71. def run_command(args: List[str], step_name: str, output_dir: Path) -> Dict[str, Any]:
  72. started = datetime.now().isoformat(timespec="seconds")
  73. proc = subprocess.run(
  74. args,
  75. cwd=str(SKILL_ROOT),
  76. text=True,
  77. encoding="utf-8",
  78. errors="replace",
  79. capture_output=True,
  80. )
  81. log_path = output_dir / f"{step_name}.log"
  82. log_path.write_text(
  83. "COMMAND: " + " ".join(args) + "\n\nSTDOUT:\n" + proc.stdout + "\n\nSTDERR:\n" + proc.stderr,
  84. encoding="utf-8",
  85. )
  86. parsed: Optional[Any] = None
  87. stdout = proc.stdout.strip()
  88. if stdout:
  89. try:
  90. parsed = json.loads(stdout)
  91. except Exception:
  92. parsed = None
  93. return {
  94. "step": step_name,
  95. "command": args,
  96. "returncode": proc.returncode,
  97. "started_at": started,
  98. "finished_at": datetime.now().isoformat(timespec="seconds"),
  99. "log_path": str(log_path),
  100. "json": parsed,
  101. "ok": proc.returncode == 0,
  102. }
  103. def feishu_config_status(config: Dict[str, Any]) -> Dict[str, Any]:
  104. project_cfg = Path.cwd() / FEISHU_CONFIG_NAME
  105. if not project_cfg.exists():
  106. return {
  107. "configured": False,
  108. "enabled": False,
  109. "required": bool(config.get("sync_feishu", True)),
  110. "message": "Local workbook updated; Feishu sync is waiting for feishu_sync_config.json.",
  111. }
  112. try:
  113. data = read_json(project_cfg)
  114. except Exception as exc:
  115. return {
  116. "configured": True,
  117. "enabled": False,
  118. "required": True,
  119. "error": str(exc),
  120. "message": "Feishu config exists but cannot be parsed.",
  121. }
  122. enabled = bool(data.get("enabled"))
  123. return {
  124. "configured": True,
  125. "enabled": enabled,
  126. "required": enabled,
  127. "config_path": str(project_cfg.resolve()),
  128. "summary_sheet_name": data.get("summary_sheet_name", SUMMARY_SHEET),
  129. "spreadsheet_url_present": bool(data.get("spreadsheet_url") or data.get("spreadsheet_token")),
  130. "message": "Agent must call the WorkBuddy lark-sheets plugin after local writeback." if enabled else "Feishu sync disabled by config.",
  131. }
  132. def build_plan(config: Dict[str, Any], stages: List[str], workbook_info: Dict[str, Any]) -> Dict[str, Any]:
  133. blocked_external: List[str] = []
  134. for key in ("send_email", "send_facebook_dm"):
  135. if config.get(key):
  136. blocked_external.append(key)
  137. return {
  138. "country": config.get("country"),
  139. "profile_id": config.get("profile_id"),
  140. "daily_outreach_target": config.get("daily_outreach_target"),
  141. "stages": stages,
  142. "workbook": {key: str(value) if isinstance(value, Path) else value for key, value in workbook_info.items()},
  143. "blocked_external_actions": blocked_external,
  144. "human_confirmation_required": config.get("human_confirmation_required", []),
  145. "notes": [
  146. "This workflow orchestrates safe local post-processing by default.",
  147. "Email sending and Facebook/LinkedIn outbound actions still require preview and explicit user confirmation.",
  148. "Any browser stage must use AdsPower + Playwright and must not close the browser.",
  149. ],
  150. }
  151. def main(argv: Optional[Sequence[str]] = None) -> int:
  152. parser = argparse.ArgumentParser(description="Run the Wuling dealer-expansion workflow orchestrator.")
  153. parser.add_argument("--config", default="", help="Project workflow config JSON. Default: ./workflow_config.json or skill example.")
  154. parser.add_argument("--excel", default="", help="Workbook path. Overrides config.excel.")
  155. parser.add_argument("--country", default="", help="Target country. Overrides config.country.")
  156. parser.add_argument("--profile-id", default="", help="AdsPower profile ID for browser stages. Overrides config.profile_id.")
  157. parser.add_argument("--stages", default="", help="Comma-separated stages. Default from config: summary,dashboard,feishu_check.")
  158. parser.add_argument("--run-id", default="", help="Run ID for artifacts.")
  159. parser.add_argument("--review-only", action="store_true", help="Only emit plan; do not write workbook or generate dashboard.")
  160. parser.add_argument("--write-workbook", action="store_true", help="Allow local workbook write stages such as summary rebuild.")
  161. parser.add_argument("--output", default="", help="Optional JSON report path.")
  162. args = parser.parse_args(argv)
  163. config = load_config(args.config)
  164. if args.excel:
  165. config["excel"] = args.excel
  166. if args.country:
  167. config["country"] = args.country
  168. if args.profile_id:
  169. config["profile_id"] = args.profile_id
  170. run_id = args.run_id or new_run_id("dealer_pipeline")
  171. out_dir = run_dir(run_id)
  172. out_dir.mkdir(parents=True, exist_ok=True)
  173. stages = parse_stages(args.stages, config)
  174. workbook_info = resolve_workbook_path(
  175. str(config.get("excel") or ""),
  176. create_from_template=bool(args.write_workbook),
  177. template_root=SKILL_ROOT,
  178. )
  179. plan = build_plan(config, stages, workbook_info)
  180. write_json(out_dir / "workflow-config-snapshot.json", config)
  181. write_json(out_dir / "workflow-plan.json", plan)
  182. report: Dict[str, Any] = {
  183. "run_id": run_id,
  184. "run_dir": str(out_dir),
  185. "config_found": config.get("_config_found", False),
  186. "config_path": config.get("_config_path", ""),
  187. "plan": plan,
  188. "steps": [],
  189. "feishu": {},
  190. "review_only": bool(args.review_only),
  191. }
  192. if workbook_info.get("source") == "missing":
  193. report["ok"] = False
  194. report["error"] = "No local workbook found. Use --excel, add a project workbook, or rerun with --write-workbook to copy the skill blank template."
  195. elif args.review_only:
  196. report["ok"] = True
  197. else:
  198. workbook_path = str(workbook_info["path"])
  199. if "summary" in stages:
  200. summary_report_path = resolve_artifact_path(
  201. "",
  202. kind="workflow_summary",
  203. default_name="summary-report.json",
  204. run_id=run_id,
  205. )
  206. cmd = [
  207. sys.executable,
  208. str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
  209. "--excel",
  210. workbook_path,
  211. "--output",
  212. str(summary_report_path),
  213. "--run-id",
  214. run_id,
  215. ]
  216. if args.write_workbook or bool(config.get("refresh_summary", True)):
  217. cmd.append("--write-summary")
  218. else:
  219. cmd.append("--dry-run")
  220. report["steps"].append(run_command(cmd, "summary", out_dir))
  221. if "dashboard" in stages:
  222. dashboard_html = resolve_artifact_path(
  223. "",
  224. kind="workflow_dashboard",
  225. default_name="customer_dashboard.html",
  226. run_id=run_id,
  227. )
  228. dashboard_json = resolve_artifact_path(
  229. "",
  230. kind="workflow_dashboard",
  231. default_name="customer_dashboard_data.json",
  232. run_id=run_id,
  233. )
  234. cmd = [
  235. sys.executable,
  236. str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
  237. "--excel",
  238. workbook_path,
  239. "--output-html",
  240. str(dashboard_html),
  241. "--output-json",
  242. str(dashboard_json),
  243. "--run-id",
  244. run_id,
  245. "--latest-dir",
  246. str(config.get("dashboard_latest_dir") or "dashboards/latest"),
  247. ]
  248. report["steps"].append(run_command(cmd, "dashboard", out_dir))
  249. if "facebook_outreach_preview" in stages:
  250. if not config.get("profile_id"):
  251. report["steps"].append({
  252. "step": "facebook_outreach_preview",
  253. "ok": False,
  254. "skipped": True,
  255. "reason": "profile_id is required for Facebook review planning.",
  256. })
  257. else:
  258. cmd = [
  259. sys.executable,
  260. str(SKILL_ROOT / "scripts" / "social" / "run_facebook_follow_dm.py"),
  261. "--excel",
  262. workbook_path,
  263. "--profile-id",
  264. str(config.get("profile_id")),
  265. "--review-only",
  266. "--run-id",
  267. run_id,
  268. ]
  269. report["steps"].append(run_command(cmd, "facebook_outreach_preview", out_dir))
  270. if "feishu_check" in stages:
  271. report["feishu"] = feishu_config_status(config)
  272. report["ok"] = all(step.get("ok") or step.get("skipped") for step in report["steps"])
  273. output_path = resolve_artifact_path(
  274. args.output,
  275. kind="workflow_report",
  276. default_name="dealer-pipeline-report.json",
  277. run_id=run_id,
  278. )
  279. write_json(output_path, report)
  280. report["report_path"] = str(output_path)
  281. print(json.dumps(report, ensure_ascii=False, indent=2))
  282. return 0 if report.get("ok") else 1
  283. if __name__ == "__main__":
  284. raise SystemExit(main())