From fa5575592101125d8cc7c4cf3d7e7f51cb427bab Mon Sep 17 00:00:00 2001 From: hejk Date: Tue, 24 Mar 2026 14:58:42 +0800 Subject: [PATCH] feat(http): add sns HTTP API endpoints --- docs/HTTP-API.md | 117 ++++++++++- electron/services/httpService.ts | 324 ++++++++++++++++++++++++++++++- 2 files changed, 437 insertions(+), 4 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 052dd8a..bc25545 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -433,7 +433,118 @@ 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[].rawUrl/rawThumb`:原始朋友圈地址 +- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 +- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) +- `replace=1` 时,`media[].url/thumb` 会被替换为可用地址 + +### 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 +587,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 8. 使用示例 +## 9. 使用示例 ### PowerShell @@ -525,7 +636,7 @@ members = requests.get( --- -## 9. 注意事项 +## 10. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 02fa030..c757db2 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,286 @@ 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 || !result.data) { + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + return + } + + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', result.data.length) + res.writeHead(200) + res.end(result.data) + } + + 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 +1768,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}`) + } + /** * 发送错误响应 */