Files
jackwener__opencli/clis/zhihu/comment.js
jakevin d2974a9ff6 refactor(adapters): convert adapter layer from TypeScript to JavaScript (#928)
* refactor(adapters): convert adapter layer from TypeScript to JavaScript

Core framework stays TypeScript; adapter layer moves to JS-first.
Adapters are essentially "executable config + browser scripts" that
barely use TS features — this simplifies the build/distribution pipeline
by removing the dist/clis/ intermediate compilation step.

Changes:
- Convert all 753 adapter files in clis/ from .ts to .js
- Update tsconfig to exclude clis/ from compilation
- Simplify build-manifest to scan clis/*.js directly (no dist/clis/)
- Update discovery, main, fetch-adapters to load JS adapters from clis/
- Update generate-verified to output .js artifacts
- Update package.json files field: dist/clis/ → clis/
- Fix all test files for the .ts → .js transition

* fix(main): use findPackageRoot for BUILTIN_CLIS path

The previous relative path (../../clis from __dirname) only worked for
dist/src/main.js but broke dev mode (tsx src/main.ts) where __dirname
is <repo>/src — resolving to /clis instead of <repo>/clis.

Use findPackageRoot() which works for both dev and prod paths.
2026-04-10 14:52:18 +08:00

336 lines
19 KiB
JavaScript

import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { assertAllowedKinds, parseTarget } from './target.js';
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
const COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]';
cli({
site: 'zhihu',
name: 'comment',
description: 'Create a top-level comment on a Zhihu answer or article',
domain: 'zhihu.com',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
{ name: 'text', positional: true, help: 'Comment text' },
{ name: 'file', help: 'Comment text file path' },
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
],
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'],
func: async (page, kwargs) => {
if (!page)
throw new CommandExecutionError('Browser session required for zhihu comment');
requireExecute(kwargs);
const rawTarget = String(kwargs.target);
const target = assertAllowedKinds('comment', parseTarget(rawTarget));
const payload = await resolvePayload(kwargs);
await page.goto(target.url);
const authorIdentity = await resolveCurrentUserIdentity(page);
const entryPath = await page.evaluate(`(() => {
const targetKind = ${JSON.stringify(target.kind)};
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
let scope = document;
if (targetKind === 'answer') {
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const href = link.getAttribute('href') || '';
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
});
});
if (!block) return { entryPathSafe: false, wrongAnswer: true };
scope = block;
} else {
scope =
document.querySelector('article')
|| document.querySelector('.Post-Main')
|| document.querySelector('[itemprop="articleBody"]')
|| document;
}
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
const replyHint = editor.getAttribute('data-reply-to') || '';
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, replyHint, text, nestedReply };
}).filter((candidate) => candidate.container && !candidate.nestedReply);
return {
entryPathSafe: topLevelCandidates.length === 1
&& !restoredDraft
&& !topLevelCandidates[0].replyHint
&& !String(topLevelCandidates[0].text || '').trim(),
wrongAnswer: false,
};
})()`);
if (entryPath.wrongAnswer) {
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
}
if (!entryPath.entryPathSafe) {
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free');
}
const beforeSubmitSnapshot = await page.evaluate(`(() => {
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
const targetKind = ${JSON.stringify(target.kind)};
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
let scope = document;
if (targetKind === 'answer') {
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const href = link.getAttribute('href') || '';
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
});
});
if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] };
scope = block;
} else {
scope =
document.querySelector('article')
|| document.querySelector('.Post-Main')
|| document.querySelector('[itemprop="articleBody"]')
|| document;
}
return {
wrongAnswer: false,
rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({
id: node.getAttribute('data-comment-id') || '',
text: normalize(node.textContent || ''),
})),
commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]'))
.map((node) => node.getAttribute('href') || '')
.filter(Boolean),
};
})()`);
if (beforeSubmitSnapshot.wrongAnswer) {
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
}
const composer = await page.evaluate(`(async () => {
const targetKind = ${JSON.stringify(target.kind)};
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
let scope = document;
if (targetKind === 'answer') {
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const href = link.getAttribute('href') || '';
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
});
});
if (!block) return { composerState: 'wrong_answer' };
scope = block;
} else {
scope =
document.querySelector('article')
|| document.querySelector('.Post-Main')
|| document.querySelector('[itemprop="articleBody"]')
|| document;
}
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
const replyHint = editor.getAttribute('data-reply-to') || '';
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, replyHint, text, nestedReply };
}).filter((candidate) => candidate.container && !candidate.nestedReply);
if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' };
return {
composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe',
};
})()`);
if (composer.composerState === 'wrong_answer') {
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
}
if (composer.composerState !== 'fresh_top_level') {
throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer');
}
const editorCheck = await page.evaluate(`(async () => {
const textToInsert = ${JSON.stringify(payload)};
const targetKind = ${JSON.stringify(target.kind)};
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
let scope = document;
if (targetKind === 'answer') {
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const href = link.getAttribute('href') || '';
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
});
});
if (!block) return { editorContent: '', mode: 'wrong_answer' };
scope = block;
} else {
scope =
document.querySelector('article')
|| document.querySelector('.Post-Main')
|| document.querySelector('[itemprop="articleBody"]')
|| document;
}
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, nestedReply };
}).filter((candidate) => candidate.container && !candidate.nestedReply);
if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' };
const { editor } = topLevelCandidates[0];
editor.focus();
if ('value' in editor) {
editor.value = '';
editor.dispatchEvent(new Event('input', { bubbles: true }));
editor.value = textToInsert;
editor.dispatchEvent(new Event('input', { bubbles: true }));
} else {
editor.textContent = '';
document.execCommand('insertText', false, textToInsert);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
}
await new Promise((resolve) => setTimeout(resolve, 200));
const content = 'value' in editor ? editor.value : (editor.textContent || '');
const replyHint = editor.getAttribute('data-reply-to') || '';
return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' };
})()`);
if (editorCheck.mode === 'wrong_answer') {
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
}
if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) {
throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit');
}
const proof = await page.evaluate(`(async () => {
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)};
const readCommentAuthorSlug = (node) => {
const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector));
const slugs = Array.from(new Set(authorScopes
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
.filter(Boolean)));
return slugs.length === 1 ? slugs[0] : null;
};
const targetKind = ${JSON.stringify(target.kind)};
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
let scope = document;
if (targetKind === 'answer') {
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const href = link.getAttribute('href') || '';
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
});
});
if (!block) return { proofType: 'wrong_answer' };
scope = block;
} else {
scope =
document.querySelector('article')
|| document.querySelector('.Post-Main')
|| document.querySelector('[itemprop="articleBody"]')
|| document;
}
const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, nestedReply };
}).filter((candidate) => candidate.container && !candidate.nestedReply);
if (topLevelCandidates.length !== 1) return { proofType: 'unknown' };
const submitScope = topLevelCandidates[0].container || scope;
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || ''));
submit && submit.click();
await new Promise((resolve) => setTimeout(resolve, 1200));
const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => {
const href = node.getAttribute('href') || '';
return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href);
});
if (createdLink) {
const card = createdLink.closest('[data-comment-id], .CommentItem, li');
const authorSlug = card ? readCommentAuthorSlug(card) : null;
const contentNode =
card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent')
|| card;
const text = normalize(contentNode?.textContent || '');
const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem'));
return {
proofType: 'stable_url',
createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href,
commentScope: nestedReply ? 'nested_reply' : 'top_level_only',
authorIdentity: authorSlug,
targetMatches: text === normalize(${JSON.stringify(payload)}),
};
}
const currentUserSlug = ${JSON.stringify(authorIdentity)};
const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))});
const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))});
const normalizedPayload = normalize(${JSON.stringify(payload)});
const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => {
return {
id: node.getAttribute('data-comment-id') || '',
text: normalize(node.textContent || ''),
authorSlug: readCommentAuthorSlug(node),
topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'),
};
});
const matching = after.filter((row) =>
!beforeIds.has(row.id)
&& row.authorSlug === currentUserSlug
&& row.topLevel
&& row.text === normalizedPayload
&& !beforeTexts.has(row.text)
);
return matching.length === 1
? {
proofType: 'fallback',
createdProof: {
proof_type: 'comment_fallback',
author_scope: 'current_user',
target_scope: 'requested_target',
comment_scope: 'top_level_only',
content_match: 'exact_normalized',
observed_after_submit: true,
present_in_pre_submit_snapshot: false,
new_matching_entries: 1,
post_submit_matching_entries: after.filter((row) =>
row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload
).length,
snapshot_scope: ${JSON.stringify(target.kind === 'answer'
? 'stabilized_expanded_target_answer_comment_list'
: 'stabilized_expanded_target_article_comment_list')},
},
}
: { proofType: 'unknown' };
})()`);
if (proof.proofType === 'wrong_answer') {
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
}
if (proof.proofType === 'fallback') {
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
author_identity: authorIdentity,
created_proof: proof.createdProof,
});
}
if (proof.proofType !== 'stable_url') {
throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely');
}
if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) {
throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely');
}
return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
author_identity: authorIdentity,
created_url: proof.createdUrl,
});
},
});