@jackwener/opencli 1.5.5 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/README.zh-CN.md +36 -4
- package/dist/browser/daemon-client.d.ts +5 -1
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +15 -0
- package/dist/cli-manifest.json +1229 -67
- package/dist/clis/band/bands.d.ts +1 -0
- package/dist/clis/band/bands.js +72 -0
- package/dist/clis/band/mentions.d.ts +1 -0
- package/dist/clis/band/mentions.js +127 -0
- package/dist/clis/band/post.d.ts +1 -0
- package/dist/clis/band/post.js +175 -0
- package/dist/clis/band/posts.d.ts +1 -0
- package/dist/clis/band/posts.js +94 -0
- package/dist/clis/doubao/detail.d.ts +1 -0
- package/dist/clis/doubao/detail.js +33 -0
- package/dist/clis/doubao/detail.test.d.ts +1 -0
- package/dist/clis/doubao/detail.test.js +42 -0
- package/dist/clis/doubao/history.d.ts +1 -0
- package/dist/clis/doubao/history.js +28 -0
- package/dist/clis/doubao/history.test.d.ts +1 -0
- package/dist/clis/doubao/history.test.js +37 -0
- package/dist/clis/doubao/meeting-summary.d.ts +1 -0
- package/dist/clis/doubao/meeting-summary.js +39 -0
- package/dist/clis/doubao/meeting-transcript.d.ts +1 -0
- package/dist/clis/doubao/meeting-transcript.js +36 -0
- package/dist/clis/doubao/utils.d.ts +27 -0
- package/dist/clis/doubao/utils.js +317 -0
- package/dist/clis/doubao/utils.test.d.ts +1 -0
- package/dist/clis/doubao/utils.test.js +24 -0
- package/dist/clis/douyin/_shared/public-api.d.ts +33 -0
- package/dist/clis/douyin/_shared/public-api.js +29 -0
- package/dist/clis/douyin/user-videos.d.ts +5 -0
- package/dist/clis/douyin/user-videos.js +74 -0
- package/dist/clis/douyin/user-videos.test.d.ts +1 -0
- package/dist/clis/douyin/user-videos.test.js +108 -0
- package/dist/clis/ones/common.d.ts +32 -0
- package/dist/clis/ones/common.js +144 -0
- package/dist/clis/ones/enrich-tasks.d.ts +5 -0
- package/dist/clis/ones/enrich-tasks.js +37 -0
- package/dist/clis/ones/login.d.ts +1 -0
- package/dist/clis/ones/login.js +80 -0
- package/dist/clis/ones/logout.d.ts +1 -0
- package/dist/clis/ones/logout.js +17 -0
- package/dist/clis/ones/me.d.ts +1 -0
- package/dist/clis/ones/me.js +30 -0
- package/dist/clis/ones/my-tasks.d.ts +1 -0
- package/dist/clis/ones/my-tasks.js +120 -0
- package/dist/clis/ones/resolve-labels.d.ts +10 -0
- package/dist/clis/ones/resolve-labels.js +64 -0
- package/dist/clis/ones/task-helpers.d.ts +29 -0
- package/dist/clis/ones/task-helpers.js +212 -0
- package/dist/clis/ones/task-helpers.test.d.ts +1 -0
- package/dist/clis/ones/task-helpers.test.js +12 -0
- package/dist/clis/ones/task.d.ts +1 -0
- package/dist/clis/ones/task.js +66 -0
- package/dist/clis/ones/tasks.d.ts +1 -0
- package/dist/clis/ones/tasks.js +79 -0
- package/dist/clis/ones/token-info.d.ts +1 -0
- package/dist/clis/ones/token-info.js +42 -0
- package/dist/clis/ones/worklog.d.ts +11 -0
- package/dist/clis/ones/worklog.js +267 -0
- package/dist/clis/ones/worklog.test.d.ts +1 -0
- package/dist/clis/ones/worklog.test.js +20 -0
- package/dist/clis/spotify/spotify.d.ts +1 -0
- package/dist/clis/spotify/spotify.js +316 -0
- package/dist/clis/spotify/utils.d.ts +21 -0
- package/dist/clis/spotify/utils.js +66 -0
- package/dist/clis/spotify/utils.test.d.ts +1 -0
- package/dist/clis/spotify/utils.test.js +67 -0
- package/dist/clis/tieba/commands.test.d.ts +4 -0
- package/dist/clis/tieba/commands.test.js +79 -0
- package/dist/clis/tieba/hot.d.ts +1 -0
- package/dist/clis/tieba/hot.js +48 -0
- package/dist/clis/tieba/posts.d.ts +1 -0
- package/dist/clis/tieba/posts.js +85 -0
- package/dist/clis/tieba/read.d.ts +1 -0
- package/dist/clis/tieba/read.js +140 -0
- package/dist/clis/tieba/search.d.ts +1 -0
- package/dist/clis/tieba/search.js +108 -0
- package/dist/clis/tieba/utils.d.ts +101 -0
- package/dist/clis/tieba/utils.js +240 -0
- package/dist/clis/tieba/utils.test.d.ts +1 -0
- package/dist/clis/tieba/utils.test.js +290 -0
- package/dist/clis/weread/book.js +100 -13
- package/dist/clis/weread/commands.test.js +221 -0
- package/dist/clis/weread/private-api-regression.test.d.ts +1 -0
- package/dist/{weread-private-api-regression.test.js → clis/weread/private-api-regression.test.js} +92 -30
- package/dist/clis/weread/search-regression.test.d.ts +1 -0
- package/dist/clis/weread/search-regression.test.js +407 -0
- package/dist/clis/weread/search.js +143 -7
- package/dist/clis/weread/shelf.js +13 -95
- package/dist/clis/weread/utils.d.ts +46 -0
- package/dist/clis/weread/utils.js +214 -7
- package/dist/clis/weread/utils.test.js +71 -1
- package/dist/clis/xiaohongshu/publish.d.ts +1 -1
- package/dist/clis/xiaohongshu/publish.js +78 -31
- package/dist/clis/xiaohongshu/publish.test.js +66 -1
- package/dist/clis/xiaohongshu/user-helpers.d.ts +1 -0
- package/dist/clis/xiaohongshu/user-helpers.js +2 -0
- package/dist/clis/xiaohongshu/user-helpers.test.js +18 -0
- package/dist/clis/xueqiu/comments.d.ts +118 -0
- package/dist/clis/xueqiu/comments.js +354 -0
- package/dist/clis/xueqiu/comments.test.d.ts +1 -0
- package/dist/clis/xueqiu/comments.test.js +696 -0
- package/dist/clis/youtube/transcript.js +2 -4
- package/dist/clis/youtube/utils.d.ts +9 -0
- package/dist/clis/youtube/utils.js +67 -3
- package/dist/clis/youtube/utils.test.d.ts +1 -0
- package/dist/clis/youtube/utils.test.js +37 -0
- package/dist/clis/youtube/video.js +16 -15
- package/dist/clis/zsxq/dynamics.d.ts +1 -0
- package/dist/clis/zsxq/dynamics.js +47 -0
- package/dist/clis/zsxq/groups.d.ts +1 -0
- package/dist/clis/zsxq/groups.js +32 -0
- package/dist/clis/zsxq/search.d.ts +1 -0
- package/dist/clis/zsxq/search.js +43 -0
- package/dist/clis/zsxq/search.test.d.ts +1 -0
- package/dist/clis/zsxq/search.test.js +24 -0
- package/dist/clis/zsxq/topic.d.ts +1 -0
- package/dist/clis/zsxq/topic.js +47 -0
- package/dist/clis/zsxq/topic.test.d.ts +1 -0
- package/dist/clis/zsxq/topic.test.js +29 -0
- package/dist/clis/zsxq/topics.d.ts +1 -0
- package/dist/clis/zsxq/topics.js +25 -0
- package/dist/clis/zsxq/topics.test.d.ts +1 -0
- package/dist/clis/zsxq/topics.test.js +24 -0
- package/dist/clis/zsxq/utils.d.ts +97 -0
- package/dist/clis/zsxq/utils.js +230 -0
- package/dist/commanderAdapter.js +1 -1
- package/dist/commanderAdapter.test.js +39 -0
- package/dist/external-clis.yaml +17 -0
- package/dist/types.d.ts +5 -0
- package/docs/.vitepress/config.mts +3 -0
- package/docs/adapters/browser/band.md +63 -0
- package/docs/adapters/browser/ones.md +59 -0
- package/docs/adapters/browser/spotify.md +62 -0
- package/docs/adapters/browser/tieba.md +45 -0
- package/docs/adapters/browser/xueqiu.md +5 -0
- package/docs/adapters/browser/zsxq.md +49 -0
- package/docs/adapters/index.md +5 -2
- package/docs/adapters-doc/ones.md +32 -0
- package/extension/src/background.ts +15 -0
- package/extension/src/cdp.ts +42 -0
- package/extension/src/protocol.ts +5 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +16 -0
- package/src/browser/daemon-client.ts +5 -1
- package/src/browser/page.ts +16 -0
- package/src/clis/band/bands.ts +76 -0
- package/src/clis/band/mentions.ts +134 -0
- package/src/clis/band/post.ts +187 -0
- package/src/clis/band/posts.ts +106 -0
- package/src/clis/doubao/detail.test.ts +53 -0
- package/src/clis/doubao/detail.ts +41 -0
- package/src/clis/doubao/history.test.ts +45 -0
- package/src/clis/doubao/history.ts +32 -0
- package/src/clis/doubao/meeting-summary.ts +53 -0
- package/src/clis/doubao/meeting-transcript.ts +48 -0
- package/src/clis/doubao/utils.test.ts +45 -0
- package/src/clis/doubao/utils.ts +371 -0
- package/src/clis/douyin/_shared/public-api.ts +84 -0
- package/src/clis/douyin/user-videos.test.ts +122 -0
- package/src/clis/douyin/user-videos.ts +101 -0
- package/src/clis/ones/common.ts +187 -0
- package/src/clis/ones/enrich-tasks.ts +47 -0
- package/src/clis/ones/login.ts +103 -0
- package/src/clis/ones/logout.ts +19 -0
- package/src/clis/ones/me.ts +34 -0
- package/src/clis/ones/my-tasks.ts +148 -0
- package/src/clis/ones/resolve-labels.ts +80 -0
- package/src/clis/ones/task-helpers.test.ts +14 -0
- package/src/clis/ones/task-helpers.ts +214 -0
- package/src/clis/ones/task.ts +79 -0
- package/src/clis/ones/tasks.ts +92 -0
- package/src/clis/ones/token-info.ts +46 -0
- package/src/clis/ones/worklog.test.ts +24 -0
- package/src/clis/ones/worklog.ts +306 -0
- package/src/clis/spotify/spotify.ts +328 -0
- package/src/clis/spotify/utils.test.ts +87 -0
- package/src/clis/spotify/utils.ts +92 -0
- package/src/clis/tieba/commands.test.ts +86 -0
- package/src/clis/tieba/hot.ts +52 -0
- package/src/clis/tieba/posts.ts +108 -0
- package/src/clis/tieba/read.ts +158 -0
- package/src/clis/tieba/search.ts +119 -0
- package/src/clis/tieba/utils.test.ts +322 -0
- package/src/clis/tieba/utils.ts +348 -0
- package/src/clis/weread/book.ts +116 -13
- package/src/clis/weread/commands.test.ts +249 -0
- package/src/{weread-private-api-regression.test.ts → clis/weread/private-api-regression.test.ts} +108 -30
- package/src/clis/weread/search-regression.test.ts +440 -0
- package/src/clis/weread/search.ts +189 -9
- package/src/clis/weread/shelf.ts +20 -122
- package/src/clis/weread/utils.test.ts +81 -1
- package/src/clis/weread/utils.ts +264 -7
- package/src/clis/xiaohongshu/publish.test.ts +79 -1
- package/src/clis/xiaohongshu/publish.ts +84 -30
- package/src/clis/xiaohongshu/user-helpers.test.ts +23 -0
- package/src/clis/xiaohongshu/user-helpers.ts +4 -0
- package/src/clis/xueqiu/comments.test.ts +823 -0
- package/src/clis/xueqiu/comments.ts +461 -0
- package/src/clis/youtube/transcript.ts +2 -4
- package/src/clis/youtube/utils.test.ts +43 -0
- package/src/clis/youtube/utils.ts +69 -0
- package/src/clis/youtube/video.ts +16 -15
- package/src/clis/zsxq/dynamics.ts +60 -0
- package/src/clis/zsxq/groups.ts +41 -0
- package/src/clis/zsxq/search.test.ts +29 -0
- package/src/clis/zsxq/search.ts +54 -0
- package/src/clis/zsxq/topic.test.ts +34 -0
- package/src/clis/zsxq/topic.ts +68 -0
- package/src/clis/zsxq/topics.test.ts +29 -0
- package/src/clis/zsxq/topics.ts +36 -0
- package/src/clis/zsxq/utils.ts +351 -0
- package/src/commanderAdapter.test.ts +47 -0
- package/src/commanderAdapter.ts +1 -1
- package/src/external-clis.yaml +17 -0
- package/src/types.ts +5 -0
- package/tests/e2e/band-auth.test.ts +20 -0
- package/tests/e2e/browser-auth-helpers.ts +18 -0
- package/tests/e2e/browser-auth.test.ts +35 -47
- package/tests/e2e/browser-public.test.ts +288 -0
- package/tests/e2e/management.test.ts +1 -1
- package/tests/e2e/plugin-management.test.ts +1 -1
- package/vitest.config.ts +1 -0
- package/SKILL.md +0 -879
- package/dist/weread-private-api-regression.test.d.ts +0 -1
- package/dist/weread-search-regression.test.d.ts +0 -1
- package/dist/weread-search-regression.test.js +0 -39
- package/src/weread-search-regression.test.ts +0 -44
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockWarn } = vi.hoisted(() => ({
|
|
4
|
+
mockWarn: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('../../logger.js', () => ({
|
|
8
|
+
log: {
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
warn: mockWarn,
|
|
11
|
+
error: vi.fn(),
|
|
12
|
+
verbose: vi.fn(),
|
|
13
|
+
debug: vi.fn(),
|
|
14
|
+
step: vi.fn(),
|
|
15
|
+
stepResult: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '../../errors.js';
|
|
20
|
+
import { getRegistry } from '../../registry.js';
|
|
21
|
+
import {
|
|
22
|
+
classifyXueqiuCommentsResponse,
|
|
23
|
+
collectCommentRows,
|
|
24
|
+
mergeUniqueCommentRows,
|
|
25
|
+
normalizeCommentItem,
|
|
26
|
+
normalizeSymbolInput,
|
|
27
|
+
} from './comments.js';
|
|
28
|
+
|
|
29
|
+
const command = getRegistry().get('xueqiu/comments');
|
|
30
|
+
|
|
31
|
+
function createCommandPage(response: unknown) {
|
|
32
|
+
return {
|
|
33
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
evaluate: vi.fn().mockResolvedValue(response),
|
|
35
|
+
} as any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('xueqiu comments', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockWarn.mockReset();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects blank symbol before any request is made', () => {
|
|
44
|
+
expect(() => normalizeSymbolInput(' ')).toThrow(ArgumentError);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects URL-like input before any request is made', () => {
|
|
48
|
+
expect(() => normalizeSymbolInput('https://xueqiu.com/S/SH600519')).toThrow(ArgumentError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('normalizes symbol by trimming and upper-casing it', () => {
|
|
52
|
+
expect(normalizeSymbolInput(' sh600519 ')).toBe('SH600519');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('accepts supported US and HK-style symbols', () => {
|
|
56
|
+
expect(normalizeSymbolInput('aapl')).toBe('AAPL');
|
|
57
|
+
expect(normalizeSymbolInput('00700')).toBe('00700');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects obviously invalid symbols before any request is made', () => {
|
|
61
|
+
expect(() => normalizeSymbolInput('INVALID')).toThrow(ArgumentError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('classifies 401 responses as auth failures', () => {
|
|
65
|
+
expect(
|
|
66
|
+
classifyXueqiuCommentsResponse({
|
|
67
|
+
status: 401,
|
|
68
|
+
contentType: 'application/json',
|
|
69
|
+
json: null,
|
|
70
|
+
textSnippet: '',
|
|
71
|
+
}),
|
|
72
|
+
).toMatchObject({ kind: 'auth' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('classifies html challenge pages as anti-bot failures', () => {
|
|
76
|
+
expect(
|
|
77
|
+
classifyXueqiuCommentsResponse({
|
|
78
|
+
status: 200,
|
|
79
|
+
contentType: 'text/html',
|
|
80
|
+
json: null,
|
|
81
|
+
textSnippet: '<textarea id="renderData">{"_waf_bd8ce2ce37":"token"}</textarea>',
|
|
82
|
+
}),
|
|
83
|
+
).toMatchObject({ kind: 'anti-bot' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('classifies 403 html challenge pages as anti-bot failures', () => {
|
|
87
|
+
expect(
|
|
88
|
+
classifyXueqiuCommentsResponse({
|
|
89
|
+
status: 403,
|
|
90
|
+
contentType: 'text/html',
|
|
91
|
+
json: null,
|
|
92
|
+
textSnippet: '<textarea id="renderData">{"_waf_bd8ce2ce37":"token"}</textarea>',
|
|
93
|
+
}),
|
|
94
|
+
).toMatchObject({ kind: 'anti-bot' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('classifies 403 html challenge pages without waf markers as anti-bot failures', () => {
|
|
98
|
+
expect(
|
|
99
|
+
classifyXueqiuCommentsResponse({
|
|
100
|
+
status: 403,
|
|
101
|
+
contentType: 'text/html',
|
|
102
|
+
json: null,
|
|
103
|
+
textSnippet: '<html><body>security challenge required</body></html>',
|
|
104
|
+
}),
|
|
105
|
+
).toMatchObject({ kind: 'anti-bot' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not misclassify generic html error pages as anti-bot failures', () => {
|
|
109
|
+
expect(
|
|
110
|
+
classifyXueqiuCommentsResponse({
|
|
111
|
+
status: 500,
|
|
112
|
+
contentType: 'text/html',
|
|
113
|
+
json: null,
|
|
114
|
+
textSnippet: '<html><body>server error</body></html>',
|
|
115
|
+
}),
|
|
116
|
+
).toMatchObject({ kind: 'unknown' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('classifies html login pages as auth failures', () => {
|
|
120
|
+
expect(
|
|
121
|
+
classifyXueqiuCommentsResponse({
|
|
122
|
+
status: 200,
|
|
123
|
+
contentType: 'text/html',
|
|
124
|
+
json: null,
|
|
125
|
+
textSnippet: '<html><body>login required</body></html>',
|
|
126
|
+
}),
|
|
127
|
+
).toMatchObject({ kind: 'auth' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('classifies invalid-symbol json envelopes as argument failures', () => {
|
|
131
|
+
expect(
|
|
132
|
+
classifyXueqiuCommentsResponse({
|
|
133
|
+
status: 200,
|
|
134
|
+
contentType: 'application/json',
|
|
135
|
+
json: { success: false, error: 'invalid symbol format' },
|
|
136
|
+
textSnippet: '',
|
|
137
|
+
}),
|
|
138
|
+
).toMatchObject({ kind: 'argument' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not misclassify required-field backend errors as auth failures', () => {
|
|
142
|
+
expect(
|
|
143
|
+
classifyXueqiuCommentsResponse({
|
|
144
|
+
status: 200,
|
|
145
|
+
contentType: 'application/json',
|
|
146
|
+
json: { success: false, message: 'symbol is required' },
|
|
147
|
+
textSnippet: '',
|
|
148
|
+
}),
|
|
149
|
+
).toMatchObject({ kind: 'incompatible' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('classifies json responses without a usable list as incompatible', () => {
|
|
153
|
+
expect(
|
|
154
|
+
classifyXueqiuCommentsResponse({
|
|
155
|
+
status: 200,
|
|
156
|
+
contentType: 'application/json',
|
|
157
|
+
json: { success: true, data: { next_max_id: 1 } },
|
|
158
|
+
textSnippet: '',
|
|
159
|
+
}),
|
|
160
|
+
).toMatchObject({ kind: 'incompatible' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('classifies empty discussion lists as empty results', () => {
|
|
164
|
+
expect(
|
|
165
|
+
classifyXueqiuCommentsResponse({
|
|
166
|
+
status: 200,
|
|
167
|
+
contentType: 'application/json',
|
|
168
|
+
json: { list: [] },
|
|
169
|
+
textSnippet: '',
|
|
170
|
+
}),
|
|
171
|
+
).toMatchObject({ kind: 'empty' });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('classifies unclear json error envelopes as incompatible', () => {
|
|
175
|
+
expect(
|
|
176
|
+
classifyXueqiuCommentsResponse({
|
|
177
|
+
status: 200,
|
|
178
|
+
contentType: 'application/json',
|
|
179
|
+
json: { success: false, message: 'unexpected backend state' },
|
|
180
|
+
textSnippet: '',
|
|
181
|
+
}),
|
|
182
|
+
).toMatchObject({ kind: 'incompatible' });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('deduplicates rows by stable id while preserving order', () => {
|
|
186
|
+
expect(
|
|
187
|
+
mergeUniqueCommentRows(
|
|
188
|
+
[],
|
|
189
|
+
[
|
|
190
|
+
{ id: 'a', author: 'alice' },
|
|
191
|
+
{ id: 'b', author: 'bob' },
|
|
192
|
+
{ id: 'a', author: 'alice-duplicate' },
|
|
193
|
+
],
|
|
194
|
+
),
|
|
195
|
+
).toEqual([
|
|
196
|
+
{ id: 'a', author: 'alice' },
|
|
197
|
+
{ id: 'b', author: 'bob' },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('normalizes one raw discussion item into a cleaned row', () => {
|
|
202
|
+
expect(
|
|
203
|
+
normalizeCommentItem({
|
|
204
|
+
id: 123,
|
|
205
|
+
description: '<p>hello <b>world</b></p>',
|
|
206
|
+
created_at: 1700000000000,
|
|
207
|
+
user: { screen_name: 'alice', id: 99 },
|
|
208
|
+
reply_count: 2,
|
|
209
|
+
retweet_count: 3,
|
|
210
|
+
fav_count: 4,
|
|
211
|
+
}),
|
|
212
|
+
).toEqual({
|
|
213
|
+
id: '123',
|
|
214
|
+
author: 'alice',
|
|
215
|
+
text: 'hello world',
|
|
216
|
+
likes: 4,
|
|
217
|
+
replies: 2,
|
|
218
|
+
retweets: 3,
|
|
219
|
+
created_at: new Date(1700000000000).toISOString(),
|
|
220
|
+
url: 'https://xueqiu.com/99/123',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('drops invalid created_at values instead of throwing', () => {
|
|
225
|
+
expect(
|
|
226
|
+
normalizeCommentItem({
|
|
227
|
+
id: 456,
|
|
228
|
+
description: 'hello',
|
|
229
|
+
created_at: 'not-a-date',
|
|
230
|
+
user: { screen_name: 'bob', id: 100 },
|
|
231
|
+
}),
|
|
232
|
+
).toEqual({
|
|
233
|
+
id: '456',
|
|
234
|
+
author: 'bob',
|
|
235
|
+
text: 'hello',
|
|
236
|
+
likes: 0,
|
|
237
|
+
replies: 0,
|
|
238
|
+
retweets: 0,
|
|
239
|
+
created_at: null,
|
|
240
|
+
url: 'https://xueqiu.com/100/456',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('drops object-like ids instead of turning them into fake identifiers', () => {
|
|
245
|
+
expect(
|
|
246
|
+
normalizeCommentItem({
|
|
247
|
+
id: { broken: true },
|
|
248
|
+
description: 'hello',
|
|
249
|
+
created_at: 1700000000000,
|
|
250
|
+
user: { screen_name: 'eve', id: { broken: true } },
|
|
251
|
+
}),
|
|
252
|
+
).toEqual({
|
|
253
|
+
id: '',
|
|
254
|
+
author: 'eve',
|
|
255
|
+
text: 'hello',
|
|
256
|
+
likes: 0,
|
|
257
|
+
replies: 0,
|
|
258
|
+
retweets: 0,
|
|
259
|
+
created_at: new Date(1700000000000).toISOString(),
|
|
260
|
+
url: null,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('normalizes invalid count fields to zero', () => {
|
|
265
|
+
expect(
|
|
266
|
+
normalizeCommentItem({
|
|
267
|
+
id: 789,
|
|
268
|
+
description: 'hello',
|
|
269
|
+
created_at: 1700000000000,
|
|
270
|
+
user: { screen_name: 'carol', id: 101 },
|
|
271
|
+
reply_count: 'oops',
|
|
272
|
+
retweet_count: Infinity,
|
|
273
|
+
fav_count: '',
|
|
274
|
+
}),
|
|
275
|
+
).toEqual({
|
|
276
|
+
id: '789',
|
|
277
|
+
author: 'carol',
|
|
278
|
+
text: 'hello',
|
|
279
|
+
likes: 0,
|
|
280
|
+
replies: 0,
|
|
281
|
+
retweets: 0,
|
|
282
|
+
created_at: new Date(1700000000000).toISOString(),
|
|
283
|
+
url: 'https://xueqiu.com/101/789',
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('registers the xueqiu comments command', () => {
|
|
288
|
+
expect(command).toMatchObject({
|
|
289
|
+
site: 'xueqiu',
|
|
290
|
+
name: 'comments',
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects blank symbol before navigating the page', async () => {
|
|
295
|
+
const page = {
|
|
296
|
+
goto: vi.fn(),
|
|
297
|
+
} as any;
|
|
298
|
+
|
|
299
|
+
await expect(command!.func!(page, { symbol: ' ', limit: 5 })).rejects.toThrow(ArgumentError);
|
|
300
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('throws auth error when the first page responds with 401', async () => {
|
|
304
|
+
const page = createCommandPage({
|
|
305
|
+
status: 401,
|
|
306
|
+
contentType: 'application/json',
|
|
307
|
+
json: null,
|
|
308
|
+
textSnippet: '',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await expect(command!.func!(page, { symbol: 'sh600519', limit: 5 })).rejects.toThrow(AuthRequiredError);
|
|
312
|
+
expect(page.goto).toHaveBeenCalledWith('https://xueqiu.com');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('rejects invalid symbols before navigating the page', async () => {
|
|
316
|
+
const page = {
|
|
317
|
+
goto: vi.fn(),
|
|
318
|
+
} as any;
|
|
319
|
+
|
|
320
|
+
await expect(command!.func!(page, { symbol: 'INVALID', limit: 5 })).rejects.toThrow(ArgumentError);
|
|
321
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('rejects non-positive limit before navigating the page', async () => {
|
|
325
|
+
const page = {
|
|
326
|
+
goto: vi.fn(),
|
|
327
|
+
} as any;
|
|
328
|
+
|
|
329
|
+
await expect(command!.func!(page, { symbol: 'SH600519', limit: 0 })).rejects.toThrow(ArgumentError);
|
|
330
|
+
await expect(command!.func!(page, { symbol: 'SH600519', limit: -1 })).rejects.toThrow(ArgumentError);
|
|
331
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('rejects limits above the supported maximum before navigating the page', async () => {
|
|
335
|
+
const page = {
|
|
336
|
+
goto: vi.fn(),
|
|
337
|
+
} as any;
|
|
338
|
+
|
|
339
|
+
await expect(command!.func!(page, { symbol: 'SH600519', limit: 101 })).rejects.toThrow(ArgumentError);
|
|
340
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('throws empty-result error with normalized symbol when the first page is empty', async () => {
|
|
344
|
+
const page = createCommandPage({
|
|
345
|
+
status: 200,
|
|
346
|
+
contentType: 'application/json',
|
|
347
|
+
json: { list: [] },
|
|
348
|
+
textSnippet: '',
|
|
349
|
+
});
|
|
350
|
+
const rejection = command!.func!(page, { symbol: 'sh600519', limit: 5 });
|
|
351
|
+
|
|
352
|
+
await expect(rejection).rejects.toThrow(EmptyResultError);
|
|
353
|
+
await expect(rejection).rejects.toThrow('SH600519');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('throws argument error when the first page reports an invalid symbol', async () => {
|
|
357
|
+
const page = createCommandPage({
|
|
358
|
+
status: 200,
|
|
359
|
+
contentType: 'application/json',
|
|
360
|
+
json: { success: false, error: 'invalid symbol format' },
|
|
361
|
+
textSnippet: '',
|
|
362
|
+
});
|
|
363
|
+
const rejection = command!.func!(page, { symbol: 'sh600519', limit: 5 });
|
|
364
|
+
|
|
365
|
+
await expect(rejection).rejects.toThrow(ArgumentError);
|
|
366
|
+
await expect(rejection).rejects.toThrow('SH600519');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('throws a compact incompatible-response error when json shape is unusable', async () => {
|
|
370
|
+
const page = createCommandPage({
|
|
371
|
+
status: 200,
|
|
372
|
+
contentType: 'application/json',
|
|
373
|
+
json: { success: true, data: { next_max_id: 1 } },
|
|
374
|
+
textSnippet: '',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const rejection = command!.func!(page, { symbol: 'sh600519', limit: 5 });
|
|
378
|
+
|
|
379
|
+
await expect(rejection).rejects.toThrow(CommandExecutionError);
|
|
380
|
+
await expect(rejection).rejects.toThrow('Unexpected response');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('throws auth-required error when the first page is an html challenge', async () => {
|
|
384
|
+
const page = createCommandPage({
|
|
385
|
+
status: 200,
|
|
386
|
+
contentType: 'text/html',
|
|
387
|
+
json: null,
|
|
388
|
+
textSnippet: '<textarea id="renderData">{"_waf_bd8ce2ce37":"token"}</textarea>',
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await expect(command!.func!(page, { symbol: 'sh600519', limit: 5 })).rejects.toThrow(AuthRequiredError);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('throws command-execution error when the first page fetch fails before any rows are available', async () => {
|
|
395
|
+
const page = createCommandPage({
|
|
396
|
+
status: 0,
|
|
397
|
+
contentType: 'text/plain',
|
|
398
|
+
json: null,
|
|
399
|
+
textSnippet: 'network failed',
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const rejection = command!.func!(page, { symbol: 'sh600519', limit: 5 });
|
|
403
|
+
|
|
404
|
+
await expect(rejection).rejects.toThrow(CommandExecutionError);
|
|
405
|
+
await expect(rejection).rejects.toThrow('Unexpected response');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('returns normalized rows when the first page includes discussion items', async () => {
|
|
409
|
+
const page = createCommandPage({
|
|
410
|
+
status: 200,
|
|
411
|
+
contentType: 'application/json',
|
|
412
|
+
json: {
|
|
413
|
+
list: [
|
|
414
|
+
{
|
|
415
|
+
id: 123,
|
|
416
|
+
description: '<p>hello <b>world</b></p>',
|
|
417
|
+
created_at: 1700000000000,
|
|
418
|
+
user: { screen_name: 'alice', id: 99 },
|
|
419
|
+
reply_count: 2,
|
|
420
|
+
retweet_count: 3,
|
|
421
|
+
fav_count: 4,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
textSnippet: '',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const result = await command!.func!(page, { symbol: 'sh600519', limit: 5 });
|
|
429
|
+
|
|
430
|
+
expect(result).toEqual([
|
|
431
|
+
{
|
|
432
|
+
author: 'alice',
|
|
433
|
+
text: 'hello world',
|
|
434
|
+
likes: 4,
|
|
435
|
+
replies: 2,
|
|
436
|
+
retweets: 3,
|
|
437
|
+
created_at: new Date(1700000000000).toISOString(),
|
|
438
|
+
url: 'https://xueqiu.com/99/123',
|
|
439
|
+
},
|
|
440
|
+
]);
|
|
441
|
+
expect(Object.keys((result as Array<Record<string, unknown>>)[0]).sort()).toEqual([
|
|
442
|
+
'author',
|
|
443
|
+
'created_at',
|
|
444
|
+
'likes',
|
|
445
|
+
'replies',
|
|
446
|
+
'retweets',
|
|
447
|
+
'text',
|
|
448
|
+
'url',
|
|
449
|
+
]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('collects later pages, deduplicates rows, and trims to limit', async () => {
|
|
453
|
+
const fetchPage = vi
|
|
454
|
+
.fn()
|
|
455
|
+
.mockResolvedValueOnce({
|
|
456
|
+
status: 200,
|
|
457
|
+
contentType: 'application/json',
|
|
458
|
+
json: {
|
|
459
|
+
list: [
|
|
460
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
461
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
textSnippet: '',
|
|
465
|
+
})
|
|
466
|
+
.mockResolvedValueOnce({
|
|
467
|
+
status: 200,
|
|
468
|
+
contentType: 'application/json',
|
|
469
|
+
json: {
|
|
470
|
+
list: [
|
|
471
|
+
{ id: 2, description: 'beta-duplicate', user: { screen_name: 'bob', id: 11 } },
|
|
472
|
+
{ id: 3, description: 'gamma', user: { screen_name: 'carol', id: 12 } },
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
textSnippet: '',
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await expect(
|
|
479
|
+
collectCommentRows({
|
|
480
|
+
symbol: 'SH600519',
|
|
481
|
+
limit: 3,
|
|
482
|
+
pageSize: 2,
|
|
483
|
+
maxRequests: 5,
|
|
484
|
+
fetchPage,
|
|
485
|
+
warn: mockWarn,
|
|
486
|
+
}),
|
|
487
|
+
).resolves.toMatchObject([
|
|
488
|
+
{ id: '1', text: 'alpha' },
|
|
489
|
+
{ id: '2', text: 'beta' },
|
|
490
|
+
{ id: '3', text: 'gamma' },
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
494
|
+
expect(mockWarn).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('returns partial rows and emits warning when a later page fails', async () => {
|
|
498
|
+
const fetchPage = vi
|
|
499
|
+
.fn()
|
|
500
|
+
.mockResolvedValueOnce({
|
|
501
|
+
status: 200,
|
|
502
|
+
contentType: 'application/json',
|
|
503
|
+
json: {
|
|
504
|
+
list: [
|
|
505
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
506
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
textSnippet: '',
|
|
510
|
+
})
|
|
511
|
+
.mockResolvedValueOnce({
|
|
512
|
+
status: 200,
|
|
513
|
+
contentType: 'text/html',
|
|
514
|
+
json: null,
|
|
515
|
+
textSnippet: '<textarea id="renderData">{"_waf_bd8ce2ce37":"token"}</textarea>',
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await expect(
|
|
519
|
+
collectCommentRows({
|
|
520
|
+
symbol: 'SH600519',
|
|
521
|
+
limit: 3,
|
|
522
|
+
pageSize: 2,
|
|
523
|
+
maxRequests: 5,
|
|
524
|
+
fetchPage,
|
|
525
|
+
warn: mockWarn,
|
|
526
|
+
}),
|
|
527
|
+
).resolves.toMatchObject([
|
|
528
|
+
{ id: '1', text: 'alpha' },
|
|
529
|
+
{ id: '2', text: 'beta' },
|
|
530
|
+
]);
|
|
531
|
+
|
|
532
|
+
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
533
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('2/3'));
|
|
534
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('anti-bot'));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('returns partial rows and emits warning when a later page has an unknown fetch failure', async () => {
|
|
538
|
+
const fetchPage = vi
|
|
539
|
+
.fn()
|
|
540
|
+
.mockResolvedValueOnce({
|
|
541
|
+
status: 200,
|
|
542
|
+
contentType: 'application/json',
|
|
543
|
+
json: {
|
|
544
|
+
list: [
|
|
545
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
546
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
textSnippet: '',
|
|
550
|
+
})
|
|
551
|
+
.mockResolvedValueOnce({
|
|
552
|
+
status: 0,
|
|
553
|
+
contentType: 'text/plain',
|
|
554
|
+
json: null,
|
|
555
|
+
textSnippet: 'network failed',
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await expect(
|
|
559
|
+
collectCommentRows({
|
|
560
|
+
symbol: 'SH600519',
|
|
561
|
+
limit: 3,
|
|
562
|
+
pageSize: 2,
|
|
563
|
+
maxRequests: 5,
|
|
564
|
+
fetchPage,
|
|
565
|
+
warn: mockWarn,
|
|
566
|
+
}),
|
|
567
|
+
).resolves.toMatchObject([
|
|
568
|
+
{ id: '1', text: 'alpha' },
|
|
569
|
+
{ id: '2', text: 'beta' },
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
573
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('2/3'));
|
|
574
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('unknown request failure'));
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('ends pagination quietly when a later page returns an empty list', async () => {
|
|
578
|
+
const fetchPage = vi
|
|
579
|
+
.fn()
|
|
580
|
+
.mockResolvedValueOnce({
|
|
581
|
+
status: 200,
|
|
582
|
+
contentType: 'application/json',
|
|
583
|
+
json: {
|
|
584
|
+
list: [
|
|
585
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
586
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
textSnippet: '',
|
|
590
|
+
})
|
|
591
|
+
.mockResolvedValueOnce({
|
|
592
|
+
status: 200,
|
|
593
|
+
contentType: 'application/json',
|
|
594
|
+
json: { list: [] },
|
|
595
|
+
textSnippet: '',
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const result = await collectCommentRows({
|
|
599
|
+
symbol: 'SH600519',
|
|
600
|
+
limit: 3,
|
|
601
|
+
pageSize: 2,
|
|
602
|
+
maxRequests: 5,
|
|
603
|
+
fetchPage,
|
|
604
|
+
warn: mockWarn,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
expect(result).toMatchObject([
|
|
608
|
+
{ id: '1', text: 'alpha' },
|
|
609
|
+
{ id: '2', text: 'beta' },
|
|
610
|
+
]);
|
|
611
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
612
|
+
expect(mockWarn).not.toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('returns partial rows and emits warning when a later page does not advance pagination', async () => {
|
|
616
|
+
const fetchPage = vi
|
|
617
|
+
.fn()
|
|
618
|
+
.mockResolvedValueOnce({
|
|
619
|
+
status: 200,
|
|
620
|
+
contentType: 'application/json',
|
|
621
|
+
json: {
|
|
622
|
+
list: [
|
|
623
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
624
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
textSnippet: '',
|
|
628
|
+
})
|
|
629
|
+
.mockResolvedValueOnce({
|
|
630
|
+
status: 200,
|
|
631
|
+
contentType: 'application/json',
|
|
632
|
+
json: {
|
|
633
|
+
list: [
|
|
634
|
+
{ id: 1, description: 'alpha-duplicate', user: { screen_name: 'alice', id: 10 } },
|
|
635
|
+
{ id: 2, description: 'beta-duplicate', user: { screen_name: 'bob', id: 11 } },
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
textSnippet: '',
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const result = await collectCommentRows({
|
|
642
|
+
symbol: 'SH600519',
|
|
643
|
+
limit: 3,
|
|
644
|
+
pageSize: 2,
|
|
645
|
+
maxRequests: 5,
|
|
646
|
+
fetchPage,
|
|
647
|
+
warn: mockWarn,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
expect(result).toMatchObject([
|
|
651
|
+
{ id: '1', text: 'alpha' },
|
|
652
|
+
{ id: '2', text: 'beta' },
|
|
653
|
+
]);
|
|
654
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
655
|
+
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
656
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('2/3'));
|
|
657
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('pagination did not advance'));
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('drops rows without ids and warns when pagination cannot advance', async () => {
|
|
661
|
+
const fetchPage = vi
|
|
662
|
+
.fn()
|
|
663
|
+
.mockResolvedValueOnce({
|
|
664
|
+
status: 200,
|
|
665
|
+
contentType: 'application/json',
|
|
666
|
+
json: {
|
|
667
|
+
list: [
|
|
668
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
669
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
670
|
+
],
|
|
671
|
+
},
|
|
672
|
+
textSnippet: '',
|
|
673
|
+
})
|
|
674
|
+
.mockResolvedValueOnce({
|
|
675
|
+
status: 200,
|
|
676
|
+
contentType: 'application/json',
|
|
677
|
+
json: {
|
|
678
|
+
list: [
|
|
679
|
+
{ description: 'missing-id-a', user: { screen_name: 'carol', id: 12 } },
|
|
680
|
+
{ description: 'missing-id-b', user: { screen_name: 'dave', id: 13 } },
|
|
681
|
+
],
|
|
682
|
+
},
|
|
683
|
+
textSnippet: '',
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const result = await collectCommentRows({
|
|
687
|
+
symbol: 'SH600519',
|
|
688
|
+
limit: 3,
|
|
689
|
+
pageSize: 2,
|
|
690
|
+
maxRequests: 5,
|
|
691
|
+
fetchPage,
|
|
692
|
+
warn: mockWarn,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
expect(result).toMatchObject([
|
|
696
|
+
{ id: '1', text: 'alpha' },
|
|
697
|
+
{ id: '2', text: 'beta' },
|
|
698
|
+
]);
|
|
699
|
+
expect(result).toHaveLength(2);
|
|
700
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
701
|
+
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
702
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('2/3'));
|
|
703
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('unknown request failure'));
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('continues pagination when a full page contains both valid rows and missing-id rows', async () => {
|
|
707
|
+
const fetchPage = vi
|
|
708
|
+
.fn()
|
|
709
|
+
.mockResolvedValueOnce({
|
|
710
|
+
status: 200,
|
|
711
|
+
contentType: 'application/json',
|
|
712
|
+
json: {
|
|
713
|
+
list: [
|
|
714
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
715
|
+
{ description: 'missing-id', user: { screen_name: 'carol', id: 12 } },
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
textSnippet: '',
|
|
719
|
+
})
|
|
720
|
+
.mockResolvedValueOnce({
|
|
721
|
+
status: 200,
|
|
722
|
+
contentType: 'application/json',
|
|
723
|
+
json: {
|
|
724
|
+
list: [
|
|
725
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
726
|
+
{ id: 3, description: 'gamma', user: { screen_name: 'dave', id: 13 } },
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
textSnippet: '',
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const result = await collectCommentRows({
|
|
733
|
+
symbol: 'SH600519',
|
|
734
|
+
limit: 3,
|
|
735
|
+
pageSize: 2,
|
|
736
|
+
maxRequests: 5,
|
|
737
|
+
fetchPage,
|
|
738
|
+
warn: mockWarn,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
expect(result).toMatchObject([
|
|
742
|
+
{ id: '1', text: 'alpha' },
|
|
743
|
+
{ id: '2', text: 'beta' },
|
|
744
|
+
{ id: '3', text: 'gamma' },
|
|
745
|
+
]);
|
|
746
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
747
|
+
expect(mockWarn).not.toHaveBeenCalled();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('does not warn when a short final page contains only duplicate rows', async () => {
|
|
751
|
+
const fetchPage = vi
|
|
752
|
+
.fn()
|
|
753
|
+
.mockResolvedValueOnce({
|
|
754
|
+
status: 200,
|
|
755
|
+
contentType: 'application/json',
|
|
756
|
+
json: {
|
|
757
|
+
list: [
|
|
758
|
+
{ id: 1, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
759
|
+
{ id: 2, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
textSnippet: '',
|
|
763
|
+
})
|
|
764
|
+
.mockResolvedValueOnce({
|
|
765
|
+
status: 200,
|
|
766
|
+
contentType: 'application/json',
|
|
767
|
+
json: {
|
|
768
|
+
list: [
|
|
769
|
+
{ id: 2, description: 'beta-duplicate', user: { screen_name: 'bob', id: 11 } },
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
textSnippet: '',
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const result = await collectCommentRows({
|
|
776
|
+
symbol: 'SH600519',
|
|
777
|
+
limit: 5,
|
|
778
|
+
pageSize: 2,
|
|
779
|
+
maxRequests: 5,
|
|
780
|
+
fetchPage,
|
|
781
|
+
warn: mockWarn,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(result).toMatchObject([
|
|
785
|
+
{ id: '1', text: 'alpha' },
|
|
786
|
+
{ id: '2', text: 'beta' },
|
|
787
|
+
]);
|
|
788
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
789
|
+
expect(mockWarn).not.toHaveBeenCalled();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('emits warning when pagination stops at the safety cap', async () => {
|
|
793
|
+
let nextId = 1;
|
|
794
|
+
const fetchPage = vi
|
|
795
|
+
.fn()
|
|
796
|
+
.mockImplementation(async () => ({
|
|
797
|
+
status: 200,
|
|
798
|
+
contentType: 'application/json',
|
|
799
|
+
json: {
|
|
800
|
+
list: [
|
|
801
|
+
{ id: nextId++, description: 'alpha', user: { screen_name: 'alice', id: 10 } },
|
|
802
|
+
{ id: nextId++, description: 'beta', user: { screen_name: 'bob', id: 11 } },
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
textSnippet: '',
|
|
806
|
+
}));
|
|
807
|
+
|
|
808
|
+
const result = await collectCommentRows({
|
|
809
|
+
symbol: 'SH600519',
|
|
810
|
+
limit: 12,
|
|
811
|
+
pageSize: 2,
|
|
812
|
+
maxRequests: 5,
|
|
813
|
+
fetchPage,
|
|
814
|
+
warn: mockWarn,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
expect(result).toHaveLength(10);
|
|
818
|
+
expect(fetchPage).toHaveBeenCalledTimes(5);
|
|
819
|
+
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
820
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('10/12'));
|
|
821
|
+
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('reached safety cap'));
|
|
822
|
+
});
|
|
823
|
+
});
|