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:
commit
b9655a5ab1
1048 files changed
+130851
No files matched your search
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 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<StateField, string>;
|
||||
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 承接 web 客户端用的字段,含不透明服务端状态。 */
|
||||
export function nextQzoneFeedState(
|
||||
main: Record<string, unknown>,
|
||||
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<typeof DomUtils.findAll>[number];
|
||||
type Node = ReturnType<typeof parseDocument>['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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user