SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

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