export type QzonePublicObject = Record; export type QzonePublicContent = string | QzonePublicObject[]; export type QzonePublicOperation = | 'capabilities' | 'feeds' | 'detail' | 'comments' | 'replies' | 'upvote' | 'downvote' | 'like' | 'unlike' | 'follow' | 'unfollow' | 'commentLike' | 'commentUnlike' | 'addComment' | 'addReply' | 'deleteComment' | 'deleteReply' | 'forward'; export interface QzonePublicFeedsPage extends QzonePublicObject { feeds: QzonePublicObject[]; has_more: boolean; next_cursor: string | null; } export interface QzonePublicRequestOptions extends QzonePublicObject { feed?: QzonePublicObject; comment?: QzonePublicObject; reply?: QzonePublicObject; content?: QzonePublicContent; cursor?: string; pageSize?: number; reason?: string; selfUin?: string; user?: QzonePublicObject; refreshId?: string; lastUnionId?: QzonePublicObject; } export interface QzonePublicRoute { service: string; method: string; oidb: string; } const READER = 'trpc.qzone.feeds_reader.FeedsReader'; const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter'; const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter'; const RELATION = 'trpc.public_qzone.relation.RelationService'; const UPVOTE = 'trpc.public_qzone.interaction.UFOComment'; const MAX_OBJECT_BYTES = 8 * 1024 * 1024; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const MAX_IDENTITY_LENGTH = 8192; function route (service: string, method: string, oidb: string): QzonePublicRoute { return Object.freeze({ service, method, oidb }); } export const QZONE_PUBLIC_ROUTES: Record = Object.freeze({ capabilities: route('trpc.public_qzone.entrance_ctrl.EntranceCtrl', 'BatchEntranceWhitelist', '0x9741_0'), feeds: route(READER, 'GetPublicRecomFeeds', '0x9a38_0'), detail: route(READER, 'GetFeedDetail', '0x97b1_1'), comments: route(READER, 'GetComment', '0x9bfd_1'), replies: route(READER, 'GetReplies', '0x9bfe_1'), upvote: route(UPVOTE, 'Upvote', '0x9d18_1'), downvote: route(UPVOTE, 'Downvote', '0x9d19_1'), like: route(INTERACTION, 'DoLike', '0x9699_1'), unlike: route(INTERACTION, 'UnLike', '0x969c_1'), follow: route(RELATION, 'Follow', '0x9d1a_1'), unfollow: route(RELATION, 'Unfollow', '0x9d1b_1'), commentLike: route(INTERACTION, 'DoLike', '0x9699_1'), commentUnlike: route(INTERACTION, 'UnLike', '0x969c_1'), addComment: route(INTERACTION, 'AddComment', '0x9676_1'), addReply: route(INTERACTION, 'AddReply', '0x9677_1'), deleteComment: route(INTERACTION, 'DeleteComment', '0x9989_0'), deleteReply: route(INTERACTION, 'DeleteReply', '0x998a_0'), forward: route(WRITER, 'ForwardFeed', '0x969a_1'), }); function object (value: unknown): value is QzonePublicObject { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function assertOperation (operation: QzonePublicOperation): void { if (!Object.prototype.hasOwnProperty.call(QZONE_PUBLIC_ROUTES, operation)) throw new Error('QQ空间公开接口操作无效'); } /** Clone JSON without calling toJSON or assigning prototype setters. */ function snapshot (value: T, name: string, maxBytes = MAX_OBJECT_BYTES): T { const seen = new Set(); let nodes = 0; const maxNodes = 200000; const maxDepth = 64; function copy (input: unknown, depth: number): unknown { if (++nodes > maxNodes || depth > maxDepth) throw new Error(`${name}结构超出限制`); if (input === null || typeof input === 'boolean') return input; if (typeof input === 'string') { if (Buffer.byteLength(input, 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`); return input; } if (typeof input === 'number' && Number.isFinite(input)) return input; if (!Array.isArray(input) && !object(input)) throw new Error(`${name}必须为JSON数据`); if (seen.has(input)) throw new Error(`${name}不能包含循环引用`); seen.add(input); let output: unknown; if (Array.isArray(input)) { const items: unknown[] = []; for (let index = 0; index < input.length; index++) items.push(copy(input[index], depth + 1)); output = items; } else { const result: QzonePublicObject = {}; for (const key of Object.keys(input)) { const item = input[key]; if (item === undefined) continue; Object.defineProperty(result, key, { value: copy(item, depth + 1), enumerable: true, configurable: true, writable: true }); } output = result; } seen.delete(input); return output; } const result = copy(value, 0) as T; if (Buffer.byteLength(JSON.stringify(result), 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`); return result; } function jsonObject (value: unknown, name: string): QzonePublicObject { if (!object(value)) throw new Error(`${name}必须为JSON对象`); return snapshot(value, name); } function identity (value: unknown, name: string, allowEmpty = false): string { if (typeof value !== 'string' || value.length > MAX_IDENTITY_LENGTH || (!allowEmpty && !value.trim())) { throw new Error(`${name}无效`); } for (const character of value) { const code = character.charCodeAt(0); if (code < 32 || code === 127) throw new Error(`${name}无效`); } return value; } function uin (value: unknown, name: string): string { if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value)) throw new Error(`${name}无效`); return value; } function pageToken (value: unknown, name: string, allowEmpty = false, maxLength = MAX_OBJECT_BYTES): string { if (typeof value !== 'string' || value.length > maxLength || (!allowEmpty && value.length === 0)) throw new Error(`${name}无效`); return value; } function unionId (value: unknown): QzonePublicObject { const union = jsonObject(value, 'QQ空间完整动态标识'); let identified = false; for (const key of ['uf_key', 'ugc_id', 'feed_id', 'serialized_data']) { if (union[key] === undefined || union[key] === null) continue; const field = identity(union[key], 'QQ空间动态身份字段', true); if (field.trim()) identified = true; } if (!identified) throw new Error('QQ空间动态缺少完整有效身份'); if (union['ext_ids'] !== undefined && union['ext_ids'] !== null && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !object(item)))) { throw new Error('QQ空间动态扩展身份无效'); } return union; } function readComment (value: unknown, name: string): QzonePublicObject { const comment = jsonObject(value, name); const id = comment['id']; if (typeof id === 'string') identity(id, `${name}标识`); else if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0) throw new Error(`${name}标识无效`); if (comment['user'] !== undefined && !object(comment['user'])) throw new Error(`${name}用户必须为JSON对象`); return comment; } function user (value: unknown, name: string): QzonePublicObject { const result = jsonObject(value, name); const userUin = result['uin']; const userUid = result['uid']; const absentUin = userUin === undefined || userUin === null || userUin === '' || userUin === '0' || userUin === 0; if (!absentUin) uin(userUin, `${name}QQ号`); if (userUid !== undefined && userUid !== null && userUid !== '') identity(userUid, `${name}UID`); if (absentUin && !userUid) throw new Error(`${name}缺少QQ号或UID`); return result; } function content (value: unknown): QzonePublicObject[] { if (typeof value === 'string') { if (!value.length) throw new Error('QQ空间评论正文不能为空'); return snapshot([{ type: 0, content: value }], 'QQ空间评论正文'); } if (!Array.isArray(value) || !value.length) throw new Error('QQ空间评论正文必须为非空文本或RichMsg数组'); const items = snapshot(value, 'QQ空间评论正文'); for (const item of items) { if (!object(item)) throw new Error('QQ空间RichMsg条目必须为JSON对象'); } return items as QzonePublicObject[]; } export function buildQzonePublicRequest (operation: QzonePublicOperation, options: QzonePublicRequestOptions): QzonePublicObject { assertOperation(operation); if (!object(options)) throw new Error('QQ空间公开接口参数必须为对象'); if (operation === 'capabilities') return { uins: [uin(options.selfUin, '当前QQ号')], entrance_types: [1, 15] }; if (operation === 'feeds') { const body: QzonePublicObject = { refresh_id: identity(options.refreshId, 'QQ空间刷新标识') }; if (options.cursor !== undefined) body['attach_info'] = pageToken(options.cursor, 'QQ空间分页标识', true, 65536); if (options.lastUnionId !== undefined) body['last_client_feed_union_id'] = unionId(options.lastUnionId); return body; } const feed = jsonObject(options.feed, 'QQ空间原始Feed'); const common = feed['cell_common']; if (!object(common)) throw new Error('QQ空间原始Feed缺少cell_common'); const union = unionId(common['union_id']); const body: QzonePublicObject = { union_id: union }; if (feed['cell_feed_transfer'] !== undefined) body['cell_feed_transfer'] = snapshot(feed['cell_feed_transfer'], 'QQ空间动态上下文'); if (operation === 'detail') return { ...body, feed_action_domain_type: 3, from_page: 1 }; if (operation === 'comments') return { ...body, attach_info: pageToken(options.cursor, 'QQ空间评论分页标识') }; if (operation === 'replies') { const parent = readComment(options.comment, 'QQ空间父评论'); const size = options.pageSize === undefined ? 20 : options.pageSize; if (typeof size !== 'number' || !Number.isFinite(size) || size < 1) throw new Error('QQ空间回复page_size必须为不小于1的有限数值'); const pointer = options.cursor || parent['reply_attach_info'] || ''; return { ...body, cur_comment: parent, comment_attach_info: '', reply_attach_info: pageToken(pointer, 'QQ空间回复分页标识', true), page_size: Math.trunc(size) }; } if (operation === 'upvote' || operation === 'downvote') return body; if (operation === 'like' || operation === 'unlike') { return { ...body, like_key: identity(common['cur_like_key'], 'QQ空间动态点赞标识'), like_type: 1 }; } if (operation === 'follow' || operation === 'unfollow') { const userInfo = feed['cell_user_info']; const author = user(object(userInfo) ? userInfo['user'] : undefined, 'QQ空间动态作者'); const authorUin = typeof author['uin'] === 'string' && /^[1-9]\d{0,19}$/.test(author['uin']) ? author['uin'] : ''; const authorUid = typeof author['uid'] === 'string' ? author['uid'].trim() : ''; const useUid = (common['domain_type'] === 3 && !!authorUid) || !authorUin; const request: QzonePublicObject = { follow_from_type: 1, ...(useUid ? { target_uid: authorUid } : { target_uin: authorUin }) }; if ((useUid && !authorUid) || (!useUid && !authorUin)) throw new Error('QQ空间动态缺少可蹲的作者身份'); if (typeof union['uf_key'] === 'string' && union['uf_key']) request['follow_feed_id'] = union['uf_key']; if (feed['cell_feed_transfer']) request['cell_feed_transfer'] = body['cell_feed_transfer']; return request; } if (operation === 'commentLike' || operation === 'commentUnlike') { const parent = readComment(options.comment, 'QQ空间父评论'); const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复'); const like = target['like']; return { ...body, like_key: identity(object(like) ? like['id'] : undefined, 'QQ空间评论点赞标识'), like_type: options.reply === undefined ? 2 : 3 }; } if (operation === 'addComment') return { ...body, comment: { user: user(options.user, '当前用户'), content: content(options.content) } }; if (operation === 'addReply') { const parent = readComment(options.comment, 'QQ空间父评论'); const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复'); return { ...body, comment: parent, reply: { content: content(options.content), target_user: user(target['user'], 'QQ空间回复目标'), user: user(options.user, '当前用户') } }; } if (operation === 'deleteComment') return { ...body, comment: readComment(options.comment, 'QQ空间父评论') }; if (operation === 'deleteReply') return { ...body, comment: readComment(options.comment, 'QQ空间父评论'), reply: readComment(options.reply, 'QQ空间回复') }; if (operation === 'forward') { const reason = options.reason ?? ''; if (typeof reason !== 'string') throw new Error('QQ空间转发理由必须为文本'); return { ...body, reason: snapshot(reason, 'QQ空间转发理由') }; } throw new Error('QQ空间公开接口操作无效'); } /** Return the business data, preserving unknown fields while checking the observed acknowledgements. */ export function parseQzonePublicResponse (value: unknown, operation: QzonePublicOperation): QzonePublicObject { assertOperation(operation); if (object(value) && typeof value['retcode'] === 'number' && Number.isSafeInteger(value['retcode']) && value['retcode'] !== 0) { throw new Error(`QQ空间公开接口拒绝请求 (retcode=${value['retcode']})`); } if (!object(value) || value['retcode'] !== 0 || !object(value['data'])) throw new Error('QQ空间公开接口未返回明确成功响应'); const data = snapshot(value['data'], 'QQ空间公开接口响应', MAX_RESPONSE_BYTES); if (operation === 'follow' || operation === 'unfollow') { const code = data['ret_code']; if (code !== undefined && code !== null && code !== 0 && code !== '0') throw new Error('QQ空间蹲操作未确认成功'); } const resultFields: Partial> = { upvote: 'upvote', downvote: 'upvote', like: 'like', unlike: 'like', commentLike: 'like', commentUnlike: 'like', addComment: 'comment', addReply: 'reply', }; const field = resultFields[operation]; if (field && !data[field]) throw new Error('QQ空间公开互动未返回业务成功结果'); return data; }