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( url, 'GET', '', { Cookie: this.cookieToString(cookieObject) } ); return ret.retcode === 0 ? ret : undefined; } catch { return undefined; } } async getGroupMembers (GroupCode: string): Promise { // logDebug('webapi 获取群成员', GroupCode); const memberData: Array = new Array(); const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); const retList: Promise[] = []; const fastRet = await RequestUtil.HttpGetJson( `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( `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( `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 { const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); try { const ret = await RequestUtil.HttpGetJson( `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 { 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(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(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(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(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(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 { 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 { 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 { 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 { 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(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(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) { const impl = await import('./qzone-public-web'); return impl.getQzonePublicFeedDetail(cookie, bkn, feed); } async getQzonePublicComments (cookie: string, bkn: string, feed: Record, cursor: string) { const impl = await import('./qzone-public-web'); return impl.getQzonePublicComments(cookie, bkn, feed, cursor); } async getQzonePublicReplies (cookie: string, bkn: string, feed: Record, comment: Record, 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, enable: boolean) { const impl = await import('./qzone-public-web'); return impl.setQzonePublicUpvote(cookie, bkn, feed, enable); } async setQzonePublicLike (cookie: string, bkn: string, feed: Record, enable: boolean) { const impl = await import('./qzone-public-web'); return impl.setQzonePublicLike(cookie, bkn, feed, enable); } async setQzonePublicCommentLike (cookie: string, bkn: string, feed: Record, comment: Record, enable: boolean, reply?: Record) { const impl = await import('./qzone-public-web'); return impl.setQzonePublicCommentLike(cookie, bkn, feed, comment, enable, reply); } async commentQzonePublicFeed (cookie: string, bkn: string, feed: Record, user: Record, content: string | Record[]) { const impl = await import('./qzone-public-web'); return impl.commentQzonePublicFeed(cookie, bkn, feed, user, content); } async replyQzonePublicFeed (cookie: string, bkn: string, feed: Record, user: Record, comment: Record, content: string | Record[], reply?: Record) { 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, comment: Record) { const impl = await import('./qzone-public-web'); return impl.deleteQzonePublicComment(cookie, bkn, feed, comment); } async deleteQzonePublicReply (cookie: string, bkn: string, feed: Record, comment: Record, reply: Record) { const impl = await import('./qzone-public-web'); return impl.deleteQzonePublicReply(cookie, bkn, feed, comment, reply); } async setQzonePublicFollow (cookie: string, bkn: string, feed: Record, enable: boolean) { const impl = await import('./qzone-public-web'); return impl.setQzonePublicFollow(cookie, bkn, feed, enable); } async forwardQzonePublicFeed (cookie: string, bkn: string, feed: Record, reason: string) { const impl = await import('./qzone-public-web'); return impl.forwardQzonePublicFeed(cookie, bkn, feed, reason); } }