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

+961
View File
@@ -0,0 +1,961 @@
/**
* SnowCat · QQ空间(Qzone)个人空间 Web 线 —— 纯数据构造与解析
*
* 从 SnowLuma v2.2.1 (packages/protocol/src/web/qzone.ts) 移植改造:
* - SnowCat 已有 buildQzoneUploadImageBody/buildQzonePublishBody/buildQzoneDeleteBody
* (data/qzone.ts 原件保留),本文件只补缺的:读列表/详情/点赞/评论/回复/资料/拉黑/权限。
* - 不发起任何网络请求,方便单独测试(对齐 data/qzone.ts 头注)。
* - 统一走 NapCat 的 cookie 串(getQzoneAuth 产出的 p_uin/p_skey/skey/uin 形态),
* 不用 SnowLuma 的 cookieObject Record 形态——但保留 tolerant 解析器。
*/
// ─────────────── tolerant JSON/JSONP 解析(SnowLuma 原样移植) ───────────────
/**
* 解析 Qzone CGI body:可能是裸 JSON,也可能是 JSONP 回调包裹
* (`_Callback({...});` / `frameElement.callback({...});` / `back({...})`)。
* 分层 fallback:直接 JSON.parse {} 切片 → back({...}) → 末个 back(...) 提取。
* 无对象体时抛错(如 HTML 错误页)。
*/
export function parseQzoneJson<T>(text: string): T {
const s = text.trim();
// 1st: 直接 JSON.parse {} 切片
try {
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(s.slice(start, end + 1)) as T;
}
} catch { /* fallback */ }
// 2nd: back({...}) 整体
try {
const start = s.indexOf('back({');
const end = s.lastIndexOf('})');
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(s.slice(start + 5, end + 1)) as T;
}
} catch { /* fallback */ }
// 3rd: 找最后一个 back(...),取内部 {}
const backStart = s.lastIndexOf('back(');
if (backStart === -1) {
throw new Error('invalid response from qzone api');
}
const callEnd = s.indexOf(');', backStart);
if (callEnd === -1) {
throw new Error('invalid response from qzone api');
}
const inner = s.slice(backStart + 5, callEnd).trim();
const braceStart = inner.indexOf('{');
const braceEnd = inner.lastIndexOf('}');
if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) {
throw new Error('invalid response from qzone api');
}
return JSON.parse(inner.slice(braceStart, braceEnd + 1)) as T;
}
/**
* 解析 feeds3_html_more 的 JS 对象字面量载荷(不执行)。
* `_preloadCallback({ … })` 的 data 用无引号键/单引号串/\xNN 转义/字面 undefined
* ——给浏览器回调 eval 用的,JSON.parse 咽不下。替代 eval(vm 沙箱不是安全边界),
* 递归下降把字面量当数据解析。`__proto__` 键丢弃(防原型污染)。
* SnowLuma 原样移植。
*/
export function parseJsLiteral(src: string): unknown {
let i = 0;
const n = src.length;
const isWs = (c: string): boolean =>
c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v';
const skipWs = (): void => {
while (i < n && isWs(src[i]!)) i++;
};
function parseString(quote: string): string {
i++; // 开引号
let out = '';
while (i < n) {
const c = src[i]!;
if (c === '\\') {
const e = src[i + 1];
if (e === 'x') {
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 4), 16));
i += 4;
continue;
}
if (e === 'u') {
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
i += 6;
continue;
}
const simple: Record<string, string> = {
n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0',
};
out += e !== undefined ? (simple[e] ?? e) : '';
i += 2;
continue;
}
if (c === quote) {
i++;
return out;
}
out += c;
i++;
}
throw new Error('unterminated string in qzone feeds payload');
}
function parseKey(): string {
skipWs();
const c = src[i];
if (c === '"' || c === "'") return parseString(c);
let s = '';
while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) {
s += src[i];
i++;
}
if (!s) throw new Error('expected object key in qzone feeds payload');
return s;
}
function parseObject(): Record<string, unknown> {
i++; // {
const obj: Record<string, unknown> = {};
skipWs();
if (src[i] === '}') { i++; return obj; }
for (;;) {
const key = parseKey();
skipWs();
if (src[i] !== ':') throw new Error('expected ":" in qzone feeds payload');
i++;
const value = parseValue();
if (key !== '__proto__') obj[key] = value;
skipWs();
const ch = src[i];
if (ch === ',') {
i++;
skipWs();
if (src[i] === '}') { i++; return obj; }
continue;
}
if (ch === '}') { i++; return obj; }
throw new Error('expected "," or "}" in qzone feeds payload');
}
}
function parseArray(): unknown[] {
i++; // [
const arr: unknown[] = [];
skipWs();
if (src[i] === ']') { i++; return arr; }
for (;;) {
arr.push(parseValue());
skipWs();
const ch = src[i];
if (ch === ',') {
i++;
skipWs();
if (src[i] === ']') { i++; return arr; }
continue;
}
if (ch === ']') { i++; return arr; }
throw new Error('expected "," or "]" in qzone feeds payload');
}
}
function parseValue(): unknown {
skipWs();
const c = src[i];
if (c === undefined) throw new Error('unexpected end of qzone feeds payload');
if (c === '{') return parseObject();
if (c === '[') return parseArray();
if (c === '"' || c === "'") return parseString(c);
let token = '';
while (i < n && !/[,}\]:\s]/.test(src[i]!)) {
token += src[i];
i++;
}
if (token === 'true') return true;
if (token === 'false') return false;
if (token === 'null') return null;
if (token === 'undefined') return undefined;
if (token !== '') {
const num = Number(token);
if (!Number.isNaN(num)) return num;
}
throw new Error('unexpected token in qzone feeds payload: ' + token.slice(0, 20));
}
return parseValue();
}
/**
* 提取 Qzone feeds JSONP body 回调实参对象,按数据解析(不执行)。
* 从首个 `{` 切片解析一个平衡值;尾部 `);` 忽略。非对象体抛错。
*/
export function parseQzoneCallback<T>(text: string): T {
const start = text.indexOf('{');
if (start === -1) {
throw new Error('invalid feeds response from qzone api');
}
const value = parseJsLiteral(text.slice(start));
if (value === null || typeof value !== 'object') {
throw new Error('invalid feeds response from qzone api');
}
return value as T;
}
// ─────────────── 原始响应 shape(taotao emotion_cgi_msglist_v6) ───────────────
interface RawPic {
url1?: string;
url2?: string;
url3?: string;
smallurl?: string;
}
interface RawEmotion {
tid?: string;
content?: string;
created_time?: number;
cmtnum?: number;
secret?: number;
pic?: RawPic[];
}
export interface RawMsgListResponse {
code?: number;
subcode?: number;
message?: string;
total?: number;
msglist?: RawEmotion[] | null;
}
// ─────────────── OneBot 面向 shape ───────────────
/** 一条说说,规范化后对 OneBot 友好的形态。 */
export interface QzoneEmotion {
tid: string;
content: string;
/** 发帖 unix 秒 */
time: number;
comment_num: number;
/** 仅自己可见标记 */
is_private: boolean;
/** 每张图取最大可用的 URL */
images: string[];
}
export interface QzoneMsgListResult {
/** 该账号说说总数(非本页数量) */
total: number;
msglist: QzoneEmotion[];
}
export interface QzoneProfile {
name: string;
description: string;
}
export interface QzoneProfileUpdate {
name?: string;
description?: string;
}
export interface QzoneCommentReply {
uin: number;
name: string;
/** unix 秒 */
time: number;
content: string;
/** 在所属说说(或父评论)内唯一 */
comment_id: string;
}
export interface QzoneComment extends QzoneCommentReply {
/** Qzone 内联附带的回复;可能不含全部回复 */
replies: QzoneCommentReply[];
}
export interface QzoneMsgDetailResult {
tid: string;
content: string;
/** 服务端总数,非本页评论数 */
comment_num: number;
comments: QzoneComment[];
}
function pickPicUrl(pic: RawPic): string | undefined {
return pic.url1 ?? pic.url2 ?? pic.url3 ?? pic.smallurl;
}
/** 纯映射:msglist_v6 → 规范化列表。 */
export function mapQzoneMsgList(data: RawMsgListResponse): QzoneMsgListResult {
const list = data.msglist ?? [];
return {
total: Number(data.total ?? list.length),
msglist: list.map((e) => ({
tid: String(e.tid ?? ''),
content: e.content ?? '',
time: Number(e.created_time ?? 0),
comment_num: Number(e.cmtnum ?? 0),
is_private: Number(e.secret ?? 0) !== 0,
images: (e.pic ?? []).map(pickPicUrl).filter((u): u is string => !!u),
})),
};
}
// ─────────────── 说说列表路由(双路防 -10000 限频) ───────────────
export interface QzoneRoute {
url: string;
headers: Record<string, string>;
}
/** 主路(legacy):h5.qzone.qq.com → taotao.qzone.qq.com。 */
export function legacyMsgListRoute(
cookie: string,
targetUin: string,
pos: number,
num: number,
bkn: string,
): QzoneRoute {
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
{
uin: targetUin,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
replynum: '100',
g_tk: bkn,
callback: '_preloadCallback',
code_version: '1',
format: 'jsonp',
need_private_comment: '1',
},
).toString()}`;
return { url, headers: { Cookie: cookie } };
}
/** 备路:user.qzone.qq.com → taotao.qq.com(注意域不同)。实测不吃 legacy 路的 -10000 限频。 */
export function userMsgListRoute(
cookie: string,
targetUin: string,
pos: number,
num: number,
bkn: string,
): QzoneRoute {
const url = `https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
{
uin: targetUin,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
g_tk: bkn,
code_version: '1',
format: 'json',
},
).toString()}`;
return {
url,
headers: {
Cookie: cookie,
Referer: `https://user.qzone.qq.com/${targetUin}`,
},
};
}
// ─────────────── 权限/目标校验 ───────────────
export const QZONE_UGC_RIGHTS = new Set<number>([1, 4, 16, 64, 128]);
export function normalizeQzoneUgcRight(ugcRight: number): number {
if (!QZONE_UGC_RIGHTS.has(ugcRight)) {
throw new Error('ugc_right must be one of 1, 4, 16, 64, 128');
}
return ugcRight;
}
export function normalizeTargetUins(targetUins?: string | number[]): string {
const raw = Array.isArray(targetUins)
? targetUins.join('|')
: (targetUins ?? '').trim();
if (!raw) return '';
const seen = new Set<string>();
for (const part of raw.split('|')) {
const uin = part.trim();
if (!uin) continue;
if (!/^\d+$/.test(uin)) {
throw new Error('target_uins must contain QQ numbers separated by |');
}
seen.add(uin);
}
return [...seen].join('|');
}
// ─────────────── 发说说 body(SnowCat 已有 buildQzonePublishBody,此处为富参版) ───────────────
export interface RawPublishResponse {
code?: number;
subcode?: number;
message?: string;
// publish_v6 成功包络把新 feed id 命名为 t1_tid、时间 t1_time(字符串)。
// tid/now 是防御性 fallback,t1_tid 才是主名。
t1_tid?: string;
t1_time?: string;
tid?: string;
now?: number;
}
export interface QzonePublishResult {
tid: string;
time: number;
}
/** 解析发说说响应,提取 tid+time。code!=0 或缺 tid 都抛错。 */
export function parseQzonePublishFull(text: string): QzonePublishResult {
const data = parseQzoneJson<RawPublishResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone publish failed: code=${data.code} ${data.message ?? ''}`.trim());
}
const tid = data.t1_tid ?? data.tid;
if (!tid) {
throw new Error('发表说说失败:响应缺少 tid');
}
return { tid: String(tid), time: Number(data.t1_time ?? data.now ?? 0) };
}
// ─────────────── 点赞/取消赞(internal_dolike_app) ───────────────
export interface RawLikeResponse {
code?: number;
subcode?: number;
message?: string;
}
/** 构造点赞/取消赞表单体。unikey/curkey 定位说说(mood, appid 311),fid=tid。 */
export function buildQzoneLikeBody(
opUin: string,
targetUin: string,
tid: string,
abstime = 0,
): string {
const unikey = `http://user.qzone.qq.com/${targetUin}/mood/${tid}`;
return new URLSearchParams({
qzreferrer: `https://user.qzone.qq.com/${opUin}`,
opuin: opUin,
unikey,
curkey: unikey,
appid: '311',
typeid: '0',
abstime: String(abstime),
fid: tid,
from: '1',
active: '0',
fupdate: '1',
format: 'json',
}).toString();
}
/** 解析点赞响应:非零 code/subcode 抛错。 */
export function parseQzoneLikeResponse(text: string, verb: string): void {
const data = parseQzoneJson<RawLikeResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone ${verb} failed: code=${data.code} ${data.message ?? ''}`.trim());
}
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
}
}
// ─────────────── 评论说说(emotion_cgi_re_feeds) ───────────────
interface RawCommentResponse {
code?: number;
subcode?: number;
message?: string;
commentid?: string | number;
commentId?: string | number;
}
export interface QzoneCommentResult {
/** 新评论 id(响应缺失时为 ''——code 0 不算失败) */
comment_id: string;
}
export interface QzoneReplyTarget {
/** 顶层评论 id(即便回复的是它的楼中楼) */
commentId: string;
commentUin: string;
/** 可选:楼中楼里点名的作者 */
replyTo?: { uin: string; name: string };
}
export interface QzoneReplyResult extends QzoneCommentResult {
parent_comment_id: string;
parent_comment_uin: number;
}
/** 构造评论说说表单体。topicId 形如 `<hostUin>_<tid>__1`(__1 后缀为社区实现主流形态)。 */
export function buildQzoneCommentBody(
selfUin: string,
hostUin: string,
tid: string,
content: string,
richType?: number,
richval?: string,
): string {
return new URLSearchParams({
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
inCharset: 'utf-8',
outCharset: 'utf-8',
hostUin,
format: 'json',
ref: 'feeds',
topicId: `${hostUin}_${tid}__1`,
feedsType: '100',
private: '0',
paramstr: '1',
richtype: richType !== undefined ? String(richType) : '',
richval: richval ?? '',
isSignIn: '',
uin: selfUin,
content,
plat: 'qzone',
source: 'ic',
platformid: '52',
}).toString();
}
/** 解析评论响应。 */
export function parseQzoneCommentResponse(text: string, hostUin: string, tid: string): QzoneCommentResult {
const data = parseQzoneJson<RawCommentResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone comment failed: code=${data.code} ${data.message ?? ''}`.trim());
}
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone comment failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
}
const commentId = data.commentid ?? data.commentId;
void hostUin; void tid; // 日志参数留位
return { comment_id: commentId !== undefined ? String(commentId) : '' };
}
/** 构造楼中楼回复表单体(独立端点 emotion_cgi_addreply_ugc)。 */
export function buildQzoneReplyBody(
selfUin: string,
hostUin: string,
tid: string,
content: string,
target: QzoneReplyTarget,
): string {
let replyContent = content;
if (target.replyTo !== undefined) {
const { uin, name } = target.replyTo;
// 对齐 web 编辑器的 @ 提及编码
const escapedName = name
.trim()
.replace(/%/g, '%25')
.replace(/,/g, '%2C')
.replace(/}/g, '%7D');
replyContent = `@{uin:${uin},nick:${escapedName},auto:1} ${content}`;
}
return new URLSearchParams({
uin: selfUin,
hostUin,
hostuin: selfUin,
topicId: `${hostUin}_${tid}`,
commentId: target.commentId,
commentUin: target.commentUin,
content: replyContent,
inCharset: 'utf-8',
outCharset: 'utf-8',
ref: '',
code_version: '1',
format: 'fs',
qzreferrer: `https://user.qzone.qq.com/${hostUin}`,
}).toString();
}
// ─────────────── 拉黑/解拉黑(cgi_black_action_new) ───────────────
interface RawBlackActionResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
}
/** 构造空间拉黑表单体。action=1 拉黑,action=2 解拉黑。 */
export function buildQzoneBlackBody(selfUin: string, targetUin: string, ban: boolean): string {
return new URLSearchParams({
uin: selfUin,
act_uin: targetUin,
action: ban ? '1' : '2',
fupdate: '1',
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
}).toString();
}
/** 解析拉黑响应(响应带 {name:"Ack"} 无引号键碎片,先补引号再解析)。 */
export function parseQzoneBlackResponse(text: string, ban: boolean): void {
const verb = ban ? 'ban' : 'unban';
let data: RawBlackActionResponse;
try {
data = parseQzoneJson<RawBlackActionResponse>(text.replace(/\{\s*name\s*:/, '{"name":'));
} catch {
const snippet = text.trim().slice(0, 300);
throw new Error(`qzone ${verb} failed: ${snippet}${text.length > 300 ? '…' : ''}`);
}
const msg = data.message ?? data.msg ?? '';
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${msg}`.trim());
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone ${verb} failed: code=${data.code} ${msg}`.trim());
}
}
// ─────────────── 说说详情(emotion_cgi_msgdetail_v6) ───────────────
interface RawMsgDetailPic {
pic_id?: string;
pictype?: number | string;
type?: number | string;
height?: number | string;
b_height?: number | string;
width?: number | string;
b_width?: number | string;
smallurl?: string;
url1?: string;
url2?: string;
url3?: string;
}
export interface RawMsgDetailResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
tid?: string;
uin?: number | string;
content?: string;
cmtnum?: unknown;
commentlist?: unknown;
conlist?: Array<{ con?: string }> | null;
pic?: RawMsgDetailPic[] | null;
richtype?: number | string;
richval?: string;
pic_template?: string;
special_url?: string;
t1_subtype?: number | string;
subrichtype?: number | string;
feedversion?: number | string;
ver?: number | string;
ugc_right?: number | string;
to_sign?: number | string;
ugcright_id?: string;
code_version?: number | string;
}
export interface RawUpdateResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
ugc_right?: number | string;
}
export interface QzoneUpdateRightResult {
/** 更新后的 ugc_right(服务端回显时用回显值) */
ugc_right: number;
}
/** 构造详情 GET 查询串(注意代理域是 taotao.qq.com 非 taotao.qzone.qq.com)。 */
export function buildQzoneMsgDetailQuery(
targetUin: string,
tid: string,
bkn: string,
page?: { pos: number; num: number },
): string {
return new URLSearchParams({
tid,
uin: targetUin,
t1_source: '1',
not_trunc_con: '1',
need_right: '1',
not_adapt_outpic: '1',
g_tk: bkn,
...(page ? { pos: String(page.pos), num: String(page.num) } : {}),
}).toString();
}
/** 从 msgdetail_v6 响应重建 emotion_cgi_update 全量载荷(php-qzone buildUpdatePayloadFromDetail 逐字段移植)。 */
export function buildQzoneUpdatePayload(
detail: RawMsgDetailResponse,
selfUin: string,
): URLSearchParams {
if (!detail.tid) {
throw new Error('qzone msg detail is missing tid');
}
const pics = detail.pic ?? [];
const richtype =
detail.richtype !== undefined ? Number(detail.richtype) : pics.length === 0 ? '' : 1;
const richvals: string[] = [];
const picBoItems: string[] = [];
for (const pic of pics) {
const picId = String(pic.pic_id ?? '');
if (!picId) continue;
const parts = picId.split(',');
const albumId = parts[1] ?? '';
const lloc = parts[2] ?? '';
if (!albumId || !lloc) {
throw new Error('qzone msg detail has unsupported pic info');
}
const sloc = lloc;
const picType = String(pic.pictype ?? pic.type ?? 22);
const height = String(pic.height ?? pic.b_height ?? 0);
const width = String(pic.width ?? pic.b_width ?? 0);
richvals.push(`,${albumId},${lloc},${sloc},${picType},${height},${width},,0,0`);
for (const urlKey of ['smallurl', 'url1', 'url2', 'url3'] as const) {
const picUrl = pic[urlKey];
if (!picUrl) continue;
const match = /[?&]bo=([^&#]+)/.exec(picUrl);
if (match) {
picBoItems.push(decodeURIComponent(match[1]!));
break;
}
}
}
if (richtype === 1 && richvals.length === 0) {
throw new Error('qzone image post detail is missing pic info');
}
let content = detail.content ?? '';
const conParts = (detail.conlist ?? [])
.map((c) => c.con)
.filter((c): c is string => c !== undefined);
if (conParts.length > 0) {
content = conParts.join('').trim();
}
const subrichtype = detail.t1_subtype ?? detail.subrichtype ?? (richvals.length === 0 ? '' : 1);
const boGroup = picBoItems.join(',');
const picBo = boGroup ? `${boGroup}\t${boGroup}` : '';
const hostuin = String(detail.uin ?? selfUin);
return new URLSearchParams({
syn_tweet_verson: '1',
tid: String(detail.tid),
paramstr: '1',
pic_template: detail.pic_template ?? '',
richtype: String(richtype),
richval: richvals.length > 0 ? richvals.join('\t') : (detail.richval ?? ''),
special_url: detail.special_url ?? '',
subrichtype: String(subrichtype),
pic_bo: picBo,
con: content,
feedversion: String(detail.feedversion ?? 1),
ver: String(detail.ver ?? 1),
ugc_right: String(Number(detail.ugc_right ?? 1)),
to_sign: String(Number(detail.to_sign ?? 0)),
ugcright_id: String(detail.ugcright_id ?? detail.tid),
hostuin,
code_version: String(detail.code_version ?? 1),
format: 'fs',
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
});
}
// 详情字段严格校验小件
export function detailObject(value: unknown, field: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`invalid qzone msg detail: ${field} must be an object`);
}
return value as Record<string, unknown>;
}
export function detailString(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`invalid qzone msg detail: ${field} must be a string`);
}
return value;
}
export function detailInteger(value: unknown, field: string, min = 0): number {
const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min) {
throw new Error(`invalid qzone msg detail: ${field} must be an integer >= ${min}`);
}
return number;
}
export function detailList(value: unknown, field: string): unknown[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
throw new Error(`invalid qzone msg detail: ${field} must be an array`);
}
return value;
}
function mapQzoneComment(value: Record<string, unknown>, field: string): QzoneCommentReply {
const id =
typeof value['tid'] === 'number'
? String(detailInteger(value['tid'], `${field}.tid`))
: detailString(value['tid'], `${field}.tid`);
if (!id) {
throw new Error(`invalid qzone msg detail: ${field}.tid is empty`);
}
return {
comment_id: id,
uin: detailInteger(value['uin'], `${field}.uin`, 1),
name: detailString(value['name'], `${field}.name`),
time: detailInteger(value['create_time'], `${field}.create_time`),
content: detailString(value['content'], `${field}.content`),
};
}
/**
* 详情响应 → 规范化(含评论+内联回复)。结构坏数据不得伪装成成功的空页。
* 对齐 SmartHypercube/Qzone-API 的 Comment / Emotion.load。
*/
export function mapQzoneMsgDetail(
data: RawMsgDetailResponse,
tid: string,
): QzoneMsgDetailResult {
const resultTid = detailString(data.tid, 'tid');
if (!resultTid || resultTid !== tid) {
throw new Error('invalid qzone msg detail: tid does not match the requested post');
}
return {
tid: resultTid,
content: detailString(data.content, 'content'),
comment_num: detailInteger(data.cmtnum, 'cmtnum'),
comments: detailList(data.commentlist, 'commentlist').map((value, index) => {
const field = `commentlist[${index}]`;
const comment = detailObject(value, field);
const mappedComment = mapQzoneComment(comment, field);
return {
...mappedComment,
replies: detailList(comment['list_3'], `${field}.list_3`).map((reply, replyIndex) => {
const replyField = `${field}.list_3[${replyIndex}]`;
return {
...mapQzoneComment(detailObject(reply, replyField), replyField),
parent_comment_id: mappedComment.comment_id,
parent_comment_uin: mappedComment.uin,
};
}),
};
}),
};
}
/** 详情响应错误包络统一判定(code 或 subcode 任一非零即失败,subcode=0 不遮掩 code!=0)。 */
export function parseQzoneDetailError(data: RawMsgDetailResponse, tid: string): void {
const code =
data.code !== undefined && data.code !== 0 ? data.code : (data.subcode ?? data.code ?? -1);
if (code !== 0) {
throw new Error(
`qzone msg detail failed: code=${code} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
void tid;
}
/** 解析 emotion_cgi_update(改权限)响应。 */
export function parseQzoneUpdateRightResponse(
text: string,
tid: string,
right: number,
): QzoneUpdateRightResult {
const data = parseQzoneJson<RawUpdateResponse>(text);
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(
`qzone update right failed: subcode=${data.subcode} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(
`qzone update right failed: code=${data.code} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
void tid;
return { ugc_right: Number(data.ugc_right ?? right) };
}
// ─────────────── 空间资料 ───────────────
interface RawQzoneProfileResponse {
code?: number;
subcode?: number;
message?: string;
data?: {
spacename?: string;
desc?: string;
};
}
/** 构造空间资料 GET 查询(cgi_userinfo_get_all)。 */
export function buildQzoneProfileQuery(selfUin: string, targetUin: string, bkn: string): string {
return new URLSearchParams({
uin: targetUin,
vuin: selfUin,
fupdate: '1',
rd: String(Math.random()),
g_tk: bkn,
}).toString();
}
/** 解析空间资料响应。 */
export function parseQzoneProfileResponse(text: string): QzoneProfile {
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
throw new Error(
`qzone profile failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
);
}
if (typeof response.data?.spacename !== 'string' || typeof response.data.desc !== 'string') {
throw new Error('invalid qzone profile response');
}
return { name: response.data.spacename, description: response.data.desc };
}
/** 构造空间资料修改表单体(字段位图 mb:spacename=2048, desc=4096)。 */
export function buildQzoneProfileUpdateBody(
selfUin: string,
profile: QzoneProfileUpdate,
): string {
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
let bitmap = 0;
// 空间设置表单用字段位图;独立富文本签名不含。
// https://qzonestyle.gtimg.cn/qzone/v6/setting/profile/scripts/profile.js
if (profile.name !== undefined) {
params.set('spacename', profile.name.replaceAll('\r', ''));
bitmap |= 2048;
}
if (profile.description !== undefined) {
params.set('desc', profile.description.replaceAll('\r', ''));
bitmap |= 4096;
}
params.set('mb', String(bitmap));
return params.toString();
}
/** 解析空间资料修改响应。 */
export function parseQzoneProfileUpdateResponse(text: string): void {
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
throw new Error(
`qzone profile update failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
);
}
}