mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-20 14:50:50 +08:00
feat(wrapped): 增加月度好友墙卡片
- 新增月度好友墙卡片(chat/monthly_best_friends_wall):按月评选聊天搭子并输出评分维度 - 前端新增拍立得墙展示 12 个月获胜者与指标条,支持头像失败降级 - Wrapped deck 插入新卡片;emoji 卡片 id 顺延为 5,并同步更新测试 - Wrapped 页面默认展示上一年;切换年份时保持当前页并按需懒加载卡片 - WrappedCardShell(slide)支持 wide 布局;更新 wrapped cache version
This commit is contained in:
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-wrap justify-center gap-x-3 gap-y-4 px-3 py-2">
|
||||||
|
<article
|
||||||
|
v-for="item in months"
|
||||||
|
:key="`month-${item.month}`"
|
||||||
|
class="relative flex-shrink-0 monthly-polaroid"
|
||||||
|
:class="item.winner ? '' : 'monthly-polaroid--empty'"
|
||||||
|
:style="monthCardStyle(item.month)"
|
||||||
|
>
|
||||||
|
<!-- 有获胜者 -->
|
||||||
|
<template v-if="item.winner">
|
||||||
|
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div class="polaroid-photo flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
|
||||||
|
:src="winnerAvatar(item)"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="avatarOk[item.winner.username] = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="wrapped-number text-xl select-none" style="color:var(--accent)">
|
||||||
|
{{ avatarFallback(item.winner.displayName) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 右列:姓名 / 月份 / 综合分 / 4 维度 -->
|
||||||
|
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between gap-1 min-w-0">
|
||||||
|
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
|
||||||
|
{{ item.winner.displayName }}
|
||||||
|
</div>
|
||||||
|
<!-- 月份徽章 -->
|
||||||
|
<div class="month-badge wrapped-number text-[8px] font-bold flex-shrink-0" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)' }">
|
||||||
|
{{ item.month }}月
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 wrapped-number text-[9px] font-semibold" :style="{ color: 'var(--accent)' }">
|
||||||
|
综合分 {{ formatScore(item.winner.score100) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 4 维度 2×2 -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||||
|
<div v-for="metric in metricRows(item)" :key="metric.key" class="min-w-0">
|
||||||
|
<div class="flex items-center justify-between wrapped-label text-[8px] text-[#00000066]">
|
||||||
|
<span class="truncate">{{ metric.label }}</span>
|
||||||
|
<span v-if="metric.pct !== 100" class="wrapped-number flex-shrink-0 ml-0.5">{{ metric.pct }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 h-1 rounded-full bg-[#0000000D] overflow-hidden">
|
||||||
|
<div class="h-full rounded-full" :style="{ width: `${metric.pct}%`, backgroundColor: 'var(--accent)', opacity: '0.75' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 统计行 -->
|
||||||
|
<div class="polaroid-caption">
|
||||||
|
<div class="wrapped-body text-[9px] text-[#00000055] leading-snug">
|
||||||
|
共 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.totalMessages) }}</span> 条 ·
|
||||||
|
互动 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.interaction) }}</span> 次 ·
|
||||||
|
活跃 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.activeDays) }}</span> 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 无数据:空白拍立得 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="polaroid-photo-empty flex-shrink-0 mx-auto">
|
||||||
|
<span class="text-lg select-none" style="color:var(--accent);opacity:0.25">〜</span>
|
||||||
|
</div>
|
||||||
|
<div class="polaroid-caption">
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<div class="wrapped-label text-[9px] text-[#00000044]">本月静悄悄</div>
|
||||||
|
<div class="month-badge wrapped-number text-[8px]" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)', opacity: '0.5' }">
|
||||||
|
{{ item.month }}月
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WrappedCardShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: { type: Object, required: true },
|
||||||
|
variant: { type: String, default: 'panel' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||||
|
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||||
|
const formatScore = (n) => {
|
||||||
|
const x = Number(n)
|
||||||
|
if (!Number.isFinite(x)) return '0.0'
|
||||||
|
return x.toFixed(1)
|
||||||
|
}
|
||||||
|
const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100)))
|
||||||
|
|
||||||
|
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||||
|
const resolveMediaUrl = (value) => {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
|
try {
|
||||||
|
const host = new URL(raw).hostname.toLowerCase()
|
||||||
|
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||||
|
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarFallback = (name) => {
|
||||||
|
const s = String(name || '').trim()
|
||||||
|
return s ? s[0] : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = computed(() => {
|
||||||
|
const raw = Array.isArray(props.card?.data?.months) ? props.card.data.months : []
|
||||||
|
const byMonth = new Map()
|
||||||
|
for (const x of raw) {
|
||||||
|
const m = Number(x?.month)
|
||||||
|
if (Number.isFinite(m) && m >= 1 && m <= 12) byMonth.set(m, x)
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
for (let m = 1; m <= 12; m += 1) {
|
||||||
|
out.push(byMonth.get(m) || { month: m, winner: null, metrics: null, raw: null })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarOk = reactive({})
|
||||||
|
watch(
|
||||||
|
months,
|
||||||
|
() => { for (const key of Object.keys(avatarOk)) delete avatarOk[key] },
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const winnerAvatar = (item) => resolveMediaUrl(item?.winner?.avatarUrl)
|
||||||
|
|
||||||
|
const metricRows = (item) => {
|
||||||
|
const m = item?.metrics || {}
|
||||||
|
return [
|
||||||
|
{ key: 'interaction', label: '互动', pct: clampPct(m.interactionScore) },
|
||||||
|
{ key: 'speed', label: '速度', pct: clampPct(m.speedScore) },
|
||||||
|
{ key: 'continuity', label: '连续', pct: clampPct(m.continuityScore) },
|
||||||
|
{ key: 'coverage', label: '覆盖', pct: clampPct(m.coverageScore) }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12 个月各自独立 accent 色,驱动胶带、徽章、进度条
|
||||||
|
const accents = [
|
||||||
|
'#C96A4E', // 1月 砖红
|
||||||
|
'#5B82C4', // 2月 矢车菊蓝
|
||||||
|
'#4EA87A', // 3月 薄荷绿
|
||||||
|
'#C4953A', // 4月 琥珀金
|
||||||
|
'#8B65B5', // 5月 薰衣草紫
|
||||||
|
'#3A9FB5', // 6月 孔雀蓝
|
||||||
|
'#C45F7A', // 7月 玫瑰粉
|
||||||
|
'#3E7FC4', // 8月 天蓝
|
||||||
|
'#6AA86A', // 9月 苔绿
|
||||||
|
'#C47A3A', // 10月 暖橙
|
||||||
|
'#9B6BAF', // 11月 丁香紫
|
||||||
|
'#4A8EB5', // 12月 冬湖蓝
|
||||||
|
]
|
||||||
|
|
||||||
|
const monthCardStyle = (month) => {
|
||||||
|
const idx = Math.max(0, Math.min(11, Number(month || 1) - 1))
|
||||||
|
const rotations = [-9, 5, -4, 11, -2, 8, -7, 3, -10, 6, -3, 9]
|
||||||
|
const yOffsets = [5, -4, 6, -5, 3, -7, 3, -4, 7, -3, 5, -4]
|
||||||
|
const widths = [172, 165, 178, 168, 175, 163, 172, 167, 165, 178, 165, 170]
|
||||||
|
return {
|
||||||
|
transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`,
|
||||||
|
width: `${widths[idx]}px`,
|
||||||
|
'--delay': `${idx * 0.07}s`,
|
||||||
|
'--accent': accents[idx],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── 拍立得卡片基础 ── */
|
||||||
|
.monthly-polaroid {
|
||||||
|
background: #FFFDF7; /* 暖奶油底色 */
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 1px rgba(0,0,0,0.06),
|
||||||
|
0 4px 12px rgba(0,0,0,0.10),
|
||||||
|
0 12px 28px rgba(0,0,0,0.08);
|
||||||
|
animation: cardAppear 0.4s ease-out both;
|
||||||
|
animation-delay: var(--delay, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardAppear {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空月卡片底色更浅 */
|
||||||
|
.monthly-polaroid--empty {
|
||||||
|
background: #F7F5F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 彩色胶带条 ── */
|
||||||
|
.monthly-polaroid::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: 50%;
|
||||||
|
width: 38px;
|
||||||
|
height: 14px;
|
||||||
|
transform: translateX(-50%) rotate(-1deg);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent, #c8a060);
|
||||||
|
opacity: 0.55;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 头像区域 ── */
|
||||||
|
.polaroid-photo {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
background: #e0ddd8;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px; /* 照片圆角,更自然 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 空月占位图 ── */
|
||||||
|
.polaroid-photo-empty {
|
||||||
|
width: 70px;
|
||||||
|
height: 44px;
|
||||||
|
background: #E8E5DF;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 月份小徽章 ── */
|
||||||
|
.month-badge {
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 底部信息条 ── */
|
||||||
|
.polaroid-caption {
|
||||||
|
padding: 5px 5px 6px;
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.04); /* 细分隔线,区分照片与文字区 */
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -20,7 +20,12 @@
|
|||||||
|
|
||||||
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
|
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
|
||||||
<section v-else class="relative h-full w-full overflow-hidden">
|
<section v-else class="relative h-full w-full overflow-hidden">
|
||||||
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
|
<div
|
||||||
|
class="relative h-full flex flex-col"
|
||||||
|
:class="wide
|
||||||
|
? 'px-10 pt-20 pb-12 sm:px-14 sm:pt-24 sm:pb-14 lg:px-20 xl:px-20 2xl:px-40'
|
||||||
|
: 'max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
|
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
|
||||||
@@ -47,6 +52,9 @@ defineProps({
|
|||||||
cardId: { type: Number, required: true },
|
cardId: { type: Number, required: true },
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
narrative: { type: String, default: '' },
|
narrative: { type: String, default: '' },
|
||||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
|
||||||
|
// Slide 模式下是否取消 max-width 限制(让内容直接铺满页面宽度)。
|
||||||
|
// 用于需要横向展示的可视化(如年度日历热力图)。
|
||||||
|
wide: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -242,6 +242,10 @@ const PREVIEW_BY_KIND = {
|
|||||||
summary: '回复速度',
|
summary: '回复速度',
|
||||||
question: '谁是你愿意秒回的那个人?'
|
question: '谁是你愿意秒回的那个人?'
|
||||||
},
|
},
|
||||||
|
'chat/monthly_best_friends_wall': {
|
||||||
|
summary: '月度好友墙',
|
||||||
|
question: '每个月谁是你最有默契的聊天搭子?'
|
||||||
|
},
|
||||||
'emoji/annual_universe': {
|
'emoji/annual_universe': {
|
||||||
summary: '梗图年鉴',
|
summary: '梗图年鉴',
|
||||||
question: '你这一年最常丢出的表情包是哪张?'
|
question: '你这一年最常丢出的表情包是哪张?'
|
||||||
|
|||||||
@@ -163,8 +163,14 @@
|
|||||||
variant="slide"
|
variant="slide"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
<Card04MonthlyBestFriendsWall
|
||||||
|
v-else-if="c && (c.kind === 'chat/monthly_best_friends_wall' || c.id === 4)"
|
||||||
|
:card="c"
|
||||||
|
variant="slide"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
<Card04EmojiUniverse
|
<Card04EmojiUniverse
|
||||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
|
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 5)"
|
||||||
:card="c"
|
:card="c"
|
||||||
variant="slide"
|
variant="slide"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
@@ -199,7 +205,9 @@ const api = useApi()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const year = ref(Number(route.query?.year) || new Date().getFullYear())
|
const queryYear = Number(route.query?.year)
|
||||||
|
const defaultYear = new Date().getFullYear() - 1
|
||||||
|
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
|
||||||
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
||||||
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
||||||
|
|
||||||
@@ -459,9 +467,10 @@ const retryCard = async (cardId) => {
|
|||||||
await ensureCardLoaded(cardId)
|
await ensureCardLoaded(cardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reload = async (forceRefresh = false) => {
|
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
||||||
const token = ++reportToken
|
const token = ++reportToken
|
||||||
activeIndex.value = 0
|
const keepIndex = preserveIndex ? activeIndex.value : 0
|
||||||
|
if (!preserveIndex) activeIndex.value = 0
|
||||||
error.value = ''
|
error.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
refreshCards.value = !!forceRefresh
|
refreshCards.value = !!forceRefresh
|
||||||
@@ -502,6 +511,15 @@ const reload = async (forceRefresh = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
|
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
|
||||||
|
|
||||||
|
if (preserveIndex) {
|
||||||
|
activeIndex.value = clampIndex(keepIndex)
|
||||||
|
const cardIdx = Number(activeIndex.value) - 1
|
||||||
|
if (cardIdx >= 0) {
|
||||||
|
const id = Number(report.value?.cards?.[cardIdx]?.id)
|
||||||
|
if (Number.isFinite(id)) void ensureCardLoaded(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (token !== reportToken) return
|
if (token !== reportToken) return
|
||||||
report.value = null
|
report.value = null
|
||||||
@@ -576,7 +594,7 @@ watch(year, async (newYear, oldYear) => {
|
|||||||
year.value = oldYear
|
year.value = oldYear
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await reload()
|
await reload(false, true)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ def build_card_04_emoji_universe(*, account_dir: Path, year: int) -> dict[str, A
|
|||||||
narrative = "".join(parts)
|
narrative = "".join(parts)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": 4,
|
"id": 5,
|
||||||
"title": "这一年,你的表情包里藏了多少心情?",
|
"title": "这一年,你的表情包里藏了多少心情?",
|
||||||
"scope": "global",
|
"scope": "global",
|
||||||
"category": "B",
|
"category": "B",
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...chat_helpers import (
|
||||||
|
_build_avatar_url,
|
||||||
|
_load_contact_rows,
|
||||||
|
_pick_display_name,
|
||||||
|
_should_keep_session,
|
||||||
|
)
|
||||||
|
from ...chat_search_index import (
|
||||||
|
get_chat_search_index_db_path,
|
||||||
|
get_chat_search_index_status,
|
||||||
|
start_chat_search_index_build,
|
||||||
|
)
|
||||||
|
from ...logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
|
||||||
|
start = int(datetime(year, 1, 1).timestamp())
|
||||||
|
end = int(datetime(year + 1, 1, 1).timestamp())
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_name(name: str) -> str:
|
||||||
|
s = str(name or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
if len(s) == 1:
|
||||||
|
return "*"
|
||||||
|
if len(s) == 2:
|
||||||
|
return s[0] + "*"
|
||||||
|
return s[0] + ("*" * (len(s) - 2)) + s[-1]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _MonthConvAgg:
|
||||||
|
username: str
|
||||||
|
month: int
|
||||||
|
incoming: int = 0
|
||||||
|
outgoing: int = 0
|
||||||
|
replies: int = 0
|
||||||
|
sum_gap: int = 0
|
||||||
|
sum_gap_capped: int = 0
|
||||||
|
active_days: set[int] = field(default_factory=set)
|
||||||
|
time_bucket_mask: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return int(self.incoming) + int(self.outgoing)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interaction(self) -> int:
|
||||||
|
return min(int(self.incoming), int(self.outgoing))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_days_count(self) -> int:
|
||||||
|
return len(self.active_days)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_bucket_count(self) -> int:
|
||||||
|
m = int(self.time_bucket_mask) & 0xF
|
||||||
|
return (m & 1) + ((m >> 1) & 1) + ((m >> 2) & 1) + ((m >> 3) & 1)
|
||||||
|
|
||||||
|
def avg_reply_seconds(self) -> float:
|
||||||
|
if self.replies <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(self.sum_gap) / float(self.replies)
|
||||||
|
|
||||||
|
def avg_reply_seconds_capped(self) -> float:
|
||||||
|
if self.replies <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(self.sum_gap_capped) / float(self.replies)
|
||||||
|
|
||||||
|
def observe(self, *, day: int, hour: int) -> None:
|
||||||
|
if 1 <= day <= 31:
|
||||||
|
self.active_days.add(int(day))
|
||||||
|
bucket = max(0, min(3, int(hour) // 6))
|
||||||
|
self.time_bucket_mask |= 1 << bucket
|
||||||
|
|
||||||
|
|
||||||
|
def _score_month_agg(
|
||||||
|
*,
|
||||||
|
agg: _MonthConvAgg,
|
||||||
|
month_max_interaction: int,
|
||||||
|
month_max_active_days: int,
|
||||||
|
tau_seconds: float,
|
||||||
|
weights: dict[str, float],
|
||||||
|
) -> dict[str, float]:
|
||||||
|
max_interaction = max(1, int(month_max_interaction))
|
||||||
|
max_active = max(1, int(month_max_active_days))
|
||||||
|
interaction_score = math.log1p(float(agg.interaction)) / math.log1p(float(max_interaction))
|
||||||
|
speed_score = 1.0 / (1.0 + (float(agg.avg_reply_seconds_capped()) / float(max(1.0, tau_seconds))))
|
||||||
|
continuity_score = float(agg.active_days_count) / float(max_active)
|
||||||
|
coverage_score = float(agg.time_bucket_count) / 4.0
|
||||||
|
final_score = (
|
||||||
|
float(weights["interaction"]) * interaction_score
|
||||||
|
+ float(weights["speed"]) * speed_score
|
||||||
|
+ float(weights["continuity"]) * continuity_score
|
||||||
|
+ float(weights["coverage"]) * coverage_score
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"interaction": float(interaction_score),
|
||||||
|
"speed": float(speed_score),
|
||||||
|
"continuity": float(continuity_score),
|
||||||
|
"coverage": float(coverage_score),
|
||||||
|
"final": float(final_score),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_monthly_best_friends_wall_stats(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||||
|
start_ts, end_ts = _year_range_epoch_seconds(int(year))
|
||||||
|
my_username = str(account_dir.name or "").strip()
|
||||||
|
|
||||||
|
gap_cap_seconds = 6 * 60 * 60
|
||||||
|
tau_seconds = 30 * 60
|
||||||
|
weights = {
|
||||||
|
"interaction": 0.40,
|
||||||
|
"speed": 0.30,
|
||||||
|
"continuity": 0.20,
|
||||||
|
"coverage": 0.10,
|
||||||
|
}
|
||||||
|
eligibility = {
|
||||||
|
"minTotalMessages": 8,
|
||||||
|
"minInteraction": 3,
|
||||||
|
"minReplyCount": 1,
|
||||||
|
"minActiveDays": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
per_month_aggs: dict[int, list[_MonthConvAgg]] = {m: [] for m in range(1, 13)}
|
||||||
|
used_index = False
|
||||||
|
index_status: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
index_path = get_chat_search_index_db_path(account_dir)
|
||||||
|
if index_path.exists():
|
||||||
|
conn = sqlite3.connect(str(index_path))
|
||||||
|
try:
|
||||||
|
has_fts = (
|
||||||
|
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if has_fts and my_username:
|
||||||
|
used_index = True
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
ts_expr = (
|
||||||
|
"CASE "
|
||||||
|
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||||
|
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||||
|
"ELSE CAST(create_time AS INTEGER) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
|
||||||
|
where = (
|
||||||
|
f"{ts_expr} >= ? AND {ts_expr} < ? "
|
||||||
|
"AND db_stem NOT LIKE 'biz_message%' "
|
||||||
|
"AND CAST(local_type AS INTEGER) != 10000 "
|
||||||
|
"AND username NOT LIKE '%@chatroom'"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"SELECT "
|
||||||
|
"username, sender_username, "
|
||||||
|
f"{ts_expr} AS ts, "
|
||||||
|
"CAST(sort_seq AS INTEGER) AS sort_seq_i, "
|
||||||
|
"CAST(local_id AS INTEGER) AS local_id_i "
|
||||||
|
"FROM message_fts "
|
||||||
|
f"WHERE {where} "
|
||||||
|
"ORDER BY username ASC, ts ASC, sort_seq_i ASC, local_id_i ASC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur = conn.execute(sql, (start_ts, end_ts))
|
||||||
|
|
||||||
|
cur_username = ""
|
||||||
|
conv_month_aggs: dict[int, _MonthConvAgg] = {}
|
||||||
|
prev_other_ts: int | None = None
|
||||||
|
|
||||||
|
def flush_conv() -> None:
|
||||||
|
nonlocal cur_username, conv_month_aggs, prev_other_ts
|
||||||
|
if not cur_username:
|
||||||
|
return
|
||||||
|
for m, agg in conv_month_aggs.items():
|
||||||
|
if 1 <= int(m) <= 12 and agg.total > 0:
|
||||||
|
per_month_aggs[int(m)].append(agg)
|
||||||
|
conv_month_aggs = {}
|
||||||
|
prev_other_ts = None
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
try:
|
||||||
|
username = str(row[0] or "").strip()
|
||||||
|
sender = str(row[1] or "").strip()
|
||||||
|
ts = int(row[2] or 0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ts <= 0 or not username:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if username != cur_username:
|
||||||
|
flush_conv()
|
||||||
|
cur_username = username
|
||||||
|
|
||||||
|
if not _should_keep_session(username, include_official=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dt = datetime.fromtimestamp(ts)
|
||||||
|
month = int(dt.month)
|
||||||
|
if month < 1 or month > 12:
|
||||||
|
continue
|
||||||
|
agg = conv_month_aggs.get(month)
|
||||||
|
if agg is None:
|
||||||
|
agg = _MonthConvAgg(username=username, month=month)
|
||||||
|
conv_month_aggs[month] = agg
|
||||||
|
agg.observe(day=int(dt.day), hour=int(dt.hour))
|
||||||
|
|
||||||
|
is_me = sender == my_username
|
||||||
|
if is_me:
|
||||||
|
agg.outgoing += 1
|
||||||
|
if prev_other_ts is not None and ts >= prev_other_ts:
|
||||||
|
gap = int(ts - prev_other_ts)
|
||||||
|
agg.replies += 1
|
||||||
|
agg.sum_gap += gap
|
||||||
|
agg.sum_gap_capped += min(gap, gap_cap_seconds)
|
||||||
|
prev_other_ts = None
|
||||||
|
else:
|
||||||
|
agg.incoming += 1
|
||||||
|
prev_other_ts = ts
|
||||||
|
|
||||||
|
flush_conv()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Wrapped card#4 monthly_best_friends computed (search index): account=%s year=%s elapsed=%.2fs",
|
||||||
|
str(account_dir.name or "").strip(),
|
||||||
|
int(year),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not used_index:
|
||||||
|
try:
|
||||||
|
index_status = get_chat_search_index_status(account_dir)
|
||||||
|
index = dict(index_status.get("index") or {})
|
||||||
|
build = dict(index.get("build") or {})
|
||||||
|
index_ready = bool(index.get("ready"))
|
||||||
|
build_status = str(build.get("status") or "")
|
||||||
|
index_exists = bool(index.get("exists"))
|
||||||
|
if (not index_ready) and build_status not in {"building", "error"}:
|
||||||
|
start_chat_search_index_build(account_dir, rebuild=bool(index_exists))
|
||||||
|
index_status = get_chat_search_index_status(account_dir)
|
||||||
|
except Exception:
|
||||||
|
index_status = None
|
||||||
|
|
||||||
|
month_winner_raw: dict[int, dict[str, Any]] = {}
|
||||||
|
winner_usernames: list[str] = []
|
||||||
|
for month in range(1, 13):
|
||||||
|
aggs = list(per_month_aggs.get(month) or [])
|
||||||
|
eligible: list[_MonthConvAgg] = []
|
||||||
|
for agg in aggs:
|
||||||
|
if agg.total < int(eligibility["minTotalMessages"]):
|
||||||
|
continue
|
||||||
|
if agg.interaction < int(eligibility["minInteraction"]):
|
||||||
|
continue
|
||||||
|
if agg.replies < int(eligibility["minReplyCount"]):
|
||||||
|
continue
|
||||||
|
if agg.active_days_count < int(eligibility["minActiveDays"]):
|
||||||
|
continue
|
||||||
|
eligible.append(agg)
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
continue
|
||||||
|
|
||||||
|
month_max_interaction = max(agg.interaction for agg in eligible)
|
||||||
|
month_max_active_days = max(agg.active_days_count for agg in eligible)
|
||||||
|
scored: list[tuple[tuple[float, float, float, float, str], _MonthConvAgg, dict[str, float]]] = []
|
||||||
|
for agg in eligible:
|
||||||
|
score = _score_month_agg(
|
||||||
|
agg=agg,
|
||||||
|
month_max_interaction=month_max_interaction,
|
||||||
|
month_max_active_days=month_max_active_days,
|
||||||
|
tau_seconds=float(tau_seconds),
|
||||||
|
weights=weights,
|
||||||
|
)
|
||||||
|
tie_key = (
|
||||||
|
-float(score["final"]),
|
||||||
|
-float(agg.interaction),
|
||||||
|
float(agg.avg_reply_seconds_capped()),
|
||||||
|
-float(agg.active_days_count),
|
||||||
|
str(agg.username),
|
||||||
|
)
|
||||||
|
scored.append((tie_key, agg, score))
|
||||||
|
scored.sort(key=lambda x: x[0])
|
||||||
|
_, winner_agg, winner_score = scored[0]
|
||||||
|
month_winner_raw[month] = {
|
||||||
|
"agg": winner_agg,
|
||||||
|
"score": winner_score,
|
||||||
|
}
|
||||||
|
winner_usernames.append(winner_agg.username)
|
||||||
|
|
||||||
|
uniq_winner_usernames: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for u in winner_usernames:
|
||||||
|
if u and u not in seen:
|
||||||
|
seen.add(u)
|
||||||
|
uniq_winner_usernames.append(u)
|
||||||
|
|
||||||
|
contact_rows = _load_contact_rows(account_dir / "contact.db", uniq_winner_usernames) if uniq_winner_usernames else {}
|
||||||
|
|
||||||
|
months: list[dict[str, Any]] = []
|
||||||
|
for month in range(1, 13):
|
||||||
|
winner_pack = month_winner_raw.get(month)
|
||||||
|
if not winner_pack:
|
||||||
|
months.append(
|
||||||
|
{
|
||||||
|
"month": month,
|
||||||
|
"winner": None,
|
||||||
|
"metrics": None,
|
||||||
|
"raw": None,
|
||||||
|
"isFallback": False,
|
||||||
|
"reason": "insufficient_data",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
agg: _MonthConvAgg = winner_pack["agg"]
|
||||||
|
score = dict(winner_pack["score"] or {})
|
||||||
|
row = contact_rows.get(agg.username)
|
||||||
|
display = _pick_display_name(row, agg.username)
|
||||||
|
avatar = _build_avatar_url(str(account_dir.name or ""), agg.username) if agg.username else ""
|
||||||
|
|
||||||
|
months.append(
|
||||||
|
{
|
||||||
|
"month": month,
|
||||||
|
"winner": {
|
||||||
|
"username": agg.username,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
"score": float(score.get("final") or 0.0),
|
||||||
|
"score100": round(float(score.get("final") or 0.0) * 100.0, 1),
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"interactionScore": float(score.get("interaction") or 0.0),
|
||||||
|
"speedScore": float(score.get("speed") or 0.0),
|
||||||
|
"continuityScore": float(score.get("continuity") or 0.0),
|
||||||
|
"coverageScore": float(score.get("coverage") or 0.0),
|
||||||
|
},
|
||||||
|
"raw": {
|
||||||
|
"incomingMessages": int(agg.incoming),
|
||||||
|
"outgoingMessages": int(agg.outgoing),
|
||||||
|
"totalMessages": int(agg.total),
|
||||||
|
"interaction": int(agg.interaction),
|
||||||
|
"replyCount": int(agg.replies),
|
||||||
|
"avgReplySeconds": float(agg.avg_reply_seconds()),
|
||||||
|
"avgReplySecondsCapped": float(agg.avg_reply_seconds_capped()),
|
||||||
|
"activeDays": int(agg.active_days_count),
|
||||||
|
"timeBucketsCount": int(agg.time_bucket_count),
|
||||||
|
},
|
||||||
|
"isFallback": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
winner_month_counts: dict[str, int] = {}
|
||||||
|
for item in months:
|
||||||
|
w = item.get("winner")
|
||||||
|
if not isinstance(w, dict):
|
||||||
|
continue
|
||||||
|
u = str(w.get("username") or "").strip()
|
||||||
|
if not u:
|
||||||
|
continue
|
||||||
|
winner_month_counts[u] = int(winner_month_counts.get(u, 0)) + 1
|
||||||
|
|
||||||
|
top_champion = None
|
||||||
|
if winner_month_counts:
|
||||||
|
champion_username = sorted(winner_month_counts.items(), key=lambda kv: (-int(kv[1]), str(kv[0])))[0][0]
|
||||||
|
champion_months = int(winner_month_counts.get(champion_username) or 0)
|
||||||
|
row = contact_rows.get(champion_username)
|
||||||
|
display = _pick_display_name(row, champion_username)
|
||||||
|
top_champion = {
|
||||||
|
"username": champion_username,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"monthsWon": champion_months,
|
||||||
|
}
|
||||||
|
|
||||||
|
filled_months = [int(x.get("month") or 0) for x in months if isinstance(x.get("winner"), dict)]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": int(year),
|
||||||
|
"months": months,
|
||||||
|
"summary": {
|
||||||
|
"monthsWithWinner": int(len(filled_months)),
|
||||||
|
"topChampion": top_champion,
|
||||||
|
"filledMonths": filled_months,
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"weights": {
|
||||||
|
"interaction": float(weights["interaction"]),
|
||||||
|
"speed": float(weights["speed"]),
|
||||||
|
"continuity": float(weights["continuity"]),
|
||||||
|
"coverage": float(weights["coverage"]),
|
||||||
|
},
|
||||||
|
"tauSeconds": int(tau_seconds),
|
||||||
|
"gapCapSeconds": int(gap_cap_seconds),
|
||||||
|
"eligibility": {
|
||||||
|
"minTotalMessages": int(eligibility["minTotalMessages"]),
|
||||||
|
"minInteraction": int(eligibility["minInteraction"]),
|
||||||
|
"minReplyCount": int(eligibility["minReplyCount"]),
|
||||||
|
"minActiveDays": int(eligibility["minActiveDays"]),
|
||||||
|
},
|
||||||
|
"usedIndex": bool(used_index),
|
||||||
|
"indexStatus": index_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_card_04_monthly_best_friends_wall(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||||
|
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=year)
|
||||||
|
summary = dict(data.get("summary") or {})
|
||||||
|
top_champion = summary.get("topChampion")
|
||||||
|
months_with_winner = int(summary.get("monthsWithWinner") or 0)
|
||||||
|
|
||||||
|
if months_with_winner <= 0:
|
||||||
|
narrative = "今年还没有足够的聊天互动数据来评选每月最佳好友(或搜索索引尚未就绪)。"
|
||||||
|
elif isinstance(top_champion, dict) and top_champion.get("displayName"):
|
||||||
|
champ_name = str(top_champion.get("displayName") or "")
|
||||||
|
months_won = int(top_champion.get("monthsWon") or 0)
|
||||||
|
narrative = f"{champ_name} 拿下了 {months_won} 个月的月度最佳好友;这一年你们的聊天默契很稳定。"
|
||||||
|
else:
|
||||||
|
narrative = f"你在 {months_with_winner} 个月里都出现了稳定的“月度最佳好友”。"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": 4,
|
||||||
|
"title": "陪你走过每个月的人",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "B",
|
||||||
|
"status": "ok",
|
||||||
|
"kind": "chat/monthly_best_friends_wall",
|
||||||
|
"narrative": narrative,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ from .cards.card_00_global_overview import build_card_00_global_overview
|
|||||||
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
||||||
from .cards.card_02_message_chars import build_card_02_message_chars
|
from .cards.card_02_message_chars import build_card_02_message_chars
|
||||||
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||||
|
from .cards.card_04_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall
|
||||||
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -23,9 +24,9 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||||
# an older partial cache.
|
# an older partial cache.
|
||||||
_IMPLEMENTED_UPTO_ID = 4
|
_IMPLEMENTED_UPTO_ID = 5
|
||||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||||
_CACHE_VERSION = 15
|
_CACHE_VERSION = 18
|
||||||
|
|
||||||
|
|
||||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||||
@@ -61,6 +62,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
|
"title": "这一年,每个月谁最懂你?",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "B",
|
||||||
|
"kind": "chat/monthly_best_friends_wall",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
"title": "这一年,你的表情包里藏了多少心情?",
|
"title": "这一年,你的表情包里藏了多少心情?",
|
||||||
"scope": "global",
|
"scope": "global",
|
||||||
"category": "B",
|
"category": "B",
|
||||||
@@ -282,7 +290,7 @@ def build_wrapped_annual_response(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build annual wrapped response for the given account/year.
|
"""Build annual wrapped response for the given account/year.
|
||||||
|
|
||||||
For now we implement cards up to id=4 (plus a meta overview card id=0).
|
For now we implement cards up to id=5 (plus a meta overview card id=0).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
@@ -325,7 +333,9 @@ def build_wrapped_annual_response(
|
|||||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
||||||
# Page 5: reply speed / best chat buddy.
|
# Page 5: reply speed / best chat buddy.
|
||||||
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
||||||
# Page 6: annual emoji universe / meme almanac.
|
# Page 6: monthly best friends wall (photo wall).
|
||||||
|
cards.append(build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y))
|
||||||
|
# Page 7: annual emoji universe / meme almanac.
|
||||||
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
||||||
|
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
@@ -519,6 +529,8 @@ def build_wrapped_annual_card(
|
|||||||
elif cid == 3:
|
elif cid == 3:
|
||||||
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||||
elif cid == 4:
|
elif cid == 4:
|
||||||
|
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||||
|
elif cid == 5:
|
||||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||||
else:
|
else:
|
||||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ class TestWrappedEmojiUniverse(unittest.TestCase):
|
|||||||
self._seed_session_db(account_dir / "session.db", usernames=[])
|
self._seed_session_db(account_dir / "session.db", usernames=[])
|
||||||
|
|
||||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=2025)
|
card = build_card_04_emoji_universe(account_dir=account_dir, year=2025)
|
||||||
self.assertEqual(card["id"], 4)
|
self.assertEqual(card["id"], 5)
|
||||||
self.assertEqual(card["status"], "ok")
|
self.assertEqual(card["status"], "ok")
|
||||||
self.assertEqual(card["data"]["sentStickerCount"], 0)
|
self.assertEqual(card["data"]["sentStickerCount"], 0)
|
||||||
self.assertIn("几乎没用表情表达", card["narrative"])
|
self.assertIn("几乎没用表情表达", card["narrative"])
|
||||||
|
|||||||
271
tests/test_wrapped_monthly_best_friends.py
Normal file
271
tests/test_wrapped_monthly_best_friends.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure "src/" is importable when running tests from repo root.
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrappedMonthlyBestFriends(unittest.TestCase):
|
||||||
|
def _ts(self, y: int, m: int, d: int, hh: int, mm: int, ss: int) -> int:
|
||||||
|
return int(datetime(y, m, d, hh, mm, ss).timestamp())
|
||||||
|
|
||||||
|
def _seed_contact_db(self, path: Path, usernames: list[str]) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS contact (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
remark TEXT,
|
||||||
|
nick_name TEXT,
|
||||||
|
alias TEXT,
|
||||||
|
big_head_url TEXT,
|
||||||
|
small_head_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for u in usernames:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO contact(username, nick_name) VALUES(?, ?)",
|
||||||
|
(u, f"Nick_{u}"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _seed_index_db(self, path: Path, rows: list[dict]) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_fts (
|
||||||
|
username TEXT,
|
||||||
|
sender_username TEXT,
|
||||||
|
create_time INTEGER,
|
||||||
|
sort_seq INTEGER,
|
||||||
|
local_id INTEGER,
|
||||||
|
local_type INTEGER,
|
||||||
|
db_stem TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO message_fts(
|
||||||
|
username, sender_username, create_time, sort_seq, local_id, local_type, db_stem
|
||||||
|
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
r["username"],
|
||||||
|
r["sender_username"],
|
||||||
|
int(r["create_time"]),
|
||||||
|
int(r["sort_seq"]),
|
||||||
|
int(r["local_id"]),
|
||||||
|
int(r.get("local_type", 1)),
|
||||||
|
str(r.get("db_stem", "message_0")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_balanced_profile_can_beat_higher_volume(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||||
|
compute_monthly_best_friends_wall_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
account = "wxid_me"
|
||||||
|
account_dir = Path(td) / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
user_volume = "wxid_volume"
|
||||||
|
user_balanced = "wxid_balanced"
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", [user_volume, user_balanced])
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
lid = 1
|
||||||
|
# High-volume user: more messages but consistently slow replies and low continuity.
|
||||||
|
for d in [3, 18]:
|
||||||
|
for i in range(6):
|
||||||
|
t = self._ts(2025, 1, d, 21, i * 3, 0)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user_volume,
|
||||||
|
"sender_username": user_volume,
|
||||||
|
"create_time": t,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user_volume,
|
||||||
|
"sender_username": account,
|
||||||
|
"create_time": t + 7200,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
|
||||||
|
# Balanced user: slightly fewer interactions, but much faster and spread over more days/hours.
|
||||||
|
day_hour = [
|
||||||
|
(2, 1),
|
||||||
|
(6, 8),
|
||||||
|
(9, 13),
|
||||||
|
(13, 19),
|
||||||
|
(20, 10),
|
||||||
|
(24, 22),
|
||||||
|
(27, 7),
|
||||||
|
(29, 16),
|
||||||
|
(30, 12),
|
||||||
|
(31, 20),
|
||||||
|
]
|
||||||
|
for d, hh in day_hour:
|
||||||
|
t = self._ts(2025, 1, d, hh, 10, 0)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user_balanced,
|
||||||
|
"sender_username": user_balanced,
|
||||||
|
"create_time": t,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user_balanced,
|
||||||
|
"sender_username": account,
|
||||||
|
"create_time": t + 20,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||||
|
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||||
|
jan = data["months"][0]
|
||||||
|
self.assertIsNotNone(jan["winner"])
|
||||||
|
self.assertEqual(jan["winner"]["username"], user_balanced)
|
||||||
|
|
||||||
|
def test_allows_consecutive_month_wins(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||||
|
compute_monthly_best_friends_wall_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
account = "wxid_me"
|
||||||
|
account_dir = Path(td) / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
buddy = "wxid_best"
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", [buddy])
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
lid = 1
|
||||||
|
for month in [1, 2]:
|
||||||
|
for d in [3, 8, 12, 18]:
|
||||||
|
t = self._ts(2025, month, d, 12, 0, 0)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": buddy,
|
||||||
|
"sender_username": buddy,
|
||||||
|
"create_time": t,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": buddy,
|
||||||
|
"sender_username": account,
|
||||||
|
"create_time": t + 30,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||||
|
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||||
|
jan = data["months"][0]
|
||||||
|
feb = data["months"][1]
|
||||||
|
self.assertEqual(jan["winner"]["username"], buddy)
|
||||||
|
self.assertEqual(feb["winner"]["username"], buddy)
|
||||||
|
|
||||||
|
def test_month_without_enough_activity_is_empty(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||||
|
compute_monthly_best_friends_wall_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
account = "wxid_me"
|
||||||
|
account_dir = Path(td) / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
user = "wxid_low"
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", [user])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
lid = 1
|
||||||
|
# Only 3 reply pairs in March -> total 6 messages, below minTotalMessages=8.
|
||||||
|
for d in [5, 11, 25]:
|
||||||
|
t = self._ts(2025, 3, d, 10, 0, 0)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user,
|
||||||
|
"sender_username": user,
|
||||||
|
"create_time": t,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"username": user,
|
||||||
|
"sender_username": account,
|
||||||
|
"create_time": t + 40,
|
||||||
|
"sort_seq": lid,
|
||||||
|
"local_id": lid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lid += 1
|
||||||
|
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||||
|
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||||
|
march = data["months"][2]
|
||||||
|
self.assertIsNone(march["winner"])
|
||||||
|
self.assertEqual(march["reason"], "insufficient_data")
|
||||||
|
|
||||||
|
def test_card_shape_and_kind(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||||
|
build_card_04_monthly_best_friends_wall,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
account = "wxid_me"
|
||||||
|
account_dir = Path(td) / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", [])
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", [])
|
||||||
|
|
||||||
|
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=2025)
|
||||||
|
self.assertEqual(card["id"], 4)
|
||||||
|
self.assertEqual(card["kind"], "chat/monthly_best_friends_wall")
|
||||||
|
self.assertEqual(card["status"], "ok")
|
||||||
|
self.assertEqual(len(card["data"]["months"]), 12)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user