Files
wehub-resource-sync 1d6fd29cda
tests / test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

646 lines
21 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.
#!/usr/bin/env python3
"""
simp-skill · Time Tracker
互动时间记录与分析 — 数据录入、查询、分析
用法:
python3 tools/time_tracker.py record <slug> <type> [options]
python3 tools/time_tracker.py analyze <slug> [--frequency|--milestones|--reply|--golden] [--output file]
"""
import argparse
import json
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
DEFAULT_BASE_DIR = Path("crushes")
VALID_INTERACTION_TYPES = frozenset({
"chat_sent",
"chat_received",
"meeting",
"call",
"online_interaction",
})
_DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
def _needs_leading_newline(path: Path) -> bool:
"""文件已存在、非空且不以换行结尾(上次写入被截断)时返回 True,
据此在追加前补一个前导换行,避免新记录被拼接进损坏的半行后一起丢失。"""
if not path.exists() or path.stat().st_size == 0:
return False
with path.open("rb") as f:
f.seek(-1, 2)
return f.read(1) != b"\n"
def record_interaction(
slug: str,
interaction_type: str,
data: dict[str, Any],
ts: datetime | None = None,
base_dir: Path = DEFAULT_BASE_DIR,
) -> None:
if interaction_type not in VALID_INTERACTION_TYPES:
raise ValueError(f"未知互动类型: {interaction_type}")
crush_dir = base_dir / slug
if not crush_dir.exists():
raise FileNotFoundError(f"档案不存在: {slug}")
if ts is None:
ts = datetime.now()
interactions_path = crush_dir / "interactions.jsonl"
# Dedup: same ts + same type → skip
if interactions_path.exists():
last_line = ""
with interactions_path.open("r", encoding="utf-8") as f:
for line in f:
stripped = line.strip()
if stripped:
last_line = stripped
if last_line:
try:
last_record = json.loads(last_line)
if last_record.get("ts") == ts.isoformat() and last_record.get("type") == interaction_type:
logger.info("⏭️ 重复记录已跳过")
return
except json.JSONDecodeError:
pass
computed = {
**data,
"hour": ts.hour,
"day_of_week": _DAY_NAMES[ts.weekday()],
}
if interaction_type == "chat_sent":
computed["is_initiator"] = True
elif interaction_type == "chat_received":
computed["is_initiator"] = False
record = {
"ts": ts.isoformat(),
"v": 1,
"type": interaction_type,
"slug": slug,
"data": computed,
}
prefix = "\n" if _needs_leading_newline(interactions_path) else ""
with interactions_path.open("a", encoding="utf-8") as f:
f.write(prefix + json.dumps(record, ensure_ascii=False) + "\n")
meta_path = crush_dir / "meta.json"
if meta_path.exists():
meta = json.loads(meta_path.read_text(encoding="utf-8"))
updated_meta = {
**meta,
"interaction_count": len(get_interactions(slug, base_dir=base_dir)),
"last_interaction": ts.isoformat(),
"updated_at": datetime.now().isoformat(),
}
meta_path.write_text(json.dumps(updated_meta, ensure_ascii=False, indent=2), encoding="utf-8")
def get_interactions(
slug: str,
days: int | None = None,
types: list[str] | None = None,
base_dir: Path = DEFAULT_BASE_DIR,
) -> list[dict[str, Any]]:
interactions_path = base_dir / slug / "interactions.jsonl"
if not interactions_path.exists():
return []
cutoff = datetime.now() - timedelta(days=days) if days else None
results: list[dict[str, Any]] = []
for idx, line in enumerate(interactions_path.read_text(encoding="utf-8").splitlines(), start=1):
stripped = line.strip()
if not stripped:
continue
try:
record = json.loads(stripped)
except json.JSONDecodeError:
logger.warning("⚠️ interactions.jsonl 第 %d 行损坏,已跳过:%.80s", idx, stripped)
continue
if types and record.get("type") not in types:
continue
if cutoff:
try:
record_ts = datetime.fromisoformat(record["ts"])
if record_ts < cutoff:
continue
except (ValueError, KeyError):
continue
results.append(record)
return results
def get_reply_times(
slug: str,
days: int = 30,
base_dir: Path = DEFAULT_BASE_DIR,
) -> list[dict[str, Any]]:
interactions = get_interactions(slug, days=days, types=["chat_received"], base_dir=base_dir)
return [i for i in interactions if "reply_delay_min" in i.get("data", {})]
def get_interaction_frequency(
slug: str,
days: int = 30,
base_dir: Path = DEFAULT_BASE_DIR,
) -> dict[str, Any]:
interactions = get_interactions(slug, days=days, base_dir=base_dir)
hour_counts: dict[int, int] = {}
dow_counts: dict[str, int] = {}
for interaction in interactions:
data = interaction.get("data", {})
hour = data.get("hour")
dow = data.get("day_of_week")
if hour is not None:
hour_counts[hour] = hour_counts.get(hour, 0) + 1
if dow:
dow_counts[dow] = dow_counts.get(dow, 0) + 1
return {
"total": len(interactions),
"by_hour": dict(sorted(hour_counts.items())),
"by_day_of_week": {d: dow_counts.get(d, 0) for d in _DAY_NAMES},
}
def analyze_timeline(
slug: str,
days: int = 30,
base_dir: Path = DEFAULT_BASE_DIR,
) -> dict[str, Any]:
interactions = get_interactions(slug, days=days, base_dir=base_dir)
if not interactions:
return {
"total": 0,
"active_days": 0,
"total_days": days,
"current_streak": 0,
"max_streak": 0,
"user_ratio": 0.0,
}
active_dates: set[str] = set()
user_count = 0
them_count = 0
for interaction in interactions:
ts_str = interaction.get("ts", "")[:10]
if ts_str:
active_dates.add(ts_str)
data = interaction.get("data", {})
if data.get("is_initiator") is True:
user_count += 1
elif data.get("is_initiator") is False:
them_count += 1
sorted_dates = sorted(active_dates)
max_streak = 1
current_streak = 1
for i in range(1, len(sorted_dates)):
prev = datetime.strptime(sorted_dates[i - 1], "%Y-%m-%d").date()
curr = datetime.strptime(sorted_dates[i], "%Y-%m-%d").date()
if (curr - prev).days == 1:
current_streak += 1
max_streak = max(max_streak, current_streak)
else:
current_streak = 1
today = datetime.now().strftime("%Y-%m-%d")
if sorted_dates and sorted_dates[-1] == today:
display_streak = current_streak
elif sorted_dates:
last_date = datetime.strptime(sorted_dates[-1], "%Y-%m-%d").date()
if (datetime.now().date() - last_date).days == 1:
display_streak = current_streak
else:
display_streak = 0
else:
display_streak = 0
total = len(interactions)
user_ratio = round(user_count / total * 100, 1) if total else 0.0
return {
"total": total,
"active_days": len(active_dates),
"total_days": days,
"current_streak": display_streak,
"max_streak": max(max_streak, 1) if sorted_dates else 0,
"user_count": user_count,
"them_count": them_count,
"user_ratio": user_ratio,
}
_REPLY_BUCKETS = [
("lte_5min", 0, 5),
("min_5_to_15", 5, 15),
("min_15_to_60", 15, 60),
("hr_1_to_4", 60, 240),
("gt_4h", 240, float("inf")),
]
def analyze_reply_times(
slug: str,
days: int = 30,
base_dir: Path = DEFAULT_BASE_DIR,
) -> dict[str, Any]:
replies = get_reply_times(slug, days=days, base_dir=base_dir)
if not replies:
return {
"total_replies": 0,
"average_min": None,
"median_min": None,
"distribution": {},
"weekly_trend": [],
}
delays = [r["data"]["reply_delay_min"] for r in replies if "reply_delay_min" in r.get("data", {})]
if not delays:
return {
"total_replies": 0,
"average_min": None,
"median_min": None,
"distribution": {},
"weekly_trend": [],
}
average_min = round(sum(delays) / len(delays), 1)
sorted_delays = sorted(delays)
median_min = sorted_delays[len(sorted_delays) // 2]
bucket_counts: dict[str, int] = {label: 0 for label, _, _ in _REPLY_BUCKETS}
for d in delays:
for label, lo, hi in _REPLY_BUCKETS:
if lo <= d < hi:
bucket_counts[label] += 1
break
total = len(delays)
distribution = {label: round(count / total * 100, 1) for label, count in bucket_counts.items()}
return {
"total_replies": total,
"average_min": average_min,
"median_min": median_min,
"distribution": distribution,
"weekly_trend": [],
}
def analyze_golden_hours(
slug: str,
days: int = 30,
base_dir: Path = DEFAULT_BASE_DIR,
) -> dict[str, Any]:
received = get_interactions(slug, days=days, types=["chat_received"], base_dir=base_dir)
if not received:
return {"peak_hour": None, "top_windows": [], "weekday_peak": None, "weekend_peak": None}
hour_counts: dict[int, int] = {}
weekday_hours: dict[int, int] = {}
weekend_hours: dict[int, int] = {}
for r in received:
data = r.get("data", {})
hour = data.get("hour")
dow = data.get("day_of_week", "")
if hour is None:
continue
hour_counts[hour] = hour_counts.get(hour, 0) + 1
if dow in ("sat", "sun"):
weekend_hours[hour] = weekend_hours.get(hour, 0) + 1
else:
weekday_hours[hour] = weekday_hours.get(hour, 0) + 1
peak_hour = max(hour_counts, key=hour_counts.get) if hour_counts else None
sorted_hours = sorted(hour_counts.items(), key=lambda x: -x[1])
top_windows = [{"hour": h, "count": c, "pct": round(c / len(received) * 100, 1)} for h, c in sorted_hours[:3]]
weekday_peak = max(weekday_hours, key=weekday_hours.get) if weekday_hours else None
weekend_peak = max(weekend_hours, key=weekend_hours.get) if weekend_hours else None
return {
"peak_hour": peak_hour,
"top_windows": top_windows,
"weekday_peak": weekday_peak,
"weekend_peak": weekend_peak,
}
_STAGE_BASELINES: dict[str, tuple[int, int]] = {
"破冰期": (7, 14),
"升温期": (10, 21),
"暧昧期": (14, 35),
"表白前": (7, 21),
"表白后-成功": (0, 0),
}
def analyze_milestones(
slug: str,
base_dir: Path = DEFAULT_BASE_DIR,
) -> dict[str, Any]:
crush_dir = base_dir / slug
profile_path = crush_dir / "profile.md"
if not profile_path.exists():
return {"stages": [], "total_days": 0}
profile_text = profile_path.read_text(encoding="utf-8")
created_at_str: str | None = None
if profile_text.startswith("---"):
parts = profile_text.split("---", 2)
if len(parts) >= 3:
for line in parts[1].strip().splitlines():
if line.strip().startswith("created_at:"):
created_at_str = line.split(":", 1)[1].strip().strip('"').strip("'")
break
if not created_at_str:
return {"stages": [], "total_days": 0}
try:
created_at = datetime.strptime(created_at_str[:10], "%Y-%m-%d").date()
except ValueError:
return {"stages": [], "total_days": 0}
events_path = crush_dir / "events.jsonl"
stage_transitions: list[dict[str, Any]] = []
if events_path.exists():
for line in events_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
event = json.loads(stripped)
except json.JSONDecodeError:
continue
if event.get("type") == "stage_changed":
ts_str = event.get("ts", "")[:10]
try:
ts_date = datetime.strptime(ts_str, "%Y-%m-%d").date()
except ValueError:
continue
stage_transitions.append({
"date": ts_date,
"from": event.get("data", {}).get("from", ""),
"to": event.get("data", {}).get("to", ""),
})
today = datetime.now().date()
total_days = (today - created_at).days
stages: list[dict[str, Any]] = []
prev_date = created_at
for i, transition in enumerate(stage_transitions):
days_in_stage = (transition["date"] - prev_date).days
stage_name = transition["from"]
baseline_lo, baseline_hi = _STAGE_BASELINES.get(stage_name, (0, 0))
status = "normal"
if baseline_lo > 0 and days_in_stage > baseline_hi:
status = "slow"
elif baseline_lo > 0 and days_in_stage < baseline_lo:
status = "fast"
stages.append({
"name": stage_name,
"start": prev_date.isoformat(),
"end": transition["date"].isoformat(),
"days": days_in_stage,
"baseline_lo": baseline_lo,
"baseline_hi": baseline_hi,
"status": status,
})
prev_date = transition["date"]
if stage_transitions:
current_stage_name = stage_transitions[-1]["to"]
else:
current_stage_name = "破冰期"
current_days = (today - prev_date).days
baseline_lo, baseline_hi = _STAGE_BASELINES.get(current_stage_name, (0, 0))
current_status = "normal"
if baseline_lo > 0 and current_days > baseline_hi:
current_status = "slow"
elif baseline_lo > 0 and current_days < baseline_lo:
current_status = "fast"
stages.append({
"name": current_stage_name,
"start": prev_date.isoformat(),
"end": None,
"days": current_days,
"baseline_lo": baseline_lo,
"baseline_hi": baseline_hi,
"status": current_status,
})
return {
"stages": stages,
"total_days": total_days,
"created_at": created_at.isoformat(),
}
def _bar(pct: float, width: int = 20) -> str:
filled = int(pct / 100 * width)
return "█" * filled + "░" * (width - filled)
def _format_frequency(tl: dict, freq: dict) -> str:
lines = [
"📊 互动频率分析",
f" 总互动次数: {tl['total']}",
f" 活跃天数: {tl['active_days']}/{tl['total_days']}",
f" 连续互动: 当前 {tl['current_streak']} 天 | 最长 {tl['max_streak']} 天",
f" 主动比例: {tl['user_ratio']}%",
"",
" 时段分布:",
]
by_hour = freq.get("by_hour", {})
max_count = max(by_hour.values()) if by_hour else 1
for hour in range(24):
count = by_hour.get(hour, 0)
if count > 0:
bar_width = int(count / max_count * 15)
lines.append(f" {hour:02d}:00 {'█' * bar_width} ({count})")
return "\n".join(lines)
def _format_milestones(ms: dict) -> str:
if not ms["stages"]:
return "🎯 追求进度追踪\n 暂无阶段数据"
lines = [
"🎯 追求进度追踪",
f" 总天数: {ms['total_days']} 天",
"",
]
for stage in ms["stages"]:
status_icon = {"fast": "⚡", "normal": "✅", "slow": "🐌"}.get(stage["status"], "❓")
end_str = stage["end"] or "进行中"
baseline_str = f"(基线 {stage['baseline_lo']}-{stage['baseline_hi']} 天)" if stage["baseline_lo"] > 0 else ""
lines.append(f" {status_icon} {stage['name']}: {stage['days']}{baseline_str}")
lines.append(f" {stage['start']}{end_str}")
return "\n".join(lines)
def _format_reply(rt: dict) -> str:
if rt["total_replies"] == 0:
return "⏱️ 回复时间分析\n 暂无回复数据"
lines = [
"⏱️ 回复时间分析",
f" 平均回复: {rt['average_min']:.0f} 分钟",
f" 中位回复: {rt['median_min']:.0f} 分钟",
"",
" 分布:",
]
labels = {
"lte_5min": "≤5分钟",
"min_5_to_15": "5-15分钟",
"min_15_to_60": "15-60分钟",
"hr_1_to_4": "1-4小时",
"gt_4h": ">4小时",
}
for key, label in labels.items():
pct = rt["distribution"].get(key, 0)
lines.append(f" {label:>8s} {_bar(pct)} {pct:.0f}%")
return "\n".join(lines)
def _format_golden(gh: dict) -> str:
if gh["peak_hour"] is None:
return "🌟 黄金时段建议\n 暂无数据"
lines = [
"🌟 黄金时段建议",
f" 最活跃时段: {gh['peak_hour']}:00",
"",
" 最佳发送窗口:",
]
for w in gh["top_windows"]:
lines.append(f" {w['hour']:02d}:00 ({w['count']} 次, {w['pct']:.0f}%)")
if gh["weekday_peak"] is not None:
lines.append(f" 工作日高峰: {gh['weekday_peak']}:00")
if gh["weekend_peak"] is not None:
lines.append(f" 周末高峰: {gh['weekend_peak']}:00")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="simp-skill · 互动时间追踪")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("record", help="记录互动")
p.add_argument("slug")
p.add_argument("type", choices=sorted(VALID_INTERACTION_TYPES))
p.add_argument("--summary", help="内容摘要")
p.add_argument("--duration", type=int, help="时长(分钟)")
p.add_argument("--activity", help="活动描述")
p.add_argument("--location", help="地点")
p.add_argument("--initiator", choices=["me", "them", "mutual"], help="发起方")
p.add_argument("--time", help="时间(ISO 格式,如 2026-05-15T22:30")
p.add_argument("--base-dir", default="crushes")
p = sub.add_parser("analyze", help="分析互动数据")
p.add_argument("slug")
p.add_argument("--frequency", action="store_true")
p.add_argument("--milestones", action="store_true")
p.add_argument("--reply", action="store_true")
p.add_argument("--golden", action="store_true")
p.add_argument("--output", help="导出 Markdown 报告")
p.add_argument("--days", type=int, default=30)
p.add_argument("--base-dir", default="crushes")
args = parser.parse_args()
base_dir = Path(args.base_dir)
if args.cmd == "record":
ts = datetime.fromisoformat(args.time) if args.time else None
data: dict[str, Any] = {}
if args.summary:
data["content_summary"] = args.summary
if args.duration:
data["duration_min"] = args.duration
if args.activity:
data["activity"] = args.activity
if args.location:
data["location"] = args.location
if args.initiator:
data["initiator"] = args.initiator
record_interaction(args.slug, args.type, data, ts=ts, base_dir=base_dir)
logger.info("✅ 互动已记录:%s", args.type)
elif args.cmd == "analyze":
slug = args.slug
base = Path(args.base_dir)
days = args.days
sections: list[str] = []
if args.frequency or not (args.frequency or args.milestones or args.reply or args.golden):
tl = analyze_timeline(slug, days=days, base_dir=base)
freq = get_interaction_frequency(slug, days=days, base_dir=base)
section = _format_frequency(tl, freq)
sections.append(section)
if args.milestones or not (args.frequency or args.milestones or args.reply or args.golden):
ms = analyze_milestones(slug, base_dir=base)
section = _format_milestones(ms)
sections.append(section)
if args.reply or not (args.frequency or args.milestones or args.reply or args.golden):
rt = analyze_reply_times(slug, days=days, base_dir=base)
section = _format_reply(rt)
sections.append(section)
if args.golden or not (args.frequency or args.milestones or args.reply or args.golden):
gh = analyze_golden_hours(slug, days=days, base_dir=base)
section = _format_golden(gh)
sections.append(section)
report = "\n\n".join(sections)
if args.output:
Path(args.output).write_text(report, encoding="utf-8")
logger.info("📊 报告已导出到 %s", args.output)
else:
print(report)
if __name__ == "__main__":
main()