Files
DrSmoothl 9bf4b9107b feat(onebot): add structured Qzone feeds and cursor pagination
Expose readable feed content, media and available statistics while preserving existing result fields and callers. Carry continuation state across pages and report invalid or stalled pagination explicitly.
2026-10-24 22:16:06 +08:00

84 lines
3.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { mapFeeds, parseQzoneCallback } from '@snowluma/protocol/web/qzone';
// Real feeds3_html_more response captured live (issue #182). Its `data` value
// is a JS object literal (unquoted keys, single-quoted strings, \xNN escapes,
// trailing `undefined`) — JSON.parse throws on it; parseQzoneCallback must not.
const SAMPLE = readFileSync(
fileURLToPath(new URL('./fixtures/qzone-feeds-sample.jsonp', import.meta.url)),
'utf8',
);
interface RawFeeds {
code?: number;
data?: { data?: unknown[]; hasmore?: number | string };
}
describe('qzone feeds JS-literal parsing (issue #182)', () => {
it('JSON.parse chokes on the real body (regression baseline)', () => {
const inner = SAMPLE.slice(SAMPLE.indexOf('{'), SAMPLE.lastIndexOf('}') + 1);
expect(() => JSON.parse(inner)).toThrow();
});
it('parseQzoneCallback evaluates the JS-literal payload', () => {
const data = parseQzoneCallback<RawFeeds>(SAMPLE);
expect(data.code).toBe(0);
expect(Array.isArray(data.data?.data)).toBe(true);
// The array carries a trailing `undefined` hole alongside the real feed.
expect(data.data!.data!.length).toBe(2);
expect(data.data!.data!.some((x) => x === undefined)).toBe(true);
});
it('mapFeeds drops the undefined hole and normalises the feed', () => {
const data = parseQzoneCallback(SAMPLE);
const result = mapFeeds(data as any);
expect(result.feeds).toHaveLength(1);
const f = result.feeds[0]!;
expect(f.uin).toBe(20050606);
expect(f.nickname).toBe('官方Qzone');
expect(f.appid).toBe(311);
expect(f.time).toBe(1780711209);
expect(f.key).toBe('aef23101297f236ae4f10600');
// \xNN escapes decoded by the JS eval, html passed through verbatim.
expect(f.html).toContain('<li class="f-single f-s-s"');
expect(f.html).toContain('官方Qzone');
expect(f.tid).toBe(f.key);
expect(f.content).toContain('QQ空间');
expect(f.content).not.toContain('展开全文');
expect(f.content_complete).toBe(false);
expect(f.images).toEqual([]);
expect(f.like_count).toBe(3173513);
expect(f.comment_count).toBeNull();
expect(result.has_more).toBe(false);
});
it('parses as inert data — never resolves identifiers or executes code', () => {
// The parser has no notion of globals, member access, or calls: any such
// token is rejected, so a tampered body cannot reach `process`/`Function`
// etc. (the whole reason we do NOT eval this payload).
expect(() => parseQzoneCallback('_preloadCallback({x: process})')).toThrow();
expect(() => parseQzoneCallback('_preloadCallback({x: callback.constructor})')).toThrow();
expect(() =>
parseQzoneCallback('_preloadCallback({x: (function(){return 1})()})'),
).toThrow();
expect(() => parseQzoneCallback('_preloadCallback(eval("1+1"))')).toThrow();
});
it('does not pollute Object.prototype via a __proto__ key', () => {
parseQzoneCallback('_preloadCallback({"__proto__":{"polluted":1},"code":0})');
expect(({} as any).polluted).toBeUndefined();
});
it('decodes JS string escapes (\\xNN, \\/) literally, not as markup', () => {
const d = parseQzoneCallback<{ s?: string }>("_preloadCallback({s:'\\x3Cb\\x3E&\\/x'})");
expect(d.s).toBe('<b>&/x');
});
it('rejects a body that captures no object', () => {
expect(() => parseQzoneCallback('_preloadCallback(42)')).toThrow('invalid feeds response');
expect(() => parseQzoneCallback('void 0')).toThrow('invalid feeds response');
});
});