372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""
|
||
聊天记录查看器
|
||
用法:
|
||
python scripts/chat_log_viewer.py # 列出所有客户
|
||
python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话
|
||
python scripts/chat_log_viewer.py -s <关键词> # 全局搜索
|
||
python scripts/chat_log_viewer.py -t <客户ID> # 只看今天
|
||
python scripts/chat_log_viewer.py -l # 实时监听最新消息(10条/刷新)
|
||
"""
|
||
import sys
|
||
from pathlib import Path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
import time
|
||
import os
|
||
from datetime import datetime
|
||
|
||
# 强制 UTF-8 输出(Windows 终端需要)
|
||
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||
import io
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||
|
||
from db import chat_log_db as db
|
||
|
||
# ========== ANSI 颜色 ==========
|
||
try:
|
||
import ctypes
|
||
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7)
|
||
except Exception:
|
||
pass
|
||
|
||
RESET = "\033[0m"
|
||
BOLD = "\033[1m"
|
||
DIM = "\033[2m"
|
||
GREEN = "\033[32m"
|
||
CYAN = "\033[36m"
|
||
YELLOW = "\033[33m"
|
||
BLUE = "\033[34m"
|
||
MAGENTA= "\033[35m"
|
||
RED = "\033[31m"
|
||
WHITE = "\033[97m"
|
||
BG_DARK= "\033[48;5;236m"
|
||
|
||
|
||
def clear():
|
||
os.system("cls" if os.name == "nt" else "clear")
|
||
|
||
|
||
def header(text: str):
|
||
width = 60
|
||
print(f"\n{BOLD}{CYAN}{'─' * width}{RESET}")
|
||
print(f"{BOLD}{CYAN} {text}{RESET}")
|
||
print(f"{BOLD}{CYAN}{'─' * width}{RESET}\n")
|
||
|
||
|
||
def fmt_time(ts: str) -> str:
|
||
"""缩短时间戳显示"""
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
if ts.startswith(today):
|
||
return ts[11:16] # 只显示 HH:MM
|
||
return ts[:16]
|
||
|
||
|
||
def platform_badge(platform: str) -> str:
|
||
badges = {
|
||
"AliWorkbench": f"{YELLOW}[淘宝]{RESET}",
|
||
"taobao": f"{YELLOW}[淘宝]{RESET}",
|
||
"pinduoduo": f"{RED}[拼多多]{RESET}",
|
||
"jd": f"{RED}[京东]{RESET}",
|
||
"wechat": f"{GREEN}[微信]{RESET}",
|
||
"email": f"{BLUE}[邮件]{RESET}",
|
||
}
|
||
return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "")
|
||
|
||
|
||
def print_bubble(direction: str, message: str, ts: str):
|
||
"""打印聊天气泡"""
|
||
time_str = fmt_time(ts)
|
||
lines = []
|
||
if "#*#" in (message or ""):
|
||
parts = [p.strip() for p in message.split("#*#") if p.strip()]
|
||
if parts:
|
||
lines = parts
|
||
if not lines:
|
||
lines = (message or "").split("\n")
|
||
|
||
if direction == "in": # 客户来消息 → 左对齐
|
||
print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}")
|
||
for line in lines:
|
||
print(f" {BG_DARK} {line} {RESET}")
|
||
else: # 客服回复 → 右对齐(缩进)
|
||
print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}")
|
||
for line in lines:
|
||
print(f" {GREEN}> {line}{RESET}")
|
||
print()
|
||
|
||
|
||
def cmd_list_customers():
|
||
"""列出所有客户"""
|
||
customers = db.get_customers(limit=100)
|
||
if not customers:
|
||
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||
return
|
||
|
||
header(f"客户列表 共 {len(customers)} 人")
|
||
print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}")
|
||
print(f" {'─'*4} {'─'*24} {'─'*12} {'─'*10} {'─'*6} {'─'*16}")
|
||
for i, c in enumerate(customers, 1):
|
||
badge = platform_badge(c.get("platform", ""))
|
||
name = (c.get("customer_name") or "")[:10]
|
||
cid = c["customer_id"]
|
||
total = c["total_msgs"]
|
||
last = c.get("last_time", "")[:16]
|
||
print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}条 {DIM}{last}{RESET}")
|
||
|
||
print(f"\n{DIM}用法:python chat_log_viewer.py <客户ID>{RESET}\n")
|
||
|
||
|
||
def cmd_show_conversation(customer_id: str, today_only: bool = False):
|
||
"""显示某客户对话"""
|
||
if today_only:
|
||
messages = db.get_conversation_today(customer_id)
|
||
title = f"今日对话 {customer_id}"
|
||
else:
|
||
messages = db.get_conversation(customer_id, limit=300)
|
||
title = f"对话记录 {customer_id}"
|
||
|
||
if not messages:
|
||
print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}")
|
||
return
|
||
|
||
header(f"{title} ({len(messages)} 条)")
|
||
|
||
last_date = ""
|
||
for m in messages:
|
||
ts = m.get("timestamp", "")
|
||
date = ts[:10]
|
||
if date != last_date:
|
||
print(f" {DIM}{'─'*20} {date} {'─'*20}{RESET}")
|
||
last_date = date
|
||
print_bubble(m["direction"], m["message"], ts)
|
||
|
||
print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n")
|
||
|
||
|
||
def cmd_search(keyword: str, customer_id: str = None):
|
||
"""搜索关键词"""
|
||
results = db.search_messages(keyword, customer_id=customer_id, limit=50)
|
||
title = f"搜索 [{keyword}]"
|
||
if customer_id:
|
||
title += f" 客户:{customer_id}"
|
||
header(f"{title} 共 {len(results)} 条")
|
||
|
||
if not results:
|
||
print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}")
|
||
return
|
||
|
||
last_cid = ""
|
||
for r in results:
|
||
cid = r["customer_id"]
|
||
if cid != last_cid:
|
||
print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}")
|
||
last_cid = cid
|
||
direction = "买家" if r["direction"] == "in" else "客服"
|
||
color = WHITE if r["direction"] == "in" else GREEN
|
||
# 高亮关键词
|
||
msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}")
|
||
print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}")
|
||
print()
|
||
|
||
|
||
def cmd_live(refresh: int = 3):
|
||
"""实时监听最新消息"""
|
||
header("实时消息监听 Ctrl+C 退出")
|
||
seen_ids = set()
|
||
|
||
try:
|
||
while True:
|
||
import sqlite3
|
||
conn = sqlite3.connect(db._DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute("""
|
||
SELECT id, customer_id, customer_name, direction, message, timestamp
|
||
FROM chat_logs
|
||
ORDER BY id DESC LIMIT 20
|
||
""").fetchall()
|
||
conn.close()
|
||
|
||
new_rows = [dict(r) for r in rows if r["id"] not in seen_ids]
|
||
if new_rows:
|
||
new_rows.reverse()
|
||
for r in new_rows:
|
||
seen_ids.add(r["id"])
|
||
cid = r["customer_id"]
|
||
name = r.get("customer_name") or ""
|
||
label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "")
|
||
print(f"\n{label}")
|
||
print_bubble(r["direction"], r["message"], r["timestamp"])
|
||
else:
|
||
print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True)
|
||
|
||
time.sleep(refresh)
|
||
except KeyboardInterrupt:
|
||
print(f"\n{DIM}已退出监听。{RESET}")
|
||
|
||
|
||
def _extract_urls(msg: str) -> list:
|
||
if not msg:
|
||
return []
|
||
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
|
||
urls = []
|
||
for p in parts:
|
||
if p.startswith("http://") or p.startswith("https://"):
|
||
urls.append(p)
|
||
if not urls and ("http://" in msg or "https://" in msg):
|
||
import re as _re
|
||
tokens = _re.findall(r'(https?://\S+)', msg)
|
||
for t in tokens:
|
||
tl = t.lower()
|
||
if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
|
||
urls.append(t)
|
||
return urls
|
||
|
||
|
||
def _msg_refers_images(msg: str) -> bool:
|
||
if not msg:
|
||
return False
|
||
refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张")
|
||
return any(r in msg for r in refs)
|
||
|
||
|
||
def _parse_ts(ts: str):
|
||
try:
|
||
from datetime import datetime as _dt
|
||
return _dt.fromisoformat(ts.replace("Z",""))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def analyze_conversation(messages: list) -> list:
|
||
issues = []
|
||
n = len(messages)
|
||
for i, m in enumerate(messages):
|
||
msg = m.get("message") or ""
|
||
dir = m.get("direction")
|
||
ts = _parse_ts(m.get("timestamp",""))
|
||
# 图片后未及时回复
|
||
if dir == "in" and _extract_urls(msg):
|
||
replied = False
|
||
delay_ok = True
|
||
for j in range(i+1, min(i+6, n)):
|
||
mj = messages[j]
|
||
if mj.get("direction") == "out":
|
||
replied = True
|
||
tsj = _parse_ts(mj.get("timestamp",""))
|
||
if ts and tsj and (tsj - ts).total_seconds() > 180:
|
||
delay_ok = False
|
||
break
|
||
if not replied:
|
||
issues.append("图片消息后未回复")
|
||
elif not delay_ok:
|
||
issues.append("图片消息后回复延迟超过3分钟")
|
||
# 引用图片但找不到历史图片
|
||
if dir == "in" and _msg_refers_images(msg):
|
||
has_prev_img = False
|
||
for k in range(max(0, i-10), i):
|
||
if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")):
|
||
has_prev_img = True
|
||
break
|
||
if not has_prev_img:
|
||
issues.append("引用图片但历史中未找到对应图片")
|
||
# 订单后未确认/引导
|
||
if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg):
|
||
confirmed = False
|
||
for j in range(i+1, min(i+6, n)):
|
||
if messages[j].get("direction") == "out":
|
||
confirmed = True
|
||
break
|
||
if not confirmed:
|
||
issues.append("订单消息后未进行确认或引导付款")
|
||
# 合成需求未报价格
|
||
if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")):
|
||
priced = False
|
||
for j in range(i+1, min(i+6, n)):
|
||
mj = messages[j]
|
||
if mj.get("direction") == "out":
|
||
rm = mj.get("message","")
|
||
if "元" in rm:
|
||
priced = True
|
||
break
|
||
if not priced:
|
||
issues.append("客户提出合成需求但未给出价格")
|
||
# 去重
|
||
dedup = []
|
||
seen = set()
|
||
for it in issues:
|
||
if it not in seen:
|
||
seen.add(it)
|
||
dedup.append(it)
|
||
return dedup
|
||
|
||
|
||
def cmd_analyze_all():
|
||
customers = db.get_customers(limit=200)
|
||
if not customers:
|
||
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||
return
|
||
header("聊天记录上下文分析")
|
||
total_issues = 0
|
||
for c in customers:
|
||
cid = c["customer_id"]
|
||
msgs = db.get_conversation(cid, limit=500)
|
||
issues = analyze_conversation(msgs)
|
||
if issues:
|
||
total_issues += len(issues)
|
||
print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}")
|
||
for s in issues:
|
||
print(f" - {RED}{s}{RESET}")
|
||
print()
|
||
if total_issues == 0:
|
||
print(f"{GREEN}未发现明显异常。{RESET}")
|
||
else:
|
||
print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}")
|
||
|
||
|
||
def print_help():
|
||
print(f"""
|
||
{BOLD}聊天记录查看器{RESET}
|
||
|
||
{CYAN}python chat_log_viewer.py{RESET} 列出所有客户
|
||
{CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话
|
||
{CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话
|
||
{CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索
|
||
{CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息
|
||
{CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项
|
||
{CYAN}python chat_log_viewer.py -h{RESET} 显示帮助
|
||
""")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
args = sys.argv[1:]
|
||
|
||
if not args:
|
||
cmd_list_customers()
|
||
|
||
elif args[0] in ("-h", "--help"):
|
||
print_help()
|
||
|
||
elif args[0] == "-s":
|
||
keyword = args[1] if len(args) > 1 else ""
|
||
if not keyword:
|
||
print(f"{RED}请提供搜索关键词:python chat_log_viewer.py -s <关键词>{RESET}")
|
||
else:
|
||
cmd_search(keyword)
|
||
|
||
elif args[0] == "-t":
|
||
cid = args[1] if len(args) > 1 else ""
|
||
if not cid:
|
||
print(f"{RED}请提供客户ID:python chat_log_viewer.py -t <客户ID>{RESET}")
|
||
else:
|
||
cmd_show_conversation(cid, today_only=True)
|
||
|
||
elif args[0] == "-l":
|
||
cmd_live()
|
||
|
||
elif args[0] == "-a":
|
||
cmd_analyze_all()
|
||
|
||
else:
|
||
cmd_show_conversation(args[0])
|