Files
天天的鸟蛋蛋 b9655a5ab1 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
2026-11-12 06:27:36 +08:00

1001 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}