diff --git a/src/core/apis/webapi.ts b/src/core/apis/webapi.ts index 82895ac1..6d70c8d5 100644 --- a/src/core/apis/webapi.ts +++ b/src/core/apis/webapi.ts @@ -8,10 +8,10 @@ import { WebHonorType, } from '@/core'; import { NapCatCore } from '..'; -import { createReadStream, readFileSync } from 'node:fs'; +import { createReadStream, readFileSync, statSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { basename } from 'node:path'; -import { createStreamUploadChunk, qunAlbumControl } from '../data/webapi'; +import { qunAlbumControl } from '../data/webapi'; export class NTQQWebApi { context: InstanceContext; core: NapCatCore; @@ -336,77 +336,8 @@ export class NTQQWebApi { }); return response.data.album; } - - async uploadImageToQunAlbum(gc: string, sAlbumID: string, sAlbumName: string, path: string) { - const skey = await this.core.apis.UserApi.getSKey() || ''; - const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || ''; - const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey)).data.session; - if (!session) throw new Error('创建群相册会话失败'); - - const uin = this.core.selfInfo.uin || '10001'; - const chunk = createStreamUploadChunk(createReadStream(path), uin, session, 16384); - - // 准备上传参数 - const total = readFileSync(path).length; - const GTK = this.getBknFromSKey(pskey); - const cookie = `p_uin=${uin}; p_skey=${pskey}; skey=${skey}; uin=${uin}`; - - // 收集所有分片 - const allChunks: NonNullable>>[] = []; - let chunked = await chunk.getNextChunk(); - - while (chunked) { - allChunks.push(chunked); - chunked = await chunk.getNextChunk(); - } - - // 将分片分成3组,每组内部按顺序执行,3组之间并行执行 - const chunkGroups: typeof allChunks[] = [[], [], []]; - allChunks.forEach((chunk, index) => { - const groupIndex = index % 3; - chunkGroups[groupIndex]!.push(chunk); - }); - - // 创建单个上传分片的函数 - const uploadChunk = async (chunkData: typeof allChunks[0]) => { - const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${chunkData.seq}&retry=0&offset=${chunkData.offset}&end=${chunkData.end}&total=${total}&type=json&g_tk=${GTK}`; - - const post = await RequestUtil.HttpGetJson<{ data: { offset: string }, ret: number, msg: string }>(api, 'POST', chunkData, { - 'Cookie': cookie, - 'Content-Type': 'application/json', - 'origin': 'https://h5.qzone.qq.com', - }); - if (post.ret !== 0) throw new Error(`分片 ${chunkData.seq} 上传失败: ${post.msg}`); - - return { seq: chunkData.seq, offset: chunkData.offset, success: true }; - }; - - // 创建每组顺序上传的函数 - const uploadGroupSequentially = async (group: typeof allChunks) => { - const groupResults = []; - for (const chunk of group) { - const result = await uploadChunk(chunk); - groupResults.push(result); - } - return groupResults; - }; - - // 3个队列并行执行,每个队列内部按顺序执行 - const groupPromises = chunkGroups.map(group => uploadGroupSequentially(group)); - const groupResults = await Promise.all(groupPromises); - - // 合并所有结果 - const results = groupResults.flat(); - - // 按序号排序结果 - results.sort((a, b) => a.seq - b.seq); - return results; - } - - async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string) { + async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) { const img = readFileSync(path); - const uin = this.core.selfInfo.uin || '10001'; - const img_md5 = createHash('md5').update(img).digest('hex'); const img_size = img.length; const img_name = basename(path); const GTK = this.getBknFromSKey(pskey); @@ -428,4 +359,64 @@ export class NTQQWebApi { }); return post; } -} + + async uploadQunAlbumSlice(path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) { + const img_size = statSync(path).size; + let seq = 0; + let offset = 0; + const GTK = this.getBknFromSKey(pskey); + const cookie = `p_uin=${uin}; p_skey=${pskey}; skey=${skey}; uin=${uin}`; + + const stream = createReadStream(path, { highWaterMark: slice_size }); + + for await (const chunk of stream) { + const end = Math.min(offset + chunk.length, img_size); + const form = new FormData(); + form.append('uin', uin); + form.append('appid', 'qun'); + form.append('session', session); + form.append('offset', offset.toString()); + form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob'); + form.append('checksum', ''); + form.append('check_type', '0'); + form.append('retry', '0'); + form.append('seq', seq.toString()); + form.append('end', end.toString()); + form.append('cmd', 'FileUpload'); + form.append('slice_size', slice_size.toString()); + form.append('biz_req.iUploadType', '0'); + + const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${GTK}`; + const response = await fetch(api, { + method: 'POST', + headers: { + 'Cookie': cookie, + }, + body: form + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) { + throw new Error(`分片 ${seq} 上传失败: ${post.msg}`); + } + this.context.logger.log(`上传 ${api} 成功`); + offset += chunk.length; + seq++; + } + + return { success: true, message: '上传完成' }; + } + + async uploadImageToQunAlbum(gc: string, sAlbumID: string, sAlbumName: string, path: string) { + const skey = await this.core.apis.UserApi.getSKey() || ''; + const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || ''; + const img_md5 = createHash('md5').update(readFileSync(path)).digest('hex'); + const uin = this.core.selfInfo.uin || '10001'; + const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session; + if (!session) throw new Error('创建群相册会话失败'); + await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384); + } +} \ No newline at end of file diff --git a/src/core/data/webapi.ts b/src/core/data/webapi.ts index 13c68dfa..69fff601 100644 --- a/src/core/data/webapi.ts +++ b/src/core/data/webapi.ts @@ -1,4 +1,4 @@ -import { ReadStream } from "node:fs"; + export interface ControlReq { appid?: string; asy_upload?: number; @@ -12,7 +12,6 @@ export interface ControlReq { session?: string; token?: Token; uin?: string; - [property: string]: any; } export interface BizReq { @@ -30,36 +29,36 @@ export interface BizReq { mapExt: MapExt; sAlbumID: string; sAlbumName: string; - sExif_CameraMaker: string; - sExif_CameraModel: string; - sExif_Latitude: string; - sExif_LatitudeRef: string; - sExif_Longitude: string; - sExif_LongitudeRef: string; - sExif_Time: string; sPicDesc: string; sPicPath: string; sPicTitle: string; - [property: string]: any; + stExtendInfo: StExtendInfo; } export interface MapExt { appid: string; userid: string; - [property: string]: any; +} + +export interface StExtendInfo { + mapParams: MapParams; +} + +export interface MapParams { + batch_num: string; + photo_num: string; + video_num: string; } export interface Env { deviceInfo: string; refer: string; - [property: string]: any; } export interface Token { appid: number; data: string; type: number; - [property: string]: any; } export function qunAlbumControl({ @@ -70,7 +69,10 @@ export function qunAlbumControl({ img_size, img_name, sAlbumName, - sAlbumID + sAlbumID, + photo_num = "1", + video_num = "0", + batch_num = "1" }: { uin: string, group_id: string, @@ -80,10 +82,15 @@ export function qunAlbumControl({ img_name: string, sAlbumName: string, sAlbumID: string, + photo_num?: string, + video_num?: string, + batch_num?: string } ): { control_req: ControlReq[] } { + const timestamp = Math.floor(Date.now() / 1000); + return { control_req: [ { @@ -109,27 +116,27 @@ export function qunAlbumControl({ sAlbumID: sAlbumID, iAlbumTypeID: 0, iBitmap: 0, - iUploadType: 3, + iUploadType: 0, iUpPicType: 0, - iBatchID: +(Date.now().toString() + '4000'),//17位时间戳 + iBatchID: timestamp, sPicPath: "", iPicWidth: 0, iPicHight: 0, iWaterType: 0, iDistinctUse: 0, iNeedFeeds: 1, - iUploadTime: +(Math.floor(Date.now() / 1000).toString()), + iUploadTime: timestamp, mapExt: { appid: "qun", userid: group_id }, - sExif_CameraMaker: "", - sExif_CameraModel: "", - sExif_Time: "", - sExif_LatitudeRef: "", - sExif_Latitude: "", - sExif_LongitudeRef: "", - sExif_Longitude: "" + stExtendInfo: { + mapParams: { + photo_num: photo_num, + video_num: video_num, + batch_num: batch_num + } + } }, session: "", asy_upload: 0, @@ -167,136 +174,4 @@ export function createStreamUpload( iUploadType: 3 } }; -} - -class ChunkData { - private reader: ReadStream; - private uin: string; - private chunkSize: number; - private offset: number = 0; - private seq: number = 0; - private buffer: Uint8Array = new Uint8Array(0); - private isCompleted: boolean = false; - private session: string; - - constructor(file: ReadStream, uin: string, chunkSize: number = 16384, session: string = '') { - this.reader = file; - this.uin = uin; - this.chunkSize = chunkSize; - this.session = session; - } - - async getNextChunk(): Promise | null> { - if (this.isCompleted && this.buffer.length === 0) { - return null; - } - - try { - return new Promise((resolve, reject) => { - const processChunk = () => { - // 如果没有数据了,返回 null - if (this.buffer.length === 0) { - resolve(null); - return; - } - - // 准备当前块数据 - const chunkToSend = this.buffer.slice(0, Math.min(this.chunkSize, this.buffer.length)); - this.buffer = this.buffer.slice(chunkToSend.length); - - // 计算位置信息 - const start = this.offset; - this.offset += chunkToSend.length; - const end = this.offset; - - // 转换为 Base64 - const base64Data = Buffer.from(chunkToSend).toString('base64'); - - // 创建上传数据对象 - const uploadData = createStreamUpload({ - uin: this.uin, - session: this.session, - offset: start, - seq: this.seq, - end: end, - slice_size: this.chunkSize, - data: base64Data - }); - - this.seq++; - - resolve(uploadData); - }; - - // 如果缓冲区已经有足够数据,直接处理 - if (this.buffer.length >= this.chunkSize) { - processChunk(); - return; - } - - // 否则,从流中读取更多数据 - const dataHandler = (chunk: string | Buffer) => { - // 确保处理的是 Buffer - const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - - // 合并缓冲区 - const newBuffer = new Uint8Array(this.buffer.length + bufferChunk.length); - newBuffer.set(this.buffer); - newBuffer.set(new Uint8Array(bufferChunk), this.buffer.length); - this.buffer = newBuffer; - - // 如果有足够的数据,处理并返回 - if (this.buffer.length >= this.chunkSize) { - this.reader.removeListener('data', dataHandler); - this.reader.removeListener('end', endHandler); - this.reader.removeListener('error', errorHandler); - processChunk(); - } - }; - - const endHandler = () => { - this.isCompleted = true; - this.reader.removeListener('data', dataHandler); - this.reader.removeListener('end', endHandler); - this.reader.removeListener('error', errorHandler); - - // 处理剩余数据 - processChunk(); - }; - - const errorHandler = (err: Error) => { - this.reader.removeListener('data', dataHandler); - this.reader.removeListener('end', endHandler); - this.reader.removeListener('error', errorHandler); - reject(err); - }; - - // 添加事件监听器 - this.reader.on('data', dataHandler); - this.reader.on('end', endHandler); - this.reader.on('error', errorHandler); - }); - } catch (error) { - console.error('Error getting next chunk:', error); - throw error; - } - } - - setSession(session: string): void { - this.session = session; - } - - getProgress(): number { - return this.offset; - } - - isFinished(): boolean { - return this.isCompleted && this.buffer.length === 0; - } -} - - -// 根据文件流 按chunk持续函数 -export function createStreamUploadChunk(file: ReadStream, uin: string, session: string, chunk: number = 16384): ChunkData { - return new ChunkData(file, uin, chunk, session); } \ No newline at end of file