/** * SnowCat · QQ空间好友动态流(feeds3_html_more)—— 纯数据构造与解析 * * SnowLuma v2.2.1 (qzone-feed-cursor.ts + qzone-feed-content.ts + qzone.ts feeds 部分) 移植改造: * - cursor 状态编解码(base64url,绑 uin 防串号) * - HTML feed 内容解析(htmlparser2,不执行远程内容) * - feeds 响应映射 */ // ─────────────── cursor 状态机 ─────────────── const numericFields = [ 'begintime', 'dayspac', 'pagenum', 'aisortEndTime', 'aisortOffset', 'aisortBeginTime', 'aisortNextTime', 'icServerTime', ] as const; const stringFields = ['externparam', 'daylist', 'uinlist'] as const; const fields = [...numericFields, ...stringFields]; type StateField = (typeof fields)[number]; export type QzoneFeedState = Record; export function initialQzoneFeedState(): QzoneFeedState { return { begintime: '0', dayspac: '0', pagenum: '1', aisortEndTime: '0', aisortOffset: '0', aisortBeginTime: '0', aisortNextTime: '0', icServerTime: '0', externparam: '', daylist: '', uinlist: '', }; } function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** 承接 web 客户端用的字段,含不透明服务端状态。 */ export function nextQzoneFeedState( main: Record, previous: QzoneFeedState, ): QzoneFeedState { const next = { ...previous }; for (const field of numericFields) { const value = main[field]; if (value === undefined || value === '') continue; if ( (typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value)) || (field === 'pagenum' && Number(value) < 1) ) { throw new Error(`空间好友动态分页字段异常: ${field}`); } next[field] = String(Number(value)); } for (const field of stringFields) { const value = main[field]; if (value === undefined || (value === '' && field !== 'uinlist')) continue; if (typeof value !== 'string' || value.length > 32_768) { throw new Error(`空间好友动态分页字段异常: ${field}`); } next[field] = value; } return next; } /** 服务端时钟单独不算翻页进度。 */ export function qzoneFeedPosition(state: QzoneFeedState): string { return JSON.stringify( fields.filter((field) => field !== 'icServerTime').map((field) => state[field]), ); } export function encodeQzoneFeedCursor(uin: string, page: number, state: QzoneFeedState): string { if (!Number.isSafeInteger(page) || page < 2) { throw new Error('空间好友动态分页页码超出范围'); } const cursor = Buffer.from(JSON.stringify({ version: 1, uin, page, state })).toString( 'base64url', ); if (cursor.length > 65_536) { throw new Error('空间好友动态分页游标过大'); } return cursor; } export function decodeQzoneFeedCursor( cursor: string, uin: string, ): { page: number; state: QzoneFeedState } { if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) { throw new Error('无效的空间好友动态 cursor'); } let value: unknown; try { const bytes = Buffer.from(cursor, 'base64url'); if (bytes.toString('base64url') !== cursor) { throw new Error('non-canonical encoding'); } value = JSON.parse(bytes.toString('utf8')); } catch { throw new Error('无效的空间好友动态 cursor'); } if ( !record(value) || value['version'] !== 1 || value['uin'] !== uin || typeof value['page'] !== 'number' || !Number.isSafeInteger(value['page']) || value['page'] < 2 || !record(value['state']) ) { throw new Error('空间好友动态 cursor 无效或不属于当前账号'); } const rawState = value['state']; if ( Object.keys(rawState).length !== fields.length || !fields.every((field) => typeof rawState[field] === 'string') ) { throw new Error('无效的空间好友动态 cursor 状态'); } const state = nextQzoneFeedState(rawState, initialQzoneFeedState()); if (fields.some((field) => state[field] !== rawState[field])) { throw new Error('无效的空间好友动态 cursor 状态'); } return { page: value['page'], state }; } // ─────────────── HTML feed 内容解析(htmlparser2,数据级不执行) ─────────────── import { DomUtils, parseDocument } from 'htmlparser2'; type Element = ReturnType[number]; type Node = ReturnType['children'][number]; export interface QzoneFeedContent { tid: string | null; content: string | null; content_complete: boolean | null; images: string[] | null; like_count: number | null; comment_count: number | null; } function hasClass(node: Element, name: string): boolean { return (node.attribs['class'] ?? '').split(/\s+/).includes(name); } function isContentNode(node: Element, root: Element): boolean { for (let parent = node.parent; parent && parent !== root; parent = parent.parent) { if ( 'attribs' in parent && (hasClass(parent, 'mod-comments') || hasClass(parent, 'comments-list') || hasClass(parent, 'f-item-passive') || hasClass(parent, 'f-ct-video')) ) { return false; } } return true; } function text(node: Element): string { const chunks: string[] = []; const stack: (Node | string)[] = [...node.children].reverse(); while (stack.length) { const next = stack.pop()!; if (typeof next === 'string') { chunks.push(next); } else if (next.type === 'text') { chunks.push(next.data.replace(/\s+/g, ' ')); } else if (next.type === 'tag') { if (next.attribs['data-cmd'] === 'qz_toggle' || hasClass(next, 'load_img')) { continue; } if (next.name === 'br') { chunks.push('\n'); } else if (next.name === 'img') { chunks.push(next.attribs['alt'] ?? ''); } else { if (['div', 'p', 'li'].includes(next.name)) { chunks.push('\n'); stack.push('\n'); } for (let i = next.children.length - 1; i >= 0; i--) { const child = next.children[i]; if (child !== undefined) stack.push(child); } } } } return chunks .join('') .replace(/ *\n */g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function count(value: unknown): number | null { if (typeof value !== 'number' && (typeof value !== 'string' || !/^\d+$/.test(value))) { return null; } const number = Number(value); return Number.isSafeInteger(number) && number >= 0 ? number : null; } function imageUrl(value: string | undefined): string | null { if (!value) return null; try { const url = new URL(value.startsWith('//') ? `https:${value}` : value); return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; } catch { return null; } } /** 只抽帖子本身的内容;评论/头像/视频封面另算。 */ export function parseQzoneFeedContent( html: string, key: string, uin: number, appid: number, likeCount?: unknown, commentCount?: unknown, ): QzoneFeedContent { const doc = parseDocument(html); const elements = DomUtils.findAll(() => true, doc.children); const body = elements.find( (node) => hasClass(node, 'f-item') && node.attribs['data-key'] === key, ); const result: QzoneFeedContent = { tid: null, content: null, content_complete: null, images: null, like_count: count(likeCount), comment_count: count(commentCount), }; if (!body) return result; let root = body; for (let parent = body.parent; parent && 'attribs' in parent; parent = parent.parent) { if (hasClass(parent, 'f-single')) { root = parent; break; } } const bodyElements = DomUtils.findAll((node) => isContentNode(node, body), body.children); const metadata = bodyElements.find( (node) => node.attribs['name'] === 'feed_data' && node.attribs['data-uin'] === String(uin) && node.attribs['data-fkey'] === key, ); if (appid === 311 && metadata?.attribs['data-tid']) { result.tid = metadata.attribs['data-tid']; } const contentNodes = bodyElements.filter((node) => hasClass(node, 'f-info')); const complete = contentNodes.find( (node) => hasClass(node, 'qz_info_complete') && text(node) !== '', ); const content = complete ?? contentNodes.find((node) => !hasClass(node, 'qz_info_complete')); if (content) { result.content = text(content); result.content_complete = !!complete || !DomUtils.findAll((node) => node.attribs['data-cmd'] === 'qz_toggle', content.children) .length; } const images = new Set(); for (const item of bodyElements.filter((node) => hasClass(node, 'img-item'))) { if (item.attribs['data-v_itemid'] || item.attribs['data-videotype']) { continue; } const full = imageUrl(item.attribs['data-originurl'] ?? item.attribs['data-url3']); if (full) { images.add(full); continue; } for (const image of DomUtils.findAll((node) => node.name === 'img', item.children)) { const url = imageUrl(image.attribs['truesrc'] ?? image.attribs['src']); if (url) { images.add(url); } } } result.images = [...images]; const like = DomUtils.findAll( (node) => hasClass(node, 'qz_like_btn_v3') && isContentNode(node, root), root.children, )[0]; result.like_count ??= count(like?.attribs['data-likecnt']); return result; } // ─────────────── feeds 响应 shape 与映射 ─────────────── export interface RawFeedsItem { uin?: number | string; nickname?: string; abstime?: number | string; appid?: number | string; key?: string; feedskey?: string; html?: string; likecnt?: unknown; commentcnt?: unknown; } interface RawFeedsMain { hasMoreFeeds?: boolean | number | string; error?: string; [key: string]: unknown; } export interface RawFeedsResponse { code?: number; subcode?: number; message?: string; data?: { main?: RawFeedsMain | null; hasmore?: boolean | number | string; data?: (RawFeedsItem | null | undefined)[] | null; } | null; } export interface QzoneFeed { uin: number; nickname: string; time: number; appid: number; key: string; html: string; } export interface QzoneFeedWithContent extends QzoneFeed, QzoneFeedContent {} export interface QzoneFeedsPage { feeds: QzoneFeedWithContent[]; has_more: boolean; next_cursor: string | null; } function feedHasMore(data: RawFeedsResponse): boolean { const value = data.data?.main === undefined ? data.data?.hasmore : data.data?.main?.hasMoreFeeds; if (value === true || value === 1 || value === '1') return true; if (value === false || value === 0 || value === '0') return false; throw new Error('空间好友动态缺少有效的分页结束标志'); } /** 纯映射;翻页续传由 getQzoneFeeds 接线。 */ export function mapQzoneFeeds(data: RawFeedsResponse): QzoneFeedsPage { if (!Array.isArray(data.data?.data)) { throw new Error('无法获取空间好友动态(响应结构异常)'); } // web 载荷用 null/undefined 填充列表;这些不是 feed 条目 const list = data.data.data.filter((item) => item != null); return { feeds: list.map((item) => { if (typeof item !== 'object' || Array.isArray(item)) { throw new Error('空间好友动态条目结构异常'); } const feed: QzoneFeed = { uin: Number(item['uin'] ?? 0), nickname: String(item['nickname'] ?? ''), time: Number(item['abstime'] ?? 0), appid: Number(item['appid'] ?? 0), key: String(item['key'] ?? item['feedskey'] ?? ''), html: String(item['html'] ?? ''), }; if ( ![feed.uin, feed.time, feed.appid].every( (n) => Number.isSafeInteger(n) && n >= 0, ) || typeof feed.nickname !== 'string' || typeof feed.html !== 'string' ) { throw new Error('空间好友动态条目字段异常'); } return { ...feed, ...parseQzoneFeedContent( feed.html, feed.key, feed.uin, feed.appid, item['likecnt'], item['commentcnt'], ), }; }), has_more: feedHasMore(data), next_cursor: null, }; }