mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(wrapped): 新增便当总览卡片(Card #7)
- 增加 Card07BentoSummary 前端渲染与加载/失败重试交互(注入 wrappedRetryCard) - 后端新增 card_07_bento_summary:基于已实现卡片聚合生成 snapshot,保证渲染稳定 - Wrapped manifest/implemented_upto 升级到 7,并支持按卡片 id 单独构建 - 新增 manifest 末尾卡片校验测试
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,12 @@
|
|||||||
variant="slide"
|
variant="slide"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
<Card07BentoSummary
|
||||||
|
v-else-if="c && (c.kind === 'global/bento_summary' || c.id === 7)"
|
||||||
|
:card="c"
|
||||||
|
variant="slide"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
<WrappedCardShell
|
<WrappedCardShell
|
||||||
v-else
|
v-else
|
||||||
:card-id="Number(c?.id || (idx + 1))"
|
:card-id="Number(c?.id || (idx + 1))"
|
||||||
@@ -478,6 +484,8 @@ const retryCard = async (cardId) => {
|
|||||||
await ensureCardLoaded(cardId)
|
await ensureCardLoaded(cardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
provide('wrappedRetryCard', retryCard)
|
||||||
|
|
||||||
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
||||||
const token = ++reportToken
|
const token = ++reportToken
|
||||||
const keepIndex = preserveIndex ? activeIndex.value : 0
|
const keepIndex = preserveIndex ? activeIndex.value : 0
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _as_data(obj: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return {}
|
||||||
|
data = obj.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_int(x: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(x)
|
||||||
|
except Exception:
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_float(x: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
v = float(x)
|
||||||
|
return v if v == v else float(default) # NaN guard
|
||||||
|
except Exception:
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_str(x: Any, default: str = "") -> str:
|
||||||
|
s = str(x or "").strip()
|
||||||
|
return s if s else str(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_obj(d: Any, keys: tuple[str, ...]) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return None
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k in keys:
|
||||||
|
if k in d:
|
||||||
|
out[k] = d.get(k)
|
||||||
|
return out if out else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_card_07_bento_summary_from_sources(
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
overview: dict[str, Any],
|
||||||
|
heatmap: dict[str, Any],
|
||||||
|
message_chars: dict[str, Any],
|
||||||
|
reply_speed: dict[str, Any],
|
||||||
|
monthly: dict[str, Any],
|
||||||
|
emoji: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Card #7: Bento Summary (prototype style merged into Wrapped deck).
|
||||||
|
|
||||||
|
The frontend expects a stable `data.snapshot` object to render without running extra JS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
overview_d = _as_data(overview)
|
||||||
|
heatmap_d = _as_data(heatmap)
|
||||||
|
message_chars_d = _as_data(message_chars)
|
||||||
|
reply_speed_d = _as_data(reply_speed)
|
||||||
|
monthly_d = _as_data(monthly)
|
||||||
|
emoji_d = _as_data(emoji)
|
||||||
|
|
||||||
|
top_group_raw = overview_d.get("topGroup")
|
||||||
|
top_group = None
|
||||||
|
if isinstance(top_group_raw, dict):
|
||||||
|
display = _pick_str(top_group_raw.get("displayName"), "--")
|
||||||
|
top_group = {
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": display,
|
||||||
|
"avatarUrl": _pick_str(top_group_raw.get("avatarUrl"), ""),
|
||||||
|
"messages": _pick_int(top_group_raw.get("messages"), 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
best_buddy_raw = reply_speed_d.get("bestBuddy")
|
||||||
|
best_buddy = None
|
||||||
|
if isinstance(best_buddy_raw, dict):
|
||||||
|
display = _pick_str(best_buddy_raw.get("displayName"), "--")
|
||||||
|
best_buddy = {
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": display,
|
||||||
|
"avatarUrl": _pick_str(best_buddy_raw.get("avatarUrl"), ""),
|
||||||
|
"totalMessages": _pick_int(best_buddy_raw.get("totalMessages"), 0),
|
||||||
|
"longestStreakDays": _pick_int(best_buddy_raw.get("longestStreakDays"), 0),
|
||||||
|
"peakHour": best_buddy_raw.get("peakHour"),
|
||||||
|
"peakHourLabel": _pick_str(best_buddy_raw.get("peakHourLabel"), ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
fastest_raw = reply_speed_d.get("fastest")
|
||||||
|
fastest = None
|
||||||
|
if isinstance(fastest_raw, dict):
|
||||||
|
display = _pick_str(fastest_raw.get("displayName"), "--")
|
||||||
|
fastest = {
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": display,
|
||||||
|
"avatarUrl": _pick_str(fastest_raw.get("avatarUrl"), ""),
|
||||||
|
"seconds": _pick_int(fastest_raw.get("seconds"), 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
slowest_raw = reply_speed_d.get("slowest")
|
||||||
|
slowest = None
|
||||||
|
if isinstance(slowest_raw, dict):
|
||||||
|
display = _pick_str(slowest_raw.get("displayName"), "--")
|
||||||
|
slowest = {
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": display,
|
||||||
|
"avatarUrl": _pick_str(slowest_raw.get("avatarUrl"), ""),
|
||||||
|
"seconds": _pick_int(slowest_raw.get("seconds"), 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
reply_stats_raw = reply_speed_d.get("replyStats")
|
||||||
|
reply_stats = None
|
||||||
|
if isinstance(reply_stats_raw, dict):
|
||||||
|
reply_stats = {
|
||||||
|
"p50Seconds": reply_stats_raw.get("p50Seconds"),
|
||||||
|
"p90Seconds": reply_stats_raw.get("p90Seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
top_phrase_raw = overview_d.get("topPhrase")
|
||||||
|
top_phrase = None
|
||||||
|
if isinstance(top_phrase_raw, dict):
|
||||||
|
phrase = _pick_str(top_phrase_raw.get("phrase"), "")
|
||||||
|
count = _pick_int(top_phrase_raw.get("count"), 0)
|
||||||
|
if phrase and count > 0:
|
||||||
|
top_phrase = {"phrase": phrase, "count": count}
|
||||||
|
|
||||||
|
sent_sticker_count = _pick_int(emoji_d.get("sentStickerCount"), _pick_int(overview_d.get("sentStickerCount"), 0))
|
||||||
|
top_sticker = None
|
||||||
|
top_stickers = emoji_d.get("topStickers")
|
||||||
|
if isinstance(top_stickers, list) and top_stickers:
|
||||||
|
x0 = top_stickers[0] if isinstance(top_stickers[0], dict) else None
|
||||||
|
if x0:
|
||||||
|
url = _pick_str(x0.get("emojiUrl") or x0.get("imageUrl") or x0.get("url"), "")
|
||||||
|
cnt = _pick_int(x0.get("count"), 0)
|
||||||
|
if url:
|
||||||
|
top_sticker = {"imageUrl": url, "count": cnt}
|
||||||
|
|
||||||
|
top_unicode_emoji = ""
|
||||||
|
top_unicode_emoji_count = 0
|
||||||
|
top_unicode_emojis = emoji_d.get("topUnicodeEmojis")
|
||||||
|
if isinstance(top_unicode_emojis, list) and top_unicode_emojis:
|
||||||
|
x0 = top_unicode_emojis[0] if isinstance(top_unicode_emojis[0], dict) else None
|
||||||
|
if x0:
|
||||||
|
top_unicode_emoji = _pick_str(x0.get("emoji"), "")
|
||||||
|
top_unicode_emoji_count = _pick_int(x0.get("count"), 0)
|
||||||
|
|
||||||
|
monthly_best_buddies: list[dict[str, Any]] = []
|
||||||
|
months = monthly_d.get("months")
|
||||||
|
if isinstance(months, list) and months:
|
||||||
|
for item in months:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
m = _pick_int(item.get("month"), 0)
|
||||||
|
winner = item.get("winner") if isinstance(item.get("winner"), dict) else None
|
||||||
|
metrics = item.get("metrics") if isinstance(item.get("metrics"), dict) else None
|
||||||
|
raw = item.get("raw") if isinstance(item.get("raw"), dict) else None
|
||||||
|
monthly_best_buddies.append(
|
||||||
|
{
|
||||||
|
"month": m,
|
||||||
|
"displayName": _pick_str((winner or {}).get("displayName"), "--"),
|
||||||
|
"maskedName": _pick_str((winner or {}).get("displayName"), "--"),
|
||||||
|
"avatarUrl": _pick_str((winner or {}).get("avatarUrl"), ""),
|
||||||
|
"messages": _pick_int((raw or {}).get("totalMessages"), 0),
|
||||||
|
"metrics": metrics if metrics else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we always return 12 items for the grid.
|
||||||
|
if len(monthly_best_buddies) != 12:
|
||||||
|
fixed = {int(x.get("month") or 0): x for x in monthly_best_buddies if isinstance(x, dict)}
|
||||||
|
monthly_best_buddies = []
|
||||||
|
for m in range(1, 13):
|
||||||
|
monthly_best_buddies.append(
|
||||||
|
fixed.get(m)
|
||||||
|
or {
|
||||||
|
"month": m,
|
||||||
|
"displayName": "--",
|
||||||
|
"maskedName": "--",
|
||||||
|
"avatarUrl": "",
|
||||||
|
"messages": 0,
|
||||||
|
"metrics": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot: dict[str, Any] = {
|
||||||
|
"year": _pick_int(year),
|
||||||
|
"totalMessages": _pick_int(overview_d.get("totalMessages"), _pick_int(heatmap_d.get("totalMessages"), 0)),
|
||||||
|
"messagesPerDay": _pick_float(overview_d.get("messagesPerDay"), 0.0),
|
||||||
|
"sentChars": _pick_int(message_chars_d.get("sentChars"), 0),
|
||||||
|
"addedFriends": _pick_int(overview_d.get("addedFriends"), 0),
|
||||||
|
"mostActiveHour": overview_d.get("mostActiveHour"),
|
||||||
|
"topGroup": top_group,
|
||||||
|
"bestBuddy": best_buddy,
|
||||||
|
"fastest": fastest,
|
||||||
|
"slowest": slowest,
|
||||||
|
"replyStats": reply_stats,
|
||||||
|
"topPhrase": top_phrase,
|
||||||
|
"sentStickerCount": int(sent_sticker_count),
|
||||||
|
"topSticker": top_sticker,
|
||||||
|
"topUnicodeEmoji": top_unicode_emoji,
|
||||||
|
"topUnicodeEmojiCount": int(top_unicode_emoji_count),
|
||||||
|
"monthlyBestBuddies": monthly_best_buddies,
|
||||||
|
"weekdayLabels": heatmap_d.get("weekdayLabels") or [],
|
||||||
|
"hourLabels": heatmap_d.get("hourLabels") or [],
|
||||||
|
"weekdayHourMatrix": heatmap_d.get("matrix") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": 7,
|
||||||
|
"title": "便当总览:一屏看完这一年",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "A",
|
||||||
|
"status": "ok",
|
||||||
|
"kind": "global/bento_summary",
|
||||||
|
"narrative": "把这一年的关键信息装进一份便当。",
|
||||||
|
"data": {"snapshot": snapshot},
|
||||||
|
}
|
||||||
@@ -19,13 +19,14 @@ 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_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_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall
|
||||||
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||||
|
from .cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||||
# an older partial cache.
|
# an older partial cache.
|
||||||
_IMPLEMENTED_UPTO_ID = 6
|
_IMPLEMENTED_UPTO_ID = 7
|
||||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||||
_CACHE_VERSION = 24
|
_CACHE_VERSION = 24
|
||||||
|
|
||||||
@@ -82,6 +83,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
|||||||
"category": "B",
|
"category": "B",
|
||||||
"kind": "emoji/annual_universe",
|
"kind": "emoji/annual_universe",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "便当总览:一屏看完这一年",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "A",
|
||||||
|
"kind": "global/bento_summary",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
||||||
|
|
||||||
@@ -300,7 +308,7 @@ def build_wrapped_annual_response(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build annual wrapped response for the given account/year.
|
"""Build annual wrapped response for the given account/year.
|
||||||
|
|
||||||
For now we implement cards up to id=6 (plus a meta overview card id=0).
|
For now we implement cards up to id=7 (plus a meta overview card id=0).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
@@ -345,19 +353,37 @@ def build_wrapped_annual_response(
|
|||||||
# in first-person narratives like "你最常...".
|
# in first-person narratives like "你最常...".
|
||||||
heatmap_sent = _get_or_compute_heatmap_sent(account_dir=account_dir, scope=scope, year=y, refresh=refresh)
|
heatmap_sent = _get_or_compute_heatmap_sent(account_dir=account_dir, scope=scope, year=y, refresh=refresh)
|
||||||
# Page 2: global overview (page 1 is the frontend cover slide).
|
# Page 2: global overview (page 1 is the frontend cover slide).
|
||||||
cards.append(build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
card_overview = build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
||||||
|
cards.append(card_overview)
|
||||||
# Page 3: cyber schedule heatmap.
|
# Page 3: cyber schedule heatmap.
|
||||||
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
card_heatmap = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
||||||
|
cards.append(card_heatmap)
|
||||||
# Page 4: message char counts (sent vs received).
|
# Page 4: message char counts (sent vs received).
|
||||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
card_message_chars = build_card_02_message_chars(account_dir=account_dir, year=y)
|
||||||
|
cards.append(card_message_chars)
|
||||||
# Page 5: annual keywords (bubble storm -> word cloud).
|
# Page 5: annual keywords (bubble storm -> word cloud).
|
||||||
cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y))
|
cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y))
|
||||||
# Page 6: reply speed / best chat buddy.
|
# Page 6: reply speed / best chat buddy.
|
||||||
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
card_reply_speed = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||||
|
cards.append(card_reply_speed)
|
||||||
# Page 7: 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))
|
card_monthly = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||||
|
cards.append(card_monthly)
|
||||||
# Page 8: 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))
|
card_emoji = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||||
|
cards.append(card_emoji)
|
||||||
|
# Page 9: bento summary (prototype). Build from prior cards for consistency.
|
||||||
|
cards.append(
|
||||||
|
build_card_07_bento_summary_from_sources(
|
||||||
|
year=y,
|
||||||
|
overview=card_overview,
|
||||||
|
heatmap=card_heatmap,
|
||||||
|
message_chars=card_message_chars,
|
||||||
|
reply_speed=card_reply_speed,
|
||||||
|
monthly=card_monthly,
|
||||||
|
emoji=card_emoji,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"account": account_dir.name,
|
"account": account_dir.name,
|
||||||
@@ -557,6 +583,23 @@ def build_wrapped_annual_card(
|
|||||||
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||||
elif cid == 5:
|
elif cid == 5:
|
||||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||||
|
elif cid == 7:
|
||||||
|
# Build from already-implemented cards so we can reuse their caches if available.
|
||||||
|
overview = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=0, refresh=refresh)
|
||||||
|
heatmap = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=1, refresh=refresh)
|
||||||
|
message_chars = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=2, refresh=refresh)
|
||||||
|
reply_speed = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=3, refresh=refresh)
|
||||||
|
monthly = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=4, refresh=refresh)
|
||||||
|
emoji = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=5, refresh=refresh)
|
||||||
|
card = build_card_07_bento_summary_from_sources(
|
||||||
|
year=y,
|
||||||
|
overview=overview,
|
||||||
|
heatmap=heatmap,
|
||||||
|
message_chars=message_chars,
|
||||||
|
reply_speed=reply_speed,
|
||||||
|
monthly=monthly,
|
||||||
|
emoji=emoji,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||||
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure "src/" is importable when running tests from repo root.
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrappedManifestBentoSummary(unittest.TestCase):
|
||||||
|
def test_manifest_appends_bento_summary(self):
|
||||||
|
try:
|
||||||
|
from wechat_decrypt_tool.wrapped.service import _WRAPPED_CARD_MANIFEST
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
# Some dev/test environments may not have optional deps installed (e.g. pypinyin).
|
||||||
|
# The manifest itself doesn't depend on them, but importing the service module does.
|
||||||
|
if getattr(e, "name", "") == "pypinyin":
|
||||||
|
self.skipTest("pypinyin is not installed")
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.assertTrue(len(_WRAPPED_CARD_MANIFEST) > 0)
|
||||||
|
last = _WRAPPED_CARD_MANIFEST[-1]
|
||||||
|
self.assertEqual(int(last.get("id")), 7)
|
||||||
|
self.assertEqual(str(last.get("kind")), "global/bento_summary")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user