@jackwener/opencli 1.6.7 → 1.6.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 +5 -1
- package/README.zh-CN.md +8 -3
- package/dist/clis/1688/assets.d.ts +42 -0
- package/dist/clis/1688/assets.js +204 -0
- package/dist/clis/1688/assets.test.d.ts +1 -0
- package/dist/clis/1688/assets.test.js +39 -0
- package/dist/clis/1688/download.d.ts +9 -0
- package/dist/clis/1688/download.js +76 -0
- package/dist/clis/1688/download.test.d.ts +1 -0
- package/dist/clis/1688/download.test.js +31 -0
- package/dist/clis/1688/shared.d.ts +10 -0
- package/dist/clis/1688/shared.js +43 -0
- package/dist/clis/jianyu/search.d.ts +14 -0
- package/dist/clis/jianyu/search.js +135 -0
- package/dist/clis/jianyu/search.test.d.ts +1 -0
- package/dist/clis/jianyu/search.test.js +23 -0
- package/dist/clis/linux-do/topic-content.d.ts +35 -0
- package/dist/clis/linux-do/topic-content.js +154 -0
- package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
- package/dist/clis/linux-do/topic-content.test.js +59 -0
- package/dist/clis/linux-do/topic.yaml +1 -16
- package/dist/clis/quark/ls.d.ts +1 -0
- package/dist/clis/quark/ls.js +63 -0
- package/dist/clis/quark/mkdir.d.ts +1 -0
- package/dist/clis/quark/mkdir.js +36 -0
- package/dist/clis/quark/mv.d.ts +1 -0
- package/dist/clis/quark/mv.js +53 -0
- package/dist/clis/quark/rename.d.ts +1 -0
- package/dist/clis/quark/rename.js +26 -0
- package/dist/clis/quark/rm.d.ts +1 -0
- package/dist/clis/quark/rm.js +24 -0
- package/dist/clis/quark/save.d.ts +1 -0
- package/dist/clis/quark/save.js +80 -0
- package/dist/clis/quark/share-tree.d.ts +1 -0
- package/dist/clis/quark/share-tree.js +45 -0
- package/dist/clis/quark/utils.d.ts +50 -0
- package/dist/clis/quark/utils.js +146 -0
- package/dist/clis/quark/utils.test.d.ts +1 -0
- package/dist/clis/quark/utils.test.js +58 -0
- package/dist/clis/twitter/reply.js +3 -8
- package/dist/clis/twitter/reply.test.js +5 -5
- package/dist/clis/xiaohongshu/note.js +8 -3
- package/dist/clis/xiaohongshu/note.test.js +11 -0
- package/dist/clis/xueqiu/groups.yaml +23 -0
- package/dist/clis/xueqiu/kline.yaml +65 -0
- package/dist/clis/xueqiu/watchlist.yaml +9 -9
- package/dist/clis/zhihu/answer.d.ts +1 -0
- package/dist/clis/zhihu/answer.js +194 -0
- package/dist/clis/zhihu/answer.test.d.ts +1 -0
- package/dist/clis/zhihu/answer.test.js +81 -0
- package/dist/clis/zhihu/comment.d.ts +1 -0
- package/dist/clis/zhihu/comment.js +335 -0
- package/dist/clis/zhihu/comment.test.d.ts +1 -0
- package/dist/clis/zhihu/comment.test.js +54 -0
- package/dist/clis/zhihu/favorite.d.ts +1 -0
- package/dist/clis/zhihu/favorite.js +224 -0
- package/dist/clis/zhihu/favorite.test.d.ts +1 -0
- package/dist/clis/zhihu/favorite.test.js +196 -0
- package/dist/clis/zhihu/follow.d.ts +1 -0
- package/dist/clis/zhihu/follow.js +80 -0
- package/dist/clis/zhihu/follow.test.d.ts +1 -0
- package/dist/clis/zhihu/follow.test.js +45 -0
- package/dist/clis/zhihu/like.d.ts +1 -0
- package/dist/clis/zhihu/like.js +91 -0
- package/dist/clis/zhihu/like.test.d.ts +1 -0
- package/dist/clis/zhihu/like.test.js +64 -0
- package/dist/clis/zhihu/target.d.ts +24 -0
- package/dist/clis/zhihu/target.js +91 -0
- package/dist/clis/zhihu/target.test.d.ts +1 -0
- package/dist/clis/zhihu/target.test.js +77 -0
- package/dist/clis/zhihu/write-shared.d.ts +32 -0
- package/dist/clis/zhihu/write-shared.js +221 -0
- package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
- package/dist/clis/zhihu/write-shared.test.js +175 -0
- package/dist/src/analysis.d.ts +2 -0
- package/dist/src/analysis.js +6 -0
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +30 -24
- package/dist/src/browser/cdp.js +96 -0
- package/dist/src/browser/daemon-client.d.ts +17 -8
- package/dist/src/browser/daemon-client.js +12 -13
- package/dist/src/browser/daemon-client.test.js +32 -25
- package/dist/src/browser/index.d.ts +2 -1
- package/dist/src/browser/index.js +1 -1
- package/dist/src/browser.test.js +2 -3
- package/dist/src/build-manifest.d.ts +3 -1
- package/dist/src/build-manifest.js +10 -7
- package/dist/src/build-manifest.test.js +8 -4
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +48 -46
- package/dist/src/clis/binance/commands.test.d.ts +1 -0
- package/dist/src/clis/binance/commands.test.js +54 -0
- package/dist/src/commanderAdapter.js +19 -6
- package/dist/src/commands/daemon.js +2 -10
- package/dist/src/diagnostic.d.ts +28 -2
- package/dist/src/diagnostic.js +263 -25
- package/dist/src/diagnostic.test.js +220 -1
- package/dist/src/discovery.js +7 -17
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +59 -31
- package/dist/src/doctor.test.js +89 -16
- package/dist/src/download/progress.js +7 -2
- package/dist/src/execution.js +1 -13
- package/dist/src/explore.d.ts +0 -2
- package/dist/src/explore.js +61 -38
- package/dist/src/extension-manifest-regression.test.js +0 -1
- package/dist/src/generate.d.ts +3 -6
- package/dist/src/generate.js +4 -8
- package/dist/src/package-paths.d.ts +8 -0
- package/dist/src/package-paths.js +41 -0
- package/dist/src/plugin-scaffold.js +1 -3
- package/dist/src/plugin.d.ts +2 -1
- package/dist/src/plugin.js +25 -8
- package/dist/src/plugin.test.js +16 -1
- package/dist/src/record.d.ts +1 -2
- package/dist/src/record.js +14 -52
- package/dist/src/synthesize.d.ts +0 -2
- package/dist/src/synthesize.js +8 -4
- package/package.json +3 -3
- package/dist/cli-manifest.json +0 -17250
- package/dist/src/browser/discover.d.ts +0 -15
- package/dist/src/browser/discover.js +0 -19
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
4
|
+
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
|
|
5
|
+
const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
|
|
6
|
+
cli({
|
|
7
|
+
site: 'zhihu',
|
|
8
|
+
name: 'answer',
|
|
9
|
+
description: 'Answer a Zhihu question',
|
|
10
|
+
domain: 'www.zhihu.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
|
|
15
|
+
{ name: 'text', positional: true, help: 'Answer text' },
|
|
16
|
+
{ name: 'file', help: 'Answer text file path' },
|
|
17
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'created_target', 'created_url', 'author_identity'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
if (!page)
|
|
22
|
+
throw new CommandExecutionError('Browser session required for zhihu answer');
|
|
23
|
+
requireExecute(kwargs);
|
|
24
|
+
const rawTarget = String(kwargs.target);
|
|
25
|
+
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
|
|
26
|
+
const questionTarget = target;
|
|
27
|
+
const payload = await resolvePayload(kwargs);
|
|
28
|
+
await page.goto(target.url);
|
|
29
|
+
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
30
|
+
const entryPath = await page.evaluate(`(() => {
|
|
31
|
+
const currentUserSlug = ${JSON.stringify(authorIdentity)};
|
|
32
|
+
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
|
|
33
|
+
const readAnswerAuthorSlug = (node) => {
|
|
34
|
+
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
|
|
35
|
+
const slugs = Array.from(new Set(authorScopes
|
|
36
|
+
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
|
|
37
|
+
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
|
|
38
|
+
.filter(Boolean)));
|
|
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);
|
|
163
|
+
});
|
|
164
|
+
})
|
|
165
|
+
: null;
|
|
166
|
+
const authorSlug = answerContainer ? readAnswerAuthorSlug(answerContainer) : null;
|
|
167
|
+
const bodyNode =
|
|
168
|
+
answerContainer?.querySelector('[itemprop="text"]')
|
|
169
|
+
|| answerContainer?.querySelector('.RichContent-inner')
|
|
170
|
+
|| answerContainer?.querySelector('.RichText')
|
|
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');
|
|
187
|
+
}
|
|
188
|
+
return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
|
|
189
|
+
created_target: proof.createdTarget,
|
|
190
|
+
created_url: proof.createdUrl,
|
|
191
|
+
author_identity: authorIdentity,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './answer.js';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './answer.js';
|
|
4
|
+
describe('zhihu answer', () => {
|
|
5
|
+
it('rejects create mode when the current user already answered the question', async () => {
|
|
6
|
+
const cmd = getRegistry().get('zhihu/answer');
|
|
7
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
8
|
+
const page = {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
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' });
|
|
26
|
+
});
|
|
27
|
+
it('rejects when a unique safe answer composer cannot be proven', async () => {
|
|
28
|
+
const cmd = getRegistry().get('zhihu/answer');
|
|
29
|
+
const page = {
|
|
30
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
evaluate: vi.fn()
|
|
32
|
+
.mockResolvedValueOnce({ slug: 'alice' })
|
|
33
|
+
.mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: false }),
|
|
34
|
+
};
|
|
35
|
+
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
36
|
+
});
|
|
37
|
+
it('rejects when anonymous mode cannot be proven off', async () => {
|
|
38
|
+
const cmd = getRegistry().get('zhihu/answer');
|
|
39
|
+
const page = {
|
|
40
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
evaluate: vi.fn()
|
|
42
|
+
.mockResolvedValueOnce({ slug: 'alice' })
|
|
43
|
+
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
|
|
44
|
+
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
|
|
45
|
+
};
|
|
46
|
+
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
47
|
+
});
|
|
48
|
+
it('requires a side-effect-free entry path and exact editor content before publish', async () => {
|
|
49
|
+
const cmd = getRegistry().get('zhihu/answer');
|
|
50
|
+
const page = {
|
|
51
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
evaluate: vi.fn()
|
|
53
|
+
.mockResolvedValueOnce({ slug: 'alice' })
|
|
54
|
+
.mockResolvedValueOnce({ entryPathSafe: true })
|
|
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
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).resolves.toEqual([
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
outcome: 'created',
|
|
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/\"]')");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
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
|
+
cli({
|
|
7
|
+
site: 'zhihu',
|
|
8
|
+
name: 'comment',
|
|
9
|
+
description: 'Create a top-level comment on a Zhihu answer or article',
|
|
10
|
+
domain: 'zhihu.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
15
|
+
{ name: 'text', positional: true, help: 'Comment text' },
|
|
16
|
+
{ name: 'file', help: 'Comment text file path' },
|
|
17
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
if (!page)
|
|
22
|
+
throw new CommandExecutionError('Browser session required for zhihu comment');
|
|
23
|
+
requireExecute(kwargs);
|
|
24
|
+
const rawTarget = String(kwargs.target);
|
|
25
|
+
const target = assertAllowedKinds('comment', parseTarget(rawTarget));
|
|
26
|
+
const payload = await resolvePayload(kwargs);
|
|
27
|
+
await page.goto(target.url);
|
|
28
|
+
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
29
|
+
const entryPath = await page.evaluate(`(() => {
|
|
30
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
31
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
32
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
33
|
+
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
|
|
34
|
+
let scope = document;
|
|
35
|
+
if (targetKind === 'answer') {
|
|
36
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
37
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
38
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
39
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
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,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (proof.proofType !== 'stable_url') {
|
|
325
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely');
|
|
326
|
+
}
|
|
327
|
+
if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) {
|
|
328
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely');
|
|
329
|
+
}
|
|
330
|
+
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
|
|
331
|
+
author_identity: authorIdentity,
|
|
332
|
+
created_url: proof.createdUrl,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './comment.js';
|