NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 群相册列表请求参数接口
|
||||
*/
|
||||
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;
|
||||
feed_id?: 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 图片ID;不传或为空时评论整个上传批次
|
||||
* @param batchId 上传批次ID;lloc 为空时必填
|
||||
* @returns 媒体请求参数对象
|
||||
*/
|
||||
export function createAlbumMediaFeed (
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc?: string,
|
||||
batchId: string = ''
|
||||
): AlbumMediaFeed {
|
||||
if (!lloc && !batchId) {
|
||||
throw new Error('lloc 或 batch_id 至少需要提供一个');
|
||||
}
|
||||
|
||||
return {
|
||||
cell_common: {
|
||||
time: '',
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: batchId,
|
||||
media_items: lloc ? [{ 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;
|
||||
};
|
||||
like: {
|
||||
id: string;
|
||||
count: number;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
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,
|
||||
},
|
||||
// QQNT 会将 like 转换为对象,即使新评论没有点赞也不能省略。
|
||||
like: {
|
||||
id: '',
|
||||
count: 0,
|
||||
status: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,368 @@
|
||||
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,
|
||||
}
|
||||
/** 闪传文件集默认有效期(秒):14 天 */
|
||||
export const DEFAULT_FLASH_VALIDITY_SECONDS = 1209600;
|
||||
|
||||
export interface StartFlashTaskRequests {
|
||||
scene: number; // 1 PC-QQ
|
||||
name?: string;
|
||||
uploaders: UploaderInfo[];
|
||||
permission?: { code: number; };
|
||||
coverPath?: string;
|
||||
paths: string[]; // 文件的绝对路径,可以是文件夹
|
||||
excludePaths?: string[];
|
||||
expireLeftTime?: number, // 0
|
||||
validitySeconds: number, // 文件集有效期(秒):14 天 1209600 / 90 天 7776000 / 180 天 15552000,必须传,否则新版内核段错误
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
isNeedDelLocation: boolean,
|
||||
coverOriginalInfos?: {
|
||||
path: string,
|
||||
thumbnailPath: string,
|
||||
}[],
|
||||
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
|
||||
detectPrivacyInfoResult: {
|
||||
exists: boolean,
|
||||
allDetectResults: {};
|
||||
};
|
||||
announcement: {
|
||||
announcementSegments: unknown[],
|
||||
modifyTime: string,
|
||||
avatarUrl: string;
|
||||
};
|
||||
albumFilePathList: unknown[];
|
||||
}
|
||||
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,146 @@
|
||||
import {
|
||||
normalizeQzoneMediaImages,
|
||||
normalizeQzoneRichContent,
|
||||
type QzoneComment,
|
||||
type QzoneFeedReference,
|
||||
} from './qzone-rpc';
|
||||
|
||||
export interface QzoneReplyItem {
|
||||
reply_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
target_user_id: string | null;
|
||||
target_uid: string | null;
|
||||
target_nickname: string | null;
|
||||
reply: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentItem {
|
||||
comment_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
reply_num: number | null;
|
||||
has_more_replies: boolean | null;
|
||||
replies: QzoneReplyItem[];
|
||||
comment: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_num: number | null;
|
||||
comments: QzoneCommentItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneReplyPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_id: string;
|
||||
replies: QzoneReplyItem[];
|
||||
reply_num: number | null;
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identifier (value: unknown): string | null {
|
||||
if (typeof value === 'string') return value.trim().length > 0 && value.length <= 8192 ? value : null;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function userId (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function count (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const result = Number(value);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
}
|
||||
|
||||
function nickname (value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function likeState (value: unknown): boolean | null {
|
||||
return value === 1 ? true : value === 0 ? false : null;
|
||||
}
|
||||
|
||||
function author (value: unknown): { user_id: string | null; uid: string | null; nickname: string | null; } {
|
||||
const user = object(value);
|
||||
const uid = user?.['uid'];
|
||||
return {
|
||||
user_id: userId(user?.['uin']),
|
||||
uid: typeof uid === 'string' ? identifier(uid) : null,
|
||||
nickname: nickname(user?.['nick']),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneReply (value: QzoneComment): QzoneReplyItem {
|
||||
const reply = object(value);
|
||||
if (!reply) throw new Error('QQ空间回复数据格式异常');
|
||||
const user = author(reply['user']);
|
||||
const target = author(reply['target_user']);
|
||||
const like = object(reply['like']);
|
||||
return {
|
||||
reply_id: identifier(reply['id']),
|
||||
...user,
|
||||
// The renderer uses time in Unix seconds. create_time is not this wire field.
|
||||
time: count(reply['time']),
|
||||
content: normalizeQzoneRichContent(reply['content']),
|
||||
images: normalizeQzoneMediaImages(reply['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
target_user_id: target.user_id,
|
||||
target_uid: target.uid,
|
||||
target_nickname: target.nickname,
|
||||
reply: value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneComment (value: QzoneComment): QzoneCommentItem {
|
||||
const comment = object(value);
|
||||
if (!comment) throw new Error('QQ空间评论数据格式异常');
|
||||
const user = author(comment['user']);
|
||||
const like = object(comment['like']);
|
||||
const inline = comment['replys'];
|
||||
if (inline !== undefined && inline !== null && !Array.isArray(inline)) throw new Error('QQ空间内联回复列表格式异常');
|
||||
const replies = (inline ?? []) as unknown[];
|
||||
return {
|
||||
comment_id: identifier(comment['id']),
|
||||
...user,
|
||||
time: count(comment['time']),
|
||||
content: normalizeQzoneRichContent(comment['content']),
|
||||
images: normalizeQzoneMediaImages(comment['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
reply_num: count(comment['reply_num']),
|
||||
has_more_replies: typeof comment['has_more_reply'] === 'boolean' ? comment['has_more_reply'] : null,
|
||||
// These are only the inline replies returned on this page, not an exhaustiveness claim.
|
||||
replies: replies.map((reply) => {
|
||||
if (!object(reply)) throw new Error('QQ空间内联回复数据格式异常');
|
||||
return normalizeQzoneReply(reply as QzoneComment);
|
||||
}),
|
||||
comment: value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
export type QzonePublicObject = Record<string, unknown>;
|
||||
export type QzonePublicContent = string | QzonePublicObject[];
|
||||
export type QzonePublicOperation =
|
||||
| 'capabilities' | 'feeds' | 'detail' | 'comments' | 'replies'
|
||||
| 'upvote' | 'downvote' | 'like' | 'unlike' | 'follow' | 'unfollow'
|
||||
| 'commentLike' | 'commentUnlike' | 'addComment' | 'addReply'
|
||||
| 'deleteComment' | 'deleteReply' | 'forward';
|
||||
|
||||
export interface QzonePublicFeedsPage extends QzonePublicObject {
|
||||
feeds: QzonePublicObject[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface QzonePublicRequestOptions extends QzonePublicObject {
|
||||
feed?: QzonePublicObject;
|
||||
comment?: QzonePublicObject;
|
||||
reply?: QzonePublicObject;
|
||||
content?: QzonePublicContent;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
reason?: string;
|
||||
selfUin?: string;
|
||||
user?: QzonePublicObject;
|
||||
refreshId?: string;
|
||||
lastUnionId?: QzonePublicObject;
|
||||
}
|
||||
|
||||
export interface QzonePublicRoute {
|
||||
service: string;
|
||||
method: string;
|
||||
oidb: string;
|
||||
}
|
||||
|
||||
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
|
||||
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
|
||||
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
|
||||
const RELATION = 'trpc.public_qzone.relation.RelationService';
|
||||
const UPVOTE = 'trpc.public_qzone.interaction.UFOComment';
|
||||
const MAX_OBJECT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
|
||||
function route (service: string, method: string, oidb: string): QzonePublicRoute {
|
||||
return Object.freeze({ service, method, oidb });
|
||||
}
|
||||
|
||||
export const QZONE_PUBLIC_ROUTES: Record<QzonePublicOperation, QzonePublicRoute> = Object.freeze({
|
||||
capabilities: route('trpc.public_qzone.entrance_ctrl.EntranceCtrl', 'BatchEntranceWhitelist', '0x9741_0'),
|
||||
feeds: route(READER, 'GetPublicRecomFeeds', '0x9a38_0'),
|
||||
detail: route(READER, 'GetFeedDetail', '0x97b1_1'),
|
||||
comments: route(READER, 'GetComment', '0x9bfd_1'),
|
||||
replies: route(READER, 'GetReplies', '0x9bfe_1'),
|
||||
upvote: route(UPVOTE, 'Upvote', '0x9d18_1'),
|
||||
downvote: route(UPVOTE, 'Downvote', '0x9d19_1'),
|
||||
like: route(INTERACTION, 'DoLike', '0x9699_1'),
|
||||
unlike: route(INTERACTION, 'UnLike', '0x969c_1'),
|
||||
follow: route(RELATION, 'Follow', '0x9d1a_1'),
|
||||
unfollow: route(RELATION, 'Unfollow', '0x9d1b_1'),
|
||||
commentLike: route(INTERACTION, 'DoLike', '0x9699_1'),
|
||||
commentUnlike: route(INTERACTION, 'UnLike', '0x969c_1'),
|
||||
addComment: route(INTERACTION, 'AddComment', '0x9676_1'),
|
||||
addReply: route(INTERACTION, 'AddReply', '0x9677_1'),
|
||||
deleteComment: route(INTERACTION, 'DeleteComment', '0x9989_0'),
|
||||
deleteReply: route(INTERACTION, 'DeleteReply', '0x998a_0'),
|
||||
forward: route(WRITER, 'ForwardFeed', '0x969a_1'),
|
||||
});
|
||||
|
||||
function object (value: unknown): value is QzonePublicObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function assertOperation (operation: QzonePublicOperation): void {
|
||||
if (!Object.prototype.hasOwnProperty.call(QZONE_PUBLIC_ROUTES, operation)) throw new Error('QQ空间公开接口操作无效');
|
||||
}
|
||||
|
||||
/** Clone JSON without calling toJSON or assigning prototype setters. */
|
||||
function snapshot<T> (value: T, name: string, maxBytes = MAX_OBJECT_BYTES): T {
|
||||
const seen = new Set<object>();
|
||||
let nodes = 0;
|
||||
const maxNodes = 200000;
|
||||
const maxDepth = 64;
|
||||
function copy (input: unknown, depth: number): unknown {
|
||||
if (++nodes > maxNodes || depth > maxDepth) throw new Error(`${name}结构超出限制`);
|
||||
if (input === null || typeof input === 'boolean') return input;
|
||||
if (typeof input === 'string') {
|
||||
if (Buffer.byteLength(input, 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
|
||||
return input;
|
||||
}
|
||||
if (typeof input === 'number' && Number.isFinite(input)) return input;
|
||||
if (!Array.isArray(input) && !object(input)) throw new Error(`${name}必须为JSON数据`);
|
||||
if (seen.has(input)) throw new Error(`${name}不能包含循环引用`);
|
||||
seen.add(input);
|
||||
let output: unknown;
|
||||
if (Array.isArray(input)) {
|
||||
const items: unknown[] = [];
|
||||
for (let index = 0; index < input.length; index++) items.push(copy(input[index], depth + 1));
|
||||
output = items;
|
||||
} else {
|
||||
const result: QzonePublicObject = {};
|
||||
for (const key of Object.keys(input)) {
|
||||
const item = input[key];
|
||||
if (item === undefined) continue;
|
||||
Object.defineProperty(result, key, { value: copy(item, depth + 1), enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
output = result;
|
||||
}
|
||||
seen.delete(input);
|
||||
return output;
|
||||
}
|
||||
const result = copy(value, 0) as T;
|
||||
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function jsonObject (value: unknown, name: string): QzonePublicObject {
|
||||
if (!object(value)) throw new Error(`${name}必须为JSON对象`);
|
||||
return snapshot(value, name);
|
||||
}
|
||||
|
||||
function identity (value: unknown, name: string, allowEmpty = false): string {
|
||||
if (typeof value !== 'string' || value.length > MAX_IDENTITY_LENGTH || (!allowEmpty && !value.trim())) {
|
||||
throw new Error(`${name}无效`);
|
||||
}
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) throw new Error(`${name}无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uin (value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value)) throw new Error(`${name}无效`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function pageToken (value: unknown, name: string, allowEmpty = false, maxLength = MAX_OBJECT_BYTES): string {
|
||||
if (typeof value !== 'string' || value.length > maxLength || (!allowEmpty && value.length === 0)) throw new Error(`${name}无效`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function unionId (value: unknown): QzonePublicObject {
|
||||
const union = jsonObject(value, 'QQ空间完整动态标识');
|
||||
let identified = false;
|
||||
for (const key of ['uf_key', 'ugc_id', 'feed_id', 'serialized_data']) {
|
||||
if (union[key] === undefined || union[key] === null) continue;
|
||||
const field = identity(union[key], 'QQ空间动态身份字段', true);
|
||||
if (field.trim()) identified = true;
|
||||
}
|
||||
if (!identified) throw new Error('QQ空间动态缺少完整有效身份');
|
||||
if (union['ext_ids'] !== undefined && union['ext_ids'] !== null && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !object(item)))) {
|
||||
throw new Error('QQ空间动态扩展身份无效');
|
||||
}
|
||||
return union;
|
||||
}
|
||||
|
||||
function readComment (value: unknown, name: string): QzonePublicObject {
|
||||
const comment = jsonObject(value, name);
|
||||
const id = comment['id'];
|
||||
if (typeof id === 'string') identity(id, `${name}标识`);
|
||||
else if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0) throw new Error(`${name}标识无效`);
|
||||
if (comment['user'] !== undefined && !object(comment['user'])) throw new Error(`${name}用户必须为JSON对象`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
function user (value: unknown, name: string): QzonePublicObject {
|
||||
const result = jsonObject(value, name);
|
||||
const userUin = result['uin'];
|
||||
const userUid = result['uid'];
|
||||
const absentUin = userUin === undefined || userUin === null || userUin === '' || userUin === '0' || userUin === 0;
|
||||
if (!absentUin) uin(userUin, `${name}QQ号`);
|
||||
if (userUid !== undefined && userUid !== null && userUid !== '') identity(userUid, `${name}UID`);
|
||||
if (absentUin && !userUid) throw new Error(`${name}缺少QQ号或UID`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function content (value: unknown): QzonePublicObject[] {
|
||||
if (typeof value === 'string') {
|
||||
if (!value.length) throw new Error('QQ空间评论正文不能为空');
|
||||
return snapshot([{ type: 0, content: value }], 'QQ空间评论正文');
|
||||
}
|
||||
if (!Array.isArray(value) || !value.length) throw new Error('QQ空间评论正文必须为非空文本或RichMsg数组');
|
||||
const items = snapshot(value, 'QQ空间评论正文');
|
||||
for (const item of items) {
|
||||
if (!object(item)) throw new Error('QQ空间RichMsg条目必须为JSON对象');
|
||||
}
|
||||
return items as QzonePublicObject[];
|
||||
}
|
||||
|
||||
export function buildQzonePublicRequest (operation: QzonePublicOperation, options: QzonePublicRequestOptions): QzonePublicObject {
|
||||
assertOperation(operation);
|
||||
if (!object(options)) throw new Error('QQ空间公开接口参数必须为对象');
|
||||
if (operation === 'capabilities') return { uins: [uin(options.selfUin, '当前QQ号')], entrance_types: [1, 15] };
|
||||
if (operation === 'feeds') {
|
||||
const body: QzonePublicObject = { refresh_id: identity(options.refreshId, 'QQ空间刷新标识') };
|
||||
if (options.cursor !== undefined) body['attach_info'] = pageToken(options.cursor, 'QQ空间分页标识', true, 65536);
|
||||
if (options.lastUnionId !== undefined) body['last_client_feed_union_id'] = unionId(options.lastUnionId);
|
||||
return body;
|
||||
}
|
||||
|
||||
const feed = jsonObject(options.feed, 'QQ空间原始Feed');
|
||||
const common = feed['cell_common'];
|
||||
if (!object(common)) throw new Error('QQ空间原始Feed缺少cell_common');
|
||||
const union = unionId(common['union_id']);
|
||||
const body: QzonePublicObject = { union_id: union };
|
||||
if (feed['cell_feed_transfer'] !== undefined) body['cell_feed_transfer'] = snapshot(feed['cell_feed_transfer'], 'QQ空间动态上下文');
|
||||
|
||||
if (operation === 'detail') return { ...body, feed_action_domain_type: 3, from_page: 1 };
|
||||
if (operation === 'comments') return { ...body, attach_info: pageToken(options.cursor, 'QQ空间评论分页标识') };
|
||||
if (operation === 'replies') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const size = options.pageSize === undefined ? 20 : options.pageSize;
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size < 1) throw new Error('QQ空间回复page_size必须为不小于1的有限数值');
|
||||
const pointer = options.cursor || parent['reply_attach_info'] || '';
|
||||
return { ...body, cur_comment: parent, comment_attach_info: '', reply_attach_info: pageToken(pointer, 'QQ空间回复分页标识', true), page_size: Math.trunc(size) };
|
||||
}
|
||||
if (operation === 'upvote' || operation === 'downvote') return body;
|
||||
if (operation === 'like' || operation === 'unlike') {
|
||||
return { ...body, like_key: identity(common['cur_like_key'], 'QQ空间动态点赞标识'), like_type: 1 };
|
||||
}
|
||||
if (operation === 'follow' || operation === 'unfollow') {
|
||||
const userInfo = feed['cell_user_info'];
|
||||
const author = user(object(userInfo) ? userInfo['user'] : undefined, 'QQ空间动态作者');
|
||||
const authorUin = typeof author['uin'] === 'string' && /^[1-9]\d{0,19}$/.test(author['uin']) ? author['uin'] : '';
|
||||
const authorUid = typeof author['uid'] === 'string' ? author['uid'].trim() : '';
|
||||
const useUid = (common['domain_type'] === 3 && !!authorUid) || !authorUin;
|
||||
const request: QzonePublicObject = { follow_from_type: 1, ...(useUid ? { target_uid: authorUid } : { target_uin: authorUin }) };
|
||||
if ((useUid && !authorUid) || (!useUid && !authorUin)) throw new Error('QQ空间动态缺少可蹲的作者身份');
|
||||
if (typeof union['uf_key'] === 'string' && union['uf_key']) request['follow_feed_id'] = union['uf_key'];
|
||||
if (feed['cell_feed_transfer']) request['cell_feed_transfer'] = body['cell_feed_transfer'];
|
||||
return request;
|
||||
}
|
||||
if (operation === 'commentLike' || operation === 'commentUnlike') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
|
||||
const like = target['like'];
|
||||
return { ...body, like_key: identity(object(like) ? like['id'] : undefined, 'QQ空间评论点赞标识'), like_type: options.reply === undefined ? 2 : 3 };
|
||||
}
|
||||
if (operation === 'addComment') return { ...body, comment: { user: user(options.user, '当前用户'), content: content(options.content) } };
|
||||
if (operation === 'addReply') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
|
||||
return { ...body, comment: parent, reply: { content: content(options.content), target_user: user(target['user'], 'QQ空间回复目标'), user: user(options.user, '当前用户') } };
|
||||
}
|
||||
if (operation === 'deleteComment') return { ...body, comment: readComment(options.comment, 'QQ空间父评论') };
|
||||
if (operation === 'deleteReply') return { ...body, comment: readComment(options.comment, 'QQ空间父评论'), reply: readComment(options.reply, 'QQ空间回复') };
|
||||
if (operation === 'forward') {
|
||||
const reason = options.reason ?? '';
|
||||
if (typeof reason !== 'string') throw new Error('QQ空间转发理由必须为文本');
|
||||
return { ...body, reason: snapshot(reason, 'QQ空间转发理由') };
|
||||
}
|
||||
throw new Error('QQ空间公开接口操作无效');
|
||||
}
|
||||
|
||||
/** Return the business data, preserving unknown fields while checking the observed acknowledgements. */
|
||||
export function parseQzonePublicResponse (value: unknown, operation: QzonePublicOperation): QzonePublicObject {
|
||||
assertOperation(operation);
|
||||
if (object(value) && typeof value['retcode'] === 'number' && Number.isSafeInteger(value['retcode']) && value['retcode'] !== 0) {
|
||||
throw new Error(`QQ空间公开接口拒绝请求 (retcode=${value['retcode']})`);
|
||||
}
|
||||
if (!object(value) || value['retcode'] !== 0 || !object(value['data'])) throw new Error('QQ空间公开接口未返回明确成功响应');
|
||||
const data = snapshot(value['data'], 'QQ空间公开接口响应', MAX_RESPONSE_BYTES);
|
||||
if (operation === 'follow' || operation === 'unfollow') {
|
||||
const code = data['ret_code'];
|
||||
if (code !== undefined && code !== null && code !== 0 && code !== '0') throw new Error('QQ空间蹲操作未确认成功');
|
||||
}
|
||||
const resultFields: Partial<Record<QzonePublicOperation, string>> = {
|
||||
upvote: 'upvote',
|
||||
downvote: 'upvote',
|
||||
like: 'like',
|
||||
unlike: 'like',
|
||||
commentLike: 'like',
|
||||
commentUnlike: 'like',
|
||||
addComment: 'comment',
|
||||
addReply: 'reply',
|
||||
};
|
||||
const field = resultFields[operation];
|
||||
if (field && !data[field]) throw new Error('QQ空间公开互动未返回业务成功结果');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export const QZONE_REFERENCE_MAX_BYTES = 64 * 1024;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
export interface QzoneUnionExtension {
|
||||
ext_id_type?: number;
|
||||
numeric?: number | string;
|
||||
str?: string;
|
||||
unsigned_numeric?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUnionId {
|
||||
uf_key?: string;
|
||||
ugc_id?: string;
|
||||
feed_id?: string;
|
||||
ext_ids?: QzoneUnionExtension[];
|
||||
serialized_data?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUser {
|
||||
uin: string;
|
||||
uid?: string;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRichMsg {
|
||||
type: number;
|
||||
content?: string;
|
||||
media_items?: unknown[];
|
||||
name?: string;
|
||||
uid?: string;
|
||||
uin?: string;
|
||||
url?: string;
|
||||
who?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRawCommentUser {
|
||||
uin?: string;
|
||||
uid?: string | null;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneComment {
|
||||
id?: string | number;
|
||||
user?: QzoneRawCommentUser;
|
||||
content?: QzoneRichMsg[];
|
||||
replys?: QzoneComment[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeed {
|
||||
cell_common?: {
|
||||
union_id?: QzoneUnionId;
|
||||
time?: number | string;
|
||||
app_id?: number;
|
||||
feed_type?: number;
|
||||
feed_sub_type?: number;
|
||||
feed_biz_type?: number;
|
||||
client_key?: string;
|
||||
cur_like_key?: string;
|
||||
domain_type?: number;
|
||||
operate_mask?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
cell_user_info?: { user?: QzoneUser; [key: string]: unknown; };
|
||||
cell_summary?: { summary?: QzoneRichMsg[]; [key: string]: unknown; };
|
||||
cell_media?: { media_items?: unknown[]; [key: string]: unknown; };
|
||||
cell_comment?: { num?: number | string; comments?: QzoneComment[]; [key: string]: unknown; };
|
||||
cell_like?: { num?: number | string; liked?: boolean; [key: string]: unknown; };
|
||||
cell_feed_transfer?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedReference {
|
||||
union_id: QzoneUnionId;
|
||||
cell_feed_transfer?: unknown;
|
||||
feed_action_domain_type?: number;
|
||||
like_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedItem {
|
||||
tid: string | null;
|
||||
user_id: string | null;
|
||||
content: string | null;
|
||||
time: number | null;
|
||||
comment_num: number | null;
|
||||
like_num: number | null;
|
||||
images: string[] | null;
|
||||
feed_ref: QzoneFeedReference | null;
|
||||
feed: QzoneFeed;
|
||||
}
|
||||
|
||||
export interface QzoneFeedPage {
|
||||
feeds: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface QzoneMsgPage {
|
||||
msglist: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
seal_off_memory?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneDetail extends QzoneFeedItem {
|
||||
comments: QzoneComment[];
|
||||
comment_has_more?: boolean;
|
||||
comment_next_cursor?: string | null;
|
||||
}
|
||||
|
||||
function record (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identity (value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_IDENTITY_LENGTH ? value : null;
|
||||
}
|
||||
|
||||
function nonnegativeInteger (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function numericIdentifier (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
export function getQzoneGtk (key: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < key.length; i++) hash += (hash << 5) + key.charCodeAt(i);
|
||||
return hash & 0x7fffffff;
|
||||
}
|
||||
|
||||
export function getQzoneTid (feed: QzoneFeed): string | null {
|
||||
const common = record(feed?.cell_common);
|
||||
const union = record(common?.['union_id']);
|
||||
if (!union) return null;
|
||||
const extensions = union['ext_ids'];
|
||||
if (Array.isArray(extensions)) {
|
||||
const extension = extensions.map(record).find(item => item?.['ext_id_type'] === 5);
|
||||
if (extension) {
|
||||
const tid = identity(extension['str']) ?? numericIdentifier(extension['unsigned_numeric']) ?? numericIdentifier(extension['numeric']);
|
||||
if (tid !== null) return tid;
|
||||
}
|
||||
}
|
||||
// Other feed kinds also have ugc_id; an album ID is not a mood tid.
|
||||
if (common?.['feed_sub_type'] !== 1 && common?.['app_id'] !== 311) return null;
|
||||
return identity(union['ugc_id']);
|
||||
}
|
||||
|
||||
export function normalizeQzoneRichContent (value: unknown): string | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const item of value) {
|
||||
const segment = record(item);
|
||||
if (!segment) return null;
|
||||
switch (segment['type']) {
|
||||
case 0:
|
||||
case 2:
|
||||
case 4:
|
||||
if (typeof segment['content'] !== 'string') return null;
|
||||
parts.push(segment['content']);
|
||||
break;
|
||||
case 1:
|
||||
case 5:
|
||||
if (typeof segment['name'] !== 'string') return null;
|
||||
parts.push(segment['type'] === 1 ? `@${segment['name']}` : segment['name']);
|
||||
break;
|
||||
case 3:
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function imageUrl (value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
const url = value.startsWith('//') ? `https:${value}` : value;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.username && !parsed.password ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeQzoneMediaImages (value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const images: string[] = [];
|
||||
for (const item of value) {
|
||||
const media = record(item);
|
||||
if (!media) return null;
|
||||
if (media['type'] === 1) continue; // Video covers are not post images.
|
||||
if (media['type'] !== 0) return null;
|
||||
const image = record(media['image']);
|
||||
if (!image) return null;
|
||||
const variants = Array.isArray(image['photo_url']) ? image['photo_url'].map(record) : [];
|
||||
let selected: string | null = null;
|
||||
// Same original-image preference as the H5 client: download, origin, big, current.
|
||||
for (const spec of [6, 0, 1, 5]) {
|
||||
const variant = variants.find(candidate => candidate?.['spec'] === spec);
|
||||
selected = imageUrl(record(variant?.['url'])?.['url']);
|
||||
if (selected !== null) break;
|
||||
}
|
||||
selected ??= imageUrl(record(image['default_url'])?.['url']);
|
||||
if (selected === null) return null;
|
||||
images.push(selected);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function assertJson (value: unknown, depth = 0): void {
|
||||
if (depth > 32) throw new Error('QQ空间动态引用嵌套过深');
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertJson(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
const object = record(value);
|
||||
if (!object) throw new Error('QQ空间动态引用必须为JSON数据');
|
||||
for (const item of Object.values(object)) {
|
||||
if (item !== undefined) assertJson(item, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateQzoneFeedReference (value: unknown): QzoneFeedReference {
|
||||
const reference = record(value);
|
||||
const union = record(reference?.['union_id']);
|
||||
if (!reference || !union) throw new Error('QQ空间动态引用缺少union_id');
|
||||
const keys = ['uf_key', 'ugc_id', 'feed_id', 'serialized_data'];
|
||||
let hasIdentity = false;
|
||||
for (const key of keys) {
|
||||
if (union[key] === undefined) continue;
|
||||
const field = union[key];
|
||||
if (typeof field !== 'string' || field.length > MAX_IDENTITY_LENGTH) throw new Error('QQ空间动态标识无效');
|
||||
// Protobuf JSON may include empty defaults alongside an issued identity.
|
||||
if (field.trim().length > 0) hasIdentity = true;
|
||||
}
|
||||
if (!hasIdentity) throw new Error('QQ空间动态引用缺少有效标识');
|
||||
if (union['ext_ids'] !== undefined && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !record(item)))) {
|
||||
throw new Error('QQ空间动态扩展标识无效');
|
||||
}
|
||||
const domain = reference['feed_action_domain_type'];
|
||||
if (domain !== undefined && (typeof domain !== 'number' || !Number.isInteger(domain) || domain < 1 || domain > 3)) {
|
||||
throw new Error('QQ空间动态所属域无效');
|
||||
}
|
||||
if (reference['like_key'] !== undefined && identity(reference['like_key']) === null) throw new Error('QQ空间点赞标识无效');
|
||||
assertJson(reference);
|
||||
let serialized: string;
|
||||
try {
|
||||
const encoded = JSON.stringify(reference);
|
||||
if (typeof encoded !== 'string') throw new Error('Invalid JSON');
|
||||
serialized = encoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间动态引用无法序列化');
|
||||
}
|
||||
if (Buffer.byteLength(serialized, 'utf8') > QZONE_REFERENCE_MAX_BYTES) throw new Error('QQ空间动态引用不能超过64KiB');
|
||||
return reference as unknown as QzoneFeedReference;
|
||||
}
|
||||
|
||||
export function normalizeQzoneFeed (feed: QzoneFeed): QzoneFeedItem {
|
||||
if (!record(feed)) throw new Error('QQ空间动态数据格式无效');
|
||||
const common = record(feed.cell_common);
|
||||
const user = record(record(feed.cell_user_info)?.['user']);
|
||||
let reference: QzoneFeedReference | null = null;
|
||||
if (record(common?.['union_id'])) {
|
||||
const candidate: Record<string, unknown> = { union_id: common?.['union_id'] };
|
||||
if (feed.cell_feed_transfer !== undefined) candidate['cell_feed_transfer'] = feed.cell_feed_transfer;
|
||||
const domain = common?.['domain_type'];
|
||||
if (typeof domain === 'number' && Number.isInteger(domain) && domain >= 1 && domain <= 3) candidate['feed_action_domain_type'] = domain;
|
||||
if (identity(common?.['cur_like_key']) !== null) candidate['like_key'] = common?.['cur_like_key'];
|
||||
try {
|
||||
reference = validateQzoneFeedReference(candidate);
|
||||
} catch {
|
||||
// Preserve the raw feed while declining to issue an unusable action handle.
|
||||
}
|
||||
}
|
||||
return {
|
||||
tid: getQzoneTid(feed),
|
||||
user_id: numericIdentifier(user?.['uin']),
|
||||
content: normalizeQzoneRichContent(record(feed.cell_summary)?.['summary']),
|
||||
time: nonnegativeInteger(common?.['time']),
|
||||
comment_num: nonnegativeInteger(record(feed.cell_comment)?.['num']),
|
||||
like_num: nonnegativeInteger(record(feed.cell_like)?.['num']),
|
||||
images: normalizeQzoneMediaImages(record(feed.cell_media)?.['media_items']),
|
||||
feed_ref: reference,
|
||||
feed,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQzoneText (content: string): QzoneRichMsg[] {
|
||||
if (typeof content !== 'string' || !content.trim()) throw new Error('QQ空间正文不能为空');
|
||||
if (content.length > MAX_TEXT_LENGTH) throw new Error('QQ空间正文不能超过2000字');
|
||||
return [{ content, media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 }];
|
||||
}
|
||||
|
||||
export function buildQzonePublishRequest (
|
||||
user: QzoneUser,
|
||||
content: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[],
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
key?: string
|
||||
): { feed: QzoneFeed; from: number; } {
|
||||
if (!user || typeof user.uin !== 'string' || numericIdentifier(user.uin) === null) throw new Error('QQ空间当前账号无效');
|
||||
if (user.uid !== undefined && typeof user.uid !== 'string') throw new Error('QQ空间当前账号UID无效');
|
||||
if (user.nick !== undefined && typeof user.nick !== 'string') throw new Error('QQ空间当前账号昵称无效');
|
||||
const permissions = new Map([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]]);
|
||||
const flag = permissions.get(ugcRight);
|
||||
if (flag === undefined) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && (!Array.isArray(targetUins) || targetUins.some(uin => typeof uin !== 'string' || numericIdentifier(uin) === null))) {
|
||||
throw new Error('target_uins必须为有效QQ号数组');
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && !targetUins?.length) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('QQ空间发表时间无效');
|
||||
const clientKey = key ?? `${user.uin}_${now}_${Math.floor(Math.random() * 1e10)}`;
|
||||
if (identity(clientKey) === null) throw new Error('QQ空间发表请求标识无效');
|
||||
const right: Record<string, unknown> = { flag: [flag] };
|
||||
if (ugcRight === 16) right['white_uins'] = [...targetUins!];
|
||||
if (ugcRight === 128) right['black_uins'] = [...targetUins!];
|
||||
return {
|
||||
feed: {
|
||||
cell_common: {
|
||||
time: now,
|
||||
client_key: clientKey,
|
||||
feed_type: 1,
|
||||
feed_sub_type: 1,
|
||||
union_id: { uf_key: clientKey, ugc_id: clientKey, feed_id: clientKey },
|
||||
feed_biz_type: 0,
|
||||
},
|
||||
cell_user_info: { user: { uin: user.uin, uid: user.uid ?? '', nick: user.nick ?? '' } },
|
||||
cell_summary: { summary: buildQzoneText(content) },
|
||||
cell_permission: { right },
|
||||
},
|
||||
from: 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseQzoneRpcResponse (value: unknown): Record<string, unknown> {
|
||||
const response = record(value);
|
||||
if (!response || response['retcode'] !== 0) {
|
||||
const code = response?.['retcode'];
|
||||
const suffix = typeof code === 'number' && Number.isSafeInteger(code) ? ` (retcode=${code})` : '';
|
||||
throw new Error(`QQ空间请求失败${suffix}`);
|
||||
}
|
||||
const data = record(response['data']);
|
||||
if (!data) throw new Error('QQ空间响应缺少有效data对象');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import JSON5 from 'json5';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export interface SlQzoneFeed {
|
||||
uin: number;
|
||||
nickname: string;
|
||||
time: number;
|
||||
appid: number;
|
||||
key: string;
|
||||
html: string;
|
||||
tid: string | null;
|
||||
content: string | null;
|
||||
content_complete: boolean | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
comment_count: number | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedPage {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedState {
|
||||
begintime: string;
|
||||
dayspac: string;
|
||||
pagenum: string;
|
||||
aisortEndTime: string;
|
||||
aisortOffset: string;
|
||||
aisortBeginTime: string;
|
||||
aisortNextTime: string;
|
||||
icServerTime: string;
|
||||
externparam: string;
|
||||
daylist: string;
|
||||
uinlist: string;
|
||||
}
|
||||
|
||||
export interface SlQzoneParsedFeeds {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
main: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
const MAX_TOKENS = 200000;
|
||||
const MAX_HTML_NODES = 50000;
|
||||
const STATE_NUMBERS = ['begintime', 'dayspac', 'pagenum', 'aisortEndTime', 'aisortOffset', 'aisortBeginTime', 'aisortNextTime', 'icServerTime'] as const;
|
||||
const STATE_STRINGS = ['externparam', 'daylist', 'uinlist'] as const;
|
||||
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
function record (value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
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 scalar (value: unknown, label: string): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value !== 'string') throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireNumber (value: unknown, label: string): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const result = count(value);
|
||||
if (result === null) throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Only adapt data syntax that JSON5 lacks: undefined values and elided array slots. */
|
||||
function json5DataSource (source: string, undefinedMarker: string): string {
|
||||
const chunks: string[] = [];
|
||||
const nesting: Array<{ array: boolean; awaiting: boolean; }> = [];
|
||||
let offset = 0;
|
||||
let tokens = 0;
|
||||
const statusKeys = new Set<string>();
|
||||
const markValue = () => {
|
||||
const parent = nesting[nesting.length - 1];
|
||||
if (parent?.array) parent.awaiting = false;
|
||||
};
|
||||
const nextSignificant = (start: number): number => {
|
||||
let index = start;
|
||||
while (index < source.length) {
|
||||
if (/\s/.test(source[index]!)) index++;
|
||||
else if (source.startsWith('//', index)) {
|
||||
const end = source.indexOf('\n', index + 2);
|
||||
index = end < 0 ? source.length : end + 1;
|
||||
} else if (source.startsWith('/*', index)) {
|
||||
const end = source.indexOf('*/', index + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
index = end + 2;
|
||||
} else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const checkStatusKey = (raw: string, quoted: boolean, end: number) => {
|
||||
if (nesting.length !== 1 || nesting[0]?.array || source[nextSignificant(end)] !== ':') return;
|
||||
let key: unknown = raw;
|
||||
if (quoted) key = JSON5.parse(raw);
|
||||
else if (raw.includes('\\')) key = Object.keys(JSON5.parse(`{${raw}:0}`))[0];
|
||||
if (key !== 'code' && key !== 'subcode') return;
|
||||
if (statusKeys.has(key)) throw new Error('QQ空间动态响应状态字段重复');
|
||||
statusKeys.add(key);
|
||||
};
|
||||
while (offset < source.length) {
|
||||
if (++tokens > MAX_TOKENS) throw new Error('QQ空间动态字面量节点过多');
|
||||
const char = source[offset]!;
|
||||
if (/\s/.test(char)) {
|
||||
const start = offset++;
|
||||
while (offset < source.length && /\s/.test(source[offset]!)) offset++;
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith('//', offset) || source.startsWith('/*', offset)) {
|
||||
const start = offset;
|
||||
if (source[offset + 1] === '/') {
|
||||
const end = source.indexOf('\n', offset + 2);
|
||||
offset = end < 0 ? source.length : end + 1;
|
||||
} else {
|
||||
const end = source.indexOf('*/', offset + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
offset = end + 2;
|
||||
}
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const start = offset++;
|
||||
let closed = false;
|
||||
while (offset < source.length) {
|
||||
const current = source[offset++]!;
|
||||
if (current === '\\') offset++;
|
||||
else if (current === char) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!closed) throw new Error('QQ空间动态字面量字符串未结束');
|
||||
const literal = source.slice(start, offset);
|
||||
checkStatusKey(literal, true, offset);
|
||||
chunks.push(literal);
|
||||
markValue();
|
||||
continue;
|
||||
}
|
||||
if (char === '[' || char === '{') {
|
||||
markValue();
|
||||
nesting.push({ array: char === '[', awaiting: true });
|
||||
if (nesting.length > MAX_DEPTH) throw new Error('QQ空间动态字面量嵌套过深');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']' || char === '}') {
|
||||
const current = nesting.pop();
|
||||
if (!current || current.array !== (char === ']')) throw new Error('QQ空间动态字面量括号不匹配');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
const current = nesting[nesting.length - 1];
|
||||
if (current?.array) {
|
||||
if (current.awaiting) chunks.push('null');
|
||||
current.awaiting = true;
|
||||
}
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ':') {
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
const start = offset++;
|
||||
while (offset < source.length && !/[\s[\]{},:'"]/.test(source[offset]!) && !source.startsWith('//', offset) && !source.startsWith('/*', offset)) offset++;
|
||||
const token = source.slice(start, offset);
|
||||
checkStatusKey(token, false, offset);
|
||||
chunks.push(token === 'undefined' && source[nextSignificant(offset)] !== ':' ? JSON.stringify(undefinedMarker) : token);
|
||||
markValue();
|
||||
}
|
||||
if (nesting.length) throw new Error('QQ空间动态字面量括号未结束');
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
function parseDataBody (text: string): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_INPUT_BYTES) throw new Error('QQ空间动态响应过大或无效');
|
||||
let source = text.trim();
|
||||
if (!source.startsWith('{')) {
|
||||
const prefix = /^(?:_preloadCallback|_Callback|callback)\s*\(/.exec(source);
|
||||
const suffix = /\)\s*;?\s*$/.exec(source);
|
||||
if (!prefix || !suffix || suffix.index < prefix[0].length) throw new Error('QQ空间动态回调格式异常');
|
||||
source = source.slice(prefix[0].length, suffix.index).trim();
|
||||
}
|
||||
if (!source.startsWith('{')) throw new Error('QQ空间动态缺少对象响应');
|
||||
let value: unknown;
|
||||
try {
|
||||
// Content-addressed marker keeps optional object fields absent instead of changing undefined into null.
|
||||
const undefinedMarker = `\u0000qz:${createHash('sha256').update(source).digest('hex')}`;
|
||||
value = JSON5.parse(json5DataSource(source, undefinedMarker), (key, item: unknown) => {
|
||||
if (DANGEROUS_KEYS.has(key) || (typeof item === 'number' && !Number.isFinite(item))) throw new Error('Unsupported data');
|
||||
return item === undefinedMarker ? undefined : item;
|
||||
});
|
||||
} catch {
|
||||
throw new Error('QQ空间动态字面量格式异常');
|
||||
}
|
||||
if (!record(value)) throw new Error('QQ空间动态缺少对象响应');
|
||||
return value;
|
||||
}
|
||||
|
||||
interface HtmlElement {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
parent?: HtmlElement;
|
||||
children: Array<HtmlElement | string>;
|
||||
}
|
||||
|
||||
function classes (element: HtmlElement): string[] {
|
||||
return (element.attributes['class'] ?? '').split(/\s+/);
|
||||
}
|
||||
|
||||
const EXCLUDED_CLASSES = new Set(['mod-comments', 'comments-list', 'f-item-passive', 'f-ct-video']);
|
||||
const EXCLUDED_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
||||
|
||||
function excluded (element: HtmlElement): boolean {
|
||||
return EXCLUDED_TAGS.has(element.tag) || classes(element).some(name => EXCLUDED_CLASSES.has(name));
|
||||
}
|
||||
|
||||
function descendants (root: HtmlElement, prune: boolean): HtmlElement[] {
|
||||
const result: HtmlElement[] = [];
|
||||
const pending: HtmlElement[] = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop()!;
|
||||
if (current !== root && prune && excluded(current)) continue;
|
||||
result.push(current);
|
||||
for (let index = current.children.length - 1; index >= 0; index--) {
|
||||
const child = current.children[index];
|
||||
if (typeof child !== 'string' && child) pending.push(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function htmlTree (html: string): HtmlElement {
|
||||
if (Buffer.byteLength(html) > MAX_HTML_BYTES) throw new Error('QQ空间动态HTML过大');
|
||||
const root: HtmlElement = { tag: '#root', attributes: {}, children: [] };
|
||||
const stack = [root];
|
||||
let nodes = 0;
|
||||
const parser = new Parser({
|
||||
onopentag (tag, attributes) {
|
||||
if (++nodes > MAX_HTML_NODES || stack.length > MAX_DEPTH) throw new Error('QQ空间动态HTML结构过大');
|
||||
const parent = stack[stack.length - 1]!;
|
||||
const element: HtmlElement = { tag, attributes: { ...attributes }, parent, children: [] };
|
||||
parent.children.push(element);
|
||||
stack.push(element);
|
||||
},
|
||||
onclosetag (tag) {
|
||||
for (let index = stack.length - 1; index > 0; index--) {
|
||||
if (stack[index]?.tag === tag) {
|
||||
stack.length = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext (text) {
|
||||
if (++nodes > MAX_HTML_NODES) throw new Error('QQ空间动态HTML结构过大');
|
||||
stack[stack.length - 1]!.children.push(text);
|
||||
},
|
||||
}, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
|
||||
parser.end(html);
|
||||
return root;
|
||||
}
|
||||
|
||||
function contentText (element: HtmlElement): string {
|
||||
const pieces: string[] = [];
|
||||
const blocks = new Set(['p', 'div', 'li', 'section', 'article', 'blockquote', 'ul', 'ol']);
|
||||
const visit = (node: HtmlElement | string) => {
|
||||
if (typeof node === 'string') {
|
||||
pieces.push(node.replace(/\s+/g, ' '));
|
||||
return;
|
||||
}
|
||||
if (excluded(node) || node.attributes['data-cmd'] === 'qz_toggle' || classes(node).includes('load_img')) return;
|
||||
if (node.tag === 'br') pieces.push('\n');
|
||||
else if (node.tag === 'img') pieces.push(node.attributes['alt'] ?? '');
|
||||
else {
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
for (const child of node.children) visit(child);
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
}
|
||||
};
|
||||
visit(element);
|
||||
return pieces.join('').replace(/[^\S\n]*\n[^\S\n]*/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function imageUrl (value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
|
||||
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectHtml (feed: SlQzoneFeed): void {
|
||||
const document = htmlTree(feed.html);
|
||||
const roots = descendants(document, true).filter(node => classes(node).includes('f-item') && node.attributes['data-key'] === feed.key);
|
||||
if (!feed.key || roots.length !== 1) return;
|
||||
const root = roots[0]!;
|
||||
if (excluded(root)) return;
|
||||
const elements = descendants(root, true);
|
||||
const metadata = elements.find(node => node.attributes['name'] === 'feed_data' && node.attributes['data-fkey'] === feed.key && node.attributes['data-uin'] === String(feed.uin));
|
||||
const tid = metadata?.attributes['data-tid'];
|
||||
if (feed.appid === 311 && tid?.trim() && tid.length <= 8192) feed.tid = tid;
|
||||
const textElements = elements.filter(node => classes(node).includes('f-info'));
|
||||
const full = textElements.find(node => classes(node).includes('qz_info_complete') && contentText(node).length > 0);
|
||||
const selected = full ?? textElements.find(node => !classes(node).includes('qz_info_complete'));
|
||||
if (selected) {
|
||||
feed.content = contentText(selected);
|
||||
feed.content_complete = full !== undefined || !descendants(selected, true).some(node => node.attributes['data-cmd'] === 'qz_toggle');
|
||||
}
|
||||
const pictures = new Set<string>();
|
||||
for (const element of elements) {
|
||||
if (!classes(element).includes('img-item') || element.attributes['data-v_itemid'] !== undefined || element.attributes['data-videotype'] !== undefined) continue;
|
||||
const fullUrl = imageUrl(element.attributes['data-originurl'] ?? element.attributes['data-url3']);
|
||||
if (fullUrl) pictures.add(fullUrl);
|
||||
else {
|
||||
for (const image of descendants(element, true)) {
|
||||
if (image.tag !== 'img') continue;
|
||||
const url = imageUrl(image.attributes['truesrc'] ?? image.attributes['src']);
|
||||
if (url) pictures.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
feed.images = [...pictures];
|
||||
if (feed.like_count === null) {
|
||||
let card = root;
|
||||
for (let parent = root.parent; parent; parent = parent.parent) {
|
||||
if (classes(parent).includes('f-single')) {
|
||||
card = parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const button = descendants(card, true).find(node => classes(node).includes('qz_like_btn_v3'));
|
||||
feed.like_count = count(button?.attributes['data-likecnt']);
|
||||
}
|
||||
}
|
||||
|
||||
function moreFlag (value: unknown): boolean {
|
||||
if (value === true || value === 1 || value === '1') return true;
|
||||
if (value === false || value === 0 || value === '0') return false;
|
||||
throw new Error('QQ空间动态缺少明确的分页结束标志');
|
||||
}
|
||||
|
||||
export function parseSlQzoneFeeds (text: string): SlQzoneParsedFeeds {
|
||||
const response = parseDataBody(text);
|
||||
if (response['code'] !== 0 || (response['subcode'] !== undefined && response['subcode'] !== 0)) throw new Error('QQ空间好友动态请求失败');
|
||||
const data = response['data'];
|
||||
if (!record(data) || !Array.isArray(data['data']) || data['data'].length > 1000) throw new Error('QQ空间好友动态列表格式异常');
|
||||
const mainValue = data['main'];
|
||||
if (mainValue !== undefined && !record(mainValue)) throw new Error('QQ空间好友动态分页信息无效');
|
||||
const main = record(mainValue) ? mainValue : null;
|
||||
if (main && main['error'] !== undefined && main['error'] !== '') throw new Error('QQ空间好友动态服务端报告失败');
|
||||
const hasMore = moreFlag(main ? main['hasMoreFeeds'] : data['hasmore']);
|
||||
if (hasMore && !main) throw new Error('QQ空间好友动态缺少续页状态');
|
||||
const feeds: SlQzoneFeed[] = [];
|
||||
for (const value of data['data']) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (!record(value)) throw new Error('QQ空间好友动态条目格式异常');
|
||||
const key = scalar(value['key'] ?? value['feedskey'], '标识');
|
||||
if (key.length > 8192) throw new Error('QQ空间动态标识过长');
|
||||
const feed: SlQzoneFeed = {
|
||||
uin: wireNumber(value['uin'], '账号'),
|
||||
nickname: scalar(value['nickname'], '昵称'),
|
||||
time: wireNumber(value['abstime'], '时间'),
|
||||
appid: wireNumber(value['appid'], '应用'),
|
||||
key,
|
||||
html: scalar(value['html'], 'HTML'),
|
||||
tid: null,
|
||||
content: null,
|
||||
content_complete: null,
|
||||
images: null,
|
||||
like_count: count(value['likecnt']),
|
||||
comment_count: count(value['commentcnt']),
|
||||
};
|
||||
projectHtml(feed);
|
||||
feeds.push(feed);
|
||||
}
|
||||
return { feeds, has_more: hasMore, main };
|
||||
}
|
||||
|
||||
export function initialSlQzoneFeedState (): SlQzoneFeedState {
|
||||
return {
|
||||
begintime: '0',
|
||||
dayspac: '0',
|
||||
pagenum: '1',
|
||||
aisortEndTime: '0',
|
||||
aisortOffset: '0',
|
||||
aisortBeginTime: '0',
|
||||
aisortNextTime: '0',
|
||||
icServerTime: '0',
|
||||
externparam: '',
|
||||
daylist: '',
|
||||
uinlist: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceSlQzoneFeedState (previous: SlQzoneFeedState, main: Record<string, unknown>): SlQzoneFeedState {
|
||||
const next = { ...previous };
|
||||
for (const field of STATE_NUMBERS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || value === '') continue;
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) ||
|
||||
(typeof value === 'number' && !Number.isSafeInteger(value)) || String(value).length > 32) {
|
||||
throw new Error('QQ空间动态分页数值无效');
|
||||
}
|
||||
const canonical = BigInt(value).toString();
|
||||
if (field === 'pagenum' && canonical === '0') throw new Error('QQ空间动态分页页码无效');
|
||||
next[field] = canonical;
|
||||
}
|
||||
for (const field of STATE_STRINGS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
|
||||
if (typeof value !== 'string' || value.length > 32768) throw new Error('QQ空间动态分页字符串无效');
|
||||
next[field] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function slQzoneFeedPosition (state: SlQzoneFeedState): string {
|
||||
const position: Record<string, string> = {};
|
||||
for (const field of Object.keys(state).sort()) {
|
||||
if (field !== 'icServerTime') position[field] = state[field as keyof SlQzoneFeedState];
|
||||
}
|
||||
return JSON.stringify(position);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
export interface SlQzoneEmotion {
|
||||
tid: string;
|
||||
content: string;
|
||||
time: number;
|
||||
comment_num: number;
|
||||
is_private: boolean;
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgList {
|
||||
total: number;
|
||||
/** False means total is the legacy page-length fallback, not a measured account total. */
|
||||
total_known?: boolean;
|
||||
msglist: SlQzoneEmotion[];
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatReply {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
parent_comment_id: string;
|
||||
parent_comment_uin: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatComment {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
replies: SlQzoneFlatReply[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgDetail {
|
||||
tid: string;
|
||||
content: string;
|
||||
comment_num: number;
|
||||
comments: SlQzoneFlatComment[];
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentResult {
|
||||
comment_id: string;
|
||||
parent_comment_id?: string;
|
||||
parent_comment_uin?: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentOptions {
|
||||
imageUrls?: string[];
|
||||
comment_id?: string;
|
||||
comment_uin?: string;
|
||||
reply_uin?: string;
|
||||
reply_name?: string;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function requireObject (value: unknown): Record<string, unknown> {
|
||||
const result = object(value);
|
||||
if (!result) throw new Error('QQ空间兼容响应对象格式异常');
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseCodes (response: Record<string, unknown>, requireAcknowledgement = false, requireCode = false): void {
|
||||
let acknowledged = false;
|
||||
for (const field of ['code', 'subcode', 'retcode', 'ret']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const value = response[field];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value !== 0) {
|
||||
const code = typeof value === 'number' && Number.isSafeInteger(value) ? ` (${field}=${value})` : '';
|
||||
throw new Error(`QQ空间兼容接口拒绝请求${code}`);
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if ((requireAcknowledgement && !acknowledged) || (requireCode && response['code'] !== 0)) {
|
||||
throw new Error('QQ空间兼容响应缺少明确成功状态');
|
||||
}
|
||||
}
|
||||
|
||||
function integer (value: unknown, minimum = 0): number {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) throw new Error('QQ空间兼容响应数值无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error('QQ空间兼容响应数值无效');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredText (value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('QQ空间兼容响应文本字段无效');
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasControlCharacter (value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function id (value: unknown, allowEmpty = false): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (allowEmpty) return '';
|
||||
throw new Error('QQ空间兼容响应缺少标识');
|
||||
}
|
||||
if (typeof value === 'number') return String(integer(value));
|
||||
if (typeof value !== 'string' || value.length > 8192 || hasControlCharacter(value) || (!value.trim() && (!allowEmpty || value !== ''))) {
|
||||
throw new Error('QQ空间兼容响应标识无效');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function array (value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new Error('QQ空间兼容响应列表格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function pictureUrls (value: unknown): string[] {
|
||||
const result: string[] = [];
|
||||
for (const picture of array(value)) {
|
||||
const fields = requireObject(picture);
|
||||
for (const key of ['url3', 'url2', 'url1', 'smallurl']) {
|
||||
const url = fields[key];
|
||||
if (url === undefined || url === null || url === '') continue;
|
||||
if (typeof url !== 'string') throw new Error('QQ空间说说图片地址格式异常');
|
||||
result.push(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function privateFlag (value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^-?\d+$/.test(value))) throw new Error('QQ空间说说私密标记无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) throw new Error('QQ空间说说私密标记无效');
|
||||
return parsed !== 0;
|
||||
}
|
||||
|
||||
/** Legacy zero/empty sentinels apply only to absent fields, never malformed supplied values. */
|
||||
export function parseSlQzoneMsgList (response: Record<string, unknown>): SlQzoneMsgList {
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (!Array.isArray(fields['msglist'])) throw new Error('QQ空间兼容说说列表响应不完整');
|
||||
const msglist: SlQzoneEmotion[] = [];
|
||||
for (const entry of fields['msglist']) {
|
||||
const row = requireObject(entry);
|
||||
msglist.push({
|
||||
tid: id(row['tid'], true),
|
||||
content: row['content'] === undefined || row['content'] === null ? '' : requiredText(row['content']),
|
||||
time: row['created_time'] === undefined || row['created_time'] === null ? 0 : integer(row['created_time']),
|
||||
comment_num: row['cmtnum'] === undefined || row['cmtnum'] === null ? 0 : integer(row['cmtnum']),
|
||||
is_private: privateFlag(row['secret']),
|
||||
images: pictureUrls(row['pic']),
|
||||
});
|
||||
}
|
||||
const totalKnown = fields['total'] !== undefined && fields['total'] !== null;
|
||||
return {
|
||||
total: totalKnown ? integer(fields['total']) : msglist.length,
|
||||
total_known: totalKnown,
|
||||
msglist,
|
||||
};
|
||||
}
|
||||
|
||||
function flatComment (value: unknown): Omit<SlQzoneFlatComment, 'replies'> {
|
||||
const fields = requireObject(value);
|
||||
return {
|
||||
comment_id: id(fields['tid']),
|
||||
uin: integer(fields['uin'], 1),
|
||||
name: requiredText(fields['name']),
|
||||
time: integer(fields['create_time']),
|
||||
content: requiredText(fields['content']),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlQzoneMsgDetail (response: Record<string, unknown>, tid: string): SlQzoneMsgDetail {
|
||||
assertTid(tid);
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (typeof fields['tid'] !== 'string' || fields['tid'] !== tid) throw new Error('QQ空间兼容详情与请求的说说不匹配');
|
||||
const comments: SlQzoneFlatComment[] = [];
|
||||
for (const value of array(fields['commentlist'])) {
|
||||
const original = requireObject(value);
|
||||
const parent = flatComment(original);
|
||||
const replies: SlQzoneFlatReply[] = [];
|
||||
for (const reply of array(original['list_3'])) {
|
||||
replies.push({ ...flatComment(reply), parent_comment_id: parent.comment_id, parent_comment_uin: parent.uin });
|
||||
}
|
||||
comments.push({ ...parent, replies });
|
||||
}
|
||||
return { tid, content: requiredText(fields['content']), comment_num: integer(fields['cmtnum']), comments };
|
||||
}
|
||||
|
||||
function assertUin (value: unknown, safeNumeric = false): asserts value is string {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value) || (safeNumeric && !Number.isSafeInteger(Number(value)))) {
|
||||
throw new Error('QQ空间兼容账号标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertTid (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > 8192 || hasControlCharacter(value)) throw new Error('QQ空间兼容说说标识无效');
|
||||
}
|
||||
|
||||
export function buildSlQzoneLikeBody (self: string, target: string, tid: string, enable: boolean, abstime: number): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof enable !== 'boolean' || typeof abstime !== 'number' || !Number.isSafeInteger(abstime) || abstime < 0) throw new Error('QQ空间兼容点赞参数无效');
|
||||
const resource = `http://user.qzone.qq.com/${target}/mood/${tid}`;
|
||||
const form = new URLSearchParams();
|
||||
form.set('opuin', self);
|
||||
form.set('fid', tid);
|
||||
form.set('abstime', String(abstime));
|
||||
form.set('unikey', resource);
|
||||
form.set('curkey', resource);
|
||||
for (const [key, value] of Object.entries({ appid: '311', typeid: '0', from: '1', active: '0', fupdate: '1', format: 'json' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
function commentOptions (options: SlQzoneCommentOptions | undefined): { options: SlQzoneCommentOptions; reply: boolean; } {
|
||||
if (options !== undefined && !object(options)) throw new Error('QQ空间兼容评论选项无效');
|
||||
const fields: SlQzoneCommentOptions = options ?? {};
|
||||
if (Object.keys(fields).some(key => !['imageUrls', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name'].includes(key))) throw new Error('QQ空间兼容评论选项无效');
|
||||
const reply = fields.comment_id !== undefined || fields.comment_uin !== undefined;
|
||||
if (reply) {
|
||||
assertTid(fields.comment_id);
|
||||
assertUin(fields.comment_uin, true);
|
||||
}
|
||||
const addressed = fields.reply_uin !== undefined || fields.reply_name !== undefined;
|
||||
if (addressed) {
|
||||
if (!reply || typeof fields.reply_name !== 'string' || !fields.reply_name.trim() || fields.reply_name.length > 8192) throw new Error('指定回复目标需要完整父评论与昵称');
|
||||
assertUin(fields.reply_uin, true);
|
||||
}
|
||||
if (fields.imageUrls !== undefined) {
|
||||
if (!Array.isArray(fields.imageUrls)) throw new Error('QQ空间评论图片地址必须为数组');
|
||||
if (reply && fields.imageUrls.length > 0) throw new Error('QQ空间楼中楼回复不支持图片');
|
||||
for (const url of fields.imageUrls) {
|
||||
if (typeof url !== 'string' || !url || url.length > 8192 || url.trim() !== url) throw new Error('QQ空间评论图片地址无效');
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || /[\t\r\n]/.test(url)) throw new Error('Invalid URL');
|
||||
} catch {
|
||||
throw new Error('QQ空间评论图片地址无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { options: fields, reply };
|
||||
}
|
||||
|
||||
function addressedContent (content: string, options: SlQzoneCommentOptions): string {
|
||||
if (options.reply_uin === undefined) return content;
|
||||
const escapes: Record<string, string> = { '%': '%25', ',': '%2C', '}': '%7D' };
|
||||
const name = [...options.reply_name!.trim()].map(character => escapes[character] ?? character).join('');
|
||||
return `@{uin:${options.reply_uin},nick:${name},auto:1} ${content}`;
|
||||
}
|
||||
|
||||
export function buildSlQzoneCommentBody (self: string, target: string, tid: string, content: string, options?: SlQzoneCommentOptions): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof content !== 'string' || content === '') throw new Error('QQ空间兼容评论正文不能为空');
|
||||
const branch = commentOptions(options);
|
||||
const form = new URLSearchParams({ uin: self, hostUin: target, inCharset: 'utf-8', outCharset: 'utf-8' });
|
||||
if (branch.reply) {
|
||||
if (!content.trim()) throw new Error('QQ空间兼容回复正文不能为空白');
|
||||
form.set('hostuin', self);
|
||||
form.set('topicId', `${target}_${tid}`);
|
||||
form.set('commentId', branch.options.comment_id!);
|
||||
form.set('commentUin', branch.options.comment_uin!);
|
||||
form.set('content', addressedContent(content, branch.options));
|
||||
form.set('ref', '');
|
||||
form.set('code_version', '1');
|
||||
form.set('format', 'fs');
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${target}`);
|
||||
} else {
|
||||
form.set('topicId', `${target}_${tid}__1`);
|
||||
form.set('content', content);
|
||||
form.set('richtype', branch.options.imageUrls?.length ? '1' : '');
|
||||
form.set('richval', branch.options.imageUrls?.join('\t') ?? '');
|
||||
for (const [key, value] of Object.entries({ format: 'json', ref: 'feeds', feedsType: '100', private: '0', paramstr: '1', isSignIn: '', plat: 'qzone', source: 'ic', platformid: '52' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
}
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
export function parseSlQzoneComment (response: Record<string, unknown>, options?: SlQzoneCommentOptions): SlQzoneCommentResult {
|
||||
const fields = requireObject(response);
|
||||
const branch = commentOptions(options);
|
||||
responseCodes(fields, true, branch.reply);
|
||||
if (branch.reply) {
|
||||
const result = object(fields['data']);
|
||||
if (!result) throw new Error('QQ空间回复响应缺少新标识,结果未知,请查询后确认,勿自动重试');
|
||||
let replyId: string;
|
||||
try {
|
||||
replyId = id(result['id']);
|
||||
} catch {
|
||||
throw new Error('QQ空间回复响应缺少有效新标识,结果未知,请查询后确认,勿自动重试');
|
||||
}
|
||||
return { comment_id: replyId, parent_comment_id: branch.options.comment_id!, parent_comment_uin: Number(branch.options.comment_uin!) };
|
||||
}
|
||||
// An empty ID is the explicit legacy sentinel after an acknowledged ordinary comment.
|
||||
return { comment_id: id(fields['commentid'] ?? fields['commentId'], true) };
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
export interface QzoneProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface QzoneProfileUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
|
||||
|
||||
const SUCCESS_FIELDS = ['code', 'subcode', 'retcode', 'ret'];
|
||||
const MAX_PROFILE_LENGTH = 8192;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
|
||||
export class QzoneWebError extends Error {
|
||||
constructor (public readonly field: string, public readonly code: number) {
|
||||
super(`QQ空间接口拒绝请求 (${field}=${code})`);
|
||||
}
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function assertUin (uin: unknown): asserts uin is string {
|
||||
if (typeof uin !== 'string' || !/^[1-9]\d{0,19}$/.test(uin)) throw new Error('QQ空间账号标识无效');
|
||||
}
|
||||
|
||||
function assertIdentity (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > MAX_IDENTITY_LENGTH) {
|
||||
throw new Error('QQ空间说说标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuccess (response: Record<string, unknown>, requireCode: boolean): void {
|
||||
let acknowledged = false;
|
||||
for (const field of SUCCESS_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const code = response[field];
|
||||
if (typeof code !== 'number' || !Number.isSafeInteger(code) || code !== 0) {
|
||||
// CGI messages may contain account content or tickets. Only include a numeric code.
|
||||
if (typeof code === 'number' && Number.isSafeInteger(code)) throw new QzoneWebError(field, code);
|
||||
throw new Error('QQ空间接口拒绝请求');
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if (!acknowledged || (requireCode && response['code'] !== 0)) throw new Error('QQ空间响应缺少明确成功状态');
|
||||
}
|
||||
|
||||
/** Quote only observed bare name keys with string values, never string contents. */
|
||||
function normalizeNameStringKeys (text: string): string {
|
||||
let result = '';
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
const containers: Array<{ object: boolean; key: boolean; }> = [];
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
if (!quoted) {
|
||||
const container = containers[containers.length - 1];
|
||||
if (container?.object && container.key && character === 'n' && /^name(?=\s*:\s*")/.test(text.slice(index))) {
|
||||
result += '"name"';
|
||||
index += 'name'.length - 1;
|
||||
container.key = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '{') containers.push({ object: true, key: true });
|
||||
else if (character === '[') containers.push({ object: false, key: false });
|
||||
else if (character === '}' || character === ']') containers.pop();
|
||||
else if (character === ',' && container?.object) container.key = true;
|
||||
else if (!/\s/.test(character) && container?.object) container.key = false;
|
||||
}
|
||||
result += character;
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === '\\') escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
} else if (character === '"') {
|
||||
quoted = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rejectDuplicateSuccessFields (text: string): void {
|
||||
const seen = new Set<string>();
|
||||
let depth = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index];
|
||||
if (character === '{' || character === '[') depth++;
|
||||
else if (character === '}' || character === ']') depth--;
|
||||
else if (character === '"') {
|
||||
const start = index;
|
||||
let escaped = false;
|
||||
for (index++; index < text.length; index++) {
|
||||
const current = text[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (current === '\\') escaped = true;
|
||||
else if (current === '"') break;
|
||||
}
|
||||
let next = index + 1;
|
||||
while (/\s/.test(text[next] ?? '') && next < text.length) next++;
|
||||
if (depth !== 1 || text[next] !== ':') continue;
|
||||
const field: unknown = JSON.parse(text.slice(start, index + 1));
|
||||
if (typeof field !== 'string' || !SUCCESS_FIELDS.includes(field)) continue;
|
||||
if (seen.has(field)) throw new Error('QQ空间响应包含重复状态字段');
|
||||
seen.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FormSender's response is a data callback inside one fixed, passive HTML shell.
|
||||
// Consume its observed domain setup as text; never run the returned JavaScript.
|
||||
const FORM_SENDER_HTML = /^(?:<!doctype\s+html>\s*)?<html>\s*<head>\s*<meta\s+http-equiv="content-type"\s+content="text\/html;\s*charset=utf-8"\s*\/?>\s*<\/head>\s*<body>\s*<script\s+type="text\/javascript">\s*([\s\S]*?)\s*<\/script>\s*<\/body>\s*<\/html>$/i;
|
||||
const DOMAIN_SETUP = 'document\\s*\\.\\s*domain\\s*=\\s*"h5\\.qzone\\.qq\\.com"\\s*;\\s*';
|
||||
const CALLBACK_SETUP = 'cb\\s*=\\s*frameElement\\s*\\.\\s*callback\\s*;\\s*';
|
||||
const DOMAIN_TRY_SETUP = new RegExp(
|
||||
'^var\\s+cb\\s*;\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP + '\\}\\s*\\}\\s*'
|
||||
);
|
||||
const DOMAIN_ONLY_SETUP = new RegExp('^' + DOMAIN_SETUP);
|
||||
|
||||
function unwrapFormSenderHtml (text: string): string | undefined {
|
||||
const html = FORM_SENDER_HTML.exec(text);
|
||||
if (!html) return undefined;
|
||||
const script = html[1]!.trim();
|
||||
const setup = DOMAIN_TRY_SETUP.exec(script) ?? DOMAIN_ONLY_SETUP.exec(script);
|
||||
if (!setup) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
const callback = script.slice(setup[0].length).trim();
|
||||
if (!/^frameElement\.callback\s*\(/.test(callback)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
return callback;
|
||||
}
|
||||
|
||||
/** Parse data only: no script evaluation and no first-brace/last-brace extraction. */
|
||||
export function parseQzoneWebResponse (text: string, requireCode = false): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || text.length > 8 * 1024 * 1024) throw new Error('QQ空间响应格式异常');
|
||||
let payload = text.trim();
|
||||
const formSender = unwrapFormSenderHtml(payload);
|
||||
if (formSender !== undefined) payload = formSender;
|
||||
// Preserve the original single-script and bare callback envelopes as well.
|
||||
const script = /^<script>\s*([\s\S]*?)\s*<\/script>$/i.exec(payload);
|
||||
if (script) {
|
||||
payload = script[1]!.trim();
|
||||
if (!/^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(/.test(payload)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
} else if (payload.endsWith('</script>') && payload.startsWith('frameElement.callback')) payload = payload.slice(0, -9).trim();
|
||||
if (!payload.startsWith('{')) {
|
||||
const callback = /^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(\s*([\s\S]*)\s*\)\s*;?\s*$/.exec(payload);
|
||||
if (!callback) throw new Error('QQ空间响应格式异常');
|
||||
payload = callback[1]!.trim();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
payload = normalizeNameStringKeys(payload);
|
||||
rejectDuplicateSuccessFields(payload);
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
throw new Error('QQ空间响应JSON格式异常');
|
||||
}
|
||||
const response = object(parsed);
|
||||
if (!response) throw new Error('QQ空间响应必须是对象');
|
||||
assertSuccess(response, requireCode);
|
||||
return response;
|
||||
}
|
||||
|
||||
export function parseQzoneProfile (response: Record<string, unknown>): QzoneProfile {
|
||||
if (!object(response)) throw new Error('QQ空间资料响应格式异常');
|
||||
assertSuccess(response, true);
|
||||
const data = object(response['data']);
|
||||
if (!data || typeof data['spacename'] !== 'string' || typeof data['desc'] !== 'string') {
|
||||
throw new Error('QQ空间资料响应不完整');
|
||||
}
|
||||
return { name: data['spacename'], description: data['desc'] };
|
||||
}
|
||||
|
||||
export function buildQzoneProfileBody (selfUin: string, profile: QzoneProfileUpdate): string {
|
||||
assertUin(selfUin);
|
||||
const fields = object(profile);
|
||||
if (!fields || Object.keys(fields).some(key => key !== 'name' && key !== 'description')) throw new Error('QQ空间资料参数无效');
|
||||
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
|
||||
let bitmap = 0;
|
||||
for (const [field, wireName, bit] of [['name', 'spacename', 2048], ['description', 'desc', 4096]] as const) {
|
||||
const value = fields[field];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== 'string' || value.length > MAX_PROFILE_LENGTH) throw new Error('QQ空间名称或说明必须是8192字以内的文本');
|
||||
params.set(wireName, value.replaceAll('\r', ''));
|
||||
bitmap |= bit;
|
||||
}
|
||||
if (bitmap === 0) throw new Error('至少提供空间名称或说明');
|
||||
params.set('mb', String(bitmap));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function normalizeQzoneRight (ugcRight: number, targetUins?: string[]): { right: QzoneUgcRight; targetUins: string[]; } {
|
||||
const rights = [1, 4, 16, 64, 128];
|
||||
if (typeof ugcRight !== 'number' || !rights.includes(ugcRight)) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && !Array.isArray(targetUins)) throw new Error('target_uins必须为QQ号数组');
|
||||
const targets: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const uin of targetUins ?? []) {
|
||||
assertUin(uin);
|
||||
if (!seen.has(uin)) {
|
||||
targets.push(uin);
|
||||
seen.add(uin);
|
||||
}
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && targets.length === 0) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
return { right: ugcRight as QzoneUgcRight, targetUins: targets };
|
||||
}
|
||||
|
||||
function wireText (value: unknown, fallback: string): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间说说重建字段格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireInteger (value: unknown, fallback: string, minimum = 0, allowEmpty = false): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (allowEmpty && value === '') return '';
|
||||
if ((typeof value !== 'number' || !Number.isSafeInteger(value)) && (typeof value !== 'string' || !/^\d+$/.test(value))) {
|
||||
throw new Error('QQ空间说说重建数值无效');
|
||||
}
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error('QQ空间说说重建数值无效');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function firstPresent (first: unknown, second: unknown): unknown {
|
||||
return first === undefined ? second : first;
|
||||
}
|
||||
|
||||
function hasMediaValue (value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
const record = object(value);
|
||||
return record ? Object.keys(record).length > 0 : true;
|
||||
}
|
||||
|
||||
function hasObservedEmptyForwardContent (detail: Record<string, unknown>): boolean {
|
||||
if (detail['rt_tid'] !== '' || detail['rt_uin'] !== 0 || detail['rt_source'] !== 0) return false;
|
||||
const content = object(detail['rt_con']);
|
||||
if (!content) return false;
|
||||
const keys = Object.keys(content);
|
||||
return keys.length === 2 && keys.includes('conlist') && keys.includes('content') &&
|
||||
content['conlist'] === null && content['content'] === '';
|
||||
}
|
||||
|
||||
function assertSupportedMedia (detail: Record<string, unknown>): void {
|
||||
for (const field of [
|
||||
'video', 'videos', 'video_info', 'video_url', 'videoid', 'video_id', 'vid',
|
||||
'music', 'music_info', 'audio', 'voice', 'voice_info',
|
||||
'rt_tid', 'rt_uin', 'rt_con', 'rt_source', 'forward', 'forward_info', 'forward_tid',
|
||||
'origin', 'origin_tid', 'origin_feed', 'cell_origin',
|
||||
]) {
|
||||
// The legacy detail CGI supplies this exact empty object for ordinary text and image posts.
|
||||
if (field === 'rt_con' && hasObservedEmptyForwardContent(detail)) continue;
|
||||
if (hasMediaValue(detail[field])) throw new Error('该说说包含暂不支持完整重建的媒体或转发内容');
|
||||
}
|
||||
for (const field of ['appid', 'app_id']) {
|
||||
if (detail[field] !== undefined && detail[field] !== 311 && detail[field] !== '311') throw new Error('该动态不是可重建的说说');
|
||||
}
|
||||
if (wireText(detail['special_url'], '') !== '') throw new Error('暂不支持修改含特殊分享内容的说说权限');
|
||||
}
|
||||
|
||||
function pictureBo (picture: Record<string, unknown>): string | undefined {
|
||||
for (const key of ['smallurl', 'url1', 'url2', 'url3']) {
|
||||
const value = picture[key];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间图片地址元数据无效');
|
||||
const matches = [...value.matchAll(/[?&]bo=([^&#]*)/g)];
|
||||
if (matches.length > 1) throw new Error('QQ空间图片包含歧义元数据');
|
||||
if (matches.length === 0) continue;
|
||||
try {
|
||||
const decoded = decodeURIComponent(matches[0]![1]!);
|
||||
if (!decoded || /[\t\r\n]/.test(decoded)) throw new Error('Invalid picture metadata');
|
||||
return decoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间图片元数据无法完整重建');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rebuildContent (detail: Record<string, unknown>): string {
|
||||
const conlist = detail['conlist'];
|
||||
if (conlist !== undefined && conlist !== null) {
|
||||
if (!Array.isArray(conlist)) throw new Error('QQ空间说说正文分段格式异常');
|
||||
if (conlist.length > 0) {
|
||||
return conlist.map((value) => {
|
||||
const part = object(value);
|
||||
if (!part || typeof part['con'] !== 'string') throw new Error('QQ空间说说正文分段不完整');
|
||||
return part['con'];
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
if (typeof detail['content'] !== 'string') throw new Error('QQ空间说说缺少完整正文');
|
||||
return detail['content'];
|
||||
}
|
||||
|
||||
/** Permission updates resubmit a whole post, so unsupported or partial data must fail before POST. */
|
||||
export function buildQzoneRightBody (
|
||||
detail: Record<string, unknown>,
|
||||
selfUin: string,
|
||||
tid: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[]
|
||||
): string {
|
||||
assertUin(selfUin);
|
||||
assertIdentity(tid);
|
||||
const source = object(detail);
|
||||
if (!source || source['tid'] !== tid) throw new Error('QQ空间详情与请求的说说标识不匹配');
|
||||
const owner = source['uin'];
|
||||
if ((typeof owner !== 'string' && !(typeof owner === 'number' && Number.isSafeInteger(owner))) || String(owner) !== selfUin) {
|
||||
throw new Error('只能修改当前账号自己的说说权限');
|
||||
}
|
||||
if (SUCCESS_FIELDS.some(field => Object.prototype.hasOwnProperty.call(source, field))) assertSuccess(source, false);
|
||||
const permission = normalizeQzoneRight(ugcRight, targetUins);
|
||||
assertSupportedMedia(source);
|
||||
const pics = source['pic'] ?? [];
|
||||
if (!Array.isArray(pics)) throw new Error('QQ空间说说图片列表格式异常');
|
||||
const richType = wireInteger(source['richtype'], pics.length ? '1' : '', 0, true);
|
||||
if (!['', '0', '1'].includes(richType)) throw new Error('该说说的媒体类型暂不支持完整重建');
|
||||
if ((pics.length > 0 && richType !== '1') || (pics.length === 0 && richType === '1')) throw new Error('QQ空间说说图片信息不完整');
|
||||
const richValues: string[] = [];
|
||||
const boValues: Array<string | undefined> = [];
|
||||
for (const value of pics) {
|
||||
const picture = object(value);
|
||||
const pictureId = picture?.['pic_id'];
|
||||
if (!picture || typeof pictureId !== 'string' || !pictureId || /[\t\r\n]/.test(pictureId)) throw new Error('QQ空间说说图片标识不完整');
|
||||
const parts = pictureId.split(',');
|
||||
const albumId = parts[1];
|
||||
const lloc = parts[2];
|
||||
if (!albumId?.trim() || !lloc?.trim()) throw new Error('QQ空间说说图片信息无法完整重建');
|
||||
// 22 is the observed legacy image default; existing image dimensions cannot be guessed.
|
||||
const pictureType = wireInteger(firstPresent(picture['pictype'], picture['type']), '22');
|
||||
const originalHeight = firstPresent(picture['height'], picture['b_height']);
|
||||
const originalWidth = firstPresent(picture['width'], picture['b_width']);
|
||||
if (originalHeight === undefined || originalWidth === undefined) throw new Error('QQ空间说说图片尺寸不完整');
|
||||
const height = wireInteger(originalHeight, '', 1);
|
||||
const width = wireInteger(originalWidth, '', 1);
|
||||
richValues.push(`,${albumId},${lloc},${lloc},${pictureType},${height},${width},,0,0`);
|
||||
boValues.push(pictureBo(picture));
|
||||
}
|
||||
if (boValues.some(value => value !== undefined) && boValues.some(value => value === undefined)) throw new Error('QQ空间图片元数据数量不完整');
|
||||
const boGroup = boValues.every(value => value !== undefined) ? boValues.join(',') : '';
|
||||
const originalRichValue = wireText(source['richval'], '');
|
||||
if (richValues.length === 0 && originalRichValue !== '') throw new Error('QQ空间说说包含无法完整重建的媒体数据');
|
||||
const subtype = wireInteger(firstPresent(source['t1_subtype'], source['subrichtype']), richValues.length ? '1' : '', 0, true);
|
||||
// Subtype 2 was observed on plain text, without either rich-type field or pictures.
|
||||
const observedPlainTextSubtype = subtype === '2' && hasObservedEmptyForwardContent(source) &&
|
||||
pics.length === 0 && source['richtype'] === undefined && source['subrichtype'] === undefined;
|
||||
if (!['', '0', '1'].includes(subtype) && !observedPlainTextSubtype) throw new Error('该说说的媒体子类型暂不支持完整重建');
|
||||
const sign = wireInteger(source['to_sign'], '0');
|
||||
if (sign !== '0' && sign !== '1') throw new Error('QQ空间说说同步标记无效');
|
||||
const rightId = wireText(source['ugcright_id'], tid);
|
||||
assertIdentity(rightId);
|
||||
const params = new URLSearchParams({
|
||||
syn_tweet_verson: '1',
|
||||
tid,
|
||||
paramstr: '1',
|
||||
pic_template: wireText(source['pic_template'], ''),
|
||||
richtype: richType,
|
||||
richval: richValues.join('\t'),
|
||||
special_url: '',
|
||||
subrichtype: subtype,
|
||||
pic_bo: boGroup ? `${boGroup}\t${boGroup}` : '',
|
||||
con: rebuildContent(source),
|
||||
feedversion: wireInteger(source['feedversion'], '1', 1),
|
||||
ver: wireInteger(source['ver'], '1', 1),
|
||||
ugc_right: String(permission.right),
|
||||
to_sign: sign,
|
||||
ugcright_id: rightId,
|
||||
hostuin: selfUin,
|
||||
code_version: wireInteger(source['code_version'], '1', 1),
|
||||
format: 'fs',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
|
||||
});
|
||||
if (permission.right === 16 || permission.right === 128) params.set('allow_uins', permission.targetUins.join('|'));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function buildQzoneBanBody (selfUin: string, targetUin: string, enable: boolean): string {
|
||||
assertUin(selfUin);
|
||||
assertUin(targetUin);
|
||||
if (typeof enable !== 'boolean') throw new Error('空间黑名单状态必须是布尔值');
|
||||
return new URLSearchParams({
|
||||
uin: selfUin,
|
||||
act_uin: targetUin,
|
||||
action: enable ? '1' : '2',
|
||||
fupdate: '1',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
|
||||
}).toString();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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;
|
||||
t1_time?: string | number;
|
||||
t1_ntime?: string | number;
|
||||
now?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export function parseQzonePublishTime (value: unknown): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const time = (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) ? Number(value) : NaN;
|
||||
if (!Number.isSafeInteger(time) || time < 0) throw new Error('QQ空间发表时间无效,结果需查询确认,勿自动重试');
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析发表说说接口返回的 JSON, 提取 tid
|
||||
*/
|
||||
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; time: number; } {
|
||||
for (const code of [json.subcode, json.code]) {
|
||||
if (code !== undefined && code !== 0) throw new Error('QQ空间发说说失败,服务端拒绝请求');
|
||||
}
|
||||
const tid = json.t1_tid || json.tid;
|
||||
if (!tid) {
|
||||
throw new Error('QQ空间发说说失败, 未返回tid');
|
||||
}
|
||||
// t1_ntime is Unix seconds; current CGI t1_time is a display label such as "22:08".
|
||||
const displayTime = typeof json.t1_time === 'string' && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(json.t1_time);
|
||||
const time = json.t1_ntime ?? (displayTime ? json.now : json.t1_time ?? json.now);
|
||||
// SL uses zero when no machine-readable creation time is supplied, never the local clock.
|
||||
return { tid, time: parseQzonePublishTime(time) };
|
||||
}
|
||||
|
||||
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,386 @@
|
||||
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;
|
||||
mutliPicInfo?: MutliPicInfo;
|
||||
sAlbumID: string;
|
||||
sAlbumName: string;
|
||||
sPicDesc: string;
|
||||
sPicPath: string;
|
||||
sPicTitle: string;
|
||||
stExtendInfo?: StExtendInfo;
|
||||
// 视频主体上传专用
|
||||
extend_info?: VideoExtendInfo;
|
||||
iFlag?: number;
|
||||
iIsFormatF20?: number;
|
||||
iIsNew?: number;
|
||||
iIsOriginalVideo?: number;
|
||||
iPlayTime?: number;
|
||||
sCoverUrl?: string;
|
||||
sDesc?: string;
|
||||
sTitle?: string;
|
||||
// 视频封面二段上传专用
|
||||
stExternalMapExt?: StExternalMapExt;
|
||||
sExif_CameraMaker?: string;
|
||||
sExif_CameraModel?: string;
|
||||
sExif_Latitude?: string;
|
||||
sExif_LatitudeRef?: string;
|
||||
sExif_Longitude?: string;
|
||||
sExif_LongitudeRef?: string;
|
||||
sExif_Time?: string;
|
||||
}
|
||||
|
||||
// 多图同批上传时描述「本批共几张 / 当前第几张」,相册里据此合并成同一条
|
||||
export interface MutliPicInfo {
|
||||
iBatUploadNum: string;
|
||||
iCurUpload: string;
|
||||
iSuccNum: number;
|
||||
iFailNum: number;
|
||||
}
|
||||
|
||||
// 视频主体上传用(视频不走 stExtendInfo.mapParams,而是这段扩展信息)
|
||||
export interface VideoExtendInfo {
|
||||
video_type: string;
|
||||
domainid: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
batch_num: string;
|
||||
qun_id: string;
|
||||
}
|
||||
|
||||
// 视频封面二段上传用:声明这是客户端上传的封面,且与图片混合在同一条动态里
|
||||
export interface StExternalMapExt {
|
||||
is_client_upload_cover: string;
|
||||
is_pic_video_mix_feeds: string;
|
||||
}
|
||||
|
||||
export interface MapExt {
|
||||
appid: string;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export interface StExtendInfo {
|
||||
mapParams: MapParams;
|
||||
}
|
||||
|
||||
export interface MapParams {
|
||||
batch_num: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
// 视频封面二段上传时用它把封面挂到已上传的视频上
|
||||
vid?: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
deviceInfo: string;
|
||||
refer: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
appid: number;
|
||||
data: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
// 一次上传的批次信息:同批的多张图必须共用 batchId,相册里才会显示成同一条。
|
||||
export interface QunAlbumBatch {
|
||||
batchId: number,
|
||||
count: string,
|
||||
index: string
|
||||
}
|
||||
|
||||
let lastQunAlbumBatchId = 0;
|
||||
|
||||
// 批次号取秒级时间戳。同一秒里发起的两次上传要错开,不然会被相册并成同一条
|
||||
export function nextQunAlbumBatchId () {
|
||||
lastQunAlbumBatchId = Math.max(Math.floor(Date.now() / 1000), lastQunAlbumBatchId + 1);
|
||||
return lastQunAlbumBatchId;
|
||||
}
|
||||
|
||||
// control_req 的外层(鉴权、来源、会话),图片、视频、封面都一样
|
||||
function buildQunAlbumControlReq (
|
||||
{ uin, pskey, appid, checksum, check_type, file_len, refer = 'qzone', cmd }: {
|
||||
uin: string,
|
||||
pskey: string,
|
||||
appid: string,
|
||||
checksum: string,
|
||||
check_type: number,
|
||||
file_len: number,
|
||||
refer?: string,
|
||||
cmd?: string
|
||||
},
|
||||
biz_req: BizReq
|
||||
): ControlReq {
|
||||
const req: ControlReq = {
|
||||
uin,
|
||||
token: { type: 4, data: pskey, appid: 5 },
|
||||
appid,
|
||||
checksum,
|
||||
check_type,
|
||||
file_len,
|
||||
env: { refer, deviceInfo: 'h5' },
|
||||
model: 0,
|
||||
biz_req,
|
||||
session: '',
|
||||
asy_upload: 0,
|
||||
};
|
||||
if (cmd) req.cmd = cmd;
|
||||
return req;
|
||||
}
|
||||
|
||||
// biz_req 里三种上传都要带的基础字段
|
||||
function buildQunAlbumBizBase (
|
||||
{ iUploadType, sAlbumName = '', sAlbumID = '', iBatchID = 0, sPicTitle = '' }: {
|
||||
iUploadType: number,
|
||||
sAlbumName?: string,
|
||||
sAlbumID?: string,
|
||||
iBatchID?: number,
|
||||
sPicTitle?: string
|
||||
}
|
||||
): BizReq {
|
||||
return {
|
||||
sPicTitle,
|
||||
sPicDesc: '',
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
iAlbumTypeID: 0,
|
||||
iBitmap: 0,
|
||||
iUploadType,
|
||||
iUpPicType: 0,
|
||||
iBatchID,
|
||||
sPicPath: '',
|
||||
iPicWidth: 0,
|
||||
iPicHight: 0,
|
||||
iWaterType: 0,
|
||||
iDistinctUse: 0,
|
||||
iUploadTime: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// 进相册的那一步(图片、视频封面)要带的群与批次信息
|
||||
function buildQunAlbumFeedFields (group_id: string, mapParams: MapParams, batchIndex: string) {
|
||||
return {
|
||||
iNeedFeeds: 1,
|
||||
mapExt: { appid: 'qun', userid: group_id },
|
||||
stExtendInfo: { mapParams },
|
||||
mutliPicInfo: {
|
||||
iBatUploadNum: mapParams.batch_num,
|
||||
iCurUpload: batchIndex,
|
||||
iSuccNum: 0,
|
||||
iFailNum: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function qunAlbumControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
pic_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
photo_num = '1',
|
||||
video_num = '0',
|
||||
batch_num = '1',
|
||||
batchId = nextQunAlbumBatchId(),
|
||||
batchIndex = '0',
|
||||
}: {
|
||||
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,
|
||||
batchId?: number,
|
||||
batchIndex?: string
|
||||
}
|
||||
): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'qun', checksum: pic_md5, check_type: 0, file_len: img_size, cmd: 'FileUpload' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 0, sAlbumName, sAlbumID, iBatchID: batchId, sPicTitle: img_name }),
|
||||
...buildQunAlbumFeedFields(group_id, { photo_num, video_num, batch_num }, batchIndex),
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频主体上传的控制体。
|
||||
*
|
||||
* 视频这一步不进相册(相册留空,iBatchID 为 0),只是先把视频传成素材;
|
||||
* 分片响应里拿到 sVid 后,再由封面上传(qunAlbumVideoCoverControl)把视频挂进相册。
|
||||
*/
|
||||
export function qunAlbumVideoControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
video_sha1,
|
||||
video_size,
|
||||
play_time = 0,
|
||||
photo_num = '0',
|
||||
video_num = '1',
|
||||
batch_num = '1',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
video_sha1: string,
|
||||
video_size: number,
|
||||
play_time?: number,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string
|
||||
}): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'video_qun', checksum: video_sha1, check_type: 1, file_len: video_size, cmd: 'FileUploadVideo' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 3 }),
|
||||
sTitle: '',
|
||||
sDesc: '',
|
||||
sCoverUrl: '',
|
||||
iFlag: 0,
|
||||
iPlayTime: play_time,
|
||||
iIsNew: 111,
|
||||
iIsOriginalVideo: 0,
|
||||
iIsFormatF20: 0,
|
||||
extend_info: {
|
||||
video_type: '3',
|
||||
domainid: '5',
|
||||
photo_num,
|
||||
video_num,
|
||||
batch_num,
|
||||
qun_id: group_id,
|
||||
},
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频封面上传的控制体。
|
||||
*
|
||||
* 按图片上传,但 iUploadType 为 2,并用 mapParams.vid 指向已上传的视频。
|
||||
* 相册和批次号只在这一步出现,视频是靠封面进的相册。
|
||||
*/
|
||||
export function qunAlbumVideoCoverControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
cover_md5,
|
||||
cover_size,
|
||||
vid,
|
||||
batchId = nextQunAlbumBatchId(),
|
||||
batchIndex = '0',
|
||||
photo_num = '0',
|
||||
video_num = '1',
|
||||
batch_num = '1',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
sAlbumName: string,
|
||||
sAlbumID: string,
|
||||
cover_md5: string,
|
||||
cover_size: number,
|
||||
vid: string,
|
||||
batchId?: number,
|
||||
batchIndex?: string,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string
|
||||
}): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'qun', checksum: cover_md5, check_type: 0, file_len: cover_size, refer: 'huodong' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 2, sAlbumName, sAlbumID, iBatchID: batchId }),
|
||||
...buildQunAlbumFeedFields(group_id, { vid, photo_num, video_num, batch_num }, batchIndex),
|
||||
// 标明封面由客户端上传,且允许和图片出现在同一条动态里
|
||||
stExternalMapExt: {
|
||||
is_client_upload_cover: '1',
|
||||
is_pic_video_mix_feeds: '1',
|
||||
},
|
||||
sExif_CameraMaker: '',
|
||||
sExif_CameraModel: '',
|
||||
sExif_Time: '',
|
||||
sExif_LatitudeRef: '',
|
||||
sExif_Latitude: '',
|
||||
sExif_LongitudeRef: '',
|
||||
sExif_Longitude: '',
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user