SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码): - snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包 - snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线 - snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI) - snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports) - snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时 - 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏) - 工具件: 解包器/启动器/便携制作器/doctor GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
commit
b9655a5ab1
1048 files changed
+130851
No files matched your search
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 群相册列表请求参数接口
|
||||
*/
|
||||
export interface AlbumListRequest {
|
||||
qun_id: string;
|
||||
attach_info: string;
|
||||
seq: number;
|
||||
request_time_line: {
|
||||
request_invoke_time: string;
|
||||
};
|
||||
album_id: string;
|
||||
lloc: string;
|
||||
batch_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群相册列表请求参数
|
||||
* @param qunId 群号
|
||||
* @param albumId 相册ID
|
||||
* @param seq 请求序列号,默认值为0
|
||||
* @returns 请求参数对象
|
||||
*/
|
||||
export function createAlbumListRequest (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
seq: number = 0
|
||||
): AlbumListRequest {
|
||||
return {
|
||||
qun_id: qunId,
|
||||
attach_info: '',
|
||||
seq,
|
||||
request_time_line: {
|
||||
request_invoke_time: '0',
|
||||
},
|
||||
album_id: albumId,
|
||||
lloc: '',
|
||||
batch_id: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册媒体项请求接口
|
||||
*/
|
||||
export interface AlbumMediaFeed {
|
||||
cell_common: {
|
||||
time: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: string;
|
||||
media_items: Array<{
|
||||
image: {
|
||||
lloc: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册媒体请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param lloc
|
||||
* @returns 媒体请求参数对象
|
||||
*/
|
||||
export function createAlbumMediaFeed (
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc: string
|
||||
): AlbumMediaFeed {
|
||||
return {
|
||||
cell_common: {
|
||||
time: '',
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: '',
|
||||
media_items: [{
|
||||
image: {
|
||||
lloc,
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 相册评论内容接口
|
||||
*/
|
||||
export interface AlbumCommentContent {
|
||||
type: number;
|
||||
content: string;
|
||||
who: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册评论请求接口
|
||||
*/
|
||||
export interface AlbumCommentReplyContent {
|
||||
client_key: number;
|
||||
content: AlbumCommentContent[];
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
}
|
||||
export enum RichMsgType {
|
||||
KRICHMSGTYPEPLAINTEXT,
|
||||
KRICHMSGTYPEAT,
|
||||
KRICHMSGTYPEURL,
|
||||
KRICHMSGTYPEMEDIA,
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册评论请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param content 评论内容
|
||||
* @param client_key 客户端鉴权密钥
|
||||
* @returns 评论请求参数对象
|
||||
*/
|
||||
export function createAlbumCommentRequest (
|
||||
uin: string,
|
||||
content: string,
|
||||
client_key: number
|
||||
): AlbumCommentReplyContent {
|
||||
return {
|
||||
client_key,
|
||||
// 暂时只支持纯文本吧
|
||||
content: [{
|
||||
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
|
||||
content,
|
||||
who: 0,
|
||||
uid: '',
|
||||
name: '',
|
||||
url: '',
|
||||
}],
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlbumFeedLikePublish {
|
||||
cell_common: {
|
||||
time: number;
|
||||
feed_id: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: string | number;
|
||||
media_items?: {
|
||||
type: number;
|
||||
image: {
|
||||
lloc: string;
|
||||
sloc: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
cell_qun_info: {
|
||||
qun_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册动态发布请求参数
|
||||
* @param qunId 群号
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param batchId 批次ID
|
||||
* @returns 动态发布请求参数对象
|
||||
*/
|
||||
export function createAlbumFeedPublish (
|
||||
qunId: string,
|
||||
uin: string,
|
||||
albumId: string,
|
||||
batchId: string
|
||||
) {
|
||||
return {
|
||||
cell_common: {
|
||||
time: Date.now(),
|
||||
feed_id: `422_0_${batchId}`, // 需要补全feed_id
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: batchId,
|
||||
},
|
||||
cell_qun_info: {
|
||||
qun_id: qunId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
export interface FlashBaseRequest {
|
||||
fileSetId: string;
|
||||
}
|
||||
|
||||
export interface UploaderInfo {
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string, // ""
|
||||
}
|
||||
|
||||
export interface thumbnailInfo {
|
||||
id: string,
|
||||
url: {
|
||||
spec: number,
|
||||
uri: string,
|
||||
}[],
|
||||
localCachePath: string,
|
||||
}
|
||||
|
||||
export interface SendTarget {
|
||||
destType: number; // 1私聊
|
||||
destUin?: string,
|
||||
destUid: string,
|
||||
}
|
||||
|
||||
export interface SendTargetRequests {
|
||||
fileSetId: string;
|
||||
targets: SendTarget[];
|
||||
}
|
||||
|
||||
export interface DownloadStatusInfo {
|
||||
result: number; // 0
|
||||
fileSetId: string;
|
||||
status: number;
|
||||
info: {
|
||||
curDownLoadFailFileNum: number,
|
||||
curDownLoadedPauseFileNum: number,
|
||||
curDownLoadedFileNum: number,
|
||||
curRealDownLoadedFileNum: number,
|
||||
curDownloadingFileNum: number,
|
||||
totalDownLoadedFileNum: number,
|
||||
curDownLoadedBytes: string, // "0"
|
||||
totalDownLoadedBytes: string,
|
||||
curSpeedBps: number,
|
||||
avgSpeedBps: number,
|
||||
maxSpeedBps: number,
|
||||
remainDownLoadSeconds: number,
|
||||
failFileIdList: [],
|
||||
allFileIdList: [],
|
||||
hasNormalFileDownloading: boolean,
|
||||
onlyCompressInnerFileDownloading: boolean,
|
||||
isAllFileAlreadyDownloaded: boolean,
|
||||
saveFileSetDir: string,
|
||||
allWaitingStatusTask: boolean,
|
||||
downloadSceneType: DownloadSceneType,
|
||||
retryCount: number,
|
||||
statisticInfo: {
|
||||
downloadTaskId: string,
|
||||
downloadFilesetName: string,
|
||||
downloadFileTypeDistribution: string,
|
||||
downloadFileSizeDistribution: string;
|
||||
},
|
||||
albumStorageFailImageNum: number,
|
||||
albumStorageFailVideoNum: number,
|
||||
albumStorageFailFileIdList: [],
|
||||
albumStorageSucImageNum: number,
|
||||
albumStorageSucVideoNum: number,
|
||||
albumStorageSucFileIdList: [],
|
||||
albumStorageFileNum: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface physicalInfo {
|
||||
id: string,
|
||||
url: string,
|
||||
status: number, // 2 已下载
|
||||
processing: string,
|
||||
localPath: string,
|
||||
width: 0,
|
||||
height: 0,
|
||||
time: number,
|
||||
}
|
||||
|
||||
export interface downloadInfo {
|
||||
status: number,
|
||||
curDownLoadBytes: string,
|
||||
totalFileBytes: string,
|
||||
errorCode: number,
|
||||
}
|
||||
|
||||
export interface uploadInfo {
|
||||
uploadedBytes: string,
|
||||
errorCode: number,
|
||||
svrRrrCode: number,
|
||||
errMsg: string,
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
thumbnailUploadState: number;
|
||||
isSecondHit: boolean,
|
||||
hasModifiedErr: boolean,
|
||||
}
|
||||
|
||||
export interface folderUploadInfo {
|
||||
totalUploadedFileSize: string;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export interface folderDownloadInfo {
|
||||
totalDownloadedFileSize: string;
|
||||
totalFileSize: string;
|
||||
totalDownloadFileCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
pausedCount: number;
|
||||
cancelCount: number;
|
||||
downloadingCount: number;
|
||||
partialDownloadCount: number;
|
||||
curLevelDownloadedFileCount: number;
|
||||
curLevelUnDownloadedFileCount: number;
|
||||
}
|
||||
|
||||
export interface compressFileFolderInfo {
|
||||
downloadStatus: number;
|
||||
saveFileDirPath: string;
|
||||
totalFileCount: string;
|
||||
totalFileSize: string;
|
||||
}
|
||||
|
||||
export interface albumStorgeInfo {
|
||||
status: number;
|
||||
localIdentifier: string;
|
||||
errorCode: number;
|
||||
timeCost: number;
|
||||
}
|
||||
|
||||
export interface FlashOneFileInfo {
|
||||
fileSetId: string;
|
||||
cliFileId: string; // client?? 或许可以换取url
|
||||
compressedFileFolderId: string;
|
||||
archiveIndex: 0;
|
||||
indexPath: string;
|
||||
isDir: boolean; // 文件或者文件夹!!
|
||||
parentId: string;
|
||||
depth: number; // 1
|
||||
cliFileIndex: number;
|
||||
fileType: number; // 枚举!! 已完成枚举!!
|
||||
name: string;
|
||||
namePinyin: string;
|
||||
isCover: boolean;
|
||||
isCoverOriginal: boolean;
|
||||
fileSize: string;
|
||||
fileCount: number;
|
||||
thumbnail: thumbnailInfo;
|
||||
physical: physicalInfo;
|
||||
srvFileId: string; // service?? 服务器上面的id吗?
|
||||
srvParentFileId: string;
|
||||
svrLastUpdateTimestamp: string;
|
||||
downloadInfo: downloadInfo;
|
||||
saveFilePath: string;
|
||||
search_relative_path: string;
|
||||
disk_relative_path: string;
|
||||
uploadInfo: uploadInfo;
|
||||
status: number;
|
||||
uploadStatus: number; // 3已上传成功
|
||||
downloadStatus: number; // 0未下载
|
||||
folderUploadInfo: folderUploadInfo;
|
||||
folderDownloadInfo: folderDownloadInfo;
|
||||
sha1: string;
|
||||
bookmark: string;
|
||||
compressFileFolderInfo: compressFileFolderInfo;
|
||||
uploadPauseReason: string;
|
||||
downloadPauseReason: string;
|
||||
filePhysicalSize: string;
|
||||
thumbnail_sha1: string | null;
|
||||
thumbnail_size: string | null;
|
||||
needAlbumStorage: boolean;
|
||||
albumStorageInfo: albumStorgeInfo;
|
||||
}
|
||||
|
||||
export interface fileListsInfo {
|
||||
parentId: string,
|
||||
depth: number, // 1
|
||||
fileList: FlashOneFileInfo[],
|
||||
paginationInfo: {};
|
||||
isEnd: boolean,
|
||||
isCache: boolean,
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,
|
||||
fileLists: fileListsInfo[],
|
||||
}
|
||||
|
||||
export interface createFlashTransferResult {
|
||||
fileSetId: string,
|
||||
shareLink: string,
|
||||
expireTime: string,
|
||||
expireLeftTime: string,
|
||||
}
|
||||
export enum UploadSceneType {
|
||||
KUPLOADSCENEUNKNOWN,
|
||||
KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU,
|
||||
KUPLOADSCENEFLOATWINDOWDRAG,
|
||||
KUPLOADSCENEFLOATWINDOWFILESELECTOR,
|
||||
KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTDRAG,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEAIODRAG,
|
||||
KUPLOADSCENEAIOFILESELECTOR,
|
||||
KUPLOADSCENEAIOSHORTCUTKEYCTRLCV,
|
||||
}
|
||||
export interface StartFlashTaskRequests {
|
||||
screen: number; // 1 PC-QQ
|
||||
name?: string;
|
||||
uploaders: UploaderInfo[];
|
||||
permission?: {};
|
||||
coverPath?: string;
|
||||
paths: string[]; // 文件的绝对路径,可以是文件夹
|
||||
excludePaths?: string[];
|
||||
expireLeftTime?: number, // 0
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
isNeedDelLocation: boolean,
|
||||
coverOriginalInfos?: {
|
||||
path: string,
|
||||
thumbnailPath: string,
|
||||
}[],
|
||||
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
|
||||
detectPrivacyInfoResult: {
|
||||
exists: boolean,
|
||||
allDetectResults: {};
|
||||
};
|
||||
}
|
||||
export enum BusiScene {
|
||||
KBUSISCENEINVALID,
|
||||
KBUSISCENEFLASHSCENE,
|
||||
}
|
||||
export interface FileListInfoRequests {
|
||||
seq: number, // 0
|
||||
fileSetId: string,
|
||||
isUseCache: boolean,
|
||||
sceneType: BusiScene, // 1
|
||||
reqInfos: {
|
||||
count: number, // 18 ?? 硬编码吧 不懂
|
||||
paginationInfo: {},
|
||||
parentId: string,
|
||||
reqIndexPath: string,
|
||||
reqDepth: number, // 1
|
||||
filterCondition: {
|
||||
fileCategory: number,
|
||||
filterType: number,
|
||||
}, // 0
|
||||
sortConditions: {
|
||||
sortField: number,
|
||||
sortOrder: number,
|
||||
}[],
|
||||
isNeedPhysicalInfoReady: boolean;
|
||||
}[];
|
||||
}
|
||||
export enum DownloadSceneType {
|
||||
KDOWNLOADSCENEUNKNOWN,
|
||||
KDOWNLOADSCENEARKC2C,
|
||||
KDOWNLOADSCENEARKC2CDETAILPAGE,
|
||||
KDOWNLOADSCENEARKGROUP,
|
||||
KDOWNLOADSCENEARKGROUPDETAILPAGE,
|
||||
KDOWNLOADSCENELINKC2C,
|
||||
KDOWNLOADSCENELINKGROUP,
|
||||
KDOWNLOADSCENELINKCHANNEL,
|
||||
KDOWNLOADSCENELINKTEMPCHAT,
|
||||
KDOWNLOADSCENELINKOTHERINQQ,
|
||||
KDOWNLOADSCENESCANQRCODE,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA,
|
||||
}
|
||||
export interface FlashFileSetInfo {
|
||||
fileSetId: string,
|
||||
name: string,
|
||||
namePinyin: string,
|
||||
totalFileCount: number,
|
||||
totalFileSize: number,
|
||||
permission: {},
|
||||
shareInfo: {
|
||||
shareLink: string,
|
||||
extractionCode: string,
|
||||
},
|
||||
cover: {
|
||||
id: string,
|
||||
urls: [
|
||||
{
|
||||
spec: number, // 2
|
||||
url: string;
|
||||
}
|
||||
],
|
||||
localCachePath: string;
|
||||
},
|
||||
uploaders: [
|
||||
{
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string;
|
||||
}
|
||||
],
|
||||
expireLeftTime: number,
|
||||
aiClusteringStatus: {
|
||||
firstClusteringList: [],
|
||||
shouldPull: boolean;
|
||||
},
|
||||
createTime: number,
|
||||
expireTime: number,
|
||||
firstLevelItemCount: 1,
|
||||
svrLastUpdateTimestamp: 0,
|
||||
taskId: string, // 同 fileSetId
|
||||
uploadInfo: {
|
||||
totalUploadedFileSize: number,
|
||||
successCount: number,
|
||||
failedCount: number;
|
||||
},
|
||||
downloadInfo: {
|
||||
totalDownloadedFileSize: 0,
|
||||
totalFileSize: 0,
|
||||
totalDownloadFileCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
cancelCount: 0,
|
||||
status: 0,
|
||||
curLevelDownloadedFileCount: number,
|
||||
curLevelUnDownloadedFileCount: 0;
|
||||
},
|
||||
transferType: number,
|
||||
isLocalCreate: true,
|
||||
status: number, // todo 枚举全部状态
|
||||
uploadStatus: number, // todo 同上
|
||||
uploadPauseReason: 0,
|
||||
downloadStatus: 0,
|
||||
downloadPauseReason: 0,
|
||||
saveFileSetDir: string,
|
||||
uploadSceneType: UploadSceneType,
|
||||
downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web
|
||||
retryCount: number,
|
||||
isMergeShareUpload: 0,
|
||||
isRemoveDeviceInfo: boolean,
|
||||
isRemoveLocation: boolean;
|
||||
}
|
||||
|
||||
export interface SendStatus {
|
||||
result: number,
|
||||
msg: string,
|
||||
target: {
|
||||
destType: number,
|
||||
destUid: string,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter, GroupManagementSettings } from '../types';
|
||||
|
||||
const MEMBER_INVITE_PRIVILEGE_MASK = 0x06100000;
|
||||
const MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK = 0x1;
|
||||
const MEMBER_CREATE_GROUP_FLAG = 0x8000;
|
||||
const MEMBER_TEMPORARY_SESSION_FLAG = 0x10000;
|
||||
const NEW_MEMBER_RECENT_HISTORY_FLAG = 0x4;
|
||||
|
||||
function mergeMaskedFlag (currentFlag: number, requestedFlag: number, maskValue: number): number {
|
||||
const mask = BigInt(maskValue);
|
||||
return Number((BigInt(currentFlag) & ~mask) | (BigInt(requestedFlag) & mask));
|
||||
}
|
||||
|
||||
export function applyGroupManagementSettings (
|
||||
param: GroupDetailInfoV2Param,
|
||||
settings: GroupManagementSettings
|
||||
): boolean {
|
||||
let changed = false;
|
||||
|
||||
if (settings.memberInvite !== undefined) {
|
||||
const privilegeByPolicy = {
|
||||
disabled: 0x04000000,
|
||||
require_approval: 0,
|
||||
no_approval: 0x00100000,
|
||||
no_approval_under_100: 0x02000000,
|
||||
} satisfies Record<NonNullable<GroupManagementSettings['memberInvite']>, number>;
|
||||
|
||||
param.filter.allowMemberInvite = 1;
|
||||
param.modifyInfo.allowMemberInvite = settings.memberInvite === 'disabled' ? 0 : 1;
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_INVITE_PRIVILEGE_MASK;
|
||||
param.modifyInfo.appPrivilegeFlag |= privilegeByPolicy[settings.memberInvite];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberUploadAlbum !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
if (!settings.allowMemberUploadAlbum) param.modifyInfo.appPrivilegeFlag |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberTemporarySession !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
if (!settings.allowMemberTemporarySession) param.modifyInfo.appPrivilegeFlag |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberCreateGroup !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_CREATE_GROUP_FLAG;
|
||||
if (!settings.allowMemberCreateGroup) param.modifyInfo.appPrivilegeFlag |= MEMBER_CREATE_GROUP_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.newMembersSeeRecentHistory !== undefined) {
|
||||
param.filter.groupFlagExt4 = 1;
|
||||
param.filter.groupFlagExt4Mask = 1;
|
||||
param.modifyInfo.groupFlagExt4Mask |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
if (settings.newMembersSeeRecentHistory) param.modifyInfo.groupFlagExt4 |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function createGroupManagementRequests (
|
||||
groupCode: string,
|
||||
settings: GroupManagementSettings,
|
||||
initialAppPrivilegeFlag = 0,
|
||||
initialGroupFlagExt4 = 0
|
||||
) {
|
||||
const requests: Array<{ setting: string, param: GroupDetailInfoV2Param, operationType: number; }> = [];
|
||||
const requestSettings: Array<{
|
||||
setting: string,
|
||||
value: GroupManagementSettings,
|
||||
operationType: number;
|
||||
}> = [
|
||||
{ setting: 'member_invite', value: { memberInvite: settings.memberInvite }, operationType: 0 },
|
||||
{
|
||||
setting: 'new_members_see_recent_history',
|
||||
value: { newMembersSeeRecentHistory: settings.newMembersSeeRecentHistory },
|
||||
operationType: 0,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_upload_album',
|
||||
value: { allowMemberUploadAlbum: settings.allowMemberUploadAlbum },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_temporary_session',
|
||||
value: { allowMemberTemporarySession: settings.allowMemberTemporarySession },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_create_group',
|
||||
value: { allowMemberCreateGroup: settings.allowMemberCreateGroup },
|
||||
operationType: 8,
|
||||
},
|
||||
];
|
||||
|
||||
let currentAppPrivilegeFlag = initialAppPrivilegeFlag;
|
||||
let currentGroupFlagExt4 = initialGroupFlagExt4;
|
||||
for (const requestSetting of requestSettings) {
|
||||
const param = createGroupDetailInfoV2Param(groupCode);
|
||||
if (applyGroupManagementSettings(param, requestSetting.value)) {
|
||||
if (param.filter.appPrivilegeFlag) {
|
||||
const nextAppPrivilegeFlag = mergeMaskedFlag(
|
||||
currentAppPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeMask
|
||||
);
|
||||
if (requestSetting.operationType === 8 && nextAppPrivilegeFlag === currentAppPrivilegeFlag) continue;
|
||||
currentAppPrivilegeFlag = nextAppPrivilegeFlag;
|
||||
// Linux NTQQ's wrapper builds this field from the current complete privilege value.
|
||||
// Supplying only the targeted bit makes zero-valued clears get acknowledged but ignored.
|
||||
param.modifyInfo.appPrivilegeFlag = currentAppPrivilegeFlag;
|
||||
}
|
||||
if (param.filter.groupFlagExt4) {
|
||||
const nextGroupFlagExt4 = mergeMaskedFlag(
|
||||
currentGroupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4Mask
|
||||
);
|
||||
if (nextGroupFlagExt4 === currentGroupFlagExt4) continue;
|
||||
currentGroupFlagExt4 = nextGroupFlagExt4;
|
||||
param.modifyInfo.groupFlagExt4 = currentGroupFlagExt4;
|
||||
}
|
||||
requests.push({ setting: requestSetting.setting, param, operationType: requestSetting.operationType });
|
||||
}
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function createGroupDetailInfoV2Param (group_code: string): GroupDetailInfoV2Param {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
filter:
|
||||
{
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: 0,
|
||||
classExt: 0,
|
||||
classText: 0,
|
||||
fingerMemo: 0,
|
||||
richFingerMemo: 0,
|
||||
tagRecord: 0,
|
||||
groupGeoInfo:
|
||||
{
|
||||
ownerUid: 0,
|
||||
setTime: 0,
|
||||
cityId: 0,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
geoContent: 0,
|
||||
poiId: 0,
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: 0,
|
||||
groupAioSkinUrl: 0,
|
||||
groupBoardSkinUrl: 0,
|
||||
groupCoverSkinUrl: 0,
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: 0,
|
||||
groupNewGuideLines:
|
||||
{
|
||||
enabled: 0,
|
||||
content: 0,
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly:
|
||||
{
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: 0,
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: 0,
|
||||
groupAnswer: 0,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: 0,
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: 0,
|
||||
grade: 0,
|
||||
school: 0,
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: 0,
|
||||
rptPrefix: 0,
|
||||
},
|
||||
allianceId: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
modifyInfo: {
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: '',
|
||||
classExt: 0,
|
||||
classText: '',
|
||||
fingerMemo: '',
|
||||
richFingerMemo: '',
|
||||
tagRecord: [],
|
||||
groupGeoInfo: {
|
||||
ownerUid: '',
|
||||
SetTime: 0,
|
||||
CityId: 0,
|
||||
Longitude: '',
|
||||
Latitude: '',
|
||||
GeoContent: '',
|
||||
poiId: '',
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: '',
|
||||
groupAioSkinUrl: '',
|
||||
groupBoardSkinUrl: '',
|
||||
groupCoverSkinUrl: '',
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: '',
|
||||
groupNewGuideLines: {
|
||||
enabled: false,
|
||||
content: '',
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly: {
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: '',
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: '',
|
||||
groupAnswer: '',
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: '',
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: '',
|
||||
grade: 0,
|
||||
school: '',
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: '',
|
||||
rptPrefix: [],
|
||||
},
|
||||
allianceId: '',
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtInfo (group_code: string): GroupExtInfo {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
resultCode: 0,
|
||||
extInfo: {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: '',
|
||||
lightCharNum: 0,
|
||||
luckyWord: '',
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: '',
|
||||
groupOwnerId: {
|
||||
memberUin: '',
|
||||
memberUid: '',
|
||||
memberQid: '',
|
||||
},
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: '',
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: '',
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: '',
|
||||
groupBindGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
viewedMsgDisappearTime: '',
|
||||
groupExtFlameData: {
|
||||
switchState: 0,
|
||||
state: 0,
|
||||
dayNums: [],
|
||||
version: 0,
|
||||
updateTime: '',
|
||||
isDisplayDayNum: false,
|
||||
},
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: '',
|
||||
groupExcludeGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: '',
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtFilter (): GroupExtFilter {
|
||||
return {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: 0,
|
||||
lightCharNum: 0,
|
||||
luckyWord: 0,
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: 0,
|
||||
groupOwnerId: 0,
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: 0,
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: 0,
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupBindGuildIds: 0,
|
||||
viewedMsgDisappearTime: 0,
|
||||
groupExtFlameData: 0,
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: 0,
|
||||
groupExcludeGuildIds: 0,
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: 0,
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './group';
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* SnowCat · QQ空间好友动态流(feeds3_html_more)—— 纯数据构造与解析
|
||||
*
|
||||
* SnowLuma v2.2.1 (qzone-feed-cursor.ts + qzone-feed-content.ts + qzone.ts feeds 部分) 移植改造:
|
||||
* - cursor 状态编解码(base64url,绑 uin 防串号)
|
||||
* - HTML feed 内容解析(htmlparser2,不执行远程内容)
|
||||
* - feeds 响应映射
|
||||
*/
|
||||
|
||||
// ─────────────── cursor 状态机 ───────────────
|
||||
|
||||
const numericFields = [
|
||||
'begintime',
|
||||
'dayspac',
|
||||
'pagenum',
|
||||
'aisortEndTime',
|
||||
'aisortOffset',
|
||||
'aisortBeginTime',
|
||||
'aisortNextTime',
|
||||
'icServerTime',
|
||||
] as const;
|
||||
const stringFields = ['externparam', 'daylist', 'uinlist'] as const;
|
||||
const fields = [...numericFields, ...stringFields];
|
||||
type StateField = (typeof fields)[number];
|
||||
export type QzoneFeedState = Record<StateField, string>;
|
||||
|
||||
export function initialQzoneFeedState(): QzoneFeedState {
|
||||
return {
|
||||
begintime: '0',
|
||||
dayspac: '0',
|
||||
pagenum: '1',
|
||||
aisortEndTime: '0',
|
||||
aisortOffset: '0',
|
||||
aisortBeginTime: '0',
|
||||
aisortNextTime: '0',
|
||||
icServerTime: '0',
|
||||
externparam: '',
|
||||
daylist: '',
|
||||
uinlist: '',
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 承接 web 客户端用的字段,含不透明服务端状态。 */
|
||||
export function nextQzoneFeedState(
|
||||
main: Record<string, unknown>,
|
||||
previous: QzoneFeedState,
|
||||
): QzoneFeedState {
|
||||
const next = { ...previous };
|
||||
for (const field of numericFields) {
|
||||
const value = main[field];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (
|
||||
(typeof value !== 'number' && typeof value !== 'string') ||
|
||||
!/^\d+$/.test(String(value)) ||
|
||||
!Number.isSafeInteger(Number(value)) ||
|
||||
(field === 'pagenum' && Number(value) < 1)
|
||||
) {
|
||||
throw new Error(`空间好友动态分页字段异常: ${field}`);
|
||||
}
|
||||
next[field] = String(Number(value));
|
||||
}
|
||||
for (const field of stringFields) {
|
||||
const value = main[field];
|
||||
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
|
||||
if (typeof value !== 'string' || value.length > 32_768) {
|
||||
throw new Error(`空间好友动态分页字段异常: ${field}`);
|
||||
}
|
||||
next[field] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 服务端时钟单独不算翻页进度。 */
|
||||
export function qzoneFeedPosition(state: QzoneFeedState): string {
|
||||
return JSON.stringify(
|
||||
fields.filter((field) => field !== 'icServerTime').map((field) => state[field]),
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeQzoneFeedCursor(uin: string, page: number, state: QzoneFeedState): string {
|
||||
if (!Number.isSafeInteger(page) || page < 2) {
|
||||
throw new Error('空间好友动态分页页码超出范围');
|
||||
}
|
||||
const cursor = Buffer.from(JSON.stringify({ version: 1, uin, page, state })).toString(
|
||||
'base64url',
|
||||
);
|
||||
if (cursor.length > 65_536) {
|
||||
throw new Error('空间好友动态分页游标过大');
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
export function decodeQzoneFeedCursor(
|
||||
cursor: string,
|
||||
uin: string,
|
||||
): { page: number; state: QzoneFeedState } {
|
||||
if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
|
||||
throw new Error('无效的空间好友动态 cursor');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
const bytes = Buffer.from(cursor, 'base64url');
|
||||
if (bytes.toString('base64url') !== cursor) {
|
||||
throw new Error('non-canonical encoding');
|
||||
}
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new Error('无效的空间好友动态 cursor');
|
||||
}
|
||||
if (
|
||||
!record(value) ||
|
||||
value['version'] !== 1 ||
|
||||
value['uin'] !== uin ||
|
||||
typeof value['page'] !== 'number' ||
|
||||
!Number.isSafeInteger(value['page']) ||
|
||||
value['page'] < 2 ||
|
||||
!record(value['state'])
|
||||
) {
|
||||
throw new Error('空间好友动态 cursor 无效或不属于当前账号');
|
||||
}
|
||||
const rawState = value['state'];
|
||||
if (
|
||||
Object.keys(rawState).length !== fields.length ||
|
||||
!fields.every((field) => typeof rawState[field] === 'string')
|
||||
) {
|
||||
throw new Error('无效的空间好友动态 cursor 状态');
|
||||
}
|
||||
const state = nextQzoneFeedState(rawState, initialQzoneFeedState());
|
||||
if (fields.some((field) => state[field] !== rawState[field])) {
|
||||
throw new Error('无效的空间好友动态 cursor 状态');
|
||||
}
|
||||
return { page: value['page'], state };
|
||||
}
|
||||
|
||||
// ─────────────── HTML feed 内容解析(htmlparser2,数据级不执行) ───────────────
|
||||
|
||||
import { DomUtils, parseDocument } from 'htmlparser2';
|
||||
|
||||
type Element = ReturnType<typeof DomUtils.findAll>[number];
|
||||
type Node = ReturnType<typeof parseDocument>['children'][number];
|
||||
|
||||
export interface QzoneFeedContent {
|
||||
tid: string | null;
|
||||
content: string | null;
|
||||
content_complete: boolean | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
comment_count: number | null;
|
||||
}
|
||||
|
||||
function hasClass(node: Element, name: string): boolean {
|
||||
return (node.attribs['class'] ?? '').split(/\s+/).includes(name);
|
||||
}
|
||||
|
||||
function isContentNode(node: Element, root: Element): boolean {
|
||||
for (let parent = node.parent; parent && parent !== root; parent = parent.parent) {
|
||||
if (
|
||||
'attribs' in parent &&
|
||||
(hasClass(parent, 'mod-comments') ||
|
||||
hasClass(parent, 'comments-list') ||
|
||||
hasClass(parent, 'f-item-passive') ||
|
||||
hasClass(parent, 'f-ct-video'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function text(node: Element): string {
|
||||
const chunks: string[] = [];
|
||||
const stack: (Node | string)[] = [...node.children].reverse();
|
||||
while (stack.length) {
|
||||
const next = stack.pop()!;
|
||||
if (typeof next === 'string') {
|
||||
chunks.push(next);
|
||||
} else if (next.type === 'text') {
|
||||
chunks.push(next.data.replace(/\s+/g, ' '));
|
||||
} else if (next.type === 'tag') {
|
||||
if (next.attribs['data-cmd'] === 'qz_toggle' || hasClass(next, 'load_img')) {
|
||||
continue;
|
||||
}
|
||||
if (next.name === 'br') {
|
||||
chunks.push('\n');
|
||||
} else if (next.name === 'img') {
|
||||
chunks.push(next.attribs['alt'] ?? '');
|
||||
} else {
|
||||
if (['div', 'p', 'li'].includes(next.name)) {
|
||||
chunks.push('\n');
|
||||
stack.push('\n');
|
||||
}
|
||||
for (let i = next.children.length - 1; i >= 0; i--) {
|
||||
const child = next.children[i];
|
||||
if (child !== undefined) stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
.join('')
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function count(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && (typeof value !== 'string' || !/^\d+$/.test(value))) {
|
||||
return null;
|
||||
}
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function imageUrl(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只抽帖子本身的内容;评论/头像/视频封面另算。 */
|
||||
export function parseQzoneFeedContent(
|
||||
html: string,
|
||||
key: string,
|
||||
uin: number,
|
||||
appid: number,
|
||||
likeCount?: unknown,
|
||||
commentCount?: unknown,
|
||||
): QzoneFeedContent {
|
||||
const doc = parseDocument(html);
|
||||
const elements = DomUtils.findAll(() => true, doc.children);
|
||||
const body = elements.find(
|
||||
(node) => hasClass(node, 'f-item') && node.attribs['data-key'] === key,
|
||||
);
|
||||
const result: QzoneFeedContent = {
|
||||
tid: null,
|
||||
content: null,
|
||||
content_complete: null,
|
||||
images: null,
|
||||
like_count: count(likeCount),
|
||||
comment_count: count(commentCount),
|
||||
};
|
||||
if (!body) return result;
|
||||
|
||||
let root = body;
|
||||
for (let parent = body.parent; parent && 'attribs' in parent; parent = parent.parent) {
|
||||
if (hasClass(parent, 'f-single')) {
|
||||
root = parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyElements = DomUtils.findAll((node) => isContentNode(node, body), body.children);
|
||||
const metadata = bodyElements.find(
|
||||
(node) =>
|
||||
node.attribs['name'] === 'feed_data' &&
|
||||
node.attribs['data-uin'] === String(uin) &&
|
||||
node.attribs['data-fkey'] === key,
|
||||
);
|
||||
|
||||
if (appid === 311 && metadata?.attribs['data-tid']) {
|
||||
result.tid = metadata.attribs['data-tid'];
|
||||
}
|
||||
|
||||
const contentNodes = bodyElements.filter((node) => hasClass(node, 'f-info'));
|
||||
const complete = contentNodes.find(
|
||||
(node) => hasClass(node, 'qz_info_complete') && text(node) !== '',
|
||||
);
|
||||
const content = complete ?? contentNodes.find((node) => !hasClass(node, 'qz_info_complete'));
|
||||
|
||||
if (content) {
|
||||
result.content = text(content);
|
||||
result.content_complete =
|
||||
!!complete ||
|
||||
!DomUtils.findAll((node) => node.attribs['data-cmd'] === 'qz_toggle', content.children)
|
||||
.length;
|
||||
}
|
||||
|
||||
const images = new Set<string>();
|
||||
for (const item of bodyElements.filter((node) => hasClass(node, 'img-item'))) {
|
||||
if (item.attribs['data-v_itemid'] || item.attribs['data-videotype']) {
|
||||
continue;
|
||||
}
|
||||
const full = imageUrl(item.attribs['data-originurl'] ?? item.attribs['data-url3']);
|
||||
if (full) {
|
||||
images.add(full);
|
||||
continue;
|
||||
}
|
||||
for (const image of DomUtils.findAll((node) => node.name === 'img', item.children)) {
|
||||
const url = imageUrl(image.attribs['truesrc'] ?? image.attribs['src']);
|
||||
if (url) {
|
||||
images.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.images = [...images];
|
||||
const like = DomUtils.findAll(
|
||||
(node) => hasClass(node, 'qz_like_btn_v3') && isContentNode(node, root),
|
||||
root.children,
|
||||
)[0];
|
||||
result.like_count ??= count(like?.attribs['data-likecnt']);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────── feeds 响应 shape 与映射 ───────────────
|
||||
|
||||
export interface RawFeedsItem {
|
||||
uin?: number | string;
|
||||
nickname?: string;
|
||||
abstime?: number | string;
|
||||
appid?: number | string;
|
||||
key?: string;
|
||||
feedskey?: string;
|
||||
html?: string;
|
||||
likecnt?: unknown;
|
||||
commentcnt?: unknown;
|
||||
}
|
||||
|
||||
interface RawFeedsMain {
|
||||
hasMoreFeeds?: boolean | number | string;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RawFeedsResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
main?: RawFeedsMain | null;
|
||||
hasmore?: boolean | number | string;
|
||||
data?: (RawFeedsItem | null | undefined)[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface QzoneFeed {
|
||||
uin: number;
|
||||
nickname: string;
|
||||
time: number;
|
||||
appid: number;
|
||||
key: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface QzoneFeedWithContent extends QzoneFeed, QzoneFeedContent {}
|
||||
|
||||
export interface QzoneFeedsPage {
|
||||
feeds: QzoneFeedWithContent[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
function feedHasMore(data: RawFeedsResponse): boolean {
|
||||
const value = data.data?.main === undefined ? data.data?.hasmore : data.data?.main?.hasMoreFeeds;
|
||||
if (value === true || value === 1 || value === '1') return true;
|
||||
if (value === false || value === 0 || value === '0') return false;
|
||||
throw new Error('空间好友动态缺少有效的分页结束标志');
|
||||
}
|
||||
|
||||
/** 纯映射;翻页续传由 getQzoneFeeds 接线。 */
|
||||
export function mapQzoneFeeds(data: RawFeedsResponse): QzoneFeedsPage {
|
||||
if (!Array.isArray(data.data?.data)) {
|
||||
throw new Error('无法获取空间好友动态(响应结构异常)');
|
||||
}
|
||||
// web 载荷用 null/undefined 填充列表;这些不是 feed 条目
|
||||
const list = data.data.data.filter((item) => item != null);
|
||||
return {
|
||||
feeds: list.map((item) => {
|
||||
if (typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new Error('空间好友动态条目结构异常');
|
||||
}
|
||||
const feed: QzoneFeed = {
|
||||
uin: Number(item['uin'] ?? 0),
|
||||
nickname: String(item['nickname'] ?? ''),
|
||||
time: Number(item['abstime'] ?? 0),
|
||||
appid: Number(item['appid'] ?? 0),
|
||||
key: String(item['key'] ?? item['feedskey'] ?? ''),
|
||||
html: String(item['html'] ?? ''),
|
||||
};
|
||||
if (
|
||||
![feed.uin, feed.time, feed.appid].every(
|
||||
(n) => Number.isSafeInteger(n) && n >= 0,
|
||||
) ||
|
||||
typeof feed.nickname !== 'string' ||
|
||||
typeof feed.html !== 'string'
|
||||
) {
|
||||
throw new Error('空间好友动态条目字段异常');
|
||||
}
|
||||
return {
|
||||
...feed,
|
||||
...parseQzoneFeedContent(
|
||||
feed.html,
|
||||
feed.key,
|
||||
feed.uin,
|
||||
feed.appid,
|
||||
item['likecnt'],
|
||||
item['commentcnt'],
|
||||
),
|
||||
};
|
||||
}),
|
||||
has_more: feedHasMore(data),
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* SnowCat · QQ空间(Qzone)个人空间 Web 线 —— 纯数据构造与解析
|
||||
*
|
||||
* 从 SnowLuma v2.2.1 (packages/protocol/src/web/qzone.ts) 移植改造:
|
||||
* - SnowCat 已有 buildQzoneUploadImageBody/buildQzonePublishBody/buildQzoneDeleteBody
|
||||
* (data/qzone.ts 原件保留),本文件只补缺的:读列表/详情/点赞/评论/回复/资料/拉黑/权限。
|
||||
* - 不发起任何网络请求,方便单独测试(对齐 data/qzone.ts 头注)。
|
||||
* - 统一走 NapCat 的 cookie 串(getQzoneAuth 产出的 p_uin/p_skey/skey/uin 形态),
|
||||
* 不用 SnowLuma 的 cookieObject Record 形态——但保留 tolerant 解析器。
|
||||
*/
|
||||
|
||||
// ─────────────── tolerant JSON/JSONP 解析(SnowLuma 原样移植) ───────────────
|
||||
|
||||
/**
|
||||
* 解析 Qzone CGI body:可能是裸 JSON,也可能是 JSONP 回调包裹
|
||||
* (`_Callback({...});` / `frameElement.callback({...});` / `back({...})`)。
|
||||
* 分层 fallback:直接 JSON.parse {} 切片 → back({...}) → 末个 back(...) 提取。
|
||||
* 无对象体时抛错(如 HTML 错误页)。
|
||||
*/
|
||||
export function parseQzoneJson<T>(text: string): T {
|
||||
const s = text.trim();
|
||||
|
||||
// 1st: 直接 JSON.parse {} 切片
|
||||
try {
|
||||
const start = s.indexOf('{');
|
||||
const end = s.lastIndexOf('}');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(s.slice(start, end + 1)) as T;
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
// 2nd: back({...}) 整体
|
||||
try {
|
||||
const start = s.indexOf('back({');
|
||||
const end = s.lastIndexOf('})');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(s.slice(start + 5, end + 1)) as T;
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
// 3rd: 找最后一个 back(...),取内部 {}
|
||||
const backStart = s.lastIndexOf('back(');
|
||||
if (backStart === -1) {
|
||||
throw new Error('invalid response from qzone api');
|
||||
}
|
||||
const callEnd = s.indexOf(');', backStart);
|
||||
if (callEnd === -1) {
|
||||
throw new Error('invalid response from qzone api');
|
||||
}
|
||||
const inner = s.slice(backStart + 5, callEnd).trim();
|
||||
const braceStart = inner.indexOf('{');
|
||||
const braceEnd = inner.lastIndexOf('}');
|
||||
if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) {
|
||||
throw new Error('invalid response from qzone api');
|
||||
}
|
||||
return JSON.parse(inner.slice(braceStart, braceEnd + 1)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 feeds3_html_more 的 JS 对象字面量载荷(不执行)。
|
||||
* `_preloadCallback({ … })` 的 data 用无引号键/单引号串/\xNN 转义/字面 undefined
|
||||
* ——给浏览器回调 eval 用的,JSON.parse 咽不下。替代 eval(vm 沙箱不是安全边界),
|
||||
* 递归下降把字面量当数据解析。`__proto__` 键丢弃(防原型污染)。
|
||||
* SnowLuma 原样移植。
|
||||
*/
|
||||
export function parseJsLiteral(src: string): unknown {
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isWs = (c: string): boolean =>
|
||||
c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v';
|
||||
|
||||
const skipWs = (): void => {
|
||||
while (i < n && isWs(src[i]!)) i++;
|
||||
};
|
||||
|
||||
function parseString(quote: string): string {
|
||||
i++; // 开引号
|
||||
let out = '';
|
||||
while (i < n) {
|
||||
const c = src[i]!;
|
||||
if (c === '\\') {
|
||||
const e = src[i + 1];
|
||||
if (e === 'x') {
|
||||
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 4), 16));
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
if (e === 'u') {
|
||||
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
const simple: Record<string, string> = {
|
||||
n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0',
|
||||
};
|
||||
out += e !== undefined ? (simple[e] ?? e) : '';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) {
|
||||
i++;
|
||||
return out;
|
||||
}
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
throw new Error('unterminated string in qzone feeds payload');
|
||||
}
|
||||
|
||||
function parseKey(): string {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === '"' || c === "'") return parseString(c);
|
||||
let s = '';
|
||||
while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) {
|
||||
s += src[i];
|
||||
i++;
|
||||
}
|
||||
if (!s) throw new Error('expected object key in qzone feeds payload');
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseObject(): Record<string, unknown> {
|
||||
i++; // {
|
||||
const obj: Record<string, unknown> = {};
|
||||
skipWs();
|
||||
if (src[i] === '}') { i++; return obj; }
|
||||
for (;;) {
|
||||
const key = parseKey();
|
||||
skipWs();
|
||||
if (src[i] !== ':') throw new Error('expected ":" in qzone feeds payload');
|
||||
i++;
|
||||
const value = parseValue();
|
||||
if (key !== '__proto__') obj[key] = value;
|
||||
skipWs();
|
||||
const ch = src[i];
|
||||
if (ch === ',') {
|
||||
i++;
|
||||
skipWs();
|
||||
if (src[i] === '}') { i++; return obj; }
|
||||
continue;
|
||||
}
|
||||
if (ch === '}') { i++; return obj; }
|
||||
throw new Error('expected "," or "}" in qzone feeds payload');
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray(): unknown[] {
|
||||
i++; // [
|
||||
const arr: unknown[] = [];
|
||||
skipWs();
|
||||
if (src[i] === ']') { i++; return arr; }
|
||||
for (;;) {
|
||||
arr.push(parseValue());
|
||||
skipWs();
|
||||
const ch = src[i];
|
||||
if (ch === ',') {
|
||||
i++;
|
||||
skipWs();
|
||||
if (src[i] === ']') { i++; return arr; }
|
||||
continue;
|
||||
}
|
||||
if (ch === ']') { i++; return arr; }
|
||||
throw new Error('expected "," or "]" in qzone feeds payload');
|
||||
}
|
||||
}
|
||||
|
||||
function parseValue(): unknown {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === undefined) throw new Error('unexpected end of qzone feeds payload');
|
||||
if (c === '{') return parseObject();
|
||||
if (c === '[') return parseArray();
|
||||
if (c === '"' || c === "'") return parseString(c);
|
||||
let token = '';
|
||||
while (i < n && !/[,}\]:\s]/.test(src[i]!)) {
|
||||
token += src[i];
|
||||
i++;
|
||||
}
|
||||
if (token === 'true') return true;
|
||||
if (token === 'false') return false;
|
||||
if (token === 'null') return null;
|
||||
if (token === 'undefined') return undefined;
|
||||
if (token !== '') {
|
||||
const num = Number(token);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
}
|
||||
throw new Error('unexpected token in qzone feeds payload: ' + token.slice(0, 20));
|
||||
}
|
||||
|
||||
return parseValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Qzone feeds JSONP body 回调实参对象,按数据解析(不执行)。
|
||||
* 从首个 `{` 切片解析一个平衡值;尾部 `);` 忽略。非对象体抛错。
|
||||
*/
|
||||
export function parseQzoneCallback<T>(text: string): T {
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) {
|
||||
throw new Error('invalid feeds response from qzone api');
|
||||
}
|
||||
const value = parseJsLiteral(text.slice(start));
|
||||
if (value === null || typeof value !== 'object') {
|
||||
throw new Error('invalid feeds response from qzone api');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
// ─────────────── 原始响应 shape(taotao emotion_cgi_msglist_v6) ───────────────
|
||||
|
||||
interface RawPic {
|
||||
url1?: string;
|
||||
url2?: string;
|
||||
url3?: string;
|
||||
smallurl?: string;
|
||||
}
|
||||
|
||||
interface RawEmotion {
|
||||
tid?: string;
|
||||
content?: string;
|
||||
created_time?: number;
|
||||
cmtnum?: number;
|
||||
secret?: number;
|
||||
pic?: RawPic[];
|
||||
}
|
||||
|
||||
export interface RawMsgListResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
total?: number;
|
||||
msglist?: RawEmotion[] | null;
|
||||
}
|
||||
|
||||
// ─────────────── OneBot 面向 shape ───────────────
|
||||
|
||||
/** 一条说说,规范化后对 OneBot 友好的形态。 */
|
||||
export interface QzoneEmotion {
|
||||
tid: string;
|
||||
content: string;
|
||||
/** 发帖 unix 秒 */
|
||||
time: number;
|
||||
comment_num: number;
|
||||
/** 仅自己可见标记 */
|
||||
is_private: boolean;
|
||||
/** 每张图取最大可用的 URL */
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface QzoneMsgListResult {
|
||||
/** 该账号说说总数(非本页数量) */
|
||||
total: number;
|
||||
msglist: QzoneEmotion[];
|
||||
}
|
||||
|
||||
export interface QzoneProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface QzoneProfileUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface QzoneCommentReply {
|
||||
uin: number;
|
||||
name: string;
|
||||
/** unix 秒 */
|
||||
time: number;
|
||||
content: string;
|
||||
/** 在所属说说(或父评论)内唯一 */
|
||||
comment_id: string;
|
||||
}
|
||||
|
||||
export interface QzoneComment extends QzoneCommentReply {
|
||||
/** Qzone 内联附带的回复;可能不含全部回复 */
|
||||
replies: QzoneCommentReply[];
|
||||
}
|
||||
|
||||
export interface QzoneMsgDetailResult {
|
||||
tid: string;
|
||||
content: string;
|
||||
/** 服务端总数,非本页评论数 */
|
||||
comment_num: number;
|
||||
comments: QzoneComment[];
|
||||
}
|
||||
|
||||
function pickPicUrl(pic: RawPic): string | undefined {
|
||||
return pic.url1 ?? pic.url2 ?? pic.url3 ?? pic.smallurl;
|
||||
}
|
||||
|
||||
/** 纯映射:msglist_v6 → 规范化列表。 */
|
||||
export function mapQzoneMsgList(data: RawMsgListResponse): QzoneMsgListResult {
|
||||
const list = data.msglist ?? [];
|
||||
return {
|
||||
total: Number(data.total ?? list.length),
|
||||
msglist: list.map((e) => ({
|
||||
tid: String(e.tid ?? ''),
|
||||
content: e.content ?? '',
|
||||
time: Number(e.created_time ?? 0),
|
||||
comment_num: Number(e.cmtnum ?? 0),
|
||||
is_private: Number(e.secret ?? 0) !== 0,
|
||||
images: (e.pic ?? []).map(pickPicUrl).filter((u): u is string => !!u),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────── 说说列表路由(双路防 -10000 限频) ───────────────
|
||||
|
||||
export interface QzoneRoute {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 主路(legacy):h5.qzone.qq.com → taotao.qzone.qq.com。 */
|
||||
export function legacyMsgListRoute(
|
||||
cookie: string,
|
||||
targetUin: string,
|
||||
pos: number,
|
||||
num: number,
|
||||
bkn: string,
|
||||
): QzoneRoute {
|
||||
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
|
||||
{
|
||||
uin: targetUin,
|
||||
ftype: '0',
|
||||
sort: '0',
|
||||
pos: String(pos),
|
||||
num: String(num),
|
||||
replynum: '100',
|
||||
g_tk: bkn,
|
||||
callback: '_preloadCallback',
|
||||
code_version: '1',
|
||||
format: 'jsonp',
|
||||
need_private_comment: '1',
|
||||
},
|
||||
).toString()}`;
|
||||
return { url, headers: { Cookie: cookie } };
|
||||
}
|
||||
|
||||
/** 备路:user.qzone.qq.com → taotao.qq.com(注意域不同)。实测不吃 legacy 路的 -10000 限频。 */
|
||||
export function userMsgListRoute(
|
||||
cookie: string,
|
||||
targetUin: string,
|
||||
pos: number,
|
||||
num: number,
|
||||
bkn: string,
|
||||
): QzoneRoute {
|
||||
const url = `https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
|
||||
{
|
||||
uin: targetUin,
|
||||
ftype: '0',
|
||||
sort: '0',
|
||||
pos: String(pos),
|
||||
num: String(num),
|
||||
g_tk: bkn,
|
||||
code_version: '1',
|
||||
format: 'json',
|
||||
},
|
||||
).toString()}`;
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Referer: `https://user.qzone.qq.com/${targetUin}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────── 权限/目标校验 ───────────────
|
||||
|
||||
export const QZONE_UGC_RIGHTS = new Set<number>([1, 4, 16, 64, 128]);
|
||||
|
||||
export function normalizeQzoneUgcRight(ugcRight: number): number {
|
||||
if (!QZONE_UGC_RIGHTS.has(ugcRight)) {
|
||||
throw new Error('ugc_right must be one of 1, 4, 16, 64, 128');
|
||||
}
|
||||
return ugcRight;
|
||||
}
|
||||
|
||||
export function normalizeTargetUins(targetUins?: string | number[]): string {
|
||||
const raw = Array.isArray(targetUins)
|
||||
? targetUins.join('|')
|
||||
: (targetUins ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const seen = new Set<string>();
|
||||
for (const part of raw.split('|')) {
|
||||
const uin = part.trim();
|
||||
if (!uin) continue;
|
||||
if (!/^\d+$/.test(uin)) {
|
||||
throw new Error('target_uins must contain QQ numbers separated by |');
|
||||
}
|
||||
seen.add(uin);
|
||||
}
|
||||
return [...seen].join('|');
|
||||
}
|
||||
|
||||
// ─────────────── 发说说 body(SnowCat 已有 buildQzonePublishBody,此处为富参版) ───────────────
|
||||
|
||||
export interface RawPublishResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
// publish_v6 成功包络把新 feed id 命名为 t1_tid、时间 t1_time(字符串)。
|
||||
// tid/now 是防御性 fallback,t1_tid 才是主名。
|
||||
t1_tid?: string;
|
||||
t1_time?: string;
|
||||
tid?: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface QzonePublishResult {
|
||||
tid: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** 解析发说说响应,提取 tid+time。code!=0 或缺 tid 都抛错。 */
|
||||
export function parseQzonePublishFull(text: string): QzonePublishResult {
|
||||
const data = parseQzoneJson<RawPublishResponse>(text);
|
||||
if (typeof data.code === 'number' && data.code !== 0) {
|
||||
throw new Error(`qzone publish failed: code=${data.code} ${data.message ?? ''}`.trim());
|
||||
}
|
||||
const tid = data.t1_tid ?? data.tid;
|
||||
if (!tid) {
|
||||
throw new Error('发表说说失败:响应缺少 tid');
|
||||
}
|
||||
return { tid: String(tid), time: Number(data.t1_time ?? data.now ?? 0) };
|
||||
}
|
||||
|
||||
// ─────────────── 点赞/取消赞(internal_dolike_app) ───────────────
|
||||
|
||||
export interface RawLikeResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 构造点赞/取消赞表单体。unikey/curkey 定位说说(mood, appid 311),fid=tid。 */
|
||||
export function buildQzoneLikeBody(
|
||||
opUin: string,
|
||||
targetUin: string,
|
||||
tid: string,
|
||||
abstime = 0,
|
||||
): string {
|
||||
const unikey = `http://user.qzone.qq.com/${targetUin}/mood/${tid}`;
|
||||
return new URLSearchParams({
|
||||
qzreferrer: `https://user.qzone.qq.com/${opUin}`,
|
||||
opuin: opUin,
|
||||
unikey,
|
||||
curkey: unikey,
|
||||
appid: '311',
|
||||
typeid: '0',
|
||||
abstime: String(abstime),
|
||||
fid: tid,
|
||||
from: '1',
|
||||
active: '0',
|
||||
fupdate: '1',
|
||||
format: 'json',
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** 解析点赞响应:非零 code/subcode 抛错。 */
|
||||
export function parseQzoneLikeResponse(text: string, verb: string): void {
|
||||
const data = parseQzoneJson<RawLikeResponse>(text);
|
||||
if (typeof data.code === 'number' && data.code !== 0) {
|
||||
throw new Error(`qzone ${verb} failed: code=${data.code} ${data.message ?? ''}`.trim());
|
||||
}
|
||||
if (typeof data.subcode === 'number' && data.subcode !== 0) {
|
||||
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── 评论说说(emotion_cgi_re_feeds) ───────────────
|
||||
|
||||
interface RawCommentResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
commentid?: string | number;
|
||||
commentId?: string | number;
|
||||
}
|
||||
|
||||
export interface QzoneCommentResult {
|
||||
/** 新评论 id(响应缺失时为 ''——code 0 不算失败) */
|
||||
comment_id: string;
|
||||
}
|
||||
|
||||
export interface QzoneReplyTarget {
|
||||
/** 顶层评论 id(即便回复的是它的楼中楼) */
|
||||
commentId: string;
|
||||
commentUin: string;
|
||||
/** 可选:楼中楼里点名的作者 */
|
||||
replyTo?: { uin: string; name: string };
|
||||
}
|
||||
|
||||
export interface QzoneReplyResult extends QzoneCommentResult {
|
||||
parent_comment_id: string;
|
||||
parent_comment_uin: number;
|
||||
}
|
||||
|
||||
/** 构造评论说说表单体。topicId 形如 `<hostUin>_<tid>__1`(__1 后缀为社区实现主流形态)。 */
|
||||
export function buildQzoneCommentBody(
|
||||
selfUin: string,
|
||||
hostUin: string,
|
||||
tid: string,
|
||||
content: string,
|
||||
richType?: number,
|
||||
richval?: string,
|
||||
): string {
|
||||
return new URLSearchParams({
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
hostUin,
|
||||
format: 'json',
|
||||
ref: 'feeds',
|
||||
topicId: `${hostUin}_${tid}__1`,
|
||||
feedsType: '100',
|
||||
private: '0',
|
||||
paramstr: '1',
|
||||
richtype: richType !== undefined ? String(richType) : '',
|
||||
richval: richval ?? '',
|
||||
isSignIn: '',
|
||||
uin: selfUin,
|
||||
content,
|
||||
plat: 'qzone',
|
||||
source: 'ic',
|
||||
platformid: '52',
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** 解析评论响应。 */
|
||||
export function parseQzoneCommentResponse(text: string, hostUin: string, tid: string): QzoneCommentResult {
|
||||
const data = parseQzoneJson<RawCommentResponse>(text);
|
||||
if (typeof data.code === 'number' && data.code !== 0) {
|
||||
throw new Error(`qzone comment failed: code=${data.code} ${data.message ?? ''}`.trim());
|
||||
}
|
||||
if (typeof data.subcode === 'number' && data.subcode !== 0) {
|
||||
throw new Error(`qzone comment failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
|
||||
}
|
||||
const commentId = data.commentid ?? data.commentId;
|
||||
void hostUin; void tid; // 日志参数留位
|
||||
return { comment_id: commentId !== undefined ? String(commentId) : '' };
|
||||
}
|
||||
|
||||
/** 构造楼中楼回复表单体(独立端点 emotion_cgi_addreply_ugc)。 */
|
||||
export function buildQzoneReplyBody(
|
||||
selfUin: string,
|
||||
hostUin: string,
|
||||
tid: string,
|
||||
content: string,
|
||||
target: QzoneReplyTarget,
|
||||
): string {
|
||||
let replyContent = content;
|
||||
if (target.replyTo !== undefined) {
|
||||
const { uin, name } = target.replyTo;
|
||||
// 对齐 web 编辑器的 @ 提及编码
|
||||
const escapedName = name
|
||||
.trim()
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/,/g, '%2C')
|
||||
.replace(/}/g, '%7D');
|
||||
replyContent = `@{uin:${uin},nick:${escapedName},auto:1} ${content}`;
|
||||
}
|
||||
return new URLSearchParams({
|
||||
uin: selfUin,
|
||||
hostUin,
|
||||
hostuin: selfUin,
|
||||
topicId: `${hostUin}_${tid}`,
|
||||
commentId: target.commentId,
|
||||
commentUin: target.commentUin,
|
||||
content: replyContent,
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
ref: '',
|
||||
code_version: '1',
|
||||
format: 'fs',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostUin}`,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
// ─────────────── 拉黑/解拉黑(cgi_black_action_new) ───────────────
|
||||
|
||||
interface RawBlackActionResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/** 构造空间拉黑表单体。action=1 拉黑,action=2 解拉黑。 */
|
||||
export function buildQzoneBlackBody(selfUin: string, targetUin: string, ban: boolean): string {
|
||||
return new URLSearchParams({
|
||||
uin: selfUin,
|
||||
act_uin: targetUin,
|
||||
action: ban ? '1' : '2',
|
||||
fupdate: '1',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** 解析拉黑响应(响应带 {name:"Ack"} 无引号键碎片,先补引号再解析)。 */
|
||||
export function parseQzoneBlackResponse(text: string, ban: boolean): void {
|
||||
const verb = ban ? 'ban' : 'unban';
|
||||
let data: RawBlackActionResponse;
|
||||
try {
|
||||
data = parseQzoneJson<RawBlackActionResponse>(text.replace(/\{\s*name\s*:/, '{"name":'));
|
||||
} catch {
|
||||
const snippet = text.trim().slice(0, 300);
|
||||
throw new Error(`qzone ${verb} failed: ${snippet}${text.length > 300 ? '…' : ''}`);
|
||||
}
|
||||
const msg = data.message ?? data.msg ?? '';
|
||||
if (typeof data.subcode === 'number' && data.subcode !== 0) {
|
||||
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${msg}`.trim());
|
||||
}
|
||||
if (typeof data.code === 'number' && data.code !== 0) {
|
||||
throw new Error(`qzone ${verb} failed: code=${data.code} ${msg}`.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── 说说详情(emotion_cgi_msgdetail_v6) ───────────────
|
||||
|
||||
interface RawMsgDetailPic {
|
||||
pic_id?: string;
|
||||
pictype?: number | string;
|
||||
type?: number | string;
|
||||
height?: number | string;
|
||||
b_height?: number | string;
|
||||
width?: number | string;
|
||||
b_width?: number | string;
|
||||
smallurl?: string;
|
||||
url1?: string;
|
||||
url2?: string;
|
||||
url3?: string;
|
||||
}
|
||||
|
||||
export interface RawMsgDetailResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
tid?: string;
|
||||
uin?: number | string;
|
||||
content?: string;
|
||||
cmtnum?: unknown;
|
||||
commentlist?: unknown;
|
||||
conlist?: Array<{ con?: string }> | null;
|
||||
pic?: RawMsgDetailPic[] | null;
|
||||
richtype?: number | string;
|
||||
richval?: string;
|
||||
pic_template?: string;
|
||||
special_url?: string;
|
||||
t1_subtype?: number | string;
|
||||
subrichtype?: number | string;
|
||||
feedversion?: number | string;
|
||||
ver?: number | string;
|
||||
ugc_right?: number | string;
|
||||
to_sign?: number | string;
|
||||
ugcright_id?: string;
|
||||
code_version?: number | string;
|
||||
}
|
||||
|
||||
export interface RawUpdateResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
ugc_right?: number | string;
|
||||
}
|
||||
|
||||
export interface QzoneUpdateRightResult {
|
||||
/** 更新后的 ugc_right(服务端回显时用回显值) */
|
||||
ugc_right: number;
|
||||
}
|
||||
|
||||
/** 构造详情 GET 查询串(注意代理域是 taotao.qq.com 非 taotao.qzone.qq.com)。 */
|
||||
export function buildQzoneMsgDetailQuery(
|
||||
targetUin: string,
|
||||
tid: string,
|
||||
bkn: string,
|
||||
page?: { pos: number; num: number },
|
||||
): string {
|
||||
return new URLSearchParams({
|
||||
tid,
|
||||
uin: targetUin,
|
||||
t1_source: '1',
|
||||
not_trunc_con: '1',
|
||||
need_right: '1',
|
||||
not_adapt_outpic: '1',
|
||||
g_tk: bkn,
|
||||
...(page ? { pos: String(page.pos), num: String(page.num) } : {}),
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** 从 msgdetail_v6 响应重建 emotion_cgi_update 全量载荷(php-qzone buildUpdatePayloadFromDetail 逐字段移植)。 */
|
||||
export function buildQzoneUpdatePayload(
|
||||
detail: RawMsgDetailResponse,
|
||||
selfUin: string,
|
||||
): URLSearchParams {
|
||||
if (!detail.tid) {
|
||||
throw new Error('qzone msg detail is missing tid');
|
||||
}
|
||||
|
||||
const pics = detail.pic ?? [];
|
||||
const richtype =
|
||||
detail.richtype !== undefined ? Number(detail.richtype) : pics.length === 0 ? '' : 1;
|
||||
const richvals: string[] = [];
|
||||
const picBoItems: string[] = [];
|
||||
|
||||
for (const pic of pics) {
|
||||
const picId = String(pic.pic_id ?? '');
|
||||
if (!picId) continue;
|
||||
const parts = picId.split(',');
|
||||
const albumId = parts[1] ?? '';
|
||||
const lloc = parts[2] ?? '';
|
||||
if (!albumId || !lloc) {
|
||||
throw new Error('qzone msg detail has unsupported pic info');
|
||||
}
|
||||
const sloc = lloc;
|
||||
const picType = String(pic.pictype ?? pic.type ?? 22);
|
||||
const height = String(pic.height ?? pic.b_height ?? 0);
|
||||
const width = String(pic.width ?? pic.b_width ?? 0);
|
||||
richvals.push(`,${albumId},${lloc},${sloc},${picType},${height},${width},,0,0`);
|
||||
|
||||
for (const urlKey of ['smallurl', 'url1', 'url2', 'url3'] as const) {
|
||||
const picUrl = pic[urlKey];
|
||||
if (!picUrl) continue;
|
||||
const match = /[?&]bo=([^&#]+)/.exec(picUrl);
|
||||
if (match) {
|
||||
picBoItems.push(decodeURIComponent(match[1]!));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (richtype === 1 && richvals.length === 0) {
|
||||
throw new Error('qzone image post detail is missing pic info');
|
||||
}
|
||||
|
||||
let content = detail.content ?? '';
|
||||
const conParts = (detail.conlist ?? [])
|
||||
.map((c) => c.con)
|
||||
.filter((c): c is string => c !== undefined);
|
||||
if (conParts.length > 0) {
|
||||
content = conParts.join('').trim();
|
||||
}
|
||||
|
||||
const subrichtype = detail.t1_subtype ?? detail.subrichtype ?? (richvals.length === 0 ? '' : 1);
|
||||
const boGroup = picBoItems.join(',');
|
||||
const picBo = boGroup ? `${boGroup}\t${boGroup}` : '';
|
||||
|
||||
const hostuin = String(detail.uin ?? selfUin);
|
||||
return new URLSearchParams({
|
||||
syn_tweet_verson: '1',
|
||||
tid: String(detail.tid),
|
||||
paramstr: '1',
|
||||
pic_template: detail.pic_template ?? '',
|
||||
richtype: String(richtype),
|
||||
richval: richvals.length > 0 ? richvals.join('\t') : (detail.richval ?? ''),
|
||||
special_url: detail.special_url ?? '',
|
||||
subrichtype: String(subrichtype),
|
||||
pic_bo: picBo,
|
||||
con: content,
|
||||
feedversion: String(detail.feedversion ?? 1),
|
||||
ver: String(detail.ver ?? 1),
|
||||
ugc_right: String(Number(detail.ugc_right ?? 1)),
|
||||
to_sign: String(Number(detail.to_sign ?? 0)),
|
||||
ugcright_id: String(detail.ugcright_id ?? detail.tid),
|
||||
hostuin,
|
||||
code_version: String(detail.code_version ?? 1),
|
||||
format: 'fs',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 详情字段严格校验小件
|
||||
|
||||
export function detailObject(value: unknown, field: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`invalid qzone msg detail: ${field} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function detailString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`invalid qzone msg detail: ${field} must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function detailInteger(value: unknown, field: string, min = 0): number {
|
||||
const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
||||
if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min) {
|
||||
throw new Error(`invalid qzone msg detail: ${field} must be an integer >= ${min}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
export function detailList(value: unknown, field: string): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`invalid qzone msg detail: ${field} must be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapQzoneComment(value: Record<string, unknown>, field: string): QzoneCommentReply {
|
||||
const id =
|
||||
typeof value['tid'] === 'number'
|
||||
? String(detailInteger(value['tid'], `${field}.tid`))
|
||||
: detailString(value['tid'], `${field}.tid`);
|
||||
if (!id) {
|
||||
throw new Error(`invalid qzone msg detail: ${field}.tid is empty`);
|
||||
}
|
||||
return {
|
||||
comment_id: id,
|
||||
uin: detailInteger(value['uin'], `${field}.uin`, 1),
|
||||
name: detailString(value['name'], `${field}.name`),
|
||||
time: detailInteger(value['create_time'], `${field}.create_time`),
|
||||
content: detailString(value['content'], `${field}.content`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情响应 → 规范化(含评论+内联回复)。结构坏数据不得伪装成成功的空页。
|
||||
* 对齐 SmartHypercube/Qzone-API 的 Comment / Emotion.load。
|
||||
*/
|
||||
export function mapQzoneMsgDetail(
|
||||
data: RawMsgDetailResponse,
|
||||
tid: string,
|
||||
): QzoneMsgDetailResult {
|
||||
const resultTid = detailString(data.tid, 'tid');
|
||||
if (!resultTid || resultTid !== tid) {
|
||||
throw new Error('invalid qzone msg detail: tid does not match the requested post');
|
||||
}
|
||||
return {
|
||||
tid: resultTid,
|
||||
content: detailString(data.content, 'content'),
|
||||
comment_num: detailInteger(data.cmtnum, 'cmtnum'),
|
||||
comments: detailList(data.commentlist, 'commentlist').map((value, index) => {
|
||||
const field = `commentlist[${index}]`;
|
||||
const comment = detailObject(value, field);
|
||||
const mappedComment = mapQzoneComment(comment, field);
|
||||
return {
|
||||
...mappedComment,
|
||||
replies: detailList(comment['list_3'], `${field}.list_3`).map((reply, replyIndex) => {
|
||||
const replyField = `${field}.list_3[${replyIndex}]`;
|
||||
return {
|
||||
...mapQzoneComment(detailObject(reply, replyField), replyField),
|
||||
parent_comment_id: mappedComment.comment_id,
|
||||
parent_comment_uin: mappedComment.uin,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 详情响应错误包络统一判定(code 或 subcode 任一非零即失败,subcode=0 不遮掩 code!=0)。 */
|
||||
export function parseQzoneDetailError(data: RawMsgDetailResponse, tid: string): void {
|
||||
const code =
|
||||
data.code !== undefined && data.code !== 0 ? data.code : (data.subcode ?? data.code ?? -1);
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`qzone msg detail failed: code=${code} ${data.message ?? data.msg ?? ''}`.trim(),
|
||||
);
|
||||
}
|
||||
void tid;
|
||||
}
|
||||
|
||||
/** 解析 emotion_cgi_update(改权限)响应。 */
|
||||
export function parseQzoneUpdateRightResponse(
|
||||
text: string,
|
||||
tid: string,
|
||||
right: number,
|
||||
): QzoneUpdateRightResult {
|
||||
const data = parseQzoneJson<RawUpdateResponse>(text);
|
||||
if (typeof data.subcode === 'number' && data.subcode !== 0) {
|
||||
throw new Error(
|
||||
`qzone update right failed: subcode=${data.subcode} ${data.message ?? data.msg ?? ''}`.trim(),
|
||||
);
|
||||
}
|
||||
if (typeof data.code === 'number' && data.code !== 0) {
|
||||
throw new Error(
|
||||
`qzone update right failed: code=${data.code} ${data.message ?? data.msg ?? ''}`.trim(),
|
||||
);
|
||||
}
|
||||
void tid;
|
||||
return { ugc_right: Number(data.ugc_right ?? right) };
|
||||
}
|
||||
|
||||
// ─────────────── 空间资料 ───────────────
|
||||
|
||||
interface RawQzoneProfileResponse {
|
||||
code?: number;
|
||||
subcode?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
spacename?: string;
|
||||
desc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造空间资料 GET 查询(cgi_userinfo_get_all)。 */
|
||||
export function buildQzoneProfileQuery(selfUin: string, targetUin: string, bkn: string): string {
|
||||
return new URLSearchParams({
|
||||
uin: targetUin,
|
||||
vuin: selfUin,
|
||||
fupdate: '1',
|
||||
rd: String(Math.random()),
|
||||
g_tk: bkn,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** 解析空间资料响应。 */
|
||||
export function parseQzoneProfileResponse(text: string): QzoneProfile {
|
||||
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
|
||||
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
|
||||
throw new Error(
|
||||
`qzone profile failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
|
||||
);
|
||||
}
|
||||
if (typeof response.data?.spacename !== 'string' || typeof response.data.desc !== 'string') {
|
||||
throw new Error('invalid qzone profile response');
|
||||
}
|
||||
return { name: response.data.spacename, description: response.data.desc };
|
||||
}
|
||||
|
||||
/** 构造空间资料修改表单体(字段位图 mb:spacename=2048, desc=4096)。 */
|
||||
export function buildQzoneProfileUpdateBody(
|
||||
selfUin: string,
|
||||
profile: QzoneProfileUpdate,
|
||||
): string {
|
||||
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
|
||||
let bitmap = 0;
|
||||
// 空间设置表单用字段位图;独立富文本签名不含。
|
||||
// https://qzonestyle.gtimg.cn/qzone/v6/setting/profile/scripts/profile.js
|
||||
if (profile.name !== undefined) {
|
||||
params.set('spacename', profile.name.replaceAll('\r', ''));
|
||||
bitmap |= 2048;
|
||||
}
|
||||
if (profile.description !== undefined) {
|
||||
params.set('desc', profile.description.replaceAll('\r', ''));
|
||||
bitmap |= 4096;
|
||||
}
|
||||
params.set('mb', String(bitmap));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** 解析空间资料修改响应。 */
|
||||
export function parseQzoneProfileUpdateResponse(text: string): void {
|
||||
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
|
||||
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
|
||||
throw new Error(
|
||||
`qzone profile update failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* QQ空间(Qzone)发说说/上传图片相关的纯数据构造与解析函数
|
||||
* 不发起任何网络请求,方便单独测试
|
||||
*/
|
||||
|
||||
export interface QzoneUploadImageParams {
|
||||
uin: string;
|
||||
skey: string;
|
||||
pskey: string;
|
||||
g_tk: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造上传图片到 QQ 空间的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image
|
||||
*/
|
||||
export function buildQzoneUploadImageBody (params: QzoneUploadImageParams): string {
|
||||
const { uin, skey, pskey, g_tk, base64 } = params;
|
||||
const backUrls = `http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,http://119.147.64.75/cgi-bin/upload/cgi_upload_image&url=https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
|
||||
const query = new URLSearchParams({
|
||||
filename: 'filename',
|
||||
uin,
|
||||
skey,
|
||||
zzpaneluin: uin,
|
||||
p_uin: uin,
|
||||
p_skey: pskey,
|
||||
uploadtype: '1',
|
||||
albumtype: '7',
|
||||
exttype: '0',
|
||||
refer: 'shuoshuo',
|
||||
output_type: 'jsonhtml',
|
||||
charset: 'utf-8',
|
||||
output_charset: 'utf-8',
|
||||
upload_hd: '1',
|
||||
hd_width: '2048',
|
||||
hd_height: '10000',
|
||||
hd_quality: '96',
|
||||
backUrls,
|
||||
base64: '1',
|
||||
jsonhtml_callback: 'callback',
|
||||
picfile: base64,
|
||||
qzreferrer: `https://user.qzone.qq.com/${uin}/main`,
|
||||
});
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export interface QzoneUploadImageData {
|
||||
albumid: string;
|
||||
lloc: string;
|
||||
type: string;
|
||||
height: string;
|
||||
width: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface QzoneUploadImageRawResponse {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: QzoneUploadImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析上传图片接口返回的 `frameElement.callback({...});</script>` 包裹响应
|
||||
*/
|
||||
export function parseQzoneUploadImageResponseText (raw: string): QzoneUploadImageRawResponse {
|
||||
const startTag = 'frameElement.callback';
|
||||
const endTag = '</script>';
|
||||
const startIdx = raw.indexOf(startTag);
|
||||
const endIdx = raw.indexOf(endTag);
|
||||
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
||||
throw new Error('QQ空间图片上传响应格式异常');
|
||||
}
|
||||
const segment = raw.slice(startIdx + startTag.length, endIdx);
|
||||
const jsonStart = segment.indexOf('(');
|
||||
const jsonEnd = segment.lastIndexOf(')');
|
||||
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
|
||||
throw new Error('QQ空间图片上传响应解析失败');
|
||||
}
|
||||
const jsonText = segment.slice(jsonStart + 1, jsonEnd);
|
||||
try {
|
||||
return JSON.parse(jsonText) as QzoneUploadImageRawResponse;
|
||||
} catch {
|
||||
throw new Error('QQ空间图片上传响应JSON解析失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据上传图片返回数据拼接 richval 字段
|
||||
* 格式: ,albumid,lloc,sloc,type,height,width,,height,width (sloc 与 lloc 一致)
|
||||
*/
|
||||
export function toQzoneRichval (data: QzoneUploadImageData): string {
|
||||
const { albumid, lloc, type, height, width } = data;
|
||||
return `,${albumid},${lloc},${lloc},${type},${height},${width},,${height},${width}`;
|
||||
}
|
||||
|
||||
export interface QzonePublishParams {
|
||||
hostuin: string;
|
||||
content: string;
|
||||
/** 多张图片使用 \t 拼接后的 richval,无图时不传 */
|
||||
richval?: string;
|
||||
ugcRight: number;
|
||||
/** ugc_right 为 16/128 时必填,多个QQ号使用 | 拼接 */
|
||||
allowUins?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造发表说说的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6
|
||||
*/
|
||||
export function buildQzonePublishBody (params: QzonePublishParams): string {
|
||||
const { hostuin, content, richval, ugcRight, allowUins } = params;
|
||||
const fields: Record<string, string> = {
|
||||
syn_tweet_verson: '1',
|
||||
paramstr: '1',
|
||||
con: content,
|
||||
feedversion: '1',
|
||||
ver: '1',
|
||||
ugc_right: String(ugcRight),
|
||||
to_sign: '0',
|
||||
hostuin,
|
||||
code_version: '1',
|
||||
format: 'json',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostuin}/main`,
|
||||
};
|
||||
if (richval) {
|
||||
fields['richtype'] = '1';
|
||||
fields['richval'] = richval;
|
||||
}
|
||||
if (allowUins) {
|
||||
fields['allow_uins'] = allowUins;
|
||||
fields['who'] = '1';
|
||||
}
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
export interface QzonePublishResponse {
|
||||
subcode?: number;
|
||||
code?: number;
|
||||
tid?: string;
|
||||
t1_tid?: string;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析发表说说接口返回的 JSON, 提取 tid
|
||||
*/
|
||||
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; } {
|
||||
const subcode = json.subcode ?? json.code;
|
||||
if (subcode !== undefined && subcode !== 0) {
|
||||
throw new Error(json.message || json.msg || `QQ空间发说说失败, subcode=${subcode}`);
|
||||
}
|
||||
const tid = json.t1_tid || json.tid;
|
||||
if (!tid) {
|
||||
throw new Error('QQ空间发说说失败, 未返回tid');
|
||||
}
|
||||
return { tid };
|
||||
}
|
||||
|
||||
export interface QzoneDeleteParams {
|
||||
hostuin: string;
|
||||
tid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造删除说说的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6
|
||||
*/
|
||||
export function buildQzoneDeleteBody (params: QzoneDeleteParams): string {
|
||||
const { hostuin, tid } = params;
|
||||
const fields: Record<string, string> = {
|
||||
hostuin,
|
||||
tid,
|
||||
t1_source: '1',
|
||||
code_version: '1',
|
||||
format: 'json',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
|
||||
};
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
export interface QzoneDeleteResponse {
|
||||
subcode?: number;
|
||||
code?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析删除说说接口返回的 JSON
|
||||
*/
|
||||
export function parseQzoneDeleteResponse (json: QzoneDeleteResponse): { success: true; } {
|
||||
const subcode = json.subcode ?? json.code;
|
||||
if (subcode !== undefined && subcode !== 0) {
|
||||
throw new Error(json.message || json.msg || `QQ空间删说说失败, subcode=${subcode}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
export interface ControlReq {
|
||||
appid?: string;
|
||||
asy_upload?: number;
|
||||
biz_req?: BizReq;
|
||||
check_type?: number;
|
||||
checksum?: string;
|
||||
cmd?: string;
|
||||
env?: Env;
|
||||
file_len?: number;
|
||||
model?: number;
|
||||
session?: string;
|
||||
token?: Token;
|
||||
uin?: string;
|
||||
}
|
||||
|
||||
export interface BizReq {
|
||||
iAlbumTypeID: number;
|
||||
iBatchID: number;
|
||||
iBitmap: number;
|
||||
iDistinctUse: number;
|
||||
iNeedFeeds: number;
|
||||
iPicHight: number;
|
||||
iPicWidth: number;
|
||||
iUploadTime: number;
|
||||
iUploadType: number;
|
||||
iUpPicType: number;
|
||||
iWaterType: number;
|
||||
mapExt: MapExt;
|
||||
sAlbumID: string;
|
||||
sAlbumName: string;
|
||||
sPicDesc: string;
|
||||
sPicPath: string;
|
||||
sPicTitle: string;
|
||||
stExtendInfo: StExtendInfo;
|
||||
}
|
||||
|
||||
export interface MapExt {
|
||||
appid: string;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export interface StExtendInfo {
|
||||
mapParams: MapParams;
|
||||
}
|
||||
|
||||
export interface MapParams {
|
||||
batch_num: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
deviceInfo: string;
|
||||
refer: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
appid: number;
|
||||
data: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export function qunAlbumControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
pic_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
photo_num = '1',
|
||||
video_num = '0',
|
||||
batch_num = '1',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
pic_md5: string,
|
||||
img_size: number,
|
||||
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: [
|
||||
{
|
||||
uin,
|
||||
token: {
|
||||
type: 4,
|
||||
data: pskey,
|
||||
appid: 5,
|
||||
},
|
||||
appid: 'qun',
|
||||
checksum: pic_md5,
|
||||
check_type: 0,
|
||||
file_len: img_size,
|
||||
env: {
|
||||
refer: 'qzone',
|
||||
deviceInfo: 'h5',
|
||||
},
|
||||
model: 0,
|
||||
biz_req: {
|
||||
sPicTitle: img_name,
|
||||
sPicDesc: '',
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
iAlbumTypeID: 0,
|
||||
iBitmap: 0,
|
||||
iUploadType: 0,
|
||||
iUpPicType: 0,
|
||||
iBatchID: timestamp,
|
||||
sPicPath: '',
|
||||
iPicWidth: 0,
|
||||
iPicHight: 0,
|
||||
iWaterType: 0,
|
||||
iDistinctUse: 0,
|
||||
iNeedFeeds: 1,
|
||||
iUploadTime: timestamp,
|
||||
mapExt: {
|
||||
appid: 'qun',
|
||||
userid: group_id,
|
||||
},
|
||||
stExtendInfo: {
|
||||
mapParams: {
|
||||
photo_num,
|
||||
video_num,
|
||||
batch_num,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: '',
|
||||
asy_upload: 0,
|
||||
cmd: 'FileUpload',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamUpload (
|
||||
{
|
||||
uin,
|
||||
session,
|
||||
offset,
|
||||
seq,
|
||||
end,
|
||||
slice_size,
|
||||
data,
|
||||
|
||||
}: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string }
|
||||
) {
|
||||
return {
|
||||
uin,
|
||||
appid: 'qun',
|
||||
session,
|
||||
offset, // 分片起始位置
|
||||
data, // base64编码数据
|
||||
checksum: '',
|
||||
check_type: 0,
|
||||
retry: 0, // 重试次数
|
||||
seq, // 分片序号
|
||||
end, // 分片结束位置 文件总大小
|
||||
cmd: 'FileUpload',
|
||||
slice_size, // 分片大小16KB 16384
|
||||
biz_req: {
|
||||
iUploadType: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user