feat: 完善日报图片与公众号消息展示

修复日报图片读取格式与模型不支持时的友好降级
恢复话题关键词回退,确保词云在缺少顶层关键词时仍可展示
支持公众号多文章消息在聊天、日报、检索和 HTML 导出中完整呈现
迁移账号身份缓存并优化首次连接的账号占位信息
补充图片、词云、账号缓存与多文章消息回归测试
This commit is contained in:
电摇小子
2026-08-07 00:00:52 +08:00
parent b5f67c47e5
commit a6a004e403
18 changed files with 432 additions and 36 deletions
+29
View File
@@ -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
View File
@@ -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
? {
+66 -5
View File
@@ -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 {
+34 -2
View File
@@ -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 = {