397 lines
18 KiB
TypeScript
397 lines
18 KiB
TypeScript
export interface QzoneProfile {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface QzoneProfileUpdate {
|
|
name?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
|
|
|
|
const SUCCESS_FIELDS = ['code', 'subcode', 'retcode', 'ret'];
|
|
const MAX_PROFILE_LENGTH = 8192;
|
|
const MAX_IDENTITY_LENGTH = 8192;
|
|
|
|
export class QzoneWebError extends Error {
|
|
constructor (public readonly field: string, public readonly code: number) {
|
|
super(`QQ空间接口拒绝请求 (${field}=${code})`);
|
|
}
|
|
}
|
|
|
|
function object (value: unknown): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
|
}
|
|
|
|
function assertUin (uin: unknown): asserts uin is string {
|
|
if (typeof uin !== 'string' || !/^[1-9]\d{0,19}$/.test(uin)) throw new Error('QQ空间账号标识无效');
|
|
}
|
|
|
|
function assertIdentity (value: unknown): asserts value is string {
|
|
if (typeof value !== 'string' || !value.trim() || value.length > MAX_IDENTITY_LENGTH) {
|
|
throw new Error('QQ空间说说标识无效');
|
|
}
|
|
}
|
|
|
|
function assertSuccess (response: Record<string, unknown>, requireCode: boolean): void {
|
|
let acknowledged = false;
|
|
for (const field of SUCCESS_FIELDS) {
|
|
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
|
const code = response[field];
|
|
if (typeof code !== 'number' || !Number.isSafeInteger(code) || code !== 0) {
|
|
// CGI messages may contain account content or tickets. Only include a numeric code.
|
|
if (typeof code === 'number' && Number.isSafeInteger(code)) throw new QzoneWebError(field, code);
|
|
throw new Error('QQ空间接口拒绝请求');
|
|
}
|
|
acknowledged = true;
|
|
}
|
|
if (!acknowledged || (requireCode && response['code'] !== 0)) throw new Error('QQ空间响应缺少明确成功状态');
|
|
}
|
|
|
|
/** Quote only observed bare name keys with string values, never string contents. */
|
|
function normalizeNameStringKeys (text: string): string {
|
|
let result = '';
|
|
let quoted = false;
|
|
let escaped = false;
|
|
const containers: Array<{ object: boolean; key: boolean; }> = [];
|
|
for (let index = 0; index < text.length; index++) {
|
|
const character = text[index]!;
|
|
if (!quoted) {
|
|
const container = containers[containers.length - 1];
|
|
if (container?.object && container.key && character === 'n' && /^name(?=\s*:\s*")/.test(text.slice(index))) {
|
|
result += '"name"';
|
|
index += 'name'.length - 1;
|
|
container.key = false;
|
|
continue;
|
|
}
|
|
if (character === '{') containers.push({ object: true, key: true });
|
|
else if (character === '[') containers.push({ object: false, key: false });
|
|
else if (character === '}' || character === ']') containers.pop();
|
|
else if (character === ',' && container?.object) container.key = true;
|
|
else if (!/\s/.test(character) && container?.object) container.key = false;
|
|
}
|
|
result += character;
|
|
if (quoted) {
|
|
if (escaped) escaped = false;
|
|
else if (character === '\\') escaped = true;
|
|
else if (character === '"') quoted = false;
|
|
} else if (character === '"') {
|
|
quoted = true;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function rejectDuplicateSuccessFields (text: string): void {
|
|
const seen = new Set<string>();
|
|
let depth = 0;
|
|
for (let index = 0; index < text.length; index++) {
|
|
const character = text[index];
|
|
if (character === '{' || character === '[') depth++;
|
|
else if (character === '}' || character === ']') depth--;
|
|
else if (character === '"') {
|
|
const start = index;
|
|
let escaped = false;
|
|
for (index++; index < text.length; index++) {
|
|
const current = text[index];
|
|
if (escaped) escaped = false;
|
|
else if (current === '\\') escaped = true;
|
|
else if (current === '"') break;
|
|
}
|
|
let next = index + 1;
|
|
while (/\s/.test(text[next] ?? '') && next < text.length) next++;
|
|
if (depth !== 1 || text[next] !== ':') continue;
|
|
const field: unknown = JSON.parse(text.slice(start, index + 1));
|
|
if (typeof field !== 'string' || !SUCCESS_FIELDS.includes(field)) continue;
|
|
if (seen.has(field)) throw new Error('QQ空间响应包含重复状态字段');
|
|
seen.add(field);
|
|
}
|
|
}
|
|
}
|
|
|
|
// FormSender's response is a data callback inside one fixed, passive HTML shell.
|
|
// Consume its observed domain setup as text; never run the returned JavaScript.
|
|
const FORM_SENDER_HTML = /^(?:<!doctype\s+html>\s*)?<html>\s*<head>\s*<meta\s+http-equiv="content-type"\s+content="text\/html;\s*charset=utf-8"\s*\/?>\s*<\/head>\s*<body>\s*<script\s+type="text\/javascript">\s*([\s\S]*?)\s*<\/script>\s*<\/body>\s*<\/html>$/i;
|
|
const DOMAIN_SETUP = 'document\\s*\\.\\s*domain\\s*=\\s*"h5\\.qzone\\.qq\\.com"\\s*;\\s*';
|
|
const CALLBACK_SETUP = 'cb\\s*=\\s*frameElement\\s*\\.\\s*callback\\s*;\\s*';
|
|
const DOMAIN_TRY_SETUP = new RegExp(
|
|
'^var\\s+cb\\s*;\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
|
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
|
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP + '\\}\\s*\\}\\s*'
|
|
);
|
|
const DOMAIN_ONLY_SETUP = new RegExp('^' + DOMAIN_SETUP);
|
|
|
|
function unwrapFormSenderHtml (text: string): string | undefined {
|
|
const html = FORM_SENDER_HTML.exec(text);
|
|
if (!html) return undefined;
|
|
const script = html[1]!.trim();
|
|
const setup = DOMAIN_TRY_SETUP.exec(script) ?? DOMAIN_ONLY_SETUP.exec(script);
|
|
if (!setup) throw new Error('QQ空间响应脚本包裹格式异常');
|
|
const callback = script.slice(setup[0].length).trim();
|
|
if (!/^frameElement\.callback\s*\(/.test(callback)) throw new Error('QQ空间响应脚本包裹格式异常');
|
|
return callback;
|
|
}
|
|
|
|
/** Parse data only: no script evaluation and no first-brace/last-brace extraction. */
|
|
export function parseQzoneWebResponse (text: string, requireCode = false): Record<string, unknown> {
|
|
if (typeof text !== 'string' || text.length > 8 * 1024 * 1024) throw new Error('QQ空间响应格式异常');
|
|
let payload = text.trim();
|
|
const formSender = unwrapFormSenderHtml(payload);
|
|
if (formSender !== undefined) payload = formSender;
|
|
// Preserve the original single-script and bare callback envelopes as well.
|
|
const script = /^<script>\s*([\s\S]*?)\s*<\/script>$/i.exec(payload);
|
|
if (script) {
|
|
payload = script[1]!.trim();
|
|
if (!/^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(/.test(payload)) throw new Error('QQ空间响应脚本包裹格式异常');
|
|
} else if (payload.endsWith('</script>') && payload.startsWith('frameElement.callback')) payload = payload.slice(0, -9).trim();
|
|
if (!payload.startsWith('{')) {
|
|
const callback = /^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(\s*([\s\S]*)\s*\)\s*;?\s*$/.exec(payload);
|
|
if (!callback) throw new Error('QQ空间响应格式异常');
|
|
payload = callback[1]!.trim();
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
payload = normalizeNameStringKeys(payload);
|
|
rejectDuplicateSuccessFields(payload);
|
|
parsed = JSON.parse(payload);
|
|
} catch {
|
|
throw new Error('QQ空间响应JSON格式异常');
|
|
}
|
|
const response = object(parsed);
|
|
if (!response) throw new Error('QQ空间响应必须是对象');
|
|
assertSuccess(response, requireCode);
|
|
return response;
|
|
}
|
|
|
|
export function parseQzoneProfile (response: Record<string, unknown>): QzoneProfile {
|
|
if (!object(response)) throw new Error('QQ空间资料响应格式异常');
|
|
assertSuccess(response, true);
|
|
const data = object(response['data']);
|
|
if (!data || typeof data['spacename'] !== 'string' || typeof data['desc'] !== 'string') {
|
|
throw new Error('QQ空间资料响应不完整');
|
|
}
|
|
return { name: data['spacename'], description: data['desc'] };
|
|
}
|
|
|
|
export function buildQzoneProfileBody (selfUin: string, profile: QzoneProfileUpdate): string {
|
|
assertUin(selfUin);
|
|
const fields = object(profile);
|
|
if (!fields || Object.keys(fields).some(key => key !== 'name' && key !== 'description')) throw new Error('QQ空间资料参数无效');
|
|
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
|
|
let bitmap = 0;
|
|
for (const [field, wireName, bit] of [['name', 'spacename', 2048], ['description', 'desc', 4096]] as const) {
|
|
const value = fields[field];
|
|
if (value === undefined) continue;
|
|
if (typeof value !== 'string' || value.length > MAX_PROFILE_LENGTH) throw new Error('QQ空间名称或说明必须是8192字以内的文本');
|
|
params.set(wireName, value.replaceAll('\r', ''));
|
|
bitmap |= bit;
|
|
}
|
|
if (bitmap === 0) throw new Error('至少提供空间名称或说明');
|
|
params.set('mb', String(bitmap));
|
|
return params.toString();
|
|
}
|
|
|
|
export function normalizeQzoneRight (ugcRight: number, targetUins?: string[]): { right: QzoneUgcRight; targetUins: string[]; } {
|
|
const rights = [1, 4, 16, 64, 128];
|
|
if (typeof ugcRight !== 'number' || !rights.includes(ugcRight)) throw new Error('ugc_right仅支持1、4、16、64、128');
|
|
if (targetUins !== undefined && !Array.isArray(targetUins)) throw new Error('target_uins必须为QQ号数组');
|
|
const targets: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const uin of targetUins ?? []) {
|
|
assertUin(uin);
|
|
if (!seen.has(uin)) {
|
|
targets.push(uin);
|
|
seen.add(uin);
|
|
}
|
|
}
|
|
if ((ugcRight === 16 || ugcRight === 128) && targets.length === 0) throw new Error('ugc_right为16或128时target_uins不能为空');
|
|
return { right: ugcRight as QzoneUgcRight, targetUins: targets };
|
|
}
|
|
|
|
function wireText (value: unknown, fallback: string): string {
|
|
if (value === undefined) return fallback;
|
|
if (typeof value !== 'string') throw new Error('QQ空间说说重建字段格式异常');
|
|
return value;
|
|
}
|
|
|
|
function wireInteger (value: unknown, fallback: string, minimum = 0, allowEmpty = false): string {
|
|
if (value === undefined) return fallback;
|
|
if (allowEmpty && value === '') return '';
|
|
if ((typeof value !== 'number' || !Number.isSafeInteger(value)) && (typeof value !== 'string' || !/^\d+$/.test(value))) {
|
|
throw new Error('QQ空间说说重建数值无效');
|
|
}
|
|
const number = Number(value);
|
|
if (!Number.isSafeInteger(number) || number < minimum) throw new Error('QQ空间说说重建数值无效');
|
|
return String(value);
|
|
}
|
|
|
|
function firstPresent (first: unknown, second: unknown): unknown {
|
|
return first === undefined ? second : first;
|
|
}
|
|
|
|
function hasMediaValue (value: unknown): boolean {
|
|
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') return false;
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
const record = object(value);
|
|
return record ? Object.keys(record).length > 0 : true;
|
|
}
|
|
|
|
function hasObservedEmptyForwardContent (detail: Record<string, unknown>): boolean {
|
|
if (detail['rt_tid'] !== '' || detail['rt_uin'] !== 0 || detail['rt_source'] !== 0) return false;
|
|
const content = object(detail['rt_con']);
|
|
if (!content) return false;
|
|
const keys = Object.keys(content);
|
|
return keys.length === 2 && keys.includes('conlist') && keys.includes('content') &&
|
|
content['conlist'] === null && content['content'] === '';
|
|
}
|
|
|
|
function assertSupportedMedia (detail: Record<string, unknown>): void {
|
|
for (const field of [
|
|
'video', 'videos', 'video_info', 'video_url', 'videoid', 'video_id', 'vid',
|
|
'music', 'music_info', 'audio', 'voice', 'voice_info',
|
|
'rt_tid', 'rt_uin', 'rt_con', 'rt_source', 'forward', 'forward_info', 'forward_tid',
|
|
'origin', 'origin_tid', 'origin_feed', 'cell_origin',
|
|
]) {
|
|
// The legacy detail CGI supplies this exact empty object for ordinary text and image posts.
|
|
if (field === 'rt_con' && hasObservedEmptyForwardContent(detail)) continue;
|
|
if (hasMediaValue(detail[field])) throw new Error('该说说包含暂不支持完整重建的媒体或转发内容');
|
|
}
|
|
for (const field of ['appid', 'app_id']) {
|
|
if (detail[field] !== undefined && detail[field] !== 311 && detail[field] !== '311') throw new Error('该动态不是可重建的说说');
|
|
}
|
|
if (wireText(detail['special_url'], '') !== '') throw new Error('暂不支持修改含特殊分享内容的说说权限');
|
|
}
|
|
|
|
function pictureBo (picture: Record<string, unknown>): string | undefined {
|
|
for (const key of ['smallurl', 'url1', 'url2', 'url3']) {
|
|
const value = picture[key];
|
|
if (value === undefined || value === '') continue;
|
|
if (typeof value !== 'string') throw new Error('QQ空间图片地址元数据无效');
|
|
const matches = [...value.matchAll(/[?&]bo=([^&#]*)/g)];
|
|
if (matches.length > 1) throw new Error('QQ空间图片包含歧义元数据');
|
|
if (matches.length === 0) continue;
|
|
try {
|
|
const decoded = decodeURIComponent(matches[0]![1]!);
|
|
if (!decoded || /[\t\r\n]/.test(decoded)) throw new Error('Invalid picture metadata');
|
|
return decoded;
|
|
} catch {
|
|
throw new Error('QQ空间图片元数据无法完整重建');
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function rebuildContent (detail: Record<string, unknown>): string {
|
|
const conlist = detail['conlist'];
|
|
if (conlist !== undefined && conlist !== null) {
|
|
if (!Array.isArray(conlist)) throw new Error('QQ空间说说正文分段格式异常');
|
|
if (conlist.length > 0) {
|
|
return conlist.map((value) => {
|
|
const part = object(value);
|
|
if (!part || typeof part['con'] !== 'string') throw new Error('QQ空间说说正文分段不完整');
|
|
return part['con'];
|
|
}).join('');
|
|
}
|
|
}
|
|
if (typeof detail['content'] !== 'string') throw new Error('QQ空间说说缺少完整正文');
|
|
return detail['content'];
|
|
}
|
|
|
|
/** Permission updates resubmit a whole post, so unsupported or partial data must fail before POST. */
|
|
export function buildQzoneRightBody (
|
|
detail: Record<string, unknown>,
|
|
selfUin: string,
|
|
tid: string,
|
|
ugcRight: number,
|
|
targetUins?: string[]
|
|
): string {
|
|
assertUin(selfUin);
|
|
assertIdentity(tid);
|
|
const source = object(detail);
|
|
if (!source || source['tid'] !== tid) throw new Error('QQ空间详情与请求的说说标识不匹配');
|
|
const owner = source['uin'];
|
|
if ((typeof owner !== 'string' && !(typeof owner === 'number' && Number.isSafeInteger(owner))) || String(owner) !== selfUin) {
|
|
throw new Error('只能修改当前账号自己的说说权限');
|
|
}
|
|
if (SUCCESS_FIELDS.some(field => Object.prototype.hasOwnProperty.call(source, field))) assertSuccess(source, false);
|
|
const permission = normalizeQzoneRight(ugcRight, targetUins);
|
|
assertSupportedMedia(source);
|
|
const pics = source['pic'] ?? [];
|
|
if (!Array.isArray(pics)) throw new Error('QQ空间说说图片列表格式异常');
|
|
const richType = wireInteger(source['richtype'], pics.length ? '1' : '', 0, true);
|
|
if (!['', '0', '1'].includes(richType)) throw new Error('该说说的媒体类型暂不支持完整重建');
|
|
if ((pics.length > 0 && richType !== '1') || (pics.length === 0 && richType === '1')) throw new Error('QQ空间说说图片信息不完整');
|
|
const richValues: string[] = [];
|
|
const boValues: Array<string | undefined> = [];
|
|
for (const value of pics) {
|
|
const picture = object(value);
|
|
const pictureId = picture?.['pic_id'];
|
|
if (!picture || typeof pictureId !== 'string' || !pictureId || /[\t\r\n]/.test(pictureId)) throw new Error('QQ空间说说图片标识不完整');
|
|
const parts = pictureId.split(',');
|
|
const albumId = parts[1];
|
|
const lloc = parts[2];
|
|
if (!albumId?.trim() || !lloc?.trim()) throw new Error('QQ空间说说图片信息无法完整重建');
|
|
// 22 is the observed legacy image default; existing image dimensions cannot be guessed.
|
|
const pictureType = wireInteger(firstPresent(picture['pictype'], picture['type']), '22');
|
|
const originalHeight = firstPresent(picture['height'], picture['b_height']);
|
|
const originalWidth = firstPresent(picture['width'], picture['b_width']);
|
|
if (originalHeight === undefined || originalWidth === undefined) throw new Error('QQ空间说说图片尺寸不完整');
|
|
const height = wireInteger(originalHeight, '', 1);
|
|
const width = wireInteger(originalWidth, '', 1);
|
|
richValues.push(`,${albumId},${lloc},${lloc},${pictureType},${height},${width},,0,0`);
|
|
boValues.push(pictureBo(picture));
|
|
}
|
|
if (boValues.some(value => value !== undefined) && boValues.some(value => value === undefined)) throw new Error('QQ空间图片元数据数量不完整');
|
|
const boGroup = boValues.every(value => value !== undefined) ? boValues.join(',') : '';
|
|
const originalRichValue = wireText(source['richval'], '');
|
|
if (richValues.length === 0 && originalRichValue !== '') throw new Error('QQ空间说说包含无法完整重建的媒体数据');
|
|
const subtype = wireInteger(firstPresent(source['t1_subtype'], source['subrichtype']), richValues.length ? '1' : '', 0, true);
|
|
// Subtype 2 was observed on plain text, without either rich-type field or pictures.
|
|
const observedPlainTextSubtype = subtype === '2' && hasObservedEmptyForwardContent(source) &&
|
|
pics.length === 0 && source['richtype'] === undefined && source['subrichtype'] === undefined;
|
|
if (!['', '0', '1'].includes(subtype) && !observedPlainTextSubtype) throw new Error('该说说的媒体子类型暂不支持完整重建');
|
|
const sign = wireInteger(source['to_sign'], '0');
|
|
if (sign !== '0' && sign !== '1') throw new Error('QQ空间说说同步标记无效');
|
|
const rightId = wireText(source['ugcright_id'], tid);
|
|
assertIdentity(rightId);
|
|
const params = new URLSearchParams({
|
|
syn_tweet_verson: '1',
|
|
tid,
|
|
paramstr: '1',
|
|
pic_template: wireText(source['pic_template'], ''),
|
|
richtype: richType,
|
|
richval: richValues.join('\t'),
|
|
special_url: '',
|
|
subrichtype: subtype,
|
|
pic_bo: boGroup ? `${boGroup}\t${boGroup}` : '',
|
|
con: rebuildContent(source),
|
|
feedversion: wireInteger(source['feedversion'], '1', 1),
|
|
ver: wireInteger(source['ver'], '1', 1),
|
|
ugc_right: String(permission.right),
|
|
to_sign: sign,
|
|
ugcright_id: rightId,
|
|
hostuin: selfUin,
|
|
code_version: wireInteger(source['code_version'], '1', 1),
|
|
format: 'fs',
|
|
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
|
|
});
|
|
if (permission.right === 16 || permission.right === 128) params.set('allow_uins', permission.targetUins.join('|'));
|
|
return params.toString();
|
|
}
|
|
|
|
export function buildQzoneBanBody (selfUin: string, targetUin: string, enable: boolean): string {
|
|
assertUin(selfUin);
|
|
assertUin(targetUin);
|
|
if (typeof enable !== 'boolean') throw new Error('空间黑名单状态必须是布尔值');
|
|
return new URLSearchParams({
|
|
uin: selfUin,
|
|
act_uin: targetUin,
|
|
action: enable ? '1' : '2',
|
|
fupdate: '1',
|
|
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
|
|
}).toString();
|
|
}
|