@jackwener/opencli 1.7.7 → 1.7.8
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/cli-manifest.json +144 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +32 -6
- package/clis/deepseek/ask.test.js +104 -3
- package/clis/deepseek/utils.js +5 -6
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
const NON_TITLE_LINES = new Set([
|
|
4
|
+
'展现', '阅读', '点赞', '评论',
|
|
5
|
+
'查看数据', '查看评论', '修改', '更多', '首发',
|
|
6
|
+
'已发布', '定时发布', '定时发布中', '由文章生成', '审核中',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function parseToutiaoArticlesText(text) {
|
|
10
|
+
const lines = String(text || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
11
|
+
const results = [];
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < lines.length; i++) {
|
|
14
|
+
const line = lines[i];
|
|
15
|
+
if (!/^\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(line)) continue;
|
|
16
|
+
|
|
17
|
+
const date = line;
|
|
18
|
+
let title = '';
|
|
19
|
+
let status = '';
|
|
20
|
+
let stats = null;
|
|
21
|
+
|
|
22
|
+
for (let back = 3; back >= 1; back--) {
|
|
23
|
+
const prev = lines[i - back] || '';
|
|
24
|
+
if (!prev || prev.length >= 100 || /^\d+$/.test(prev) || NON_TITLE_LINES.has(prev)) continue;
|
|
25
|
+
title = prev;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (let fwd = 1; fwd < 8; fwd++) {
|
|
30
|
+
const fwdLine = lines[i + fwd] || '';
|
|
31
|
+
if (fwdLine === '已发布' || fwdLine === '定时发布中' || fwdLine === '审核中' || fwdLine === '由文章生成') {
|
|
32
|
+
status = fwdLine;
|
|
33
|
+
}
|
|
34
|
+
if (fwdLine.includes('展现') && fwdLine.includes('阅读')) {
|
|
35
|
+
const match = fwdLine.match(/展现\s*([\d,]+)\s*阅读\s*([\d,]+)\s*点赞\s*([\d,]+)\s*评论\s*([\d,]*)/);
|
|
36
|
+
if (match) {
|
|
37
|
+
stats = {
|
|
38
|
+
'展现': match[1],
|
|
39
|
+
'阅读': match[2],
|
|
40
|
+
'点赞': match[3],
|
|
41
|
+
'评论': match[4] || '0',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (title && stats) results.push({ title, date, status, ...stats });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cli({
|
|
54
|
+
site: 'toutiao',
|
|
55
|
+
name: 'articles',
|
|
56
|
+
description: '获取头条号创作者后台文章列表及数据',
|
|
57
|
+
domain: 'mp.toutiao.com',
|
|
58
|
+
args: [
|
|
59
|
+
{ name: 'page', type: 'int', default: 1, help: '页码 (1-4)' },
|
|
60
|
+
],
|
|
61
|
+
columns: ['title', 'date', 'status', '展现', '阅读', '点赞', '评论'],
|
|
62
|
+
pipeline: [
|
|
63
|
+
{ navigate: 'https://mp.toutiao.com/profile_v4/manage/content/all?page=${{ args.page }}' },
|
|
64
|
+
{ wait: 'networkidle' },
|
|
65
|
+
{ wait: 3000 },
|
|
66
|
+
{
|
|
67
|
+
evaluate: `
|
|
68
|
+
(async () => {
|
|
69
|
+
// Wait for content to load
|
|
70
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
71
|
+
const parse = ${parseToutiaoArticlesText.toString()};
|
|
72
|
+
return parse(document.body.innerText || '');
|
|
73
|
+
})()
|
|
74
|
+
`
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const __test__ = {
|
|
80
|
+
parseToutiaoArticlesText,
|
|
81
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './articles.js';
|
|
3
|
+
|
|
4
|
+
describe('toutiao articles parser', () => {
|
|
5
|
+
it('keeps short chinese titles instead of silently dropping the row', () => {
|
|
6
|
+
const text = [
|
|
7
|
+
'短标题',
|
|
8
|
+
'04-20 20:30',
|
|
9
|
+
'已发布',
|
|
10
|
+
'展现 8 阅读 0 点赞 0 评论 0',
|
|
11
|
+
].join('\n');
|
|
12
|
+
|
|
13
|
+
expect(__test__.parseToutiaoArticlesText(text)).toEqual([{
|
|
14
|
+
title: '短标题',
|
|
15
|
+
date: '04-20 20:30',
|
|
16
|
+
status: '已发布',
|
|
17
|
+
'展现': '8',
|
|
18
|
+
'阅读': '0',
|
|
19
|
+
'点赞': '0',
|
|
20
|
+
'评论': '0',
|
|
21
|
+
}]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
5
|
+
const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
|
|
6
|
+
|
|
7
|
+
async function getToken(page) {
|
|
8
|
+
return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function navigateToEditor(page) {
|
|
12
|
+
await page.goto(WEIXIN_HOME);
|
|
13
|
+
await page.wait(3);
|
|
14
|
+
const token = await getToken(page);
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw new CommandExecutionError('Could not extract session token. Please log in to mp.weixin.qq.com');
|
|
17
|
+
}
|
|
18
|
+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`);
|
|
19
|
+
await page.wait(4);
|
|
20
|
+
const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
|
|
21
|
+
if (!hasTitle) {
|
|
22
|
+
throw new CommandExecutionError('Article editor did not load. Session may have expired');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function fillField(page, selector, value) {
|
|
27
|
+
return page.evaluate(`(() => {
|
|
28
|
+
var el = document.querySelector('${selector}');
|
|
29
|
+
if (!el) return { ok: false, reason: 'not found: ${selector}' };
|
|
30
|
+
el.focus();
|
|
31
|
+
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
32
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
33
|
+
if (setter && setter.set) setter.set.call(el, ${JSON.stringify(value)});
|
|
34
|
+
else el.value = ${JSON.stringify(value)};
|
|
35
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(value)} }));
|
|
36
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
37
|
+
el.blur();
|
|
38
|
+
return { ok: true };
|
|
39
|
+
})()`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fillContent(page, text) {
|
|
43
|
+
return page.evaluate(`(() => {
|
|
44
|
+
var editors = document.querySelectorAll('div[contenteditable="true"]');
|
|
45
|
+
var editor = editors[editors.length - 1];
|
|
46
|
+
if (!editor) return { ok: false, reason: 'content editor not found' };
|
|
47
|
+
editor.focus();
|
|
48
|
+
if (editor.querySelector('[contenteditable="false"]')) editor.innerHTML = '';
|
|
49
|
+
document.execCommand('selectAll', false, null);
|
|
50
|
+
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
51
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
52
|
+
return { ok: true };
|
|
53
|
+
})()`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function uploadContentImage(page, imagePath) {
|
|
57
|
+
const fs = await import('node:fs');
|
|
58
|
+
const path = await import('node:path');
|
|
59
|
+
const absPath = path.default.resolve(imagePath);
|
|
60
|
+
if (!fs.default.existsSync(absPath)) {
|
|
61
|
+
throw new CommandExecutionError(`Image not found: ${absPath}`);
|
|
62
|
+
}
|
|
63
|
+
if (!page.setFileInput) {
|
|
64
|
+
throw new CommandExecutionError('Image upload requires Browser Bridge with CDP support');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await page.evaluate(`(() => {
|
|
68
|
+
var li = document.querySelector('#js_editor_insertimage');
|
|
69
|
+
if (li) li.click();
|
|
70
|
+
})()`);
|
|
71
|
+
await page.wait(1);
|
|
72
|
+
await page.evaluate(`(() => {
|
|
73
|
+
var items = document.querySelectorAll('.js_img_dropdown_menu .tpl_dropdown_menu_item');
|
|
74
|
+
if (items[0]) items[0].click();
|
|
75
|
+
})()`);
|
|
76
|
+
await page.wait(1);
|
|
77
|
+
|
|
78
|
+
await page.setFileInput([absPath], 'input[type="file"][name="file"]');
|
|
79
|
+
await page.wait(8);
|
|
80
|
+
|
|
81
|
+
const cdnCount = await page.evaluate(`(() => {
|
|
82
|
+
var editor = document.querySelector('#ueditor_0');
|
|
83
|
+
return editor ? editor.querySelectorAll('img[src*="mmbiz"]').length : 0;
|
|
84
|
+
})()`);
|
|
85
|
+
if (cdnCount === 0) {
|
|
86
|
+
throw new CommandExecutionError('Image did not upload to WeChat CDN');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function selectCoverFromContent(page) {
|
|
91
|
+
await page.evaluate('document.querySelector("#js_cover_description_area")?.scrollIntoView()');
|
|
92
|
+
await page.wait(1);
|
|
93
|
+
|
|
94
|
+
await page.evaluate('document.querySelector(".js_cover_btn_area")?.click()');
|
|
95
|
+
await page.wait(1);
|
|
96
|
+
|
|
97
|
+
await page.evaluate(`(() => {
|
|
98
|
+
var links = document.querySelectorAll('a.pop-opr__button');
|
|
99
|
+
for (var i = 0; i < links.length; i++) {
|
|
100
|
+
if (links[i].textContent.trim() === '从正文选择') { links[i].click(); return; }
|
|
101
|
+
}
|
|
102
|
+
})()`);
|
|
103
|
+
await page.wait(2);
|
|
104
|
+
|
|
105
|
+
await page.evaluate(`(() => {
|
|
106
|
+
var img = document.querySelector('.weui-desktop-dialog_img-picker .appmsg_content_img');
|
|
107
|
+
if (img) img.click();
|
|
108
|
+
})()`);
|
|
109
|
+
await page.wait(1);
|
|
110
|
+
|
|
111
|
+
await page.evaluate(`(() => {
|
|
112
|
+
var btns = document.querySelectorAll('.weui-desktop-dialog_img-picker button');
|
|
113
|
+
for (var i = 0; i < btns.length; i++) {
|
|
114
|
+
if (btns[i].textContent.trim() === '下一步' && !btns[i].disabled) { btns[i].click(); return; }
|
|
115
|
+
}
|
|
116
|
+
})()`);
|
|
117
|
+
|
|
118
|
+
// Crop dialog image rendering can be slow
|
|
119
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
120
|
+
await page.wait(2);
|
|
121
|
+
const ready = await page.evaluate(`(() => {
|
|
122
|
+
var btns = document.querySelectorAll('button');
|
|
123
|
+
for (var i = 0; i < btns.length; i++) {
|
|
124
|
+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
})()`);
|
|
128
|
+
if (ready) break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await page.evaluate(`(() => {
|
|
132
|
+
var btns = document.querySelectorAll('button');
|
|
133
|
+
for (var i = 0; i < btns.length; i++) {
|
|
134
|
+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
|
|
135
|
+
}
|
|
136
|
+
})()`);
|
|
137
|
+
await page.wait(2);
|
|
138
|
+
const hasCover = await page.evaluate(`(() => {
|
|
139
|
+
var area = document.querySelector('#js_cover_area');
|
|
140
|
+
if (!area) return false;
|
|
141
|
+
var found = false;
|
|
142
|
+
area.querySelectorAll('*').forEach(function(el) {
|
|
143
|
+
var bg = window.getComputedStyle(el).backgroundImage;
|
|
144
|
+
if (bg && bg.includes('mmbiz')) found = true;
|
|
145
|
+
});
|
|
146
|
+
return found;
|
|
147
|
+
})()`);
|
|
148
|
+
return hasCover;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function clickSaveDraft(page) {
|
|
152
|
+
const result = await page.evaluate(`(() => {
|
|
153
|
+
var btns = document.querySelectorAll('span, button, a');
|
|
154
|
+
for (var i = 0; i < btns.length; i++) {
|
|
155
|
+
if ((btns[i].textContent || '').trim() === '保存为草稿') { btns[i].click(); return { ok: true }; }
|
|
156
|
+
}
|
|
157
|
+
return { ok: false };
|
|
158
|
+
})()`);
|
|
159
|
+
if (!result?.ok) throw new CommandExecutionError('Save draft button not found');
|
|
160
|
+
|
|
161
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
162
|
+
await page.wait(2);
|
|
163
|
+
const saved = await page.evaluate(`(() => {
|
|
164
|
+
var el = document.querySelector('#js_save_success');
|
|
165
|
+
if (el && window.getComputedStyle(el).display !== 'none') return true;
|
|
166
|
+
return document.body.innerText.includes('已保存');
|
|
167
|
+
})()`);
|
|
168
|
+
if (saved) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const createDraftCommand = cli({
|
|
174
|
+
site: 'weixin',
|
|
175
|
+
name: 'create-draft',
|
|
176
|
+
description: '创建微信公众号图文草稿',
|
|
177
|
+
domain: WEIXIN_DOMAIN,
|
|
178
|
+
strategy: Strategy.COOKIE,
|
|
179
|
+
browser: true,
|
|
180
|
+
navigateBefore: false,
|
|
181
|
+
timeoutSeconds: 180,
|
|
182
|
+
args: [
|
|
183
|
+
{ name: 'title', required: true, help: '文章标题 (最长64字)' },
|
|
184
|
+
{ name: 'content', required: true, positional: true, help: '文章正文' },
|
|
185
|
+
{ name: 'author', help: '作者名 (最长8字)' },
|
|
186
|
+
{ name: 'cover-image', help: '封面图片路径 (会先上传到正文再设为封面)' },
|
|
187
|
+
{ name: 'summary', help: '文章摘要' },
|
|
188
|
+
],
|
|
189
|
+
columns: ['status', 'detail'],
|
|
190
|
+
|
|
191
|
+
func: async (page, kwargs) => {
|
|
192
|
+
await navigateToEditor(page);
|
|
193
|
+
|
|
194
|
+
const titleResult = await fillField(page, 'textarea#title', kwargs.title);
|
|
195
|
+
if (!titleResult?.ok) throw new CommandExecutionError('Failed to fill title');
|
|
196
|
+
|
|
197
|
+
if (kwargs.author) {
|
|
198
|
+
const authorResult = await fillField(page, 'input#author', kwargs.author);
|
|
199
|
+
if (!authorResult?.ok) throw new CommandExecutionError('Failed to fill author');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const contentResult = await fillContent(page, kwargs.content);
|
|
203
|
+
if (!contentResult?.ok) throw new CommandExecutionError('Failed to fill content');
|
|
204
|
+
|
|
205
|
+
if (kwargs['cover-image']) {
|
|
206
|
+
await uploadContentImage(page, kwargs['cover-image']);
|
|
207
|
+
const coverSet = await selectCoverFromContent(page);
|
|
208
|
+
if (!coverSet) {
|
|
209
|
+
// Non-fatal: draft can be saved without cover
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (kwargs.summary) {
|
|
214
|
+
await fillField(page, 'textarea#js_description', kwargs.summary);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await page.wait(1);
|
|
218
|
+
const success = await clickSaveDraft(page);
|
|
219
|
+
|
|
220
|
+
return [{
|
|
221
|
+
status: success ? 'draft saved' : 'save attempted, check browser to confirm',
|
|
222
|
+
detail: `"${kwargs.title}"${kwargs.author ? ` by ${kwargs.author}` : ''}${kwargs['cover-image'] ? ' (with cover)' : ''}`,
|
|
223
|
+
}];
|
|
224
|
+
},
|
|
225
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
5
|
+
|
|
6
|
+
export const draftsCommand = cli({
|
|
7
|
+
site: 'weixin',
|
|
8
|
+
name: 'drafts',
|
|
9
|
+
description: '列出微信公众号草稿箱',
|
|
10
|
+
domain: WEIXIN_DOMAIN,
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
timeoutSeconds: 60,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'limit', type: 'int', default: 10, help: '最多显示条数' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['Index', 'Title', 'Time'],
|
|
19
|
+
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
await page.goto('https://mp.weixin.qq.com/');
|
|
22
|
+
await page.wait(3);
|
|
23
|
+
const token = await page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new AuthRequiredError(WEIXIN_DOMAIN, '微信公众号草稿箱需要已登录的 mp.weixin.qq.com 会话');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?begin=0&count=${kwargs.limit}&type=77&action=list_card&token=${token}&lang=zh_CN`);
|
|
29
|
+
await page.wait(4);
|
|
30
|
+
|
|
31
|
+
const drafts = await page.evaluate(`(() => {
|
|
32
|
+
var results = [];
|
|
33
|
+
var idx = 0;
|
|
34
|
+
|
|
35
|
+
var cards = document.querySelectorAll('.weui-desktop-card');
|
|
36
|
+
for (var i = 0; i < cards.length; i++) {
|
|
37
|
+
if (cards[i].className.includes('card_new')) continue;
|
|
38
|
+
var titleEl = cards[i].querySelector('[class*=title]');
|
|
39
|
+
var timeEl = cards[i].querySelector('[class*=tips]');
|
|
40
|
+
var title = titleEl ? titleEl.textContent.trim() : '';
|
|
41
|
+
var time = timeEl ? timeEl.textContent.trim().replace(/\\s+/g, ' ') : '';
|
|
42
|
+
if (title) results.push({ Index: ++idx, Title: title, Time: time });
|
|
43
|
+
}
|
|
44
|
+
if (results.length > 0) return results;
|
|
45
|
+
|
|
46
|
+
var rows = document.querySelectorAll('tr, [class*=appmsg_item], [class*=list_item]');
|
|
47
|
+
rows.forEach(function(row) {
|
|
48
|
+
var titleEl = row.querySelector('[class*=title] a, [class*=title], h4');
|
|
49
|
+
var timeEl = row.querySelector('[class*=time], td:nth-child(2)');
|
|
50
|
+
var title = titleEl ? titleEl.textContent.trim() : '';
|
|
51
|
+
var time = timeEl ? timeEl.textContent.trim() : '';
|
|
52
|
+
if (title && title !== '内容' && title.length < 80) {
|
|
53
|
+
results.push({ Index: ++idx, Title: title, Time: time });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return results;
|
|
57
|
+
})()`);
|
|
58
|
+
|
|
59
|
+
if (!drafts || drafts.length === 0) {
|
|
60
|
+
throw new EmptyResultError('weixin drafts', 'No structured drafts found in the current Weixin Official Account backend');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return drafts.slice(0, kwargs.limit);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './create-draft.js';
|
|
5
|
+
import './drafts.js';
|
|
6
|
+
|
|
7
|
+
function createPageMock(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: overrides.evaluate ?? vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('weixin command registration', () => {
|
|
17
|
+
it('registers create-draft and drafts commands', () => {
|
|
18
|
+
const registry = getRegistry();
|
|
19
|
+
const values = [...registry.values()];
|
|
20
|
+
expect(values.find(c => c.site === 'weixin' && c.name === 'create-draft')).toBeDefined();
|
|
21
|
+
expect(values.find(c => c.site === 'weixin' && c.name === 'drafts')).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('weixin drafts command', () => {
|
|
26
|
+
it('throws AuthRequiredError when no session token is available', async () => {
|
|
27
|
+
const command = getRegistry().get('weixin/drafts');
|
|
28
|
+
const page = createPageMock({
|
|
29
|
+
evaluate: vi.fn().mockResolvedValueOnce(undefined),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('fails instead of scraping arbitrary body text when structured selectors miss', async () => {
|
|
36
|
+
const command = getRegistry().get('weixin/drafts');
|
|
37
|
+
const evaluate = vi.fn()
|
|
38
|
+
.mockResolvedValueOnce('123456')
|
|
39
|
+
.mockImplementationOnce(async (script) => {
|
|
40
|
+
expect(script).not.toContain('document.body.innerText');
|
|
41
|
+
return [];
|
|
42
|
+
});
|
|
43
|
+
const page = createPageMock({ evaluate });
|
|
44
|
+
|
|
45
|
+
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns structured drafts and respects the requested limit', async () => {
|
|
49
|
+
const command = getRegistry().get('weixin/drafts');
|
|
50
|
+
const page = createPageMock({
|
|
51
|
+
evaluate: vi.fn()
|
|
52
|
+
.mockResolvedValueOnce('123456')
|
|
53
|
+
.mockResolvedValueOnce([
|
|
54
|
+
{ Index: 1, Title: '第一篇草稿', Time: '2026-04-24 10:00' },
|
|
55
|
+
{ Index: 2, Title: '第二篇草稿', Time: '2026-04-24 11:00' },
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const result = await command.func(page, { limit: 1 });
|
|
60
|
+
|
|
61
|
+
expect(result).toEqual([
|
|
62
|
+
{ Index: 1, Title: '第一篇草稿', Time: '2026-04-24 10:00' },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -71,6 +71,18 @@ export function registerCommandToProgram(siteCmd, cmd) {
|
|
|
71
71
|
if (v !== undefined)
|
|
72
72
|
rawKwargs[arg.name] = v;
|
|
73
73
|
}
|
|
74
|
+
const optionSources = {};
|
|
75
|
+
for (const arg of cmd.args) {
|
|
76
|
+
if (arg.positional)
|
|
77
|
+
continue;
|
|
78
|
+
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
|
|
79
|
+
const source = subCmd.getOptionValueSource(camelName) ?? subCmd.getOptionValueSource(arg.name);
|
|
80
|
+
if (source === 'cli')
|
|
81
|
+
optionSources[arg.name] = source;
|
|
82
|
+
}
|
|
83
|
+
if (Object.keys(optionSources).length > 0) {
|
|
84
|
+
rawKwargs.__opencliOptionSources = optionSources;
|
|
85
|
+
}
|
|
74
86
|
const kwargs = prepareCommandArgs(cmd, rawKwargs);
|
|
75
87
|
const verbose = optionsRecord.verbose === true;
|
|
76
88
|
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
|
|
@@ -56,6 +56,17 @@ describe('commanderAdapter arg passing', () => {
|
|
|
56
56
|
expect(kwargs.pdf).toBe('./paper.pdf');
|
|
57
57
|
expect(kwargs['prepare-only']).toBe(true);
|
|
58
58
|
});
|
|
59
|
+
it('passes option value sources through for adapters that need explicit-vs-default semantics', async () => {
|
|
60
|
+
const program = new Command();
|
|
61
|
+
const siteCmd = program.command('paperreview');
|
|
62
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
63
|
+
await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--prepare-only']);
|
|
64
|
+
expect(mockExecuteCommand).toHaveBeenCalled();
|
|
65
|
+
const kwargs = mockExecuteCommand.mock.calls[0][1];
|
|
66
|
+
expect(kwargs.__opencliOptionSources).toMatchObject({
|
|
67
|
+
'prepare-only': 'cli',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
59
70
|
it('rejects invalid bool values before calling executeCommand', async () => {
|
|
60
71
|
const program = new Command();
|
|
61
72
|
const siteCmd = program.command('paperreview');
|