@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,196 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './favorite.js';
|
|
4
|
+
describe('zhihu favorite', () => {
|
|
5
|
+
it('rejects missing collection selectors before opening the chooser', async () => {
|
|
6
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
7
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
8
|
+
const page = { goto: vi.fn(), evaluate: vi.fn() };
|
|
9
|
+
await expect(cmd.func(page, { target: 'article:1', execute: true })).rejects.toMatchObject({
|
|
10
|
+
code: 'INVALID_INPUT',
|
|
11
|
+
});
|
|
12
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
13
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
14
|
+
});
|
|
15
|
+
it('requires persisted read-back and preserves previously selected collections', async () => {
|
|
16
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
17
|
+
const page = {
|
|
18
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
evaluate: vi.fn()
|
|
20
|
+
.mockResolvedValueOnce({
|
|
21
|
+
chooserRows: [
|
|
22
|
+
{ id: 'fav-a', name: '已存在', selected: true },
|
|
23
|
+
{ id: 'fav-b', name: '默认收藏夹', selected: false },
|
|
24
|
+
],
|
|
25
|
+
targetRowId: 'fav-b',
|
|
26
|
+
targetRowName: '默认收藏夹',
|
|
27
|
+
})
|
|
28
|
+
.mockResolvedValueOnce({
|
|
29
|
+
persisted: true,
|
|
30
|
+
readbackSource: 'reopened_chooser',
|
|
31
|
+
selectedBefore: ['fav-a'],
|
|
32
|
+
selectedAfter: ['fav-a', 'fav-b'],
|
|
33
|
+
targetSelected: true,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
|
|
37
|
+
expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹', target: 'article:1' }),
|
|
38
|
+
]);
|
|
39
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('waitForChooserRows(false)');
|
|
40
|
+
expect(page.evaluate.mock.calls[1][0]).toContain("readbackSource");
|
|
41
|
+
});
|
|
42
|
+
it('requires persisted read-back before returning already_applied', async () => {
|
|
43
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
44
|
+
const page = {
|
|
45
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
evaluate: vi.fn()
|
|
47
|
+
.mockResolvedValueOnce({
|
|
48
|
+
chooserRows: [{ id: 'fav-a', name: '默认收藏夹', selected: true }],
|
|
49
|
+
targetRowId: 'fav-a',
|
|
50
|
+
targetRowName: '默认收藏夹',
|
|
51
|
+
})
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
persisted: true,
|
|
54
|
+
readbackSource: 'reopened_chooser',
|
|
55
|
+
selectedAfter: ['fav-a'],
|
|
56
|
+
targetSelected: true,
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
|
|
60
|
+
expect.objectContaining({ outcome: 'already_applied', collection_name: '默认收藏夹' }),
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
it('accepts --collection-id as the stable selector path', async () => {
|
|
64
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
65
|
+
const page = {
|
|
66
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
67
|
+
evaluate: vi.fn()
|
|
68
|
+
.mockResolvedValueOnce({
|
|
69
|
+
chooserRows: [
|
|
70
|
+
{ id: 'fav-a', name: '默认收藏夹', selected: false },
|
|
71
|
+
{ id: 'fav-b', name: '同名收藏夹', selected: false },
|
|
72
|
+
],
|
|
73
|
+
targetRowId: 'fav-b',
|
|
74
|
+
targetRowName: null,
|
|
75
|
+
})
|
|
76
|
+
.mockResolvedValueOnce({
|
|
77
|
+
persisted: true,
|
|
78
|
+
readbackSource: 'reopened_chooser',
|
|
79
|
+
selectedAfter: ['fav-b'],
|
|
80
|
+
targetSelected: true,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
await expect(cmd.func(page, { target: 'article:1', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
|
|
84
|
+
expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b' }),
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
it('rejects duplicate collection names before selecting any row', async () => {
|
|
88
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
89
|
+
const page = {
|
|
90
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
92
|
+
chooserRows: [
|
|
93
|
+
{ id: 'fav-a', name: '默认收藏夹', selected: false },
|
|
94
|
+
{ id: 'fav-b', name: '默认收藏夹', selected: false },
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
|
|
99
|
+
});
|
|
100
|
+
it('rejects optimistic chooser state that was not re-read from a reopened chooser', async () => {
|
|
101
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
102
|
+
const page = {
|
|
103
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
104
|
+
evaluate: vi.fn()
|
|
105
|
+
.mockResolvedValueOnce({
|
|
106
|
+
chooserRows: [
|
|
107
|
+
{ id: 'fav-a', name: '已存在', selected: true },
|
|
108
|
+
{ id: 'fav-b', name: '默认收藏夹', selected: false },
|
|
109
|
+
],
|
|
110
|
+
targetRowId: 'fav-b',
|
|
111
|
+
targetRowName: '默认收藏夹',
|
|
112
|
+
})
|
|
113
|
+
.mockResolvedValueOnce({
|
|
114
|
+
persisted: true,
|
|
115
|
+
readbackSource: 'same_modal',
|
|
116
|
+
selectedAfter: ['fav-a', 'fav-b'],
|
|
117
|
+
targetSelected: true,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'OUTCOME_UNKNOWN' });
|
|
121
|
+
});
|
|
122
|
+
it('matches unique collection names even when chooser rows include extra UI text', async () => {
|
|
123
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
124
|
+
const page = {
|
|
125
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
126
|
+
evaluate: vi.fn()
|
|
127
|
+
.mockResolvedValueOnce({
|
|
128
|
+
chooserRows: [
|
|
129
|
+
{ id: 'fav-b', name: '默认收藏夹 12 条内容', selected: false },
|
|
130
|
+
],
|
|
131
|
+
targetRowId: null,
|
|
132
|
+
targetRowName: '默认收藏夹',
|
|
133
|
+
})
|
|
134
|
+
.mockResolvedValueOnce({
|
|
135
|
+
persisted: true,
|
|
136
|
+
readbackSource: 'reopened_chooser',
|
|
137
|
+
selectedAfter: ['fav-b'],
|
|
138
|
+
targetSelected: true,
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
|
|
142
|
+
expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
|
|
143
|
+
]);
|
|
144
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('normalizeCollectionName');
|
|
145
|
+
});
|
|
146
|
+
it('normalizes id-less row keys during reopened chooser verification', async () => {
|
|
147
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
148
|
+
const page = {
|
|
149
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
150
|
+
evaluate: vi.fn()
|
|
151
|
+
.mockResolvedValueOnce({
|
|
152
|
+
chooserRows: [
|
|
153
|
+
{ id: '', name: '默认收藏夹 12 条内容', selected: false },
|
|
154
|
+
],
|
|
155
|
+
targetRowId: null,
|
|
156
|
+
targetRowName: '默认收藏夹',
|
|
157
|
+
})
|
|
158
|
+
.mockResolvedValueOnce({
|
|
159
|
+
persisted: true,
|
|
160
|
+
readbackSource: 'reopened_chooser',
|
|
161
|
+
selectedAfter: ['name:默认收藏夹'],
|
|
162
|
+
targetSelected: true,
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
|
|
166
|
+
expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
|
|
167
|
+
]);
|
|
168
|
+
expect(page.evaluate.mock.calls[1][0]).toContain("const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);");
|
|
169
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey)');
|
|
170
|
+
});
|
|
171
|
+
it('reuses data-attribute answer anchoring during reopened chooser verification', async () => {
|
|
172
|
+
const cmd = getRegistry().get('zhihu/favorite');
|
|
173
|
+
const page = {
|
|
174
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
175
|
+
evaluate: vi.fn()
|
|
176
|
+
.mockResolvedValueOnce({
|
|
177
|
+
chooserRows: [
|
|
178
|
+
{ id: 'fav-b', name: '默认收藏夹', selected: false },
|
|
179
|
+
],
|
|
180
|
+
targetRowId: 'fav-b',
|
|
181
|
+
targetRowName: null,
|
|
182
|
+
})
|
|
183
|
+
.mockResolvedValueOnce({
|
|
184
|
+
persisted: true,
|
|
185
|
+
readbackSource: 'reopened_chooser',
|
|
186
|
+
selectedAfter: ['fav-b'],
|
|
187
|
+
targetSelected: true,
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
|
|
191
|
+
expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b', target: 'answer:1:2' }),
|
|
192
|
+
]);
|
|
193
|
+
expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-answerid')");
|
|
194
|
+
expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-zop-question-answer')");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
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 } from './write-shared.js';
|
|
5
|
+
cli({
|
|
6
|
+
site: 'zhihu',
|
|
7
|
+
name: 'follow',
|
|
8
|
+
description: 'Follow a Zhihu user or question',
|
|
9
|
+
domain: 'www.zhihu.com',
|
|
10
|
+
strategy: Strategy.UI,
|
|
11
|
+
browser: true,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
14
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for zhihu follow');
|
|
20
|
+
requireExecute(kwargs);
|
|
21
|
+
const rawTarget = String(kwargs.target);
|
|
22
|
+
const target = assertAllowedKinds('follow', parseTarget(rawTarget));
|
|
23
|
+
await page.goto(target.url);
|
|
24
|
+
const result = await page.evaluate(`(async () => {
|
|
25
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
26
|
+
const mainRoot = document.querySelector('main') || document;
|
|
27
|
+
let followBtn = null;
|
|
28
|
+
|
|
29
|
+
if (targetKind === 'question') {
|
|
30
|
+
const questionRoots = Array.from(mainRoot.querySelectorAll('.QuestionHeader, .Question-main, [data-zop-question-id], [class*="QuestionHeader"]'));
|
|
31
|
+
const scopedRoots = questionRoots.length ? questionRoots : [mainRoot];
|
|
32
|
+
const candidates = Array.from(new Set(scopedRoots.flatMap((root) => Array.from(root.querySelectorAll('button, a'))))).filter((node) => {
|
|
33
|
+
const text = (node.textContent || '').trim();
|
|
34
|
+
const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
|
|
35
|
+
const inAnswerBlock = Boolean(node.closest('article, .AnswerItem, [data-zop-question-answer]'));
|
|
36
|
+
return /关注问题|已关注/.test(text) && !inAside && !inAnswerBlock;
|
|
37
|
+
});
|
|
38
|
+
if (candidates.length !== 1) return { state: 'ambiguous_question_follow' };
|
|
39
|
+
followBtn = candidates[0];
|
|
40
|
+
} else {
|
|
41
|
+
const candidates = Array.from(mainRoot.querySelectorAll('button, a')).filter((node) => {
|
|
42
|
+
const text = (node.textContent || '').trim();
|
|
43
|
+
const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
|
|
44
|
+
return /关注|已关注/.test(text) && !/邀请|收藏|评论/.test(text) && !inAside;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (candidates.length !== 1) return { state: 'ambiguous_user_follow' };
|
|
48
|
+
followBtn = candidates[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!followBtn) return { state: 'missing' };
|
|
52
|
+
if ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') {
|
|
53
|
+
return { state: 'already_following' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
followBtn.click();
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
58
|
+
|
|
59
|
+
return ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true')
|
|
60
|
+
? { state: 'followed' }
|
|
61
|
+
: { state: 'unknown' };
|
|
62
|
+
})()`);
|
|
63
|
+
if (result?.state === 'already_following') {
|
|
64
|
+
return buildResultRow(`Already followed ${target.kind}`, target.kind, rawTarget, 'already_applied');
|
|
65
|
+
}
|
|
66
|
+
if (result?.state === 'ambiguous_question_follow') {
|
|
67
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Question follow control was not uniquely anchored on the requested question page');
|
|
68
|
+
}
|
|
69
|
+
if (result?.state === 'ambiguous_user_follow') {
|
|
70
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'User follow control was not uniquely anchored on the requested profile page');
|
|
71
|
+
}
|
|
72
|
+
if (result?.state === 'missing') {
|
|
73
|
+
throw new CliError('ACTION_FAILED', 'Zhihu follow control was missing before any write was dispatched');
|
|
74
|
+
}
|
|
75
|
+
if (result?.state !== 'followed') {
|
|
76
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Zhihu follow click was dispatched, but the final state could not be verified safely');
|
|
77
|
+
}
|
|
78
|
+
return buildResultRow(`Followed ${target.kind}`, target.kind, rawTarget, 'applied');
|
|
79
|
+
},
|
|
80
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './follow.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './follow.js';
|
|
4
|
+
describe('zhihu follow', () => {
|
|
5
|
+
it('rejects missing --execute before any browser write path', async () => {
|
|
6
|
+
const cmd = getRegistry().get('zhihu/follow');
|
|
7
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
8
|
+
const page = { goto: vi.fn(), evaluate: vi.fn() };
|
|
9
|
+
await expect(cmd.func(page, { target: 'question:123' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
|
10
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
11
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
12
|
+
});
|
|
13
|
+
it('rejects user pages where the primary follow control is not uniquely anchored', async () => {
|
|
14
|
+
const cmd = getRegistry().get('zhihu/follow');
|
|
15
|
+
const page = {
|
|
16
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_user_follow' }),
|
|
18
|
+
};
|
|
19
|
+
await expect(cmd.func(page, { target: 'user:alice', execute: true })).rejects.toMatchObject({
|
|
20
|
+
code: 'ACTION_NOT_AVAILABLE',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('returns already_applied when already following', async () => {
|
|
24
|
+
const cmd = getRegistry().get('zhihu/follow');
|
|
25
|
+
const page = {
|
|
26
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'already_following' }),
|
|
28
|
+
};
|
|
29
|
+
await expect(cmd.func(page, { target: 'question:123', execute: true })).resolves.toEqual([
|
|
30
|
+
expect.objectContaining({ outcome: 'already_applied', target_type: 'question', target: 'question:123' }),
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
it('rejects question pages where the question follow control is not uniquely anchored', async () => {
|
|
34
|
+
const cmd = getRegistry().get('zhihu/follow');
|
|
35
|
+
const page = {
|
|
36
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_question_follow' }),
|
|
38
|
+
};
|
|
39
|
+
await expect(cmd.func(page, { target: 'question:123', execute: true })).rejects.toMatchObject({
|
|
40
|
+
code: 'ACTION_NOT_AVAILABLE',
|
|
41
|
+
});
|
|
42
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('QuestionHeader');
|
|
43
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('new Set(');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
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 } from './write-shared.js';
|
|
5
|
+
cli({
|
|
6
|
+
site: 'zhihu',
|
|
7
|
+
name: 'like',
|
|
8
|
+
description: 'Like a Zhihu answer or article',
|
|
9
|
+
domain: 'zhihu.com',
|
|
10
|
+
strategy: Strategy.UI,
|
|
11
|
+
browser: true,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
|
|
14
|
+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'outcome', 'message', 'target_type', 'target'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for zhihu like');
|
|
20
|
+
requireExecute(kwargs);
|
|
21
|
+
const rawTarget = String(kwargs.target);
|
|
22
|
+
const target = assertAllowedKinds('like', parseTarget(rawTarget));
|
|
23
|
+
await page.goto(target.url);
|
|
24
|
+
const result = await page.evaluate(`(async () => {
|
|
25
|
+
const targetKind = ${JSON.stringify(target.kind)};
|
|
26
|
+
const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
|
|
27
|
+
const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
|
|
28
|
+
|
|
29
|
+
let btn = null;
|
|
30
|
+
if (targetKind === 'answer') {
|
|
31
|
+
const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
|
|
32
|
+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
33
|
+
if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
|
|
34
|
+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
35
|
+
const href = link.getAttribute('href') || '';
|
|
36
|
+
return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
if (!block) return { state: 'wrong_answer' };
|
|
40
|
+
const candidates = Array.from(block?.querySelectorAll('button') || []).filter((node) => {
|
|
41
|
+
const text = (node.textContent || '').trim();
|
|
42
|
+
const inCommentItem = Boolean(node.closest('[data-comment-id], .CommentItem'));
|
|
43
|
+
return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem;
|
|
44
|
+
});
|
|
45
|
+
if (candidates.length !== 1) return { state: 'ambiguous_answer_like' };
|
|
46
|
+
btn = candidates[0];
|
|
47
|
+
} else {
|
|
48
|
+
const articleRoot =
|
|
49
|
+
document.querySelector('article')
|
|
50
|
+
|| document.querySelector('.Post-Main')
|
|
51
|
+
|| document.querySelector('[itemprop="articleBody"]')
|
|
52
|
+
|| document;
|
|
53
|
+
const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => {
|
|
54
|
+
const text = (node.textContent || '').trim();
|
|
55
|
+
return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed');
|
|
56
|
+
});
|
|
57
|
+
if (candidates.length !== 1) return { state: 'ambiguous_article_like' };
|
|
58
|
+
btn = candidates[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!btn) return { state: 'missing' };
|
|
62
|
+
if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' };
|
|
63
|
+
|
|
64
|
+
btn.click();
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
66
|
+
|
|
67
|
+
return btn.getAttribute('aria-pressed') === 'true'
|
|
68
|
+
? { state: 'liked' }
|
|
69
|
+
: { state: 'unknown' };
|
|
70
|
+
})()`);
|
|
71
|
+
if (result?.state === 'wrong_answer') {
|
|
72
|
+
throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
|
|
73
|
+
}
|
|
74
|
+
if (result?.state === 'already_liked') {
|
|
75
|
+
return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied');
|
|
76
|
+
}
|
|
77
|
+
if (result?.state === 'ambiguous_answer_like') {
|
|
78
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer');
|
|
79
|
+
}
|
|
80
|
+
if (result?.state === 'ambiguous_article_like') {
|
|
81
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target');
|
|
82
|
+
}
|
|
83
|
+
if (result?.state === 'missing') {
|
|
84
|
+
throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched');
|
|
85
|
+
}
|
|
86
|
+
if (result?.state !== 'liked') {
|
|
87
|
+
throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely');
|
|
88
|
+
}
|
|
89
|
+
return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied');
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './like.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './like.js';
|
|
4
|
+
describe('zhihu like', () => {
|
|
5
|
+
it('rejects article pages where the like control is not uniquely anchored', async () => {
|
|
6
|
+
const cmd = getRegistry().get('zhihu/like');
|
|
7
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
8
|
+
const page = {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_article_like' }),
|
|
11
|
+
};
|
|
12
|
+
await expect(cmd.func(page, { target: 'article:9', execute: true })).rejects.toMatchObject({
|
|
13
|
+
code: 'ACTION_NOT_AVAILABLE',
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
it('returns already_applied for an already-liked article target', async () => {
|
|
17
|
+
const cmd = getRegistry().get('zhihu/like');
|
|
18
|
+
const page = {
|
|
19
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'already_liked' }),
|
|
21
|
+
};
|
|
22
|
+
await expect(cmd.func(page, { target: 'article:9', execute: true })).resolves.toEqual([
|
|
23
|
+
expect.objectContaining({ outcome: 'already_applied', target_type: 'article', target: 'article:9' }),
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
it('anchors to the requested answer block before clicking like', async () => {
|
|
27
|
+
const cmd = getRegistry().get('zhihu/like');
|
|
28
|
+
const page = {
|
|
29
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'liked' }),
|
|
31
|
+
};
|
|
32
|
+
await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).resolves.toEqual([
|
|
33
|
+
expect.objectContaining({ outcome: 'applied', target_type: 'answer', target: 'answer:123:456' }),
|
|
34
|
+
]);
|
|
35
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/question/123/answer/456');
|
|
36
|
+
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('targetQuestionId');
|
|
38
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('"123"');
|
|
39
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('"456"');
|
|
40
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-answerid')");
|
|
41
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-zop-question-answer')");
|
|
42
|
+
});
|
|
43
|
+
it('rejects answer targets when the answer-level like control is not unique', async () => {
|
|
44
|
+
const cmd = getRegistry().get('zhihu/like');
|
|
45
|
+
const page = {
|
|
46
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_answer_like' }),
|
|
48
|
+
};
|
|
49
|
+
await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
|
|
50
|
+
code: 'ACTION_NOT_AVAILABLE',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it('maps missing answer blocks to TARGET_NOT_FOUND', async () => {
|
|
54
|
+
const cmd = getRegistry().get('zhihu/like');
|
|
55
|
+
const page = {
|
|
56
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
evaluate: vi.fn().mockResolvedValue({ state: 'wrong_answer' }),
|
|
58
|
+
};
|
|
59
|
+
await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
|
|
60
|
+
code: 'TARGET_NOT_FOUND',
|
|
61
|
+
});
|
|
62
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("if (!block) return { state: 'wrong_answer' }");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ZhihuTarget = {
|
|
2
|
+
kind: 'user';
|
|
3
|
+
slug: string;
|
|
4
|
+
url: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'question';
|
|
7
|
+
id: string;
|
|
8
|
+
url: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'answer';
|
|
11
|
+
questionId: string;
|
|
12
|
+
id: string;
|
|
13
|
+
url: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'article';
|
|
16
|
+
id: string;
|
|
17
|
+
url: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function parseTarget(input: string): ZhihuTarget;
|
|
20
|
+
export declare function assertAllowedKinds(command: string, target: ZhihuTarget): ZhihuTarget;
|
|
21
|
+
export declare const __test__: {
|
|
22
|
+
parseTarget: typeof parseTarget;
|
|
23
|
+
assertAllowedKinds: typeof assertAllowedKinds;
|
|
24
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
2
|
+
const USER_RE = /^user:([A-Za-z0-9_-]+)$/;
|
|
3
|
+
const QUESTION_RE = /^question:(\d+)$/;
|
|
4
|
+
const ANSWER_RE = /^answer:(\d+):(\d+)$/;
|
|
5
|
+
const ARTICLE_RE = /^article:(\d+)$/;
|
|
6
|
+
const USER_PATH_RE = /^\/people\/([A-Za-z0-9_-]+)\/?$/;
|
|
7
|
+
const QUESTION_PATH_RE = /^\/question\/(\d+)\/?$/;
|
|
8
|
+
const ANSWER_PATH_RE = /^\/question\/(\d+)\/answer\/(\d+)\/?$/;
|
|
9
|
+
const ARTICLE_PATH_RE = /^\/p\/(\d+)\/?$/;
|
|
10
|
+
const EMPTY_AUTHORITY_RE = /^https:\/\/(?::)?@/i;
|
|
11
|
+
function isAllowedZhihuUrl(url) {
|
|
12
|
+
return url.protocol === 'https:' && url.username === '' && url.password === '' && url.port === '';
|
|
13
|
+
}
|
|
14
|
+
export function parseTarget(input) {
|
|
15
|
+
const value = String(input).trim();
|
|
16
|
+
if (EMPTY_AUTHORITY_RE.test(value)) {
|
|
17
|
+
throw new CliError('INVALID_INPUT', 'Zhihu write commands require a normal HTTPS Zhihu URL without malformed authority', 'Example: https://www.zhihu.com/question/123456');
|
|
18
|
+
}
|
|
19
|
+
if (value.startsWith('answer:') && !ANSWER_RE.test(value)) {
|
|
20
|
+
throw new CliError('INVALID_INPUT', 'Invalid answer target, expected answer:<questionId>:<answerId>', 'Example: opencli zhihu like answer:123:456 --execute');
|
|
21
|
+
}
|
|
22
|
+
let match = value.match(USER_RE);
|
|
23
|
+
if (match) {
|
|
24
|
+
return { kind: 'user', slug: match[1], url: `https://www.zhihu.com/people/${match[1]}` };
|
|
25
|
+
}
|
|
26
|
+
match = value.match(QUESTION_RE);
|
|
27
|
+
if (match) {
|
|
28
|
+
return { kind: 'question', id: match[1], url: `https://www.zhihu.com/question/${match[1]}` };
|
|
29
|
+
}
|
|
30
|
+
match = value.match(ANSWER_RE);
|
|
31
|
+
if (match) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'answer',
|
|
34
|
+
questionId: match[1],
|
|
35
|
+
id: match[2],
|
|
36
|
+
url: `https://www.zhihu.com/question/${match[1]}/answer/${match[2]}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
match = value.match(ARTICLE_RE);
|
|
40
|
+
if (match) {
|
|
41
|
+
return { kind: 'article', id: match[1], url: `https://zhuanlan.zhihu.com/p/${match[1]}` };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(value);
|
|
45
|
+
if (!isAllowedZhihuUrl(url)) {
|
|
46
|
+
throw new Error('unsupported zhihu url variant');
|
|
47
|
+
}
|
|
48
|
+
if (url.hostname === 'www.zhihu.com') {
|
|
49
|
+
const userMatch = url.pathname.match(USER_PATH_RE);
|
|
50
|
+
if (userMatch) {
|
|
51
|
+
const slug = userMatch[1];
|
|
52
|
+
return { kind: 'user', slug, url: `https://www.zhihu.com/people/${slug}` };
|
|
53
|
+
}
|
|
54
|
+
const questionMatch = url.pathname.match(QUESTION_PATH_RE);
|
|
55
|
+
if (questionMatch) {
|
|
56
|
+
return { kind: 'question', id: questionMatch[1], url: `https://www.zhihu.com/question/${questionMatch[1]}` };
|
|
57
|
+
}
|
|
58
|
+
const answerMatch = url.pathname.match(ANSWER_PATH_RE);
|
|
59
|
+
if (answerMatch) {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'answer',
|
|
62
|
+
questionId: answerMatch[1],
|
|
63
|
+
id: answerMatch[2],
|
|
64
|
+
url: `https://www.zhihu.com/question/${answerMatch[1]}/answer/${answerMatch[2]}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (url.hostname === 'zhuanlan.zhihu.com') {
|
|
69
|
+
const articleMatch = url.pathname.match(ARTICLE_PATH_RE);
|
|
70
|
+
if (articleMatch) {
|
|
71
|
+
return { kind: 'article', id: articleMatch[1], url: `https://zhuanlan.zhihu.com/p/${articleMatch[1]}` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
throw new CliError('INVALID_INPUT', 'Zhihu write commands require a Zhihu URL or typed target like question:123 or answer:123:456', 'Example: opencli zhihu like answer:123:456 --execute');
|
|
77
|
+
}
|
|
78
|
+
export function assertAllowedKinds(command, target) {
|
|
79
|
+
const allowed = {
|
|
80
|
+
follow: ['user', 'question'],
|
|
81
|
+
like: ['answer', 'article'],
|
|
82
|
+
favorite: ['answer', 'article'],
|
|
83
|
+
comment: ['answer', 'article'],
|
|
84
|
+
answer: ['question'],
|
|
85
|
+
};
|
|
86
|
+
if (!allowed[command]?.includes(target.kind)) {
|
|
87
|
+
throw new CliError('UNSUPPORTED_TARGET', `zhihu ${command} does not support ${target.kind} targets`);
|
|
88
|
+
}
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
export const __test__ = { parseTarget, assertAllowedKinds };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|