@jackwener/opencli 1.6.8 → 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 +2 -0
- package/README.zh-CN.md +2 -1
- 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/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/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/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +30 -24
- 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/cli.js +3 -3
- 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/diagnostic.d.ts +1 -0
- package/dist/src/diagnostic.js +64 -2
- package/dist/src/diagnostic.test.js +91 -1
- 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/execution.js +1 -13
- package/dist/src/explore.js +1 -1
- package/dist/src/generate.d.ts +2 -5
- package/dist/src/generate.js +2 -5
- package/dist/src/plugin.d.ts +2 -1
- package/dist/src/plugin.js +25 -8
- package/dist/src/plugin.test.js +16 -1
- package/package.json +3 -3
- package/dist/src/browser/discover.d.ts +0 -15
- package/dist/src/browser/discover.js +0 -19
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, getErrorMessage } from '@jackwener/opencli/errors';
|
|
2
|
+
export const SHARE_API = 'https://drive-h.quark.cn/1/clouddrive/share/sharepage';
|
|
3
|
+
export const DRIVE_API = 'https://drive-pc.quark.cn/1/clouddrive/file';
|
|
4
|
+
export const TASK_API = 'https://drive-pc.quark.cn/1/clouddrive/task';
|
|
5
|
+
const QUARK_DOMAIN = 'pan.quark.cn';
|
|
6
|
+
const AUTH_HINT = 'Quark Drive requires a logged-in browser session';
|
|
7
|
+
function isAuthFailure(message, status) {
|
|
8
|
+
if (status === 401 || status === 403)
|
|
9
|
+
return true;
|
|
10
|
+
return /not logged in|login required|please log in|authentication required|unauthorized|forbidden|未登录|请先登录|需要登录|登录/.test(message.toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
function getErrorStatus(error) {
|
|
13
|
+
if (!error || typeof error !== 'object' || !('status' in error))
|
|
14
|
+
return undefined;
|
|
15
|
+
const status = error.status;
|
|
16
|
+
return typeof status === 'number' ? status : undefined;
|
|
17
|
+
}
|
|
18
|
+
function unwrapApiData(resp, action) {
|
|
19
|
+
if (resp.status === 200)
|
|
20
|
+
return resp.data;
|
|
21
|
+
if (isAuthFailure(resp.message, resp.status)) {
|
|
22
|
+
throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
|
|
23
|
+
}
|
|
24
|
+
throw new CommandExecutionError(`quark: ${action}: ${resp.message}`);
|
|
25
|
+
}
|
|
26
|
+
export function extractPwdId(url) {
|
|
27
|
+
const m = url.match(/\/s\/([a-zA-Z0-9]+)/);
|
|
28
|
+
if (m)
|
|
29
|
+
return m[1];
|
|
30
|
+
if (/^[a-zA-Z0-9]+$/.test(url))
|
|
31
|
+
return url;
|
|
32
|
+
throw new ArgumentError(`Invalid Quark share URL: ${url}`);
|
|
33
|
+
}
|
|
34
|
+
export async function fetchJson(page, url, options) {
|
|
35
|
+
const method = options?.method || 'GET';
|
|
36
|
+
const body = options?.body ? JSON.stringify(options.body) : undefined;
|
|
37
|
+
const js = `fetch(${JSON.stringify(url)}, {
|
|
38
|
+
method: ${JSON.stringify(method)},
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
credentials: 'include',
|
|
41
|
+
${body ? `body: ${JSON.stringify(body)},` : ''}
|
|
42
|
+
}).then(async r => {
|
|
43
|
+
const ct = r.headers.get('content-type') || '';
|
|
44
|
+
if (!ct.includes('json')) {
|
|
45
|
+
const text = await r.text().catch(() => '');
|
|
46
|
+
throw Object.assign(new Error('Non-JSON response: ' + text.slice(0, 200)), { status: r.status });
|
|
47
|
+
}
|
|
48
|
+
return r.json();
|
|
49
|
+
})`;
|
|
50
|
+
try {
|
|
51
|
+
return await page.evaluate(js);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (isAuthFailure(getErrorMessage(error), getErrorStatus(error))) {
|
|
55
|
+
throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function apiGet(page, url) {
|
|
61
|
+
const resp = await fetchJson(page, url);
|
|
62
|
+
return unwrapApiData(resp, 'API error');
|
|
63
|
+
}
|
|
64
|
+
export async function apiPost(page, url, body) {
|
|
65
|
+
const resp = await fetchJson(page, url, { method: 'POST', body });
|
|
66
|
+
return unwrapApiData(resp, 'API error');
|
|
67
|
+
}
|
|
68
|
+
export async function getToken(page, pwdId, passcode = '') {
|
|
69
|
+
const data = await fetchJson(page, `${SHARE_API}/token?pr=ucpro&fr=pc`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: { pwd_id: pwdId, passcode, support_visit_limit_private_share: true },
|
|
72
|
+
});
|
|
73
|
+
return unwrapApiData(data, 'Failed to get token').stoken;
|
|
74
|
+
}
|
|
75
|
+
export async function getShareList(page, pwdId, stoken, pdirFid = '0', options) {
|
|
76
|
+
const allFiles = [];
|
|
77
|
+
let pageNum = 1;
|
|
78
|
+
let total = 0;
|
|
79
|
+
do {
|
|
80
|
+
const sortParam = options?.sort ? `&_sort=${options.sort}` : '';
|
|
81
|
+
const url = `${SHARE_API}/detail?pr=ucpro&fr=pc&ver=2&pwd_id=${pwdId}&stoken=${encodeURIComponent(stoken)}&pdir_fid=${pdirFid}&force=0&_page=${pageNum}&_size=200&_fetch_total=1${sortParam}`;
|
|
82
|
+
const data = await fetchJson(page, url);
|
|
83
|
+
const files = unwrapApiData(data, 'Failed to get share list')?.list || [];
|
|
84
|
+
allFiles.push(...files);
|
|
85
|
+
total = data.metadata?._total || 0;
|
|
86
|
+
pageNum++;
|
|
87
|
+
} while (allFiles.length < total);
|
|
88
|
+
return allFiles;
|
|
89
|
+
}
|
|
90
|
+
export async function listMyDrive(page, pdirFid) {
|
|
91
|
+
const allFiles = [];
|
|
92
|
+
let pageNum = 1;
|
|
93
|
+
let total = 0;
|
|
94
|
+
do {
|
|
95
|
+
const url = `${DRIVE_API}/sort?pr=ucpro&fr=pc&pdir_fid=${pdirFid}&_page=${pageNum}&_size=200&_fetch_total=1&_sort=file_type:asc,file_name:asc`;
|
|
96
|
+
const data = await fetchJson(page, url);
|
|
97
|
+
const files = unwrapApiData(data, 'Failed to list drive')?.list || [];
|
|
98
|
+
allFiles.push(...files);
|
|
99
|
+
total = data.metadata?._total || 0;
|
|
100
|
+
pageNum++;
|
|
101
|
+
} while (allFiles.length < total);
|
|
102
|
+
return allFiles;
|
|
103
|
+
}
|
|
104
|
+
export async function findFolder(page, path) {
|
|
105
|
+
const parts = path.split('/').filter(Boolean);
|
|
106
|
+
let currentFid = '0';
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
const files = await listMyDrive(page, currentFid);
|
|
109
|
+
const existing = files.find(f => f.dir && f.file_name === part);
|
|
110
|
+
if (existing) {
|
|
111
|
+
currentFid = existing.fid;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new CommandExecutionError(`quark: Folder "${part}" not found in "${path}"`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return currentFid;
|
|
118
|
+
}
|
|
119
|
+
export function formatDate(ts) {
|
|
120
|
+
if (!ts)
|
|
121
|
+
return '';
|
|
122
|
+
const d = new Date(ts);
|
|
123
|
+
return d.toISOString().replace('T', ' ').slice(0, 19);
|
|
124
|
+
}
|
|
125
|
+
export function formatSize(bytes) {
|
|
126
|
+
if (bytes <= 0)
|
|
127
|
+
return '0 B';
|
|
128
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
129
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
130
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
|
131
|
+
}
|
|
132
|
+
export async function getTaskStatus(page, taskId) {
|
|
133
|
+
const url = `${TASK_API}?pr=ucpro&fr=pc&task_id=${taskId}&retry_index=0`;
|
|
134
|
+
return apiGet(page, url);
|
|
135
|
+
}
|
|
136
|
+
export async function pollTask(page, taskId, onDone, maxAttempts = 30, intervalMs = 500) {
|
|
137
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
138
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
139
|
+
const task = await getTaskStatus(page, taskId);
|
|
140
|
+
if (task?.status === 2) {
|
|
141
|
+
onDone?.(task);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { apiGet, apiPost, extractPwdId, getShareList, getToken } from './utils.js';
|
|
4
|
+
function makePage(evaluateImpl) {
|
|
5
|
+
return {
|
|
6
|
+
evaluate: vi.fn(evaluateImpl),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
describe('quark utils', () => {
|
|
10
|
+
it('extractPwdId accepts share URLs and raw ids', () => {
|
|
11
|
+
expect(extractPwdId('https://pan.quark.cn/s/abc123')).toBe('abc123');
|
|
12
|
+
expect(extractPwdId('abc123')).toBe('abc123');
|
|
13
|
+
});
|
|
14
|
+
it('maps JSON auth failures to AuthRequiredError', async () => {
|
|
15
|
+
const page = makePage(async () => ({
|
|
16
|
+
status: 401,
|
|
17
|
+
code: 401,
|
|
18
|
+
message: '未登录',
|
|
19
|
+
data: null,
|
|
20
|
+
}));
|
|
21
|
+
await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(AuthRequiredError);
|
|
22
|
+
});
|
|
23
|
+
it('maps non-JSON auth pages to AuthRequiredError', async () => {
|
|
24
|
+
const page = makePage(async () => {
|
|
25
|
+
const error = Object.assign(new Error('Non-JSON response: <html><title>登录</title></html>'), { status: 401 });
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
await expect(apiPost(page, 'https://drive-pc.quark.cn/test', {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
29
|
+
});
|
|
30
|
+
it('keeps generic API failures as CommandExecutionError', async () => {
|
|
31
|
+
const page = makePage(async () => ({
|
|
32
|
+
status: 500,
|
|
33
|
+
code: 500,
|
|
34
|
+
message: 'server busy',
|
|
35
|
+
data: null,
|
|
36
|
+
}));
|
|
37
|
+
await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(CommandExecutionError);
|
|
38
|
+
});
|
|
39
|
+
it('unwraps successful token responses', async () => {
|
|
40
|
+
const page = makePage(async () => ({
|
|
41
|
+
status: 200,
|
|
42
|
+
code: 0,
|
|
43
|
+
message: 'ok',
|
|
44
|
+
data: { stoken: 'token123' },
|
|
45
|
+
}));
|
|
46
|
+
await expect(getToken(page, 'abc123')).resolves.toBe('token123');
|
|
47
|
+
});
|
|
48
|
+
it('maps share-tree detail auth failures to AuthRequiredError', async () => {
|
|
49
|
+
const page = makePage(async () => ({
|
|
50
|
+
status: 401,
|
|
51
|
+
code: 401,
|
|
52
|
+
message: '请先登录',
|
|
53
|
+
data: null,
|
|
54
|
+
metadata: { _total: 0 },
|
|
55
|
+
}));
|
|
56
|
+
await expect(getShareList(page, 'abc123', 'token123')).rejects.toBeInstanceOf(AuthRequiredError);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -240,18 +240,13 @@ cli({
|
|
|
240
240
|
localImagePath = await downloadRemoteImage(kwargs['image-url']);
|
|
241
241
|
cleanupDir = path.dirname(localImagePath);
|
|
242
242
|
}
|
|
243
|
-
// Dedicated composer is more reliable
|
|
244
|
-
|
|
243
|
+
// Dedicated composer is more reliable than the inline tweet page reply box.
|
|
244
|
+
await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
|
|
245
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
245
246
|
if (localImagePath) {
|
|
246
|
-
await page.goto(buildReplyComposerUrl(kwargs.url));
|
|
247
|
-
await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
|
|
248
247
|
await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
249
248
|
await attachReplyImage(page, localImagePath);
|
|
250
249
|
}
|
|
251
|
-
else {
|
|
252
|
-
await page.goto(kwargs.url);
|
|
253
|
-
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
254
|
-
}
|
|
255
250
|
const result = await submitReply(page, kwargs.text);
|
|
256
251
|
if (result.ok) {
|
|
257
252
|
await page.wait(3); // Wait for network submission to complete
|
|
@@ -34,7 +34,7 @@ function createPageMock(evaluateResults, overrides = {}) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
describe('twitter reply command', () => {
|
|
37
|
-
it('
|
|
37
|
+
it('uses the dedicated reply composer for text-only replies too', async () => {
|
|
38
38
|
const cmd = getRegistry().get('twitter/reply');
|
|
39
39
|
expect(cmd?.func).toBeTypeOf('function');
|
|
40
40
|
const page = createPageMock([
|
|
@@ -44,8 +44,8 @@ describe('twitter reply command', () => {
|
|
|
44
44
|
url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
|
|
45
45
|
text: 'text-only reply',
|
|
46
46
|
});
|
|
47
|
-
expect(page.goto).toHaveBeenCalledWith('https://x.com/
|
|
48
|
-
expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="
|
|
47
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
|
|
48
|
+
expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
49
49
|
expect(result).toEqual([
|
|
50
50
|
{
|
|
51
51
|
status: 'success',
|
|
@@ -72,8 +72,8 @@ describe('twitter reply command', () => {
|
|
|
72
72
|
text: 'reply with image',
|
|
73
73
|
image: imagePath,
|
|
74
74
|
});
|
|
75
|
-
expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161');
|
|
76
|
-
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]' });
|
|
75
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
|
|
76
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
77
77
|
expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
|
|
78
78
|
expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
|
|
79
79
|
expect(result).toEqual([
|
|
@@ -37,9 +37,14 @@ cli({
|
|
|
37
37
|
const title = clean(document.querySelector('#detail-title, .title'))
|
|
38
38
|
const desc = clean(document.querySelector('#detail-desc, .desc, .note-text'))
|
|
39
39
|
const author = clean(document.querySelector('.username, .author-wrapper .name'))
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Scope to .interact-container — the post's main interaction bar.
|
|
41
|
+
// Without scoping, .like-wrapper / .chat-wrapper also match each
|
|
42
|
+
// comment's like/reply buttons in the comment section, and
|
|
43
|
+
// querySelector returns the FIRST match (a comment's count, not the
|
|
44
|
+
// post's). The post's true counts live inside .interact-container.
|
|
45
|
+
const likes = clean(document.querySelector('.interact-container .like-wrapper .count'))
|
|
46
|
+
const collects = clean(document.querySelector('.interact-container .collect-wrapper .count'))
|
|
47
|
+
const comments = clean(document.querySelector('.interact-container .chat-wrapper .count'))
|
|
43
48
|
|
|
44
49
|
// Try to extract tags/topics
|
|
45
50
|
const tags = []
|
|
@@ -170,6 +170,17 @@ describe('xiaohongshu note', () => {
|
|
|
170
170
|
expect(result.find((r) => r.field === 'collects').value).toBe('0');
|
|
171
171
|
expect(result.find((r) => r.field === 'comments').value).toBe('0');
|
|
172
172
|
});
|
|
173
|
+
it('scopes metric selectors to .interact-container to avoid matching comment like buttons', async () => {
|
|
174
|
+
const page = createPageMock({
|
|
175
|
+
loginWall: false, notFound: false,
|
|
176
|
+
title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
|
|
177
|
+
});
|
|
178
|
+
await command.func(page, { 'note-id': 'abc123' });
|
|
179
|
+
const evaluateScript = page.evaluate.mock.calls[0][0];
|
|
180
|
+
expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
|
|
181
|
+
expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
|
|
182
|
+
expect(evaluateScript).toContain('.interact-container .chat-wrapper .count');
|
|
183
|
+
});
|
|
173
184
|
it('omits tags row when no tags present', async () => {
|
|
174
185
|
const page = createPageMock({
|
|
175
186
|
loginWall: false, notFound: false,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|