mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
122
docs/HTTP-API.md
122
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 中完成数据库连接。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<any[]> {
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
|
||||
@@ -537,6 +537,32 @@ class SnsService {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
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<string>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user