@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
|
@@ -0,0 +1,127 @@
|
|
|
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 validatePositiveInt(value, name) {
|
|
6
|
+
const n = Number(value);
|
|
7
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
8
|
+
throw new ArgumentError(`zhihu collections --${name} must be a positive integer`, 'Example: opencli zhihu collections --limit 20');
|
|
9
|
+
}
|
|
10
|
+
return n;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function fetchJson(page, url, errorLabel) {
|
|
14
|
+
const data = await page.evaluate(`
|
|
15
|
+
(async () => {
|
|
16
|
+
const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
17
|
+
if (!r.ok) return { __httpError: r.status };
|
|
18
|
+
return await r.json();
|
|
19
|
+
})()
|
|
20
|
+
`);
|
|
21
|
+
|
|
22
|
+
if (!data || data.__httpError) {
|
|
23
|
+
const status = data?.__httpError;
|
|
24
|
+
if (status === 401 || status === 403) {
|
|
25
|
+
throw new AuthRequiredError('www.zhihu.com', `${errorLabel} from Zhihu failed. Please ensure you are logged in.`);
|
|
26
|
+
}
|
|
27
|
+
throw new CommandExecutionError(
|
|
28
|
+
status ? `${errorLabel} from Zhihu failed (HTTP ${status})` : `${errorLabel} from Zhihu failed`,
|
|
29
|
+
'Try again later or rerun with -v for more detail',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectionKey(item) {
|
|
36
|
+
return String(item?.id || item?.url || item?.title || '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cli({
|
|
40
|
+
site: 'zhihu',
|
|
41
|
+
name: 'collections',
|
|
42
|
+
description: '知乎收藏夹列表(需要登录)',
|
|
43
|
+
domain: 'www.zhihu.com',
|
|
44
|
+
strategy: Strategy.COOKIE,
|
|
45
|
+
browser: true,
|
|
46
|
+
args: [
|
|
47
|
+
{ name: 'limit', type: 'int', default: 20, help: '每页数量(最大 20)' },
|
|
48
|
+
],
|
|
49
|
+
columns: ['rank', 'title', 'item_count', 'description', 'collection_id'],
|
|
50
|
+
func: async (page, kwargs) => {
|
|
51
|
+
const { limit = 20 } = kwargs;
|
|
52
|
+
const requestedLimit = validatePositiveInt(limit, 'limit');
|
|
53
|
+
|
|
54
|
+
// 先访问知乎主页建立 session
|
|
55
|
+
await page.goto('https://www.zhihu.com');
|
|
56
|
+
// 获取当前用户的 url_token
|
|
57
|
+
const meData = await fetchJson(page, 'https://www.zhihu.com/api/v4/me?include=url_token', 'Zhihu user info request');
|
|
58
|
+
|
|
59
|
+
const urlToken = meData.url_token;
|
|
60
|
+
if (!urlToken) {
|
|
61
|
+
throw new CommandExecutionError('Failed to get user url_token from Zhihu', 'Please ensure you are logged in.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const collected = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
let totals = 0;
|
|
67
|
+
let offset = 0;
|
|
68
|
+
const pageLimit = Math.min(requestedLimit, 20);
|
|
69
|
+
const maxPages = Math.ceil(requestedLimit / pageLimit) + 2;
|
|
70
|
+
|
|
71
|
+
for (let pageIndex = 0; pageIndex < maxPages && collected.length < requestedLimit; pageIndex += 1) {
|
|
72
|
+
const currentFetchLimit = Math.min(pageLimit, requestedLimit - collected.length);
|
|
73
|
+
const url = `https://www.zhihu.com/api/v4/people/${urlToken}/collections?include=data%5B*%5D.updated_time&offset=${offset}&limit=${currentFetchLimit}`;
|
|
74
|
+
const data = await fetchJson(page, url, 'Zhihu favorite collections request');
|
|
75
|
+
const items = Array.isArray(data.data) ? data.data : [];
|
|
76
|
+
const paging = data.paging || {};
|
|
77
|
+
totals = Number(paging.totals || totals || 0);
|
|
78
|
+
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
const key = collectionKey(item);
|
|
81
|
+
if (key && !seen.has(key)) {
|
|
82
|
+
seen.add(key);
|
|
83
|
+
collected.push(item);
|
|
84
|
+
}
|
|
85
|
+
if (collected.length >= requestedLimit) break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (items.length === 0 || paging.is_end || collected.length >= requestedLimit) break;
|
|
89
|
+
if (typeof paging.next === 'string') {
|
|
90
|
+
try {
|
|
91
|
+
const nextUrl = new URL(paging.next);
|
|
92
|
+
const parsedOffset = Number(nextUrl.searchParams.get('offset'));
|
|
93
|
+
if (Number.isInteger(parsedOffset) && parsedOffset > offset) {
|
|
94
|
+
offset = parsedOffset;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
if (items.length < currentFetchLimit) break;
|
|
100
|
+
const fallbackOffset = offset + items.length;
|
|
101
|
+
if (fallbackOffset <= offset) break;
|
|
102
|
+
offset = fallbackOffset;
|
|
103
|
+
if (totals && offset >= totals) break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (totals > 0) {
|
|
107
|
+
log.info(`共有 ${totals} 个收藏夹`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (collected.length === 0) {
|
|
111
|
+
throw new EmptyResultError('zhihu collections', 'No favorite collections were returned for the logged-in user.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return collected.slice(0, requestedLimit).map((item, i) => ({
|
|
115
|
+
rank: i + 1,
|
|
116
|
+
title: item.title || '未命名',
|
|
117
|
+
item_count: item.item_count ?? item.answer_count ?? 0,
|
|
118
|
+
description: item.description || '',
|
|
119
|
+
collection_id: String(item.id || ''),
|
|
120
|
+
}));
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const __test__ = {
|
|
125
|
+
validatePositiveInt,
|
|
126
|
+
collectionKey,
|
|
127
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
// Mock logger
|
|
6
|
+
vi.mock('@jackwener/opencli/logger', () => ({
|
|
7
|
+
log: {
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
status: vi.fn(),
|
|
10
|
+
success: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
verbose: vi.fn(),
|
|
14
|
+
debug: vi.fn(),
|
|
15
|
+
step: vi.fn(),
|
|
16
|
+
stepResult: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import './collections.js';
|
|
21
|
+
|
|
22
|
+
describe('zhihu collections', () => {
|
|
23
|
+
it('returns list of collections', async () => {
|
|
24
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
25
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
26
|
+
|
|
27
|
+
const goto = vi.fn().mockResolvedValue(undefined);
|
|
28
|
+
let callCount = 0;
|
|
29
|
+
const evaluate = vi.fn().mockImplementation(async (js) => {
|
|
30
|
+
callCount++;
|
|
31
|
+
if (callCount === 1) {
|
|
32
|
+
expect(js).toContain('api/v4/me');
|
|
33
|
+
return { url_token: 'testuser', id: 'abc123' };
|
|
34
|
+
}
|
|
35
|
+
expect(js).toContain('people/testuser/collections');
|
|
36
|
+
return {
|
|
37
|
+
data: [
|
|
38
|
+
{ id: 123456, title: '我的收藏夹', item_count: 42, description: '待读' },
|
|
39
|
+
{ id: 789012, title: '技术文章', item_count: 100, description: '' },
|
|
40
|
+
],
|
|
41
|
+
paging: { totals: 2 },
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const page = { goto, evaluate };
|
|
46
|
+
const result = await cmd.func(page, { limit: 20 });
|
|
47
|
+
|
|
48
|
+
expect(result).toHaveLength(2);
|
|
49
|
+
expect(result[0]).toMatchObject({
|
|
50
|
+
rank: 1,
|
|
51
|
+
title: '我的收藏夹',
|
|
52
|
+
item_count: 42,
|
|
53
|
+
description: '待读',
|
|
54
|
+
collection_id: '123456',
|
|
55
|
+
});
|
|
56
|
+
expect(result[1]).toMatchObject({
|
|
57
|
+
rank: 2,
|
|
58
|
+
title: '技术文章',
|
|
59
|
+
item_count: 100,
|
|
60
|
+
description: '',
|
|
61
|
+
collection_id: '789012',
|
|
62
|
+
});
|
|
63
|
+
expect(evaluate).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns list of collections with answer_count fallback', async () => {
|
|
67
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
68
|
+
let callCount = 0;
|
|
69
|
+
const evaluate = vi.fn().mockImplementation(async () => {
|
|
70
|
+
callCount++;
|
|
71
|
+
if (callCount === 1) {
|
|
72
|
+
return { url_token: 'testuser', id: 'abc123' };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
data: [{ id: 111, title: '默认收藏夹', answer_count: 15, description: 'test desc' }],
|
|
76
|
+
paging: { totals: 1 },
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
81
|
+
const result = await cmd.func(page, { limit: 20 });
|
|
82
|
+
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0]).toMatchObject({
|
|
85
|
+
title: '默认收藏夹',
|
|
86
|
+
item_count: 15,
|
|
87
|
+
description: 'test desc',
|
|
88
|
+
collection_id: '111',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('maps auth failures to AuthRequiredError', async () => {
|
|
93
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
94
|
+
const page = {
|
|
95
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
evaluate: vi.fn().mockResolvedValue({ __httpError: 401 }),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await expect(cmd.func(page, { limit: 20 }))
|
|
100
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles missing url_token', async () => {
|
|
104
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
105
|
+
let callCount = 0;
|
|
106
|
+
const page = {
|
|
107
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
108
|
+
evaluate: vi.fn().mockImplementation(async () => {
|
|
109
|
+
callCount++;
|
|
110
|
+
if (callCount === 1) return { id: 'abc123' };
|
|
111
|
+
return {};
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await expect(cmd.func(page, { limit: 20 }))
|
|
116
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('respects limit parameter', async () => {
|
|
120
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
121
|
+
let callCount = 0;
|
|
122
|
+
const evaluate = vi.fn().mockImplementation(async (js) => {
|
|
123
|
+
callCount++;
|
|
124
|
+
if (callCount === 1) {
|
|
125
|
+
return { url_token: 'testuser', id: 'abc123' };
|
|
126
|
+
}
|
|
127
|
+
expect(js).toContain('limit=10');
|
|
128
|
+
return {
|
|
129
|
+
data: [{ id: 1, title: 'Test', answer_count: 0, description: '' }],
|
|
130
|
+
paging: { totals: 1 },
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
135
|
+
const result = await cmd.func(page, { limit: 10 });
|
|
136
|
+
|
|
137
|
+
expect(result).toHaveLength(1);
|
|
138
|
+
expect(evaluate).toHaveBeenCalledTimes(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('rejects invalid limits before navigation', async () => {
|
|
142
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
143
|
+
const page = { goto: vi.fn(), evaluate: vi.fn() };
|
|
144
|
+
|
|
145
|
+
await expect(cmd.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
146
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('paginates collection list and deduplicates by collection id', async () => {
|
|
150
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
151
|
+
const evaluate = vi.fn()
|
|
152
|
+
.mockResolvedValueOnce({ url_token: 'testuser', id: 'abc123' })
|
|
153
|
+
.mockResolvedValueOnce({
|
|
154
|
+
data: [{ id: 1, title: 'A', item_count: 1, description: '' }],
|
|
155
|
+
paging: { totals: 3, is_end: false, next: 'https://www.zhihu.com/api/v4/people/testuser/collections?offset=1&limit=1' },
|
|
156
|
+
})
|
|
157
|
+
.mockResolvedValueOnce({
|
|
158
|
+
data: [
|
|
159
|
+
{ id: 1, title: 'A duplicate', item_count: 1, description: '' },
|
|
160
|
+
{ id: 2, title: 'B', item_count: 2, description: '' },
|
|
161
|
+
],
|
|
162
|
+
paging: { totals: 3, is_end: true },
|
|
163
|
+
});
|
|
164
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
165
|
+
|
|
166
|
+
const result = await cmd.func(page, { limit: 2 });
|
|
167
|
+
|
|
168
|
+
expect(result.map((row) => row.title)).toEqual(['A', 'B']);
|
|
169
|
+
expect(evaluate).toHaveBeenCalledTimes(3);
|
|
170
|
+
expect(evaluate.mock.calls[2][0]).toContain('offset=1');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('throws EmptyResultError when no collections are returned', async () => {
|
|
174
|
+
const cmd = getRegistry().get('zhihu/collections');
|
|
175
|
+
const evaluate = vi.fn()
|
|
176
|
+
.mockResolvedValueOnce({ url_token: 'testuser', id: 'abc123' })
|
|
177
|
+
.mockResolvedValueOnce({ data: [], paging: { totals: 0, is_end: true } });
|
|
178
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
179
|
+
|
|
180
|
+
await expect(cmd.func(page, { limit: 20 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/clis/zhihu/comment.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 COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]';
|
|
6
5
|
cli({
|
|
7
6
|
site: 'zhihu',
|
|
8
7
|
name: 'comment',
|
|
9
8
|
description: 'Create a top-level comment on a Zhihu answer or article',
|
|
10
9
|
domain: '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 target URL or typed target' },
|
|
@@ -16,7 +15,7 @@ cli({
|
|
|
16
15
|
{ name: 'file', help: 'Comment text file path' },
|
|
17
16
|
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
18
17
|
],
|
|
19
|
-
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url'
|
|
18
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url'],
|
|
20
19
|
func: async (page, kwargs) => {
|
|
21
20
|
if (!page)
|
|
22
21
|
throw new CommandExecutionError('Browser session required for zhihu comment');
|
|
@@ -25,311 +24,31 @@ cli({
|
|
|
25
24
|
const target = assertAllowedKinds('comment', parseTarget(rawTarget));
|
|
26
25
|
const payload = await resolvePayload(kwargs);
|
|
27
26
|
await page.goto(target.url);
|
|
27
|
+
await page.wait(3);
|
|
28
28
|
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const href = link.getAttribute('href') || '';
|
|
41
|
-
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
if (!block) return { entryPathSafe: false, wrongAnswer: true };
|
|
45
|
-
scope = block;
|
|
46
|
-
} else {
|
|
47
|
-
scope =
|
|
48
|
-
document.querySelector('article')
|
|
49
|
-
|| document.querySelector('.Post-Main')
|
|
50
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
51
|
-
|| document;
|
|
52
|
-
}
|
|
53
|
-
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
54
|
-
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
55
|
-
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
56
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
57
|
-
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
58
|
-
return { editor, container, replyHint, text, nestedReply };
|
|
59
|
-
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
60
|
-
return {
|
|
61
|
-
entryPathSafe: topLevelCandidates.length === 1
|
|
62
|
-
&& !restoredDraft
|
|
63
|
-
&& !topLevelCandidates[0].replyHint
|
|
64
|
-
&& !String(topLevelCandidates[0].text || '').trim(),
|
|
65
|
-
wrongAnswer: false,
|
|
66
|
-
};
|
|
67
|
-
})()`);
|
|
68
|
-
if (entryPath.wrongAnswer) {
|
|
69
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
70
|
-
}
|
|
71
|
-
if (!entryPath.entryPathSafe) {
|
|
72
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free');
|
|
73
|
-
}
|
|
74
|
-
const beforeSubmitSnapshot = await page.evaluate(`(() => {
|
|
75
|
-
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
76
|
-
const targetKind = ${JSON.stringify(target.kind)};
|
|
77
|
-
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
78
|
-
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
79
|
-
let scope = document;
|
|
80
|
-
if (targetKind === 'answer') {
|
|
81
|
-
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
82
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
83
|
-
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
84
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
85
|
-
const href = link.getAttribute('href') || '';
|
|
86
|
-
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] };
|
|
90
|
-
scope = block;
|
|
91
|
-
} else {
|
|
92
|
-
scope =
|
|
93
|
-
document.querySelector('article')
|
|
94
|
-
|| document.querySelector('.Post-Main')
|
|
95
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
96
|
-
|| document;
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
wrongAnswer: false,
|
|
100
|
-
rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({
|
|
101
|
-
id: node.getAttribute('data-comment-id') || '',
|
|
102
|
-
text: normalize(node.textContent || ''),
|
|
103
|
-
})),
|
|
104
|
-
commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]'))
|
|
105
|
-
.map((node) => node.getAttribute('href') || '')
|
|
106
|
-
.filter(Boolean),
|
|
107
|
-
};
|
|
108
|
-
})()`);
|
|
109
|
-
if (beforeSubmitSnapshot.wrongAnswer) {
|
|
110
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
111
|
-
}
|
|
112
|
-
const composer = await page.evaluate(`(async () => {
|
|
113
|
-
const targetKind = ${JSON.stringify(target.kind)};
|
|
114
|
-
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
115
|
-
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
116
|
-
let scope = document;
|
|
117
|
-
if (targetKind === 'answer') {
|
|
118
|
-
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
119
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
120
|
-
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
121
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
122
|
-
const href = link.getAttribute('href') || '';
|
|
123
|
-
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
if (!block) return { composerState: 'wrong_answer' };
|
|
127
|
-
scope = block;
|
|
128
|
-
} else {
|
|
129
|
-
scope =
|
|
130
|
-
document.querySelector('article')
|
|
131
|
-
|| document.querySelector('.Post-Main')
|
|
132
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
133
|
-
|| document;
|
|
134
|
-
}
|
|
135
|
-
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
136
|
-
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
137
|
-
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
138
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
139
|
-
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
140
|
-
return { editor, container, replyHint, text, nestedReply };
|
|
141
|
-
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
142
|
-
if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' };
|
|
143
|
-
return {
|
|
144
|
-
composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe',
|
|
145
|
-
};
|
|
146
|
-
})()`);
|
|
147
|
-
if (composer.composerState === 'wrong_answer') {
|
|
148
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
149
|
-
}
|
|
150
|
-
if (composer.composerState !== 'fresh_top_level') {
|
|
151
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer');
|
|
152
|
-
}
|
|
153
|
-
const editorCheck = await page.evaluate(`(async () => {
|
|
154
|
-
const textToInsert = ${JSON.stringify(payload)};
|
|
155
|
-
const targetKind = ${JSON.stringify(target.kind)};
|
|
156
|
-
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
157
|
-
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
158
|
-
let scope = document;
|
|
159
|
-
if (targetKind === 'answer') {
|
|
160
|
-
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
161
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
162
|
-
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
163
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
164
|
-
const href = link.getAttribute('href') || '';
|
|
165
|
-
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
if (!block) return { editorContent: '', mode: 'wrong_answer' };
|
|
169
|
-
scope = block;
|
|
170
|
-
} else {
|
|
171
|
-
scope =
|
|
172
|
-
document.querySelector('article')
|
|
173
|
-
|| document.querySelector('.Post-Main')
|
|
174
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
175
|
-
|| document;
|
|
176
|
-
}
|
|
177
|
-
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
178
|
-
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
179
|
-
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
180
|
-
return { editor, container, nestedReply };
|
|
181
|
-
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
182
|
-
if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' };
|
|
183
|
-
const { editor } = topLevelCandidates[0];
|
|
184
|
-
editor.focus();
|
|
185
|
-
if ('value' in editor) {
|
|
186
|
-
editor.value = '';
|
|
187
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
188
|
-
editor.value = textToInsert;
|
|
189
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
190
|
-
} else {
|
|
191
|
-
editor.textContent = '';
|
|
192
|
-
document.execCommand('insertText', false, textToInsert);
|
|
193
|
-
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
|
|
194
|
-
}
|
|
195
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
196
|
-
const content = 'value' in editor ? editor.value : (editor.textContent || '');
|
|
197
|
-
const replyHint = editor.getAttribute('data-reply-to') || '';
|
|
198
|
-
return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' };
|
|
199
|
-
})()`);
|
|
200
|
-
if (editorCheck.mode === 'wrong_answer') {
|
|
201
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
202
|
-
}
|
|
203
|
-
if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) {
|
|
204
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit');
|
|
205
|
-
}
|
|
206
|
-
const proof = await page.evaluate(`(async () => {
|
|
207
|
-
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
208
|
-
const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)};
|
|
209
|
-
const readCommentAuthorSlug = (node) => {
|
|
210
|
-
const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector));
|
|
211
|
-
const slugs = Array.from(new Set(authorScopes
|
|
212
|
-
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
|
|
213
|
-
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
|
|
214
|
-
.filter(Boolean)));
|
|
215
|
-
return slugs.length === 1 ? slugs[0] : null;
|
|
216
|
-
};
|
|
217
|
-
const targetKind = ${JSON.stringify(target.kind)};
|
|
218
|
-
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
219
|
-
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
220
|
-
let scope = document;
|
|
221
|
-
if (targetKind === 'answer') {
|
|
222
|
-
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
223
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
224
|
-
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
225
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
226
|
-
const href = link.getAttribute('href') || '';
|
|
227
|
-
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
if (!block) return { proofType: 'wrong_answer' };
|
|
231
|
-
scope = block;
|
|
232
|
-
} else {
|
|
233
|
-
scope =
|
|
234
|
-
document.querySelector('article')
|
|
235
|
-
|| document.querySelector('.Post-Main')
|
|
236
|
-
|| document.querySelector('[itemprop="articleBody"]')
|
|
237
|
-
|| document;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
241
|
-
const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
|
|
242
|
-
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
243
|
-
return { editor, container, nestedReply };
|
|
244
|
-
}).filter((candidate) => candidate.container && !candidate.nestedReply);
|
|
245
|
-
if (topLevelCandidates.length !== 1) return { proofType: 'unknown' };
|
|
246
|
-
const submitScope = topLevelCandidates[0].container || scope;
|
|
247
|
-
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || ''));
|
|
248
|
-
submit && submit.click();
|
|
249
|
-
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
250
|
-
const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => {
|
|
251
|
-
const href = node.getAttribute('href') || '';
|
|
252
|
-
return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
if (createdLink) {
|
|
256
|
-
const card = createdLink.closest('[data-comment-id], .CommentItem, li');
|
|
257
|
-
const authorSlug = card ? readCommentAuthorSlug(card) : null;
|
|
258
|
-
const contentNode =
|
|
259
|
-
card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent')
|
|
260
|
-
|| card;
|
|
261
|
-
const text = normalize(contentNode?.textContent || '');
|
|
262
|
-
const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem'));
|
|
263
|
-
return {
|
|
264
|
-
proofType: 'stable_url',
|
|
265
|
-
createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href,
|
|
266
|
-
commentScope: nestedReply ? 'nested_reply' : 'top_level_only',
|
|
267
|
-
authorIdentity: authorSlug,
|
|
268
|
-
targetMatches: text === normalize(${JSON.stringify(payload)}),
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const currentUserSlug = ${JSON.stringify(authorIdentity)};
|
|
273
|
-
const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))});
|
|
274
|
-
const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))});
|
|
275
|
-
const normalizedPayload = normalize(${JSON.stringify(payload)});
|
|
276
|
-
const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => {
|
|
277
|
-
return {
|
|
278
|
-
id: node.getAttribute('data-comment-id') || '',
|
|
279
|
-
text: normalize(node.textContent || ''),
|
|
280
|
-
authorSlug: readCommentAuthorSlug(node),
|
|
281
|
-
topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'),
|
|
282
|
-
};
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
const matching = after.filter((row) =>
|
|
286
|
-
!beforeIds.has(row.id)
|
|
287
|
-
&& row.authorSlug === currentUserSlug
|
|
288
|
-
&& row.topLevel
|
|
289
|
-
&& row.text === normalizedPayload
|
|
290
|
-
&& !beforeTexts.has(row.text)
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
return matching.length === 1
|
|
294
|
-
? {
|
|
295
|
-
proofType: 'fallback',
|
|
296
|
-
createdProof: {
|
|
297
|
-
proof_type: 'comment_fallback',
|
|
298
|
-
author_scope: 'current_user',
|
|
299
|
-
target_scope: 'requested_target',
|
|
300
|
-
comment_scope: 'top_level_only',
|
|
301
|
-
content_match: 'exact_normalized',
|
|
302
|
-
observed_after_submit: true,
|
|
303
|
-
present_in_pre_submit_snapshot: false,
|
|
304
|
-
new_matching_entries: 1,
|
|
305
|
-
post_submit_matching_entries: after.filter((row) =>
|
|
306
|
-
row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload
|
|
307
|
-
).length,
|
|
308
|
-
snapshot_scope: ${JSON.stringify(target.kind === 'answer'
|
|
309
|
-
? 'stabilized_expanded_target_answer_comment_list'
|
|
310
|
-
: 'stabilized_expanded_target_article_comment_list')},
|
|
311
|
-
},
|
|
312
|
-
}
|
|
313
|
-
: { proofType: 'unknown' };
|
|
314
|
-
})()`);
|
|
315
|
-
if (proof.proofType === 'wrong_answer') {
|
|
316
|
-
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
317
|
-
}
|
|
318
|
-
if (proof.proofType === 'fallback') {
|
|
319
|
-
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
|
|
320
|
-
author_identity: authorIdentity,
|
|
321
|
-
created_proof: proof.createdProof,
|
|
29
|
+
const apiResult = await page.evaluate(`(async () => {
|
|
30
|
+
var targetKind = ${JSON.stringify(target.kind)};
|
|
31
|
+
var targetId = ${JSON.stringify(target.id)};
|
|
32
|
+
var content = ${JSON.stringify(payload)};
|
|
33
|
+
var resourceType = targetKind === 'answer' ? 'answers' : 'articles';
|
|
34
|
+
var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/comments';
|
|
35
|
+
var resp = await fetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
credentials: 'include',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ content: content }),
|
|
322
40
|
});
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
41
|
+
var data = await resp.json();
|
|
42
|
+
if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
|
|
43
|
+
if (!data || !data.id) return { ok: false, status: resp.status, message: 'Comment API response did not include a created comment id' };
|
|
44
|
+
return { ok: true, id: data.id, url: data.url };
|
|
45
|
+
})()`);
|
|
46
|
+
if (!apiResult?.ok) {
|
|
47
|
+
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create comment');
|
|
48
|
+
}
|
|
49
|
+
return buildResultRow(`Commented on ${target.kind} ${target.id}`, target.kind, rawTarget, 'created', {
|
|
331
50
|
author_identity: authorIdentity,
|
|
332
|
-
created_url:
|
|
51
|
+
created_url: apiResult.url || '',
|
|
333
52
|
});
|
|
334
53
|
},
|
|
335
54
|
});
|