@jackwener/opencli 1.7.7 → 1.7.9
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 +782 -55
- package/clis/36kr/news.js +1 -1
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- 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 +4 -20
- package/clis/chatgpt-app/ax.js +135 -2
- package/clis/chatgpt-app/ax.test.js +35 -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 +3 -22
- 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 +49 -10
- package/clis/deepseek/ask.test.js +150 -3
- package/clis/deepseek/utils.js +60 -22
- 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/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/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- 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 +6 -3
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +80 -0
- package/clis/toutiao/articles.test.js +30 -0
- 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/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -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 -0
- package/dist/src/browser/bridge.js +36 -9
- 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/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 +462 -32
- package/dist/src/cli.test.js +209 -2
- package/dist/src/commanderAdapter.js +29 -9
- package/dist/src/commanderAdapter.test.js +78 -2
- package/dist/src/commands/daemon.js +6 -0
- 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 +4 -6
- package/dist/src/doctor.js +80 -22
- package/dist/src/doctor.test.js +82 -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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './channel.js';
|
|
3
|
+
|
|
4
|
+
function tab(title, contents, selected = false) {
|
|
5
|
+
return {
|
|
6
|
+
tabRenderer: {
|
|
7
|
+
title,
|
|
8
|
+
selected,
|
|
9
|
+
content: {
|
|
10
|
+
richGridRenderer: {
|
|
11
|
+
contents,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function browseData(tabs) {
|
|
19
|
+
return {
|
|
20
|
+
contents: {
|
|
21
|
+
twoColumnBrowseResultsRenderer: {
|
|
22
|
+
tabs,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('youtube channel helpers', () => {
|
|
29
|
+
it('uses the selected rich-grid tab instead of the first tab', () => {
|
|
30
|
+
const home = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'home' } } } }];
|
|
31
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'videos' } } } }];
|
|
32
|
+
|
|
33
|
+
expect(__test__.extractSelectedRichGridContents(browseData([
|
|
34
|
+
tab('Home', home),
|
|
35
|
+
tab('Videos', videos, true),
|
|
36
|
+
]))).toBe(videos);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the first non-empty rich-grid tab when no tab is selected', () => {
|
|
40
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'only' } } } }];
|
|
41
|
+
|
|
42
|
+
expect(__test__.extractSelectedRichGridContents(browseData([
|
|
43
|
+
tab('Home', []),
|
|
44
|
+
tab('Videos', videos),
|
|
45
|
+
]))).toBe(videos);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('is self-contained for browser evaluate injection', () => {
|
|
49
|
+
const extractSelectedRichGridContents = Function(
|
|
50
|
+
`return ${__test__.extractSelectedRichGridContents.toString()}`
|
|
51
|
+
)();
|
|
52
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'serialized' } } } }];
|
|
53
|
+
|
|
54
|
+
expect(extractSelectedRichGridContents(browseData([
|
|
55
|
+
tab('Home', []),
|
|
56
|
+
tab('Videos', videos, true),
|
|
57
|
+
]))).toEqual(videos);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/clis/zhihu/answer.js
CHANGED
|
@@ -2,13 +2,12 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
4
4
|
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
|
|
5
|
-
const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
|
|
6
5
|
cli({
|
|
7
6
|
site: 'zhihu',
|
|
8
7
|
name: 'answer',
|
|
9
8
|
description: 'Answer a Zhihu question',
|
|
10
9
|
domain: 'www.zhihu.com',
|
|
11
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
12
11
|
browser: true,
|
|
13
12
|
args: [
|
|
14
13
|
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
|
|
@@ -23,171 +22,31 @@ cli({
|
|
|
23
22
|
requireExecute(kwargs);
|
|
24
23
|
const rawTarget = String(kwargs.target);
|
|
25
24
|
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
|
|
26
|
-
const questionTarget = target;
|
|
27
25
|
const payload = await resolvePayload(kwargs);
|
|
28
26
|
await page.goto(target.url);
|
|
27
|
+
await page.wait(3);
|
|
29
28
|
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return slugs.length === 1 ? slugs[0] : null;
|
|
40
|
-
};
|
|
41
|
-
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
|
|
42
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
43
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
44
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
45
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
46
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
47
|
-
return { editor, container, text, submitButton, nestedComment };
|
|
48
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
49
|
-
const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
|
|
50
|
-
return readAnswerAuthorSlug(node) === currentUserSlug;
|
|
51
|
-
});
|
|
52
|
-
return {
|
|
53
|
-
entryPathSafe: composerCandidates.length === 1
|
|
54
|
-
&& !String(composerCandidates[0].text || '').trim()
|
|
55
|
-
&& !restoredDraft
|
|
56
|
-
&& !hasExistingAnswerByCurrentUser,
|
|
57
|
-
hasExistingAnswerByCurrentUser,
|
|
58
|
-
};
|
|
59
|
-
})()`);
|
|
60
|
-
if (entryPath.hasExistingAnswerByCurrentUser) {
|
|
61
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
|
|
62
|
-
}
|
|
63
|
-
if (!entryPath.entryPathSafe) {
|
|
64
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
|
|
65
|
-
}
|
|
66
|
-
const editorState = await page.evaluate(`(async () => {
|
|
67
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
68
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
69
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
70
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
71
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
72
|
-
return { editor, container, text, submitButton, nestedComment };
|
|
73
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
74
|
-
if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
|
|
75
|
-
const { editor, text } = composerCandidates[0];
|
|
76
|
-
const anonymousLabeledControl =
|
|
77
|
-
(composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
|
|
78
|
-
|| Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
|
|
79
|
-
|| null;
|
|
80
|
-
const anonymousToggle =
|
|
81
|
-
anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
|
|
82
|
-
? anonymousLabeledControl
|
|
83
|
-
: anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
|
|
84
|
-
|| null;
|
|
85
|
-
let anonymousMode = 'unknown';
|
|
86
|
-
if (anonymousToggle) {
|
|
87
|
-
const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
|
|
88
|
-
const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
|
|
89
|
-
if (ariaChecked === 'true' || checked) anonymousMode = 'on';
|
|
90
|
-
else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
|
|
94
|
-
anonymousMode,
|
|
95
|
-
};
|
|
96
|
-
})()`);
|
|
97
|
-
if (editorState.editorState !== 'fresh_empty') {
|
|
98
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
|
|
99
|
-
}
|
|
100
|
-
if (editorState.anonymousMode !== 'off') {
|
|
101
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
|
|
102
|
-
}
|
|
103
|
-
const editorCheck = await page.evaluate(`(async () => {
|
|
104
|
-
const textToInsert = ${JSON.stringify(payload)};
|
|
105
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
106
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
107
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
108
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
109
|
-
return { editor, container, submitButton, nestedComment };
|
|
110
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
111
|
-
if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
|
|
112
|
-
const { editor } = composerCandidates[0];
|
|
113
|
-
editor.focus();
|
|
114
|
-
if ('value' in editor) {
|
|
115
|
-
editor.value = '';
|
|
116
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
117
|
-
editor.value = textToInsert;
|
|
118
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
119
|
-
} else {
|
|
120
|
-
editor.textContent = '';
|
|
121
|
-
document.execCommand('insertText', false, textToInsert);
|
|
122
|
-
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
|
|
123
|
-
}
|
|
124
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
125
|
-
const content = 'value' in editor ? editor.value : (editor.textContent || '');
|
|
126
|
-
return { editorContent: content, bodyMatches: content === textToInsert };
|
|
127
|
-
})()`);
|
|
128
|
-
if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
|
|
129
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
|
|
130
|
-
}
|
|
131
|
-
const proof = await page.evaluate(`(async () => {
|
|
132
|
-
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
133
|
-
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
|
|
134
|
-
const readAnswerAuthorSlug = (node) => {
|
|
135
|
-
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
|
|
136
|
-
const slugs = Array.from(new Set(authorScopes
|
|
137
|
-
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
|
|
138
|
-
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
|
|
139
|
-
.filter(Boolean)));
|
|
140
|
-
return slugs.length === 1 ? slugs[0] : null;
|
|
141
|
-
};
|
|
142
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
143
|
-
const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
144
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
145
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
146
|
-
return { editor, container, submitButton, nestedComment };
|
|
147
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
148
|
-
if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
|
|
149
|
-
const submitScope = composerCandidates[0].container || document;
|
|
150
|
-
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
151
|
-
submit && submit.click();
|
|
152
|
-
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
153
|
-
const href = location.href;
|
|
154
|
-
const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
|
|
155
|
-
const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
|
|
156
|
-
const answerContainer = targetHref
|
|
157
|
-
? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
|
|
158
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
159
|
-
if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
|
|
160
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
161
|
-
const hrefValue = link.getAttribute('href') || '';
|
|
162
|
-
return hrefValue.includes(targetHref);
|
|
29
|
+
const apiResult = await page.evaluate(`(async () => {
|
|
30
|
+
var questionId = ${JSON.stringify(target.id)};
|
|
31
|
+
var content = ${JSON.stringify(payload)};
|
|
32
|
+
var url = 'https://www.zhihu.com/api/v4/questions/' + questionId + '/answers';
|
|
33
|
+
var resp = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
credentials: 'include',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ content: content, reshipment_settings: 'disallowed' }),
|
|
163
38
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|| answerContainer;
|
|
172
|
-
const bodyText = normalize(bodyNode?.textContent || '');
|
|
173
|
-
return match
|
|
174
|
-
? {
|
|
175
|
-
createdTarget: 'answer:' + match[1] + ':' + match[2],
|
|
176
|
-
createdUrl: href,
|
|
177
|
-
authorIdentity: authorSlug,
|
|
178
|
-
bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
|
|
179
|
-
}
|
|
180
|
-
: { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
|
|
181
|
-
})()`);
|
|
182
|
-
if (proof.authorIdentity !== authorIdentity) {
|
|
183
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
|
|
184
|
-
}
|
|
185
|
-
if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
|
|
186
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
|
|
39
|
+
var data = await resp.json();
|
|
40
|
+
if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
|
|
41
|
+
if (!data || !data.id) return { ok: false, status: resp.status, message: 'Answer API response did not include a created answer id' };
|
|
42
|
+
return { ok: true, id: String(data.id), url: data.url || ('https://www.zhihu.com/question/' + questionId + '/answer/' + data.id) };
|
|
43
|
+
})()`);
|
|
44
|
+
if (!apiResult?.ok) {
|
|
45
|
+
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create answer');
|
|
187
46
|
}
|
|
188
|
-
return buildResultRow(`Answered question ${
|
|
189
|
-
created_target:
|
|
190
|
-
created_url:
|
|
47
|
+
return buildResultRow(`Answered question ${target.id}`, target.kind, rawTarget, 'created', {
|
|
48
|
+
created_target: 'answer:' + target.id + ':' + apiResult.id,
|
|
49
|
+
created_url: apiResult.url,
|
|
191
50
|
author_identity: authorIdentity,
|
|
192
51
|
});
|
|
193
52
|
},
|
|
@@ -2,80 +2,53 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import './answer.js';
|
|
4
4
|
describe('zhihu answer', () => {
|
|
5
|
-
it('
|
|
5
|
+
it('registers as a cookie browser command', () => {
|
|
6
6
|
const cmd = getRegistry().get('zhihu/answer');
|
|
7
|
-
expect(cmd
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
evaluate: vi.fn()
|
|
11
|
-
.mockResolvedValueOnce({ slug: 'alice' })
|
|
12
|
-
.mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: true }),
|
|
13
|
-
};
|
|
14
|
-
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
15
|
-
});
|
|
16
|
-
it('rejects anonymous mode instead of toggling it', async () => {
|
|
17
|
-
const cmd = getRegistry().get('zhihu/answer');
|
|
18
|
-
const page = {
|
|
19
|
-
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
-
evaluate: vi.fn()
|
|
21
|
-
.mockResolvedValueOnce({ slug: 'alice' })
|
|
22
|
-
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
|
|
23
|
-
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'on' }),
|
|
24
|
-
};
|
|
25
|
-
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
7
|
+
expect(cmd).toBeDefined();
|
|
8
|
+
expect(cmd.strategy).toBe('cookie');
|
|
9
|
+
expect(cmd.browser).toBe(true);
|
|
26
10
|
});
|
|
27
|
-
it('
|
|
11
|
+
it('creates an answer via API and returns result', async () => {
|
|
28
12
|
const cmd = getRegistry().get('zhihu/answer');
|
|
29
13
|
const page = {
|
|
30
14
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
31
16
|
evaluate: vi.fn()
|
|
32
17
|
.mockResolvedValueOnce({ slug: 'alice' })
|
|
33
|
-
.mockResolvedValueOnce({
|
|
18
|
+
.mockResolvedValueOnce({ ok: true, id: '42', url: 'https://www.zhihu.com/question/1/answer/42' }),
|
|
34
19
|
};
|
|
35
|
-
await
|
|
20
|
+
const rows = await cmd.func(page, { target: 'question:1', text: 'hello', execute: true });
|
|
21
|
+
expect(rows).toEqual([
|
|
22
|
+
expect.objectContaining({
|
|
23
|
+
outcome: 'created',
|
|
24
|
+
created_target: 'answer:1:42',
|
|
25
|
+
author_identity: 'alice',
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
36
28
|
});
|
|
37
|
-
it('
|
|
29
|
+
it('throws on API error', async () => {
|
|
38
30
|
const cmd = getRegistry().get('zhihu/answer');
|
|
39
31
|
const page = {
|
|
40
32
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
41
34
|
evaluate: vi.fn()
|
|
42
35
|
.mockResolvedValueOnce({ slug: 'alice' })
|
|
43
|
-
.mockResolvedValueOnce({
|
|
44
|
-
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
|
|
36
|
+
.mockResolvedValueOnce({ ok: false, status: 400, message: 'already answered' }),
|
|
45
37
|
};
|
|
46
|
-
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
|
|
38
|
+
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
|
|
39
|
+
.rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
47
40
|
});
|
|
48
|
-
it('requires
|
|
41
|
+
it('requires the answer API response to include the created id', async () => {
|
|
49
42
|
const cmd = getRegistry().get('zhihu/answer');
|
|
50
43
|
const page = {
|
|
51
44
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
52
46
|
evaluate: vi.fn()
|
|
53
47
|
.mockResolvedValueOnce({ slug: 'alice' })
|
|
54
|
-
.mockResolvedValueOnce({
|
|
55
|
-
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'off' })
|
|
56
|
-
.mockResolvedValueOnce({ editorContent: 'hello', bodyMatches: true })
|
|
57
|
-
.mockResolvedValueOnce({
|
|
58
|
-
createdTarget: 'answer:1:2',
|
|
59
|
-
createdUrl: 'https://www.zhihu.com/question/1/answer/2',
|
|
60
|
-
authorIdentity: 'alice',
|
|
61
|
-
bodyMatches: true,
|
|
62
|
-
}),
|
|
48
|
+
.mockResolvedValueOnce({ ok: false, status: 200, message: 'Answer API response did not include a created answer id' }),
|
|
63
49
|
};
|
|
64
|
-
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
created_target: 'answer:1:2',
|
|
68
|
-
created_url: 'https://www.zhihu.com/question/1/answer/2',
|
|
69
|
-
author_identity: 'alice',
|
|
70
|
-
}),
|
|
71
|
-
]);
|
|
72
|
-
expect(page.evaluate.mock.calls[1][0]).toContain('composerCandidates.length === 1');
|
|
73
|
-
expect(page.evaluate.mock.calls[1][0]).not.toContain('writeAnswerButton');
|
|
74
|
-
expect(page.evaluate.mock.calls[1][0]).toContain('const readAnswerAuthorSlug = (node) =>');
|
|
75
|
-
expect(page.evaluate.mock.calls[1][0]).toContain('const answerAuthorScopeSelector = ".AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop=\\"author\\"]"');
|
|
76
|
-
expect(page.evaluate.mock.calls[1][0]).not.toContain("node.querySelector('a[href^=\"/people/\"]')");
|
|
77
|
-
expect(page.evaluate.mock.calls[3][0]).toContain('composerCandidates.length !== 1');
|
|
78
|
-
expect(page.evaluate.mock.calls[4][0]).toContain('const readAnswerAuthorSlug = (node) =>');
|
|
79
|
-
expect(page.evaluate.mock.calls[4][0]).not.toContain("answerContainer?.querySelector('a[href^=\"/people/\"]')");
|
|
50
|
+
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
|
|
51
|
+
.rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
52
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('Answer API response did not include a created answer id');
|
|
80
53
|
});
|
|
81
54
|
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { log } from '@jackwener/opencli/logger';
|
|
4
|
+
|
|
5
|
+
function stripHtml(html) {
|
|
6
|
+
return html
|
|
7
|
+
.replace(/<[^>]+>/g, '')
|
|
8
|
+
.replace(/ /g, ' ')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/<em>/g, '')
|
|
14
|
+
.replace(/<\/em>/g, '')
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validatePositiveInt(value, name) {
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
21
|
+
throw new ArgumentError(`zhihu collection --${name} must be a positive integer`, 'Example: opencli zhihu collection 83283292 --limit 20');
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateNonNegativeInt(value, name) {
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
29
|
+
throw new ArgumentError(`zhihu collection --${name} must be a non-negative integer`, 'Example: opencli zhihu collection 83283292 --offset 0');
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchCollectionPage(page, collectionId, offset, limit) {
|
|
35
|
+
const url = `https://www.zhihu.com/api/v4/collections/${collectionId}/items?offset=${offset}&limit=${limit}`;
|
|
36
|
+
const data = await page.evaluate(`
|
|
37
|
+
(async () => {
|
|
38
|
+
const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
39
|
+
if (!r.ok) return { __httpError: r.status };
|
|
40
|
+
return await r.json();
|
|
41
|
+
})()
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
if (!data || data.__httpError) {
|
|
45
|
+
const status = data?.__httpError;
|
|
46
|
+
if (status === 401 || status === 403) {
|
|
47
|
+
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch collection data from Zhihu. Please ensure you are logged in.');
|
|
48
|
+
}
|
|
49
|
+
throw new CommandExecutionError(
|
|
50
|
+
status ? `Zhihu collection request failed (HTTP ${status})` : 'Zhihu collection request failed',
|
|
51
|
+
'Try again later or rerun with -v for more detail',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function itemKey(item) {
|
|
58
|
+
const content = item?.content || {};
|
|
59
|
+
return `${content.type || 'unknown'}:${content.id || content.url || JSON.stringify(content).slice(0, 80)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapCollectionItem(item, rank) {
|
|
63
|
+
const content = item.content || {};
|
|
64
|
+
const type = content.type || 'unknown';
|
|
65
|
+
|
|
66
|
+
let title = '';
|
|
67
|
+
let excerpt = '';
|
|
68
|
+
let url = '';
|
|
69
|
+
let author = '';
|
|
70
|
+
let votes = 0;
|
|
71
|
+
|
|
72
|
+
if (type === 'answer') {
|
|
73
|
+
const question = content.question || {};
|
|
74
|
+
title = question.title || '';
|
|
75
|
+
excerpt = stripHtml(content.content || '').substring(0, 150);
|
|
76
|
+
url = content.url || `https://www.zhihu.com/question/${question.id}/answer/${content.id}`;
|
|
77
|
+
author = content.author?.name || '匿名用户';
|
|
78
|
+
votes = content.voteup_count || 0;
|
|
79
|
+
} else if (type === 'article') {
|
|
80
|
+
title = content.title || '';
|
|
81
|
+
excerpt = stripHtml(content.content || '').substring(0, 150);
|
|
82
|
+
url = content.url || `https://zhuanlan.zhihu.com/p/${content.id}`;
|
|
83
|
+
author = content.author?.name || '匿名用户';
|
|
84
|
+
votes = content.voteup_count || 0;
|
|
85
|
+
} else if (type === 'pin') {
|
|
86
|
+
title = '想法';
|
|
87
|
+
excerpt = stripHtml((content.content || []).map((c) => c.content || '').join(' ')).substring(0, 150);
|
|
88
|
+
url = content.url || `https://www.zhihu.com/pin/${content.id}`;
|
|
89
|
+
author = content.author?.name || '匿名用户';
|
|
90
|
+
votes = content.reaction_count || 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
rank,
|
|
95
|
+
type,
|
|
96
|
+
title: title.substring(0, 100),
|
|
97
|
+
author,
|
|
98
|
+
votes,
|
|
99
|
+
excerpt,
|
|
100
|
+
url,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
cli({
|
|
105
|
+
site: 'zhihu',
|
|
106
|
+
name: 'collection',
|
|
107
|
+
description: '知乎收藏夹内容列表(需要登录)',
|
|
108
|
+
domain: 'www.zhihu.com',
|
|
109
|
+
strategy: Strategy.COOKIE,
|
|
110
|
+
browser: true,
|
|
111
|
+
args: [
|
|
112
|
+
{ name: 'id', positional: true, required: true, help: '收藏夹 ID (数字,可从收藏夹 URL 中获取)' },
|
|
113
|
+
{ name: 'offset', type: 'int', default: 0, help: '起始偏移量(用于分页)' },
|
|
114
|
+
{ name: 'limit', type: 'int', default: 20, help: '每页数量(最大 20)' },
|
|
115
|
+
],
|
|
116
|
+
columns: ['rank', 'type', 'title', 'author', 'votes', 'excerpt', 'url'],
|
|
117
|
+
func: async (page, kwargs) => {
|
|
118
|
+
const { id, offset = 0, limit = 20 } = kwargs;
|
|
119
|
+
|
|
120
|
+
const collectionId = String(id);
|
|
121
|
+
|
|
122
|
+
// 验证收藏夹 ID 为数字
|
|
123
|
+
if (!/^\d+$/.test(collectionId)) {
|
|
124
|
+
throw new ArgumentError('Collection ID must be numeric', 'Example: opencli zhihu collection 83283292');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pageOffset = validateNonNegativeInt(offset, 'offset');
|
|
128
|
+
const requestedLimit = validatePositiveInt(limit, 'limit');
|
|
129
|
+
const pageLimit = Math.min(requestedLimit, 20); // 知乎 API 限制每页最大 20
|
|
130
|
+
|
|
131
|
+
// 先访问知乎主页建立 session
|
|
132
|
+
await page.goto('https://www.zhihu.com');
|
|
133
|
+
|
|
134
|
+
const collected = [];
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
let totals = 0;
|
|
137
|
+
let nextOffset = pageOffset;
|
|
138
|
+
const maxPages = Math.ceil(requestedLimit / pageLimit) + 2;
|
|
139
|
+
for (let pageIndex = 0; pageIndex < maxPages && collected.length < requestedLimit; pageIndex += 1) {
|
|
140
|
+
const currentFetchLimit = Math.min(pageLimit, requestedLimit - collected.length);
|
|
141
|
+
const data = await fetchCollectionPage(page, collectionId, nextOffset, currentFetchLimit);
|
|
142
|
+
const items = Array.isArray(data.data) ? data.data : [];
|
|
143
|
+
const paging = data.paging || {};
|
|
144
|
+
totals = Number(paging.totals || totals || 0);
|
|
145
|
+
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
const key = itemKey(item);
|
|
148
|
+
if (!seen.has(key)) {
|
|
149
|
+
seen.add(key);
|
|
150
|
+
collected.push(item);
|
|
151
|
+
}
|
|
152
|
+
if (collected.length >= requestedLimit) break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (items.length === 0 || paging.is_end || collected.length >= requestedLimit) break;
|
|
156
|
+
if (typeof paging.next === 'string') {
|
|
157
|
+
try {
|
|
158
|
+
const nextUrl = new URL(paging.next);
|
|
159
|
+
const parsedOffset = Number(nextUrl.searchParams.get('offset'));
|
|
160
|
+
if (Number.isInteger(parsedOffset) && parsedOffset > nextOffset) {
|
|
161
|
+
nextOffset = parsedOffset;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
if (items.length < currentFetchLimit) break;
|
|
167
|
+
const fallbackOffset = nextOffset + items.length;
|
|
168
|
+
if (fallbackOffset <= nextOffset) break;
|
|
169
|
+
nextOffset = fallbackOffset;
|
|
170
|
+
if (totals && nextOffset >= totals) break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 计算总页数
|
|
174
|
+
const totalPages = Math.ceil(totals / pageLimit);
|
|
175
|
+
const currentPage = Math.floor(pageOffset / pageLimit) + 1;
|
|
176
|
+
|
|
177
|
+
// 输出统计信息
|
|
178
|
+
if (totals > 0) {
|
|
179
|
+
log.info(`收藏夹共有 ${totals} 条内容,共 ${totalPages} 页`);
|
|
180
|
+
log.info(`当前第 ${currentPage} 页,显示第 ${pageOffset + 1} - ${Math.min(pageOffset + collected.length, totals)} 条`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (collected.length === 0) {
|
|
184
|
+
throw new EmptyResultError('zhihu collection', `No items found for collection ${collectionId}. The collection may be empty, private, or the offset may be out of range.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return collected.slice(0, requestedLimit).map((item, i) => mapCollectionItem(item, pageOffset + i + 1));
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const __test__ = {
|
|
192
|
+
stripHtml,
|
|
193
|
+
validatePositiveInt,
|
|
194
|
+
validateNonNegativeInt,
|
|
195
|
+
itemKey,
|
|
196
|
+
mapCollectionItem,
|
|
197
|
+
};
|