feat: add experimental Weibo context to AI insights

This commit is contained in:
Jason
2026-04-12 20:53:10 +08:00
parent 9c5426159d
commit 1be03734a4
9 changed files with 833 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import './preload-env'
import './preload-env'
import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
import { Worker } from 'worker_threads'
import { randomUUID } from 'crypto'
@@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService'
// 配置自动更新
@@ -1651,6 +1652,32 @@ function registerIpcHandlers() {
return insightService.generateFootprintInsight(payload)
})
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
try {
if (!configService) {
return { success: false, error: 'Config service is not initialized' }
}
const normalized = normalizeWeiboCookieInput(rawInput)
configService.set('aiInsightWeiboCookie' as any, normalized as any)
weiboService.clearCache()
return { success: true, normalized, hasCookie: Boolean(normalized) }
} catch (error) {
return { success: false, error: (error as Error).message || 'Failed to save Weibo cookie' }
}
})
ipcMain.handle('social:validateWeiboUid', async (_, uid: string) => {
try {
if (!configService) {
return { success: false, error: 'Config service is not initialized' }
}
const cookie = String(configService.get('aiInsightWeiboCookie' as any) || '')
return await weiboService.validateUid(uid, cookie)
} catch (error) {
return { success: false, error: (error as Error).message || 'Failed to validate Weibo UID' }
}
})
ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false)
@@ -3734,3 +3761,7 @@ app.on('window-all-closed', () => {
app.quit()
}
})

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
@@ -540,5 +540,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
},
social: {
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
}
})

View File

@@ -1,4 +1,4 @@
import { join } from 'path'
import { join } from 'path'
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
@@ -82,6 +82,7 @@ interface ConfigSchema {
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowSocialContext: boolean
aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[]
/** 活跃分析冷却时间分钟0 表示无冷却 */
@@ -113,7 +114,8 @@ const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'authPassword',
'httpApiToken',
'aiModelApiKey',
'aiInsightApiKey'
'aiInsightApiKey',
'aiInsightWeiboCookie'
])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -196,15 +198,19 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowSocialContext: false,
aiInsightWhitelistEnabled: false,
aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120,
aiInsightScanIntervalHours: 4,
aiInsightContextCount: 40,
aiInsightSocialContextCount: 3,
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: '',
aiInsightWeiboCookie: '',
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false
@@ -825,3 +831,4 @@ export class ConfigService {
this.unlockPassword = null
}
}

View File

@@ -1,4 +1,4 @@
/**
/**
* insightService.ts
*
* AI 见解后台服务:
@@ -21,6 +21,7 @@ import { URL } from 'url'
import { app, Notification } from 'electron'
import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService'
import { weiboService } from './social/weiboService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -46,6 +47,10 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiBaseUrl',
'aiModelApiKey',
'aiModelApiModel',
'aiInsightAllowSocialContext',
'aiInsightSocialContextCount',
'aiInsightWeiboCookie',
'aiInsightWeiboBindings',
'dbPath',
'decryptKey',
'myWxid'
@@ -318,6 +323,10 @@ class InsightService {
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
// 数据库相关配置变更后,丢弃缓存并强制下次重连
if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') {
weiboService.clearCache()
}
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
this.clearRuntimeCache()
}
@@ -350,6 +359,7 @@ class InsightService {
this.lastSeenTimestamp.clear()
this.todayTriggers.clear()
this.todayDate = getStartOfDay()
weiboService.clearCache()
}
private clearTimers(): void {
@@ -1028,6 +1038,8 @@ ${topMentionText}
}
}
const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
@@ -1190,3 +1202,5 @@ ${topMentionText}
}
export const insightService = new InsightService()

View File

@@ -0,0 +1,273 @@
import https from 'https'
import { createHash } from 'crypto'
import { URL } from 'url'
const WEIBO_TIMEOUT_MS = 10_000
const WEIBO_MAX_POSTS = 5
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
const WEIBO_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
interface BrowserCookieEntry {
domain?: string
name?: string
value?: string
}
interface WeiboUserInfo {
id?: number | string
screen_name?: string
}
interface WeiboWaterFallItem {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
isLongText?: boolean
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
interface WeiboWaterFallResponse {
ok?: number
data?: {
list?: WeiboWaterFallItem[]
next_cursor?: string
}
}
interface WeiboStatusShowResponse {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
export interface WeiboRecentPost {
id: string
createdAt: string
url: string
text: string
screenName?: string
}
interface CachedRecentPosts {
expiresAt: number
posts: WeiboRecentPost[]
}
function requestJson<T>(url: string, options: { cookie: string; referer?: string }): Promise<T> {
return new Promise((resolve, reject) => {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
reject(new Error())
return
}
const req = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
Accept: 'application/json, text/plain, */*',
Referer: options.referer || 'https://weibo.com',
'User-Agent': WEIBO_USER_AGENT,
'X-Requested-With': 'XMLHttpRequest',
Cookie: options.cookie
}
}, (res) => {
let raw = ''
res.setEncoding('utf8')
res.on('data', (chunk) => { raw += chunk })
res.on('end', () => {
const statusCode = res.statusCode || 0
if (statusCode < 200 || statusCode >= 300) {
reject(new Error( ))
return
}
try {
resolve(JSON.parse(raw) as T)
} catch {
reject(new Error('微博接口返回了非 JSON 响应'))
}
})
})
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('微博请求超时'))
})
req.on('error', reject)
req.end()
})
}
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
const picked = new Map<string, string>()
for (const entry of entries) {
const name = String(entry?.name || '').trim()
const value = String(entry?.value || '').trim()
const domain = String(entry?.domain || '').trim().toLowerCase()
if (!name || !value) continue
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
picked.set(name, value)
}
return Array.from(picked.entries()).map(([name, value]) => ${name}=).join('; ')
}
export function normalizeWeiboCookieInput(rawInput: string): string {
const trimmed = String(rawInput || '').trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) {
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
if (normalized) return normalized
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
}
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
}
return trimmed.replace(/^Cookie:\s*/i, '').trim()
}
function normalizeWeiboUid(input: string): string {
const trimmed = String(input || '').trim()
const directMatch = trimmed.match(/^\d{5,}$/)
if (directMatch) return directMatch[0]
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
if (linkMatch) return linkMatch[1]
throw new Error('请输入有效的微博 UID纯数字')
}
function sanitizeWeiboText(text: string): string {
return String(text || '')
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
.replace(/ +/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
const baseText = sanitizeWeiboText(item.text_raw || '')
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
if (!retweetText) return baseText
if (!baseText || baseText === '转发微博') return
return ${baseText}\n\n转发内容
}
function buildCacheKey(uid: string, count: number, cookie: string): string {
const cookieHash = createHash('sha1').update(cookie).digest('hex')
return ${uid}::
}
class WeiboService {
private recentPostsCache = new Map<string, CachedRecentPosts>()
clearCache(): void {
this.recentPostsCache.clear()
}
async validateUid(uidInput: string, cookieInput: string): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
try {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
if (!cookie) return { success: false, error: '请先填写有效的微博 Cookie' }
const timeline = await this.fetchTimeline(uid, cookie)
const firstItem = timeline.data?.list?.[0]
if (!firstItem) {
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
}
const screenName = firstItem.user?.screen_name
return { success: true, uid, screenName }
} catch (error) {
return { success: false, error: (error as Error).message || '微博 UID 校验失败' }
}
}
async fetchRecentPosts(uidInput: string, cookieInput: string, requestedCount: number): Promise<WeiboRecentPost[]> {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
if (!cookie) return []
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
const cacheKey = buildCacheKey(uid, count, cookie)
const cached = this.recentPostsCache.get(cacheKey)
const now = Date.now()
if (cached && cached.expiresAt > now) return cached.posts
const timeline = await this.fetchTimeline(uid, cookie)
const rawItems = Array.isArray(timeline.data?.list) ? timeline.data.list : []
const posts: WeiboRecentPost[] = []
for (const item of rawItems) {
if (posts.length >= count) break
const id = String(item.idstr || item.id || '').trim()
if (!id) continue
let text = mergeRetweetText(item)
if (item.isLongText) {
try {
const detail = await this.fetchDetail(id, cookie)
text = mergeRetweetText(detail)
} catch {
}
}
text = sanitizeWeiboText(text)
if (!text) continue
posts.push({
id,
createdAt: String(item.created_at || ''),
url: https://m.weibo.cn/detail/,
text,
screenName: item.user?.screen_name
})
}
this.recentPostsCache.set(cacheKey, {
expiresAt: now + WEIBO_CACHE_TTL_MS,
posts
})
return posts
}
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
return requestJson<WeiboWaterFallResponse>(
https://weibo.com/ajax/profile/getWaterFallContent?uid=,
{ cookie, referer: https://weibo.com/u/ }
).then((response) => {
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
}
return response
})
}
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
return requestJson<WeiboStatusShowResponse>(
https://weibo.com/ajax/statuses/show?id=&isGetLongText=true,
{ cookie, referer: https://weibo.com/detail/ }
).then((response) => {
if (!response || (!response.id && !response.idstr)) {
throw new Error('微博详情获取失败')
}
return response
})
}
}
export const weiboService = new WeiboService()