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

+1000
View File
@@ -0,0 +1,1000 @@
import { RequestUtil } from 'snowcat-common/src/request';
import {
GroupEssenceMsgRet,
InstanceContext,
WebApiGroupMember,
WebApiGroupMemberRet,
WebApiGroupNoticeRet,
WebHonorType, SnowCatCore,
} from '@/snowcat-core/index';
import { createReadStream, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { qunAlbumControl } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
QzoneDeleteResponse,
QzonePublishResponse,
} from '../data/qzone';
// SnowCat · qzone Web 线扩展(SnowLuma 移植):读列表/详情/点赞/评论/回复/资料/拉黑/权限
import {
decodeQzoneFeedCursor,
encodeQzoneFeedCursor,
initialQzoneFeedState,
mapQzoneFeeds,
nextQzoneFeedState,
qzoneFeedPosition,
type QzoneFeedState,
type RawFeedsResponse,
} from '../data/qzone-feeds';
import { parseQzoneCallback } from '../data/qzone-web';
import {
buildQzoneBlackBody,
buildQzoneCommentBody,
buildQzoneLikeBody,
buildQzoneMsgDetailQuery,
buildQzoneProfileQuery,
buildQzoneProfileUpdateBody,
buildQzoneReplyBody,
legacyMsgListRoute,
mapQzoneMsgDetail,
mapQzoneMsgList,
parseQzoneBlackResponse,
parseQzoneCommentResponse,
parseQzoneDetailError,
parseQzoneJson,
parseQzoneLikeResponse,
parseQzoneProfileResponse,
parseQzoneProfileUpdateResponse,
parseQzonePublishFull,
parseQzoneUpdateRightResponse,
userMsgListRoute,
buildQzoneUpdatePayload,
type QzoneProfile,
type QzoneProfileUpdate,
type QzoneReplyTarget,
type QzoneUpdateRightResult,
type RawMsgListResponse,
type RawMsgDetailResponse,
type QzoneRoute,
} from '../data/qzone-web';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
export class NTQQWebApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
group_code: groupCode,
msg_seq: msgSeq,
msg_random: msgRandom,
target_group_code: targetGroupCode,
}).toString()}`;
try {
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
} catch {
return undefined;
}
}
async getGroupEssenceMsgAll (GroupCode: string) {
const ret: GroupEssenceMsgRet[] = [];
for (let i = 0; i < 20; i++) {
const data = await this.getGroupEssenceMsg(GroupCode, i, 50);
if (!data) break;
ret.push(data);
if (data.data.is_end) break;
}
return ret;
}
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
page_start: page_start.toString(),
page_limit: page_limit.toString(),
group_code: GroupCode,
}).toString()}`;
try {
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
url,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret.retcode === 0 ? ret : undefined;
} catch {
return undefined;
}
}
async getGroupMembers (GroupCode: string): Promise<WebApiGroupMember[]> {
// logDebug('webapi 获取群成员', GroupCode);
const memberData: Array<WebApiGroupMember> = new Array<WebApiGroupMember>();
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const retList: Promise<WebApiGroupMemberRet>[] = [];
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: '0',
end: '40',
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
return [];
} else {
for (const key in fastRet.mems) {
if (fastRet.mems[key]) {
memberData.push(fastRet.mems[key]);
}
}
}
// 初始化获取PageNum
const PageNum = Math.ceil(fastRet.count / 40);
// 遍历批量请求
for (let i = 2; i <= PageNum; i++) {
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: ((i - 1) * 40).toString(),
end: (i * 40).toString(),
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
retList.push(ret);
}
// 批量等待
for (let i = 1; i <= PageNum; i++) {
const ret = await (retList[i]);
if (!ret?.count || ret?.errcode !== 0 || !ret?.mems) {
continue;
}
for (const key in ret.mems) {
if (ret.mems[key]) {
memberData.push(ret.mems[key]);
}
}
}
return memberData;
}
// public async addGroupDigest(groupCode: string, msgSeq: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/cancel_digest?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&msg_seq=${msgSeq}&msg_random=444021292`;
// const res = await this.request(url);
// return await res.json();
// }
// public async getGroupDigest(groupCode: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&page_start=0&page_limit=20`;
// const res = await this.request(url);
// return await res.json();
// }
async setGroupNotice (
GroupCode: string,
Content: string,
pinned: number = 0,
type: number = 1,
is_show_edit_card: number = 1,
tip_window_type: number = 1,
confirm_required: number = 1,
picId: string = '',
imgWidth: number = 540,
imgHeight: number = 300
) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const settings = JSON.stringify({
is_show_edit_card,
tip_window_type,
confirm_required,
});
const externalParam = {
pic: picId,
imgWidth: imgWidth.toString(),
imgHeight: imgHeight.toString(),
};
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
text: Content,
pinned: pinned.toString(),
type: type.toString(),
settings,
...(picId === '' ? {} : externalParam),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret;
} catch {
return undefined;
}
}
async getGroupNotice (GroupCode: string): Promise<undefined | WebApiGroupNoticeRet> {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
ft: '23',
ni: '1',
n: '1',
i: '1',
log_read: '1',
platform: '1',
s: '-1',
}).toString()}&n=20`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret?.ec === 0 ? ret : undefined;
} catch {
return undefined;
}
}
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
let resJson;
try {
const res = await RequestUtil.HttpGetText(
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
gc: groupCode,
type: type.toString(),
}).toString()}`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match?.[1]) {
resJson = JSON.parse(match[1].trim());
}
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
} catch (e) {
this.context.logger.logDebug('获取当前群荣耀失败', e);
return undefined;
}
}
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
const data = await this.getDataInternal(cookieObject, groupCode, type);
if (!data) {
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
return [];
}
return data.map((item: {
uin: string,
name: string,
avatar: string,
desc: string,
}) => ({
user_id: item?.uin,
nickname: item?.name,
avatar: item?.avatar,
description: item?.desc,
}));
}
async getGroupHonorInfo (groupCode: string, getType: WebHonorType) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const HonorInfo = {
group_id: Number(groupCode),
current_talkative: {},
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) {
const talkativeList = await this.getHonorList(cookieObject, groupCode, 1);
if (talkativeList.length > 0) {
HonorInfo.current_talkative = talkativeList[0];
HonorInfo.talkative_list = talkativeList;
}
}
if (getType === WebHonorType.PERFORMER || getType === WebHonorType.ALL) {
HonorInfo.performer_list = await this.getHonorList(cookieObject, groupCode, 2);
}
if (getType === WebHonorType.LEGEND || getType === WebHonorType.ALL) {
HonorInfo.legend_list = await this.getHonorList(cookieObject, groupCode, 3);
}
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.emotion_list = await this.getHonorList(cookieObject, groupCode, 6);
}
// 冒尖小春笋好像已经被tx扬了 R.I.P.
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.strong_newbie_list = [];
}
return HonorInfo;
}
private cookieToString (cookieObject: { [key: string]: string; }) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
}
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
const sKey = cookieObject['skey'] as string;
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromSKey (sKey: string) {
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromPSKey (psKey: string) {
return this.getBknFromSKey(psKey);
}
async getAlbumListByNTQQ (gc: string, attach_info: string = '') {
return await this.context.session.getAlbumService().getAlbumList({
qun_id: gc,
attach_info,
seq: 3331,
request_time_line: {
request_invoke_time: '0',
},
});
}
async getAlbumList (gc: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const bkn = this.getBknFromSKey(skey);
const uin = this.core.selfInfo.uin || '10001';
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?';
const params = new URLSearchParams({
random: '7570',
g_tk: bkn,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
cmd: 'qunGetAlbumList',
qunId: gc,
qunid: gc,
start: '0',
num: '1000',
uin,
getMemberRole: '0',
});
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
Cookie: cookies,
});
return response.data.album;
}
async createQunAlbumSession (gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) {
const img = readFileSync(path);
const img_size = img.length;
const img_name = basename(path);
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const body = qunAlbumControl({
uin,
group_id: gc,
pskey,
pic_md5: img_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
async uploadQunAlbumSlice (path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) {
const img_size = statSync(path).size;
let seq = 0;
let offset = 0;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const stream = createReadStream(path, { highWaterMark: slice_size });
for await (const chunk of stream) {
const end = Math.min(offset + chunk.length, img_size);
const form = new FormData();
form.append('uin', uin);
form.append('appid', 'qun');
form.append('session', session);
form.append('offset', offset.toString());
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
form.append('checksum', '');
form.append('check_type', '0');
form.append('retry', '0');
form.append('seq', seq.toString());
form.append('end', end.toString());
form.append('cmd', 'FileUpload');
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${GTK}`;
const response = await fetch(api, {
method: 'POST',
headers: {
Cookie: cookie,
},
body: form,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json() as { ret: number, msg: string; }; if (post.ret !== 0) {
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
}
offset += chunk.length;
seq++;
}
return { success: true, message: '上传完成' };
}
async uploadImageToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const img_md5 = createHash('md5').update(readFileSync(path)).digest('hex');
const uin = this.core.selfInfo.uin || '10001';
const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session;
if (!session) throw new Error('创建群相册会话失败');
await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384);
}
async getAlbumMediaListByNTQQ (gc: string, albumId: string, attach_info: string = '') {
return (await this.context.session.getAlbumService().getMediaList({
qun_id: gc,
attach_info,
seq: 0,
request_time_line: {
request_invoke_time: '0',
},
album_id: albumId,
lloc: '',
batch_id: '',
})).response;
}
async doAlbumMediaPlainCommentByNTQQ (
qunId: string,
albumId: string,
lloc: string,
content: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
// 16位number数字
const client_key = Date.now() * 1000;
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
createAlbumCommentRequest(uin, content, client_key)
);
}
async deleteAlbumMediaByNTQQ (
qunId: string,
albumId: string,
lloc: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
return await this.context.session.getAlbumService().deleteMedias(
random_seq,
qunId,
albumId,
[lloc],
[]
);
}
async doAlbumMediaLikeByNTQQ (
qunId: string,
albumId: string,
batchId: string,
lloc: string | undefined,
isLike: boolean
) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
const type = isLike ? 2 : 1;
const status = isLike ? 0 : 1;
let id = '';
if (lloc) {
id = `421_1_0_${qunId}|${albumId}|${batchId}^||^421_1_0_${qunId}|${albumId}|${lloc}^||^0`;
} else {
id = `421_1_0_${qunId}|${albumId}|${batchId}`;
}
return await this.context.session.getAlbumService().doQunLike(
random_seq,
{
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
type,
{
id,
status,
},
createAlbumFeedPublish(qunId, uin, albumId, batchId)
);
}
private async getQzoneAuth () {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const uin = this.core.selfInfo.uin || '10001';
const g_tk = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
return { skey, pskey, uin, g_tk, cookie };
}
/**
* 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串
* @param base64 图片Base64编码内容(不带data:前缀)
*/
async uploadImageToQzone (base64: string): Promise<string> {
const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 });
const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
const parsed = parseQzoneUploadImageResponseText(resultText);
if (parsed.code !== undefined && parsed.code !== 0) {
throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`);
}
if (!parsed.data) {
throw new Error('QQ空间图片上传失败, 响应缺少data字段');
}
return toQzoneRichval(parsed.data);
}
/**
* 发表QQ空间说说
* @param content 说说正文
* @param richvals 每张图片对应的richval数组, 为空表示纯文字说说
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
*/
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: number[]) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
const body = buildQzonePublishBody({
hostuin: uin,
content,
richval,
ugcRight,
allowUins,
});
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzonePublishResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzonePublishResponse(result);
}
/**
* 删除QQ空间说说
* @param tid 说说Tid, 来自 publishQzoneMsg 的返回值
*/
async deleteQzoneMsg (tid: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneDeleteBody({ hostuin: uin, tid });
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzoneDeleteResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzoneDeleteResponse(result);
}
async getDaySignedList (groupCode: string) {
const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
const selfUin = this.core.selfInfo.uin;
const cookie = `p_uin=o${selfUin}; p_skey=${pSkey}; uin=o${selfUin}`;
const post = await RequestUtil.HttpGetJson<{
retCode: number,
costTime: number,
response: {
ret?: {
code: string,
msg: string,
},
page?: {
infos?: {
uid: string,
uidGroupNick: string,
signedTimeStamp: string,
signInRank: number,
}[],
offset: number,
total: number,
}[],
},
funcCode: number,
}>(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${this.getBknFromPSKey(pSkey)}`, 'POST', {
dayYmd: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
offset: 0,
limit: 100,
uid: selfUin,
groupId: groupCode,
}, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
// ───────────── SnowCat · qzone Web 线扩展(SnowLuma 移植) ─────────────
/** 获取说说列表(默认自己空间;-10000 限频自动切 user.qzone 备路) */
async getQzoneMsgList (targetUin?: string, pos = 0, num = 20) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const host = targetUin ?? uin;
let route: QzoneRoute = legacyMsgListRoute(cookie, host, pos, num, g_tk);
let text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
let data = parseQzoneJson<RawMsgListResponse>(text);
// -10000「使用人数过多」是 legacy 网关限频,切备路重试一次
if (data.code === -10000) {
route = userMsgListRoute(cookie, host, pos, num, g_tk);
text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
data = parseQzoneJson<RawMsgListResponse>(text);
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone msglist failed: code=${data.code} ${data.message ?? ''}`.trim());
}
return mapQzoneMsgList(data);
}
/** 获取单条说说详情 + 一页评论(含内联回复) */
async getQzoneMsgDetail (targetUin: string, tid: string, pos = 0, num = 20) {
const { g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneMsgDetailQuery(targetUin, tid, g_tk, { pos, num });
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneJson<RawMsgDetailResponse>(text);
parseQzoneDetailError(data, tid);
return mapQzoneMsgDetail(data, tid);
}
/** 点赞/取消赞一条说说(abstime 传目标帖的发帖时间更稳,0 可容忍) */
async setQzoneLike (targetUin: string, tid: string, like: boolean, abstime = 0) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const verb = like ? 'like' : 'unlike';
const cgi = like ? 'internal_dolike_app' : 'internal_unlike_app';
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/likes/${cgi}?g_tk=${g_tk}`;
const body = buildQzoneLikeBody(uin, targetUin, tid, abstime);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneLikeResponse(text, verb);
}
/** 评论一条说说(richval 传图片直链,与发说说的 richval 串不同) */
async commentQzoneMsg (hostUin: string, tid: string, content: string, richval?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds?g_tk=${g_tk}`;
const body = buildQzoneCommentBody(uin, hostUin, tid, content, richval ? 1 : undefined, richval);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneCommentResponse(text, hostUin, tid);
}
/** 楼中楼回复(独立端点 emotion_cgi_addreply_ugc;replyTo 自动转 @ 提及) */
async replyQzoneComment (hostUin: string, tid: string, content: string, target: QzoneReplyTarget) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${g_tk}`;
const body = buildQzoneReplyBody(uin, hostUin, tid, content, target);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
// 复用评论解析:回复的 id 在 data.id(与评论的 commentid 不同名)
const response = parseQzoneJson<{ code?: number; subcode?: number; message?: string; data?: { id?: string | number } }>(text);
if (typeof response.code === 'number' && response.code !== 0) {
throw new Error(`qzone reply failed: code=${response.code} ${response.message ?? ''}`.trim());
}
const id = response.data?.id;
return { comment_id: id !== undefined ? String(id) : '' };
}
/** 发说说(富返回:tid + time;SnowCat 原版 publishQzoneMsg 只返回 tid,保留不动) */
async publishQzoneMsgFull (content: string, richvals: string[], ugcRight: number, targetUins?: string[]) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
const body = buildQzonePublishBody({ hostuin: uin, content, richval, ugcRight, allowUins });
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzonePublishFull(resultText);
}
/** 获取空间名称与说明(默认自己,可查别人——需有访问权) */
async getQzoneProfile (targetUin?: string): Promise<QzoneProfile> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneProfileQuery(uin, targetUin ?? uin, g_tk);
const url = `https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
return parseQzoneProfileResponse(text);
}
/** 修改空间名称/说明(只动传了的字段;空串=清空) */
async setQzoneProfile (profile: QzoneProfileUpdate): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneProfileUpdateBody(uin, profile);
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneProfileUpdateResponse(text);
}
/** 空间拉黑/解拉黑(action=1 拉黑 / 2 解拉黑) */
async setQzoneBlack (targetUin: string, ban: boolean): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new?g_tk=${g_tk}`;
const body = buildQzoneBlackBody(uin, targetUin, ban);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneBlackResponse(text, ban);
}
/** 改已有说说的查看权限(msgdetail_v6 → emotion_cgi_update 两步全量重提) */
async setQzoneMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<QzoneUpdateRightResult> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
// 第一步:拉详情
const detailQuery = buildQzoneMsgDetailQuery(uin, tid, g_tk);
const detailUrl = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${detailQuery}`;
const detailText = await RequestUtil.HttpGetText(detailUrl, 'GET', '', { Cookie: cookie });
const detail = parseQzoneJson<RawMsgDetailResponse>(detailText);
parseQzoneDetailError(detail, tid);
// 第二步:全量重提 + 覆写权限
const bodyParams = buildQzoneUpdatePayload(detail, uin);
bodyParams.set('ugc_right', String(ugcRight));
if (targetUins && targetUins.length > 0) {
bodyParams.set('allow_uins', targetUins.join('|'));
}
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', bodyParams.toString(), {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneUpdateRightResponse(text, tid, ugcRight);
}
/**
* 获取空间好友动态流(feeds3_html_more,HTML 载荷数据级解析)。
* cursor 传上次返回的 next_cursor 续翻;不传从第 page_num 页走。
*/
async getQzoneFeeds (pageNum = 1, count = 10, cursor?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
throw new Error('空间好友动态页码或数量无效');
}
const start = cursor === undefined
? { page: 1, state: initialQzoneFeedState() }
: decodeQzoneFeedCursor(cursor, uin);
if (cursor !== undefined && pageNum !== 1 && pageNum !== start.page) {
throw new Error('page_num 与空间好友动态 cursor 不匹配');
}
const target = cursor === undefined ? pageNum : start.page;
let state: QzoneFeedState = start.state;
const seen = new Set([qzoneFeedPosition(state)]);
for (let page = start.page; page <= target; page++) {
const query = new URLSearchParams({
uin,
scope: '0',
view: '1',
gid: '',
flag: '1',
filter: 'all',
applist: 'all',
refresh: '0',
firstGetGroup: '0',
mixnocache: '0',
scene: '0',
sidomain: 'qzonestyle.gtimg.cn',
count: String(count),
...state,
getAisort: state.aisortNextTime,
g_tk: g_tk,
callback: '_preloadCallback',
format: 'jsonp',
useutf8: '1',
outputhtmlfeed: '1',
usertime: String(Date.now()),
rd: String(Math.random()),
windowId: String(Math.random()),
});
query.delete('aisortNextTime');
const url = `https://h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${query}`;
const body = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneCallback<RawFeedsResponse>(body);
if (data.code !== 0 || (data.subcode !== undefined && data.subcode !== 0)) {
throw new Error(
`qzone feeds failed: code=${data.code} subcode=${data.subcode ?? 0} ${typeof data.message === 'string' ? data.message : ''}`.trim(),
);
}
const main = data.data?.main;
if (main !== undefined && (main === null || typeof main !== 'object' || Array.isArray(main))) {
throw new Error('空间好友动态分页信息结构异常');
}
if (main?.['error'] !== undefined && main['error'] !== '') {
throw new Error('空间好友动态服务端报告拉取失败');
}
const result = mapQzoneFeeds(data);
if (result.has_more) {
if (!main) throw new Error('空间好友动态缺少下一页游标');
state = nextQzoneFeedState(main, state);
const position = qzoneFeedPosition(state);
if (seen.has(position)) throw new Error('空间好友动态分页游标未推进');
seen.add(position);
result.next_cursor = encodeQzoneFeedCursor(uin, page + 1, state);
}
if (page === target) return result;
if (!result.has_more) {
return { feeds: [], has_more: false, next_cursor: null };
}
}
throw new Error('空间好友动态分页未完成');
}
// ───────────── SnowCat · qzone 公共空间(空友)代理(SnowLuma 移植) ─────────────
async getQzonePublicCapabilities (cookie: string, bkn: string, uin: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicCapabilities(cookie, bkn, uin);
}
async getQzonePublicFeeds (cookie: string, bkn: string, uin: string, cursor?: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeeds(cookie, bkn, uin, cursor);
}
async getQzonePublicFeedDetail (cookie: string, bkn: string, feed: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeedDetail(cookie, bkn, feed);
}
async getQzonePublicComments (cookie: string, bkn: string, feed: Record<string, unknown>, cursor: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicComments(cookie, bkn, feed, cursor);
}
async getQzonePublicReplies (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, cursor: string, pageSize: number) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicReplies(cookie, bkn, feed, comment, cursor, pageSize);
}
async setQzonePublicUpvote (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicUpvote(cookie, bkn, feed, enable);
}
async setQzonePublicLike (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicLike(cookie, bkn, feed, enable);
}
async setQzonePublicCommentLike (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, enable: boolean, reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicCommentLike(cookie, bkn, feed, comment, enable, reply);
}
async commentQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, content: string | Record<string, unknown>[]) {
const impl = await import('./qzone-public-web');
return impl.commentQzonePublicFeed(cookie, bkn, feed, user, content);
}
async replyQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, comment: Record<string, unknown>, content: string | Record<string, unknown>[], reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.replyQzonePublicFeed(cookie, bkn, feed, user, comment, content, reply);
}
async deleteQzonePublicComment (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicComment(cookie, bkn, feed, comment);
}
async deleteQzonePublicReply (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, reply: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicReply(cookie, bkn, feed, comment, reply);
}
async setQzonePublicFollow (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicFollow(cookie, bkn, feed, enable);
}
async forwardQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, reason: string) {
const impl = await import('./qzone-public-web');
return impl.forwardQzonePublicFeed(cookie, bkn, feed, reason);
}
}