feat(wrapped): 新增年度关键词词云卡片(Card #6)

- 新增关键词词云后端计算:消息采样、jieba 分词、关键词权重与例句回看数据。

- Wrapped 年度卡片接入 Card #6,并对该卡片改为按请求重算(不走单卡缓存)。

- 前端新增 storm->cloud 词云动效卡片,支持词点击查看例句及动画阶段隐藏 deck 顶部 UI。

- 统一 wrapped 消息表名解码逻辑,兼容 sqlite text_factory=bytes。

- 新增关键词词云测试并加入 jieba 依赖。
This commit is contained in:
2977094657
2026-02-22 18:59:30 +08:00
Unverified
parent 9acbfa7582
commit 2dd103814a
12 changed files with 2136 additions and 25 deletions
@@ -0,0 +1,968 @@
<template>
<div ref="cardRoot" class="h-full w-full">
<!-- 全屏气泡覆盖层storm/packed/merge/burst 阶段 Teleport body不受父级 transform 影响 -->
<Teleport to="body">
<div
v-if="showOverlay"
ref="overlayEl"
class="kw-overlay fixed inset-0 overflow-hidden"
:style="{ zIndex: 9999 }"
@pointerdown="onStagePointerDown"
>
<!-- 控制按钮 -->
<div class="absolute top-3 right-3 z-40 flex items-center gap-2" data-no-accel>
<button
type="button"
class="kw-chip"
:class="privacyMode ? 'kw-chip--on' : ''"
@click="privacyMode = !privacyMode"
>
{{ privacyMode ? '隐私:开' : '隐私:关' }}
</button>
<button type="button" class="kw-chip" @click="skipToCloud">跳过</button>
<button type="button" class="kw-chip" @click="replay">重播</button>
</div>
<!-- 提示accelerated 默认开启此提示基本不显示 -->
<div
v-if="showHint"
class="absolute bottom-3 right-3 z-30 wrapped-label text-[10px] text-[#00000055] bg-white/55 backdrop-blur rounded-lg px-2 py-1 border border-[#0000000a]"
data-no-accel
>
点击空白处加速 · 右上角可重播
</div>
<!-- 气泡层 -->
<div class="absolute inset-0 z-10">
<div
v-for="b in bubbles"
:key="b.id"
:ref="(el) => registerBubbleEl(b.id, el)"
class="kw-bubble absolute"
:class="`kw-bubble--d${b.depth}`"
:style="bubbleStyle(b)"
>
<div
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r"
:class="privacyMode ? 'privacy-blur' : ''"
>
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
<span v-else>{{ b.text }}</span>
</div>
</div>
</div>
<!-- 粒子 (burst) -->
<canvas
v-show="showParticles"
ref="particleCanvas"
class="absolute inset-0 z-20 pointer-events-none"
/>
</div>
</Teleport>
<!-- 卡片壳体 -->
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant" :wide="true" :hide-chrome="isAnimating">
<template #narrative>
<div class="mt-2 wrapped-body text-sm sm:text-base text-[#7F7F7F] leading-relaxed">
<p class="whitespace-pre-wrap">
<template v-if="phase !== 'cloud'">
你的话正在涌来
</template>
<template v-else>
每一句话都是一个气泡最终爆开成你的年度关键词词云点击关键词回看它出现的瞬间
</template>
</p>
</div>
</template>
<div class="w-full">
<div
ref="stageEl"
class="kw-stage relative w-full h-[56vh] min-h-[360px] max-h-[680px] rounded-[28px] overflow-hidden"
>
<!-- cloud 阶段的控制按钮 -->
<div v-if="phase === 'cloud'" class="absolute top-3 right-3 z-40 flex items-center gap-2">
<button
type="button"
class="kw-chip"
:class="privacyMode ? 'kw-chip--on' : ''"
@click="privacyMode = !privacyMode"
>
{{ privacyMode ? '隐私:开' : '隐私:关' }}
</button>
<button type="button" class="kw-chip" @click="replay">重播</button>
</div>
<!-- 词云 -->
<transition name="cloud-fade">
<div v-if="phase === 'cloud'" class="absolute inset-0 z-30 p-3 sm:p-5">
<KeywordWordCloud
:keywords="keywords"
:examples="examples"
:privacy-mode="privacyMode"
:animate="true"
:reduced-motion="reducedMotion"
/>
</div>
</transition>
</div>
</div>
</WrappedCardShell>
</div>
</template>
<script setup>
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { gsap } from 'gsap'
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const cardRoot = ref(null)
const stageEl = ref(null)
const overlayEl = ref(null)
const particleCanvas = ref(null)
const phase = ref('idle') // 'idle' | 'storm' | 'packed' | 'merge' | 'burst' | 'cloud'
const hasPlayed = ref(false)
const privacyMode = ref(false)
const accelerated = ref(true) // 默认加速
const showParticles = ref(false)
// 通知父级 deck 隐藏顶部 UI
const deckChromeHidden = inject('deckChromeHidden', ref(false))
const isAnimating = computed(() => ['storm', 'packed', 'merge', 'burst'].includes(phase.value))
const showOverlay = computed(() => isAnimating.value && !reducedMotion.value)
// phase 变化时同步 deck chrome 可见性
watch(phase, () => {
deckChromeHidden.value = isAnimating.value
})
const reducedMotion = ref(false)
const detectReducedMotion = () => {
if (!import.meta.client) return
try {
reducedMotion.value = !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
} catch {
reducedMotion.value = false
}
}
const keywords = computed(() => Array.isArray(props.card?.data?.keywords) ? props.card.data.keywords : [])
const examples = computed(() => Array.isArray(props.card?.data?.examples) ? props.card.data.examples : [])
const bubblePool = computed(() => {
const xs = Array.isArray(props.card?.data?.bubbleMessages) ? props.card.data.bubbleMessages : []
return xs.map((x) => String(x || '')).filter((x) => x.trim())
})
const showHint = computed(() => (!reducedMotion.value) && phase.value === 'storm' && !accelerated.value)
const TOTAL_ANIMATION_LIMIT_MS = 10000
const STORM_STAGE_LIMIT_MS = 6200
const MERGE_MIN_BUDGET_MS = 1800
const PACKED_PAUSE_MS = 120
// 气泡状态
const bubbles = ref([])
let bubbleSeq = 0
const bubbleEls = new Map()
const registerBubbleEl = (id, el) => {
if (!id) return
if (el) bubbleEls.set(id, el)
else bubbleEls.delete(id)
}
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
const lerp = (a, b, t) => a + (b - a) * t
const hash32 = (s) => {
const str = String(s || '')
let h = 2166136261
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
const mulberry32 = (a) => () => {
let t = (a += 0x6D2B79F5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
const bubbleStyle = (b) => ({
left: `${Math.round(Number(b.x || 0))}px`,
top: `${Math.round(Number(b.y || 0))}px`,
zIndex: String(10 + (Number(b.depth || 1) * 20) + (Number(b.id || 0) % 9))
})
let textMeasureCanvas = null
const getTextMeasureContext = () => {
if (!import.meta.client) return null
if (!textMeasureCanvas) {
try {
textMeasureCanvas = document.createElement('canvas')
} catch {
textMeasureCanvas = null
}
}
return textMeasureCanvas?.getContext?.('2d') || null
}
const estimateTextWidth = (text, compact = false) => {
const s = String(text || '')
const ctx = getTextMeasureContext()
if (ctx) {
// 与 text-sm / text-[12px] 接近的字体测量。
ctx.font = compact
? "12px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
: "14px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
return Math.max(0, ctx.measureText(s).width)
}
// SSR/异常回退估算。
const chars = Array.from(s)
return chars.reduce((acc, ch) => acc + (/[^\x00-\xff]/.test(ch) ? (compact ? 11 : 13) : (compact ? 7 : 8.5)), 0)
}
const bubbleSizeForText = (text, compact = false) => {
const chars = Array.from(String(text || ''))
const visualUnits = chars.reduce((acc, ch) => acc + (/[^\x00-\xff]/.test(ch) ? 1 : 0.56), 0)
const raw = estimateTextWidth(text, compact)
const minWBase = compact ? 56 : 74
const minWLong = compact
? (visualUnits >= 18 ? 120 : (visualUnits >= 12 ? 90 : minWBase))
: (visualUnits >= 26 ? 182 : (visualUnits >= 14 ? 122 : minWBase))
const minW = Math.max(minWBase, minWLong)
// 与聊天页一致:max-w-sm (24rem = 384px) 到达上限后再换行。
const maxWByType = compact ? (visualUnits >= 18 ? 300 : 220) : 384
const maxWByViewport = Math.max(140, (curViewW || 0) - 12)
const maxW = Math.min(maxWByType, maxWByViewport)
const paddingX = compact ? 22 : 26
const preferredW = raw + paddingX
const w = clamp(Math.round(preferredW), minW, maxW)
const usableLineW = Math.max(1, w - paddingX)
const lines = Math.max(1, Math.ceil(raw / usableLineW))
// 不限制气泡高度:按估算行数增长,不做固定上限裁剪。
const lineH = compact ? 16 : 20
const paddingY = compact ? 12 : 14
const h = Math.max(compact ? 26 : 32, Math.round((lines * lineH) + paddingY))
return { w, h }
}
let stormTimer = null
let packedTimer = null
let mainTl = null
let particleRaf = null
let hardStopTimer = null
let animationStartedAt = 0
let animationDeadlineAt = 0
// 记录全屏视口尺寸(storm 阶段使用)
let curViewW = 0
let curViewH = 0
const clearTimers = () => {
if (stormTimer) clearTimeout(stormTimer)
stormTimer = null
if (packedTimer) clearTimeout(packedTimer)
packedTimer = null
if (hardStopTimer) clearTimeout(hardStopTimer)
hardStopTimer = null
}
const armHardStop = () => {
if (!import.meta.client) return
if (hardStopTimer) clearTimeout(hardStopTimer)
hardStopTimer = null
const remain = Math.max(0, Math.round(animationDeadlineAt - performance.now()))
hardStopTimer = setTimeout(() => {
if (phase.value !== 'cloud') skipToCloud()
}, remain + 8)
}
const stopParticles = () => {
showParticles.value = false
if (particleRaf) cancelAnimationFrame(particleRaf)
particleRaf = null
const c = particleCanvas.value
if (c) {
try {
const ctx = c.getContext('2d')
ctx?.clearRect?.(0, 0, c.width, c.height)
} catch {}
}
}
const killTimeline = () => {
if (mainTl) {
try { mainTl.kill() } catch {}
}
mainTl = null
}
const reset = () => {
clearTimers()
killTimeline()
stopParticles()
bubbles.value = []
bubbleEls.clear()
bubbleSeq = 0
accelerated.value = true
animationStartedAt = 0
animationDeadlineAt = 0
phase.value = 'idle'
}
const skipToCloud = () => {
clearTimers()
killTimeline()
stopParticles()
bubbles.value = []
bubbleEls.clear()
accelerated.value = true
animationStartedAt = 0
animationDeadlineAt = 0
phase.value = 'cloud'
hasPlayed.value = true
}
const replay = () => {
hasPlayed.value = false
reset()
maybeStart()
}
const onStagePointerDown = (e) => {
if (phase.value !== 'storm') return
if (e?.target?.closest?.('[data-no-accel]')) return
accelerated.value = true
}
// Visibility gating
const isVisible = ref(false)
let io = null
const updateVisibility = (v) => { isVisible.value = !!v }
const startParticles = (rng, centerX, centerY) => {
if (!import.meta.client) return
const canvas = particleCanvas.value
if (!canvas || !curViewW || !curViewH) return
const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio || 1)))
canvas.width = Math.max(1, Math.round(curViewW * dpr))
canvas.height = Math.max(1, Math.round(curViewH * dpr))
canvas.style.width = `${curViewW}px`
canvas.style.height = `${curViewH}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const N = 80
const particles = []
for (let i = 0; i < N; i += 1) {
const ang = rng() * Math.PI * 2
const sp = 120 + rng() * 320
particles.push({
x: centerX,
y: centerY,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp,
size: 0.8 + rng() * 2.4,
life: 1,
color: rng() < 0.66 ? 'rgba(7,193,96,0.65)' : (rng() < 0.5 ? 'rgba(242,170,0,0.55)' : 'rgba(14,165,233,0.55)')
})
}
showParticles.value = true
const duration = 600
let last = 0
const started = performance.now()
const tick = (now) => {
const t = now - started
const dt = last > 0 ? Math.min((now - last) / 1000, 0.05) : 0.016
last = now
ctx.clearRect(0, 0, curViewW, curViewH)
const p = clamp(t / duration, 0, 1)
const alpha = 1 - p
for (const it of particles) {
it.x += it.vx * dt
it.y += it.vy * dt
it.vx *= 0.86
it.vy *= 0.86
const a = alpha * 0.9
ctx.fillStyle = it.color.replace(/[\d.]+\)$/g, `${a})`)
ctx.beginPath()
ctx.arc(it.x, it.y, it.size, 0, Math.PI * 2)
ctx.fill()
}
if (t < duration) {
particleRaf = requestAnimationFrame(tick)
} else {
stopParticles()
}
}
particleRaf = requestAnimationFrame(tick)
}
const maybeStart = () => {
if (!import.meta.client) return
detectReducedMotion()
const ready = props.card && props.card.status === 'ok' && props.card.data
if (!ready) return
if (!isVisible.value) return
if (reducedMotion.value) {
phase.value = 'cloud'
hasPlayed.value = true
return
}
if (hasPlayed.value) return
if (phase.value !== 'idle') return
// 使用全屏视口尺寸
curViewW = window.innerWidth || 0
curViewH = window.innerHeight || 0
if (!curViewW || !curViewH) return
// 开始 storm
phase.value = 'storm'
accelerated.value = true
animationStartedAt = performance.now()
animationDeadlineAt = animationStartedAt + TOTAL_ANIMATION_LIMIT_MS
armHardStop()
const vw = curViewW
const vh = curViewH
const area = vw * vh
// 目标:先铺满一层,再形成二/三层重叠。
const maxBubbles = clamp(Math.round(area / 1900), 240, 1600)
const maxLayers = 3
const targetBaseCoverage = 0.9985
const targetLayer2Coverage = 0.20
const centerX = vw / 2
const centerY = vh / 2
const seed = hash32(`${props.card?.data?.year || 0}|${props.card?.data?.topKeyword?.word || ''}|${Date.now()}`)
const rng = mulberry32(seed)
// 打乱气泡消息
const msgs = bubblePool.value.length > 0
? [...bubblePool.value]
: (keywords.value.length > 0 ? keywords.value.map((k) => String(k?.word || '')).filter((x) => x.trim()) : [])
if (msgs.length === 0) {
skipToCloud()
return
}
for (let i = msgs.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1))
const tmp = msgs[i]
msgs[i] = msgs[j]
msgs[j] = tmp
}
let msgIdx = 0
// ========== 网格系统 ==========
const cell = 36 // 更细网格,提高覆盖检测精度
const grid = new Map()
const boxById = new Map()
const cellKey = (cx, cy) => `${cx},${cy}`
const addToGrid = (id, box) => {
const minX = Math.floor(box.x / cell)
const maxX = Math.floor((box.x + box.w) / cell)
const minY = Math.floor(box.y / cell)
const maxY = Math.floor((box.y + box.h) / cell)
for (let x = minX; x <= maxX; x += 1) {
for (let y = minY; y <= maxY; y += 1) {
const k = cellKey(x, y)
const arr = grid.get(k) || []
arr.push(id)
grid.set(k, arr)
}
}
}
const intersects = (a, b, margin) => !(
(a.x + a.w + margin) <= b.x ||
(b.x + b.w + margin) <= a.x ||
(a.y + a.h + margin) <= b.y ||
(b.y + b.h + margin) <= a.y
)
// 无边界留白,无中心留白,气泡可以铺满到边缘。
// allowOverlap=false 时用于首层紧密铺满;true 时允许叠层(最多 maxLayers 层)。
const canPlace = (box, margin, allowOverlap = false) => {
if (box.x < 0 || box.y < 0 || (box.x + box.w) > vw || (box.y + box.h) > vh) return false
// 第一层约束:真实覆盖到的网格不能超过最大层数。
const minOX = Math.floor(box.x / cell)
const maxOX = Math.floor((box.x + box.w) / cell)
const minOY = Math.floor(box.y / cell)
const maxOY = Math.floor((box.y + box.h) / cell)
for (let cx = minOX; cx <= maxOX; cx += 1) {
for (let cy = minOY; cy <= maxOY; cy += 1) {
const arr = grid.get(cellKey(cx, cy))
const layerCount = Array.isArray(arr) ? arr.length : 0
if (layerCount >= maxLayers) return false
}
}
if (allowOverlap) return true
const minCX = minOX - 1
const maxCX = maxOX + 1
const minCY = minOY - 1
const maxCY = maxOY + 1
for (let cx = minCX; cx <= maxCX; cx += 1) {
for (let cy = minCY; cy <= maxCY; cy += 1) {
const arr = grid.get(cellKey(cx, cy))
if (!arr) continue
for (const id of arr) {
const b = boxById.get(id)
if (!b) continue
if (intersects(box, b, margin)) return false
}
}
}
return true
}
// ========== Gap-filling: 找出未被覆盖的空网格单元格 ==========
const gridCols = Math.ceil(vw / cell)
const gridRows = Math.ceil(vh / cell)
const totalCells = gridCols * gridRows
const computeCoverage = (layerAtLeast = 1) => {
let covered = 0
for (let cy = 0; cy < gridRows; cy += 1) {
for (let cx = 0; cx < gridCols; cx += 1) {
const arr = grid.get(cellKey(cx, cy))
if ((arr?.length || 0) >= layerAtLeast) covered += 1
}
}
return totalCells > 0 ? covered / totalCells : 1
}
const findEmptyCells = () => {
const empty = []
for (let cy = 0; cy < gridRows; cy += 1) {
for (let cx = 0; cx < gridCols; cx += 1) {
const arr = grid.get(cellKey(cx, cy))
if (!arr || arr.length === 0) {
empty.push({ cx, cy })
}
}
}
return empty
}
// ========== 优先放置到空区域的 placeBox ==========
const placeBox = (w, h) => {
const emptyCells = findEmptyCells()
// 优先在空网格区域放置
if (emptyCells.length > 0) {
const maxTries = Math.min(emptyCells.length, 24)
for (let t = 0; t < maxTries; t += 1) {
const idx = Math.floor(rng() * emptyCells.length)
const { cx, cy } = emptyCells[idx]
const baseX = cx * cell
const baseY = cy * cell
// 在空单元格位置附近放置,带微小随机偏移
const x = clamp(Math.round(baseX + (rng() - 0.3) * cell * 0.5), 0, vw - w)
const y = clamp(Math.round(baseY + (rng() - 0.3) * cell * 0.5), 0, vh - h)
const box = { x, y, w, h }
if (canPlace(box, 1, false)) return box
// 重试:直接放在单元格起始位置
const x2 = clamp(baseX, 0, vw - w)
const y2 = clamp(baseY, 0, vh - h)
const box2 = { x: x2, y: y2, w, h }
if (canPlace(box2, -1, false)) return box2
}
}
// 随机回退:允许重叠(最多三层),用于形成堆叠层次。
for (let i = 0; i < 40; i += 1) {
const x = Math.floor(rng() * Math.max(1, vw - w))
const y = Math.floor(rng() * Math.max(1, vh - h))
const box = { x, y, w, h }
if (canPlace(box, -3, true)) return box
}
return null
}
// 高密度补缝:专门往未覆盖网格里塞紧凑泡泡,避免剩余缝隙。
const placeGapFillBox = (text) => {
const emptyCells = findEmptyCells()
if (emptyCells.length === 0) return null
const compactSz = bubbleSizeForText(text, true)
// 过长文本不强塞补缝,避免出现“长消息窄气泡”。
if (compactSz.w > 210) return null
const w = compactSz.w
const h = compactSz.h
const tries = Math.min(64, emptyCells.length)
for (let i = 0; i < tries; i += 1) {
const idx = Math.floor(rng() * emptyCells.length)
const { cx, cy } = emptyCells[idx]
const baseX = cx * cell
const baseY = cy * cell
const x = clamp(Math.round(baseX + (cell - w) / 2), 0, vw - w)
const y = clamp(Math.round(baseY + (cell - h) / 2), 0, vh - h)
const box = { x, y, w, h }
if (canPlace(box, -4, true)) return box
}
return null
}
const getLayerDepthForBox = (box) => {
let existing = 0
const minCX = Math.floor(box.x / cell)
const maxCX = Math.floor((box.x + box.w) / cell)
const minCY = Math.floor(box.y / cell)
const maxCY = Math.floor((box.y + box.h) / cell)
for (let cx = minCX; cx <= maxCX; cx += 1) {
for (let cy = minCY; cy <= maxCY; cy += 1) {
const layerCount = (grid.get(cellKey(cx, cy)) || []).length
if (layerCount > existing) existing = layerCount
}
}
return clamp(existing + 1, 1, maxLayers)
}
// ========== 逐个生成气泡 ==========
let consecutiveFailures = 0
const MAX_CONSECUTIVE_FAILURES = 80
const spawnOne = () => {
if (!isVisible.value) return
if (phase.value !== 'storm') return
const now = performance.now()
const elapsed = animationStartedAt > 0 ? (now - animationStartedAt) : 0
const remain = animationDeadlineAt > 0 ? (animationDeadlineAt - now) : TOTAL_ANIMATION_LIMIT_MS
// 结束条件:底层覆盖近乎满屏,且有可见二层重叠;或达到上限;或连续失败。
const coverage = computeCoverage(1)
const layer2Coverage = computeCoverage(2)
if (
elapsed >= STORM_STAGE_LIMIT_MS ||
remain <= MERGE_MIN_BUDGET_MS ||
(coverage >= targetBaseCoverage && layer2Coverage >= targetLayer2Coverage) ||
bubbles.value.length >= maxBubbles ||
consecutiveFailures >= MAX_CONSECUTIVE_FAILURES
) {
phase.value = 'packed'
clearTimers()
const packedPause = clamp(Math.round(Math.min(PACKED_PAUSE_MS, Math.max(36, remain - MERGE_MIN_BUDGET_MS))), 24, PACKED_PAUSE_MS)
packedTimer = setTimeout(() => runMergeBurst(rng, centerX, centerY), packedPause)
return
}
const text = msgs.length > 0 ? msgs[msgIdx % msgs.length] : ''
msgIdx += 1
const sz = bubbleSizeForText(text)
let box = placeBox(sz.w, sz.h)
// 如果标准尺寸放不下,尝试紧凑尺寸
if (!box) {
const compactSz = bubbleSizeForText(text, true)
box = placeBox(compactSz.w, compactSz.h)
if (box) {
box = { ...box, w: compactSz.w, h: compactSz.h }
}
}
if (!box) {
box = placeGapFillBox(text)
}
if (!box) {
consecutiveFailures += 1
} else {
consecutiveFailures = 0
const depth = getLayerDepthForBox(box)
const id = ++bubbleSeq
boxById.set(id, box)
addToGrid(id, box)
bubbles.value = [...bubbles.value, {
id, text, x: box.x, y: box.y, w: box.w, h: box.h,
segments: parseTextWithEmoji(text),
depth
}]
requestAnimationFrame(() => {
const el = bubbleEls.get(id)
if (!el) return
gsap.fromTo(
el,
{ opacity: 0, scale: 0.94, y: 10 },
{ opacity: 1, scale: 1, y: 0, duration: 0.18, ease: 'power2.out' }
)
})
}
// 加速模式下极快生成
const interval = accelerated.value ? 12 : Math.max(16, Math.round(lerp(420, 32, (bubbles.value.length / Math.max(1, maxBubbles)) ** 2)))
stormTimer = setTimeout(spawnOne, interval)
}
// 启动
spawnOne()
}
const runMergeBurst = (rng, centerX, centerY) => {
if (!import.meta.client) return
if (!isVisible.value) return
if (phase.value !== 'packed') return
const now = performance.now()
const remainMs = animationDeadlineAt > 0 ? Math.max(0, animationDeadlineAt - now) : TOTAL_ANIMATION_LIMIT_MS
if (remainMs <= 140) {
skipToCloud()
return
}
const els = []
const deltas = []
const dist = []
for (const b of bubbles.value) {
const el = bubbleEls.get(b.id)
if (!el) continue
const dx = (centerX - (b.x + b.w / 2))
const dy = (centerY - (b.y + b.h / 2))
const d = Math.hypot(dx, dy)
els.push(el)
deltas.push({ dx, dy, b })
dist.push(d)
}
// 按距离排序:远的先动
const order = els.map((_, i) => i).sort((a, b) => dist[b] - dist[a])
const elsSorted = order.map((i) => els[i])
const deltasSorted = order.map((i) => deltas[i])
phase.value = 'merge'
killTimeline()
// 根据剩余时间动态压缩 merge/burst,确保总时长不超过 10s。
const availableMs = Math.max(260, remainMs - 40)
const availableSec = availableMs / 1000
const n = Math.max(1, elsSorted.length)
const mergeDur = clamp((availableMs * 0.32) / 1000, 0.26, 0.80)
const squeezeDur = clamp((availableMs * 0.08) / 1000, 0.06, 0.14)
const burstDur = clamp((availableMs * 0.18) / 1000, 0.18, 0.45)
const mergeStaggerBudget = Math.max(0, (availableMs * 0.22) / 1000)
const burstStaggerBudget = Math.max(0, (availableMs * 0.12) / 1000)
const staggerMerge = n > 1 ? Math.min(0.0035, mergeStaggerBudget / (n - 1)) : 0
const staggerBurst = n > 1 ? Math.min(0.0018, burstStaggerBudget / (n - 1)) : 0
mainTl = gsap.timeline({
defaults: { ease: 'power3.inOut' },
onUpdate: () => {
if (animationDeadlineAt > 0 && performance.now() >= animationDeadlineAt && phase.value !== 'cloud') {
skipToCloud()
}
},
onComplete: () => {
bubbles.value = []
bubbleEls.clear()
clearTimers()
animationStartedAt = 0
animationDeadlineAt = 0
phase.value = 'cloud'
hasPlayed.value = true
stopParticles()
}
})
mainTl.to(elsSorted, {
duration: mergeDur,
x: (i) => {
const it = deltasSorted[i]
const jitter = (rng() - 0.5) * 18
return it.dx + jitter
},
y: (i) => {
const it = deltasSorted[i]
const jitter = (rng() - 0.5) * 18
return it.dy + jitter
},
scale: 0.72,
opacity: 0.15,
stagger: staggerMerge
})
mainTl.call(() => { phase.value = 'burst' })
mainTl.to(elsSorted, { duration: squeezeDur, scale: 0.66, ease: 'power2.in' })
const vw = curViewW || window.innerWidth
const vh = curViewH || window.innerHeight
const burstOffsets = deltasSorted.map(() => {
const ang = rng() * Math.PI * 2
const rad = Math.min(vw, vh) * (0.28 + rng() * 0.45)
return { x: Math.cos(ang) * rad, y: Math.sin(ang) * rad }
})
mainTl.to(elsSorted, {
duration: burstDur,
x: (i) => {
const it = deltasSorted[i]
return it.dx + (burstOffsets[i]?.x || 0)
},
y: (i) => {
const it = deltasSorted[i]
return it.dy + (burstOffsets[i]?.y || 0)
},
opacity: 0,
scale: 0.92,
ease: 'power3.out',
stagger: staggerBurst,
onStart: () => {
startParticles(rng, centerX, centerY)
}
})
const tlTotal = mainTl.totalDuration()
if (tlTotal > availableSec && availableSec > 0.06) {
mainTl.timeScale(Math.max(1, tlTotal / availableSec))
}
}
watch(
() => [isVisible.value, props.card?.status, props.card?.data?.year],
() => {
if (!import.meta.client) return
if (!isVisible.value) {
if (phase.value !== 'cloud') {
reset()
} else {
clearTimers()
killTimeline()
stopParticles()
}
return
}
maybeStart()
}
)
onMounted(() => {
if (!import.meta.client) return
detectReducedMotion()
if (typeof IntersectionObserver !== 'undefined' && cardRoot.value) {
io = new IntersectionObserver(
(entries) => {
const ent = entries && entries[0]
updateVisibility(!!ent?.isIntersecting && (ent.intersectionRatio || 0) >= 0.35)
},
{ threshold: [0, 0.35, 0.6, 1] }
)
io.observe(cardRoot.value)
} else {
isVisible.value = true
}
maybeStart()
})
onBeforeUnmount(() => {
io?.disconnect?.()
io = null
// 确保 deck chrome 恢复
deckChromeHidden.value = false
reset()
})
</script>
<style scoped>
.kw-stage {
transition: none !important;
}
.kw-overlay {
/* 确保不受父级 transform 影响 */
contain: layout;
/* 保持年度总结原背景,不再强制改成绿色底色。 */
background: transparent;
}
.kw-halo {
background: radial-gradient(circle at center, rgba(7, 193, 96, 0.16) 0%, rgba(7, 193, 96, 0.06) 38%, transparent 72%);
}
.kw-chip {
font-size: 11px;
line-height: 1;
padding: 7px 10px;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.55);
color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(10px);
transition: background 160ms ease, transform 160ms ease, color 160ms ease, border-color 160ms ease;
}
.kw-chip:hover {
background: rgba(255, 255, 255, 0.72);
transform: translateY(-1px);
}
.kw-chip--on {
background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.22);
color: rgba(0, 0, 0, 0.75);
}
.kw-bubble {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
}
.kw-bubble--d1 .msg-bubble { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.10); }
.kw-bubble--d2 .msg-bubble { box-shadow: 0 8px 16px rgba(0, 0, 0, 0.13); }
.kw-bubble--d3 .msg-bubble { box-shadow: 0 12px 22px rgba(0, 0, 0, 0.16); }
.cloud-fade-enter-active,
.cloud-fade-leave-active {
transition: opacity 800ms ease, transform 800ms cubic-bezier(0.22, 1, 0.36, 1);
}
.cloud-fade-enter-from,
.cloud-fade-leave-to {
opacity: 0;
transform: scale(0.96);
}
</style>
@@ -22,11 +22,11 @@
<section v-else class="relative h-full w-full overflow-hidden">
<div
class="relative h-full flex flex-col"
:class="wide
:class="hideChrome ? '' : (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'"
: '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 v-if="!hideChrome" class="flex items-start justify-between gap-4">
<div>
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
<slot name="narrative">
@@ -38,7 +38,7 @@
<slot name="badge" />
</div>
<div class="flex-1 flex items-center mt-6 sm:mt-8">
<div class="flex-1 flex items-center" :class="hideChrome ? '' : 'mt-6 sm:mt-8'">
<div class="w-full">
<slot />
</div>
@@ -55,6 +55,8 @@ defineProps({
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
// Slide 模式下是否取消 max-width 限制(让内容直接铺满页面宽度)。
// 用于需要横向展示的可视化(如年度日历热力图)。
wide: { type: Boolean, default: false }
wide: { type: Boolean, default: false },
// 隐藏标题/叙事区域(如关键词卡片 storm 阶段沉浸模式)。
hideChrome: { type: Boolean, default: false }
})
</script>
@@ -0,0 +1,388 @@
<template>
<div ref="rootEl" class="kw-cloud relative w-full h-full select-none">
<!-- Words -->
<div
class="absolute inset-0"
:class="shouldAnimate ? 'kw-animate' : ''"
@pointerdown="onBgPointerDown"
>
<button
v-for="(w, idx) in placedWords"
:key="w.word"
type="button"
class="kw-word"
:class="selectedWord === w.word ? 'kw-word--selected' : ''"
:style="wordStyle(w, idx)"
:title="`${w.word} · ${formatInt(w.count)} 次`"
@pointerdown.stop="selectWord(w.word)"
>
{{ w.word }}
</button>
</div>
<!-- Empty state -->
<div v-if="placedWords.length === 0" class="absolute inset-0 flex items-center justify-center">
<div class="rounded-2xl border border-[#EDEDED] bg-white/70 backdrop-blur px-5 py-4 text-center">
<div class="wrapped-title text-base text-[#000000e6]">暂无关键词</div>
<div class="mt-1 wrapped-body text-sm text-[#7F7F7F]">这一年你还没有足够的文字消息来生成词云</div>
</div>
</div>
<!-- Examples panel -->
<transition name="kw-panel">
<div
v-if="selectedInfo"
class="kw-panel absolute left-1/2 bottom-3 -translate-x-1/2 w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
data-no-accel
@pointerdown.stop
>
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
<div class="min-w-0">
<div class="wrapped-title text-base text-[#000000e6] truncate">
{{ selectedInfo.word }}
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} </span>
</div>
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
点击其它词看看它出现的瞬间
</div>
</div>
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded-full text-[#00000066] hover:bg-[#00000008] transition"
aria-label="关闭"
@click="clearSelection"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-4 py-3 max-h-[220px] overflow-auto">
<div v-if="selectedInfo.messages.length === 0" class="wrapped-body text-sm text-[#7F7F7F]">
没找到可展示的例句
</div>
<div v-else class="space-y-2">
<div
v-for="(m, i) in selectedInfo.messages"
:key="`${selectedInfo.word}-${i}-${m.raw}`"
class="flex justify-end"
>
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
<div :class="privacyMode ? 'privacy-blur' : ''" class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words">
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
<span v-else>{{ m.raw }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
const props = defineProps({
keywords: { type: Array, default: () => [] }, // [{word,count,weight}]
examples: { type: Array, default: () => [] }, // [{word,count,messages:[]}]
privacyMode: { type: Boolean, default: false },
animate: { type: Boolean, default: true },
reducedMotion: { type: Boolean, default: false }
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const privacyMode = computed(() => !!props.privacyMode)
const rootEl = ref(null)
const width = ref(0)
const height = ref(0)
let resizeObserver = null
const updateSize = () => {
if (!import.meta.client) return
const rect = rootEl.value?.getBoundingClientRect?.()
if (!rect) return
width.value = Math.max(0, Math.round(rect.width || 0))
height.value = Math.max(0, Math.round(rect.height || 0))
}
const shouldAnimate = computed(() => !!props.animate && !props.reducedMotion)
const examplesMap = computed(() => {
const out = new Map()
const list = Array.isArray(props.examples) ? props.examples : []
for (const x of list) {
const w = String(x?.word || '').trim()
if (!w) continue
const cnt = Number(x?.count || 0)
const msgs = Array.isArray(x?.messages) ? x.messages.map((m) => String(m || '')).filter((m) => m.trim()) : []
out.set(w, {
word: w,
count: Number.isFinite(cnt) ? cnt : 0,
messages: msgs.slice(0, 3).map((m) => ({ raw: m, segments: parseTextWithEmoji(m) }))
})
}
return out
})
const selectedWord = ref('')
const selectedInfo = computed(() => {
const w = String(selectedWord.value || '').trim()
if (!w) return null
const ex = examplesMap.value.get(w)
if (ex) return ex
// Fallback: if examples missing, still show count from keywords.
const kw = (Array.isArray(props.keywords) ? props.keywords : []).find((k) => String(k?.word || '').trim() === w)
const cnt = kw ? Number(kw.count || 0) : 0
return { word: w, count: Number.isFinite(cnt) ? cnt : 0, messages: [] }
})
const clearSelection = () => { selectedWord.value = '' }
const selectWord = (w) => { selectedWord.value = String(w || '').trim() }
const onBgPointerDown = (e) => {
if (!e) return
// Clicking blank area closes the panel.
clearSelection()
}
// Spiral layout (canvas measureText).
let measureCanvas = null
let measureCtx = null
const ensureMeasureCtx = () => {
if (!import.meta.client) return null
if (measureCtx) return measureCtx
measureCanvas = document.createElement('canvas')
measureCtx = measureCanvas.getContext('2d')
return measureCtx
}
const placedWords = ref([])
const wordStyle = (w, idx) => ({
left: `${Number(w?.x || 0)}px`,
top: `${Number(w?.y || 0)}px`,
fontSize: `${Math.max(10, Math.round(Number(w?.fontSize || 14)))}px`,
fontWeight: Number(w?.fontWeight || 600),
color: String(w?.color || '#111827'),
'--d': `${Math.max(0, Number(idx || 0) * 15)}ms`
})
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
const palette = ['#07C160', '#0EA5E9', '#F2AA00', '#111827', '#16A34A', '#2563EB']
const layoutWords = async () => {
if (!import.meta.client) return
if (!width.value || !height.value) return
const ctx = ensureMeasureCtx()
if (!ctx) return
const margin = 18
const w = width.value
const h = height.value
const cx = w / 2
const cy = h / 2
const src = (Array.isArray(props.keywords) ? props.keywords : [])
.map((x) => ({
word: String(x?.word || '').trim(),
count: Number(x?.count || 0),
weight: Number(x?.weight || 0)
}))
.filter((x) => x.word && Number.isFinite(x.count) && x.count > 0)
src.sort((a, b) => {
if (b.weight !== a.weight) return b.weight - a.weight
if (b.count !== a.count) return b.count - a.count
return a.word.localeCompare(b.word)
})
const maxFont = clamp(Math.round(Math.min(54, Math.max(34, h * 0.095))), 32, 54)
const minFont = clamp(Math.round(Math.min(20, Math.max(14, h * 0.03))), 14, 20)
const boxes = []
const placed = []
const intersects = (a, b) => !(
a.x2 <= b.x1 ||
a.x1 >= b.x2 ||
a.y2 <= b.y1 ||
a.y1 >= b.y2
)
for (let idx = 0; idx < src.length; idx += 1) {
const item = src[idx]
const wn = clamp((item.weight - 0.2) / 0.8, 0, 1)
const fontSize = Math.round(minFont + (maxFont - minFont) * wn)
const fontWeight = idx <= 2 ? 800 : (idx <= 10 ? 700 : 600)
ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif`
const textW = Math.ceil(ctx.measureText(item.word).width || 0)
const textH = Math.ceil(fontSize * 1.18)
const bw = textW + 10
const bh = textH + 6
let placedOk = false
let best = null
const angleStep = 0.35
const radiusStep = 6
for (let t = 0; t < 250; t += 1) {
const ang = t * angleStep
const rad = t * radiusStep
const x = cx + Math.cos(ang) * rad
const y = cy + Math.sin(ang) * rad
const box = { x1: x - bw / 2, y1: y - bh / 2, x2: x + bw / 2, y2: y + bh / 2 }
if (box.x1 < margin || box.y1 < margin || box.x2 > (w - margin) || box.y2 > (h - margin)) continue
let ok = true
for (const b of boxes) {
if (intersects(box, b)) { ok = false; break }
}
if (!ok) continue
best = { x, y }
placedOk = true
break
}
if (!placedOk || !best) continue
const color = idx === 0 ? '#07C160' : palette[idx % palette.length]
placed.push({
word: item.word,
count: Math.round(Number(item.count) || 0),
weight: item.weight,
fontSize,
fontWeight,
color,
x: best.x,
y: best.y
})
boxes.push({ x1: best.x - bw / 2, y1: best.y - bh / 2, x2: best.x + bw / 2, y2: best.y + bh / 2 })
}
placedWords.value = placed
await nextTick()
}
watch(
() => [width.value, height.value, props.keywords],
() => {
if (!import.meta.client) return
layoutWords()
},
{ deep: true }
)
watch(
() => props.examples,
() => {
// Keep selection stable if possible; clear if word no longer exists.
if (!selectedWord.value) return
if (!examplesMap.value.get(selectedWord.value) && !(Array.isArray(props.keywords) && props.keywords.find((k) => k?.word === selectedWord.value))) {
selectedWord.value = ''
}
},
{ deep: true }
)
onMounted(() => {
if (!import.meta.client) return
updateSize()
if (typeof ResizeObserver !== 'undefined' && rootEl.value) {
resizeObserver = new ResizeObserver(() => updateSize())
resizeObserver.observe(rootEl.value)
} else {
window.addEventListener('resize', updateSize)
}
layoutWords()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect?.()
resizeObserver = null
if (import.meta.client) window.removeEventListener('resize', updateSize)
measureCanvas = null
measureCtx = null
})
</script>
<style scoped>
.kw-word {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%);
line-height: 1.15;
letter-spacing: 0.02em;
padding: 2px 6px;
border-radius: 10px;
cursor: pointer;
user-select: none;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.08);
transition: transform 160ms ease, filter 160ms ease, background 160ms ease, box-shadow 160ms ease;
opacity: 1;
}
.kw-word:hover {
transform: translate(-50%, -50%) scale(1.04);
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.12);
}
.kw-word--selected {
background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.28);
box-shadow: 0 18px 42px rgba(7, 193, 96, 0.18);
filter: drop-shadow(0 0 10px rgba(7, 193, 96, 0.28));
}
.kw-animate .kw-word {
opacity: 0;
animation: kw-pop 450ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--d, 0ms);
}
@keyframes kw-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
filter: blur(2px);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
filter: blur(0);
}
}
.kw-panel-enter-active,
.kw-panel-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
}
.kw-panel-enter-from,
.kw-panel-leave-to {
opacity: 0;
transform: translate(-50%, 10px);
}
</style>
+13 -2
View File
@@ -8,7 +8,7 @@
<WrappedDeckBackground />
<!-- 左上角返回 + 刷新 -->
<div class="absolute top-6 left-6 z-20 select-none">
<div v-show="!deckChromeHidden" class="absolute top-6 left-6 z-20 select-none transition-opacity duration-300">
<div class="flex items-center gap-3">
<button
type="button"
@@ -65,7 +65,7 @@
</div>
<!-- 右上角年份选择器主题化 -->
<div class="absolute top-6 right-6 z-20 pointer-events-auto select-none">
<div v-show="!deckChromeHidden" class="absolute top-6 right-6 z-20 pointer-events-auto select-none transition-opacity duration-300">
<div class="relative">
<div class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div class="relative flex justify-end">
@@ -157,6 +157,12 @@
variant="slide"
class="h-full w-full"
/>
<Card06KeywordsWordCloud
v-else-if="c && (c.kind === 'text/keywords_wordcloud' || c.id === 6)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<Card03ReplySpeed
v-else-if="c && (c.kind === 'chat/reply_speed' || c.id === 3)"
:card="c"
@@ -238,6 +244,11 @@ const viewportHeight = ref(0)
const activeIndex = ref(0)
const navLocked = ref(false)
const wheelAcc = ref(0)
// 允许子卡片隐藏 deck 顶部 UI(如关键词卡片 storm 阶段)
const deckChromeHidden = ref(false)
provide('deckChromeHidden', deckChromeHidden)
let navUnlockTimer = null
let deckResizeObserver = null
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"zstandard>=0.23.0",
"pilk>=0.2.4",
"pypinyin>=0.53.0",
"jieba>=0.42.1",
"wx_key",
"packaging",
"httpx",
@@ -77,7 +77,9 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
for r in rows:
if not r or not r[0]:
continue
name = str(r[0])
name = _decode_sqlite_text(r[0]).strip()
if not name:
continue
ln = name.lower()
if ln.startswith(("msg_", "chat_")):
names.append(name)
@@ -12,6 +12,7 @@ from typing import Any, Optional
from ...chat_search_index import get_chat_search_index_db_path
from ...chat_helpers import (
_build_avatar_url,
_decode_sqlite_text,
_iter_message_db_paths,
_load_contact_rows,
_pick_display_name,
@@ -745,7 +746,9 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
for r in rows:
if not r or not r[0]:
continue
name = str(r[0])
name = _decode_sqlite_text(r[0]).strip()
if not name:
continue
ln = name.lower()
if ln.startswith(("msg_", "chat_")):
names.append(name)
@@ -11,7 +11,7 @@ from typing import Any, Optional
from pypinyin import lazy_pinyin, Style
from ...chat_helpers import _decode_message_content, _iter_message_db_paths, _quote_ident
from ...chat_helpers import _decode_message_content, _decode_sqlite_text, _iter_message_db_paths, _quote_ident
from ...chat_search_index import get_chat_search_index_db_path
from ...logging_config import get_logger
@@ -467,7 +467,9 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
for r in rows:
if not r or not r[0]:
continue
name = str(r[0])
name = _decode_sqlite_text(r[0]).strip()
if not name:
continue
ln = name.lower()
if ln.startswith(("msg_", "chat_")):
names.append(name)
@@ -0,0 +1,572 @@
from __future__ import annotations
import hashlib
import logging
import math
import random
import re
import sqlite3
import time
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
import jieba
from ...chat_helpers import _decode_message_content, _decode_sqlite_text, _iter_message_db_paths, _quote_ident
from ...logging_config import get_logger
logger = get_logger(__name__)
try:
jieba.setLogLevel(logging.ERROR)
except Exception:
pass
_MD5_HEX_RE = re.compile(r"(?i)\b[0-9a-f]{32}\b")
_URL_RE = re.compile(r"(?i)\bhttps?://\S+")
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
_HAS_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_CJK_SEQ_RE = re.compile(r"[\u4e00-\u9fff]+")
_HAS_ALNUM_RE = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]")
_EN_WORD_RE = re.compile(r"^[A-Za-z]{3,16}$")
_DATEISH_RE = re.compile(
r"^(?:"
r"\d{4}[-/]\d{1,2}[-/]\d{1,2}"
r"|"
r"\d{1,2}:\d{2}"
r"|"
r"\d{1,2}月\d{1,2}日"
r")$"
)
# Small but practical stopword list for chat keywords.
_STOPWORDS_ZH = {
"",
"",
"",
"",
"",
"",
"",
"",
"我们",
"你们",
"他们",
"她们",
"它们",
"",
"",
"这个",
"那个",
"这里",
"那里",
"这样",
"那样",
"就是",
"也是",
"还有",
"因为",
"所以",
"但是",
"如果",
"然后",
"已经",
"可以",
"还是",
"可能",
"不会",
"没有",
"不是",
"一个",
"一下",
"一下子",
"一下下",
"哈哈",
"哈哈哈",
"嘿嘿",
"呜呜",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"可以",
"ok",
"OK",
}
_STOPWORDS_EN = {
"the",
"a",
"an",
"and",
"or",
"but",
"to",
"of",
"in",
"on",
"for",
"with",
"at",
"from",
"as",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"i",
"me",
"my",
"you",
"your",
"he",
"she",
"it",
"we",
"they",
"them",
"this",
"that",
"these",
"those",
"yeah",
"haha",
"ok",
"okay",
"pls",
"lol",
}
def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
start = int(datetime(int(year), 1, 1).timestamp())
end = int(datetime(int(year) + 1, 1, 1).timestamp())
return start, end
def _stable_seed(account_name: str, year: int) -> int:
s = f"{str(account_name or '').strip()}|{int(year)}|wrapped_keywords"
h = hashlib.sha256(s.encode("utf-8")).hexdigest()
return int(h[:8], 16)
def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
try:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
except Exception:
return []
names: list[str] = []
for r in rows:
if not r or not r[0]:
continue
name = _decode_sqlite_text(r[0]).strip()
if not name:
continue
ln = name.lower()
if ln.startswith(("msg_", "chat_")):
names.append(name)
return names
def _clean_text(text: str) -> str:
s = str(text or "")
if not s:
return ""
s = s.replace("\u200b", "").replace("\ufeff", "")
s = _CTRL_RE.sub("", s)
s = _URL_RE.sub("", s)
s = re.sub(r"\s+", " ", s).strip()
if not s:
return ""
# XML-like payloads are rarely useful as bubbles/keywords.
if s.startswith("<") or s.startswith('"<'):
return ""
return s
def _is_good_bubble_text(text: str) -> bool:
s = _clean_text(text)
if not s:
return False
# 仅过滤极短噪声,不对消息长度设置上限。
if len(s) < 2:
return False
if _URL_RE.search(s):
return False
if _MD5_HEX_RE.fullmatch(s.replace(" ", "")):
return False
# Avoid pure punctuation / emoji / digits.
if not re.search(r"[\u4e00-\u9fffA-Za-z]", s):
return False
if not _HAS_ALNUM_RE.search(s):
return False
if re.fullmatch(r"[0-9]+", s):
return False
return True
def _is_good_example_text(text: str) -> bool:
s = _clean_text(text)
if not s:
return False
# 仅过滤极短噪声,不对消息长度设置上限。
if len(s) < 4:
return False
if _URL_RE.search(s):
return False
if _MD5_HEX_RE.search(s):
return False
if not re.search(r"[\u4e00-\u9fffA-Za-z]", s):
return False
return True
def _normalize_token(tok: str) -> str:
s = str(tok or "").strip()
if not s:
return ""
if len(s) > 32:
return ""
# Trim punctuation on both sides.
s = re.sub(r"^[^\w\u4e00-\u9fff]+|[^\w\u4e00-\u9fff]+$", "", s, flags=re.UNICODE).strip()
if not s:
return ""
if _MD5_HEX_RE.fullmatch(s) or _MD5_HEX_RE.search(s):
return ""
if _DATEISH_RE.fullmatch(s):
return ""
# Discard if contains obvious long ids (alnum with many digits).
if len(s) >= 18 and re.fullmatch(r"[A-Za-z0-9_-]+", s) and sum(ch.isdigit() for ch in s) >= 6:
return ""
# Remove tokens with digits.
if any(ch.isdigit() for ch in s):
return ""
has_cjk = bool(_HAS_CJK_RE.search(s))
if has_cjk:
if not (2 <= len(s) <= 8):
return ""
if s in _STOPWORDS_ZH:
return ""
return s
if _EN_WORD_RE.fullmatch(s):
low = s.lower()
if low in _STOPWORDS_EN:
return ""
return low
return ""
def extract_keywords_jieba(texts: list[str], *, top_n: int = 40) -> list[dict[str, Any]]:
counter: Counter[str] = Counter()
for raw in texts:
s = _clean_text(raw)
if not s:
continue
try:
toks = jieba.lcut(s, cut_all=False)
except Exception:
toks = []
had_token = False
for tok in toks:
w = _normalize_token(tok)
if not w:
continue
counter[w] += 1
had_token = True
# Fallback for short chat phrases that Jieba often splits into single characters
# (e.g. "在吗" -> ["在","吗"]) which we intentionally filter out.
if not had_token and _HAS_CJK_RE.search(s):
for seg in _CJK_SEQ_RE.findall(s):
if len(seg) < 2:
continue
for i in range(0, len(seg) - 1):
w = _normalize_token(seg[i : i + 2])
if not w:
continue
counter[w] += 1
if not counter:
return []
items = [(w, int(c)) for w, c in counter.items() if int(c) > 1]
if not items:
# If everything is singleton, still provide something.
items = [(w, int(c)) for w, c in counter.items() if int(c) > 0]
items.sort(key=lambda kv: (-kv[1], kv[0]))
items = items[: max(0, int(top_n or 0))]
if not items:
return []
vals = [math.sqrt(max(0, c)) for _, c in items]
minv = min(vals) if vals else 0.0
maxv = max(vals) if vals else 0.0
out: list[dict[str, Any]] = []
for (w, c), v in zip(items, vals):
if maxv <= minv:
weight = 1.0
else:
weight = 0.2 + 0.8 * ((v - minv) / (maxv - minv))
out.append({"word": w, "count": int(c), "weight": round(float(weight), 4)})
return out
def pick_examples(
keywords: list[dict[str, Any]],
message_pool: list[str],
*,
per_word: int = 3,
) -> list[dict[str, Any]]:
uniq_msgs = list(dict.fromkeys([_clean_text(x) for x in (message_pool or []) if _clean_text(x)]))
out: list[dict[str, Any]] = []
for kw in keywords:
word = str(kw.get("word") or "").strip()
if not word:
continue
count = int(kw.get("count") or 0)
hits: list[str] = []
if _HAS_CJK_RE.search(word):
for msg in uniq_msgs:
if len(hits) >= int(per_word):
break
if not _is_good_example_text(msg):
continue
if word in msg:
hits.append(msg)
else:
wlow = word.lower()
for msg in uniq_msgs:
if len(hits) >= int(per_word):
break
if not _is_good_example_text(msg):
continue
if wlow in msg.lower():
hits.append(msg)
out.append({"word": word, "count": int(count), "messages": hits})
return out
def build_keywords_payload(
*,
texts: list[str],
seed: int,
top_n: int = 40,
bubble_limit: int = 180,
examples_per_word: int = 3,
) -> dict[str, Any]:
_ = seed # 保留参数以兼容现有调用/测试;随机采样不再使用固定 seed。
keywords = extract_keywords_jieba(list(texts or []), top_n=top_n)
bubble_candidates = [_clean_text(x) for x in (texts or [])]
bubble_candidates = [x for x in bubble_candidates if _is_good_bubble_text(x)]
bubble_candidates = list(dict.fromkeys(bubble_candidates))
rnd = random.SystemRandom()
rnd.shuffle(bubble_candidates)
bubble_messages = bubble_candidates[: max(0, int(bubble_limit or 0))]
examples = pick_examples(keywords, texts, per_word=examples_per_word)
top_kw = None
if keywords:
top_kw = {"word": str(keywords[0]["word"]), "count": int(keywords[0]["count"])}
return {
"topKeyword": top_kw,
"keywords": keywords,
"bubbleMessages": bubble_messages,
"examples": examples,
}
def _scan_message_pool(
*,
account_dir: Path,
year: int,
outgoing_only: bool,
seed: int,
max_pool: int = 3000,
max_seen: int = 120_000,
) -> tuple[list[str], dict[str, Any]]:
start_ts, end_ts = _year_range_epoch_seconds(int(year))
_ = seed # 保留参数以兼容现有调用;抽样本身使用非确定性随机。
rnd = random.SystemRandom()
db_paths = _iter_message_db_paths(account_dir)
# Prefer chat shards; biz_message often contains service/ads content.
db_paths = [p for p in db_paths if not p.name.lower().startswith("biz_message")]
rnd.shuffle(db_paths)
pool: list[str] = []
seen = 0
t0 = time.time()
for db_path in db_paths:
if not db_path.exists():
continue
conn: sqlite3.Connection | None = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
my_rowid: int | None = None
if outgoing_only:
try:
r = conn.execute(
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
(str(account_dir.name),),
).fetchone()
if r is not None and r[0] is not None:
my_rowid = int(r[0])
except Exception:
my_rowid = None
if my_rowid is None:
continue
tables = _list_message_tables(conn)
if not tables:
continue
rnd.shuffle(tables)
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"
)
for table in tables:
if seen >= int(max_seen):
break
qt = _quote_ident(table)
where_sender = ""
params: tuple[Any, ...]
if outgoing_only and my_rowid is not None:
where_sender = " AND CAST(real_sender_id AS INTEGER) = ?"
params = (start_ts, end_ts, int(my_rowid))
else:
params = (start_ts, end_ts)
sql = (
"SELECT message_content, compress_content "
f"FROM {qt} "
"WHERE CAST(local_type AS INTEGER) = 1 "
f" AND {ts_expr} >= ? AND {ts_expr} < ?"
f"{where_sender}"
)
try:
cur = conn.execute(sql, params)
except Exception:
continue
for r in cur:
if seen >= int(max_seen):
break
raw_txt = ""
try:
raw_txt = _decode_message_content(r["compress_content"], r["message_content"]).strip()
except Exception:
raw_txt = ""
cleaned = _clean_text(raw_txt)
if not cleaned:
continue
seen += 1
if len(pool) < int(max_pool):
pool.append(cleaned)
continue
# Reservoir sampling over the accepted stream.
j = rnd.randrange(seen)
if j < int(max_pool):
pool[j] = cleaned
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
if seen >= int(max_seen):
break
elapsed = time.time() - t0
meta = {
"scannedMessages": int(seen),
"sampledMessages": int(len(pool)),
"sampleRate": round(float(len(pool)) / float(seen), 6) if seen > 0 else 0.0,
"elapsedSec": round(float(elapsed), 3),
}
return pool, meta
def build_card_05_keywords_wordcloud(*, account_dir: Path, year: int) -> dict[str, Any]:
title = "这一年,你把哪些词说了一遍又一遍?"
seed = _stable_seed(str(account_dir.name or ""), int(year))
pool, meta = _scan_message_pool(account_dir=account_dir, year=year, outgoing_only=True, seed=seed)
if len(pool) < 80:
pool, meta = _scan_message_pool(account_dir=account_dir, year=year, outgoing_only=False, seed=seed ^ 0x1234)
payload = build_keywords_payload(texts=pool, seed=seed)
logger.info(
"Wrapped card#6 keywords computed: account=%s year=%s keywords=%s bubble=%s scanned=%s sampled=%s elapsed=%.2fs",
str(account_dir.name or "").strip(),
int(year),
len(payload.get("keywords") or []),
len(payload.get("bubbleMessages") or []),
int(meta.get("scannedMessages") or 0),
int(meta.get("sampledMessages") or 0),
float(meta.get("elapsedSec") or 0.0),
)
return {
"id": 6,
"title": title,
"scope": "global",
"category": "C",
"status": "ok",
"kind": "text/keywords_wordcloud",
"narrative": "你的年度关键词词云",
"data": {
"year": int(year),
**payload,
"meta": {
"scannedMessages": int(meta.get("scannedMessages") or 0),
"sampledMessages": int(meta.get("sampledMessages") or 0),
"sampleRate": float(meta.get("sampleRate") or 0.0),
},
},
}
+39 -13
View File
@@ -8,13 +8,14 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from ..chat_helpers import _iter_message_db_paths, _quote_ident, _resolve_account_dir
from ..chat_helpers import _decode_sqlite_text, _iter_message_db_paths, _quote_ident, _resolve_account_dir
from ..chat_search_index import get_chat_search_index_db_path
from ..logging_config import get_logger
from .storage import wrapped_cache_dir, wrapped_cache_path
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_02_message_chars import build_card_02_message_chars
from .cards.card_05_keywords_wordcloud import build_card_05_keywords_wordcloud
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
@@ -24,9 +25,9 @@ logger = get_logger(__name__)
# We use this number to version the cache filename so adding more cards won't accidentally serve
# an older partial cache.
_IMPLEMENTED_UPTO_ID = 5
_IMPLEMENTED_UPTO_ID = 6
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
_CACHE_VERSION = 18
_CACHE_VERSION = 23
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
@@ -53,6 +54,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
"category": "C",
"kind": "text/message_chars",
},
{
"id": 6,
"title": "这一年,你把哪些词说了一遍又一遍?",
"scope": "global",
"category": "C",
"kind": "text/keywords_wordcloud",
},
{
"id": 3,
"title": "谁是你「秒回」的置顶关心?",
@@ -105,7 +113,9 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
for r in rows:
if not r or not r[0]:
continue
name = str(r[0])
name = _decode_sqlite_text(r[0]).strip()
if not name:
continue
ln = name.lower()
if ln.startswith(("msg_", "chat_")):
names.append(name)
@@ -290,7 +300,7 @@ def build_wrapped_annual_response(
) -> dict[str, Any]:
"""Build annual wrapped response for the given account/year.
For now we implement cards up to id=5 (plus a meta overview card id=0).
For now we implement cards up to id=6 (plus a meta overview card id=0).
"""
account_dir = _resolve_account_dir(account)
@@ -315,6 +325,15 @@ def build_wrapped_annual_response(
try:
cached_obj = json.loads(cache_path.read_text(encoding="utf-8"))
if isinstance(cached_obj, dict) and isinstance(cached_obj.get("cards"), list):
# Card#6(关键词词云)要求每次请求返回随机消息批次,不复用旧卡片内容。
for idx, c in enumerate(cached_obj.get("cards") or []):
try:
if int((c or {}).get("id") or -1) != 6:
continue
except Exception:
continue
cached_obj["cards"][idx] = build_card_05_keywords_wordcloud(account_dir=account_dir, year=y)
break
cached_obj["cached"] = True
cached_obj["availableYears"] = available_years
return cached_obj
@@ -331,11 +350,13 @@ def build_wrapped_annual_response(
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
# Page 4: message char counts (sent vs received).
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
# Page 5: reply speed / best chat buddy.
# Page 5: annual keywords (bubble storm -> word cloud).
cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y))
# Page 6: reply speed / best chat buddy.
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
# Page 6: monthly best friends wall (photo wall).
# Page 7: 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.
# Page 8: annual emoji universe / meme almanac.
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
obj: dict[str, Any] = {
@@ -505,10 +526,12 @@ def build_wrapped_annual_card(
scope = "global"
cache_path = _wrapped_card_cache_path(account_dir=account_dir, scope=scope, year=y, card_id=cid)
# Card#6 需要每次随机抽样,不使用按卡片缓存。
cacheable = cid != 6
lock = _get_lock(str(cache_path))
with lock:
if (not refresh) and cache_path.exists():
if cacheable and (not refresh) and cache_path.exists():
try:
cached_obj = json.loads(cache_path.read_text(encoding="utf-8"))
if isinstance(cached_obj, dict) and int(cached_obj.get("id") or -1) == cid:
@@ -526,6 +549,8 @@ def build_wrapped_annual_card(
card = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
elif cid == 2:
card = build_card_02_message_chars(account_dir=account_dir, year=y)
elif cid == 6:
card = build_card_05_keywords_wordcloud(account_dir=account_dir, year=y)
elif cid == 3:
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
elif cid == 4:
@@ -536,9 +561,10 @@ def build_wrapped_annual_card(
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
raise ValueError(f"Unknown Wrapped card id: {cid}")
try:
cache_path.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
logger.exception("Failed to write wrapped card cache: %s", cache_path)
if cacheable:
try:
cache_path.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
logger.exception("Failed to write wrapped card cache: %s", cache_path)
return card
+128
View File
@@ -0,0 +1,128 @@
import unittest
from pathlib import Path
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 TestWrappedKeywordsWordCloud(unittest.TestCase):
def test_extract_keywords_jieba_basic(self):
from wechat_decrypt_tool.wrapped.cards.card_05_keywords_wordcloud import extract_keywords_jieba
texts = [
"火锅 火锅",
"火锅太好吃了!!!",
"movie night movie",
"2024-01-01 12:30",
"哈哈哈哈",
]
out = extract_keywords_jieba(texts, top_n=40)
self.assertIsInstance(out, list)
self.assertLessEqual(len(out), 40)
# Must be sorted by count descending (tie-break by word).
counts = [int(x.get("count") or 0) for x in out]
self.assertEqual(counts, sorted(counts, reverse=True))
# Weights are normalized into [0.2, 1.0] when multiple items exist.
for x in out:
w = float(x.get("weight") or 0)
self.assertGreaterEqual(w, 0.0)
self.assertLessEqual(w, 1.0)
words = [str(x.get("word") or "") for x in out]
self.assertTrue(any("火锅" == w for w in words))
self.assertTrue(any("movie" == w for w in words))
self.assertTrue(all(not w.isdigit() for w in words if w))
def test_extract_keywords_jieba_short_phrases(self):
from wechat_decrypt_tool.wrapped.cards.card_05_keywords_wordcloud import extract_keywords_jieba
# Jieba may split short chat phrases into single characters ("在吗" -> ["在","吗"]),
# which would be filtered out by our tokenizer. Ensure we still extract meaningful
# 2-char phrases as a fallback.
texts = ["在吗"] * 30 + ["好的"] * 25 + [""] * 40 + ["哈哈"] * 40
out = extract_keywords_jieba(texts, top_n=10)
words = [str(x.get("word") or "") for x in out]
self.assertIn("在吗", words)
self.assertIn("好的", words)
def test_list_message_tables_decodes_bytes(self):
import sqlite3
from wechat_decrypt_tool.wrapped.cards.card_05_keywords_wordcloud import _list_message_tables
conn = sqlite3.connect(":memory:")
try:
conn.text_factory = bytes
conn.execute("CREATE TABLE Msg_abc (id INTEGER)")
conn.execute("CREATE TABLE Chat_def (id INTEGER)")
conn.execute("CREATE TABLE Other (id INTEGER)")
tables = _list_message_tables(conn)
finally:
conn.close()
self.assertIn("Msg_abc", tables)
self.assertIn("Chat_def", tables)
self.assertTrue(all(isinstance(x, str) for x in tables))
def test_pick_examples_contains_word(self):
from wechat_decrypt_tool.wrapped.cards.card_05_keywords_wordcloud import pick_examples
keywords = [
{"word": "火锅", "count": 3, "weight": 1.0},
{"word": "movie", "count": 2, "weight": 0.6},
]
pool = [
"今晚火锅走起",
"火锅太好吃了",
"no",
"<msg>xml</msg>",
"Movie night is fun",
"MOVIE time",
"https://example.com/movie",
]
out = pick_examples(keywords, pool, per_word=3)
self.assertEqual(len(out), 2)
m_hotpot = next(x for x in out if x["word"] == "火锅")
self.assertTrue(all("火锅" in m for m in m_hotpot["messages"]))
m_movie = next(x for x in out if x["word"] == "movie")
self.assertTrue(all("movie" in m.lower() for m in m_movie["messages"]))
def test_build_keywords_payload_structure(self):
from wechat_decrypt_tool.wrapped.cards.card_05_keywords_wordcloud import build_keywords_payload
texts = [
"今晚吃火锅吗?",
"火锅太好吃了!!!",
"一起去看电影吧",
"一起一起",
"movie night movie",
]
payload = build_keywords_payload(texts=texts, seed=123456)
self.assertIn("keywords", payload)
self.assertIn("bubbleMessages", payload)
self.assertIn("examples", payload)
self.assertIn("topKeyword", payload)
self.assertIsInstance(payload["keywords"], list)
self.assertIsInstance(payload["bubbleMessages"], list)
self.assertIsInstance(payload["examples"], list)
self.assertTrue(payload["topKeyword"] is None or isinstance(payload["topKeyword"], dict))
# bubble messages are unique and within limit
b = payload["bubbleMessages"]
self.assertLessEqual(len(b), 180)
self.assertEqual(len(b), len(list(dict.fromkeys(b))))
if __name__ == "__main__":
unittest.main()
Generated
+9 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.11"
[[package]]
@@ -296,6 +296,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "jieba"
version = "0.42.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" }
[[package]]
name = "loguru"
version = "0.7.3"
@@ -873,6 +879,7 @@ dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jieba" },
{ name = "loguru" },
{ name = "packaging" },
{ name = "pilk" },
@@ -899,6 +906,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx" },
{ name = "jieba", specifier = ">=0.42.1" },
{ name = "loguru", specifier = ">=0.7.0" },
{ name = "packaging" },
{ name = "pilk", specifier = ">=0.2.4" },