mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 21:05:39 +08:00
feat: 完善日报图片与公众号消息展示
修复日报图片读取格式与模型不支持时的友好降级 恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示 支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现 迁移账号身份缓存并优化首次连接的账号占位信息 补充图片、词云、账号缓存与多文章消息回归测试
This commit is contained in:
@@ -579,6 +579,19 @@ body {
|
||||
}
|
||||
.structured-link { color: inherit; text-decoration: none; }
|
||||
.structured-link:hover .structured-title { color: var(--accent); }
|
||||
.structured-share-articles { display: flex; flex-direction: column; }
|
||||
.structured-share-article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.structured-share-article:first-child { border-top: 0; }
|
||||
.structured-share-article img { width: 54px; height: 54px; border-radius: 4px; object-fit: cover; }
|
||||
.location-content {
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
@@ -990,6 +1003,22 @@ const renderExportScript = (name: string): string => `
|
||||
}
|
||||
const renderShareContent = (data) => {
|
||||
const label = shareLabel(data.typeVal)
|
||||
const articles = Array.isArray(data.articles) ? data.articles : []
|
||||
if (articles.length) {
|
||||
const articleMarkup = articles.map((article) => {
|
||||
const href = externalUrl(article.url)
|
||||
const cover = imageUrl(article.coverUrl)
|
||||
const body = '<span><div class="structured-title">' + displayText(article.title || '公众号文章') + '</div>' +
|
||||
(article.description ? '<div class="structured-description">' + displayText(article.description) + '</div>' : '') +
|
||||
'</span>' + (cover ? '<img src="' + cover + '" alt="">' : '')
|
||||
return href
|
||||
? '<a class="structured-share-article" href="' + href + '" target="_blank" rel="noreferrer noopener">' + body + '</a>'
|
||||
: '<div class="structured-share-article">' + body + '</div>'
|
||||
}).join('')
|
||||
return '<div class="structured-content share-content" data-rich-kind="share">' +
|
||||
'<div class="structured-kicker">' + displayText(data.appname || label) + '</div>' +
|
||||
'<div class="structured-share-articles">' + articleMarkup + '</div></div>'
|
||||
}
|
||||
let title = decodeEntities(data.title || label)
|
||||
let description = decodeEntities(data.des || '')
|
||||
if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) {
|
||||
|
||||
+14
-8
@@ -321,7 +321,7 @@ function getLocalMediaMimeType(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageResponse(image: DecodedImage): {
|
||||
function buildImageResponse(image: DecodedImage, includeData = false): {
|
||||
success: true
|
||||
data: string
|
||||
isThumb: boolean
|
||||
@@ -330,7 +330,7 @@ function buildImageResponse(image: DecodedImage): {
|
||||
} {
|
||||
const mediaService = image.cacheFilePath ? getImageMediaService() : null
|
||||
const data =
|
||||
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||
!includeData && mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||
? mediaService.createLocalMediaUrl(image.cacheFilePath)
|
||||
: image.data
|
||||
return {
|
||||
@@ -1126,7 +1126,12 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
options?: {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
priority?: number
|
||||
includeData?: boolean
|
||||
}
|
||||
) => {
|
||||
let service = imageDecryptService
|
||||
if (!service) {
|
||||
@@ -1146,6 +1151,7 @@ app.whenReady().then(async () => {
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const includeData = options?.includeData === true
|
||||
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
||||
const imageCacheKey = [
|
||||
imageMd5 || '',
|
||||
@@ -1154,10 +1160,10 @@ app.whenReady().then(async () => {
|
||||
].join('|')
|
||||
const mediaService = getImageMediaService()
|
||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !mediaService
|
||||
includeData: includeData || !mediaService
|
||||
})
|
||||
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
||||
return buildImageResponse(cachedImage)
|
||||
return buildImageResponse(cachedImage, includeData)
|
||||
}
|
||||
|
||||
return enqueueColdImageLoad(async () => {
|
||||
@@ -1183,10 +1189,10 @@ app.whenReady().then(async () => {
|
||||
// A previous queued request may have populated the cache while this one waited.
|
||||
const queuedMediaService = getImageMediaService()
|
||||
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !queuedMediaService
|
||||
includeData: includeData || !queuedMediaService
|
||||
})
|
||||
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
||||
return buildImageResponse(queuedCacheHit)
|
||||
return buildImageResponse(queuedCacheHit, includeData)
|
||||
}
|
||||
|
||||
let filePath = force
|
||||
@@ -1220,7 +1226,7 @@ app.whenReady().then(async () => {
|
||||
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
|
||||
}
|
||||
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
|
||||
return buildImageResponse(decodedImage)
|
||||
return buildImageResponse(decodedImage, includeData)
|
||||
}, priority)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,8 +89,11 @@ function sourceTextAndAttachment(message: chat.FormattedMessage): {
|
||||
if (content.type === 'share') {
|
||||
const title = content.title?.trim() || ''
|
||||
const description = content.des?.trim() || ''
|
||||
const articles = (content.articles || []).flatMap((article) =>
|
||||
[article.title, article.description].map((value) => value?.trim()).filter(Boolean)
|
||||
)
|
||||
return {
|
||||
text: [text, title, description].filter(Boolean).join('\n') || undefined,
|
||||
text: [text, title, description, ...articles].filter(Boolean).join('\n') || undefined,
|
||||
attachment:
|
||||
title || content.url
|
||||
? {
|
||||
|
||||
@@ -8,6 +8,12 @@ type LocationContent = {
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareArticle = {
|
||||
title: string
|
||||
description?: string
|
||||
url: string
|
||||
coverUrl?: string
|
||||
}
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
@@ -15,6 +21,7 @@ type ShareContent = {
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
articles?: ShareArticle[]
|
||||
}
|
||||
type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
@@ -482,17 +489,71 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
}
|
||||
}
|
||||
|
||||
const title = decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
||||
const articles = parseShareArticles(content)
|
||||
const title =
|
||||
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
|
||||
const des =
|
||||
articles[0]?.description ||
|
||||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
|
||||
''
|
||||
const url = articles[0]?.url || decodeXmlUrl(extractXmlValue(content, 'url')) || ''
|
||||
const appname =
|
||||
decodeXmlEntities(
|
||||
extractXmlValue(content, 'appname') ||
|
||||
extractXmlValue(content, 'publisher') ||
|
||||
extractXmlValue(content, 'appInfo')
|
||||
) || ''
|
||||
const typeVal = extractXmlValue(content, 'type') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
return {
|
||||
type: 'share',
|
||||
title,
|
||||
des,
|
||||
url,
|
||||
appname,
|
||||
typeVal,
|
||||
articles: articles.length > 1 ? articles : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseShareArticles(content: string): ShareArticle[] {
|
||||
if (!/<mmreader\b/i.test(content)) return []
|
||||
const articles = Array.from(
|
||||
content.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi),
|
||||
(match) => match[1] || ''
|
||||
)
|
||||
.map((item): ShareArticle | null => {
|
||||
const title = decodeXmlEntities(extractXmlValue(item, 'title'))
|
||||
const url = decodeXmlUrl(extractXmlValue(item, 'url'))
|
||||
if (!title && !url) return null
|
||||
const description = decodeXmlEntities(
|
||||
extractXmlValue(item, 'digest') ||
|
||||
extractXmlValue(item, 'summary') ||
|
||||
extractXmlValue(item, 'des')
|
||||
)
|
||||
const coverUrl = decodeXmlUrl(
|
||||
extractXmlValue(item, 'cover') || extractXmlValue(item, 'cover_1_1')
|
||||
)
|
||||
return {
|
||||
title: title || '公众号文章',
|
||||
url,
|
||||
description: description || undefined,
|
||||
coverUrl: coverUrl || undefined
|
||||
}
|
||||
})
|
||||
.filter((article): article is ShareArticle => Boolean(article))
|
||||
|
||||
const seen = new Set<string>()
|
||||
return articles.filter((article) => {
|
||||
const key = `${article.url}|${article.title}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||
|
||||
@@ -155,14 +155,46 @@ function isCurrentAccountFile(
|
||||
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getAccountCachePaths(normalizedRoot).startup
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = paths.startup
|
||||
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = startupMemory.get(file)
|
||||
if (memory) return memory
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
if (!fs.existsSync(file)) {
|
||||
// Version 1 stored startup data in one JSON file. Migrate it lazily so
|
||||
// account discovery can still show a cached nickname/avatar before the
|
||||
// database key is entered.
|
||||
if (!fs.existsSync(paths.legacy)) return null
|
||||
const legacy = fs.readJsonSync(paths.legacy) as {
|
||||
version?: number
|
||||
platform?: NodeJS.Platform
|
||||
accountRoot?: string
|
||||
updatedAt?: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
}
|
||||
if (
|
||||
legacy.version !== 1 ||
|
||||
legacy.platform !== process.platform ||
|
||||
normalizeRoot(legacy.accountRoot) !== normalizedRoot
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const migrated: StartupCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(legacy.updatedAt) || 0,
|
||||
self: legacy.self,
|
||||
contacts: Array.isArray(legacy.contacts) ? legacy.contacts : []
|
||||
}
|
||||
startupMemory.set(file, migrated)
|
||||
scheduleWrite(file, migrated, { cleanupFile: paths.legacy })
|
||||
return migrated
|
||||
}
|
||||
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
||||
const result: StartupCacheFile = {
|
||||
|
||||
Reference in New Issue
Block a user