@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/zhihu/follow.js
CHANGED
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
name: 'follow',
|
|
8
8
|
description: 'Follow a Zhihu user or question',
|
|
9
9
|
domain: 'www.zhihu.com',
|
|
10
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
11
|
browser: true,
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
@@ -20,61 +20,30 @@ cli({
|
|
|
20
20
|
requireExecute(kwargs);
|
|
21
21
|
const rawTarget = String(kwargs.target);
|
|
22
22
|
const target = assertAllowedKinds('follow', parseTarget(rawTarget));
|
|
23
|
-
await page.goto(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (candidates.length !== 1) return { state: 'ambiguous_user_follow' };
|
|
48
|
-
followBtn = candidates[0];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!followBtn) return { state: 'missing' };
|
|
52
|
-
if ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') {
|
|
53
|
-
return { state: 'already_following' };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
followBtn.click();
|
|
57
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
58
|
-
|
|
59
|
-
return ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true')
|
|
60
|
-
? { state: 'followed' }
|
|
61
|
-
: { state: 'unknown' };
|
|
62
|
-
})()`);
|
|
63
|
-
if (result?.state === 'already_following') {
|
|
64
|
-
return buildResultRow(`Already followed ${target.kind}`, target.kind, rawTarget, 'already_applied');
|
|
23
|
+
await page.goto('https://www.zhihu.com');
|
|
24
|
+
await page.wait(2);
|
|
25
|
+
const apiResult = await page.evaluate(`(async () => {
|
|
26
|
+
var targetKind = ${JSON.stringify(target.kind)};
|
|
27
|
+
var targetId = ${JSON.stringify(target.kind === 'user' ? target.slug : target.id)};
|
|
28
|
+
var url;
|
|
29
|
+
if (targetKind === 'question') {
|
|
30
|
+
url = 'https://www.zhihu.com/api/v4/questions/' + targetId + '/followers';
|
|
31
|
+
} else if (targetKind === 'user') {
|
|
32
|
+
url = 'https://www.zhihu.com/api/v4/members/' + targetId + '/followers';
|
|
33
|
+
} else {
|
|
34
|
+
return { ok: false, message: 'unsupported target type: ' + targetKind };
|
|
35
|
+
}
|
|
36
|
+
var resp = await fetch(url, { method: 'POST', credentials: 'include' });
|
|
37
|
+
if (!resp.ok) {
|
|
38
|
+
var data = {};
|
|
39
|
+
try { data = await resp.json(); } catch(e) {}
|
|
40
|
+
return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true };
|
|
43
|
+
})()`);
|
|
44
|
+
if (!apiResult?.ok) {
|
|
45
|
+
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to follow');
|
|
65
46
|
}
|
|
66
|
-
|
|
67
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Question follow control was not uniquely anchored on the requested question page');
|
|
68
|
-
}
|
|
69
|
-
if (result?.state === 'ambiguous_user_follow') {
|
|
70
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'User follow control was not uniquely anchored on the requested profile page');
|
|
71
|
-
}
|
|
72
|
-
if (result?.state === 'missing') {
|
|
73
|
-
throw new CliError('ACTION_FAILED', 'Zhihu follow control was missing before any write was dispatched');
|
|
74
|
-
}
|
|
75
|
-
if (result?.state !== 'followed') {
|
|
76
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Zhihu follow click was dispatched, but the final state could not be verified safely');
|
|
77
|
-
}
|
|
78
|
-
return buildResultRow(`Followed ${target.kind}`, target.kind, rawTarget, 'applied');
|
|
47
|
+
return buildResultRow(`Followed ${target.kind} ${target.kind === 'user' ? target.slug : target.id}`, target.kind, rawTarget, 'applied');
|
|
79
48
|
},
|
|
80
49
|
});
|
|
@@ -2,44 +2,41 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import './follow.js';
|
|
4
4
|
describe('zhihu follow', () => {
|
|
5
|
-
it('
|
|
5
|
+
it('registers as a cookie browser command', () => {
|
|
6
6
|
const cmd = getRegistry().get('zhihu/follow');
|
|
7
|
-
expect(cmd
|
|
8
|
-
|
|
9
|
-
await expect(cmd.func(page, { target: 'question:123' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
|
10
|
-
expect(page.goto).not.toHaveBeenCalled();
|
|
11
|
-
expect(page.evaluate).not.toHaveBeenCalled();
|
|
7
|
+
expect(cmd).toBeDefined();
|
|
8
|
+
expect(cmd.strategy).toBe('cookie');
|
|
12
9
|
});
|
|
13
|
-
it('
|
|
10
|
+
it('follows via API and returns result', async () => {
|
|
14
11
|
const cmd = getRegistry().get('zhihu/follow');
|
|
15
12
|
const page = {
|
|
16
13
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
17
|
-
|
|
14
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
|
|
18
16
|
};
|
|
19
|
-
await
|
|
20
|
-
|
|
21
|
-
});
|
|
17
|
+
const rows = await cmd.func(page, { target: 'question:123', execute: true });
|
|
18
|
+
expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]);
|
|
22
19
|
});
|
|
23
|
-
it('
|
|
20
|
+
it('uses the parsed user slug for user follow API calls', async () => {
|
|
24
21
|
const cmd = getRegistry().get('zhihu/follow');
|
|
25
22
|
const page = {
|
|
26
23
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
27
|
-
|
|
24
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
|
|
28
26
|
};
|
|
29
|
-
await
|
|
30
|
-
|
|
31
|
-
]);
|
|
27
|
+
await cmd.func(page, { target: 'user:alice', execute: true });
|
|
28
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("'https://www.zhihu.com/api/v4/members/' + targetId + '/followers'");
|
|
29
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('var targetId = "alice"');
|
|
30
|
+
expect(page.evaluate.mock.calls[0][0]).not.toContain('undefined');
|
|
32
31
|
});
|
|
33
|
-
it('
|
|
32
|
+
it('throws on API error', async () => {
|
|
34
33
|
const cmd = getRegistry().get('zhihu/follow');
|
|
35
34
|
const page = {
|
|
36
35
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
37
|
-
|
|
36
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'already following' }),
|
|
38
38
|
};
|
|
39
|
-
await expect(cmd.func(page, { target: 'question:123', execute: true }))
|
|
40
|
-
code: '
|
|
41
|
-
});
|
|
42
|
-
expect(page.evaluate.mock.calls[0][0]).toContain('QuestionHeader');
|
|
43
|
-
expect(page.evaluate.mock.calls[0][0]).toContain('new Set(');
|
|
39
|
+
await expect(cmd.func(page, { target: 'question:123', execute: true }))
|
|
40
|
+
.rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
44
41
|
});
|
|
45
42
|
});
|
package/clis/zhihu/like.js
CHANGED
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
name: 'like',
|
|
8
8
|
description: 'Like a Zhihu answer or article',
|
|
9
9
|
domain: 'zhihu.com',
|
|
10
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
11
|
browser: true,
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
@@ -20,72 +20,27 @@ cli({
|
|
|
20
20
|
requireExecute(kwargs);
|
|
21
21
|
const rawTarget = String(kwargs.target);
|
|
22
22
|
const target = assertAllowedKinds('like', parseTarget(rawTarget));
|
|
23
|
-
await page.goto(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem;
|
|
44
|
-
});
|
|
45
|
-
if (candidates.length !== 1) return { state: 'ambiguous_answer_like' };
|
|
46
|
-
btn = candidates[0];
|
|
47
|
-
} else {
|
|
48
|
-
const articleRoot =
|
|
49
|
-
document.querySelector('article')
|
|
50
|
-
|| document.querySelector('.Post-Main')
|
|
51
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
52
|
-
|| document;
|
|
53
|
-
const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => {
|
|
54
|
-
const text = (node.textContent || '').trim();
|
|
55
|
-
return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed');
|
|
56
|
-
});
|
|
57
|
-
if (candidates.length !== 1) return { state: 'ambiguous_article_like' };
|
|
58
|
-
btn = candidates[0];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (!btn) return { state: 'missing' };
|
|
62
|
-
if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' };
|
|
63
|
-
|
|
64
|
-
btn.click();
|
|
65
|
-
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
66
|
-
|
|
67
|
-
return btn.getAttribute('aria-pressed') === 'true'
|
|
68
|
-
? { state: 'liked' }
|
|
69
|
-
: { state: 'unknown' };
|
|
70
|
-
})()`);
|
|
71
|
-
if (result?.state === 'wrong_answer') {
|
|
72
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
23
|
+
await page.goto('https://www.zhihu.com');
|
|
24
|
+
await page.wait(2);
|
|
25
|
+
const apiResult = await page.evaluate(`(async () => {
|
|
26
|
+
var targetKind = ${JSON.stringify(target.kind)};
|
|
27
|
+
var targetId = ${JSON.stringify(target.id)};
|
|
28
|
+
var resourceType = targetKind === 'answer' ? 'answers' : 'articles';
|
|
29
|
+
var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/voters';
|
|
30
|
+
var resp = await fetch(url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
credentials: 'include',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ type: 'up' }),
|
|
35
|
+
});
|
|
36
|
+
var data = await resp.json();
|
|
37
|
+
if (!resp.ok) return { ok: false, message: data.error ? data.error.message : 'unknown error' };
|
|
38
|
+
if (data && data.success === false) return { ok: false, message: 'Zhihu like API reported success=false' };
|
|
39
|
+
return { ok: true, success: data.success };
|
|
40
|
+
})()`);
|
|
41
|
+
if (!apiResult?.ok) {
|
|
42
|
+
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to like');
|
|
73
43
|
}
|
|
74
|
-
|
|
75
|
-
return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied');
|
|
76
|
-
}
|
|
77
|
-
if (result?.state === 'ambiguous_answer_like') {
|
|
78
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer');
|
|
79
|
-
}
|
|
80
|
-
if (result?.state === 'ambiguous_article_like') {
|
|
81
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target');
|
|
82
|
-
}
|
|
83
|
-
if (result?.state === 'missing') {
|
|
84
|
-
throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched');
|
|
85
|
-
}
|
|
86
|
-
if (result?.state !== 'liked') {
|
|
87
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely');
|
|
88
|
-
}
|
|
89
|
-
return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied');
|
|
44
|
+
return buildResultRow(`Liked ${target.kind} ${target.id}`, target.kind, rawTarget, 'applied');
|
|
90
45
|
},
|
|
91
46
|
});
|
package/clis/zhihu/like.test.js
CHANGED
|
@@ -2,63 +2,40 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import './like.js';
|
|
4
4
|
describe('zhihu like', () => {
|
|
5
|
-
it('
|
|
5
|
+
it('registers as a cookie browser command', () => {
|
|
6
6
|
const cmd = getRegistry().get('zhihu/like');
|
|
7
|
-
expect(cmd
|
|
8
|
-
|
|
9
|
-
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
-
evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_article_like' }),
|
|
11
|
-
};
|
|
12
|
-
await expect(cmd.func(page, { target: 'article:9', execute: true })).rejects.toMatchObject({
|
|
13
|
-
code: 'ACTION_NOT_AVAILABLE',
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
it('returns already_applied for an already-liked article target', async () => {
|
|
17
|
-
const cmd = getRegistry().get('zhihu/like');
|
|
18
|
-
const page = {
|
|
19
|
-
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
-
evaluate: vi.fn().mockResolvedValue({ state: 'already_liked' }),
|
|
21
|
-
};
|
|
22
|
-
await expect(cmd.func(page, { target: 'article:9', execute: true })).resolves.toEqual([
|
|
23
|
-
expect.objectContaining({ outcome: 'already_applied', target_type: 'article', target: 'article:9' }),
|
|
24
|
-
]);
|
|
7
|
+
expect(cmd).toBeDefined();
|
|
8
|
+
expect(cmd.strategy).toBe('cookie');
|
|
25
9
|
});
|
|
26
|
-
it('
|
|
10
|
+
it('likes via API and returns result', async () => {
|
|
27
11
|
const cmd = getRegistry().get('zhihu/like');
|
|
28
12
|
const page = {
|
|
29
13
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
30
|
-
|
|
14
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: true, success: true }),
|
|
31
16
|
};
|
|
32
|
-
await
|
|
33
|
-
|
|
34
|
-
]);
|
|
35
|
-
expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/question/123/answer/456');
|
|
36
|
-
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
37
|
-
expect(page.evaluate.mock.calls[0][0]).toContain('targetQuestionId');
|
|
38
|
-
expect(page.evaluate.mock.calls[0][0]).toContain('"123"');
|
|
39
|
-
expect(page.evaluate.mock.calls[0][0]).toContain('"456"');
|
|
40
|
-
expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-answerid')");
|
|
41
|
-
expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-zop-question-answer')");
|
|
17
|
+
const rows = await cmd.func(page, { target: 'answer:1:2', execute: true });
|
|
18
|
+
expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]);
|
|
42
19
|
});
|
|
43
|
-
it('
|
|
20
|
+
it('throws on API error', async () => {
|
|
44
21
|
const cmd = getRegistry().get('zhihu/like');
|
|
45
22
|
const page = {
|
|
46
23
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
47
|
-
|
|
24
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'rate limited' }),
|
|
48
26
|
};
|
|
49
|
-
await expect(cmd.func(page, { target: 'answer:
|
|
50
|
-
code: '
|
|
51
|
-
});
|
|
27
|
+
await expect(cmd.func(page, { target: 'answer:1:2', execute: true }))
|
|
28
|
+
.rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
52
29
|
});
|
|
53
|
-
it('
|
|
30
|
+
it('does not treat success=false API responses as a successful like', async () => {
|
|
54
31
|
const cmd = getRegistry().get('zhihu/like');
|
|
55
32
|
const page = {
|
|
56
33
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
57
|
-
|
|
34
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Zhihu like API reported success=false' }),
|
|
58
36
|
};
|
|
59
|
-
await expect(cmd.func(page, { target: 'answer:
|
|
60
|
-
code: '
|
|
61
|
-
|
|
62
|
-
expect(page.evaluate.mock.calls[0][0]).toContain("if (!block) return { state: 'wrong_answer' }");
|
|
37
|
+
await expect(cmd.func(page, { target: 'answer:1:2', execute: true }))
|
|
38
|
+
.rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
39
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('data.success === false');
|
|
63
40
|
});
|
|
64
41
|
});
|
package/clis/zhihu/search.js
CHANGED
|
@@ -15,12 +15,13 @@ cli({
|
|
|
15
15
|
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/<em>/g, '').replace(/<\\/em>/g, '').trim();
|
|
16
16
|
const keyword = \${{ args.query | json }};
|
|
17
17
|
const limit = \${{ args.limit }};
|
|
18
|
-
|
|
18
|
+
var fetchLimit = Math.max(limit * 3, 30);
|
|
19
|
+
const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + fetchLimit, {
|
|
19
20
|
credentials: 'include'
|
|
20
21
|
});
|
|
21
22
|
const d = await res.json();
|
|
22
23
|
return (d?.data || [])
|
|
23
|
-
.filter(item => item.type === '
|
|
24
|
+
.filter(item => item.object && (item.object.type === 'answer' || item.object.type === 'article' || item.object.type === 'question'))
|
|
24
25
|
.map(item => {
|
|
25
26
|
const obj = item.object || {};
|
|
26
27
|
const q = obj.question || {};
|
|
@@ -194,7 +194,14 @@ function buildResolveCurrentUserIdentityJs() {
|
|
|
194
194
|
|
|
195
195
|
const navScopes = Array.from(document.querySelectorAll(navScopeSelector));
|
|
196
196
|
const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false);
|
|
197
|
-
|
|
197
|
+
if (slug) return { slug };
|
|
198
|
+
|
|
199
|
+
var avatarImgs = document.querySelectorAll('header img[alt*="\\u4e3b\\u9875"]');
|
|
200
|
+
for (var ai = 0; ai < avatarImgs.length; ai++) {
|
|
201
|
+
var altMatch = (avatarImgs[ai].alt || '').match(/\\u70b9\\u51fb\\u6253\\u5f00(.+?)\\u7684\\u4e3b\\u9875/);
|
|
202
|
+
if (altMatch) return { slug: altMatch[1] };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
198
205
|
})()`;
|
|
199
206
|
}
|
|
200
207
|
export async function resolveCurrentUserIdentity(page) {
|
|
@@ -163,6 +163,7 @@ describe('zhihu write shared helpers', () => {
|
|
|
163
163
|
const documentRoot = new FakeRoot({
|
|
164
164
|
'header, nav, [role="banner"], [role="navigation"]': [],
|
|
165
165
|
'a[href^="/people/"]': [],
|
|
166
|
+
'header img[alt*="\u4e3b\u9875"]': [],
|
|
166
167
|
});
|
|
167
168
|
const page = createPageForDom(documentRoot);
|
|
168
169
|
await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import {
|
|
5
|
+
buildSearchUrl,
|
|
6
|
+
normalizeZlibraryBookUrl,
|
|
7
|
+
} from './utils.js';
|
|
8
|
+
import './search.js';
|
|
9
|
+
import './info.js';
|
|
10
|
+
|
|
11
|
+
function createPageMock(evaluateResults = []) {
|
|
12
|
+
const evaluate = vi.fn();
|
|
13
|
+
for (const result of evaluateResults) {
|
|
14
|
+
evaluate.mockResolvedValueOnce(result);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
evaluate,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('zlibrary commands', () => {
|
|
24
|
+
it('registers search and info commands', () => {
|
|
25
|
+
expect(getRegistry().get('zlibrary/search')).toBeDefined();
|
|
26
|
+
expect(getRegistry().get('zlibrary/info')).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('normalizes search query and rejects empty searches', () => {
|
|
30
|
+
expect(buildSearchUrl(' test book ')).toBe('https://z-library.im/s/test%20book');
|
|
31
|
+
expect(() => buildSearchUrl(' ')).toThrow(ArgumentError);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('restricts info URLs to the configured zlibrary host', () => {
|
|
35
|
+
expect(normalizeZlibraryBookUrl('https://z-library.im/book/demo')).toBe('https://z-library.im/book/demo');
|
|
36
|
+
expect(normalizeZlibraryBookUrl('https://www.z-library.im/book/demo')).toBe('https://www.z-library.im/book/demo');
|
|
37
|
+
expect(() => normalizeZlibraryBookUrl('https://example.com/book/demo')).toThrow(ArgumentError);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('search fails fast on empty extraction results', async () => {
|
|
41
|
+
const command = getRegistry().get('zlibrary/search');
|
|
42
|
+
const page = createPageMock(['[]']);
|
|
43
|
+
|
|
44
|
+
await expect(command.func(page, { query: 'missing', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('info waits seconds, not milliseconds-as-seconds, before extracting formats', async () => {
|
|
48
|
+
const command = getRegistry().get('zlibrary/info');
|
|
49
|
+
const page = createPageMock([
|
|
50
|
+
'Demo Book',
|
|
51
|
+
undefined,
|
|
52
|
+
JSON.stringify({ pdf: 'https://z-library.im/dl/pdf', epub: '' }),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
await expect(command.func(page, { url: 'https://z-library.im/book/demo' })).resolves.toEqual([{
|
|
56
|
+
title: 'Demo Book',
|
|
57
|
+
pdf: 'https://z-library.im/dl/pdf',
|
|
58
|
+
epub: '',
|
|
59
|
+
url: 'https://z-library.im/book/demo',
|
|
60
|
+
}]);
|
|
61
|
+
expect(page.wait).toHaveBeenCalledWith({ time: 5 });
|
|
62
|
+
expect(page.wait).toHaveBeenCalledWith({ time: 3 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('info fails fast when formats are missing', async () => {
|
|
66
|
+
const command = getRegistry().get('zlibrary/info');
|
|
67
|
+
const page = createPageMock([
|
|
68
|
+
'Login Required',
|
|
69
|
+
undefined,
|
|
70
|
+
JSON.stringify({ pdf: '', epub: '' }),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
await expect(command.func(page, { url: 'https://z-library.im/book/demo' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ZLIBRARY_DOMAIN, extractBookTitle, extractFormats, normalizeZlibraryBookUrl } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'zlibrary',
|
|
7
|
+
name: 'info',
|
|
8
|
+
description: 'Get book details and available download formats from a Z-Library book page',
|
|
9
|
+
domain: ZLIBRARY_DOMAIN,
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
navigateBefore: false,
|
|
13
|
+
args: [
|
|
14
|
+
{
|
|
15
|
+
name: 'url',
|
|
16
|
+
positional: true,
|
|
17
|
+
required: true,
|
|
18
|
+
help: 'Z-Library book page URL (e.g. https://z-library.im/book/...)',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
columns: ['title', 'pdf', 'epub', 'url'],
|
|
22
|
+
func: async (page, args) => {
|
|
23
|
+
const url = normalizeZlibraryBookUrl(args.url);
|
|
24
|
+
|
|
25
|
+
await page.goto(url, { waitUntil: 'load', settleMs: 3000 });
|
|
26
|
+
await page.wait({ time: 5 });
|
|
27
|
+
|
|
28
|
+
const title = await extractBookTitle(page);
|
|
29
|
+
const formats = await extractFormats(page);
|
|
30
|
+
|
|
31
|
+
if (!title || (!formats.pdf && !formats.epub)) {
|
|
32
|
+
throw new EmptyResultError(
|
|
33
|
+
'zlibrary info',
|
|
34
|
+
'Could not extract a book title and download formats. Check the URL, login state, and whether Z-Library changed its page layout.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
title,
|
|
41
|
+
pdf: formats.pdf || '',
|
|
42
|
+
epub: formats.epub || '',
|
|
43
|
+
url,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ZLIBRARY_DOMAIN, buildSearchUrl, extractSearchResults } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'zlibrary',
|
|
7
|
+
name: 'search',
|
|
8
|
+
description: 'Search Z-Library for books by title, author, ISBN, or keyword',
|
|
9
|
+
domain: ZLIBRARY_DOMAIN,
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
navigateBefore: false,
|
|
13
|
+
args: [
|
|
14
|
+
{
|
|
15
|
+
name: 'query',
|
|
16
|
+
positional: true,
|
|
17
|
+
required: true,
|
|
18
|
+
help: 'Search keyword (title, author, ISBN, etc.)',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'limit',
|
|
22
|
+
type: 'int',
|
|
23
|
+
default: 10,
|
|
24
|
+
help: 'Max results (1–25)',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
columns: ['rank', 'title', 'author', 'url'],
|
|
28
|
+
func: async (page, args) => {
|
|
29
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 10, 25));
|
|
30
|
+
const searchUrl = buildSearchUrl(args.query);
|
|
31
|
+
|
|
32
|
+
await page.goto(searchUrl, { waitUntil: 'load', settleMs: 3000 });
|
|
33
|
+
await page.wait({ time: 5 });
|
|
34
|
+
|
|
35
|
+
const results = await extractSearchResults(page, limit);
|
|
36
|
+
|
|
37
|
+
if (!results.length) {
|
|
38
|
+
throw new EmptyResultError(
|
|
39
|
+
'zlibrary search',
|
|
40
|
+
'No books found. Try a different keyword or check that you are logged into Z-Library.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results;
|
|
45
|
+
},
|
|
46
|
+
});
|