build_dashboard.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Build a visual HTML dashboard from the consolidated outreach workbook."""
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import re
  8. import shutil
  9. import sys
  10. from collections import Counter
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import Any, Dict, List, Optional, Sequence, Tuple
  14. from openpyxl import load_workbook
  15. SCRIPT_DIR = Path(__file__).resolve().parent
  16. SKILL_ROOT = SCRIPT_DIR.parents[1]
  17. COMMON_DIR = SKILL_ROOT / "scripts" / "common"
  18. if str(COMMON_DIR) not in sys.path:
  19. sys.path.insert(0, str(COMMON_DIR))
  20. from artifact_manager import new_run_id, resolve_artifact_path # type: ignore # noqa: E402
  21. from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
  22. SUMMARY_SHEET_ALIASES = ["\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b"]
  23. CONVERSATION_SHEET = "Facebook\u5bf9\u8bdd\u8bb0\u5f55"
  24. DEFAULT_TITLE = "\u4e94\u83f1\u6d77\u5916\u5ba2\u6237\u5efa\u8054\u4e2d\u53f0"
  25. HEADER_ALIASES = {
  26. "company": ["\u516c\u53f8\u59d3\u540d", "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u540d\u79f0", "\u5ba2\u6237\u540d\u79f0", "\u516c\u53f8/\u5ba2\u6237", "Name", "Company"],
  27. "country": ["\u56fd\u5bb6", "Country"],
  28. "city": ["\u57ce\u5e02", "City"],
  29. "customer_type": ["\u5ba2\u6237\u7c7b\u578b", "\u7ec6\u5206\u5ba2\u6237\u7c7b\u578b", "Customer Type"],
  30. "website": ["\u5b98\u7f51\u94fe\u63a5", "\u516c\u53f8\u5b98\u7f51", "\u5b98\u7f51", "\u5b98\u65b9\u7f51\u7ad9", "Website"],
  31. "contact": ["\u8054\u7cfb\u4eba", "\u59d3\u540d", "Contact"],
  32. "position": ["\u804c\u4f4d", "\u804c\u52a1", "Position", "Title"],
  33. "personal_email": ["\u4e2a\u4eba\u90ae\u7bb1", "\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09"],
  34. "contact_phone": ["\u8054\u7cfb\u4eba\u7535\u8bdd", "\u4e2a\u4eba\u7535\u8bdd", "\u8054\u7cfb\u7535\u8bdd"],
  35. "facebook_link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "Facebook\u94fe\u63a5", "facebook\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5"],
  36. "linkedin_link": ["linkined\u4e3b\u9875\u94fe\u63a5", "LinkedIn\u4e3b\u9875\u94fe\u63a5", "LinkedIn\u94fe\u63a5", "linkin\u94fe\u63a5", "linkin\u8fde\u63a5"],
  37. "google_maps_link": ["google map\u94fe\u63a5", "Google Maps\u94fe\u63a5", "Google Map\u94fe\u63a5", "\u5730\u56fe\u94fe\u63a5"],
  38. "public_phone": ["\u516c\u5171\u7535\u8bdd/WhatsApp", "\u7535\u8bdd/WhatsApp", "\u516c\u53f8\u516c\u5171\u7535\u8bdd", "\u7535\u8bdd", "WhatsApp"],
  39. "public_email": ["\u516c\u5171\u90ae\u7bb1", "\u90ae\u7bb1", "\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09", "\u516c\u53f8\u516c\u5171\u90ae\u7bb1", "Email"],
  40. "attribute": ["\u5ba2\u6237\u5c5e\u6027", "\u5ba2\u6237\u5927\u7c7b", "Customer Attribute"],
  41. "lead_grade": ["\u7ebf\u7d22\u7b49\u7ea7", "Lead Grade", "Grade"],
  42. "manual_review": ["\u9700\u4eba\u5de5\u786e\u8ba4", "Manual Review"],
  43. "status": ["\u5efa\u8054\u72b6\u6001", "\u5efa\u8054\u60c5\u51b5", "\u72b6\u6001", "Status"],
  44. "next_followup": ["\u4e0b\u6b21\u8ddf\u8fdb", "\u4e0b\u6b21\u8ddf\u8fdb\u65f6\u95f4", "Next Follow-up"],
  45. "note": ["\u5907\u6ce8", "\u8bf4\u660e", "Notes"],
  46. }
  47. CONVERSATION_HEADER_ALIASES = {
  48. "record_id": ["\u8bb0\u5f55ID"],
  49. "customer_index": ["\u5ba2\u6237\u5e8f\u53f7"],
  50. "company": ["\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u59d3\u540d", "\u516c\u53f8\u540d\u79f0"],
  51. "facebook_link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5"],
  52. "message_time": ["\u6d88\u606f\u65f6\u95f4"],
  53. "direction": ["\u6d88\u606f\u65b9\u5411"],
  54. "translation": ["\u4e2d\u6587\u7ffb\u8bd1"],
  55. "effective": ["\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d"],
  56. "intent": ["\u5408\u4f5c\u610f\u5411"],
  57. "intent_reason": ["\u610f\u5411\u5224\u65ad\u4f9d\u636e"],
  58. "next_action": ["\u4e0b\u4e00\u6b65\u5efa\u8bae"],
  59. }
  60. CONTACTED_KEYWORDS = ["已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已发私信", "已关注并私信", "已建联", "等待回复", "已回复", "有意向"]
  61. BOUNCED_KEYWORDS = ["邮件退回", "退回", "拒收", "无法送达", "域名不存在", "邮箱不存在", "发送失败"]
  62. EXCLUDED_KEYWORDS = ["已剔除", "剔除", "跳过", "低优先级", "skip_"]
  63. REVIEW_KEYWORDS = ["待确认", "人工复核", "需人工", "需确认", "信息不足"]
  64. SOURCE_PATTERNS = [
  65. ("Facebook", re.compile(r"facebook", re.I)),
  66. ("LinkedIn", re.compile(r"linkedin|linkin", re.I)),
  67. ("Google Maps", re.compile(r"google\s*maps|google map|maps", re.I)),
  68. ("Moteur.ma", re.compile(r"moteur", re.I)),
  69. ("当地汽车网站", re.compile(r"当地汽车网站|OtoMoto|Wandaloo|Kerix|Kompass|Maroc Annuaire|Telecontact", re.I)),
  70. ("平台与行业渠道", re.compile(r"协会商会|平台与行业渠道|商会|协会")),
  71. ]
  72. def clean(value: Any) -> str:
  73. if value is None:
  74. return ""
  75. return re.sub(r"\s+", " ", str(value).strip())
  76. def find_sheet_name(wb, requested: str) -> str:
  77. if requested and requested in wb.sheetnames:
  78. return requested
  79. for candidate in SUMMARY_SHEET_ALIASES:
  80. if candidate in wb.sheetnames:
  81. return candidate
  82. raise ValueError(f"未找到客户信息汇总 Sheet,可用 Sheet:{', '.join(wb.sheetnames)}")
  83. def header_map(ws) -> Dict[str, int]:
  84. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  85. mapped: Dict[str, int] = {}
  86. for key, aliases in HEADER_ALIASES.items():
  87. for alias in aliases:
  88. if alias in raw:
  89. mapped[key] = raw[alias]
  90. break
  91. return mapped
  92. def row_value(ws, row: int, headers: Dict[str, int], key: str) -> str:
  93. col = headers.get(key)
  94. if not col:
  95. return ""
  96. return clean(ws.cell(row=row, column=col).value)
  97. def is_effective_row(row: Dict[str, str]) -> bool:
  98. evidence = ["company", "website", "facebook_link", "linkedin_link", "google_maps_link", "public_email", "personal_email", "public_phone", "contact_phone", "note"]
  99. return any(row.get(key) for key in evidence)
  100. def read_summary_rows(workbook_path: Path, sheet_name: str) -> Tuple[str, List[Dict[str, str]]]:
  101. wb = load_workbook(workbook_path, data_only=True, read_only=False)
  102. actual_sheet = find_sheet_name(wb, sheet_name)
  103. ws = wb[actual_sheet]
  104. headers = header_map(ws)
  105. rows: List[Dict[str, str]] = []
  106. for row_idx in range(2, ws.max_row + 1):
  107. row = {key: row_value(ws, row_idx, headers, key) for key in HEADER_ALIASES}
  108. row["_excel_row"] = str(row_idx)
  109. if is_effective_row(row):
  110. rows.append(row)
  111. return actual_sheet, rows
  112. def normalized_link(value: str) -> str:
  113. value = clean(value).casefold().split("?", 1)[0].rstrip("/")
  114. return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
  115. def customer_key(company: str, facebook_link: str, customer_index: str = "") -> str:
  116. link = normalized_link(facebook_link)
  117. if link:
  118. return "link:" + link
  119. name = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", "", clean(company).casefold())
  120. return "name:" + name if name else "index:" + clean(customer_index)
  121. def read_conversation_rows(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
  122. wb = load_workbook(workbook_path, data_only=True, read_only=False)
  123. if sheet_name not in wb.sheetnames:
  124. return []
  125. ws = wb[sheet_name]
  126. raw_headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  127. mapped: Dict[str, int] = {}
  128. for key, aliases in CONVERSATION_HEADER_ALIASES.items():
  129. for alias in aliases:
  130. if alias in raw_headers:
  131. mapped[key] = raw_headers[alias]
  132. break
  133. if "record_id" not in mapped:
  134. return []
  135. rows: List[Dict[str, str]] = []
  136. for row_idx in range(2, ws.max_row + 1):
  137. row = {
  138. key: clean(ws.cell(row=row_idx, column=column).value)
  139. for key, column in mapped.items()
  140. }
  141. if row.get("record_id"):
  142. rows.append(row)
  143. return rows
  144. def message_datetime(value: str) -> datetime:
  145. value = clean(value)
  146. if not value:
  147. return datetime.min
  148. try:
  149. return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
  150. except ValueError:
  151. match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", value)
  152. if match:
  153. return datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)))
  154. return datetime.min
  155. def build_conversation_insights(rows: Sequence[Dict[str, str]]) -> Dict[str, Any]:
  156. sent_customers = set()
  157. replied_customers = set()
  158. latest_by_customer: Dict[str, Dict[str, str]] = {}
  159. reply_trend: Counter = Counter()
  160. for row in sorted(rows, key=lambda item: message_datetime(item.get("message_time", ""))):
  161. key = customer_key(row.get("company", ""), row.get("facebook_link", ""), row.get("customer_index", ""))
  162. if row.get("direction") == "\u6211\u65b9\u53d1\u9001":
  163. sent_customers.add(key)
  164. if row.get("direction") != "\u5ba2\u6237\u56de\u590d" or row.get("effective") != "\u662f":
  165. continue
  166. replied_customers.add(key)
  167. latest_by_customer[key] = row
  168. parsed = message_datetime(row.get("message_time", ""))
  169. if parsed != datetime.min and (datetime.now() - parsed).days <= 30:
  170. reply_trend[parsed.strftime("%Y-%m-%d")] += 1
  171. reply_customers = [
  172. {
  173. "company": row.get("company", ""),
  174. "latest_reply": row.get("translation", "") or row.get("original", ""),
  175. "next_action": row.get("next_action", "") or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002",
  176. "message_time": row.get("message_time", ""),
  177. }
  178. for row in latest_by_customer.values()
  179. ]
  180. pending = [item for item in reply_customers if item.get("next_action")]
  181. trend = [
  182. {"name": day, "count": count}
  183. for day, count in sorted(reply_trend.items())
  184. ]
  185. return {
  186. "sent_customers": sent_customers,
  187. "replied_customers": replied_customers,
  188. "latest_by_customer": latest_by_customer,
  189. "reply_status_counter": Counter({"\u5df2\u56de\u590d": len(replied_customers), "\u672a\u56de\u590d": max(len(sent_customers) - len(replied_customers), 0)}),
  190. "reply_trend": trend,
  191. "reply_customers": sorted(reply_customers, key=lambda item: item.get("message_time", ""), reverse=True),
  192. "pending_review": sorted(pending, key=lambda item: item.get("message_time", ""), reverse=True),
  193. }
  194. def row_sources(row: Dict[str, str]) -> List[str]:
  195. sources: List[str] = []
  196. if row.get("facebook_link"):
  197. sources.append("Facebook")
  198. if row.get("linkedin_link"):
  199. sources.append("LinkedIn")
  200. if row.get("google_maps_link"):
  201. sources.append("Google Maps")
  202. note = " ".join([row.get("note", ""), row.get("website", "")])
  203. for label, pattern in SOURCE_PATTERNS:
  204. if pattern.search(note) and label not in sources:
  205. sources.append(label)
  206. return sources or ["未标明来源"]
  207. def pct(part: int, total: int) -> float:
  208. return round(part * 100 / total, 1) if total else 0.0
  209. def top_counter(counter: Counter, limit: int) -> List[Dict[str, Any]]:
  210. total = sum(counter.values())
  211. return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common(limit)]
  212. def has_any_link(row: Dict[str, str]) -> bool:
  213. return bool(row.get("website") or row.get("facebook_link") or row.get("linkedin_link") or row.get("google_maps_link"))
  214. def counter_to_items(counter: Counter) -> List[Dict[str, Any]]:
  215. total = sum(counter.values())
  216. return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common()]
  217. def contains_any(text: str, keywords: Sequence[str]) -> bool:
  218. lowered = text.casefold()
  219. return any(keyword.casefold() in lowered for keyword in keywords)
  220. def is_excluded(row: Dict[str, str]) -> bool:
  221. return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), EXCLUDED_KEYWORDS)
  222. def is_contactable(row: Dict[str, str]) -> bool:
  223. return (has_email(row) or has_phone(row) or has_any_link(row)) and not is_excluded(row)
  224. def is_contacted(row: Dict[str, str]) -> bool:
  225. text = " ".join([row.get("status", ""), row.get("note", "")])
  226. return contains_any(text, CONTACTED_KEYWORDS) and not is_bounced(row)
  227. def is_bounced(row: Dict[str, str]) -> bool:
  228. return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), BOUNCED_KEYWORDS)
  229. def needs_review(row: Dict[str, str]) -> bool:
  230. text = " ".join([row.get("customer_type", ""), row.get("attribute", ""), row.get("note", "")])
  231. return contains_any(text, REVIEW_KEYWORDS)
  232. def has_email(row: Dict[str, str]) -> bool:
  233. return bool(row.get("public_email") or row.get("personal_email"))
  234. def has_phone(row: Dict[str, str]) -> bool:
  235. return bool(row.get("public_phone") or row.get("contact_phone"))
  236. def display_contact(row: Dict[str, str]) -> str:
  237. values = [row.get("public_email"), row.get("personal_email"), row.get("public_phone"), row.get("contact_phone")]
  238. return next((value for value in values if value), "有主页/链接" if has_any_link(row) else "")
  239. def build_dashboard_data(
  240. rows: Sequence[Dict[str, str]],
  241. conversations: Sequence[Dict[str, str]],
  242. workbook_path: Path,
  243. sheet_name: str,
  244. run_id: str,
  245. top_n: int,
  246. ) -> Dict[str, Any]:
  247. valid_rows = [row for row in rows if not is_excluded(row)]
  248. contactable_rows = [row for row in valid_rows if is_contactable(row)]
  249. contacted_rows = [row for row in valid_rows if is_contacted(row)]
  250. bounced_rows = [row for row in rows if is_bounced(row)]
  251. review_rows = [row for row in valid_rows if needs_review(row)]
  252. email_rows = [row for row in valid_rows if has_email(row)]
  253. phone_rows = [row for row in valid_rows if has_phone(row)]
  254. conversation = build_conversation_insights(conversations)
  255. facebook_sent = len(conversation["sent_customers"])
  256. facebook_replied = len(conversation["replied_customers"])
  257. latest_replies = conversation["latest_by_customer"]
  258. attribute_counter = Counter(row.get("attribute") or "未填写" for row in valid_rows)
  259. type_counter = Counter(row.get("customer_type") or "未填写" for row in valid_rows)
  260. grade_counter = Counter(row.get("lead_grade") or "未填写" for row in valid_rows)
  261. status_counter = Counter(row.get("status") or "未填写" for row in rows)
  262. city_counter = Counter(row.get("city") or "未填写" for row in valid_rows)
  263. source_counter: Counter = Counter()
  264. for row in valid_rows:
  265. for source in row_sources(row):
  266. source_counter[source] += 1
  267. dashboard_rows = []
  268. for row in valid_rows:
  269. reply = latest_replies.get(customer_key(row.get("company", ""), row.get("facebook_link", "")), {})
  270. dashboard_rows.append({
  271. "row": row.get("_excel_row", ""),
  272. "company": row.get("company", ""),
  273. "city": row.get("city", ""),
  274. "attribute": row.get("attribute", ""),
  275. "type": row.get("customer_type", ""),
  276. "lead_grade": row.get("lead_grade", ""),
  277. "manual_review": row.get("manual_review", ""),
  278. "source": ";".join(row_sources(row)),
  279. "contact": display_contact(row),
  280. "status": row.get("status", ""),
  281. "has_email": has_email(row),
  282. "has_phone": has_phone(row),
  283. "contacted": is_contacted(row),
  284. "bounced": is_bounced(row),
  285. "review": needs_review(row),
  286. "note": row.get("note", "")[:220],
  287. "facebook_replied": bool(reply),
  288. "latest_reply": (reply.get("translation", "") or reply.get("original", ""))[:160],
  289. "next_action": (reply.get("next_action", "") or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002")[:160] if reply else "",
  290. })
  291. attention_rows = [row for row in dashboard_rows if row["contact"] and not row["contacted"] and not row["bounced"]][:top_n]
  292. return {
  293. "title": DEFAULT_TITLE,
  294. "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  295. "run_id": run_id,
  296. "workbook": str(workbook_path),
  297. "sheet_name": sheet_name,
  298. "metrics": {
  299. "total_rows": len(rows),
  300. "valid_customers": len(valid_rows),
  301. "excluded_customers": len(rows) - len(valid_rows),
  302. "contactable_customers": len(contactable_rows),
  303. "contacted_customers": len(contacted_rows),
  304. "outreach_rate": pct(len(contacted_rows), len(contactable_rows)),
  305. "email_customers": len(email_rows),
  306. "email_coverage_rate": pct(len(email_rows), len(valid_rows)),
  307. "phone_customers": len(phone_rows),
  308. "phone_coverage_rate": pct(len(phone_rows), len(valid_rows)),
  309. "bounced_customers": len(bounced_rows),
  310. "bounce_rate": pct(len(bounced_rows), len(email_rows)),
  311. "needs_review_customers": len(review_rows),
  312. "review_rate": pct(len(review_rows), len(valid_rows)),
  313. "facebook_dm_customers": facebook_sent,
  314. "facebook_reply_customers": facebook_replied,
  315. "facebook_reply_rate": pct(facebook_replied, facebook_sent),
  316. "facebook_pending_review_customers": len(conversation["pending_review"]),
  317. },
  318. "charts": {
  319. "attribute": top_counter(attribute_counter, top_n),
  320. "customer_type": top_counter(type_counter, top_n),
  321. "lead_grade": counter_to_items(grade_counter),
  322. "status": top_counter(status_counter, top_n),
  323. "source": top_counter(source_counter, top_n),
  324. "city": top_counter(city_counter, top_n),
  325. "facebook_reply_status": counter_to_items(conversation["reply_status_counter"]),
  326. "facebook_reply_trend": conversation["reply_trend"],
  327. },
  328. "dashboard_rows": dashboard_rows,
  329. "attention_customers": attention_rows,
  330. "facebook_reply_customers_detail": conversation["reply_customers"][:top_n],
  331. "facebook_pending_review_customers": conversation["pending_review"][:top_n],
  332. "definitions": {
  333. "有效客户": "总表中未被备注或状态标记为已剔除、跳过、低优先级的客户。",
  334. "可建联客户": "有效客户中至少有邮箱、电话/WhatsApp、官网、Facebook、LinkedIn 或 Google Maps 入口之一。",
  335. "已建联客户": "状态或备注包含已发送邮件、已发私信、已关注并私信、等待回复、已回复、已建联或有意向,且未被标记退信/拒收。",
  336. "建联率": "已建联客户数 / 可建联客户数。",
  337. "邮箱覆盖率": "有个人邮箱或公共邮箱的有效客户数 / 有效客户数。",
  338. "退信率": "邮件退回、拒收、无法送达或域名/邮箱不存在客户数 / 有邮箱客户数。",
  339. "Facebook回复率": "Facebook对话记录中有有效客户回复的唯一客户数 / 已发送Facebook私信的唯一客户数;系统消息和自动回复不计入。",
  340. "合作意向": "按每个客户最新一条有效回复计算五级意向,同一客户多条消息只计一次。",
  341. },
  342. }
  343. def render_html(data: Dict[str, Any]) -> str:
  344. template = (SKILL_ROOT / "assets" / "dashboard_template.html").read_text(encoding="utf-8")
  345. payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
  346. return template.replace("{{dashboard_json}}", payload)
  347. def write_outputs(data: Dict[str, Any], html_text: str, html_path: Path, json_path: Path, latest_dir: str = "") -> Dict[str, str]:
  348. html_path.parent.mkdir(parents=True, exist_ok=True)
  349. json_path.parent.mkdir(parents=True, exist_ok=True)
  350. html_path.write_text(html_text, encoding="utf-8")
  351. json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  352. copied_latest = ""
  353. if latest_dir:
  354. target_dir = Path(latest_dir).expanduser()
  355. if not target_dir.is_absolute():
  356. target_dir = Path.cwd() / target_dir
  357. target_dir.mkdir(parents=True, exist_ok=True)
  358. latest_html = target_dir / "customer_dashboard.html"
  359. latest_json = target_dir / "dashboard_data.json"
  360. shutil.copy2(html_path, latest_html)
  361. shutil.copy2(json_path, latest_json)
  362. copied_latest = str(latest_html)
  363. return {"html": str(html_path), "json": str(json_path), "latest_html": copied_latest}
  364. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  365. parser = argparse.ArgumentParser(description="Generate a Wuling outreach dashboard from 客户信息汇总表.")
  366. parser.add_argument("--excel", default="", help="Workbook path. If omitted, resolve the project workbook by skill rules.")
  367. parser.add_argument("--summary-sheet", default="客户信息汇总表", help="Summary sheet name.")
  368. parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET, help="Facebook conversation sheet name.")
  369. parser.add_argument("--output-html", default="", help="Optional HTML output path. Bare filename goes to runs/.")
  370. parser.add_argument("--output-json", default="", help="Optional JSON output path. Bare filename goes to runs/.")
  371. parser.add_argument("--run-id", default="", help="Run ID used for artifacts.")
  372. parser.add_argument("--top-n", type=int, default=12, help="Top N categories and attention rows.")
  373. parser.add_argument("--latest-dir", default="", help="Optional stable directory for a copy of the latest dashboard.")
  374. return parser.parse_args(argv)
  375. def main(argv: Optional[Sequence[str]] = None) -> int:
  376. args = parse_args(argv)
  377. run_id = args.run_id or new_run_id("dashboard")
  378. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  379. workbook_path = resolved.get("path")
  380. if not workbook_path:
  381. raise FileNotFoundError("No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.")
  382. workbook_path = Path(workbook_path)
  383. actual_sheet, rows = read_summary_rows(workbook_path, args.summary_sheet)
  384. conversations = read_conversation_rows(workbook_path, args.conversation_sheet)
  385. data = build_dashboard_data(rows, conversations, workbook_path, actual_sheet, run_id, args.top_n)
  386. html_text = render_html(data)
  387. html_path = resolve_artifact_path(args.output_html, kind="dashboard", default_name="customer_dashboard.html", run_id=run_id)
  388. json_path = resolve_artifact_path(args.output_json, kind="dashboard", default_name="dashboard_data.json", run_id=run_id)
  389. outputs = write_outputs(data, html_text, html_path, json_path, args.latest_dir)
  390. report = {
  391. "workbook": str(workbook_path),
  392. "summary_sheet": actual_sheet,
  393. "conversation_sheet": args.conversation_sheet if conversations else "",
  394. "conversation_rows": len(conversations),
  395. "run_id": run_id,
  396. "outputs": outputs,
  397. "metrics": data["metrics"],
  398. }
  399. print(json.dumps(report, ensure_ascii=False, indent=2))
  400. return 0
  401. if __name__ == "__main__":
  402. raise SystemExit(main())