mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
3 Commits
+20
-1
@@ -14,12 +14,23 @@
|
||||
// So we detect desktop onMounted and update reactively.
|
||||
const isDesktop = ref(false)
|
||||
|
||||
const updateDprVar = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isDesktop.value = !!window?.wechatDesktop
|
||||
updateDprVar()
|
||||
window.addEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--dpr: 1;
|
||||
/* Left sidebar rail (chat/sns): icon size + spacing */
|
||||
--sidebar-rail-step: 48px;
|
||||
--sidebar-rail-btn: 32px;
|
||||
--sidebar-rail-icon: 24px;
|
||||
}
|
||||
|
||||
/* Electron 桌面端使用自绘标题栏(frame: false)。
|
||||
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen(100vh),会把标题栏高度叠加进去,从而出现外层滚动条。
|
||||
* 这里把 “screen” 在桌面端视为内容区高度(100%),让标题栏高度自然内嵌在布局里。 */
|
||||
|
||||
@@ -179,6 +179,46 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈时间线
|
||||
const listSnsTimeline = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
|
||||
query.set('usernames', params.usernames.join(','))
|
||||
} else if (params && params.usernames && typeof params.usernames === 'string') {
|
||||
query.set('usernames', params.usernames)
|
||||
}
|
||||
if (params && params.keyword) query.set('keyword', params.keyword)
|
||||
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈图片本地缓存候选(用于错图时手动选择)
|
||||
const listSnsMediaCandidates = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
|
||||
if (params && params.width != null) query.set('width', String(params.width))
|
||||
if (params && params.height != null) query.set('height', String(params.height))
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 保存朋友圈图片手动匹配结果(本机)
|
||||
const saveSnsMediaPicks = async (data = {}) => {
|
||||
return await request('/sns/media_picks', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: data.account || null,
|
||||
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openChatMediaFolder = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -288,6 +328,9 @@ export const useApi = () => {
|
||||
buildChatSearchIndex,
|
||||
listChatSearchSenders,
|
||||
getChatMessagesAround,
|
||||
listSnsTimeline,
|
||||
listSnsMediaCandidates,
|
||||
saveSnsMediaPicks,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
saveMediaKeys,
|
||||
|
||||
@@ -1,46 +1,99 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="w-16 border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0">
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
style="background-color: #4B5563"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天图标 (与 oh-my-wechat 一致) -->
|
||||
<div class="w-16 h-16 flex items-center justify-center chat-tab selected text-[#07b75b]">
|
||||
<div class="w-7 h-7">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group">
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 朋友圈图标(Aperture 风格) -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="朋友圈"
|
||||
@click="goSns"
|
||||
>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors"
|
||||
:class="privacyMode ? 'text-[#03C160]' : 'text-gray-500 hover:text-gray-700'"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置按钮(仅桌面端) -->
|
||||
<div
|
||||
v-if="isDesktopEnv"
|
||||
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors text-gray-500 hover:text-gray-700"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="openDesktopSettings"
|
||||
title="设置"
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1714,6 +1767,7 @@ useHead({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
|
||||
const routeUsername = computed(() => {
|
||||
const raw = route.params.username
|
||||
@@ -1729,6 +1783,24 @@ const selectedContact = ref(null)
|
||||
|
||||
// 隐私模式
|
||||
const privacyMode = ref(false)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => privacyMode.value,
|
||||
(v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
)
|
||||
|
||||
// 桌面端设置(仅 Electron 环境可见)
|
||||
const isDesktopEnv = ref(false)
|
||||
@@ -1880,6 +1952,18 @@ const selectedAccount = ref(null)
|
||||
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
// 实时更新(WCDB DLL + db_storage watcher)
|
||||
const realtimeEnabled = ref(false)
|
||||
const realtimeAvailable = ref(false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
@@ -51,6 +52,7 @@ app.include_router(_media_router)
|
||||
app.include_router(_chat_router)
|
||||
app.include_router(_chat_export_router)
|
||||
app.include_router(_chat_media_router)
|
||||
app.include_router(_sns_router)
|
||||
|
||||
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -414,7 +414,7 @@ def _is_allowed_proxy_image_host(host: str) -> bool:
|
||||
if not h:
|
||||
return False
|
||||
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
|
||||
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn")
|
||||
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
|
||||
|
||||
|
||||
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
|
||||
@@ -435,33 +435,52 @@ async def proxy_image(url: str):
|
||||
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
|
||||
|
||||
def _download_bytes() -> tuple[bytes, str]:
|
||||
headers = {
|
||||
base_headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
# qpic/qlogo often require a mp.weixin.qq.com referer (anti-hotlink)
|
||||
"Referer": "https://mp.weixin.qq.com/",
|
||||
"Origin": "https://mp.weixin.qq.com",
|
||||
}
|
||||
r = requests.get(u, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
content_type = str(r.headers.get("Content-Type") or "").strip()
|
||||
max_bytes = 10 * 1024 * 1024
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for ch in r.iter_content(chunk_size=64 * 1024):
|
||||
if not ch:
|
||||
continue
|
||||
chunks.append(ch)
|
||||
total += len(ch)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
|
||||
return b"".join(chunks), content_type
|
||||
finally:
|
||||
|
||||
# Different Tencent CDNs enforce different anti-hotlink rules.
|
||||
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
|
||||
header_variants = [
|
||||
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
|
||||
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
|
||||
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
|
||||
{},
|
||||
]
|
||||
|
||||
last_err: Exception | None = None
|
||||
for extra in header_variants:
|
||||
headers = dict(base_headers)
|
||||
headers.update(extra)
|
||||
r = requests.get(u, headers=headers, timeout=20, stream=True)
|
||||
try:
|
||||
r.close()
|
||||
except Exception:
|
||||
pass
|
||||
r.raise_for_status()
|
||||
content_type = str(r.headers.get("Content-Type") or "").strip()
|
||||
max_bytes = 10 * 1024 * 1024
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for ch in r.iter_content(chunk_size=64 * 1024):
|
||||
if not ch:
|
||||
continue
|
||||
chunks.append(ch)
|
||||
total += len(ch)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
|
||||
return b"".join(chunks), content_type
|
||||
except HTTPException:
|
||||
# Hard failure, don't retry with another referer.
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# All variants failed.
|
||||
raise last_err or RuntimeError("proxy_image download failed")
|
||||
|
||||
try:
|
||||
data, ct = await asyncio.to_thread(_download_bytes)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
|
||||
lib.wcdb_close_account.restype = ctypes.c_int
|
||||
|
||||
# Optional: wcdb_set_my_wxid(handle, wxid)
|
||||
try:
|
||||
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
|
||||
lib.wcdb_set_my_wxid.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_sessions.restype = ctypes.c_int
|
||||
|
||||
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_group_members.restype = ctypes.c_int
|
||||
|
||||
# Optional: execute arbitrary SQL on a selected database kind/path.
|
||||
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
|
||||
try:
|
||||
lib.wcdb_exec_query.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_exec_query.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
|
||||
try:
|
||||
lib.wcdb_get_sns_timeline.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int32,
|
||||
ctypes.c_int32,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
|
||||
except Exception:
|
||||
# Older wcdb_api.dll may not expose this export.
|
||||
pass
|
||||
|
||||
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_logs.restype = ctypes.c_int
|
||||
|
||||
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
|
||||
return int(out_handle.value)
|
||||
|
||||
|
||||
def set_my_wxid(handle: int, wxid: str) -> bool:
|
||||
"""Best-effort set the "my wxid" context for some WCDB APIs."""
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_set_my_wxid", None)
|
||||
if not fn:
|
||||
return False
|
||||
|
||||
w = str(wxid or "").strip()
|
||||
if not w:
|
||||
return False
|
||||
|
||||
try:
|
||||
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return rc == 0
|
||||
|
||||
|
||||
def close_account(handle: int) -> None:
|
||||
try:
|
||||
h = int(handle)
|
||||
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
|
||||
"""Execute raw SQL on a specific db kind/path via WCDB.
|
||||
|
||||
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_exec_query", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
|
||||
|
||||
k = str(kind or "").strip()
|
||||
if not k:
|
||||
raise WCDBRealtimeError("Missing kind for exec_query.")
|
||||
|
||||
s = str(sql or "").strip()
|
||||
if not s:
|
||||
return []
|
||||
|
||||
p = None if path is None else str(path or "").strip()
|
||||
|
||||
out_json = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
k.encode("utf-8"),
|
||||
None if p is None else p.encode("utf-8"),
|
||||
s.encode("utf-8"),
|
||||
)
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def get_sns_timeline(
|
||||
handle: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
usernames: Optional[list[str]] = None,
|
||||
keyword: str | None = None,
|
||||
start_time: int = 0,
|
||||
end_time: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
|
||||
|
||||
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_get_sns_timeline", None)
|
||||
if not fn:
|
||||
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
|
||||
|
||||
lim = max(0, int(limit or 0))
|
||||
off = max(0, int(offset or 0))
|
||||
|
||||
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
|
||||
users = list(dict.fromkeys(users))
|
||||
users_json = json.dumps(users, ensure_ascii=False) if users else ""
|
||||
|
||||
kw = str(keyword or "").strip()
|
||||
|
||||
payload = _call_out_json(
|
||||
fn,
|
||||
ctypes.c_int64(int(handle)),
|
||||
ctypes.c_int32(lim),
|
||||
ctypes.c_int32(off),
|
||||
users_json.encode("utf-8"),
|
||||
kw.encode("utf-8"),
|
||||
ctypes.c_int32(int(start_time or 0)),
|
||||
ctypes.c_int32(int(end_time or 0)),
|
||||
)
|
||||
decoded = _safe_load_json(payload)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _initialized
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
handle = open_account(session_db_path, key)
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = WCDBRealtimeConnection(
|
||||
account=account,
|
||||
|
||||
Reference in New Issue
Block a user