Compare commits

...

2 Commits

4 changed files with 887 additions and 225 deletions
+2 -1
View File
@@ -453,7 +453,7 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
// 朋友圈导出(离线 ZIP,支持 HTML / JSON / TXT
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
@@ -461,6 +461,7 @@ export const useApi = () => {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
format: data.format || 'html',
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
+442 -41
View File
@@ -14,13 +14,61 @@
class="mt-2 w-full px-3 py-2 rounded-md border border-gray-200 bg-white text-sm outline-none focus:ring-2 focus:ring-[#576b95]/30 focus:border-[#576b95]"
/>
<div class="mt-3">
<div class="text-xs font-medium text-gray-700 mb-2">导出格式</div>
<div class="flex flex-wrap gap-2">
<label
v-for="item in exportFormatOptions"
:key="item.value"
class="px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors"
:class="exportFormat === item.value ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
>
<input v-model="exportFormat" type="radio" :value="item.value" class="hidden" />
<span>{{ item.label }}</span>
</label>
</div>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-gray-700">&#23548;&#20986;&#30446;&#24405;</div>
<div class="text-[11px] text-gray-400">{{ exportFolderModeText }}</div>
</div>
<div class="px-2.5 py-2 rounded-md border border-gray-200 bg-gray-50 text-xs text-gray-600 break-all min-h-[40px] flex items-center">
{{ exportFolder || '&#26410;&#36873;&#25321;' }}
</div>
<div class="flex gap-2">
<button
type="button"
class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="exportSaveBusy"
@click="chooseExportFolder"
>
&#36873;&#25321;&#25991;&#20214;&#22841;
</button>
<button
v-if="hasSelectedExportFolder"
type="button"
class="px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="exportSaveBusy"
@click="clearExportFolderSelection"
>
&#28165;&#38500;
</button>
</div>
<div v-if="exportFolderHint" class="text-[11px] text-gray-500 whitespace-pre-wrap">{{ exportFolderHint }}</div>
<div v-if="exportSaveProgressText" class="text-[11px] text-gray-500 whitespace-pre-wrap">{{ exportSaveProgressText }}</div>
<div v-else-if="exportSaveMsg" class="text-[11px] text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
<div v-else-if="exportSaveError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ exportSaveError }}</div>
</div>
<div class="mt-2 flex gap-2">
<button
type="button"
class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="onExportAllClick"
:disabled="!selectedAccount || exportJob?.status === 'running'"
title="导出全部朋友圈(HTML 离线 ZIP"
:disabled="!selectedAccount || exportJob?.status === 'running' || exportJob?.status === 'queued'"
:title="`导出全部朋友圈(${exportFormatLabel} ZIP`"
>
导出全部
</button>
@@ -28,23 +76,71 @@
type="button"
class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="onExportCurrentClick"
:disabled="!selectedAccount || !selectedSnsUser || exportJob?.status === 'running'"
title="导出当前选中联系人(HTML 离线 ZIP"
:disabled="!selectedAccount || !selectedSnsUser || exportJob?.status === 'running' || exportJob?.status === 'queued'"
:title="`导出当前选中联系人(${exportFormatLabel} ZIP`"
>
导出此人
</button>
</div>
<div v-if="exportError" class="mt-2 text-xs text-red-600 whitespace-pre-wrap">{{ exportError }}</div>
<div v-else-if="exportJob" class="mt-2 text-xs text-gray-500">
<span>导出状态{{ exportJob.status }}</span>
<button
v-if="exportJob.status === 'done' && exportJob.exportId"
type="button"
class="ml-2 text-xs text-[#576b95] hover:underline bg-transparent border-0 p-0"
@click="downloadSnsExport(exportJob.exportId)"
>
下载 ZIP
</button>
<div v-else-if="exportJob" class="mt-3 border border-gray-200 rounded-md bg-gray-50 p-3 text-xs text-gray-700 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="font-medium text-gray-900 truncate">任务{{ exportJob.exportId }}</div>
<div class="text-gray-500">状态{{ exportStatusText }}</div>
</div>
<div class="flex items-center justify-between">
<div>动态{{ exportJob.progress?.postsExported || 0 }}/{{ exportJob.progress?.postsTotal || 0 }}</div>
<div class="text-gray-500">{{ exportOverallPercent }}%</div>
</div>
<div class="h-2 rounded-full bg-white border border-gray-200 overflow-hidden">
<div class="h-full bg-[#03C160] transition-all duration-300" :style="{ width: exportOverallPercent + '%' }"></div>
</div>
<div class="flex items-center justify-between text-gray-600">
<div>联系人{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }}</div>
<div>格式{{ exportActiveFormatLabel }}</div>
</div>
<div v-if="exportCurrentTargetLabel" class="space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="truncate">
当前{{ exportCurrentTargetLabel }}{{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }}
</div>
<div class="text-gray-500">
<span v-if="exportCurrentPercent != null">{{ exportCurrentPercent }}%</span>
<span v-else></span>
</div>
</div>
<div class="h-2 rounded-full bg-white border border-gray-200 overflow-hidden">
<div
v-if="exportCurrentPercent != null"
class="h-full bg-sky-500 transition-all duration-300"
:style="{ width: exportCurrentPercent + '%' }"
></div>
<div v-else class="h-full bg-sky-500/60 animate-pulse" style="width: 30%"></div>
</div>
</div>
<div class="text-gray-500">
媒体{{ exportJob.progress?.mediaCopied || 0 }}缺失{{ exportJob.progress?.mediaMissing || 0 }}
</div>
<div v-if="exportOutputPathText" class="text-green-600 break-all">
&#24050;&#23548;&#20986;&#21040;&#65306;{{ exportOutputPathText }}
</div>
<div v-if="exportJob.status === 'done'" class="flex flex-wrap gap-3">
<button
v-if="exportJob.exportId && hasWebExportFolder"
type="button"
class="text-xs text-[#576b95] hover:underline bg-transparent border-0 p-0 disabled:text-gray-400 disabled:no-underline disabled:cursor-not-allowed"
:disabled="exportSaveBusy"
@click="saveSnsExportToSelectedFolder()"
>
{{ exportSaveBusy ? '\u4fdd\u5b58\u4e2d\u2026' : exportSaveState === 'success' ? '\u91cd\u65b0\u4fdd\u5b58\u5230\u6587\u4ef6\u5939' : '\u4fdd\u5b58\u5230\u5df2\u9009\u6587\u4ef6\u5939' }}
</button>
</div>
</div>
</div>
@@ -664,7 +760,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
import { reportServerErrorFromError, reportServerErrorFromResponse } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
@@ -762,12 +858,305 @@ const pageSize = 20
const apiBase = useApiBase()
// 朋友圈导出(HTML 离线 ZIP
// 朋友圈导出(离线 ZIP
const exportFormat = ref('html')
const exportFormatOptions = [
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON' },
{ value: 'txt', label: 'TXT' }
]
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportSaveError = ref('')
const exportSaveState = ref('idle')
const exportSaveBytesWritten = ref(0)
const exportSaveBytesTotal = ref(0)
const exportAutoSavedFor = ref('')
const exportJob = ref(null)
const exportError = ref('')
let exportEventSource = null
let exportPollTimer = null
const asNumber = (v) => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
const formatBytes = (value) => {
const bytes = Number(value)
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${units[index]}`
}
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
exportSaveMsg.value = ''
exportSaveError.value = ''
exportSaveState.value = 'idle'
exportSaveBytesWritten.value = 0
exportSaveBytesTotal.value = 0
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const hasDesktopExportFolder = computed(() => {
return !!(isDesktopExportRuntime() && String(exportFolder.value || '').trim())
})
const hasWebExportFolder = computed(() => {
return !!(!isDesktopExportRuntime() && isWebDirectoryPickerSupported() && exportFolderHandle.value)
})
const hasSelectedExportFolder = computed(() => {
return !!(hasDesktopExportFolder.value || hasWebExportFolder.value)
})
const exportFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportFormat.value)?.label || 'HTML'
})
const exportActiveFormat = computed(() => {
const raw = String(exportJob.value?.options?.format || exportFormat.value || 'html').trim().toLowerCase()
return exportFormatOptions.some((item) => item.value === raw) ? raw : 'html'
})
const exportActiveFormatLabel = computed(() => {
return exportFormatOptions.find((item) => item.value === exportActiveFormat.value)?.label || 'HTML'
})
const exportStatusText = computed(() => {
const status = String(exportJob.value?.status || '').trim()
return {
queued: '排队中',
running: '导出中',
done: '已完成',
error: '失败',
cancelled: '已取消'
}[status] || status || '-'
})
const exportOverallPercent = computed(() => {
const status = String(exportJob.value?.status || '').trim()
if (status === 'done') return 100
const progress = exportJob.value?.progress || {}
const postsTotal = asNumber(progress.postsTotal)
const postsDone = asNumber(progress.postsExported)
if (postsTotal > 0) return Math.round(clamp01(postsDone / postsTotal) * 100)
const usersTotal = asNumber(progress.usersTotal)
const usersDone = asNumber(progress.usersDone)
if (usersTotal > 0) return Math.round(clamp01(usersDone / usersTotal) * 100)
return 0
})
const exportCurrentPercent = computed(() => {
const progress = exportJob.value?.progress || {}
const total = asNumber(progress.currentUserPostsTotal)
const done = asNumber(progress.currentUserPostsDone)
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const exportCurrentTargetLabel = computed(() => {
const progress = exportJob.value?.progress || {}
return String(progress.currentDisplayName || progress.currentUsername || '').trim()
})
const exportBackendZipPath = computed(() => {
return String(exportJob.value?.zipPath || '').trim()
})
const exportFolderModeText = computed(() => {
if (isDesktopExportRuntime()) return '\u684c\u9762\u7aef\u76ee\u5f55'
if (isWebDirectoryPickerSupported()) return '\u6d4f\u89c8\u5668\u76ee\u5f55'
return '\u9700\u9009\u62e9\u6587\u4ef6\u5939'
})
const exportFolderHint = computed(() => {
if (isDesktopExportRuntime()) {
return hasDesktopExportFolder.value
? '\u4f1a\u50cf\u666e\u901a\u804a\u5929\u8bb0\u5f55\u5bfc\u51fa\u4e00\u6837\uff0c\u5b8c\u6210\u540e\u76f4\u63a5\u5199\u5165\u4e0a\u9762\u7684\u6587\u4ef6\u5939\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6587\u4ef6\u5939\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u76f4\u63a5\u5199\u5165\u8be5\u76ee\u5f55\u3002'
}
if (isWebDirectoryPickerSupported()) {
return hasWebExportFolder.value
? '\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u5230\u6240\u9009\u6d4f\u89c8\u5668\u76ee\u5f55\u3002'
: '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u76ee\u5f55\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u3002'
}
return '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668\u3002'
})
const guessSnsExportZipName = (job) => {
const raw = String(job?.zipPath || '').trim()
if (raw) {
const name = raw.replace(/\\/g, '/').split('/').pop()
if (name && name.toLowerCase().endsWith('.zip')) return name
}
const format = String(job?.options?.format || exportFormat.value || 'html').trim().toLowerCase() || 'html'
const exportId = String(job?.exportId || '').trim() || 'export'
return `wechat_sns_export_${format}_${exportId}.zip`
}
const exportSaveProgressText = computed(() => {
if (exportSaveState.value !== 'saving') return ''
const fileName = guessSnsExportZipName(exportJob.value)
if (exportSaveBytesTotal.value > 0) {
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)}\uff09`
}
return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)}\uff09`
})
const exportOutputPathText = computed(() => {
if (String(exportJob.value?.status || '') !== 'done') return ''
if (hasWebExportFolder.value) return ''
const raw = exportBackendZipPath.value
if (!raw) return ''
if (isDesktopExportRuntime()) return raw
const requestedOutputDir = String(exportJob.value?.options?.outputDir || '').trim()
return requestedOutputDir ? raw : ''
})
const chooseExportFolder = async () => {
exportError.value = ''
resetExportSaveFeedback()
try {
if (!process.client) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '').trim()
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${String(handle.name || '\u5df2\u9009\u62e9')}`
}
return
}
exportError.value = '\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
} catch (error) {
const message = String(error?.message || '').trim()
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
return
}
exportError.value = error?.message || '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55\u5931\u8d25'
}
}
const clearExportFolderSelection = () => {
exportFolder.value = ''
exportFolderHandle.value = null
resetExportSaveFeedback({ resetAutoSavedFor: true })
}
const getSnsExportDownloadUrl = (exportId) => {
return `${apiBase}/sns/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
const saveSnsExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
resetExportSaveFeedback()
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55'
return
}
const handle = exportFolderHandle.value
if (!handle || typeof handle.getFileHandle !== 'function') {
exportError.value = '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u5bfc\u51fa\u76ee\u5f55'
return
}
const exportId = exportJob.value?.exportId
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
exportError.value = '\u5bfc\u51fa\u4efb\u52a1\u5c1a\u672a\u5b8c\u6210'
return
}
exportSaveBusy.value = true
exportSaveState.value = 'saving'
try {
const response = await fetch(getSnsExportDownloadUrl(exportId))
if (!response.ok) {
await reportServerErrorFromResponse(response, {
method: 'GET',
requestUrl: getSnsExportDownloadUrl(exportId),
message: `\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`,
source: 'sns.exportDownload'
})
throw new Error(`\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`)
}
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
const fileName = guessSnsExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
if (response.body && typeof response.body.getReader === 'function') {
const reader = response.body.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || !value.byteLength) continue
await writable.write(value)
exportSaveBytesWritten.value += value.byteLength
}
await writable.close()
} catch (error) {
try {
await reader.cancel()
} catch {}
try {
await writable.abort()
} catch {}
throw error
}
} else {
const blob = await response.blob()
exportSaveBytesWritten.value = asNumber(blob.size)
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
await writable.write(blob)
await writable.close()
}
exportAutoSavedFor.value = String(exportId)
exportSaveState.value = 'success'
const folderLabel = String(exportFolder.value || '').trim() || '\u5df2\u9009\u76ee\u5f55'
exportSaveMsg.value = autoSave
? `\u6d4f\u89c8\u5668\u76ee\u5f55\u81ea\u52a8\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
: `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}`
} catch (error) {
exportSaveState.value = 'error'
exportSaveError.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u5931\u8d25\uff1a${error?.message || '\u672a\u77e5\u9519\u8bef'}`
} finally {
exportSaveBusy.value = false
}
}
const stopSnsExportPolling = () => {
if (exportEventSource) {
try {
@@ -828,52 +1217,48 @@ const startSnsExportPolling = (exportId) => {
startSnsExportHttpPolling(exportId)
}
const downloadSnsExport = (exportId) => {
if (!process.client) return
const id = String(exportId || '').trim()
if (!id) return
const url = `${apiBase}/sns/exports/${encodeURIComponent(id)}/download`
window.open(url, '_blank', 'noopener,noreferrer')
const ensureSnsExportFolderReady = () => {
if (hasSelectedExportFolder.value) return true
exportError.value = isDesktopExportRuntime() || isWebDirectoryPickerSupported()
? '\u8bf7\u5148\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55'
: '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668'
return false
}
const onExportAllClick = async () => {
const startSnsExport = async ({ scope, usernames }) => {
if (!selectedAccount.value) return
exportError.value = ''
resetExportSaveFeedback({ resetAutoSavedFor: true })
if (!ensureSnsExportFolderReady()) return
try {
const resp = await api.createSnsExport({
account: selectedAccount.value,
scope: 'all',
usernames: [],
use_cache: snsUseCache.value ? 1 : 0
scope,
usernames: Array.isArray(usernames) ? usernames : [],
format: exportFormat.value,
use_cache: snsUseCache.value ? 1 : 0,
output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null
})
exportJob.value = resp?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startSnsExportPolling(exportId)
} catch (e) {
exportError.value = e?.message || '创建导出任务失败'
exportError.value = e?.message || '\u521b\u5efa\u5bfc\u51fa\u4efb\u52a1\u5931\u8d25'
}
}
const onExportAllClick = async () => {
await startSnsExport({ scope: 'all', usernames: [] })
}
const onExportCurrentClick = async () => {
if (!selectedAccount.value) return
const uname = String(selectedSnsUser.value || '').trim()
if (!uname) return
exportError.value = ''
try {
const resp = await api.createSnsExport({
account: selectedAccount.value,
scope: 'selected',
usernames: [uname],
use_cache: snsUseCache.value ? 1 : 0
})
exportJob.value = resp?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startSnsExportPolling(exportId)
} catch (e) {
exportError.value = e?.message || '创建导出任务失败'
}
await startSnsExport({ scope: 'selected', usernames: [uname] })
}
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
const mediaErrors = ref({})
@@ -1809,6 +2194,7 @@ watch(
stopSnsExportPolling()
exportJob.value = null
exportError.value = ''
resetExportSaveFeedback({ resetAutoSavedFor: true })
snsUserQuery.value = ''
selectedSnsUser.value = ''
snsUsers.value = []
@@ -1823,6 +2209,21 @@ watch(
{ immediate: true }
)
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
if (exportSaveBusy.value) return
await saveSnsExportToSelectedFolder({ auto: true })
}
)
onMounted(async () => {
privacyStore.init()
@@ -13,23 +13,26 @@ from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
ExportFormat = Literal["html", "json", "txt"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
format: ExportFormat = Field("html", description="导出格式:html/json/txt")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 ZIP,支持 HTML/JSON/TXT")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
export_format=req.format,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
@@ -111,4 +114,3 @@ async def cancel_sns_export(export_id: str):
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
+439 -181
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
"""SNS (Moments) HTML export service (offline ZIP)."""
"""SNS (Moments) export service (offline ZIP)."""
import asyncio
from bisect import bisect_left, bisect_right
@@ -50,6 +50,7 @@ logger = get_logger(__name__)
ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
ExportScope = Literal["selected", "all"]
ExportFormat = Literal["html", "json", "txt"]
_INVALID_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_HEX_ONLY_RE = re.compile(r"[^0-9a-fA-F]+")
@@ -408,9 +409,71 @@ def _esc_attr(v: Any) -> str:
return html.escape(str(v or ""), quote=True)
def _json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return str(value)
def _guess_official_name_from_title(title: str) -> str:
t0 = str(title or "").strip()
if not t0:
return ""
m = re.search(r"[《「【](.+?)[》」】]", t0)
return str(m.group(1) or "").strip() if m and m.group(1) else ""
def _format_moment_type_label(post: dict[str, Any]) -> str:
try:
t = int(post.get("type") or 0)
except Exception:
t = 0
if t == 3:
off = post.get("official") if isinstance(post.get("official"), dict) else {}
st0 = off.get("serviceType") if isinstance(off, dict) else None
try:
st = int(st0) if st0 not in (None, "") else None
except Exception:
st = None
prefix = "服务号" if st == 1 else "公众号"
name = str(off.get("displayName") or "").strip() if isinstance(off, dict) else ""
if not name:
name = _guess_official_name_from_title(str(post.get("title") or ""))
return f"{prefix}·{name}" if name else prefix
if t == 28:
ff = post.get("finderFeed") if isinstance(post.get("finderFeed"), dict) else {}
name = str(ff.get("nickname") or "").strip() if isinstance(ff, dict) else ""
return f"视频号·{name}" if name else "视频号"
if t in (5, 42):
name0 = str(post.get("sourceName") or "").strip()
if name0:
return name0
url0 = str(post.get("contentUrl") or "").strip()
if not url0:
ml0 = post.get("media") if isinstance(post.get("media"), list) else []
m0 = ml0[0] if (ml0 and isinstance(ml0[0], dict)) else {}
url0 = str(m0.get("url") or "").strip()
if url0:
s = re.sub(r"^https?://", "", url0.strip(), flags=re.I)
s = s.split("#", 1)[0].split("?", 1)[0].rstrip("/")
return s or ("音乐" if t == 42 else "外部分享")
return "音乐" if t == 42 else "外部分享"
return ""
_SNS_EXPORT_CSS_PATCH = """
/* Moments export tweaks (keep consistent with frontend `sns.vue`). */
body { background-color: #EDEDED; }
.wse-sns-post-list > .wse-sns-post:first-child {
padding-top: 0;
}
.wse-sns-post-list > .wse-sns-post:first-child > .wse-sns-post-row {
padding-top: 12px;
}
.wse-live-photo video { display: none; }
.wse-live-photo:hover video { display: block; }
.wse-live-photo:hover img { display: none; }
@@ -477,7 +540,10 @@ class ExportProgress:
users_done: int = 0
current_username: str = ""
current_display_name: str = ""
posts_total: int = 0
posts_exported: int = 0
current_user_posts_total: int = 0
current_user_posts_done: int = 0
media_copied: int = 0
media_missing: int = 0
@@ -513,7 +579,10 @@ class ExportJob:
"usersDone": self.progress.users_done,
"currentUsername": self.progress.current_username,
"currentDisplayName": self.progress.current_display_name,
"postsTotal": self.progress.posts_total,
"postsExported": self.progress.posts_exported,
"currentUserPostsTotal": self.progress.current_user_posts_total,
"currentUserPostsDone": self.progress.current_user_posts_done,
"mediaCopied": self.progress.media_copied,
"mediaMissing": self.progress.media_missing,
},
@@ -554,6 +623,7 @@ class SnsExportManager:
account: Optional[str],
scope: ExportScope,
usernames: list[str],
export_format: ExportFormat,
use_cache: bool,
output_dir: Optional[str],
file_name: Optional[str],
@@ -568,6 +638,7 @@ class SnsExportManager:
options={
"scope": str(scope or "selected"),
"usernames": [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()],
"format": str(export_format or "html"),
"useCache": bool(use_cache),
"outputDir": str(output_dir or "").strip(),
"fileName": str(file_name or "").strip(),
@@ -627,6 +698,10 @@ class SnsExportManager:
opts = dict(job.options or {})
scope_raw = str(opts.get("scope") or "selected").strip() or "selected"
scope: ExportScope = "all" if scope_raw == "all" else "selected" # type: ignore[assignment]
export_format_raw = str(opts.get("format") or "html").strip().lower() or "html"
if export_format_raw not in {"html", "json", "txt"}:
raise ValueError(f"Unsupported export format: {export_format_raw}")
export_format: ExportFormat = export_format_raw # type: ignore[assignment]
target_usernames = [str(u or "").strip() for u in (opts.get("usernames") or []) if str(u or "").strip()]
if scope == "selected" and not target_usernames:
raise ValueError("No target usernames to export.")
@@ -638,13 +713,13 @@ class SnsExportManager:
base_name = str(opts.get("fileName") or "").strip()
if not base_name:
if scope == "all":
base_name = f"wechat_sns_export_{account_dir.name}_{ts}_{job.export_id}.zip"
base_name = f"wechat_sns_export_{account_dir.name}_{export_format}_{ts}_{job.export_id}.zip"
else:
hint = _safe_name(target_usernames[0], max_len=40) or "selected"
base_name = f"wechat_sns_export_{account_dir.name}_{hint}_{ts}_{job.export_id}.zip"
base_name = f"wechat_sns_export_{account_dir.name}_{hint}_{export_format}_{ts}_{job.export_id}.zip"
if not base_name.lower().endswith(".zip"):
base_name += ".zip"
base_name = _safe_name(base_name, max_len=120) or f"wechat_sns_export_{account_dir.name}_{ts}_{job.export_id}.zip"
base_name = _safe_name(base_name, max_len=120) or f"wechat_sns_export_{account_dir.name}_{export_format}_{ts}_{job.export_id}.zip"
final_zip = (exports_root / base_name).resolve()
tmp_zip = (exports_root / f".{base_name}.{job.export_id}.part").resolve()
@@ -1120,6 +1195,92 @@ class SnsExportManager:
media_written[cache_key] = arc
return arc
def _build_post_json_record(post: dict[str, Any]) -> dict[str, Any]:
item = _json_safe(post)
if isinstance(item, dict):
item["momentTypeLabel"] = _format_moment_type_label(post)
item["createTimeText"] = _format_dt(post.get("createTime"))
return item if isinstance(item, dict) else {"value": item}
def _render_post_text(post: dict[str, Any], index: int) -> str:
ts = _format_dt(post.get("createTime")) or "未知时间"
post_id = str(post.get("id") or post.get("tid") or "").strip()
content_desc = str(post.get("contentDesc") or "").strip()
location = str(post.get("location") or "").strip()
title0 = str(post.get("title") or "").strip()
content_url = str(post.get("contentUrl") or "").strip()
moment_label = _format_moment_type_label(post)
media_list = post.get("media") if isinstance(post.get("media"), list) else []
likes = post.get("likes") if isinstance(post.get("likes"), list) else []
comments = post.get("comments") if isinstance(post.get("comments"), list) else []
lines = [f"#{index}", f"时间: {ts}"]
if post_id:
lines.append(f"ID: {post_id}")
if moment_label:
lines.append(f"类型: {moment_label}")
if content_desc:
lines.append("内容:")
lines.append(content_desc)
if title0:
lines.append(f"标题: {title0}")
if content_url:
lines.append(f"链接: {content_url}")
if location:
lines.append(f"位置: {location}")
if media_list:
lines.append("媒体:")
for idx0, media0 in enumerate(media_list, start=1):
m = media0 if isinstance(media0, dict) else {}
mtype = str(m.get("type") or "").strip() or "-"
mid = str(m.get("id") or "").strip()
murl = str(m.get("url") or "").strip()
mthumb = str(m.get("thumb") or "").strip()
media_parts = [f"- [{idx0}] type={mtype}"]
if mid:
media_parts.append(f"id={mid}")
if murl:
media_parts.append(f"url={murl}")
if mthumb and mthumb != murl:
media_parts.append(f"thumb={mthumb}")
lines.append(" ".join(media_parts))
if likes:
like_names = [str(x or "").strip() for x in likes if str(x or "").strip()]
if like_names:
lines.append("点赞: " + "".join(like_names))
if comments:
lines.append("评论:")
for idx0, comment0 in enumerate(comments, start=1):
comment = comment0 if isinstance(comment0, dict) else {}
cn = _clean_name(comment.get("nickname") or comment.get("displayName") or comment.get("username") or "") or "未知"
refn = _clean_name(comment.get("refNickname") or comment.get("refUsername") or comment.get("refUserName") or "")
text = str(comment.get("content") or "").strip()
prefix = f"- [{idx0}] {cn}"
if refn:
prefix += f" 回复 {refn}"
if text:
prefix += f": {text}"
lines.append(prefix)
return "\n".join(lines)
def _render_user_text(*, username: str, display_name: str, post_count: int, posts: list[dict[str, Any]]) -> str:
header = [
"朋友圈导出",
f"联系人: {display_name or username}",
f"用户名: {username}",
f"条目数: {post_count}",
"",
]
body: list[str] = []
for idx0, post0 in enumerate(posts, start=1):
body.append(_render_post_text(post0, idx0))
body.append("")
return "\n".join(header + body).rstrip() + "\n"
def render_media_block(*, zf: zipfile.ZipFile, post: dict[str, Any]) -> str:
media = post.get("media") if isinstance(post.get("media"), list) else []
if not media:
@@ -1331,51 +1492,6 @@ class SnsExportManager:
likes = post.get("likes") if isinstance(post.get("likes"), list) else []
comments = post.get("comments") if isinstance(post.get("comments"), list) else []
def guess_official_name_from_title(title: str) -> str:
t0 = str(title or "").strip()
if not t0:
return ""
m = re.search(r"[《「【](.+?)[》」】]", t0)
return str(m.group(1) or "").strip() if m and m.group(1) else ""
def format_moment_type_label(p: dict[str, Any]) -> str:
try:
t = int(p.get("type") or 0)
except Exception:
t = 0
if t == 3:
off = p.get("official") if isinstance(p.get("official"), dict) else {}
st0 = off.get("serviceType") if isinstance(off, dict) else None
try:
st = int(st0) if st0 not in (None, "") else None
except Exception:
st = None
prefix = "服务号" if st == 1 else "公众号"
name = str(off.get("displayName") or "").strip() if isinstance(off, dict) else ""
if not name:
name = guess_official_name_from_title(str(p.get("title") or ""))
return f"{prefix}·{name}" if name else prefix
if t == 28:
ff = p.get("finderFeed") if isinstance(p.get("finderFeed"), dict) else {}
name = str(ff.get("nickname") or "").strip() if isinstance(ff, dict) else ""
return f"视频号·{name}" if name else "视频号"
if t in (5, 42):
name0 = str(p.get("sourceName") or "").strip()
if name0:
return name0
url0 = str(p.get("contentUrl") or "").strip()
if not url0:
ml0 = p.get("media") if isinstance(p.get("media"), list) else []
m0 = ml0[0] if (ml0 and isinstance(ml0[0], dict)) else {}
url0 = str(m0.get("url") or "").strip()
if url0:
# host+path (no query) as a readable fallback label.
s = re.sub(r"^https?://", "", url0.strip(), flags=re.I)
s = s.split("#", 1)[0].split("?", 1)[0].rstrip("/")
return s or ("音乐" if t == 42 else "外部分享")
return "音乐" if t == 42 else "外部分享"
return ""
def format_finder_feed_card_text(p: dict[str, Any]) -> str:
title0 = str(p.get("title") or "").strip()
if title0:
@@ -1426,15 +1542,15 @@ class SnsExportManager:
f'style="background-color:#4B5563">{fallback}</div></div>'
)
moment_label = format_moment_type_label(post)
moment_label = _format_moment_type_label(post)
try:
post_type = int(post.get("type") or 1)
except Exception:
post_type = 1
out: list[str] = []
out.append(f'<div class="bg-white rounded-sm px-4 py-4 mb-3" id="{_esc_attr(pid)}">')
out.append('<div class="flex items-start gap-3">')
out.append(f'<div class="wse-sns-post bg-white rounded-sm px-4 py-4 mb-3" id="{_esc_attr(pid)}">')
out.append('<div class="wse-sns-post-row flex items-start gap-3">')
out.append(avatar_html)
out.append('<div class="flex-1 min-w-0">')
out.append(f'<div class="text-sm font-medium leading-5 text-[#576b95]">{_esc_text(display)}</div>')
@@ -1558,6 +1674,7 @@ class SnsExportManager:
'<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>'
)
out.append("</div></div></div>")
out.append("</div>")
else:
out.append(render_media_block(zf=zf, post=post))
@@ -1635,7 +1752,7 @@ class SnsExportManager:
avatar_arc = export_avatar_to_zip(zf=zf, username=username, display_name=display_name)
out: list[str] = []
out.append('<div class="relative w-full mb-12 -mt-4 bg-white">')
out.append('<div class="wse-sns-cover relative w-full -mt-4">')
out.append('<div class="h-64 w-full bg-[#333333] relative overflow-hidden">')
if cover_arc:
out.append(
@@ -1644,7 +1761,7 @@ class SnsExportManager:
)
out.append("</div>")
out.append('<div class="absolute right-4 -bottom-6 flex items-end gap-4">')
out.append('<div class="absolute right-4 flex items-end gap-4" style="bottom:-12px; z-index:2;">')
out.append(
f'<div class="text-white font-bold text-xl mb-7 drop-shadow-md">{_esc_text(display_name or username)}</div>'
)
@@ -1667,22 +1784,24 @@ class SnsExportManager:
try:
with zipfile.ZipFile(str(tmp_zip), mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report) + "\n\n" + _SNS_EXPORT_CSS_PATCH
zf.writestr("assets/wechat-sns-export.css", css_payload)
written.add("assets/wechat-sns-export.css")
css_href = "assets/wechat-sns-export.css"
if export_format == "html":
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report) + "\n\n" + _SNS_EXPORT_CSS_PATCH
zf.writestr(css_href, css_payload)
written.add(css_href)
repo_root = Path(__file__).resolve().parents[2]
wxemoji_src: Optional[Path] = None
if ui_public_dir is not None:
cand = Path(ui_public_dir) / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is None:
cand = repo_root / "frontend" / "public" / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is not None:
_zip_write_tree(zf=zf, src_dir=wxemoji_src, dest_prefix="wxemoji", written=written)
repo_root = Path(__file__).resolve().parents[2]
wxemoji_src: Optional[Path] = None
if ui_public_dir is not None:
cand = Path(ui_public_dir) / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is None:
cand = repo_root / "frontend" / "public" / "wxemoji"
if cand.is_dir():
wxemoji_src = cand
if wxemoji_src is not None:
_zip_write_tree(zf=zf, src_dir=wxemoji_src, dest_prefix="wxemoji", written=written)
if scope == "all":
users = _load_sns_users(account_dir)
@@ -1691,19 +1810,37 @@ class SnsExportManager:
order = {u: i for i, u in enumerate(target_usernames)}
users.sort(key=lambda x: order.get(str(x.get("username") or ""), 10**9))
total_posts_est = 0
for user_item in users:
try:
total_posts_est += max(0, int(user_item.get("postCount") or 0))
except Exception:
continue
with self._lock:
job.progress.users_total = len(users)
job.progress.posts_total = total_posts_est
job.progress.posts_exported = 0
job.progress.current_user_posts_total = 0
job.progress.current_user_posts_done = 0
user_pages: list[dict[str, Any]] = []
css_href = "assets/wechat-sns-export.css"
user_outputs: list[dict[str, Any]] = []
exported_at = datetime.now().isoformat(timespec="seconds")
for i, u in enumerate(users):
should_cancel()
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
try:
post_count_est = max(0, int(u.get("postCount") or 0))
except Exception:
post_count_est = 0
safe_uname = _safe_name(uname, max_len=80) or hashlib.md5(uname.encode("utf-8", errors="ignore")).hexdigest()[:12]
with self._lock:
job.progress.current_username = uname
job.progress.current_display_name = display
job.progress.current_user_posts_total = post_count_est
job.progress.current_user_posts_done = 0
posts_all: list[dict[str, Any]] = []
cover_data: Optional[dict[str, Any]] = None
@@ -1728,132 +1865,252 @@ class SnsExportManager:
if not bool(resp.get("hasMore")):
break
post_parts: list[str] = []
for p in posts_all:
should_cancel()
post_parts.append(render_post_html(zf=zf, post=p))
actual_post_count = len(posts_all)
if actual_post_count != post_count_est:
with self._lock:
job.progress.posts_exported += 1
job.progress.posts_total = max(
job.progress.posts_exported,
max(0, job.progress.posts_total + (actual_post_count - post_count_est)),
)
job.progress.current_user_posts_total = actual_post_count
else:
with self._lock:
job.progress.current_user_posts_total = actual_post_count
safe_uname = _safe_name(uname, max_len=80) or hashlib.md5(uname.encode("utf-8", errors="ignore")).hexdigest()[:12]
page_name = f"sns_{safe_uname}.html"
title = f"朋友圈导出 - {display}"
back_link = (
'<a href="index.html" class="text-sm text-[#576b95] hover:underline">← 返回</a>'
if scope == "all"
else ""
)
cover_html = render_cover_header_html(zf=zf, username=uname, display_name=display, cover_data=cover_data)
page_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
f"<title>{_esc_text(title)}</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
cover_html,
('<div class="flex items-center justify-between mb-4">' + back_link + (f'<div class="text-xs text-gray-500 truncate">{_esc_text(uname)}</div>' if uname else "") + "</div>") if back_link else "",
"".join(post_parts),
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr(page_name, page_html)
written.add(page_name)
output_name = ""
if export_format == "html":
post_parts: list[str] = []
for p in posts_all:
should_cancel()
post_parts.append(render_post_html(zf=zf, post=p))
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
user_pages.append(
output_name = f"sns_{safe_uname}.html"
title = f"朋友圈导出 - {display}"
back_link = (
'<a href="index.html" class="text-sm text-[#576b95] hover:underline">← 返回</a>'
if scope == "all"
else ""
)
cover_html = render_cover_header_html(zf=zf, username=uname, display_name=display, cover_data=cover_data)
page_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
f"<title>{_esc_text(title)}</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="wse-sns-page max-w-2xl mx-auto px-4 py-4">',
cover_html,
('<div class="flex items-center justify-between mb-4">' + back_link + (f'<div class="text-xs text-gray-500 truncate">{_esc_text(uname)}</div>' if uname else "") + "</div>") if back_link else "",
'<div class="wse-sns-post-list">' + "".join(post_parts) + "</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr(output_name, page_html)
written.add(output_name)
elif export_format == "json":
exported_posts: list[dict[str, Any]] = []
for p in posts_all:
should_cancel()
exported_posts.append(_build_post_json_record(p))
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
output_name = f"sns_{safe_uname}.json"
json_payload: dict[str, Any] = {
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"username": uname,
"displayName": display,
"postCount": actual_post_count,
"posts": exported_posts,
}
if isinstance(cover_data, dict) and cover_data:
json_payload["cover"] = _json_safe(cover_data)
zf.writestr(output_name, json.dumps(json_payload, ensure_ascii=False, indent=2))
written.add(output_name)
else:
for _idx0, _post0 in enumerate(posts_all, start=1):
should_cancel()
with self._lock:
job.progress.posts_exported += 1
job.progress.current_user_posts_done += 1
output_name = f"sns_{safe_uname}.txt"
zf.writestr(
output_name,
_render_user_text(
username=uname,
display_name=display,
post_count=actual_post_count,
posts=posts_all,
),
)
written.add(output_name)
user_outputs.append(
{
"username": uname,
"displayName": display,
"postCount": int(u.get("postCount") or 0),
"page": page_name,
"postCount": actual_post_count,
"entry": output_name,
}
)
with self._lock:
job.progress.users_done = i + 1
job.progress.current_user_posts_done = actual_post_count
if scope == "all":
rows: list[str] = []
for u in user_pages:
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
pc = int(u.get("postCount") or 0)
href = str(u.get("page") or "").strip()
avatar_arc = export_avatar_to_zip(zf=zf, username=uname, display_name=display)
if avatar_arc:
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<img src="{_esc_attr(avatar_arc)}" class="w-full h-full object-cover" '
f'alt="{_esc_attr(display or uname)}" loading="lazy" referrerpolicy="no-referrer" />'
"</div>"
if export_format == "html":
if scope == "all":
rows: list[str] = []
for u in user_outputs:
uname = str(u.get("username") or "").strip()
display = _clean_name(u.get("displayName")) or uname
pc = int(u.get("postCount") or 0)
href = str(u.get("entry") or "").strip()
avatar_arc = export_avatar_to_zip(zf=zf, username=uname, display_name=display)
if avatar_arc:
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<img src="{_esc_attr(avatar_arc)}" class="w-full h-full object-cover" '
f'alt="{_esc_attr(display or uname)}" loading="lazy" referrerpolicy="no-referrer" />'
"</div>"
)
else:
fallback = _esc_text((display or uname or "")[:1] or "")
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
'<div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" '
f'style="background-color:#4B5563">{fallback}</div></div>'
)
rows.append(
'<a class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50" '
f'href="{_esc_attr(href)}">'
f"{avatar_html}"
'<div class="flex-1 min-w-0">'
f'<div class="truncate">{_esc_text(display)}</div>'
f'<div class="text-[11px] text-gray-400 truncate">{_esc_text(uname)} · {pc} 条</div>'
"</div></a>"
)
else:
fallback = _esc_text((display or uname or "")[:1] or "")
avatar_html = (
'<div class="w-8 h-8 rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
'<div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" '
f'style="background-color:#4B5563">{fallback}</div></div>'
)
rows.append(
'<a class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2 border-b border-gray-100 hover:bg-gray-50" '
f'href="{_esc_attr(href)}">'
f"{avatar_html}"
'<div class="flex-1 min-w-0">'
f'<div class="truncate">{_esc_text(display)}</div>'
f'<div class="text-[11px] text-gray-400 truncate">{_esc_text(uname)} · {pc} 条</div>'
"</div></a>"
)
index_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
"<title>朋友圈导出</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
'<div class="mb-4 flex items-center justify-between">',
'<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>',
f'<div class="text-xs text-gray-500">{len(user_pages)} 人</div>',
"</div>",
'<div class="bg-white rounded-sm overflow-hidden border border-gray-200">',
"".join(rows),
"</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr("index.html", index_html)
written.add("index.html")
else:
only_page = user_pages[0]["page"] if user_pages else ""
if only_page:
index_html = (
"<!doctype html><html><head>"
'<meta charset="utf-8" />'
f'<meta http-equiv="refresh" content="0; url={_esc_attr(only_page)}" />'
"</head><body></body></html>"
index_html = "\n".join(
[
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
"<title>朋友圈导出</title>",
f'<link rel="stylesheet" href="{_esc_attr(css_href)}" />',
"</head>",
'<body style="background-color:#EDEDED">',
'<div class="min-h-screen" style="background-color:#EDEDED">',
'<div class="max-w-2xl mx-auto px-4 py-4">',
'<div class="mb-4 flex items-center justify-between">',
'<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>',
f'<div class="text-xs text-gray-500">{len(user_outputs)} 人</div>',
"</div>",
'<div class="bg-white rounded-sm overflow-hidden border border-gray-200">',
"".join(rows),
"</div>",
"</div>",
"</div>",
"</body>",
"</html>",
"",
]
)
zf.writestr("index.html", index_html)
written.add("index.html")
else:
only_page = user_outputs[0]["entry"] if user_outputs else ""
if only_page:
index_html = (
"<!doctype html><html><head>"
'<meta charset="utf-8" />'
f'<meta http-equiv="refresh" content="0; url={_esc_attr(only_page)}" />'
"</head><body></body></html>"
)
zf.writestr("index.html", index_html)
written.add("index.html")
elif export_format == "json":
zf.writestr(
"index.json",
json.dumps(
{
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"users": user_outputs,
},
ensure_ascii=False,
indent=2,
),
)
written.add("index.json")
else:
lines = [
"朋友圈导出",
f"导出时间: {exported_at}",
f"账号: {account_dir.name}",
f"范围: {'全部联系人' if scope == 'all' else '指定联系人'}",
f"格式: {export_format}",
"",
]
for item in user_outputs:
lines.append(
f"- {item.get('displayName') or item.get('username') or ''} "
f"({item.get('username') or ''}) · {int(item.get('postCount') or 0)} 条 -> {item.get('entry') or ''}"
)
zf.writestr("index.txt", "\n".join(lines).rstrip() + "\n")
written.add("index.txt")
zf.writestr(
"manifest.json",
json.dumps(
{
"schemaVersion": 1,
"exportedAt": exported_at,
"exportId": job.export_id,
"account": account_dir.name,
"scope": scope,
"format": export_format,
"options": {
"useCache": use_cache,
},
"stats": {
"users": len(user_outputs),
"postsExported": job.progress.posts_exported,
"postsTotal": job.progress.posts_total,
"mediaCopied": job.progress.media_copied,
"mediaMissing": job.progress.media_missing,
},
"entries": user_outputs,
},
ensure_ascii=False,
indent=2,
),
)
written.add("manifest.json")
try:
zf.writestr("export_report.json", json.dumps(report, ensure_ascii=False, indent=2))
@@ -1881,8 +2138,9 @@ class SnsExportManager:
if job.status != "cancelled":
job.status = "done"
job.finished_at = time.time()
job.progress.current_user_posts_done = job.progress.current_user_posts_total
return tmp_zip
return final_out
SNS_EXPORT_MANAGER = SnsExportManager()