mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(wrapped): 新增年度关键词词云卡片(Card #6)
- 新增关键词词云后端计算:消息采样、jieba 分词、关键词权重与例句回看数据。 - Wrapped 年度卡片接入 Card #6,并对该卡片改为按请求重算(不走单卡缓存)。 - 前端新增 storm->cloud 词云动效卡片,支持词点击查看例句及动画阶段隐藏 deck 顶部 UI。 - 统一 wrapped 消息表名解码逻辑,兼容 sqlite text_factory=bytes。 - 新增关键词词云测试并加入 jieba 依赖。
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user