项目文件夹

文件
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

646 行
21 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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()