@jackwener/opencli 1.0.1 → 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 +15 -7
- package/README.zh-CN.md +15 -7
- package/SKILL.md +3 -5
- package/dist/browser/cdp.d.ts +27 -0
- package/dist/browser/cdp.js +295 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/page.js +2 -23
- package/dist/browser/utils.d.ts +10 -0
- package/dist/browser/utils.js +27 -0
- package/dist/browser.test.js +42 -1
- 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 +203 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -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/twitter/post.js +9 -2
- package/dist/clis/twitter/search.js +14 -33
- package/dist/clis/xiaohongshu/download.d.ts +1 -1
- package/dist/clis/xiaohongshu/download.js +1 -1
- package/dist/engine.js +24 -13
- package/dist/explore.js +46 -101
- package/dist/main.js +4 -193
- package/dist/output.d.ts +1 -1
- package/dist/registry.d.ts +3 -3
- 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/synthesize.js +1 -1
- package/extension/dist/background.js +338 -430
- package/extension/manifest.json +2 -2
- package/extension/src/background.ts +2 -2
- package/package.json +1 -1
- package/src/browser/cdp.ts +295 -0
- package/src/browser/index.ts +4 -0
- package/src/browser/page.ts +2 -24
- package/src/browser/utils.ts +27 -0
- package/src/browser.test.ts +46 -0
- 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/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/twitter/post.ts +9 -2
- package/src/clis/twitter/search.ts +15 -33
- package/src/clis/xiaohongshu/download.ts +1 -1
- package/src/engine.ts +20 -13
- package/src/explore.ts +51 -100
- package/src/main.ts +4 -180
- package/src/output.ts +12 -12
- package/src/registry.ts +3 -3
- package/src/scripts/framework.ts +20 -0
- package/src/scripts/interact.ts +22 -0
- package/src/scripts/store.ts +40 -0
- package/src/synthesize.ts +1 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { executeCommand } from './engine.js';
|
|
4
|
+
import { fullName, getRegistry, strategyLabel } from './registry.js';
|
|
5
|
+
import { render as renderOutput } from './output.js';
|
|
6
|
+
import { BrowserBridge, CDPBridge } from './browser/index.js';
|
|
7
|
+
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
|
8
|
+
import { PKG_VERSION } from './version.js';
|
|
9
|
+
import { printCompletionScript } from './completion.js';
|
|
10
|
+
import { CliError } from './errors.js';
|
|
11
|
+
export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
|
|
14
|
+
// ── Built-in commands ──────────────────────────────────────────────────────
|
|
15
|
+
program.command('list').description('List all available CLI commands').option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('--json', 'JSON output (deprecated)')
|
|
16
|
+
.action((opts) => {
|
|
17
|
+
const registry = getRegistry();
|
|
18
|
+
const commands = [...registry.values()].sort((a, b) => fullName(a).localeCompare(fullName(b)));
|
|
19
|
+
const rows = commands.map(c => ({
|
|
20
|
+
command: fullName(c),
|
|
21
|
+
site: c.site,
|
|
22
|
+
name: c.name,
|
|
23
|
+
description: c.description,
|
|
24
|
+
strategy: strategyLabel(c),
|
|
25
|
+
browser: c.browser,
|
|
26
|
+
args: c.args.map(a => a.name).join(', '),
|
|
27
|
+
}));
|
|
28
|
+
const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
|
|
29
|
+
if (fmt !== 'table') {
|
|
30
|
+
renderOutput(rows, {
|
|
31
|
+
fmt,
|
|
32
|
+
columns: ['command', 'site', 'name', 'description', 'strategy', 'browser', 'args'],
|
|
33
|
+
title: 'opencli/list',
|
|
34
|
+
source: 'opencli list',
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const sites = new Map();
|
|
39
|
+
for (const cmd of commands) {
|
|
40
|
+
const g = sites.get(cmd.site) ?? [];
|
|
41
|
+
g.push(cmd);
|
|
42
|
+
sites.set(cmd.site, g);
|
|
43
|
+
}
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(chalk.bold(' opencli') + chalk.dim(' — available commands'));
|
|
46
|
+
console.log();
|
|
47
|
+
for (const [site, cmds] of sites) {
|
|
48
|
+
console.log(chalk.bold.cyan(` ${site}`));
|
|
49
|
+
for (const cmd of cmds) {
|
|
50
|
+
const tag = strategyLabel(cmd) === 'public' ? chalk.green('[public]') : chalk.yellow(`[${strategyLabel(cmd)}]`);
|
|
51
|
+
console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
}
|
|
55
|
+
console.log(chalk.dim(` ${commands.length} commands across ${sites.size} sites`));
|
|
56
|
+
console.log();
|
|
57
|
+
});
|
|
58
|
+
program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name')
|
|
59
|
+
.action(async (target) => {
|
|
60
|
+
const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
|
|
61
|
+
console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
|
|
62
|
+
});
|
|
63
|
+
program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false)
|
|
64
|
+
.action(async (target, opts) => {
|
|
65
|
+
const { verifyClis, renderVerifyReport } = await import('./verify.js');
|
|
66
|
+
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
|
|
67
|
+
console.log(renderVerifyReport(r));
|
|
68
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
69
|
+
});
|
|
70
|
+
program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
|
|
71
|
+
.action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
|
|
72
|
+
program.command('synthesize').description('Synthesize CLIs from explore').argument('<target>').option('--top <n>', '', '3')
|
|
73
|
+
.action(async (target, opts) => { const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js'); console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) }))); });
|
|
74
|
+
program.command('generate').description('One-shot: explore → synthesize → register').argument('<url>').option('--goal <text>').option('--site <name>')
|
|
75
|
+
.action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; });
|
|
76
|
+
program.command('cascade').description('Strategy cascade: find simplest working strategy').argument('<url>').option('--site <name>')
|
|
77
|
+
.action(async (url, opts) => {
|
|
78
|
+
const { cascadeProbe, renderCascadeResult } = await import('./cascade.js');
|
|
79
|
+
const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge;
|
|
80
|
+
const result = await browserSession(BrowserFactory, async (page) => {
|
|
81
|
+
// Navigate to the site first for cookie context
|
|
82
|
+
try {
|
|
83
|
+
const siteUrl = new URL(url);
|
|
84
|
+
await page.goto(`${siteUrl.protocol}//${siteUrl.host}`);
|
|
85
|
+
await page.wait(2);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
return cascadeProbe(page, url);
|
|
89
|
+
});
|
|
90
|
+
console.log(renderCascadeResult(result));
|
|
91
|
+
});
|
|
92
|
+
program.command('doctor')
|
|
93
|
+
.description('Diagnose opencli browser bridge connectivity')
|
|
94
|
+
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
|
95
|
+
.action(async (opts) => {
|
|
96
|
+
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
|
97
|
+
const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
|
|
98
|
+
console.log(renderBrowserDoctorReport(report));
|
|
99
|
+
});
|
|
100
|
+
program.command('setup')
|
|
101
|
+
.description('Interactive setup: verify browser bridge connectivity')
|
|
102
|
+
.action(async () => {
|
|
103
|
+
const { runSetup } = await import('./setup.js');
|
|
104
|
+
await runSetup({ cliVersion: PKG_VERSION });
|
|
105
|
+
});
|
|
106
|
+
program.command('completion')
|
|
107
|
+
.description('Output shell completion script')
|
|
108
|
+
.argument('<shell>', 'Shell type: bash, zsh, or fish')
|
|
109
|
+
.action((shell) => {
|
|
110
|
+
printCompletionScript(shell);
|
|
111
|
+
});
|
|
112
|
+
// ── Dynamic site commands ──────────────────────────────────────────────────
|
|
113
|
+
const registry = getRegistry();
|
|
114
|
+
const siteGroups = new Map();
|
|
115
|
+
for (const [, cmd] of registry) {
|
|
116
|
+
let siteCmd = siteGroups.get(cmd.site);
|
|
117
|
+
if (!siteCmd) {
|
|
118
|
+
siteCmd = program.command(cmd.site).description(`${cmd.site} commands`);
|
|
119
|
+
siteGroups.set(cmd.site, siteCmd);
|
|
120
|
+
}
|
|
121
|
+
const subCmd = siteCmd.command(cmd.name).description(cmd.description);
|
|
122
|
+
// Register positional args first, then named options
|
|
123
|
+
const positionalArgs = [];
|
|
124
|
+
for (const arg of cmd.args) {
|
|
125
|
+
if (arg.positional) {
|
|
126
|
+
const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
|
|
127
|
+
subCmd.argument(bracket, arg.help ?? '');
|
|
128
|
+
positionalArgs.push(arg);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
|
132
|
+
if (arg.required)
|
|
133
|
+
subCmd.requiredOption(flag, arg.help ?? '');
|
|
134
|
+
else if (arg.default != null)
|
|
135
|
+
subCmd.option(flag, arg.help ?? '', String(arg.default));
|
|
136
|
+
else
|
|
137
|
+
subCmd.option(flag, arg.help ?? '');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
|
|
141
|
+
subCmd.action(async (...actionArgs) => {
|
|
142
|
+
// Commander passes positional args first, then options object, then the Command
|
|
143
|
+
const actionOpts = actionArgs[positionalArgs.length] ?? {};
|
|
144
|
+
const startTime = Date.now();
|
|
145
|
+
const kwargs = {};
|
|
146
|
+
// Collect positional args
|
|
147
|
+
for (let i = 0; i < positionalArgs.length; i++) {
|
|
148
|
+
const arg = positionalArgs[i];
|
|
149
|
+
const v = actionArgs[i];
|
|
150
|
+
if (v !== undefined)
|
|
151
|
+
kwargs[arg.name] = v;
|
|
152
|
+
}
|
|
153
|
+
// Collect named options
|
|
154
|
+
for (const arg of cmd.args) {
|
|
155
|
+
if (arg.positional)
|
|
156
|
+
continue;
|
|
157
|
+
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
|
|
158
|
+
const v = actionOpts[arg.name] ?? actionOpts[camelName];
|
|
159
|
+
if (v !== undefined)
|
|
160
|
+
kwargs[arg.name] = v;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
if (actionOpts.verbose)
|
|
164
|
+
process.env.OPENCLI_VERBOSE = '1';
|
|
165
|
+
let result;
|
|
166
|
+
if (cmd.browser) {
|
|
167
|
+
const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge;
|
|
168
|
+
result = await browserSession(BrowserFactory, async (page) => {
|
|
169
|
+
return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
result = await executeCommand(cmd, null, kwargs, actionOpts.verbose);
|
|
174
|
+
}
|
|
175
|
+
if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) {
|
|
176
|
+
console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`));
|
|
177
|
+
}
|
|
178
|
+
renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err instanceof CliError) {
|
|
182
|
+
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
|
|
183
|
+
if (err.hint)
|
|
184
|
+
console.error(chalk.yellow(`Hint: ${err.hint}`));
|
|
185
|
+
}
|
|
186
|
+
else if (actionOpts.verbose && err.stack) {
|
|
187
|
+
console.error(chalk.red(err.stack));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.error(chalk.red(`Error: ${err.message ?? err}`));
|
|
191
|
+
}
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
program.parse();
|
|
197
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'boss',
|
|
4
|
+
name: 'chatlist',
|
|
5
|
+
description: 'BOSS直聘查看聊天列表(招聘端)',
|
|
6
|
+
domain: 'www.zhipin.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
11
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
12
|
+
{ name: 'job_id', default: '0', help: 'Filter by job ID (0=all)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'],
|
|
15
|
+
func: async (page, kwargs) => {
|
|
16
|
+
if (!page)
|
|
17
|
+
throw new Error('Browser page required');
|
|
18
|
+
await page.goto('https://www.zhipin.com/web/chat/index');
|
|
19
|
+
await page.wait({ time: 2 });
|
|
20
|
+
const jobId = kwargs.job_id || '0';
|
|
21
|
+
const pageNum = kwargs.page || 1;
|
|
22
|
+
const limit = kwargs.limit || 20;
|
|
23
|
+
const targetUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`;
|
|
24
|
+
const data = await page.evaluate(`
|
|
25
|
+
async () => {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const xhr = new XMLHttpRequest();
|
|
28
|
+
xhr.open('GET', '${targetUrl}', true);
|
|
29
|
+
xhr.withCredentials = true;
|
|
30
|
+
xhr.timeout = 15000;
|
|
31
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
32
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
|
|
33
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
34
|
+
xhr.send();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
`);
|
|
38
|
+
if (data.code !== 0)
|
|
39
|
+
throw new Error(`API error: ${data.message} (code=${data.code})`);
|
|
40
|
+
const friends = (data.zpData?.friendList || []).slice(0, limit);
|
|
41
|
+
return friends.map((f) => ({
|
|
42
|
+
name: f.name || '',
|
|
43
|
+
job: f.jobName || '',
|
|
44
|
+
last_msg: f.lastMessageInfo?.text || '',
|
|
45
|
+
last_time: f.lastTime || '',
|
|
46
|
+
uid: f.encryptUid || '',
|
|
47
|
+
security_id: f.securityId || '',
|
|
48
|
+
}));
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'boss',
|
|
4
|
+
name: 'chatmsg',
|
|
5
|
+
description: 'BOSS直聘查看与候选人的聊天消息',
|
|
6
|
+
domain: 'www.zhipin.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'uid', required: true, help: 'Encrypted UID (from chatlist)' },
|
|
11
|
+
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['from', 'type', 'text', 'time'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page)
|
|
16
|
+
throw new Error('Browser page required');
|
|
17
|
+
await page.goto('https://www.zhipin.com/web/chat/index');
|
|
18
|
+
await page.wait({ time: 2 });
|
|
19
|
+
const uid = kwargs.uid;
|
|
20
|
+
const friendData = await page.evaluate(`
|
|
21
|
+
async () => {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const xhr = new XMLHttpRequest();
|
|
24
|
+
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
|
|
25
|
+
xhr.withCredentials = true;
|
|
26
|
+
xhr.timeout = 15000;
|
|
27
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
28
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
29
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
30
|
+
xhr.send();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
`);
|
|
34
|
+
if (friendData.code !== 0)
|
|
35
|
+
throw new Error('获取好友列表失败');
|
|
36
|
+
const friend = (friendData.zpData?.friendList || []).find((f) => f.encryptUid === uid);
|
|
37
|
+
if (!friend)
|
|
38
|
+
throw new Error('未找到该候选人');
|
|
39
|
+
const gid = friend.uid;
|
|
40
|
+
const securityId = encodeURIComponent(friend.securityId);
|
|
41
|
+
const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
|
|
42
|
+
const msgData = await page.evaluate(`
|
|
43
|
+
async () => {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const xhr = new XMLHttpRequest();
|
|
46
|
+
xhr.open('GET', '${msgUrl}', true);
|
|
47
|
+
xhr.withCredentials = true;
|
|
48
|
+
xhr.timeout = 15000;
|
|
49
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
50
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({raw: xhr.responseText.substring(0,500)}); } };
|
|
51
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
52
|
+
xhr.send();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
if (msgData.raw)
|
|
57
|
+
throw new Error('Non-JSON: ' + msgData.raw);
|
|
58
|
+
if (msgData.code !== 0)
|
|
59
|
+
throw new Error('API error: ' + (msgData.message || msgData.code));
|
|
60
|
+
const TYPE_MAP = { 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', 6: '名片', 7: '语音', 8: '视频', 9: '表情' };
|
|
61
|
+
const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || [];
|
|
62
|
+
return messages.map((m) => {
|
|
63
|
+
const fromObj = m.from || {};
|
|
64
|
+
const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
|
|
65
|
+
return {
|
|
66
|
+
from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
|
|
67
|
+
type: TYPE_MAP[m.type] || '其他(' + m.type + ')',
|
|
68
|
+
text: m.text || m.body?.text || '',
|
|
69
|
+
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|