Files
SnowLuma/packages/protocol/tests/web/qzone-feeds.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

287 lines
13 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { getQzoneFeeds } from '../../src/web/qzone';
import { RequestUtil } from '../../src/web/request-util';
import { parseQzoneFeedContent } from '../../src/web/qzone-feed-content';
const cookies = { p_skey: 'fixture' };
function page(key: string, more: boolean, state: Record<string, unknown> = {}) {
return `_preloadCallback(${JSON.stringify({
code: 0,
data: {
main: {
hasMoreFeeds: more,
error: '',
begintime: '1700000000',
dayspac: '5',
pagenum: '2',
externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b',
...state,
},
data: [
{
uin: '10002',
nickname: 'Reader',
abstime: '1700000001',
appid: '311',
key,
html: `<li class="f-single"><div class="f-item" data-key="${key}">
<div class="f-info">Hello &amp; <a>friend</a><br>Second line<img alt="[笑]" src="https://example.com/face.gif"></div>
<i name="feed_data" data-uin="10002" data-fkey="${key}" data-tid="${key}"></i>
<div class="qz_summary"><a class="img-item" data-originurl="https://example.com/full.jpg?a=1&amp;b=2"><img src="https://example.com/thumb.jpg"></a></div>
</div><div class="f-single-foot"><a class="qz_like_btn_v3" data-likecnt="12">赞</a>
<div class="mod-comments"><div class="f-info">Another person's comment</div><img src="https://example.com/comment.jpg"></div>
</div></li>`,
commentcnt: '3',
},
null,
],
},
})});`;
}
describe('Qzone friend feed continuation', () => {
afterEach(() => vi.restoreAllMocks());
it('uses server continuation state and preserves structured content alongside legacy fields', async () => {
const request = vi
.spyOn(RequestUtil, 'HttpGetText')
.mockResolvedValueOnce(
page('first', true, {
aisortEndTime: '17',
aisortOffset: '4',
aisortBeginTime: '11',
aisortNextTime: '2',
icServerTime: '1700000010',
daylist: 'd',
uinlist: '10002',
}),
)
.mockResolvedValueOnce(page('second', false));
const first = await getQzoneFeeds(cookies, '10001');
expect(first.has_more).toBe(true);
expect(first.next_cursor).toEqual(expect.any(String));
expect(first.feeds[0]).toMatchObject({
uin: 10002,
appid: 311,
key: 'first',
tid: 'first',
content: 'Hello & friend\nSecond line[笑]',
content_complete: true,
images: ['https://example.com/full.jpg?a=1&b=2'],
like_count: 12,
comment_count: 3,
html: expect.stringContaining('Another person'),
});
const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!);
expect(second).toMatchObject({ has_more: false, next_cursor: null });
expect(second.feeds[0].key).toBe('second');
const query = new URL(request.mock.calls[1][0]).searchParams;
expect(Object.fromEntries(query)).toMatchObject({
uin: '10001',
pagenum: '2',
begintime: '1700000000',
dayspac: '5',
externparam: 'basetime=1700000000&pagenum=2&opaque=a%26b',
aisortEndTime: '17',
aisortOffset: '4',
aisortBeginTime: '11',
getAisort: '2',
icServerTime: '1700000010',
daylist: 'd',
uinlist: '10002',
refresh: '0',
});
expect(request).toHaveBeenCalledTimes(2);
});
it('resolves a legacy page number by traversing continuations, not restarting at that number', async () => {
const request = vi
.spyOn(RequestUtil, 'HttpGetText')
.mockResolvedValueOnce(page('first', true))
.mockResolvedValueOnce(page('second', false));
const result = await getQzoneFeeds(cookies, '10001', 2, 20);
expect(result.feeds[0].key).toBe('second');
expect(request.mock.calls.map(([url]) => new URL(url).searchParams.get('pagenum'))).toEqual(
['1', '2'],
);
});
it('rejects stalled continuation instead of looping or silently ending the list', async () => {
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('same', true));
await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow(/未推进|advance/);
expect(request).toHaveBeenCalledTimes(2);
});
it('keeps empty intermediate pages traversable and stops at the real end', async () => {
const empty = JSON.parse(page('unused', true).slice(17, -2));
empty.data.data = [null];
const request = vi
.spyOn(RequestUtil, 'HttpGetText')
.mockResolvedValueOnce(JSON.stringify(empty))
.mockResolvedValueOnce(page('last', false));
const first = await getQzoneFeeds(cookies, '10001');
expect(first.feeds).toEqual([]);
expect(first.has_more).toBe(true);
expect(
(await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!)).feeds[0].key,
).toBe('last');
request.mockReset().mockResolvedValue(page('last', false));
expect(await getQzoneFeeds(cookies, '10001', 8)).toEqual({
feeds: [],
has_more: false,
next_cursor: null,
});
expect(request).toHaveBeenCalledTimes(1);
});
it('rejects foreign-account, corrupt and mismatched-page cursors before HTTP', async () => {
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(page('first', true));
const first = await getQzoneFeeds(cookies, '10001');
request.mockClear();
await expect(getQzoneFeeds(cookies, '10002', 1, 10, first.next_cursor!)).rejects.toThrow(
'账号',
);
await expect(getQzoneFeeds(cookies, '10001', 3, 10, first.next_cursor!)).rejects.toThrow(
'不匹配',
);
for (const cursor of ['', 'bad!', 'e30', 'a'.repeat(65537)]) {
await expect(getQzoneFeeds(cookies, '10001', 1, 10, cursor)).rejects.toThrow('cursor');
}
const raw = JSON.parse(Buffer.from(first.next_cursor!, 'base64url').toString());
raw.state.url = 'https://example.com/foreign';
await expect(
getQzoneFeeds(
cookies,
'10001',
1,
10,
Buffer.from(JSON.stringify(raw)).toString('base64url'),
),
).rejects.toThrow('cursor');
expect(request).not.toHaveBeenCalled();
});
it('preserves opaque parameters and sort state when a later page omits optional fields', async () => {
const request = vi
.spyOn(RequestUtil, 'HttpGetText')
.mockResolvedValueOnce(page('first', true, { aisortOffset: '9', aisortNextTime: '2' }))
.mockResolvedValueOnce(
page('second', true, {
pagenum: '3',
begintime: '1699999000',
externparam: '',
}),
)
.mockResolvedValueOnce(page('third', false));
const first = await getQzoneFeeds(cookies, '10001');
const second = await getQzoneFeeds(cookies, '10001', 1, 10, first.next_cursor!);
await getQzoneFeeds(cookies, '10001', 1, 10, second.next_cursor!);
const query = new URL(request.mock.calls[2][0]).searchParams;
expect(query.get('aisortOffset')).toBe('9');
expect(query.get('getAisort')).toBe('2');
expect(query.get('externparam')).toBe('basetime=1700000000&pagenum=2&opaque=a%26b');
});
it('does not regard the changing server clock as pagination progress', async () => {
vi.spyOn(RequestUtil, 'HttpGetText')
.mockResolvedValueOnce(page('first', true, { icServerTime: '20' }))
.mockResolvedValueOnce(page('first', true, { icServerTime: '21' }));
await expect(getQzoneFeeds(cookies, '10001', 3)).rejects.toThrow('未推进');
});
it.each([
{ code: 0, subcode: -1, data: { data: [] } },
{ code: 0, data: { main: { error: 'more', hasMoreFeeds: false }, data: [] } },
{ code: 0, data: { main: { hasMoreFeeds: 'false' }, data: [] } },
{ code: 0, data: { main: { hasMoreFeeds: true }, data: [] } },
{ code: 0, data: { main: { hasMoreFeeds: true, pagenum: {} }, data: [] } },
{ code: 0, data: { hasmore: 1, data: [] } },
{ code: 0, data: { main: [], data: [] } },
{ code: 0, data: { main: { hasMoreFeeds: false }, data: [false] } },
{ code: 0, data: { main: { hasMoreFeeds: false }, data: [{ uin: 'broken' }] } },
])(
'exposes malformed or failed responses instead of returning an empty success (%j)',
async (response) => {
vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue(JSON.stringify(response));
await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow();
},
);
it('propagates transport failures without retrying or resetting pagination', async () => {
const request = vi
.spyOn(RequestUtil, 'HttpGetText')
.mockRejectedValue(new Error('network unavailable'));
await expect(getQzoneFeeds(cookies, '10001')).rejects.toThrow('network unavailable');
expect(request).toHaveBeenCalledTimes(1);
});
});
describe('Qzone friend feed content', () => {
function parse(body: string, like?: unknown, comments?: unknown) {
return parseQzoneFeedContent(
`<div class="f-item" data-key="post">${body}</div>`,
'post',
10002,
311,
like,
comments,
);
}
it('marks excerpts as incomplete, removes expansion controls and prefers the full body when present', () => {
const excerpt =
'<div class="f-info qz_info_cut">Start<a data-cmd="qz_toggle">展开全文</a></div>';
expect(parse(excerpt + '<div class="f-info qz_info_complete none"></div>')).toMatchObject({
content: 'Start',
content_complete: false,
});
expect(
parse(excerpt + '<div class="f-info qz_info_complete none">Start and finish</div>'),
).toMatchObject({ content: 'Start and finish', content_complete: true });
});
it('preserves explicit zero, distinguishes unknown values and never estimates counts from visible comments', () => {
expect(parse('<div class="f-info"></div>', '0', 0)).toMatchObject({
content: '',
content_complete: true,
images: [],
like_count: 0,
comment_count: 0,
});
expect(parse('<div class="mod-comments"><div>one</div></div>', '', 'many')).toMatchObject({
content: null,
content_complete: null,
like_count: null,
comment_count: null,
});
expect(
parseQzoneFeedContent('<div>unrecognised template</div>', 'post', 10002, 311),
).toMatchObject({ tid: null, content: null, images: null });
});
it('excludes video covers, nested reposts, avatars and comment pictures', () => {
expect(
parse(`
<img src="https://example.com/avatar.jpg">
<div class="f-ct-video"><a class="img-item"><img src="https://example.com/video.jpg"></a></div>
<a class="img-item" data-v_itemid="video"><img src="https://example.com/video2.jpg"></a>
<div class="mod-comments"><a class="img-item"><img src="https://example.com/comment.jpg"></a></div>
<div class="f-item-passive"><div class="f-info">repost</div><a class="img-item"><img src="https://example.com/repost.jpg"></a></div>
<a class="img-item"><img truesrc="//example.com/photo.jpg?a=1&amp;b=2" src="https://example.com/loading.gif"></a>
<a class="img-item"><img src="javascript:bad()"></a>
`),
).toMatchObject({ content: null, images: ['https://example.com/photo.jpg?a=1&b=2'] });
});
it('parses markup as inert data and decodes entities without including script or style text', () => {
expect(
parse(
'<div class="f-info">&lt;b&gt;&#128512;<script>throw 1</script><style>secret</style><p>Next</p></div>',
),
).toMatchObject({ content: '<b>😀\nNext' });
});
});