mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-19 13:10:16 +08:00
Refactor Qun album image upload logic
Reworked the group album image upload process to use a new slice-based upload method, replacing the previous chunked upload implementation. Updated related interfaces and removed unused chunk upload code for improved maintainability and clarity.
This commit is contained in:
parent
d4b0a4acca
commit
74a1011fcc
@ -8,10 +8,10 @@ import {
|
|||||||
WebHonorType,
|
WebHonorType,
|
||||||
} from '@/core';
|
} from '@/core';
|
||||||
import { NapCatCore } from '..';
|
import { NapCatCore } from '..';
|
||||||
import { createReadStream, readFileSync } from 'node:fs';
|
import { createReadStream, readFileSync, statSync } from 'node:fs';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { createStreamUploadChunk, qunAlbumControl } from '../data/webapi';
|
import { qunAlbumControl } from '../data/webapi';
|
||||||
export class NTQQWebApi {
|
export class NTQQWebApi {
|
||||||
context: InstanceContext;
|
context: InstanceContext;
|
||||||
core: NapCatCore;
|
core: NapCatCore;
|
||||||
@ -336,77 +336,8 @@ export class NTQQWebApi {
|
|||||||
});
|
});
|
||||||
return response.data.album;
|
return response.data.album;
|
||||||
}
|
}
|
||||||
|
async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) {
|
||||||
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<Awaited<ReturnType<typeof chunk.getNextChunk>>>[] = [];
|
|
||||||
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) {
|
|
||||||
const img = readFileSync(path);
|
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_size = img.length;
|
||||||
const img_name = basename(path);
|
const img_name = basename(path);
|
||||||
const GTK = this.getBknFromSKey(pskey);
|
const GTK = this.getBknFromSKey(pskey);
|
||||||
@ -428,4 +359,64 @@ export class NTQQWebApi {
|
|||||||
});
|
});
|
||||||
return post;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ReadStream } from "node:fs";
|
|
||||||
export interface ControlReq {
|
export interface ControlReq {
|
||||||
appid?: string;
|
appid?: string;
|
||||||
asy_upload?: number;
|
asy_upload?: number;
|
||||||
@ -12,7 +12,6 @@ export interface ControlReq {
|
|||||||
session?: string;
|
session?: string;
|
||||||
token?: Token;
|
token?: Token;
|
||||||
uin?: string;
|
uin?: string;
|
||||||
[property: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BizReq {
|
export interface BizReq {
|
||||||
@ -30,36 +29,36 @@ export interface BizReq {
|
|||||||
mapExt: MapExt;
|
mapExt: MapExt;
|
||||||
sAlbumID: string;
|
sAlbumID: string;
|
||||||
sAlbumName: 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;
|
sPicDesc: string;
|
||||||
sPicPath: string;
|
sPicPath: string;
|
||||||
sPicTitle: string;
|
sPicTitle: string;
|
||||||
[property: string]: any;
|
stExtendInfo: StExtendInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapExt {
|
export interface MapExt {
|
||||||
appid: string;
|
appid: string;
|
||||||
userid: 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 {
|
export interface Env {
|
||||||
deviceInfo: string;
|
deviceInfo: string;
|
||||||
refer: string;
|
refer: string;
|
||||||
[property: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Token {
|
export interface Token {
|
||||||
appid: number;
|
appid: number;
|
||||||
data: string;
|
data: string;
|
||||||
type: number;
|
type: number;
|
||||||
[property: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function qunAlbumControl({
|
export function qunAlbumControl({
|
||||||
@ -70,7 +69,10 @@ export function qunAlbumControl({
|
|||||||
img_size,
|
img_size,
|
||||||
img_name,
|
img_name,
|
||||||
sAlbumName,
|
sAlbumName,
|
||||||
sAlbumID
|
sAlbumID,
|
||||||
|
photo_num = "1",
|
||||||
|
video_num = "0",
|
||||||
|
batch_num = "1"
|
||||||
}: {
|
}: {
|
||||||
uin: string,
|
uin: string,
|
||||||
group_id: string,
|
group_id: string,
|
||||||
@ -80,10 +82,15 @@ export function qunAlbumControl({
|
|||||||
img_name: string,
|
img_name: string,
|
||||||
sAlbumName: string,
|
sAlbumName: string,
|
||||||
sAlbumID: string,
|
sAlbumID: string,
|
||||||
|
photo_num?: string,
|
||||||
|
video_num?: string,
|
||||||
|
batch_num?: string
|
||||||
}
|
}
|
||||||
): {
|
): {
|
||||||
control_req: ControlReq[]
|
control_req: ControlReq[]
|
||||||
} {
|
} {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
control_req: [
|
control_req: [
|
||||||
{
|
{
|
||||||
@ -109,27 +116,27 @@ export function qunAlbumControl({
|
|||||||
sAlbumID: sAlbumID,
|
sAlbumID: sAlbumID,
|
||||||
iAlbumTypeID: 0,
|
iAlbumTypeID: 0,
|
||||||
iBitmap: 0,
|
iBitmap: 0,
|
||||||
iUploadType: 3,
|
iUploadType: 0,
|
||||||
iUpPicType: 0,
|
iUpPicType: 0,
|
||||||
iBatchID: +(Date.now().toString() + '4000'),//17位时间戳
|
iBatchID: timestamp,
|
||||||
sPicPath: "",
|
sPicPath: "",
|
||||||
iPicWidth: 0,
|
iPicWidth: 0,
|
||||||
iPicHight: 0,
|
iPicHight: 0,
|
||||||
iWaterType: 0,
|
iWaterType: 0,
|
||||||
iDistinctUse: 0,
|
iDistinctUse: 0,
|
||||||
iNeedFeeds: 1,
|
iNeedFeeds: 1,
|
||||||
iUploadTime: +(Math.floor(Date.now() / 1000).toString()),
|
iUploadTime: timestamp,
|
||||||
mapExt: {
|
mapExt: {
|
||||||
appid: "qun",
|
appid: "qun",
|
||||||
userid: group_id
|
userid: group_id
|
||||||
},
|
},
|
||||||
sExif_CameraMaker: "",
|
stExtendInfo: {
|
||||||
sExif_CameraModel: "",
|
mapParams: {
|
||||||
sExif_Time: "",
|
photo_num: photo_num,
|
||||||
sExif_LatitudeRef: "",
|
video_num: video_num,
|
||||||
sExif_Latitude: "",
|
batch_num: batch_num
|
||||||
sExif_LongitudeRef: "",
|
}
|
||||||
sExif_Longitude: ""
|
}
|
||||||
},
|
},
|
||||||
session: "",
|
session: "",
|
||||||
asy_upload: 0,
|
asy_upload: 0,
|
||||||
@ -167,136 +174,4 @@ export function createStreamUpload(
|
|||||||
iUploadType: 3
|
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<ReturnType<typeof createStreamUpload> | 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);
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user