@jackwener/opencli 1.7.8 → 1.7.10
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 +49 -14
- package/README.zh-CN.md +30 -10
- package/cli-manifest.json +646 -30
- package/clis/36kr/news.js +1 -1
- package/clis/apple-podcasts/commands.test.js +4 -4
- package/clis/apple-podcasts/episodes.js +1 -1
- package/clis/apple-podcasts/search.js +1 -1
- package/clis/apple-podcasts/top.js +1 -1
- package/clis/arxiv/paper.js +1 -1
- package/clis/arxiv/search.js +1 -1
- package/clis/band/mentions.js +3 -3
- package/clis/bbc/news.js +1 -1
- package/clis/bilibili/subtitle.js +2 -2
- package/clis/bloomberg/businessweek.js +1 -1
- package/clis/bloomberg/economics.js +1 -1
- package/clis/bloomberg/industries.js +1 -1
- package/clis/bloomberg/main.js +1 -1
- package/clis/bloomberg/markets.js +1 -1
- package/clis/bloomberg/opinions.js +1 -1
- package/clis/bloomberg/politics.js +1 -1
- package/clis/bloomberg/tech.js +1 -1
- package/clis/boss/search.js +49 -8
- package/clis/boss/search.test.js +78 -0
- package/clis/boss/send.js +3 -3
- package/clis/chatgpt/image.js +37 -8
- package/clis/chatgpt/image.test.js +92 -0
- package/clis/chatgpt/utils.js +39 -6
- package/clis/chatgpt/utils.test.js +63 -0
- package/clis/chatgpt-app/ask.js +1 -1
- package/clis/chatgpt-app/ax.js +4 -2
- package/clis/chatgpt-app/ax.test.js +12 -0
- package/clis/chatgpt-app/model.js +1 -1
- package/clis/chatgpt-app/new.js +1 -1
- package/clis/chatgpt-app/read.js +1 -1
- package/clis/chatgpt-app/send.js +1 -1
- package/clis/chatgpt-app/status.js +1 -1
- package/clis/chatwise/ask.js +2 -2
- package/clis/chatwise/model.js +2 -2
- package/clis/chatwise/send.js +2 -2
- package/clis/claude/ask.js +128 -0
- package/clis/claude/ask.test.js +338 -0
- package/clis/claude/commands.test.js +118 -0
- package/clis/claude/detail.js +29 -0
- package/clis/claude/history.js +31 -0
- package/clis/claude/new.js +21 -0
- package/clis/claude/read.js +24 -0
- package/clis/claude/send.js +41 -0
- package/clis/claude/status.js +24 -0
- package/clis/claude/utils.js +440 -0
- package/clis/claude/utils.test.js +148 -0
- package/clis/codex/ask.js +2 -2
- package/clis/codex/send.js +2 -2
- package/clis/ctrip/search.js +1 -1
- package/clis/ctrip/search.test.js +4 -4
- package/clis/cursor/ask.js +2 -2
- package/clis/cursor/composer.js +2 -2
- package/clis/cursor/send.js +2 -2
- package/clis/deepseek/ask.js +17 -4
- package/clis/deepseek/ask.test.js +46 -0
- package/clis/deepseek/utils.js +55 -16
- package/clis/deepseek/utils.test.js +124 -5
- package/clis/doubao/utils.js +53 -11
- package/clis/doubao/utils.test.js +22 -2
- package/clis/eastmoney/announcement.js +1 -1
- package/clis/eastmoney/convertible.js +1 -1
- package/clis/eastmoney/etf.js +1 -1
- package/clis/eastmoney/holders.js +1 -1
- package/clis/eastmoney/index-board.js +1 -1
- package/clis/eastmoney/kline.js +1 -1
- package/clis/eastmoney/kuaixun.js +1 -1
- package/clis/eastmoney/longhu.js +1 -1
- package/clis/eastmoney/money-flow.js +1 -1
- package/clis/eastmoney/northbound.js +1 -1
- package/clis/eastmoney/quote.js +1 -1
- package/clis/eastmoney/rank.js +1 -1
- package/clis/eastmoney/sectors.js +1 -1
- package/clis/facebook/marketplace-inbox.js +83 -0
- package/clis/facebook/marketplace-listings.js +83 -0
- package/clis/facebook/marketplace.test.js +91 -0
- package/clis/google/news.js +1 -1
- package/clis/google/suggest.js +1 -1
- package/clis/google/trends.js +1 -1
- package/clis/google-scholar/cite.js +74 -0
- package/clis/google-scholar/cite.test.js +47 -0
- package/clis/google-scholar/profile.js +92 -0
- package/clis/google-scholar/profile.test.js +49 -0
- package/clis/google-scholar/search.js +1 -1
- package/clis/google-scholar/search.test.js +15 -0
- package/clis/hf/top.js +1 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/saved.js +21 -7
- package/clis/jd/item.js +679 -47
- package/clis/jd/item.test.js +318 -7
- package/clis/jd/item.test.ts +517 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/tags.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/lesswrong/user-posts.js +1 -1
- package/clis/lesswrong/user.js +1 -1
- package/clis/paperreview/commands.test.js +6 -6
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/producthunt/posts.js +1 -1
- package/clis/producthunt/today.js +1 -1
- package/clis/sinablog/search.js +1 -1
- package/clis/sinafinance/news.js +1 -1
- package/clis/sinafinance/stock.js +1 -1
- package/clis/sinafinance/stock.test.js +2 -2
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +5 -6
- package/clis/toutiao/articles.test.js +22 -15
- package/clis/twitter/followers.js +2 -2
- package/clis/twitter/following.js +224 -73
- package/clis/twitter/following.test.js +277 -0
- package/clis/twitter/post.js +184 -47
- package/clis/twitter/post.test.js +114 -34
- package/clis/uiverse/_shared.js +63 -4
- package/clis/uiverse/_shared.test.js +7 -0
- package/clis/uiverse/code.js +1 -0
- package/clis/uiverse/navigation.test.js +12 -0
- package/clis/uiverse/preview.js +1 -0
- package/clis/web/read.js +319 -81
- package/clis/web/read.test.js +221 -5
- package/clis/weibo/favorites.js +169 -0
- package/clis/weibo/favorites.test.js +114 -0
- package/clis/weibo/publish.js +282 -0
- package/clis/weibo/publish.test.js +183 -0
- package/clis/weread/ranking.js +1 -1
- package/clis/weread/search-regression.test.js +8 -8
- package/clis/weread/search.js +1 -1
- package/clis/wikipedia/random.js +1 -1
- package/clis/wikipedia/search.js +1 -1
- package/clis/wikipedia/summary.js +1 -1
- package/clis/wikipedia/trending.js +1 -1
- package/clis/xianyu/chat.js +3 -3
- package/clis/xianyu/item.js +2 -2
- package/clis/xianyu/item.test.js +3 -3
- package/clis/xiaohongshu/search.js +17 -2
- package/clis/xiaohongshu/search.test.js +37 -1
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/download.test.js +3 -3
- package/clis/xiaoyuzhou/episode.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
- package/clis/xiaoyuzhou/podcast.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/xiaoyuzhou/transcript.test.js +5 -5
- package/clis/yollomi/models.js +1 -1
- package/clis/youtube/channel.js +24 -1
- package/clis/youtube/channel.test.js +59 -0
- package/clis/zhihu/answer.js +21 -162
- package/clis/zhihu/answer.test.js +26 -53
- package/clis/zhihu/collection.js +197 -0
- package/clis/zhihu/collection.test.js +290 -0
- package/clis/zhihu/collections.js +127 -0
- package/clis/zhihu/collections.test.js +182 -0
- package/clis/zhihu/comment.js +24 -305
- package/clis/zhihu/comment.test.js +31 -35
- package/clis/zhihu/favorite.js +44 -182
- package/clis/zhihu/favorite.test.js +30 -167
- package/clis/zhihu/follow.js +25 -56
- package/clis/zhihu/follow.test.js +20 -23
- package/clis/zhihu/like.js +22 -67
- package/clis/zhihu/like.test.js +19 -42
- package/clis/zhihu/search.js +3 -2
- package/clis/zhihu/write-shared.js +8 -1
- package/clis/zhihu/write-shared.test.js +1 -0
- package/clis/zlibrary/commands.test.js +75 -0
- package/clis/zlibrary/info.js +47 -0
- package/clis/zlibrary/search.js +46 -0
- package/clis/zlibrary/utils.js +136 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/browser/base-page.d.ts +3 -1
- package/dist/src/browser/base-page.js +76 -1
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +74 -0
- package/dist/src/browser/bridge.d.ts +1 -2
- package/dist/src/browser/bridge.js +40 -41
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +38 -4
- package/dist/src/browser/daemon-client.js +24 -7
- package/dist/src/browser/daemon-client.test.js +49 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/errors.js +3 -0
- package/dist/src/browser/errors.test.js +3 -0
- package/dist/src/browser/network-cache.d.ts +1 -0
- package/dist/src/browser/page.d.ts +3 -1
- package/dist/src/browser/page.js +10 -2
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +85 -0
- package/dist/src/build-manifest.d.ts +2 -0
- package/dist/src/build-manifest.js +13 -3
- package/dist/src/build-manifest.test.js +20 -2
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +477 -35
- package/dist/src/cli.test.js +303 -2
- package/dist/src/commanderAdapter.js +17 -9
- package/dist/src/commanderAdapter.test.js +67 -2
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +42 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/completion-shared.js +1 -2
- package/dist/src/completion.test.js +3 -2
- package/dist/src/daemon.js +125 -41
- package/dist/src/doctor.d.ts +5 -6
- package/dist/src/doctor.js +77 -19
- package/dist/src/doctor.test.js +117 -0
- package/dist/src/engine.test.js +6 -5
- package/dist/src/errors.d.ts +14 -8
- package/dist/src/errors.js +36 -30
- package/dist/src/errors.test.js +5 -5
- package/dist/src/execution.d.ts +4 -0
- package/dist/src/execution.js +173 -25
- package/dist/src/execution.test.js +171 -1
- package/dist/src/main.js +10 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/pipeline/steps/fetch.js +1 -21
- package/dist/src/pipeline/steps/fetch.test.js +6 -12
- package/dist/src/plugin-scaffold.js +1 -1
- package/dist/src/plugin-scaffold.test.js +1 -1
- package/dist/src/registry.d.ts +40 -9
- package/dist/src/registry.js +3 -1
- package/dist/src/runtime-detect.d.ts +10 -0
- package/dist/src/runtime-detect.js +19 -0
- package/dist/src/runtime-detect.test.js +12 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/update-check.d.ts +31 -1
- package/dist/src/update-check.js +62 -16
- package/dist/src/update-check.test.js +86 -1
- package/package.json +1 -1
- package/dist/src/diagnostic.d.ts +0 -63
- package/dist/src/diagnostic.js +0 -292
- package/dist/src/diagnostic.test.js +0 -302
- /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
package/clis/web/read.test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
2
3
|
|
|
3
4
|
const { mockDownloadArticle } = vi.hoisted(() => ({
|
|
4
5
|
mockDownloadArticle: vi.fn(),
|
|
@@ -12,19 +13,30 @@ const { __test__ } = await import('./read.js');
|
|
|
12
13
|
|
|
13
14
|
describe('web/read stdout behavior', () => {
|
|
14
15
|
const read = __test__.command;
|
|
15
|
-
const
|
|
16
|
-
goto: vi.fn().mockResolvedValue(undefined),
|
|
17
|
-
wait: vi.fn().mockResolvedValue(undefined),
|
|
18
|
-
evaluate: vi.fn().mockResolvedValue({
|
|
16
|
+
const extractedArticle = {
|
|
19
17
|
title: 'Example Article',
|
|
20
18
|
author: 'Author',
|
|
21
19
|
publishTime: '2026-04-22',
|
|
22
20
|
contentHtml: '<p>hello</p>',
|
|
23
21
|
imageUrls: ['https://example.com/a.jpg'],
|
|
24
|
-
|
|
22
|
+
diagnostics: {
|
|
23
|
+
url: 'https://example.com/article',
|
|
24
|
+
frames: [],
|
|
25
|
+
emptyContainers: [],
|
|
26
|
+
includedFrameCount: 0,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const page = {
|
|
30
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
evaluate: vi.fn().mockResolvedValue(extractedArticle),
|
|
33
|
+
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
34
|
+
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
25
35
|
};
|
|
26
36
|
|
|
27
37
|
beforeEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
vi.useRealTimers();
|
|
28
40
|
mockDownloadArticle.mockReset();
|
|
29
41
|
mockDownloadArticle.mockResolvedValue([{
|
|
30
42
|
title: 'Example Article',
|
|
@@ -37,6 +49,11 @@ describe('web/read stdout behavior', () => {
|
|
|
37
49
|
page.goto.mockClear();
|
|
38
50
|
page.wait.mockClear();
|
|
39
51
|
page.evaluate.mockClear();
|
|
52
|
+
page.evaluate.mockResolvedValue(extractedArticle);
|
|
53
|
+
page.startNetworkCapture.mockClear();
|
|
54
|
+
page.startNetworkCapture.mockResolvedValue(true);
|
|
55
|
+
page.readNetworkCapture.mockClear();
|
|
56
|
+
page.readNetworkCapture.mockResolvedValue([]);
|
|
40
57
|
});
|
|
41
58
|
|
|
42
59
|
it('returns null in --stdout mode so the CLI does not append result rows to stdout', async () => {
|
|
@@ -58,6 +75,7 @@ describe('web/read stdout behavior', () => {
|
|
|
58
75
|
stdout: true,
|
|
59
76
|
}),
|
|
60
77
|
);
|
|
78
|
+
expect(page.evaluate.mock.calls[0]?.[0]).toContain('const frameMode = "same-origin"');
|
|
61
79
|
});
|
|
62
80
|
|
|
63
81
|
it('still returns the saved-row payload when writing to disk', async () => {
|
|
@@ -73,4 +91,202 @@ describe('web/read stdout behavior', () => {
|
|
|
73
91
|
|
|
74
92
|
expect(result).toBe(rows);
|
|
75
93
|
});
|
|
94
|
+
|
|
95
|
+
it('waits for a selector in the main document or same-origin iframes before extracting', async () => {
|
|
96
|
+
page.evaluate
|
|
97
|
+
.mockResolvedValueOnce({ ok: true, scope: 'iframe', url: 'https://example.com/frame' })
|
|
98
|
+
.mockResolvedValueOnce(extractedArticle);
|
|
99
|
+
|
|
100
|
+
await read.func(page, {
|
|
101
|
+
url: 'https://example.com/article',
|
|
102
|
+
output: '/tmp/out',
|
|
103
|
+
'download-images': false,
|
|
104
|
+
'wait-for': '#gridDatas li',
|
|
105
|
+
wait: 7,
|
|
106
|
+
stdout: false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(page.wait).not.toHaveBeenCalled();
|
|
110
|
+
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
111
|
+
expect(page.evaluate.mock.calls[0]?.[0]).toContain('"#gridDatas li"');
|
|
112
|
+
expect(page.evaluate.mock.calls[0]?.[0]).toContain('sameOriginFrameDocs');
|
|
113
|
+
expect(page.evaluate.mock.calls[1]?.[0]).toContain('const frameMode = "same-origin"');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws a clear error when --wait-for times out', async () => {
|
|
117
|
+
page.evaluate.mockResolvedValueOnce({ ok: false, timedOut: true, selector: '#missing' });
|
|
118
|
+
|
|
119
|
+
await expect(read.func(page, {
|
|
120
|
+
url: 'https://example.com/article',
|
|
121
|
+
output: '/tmp/out',
|
|
122
|
+
'download-images': false,
|
|
123
|
+
'wait-for': '#missing',
|
|
124
|
+
wait: 1,
|
|
125
|
+
stdout: false,
|
|
126
|
+
})).rejects.toThrow('Timed out waiting for selector "#missing"');
|
|
127
|
+
|
|
128
|
+
expect(mockDownloadArticle).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('starts network capture and writes diagnostics in diagnose mode', async () => {
|
|
132
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
133
|
+
page.readNetworkCapture.mockResolvedValueOnce([{
|
|
134
|
+
method: 'POST',
|
|
135
|
+
url: 'https://example.com/api/data',
|
|
136
|
+
responseStatus: 200,
|
|
137
|
+
responseContentType: 'application/json',
|
|
138
|
+
responsePreview: '{"ok":true}',
|
|
139
|
+
}]);
|
|
140
|
+
|
|
141
|
+
await read.func(page, {
|
|
142
|
+
url: 'https://example.com/article',
|
|
143
|
+
output: '/tmp/out',
|
|
144
|
+
'download-images': false,
|
|
145
|
+
diagnose: true,
|
|
146
|
+
wait: 0,
|
|
147
|
+
stdout: false,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(page.startNetworkCapture).toHaveBeenCalledWith('');
|
|
151
|
+
expect(page.readNetworkCapture).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('[web-read diagnose]'));
|
|
153
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('POST 200 application/json https://example.com/api/data'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('passes --frames none into the extractor', async () => {
|
|
157
|
+
await read.func(page, {
|
|
158
|
+
url: 'https://example.com/article',
|
|
159
|
+
output: '/tmp/out',
|
|
160
|
+
'download-images': false,
|
|
161
|
+
frames: 'none',
|
|
162
|
+
stdout: false,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(page.evaluate.mock.calls[0]?.[0]).toContain('const frameMode = "none"');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('fails fast when --wait-until networkidle is requested but capture is unavailable', async () => {
|
|
169
|
+
page.startNetworkCapture.mockResolvedValue(false);
|
|
170
|
+
|
|
171
|
+
await expect(read.func(page, {
|
|
172
|
+
url: 'https://example.com/article',
|
|
173
|
+
output: '/tmp/out',
|
|
174
|
+
'download-images': false,
|
|
175
|
+
'wait-until': 'networkidle',
|
|
176
|
+
wait: 2,
|
|
177
|
+
stdout: false,
|
|
178
|
+
})).rejects.toThrow('Network capture is unavailable');
|
|
179
|
+
|
|
180
|
+
expect(page.wait).not.toHaveBeenCalled();
|
|
181
|
+
expect(mockDownloadArticle).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('fails fast when network traffic never settles before the networkidle timeout', async () => {
|
|
185
|
+
vi.useFakeTimers();
|
|
186
|
+
page.readNetworkCapture.mockResolvedValue([{
|
|
187
|
+
method: 'POST',
|
|
188
|
+
url: 'https://example.com/api/data',
|
|
189
|
+
responseStatus: 200,
|
|
190
|
+
responseContentType: 'application/json',
|
|
191
|
+
responsePreview: '{"ok":true}',
|
|
192
|
+
}]);
|
|
193
|
+
|
|
194
|
+
const pending = expect(read.func(page, {
|
|
195
|
+
url: 'https://example.com/article',
|
|
196
|
+
output: '/tmp/out',
|
|
197
|
+
'download-images': false,
|
|
198
|
+
'wait-until': 'networkidle',
|
|
199
|
+
wait: 1,
|
|
200
|
+
stdout: false,
|
|
201
|
+
})).rejects.toThrow('Timed out waiting for network idle after 1s');
|
|
202
|
+
|
|
203
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
204
|
+
|
|
205
|
+
await pending;
|
|
206
|
+
expect(mockDownloadArticle).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('web/read render-aware helpers', () => {
|
|
211
|
+
it('merges accessible same-origin iframe bodies into the extracted HTML', () => {
|
|
212
|
+
const dom = new JSDOM(`
|
|
213
|
+
<main>
|
|
214
|
+
<h1>Shell</h1>
|
|
215
|
+
<iframe id="MF" src="/frame.html"></iframe>
|
|
216
|
+
</main>
|
|
217
|
+
`, { url: 'https://example.com/main.html', runScripts: 'outside-only' });
|
|
218
|
+
const frame = dom.window.document.querySelector('iframe');
|
|
219
|
+
frame.contentDocument.open();
|
|
220
|
+
frame.contentDocument.write('<body><table id="gridHd"><tr><th>Name</th></tr></table><ul id="gridDatas"><li>Station A 42</li></ul></body>');
|
|
221
|
+
frame.contentDocument.close();
|
|
222
|
+
|
|
223
|
+
const result = dom.window.eval(__test__.buildRenderAwareExtractorJs({ frames: 'same-origin' }));
|
|
224
|
+
|
|
225
|
+
expect(result.diagnostics.includedFrameCount).toBe(1);
|
|
226
|
+
expect(result.contentHtml).toContain('data-opencli-iframe-source="https://example.com/frame.html"');
|
|
227
|
+
expect(result.contentHtml).toContain('来自 iframe: https://example.com/frame.html');
|
|
228
|
+
expect(result.contentHtml).toContain('Station A 42');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('marks API-like network entries as interesting and ignores static assets', () => {
|
|
232
|
+
expect(__test__.isInterestingNetworkEntry({
|
|
233
|
+
method: 'POST',
|
|
234
|
+
url: 'https://example.com/GJZ/Ajax/Publish.ashx',
|
|
235
|
+
status: 200,
|
|
236
|
+
contentType: 'text/html',
|
|
237
|
+
size: 100,
|
|
238
|
+
bodyTruncated: false,
|
|
239
|
+
})).toBe(true);
|
|
240
|
+
expect(__test__.isInterestingNetworkEntry({
|
|
241
|
+
method: 'POST',
|
|
242
|
+
url: 'https://example.com/GJZ/Ajax/Publish.ashx',
|
|
243
|
+
status: 200,
|
|
244
|
+
contentType: 'application/json',
|
|
245
|
+
size: 100,
|
|
246
|
+
bodyTruncated: false,
|
|
247
|
+
})).toBe(true);
|
|
248
|
+
expect(__test__.isInterestingNetworkEntry({
|
|
249
|
+
method: 'GET',
|
|
250
|
+
url: 'https://example.com/app.js',
|
|
251
|
+
status: 200,
|
|
252
|
+
contentType: 'application/javascript',
|
|
253
|
+
size: 100,
|
|
254
|
+
bodyTruncated: false,
|
|
255
|
+
})).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('formats frame and XHR diagnostics for shell pages', () => {
|
|
259
|
+
const output = __test__.formatDiagnostics({
|
|
260
|
+
diagnostics: {
|
|
261
|
+
url: 'https://example.com/main.html',
|
|
262
|
+
includedFrameCount: 1,
|
|
263
|
+
frames: [{
|
|
264
|
+
index: 0,
|
|
265
|
+
src: 'https://example.com/frame.html',
|
|
266
|
+
sameOrigin: true,
|
|
267
|
+
accessible: true,
|
|
268
|
+
textLength: 42,
|
|
269
|
+
}],
|
|
270
|
+
emptyContainers: [{
|
|
271
|
+
scope: 'iframe',
|
|
272
|
+
url: 'https://example.com/frame.html',
|
|
273
|
+
tag: 'ul',
|
|
274
|
+
id: 'gridDatas',
|
|
275
|
+
className: '',
|
|
276
|
+
}],
|
|
277
|
+
},
|
|
278
|
+
}, [{
|
|
279
|
+
method: 'POST',
|
|
280
|
+
url: 'https://example.com/GJZ/Ajax/Publish.ashx',
|
|
281
|
+
status: 200,
|
|
282
|
+
contentType: 'application/json',
|
|
283
|
+
size: 64,
|
|
284
|
+
bodyTruncated: false,
|
|
285
|
+
}], true);
|
|
286
|
+
|
|
287
|
+
expect(output).toContain('frames: 1, included_same_origin: 1');
|
|
288
|
+
expect(output).toContain('[frame 0] same-origin accessible text=42 https://example.com/frame.html');
|
|
289
|
+
expect(output).toContain('iframe: ul#gridDatas (https://example.com/frame.html)');
|
|
290
|
+
expect(output).toContain('POST 200 application/json https://example.com/GJZ/Ajax/Publish.ashx');
|
|
291
|
+
});
|
|
76
292
|
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getSelfUid } from './utils.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LIMIT = 20;
|
|
6
|
+
const MAX_LIMIT = 50;
|
|
7
|
+
|
|
8
|
+
function parsePositiveInt(value, name, defaultValue) {
|
|
9
|
+
const raw = value ?? defaultValue;
|
|
10
|
+
const number = Number(raw);
|
|
11
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
12
|
+
throw new ArgumentError(`weibo favorites ${name} must be a positive integer`);
|
|
13
|
+
}
|
|
14
|
+
if (number > MAX_LIMIT) {
|
|
15
|
+
throw new ArgumentError(`weibo favorites ${name} must be <= ${MAX_LIMIT}`);
|
|
16
|
+
}
|
|
17
|
+
return number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseFavoriteCard(card, favUrl) {
|
|
21
|
+
const raw = String(card?.text ?? '');
|
|
22
|
+
const lines = raw.split('\n');
|
|
23
|
+
|
|
24
|
+
let author = '';
|
|
25
|
+
let time = '';
|
|
26
|
+
let source = '';
|
|
27
|
+
let content = '';
|
|
28
|
+
let likes = '0';
|
|
29
|
+
let comments = '0';
|
|
30
|
+
let reposts = '0';
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const t = line.trim();
|
|
34
|
+
if (!t || t === '添加') continue;
|
|
35
|
+
|
|
36
|
+
if (!time && /\d+小时前|\d+分钟前|\d+秒前|昨天|前天|\d{1,2}:\d{2}/.test(t)) {
|
|
37
|
+
time = t;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (t.startsWith('来自')) {
|
|
42
|
+
source = t;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (content) {
|
|
47
|
+
const n = Number.parseInt(t, 10);
|
|
48
|
+
if (!Number.isNaN(n) && n > 0 && n < 1_000_000 && t === String(n)) {
|
|
49
|
+
if (likes === '0') likes = t;
|
|
50
|
+
else if (comments === '0') comments = t;
|
|
51
|
+
else if (reposts === '0') reposts = t;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!author && t.length < 40) {
|
|
57
|
+
author = t;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!content && author) {
|
|
62
|
+
content = t;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (content) content += ` ${t}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!content || !author) return null;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
author,
|
|
73
|
+
text: content.substring(0, 300),
|
|
74
|
+
time,
|
|
75
|
+
source,
|
|
76
|
+
likes,
|
|
77
|
+
comments,
|
|
78
|
+
reposts,
|
|
79
|
+
url: card?.url || favUrl,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dedupeFavorites(items, favUrl) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
const key = item.url && item.url !== favUrl
|
|
88
|
+
? item.url
|
|
89
|
+
: `${item.author}\n${item.text}\n${item.time}`;
|
|
90
|
+
if (seen.has(key)) continue;
|
|
91
|
+
seen.add(key);
|
|
92
|
+
result.push(item);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cli({
|
|
98
|
+
site: 'weibo',
|
|
99
|
+
name: 'favorites',
|
|
100
|
+
description: '我的微博收藏列表',
|
|
101
|
+
domain: 'weibo.com',
|
|
102
|
+
strategy: Strategy.COOKIE,
|
|
103
|
+
args: [
|
|
104
|
+
{ name: 'limit', type: 'int', default: 20, help: '数量(最多50)' },
|
|
105
|
+
],
|
|
106
|
+
columns: ['author', 'text', 'time', 'source', 'likes', 'comments', 'reposts', 'url'],
|
|
107
|
+
func: async (page, kwargs) => {
|
|
108
|
+
const limit = parsePositiveInt(kwargs.limit, 'limit', DEFAULT_LIMIT);
|
|
109
|
+
|
|
110
|
+
await page.goto('https://weibo.com');
|
|
111
|
+
await page.wait(2);
|
|
112
|
+
|
|
113
|
+
const uid = await getSelfUid(page);
|
|
114
|
+
|
|
115
|
+
const favUrl = 'https://www.weibo.com/u/page/fav/' + uid;
|
|
116
|
+
|
|
117
|
+
await page.goto(favUrl);
|
|
118
|
+
await page.wait(4);
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
await page.evaluate('() => window.scrollBy(0, 800)');
|
|
122
|
+
await page.wait(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rawData = await page.evaluate(`
|
|
126
|
+
(() => {
|
|
127
|
+
const scrollers = document.querySelectorAll('.wbpro-scroller-item, .vue-recycle-scroller__item-view');
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const s of scrollers) {
|
|
130
|
+
// Use textContent to preserve newlines, then split by \n
|
|
131
|
+
const bodyEl = s.querySelector('[class*="_body_"]') || s.querySelector('.wbpro-item-body') || s;
|
|
132
|
+
// innerText preserves newlines between block elements (unlike textContent)
|
|
133
|
+
const rawText = bodyEl.innerText || s.innerText || '';
|
|
134
|
+
|
|
135
|
+
let postUrl = '';
|
|
136
|
+
const anchors = s.querySelectorAll('a[href]');
|
|
137
|
+
for (const a of anchors) {
|
|
138
|
+
const m = String(a.href).match(/weibo\\.com\\/(\\d+)\\/([a-zA-Z0-9]+)/);
|
|
139
|
+
if (m) { postUrl = 'https://weibo.com/' + m[1] + '/' + m[2]; break; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (rawText.length > 20) out.push({ text: rawText, url: postUrl });
|
|
143
|
+
if (out.length >= ${limit}) break;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
})()
|
|
147
|
+
`);
|
|
148
|
+
|
|
149
|
+
if (!Array.isArray(rawData) || rawData.length === 0) {
|
|
150
|
+
throw new EmptyResultError('weibo favorites', 'No favorites were visible on the favorites page');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const items = rawData
|
|
154
|
+
.map(card => parseFavoriteCard(card, favUrl))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
|
|
157
|
+
const uniqueItems = dedupeFavorites(items, favUrl);
|
|
158
|
+
if (uniqueItems.length === 0) {
|
|
159
|
+
throw new CommandExecutionError('Failed to parse visible Weibo favorites');
|
|
160
|
+
}
|
|
161
|
+
return uniqueItems.slice(0, limit);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const __test__ = {
|
|
166
|
+
parseFavoriteCard,
|
|
167
|
+
parsePositiveInt,
|
|
168
|
+
dedupeFavorites,
|
|
169
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
|
|
5
|
+
import './favorites.js';
|
|
6
|
+
|
|
7
|
+
function makePage(evaluateResults = []) {
|
|
8
|
+
const queue = [...evaluateResults];
|
|
9
|
+
const evaluate = vi.fn(async (script) => {
|
|
10
|
+
if (String(script).includes('window.scrollBy')) return undefined;
|
|
11
|
+
return queue.length ? queue.shift() : [];
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
evaluate,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('weibo favorites command', () => {
|
|
22
|
+
const getCommand = () => getRegistry().get('weibo/favorites');
|
|
23
|
+
|
|
24
|
+
it('registers as a JS adapter and parses visible favorites', async () => {
|
|
25
|
+
const command = getCommand();
|
|
26
|
+
expect(command?.func).toBeTypeOf('function');
|
|
27
|
+
|
|
28
|
+
const page = makePage([
|
|
29
|
+
'123456',
|
|
30
|
+
[
|
|
31
|
+
{
|
|
32
|
+
text: [
|
|
33
|
+
'作者A',
|
|
34
|
+
'昨天 12:00',
|
|
35
|
+
'来自 iPhone',
|
|
36
|
+
'这是一条收藏微博',
|
|
37
|
+
'12',
|
|
38
|
+
'3',
|
|
39
|
+
'2',
|
|
40
|
+
].join('\n'),
|
|
41
|
+
url: 'https://weibo.com/123/AbCd1',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const result = await command.func(page, { limit: 10 });
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual([
|
|
49
|
+
{
|
|
50
|
+
author: '作者A',
|
|
51
|
+
text: '这是一条收藏微博',
|
|
52
|
+
time: '昨天 12:00',
|
|
53
|
+
source: '来自 iPhone',
|
|
54
|
+
likes: '12',
|
|
55
|
+
comments: '3',
|
|
56
|
+
reposts: '2',
|
|
57
|
+
url: 'https://weibo.com/123/AbCd1',
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
expect(page.goto).toHaveBeenCalledWith('https://weibo.com');
|
|
61
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.weibo.com/u/page/fav/123456');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws AuthRequiredError when uid cannot be resolved', async () => {
|
|
65
|
+
const command = getCommand();
|
|
66
|
+
const page = makePage([null, null]);
|
|
67
|
+
|
|
68
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('validates limit before navigation', async () => {
|
|
72
|
+
const command = getCommand();
|
|
73
|
+
const page = makePage();
|
|
74
|
+
|
|
75
|
+
await expect(command.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
76
|
+
await expect(command.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError);
|
|
77
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws EmptyResultError when no favorite cards are visible', async () => {
|
|
81
|
+
const command = getCommand();
|
|
82
|
+
const page = makePage(['123456', []]);
|
|
83
|
+
|
|
84
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws CommandExecutionError when visible cards cannot be parsed', async () => {
|
|
88
|
+
const command = getCommand();
|
|
89
|
+
const page = makePage(['123456', [{ text: '添加\n昨天', url: '' }]]);
|
|
90
|
+
|
|
91
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('deduplicates repeated cards and applies the requested limit', async () => {
|
|
95
|
+
const command = getCommand();
|
|
96
|
+
const rawCard = {
|
|
97
|
+
text: '作者A\n内容A',
|
|
98
|
+
url: 'https://weibo.com/123/AbCd1',
|
|
99
|
+
};
|
|
100
|
+
const page = makePage([
|
|
101
|
+
'123456',
|
|
102
|
+
[
|
|
103
|
+
rawCard,
|
|
104
|
+
rawCard,
|
|
105
|
+
{ text: '作者B\n内容B', url: 'https://weibo.com/123/AbCd2' },
|
|
106
|
+
],
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const result = await command.func(page, { limit: 1 });
|
|
110
|
+
|
|
111
|
+
expect(result).toHaveLength(1);
|
|
112
|
+
expect(result[0].author).toBe('作者A');
|
|
113
|
+
});
|
|
114
|
+
});
|