mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 06:10:52 +08:00
feat: add sns cover for user
This commit is contained in:
@@ -5,8 +5,14 @@
|
|||||||
<div class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
|
<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="max-w-2xl mx-auto px-4 py-4">
|
||||||
<div class="relative w-full mb-12 -mt-4 bg-white">
|
<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="absolute right-4 -bottom-6 flex items-end gap-4">
|
||||||
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
|
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
|
||||||
{{ selfInfo.nickname || '获取中...' }}
|
{{ selfInfo.nickname || '获取中...' }}
|
||||||
@@ -26,9 +32,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</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="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-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">
|
<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 isLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
const coverData = ref(null)
|
||||||
|
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
|
|
||||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||||
@@ -782,10 +795,12 @@ const loadPosts = async ({ reset }) => {
|
|||||||
offset
|
offset
|
||||||
})
|
})
|
||||||
const items = resp?.timeline || []
|
const items = resp?.timeline || []
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
posts.value = items
|
posts.value = items.filter(p => p.type !== 7)
|
||||||
|
coverData.value = resp?.cover || null
|
||||||
} else {
|
} else {
|
||||||
posts.value = [...posts.value, ...items]
|
posts.value = [...posts.value, ...items.filter(p => p.type !== 7)]
|
||||||
}
|
}
|
||||||
hasMore.value = !!resp?.hasMore
|
hasMore.value = !!resp?.hasMore
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -763,6 +763,54 @@ def _list_sns_cached_image_candidate_keys(
|
|||||||
|
|
||||||
return tuple(out)
|
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)")
|
@router.get("/api/sns/self_info", summary="获取个人信息(wxid和nickname)")
|
||||||
def api_sns_self_info(account: Optional[str] = None):
|
def api_sns_self_info(account: Optional[str] = None):
|
||||||
@@ -864,6 +912,11 @@ def list_sns_timeline(
|
|||||||
users = _parse_csv_list(usernames)
|
users = _parse_csv_list(usernames)
|
||||||
kw = str(keyword or "").strip()
|
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).
|
# 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.
|
# Fallback to the decrypted sqlite copy in output/{account}/sns.db.
|
||||||
try:
|
try:
|
||||||
@@ -962,6 +1015,10 @@ def list_sns_timeline(
|
|||||||
location = str(parsed.get("location") or "")
|
location = str(parsed.get("location") or "")
|
||||||
|
|
||||||
post_type = parsed.get("type", 1)
|
post_type = parsed.get("type", 1)
|
||||||
|
|
||||||
|
if post_type == 7: # 朋友圈封面
|
||||||
|
continue
|
||||||
|
|
||||||
title = parsed.get("title", "")
|
title = parsed.get("title", "")
|
||||||
content_url = parsed.get("contentUrl", "")
|
content_url = parsed.get("contentUrl", "")
|
||||||
finder_feed = parsed.get("finderFeed", {})
|
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:
|
except WCDBRealtimeError as e:
|
||||||
logger.info("[sns] wcdb realtime unavailable: %s", e)
|
logger.info("[sns] wcdb realtime unavailable: %s", e)
|
||||||
except Exception as 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):
|
class SnsMediaPicksSaveRequest(BaseModel):
|
||||||
@@ -1144,8 +1214,8 @@ async def get_sns_media(
|
|||||||
avoid_picked: int = 0,
|
avoid_picked: int = 0,
|
||||||
post_id: Optional[str] = None,
|
post_id: Optional[str] = None,
|
||||||
media_id: Optional[str] = None,
|
media_id: Optional[str] = None,
|
||||||
post_type: int = 1, # <--- 接收前端传来的 post_type
|
post_type: int = 1,
|
||||||
media_type: int = 2, # <--- 接收前端传来的 media_type
|
media_type: int = 2,
|
||||||
pick: Optional[str] = None,
|
pick: Optional[str] = None,
|
||||||
md5: Optional[str] = None,
|
md5: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
@@ -1154,6 +1224,17 @@ async def get_sns_media(
|
|||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
|
|
||||||
if wxid_dir and post_id and media_id:
|
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
|
exact_match_path = None
|
||||||
hit_type = ""
|
hit_type = ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user