import { describe, expect, it } from 'vitest'; import { advanceSlQzoneFeedState, initialSlQzoneFeedState, parseSlQzoneFeeds, slQzoneFeedPosition, type SlQzoneFeedState, } from '@/napcat-core/data/qzone-snowluma-feeds'; // All payloads here are newly synthesized protocol data, not reference-project fixtures. function packet (items: unknown[], main: Record = { hasMoreFeeds: false }): string { return `_preloadCallback(${JSON.stringify({ code: 0, subcode: 0, data: { main, data: items } })});`; } function post (html: string, extra: Record = {}) { return { uin: '330003', nickname: '林', abstime: '1800000123', appid: '311', key: 'entry-nine', html, ...extra }; } function ownHtml (body: string, footer = ''): string { return `
${body}
${footer}
`; } describe('independent SL feed data-literal parsing', () => { it('accepts the required JS data subset without evaluating it', () => { const input = '_preloadCallback({code:0,subcode:0,data:{main:{hasMoreFeeds:false},data:[undefined,,{uin:\'330003\',nickname:\'云\\u2601\',abstime:1800000123,appid:\'311\',key:\'entry-nine\',html:\'\\x3Cdiv class="f-item" data-key="entry-nine"\\x3E\\x3Cdiv class="f-info"\\x3E文字 undefined, } )\\x3C/div\\x3E\\x3C/div\\x3E\',},],},});'; const result = parseSlQzoneFeeds(input); expect(result.has_more).toBe(false); expect(result.feeds).toHaveLength(1); expect(result.feeds[0]).toMatchObject({ uin: 330003, nickname: '云☁', time: 1800000123, appid: 311, content: '文字 undefined, } )', content_complete: true }); expect(result.feeds[0]?.html).toContain(' { const data = { code: 0, data: { data: [], hasmore: '0' } }; for (const text of [JSON.stringify(data), `_Callback(${JSON.stringify(data)})`, `callback(${JSON.stringify(data)});`, '{/* data-only */code:0,data:{main:{hasMoreFeeds:false},data:[],},}']) { expect(parseSlQzoneFeeds(text)).toMatchObject({ feeds: [], has_more: false }); } }); it('does not interpret undefined in strings or as a property name', () => { const result = parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false,undefined:\'ordinary key\',externparam:undefined},data:[{uin:330003,appid:311,key:\'entry-nine\',html:\'undefined\',nickname:\'undefined\',abstime:0}]}}'); expect(result.feeds[0]?.nickname).toBe('undefined'); expect(result.feeds[0]?.html).toBe('undefined'); expect(result.main?.['undefined']).toBe('ordinary key'); expect(result.main).not.toHaveProperty('externparam'); expect(advanceSlQzoneFeedState({ ...initialSlQzoneFeedState(), externparam: 'retained' }, result.main!).externparam).toBe('retained'); }); it.each([ '_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});danger();', 'danger();_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});', 'unknownCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});', '', '{code:0,data:(function(){throw new Error("private");})()}', '{code:0,data:{get main(){return {hasMoreFeeds:false}},data:[]}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],x:new Date()}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],x:Infinity}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],x:NaN}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],"__proto__":{"polluted":true}}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],"constr\\u0075ctor":{}}}', '{code:0,data:{main:{hasMoreFeeds:false},data:[],x:/regex/}}', ])('rejects executable or unsupported bodies without echoing their text (%#)', (text) => { let error: unknown; try { parseSlQzoneFeeds(text); } catch (caught) { error = caught; } expect(error).toBeInstanceOf(Error); expect(String(error)).not.toMatch(/private|danger|polluted|function/); expect(({} as { polluted?: boolean; }).polluted).toBeUndefined(); }); it('requires explicit numeric success and a recognizable page structure', () => { for (const body of [ {}, { code: '0', data: { data: [], hasmore: 0 } }, { code: 0, subcode: -1 }, { code: -7, subcode: 0 }, { code: 0, subcode: '0' }, { code: 0, data: {} }, { code: 0, data: { data: [] } }, { code: 0, data: { data: [], main: null } }, { code: 0, data: { data: [], main: { hasMoreFeeds: 'false' } } }, { code: 0, data: { data: [], main: { hasMoreFeeds: false, error: 'private failure' } } }, { code: 0, data: { data: [], hasmore: true } }, ]) expect(() => parseSlQzoneFeeds(JSON.stringify(body))).toThrow(); }); it('rejects duplicate root success keys even when quoted or escaped, without rejecting ordinary unknown duplicates', () => { const data = 'data:{main:{hasMoreFeeds:false},data:[]}'; for (const keys of [ 'code:-1,code:0', 'code:-1,"code":0', '"co\\u0064e":-1,code:0', 'co\\u0064e:-1,code:0', 'code:0,subcode:-9,"sub\\u0063ode":0', 'code:0,subcode:0,subcode:0', ]) expect(() => parseSlQzoneFeeds(`_preloadCallback({${keys},${data}});`)).toThrow(); expect(parseSlQzoneFeeds('{code:0,message:\'old\',message:\'new\',data:{main:{hasMoreFeeds:false,code:-9,code:0},data:[]}}').feeds).toEqual([]); }); it('uses main.hasMoreFeeds before the legacy fallback and keeps the raw main state', () => { const main = { hasMoreFeeds: '1', pagenum: '7', externparam: 'sort=chronological&opaque=a%26b', icServerTime: '1800001000' }; const result = parseSlQzoneFeeds(packet([], main)); expect(result).toEqual({ feeds: [], has_more: true, main }); expect(() => parseSlQzoneFeeds(JSON.stringify({ code: 0, data: { main: {}, hasmore: 0, data: [] } }))).toThrow('结束标志'); }); it('bounds input and nesting before passing data to JSON5', () => { expect(() => parseSlQzoneFeeds(' '.repeat(8 * 1024 * 1024 + 1))).toThrow('过大'); expect(() => parseSlQzoneFeeds(`{code:0,data:${'['.repeat(66)}0${']'.repeat(66)}}`)).toThrow(); expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:[/* never closed')).toThrow(); expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:["unterminated]}}')).toThrow(); }); it('rejects malformed real entries rather than dropping them along with padding', () => { for (const item of [12, 'bad', [], post('', { uin: 'not-a-number' }), post('', { abstime: Number.MAX_SAFE_INTEGER + 1 }), post('', { html: {} }), post('', { key: {} })]) { expect(() => parseSlQzoneFeeds(packet([null, item]))).toThrow(); } expect(parseSlQzoneFeeds(packet([null, null])).feeds).toEqual([]); }); }); describe('independent passive HTML feed projection', () => { it('extracts own text, matching tid, original images and explicit counters while retaining HTML exactly', () => { const html = ownHtml('
测试 & 文字
第二行[云]
', ''); const feed = parseSlQzoneFeeds(packet([post(html, { commentcnt: '6' })])).feeds[0]!; expect(feed).toEqual({ uin: 330003, nickname: '林', time: 1800000123, appid: 311, key: 'entry-nine', html, tid: 'tid-nine', content: '测试 & 文字\n第二行[云]', content_complete: true, images: ['https://cdn.example.test/full.webp?a=1&b=2'], like_count: 17, comment_count: 6, }); }); it('distinguishes truncated text from hidden complete text without including expand controls', () => { const partial = ownHtml('
短内容阅读全文
'); expect(parseSlQzoneFeeds(packet([post(partial)])).feeds[0]).toMatchObject({ content: '短内容', content_complete: false }); const full = ownHtml('
短内容展开
'); expect(parseSlQzoneFeeds(packet([post(full)])).feeds[0]).toMatchObject({ content: '完整内容\n下一段', content_complete: true }); }); it('keeps avatars, comments, repost bodies, scripts and video covers out of the original post', () => { const html = ownHtml(`
原帖
评论内容
转发正文
`); const feed = parseSlQzoneFeeds(packet([post(html)])).feeds[0]!; expect(feed.content).toBe('原帖'); expect(feed.images).toEqual(['https://example.test/photo.png']); expect(feed.like_count).toBeNull(); }); it('does not derive a mood tid from a feed key, another author or a non-mood app', () => { const mismatched = ownHtml('
文字
').replace('data-uin="330003"', 'data-uin="440004"'); expect(parseSlQzoneFeeds(packet([post(mismatched)])).feeds[0]?.tid).toBeNull(); expect(parseSlQzoneFeeds(packet([post(ownHtml('
相册说明
'), { appid: 4 })])).feeds[0]?.tid).toBeNull(); const absent = parseSlQzoneFeeds(packet([post('
没有可确认的正文
')])).feeds[0]!; expect(absent).toMatchObject({ tid: null, content: null, content_complete: null, images: null, like_count: null, comment_count: null }); }); it('handles normal void tags/entities and deduplicates images without accepting unsafe URLs', () => { const html = ownHtml('

段落一 文字

段落二

'); const feed = parseSlQzoneFeeds(packet([post(html, { likecnt: '4', commentcnt: 0 })])).feeds[0]!; expect(feed.content).toBe('段落一 文字\n\n段落二'); expect(feed.images).toEqual(['https://example.test/a.png']); expect(feed.like_count).toBe(4); expect(feed.comment_count).toBe(0); }); it('declines ambiguous content roots and bounds excessive HTML structure', () => { const duplicate = '
A
B
'; expect(parseSlQzoneFeeds(packet([post(duplicate)])).feeds[0]?.content).toBeNull(); expect(() => parseSlQzoneFeeds(packet([post(`
${''.repeat(70)}deep${''.repeat(70)}
`)]))).toThrow('HTML'); expect(() => parseSlQzoneFeeds(packet([post('x'.repeat(2 * 1024 * 1024 + 1))]))).toThrow('HTML过大'); }); }); describe('SL feed server state helpers', () => { it('uses explicit initial fields and updates only values actually supplied by the server', () => { const first = initialSlQzoneFeedState(); expect(first).toEqual({ begintime: '0', dayspac: '0', pagenum: '1', aisortEndTime: '0', aisortOffset: '0', aisortBeginTime: '0', aisortNextTime: '0', icServerTime: '0', externparam: '', daylist: '', uinlist: '' }); const next = advanceSlQzoneFeedState(first, { pagenum: '0007', begintime: '1800000000', aisortNextTime: 3, externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003', ignored: 'never put into query state' }); expect(next).toMatchObject({ pagenum: '7', begintime: '1800000000', aisortNextTime: '3', externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003' }); expect(first.pagenum).toBe('1'); const partial = advanceSlQzoneFeedState(next, { externparam: '', daylist: '', uinlist: '', aisortOffset: '8' }); expect(partial).toMatchObject({ pagenum: '7', externparam: next.externparam, daylist: 'day-a', uinlist: '', aisortOffset: '8' }); expect(partial).not.toHaveProperty('ignored'); }); it('does not count clock-only changes or property order as pagination progress', () => { const state = advanceSlQzoneFeedState(initialSlQzoneFeedState(), { externparam: 'p=2', pagenum: '2' }); const changedClock = advanceSlQzoneFeedState(state, { icServerTime: '1800000123' }); expect(slQzoneFeedPosition(changedClock)).toBe(slQzoneFeedPosition(state)); const reordered = Object.fromEntries(Object.entries(state).reverse()) as unknown as SlQzoneFeedState; expect(slQzoneFeedPosition(reordered)).toBe(slQzoneFeedPosition(state)); expect(slQzoneFeedPosition(advanceSlQzoneFeedState(state, { externparam: 'p=3' }))).not.toBe(slQzoneFeedPosition(state)); }); it('preserves large digit strings without rounding and rejects unsafe numeric or oversized state', () => { expect(advanceSlQzoneFeedState(initialSlQzoneFeedState(), { icServerTime: '9007199254740993' }).icServerTime).toBe('9007199254740993'); for (const update of [{ pagenum: 0 }, { pagenum: '-2' }, { dayspac: 1.5 }, { begintime: Number.MAX_SAFE_INTEGER + 1 }, { externparam: {} }, { daylist: 'x'.repeat(32769) }]) { expect(() => advanceSlQzoneFeedState(initialSlQzoneFeedState(), update)).toThrow(); } }); });