diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 052dd8a..fb2c636 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 7. 访问导出媒体 +## 7. 朋友圈接口 + +### 7.1 获取朋友圈时间线 + +```http +GET /api/v1/sns/timeline +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | +| `offset` | number | 否 | 偏移量,默认 0 | +| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | +| `keyword` | string | 否 | 关键词过滤(正文) | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | +| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | +| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | + +示例: + +```bash +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1" +``` + +媒体字段说明(`media=1`): + +- `media[].url/thumb`:你应该优先直接使用的字段。 +- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。 +- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。 +- `media[].rawUrl/rawThumb`:原始朋友圈地址 +- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 +- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) +- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。 +- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。 +- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。 + +### 7.2 获取朋友圈发布者 + +```http +GET /api/v1/sns/usernames +``` + +### 7.3 获取朋友圈导出统计 + +```http +GET /api/v1/sns/export/stats +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | + +### 7.4 朋友圈媒体代理 + +```http +GET /api/v1/sns/media/proxy +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `url` | string | 是 | 媒体原始 URL | +| `key` | string/number | 否 | 解密 key(部分资源需要) | + +### 7.5 导出朋友圈 + +```http +POST /api/v1/sns/export +Content-Type: application/json +``` + +Body 示例: + +```json +{ + "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export", + "format": "json", + "usernames": "wxid_a,wxid_b", + "keyword": "旅行", + "exportMedia": true, + "exportImages": true, + "exportLivePhotos": true, + "exportVideos": true, + "start": "20250101", + "end": "20251231" +} +``` + +`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。 + +### 7.6 朋友圈防删开关 + +```http +GET /api/v1/sns/block-delete/status +POST /api/v1/sns/block-delete/install +POST /api/v1/sns/block-delete/uninstall +``` + +### 7.7 删除单条朋友圈 + +```http +DELETE /api/v1/sns/post/{postId} +``` + +--- + +## 8. 访问导出媒体 > 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) @@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 8. 使用示例 +## 9. 使用示例 ### PowerShell @@ -525,7 +641,7 @@ members = requests.get( --- -## 9. 注意事项 +## 10. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/services/config.ts b/electron/services/config.ts index 3269b0b..7e3b1e1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -128,7 +128,7 @@ export class ConfigService { httpApiToken: '', httpApiEnabled: false, httpApiPort: 5031, - httpApiHost: '127.0.0.1', + httpApiHost: '0.0.0.0', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 02fa030..29d8952 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -12,6 +12,7 @@ import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' import { groupAnalyticsService } from './groupAnalyticsService' +import { snsService } from './snsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -308,7 +309,7 @@ class HttpService { */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') if (req.method === 'OPTIONS') { @@ -348,6 +349,33 @@ class HttpService { await this.handleContacts(url, res) } else if (pathname === '/api/v1/group-members') { await this.handleGroupMembers(url, res) + } else if (pathname === '/api/v1/sns/timeline') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsTimeline(url, res) + } else if (pathname === '/api/v1/sns/usernames') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsUsernames(res) + } else if (pathname === '/api/v1/sns/export/stats') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsExportStats(url, res) + } else if (pathname === '/api/v1/sns/media/proxy') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsMediaProxy(url, res) + } else if (pathname === '/api/v1/sns/export') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsExport(url, res) + } else if (pathname === '/api/v1/sns/block-delete/status') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsBlockDeleteStatus(res) + } else if (pathname === '/api/v1/sns/block-delete/install') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteInstall(res) + } else if (pathname === '/api/v1/sns/block-delete/uninstall') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteUninstall(res) + } else if (pathname.startsWith('/api/v1/sns/post/')) { + if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE') + await this.handleSnsDeletePost(pathname, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -559,6 +587,15 @@ class HttpService { return defaultValue } + private parseStringListParam(value: string | null): string[] | undefined { + if (!value) return undefined + const values = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return values.length > 0 ? Array.from(new Set(values)) : undefined + } + private parseMediaOptions(url: URL): ApiMediaOptions { const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) if (!mediaEnabled) { @@ -790,6 +827,313 @@ class HttpService { } } + private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise { + const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200) + const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = (url.searchParams.get('keyword') || '').trim() || undefined + const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true) + const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false) + const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true) + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + const startTime = startTimeRaw > 0 ? startTimeRaw : undefined + const endTime = endTimeRaw > 0 ? endTimeRaw : undefined + + const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns timeline') + return + } + + let timeline = result.timeline || [] + if (resolveMedia && timeline.length > 0) { + timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia) + } + + this.sendJson(res, { + success: true, + count: timeline.length, + timeline + }) + } + + private async handleSnsUsernames(res: http.ServerResponse): Promise { + const result = await snsService.getSnsUsernames() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns usernames') + return + } + this.sendJson(res, { + success: true, + usernames: result.usernames || [] + }) + } + + private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise { + const fast = this.parseBooleanParam(url, ['fast'], false) + const result = fast + ? await snsService.getExportStatsFast() + : await snsService.getExportStats() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns export stats') + return + } + this.sendJson(res, result) + } + + private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise { + const mediaUrl = (url.searchParams.get('url') || '').trim() + if (!mediaUrl) { + this.sendError(res, 400, 'Missing required parameter: url') + return + } + + const key = this.toSnsMediaKey(url.searchParams.get('key')) + const result = await snsService.downloadImage(mediaUrl, key) + if (!result.success) { + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + return + } + + if (result.data) { + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', result.data.length) + res.writeHead(200) + res.end(result.data) + return + } + + if (result.cachePath && fs.existsSync(result.cachePath)) { + try { + const stat = fs.statSync(result.cachePath) + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', stat.size) + res.writeHead(200) + + const stream = fs.createReadStream(result.cachePath) + stream.on('error', () => { + if (!res.headersSent) { + this.sendError(res, 500, 'Failed to read proxied sns media') + } else { + try { res.destroy() } catch {} + } + }) + stream.pipe(res) + return + } catch (error) { + console.error('[HttpService] Failed to stream sns media cache:', error) + } + } + + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + } + + private async handleSnsExport(url: URL, res: http.ServerResponse): Promise { + const outputDir = String(url.searchParams.get('outputDir') || '').trim() + if (!outputDir) { + this.sendError(res, 400, 'Missing required field: outputDir') + return + } + + const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase() + const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat + if (!['json', 'html', 'arkmejson'].includes(format)) { + this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson') + return + } + + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + + const options: { + outputDir: string + format: 'json' | 'html' | 'arkmejson' + usernames?: string[] + keyword?: string + exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean + startTime?: number + endTime?: number + } = { + outputDir, + format: format as 'json' | 'html' | 'arkmejson', + usernames, + keyword, + exportMedia: this.parseBooleanParam(url, ['exportMedia'], false) + } + + if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false) + if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false) + if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false) + if (startTimeRaw > 0) options.startTime = startTimeRaw + if (endTimeRaw > 0) options.endTime = endTimeRaw + + const result = await snsService.exportTimeline(options) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to export sns timeline') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise { + const result = await snsService.checkSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to check sns block-delete status') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise { + const result = await snsService.installSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise { + const result = await snsService.uninstallSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise { + const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim() + if (!postId) { + this.sendError(res, 400, 'Missing required path parameter: postId') + return + } + + const result = await snsService.deleteSnsPost(postId) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to delete sns post') + return + } + this.sendJson(res, result) + } + + private toSnsMediaKey(value: unknown): string | number | undefined { + if (value == null) return undefined + if (typeof value === 'number' && Number.isFinite(value)) return value + const text = String(value).trim() + if (!text) return undefined + if (/^-?\d+$/.test(text)) return Number(text) + return text + } + + private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined { + const target = String(rawUrl || '').trim() + if (!target) return undefined + const params = new URLSearchParams({ url: target }) + if (key !== undefined) params.set('key', String(key)) + return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}` + } + + private async resolveSnsMediaUrl( + rawUrl: string, + key: string | number | undefined, + inline: boolean + ): Promise<{ resolvedUrl?: string; proxyUrl?: string }> { + const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key) + if (!proxyUrl) return {} + if (!inline) return { resolvedUrl: proxyUrl, proxyUrl } + + try { + const resolved = await snsService.proxyImage(rawUrl, key) + if (resolved.success && resolved.dataUrl) { + return { resolvedUrl: resolved.dataUrl, proxyUrl } + } + } catch (error) { + console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error) + } + + return { resolvedUrl: proxyUrl, proxyUrl } + } + + private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise { + return Promise.all( + (posts || []).map(async (post) => { + const mediaList = Array.isArray(post?.media) ? post.media : [] + if (mediaList.length === 0) return post + + const nextMedia = await Promise.all( + mediaList.map(async (media: any) => { + const rawUrl = typeof media?.url === 'string' ? media.url : '' + const rawThumb = typeof media?.thumb === 'string' ? media.thumb : '' + const mediaKey = this.toSnsMediaKey(media?.key) + + const [urlResolved, thumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawUrl, mediaKey, inline), + this.resolveSnsMediaUrl(rawThumb, mediaKey, inline) + ]) + + const nextItem: any = { + ...media, + rawUrl, + rawThumb, + resolvedUrl: urlResolved.resolvedUrl, + resolvedThumbUrl: thumbResolved.resolvedUrl, + proxyUrl: urlResolved.proxyUrl, + proxyThumbUrl: thumbResolved.proxyUrl + } + + if (replace) { + nextItem.url = urlResolved.resolvedUrl || rawUrl + nextItem.thumb = thumbResolved.resolvedUrl || rawThumb + } + + if (media?.livePhoto && typeof media.livePhoto === 'object') { + const livePhoto = media.livePhoto + const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : '' + const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : '' + const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey) + + const [liveUrlResolved, liveThumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline), + this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline) + ]) + + const nextLive: any = { + ...livePhoto, + rawUrl: rawLiveUrl, + rawThumb: rawLiveThumb, + resolvedUrl: liveUrlResolved.resolvedUrl, + resolvedThumbUrl: liveThumbResolved.resolvedUrl, + proxyUrl: liveUrlResolved.proxyUrl, + proxyThumbUrl: liveThumbResolved.proxyUrl + } + + if (replace) { + nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl + nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb + } + + nextItem.livePhoto = nextLive + } + + return nextItem + }) + ) + + return { + ...post, + media: nextMedia + } + }) + ) + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -1451,6 +1795,11 @@ class HttpService { res.end(JSON.stringify(data, null, 2)) } + private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void { + res.setHeader('Allow', allow) + this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`) + } + /** * 发送错误响应 */ diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index e0d31f9..d7fe76e 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -537,6 +537,32 @@ class SnsService { return raw.trim() } + private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise { + const pageSize = 500 + const uniqueUsers = new Set() + let offset = 0 + + for (let round = 0; round < maxRounds; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈发布者失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return Array.from(uniqueUsers) + } + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { const pageSize = 500 const uniqueUsers = new Set() @@ -794,7 +820,22 @@ class SnsService { if (!result.success) { return { success: false, error: result.error || '获取朋友圈联系人失败' } } - return { success: true, usernames: result.usernames || [] } + const directUsernames = Array.isArray(result.usernames) ? result.usernames : [] + if (directUsernames.length > 0) { + return { success: true, usernames: directUsernames } + } + + // 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。 + try { + const timelineUsers = await this.collectSnsUsernamesFromTimeline() + if (timelineUsers.length > 0) { + return { success: true, usernames: timelineUsers } + } + } catch { + // 忽略回退错误,保持与原行为一致返回空数组 + } + + return { success: true, usernames: directUsernames } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { @@ -1199,7 +1240,7 @@ class SnsService { return { success: false, error: result.error } } - async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> { + async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { return this.fetchAndDecryptImage(url, key) }