feat: add sns cover for user

This commit is contained in:
H3CoF6
2026-02-14 00:25:58 +08:00
parent bcf918e7e8
commit 0a47b4d3be
2 changed files with 107 additions and 11 deletions

View File

@@ -5,8 +5,14 @@
<div class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
<div class="max-w-2xl mx-auto px-4 py-4">
<div class="relative w-full mb-12 -mt-4 bg-white">
<div class="h-64 w-full bg-[#333333] object-cover"></div>
<div class="h-64 w-full bg-[#333333] relative overflow-hidden">
<img
v-if="coverData && coverData.media && coverData.media.length > 0"
:src="getSnsMediaUrl(coverData, coverData.media[0], 0, coverData.media[0].url)"
class="w-full h-full object-cover"
alt="朋友圈封面"
/>
</div>
<div class="absolute right-4 -bottom-6 flex items-end gap-4">
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
{{ selfInfo.nickname || '获取中...' }}
@@ -26,9 +32,14 @@
</div>
</div>
</div>
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="text-sm text-gray-500 py-2">加载中</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-500 py-2">暂无朋友圈数据</div>
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-4 text-center">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-8 h-8 border-[3px] border-gray-200 border-t-[#576b95] rounded-full animate-spin"></div>
<div class="mt-4 text-sm text-gray-400">正在前往朋友圈...</div>
</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
<div v-for="post in posts" :key="post.id" class="bg-white rounded-sm px-4 py-4 mb-3">
@@ -327,6 +338,8 @@ const hasMore = ref(true)
const isLoading = ref(false)
const error = ref('')
const coverData = ref(null)
const pageSize = 20
const mediaBase = process.client ? 'http://localhost:8000' : ''
@@ -782,10 +795,12 @@ const loadPosts = async ({ reset }) => {
offset
})
const items = resp?.timeline || []
if (reset) {
posts.value = items
posts.value = items.filter(p => p.type !== 7)
coverData.value = resp?.cover || null
} else {
posts.value = [...posts.value, ...items]
posts.value = [...posts.value, ...items.filter(p => p.type !== 7)]
}
hasMore.value = !!resp?.hasMore
} catch (e) {

View File

@@ -763,6 +763,54 @@ def _list_sns_cached_image_candidate_keys(
return tuple(out)
def _get_sns_cover(account_dir: Path, target_wxid: str) -> Optional[dict[str, Any]]:
"""无论多古老,强行揪出用户最近的一次朋友圈封面 (type=7)"""
cover_sql = f"SELECT tid, content FROM SnsTimeLine WHERE user_name = '{target_wxid}' AND content LIKE '%<type>7</type>%' ORDER BY tid DESC LIMIT 1"
cover_xml = None
cover_tid = None
try:
if WCDB_REALTIME.is_connected(account_dir.name):
conn = WCDB_REALTIME.ensure_connected(account_dir)
with conn.lock:
sns_db_path = conn.db_storage_dir / "sns" / "sns.db"
if not sns_db_path.exists():
sns_db_path = conn.db_storage_dir / "sns.db"
# 利用 exec_query 强行查
rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql)
if rows:
cover_xml = rows[0].get("content")
cover_tid = rows[0].get("tid")
except Exception as e:
logger.warning(f"[sns] WCDB cover fetch failed: {e}")
# 2. 如果没查到,降级从本地解密的 sns.db 查
if not cover_xml:
sns_db_path = account_dir / "sns.db"
if sns_db_path.exists():
try:
# 只读模式防止锁死
conn_sq = sqlite3.connect(f"file:{sns_db_path}?mode=ro", uri=True)
conn_sq.row_factory = sqlite3.Row
row = conn_sq.execute(cover_sql).fetchone()
if row:
cover_xml = str(row["content"] or "")
cover_tid = row["tid"]
conn_sq.close()
except Exception as e:
logger.warning(f"[sns] SQLite cover fetch failed: {e}")
if cover_xml:
parsed = _parse_timeline_xml(cover_xml, target_wxid)
return {
"id": str(cover_tid or ""),
"media": parsed.get("media", []),
"type": 7
}
return None
@router.get("/api/sns/self_info", summary="获取个人信息wxid和nickname")
def api_sns_self_info(account: Optional[str] = None):
@@ -864,6 +912,11 @@ def list_sns_timeline(
users = _parse_csv_list(usernames)
kw = str(keyword or "").strip()
cover_data = None
if offset == 0:
target_wxid = users[0] if users else account_dir.name
cover_data = _get_sns_cover(account_dir, target_wxid)
# Prefer real-time WCDB access (reads the latest encrypted db_storage/sns/sns.db).
# Fallback to the decrypted sqlite copy in output/{account}/sns.db.
try:
@@ -962,6 +1015,10 @@ def list_sns_timeline(
location = str(parsed.get("location") or "")
post_type = parsed.get("type", 1)
if post_type == 7: # 朋友圈封面
continue
title = parsed.get("title", "")
content_url = parsed.get("contentUrl", "")
finder_feed = parsed.get("finderFeed", {})
@@ -1009,7 +1066,14 @@ def list_sns_timeline(
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset, "source": "wcdb"}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"source": "wcdb",
"cover": cover_data,
}
except WCDBRealtimeError as e:
logger.info("[sns] wcdb realtime unavailable: %s", e)
except Exception as e:
@@ -1089,7 +1153,13 @@ def list_sns_timeline(
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"cover": cover_data,
}
class SnsMediaPicksSaveRequest(BaseModel):
@@ -1144,8 +1214,8 @@ async def get_sns_media(
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
post_type: int = 1, # <--- 接收前端传来的 post_type
media_type: int = 2, # <--- 接收前端传来的 media_type
post_type: int = 1,
media_type: int = 2,
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
@@ -1154,6 +1224,17 @@ async def get_sns_media(
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
if int(post_type) == 7:
raw_key = f"{post_id}_{media_id}_4" # 硬编码
md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
if bkg_path.exists() and bkg_path.is_file():
print(f"===== Hit Bkg Cover ======= {bkg_path}")
return FileResponse(bkg_path, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"})
exact_match_path = None
hit_type = ""