NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+226
View File
@@ -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,
},
};
}
+368
View File
@@ -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,
};
}
+386
View File
@@ -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,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './group';
+146
View File
@@ -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,
};
}
+282
View File
@@ -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;
}
+365
View File
@@ -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);
}
+310
View File
@@ -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) };
}
+396
View File
@@ -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();
}
+212
View File
@@ -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 };
}
+386
View File
@@ -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: '',
}
)],
};
}