@jackwener/opencli 1.0.0 → 1.0.3
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/.github/workflows/build-extension.yml +62 -0
- package/.github/workflows/ci.yml +6 -6
- package/.github/workflows/e2e-headed.yml +2 -2
- package/.github/workflows/pkg-pr-new.yml +2 -2
- package/.github/workflows/release.yml +2 -5
- package/.github/workflows/security.yml +2 -2
- package/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/README.md +35 -8
- package/README.zh-CN.md +35 -8
- package/SKILL.md +3 -5
- package/dist/browser/cdp.d.ts +27 -0
- package/dist/browser/cdp.js +295 -0
- package/dist/browser/daemon-client.d.ts +1 -1
- package/dist/browser/index.d.ts +4 -2
- package/dist/browser/index.js +5 -5
- package/dist/browser/mcp.d.ts +5 -8
- package/dist/browser/mcp.js +9 -10
- package/dist/browser/page.d.ts +8 -1
- package/dist/browser/page.js +25 -40
- package/dist/browser/utils.d.ts +10 -0
- package/dist/browser/utils.js +27 -0
- package/dist/browser.test.js +48 -7
- package/dist/chaoxing.d.ts +58 -0
- package/dist/chaoxing.js +225 -0
- package/dist/chaoxing.test.d.ts +1 -0
- package/dist/chaoxing.test.js +38 -0
- package/dist/cli-manifest.json +597 -14
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -0
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/boss/chatlist.d.ts +1 -0
- package/dist/clis/boss/chatlist.js +50 -0
- package/dist/clis/boss/chatmsg.d.ts +1 -0
- package/dist/clis/boss/chatmsg.js +73 -0
- package/dist/clis/boss/send.d.ts +1 -0
- package/dist/clis/boss/send.js +176 -0
- package/dist/clis/chaoxing/assignments.d.ts +1 -0
- package/dist/clis/chaoxing/assignments.js +74 -0
- package/dist/clis/chaoxing/exams.d.ts +1 -0
- package/dist/clis/chaoxing/exams.js +74 -0
- package/dist/clis/chatgpt/ask.js +15 -14
- package/dist/clis/chatgpt/ax.d.ts +1 -0
- package/dist/clis/chatgpt/ax.js +78 -0
- package/dist/clis/chatgpt/read.js +5 -6
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/post.js +9 -2
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +30 -11
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/clis/xiaohongshu/download.d.ts +1 -1
- package/dist/clis/xiaohongshu/download.js +1 -1
- package/dist/daemon.js +2 -2
- package/dist/doctor.d.ts +0 -21
- package/dist/doctor.js +2 -24
- package/dist/engine.js +24 -13
- package/dist/explore.js +46 -101
- package/dist/main.js +4 -203
- package/dist/output.d.ts +1 -1
- package/dist/registry.d.ts +3 -3
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/scripts/framework.d.ts +4 -0
- package/dist/scripts/framework.js +21 -0
- package/dist/scripts/interact.d.ts +4 -0
- package/dist/scripts/interact.js +20 -0
- package/dist/scripts/store.d.ts +9 -0
- package/dist/scripts/store.js +44 -0
- package/dist/setup.js +2 -2
- package/dist/synthesize.js +1 -1
- package/extension/dist/background.js +392 -0
- package/extension/manifest.json +3 -3
- package/extension/package.json +1 -1
- package/extension/src/background.ts +101 -24
- package/extension/src/protocol.ts +1 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +295 -0
- package/src/browser/daemon-client.ts +1 -1
- package/src/browser/index.ts +5 -6
- package/src/browser/mcp.ts +14 -15
- package/src/browser/page.ts +25 -41
- package/src/browser/utils.ts +27 -0
- package/src/browser.test.ts +52 -6
- package/src/chaoxing.test.ts +45 -0
- package/src/chaoxing.ts +268 -0
- package/src/cli.ts +185 -0
- package/src/clis/antigravity/SKILL.md +5 -0
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/boss/chatlist.ts +50 -0
- package/src/clis/boss/chatmsg.ts +70 -0
- package/src/clis/boss/send.ts +193 -0
- package/src/clis/chaoxing/README.md +36 -0
- package/src/clis/chaoxing/README.zh-CN.md +35 -0
- package/src/clis/chaoxing/assignments.ts +88 -0
- package/src/clis/chaoxing/exams.ts +88 -0
- package/src/clis/chatgpt/ask.ts +14 -15
- package/src/clis/chatgpt/ax.ts +81 -0
- package/src/clis/chatgpt/read.ts +5 -7
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/post.ts +9 -2
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +34 -12
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/clis/xiaohongshu/download.ts +1 -1
- package/src/daemon.ts +2 -2
- package/src/doctor.ts +2 -19
- package/src/engine.ts +20 -13
- package/src/explore.ts +51 -100
- package/src/main.ts +4 -186
- package/src/output.ts +12 -12
- package/src/registry.ts +3 -3
- package/src/runtime.ts +2 -6
- package/src/scripts/framework.ts +20 -0
- package/src/scripts/interact.ts +22 -0
- package/src/scripts/store.ts +40 -0
- package/src/setup.ts +2 -2
- package/src/synthesize.ts +1 -1
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOSS直聘 send message — via UI automation on chat page.
|
|
3
|
+
*
|
|
4
|
+
* Flow: navigate to chat → click on user in list → type in editor → send.
|
|
5
|
+
* BOSS chat uses MQTT (not HTTP) for messaging, so we must go through the UI.
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
cli({
|
|
9
|
+
site: 'boss',
|
|
10
|
+
name: 'send',
|
|
11
|
+
description: 'BOSS直聘发送聊天消息',
|
|
12
|
+
domain: 'www.zhipin.com',
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
browser: true,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
|
|
17
|
+
{ name: 'text', required: true, help: 'Message text to send' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['status', 'detail'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
if (!page)
|
|
22
|
+
throw new Error('Browser page required');
|
|
23
|
+
const uid = kwargs.uid;
|
|
24
|
+
const text = kwargs.text;
|
|
25
|
+
// Step 1: Navigate to chat page
|
|
26
|
+
await page.goto('https://www.zhipin.com/web/chat/index');
|
|
27
|
+
await page.wait({ time: 3 });
|
|
28
|
+
// Step 2: Find friend in list to get their numeric uid, then click
|
|
29
|
+
const friendData = await page.evaluate(`
|
|
30
|
+
async () => {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const xhr = new XMLHttpRequest();
|
|
33
|
+
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
|
|
34
|
+
xhr.withCredentials = true;
|
|
35
|
+
xhr.timeout = 15000;
|
|
36
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
37
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
38
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
39
|
+
xhr.send();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
`);
|
|
43
|
+
if (friendData.code !== 0) {
|
|
44
|
+
if (friendData.code === 7 || friendData.code === 37) {
|
|
45
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
46
|
+
}
|
|
47
|
+
throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code));
|
|
48
|
+
}
|
|
49
|
+
let target = null;
|
|
50
|
+
const allFriends = friendData.zpData?.friendList || [];
|
|
51
|
+
target = allFriends.find((f) => f.encryptUid === uid);
|
|
52
|
+
if (!target) {
|
|
53
|
+
for (let p = 2; p <= 5; p++) {
|
|
54
|
+
const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`;
|
|
55
|
+
const moreData = await page.evaluate(`
|
|
56
|
+
async () => {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const xhr = new XMLHttpRequest();
|
|
59
|
+
xhr.open('GET', '${moreUrl}', true);
|
|
60
|
+
xhr.withCredentials = true;
|
|
61
|
+
xhr.timeout = 15000;
|
|
62
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
63
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
64
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
65
|
+
xhr.send();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
`);
|
|
69
|
+
if (moreData.code === 0) {
|
|
70
|
+
const list = moreData.zpData?.friendList || [];
|
|
71
|
+
target = list.find((f) => f.encryptUid === uid);
|
|
72
|
+
if (target)
|
|
73
|
+
break;
|
|
74
|
+
if (list.length === 0)
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!target)
|
|
80
|
+
throw new Error('未找到该候选人,请确认 uid 是否正确');
|
|
81
|
+
const numericUid = target.uid;
|
|
82
|
+
const friendName = target.name || '候选人';
|
|
83
|
+
// Step 3: Click on the user in the chat list to open conversation
|
|
84
|
+
const clicked = await page.evaluate(`
|
|
85
|
+
async () => {
|
|
86
|
+
// The geek-item has id like _748787762-0
|
|
87
|
+
const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
|
|
88
|
+
if (item) {
|
|
89
|
+
item.click();
|
|
90
|
+
return { clicked: true, id: item.id };
|
|
91
|
+
}
|
|
92
|
+
// Fallback: try clicking by iterating geek items
|
|
93
|
+
const items = document.querySelectorAll('.geek-item');
|
|
94
|
+
for (const el of items) {
|
|
95
|
+
if (el.id && el.id.startsWith('_${numericUid}')) {
|
|
96
|
+
el.click();
|
|
97
|
+
return { clicked: true, id: el.id };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { clicked: false };
|
|
101
|
+
}
|
|
102
|
+
`);
|
|
103
|
+
if (!clicked.clicked) {
|
|
104
|
+
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
|
105
|
+
}
|
|
106
|
+
// Step 4: Wait for the conversation to load and input area to appear
|
|
107
|
+
await page.wait({ time: 2 });
|
|
108
|
+
// Step 5: Find the message editor and type
|
|
109
|
+
const typed = await page.evaluate(`
|
|
110
|
+
async () => {
|
|
111
|
+
// Look for the chat editor - BOSS uses contenteditable div or textarea
|
|
112
|
+
const selectors = [
|
|
113
|
+
'.chat-editor [contenteditable="true"]',
|
|
114
|
+
'.chat-input [contenteditable="true"]',
|
|
115
|
+
'.message-editor [contenteditable="true"]',
|
|
116
|
+
'.chat-conversation [contenteditable="true"]',
|
|
117
|
+
'[contenteditable="true"]',
|
|
118
|
+
'.chat-editor textarea',
|
|
119
|
+
'.chat-input textarea',
|
|
120
|
+
'textarea',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const sel of selectors) {
|
|
124
|
+
const el = document.querySelector(sel);
|
|
125
|
+
if (el && el.offsetParent !== null) {
|
|
126
|
+
el.focus();
|
|
127
|
+
|
|
128
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
129
|
+
el.value = ${JSON.stringify(text)};
|
|
130
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
131
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
132
|
+
} else {
|
|
133
|
+
// contenteditable
|
|
134
|
+
el.textContent = '';
|
|
135
|
+
el.focus();
|
|
136
|
+
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
137
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { found: true, selector: sel, tag: el.tagName };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Debug: list all visible elements in chat-conversation
|
|
145
|
+
const conv = document.querySelector('.chat-conversation');
|
|
146
|
+
const allEls = conv ? Array.from(conv.querySelectorAll('*')).filter(e => e.offsetParent !== null).map(e => e.tagName + '.' + (e.className?.substring?.(0, 50) || '')).slice(0, 30) : [];
|
|
147
|
+
|
|
148
|
+
return { found: false, visibleElements: allEls };
|
|
149
|
+
}
|
|
150
|
+
`);
|
|
151
|
+
if (!typed.found) {
|
|
152
|
+
throw new Error('找不到消息输入框。可能的元素: ' + JSON.stringify(typed.visibleElements || []));
|
|
153
|
+
}
|
|
154
|
+
await page.wait({ time: 0.5 });
|
|
155
|
+
// Step 6: Click the send button (Enter key doesn't trigger send on BOSS)
|
|
156
|
+
const sent = await page.evaluate(`
|
|
157
|
+
async () => {
|
|
158
|
+
// The send button is .submit inside .submit-content
|
|
159
|
+
const btn = document.querySelector('.conversation-editor .submit')
|
|
160
|
+
|| document.querySelector('.submit-content .submit')
|
|
161
|
+
|| document.querySelector('.conversation-operate .submit');
|
|
162
|
+
if (btn) {
|
|
163
|
+
btn.click();
|
|
164
|
+
return { clicked: true };
|
|
165
|
+
}
|
|
166
|
+
return { clicked: false };
|
|
167
|
+
}
|
|
168
|
+
`);
|
|
169
|
+
if (!sent.clicked) {
|
|
170
|
+
// Fallback: try Enter key
|
|
171
|
+
await page.pressKey('Enter');
|
|
172
|
+
}
|
|
173
|
+
await page.wait({ time: 1 });
|
|
174
|
+
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${text}` }];
|
|
175
|
+
},
|
|
176
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { getCourses, initSession, enterCourse, getTabIframeUrl, parseAssignmentsFromDom, sleep, } from '../../chaoxing.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'chaoxing',
|
|
5
|
+
name: 'assignments',
|
|
6
|
+
description: '学习通作业列表',
|
|
7
|
+
domain: 'mooc2-ans.chaoxing.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
timeoutSeconds: 90,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
|
|
12
|
+
{
|
|
13
|
+
name: 'status',
|
|
14
|
+
type: 'string',
|
|
15
|
+
default: 'all',
|
|
16
|
+
choices: ['all', 'pending', 'submitted', 'graded'],
|
|
17
|
+
help: '按状态过滤',
|
|
18
|
+
},
|
|
19
|
+
{ name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['rank', 'course', 'title', 'deadline', 'status', 'score'],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
|
|
24
|
+
// 1. Establish session
|
|
25
|
+
await initSession(page);
|
|
26
|
+
// 2. Get courses
|
|
27
|
+
const courses = await getCourses(page);
|
|
28
|
+
if (!courses.length)
|
|
29
|
+
throw new Error('未获取到课程列表,请确认已登录学习通');
|
|
30
|
+
const filtered = courseFilter
|
|
31
|
+
? courses.filter(c => c.title.includes(courseFilter))
|
|
32
|
+
: courses;
|
|
33
|
+
if (courseFilter && !filtered.length) {
|
|
34
|
+
throw new Error(`未找到匹配「${courseFilter}」的课程`);
|
|
35
|
+
}
|
|
36
|
+
// 3. Per-course: enter → click 作业 tab → navigate to iframe → parse
|
|
37
|
+
const allRows = [];
|
|
38
|
+
for (const c of filtered) {
|
|
39
|
+
try {
|
|
40
|
+
await enterCourse(page, c);
|
|
41
|
+
const iframeUrl = await getTabIframeUrl(page, '作业');
|
|
42
|
+
if (!iframeUrl)
|
|
43
|
+
continue;
|
|
44
|
+
await page.goto(iframeUrl);
|
|
45
|
+
await page.wait(2);
|
|
46
|
+
const rows = await parseAssignmentsFromDom(page, c.title);
|
|
47
|
+
allRows.push(...rows);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Single course failure: skip, continue
|
|
51
|
+
}
|
|
52
|
+
if (filtered.length > 1)
|
|
53
|
+
await sleep(600);
|
|
54
|
+
}
|
|
55
|
+
// 4. Sort: pending first, then by deadline
|
|
56
|
+
allRows.sort((a, b) => {
|
|
57
|
+
const order = (s) => s === '未交' ? 0 : s === '待批阅' ? 1 : s === '已完成' ? 2 : s === '已批阅' ? 3 : 4;
|
|
58
|
+
return order(a.status) - order(b.status);
|
|
59
|
+
});
|
|
60
|
+
// 5. Filter by status
|
|
61
|
+
const statusMap = {
|
|
62
|
+
pending: ['未交'],
|
|
63
|
+
submitted: ['待批阅', '已完成'],
|
|
64
|
+
graded: ['已批阅'],
|
|
65
|
+
};
|
|
66
|
+
const finalRows = statusFilter === 'all'
|
|
67
|
+
? allRows
|
|
68
|
+
: allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
|
|
69
|
+
return finalRows.slice(0, Number(limit)).map((item, i) => ({
|
|
70
|
+
rank: i + 1,
|
|
71
|
+
...item,
|
|
72
|
+
}));
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { getCourses, initSession, enterCourse, getTabIframeUrl, parseExamsFromDom, sleep, } from '../../chaoxing.js';
|
|
3
|
+
cli({
|
|
4
|
+
site: 'chaoxing',
|
|
5
|
+
name: 'exams',
|
|
6
|
+
description: '学习通考试列表',
|
|
7
|
+
domain: 'mooc2-ans.chaoxing.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
timeoutSeconds: 90,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'course', type: 'string', help: '按课程名过滤(模糊匹配)' },
|
|
12
|
+
{
|
|
13
|
+
name: 'status',
|
|
14
|
+
type: 'string',
|
|
15
|
+
default: 'all',
|
|
16
|
+
choices: ['all', 'upcoming', 'ongoing', 'finished'],
|
|
17
|
+
help: '按状态过滤',
|
|
18
|
+
},
|
|
19
|
+
{ name: 'limit', type: 'int', default: 20, help: '最大返回数量' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['rank', 'course', 'title', 'start', 'end', 'status', 'score'],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
const { course: courseFilter, status: statusFilter = 'all', limit = 20 } = kwargs;
|
|
24
|
+
// 1. Establish session
|
|
25
|
+
await initSession(page);
|
|
26
|
+
// 2. Get courses
|
|
27
|
+
const courses = await getCourses(page);
|
|
28
|
+
if (!courses.length)
|
|
29
|
+
throw new Error('未获取到课程列表,请确认已登录学习通');
|
|
30
|
+
const filtered = courseFilter
|
|
31
|
+
? courses.filter(c => c.title.includes(courseFilter))
|
|
32
|
+
: courses;
|
|
33
|
+
if (courseFilter && !filtered.length) {
|
|
34
|
+
throw new Error(`未找到匹配「${courseFilter}」的课程`);
|
|
35
|
+
}
|
|
36
|
+
// 3. Per-course: enter → click 考试 tab → navigate to iframe → parse
|
|
37
|
+
const allRows = [];
|
|
38
|
+
for (const c of filtered) {
|
|
39
|
+
try {
|
|
40
|
+
await enterCourse(page, c);
|
|
41
|
+
const iframeUrl = await getTabIframeUrl(page, '考试');
|
|
42
|
+
if (!iframeUrl)
|
|
43
|
+
continue;
|
|
44
|
+
await page.goto(iframeUrl);
|
|
45
|
+
await page.wait(2);
|
|
46
|
+
const rows = await parseExamsFromDom(page, c.title);
|
|
47
|
+
allRows.push(...rows);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Single course failure: skip, continue
|
|
51
|
+
}
|
|
52
|
+
if (filtered.length > 1)
|
|
53
|
+
await sleep(600);
|
|
54
|
+
}
|
|
55
|
+
// 4. Sort: upcoming first
|
|
56
|
+
allRows.sort((a, b) => {
|
|
57
|
+
const order = (s) => s === '未开始' ? 0 : s === '进行中' ? 1 : s === '已结束' ? 2 : s === '已完成' ? 3 : 4;
|
|
58
|
+
return order(a.status) - order(b.status);
|
|
59
|
+
});
|
|
60
|
+
// 5. Filter by status
|
|
61
|
+
const statusMap = {
|
|
62
|
+
upcoming: ['未开始'],
|
|
63
|
+
ongoing: ['进行中'],
|
|
64
|
+
finished: ['已结束', '已完成'],
|
|
65
|
+
};
|
|
66
|
+
const finalRows = statusFilter === 'all'
|
|
67
|
+
? allRows
|
|
68
|
+
: allRows.filter(r => statusMap[statusFilter]?.includes(r.status));
|
|
69
|
+
return finalRows.slice(0, Number(limit)).map((item, i) => ({
|
|
70
|
+
rank: i + 1,
|
|
71
|
+
...item,
|
|
72
|
+
}));
|
|
73
|
+
},
|
|
74
|
+
});
|
package/dist/clis/chatgpt/ask.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync, spawnSync } from 'node:child_process';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { getVisibleChatMessages } from './ax.js';
|
|
3
4
|
export const askCommand = cli({
|
|
4
5
|
site: 'chatgpt',
|
|
5
6
|
name: 'ask',
|
|
@@ -21,6 +22,7 @@ export const askCommand = cli({
|
|
|
21
22
|
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
|
|
22
23
|
}
|
|
23
24
|
catch { }
|
|
25
|
+
const messagesBefore = getVisibleChatMessages();
|
|
24
26
|
// Send the message
|
|
25
27
|
spawnSync('pbcopy', { input: text });
|
|
26
28
|
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
|
@@ -32,28 +34,27 @@ export const askCommand = cli({
|
|
|
32
34
|
"-e 'keystroke return' " +
|
|
33
35
|
"-e 'end tell'";
|
|
34
36
|
execSync(cmd);
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Restore clipboard after the prompt is sent.
|
|
38
|
+
if (clipBackup)
|
|
39
|
+
spawnSync('pbcopy', { input: clipBackup });
|
|
40
|
+
// Wait for response, then read the latest visible assistant message from the AX tree.
|
|
41
|
+
const pollInterval = 1;
|
|
39
42
|
const maxPolls = Math.ceil(timeout / pollInterval);
|
|
40
43
|
let response = '';
|
|
41
44
|
for (let i = 0; i < maxPolls; i++) {
|
|
42
|
-
// Wait
|
|
43
45
|
execSync(`sleep ${pollInterval}`);
|
|
44
|
-
// Try Cmd+Shift+C to copy the latest response
|
|
45
46
|
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
|
46
|
-
execSync("osascript -e '
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
execSync("osascript -e 'delay 0.2'");
|
|
48
|
+
const messagesNow = getVisibleChatMessages();
|
|
49
|
+
if (messagesNow.length <= messagesBefore.length)
|
|
50
|
+
continue;
|
|
51
|
+
const newMessages = messagesNow.slice(messagesBefore.length);
|
|
52
|
+
const candidate = [...newMessages].reverse().find((message) => message !== text);
|
|
53
|
+
if (candidate) {
|
|
54
|
+
response = candidate;
|
|
51
55
|
break;
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
|
-
// Restore clipboard
|
|
55
|
-
if (clipBackup)
|
|
56
|
-
spawnSync('pbcopy', { input: clipBackup });
|
|
57
58
|
if (!response) {
|
|
58
59
|
return [
|
|
59
60
|
{ Role: 'User', Text: text },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getVisibleChatMessages(): string[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const AX_READ_SCRIPT = `
|
|
3
|
+
import Cocoa
|
|
4
|
+
import ApplicationServices
|
|
5
|
+
|
|
6
|
+
func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
|
|
7
|
+
var value: CFTypeRef?
|
|
8
|
+
guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
|
|
9
|
+
return value as AnyObject?
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func s(_ el: AXUIElement, _ name: String) -> String? {
|
|
13
|
+
if let v = attr(el, name) as? String, !v.isEmpty { return v }
|
|
14
|
+
return nil
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func children(_ el: AXUIElement) -> [AXUIElement] {
|
|
18
|
+
(attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) {
|
|
22
|
+
let role = s(el, kAXRoleAttribute as String) ?? ""
|
|
23
|
+
if role == kAXListRole as String { out.append(el) }
|
|
24
|
+
for c in children(el) { collectLists(c, into: &out) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func collectTexts(_ el: AXUIElement, into out: inout [String]) {
|
|
28
|
+
let role = s(el, kAXRoleAttribute as String) ?? ""
|
|
29
|
+
if role == kAXStaticTextRole as String {
|
|
30
|
+
if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty {
|
|
31
|
+
out.append(text)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for c in children(el) { collectTexts(c, into: &out) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
|
|
38
|
+
fputs("ChatGPT not running\\n", stderr)
|
|
39
|
+
exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
43
|
+
guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
|
|
44
|
+
fputs("No focused ChatGPT window\\n", stderr)
|
|
45
|
+
exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var lists: [AXUIElement] = []
|
|
49
|
+
collectLists(win, into: &lists)
|
|
50
|
+
|
|
51
|
+
var best: [String] = []
|
|
52
|
+
for list in lists {
|
|
53
|
+
var texts: [String] = []
|
|
54
|
+
collectTexts(list, into: &texts)
|
|
55
|
+
if texts.count > best.count {
|
|
56
|
+
best = texts
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let data = try! JSONSerialization.data(withJSONObject: best, options: [])
|
|
61
|
+
print(String(data: data, encoding: .utf8)!)
|
|
62
|
+
`;
|
|
63
|
+
export function getVisibleChatMessages() {
|
|
64
|
+
const output = execFileSync('swift', ['-'], {
|
|
65
|
+
input: AX_READ_SCRIPT,
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
68
|
+
}).trim();
|
|
69
|
+
if (!output)
|
|
70
|
+
return [];
|
|
71
|
+
const parsed = JSON.parse(output);
|
|
72
|
+
if (!Array.isArray(parsed))
|
|
73
|
+
return [];
|
|
74
|
+
return parsed
|
|
75
|
+
.filter((item) => typeof item === 'string')
|
|
76
|
+
.map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
|
|
77
|
+
.filter((item) => item.length > 0);
|
|
78
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import { getVisibleChatMessages } from './ax.js';
|
|
3
4
|
export const readCommand = cli({
|
|
4
5
|
site: 'chatgpt',
|
|
5
6
|
name: 'read',
|
|
@@ -12,14 +13,12 @@ export const readCommand = cli({
|
|
|
12
13
|
func: async (page) => {
|
|
13
14
|
try {
|
|
14
15
|
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
|
15
|
-
execSync("osascript -e 'delay 0.5'");
|
|
16
|
-
execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
|
|
17
16
|
execSync("osascript -e 'delay 0.3'");
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
20
|
-
return [{ Role: 'System', Text: 'No
|
|
17
|
+
const messages = getVisibleChatMessages();
|
|
18
|
+
if (!messages.length) {
|
|
19
|
+
return [{ Role: 'System', Text: 'No visible chat messages were found in the current ChatGPT window.' }];
|
|
21
20
|
}
|
|
22
|
-
return [{ Role: 'Assistant', Text:
|
|
21
|
+
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
|
|
23
22
|
}
|
|
24
23
|
catch (err) {
|
|
25
24
|
throw new Error("Failed to read from ChatGPT: " + err.message);
|
|
@@ -38,6 +38,23 @@ export const historyCommand = cli({
|
|
|
38
38
|
if (items.length === 0) {
|
|
39
39
|
return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
|
|
42
|
+
const numericOnly = /^[\d\s]+$/;
|
|
43
|
+
const modelPath = /^[\w.-]+\/[\w.-]/;
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const deduped = items.filter((item) => {
|
|
46
|
+
const t = item.Title.trim();
|
|
47
|
+
if (dateHeaders.test(t))
|
|
48
|
+
return false;
|
|
49
|
+
if (numericOnly.test(t))
|
|
50
|
+
return false;
|
|
51
|
+
if (modelPath.test(t))
|
|
52
|
+
return false;
|
|
53
|
+
if (seen.has(t))
|
|
54
|
+
return false;
|
|
55
|
+
seen.add(t);
|
|
56
|
+
return true;
|
|
57
|
+
}).map((item, i) => ({ Index: i + 1, Title: item.Title }));
|
|
58
|
+
return deduped;
|
|
42
59
|
},
|
|
43
60
|
});
|
|
@@ -12,28 +12,40 @@ export const channelsCommand = cli({
|
|
|
12
12
|
const channels = await page.evaluate(`
|
|
13
13
|
(function() {
|
|
14
14
|
const results = [];
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
15
|
+
|
|
16
|
+
// Discord channel links: <a> tags with href like /channels/GUILD/CHANNEL
|
|
17
|
+
const links = document.querySelectorAll('a[href*="/channels/"][data-list-item-id^="channels___"]');
|
|
18
|
+
|
|
19
|
+
links.forEach(function(el) {
|
|
20
|
+
var label = el.getAttribute('aria-label') || '';
|
|
21
|
+
if (!label) return;
|
|
22
|
+
|
|
23
|
+
// Skip categories
|
|
24
|
+
if (/[((]category[))]/i.test(label)) return;
|
|
25
|
+
|
|
26
|
+
// Strip any leading status prefix before the first comma (e.g. "unread, ", locale-agnostic)
|
|
27
|
+
var commaIdx = label.search(/[,,]/);
|
|
28
|
+
var cleaned = commaIdx !== -1 ? label.slice(commaIdx + 1).trimStart() : label;
|
|
29
|
+
|
|
30
|
+
// Extract name and type from "name (type)" or "name(type)"
|
|
31
|
+
var m = cleaned.match(/^(.+?)\s*[((](.+?)[))]\s*$/);
|
|
32
|
+
// If no type annotation found, skip — real channels always have "(Type channel)" in aria-label
|
|
33
|
+
if (!m) return;
|
|
34
|
+
var name = m[1].trim();
|
|
35
|
+
var rawType = m[2].toLowerCase();
|
|
36
|
+
|
|
37
|
+
// Discord channel names are ASCII-only; skip placeholder entries (e.g. locked channels)
|
|
38
|
+
if (!name || !/^[\x20-\x7E]+$/.test(name)) return;
|
|
39
|
+
|
|
40
|
+
var type = 'Text';
|
|
41
|
+
if (rawType.includes('voice')) type = 'Voice';
|
|
42
|
+
else if (rawType.includes('forum')) type = 'Forum';
|
|
43
|
+
else if (rawType.includes('announcement')) type = 'Announcement';
|
|
44
|
+
else if (rawType.includes('stage')) type = 'Stage';
|
|
45
|
+
|
|
46
|
+
results.push({ Index: results.length + 1, Channel: name, Type: type });
|
|
35
47
|
});
|
|
36
|
-
|
|
48
|
+
|
|
37
49
|
return results;
|
|
38
50
|
})()
|
|
39
51
|
`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|