Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34624adb18 | |||
| 12fe89d12c | |||
| 4546a3d3e3 | |||
| c8c773197e | |||
| f594e500a8 | |||
| f2a3ee6ee4 | |||
| f377ec000c | |||
| 988908f348 | |||
| 2b623b35b6 | |||
| 6cdcb9dd51 | |||
| 6e950a4201 | |||
| 835c146fb7 | |||
| fc818b3c2c | |||
| 0ce46b15bb | |||
| 37f1b46a77 | |||
| f54401488a | |||
| e22a312f50 | |||
| 2d005d14a8 | |||
| 1708626731 | |||
| d50cfe345f | |||
| c7d8678c11 | |||
| 6aad5522f1 | |||
| e8c27d8da5 | |||
| a8fbb637cb |
@@ -23,4 +23,3 @@ docs/.vitepress/cache
|
||||
# Database files
|
||||
*.db
|
||||
autoresearch/results/
|
||||
extension/dist/
|
||||
|
||||
@@ -115,7 +115,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
@@ -130,8 +130,10 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
|
||||
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
|
||||
| **gemini** | `new` `ask` `image` |
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
| **xianyu** | `search` `item` `chat` |
|
||||
|
||||
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
|
||||
+3
-1
@@ -35,7 +35,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
@@ -195,7 +195,9 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **xianyu** | `search` `item` `chat` | 浏览器 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"name": "extract-npm-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com/package/express",
|
||||
"opencli operate eval \"document.querySelector('p[class*=description], [data-testid=package-description], #readme p')?.textContent?.trim()\""
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -294,7 +295,7 @@
|
||||
"opencli operate open https://example.com",
|
||||
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
"opencli operate eval \"document.title + ' ' + location.href\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
@@ -580,7 +581,8 @@
|
||||
"name": "bench-imdb-matrix",
|
||||
"steps": [
|
||||
"opencli operate open https://www.imdb.com/title/tt0133093/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1 span, [data-testid=hero__pageTitle] span')?.textContent,year:document.querySelector('a[href*=releaseinfo], [data-testid=hero-title-block__metadata] a')?.textContent})\""
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
|
||||
*
|
||||
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
|
||||
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
|
||||
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: { type: string; value?: string; minLength?: number; pattern?: string };
|
||||
}
|
||||
|
||||
function exec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function initLog(): void {
|
||||
if (!existsSync(DEBUG_LOG)) {
|
||||
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
|
||||
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const taskName = args.task;
|
||||
const maxIterations = args.iterations ?? 10;
|
||||
|
||||
if (!taskName) {
|
||||
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
|
||||
console.error('\nAvailable tasks:');
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
// Show only failing tasks
|
||||
for (const task of tasks) {
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
let lastOutput = '';
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
const passed = lastOutput.trim().length > 0; // simplified check
|
||||
if (!passed) console.error(` ✗ ${task.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
const task = tasks.find(t => t.name === taskName);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${taskName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
|
||||
console.log(` Steps: ${task.steps.length}`);
|
||||
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
|
||||
console.log(` Max iterations: ${maxIterations}\n`);
|
||||
|
||||
initLog();
|
||||
|
||||
// Phase 1: Gather — run the task and capture output
|
||||
console.log('Phase 1: Gathering symptoms...');
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
|
||||
lastOutput = exec(step);
|
||||
if (i < task.steps.length - 1) {
|
||||
console.log(` → ${lastOutput.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
|
||||
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
|
||||
|
||||
// Phase 2: Hypothesize + investigate via Claude Code
|
||||
for (let iter = 1; iter <= maxIterations; iter++) {
|
||||
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
|
||||
|
||||
const prompt = `You are debugging a failing browser automation task.
|
||||
|
||||
## Task: ${taskName}
|
||||
Steps:
|
||||
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
## Judge criteria
|
||||
${JSON.stringify(task.judge)}
|
||||
|
||||
## Last output
|
||||
${lastOutput.slice(0, 500)}
|
||||
|
||||
## Instructions
|
||||
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
|
||||
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
|
||||
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
|
||||
4. If CONFIRMED: describe the root cause and suggest a fix
|
||||
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
|
||||
|
||||
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
// Extract hypothesis and result
|
||||
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
|
||||
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
|
||||
|
||||
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
|
||||
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
|
||||
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
|
||||
|
||||
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
|
||||
console.log(` Verdict: ${verdict} — ${description.slice(0, 100)}`);
|
||||
|
||||
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
|
||||
|
||||
if (verdict === 'CONFIRMED') {
|
||||
console.log(`\n✅ Root cause found at iteration ${iter}!`);
|
||||
console.log(` ${description}`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(` Error: ${err.message?.slice(0, 100)}`);
|
||||
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
|
||||
}
|
||||
|
||||
// Re-run task for fresh output
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
for (const step of task.steps) lastOutput = exec(step);
|
||||
}
|
||||
|
||||
try { exec('opencli operate close'); } catch {}
|
||||
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:fix — Iterative error elimination.
|
||||
*
|
||||
* Auto-detects broken state (build → test → browse tests) and iteratively
|
||||
* fixes errors one at a time. Stops when error count reaches 0.
|
||||
*
|
||||
* Priority: build errors → test failures → browse task failures
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/fix.ts
|
||||
* npx tsx autoresearch/commands/fix.ts --iterations 10
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function exec(cmd: string): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execSync(cmd, {
|
||||
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return { ok: true, output };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect current broken state and return verify command + error count */
|
||||
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
|
||||
// 1. Build
|
||||
const build = exec('npm run build 2>&1');
|
||||
if (!build.ok) {
|
||||
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
|
||||
return {
|
||||
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} TypeScript build error(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Tests
|
||||
const test = exec('npm test 2>&1');
|
||||
if (!test.ok) {
|
||||
const failMatch = test.output.match(/(\d+)\s+fail/i);
|
||||
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
|
||||
return {
|
||||
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
|
||||
errors: errorCount,
|
||||
description: `${errorCount} test failure(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Browse tests
|
||||
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
|
||||
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const passed = parseInt(scoreMatch[1], 10);
|
||||
const total = parseInt(scoreMatch[2], 10);
|
||||
const failures = total - passed;
|
||||
if (failures > 0) {
|
||||
return {
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
errors: failures,
|
||||
description: `${failures} browse task failure(s) (${passed}/${total})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null; // all clean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const maxIterations = args.iterations ?? 20;
|
||||
|
||||
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
|
||||
|
||||
const broken = detectBrokenState();
|
||||
if (!broken) {
|
||||
console.log(' ✓ All clean — nothing to fix!\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found: ${broken.description}`);
|
||||
console.log(` Verify: ${broken.verify}\n`);
|
||||
|
||||
const config = {
|
||||
goal: `Fix all errors: ${broken.description}`,
|
||||
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
|
||||
metric: 'error_count',
|
||||
direction: 'lower' as const,
|
||||
verify: broken.verify,
|
||||
guard: 'npm run build',
|
||||
iterations: maxIterations,
|
||||
minDelta: 1,
|
||||
};
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: async (ctx: ModifyContext) => {
|
||||
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
|
||||
|
||||
Read the error output, understand the root cause, and make ONE focused fix.
|
||||
Do NOT fix multiple unrelated errors at once.
|
||||
Do NOT modify test files.
|
||||
|
||||
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await engine.run();
|
||||
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
|
||||
if (finalMetric === 0) {
|
||||
console.log('\n✅ All errors fixed!\n');
|
||||
} else {
|
||||
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch:plan — Interactive configuration wizard.
|
||||
*
|
||||
* Walks through goal, scope, metric, verify, guard settings
|
||||
* and outputs a ready-to-paste run command.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/plan.ts
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
|
||||
|
||||
// Offer presets first
|
||||
const presetNames = Object.keys(PRESETS);
|
||||
console.log('Available presets:');
|
||||
presetNames.forEach((name, i) => {
|
||||
console.log(` [${i + 1}] ${name} — ${PRESETS[name].goal}`);
|
||||
});
|
||||
console.log(` [0] Custom config\n`);
|
||||
|
||||
const choice = await ask('Choose preset or 0 for custom: ');
|
||||
const idx = parseInt(choice, 10);
|
||||
|
||||
if (idx > 0 && idx <= presetNames.length) {
|
||||
const name = presetNames[idx - 1];
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom config
|
||||
const goal = await ask('Goal (what to improve): ');
|
||||
const scope = await ask('Scope (file globs, comma-separated): ');
|
||||
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
|
||||
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
|
||||
const verify = await ask('Verify command (must output a number): ');
|
||||
|
||||
// Dry-run verify
|
||||
console.log('\n Dry-running verify command...');
|
||||
try {
|
||||
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const { extractMetric } = await import('../config.js');
|
||||
const value = extractMetric(output);
|
||||
if (value != null) {
|
||||
console.log(` ✓ Verify works — current ${metric}: ${value}`);
|
||||
} else {
|
||||
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
|
||||
}
|
||||
|
||||
const guard = await ask('Guard command (optional, press Enter to skip): ');
|
||||
const iterations = await ask('Iterations (empty = unbounded): ');
|
||||
|
||||
const parts = ['npx tsx autoresearch/commands/run.ts'];
|
||||
parts.push(`--goal "${goal}"`);
|
||||
parts.push(`--scope "${scope}"`);
|
||||
parts.push(`--metric "${metric}"`);
|
||||
parts.push(`--direction ${direction}`);
|
||||
parts.push(`--verify "${verify}"`);
|
||||
if (guard) parts.push(`--guard "${guard}"`);
|
||||
if (iterations) parts.push(`--iterations ${iterations}`);
|
||||
|
||||
console.log(`\n✅ Ready to run:\n`);
|
||||
console.log(` ${parts.join(' \\\n ')}\n`);
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* /autoresearch — Main autonomous iteration loop.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
|
||||
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
|
||||
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
|
||||
*
|
||||
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
|
||||
* Engine handles commit, verify, guard, keep/discard, and logging.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, type AutoResearchConfig } from '../config.js';
|
||||
import { Engine, type ModifyContext } from '../engine.js';
|
||||
import { PRESETS } from '../presets/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
|
||||
const recent = ctx.recentLog.slice(-10).map(r =>
|
||||
` ${r.status.padEnd(12)} ${r.description}`
|
||||
).join('\n');
|
||||
|
||||
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
|
||||
|
||||
## Goal
|
||||
${config.goal}
|
||||
|
||||
## Current State
|
||||
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
|
||||
- Iteration: ${ctx.iteration}
|
||||
- Consecutive discards: ${ctx.consecutiveDiscards}
|
||||
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
|
||||
|
||||
## Recent History
|
||||
${recent || ' (no history yet)'}
|
||||
|
||||
## Git Log (recent experiments)
|
||||
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
|
||||
|
||||
## Scope (files you can modify)
|
||||
${ctx.scopeFiles.join('\n')}
|
||||
|
||||
## Rules
|
||||
1. Make ONE atomic change (one logical intent, even if multiple files)
|
||||
2. Read the failing test output or code BEFORE modifying
|
||||
3. DO NOT modify test files or the verify command
|
||||
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
|
||||
5. If previous approach was discarded, try something DIFFERENT
|
||||
6. Focus on the specific failures — read error messages carefully`;
|
||||
}
|
||||
|
||||
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
|
||||
const prompt = buildModifyPrompt(ctx, config);
|
||||
|
||||
console.log(' Claude Code making a change...');
|
||||
try {
|
||||
const result = execSync(
|
||||
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
|
||||
{
|
||||
cwd: ROOT,
|
||||
timeout: 180_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}
|
||||
).trim();
|
||||
|
||||
// Extract description from Claude's response (last non-empty line or summary)
|
||||
const lines = result.split('\n').filter(l => l.trim());
|
||||
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
|
||||
return desc.slice(0, 120);
|
||||
} catch (err: any) {
|
||||
console.error(' Claude Code failed:', err.message?.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Resolve config from preset or CLI args
|
||||
let config: AutoResearchConfig;
|
||||
if (args.preset) {
|
||||
config = PRESETS[args.preset];
|
||||
if (!config) {
|
||||
console.error(`Unknown preset: ${args.preset}`);
|
||||
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Allow CLI overrides
|
||||
if (args.iterations != null) config = { ...config, iterations: args.iterations };
|
||||
if (args.guard != null) config = { ...config, guard: args.guard };
|
||||
} else if (args.goal && args.verify) {
|
||||
config = {
|
||||
goal: args.goal,
|
||||
scope: args.scope ?? ['src/**/*.ts'],
|
||||
metric: args.metric ?? 'score',
|
||||
direction: args.direction ?? 'higher',
|
||||
verify: args.verify,
|
||||
guard: args.guard,
|
||||
iterations: args.iterations,
|
||||
minDelta: args.minDelta,
|
||||
};
|
||||
} else {
|
||||
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
|
||||
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
|
||||
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 AutoResearch: ${config.goal}`);
|
||||
console.log(` Metric: ${config.metric} (${config.direction})`);
|
||||
console.log(` Verify: ${config.verify}`);
|
||||
console.log(` Guard: ${config.guard ?? '(none)'}`);
|
||||
console.log(` Iterations: ${config.iterations ?? '∞'}`);
|
||||
console.log('');
|
||||
|
||||
const logPath = join(ROOT, 'autoresearch-results.tsv');
|
||||
const engine = new Engine(config, logPath, {
|
||||
modify: (ctx) => modify(ctx, config),
|
||||
onStatus: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
try {
|
||||
await engine.run();
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* AutoResearch Configuration — type definitions and CLI parsing.
|
||||
*
|
||||
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
|
||||
*/
|
||||
|
||||
export interface AutoResearchConfig {
|
||||
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
|
||||
goal: string;
|
||||
/** Glob patterns for files the agent can modify */
|
||||
scope: string[];
|
||||
/** What the metric measures, e.g. "pass_count" */
|
||||
metric: string;
|
||||
/** Whether improvement means the number goes up or down */
|
||||
direction: 'higher' | 'lower';
|
||||
/** Shell command that outputs a number (the metric value) */
|
||||
verify: string;
|
||||
/** Optional guard command — must pass for a keep decision */
|
||||
guard?: string;
|
||||
/** Max iterations (undefined = unbounded) */
|
||||
iterations?: number;
|
||||
/** Minimum delta to count as real improvement (noise filter) */
|
||||
minDelta?: number;
|
||||
}
|
||||
|
||||
export type IterationStatus =
|
||||
| 'baseline'
|
||||
| 'keep'
|
||||
| 'keep (reworked)'
|
||||
| 'discard'
|
||||
| 'crash'
|
||||
| 'no-op'
|
||||
| 'hook-blocked';
|
||||
|
||||
export interface IterationResult {
|
||||
iteration: number;
|
||||
commit: string;
|
||||
metric: number;
|
||||
delta: number;
|
||||
guard: 'pass' | 'fail' | '-';
|
||||
status: IterationStatus;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
|
||||
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
|
||||
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (arg) {
|
||||
case '--preset': config.preset = next; i++; break;
|
||||
case '--goal': config.goal = next; i++; break;
|
||||
case '--scope': config.scope = next?.split(','); i++; break;
|
||||
case '--metric': config.metric = next; i++; break;
|
||||
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
|
||||
case '--verify': config.verify = next; i++; break;
|
||||
case '--guard': config.guard = next; i++; break;
|
||||
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
|
||||
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
|
||||
case '--task': config.task = next; i++; break;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Extract a number from command output using common patterns */
|
||||
export function extractMetric(output: string): number | null {
|
||||
// Try: last line that looks like a number
|
||||
const lines = output.trim().split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
|
||||
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
|
||||
if (scoreMatch) return parseFloat(scoreMatch[1]);
|
||||
const numMatch = line.match(/^[\d.]+$/);
|
||||
if (numMatch) return parseFloat(numMatch[0]);
|
||||
}
|
||||
// Fallback: first number in output
|
||||
const fallback = output.match(/(\d+(?:\.\d+)?)/);
|
||||
return fallback ? parseFloat(fallback[1]) : null;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
|
||||
*
|
||||
* Phase 0: Precondition checks (git clean, no locks)
|
||||
* Phase 1: Review (read scope files + log + git history)
|
||||
* Phase 2: Ideate (select next change based on history)
|
||||
* Phase 3: Modify (one atomic change — delegated to caller)
|
||||
* Phase 4: Commit (git add + commit with experiment prefix)
|
||||
* Phase 5: Verify (run verify command, extract metric)
|
||||
* Phase 5.5: Guard (optional regression check)
|
||||
* Phase 6: Decide (keep/discard/crash + rollback)
|
||||
* Phase 7: Log (append TSV)
|
||||
* Phase 8: Repeat
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export interface EngineCallbacks {
|
||||
/** Called at Phase 2-3: review context, ideate, and make ONE change.
|
||||
* Return a one-sentence description of what was changed, or null to skip. */
|
||||
modify(context: ModifyContext): Promise<string | null>;
|
||||
|
||||
/** Called when engine needs to report status */
|
||||
onStatus?(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ModifyContext {
|
||||
iteration: number;
|
||||
bestMetric: number;
|
||||
currentMetric: number;
|
||||
recentLog: IterationResult[];
|
||||
gitLog: string;
|
||||
scopeFiles: string[];
|
||||
consecutiveDiscards: number;
|
||||
stuckHint: string | null;
|
||||
}
|
||||
|
||||
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
|
||||
|
||||
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: opts?.cwd ?? ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? err.message ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function execStrict(cmd: string, opts?: { timeout?: number }): string {
|
||||
return execSync(cmd, {
|
||||
cwd: ROOT,
|
||||
timeout: opts?.timeout ?? 120_000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private config: AutoResearchConfig;
|
||||
private logger: Logger;
|
||||
private callbacks: EngineCallbacks;
|
||||
private bestMetric: number = 0;
|
||||
private currentMetric: number = 0;
|
||||
private iteration: number = 0;
|
||||
|
||||
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(logPath);
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
private log(msg: string): void {
|
||||
this.callbacks.onStatus?.(msg);
|
||||
}
|
||||
|
||||
/** Phase 0: Precondition checks */
|
||||
private checkPreconditions(): void {
|
||||
// Git repo exists
|
||||
try { execStrict('git rev-parse --git-dir'); }
|
||||
catch { throw new Error('Not a git repository'); }
|
||||
|
||||
// Clean working tree
|
||||
const status = exec('git status --porcelain');
|
||||
if (status) throw new Error(`Working tree not clean:\n${status}`);
|
||||
|
||||
// No stale locks
|
||||
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
|
||||
throw new Error('Stale .git/index.lock found — remove it first');
|
||||
}
|
||||
|
||||
// Not detached HEAD
|
||||
try { execStrict('git symbolic-ref HEAD'); }
|
||||
catch { throw new Error('Detached HEAD — checkout a branch first'); }
|
||||
}
|
||||
|
||||
/** Phase 5: Run verify command and extract metric */
|
||||
private runVerify(): number | null {
|
||||
this.log(' verify...');
|
||||
const output = exec(this.config.verify, { timeout: 300_000 });
|
||||
return extractMetric(output);
|
||||
}
|
||||
|
||||
/** Phase 5.5: Run guard command */
|
||||
private runGuard(): boolean {
|
||||
if (!this.config.guard) return true;
|
||||
this.log(' guard...');
|
||||
try {
|
||||
execStrict(this.config.guard, { timeout: 300_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 4: Commit changes */
|
||||
private commit(description: string): string | null {
|
||||
// Stage all changes in scope (but not untracked outside scope)
|
||||
exec('git add -A');
|
||||
const diff = exec('git diff --cached --quiet; echo $?');
|
||||
if (diff === '0') return null; // no changes
|
||||
|
||||
try {
|
||||
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
|
||||
return exec('git rev-parse --short HEAD');
|
||||
} catch {
|
||||
// Hook failure
|
||||
exec('git reset HEAD');
|
||||
return 'hook-blocked';
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 6: Rollback */
|
||||
private safeRevert(): void {
|
||||
try {
|
||||
execStrict('git revert HEAD --no-edit');
|
||||
} catch {
|
||||
exec('git revert --abort');
|
||||
exec('git reset --hard HEAD~1');
|
||||
}
|
||||
}
|
||||
|
||||
/** Get stuck hint when >5 consecutive discards */
|
||||
private getStuckHint(discards: number): string | null {
|
||||
if (discards < 5) return null;
|
||||
const hints = [
|
||||
'Re-read ALL scope files from scratch. Try a completely different approach.',
|
||||
'Review entire results log — what worked before? Try combining successful changes.',
|
||||
'Try the OPPOSITE of what has been failing.',
|
||||
'Try a radical architectural change instead of incremental tweaks.',
|
||||
'Simplify — remove complexity rather than adding it.',
|
||||
];
|
||||
return hints[Math.min(discards - 5, hints.length - 1)];
|
||||
}
|
||||
|
||||
/** Run the main loop */
|
||||
async run(): Promise<IterationResult[]> {
|
||||
const results: IterationResult[] = [];
|
||||
|
||||
// Phase 0: Preconditions
|
||||
this.log('Phase 0: Precondition checks...');
|
||||
this.checkPreconditions();
|
||||
|
||||
// Initialize logger
|
||||
this.logger.init(this.config);
|
||||
|
||||
// Baseline measurement
|
||||
this.log('Measuring baseline...');
|
||||
const baseline = this.runVerify();
|
||||
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
|
||||
this.bestMetric = baseline;
|
||||
this.currentMetric = baseline;
|
||||
|
||||
const baselineCommit = exec('git rev-parse --short HEAD');
|
||||
const baselineResult: IterationResult = {
|
||||
iteration: 0,
|
||||
commit: baselineCommit,
|
||||
metric: baseline,
|
||||
delta: 0,
|
||||
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
|
||||
status: 'baseline',
|
||||
description: `initial state — ${this.config.metric} ${baseline}`,
|
||||
};
|
||||
this.logger.append(baselineResult);
|
||||
results.push(baselineResult);
|
||||
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
|
||||
|
||||
// Main loop
|
||||
const maxIter = this.config.iterations ?? Infinity;
|
||||
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
|
||||
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
|
||||
|
||||
// Phase 1: Review
|
||||
const gitLog = exec('git log --oneline -20');
|
||||
const recentLog = this.logger.readLast(20);
|
||||
const scopeFiles = this.config.scope;
|
||||
const consecutiveDiscards = this.logger.consecutiveDiscards();
|
||||
|
||||
// Phase 2-3: Ideate + Modify (delegated to callback)
|
||||
const context: ModifyContext = {
|
||||
iteration: this.iteration,
|
||||
bestMetric: this.bestMetric,
|
||||
currentMetric: this.currentMetric,
|
||||
recentLog,
|
||||
gitLog,
|
||||
scopeFiles,
|
||||
consecutiveDiscards,
|
||||
stuckHint: this.getStuckHint(consecutiveDiscards),
|
||||
};
|
||||
|
||||
let description: string | null;
|
||||
try {
|
||||
description = await this.callbacks.modify(context);
|
||||
} catch (err: any) {
|
||||
this.log(` modify error: ${err.message}`);
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `modify crashed: ${err.message?.slice(0, 80)}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: 'no changes made',
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Commit
|
||||
this.log(` commit: ${description}`);
|
||||
const commitHash = this.commit(description);
|
||||
if (!commitHash) {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'no-op',
|
||||
description: `no diff after: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
if (commitHash === 'hook-blocked') {
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'hook-blocked',
|
||||
description: `hook rejected: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 5: Verify
|
||||
const metric = this.runVerify();
|
||||
if (metric == null) {
|
||||
this.log(' verify crashed — reverting');
|
||||
this.safeRevert();
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: '-',
|
||||
metric: this.currentMetric,
|
||||
delta: 0,
|
||||
guard: '-',
|
||||
status: 'crash',
|
||||
description: `verify crashed: ${description}`,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
const improved = this.config.direction === 'higher'
|
||||
? metric > this.bestMetric
|
||||
: metric < this.bestMetric;
|
||||
const delta = +(metric - this.bestMetric).toFixed(4);
|
||||
const absDelta = Math.abs(delta);
|
||||
const minDelta = this.config.minDelta ?? 0;
|
||||
|
||||
// Phase 5.5: Guard
|
||||
let guardResult: 'pass' | 'fail' | '-' = '-';
|
||||
if (this.config.guard && improved && absDelta >= minDelta) {
|
||||
guardResult = this.runGuard() ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
// Phase 6: Decide
|
||||
let status: IterationStatus;
|
||||
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
|
||||
status = 'keep';
|
||||
this.bestMetric = metric;
|
||||
this.currentMetric = metric;
|
||||
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
} else if (improved && guardResult === 'fail') {
|
||||
this.log(' guard failed — reverting');
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
this.log(` ✗ DISCARD (guard) — ${description}`);
|
||||
} else {
|
||||
this.safeRevert();
|
||||
status = 'discard';
|
||||
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
|
||||
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
|
||||
}
|
||||
|
||||
const result: IterationResult = {
|
||||
iteration: this.iteration,
|
||||
commit: status === 'keep' ? commitHash : '-',
|
||||
metric,
|
||||
delta,
|
||||
guard: guardResult,
|
||||
status,
|
||||
description,
|
||||
};
|
||||
this.logger.append(result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
|
||||
const discards = results.filter(r => r.status === 'discard');
|
||||
this.log(`\n${'━'.repeat(50)}`);
|
||||
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
|
||||
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Combined Test Suite Runner — runs browse + V2EX + Zhihu tasks.
|
||||
* Reports combined score for AutoResearch iteration.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-all.ts # Run all
|
||||
* npx tsx autoresearch/eval-all.ts --suite v2ex # Run one suite
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface SuiteResult {
|
||||
name: string;
|
||||
passed: number;
|
||||
total: number;
|
||||
failures: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function runSuite(name: string, script: string): SuiteResult {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const output = execSync(`npx tsx ${script}`, {
|
||||
cwd: ROOT,
|
||||
timeout: 600_000,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Parse SCORE=X/Y from output
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
|
||||
// Parse failures
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
const output = err.stdout ?? '';
|
||||
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
|
||||
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
|
||||
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
|
||||
const failures: string[] = [];
|
||||
const failLines = output.match(/✗.*$/gm) || [];
|
||||
for (const line of failLines) {
|
||||
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
|
||||
if (m) failures.push(m[1].replace(/:$/, ''));
|
||||
}
|
||||
return { name, passed, total, failures, duration: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleSuite = args.includes('--suite') ? args[args.indexOf('--suite') + 1] : null;
|
||||
|
||||
const suites = [
|
||||
{ name: 'browse', script: 'autoresearch/eval-browse.ts' },
|
||||
{ name: 'v2ex', script: 'autoresearch/eval-v2ex.ts' },
|
||||
{ name: 'zhihu', script: 'autoresearch/eval-zhihu.ts' },
|
||||
].filter(s => !singleSuite || s.name === singleSuite);
|
||||
|
||||
console.log(`\n🔬 Combined AutoResearch — ${suites.length} suites\n`);
|
||||
|
||||
const results: SuiteResult[] = [];
|
||||
for (const suite of suites) {
|
||||
console.log(` Running ${suite.name}...`);
|
||||
const result = runSuite(suite.name, suite.script);
|
||||
results.push(result);
|
||||
const icon = result.passed === result.total ? '✓' : '✗';
|
||||
console.log(` ${icon} ${result.name}: ${result.passed}/${result.total} (${Math.round(result.duration / 1000)}s)`);
|
||||
if (result.failures.length > 0) {
|
||||
for (const f of result.failures.slice(0, 5)) {
|
||||
console.log(` ✗ ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const totalPassed = results.reduce((s, r) => s + r.passed, 0);
|
||||
const totalTasks = results.reduce((s, r) => s + r.total, 0);
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
const allFailures = results.flatMap(r => r.failures.map(f => `${r.name}:${f}`));
|
||||
|
||||
console.log(`\n${'━'.repeat(50)}`);
|
||||
console.log(` Combined: ${totalPassed}/${totalTasks}`);
|
||||
for (const r of results) {
|
||||
console.log(` ${r.name}: ${r.passed}/${r.total}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
if (allFailures.length > 0) {
|
||||
console.log(`\n All failures:`);
|
||||
for (const f of allFailures) console.log(` ✗ ${f}`);
|
||||
}
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('all-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `all-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${totalTasks}`,
|
||||
suites: Object.fromEntries(results.map(r => [r.name, `${r.passed}/${r.total}`])),
|
||||
failures: allFailures,
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(`\n Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${totalTasks}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -76,7 +76,7 @@ function runCommand(cmd: string): string {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'skill-tasks.yaml');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
|
||||
|
||||
@@ -160,13 +159,20 @@ Always close the browser with 'opencli operate close' when done.`;
|
||||
}
|
||||
|
||||
function extractVerdict(text: string): { success: boolean; explanation: string } {
|
||||
// Try to find {"success": ...} JSON in the text
|
||||
const jsonMatches = text.match(/\{"success"\s*:\s*(true|false)\s*,\s*"explanation"\s*:\s*"([^"]*)"\s*\}/g);
|
||||
if (jsonMatches) {
|
||||
const last = jsonMatches[jsonMatches.length - 1];
|
||||
try {
|
||||
return JSON.parse(last);
|
||||
} catch { /* fall through */ }
|
||||
// Try to find and parse {"success": ...} JSON from the last occurrence
|
||||
const idx = text.lastIndexOf('{"success"');
|
||||
if (idx !== -1) {
|
||||
// Find the matching closing brace (handle escaped quotes in explanation)
|
||||
const sub = text.slice(idx);
|
||||
let braceCount = 0;
|
||||
let end = -1;
|
||||
for (let i = 0; i < sub.length; i++) {
|
||||
if (sub[i] === '{') braceCount++;
|
||||
else if (sub[i] === '}') { braceCount--; if (braceCount === 0) { end = i + 1; break; } }
|
||||
}
|
||||
if (end > 0) {
|
||||
try { return JSON.parse(sub.slice(0, end)); } catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for success indicators in text
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* V2EX Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task v2ex-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'v2ex-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name prefix pattern
|
||||
function getLayer(name: string): string {
|
||||
if (['v2ex-open-', 'v2ex-state-', 'v2ex-get-title', 'v2ex-click-tab', 'v2ex-scroll-down',
|
||||
'v2ex-get-first-', 'v2ex-eval-extract', 'v2ex-get-url', 'v2ex-back-nav', 'v2ex-wait-'].some(p => name.startsWith(p)))
|
||||
return 'L1-atomic';
|
||||
if (['v2ex-hot-topics', 'v2ex-node-list', 'v2ex-topic-meta', 'v2ex-node-topics',
|
||||
'v2ex-node-pagination', 'v2ex-tab-content', 'v2ex-topic-replies-extract',
|
||||
'v2ex-topic-reply-count', 'v2ex-member-info', 'v2ex-search-results'].includes(name))
|
||||
return 'L2-single-page';
|
||||
if (['v2ex-click-topic-read', 'v2ex-click-author', 'v2ex-navigate-node', 'v2ex-pagination-page2',
|
||||
'v2ex-topic-and-back', 'v2ex-tab-then-topic', 'v2ex-scroll-find-more',
|
||||
'v2ex-node-to-topic', 'v2ex-multi-tab-compare', 'v2ex-topic-reply-to-author'].some(p => name.startsWith(p)))
|
||||
return 'L3-multi-step';
|
||||
if (['v2ex-reply-', 'v2ex-favorite-', 'v2ex-thank-', 'v2ex-create-'].some(p => name.startsWith(p)))
|
||||
return 'L4-write';
|
||||
if (['v2ex-collect-', 'v2ex-multi-node-', 'v2ex-topic-deep-', 'v2ex-cross-page-', 'v2ex-full-'].some(p => name.startsWith(p)))
|
||||
return 'L5-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 V2EX Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Zhihu Test Suite: Deterministic command testing against v2ex.com.
|
||||
*
|
||||
* 40 tasks across 5 difficulty layers:
|
||||
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
|
||||
* → L4 Write Ops (5) → L5 Complex Chain (5)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-v2ex.ts --task zhihu-hot-topics # Run single task
|
||||
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TASKS_FILE = join(__dirname, 'zhihu-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface BrowseTask {
|
||||
name: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
set?: 'test';
|
||||
note?: string;
|
||||
_comment?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
error?: string;
|
||||
layer: string;
|
||||
}
|
||||
|
||||
// Layer classification by task name
|
||||
function getLayer(name: string): string {
|
||||
const l1 = ['zhihu-open-home', 'zhihu-get-title', 'zhihu-state', 'zhihu-get-url', 'zhihu-scroll-down',
|
||||
'zhihu-click-tab-hot', 'zhihu-back-navigation', 'zhihu-wait-page-load', 'zhihu-keys-escape', 'zhihu-screenshot'];
|
||||
const l2 = ['zhihu-feed-titles', 'zhihu-hot-list', 'zhihu-hot-metrics', 'zhihu-nav-tabs',
|
||||
'zhihu-feed-with-authors', 'zhihu-feed-types', 'zhihu-user-avatar', 'zhihu-search-input-exists'];
|
||||
const l3 = ['zhihu-question-title', 'zhihu-question-meta', 'zhihu-first-answer', 'zhihu-answer-votes',
|
||||
'zhihu-question-buttons', 'zhihu-multiple-answers', 'zhihu-question-description', 'zhihu-answer-count-number'];
|
||||
const l4 = ['zhihu-hot-to-question', 'zhihu-feed-to-question', 'zhihu-question-to-author',
|
||||
'zhihu-search-navigate', 'zhihu-topic-page', 'zhihu-user-profile', 'zhihu-question-and-back', 'zhihu-scroll-load-more'];
|
||||
const l5 = ['zhihu-upvote-button-find', 'zhihu-follow-question-find', 'zhihu-comment-button-find',
|
||||
'zhihu-bookmark-find', 'zhihu-write-answer-btn', 'zhihu-share-find'];
|
||||
const l6 = ['zhihu-hot-read-answer-author', 'zhihu-hot-to-author-profile', 'zhihu-multi-hot-topics',
|
||||
'zhihu-search-then-read', 'zhihu-question-scroll-answers', 'zhihu-compare-tabs', 'zhihu-user-answers', 'zhihu-topic-questions'];
|
||||
const l7 = ['zhihu-search-basic', 'zhihu-search-people', 'zhihu-search-topic',
|
||||
'zhihu-search-click-result', 'zhihu-search-filter-answers', 'zhihu-search-and-back'];
|
||||
const l8 = ['zhihu-full-browse-workflow', 'zhihu-deep-author-chain', 'zhihu-cross-question-compare',
|
||||
'zhihu-search-read-chain', 'zhihu-3-page-chain', 'zhihu-hot-scroll-deep-read'];
|
||||
|
||||
if (l1.includes(name)) return 'L1-atomic';
|
||||
if (l2.includes(name)) return 'L2-feed';
|
||||
if (l3.includes(name)) return 'L3-question';
|
||||
if (l4.includes(name)) return 'L4-navigation';
|
||||
if (l5.includes(name)) return 'L5-write';
|
||||
if (l6.includes(name)) return 'L6-chain';
|
||||
if (l7.includes(name)) return 'L7-search';
|
||||
if (l8.includes(name)) return 'L8-complex';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function judge(criteria: JudgeCriteria, output: string): boolean {
|
||||
try {
|
||||
switch (criteria.type) {
|
||||
case 'contains':
|
||||
return output.toLowerCase().includes(criteria.value.toLowerCase());
|
||||
case 'arrayMinLength': {
|
||||
try {
|
||||
const arr = JSON.parse(output);
|
||||
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
|
||||
} catch { /* not JSON array */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern).test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: join(__dirname, '..'),
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: BrowseTask): TaskResult {
|
||||
const start = Date.now();
|
||||
let lastOutput = '';
|
||||
|
||||
try {
|
||||
for (const step of task.steps) {
|
||||
lastOutput = runCommand(step);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 100),
|
||||
layer: getLayer(task.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
|
||||
|
||||
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
|
||||
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
|
||||
|
||||
let tasks = allTasks;
|
||||
if (singleTask) {
|
||||
tasks = allTasks.filter(t => t.name === singleTask);
|
||||
} else if (layerFilter) {
|
||||
const prefix = `L${layerFilter}`;
|
||||
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔬 Zhihu Test Suite — ${tasks.length} tasks\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const icon = result.passed ? '✓' : '✗';
|
||||
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
|
||||
|
||||
// Close browser between tasks for clean state
|
||||
if (i < tasks.length - 1) {
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Final close
|
||||
try { runCommand('opencli operate close'); } catch { /* ignore */ }
|
||||
|
||||
// Summary by layer
|
||||
const layers = [...new Set(results.map(r => r.layer))].sort();
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Total: ${totalPassed}/${results.length}`);
|
||||
for (const layer of layers) {
|
||||
const layerResults = results.filter(r => r.layer === layer);
|
||||
const layerPassed = layerResults.filter(r => r.passed).length;
|
||||
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
|
||||
}
|
||||
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
layers: Object.fromEntries(layers.map(l => {
|
||||
const lr = results.filter(r => r.layer === l);
|
||||
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
|
||||
})),
|
||||
duration: `${Math.round(totalDuration / 60000)}min`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AutoResearch TSV Logger — append-only results log with metadata header.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import type { AutoResearchConfig, IterationResult } from './config.js';
|
||||
|
||||
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
|
||||
|
||||
export class Logger {
|
||||
constructor(private path: string) {}
|
||||
|
||||
/** Create the TSV file with metadata header */
|
||||
init(config: AutoResearchConfig): void {
|
||||
const header = [
|
||||
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
|
||||
`# goal: ${config.goal}`,
|
||||
`# scope: ${config.scope.join(', ')}`,
|
||||
`# verify: ${config.verify}`,
|
||||
config.guard ? `# guard: ${config.guard}` : null,
|
||||
COLUMNS.join('\t'),
|
||||
].filter(Boolean).join('\n');
|
||||
writeFileSync(this.path, header + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Append one iteration result */
|
||||
append(result: IterationResult): void {
|
||||
const row = [
|
||||
result.iteration,
|
||||
result.commit,
|
||||
result.metric,
|
||||
result.delta >= 0 ? `+${result.delta}` : result.delta,
|
||||
result.guard,
|
||||
result.status,
|
||||
result.description,
|
||||
].join('\t');
|
||||
appendFileSync(this.path, row + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/** Read last N entries for pattern recognition */
|
||||
readLast(n: number): IterationResult[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const lines = readFileSync(this.path, 'utf-8').split('\n')
|
||||
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
|
||||
return lines.slice(-n).map(line => {
|
||||
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
|
||||
return {
|
||||
iteration: parseInt(iteration, 10),
|
||||
commit,
|
||||
metric: parseFloat(metric),
|
||||
delta: parseFloat(delta),
|
||||
guard: guard as 'pass' | 'fail' | '-',
|
||||
status: status as IterationResult['status'],
|
||||
description: desc.join('\t'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count consecutive discards from the end */
|
||||
consecutiveDiscards(): number {
|
||||
const entries = this.readLast(20);
|
||||
let count = 0;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].status === 'discard') count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Preset: Combined Reliability (browse + V2EX + Zhihu)
|
||||
*
|
||||
* Optimizes across ALL test suites simultaneously.
|
||||
* Current baseline: 57/59 + 60/60 + 60/60 = 177/179
|
||||
* Target: 179/179 (100%)
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const combinedReliability: AutoResearchConfig = {
|
||||
goal: 'Fix all remaining test failures across browse + V2EX + Zhihu (177/179 → 179/179)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
'autoresearch/browse-tasks.json',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-all.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 10,
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export { operateReliability } from './operate-reliability.js';
|
||||
export { skillQuality } from './skill-quality.js';
|
||||
export { v2exReliability } from './v2ex-reliability.js';
|
||||
export { zhihuReliability } from './zhihu-reliability.js';
|
||||
export { combinedReliability } from './combined-reliability.js';
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
import { operateReliability } from './operate-reliability.js';
|
||||
import { skillQuality } from './skill-quality.js';
|
||||
import { v2exReliability } from './v2ex-reliability.js';
|
||||
import { zhihuReliability } from './zhihu-reliability.js';
|
||||
import { combinedReliability } from './combined-reliability.js';
|
||||
|
||||
export const PRESETS: Record<string, AutoResearchConfig> = {
|
||||
'operate-reliability': operateReliability,
|
||||
'skill-quality': skillQuality,
|
||||
'v2ex-reliability': v2exReliability,
|
||||
'zhihu-reliability': zhihuReliability,
|
||||
'combined': combinedReliability,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: Operate Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
|
||||
* Metric: number of passing browse-tasks (out of 59).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const operateReliability: AutoResearchConfig = {
|
||||
goal: 'Increase operate command pass rate to 59/59 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Preset: Skill E2E Quality
|
||||
*
|
||||
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
|
||||
* Metric: number of passing skill-tasks (out of 35).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const skillQuality: AutoResearchConfig = {
|
||||
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
|
||||
scope: [
|
||||
'skills/opencli-operate/SKILL.md',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
iterations: 20,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Preset: V2EX Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the V2EX-specific test suite.
|
||||
* 40 tasks across 5 difficulty layers (atomic → complex chain).
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const v2exReliability: AutoResearchConfig = {
|
||||
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Preset: Zhihu Command Reliability
|
||||
*
|
||||
* Optimizes opencli operate commands against the Zhihu test suite.
|
||||
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
|
||||
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
|
||||
*/
|
||||
|
||||
import type { AutoResearchConfig } from '../config.js';
|
||||
|
||||
export const zhihuReliability: AutoResearchConfig = {
|
||||
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
|
||||
scope: [
|
||||
'src/browser/dom-snapshot.ts',
|
||||
'src/browser/dom-helpers.ts',
|
||||
'src/browser/base-page.ts',
|
||||
'src/browser/page.ts',
|
||||
'src/cli.ts',
|
||||
],
|
||||
metric: 'pass_count',
|
||||
direction: 'higher',
|
||||
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
|
||||
guard: 'npm run build',
|
||||
minDelta: 1,
|
||||
};
|
||||
@@ -0,0 +1,899 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-tab",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-first-topic-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-extract-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "v2ex.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait selector \"a[href^='/t/']\"",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-list",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-pagination-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"max\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=jobs",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-replies-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
|
||||
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-count",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-member-info",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Livid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-search-results",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-topic-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-click-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-navigate-node-from-home",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-pagination-page2",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "p=2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "v2ex\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-tab-then-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-find-more",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-node-to-topic-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-tab-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/?tab=creative",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-reply-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-type-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoResearch test reply"
|
||||
},
|
||||
"note": "Types into reply box but does NOT submit"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-favorite-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "favorite|收藏"
|
||||
},
|
||||
"note": "Finds favorite link but does NOT click it"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-thank-reply-find",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"found\":\\d+"
|
||||
},
|
||||
"note": "Finds thank buttons but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-reply-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "\"textarea\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-create-topic-form-detect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/new",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
},
|
||||
"note": "Detects create topic form elements, does NOT submit"
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-collect-hot-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-multi-node-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-topic-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-cross-page-data-collect",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-full-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
|
||||
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.+\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate click 1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-click-tab-tech",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+|no-ref"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-count-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-state-scroll-state",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-type-search-box",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-value-after-type",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-screenshot-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-get-html-selector",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate get html --selector h1"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-wait-text",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate wait text V2EX"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "found|appeared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-3-pages",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-navigate-extract-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
|
||||
"opencli operate back",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "V2EX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-multi-node-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/go",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-topic-replies-pagination",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-member-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/member/Livid",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-search-navigate-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-tab-topic-author",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=tech",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-node-page2-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=2",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer?p=3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-full-interaction",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
|
||||
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-chain-deep-5-step",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
|
||||
"opencli operate back",
|
||||
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate open https://v2ex.com/go/python",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/go/python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-eval-after-click",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on topic page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-scroll-and-extract",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-concurrent-eval",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":.*\"url\":.*\"links\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-unicode-content",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-first-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
},
|
||||
"note": "Finds the index of first topic link via data-opencli-ref"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-type-search",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state",
|
||||
"opencli operate type 3 TypeScript",
|
||||
"opencli operate get value 3"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
"note": "Types into search box using state index"
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-click-navigate-back",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/?tab=hot",
|
||||
"opencli operate state",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-has-interactive",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "interactive: \\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "v2ex-agent-state-after-scroll",
|
||||
"steps": [
|
||||
"opencli operate open https://v2ex.com/go/programmer",
|
||||
"opencli operate scroll down --amount 800",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "page_scroll: [\\d.]+↑"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,848 @@
|
||||
[
|
||||
{
|
||||
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-open-home",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Navigated to"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "知乎"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-state",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate state"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\[@?\\d+\\]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-get-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-down",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate scroll down --amount 500",
|
||||
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "scrolled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-click-tab-hot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "clicked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-back-navigation",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate back",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "zhihu\\.com/?$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-wait-page-load",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate wait text 推荐",
|
||||
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "loaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-keys-escape",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate keys Escape"
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "pressed|Pressed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-screenshot",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate screenshot /tmp/zhihu-test.png"
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-titles",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-list",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-metrics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-nav-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-with-authors",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-types",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"question\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-avatar",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-input-exists",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search found"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-title",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-meta",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-first-answer",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"content\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-votes",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-buttons",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multiple-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-description",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-answer-count-number",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-feed-to-question",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-to-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-page",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"name\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "zhihu.com/hot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-load-more",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-upvote-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "赞同"
|
||||
},
|
||||
"note": "Finds upvote button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-follow-question-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "关注问题"
|
||||
},
|
||||
"note": "Finds follow button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-comment-button-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "评论"
|
||||
},
|
||||
"note": "Finds comment button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-bookmark-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "收藏|found"
|
||||
},
|
||||
"note": "Finds bookmark button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-write-answer-btn",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "写回答"
|
||||
},
|
||||
"note": "Finds write answer button but does NOT click"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-share-find",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "分享"
|
||||
},
|
||||
"note": "Finds share button but does NOT click"
|
||||
},
|
||||
{
|
||||
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-read-answer-author",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"author\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-to-author-profile",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-multi-hot-topics",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-scroll-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-compare-tabs",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-user-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-topic-questions",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L7: Search Workflows (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-basic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate scroll down --amount 300",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-people",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-topic",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-click-result",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-filter-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"total\":\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-and-back",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 5",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate get url"
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-full-browse-workflow",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-deep-author-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"profile\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-cross-question-compare",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"q1_title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-read-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-3-page-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate eval \"document.title\"",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-scroll-deep-read",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate scroll down --amount 1000",
|
||||
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"title\":"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
|
||||
},
|
||||
{
|
||||
"name": "zhihu-rapid-navigate",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/",
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate open https://www.zhihu.com/people/excited-vczh",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "/people/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-hot-click-verify-url",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "on question page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-scroll-lazy-answers",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
|
||||
"opencli operate scroll down --amount 2000",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\\d+"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-extract-structured",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zhihu-question-answer-chain",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "\"answerCount\":\\d+"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,615 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^6.0.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
|
||||
|
||||
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
|
||||
|
||||
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
|
||||
|
||||
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
|
||||
|
||||
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
|
||||
|
||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ=="],
|
||||
|
||||
"@algolia/client-analytics": ["@algolia/client-analytics@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q=="],
|
||||
|
||||
"@algolia/client-common": ["@algolia/client-common@5.49.2", "", {}, "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw=="],
|
||||
|
||||
"@algolia/client-insights": ["@algolia/client-insights@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA=="],
|
||||
|
||||
"@algolia/client-personalization": ["@algolia/client-personalization@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA=="],
|
||||
|
||||
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA=="],
|
||||
|
||||
"@algolia/client-search": ["@algolia/client-search@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg=="],
|
||||
|
||||
"@algolia/ingestion": ["@algolia/ingestion@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw=="],
|
||||
|
||||
"@algolia/monitoring": ["@algolia/monitoring@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg=="],
|
||||
|
||||
"@algolia/recommend": ["@algolia/recommend@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA=="],
|
||||
|
||||
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA=="],
|
||||
|
||||
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ=="],
|
||||
|
||||
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
||||
|
||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
||||
|
||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
||||
|
||||
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
||||
|
||||
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
||||
|
||||
"algoliasearch": ["algoliasearch@5.49.2", "", { "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", "@algolia/client-analytics": "5.49.2", "@algolia/client-common": "5.49.2", "@algolia/client-insights": "5.49.2", "@algolia/client-personalization": "5.49.2", "@algolia/client-query-suggestions": "5.49.2", "@algolia/client-search": "5.49.2", "@algolia/ingestion": "1.49.2", "@algolia/monitoring": "1.49.2", "@algolia/recommend": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": "bin/esbuild" }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
||||
|
||||
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3"], "bin": "bin/vitepress.js" }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
|
||||
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
{ text: 'Grok', link: '/adapters/browser/grok' },
|
||||
{ text: 'Amazon', link: '/adapters/browser/amazon' },
|
||||
{ text: 'Gemini', link: '/adapters/browser/gemini' },
|
||||
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
|
||||
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
|
||||
{ text: 'WeRead', link: '/adapters/browser/weread' },
|
||||
{ text: 'Douban', link: '/adapters/browser/douban' },
|
||||
@@ -91,6 +92,7 @@ export default defineConfig({
|
||||
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
|
||||
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
|
||||
{ text: 'Weixin', link: '/adapters/browser/weixin' },
|
||||
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Xianyu (闲鱼)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `goofish.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xianyu search <query>` | Search Xianyu items by keyword and return item cards with `item_id` |
|
||||
| `opencli xianyu item <item_id>` | Fetch item details including title, price, condition, brand, seller, and image URLs |
|
||||
| `opencli xianyu chat <item_id> <user_id>` | Open a Xianyu chat session for the item/user pair and optionally send a message with `--text` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Search items
|
||||
opencli xianyu search "macbook" --limit 5
|
||||
|
||||
# Read a single item's details
|
||||
opencli xianyu item 1040754408976
|
||||
|
||||
# Open a chat session
|
||||
opencli xianyu chat 1038951278192 3650092411
|
||||
|
||||
# Send a message in chat
|
||||
opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?"
|
||||
|
||||
# JSON output
|
||||
opencli xianyu search "笔记本电脑" -f json
|
||||
opencli xianyu item 1040754408976 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** `goofish.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `search` returns `item_id`, which can be passed directly into `opencli xianyu item`
|
||||
- `chat` requires both the item ID and the target user's `user_id` / `peerUserId`
|
||||
- Browser-authenticated commands depend on the active Chrome login session remaining valid
|
||||
@@ -0,0 +1,64 @@
|
||||
# Yuanbao
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli yuanbao new` | Start a new Yuanbao conversation |
|
||||
| `opencli yuanbao ask <prompt>` | Send a prompt to Yuanbao web chat and wait for the reply |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Start a fresh chat
|
||||
opencli yuanbao new
|
||||
|
||||
# Basic ask (internet search on by default, deep thinking off by default)
|
||||
opencli yuanbao ask "你好"
|
||||
|
||||
# Wait longer for a longer answer
|
||||
opencli yuanbao ask "帮我总结这篇文章" --timeout 90
|
||||
|
||||
# Disable internet search explicitly
|
||||
opencli yuanbao ask "你好" --search false
|
||||
|
||||
# Enable deep thinking explicitly
|
||||
opencli yuanbao ask "你好" --think true
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### `new`
|
||||
|
||||
- No options
|
||||
|
||||
### `ask`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `prompt` | Prompt to send (required positional argument) |
|
||||
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
|
||||
| `--search` | Enable internet search before sending (default: `true`) |
|
||||
| `--think` | Enable deep thinking before sending (default: `false`) |
|
||||
|
||||
## Behavior
|
||||
|
||||
- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer.
|
||||
- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed.
|
||||
- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state.
|
||||
- It waits for transcript changes to stabilize before returning the assistant reply.
|
||||
- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome is running
|
||||
- You are already logged into `yuanbao.tencent.com`
|
||||
- [Browser Bridge extension](/guide/browser-bridge) is installed
|
||||
|
||||
## Caveats
|
||||
|
||||
- This adapter drives the Yuanbao web UI, not a public API.
|
||||
- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI.
|
||||
- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction.
|
||||
@@ -30,6 +30,7 @@ Run `opencli list` for the live registry.
|
||||
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
|
||||
| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser |
|
||||
| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser |
|
||||
| **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser |
|
||||
| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser |
|
||||
| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
|
||||
@@ -54,6 +55,7 @@ Run `opencli list` for the live registry.
|
||||
| **[zsxq](./browser/zsxq)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser |
|
||||
| **[bluesky](./browser/bluesky)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public |
|
||||
| **[douyin](./browser/douyin)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
|
||||
| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# V2EX AutoResearch Test Suite Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a comprehensive test suite using V2EX (https://v2ex.com/) as the single target website to iteratively improve OpenCLI Operate's reliability and Claude Code skill effectiveness. Run 10 rounds of AutoResearch iteration (5 code-level + 5 SKILL.md-level).
|
||||
|
||||
## Test Suite Structure — 5 Layers, 40 Tasks
|
||||
|
||||
### Layer 1: Atomic (10 tasks)
|
||||
Single operate commands testing command-level reliability.
|
||||
|
||||
### Layer 2: Single Page (10 tasks)
|
||||
Meaningful extraction/interaction within one page.
|
||||
|
||||
### Layer 3: Multi-Step (10 tasks)
|
||||
Cross-page navigation + extraction combos.
|
||||
|
||||
### Layer 4: Write Operations (5 tasks)
|
||||
Login-required write operations (reply, favorite, thank).
|
||||
|
||||
### Layer 5: Complex Chain (5 tasks)
|
||||
Long chains: cross-post reference, multi-node comparison, full workflows.
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
- **v2ex-tasks.json** — Layer 1 deterministic tasks (browse commands + judge criteria)
|
||||
- **eval-v2ex.ts** — Runner for V2EX tasks (reuses eval-browse.ts pattern)
|
||||
- **v2ex-skill-tasks** — Layer 2 LLM E2E tasks embedded in eval runner
|
||||
- **presets/v2ex-reliability.ts** — AutoResearch preset for code optimization
|
||||
- **presets/v2ex-skill.ts** — AutoResearch preset for SKILL.md optimization
|
||||
|
||||
## AutoResearch Iteration Plan
|
||||
|
||||
- Rounds 1-5: Layer 1 preset → optimize src/browser/*.ts code
|
||||
- Rounds 6-10: Layer 2 preset → optimize skills/opencli-operate/SKILL.md
|
||||
- Alternating: fix code issues first, then improve LLM guidance
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Layer 1 baseline → target 100% pass rate after 5 code iterations
|
||||
- Layer 2 baseline → target 100% pass rate after 5 SKILL.md iterations
|
||||
Vendored
+1127
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,12 @@ function createChromeMock() {
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
return tab;
|
||||
}),
|
||||
move: vi.fn(async (tabId: number, moveProps: { windowId: number; index: number }) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
tab.windowId = moveProps.windowId;
|
||||
return tab;
|
||||
}),
|
||||
onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
|
||||
},
|
||||
windows: {
|
||||
@@ -219,6 +225,39 @@ describe('background tab isolation', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('moves drifted tab back to automation window instead of creating a new one', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
// Tab 1 belongs to automation window 1 but drifted to window 2
|
||||
tabs[0].windowId = 2;
|
||||
tabs[0].url = 'https://twitter.com/home';
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
|
||||
|
||||
// Should have moved tab 1 back to window 1 and reused it
|
||||
expect(chrome.tabs.move).toHaveBeenCalledWith(1, { windowId: 1, index: -1 });
|
||||
expect(tabId).toBe(1);
|
||||
});
|
||||
|
||||
it('falls through to re-resolve when drifted tab move fails', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
tabs[0].windowId = 2;
|
||||
tabs[0].url = 'https://twitter.com/home';
|
||||
// Make move fail
|
||||
chrome.tabs.move = vi.fn(async () => { throw new Error('Cannot move tab'); });
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
// Should still resolve (by finding/creating a tab in the correct window)
|
||||
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
|
||||
expect(typeof tabId).toBe('number');
|
||||
});
|
||||
|
||||
it('idle timeout closes the automation window for site:notebooklm', async () => {
|
||||
const { chrome, tabs } = createChromeMock();
|
||||
tabs[0].url = 'https://notebooklm.google.com/';
|
||||
|
||||
+172
-10
@@ -117,6 +117,8 @@ type AutomationSession = {
|
||||
windowId: number;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
idleDeadlineAt: number;
|
||||
owned: boolean;
|
||||
preferredTabId: number | null;
|
||||
};
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
@@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void {
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
if (!current.owned) {
|
||||
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
|
||||
automationSessions.delete(workspace);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
@@ -177,6 +184,8 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom
|
||||
windowId: win.id!,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
owned: true,
|
||||
preferredTabId: null,
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
|
||||
@@ -279,6 +288,14 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
||||
return await handleSessions(cmd);
|
||||
case 'set-file-input':
|
||||
return await handleSetFileInput(cmd, workspace);
|
||||
case 'insert-text':
|
||||
return await handleInsertText(cmd, workspace);
|
||||
case 'bind-current':
|
||||
return await handleBindCurrent(cmd, workspace);
|
||||
case 'network-capture-start':
|
||||
return await handleNetworkCaptureStart(cmd, workspace);
|
||||
case 'network-capture-read':
|
||||
return await handleNetworkCaptureRead(cmd, workspace);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
@@ -326,7 +343,31 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
|
||||
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
||||
}
|
||||
|
||||
function setWorkspaceSession(workspace: string, session: Pick<AutomationSession, 'windowId'>): void {
|
||||
function matchesDomain(url: string | undefined, domain: string): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
|
||||
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
|
||||
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
|
||||
if (cmd.matchPathPrefix) {
|
||||
try {
|
||||
const parsed = new URL(tab.url!);
|
||||
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
||||
automationSessions.set(workspace, {
|
||||
@@ -348,10 +389,23 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const session = automationSessions.get(workspace);
|
||||
const matchesSession = session ? tab.windowId === session.windowId : false;
|
||||
const matchesSession = session
|
||||
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
|
||||
: false;
|
||||
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
|
||||
if (session && !matchesSession) {
|
||||
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
||||
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
|
||||
// Tab drifted to another window but content is still valid.
|
||||
// Try to move it back instead of abandoning it.
|
||||
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
||||
const moved = await chrome.tabs.get(tabId);
|
||||
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
|
||||
return { tabId, tab: moved };
|
||||
}
|
||||
} catch (moveErr) {
|
||||
console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
|
||||
}
|
||||
} else if (!isDebuggableUrl(tab.url)) {
|
||||
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
||||
}
|
||||
@@ -360,6 +414,16 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = automationSessions.get(workspace);
|
||||
if (existingSession?.preferredTabId !== null) {
|
||||
try {
|
||||
const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
|
||||
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab };
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
// Get (or create) the automation window
|
||||
const windowId = await getAutomationWindow(workspace, initialUrl);
|
||||
|
||||
@@ -397,6 +461,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
|
||||
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
if (session.preferredTabId !== null) {
|
||||
try {
|
||||
return [await chrome.tabs.get(session.preferredTabId)];
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
@@ -502,7 +574,22 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
let tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Post-navigation drift detection: if the tab moved to another window
|
||||
// during navigation (e.g. a tab-management extension regrouped it),
|
||||
// try to move it back to maintain session isolation.
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session && tab.windowId !== session.windowId) {
|
||||
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`);
|
||||
try {
|
||||
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
||||
tab = await chrome.tabs.get(tabId);
|
||||
} catch (moveErr) {
|
||||
console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
@@ -655,10 +742,12 @@ async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
|
||||
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
if (session.owned) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
}
|
||||
}
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
@@ -679,6 +768,39 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise<Resu
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInsertText(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (typeof cmd.text !== 'string') {
|
||||
return { id: cmd.id, ok: false, error: 'Missing text payload' };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
await executor.insertText(tabId, cmd.text);
|
||||
return { id: cmd.id, ok: true, data: { inserted: true } };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
await executor.startNetworkCapture(tabId, cmd.pattern);
|
||||
return { id: cmd.id, ok: true, data: { started: true } };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.readNetworkCapture(tabId);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessions(cmd: Command): Promise<Result> {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
@@ -690,11 +812,49 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
|
||||
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
||||
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
||||
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
||||
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
|
||||
if (!boundTab?.id) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: cmd.matchDomain || cmd.matchPathPrefix
|
||||
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
|
||||
: 'No active debuggable tab found',
|
||||
};
|
||||
}
|
||||
|
||||
setWorkspaceSession(workspace, {
|
||||
windowId: boundTab.windowId,
|
||||
owned: false,
|
||||
preferredTabId: boundTab.id,
|
||||
});
|
||||
resetWindowIdleTimer(workspace);
|
||||
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: true,
|
||||
data: {
|
||||
tabId: boundTab.id,
|
||||
windowId: boundTab.windowId,
|
||||
url: boundTab.url,
|
||||
title: boundTab.title,
|
||||
workspace,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleNavigate,
|
||||
isTargetUrl,
|
||||
handleTabs,
|
||||
handleSessions,
|
||||
handleBindCurrent,
|
||||
resolveTabId,
|
||||
resetWindowIdleTimer,
|
||||
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
|
||||
@@ -708,9 +868,11 @@ export const __test__ = {
|
||||
}
|
||||
setWorkspaceSession(workspace, {
|
||||
windowId,
|
||||
owned: true,
|
||||
preferredTabId: null,
|
||||
});
|
||||
},
|
||||
setSession: (workspace: string, session: { windowId: number }) => {
|
||||
setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
|
||||
setWorkspaceSession(workspace, session);
|
||||
},
|
||||
};
|
||||
|
||||
+188
-1
@@ -8,6 +8,27 @@
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
type NetworkCaptureEntry = {
|
||||
kind: 'cdp';
|
||||
url: string;
|
||||
method: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBodyKind?: string;
|
||||
requestBodyPreview?: string;
|
||||
responseStatus?: number;
|
||||
responseContentType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
responsePreview?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type NetworkCaptureState = {
|
||||
patterns: string[];
|
||||
entries: NetworkCaptureEntry[];
|
||||
requestToIndex: Map<string, number>;
|
||||
};
|
||||
|
||||
const networkCaptures = new Map<number, NetworkCaptureState>();
|
||||
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
||||
function isDebuggableUrl(url?: string): boolean {
|
||||
if (!url) return true; // empty/undefined = tab still loading, allow it
|
||||
@@ -79,6 +100,16 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
// Log detailed diagnostics for debugging extension conflicts
|
||||
let finalUrl = 'unknown';
|
||||
let finalWindowId = 'unknown';
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
finalUrl = tab.url ?? 'undefined';
|
||||
finalWindowId = String(tab.windowId);
|
||||
} catch { /* tab gone */ }
|
||||
console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
|
||||
|
||||
const hint = lastError.includes('chrome-extension://')
|
||||
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
||||
: '';
|
||||
@@ -231,18 +262,100 @@ export async function setFileInputFiles(
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertText(
|
||||
tabId: number,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await ensureAttached(tabId);
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
|
||||
}
|
||||
|
||||
function normalizeCapturePatterns(pattern?: string): string[] {
|
||||
return String(pattern || '')
|
||||
.split('|')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean {
|
||||
if (!url) return false;
|
||||
if (!patterns.length) return true;
|
||||
return patterns.some((pattern) => url.includes(pattern));
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: unknown): Record<string, string> {
|
||||
if (!headers || typeof headers !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
out[String(key)] = String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
}): NetworkCaptureEntry | null {
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return null;
|
||||
const existingIndex = state.requestToIndex.get(requestId);
|
||||
if (existingIndex !== undefined) {
|
||||
return state.entries[existingIndex] || null;
|
||||
}
|
||||
const url = fallback?.url || '';
|
||||
if (!shouldCaptureUrl(url, state.patterns)) return null;
|
||||
const entry: NetworkCaptureEntry = {
|
||||
kind: 'cdp',
|
||||
url,
|
||||
method: fallback?.method || 'GET',
|
||||
requestHeaders: fallback?.requestHeaders || {},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
state.entries.push(entry);
|
||||
state.requestToIndex.set(requestId, state.entries.length - 1);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function startNetworkCapture(
|
||||
tabId: number,
|
||||
pattern?: string,
|
||||
): Promise<void> {
|
||||
await ensureAttached(tabId);
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
|
||||
networkCaptures.set(tabId, {
|
||||
patterns: normalizeCapturePatterns(pattern),
|
||||
entries: [],
|
||||
requestToIndex: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> {
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return [];
|
||||
const entries = state.entries.slice();
|
||||
state.entries = [];
|
||||
state.requestToIndex.clear();
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function detach(tabId: number): Promise<void> {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
networkCaptures.delete(tabId);
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
networkCaptures.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
if (source.tabId) {
|
||||
attached.delete(source.tabId);
|
||||
networkCaptures.delete(source.tabId);
|
||||
}
|
||||
});
|
||||
// Invalidate attached cache when tab URL changes to non-debuggable
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||
@@ -250,4 +363,78 @@ export function registerListeners(): void {
|
||||
await detach(tabId);
|
||||
}
|
||||
});
|
||||
chrome.debugger.onEvent.addListener(async (source, method, params) => {
|
||||
const tabId = source.tabId;
|
||||
if (!tabId) return;
|
||||
const state = networkCaptures.get(tabId);
|
||||
if (!state) return;
|
||||
|
||||
if (method === 'Network.requestWillBeSent') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const request = params?.request as {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
postData?: string;
|
||||
hasPostData?: boolean;
|
||||
} | undefined;
|
||||
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
|
||||
url: request?.url,
|
||||
method: request?.method,
|
||||
requestHeaders: normalizeHeaders(request?.headers),
|
||||
});
|
||||
if (!entry) return;
|
||||
entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty';
|
||||
entry.requestBodyPreview = String(request?.postData || '').slice(0, 4000);
|
||||
try {
|
||||
const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string };
|
||||
if (postData?.postData) {
|
||||
entry.requestBodyKind = 'string';
|
||||
entry.requestBodyPreview = postData.postData.slice(0, 4000);
|
||||
}
|
||||
} catch {
|
||||
// Optional; some requests do not expose postData.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'Network.responseReceived') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const response = params?.response as {
|
||||
url?: string;
|
||||
mimeType?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
} | undefined;
|
||||
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
|
||||
url: response?.url,
|
||||
});
|
||||
if (!entry) return;
|
||||
entry.responseStatus = response?.status;
|
||||
entry.responseContentType = response?.mimeType || '';
|
||||
entry.responseHeaders = normalizeHeaders(response?.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'Network.loadingFinished') {
|
||||
const requestId = String(params?.requestId || '');
|
||||
const stateEntryIndex = state.requestToIndex.get(requestId);
|
||||
if (stateEntryIndex === undefined) return;
|
||||
const entry = state.entries[stateEntryIndex];
|
||||
if (!entry) return;
|
||||
try {
|
||||
const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as {
|
||||
body?: string;
|
||||
base64Encoded?: boolean;
|
||||
};
|
||||
if (typeof body?.body === 'string') {
|
||||
entry.responsePreview = body.base64Encoded
|
||||
? `base64:${body.body.slice(0, 4000)}`
|
||||
: body.body.slice(0, 4000);
|
||||
}
|
||||
} catch {
|
||||
// Optional; bodies are unavailable for some requests (e.g. uploads).
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,20 @@
|
||||
* Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
|
||||
export type Action =
|
||||
| 'exec'
|
||||
| 'navigate'
|
||||
| 'tabs'
|
||||
| 'cookies'
|
||||
| 'screenshot'
|
||||
| 'close-window'
|
||||
| 'sessions'
|
||||
| 'set-file-input'
|
||||
| 'insert-text'
|
||||
| 'bind-current'
|
||||
| 'network-capture-start'
|
||||
| 'network-capture-read'
|
||||
| 'cdp';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
@@ -26,6 +39,10 @@ export interface Command {
|
||||
index?: number;
|
||||
/** Cookie domain filter */
|
||||
domain?: string;
|
||||
/** Optional hostname/domain to require for current-tab binding */
|
||||
matchDomain?: string;
|
||||
/** Optional pathname prefix to require for current-tab binding */
|
||||
matchPathPrefix?: string;
|
||||
/** Screenshot format: png (default) or jpeg */
|
||||
format?: 'png' | 'jpeg';
|
||||
/** JPEG quality (0-100), only for jpeg format */
|
||||
@@ -36,6 +53,10 @@ export interface Command {
|
||||
files?: string[];
|
||||
/** CSS selector for file input element (set-file-input action) */
|
||||
selector?: string;
|
||||
/** Raw text payload for insert-text action */
|
||||
text?: string;
|
||||
/** URL substring filter pattern for network capture actions */
|
||||
pattern?: string;
|
||||
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
|
||||
cdpMethod?: string;
|
||||
/** CDP method params for 'cdp' action */
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"postinstall": "node scripts/postinstall.js || true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepare": "[ -d src ] && npm run build || true",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run --project unit",
|
||||
"test:bun": "bun vitest run --project unit",
|
||||
|
||||
@@ -22,9 +22,10 @@ Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
||||
2. **ALWAYS use `click`/`type`/`select` for interaction, NEVER use `eval` to click or type** — `eval "el.click()"` bypasses scrollIntoView and CDP click pipeline, causing failures on off-screen elements. Use `state` to find the `[N]` index, then `click <N>`.
|
||||
3. **Verify inputs with `get value`, not screenshots** — after `type`, run `get value <index>` to confirm.
|
||||
4. **Run `state` after every page change** — after `open`, `click` (on links), `scroll`, always run `state` to see the new elements and their indices. Never guess indices.
|
||||
5. **Chain safe commands with `&&`** — `type 3 "a" && type 4 "b" && click 7` is one call instead of three. But always run `state` first to get correct indices before chaining.
|
||||
5. **Chain commands aggressively with `&&`** — combine `open + state`, multiple `type` calls, and `type + get value` into single `&&` chains. Each tool call has overhead; chaining cuts it.
|
||||
6. **`eval` is read-only** — use `eval` ONLY for data extraction (`JSON.stringify(...)`), never for clicking, typing, or navigating. Always wrap in IIFE to avoid variable conflicts: `eval "(function(){ const x = ...; return JSON.stringify(x); })()"`.
|
||||
7. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
|
||||
7. **Minimize total tool calls** — plan your sequence before acting. A good task completion uses 3-5 tool calls, not 15-20. Combine `open + state` as one call. Combine `type + type + click` as one call. Only run `state` separately when you need to discover new indices.
|
||||
8. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
|
||||
|
||||
## Command Cost Guide
|
||||
|
||||
@@ -38,13 +39,24 @@ Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
||||
|
||||
Commands can be chained with `&&`. The browser persists via daemon, so chaining is safe.
|
||||
|
||||
**Safe to chain** — these don't change the page structure:
|
||||
**Always chain when possible** — fewer tool calls = faster completion:
|
||||
```bash
|
||||
# Fill multiple fields then submit
|
||||
# GOOD: open + inspect in one call (saves 1 round trip)
|
||||
opencli operate open https://example.com && opencli operate state
|
||||
|
||||
# GOOD: fill form in one call (saves 2 round trips)
|
||||
opencli operate type 3 "hello" && opencli operate type 4 "world" && opencli operate click 7
|
||||
|
||||
# Open and inspect
|
||||
opencli operate open https://example.com && opencli operate state
|
||||
# GOOD: type + verify in one call
|
||||
opencli operate type 5 "test@example.com" && opencli operate get value 5
|
||||
|
||||
# GOOD: click + wait + state in one call (for page-changing clicks)
|
||||
opencli operate click 12 && opencli operate wait time 1 && opencli operate state
|
||||
|
||||
# BAD: separate calls for each action (wasteful)
|
||||
opencli operate type 3 "hello" # Don't do this
|
||||
opencli operate type 4 "world" # when you can chain
|
||||
opencli operate click 7 # all three together
|
||||
```
|
||||
|
||||
**Page-changing — always put last** in a chain (subsequent commands see stale indices):
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ describe('BrowserBridge state', () => {
|
||||
|
||||
it('fails fast when daemon is running but extension is disconnected', async () => {
|
||||
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
|
||||
vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
|
||||
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any);
|
||||
|
||||
const bridge = new BrowserBridge();
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
|
||||
|
||||
if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY;
|
||||
if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY;
|
||||
if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY;
|
||||
|
||||
let score = 0;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ function generateId(): string {
|
||||
|
||||
export interface DaemonCommand {
|
||||
id: string;
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
|
||||
tabId?: number;
|
||||
code?: string;
|
||||
workspace?: string;
|
||||
@@ -29,6 +29,8 @@ export interface DaemonCommand {
|
||||
op?: string;
|
||||
index?: number;
|
||||
domain?: string;
|
||||
matchDomain?: string;
|
||||
matchPathPrefix?: string;
|
||||
format?: 'png' | 'jpeg';
|
||||
quality?: number;
|
||||
fullPage?: boolean;
|
||||
@@ -37,6 +39,10 @@ export interface DaemonCommand {
|
||||
files?: string[];
|
||||
/** CSS selector for file input element (set-file-input action) */
|
||||
selector?: string;
|
||||
/** Raw text payload for insert-text action */
|
||||
text?: string;
|
||||
/** URL substring filter pattern for network capture */
|
||||
pattern?: string;
|
||||
cdpMethod?: string;
|
||||
cdpParams?: Record<string, unknown>;
|
||||
}
|
||||
@@ -163,3 +169,7 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
|
||||
const result = await sendCommand('sessions');
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> {
|
||||
return sendCommand('bind-current', { workspace, ...opts });
|
||||
}
|
||||
|
||||
+26
-1
@@ -120,6 +120,9 @@ export class Page extends BasePage {
|
||||
await sendCommand('close-window', { ...this._wsOpt() });
|
||||
} catch {
|
||||
// Window may already be closed or daemon may be down
|
||||
} finally {
|
||||
this._tabId = undefined;
|
||||
this._lastUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +154,19 @@ export class Page extends BasePage {
|
||||
return base64;
|
||||
}
|
||||
|
||||
async startNetworkCapture(pattern: string = ''): Promise<void> {
|
||||
await sendCommand('network-capture-start', {
|
||||
pattern,
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
|
||||
async readNetworkCapture(): Promise<unknown[]> {
|
||||
const result = await sendCommand('network-capture-read', {
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
/**
|
||||
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
||||
* Chrome reads the files directly from the local filesystem, avoiding the
|
||||
@@ -167,6 +183,16 @@ export class Page extends BasePage {
|
||||
}
|
||||
}
|
||||
|
||||
async insertText(text: string): Promise<void> {
|
||||
const result = await sendCommand('insert-text', {
|
||||
text,
|
||||
...this._cmdOpts(),
|
||||
}) as { inserted?: boolean };
|
||||
if (!result?.inserted) {
|
||||
throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
|
||||
}
|
||||
}
|
||||
|
||||
async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
return sendCommand('cdp', {
|
||||
cdpMethod: method,
|
||||
@@ -287,4 +313,3 @@ export class Page extends BasePage {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ManifestEntry {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
valueRequired?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
@@ -62,6 +63,7 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
|
||||
type: arg.type ?? 'str',
|
||||
default: arg.default,
|
||||
required: !!arg.required,
|
||||
valueRequired: !!arg.valueRequired || undefined,
|
||||
positional: arg.positional || undefined,
|
||||
help: arg.help ?? '',
|
||||
choices: arg.choices,
|
||||
|
||||
+32
-12
@@ -149,33 +149,53 @@ function getTurnsScript(): string {
|
||||
return '';
|
||||
};
|
||||
|
||||
const extractText = (root) => {
|
||||
const selectors = [
|
||||
'[data-testid="message_text_content"]',
|
||||
'[data-testid="message_content"]',
|
||||
'[data-testid*="message_text"]',
|
||||
'[data-testid*="message_content"]',
|
||||
'[class*="message-text"]',
|
||||
'[class*="message-content"]',
|
||||
];
|
||||
const messageTextSelectors = [
|
||||
'[data-testid="message_text_content"]',
|
||||
'[data-testid="message_content"]',
|
||||
'[data-testid*="message_text"]',
|
||||
'[data-testid*="message_content"]',
|
||||
'[class*="message-text"]',
|
||||
'[class*="message-content"]',
|
||||
];
|
||||
const messageImageSelector = messageTextSelectors.map((s) => s + ' img').join(', ');
|
||||
|
||||
const extractTextChunks = (root) => {
|
||||
const chunks = [];
|
||||
const seen = new Set();
|
||||
for (const selector of selectors) {
|
||||
for (const selector of messageTextSelectors) {
|
||||
const nodes = Array.from(root.querySelectorAll(selector))
|
||||
.filter((el) => isVisible(el))
|
||||
.map((el) => clean(el.innerText || el.textContent || ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const nodeText of nodes) {
|
||||
if (seen.has(nodeText)) continue;
|
||||
seen.add(nodeText);
|
||||
chunks.push(nodeText);
|
||||
}
|
||||
|
||||
if (chunks.length > 0) break;
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
if (chunks.length > 0) return clean(chunks.join('\\n'));
|
||||
return clean(root.innerText || root.textContent || '');
|
||||
const extractImageLines = (root) => Array.from(root.querySelectorAll(messageImageSelector))
|
||||
.filter((el) => el instanceof HTMLImageElement && isVisible(el))
|
||||
.map((el) => {
|
||||
const width = el.naturalWidth || el.width || 0;
|
||||
const height = el.naturalHeight || el.height || 0;
|
||||
if (width > 0 && height > 0 && width <= 48 && height <= 48) return '';
|
||||
const url = clean(el.currentSrc || el.src || '');
|
||||
return /^https?:\\/\\//i.test(url) ? 'Image: ' + url : '';
|
||||
})
|
||||
.filter((line, index, items) => Boolean(line) && items.indexOf(line) === index);
|
||||
|
||||
const extractText = (root) => {
|
||||
const chunks = extractTextChunks(root);
|
||||
const text = chunks.length > 0 ? clean(chunks.join('\\n')) : clean(root.innerText || root.textContent || '');
|
||||
const imageLines = extractImageLines(root);
|
||||
if (imageLines.length === 0) return text;
|
||||
return text ? text + '\\n' + imageLines.join('\\n') : imageLines.join('\\n');
|
||||
};
|
||||
|
||||
const messageList = document.querySelector('[data-testid="message-list"]');
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InstagramProtocolCaptureEntry } from './protocol-capture.js';
|
||||
import {
|
||||
buildConfigureBody,
|
||||
buildConfigureSidecarPayload,
|
||||
buildConfigureToStoryPhotoPayload,
|
||||
buildConfigureToStoryVideoPayload,
|
||||
deriveInstagramJazoest,
|
||||
derivePrivateApiContextFromCapture,
|
||||
extractInstagramRuntimeInfo,
|
||||
getInstagramFeedNormalizedDimensions,
|
||||
getInstagramStoryNormalizedDimensions,
|
||||
isInstagramFeedAspectRatioAllowed,
|
||||
isInstagramStoryAspectRatioAllowed,
|
||||
publishStoryViaPrivateApi,
|
||||
publishMediaViaPrivateApi,
|
||||
publishImagesViaPrivateApi,
|
||||
readImageAsset,
|
||||
resolveInstagramPrivatePublishConfig,
|
||||
} from './private-publish.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempFile(name: string, bytes: Buffer): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-private-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram private publish helpers', () => {
|
||||
it('derives the private API context from captured instagram request headers', () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-CSRFToken': 'csrf-token',
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Instagram-AJAX': '1036517563',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
expect(derivePrivateApiContextFromCapture(entries)).toEqual({
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives jazoest from the csrf token', () => {
|
||||
expect(deriveInstagramJazoest('SJ_btbvfkpAVFKCN_tJstW')).toBe('22047');
|
||||
});
|
||||
|
||||
it('extracts app id, rollout hash, and csrf token from instagram html', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/json">
|
||||
{"csrf_token":"csrf-from-html","rollout_hash":"1036523242","X-IG-App-ID":"936619743392459"}
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
expect(extractInstagramRuntimeInfo(html)).toEqual({
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves private publish config from capture, runtime html, and cookies', async () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const page = {
|
||||
goto: async () => undefined,
|
||||
wait: async () => undefined,
|
||||
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
|
||||
startNetworkCapture: async () => undefined,
|
||||
readNetworkCapture: async () => entries,
|
||||
evaluate: async () => ({
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
}),
|
||||
} as any;
|
||||
|
||||
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-from-html',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: deriveInstagramJazoest('csrf-from-html'),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries transient private publish config resolution failures and then succeeds', async () => {
|
||||
const entries: InstagramProtocolCaptureEntry[] = [
|
||||
{
|
||||
kind: 'cdp' as never,
|
||||
url: 'https://www.instagram.com/api/v1/feed/timeline/',
|
||||
method: 'GET',
|
||||
requestHeaders: {
|
||||
'X-ASBD-ID': '359341',
|
||||
'X-IG-WWW-Claim': 'hmac.claim',
|
||||
'X-Web-Session-ID': 'abc:def:ghi',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
let evaluateAttempts = 0;
|
||||
const page = {
|
||||
goto: async () => undefined,
|
||||
wait: async () => undefined,
|
||||
getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }],
|
||||
startNetworkCapture: async () => undefined,
|
||||
readNetworkCapture: async () => entries,
|
||||
evaluate: async () => {
|
||||
evaluateAttempts += 1;
|
||||
if (evaluateAttempts === 1) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
return {
|
||||
appId: '936619743392459',
|
||||
csrfToken: 'csrf-from-html',
|
||||
instagramAjax: '1036523242',
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-from-html',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: deriveInstagramJazoest('csrf-from-html'),
|
||||
});
|
||||
expect(evaluateAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('builds the single-image configure form body', () => {
|
||||
expect(buildConfigureBody({
|
||||
uploadId: '1775134280303',
|
||||
caption: 'hello private route',
|
||||
jazoest: '22047',
|
||||
})).toBe(
|
||||
'archive_only=false&caption=hello+private+route&clips_share_preview_to_feed=1'
|
||||
+ '&disable_comments=0&disable_oa_reuse=false&igtv_share_preview_to_feed=1'
|
||||
+ '&is_meta_only_post=0&is_unified_video=1&like_and_view_counts_disabled=0'
|
||||
+ '&media_share_flow=creation_flow&share_to_facebook=&share_to_fb_destination_type=USER'
|
||||
+ '&source_type=library&upload_id=1775134280303&video_subtitles_enabled=0&jazoest=22047'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the carousel configure_sidecar JSON payload', () => {
|
||||
expect(buildConfigureSidecarPayload({
|
||||
uploadIds: ['1', '3', '2'],
|
||||
caption: 'hello carousel',
|
||||
clientSidecarId: '1775134574348',
|
||||
jazoest: '22047',
|
||||
})).toEqual({
|
||||
archive_only: false,
|
||||
caption: 'hello carousel',
|
||||
children_metadata: [
|
||||
{ upload_id: '1' },
|
||||
{ upload_id: '3' },
|
||||
{ upload_id: '2' },
|
||||
],
|
||||
client_sidecar_id: '1775134574348',
|
||||
disable_comments: '0',
|
||||
is_meta_only_post: false,
|
||||
is_open_to_public_submission: false,
|
||||
like_and_view_counts_disabled: 0,
|
||||
media_share_flow: 'creation_flow',
|
||||
share_to_facebook: '',
|
||||
share_to_fb_destination_type: 'USER',
|
||||
source_type: 'library',
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads png and jpeg image assets with mime type and dimensions', () => {
|
||||
const png = createTempFile('sample.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const jpeg = createTempFile('sample.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
|
||||
expect(readImageAsset(png)).toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
width: 3,
|
||||
height: 5,
|
||||
});
|
||||
expect(readImageAsset(jpeg)).toMatchObject({
|
||||
mimeType: 'image/jpeg',
|
||||
width: 6,
|
||||
height: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('computes feed-safe aspect-ratio normalization targets', () => {
|
||||
expect(isInstagramFeedAspectRatioAllowed(1080, 1350)).toBe(true);
|
||||
expect(isInstagramFeedAspectRatioAllowed(1179, 2556)).toBe(false);
|
||||
expect(getInstagramFeedNormalizedDimensions(1179, 2556)).toEqual({
|
||||
width: 2045,
|
||||
height: 2556,
|
||||
});
|
||||
expect(getInstagramFeedNormalizedDimensions(2120, 1140)).toBeNull();
|
||||
});
|
||||
|
||||
it('computes story-safe aspect-ratio normalization targets', () => {
|
||||
expect(isInstagramStoryAspectRatioAllowed(1080, 1920)).toBe(true);
|
||||
expect(isInstagramStoryAspectRatioAllowed(1080, 1080)).toBe(false);
|
||||
expect(getInstagramStoryNormalizedDimensions(1080, 1080)).toEqual({
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the single-photo configure_to_story payload', () => {
|
||||
expect(buildConfigureToStoryPhotoPayload({
|
||||
uploadId: '1775134280303',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
now: () => 1_775_134_280_303,
|
||||
jazoest: '22047',
|
||||
})).toMatchObject({
|
||||
source_type: '4',
|
||||
upload_id: '1775134280303',
|
||||
configure_mode: 1,
|
||||
edits: {
|
||||
crop_original_size: [1080, 1920],
|
||||
crop_center: [0, 0],
|
||||
crop_zoom: 1.3333334,
|
||||
},
|
||||
extra: {
|
||||
source_width: 1080,
|
||||
source_height: 1920,
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the single-video configure_to_story payload', () => {
|
||||
expect(buildConfigureToStoryVideoPayload({
|
||||
uploadId: '1775134280303',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 12500,
|
||||
now: () => 1_775_134_280_303,
|
||||
jazoest: '22047',
|
||||
})).toMatchObject({
|
||||
source_type: '4',
|
||||
upload_id: '1775134280303',
|
||||
configure_mode: 1,
|
||||
poster_frame_index: 0,
|
||||
length: 12.5,
|
||||
extra: {
|
||||
source_width: 1080,
|
||||
source_height: 1920,
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a single image through rupload + configure', async () => {
|
||||
const jpeg = createTempFile('private-single.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"ABC123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [jpeg],
|
||||
caption: 'private single',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 111,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toContain('https://i.instagram.com/rupload_igphoto/fb_uploader_111');
|
||||
expect(calls[0]?.init?.headers).toMatchObject({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'X-Entity-Length': String(fs.statSync(jpeg).size),
|
||||
'X-Entity-Name': 'fb_uploader_111',
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
});
|
||||
expect(calls[1]?.url).toBe('https://www.instagram.com/api/v1/media/configure/');
|
||||
expect(String(calls[1]?.init?.body || '')).toContain('upload_id=111');
|
||||
expect(response).toEqual({ code: 'ABC123', uploadIds: ['111'] });
|
||||
});
|
||||
|
||||
it('publishes a single image story through rupload + configure_to_story', async () => {
|
||||
const jpeg = createTempFile('private-story.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"111","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"pk":"1234567890"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishStoryViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItem: { type: 'image', filePath: jpeg },
|
||||
content: '',
|
||||
currentUserId: '61236465677',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 111,
|
||||
fetcher,
|
||||
prepareMediaAsset: async () => ({
|
||||
type: 'image',
|
||||
asset: {
|
||||
filePath: jpeg,
|
||||
fileName: path.basename(jpeg),
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
byteLength: fs.statSync(jpeg).size,
|
||||
bytes: fs.readFileSync(jpeg),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_111');
|
||||
expect(calls[1]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
|
||||
expect(String(calls[1]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(response).toEqual({ mediaPk: '1234567890', uploadId: '111' });
|
||||
});
|
||||
|
||||
it('publishes a single video story through rupload + cover + configure_to_story?video=1', async () => {
|
||||
const video = createTempFile('private-story.mp4', Buffer.from('story-video'));
|
||||
const coverBytes = Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9',
|
||||
'hex',
|
||||
);
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igvideo/')) {
|
||||
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
return new Response('{"upload_id":"222","status":"ok"}', { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"pk":"9988776655"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishStoryViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItem: { type: 'video', filePath: video },
|
||||
content: '',
|
||||
currentUserId: '61236465677',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 222,
|
||||
fetcher,
|
||||
prepareMediaAsset: async () => ({
|
||||
type: 'video',
|
||||
asset: {
|
||||
filePath: video,
|
||||
fileName: path.basename(video),
|
||||
mimeType: 'video/mp4',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationMs: 12500,
|
||||
byteLength: fs.statSync(video).size,
|
||||
bytes: fs.readFileSync(video),
|
||||
coverImage: {
|
||||
filePath: '/tmp/cover.jpg',
|
||||
fileName: 'cover.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
byteLength: coverBytes.length,
|
||||
bytes: coverBytes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls[0]?.url).toContain('/rupload_igvideo/fb_uploader_222');
|
||||
expect(calls[1]?.url).toContain('/rupload_igphoto/fb_uploader_222');
|
||||
expect(calls[2]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/');
|
||||
expect(calls[3]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/?video=1');
|
||||
expect(String(calls[2]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(String(calls[3]?.init?.body || '')).toContain('signed_body=');
|
||||
expect(response).toEqual({ mediaPk: '9988776655', uploadId: '222' });
|
||||
});
|
||||
|
||||
it('publishes a carousel through rupload + configure_sidecar', async () => {
|
||||
const first = createTempFile('private-carousel-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(200 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDE123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 200,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2]?.url).toBe('https://www.instagram.com/api/v1/media/configure_sidecar/');
|
||||
expect(JSON.parse(String(calls[2]?.init?.body || '{}'))).toMatchObject({
|
||||
caption: 'private carousel',
|
||||
client_sidecar_id: '200',
|
||||
children_metadata: [{ upload_id: '201' }, { upload_id: '202' }],
|
||||
});
|
||||
expect(response).toEqual({ code: 'SIDE123', uploadIds: ['201', '202'] });
|
||||
});
|
||||
|
||||
it('uses prepared assets when private carousel upload needs aspect-ratio normalization', async () => {
|
||||
const first = createTempFile('private-carousel-normalize-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-normalize-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(400 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDEPAD"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const preparedBytes = Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000007FD000009FC08060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
);
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel normalized',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 400,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => {
|
||||
if (filePath === second) {
|
||||
return {
|
||||
filePath: '/tmp/normalized.png',
|
||||
fileName: 'normalized.png',
|
||||
mimeType: 'image/png',
|
||||
width: 2045,
|
||||
height: 2556,
|
||||
byteLength: preparedBytes.length,
|
||||
bytes: preparedBytes,
|
||||
cleanupPath: '/tmp/normalized.png',
|
||||
};
|
||||
}
|
||||
return readImageAsset(filePath);
|
||||
},
|
||||
});
|
||||
|
||||
const secondUploadHeaders = calls[1]?.init?.headers ?? {};
|
||||
expect(JSON.parse(String(secondUploadHeaders['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
upload_media_width: 2045,
|
||||
upload_media_height: 2556,
|
||||
});
|
||||
expect(response).toEqual({ code: 'SIDEPAD', uploadIds: ['401', '402'] });
|
||||
});
|
||||
|
||||
it('includes the response body when configure_sidecar returns a 400', async () => {
|
||||
const first = createTempFile('private-carousel-error-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-error-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
if (String(url).includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(300 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"message":"children_metadata invalid"}', { status: 400 });
|
||||
};
|
||||
|
||||
await expect(publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 300,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
})).rejects.toThrow('children_metadata invalid');
|
||||
});
|
||||
|
||||
it('retries transient rupload fetch failures and still completes the carousel publish', async () => {
|
||||
const first = createTempFile('private-carousel-retry-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-retry-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: string[] = [];
|
||||
let firstUploadAttempts = 0;
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
const value = String(url);
|
||||
calls.push(value);
|
||||
if (value.includes('/rupload_igphoto/')) {
|
||||
firstUploadAttempts += value.includes('fb_uploader_501') ? 1 : 0;
|
||||
if (value.includes('fb_uploader_501') && firstUploadAttempts === 1) {
|
||||
throw new TypeError('fetch failed');
|
||||
}
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(500 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
return new Response('{"media":{"code":"SIDERETRY"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private carousel retry',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 500,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
});
|
||||
|
||||
expect(calls.filter((url) => url.includes('fb_uploader_501'))).toHaveLength(2);
|
||||
expect(response).toEqual({ code: 'SIDERETRY', uploadIds: ['501', '502'] });
|
||||
});
|
||||
|
||||
it('does not retry transient configure_sidecar fetch failures to avoid duplicate posts', async () => {
|
||||
const first = createTempFile('private-carousel-no-retry-1.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const second = createTempFile('private-carousel-no-retry-2.png', Buffer.from(
|
||||
'89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082',
|
||||
'hex',
|
||||
));
|
||||
const calls: string[] = [];
|
||||
let uploadCounter = 0;
|
||||
const fetcher = async (url: string | URL) => {
|
||||
const value = String(url);
|
||||
calls.push(value);
|
||||
if (value.includes('/rupload_igphoto/')) {
|
||||
uploadCounter += 1;
|
||||
return new Response(JSON.stringify({ upload_id: String(600 + uploadCounter), status: 'ok' }), { status: 200 });
|
||||
}
|
||||
throw new TypeError('fetch failed');
|
||||
};
|
||||
|
||||
await expect(publishImagesViaPrivateApi({
|
||||
page: {} as never,
|
||||
imagePaths: [first, second],
|
||||
caption: 'private no retry configure',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 600,
|
||||
fetcher,
|
||||
prepareAsset: async (filePath) => readImageAsset(filePath),
|
||||
})).rejects.toThrow('fetch failed');
|
||||
|
||||
expect(calls.filter((url) => url.includes('configure_sidecar'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishes a mixed image/video carousel and polls configure_sidecar until transcoding finishes', async () => {
|
||||
const image = createTempFile('mixed-private-image.jpg', Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9',
|
||||
'hex',
|
||||
));
|
||||
const video = createTempFile('mixed-private-video.mp4', Buffer.from('video-binary'));
|
||||
const coverBytes = Buffer.from(
|
||||
'FFD8FFE000104A46494600010100000100010000FFC00011080168028003012200021101031101FFD9',
|
||||
'hex',
|
||||
);
|
||||
const calls: Array<{ url: string; init?: { method?: string; headers?: Record<string, string>; body?: unknown } }> = [];
|
||||
let configureAttempts = 0;
|
||||
const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
|
||||
const value = String(url);
|
||||
calls.push({ url: value, init });
|
||||
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_701')) {
|
||||
return new Response('{"upload_id":"701","status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (value.includes('/rupload_igvideo/') && value.includes('fb_uploader_702')) {
|
||||
return new Response('{"media_id":17944674009157009,"status":"ok"}', { status: 200 });
|
||||
}
|
||||
if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_702')) {
|
||||
return new Response('{"upload_id":"702","status":"ok"}', { status: 200 });
|
||||
}
|
||||
configureAttempts += 1;
|
||||
if (configureAttempts === 1) {
|
||||
return new Response('{"message":"Transcode not finished yet.","status":"fail"}', { status: 202 });
|
||||
}
|
||||
return new Response('{"status":"ok","media":{"code":"MIXEDSIDE123"}}', { status: 200 });
|
||||
};
|
||||
|
||||
const response = await publishMediaViaPrivateApi({
|
||||
page: {} as never,
|
||||
mediaItems: [
|
||||
{ type: 'image', filePath: image },
|
||||
{ type: 'video', filePath: video },
|
||||
],
|
||||
caption: 'mixed private carousel',
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'hmac.claim',
|
||||
instagramAjax: '1036517563',
|
||||
webSessionId: 'abc:def:ghi',
|
||||
},
|
||||
jazoest: '22047',
|
||||
now: () => 700,
|
||||
fetcher,
|
||||
prepareMediaAsset: async (item) => {
|
||||
if (item.type === 'image') {
|
||||
return {
|
||||
type: 'image' as const,
|
||||
asset: readImageAsset(item.filePath),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'video' as const,
|
||||
asset: {
|
||||
filePath: item.filePath,
|
||||
fileName: 'mixed-private-video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
width: 640,
|
||||
height: 360,
|
||||
durationMs: 28245,
|
||||
byteLength: 12,
|
||||
bytes: Buffer.from('video-binary'),
|
||||
coverImage: {
|
||||
filePath: '/tmp/mixed-private-cover.jpg',
|
||||
fileName: 'mixed-private-cover.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 640,
|
||||
height: 360,
|
||||
byteLength: coverBytes.length,
|
||||
bytes: coverBytes,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
waitMs: async () => undefined,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(5);
|
||||
expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_701');
|
||||
expect(calls[1]?.url).toContain('/rupload_igvideo/fb_uploader_702');
|
||||
expect(calls[2]?.url).toContain('/rupload_igphoto/fb_uploader_702');
|
||||
expect(JSON.parse(String(calls[1]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
media_type: 2,
|
||||
upload_id: '702',
|
||||
upload_media_width: 640,
|
||||
upload_media_height: 360,
|
||||
upload_media_duration_ms: 28245,
|
||||
video_edit_params: {
|
||||
crop_width: 360,
|
||||
crop_height: 360,
|
||||
crop_x1: 140,
|
||||
crop_y1: 0,
|
||||
trim_start: 0,
|
||||
trim_end: 28.245,
|
||||
mute: false,
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(calls[2]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({
|
||||
media_type: 2,
|
||||
upload_id: '702',
|
||||
upload_media_width: 640,
|
||||
upload_media_height: 360,
|
||||
});
|
||||
expect(JSON.parse(String(calls[3]?.init?.body || '{}'))).toMatchObject({
|
||||
caption: 'mixed private carousel',
|
||||
client_sidecar_id: '700',
|
||||
children_metadata: [{ upload_id: '701' }, { upload_id: '702' }],
|
||||
});
|
||||
expect(response).toEqual({ code: 'MIXEDSIDE123', uploadIds: ['701', '702'] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
|
||||
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
import {
|
||||
buildInstallInstagramProtocolCaptureJs,
|
||||
buildReadInstagramProtocolCaptureJs,
|
||||
dumpInstagramProtocolCaptureIfEnabled,
|
||||
instagramPrivateApiFetch,
|
||||
installInstagramProtocolCapture,
|
||||
readInstagramProtocolCapture,
|
||||
} from './protocol-capture.js';
|
||||
|
||||
describe('instagram protocol capture helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.OPENCLI_INSTAGRAM_CAPTURE;
|
||||
try { fs.rmSync(TRACE_OUTPUT_PATH, { force: true }); } catch {}
|
||||
});
|
||||
|
||||
it('installs the protocol capture patch in page context', async () => {
|
||||
const evaluate = vi.fn().mockResolvedValue({ ok: true });
|
||||
const page = { evaluate } as unknown as IPage;
|
||||
|
||||
await installInstagramProtocolCapture(page);
|
||||
|
||||
expect(evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('/media/configure_sidecar/');
|
||||
});
|
||||
|
||||
it('prefers native page network capture when available', async () => {
|
||||
const startNetworkCapture = vi.fn().mockResolvedValue(undefined);
|
||||
const evaluate = vi.fn();
|
||||
const page = { startNetworkCapture, evaluate } as unknown as IPage;
|
||||
|
||||
await installInstagramProtocolCapture(page);
|
||||
|
||||
expect(startNetworkCapture).toHaveBeenCalledTimes(1);
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads and normalizes captured protocol entries', async () => {
|
||||
const evaluate = vi.fn().mockResolvedValue({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
|
||||
errors: ['ignored'],
|
||||
});
|
||||
const page = { evaluate } as unknown as IPage;
|
||||
|
||||
const result = await readInstagramProtocolCapture(page);
|
||||
|
||||
expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture');
|
||||
expect(result).toEqual({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }],
|
||||
errors: ['ignored'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers native page network capture reads when available', async () => {
|
||||
const readNetworkCapture = vi.fn().mockResolvedValue([
|
||||
{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' },
|
||||
]);
|
||||
const evaluate = vi.fn();
|
||||
const page = { readNetworkCapture, evaluate } as unknown as IPage;
|
||||
|
||||
const result = await readInstagramProtocolCapture(page);
|
||||
|
||||
expect(readNetworkCapture).toHaveBeenCalledTimes(1);
|
||||
expect(evaluate).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
data: [{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('dumps protocol traces to /tmp only when capture env is enabled', async () => {
|
||||
process.env.OPENCLI_INSTAGRAM_CAPTURE = '1';
|
||||
const page = {
|
||||
evaluate: vi.fn().mockResolvedValue({
|
||||
data: [{ kind: 'fetch', url: 'https://www.instagram.com/rupload_igphoto/test' }],
|
||||
errors: [],
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
|
||||
await dumpInstagramProtocolCaptureIfEnabled(page);
|
||||
|
||||
const raw = fs.readFileSync(TRACE_OUTPUT_PATH, 'utf8');
|
||||
expect(raw).toContain('rupload_igphoto');
|
||||
});
|
||||
|
||||
it('does not dump protocol traces when capture env is disabled', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn(),
|
||||
} as unknown as IPage;
|
||||
|
||||
await dumpInstagramProtocolCaptureIfEnabled(page);
|
||||
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(TRACE_OUTPUT_PATH)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram private api fetch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses browser cookies to build instagram private api requests', async () => {
|
||||
const getCookies = vi.fn()
|
||||
.mockResolvedValueOnce([{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'csrftoken', value: 'csrf', domain: '.instagram.com' } satisfies BrowserCookie,
|
||||
{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie,
|
||||
]);
|
||||
const evaluate = vi.fn().mockResolvedValue({
|
||||
appId: 'dynamic-app-id',
|
||||
csrfToken: 'csrf',
|
||||
instagramAjax: 'dynamic-rollout',
|
||||
});
|
||||
const page = { getCookies, evaluate } as unknown as IPage;
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await instagramPrivateApiFetch(page, 'https://www.instagram.com/api/v1/media/configure/', {
|
||||
method: 'POST',
|
||||
body: 'caption=test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://www.instagram.com/api/v1/media/configure/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'X-CSRFToken': 'csrf',
|
||||
'X-IG-App-ID': 'dynamic-app-id',
|
||||
'Cookie': expect.stringContaining('sessionid=sess'),
|
||||
}),
|
||||
body: 'caption=test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes stable browser-side JS builders', () => {
|
||||
expect(buildInstallInstagramProtocolCaptureJs()).toContain('/rupload_igphoto/');
|
||||
expect(buildReadInstagramProtocolCaptureJs()).toContain('__opencli_ig_protocol_capture');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
import { resolveInstagramRuntimeInfo } from './runtime-info.js';
|
||||
|
||||
const DEFAULT_CAPTURE_VAR = '__opencli_ig_protocol_capture';
|
||||
const DEFAULT_CAPTURE_ERRORS_VAR = '__opencli_ig_protocol_capture_errors';
|
||||
const TRACE_OUTPUT_PATH = path.join(os.tmpdir(), 'instagram_post_protocol_trace.json');
|
||||
const INSTAGRAM_PROTOCOL_CAPTURE_PATTERN = [
|
||||
'/rupload_igphoto/',
|
||||
'/rupload_igvideo/',
|
||||
'/api/v1/',
|
||||
'/media/configure/',
|
||||
'/media/configure_sidecar/',
|
||||
'/media/configure_to_story/',
|
||||
'/api/graphql/',
|
||||
].join('|');
|
||||
|
||||
export interface InstagramProtocolCaptureEntry {
|
||||
kind: 'fetch' | 'xhr';
|
||||
url: string;
|
||||
method: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBodyKind?: string;
|
||||
requestBodyPreview?: string;
|
||||
responseStatus?: number;
|
||||
responseContentType?: string;
|
||||
responsePreview?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function buildInstallInstagramProtocolCaptureJs(
|
||||
captureVar: string = DEFAULT_CAPTURE_VAR,
|
||||
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const CAPTURE_VAR = ${JSON.stringify(captureVar)};
|
||||
const CAPTURE_ERRORS_VAR = ${JSON.stringify(captureErrorsVar)};
|
||||
const PATCH_GUARD = CAPTURE_VAR + '_patched';
|
||||
const FILTERS = [
|
||||
'/rupload_igphoto/',
|
||||
'/rupload_igvideo/',
|
||||
'/api/v1/',
|
||||
'/media/configure/',
|
||||
'/media/configure_sidecar/',
|
||||
'/media/configure_to_story/',
|
||||
'/api/graphql/',
|
||||
];
|
||||
|
||||
const shouldCapture = (url) => {
|
||||
const value = String(url || '');
|
||||
return FILTERS.some((filter) => value.includes(filter));
|
||||
};
|
||||
|
||||
const normalizeHeaders = (headersLike) => {
|
||||
const out = {};
|
||||
try {
|
||||
if (!headersLike) return out;
|
||||
if (headersLike instanceof Headers) {
|
||||
headersLike.forEach((value, key) => { out[key] = value; });
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(headersLike)) {
|
||||
for (const pair of headersLike) {
|
||||
if (Array.isArray(pair) && pair.length >= 2) out[String(pair[0])] = String(pair[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof headersLike === 'object') {
|
||||
for (const [key, value] of Object.entries(headersLike)) out[key] = String(value);
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
};
|
||||
|
||||
const summarizeBody = async (body) => {
|
||||
if (body == null) return { kind: 'empty', preview: '' };
|
||||
try {
|
||||
if (typeof body === 'string') {
|
||||
return { kind: 'string', preview: body.slice(0, 1000) };
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
return { kind: 'urlencoded', preview: body.toString().slice(0, 1000) };
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
const parts = [];
|
||||
for (const [key, value] of body.entries()) {
|
||||
if (value instanceof File) {
|
||||
parts.push(key + '=File(' + value.name + ',' + value.type + ',' + value.size + ')');
|
||||
} else {
|
||||
parts.push(key + '=' + String(value));
|
||||
}
|
||||
}
|
||||
return { kind: 'formdata', preview: parts.join('&').slice(0, 2000) };
|
||||
}
|
||||
if (body instanceof Blob) {
|
||||
return { kind: 'blob', preview: 'Blob(' + body.type + ',' + body.size + ')' };
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return { kind: 'arraybuffer', preview: 'ArrayBuffer(' + body.byteLength + ')' };
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return { kind: 'typed-array', preview: body.constructor.name + '(' + body.byteLength + ')' };
|
||||
}
|
||||
return { kind: typeof body, preview: String(body).slice(0, 1000) };
|
||||
} catch (error) {
|
||||
return { kind: 'unknown', preview: 'body-preview-error:' + String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const capture = async (kind, url, method, headers, body, response) => {
|
||||
if (!shouldCapture(url)) return;
|
||||
try {
|
||||
const bodyInfo = await summarizeBody(body);
|
||||
const contentType = response?.headers?.get?.('content-type') || '';
|
||||
let responsePreview = '';
|
||||
try {
|
||||
if (response && typeof response.clone === 'function') {
|
||||
const clone = response.clone();
|
||||
responsePreview = (await clone.text()).slice(0, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
responsePreview = 'response-preview-error:' + String(error);
|
||||
}
|
||||
window[CAPTURE_VAR].push({
|
||||
kind,
|
||||
url: String(url || ''),
|
||||
method: String(method || 'GET').toUpperCase(),
|
||||
requestHeaders: normalizeHeaders(headers),
|
||||
requestBodyKind: bodyInfo.kind,
|
||||
requestBodyPreview: bodyInfo.preview,
|
||||
responseStatus: response?.status,
|
||||
responseContentType: contentType,
|
||||
responsePreview,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
window[CAPTURE_ERRORS_VAR].push(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
if (!Array.isArray(window[CAPTURE_VAR])) window[CAPTURE_VAR] = [];
|
||||
if (!Array.isArray(window[CAPTURE_ERRORS_VAR])) window[CAPTURE_ERRORS_VAR] = [];
|
||||
if (window[PATCH_GUARD]) return { ok: true };
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const input = args[0];
|
||||
const init = args[1] || {};
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof Request
|
||||
? input.url
|
||||
: String(input || '');
|
||||
const method = init.method || (input instanceof Request ? input.method : 'GET');
|
||||
const headers = init.headers || (input instanceof Request ? input.headers : undefined);
|
||||
const body = init.body || (input instanceof Request ? input.body : undefined);
|
||||
const response = await origFetch.apply(this, args);
|
||||
capture('fetch', url, method, headers, body, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
const origSend = XMLHttpRequest.prototype.send;
|
||||
const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this.__opencli_method = method;
|
||||
this.__opencli_url = url;
|
||||
this.__opencli_headers = {};
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
|
||||
try {
|
||||
this.__opencli_headers = this.__opencli_headers || {};
|
||||
this.__opencli_headers[String(name)] = String(value);
|
||||
} catch {}
|
||||
return origSetRequestHeader.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
this.addEventListener('load', () => {
|
||||
if (!shouldCapture(this.__opencli_url)) return;
|
||||
try {
|
||||
window[CAPTURE_VAR].push({
|
||||
kind: 'xhr',
|
||||
url: String(this.__opencli_url || ''),
|
||||
method: String(this.__opencli_method || 'GET').toUpperCase(),
|
||||
requestHeaders: this.__opencli_headers || {},
|
||||
requestBodyKind: body == null ? 'empty' : (body instanceof FormData ? 'formdata' : typeof body),
|
||||
requestBodyPreview: body == null ? '' : (body instanceof FormData ? '[formdata]' : String(body).slice(0, 2000)),
|
||||
responseStatus: this.status,
|
||||
responseContentType: this.getResponseHeader('content-type') || '',
|
||||
responsePreview: String(this.responseText || '').slice(0, 4000),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
window[CAPTURE_ERRORS_VAR].push(String(error));
|
||||
}
|
||||
});
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
window[PATCH_GUARD] = true;
|
||||
return { ok: true };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildReadInstagramProtocolCaptureJs(
|
||||
captureVar: string = DEFAULT_CAPTURE_VAR,
|
||||
captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR,
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const data = Array.isArray(window[${JSON.stringify(captureVar)}]) ? window[${JSON.stringify(captureVar)}] : [];
|
||||
const errors = Array.isArray(window[${JSON.stringify(captureErrorsVar)}]) ? window[${JSON.stringify(captureErrorsVar)}] : [];
|
||||
window[${JSON.stringify(captureVar)}] = [];
|
||||
window[${JSON.stringify(captureErrorsVar)}] = [];
|
||||
return { data, errors };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export async function installInstagramProtocolCapture(page: IPage): Promise<void> {
|
||||
if (typeof page.startNetworkCapture === 'function') {
|
||||
try {
|
||||
await page.startNetworkCapture(INSTAGRAM_PROTOCOL_CAPTURE_PATTERN);
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.evaluate(buildInstallInstagramProtocolCaptureJs());
|
||||
}
|
||||
|
||||
export async function readInstagramProtocolCapture(page: IPage): Promise<{
|
||||
data: InstagramProtocolCaptureEntry[];
|
||||
errors: string[];
|
||||
}> {
|
||||
if (typeof page.readNetworkCapture === 'function') {
|
||||
try {
|
||||
const data = await page.readNetworkCapture();
|
||||
return {
|
||||
data: Array.isArray(data) ? data as InstagramProtocolCaptureEntry[] : [],
|
||||
errors: [],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Unknown action') && !message.includes('network-capture')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await page.evaluate(buildReadInstagramProtocolCaptureJs()) as {
|
||||
data?: InstagramProtocolCaptureEntry[];
|
||||
errors?: string[];
|
||||
};
|
||||
return {
|
||||
data: Array.isArray(result?.data) ? result.data : [],
|
||||
errors: Array.isArray(result?.errors) ? result.errors : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function dumpInstagramProtocolCaptureIfEnabled(page: IPage): Promise<void> {
|
||||
if (process.env.OPENCLI_INSTAGRAM_CAPTURE !== '1') return;
|
||||
const payload = await readInstagramProtocolCapture(page);
|
||||
fs.writeFileSync(TRACE_OUTPUT_PATH, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookies: BrowserCookie[]): string {
|
||||
return cookies
|
||||
.filter((cookie) => cookie?.name && cookie?.value)
|
||||
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export async function instagramPrivateApiFetch(
|
||||
page: IPage,
|
||||
input: string | URL,
|
||||
init: {
|
||||
method?: 'GET' | 'POST';
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const url = String(input);
|
||||
const [urlCookies, domainCookies] = await Promise.all([
|
||||
page.getCookies({ url }),
|
||||
page.getCookies({ domain: 'instagram.com' }),
|
||||
]);
|
||||
const merged = new Map<string, BrowserCookie>();
|
||||
for (const cookie of domainCookies) merged.set(cookie.name, cookie);
|
||||
for (const cookie of urlCookies) merged.set(cookie.name, cookie);
|
||||
const cookieHeader = buildCookieHeader(Array.from(merged.values()));
|
||||
const csrf = merged.get('csrftoken')?.value || '';
|
||||
const initHeaders = init.headers ?? {};
|
||||
const requestedAppIdHeader = Object.entries(initHeaders).find(([key]) => key.toLowerCase() === 'x-ig-app-id')?.[1] || '';
|
||||
const runtimeInfo = requestedAppIdHeader ? null : await resolveInstagramRuntimeInfo(page);
|
||||
const appId = requestedAppIdHeader || runtimeInfo?.appId || '';
|
||||
const hasContentType = Object.keys(init.headers ?? {}).some((key) => key.toLowerCase() === 'content-type');
|
||||
|
||||
return fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'X-CSRFToken': csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://www.instagram.com',
|
||||
'Referer': 'https://www.instagram.com/',
|
||||
...(appId ? { 'X-IG-App-ID': appId } : {}),
|
||||
...(cookieHeader ? { 'Cookie': cookieHeader } : {}),
|
||||
...(typeof init.body === 'string' && !hasContentType ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } : {}),
|
||||
...initHeaders,
|
||||
},
|
||||
...(init.body !== undefined ? { body: init.body as BodyInit } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BrowserCookie, IPage } from '../../../types.js';
|
||||
|
||||
export interface InstagramRuntimeInfo {
|
||||
appId: string;
|
||||
csrfToken: string;
|
||||
instagramAjax: string;
|
||||
}
|
||||
|
||||
function pickMatch(input: string, patterns: RegExp[]): string {
|
||||
for (const pattern of patterns) {
|
||||
const match = input.match(pattern);
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index]!;
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function extractInstagramRuntimeInfo(html: string): InstagramRuntimeInfo {
|
||||
return {
|
||||
appId: pickMatch(html, [
|
||||
/"X-IG-App-ID":"(\d+)"/,
|
||||
/"appId":"(\d+)"/,
|
||||
/"app_id":"(\d+)"/,
|
||||
/"instagramWebAppId":"(\d+)"/,
|
||||
]),
|
||||
csrfToken: pickMatch(html, [
|
||||
/"csrf_token":"([^"]+)"/,
|
||||
/"csrfToken":"([^"]+)"/,
|
||||
]),
|
||||
instagramAjax: pickMatch(html, [
|
||||
/"rollout_hash":"([^"]+)"/,
|
||||
/"X-Instagram-AJAX":"([^"]+)"/,
|
||||
/"Instagram-AJAX":"([^"]+)"/,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReadInstagramRuntimeInfoJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
const pick = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(new RegExp(pattern, 'i'));
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index];
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
appId: pick([
|
||||
'"X-IG-App-ID":"(\\\\d+)"',
|
||||
'"appId":"(\\\\d+)"',
|
||||
'"app_id":"(\\\\d+)"',
|
||||
'"instagramWebAppId":"(\\\\d+)"',
|
||||
]),
|
||||
csrfToken: pick([
|
||||
'"csrf_token":"([^"]+)"',
|
||||
'"csrfToken":"([^"]+)"',
|
||||
]),
|
||||
instagramAjax: pick([
|
||||
'"rollout_hash":"([^"]+)"',
|
||||
'"X-Instagram-AJAX":"([^"]+)"',
|
||||
'"Instagram-AJAX":"([^"]+)"',
|
||||
]),
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function getCookieValue(cookies: BrowserCookie[], name: string): string {
|
||||
return cookies.find((cookie) => cookie.name === name)?.value || '';
|
||||
}
|
||||
|
||||
export async function resolveInstagramRuntimeInfo(page: IPage): Promise<InstagramRuntimeInfo> {
|
||||
const [runtime, cookies] = await Promise.all([
|
||||
page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise<InstagramRuntimeInfo>,
|
||||
page.getCookies({ domain: 'instagram.com' }),
|
||||
]);
|
||||
return {
|
||||
appId: runtime?.appId || '',
|
||||
csrfToken: runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || '',
|
||||
instagramAjax: runtime?.instagramAjax || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import './note.js';
|
||||
|
||||
function createPageMock(): IPage {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(undefined),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
describe('instagram note registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the note command with a required positional content arg', () => {
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && arg.required)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing note content before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects blank note content before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, { content: ' ' })).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects note content longer than 60 characters before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
|
||||
await expect(cmd!.func!(page, { content: 'x'.repeat(61) })).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes a note through the web inbox mutation', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/note');
|
||||
vi.mocked(page.evaluate).mockResolvedValue({
|
||||
ok: true,
|
||||
noteId: '17849203563031468',
|
||||
});
|
||||
|
||||
const rows = await cmd!.func!(page, { content: 'hello note' }) as Array<Record<string, string>>;
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.instagram.com/direct/inbox/');
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(rows).toEqual([{
|
||||
status: '✅ Posted',
|
||||
detail: 'Instagram note published successfully',
|
||||
noteId: '17849203563031468',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
type InstagramNoteSuccessRow = {
|
||||
status: string;
|
||||
detail: string;
|
||||
noteId: string;
|
||||
};
|
||||
|
||||
type BrowserNoteResult = {
|
||||
ok?: boolean;
|
||||
stage?: string;
|
||||
status?: number;
|
||||
text?: string;
|
||||
noteId?: string;
|
||||
};
|
||||
|
||||
const INSTAGRAM_INBOX_URL = 'https://www.instagram.com/direct/inbox/';
|
||||
const INSTAGRAM_NOTE_DOC_ID = '25155183657506484';
|
||||
const INSTAGRAM_NOTE_MUTATION_NAME = 'usePolarisCreateInboxTrayItemSubmitMutation';
|
||||
const INSTAGRAM_NOTE_ROOT_FIELD = 'xdt_create_inbox_tray_item';
|
||||
|
||||
function requirePage(page: IPage | null): IPage {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for instagram note');
|
||||
return page;
|
||||
}
|
||||
|
||||
function validateInstagramNoteArgs(kwargs: Record<string, unknown>): void {
|
||||
if (kwargs.content === undefined) {
|
||||
throw new ArgumentError(
|
||||
'Argument "content" is required.',
|
||||
'Provide a note text, for example: opencli instagram note "hello"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstagramNoteContent(kwargs: Record<string, unknown>): string {
|
||||
const content = String(kwargs.content ?? '').trim();
|
||||
if (!content) {
|
||||
throw new ArgumentError(
|
||||
'Instagram note content cannot be empty.',
|
||||
'Provide a non-empty note text, for example: opencli instagram note "hello"',
|
||||
);
|
||||
}
|
||||
if (Array.from(content).length > 60) {
|
||||
throw new ArgumentError(
|
||||
'Instagram note content must be 60 characters or fewer.',
|
||||
'Shorten the note text and try again.',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildNoteSuccessResult(noteId: string): InstagramNoteSuccessRow[] {
|
||||
return [{
|
||||
status: '✅ Posted',
|
||||
detail: 'Instagram note published successfully',
|
||||
noteId,
|
||||
}];
|
||||
}
|
||||
|
||||
function buildPublishInstagramNoteJs(content: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const input = ${JSON.stringify({ content })};
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
const scripts = Array.from(document.scripts || [])
|
||||
.map((script) => script.textContent || '')
|
||||
.join('\\n');
|
||||
const source = html + '\\n' + scripts;
|
||||
const pick = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = source.match(pattern);
|
||||
if (!match) continue;
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
if (match[index]) return match[index];
|
||||
}
|
||||
return match[0] || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const prefix = name + '=';
|
||||
const part = document.cookie
|
||||
.split('; ')
|
||||
.find((cookie) => cookie.startsWith(prefix));
|
||||
return part ? decodeURIComponent(part.slice(prefix.length)) : '';
|
||||
};
|
||||
const actorId = pick([
|
||||
/"actorID":"(\\d+)"/,
|
||||
/"actor_id":"(\\d+)"/,
|
||||
/"viewerId":"(\\d+)"/,
|
||||
]);
|
||||
const fbDtsg = pick([
|
||||
/(NAF[a-zA-Z0-9:_-]{20,})/,
|
||||
/(NAf[a-zA-Z0-9:_-]{20,})/,
|
||||
]);
|
||||
const lsd = pick([
|
||||
/"LSD",\\[\\],\\{"token":"([^"]+)"\\}/,
|
||||
/"lsd":"([^"]+)"/,
|
||||
]);
|
||||
const appId = pick([
|
||||
/"X-IG-App-ID":"(\\d+)"/,
|
||||
/"instagramWebAppId":"(\\d+)"/,
|
||||
/"appId":"(\\d+)"/,
|
||||
]);
|
||||
const asbdId = pick([
|
||||
/"X-ASBD-ID":"(\\d+)"/,
|
||||
/"asbd_id":"(\\d+)"/,
|
||||
]);
|
||||
const spinR = pick([/"__spin_r":(\\d+)/]);
|
||||
const spinB = pick([/"__spin_b":"([^"]+)"/]);
|
||||
const spinT = pick([/"__spin_t":(\\d+)/]);
|
||||
const csrfToken = readCookie('csrftoken') || pick([
|
||||
/"csrf_token":"([^"]+)"/,
|
||||
/"csrfToken":"([^"]+)"/,
|
||||
]);
|
||||
const jazoest = fbDtsg
|
||||
? '2' + Array.from(fbDtsg).reduce((total, char) => total + char.charCodeAt(0), 0)
|
||||
: '';
|
||||
|
||||
if (!actorId || !fbDtsg || !lsd || !appId || !csrfToken || !spinR || !spinB || !spinT || !jazoest) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'config',
|
||||
text: JSON.stringify({
|
||||
actorId: Boolean(actorId),
|
||||
fbDtsg: Boolean(fbDtsg),
|
||||
lsd: Boolean(lsd),
|
||||
appId: Boolean(appId),
|
||||
csrfToken: Boolean(csrfToken),
|
||||
spinR: Boolean(spinR),
|
||||
spinB: Boolean(spinB),
|
||||
spinT: Boolean(spinT),
|
||||
jazoest: Boolean(jazoest),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
actor_id: actorId,
|
||||
client_mutation_id: '1',
|
||||
additional_params: {
|
||||
note_create_params: {
|
||||
note_style: 0,
|
||||
text: input.content,
|
||||
},
|
||||
},
|
||||
audience: 0,
|
||||
inbox_tray_item_type: 'note',
|
||||
},
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('av', actorId);
|
||||
body.set('__user', '0');
|
||||
body.set('__a', '1');
|
||||
body.set('__req', '1');
|
||||
body.set('__hs', '');
|
||||
body.set('dpr', String(window.devicePixelRatio || 1));
|
||||
body.set('__ccg', 'UNKNOWN');
|
||||
body.set('__rev', spinR);
|
||||
body.set('__s', '');
|
||||
body.set('__hsi', '');
|
||||
body.set('__dyn', '');
|
||||
body.set('__csr', '');
|
||||
body.set('__comet_req', '7');
|
||||
body.set('fb_dtsg', fbDtsg);
|
||||
body.set('jazoest', jazoest);
|
||||
body.set('lsd', lsd);
|
||||
body.set('__spin_r', spinR);
|
||||
body.set('__spin_b', spinB);
|
||||
body.set('__spin_t', spinT);
|
||||
body.set('fb_api_caller_class', 'RelayModern');
|
||||
body.set('fb_api_req_friendly_name', ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)});
|
||||
body.set('variables', JSON.stringify(variables));
|
||||
body.set('server_timestamps', 'true');
|
||||
body.set('doc_id', ${JSON.stringify(INSTAGRAM_NOTE_DOC_ID)});
|
||||
|
||||
const headers = {
|
||||
Accept: '*/*',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-ASBD-ID': asbdId || undefined,
|
||||
'X-CSRFToken': csrfToken,
|
||||
'X-FB-Friendly-Name': ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)},
|
||||
'X-FB-LSD': lsd,
|
||||
'X-IG-App-ID': appId,
|
||||
'X-Root-Field-Name': ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)},
|
||||
};
|
||||
|
||||
const response = await fetch('/graphql/query', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: body.toString(),
|
||||
});
|
||||
const text = await response.text();
|
||||
const normalizedText = text.replace(/^for \\(;;\\);?/, '').trim();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(normalizedText);
|
||||
} catch {}
|
||||
|
||||
const rootField = ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)};
|
||||
const note = data?.data?.[rootField]?.inbox_tray_item;
|
||||
const noteId = String(note?.inbox_tray_item_id || note?.id || '');
|
||||
if (response.ok && noteId) {
|
||||
return {
|
||||
ok: true,
|
||||
stage: 'publish',
|
||||
noteId,
|
||||
text: String(note?.note_dict?.text || input.content || ''),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'publish',
|
||||
status: response.status,
|
||||
text: normalizedText || text,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'note',
|
||||
description: 'Publish a text Instagram note',
|
||||
domain: 'www.instagram.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
timeoutSeconds: 120,
|
||||
args: [
|
||||
{ name: 'content', positional: true, required: true, help: 'Note text (max 60 characters)' },
|
||||
],
|
||||
columns: ['status', 'detail', 'noteId'],
|
||||
validateArgs: validateInstagramNoteArgs,
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
const browserPage = requirePage(page);
|
||||
const content = normalizeInstagramNoteContent(kwargs as Record<string, unknown>);
|
||||
await browserPage.goto(INSTAGRAM_INBOX_URL);
|
||||
await browserPage.wait({ time: 2 });
|
||||
const result = await browserPage.evaluate(buildPublishInstagramNoteJs(content)) as BrowserNoteResult;
|
||||
if (!result?.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`Instagram note publish failed at ${String(result?.stage || 'unknown')}: ${String(result?.text || 'unknown error')}`,
|
||||
);
|
||||
}
|
||||
return buildNoteSuccessResult(String(result.noteId || ''));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import * as privatePublish from './_shared/private-publish.js';
|
||||
import { buildClickActionJs, buildEnsureComposerOpenJs, buildInspectUploadStageJs, buildPublishStatusProbeJs } from './post.js';
|
||||
import './post.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempImage(name = 'demo.jpg', bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9])): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-video-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createPageMock(evaluateResults: unknown[], overrides: Partial<IPage> = {}): IPage {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: undefined,
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram auth detection', () => {
|
||||
it('does not treat generic homepage text containing "log in" as an auth failure', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
};
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
|
||||
globalState.document = {
|
||||
body: { innerText: 'Suggested for you Log in to see more content' },
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
} as unknown as Document;
|
||||
globalState.window = { location: { pathname: '/' } } as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildEnsureComposerOpenJs()) as { ok: boolean; reason?: string }).toEqual({ ok: true });
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram publish status detection', () => {
|
||||
it('does not treat unrelated page text as share failure while the sharing dialog is still visible', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const visibleDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
visibleDialog.textContent = 'Sharing';
|
||||
visibleDialog.querySelector = () => null;
|
||||
visibleDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [visibleDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat a stale visible error dialog as share failure while sharing is still in progress', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const sharingDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
sharingDialog.textContent = 'Sharing';
|
||||
sharingDialog.querySelector = () => null;
|
||||
sharingDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
staleErrorDialog.querySelector = () => null;
|
||||
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharingDialog, staleErrorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers explicit post-shared success over stale visible error text', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {}
|
||||
|
||||
const sharedDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
sharedDialog.textContent = 'Post shared Your post has been shared.';
|
||||
sharedDialog.querySelector = () => null;
|
||||
sharedDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & {
|
||||
textContent: string;
|
||||
querySelector: () => null;
|
||||
getBoundingClientRect: () => { width: number; height: number };
|
||||
};
|
||||
staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
staleErrorDialog.querySelector = () => null;
|
||||
staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharedDialog, staleErrorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
location: { href: 'https://www.instagram.com/' },
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({
|
||||
ok: true,
|
||||
failed: false,
|
||||
settled: false,
|
||||
url: '',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram click action detection', () => {
|
||||
it('matches aria-label-only Next buttons in the media dialog', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
clicked = false;
|
||||
querySelectorAll = (_selector: string) => [] as unknown[];
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
click() {
|
||||
this.clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const nextButton = new MockHTMLElement();
|
||||
nextButton.ariaLabel = 'Next';
|
||||
|
||||
const dialog = new MockHTMLElement();
|
||||
dialog.textContent = 'Crop Back Select crop Open media gallery';
|
||||
dialog.querySelector = (selector: string) => selector === 'input[type="file"]' ? {} as Element : null;
|
||||
dialog.querySelectorAll = (selector: string) => selector === 'button, div[role="button"]' ? [nextButton] : [];
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [dialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean; label?: string }).toEqual({
|
||||
ok: true,
|
||||
label: 'Next',
|
||||
});
|
||||
expect(nextButton.clicked).toBe(true);
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not click a body-level Next button when media scope has no matching dialog controls', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
clicked = false;
|
||||
children: unknown[] = [];
|
||||
querySelectorAll = (_selector: string) => this.children;
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
click() {
|
||||
this.clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyNext = new MockHTMLElement();
|
||||
bodyNext.ariaLabel = 'Next';
|
||||
|
||||
const errorDialog = new MockHTMLElement();
|
||||
errorDialog.textContent = 'Something went wrong Try again';
|
||||
errorDialog.children = [];
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
body.children = [bodyNext];
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [errorDialog] : [],
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean }).toEqual({ ok: false });
|
||||
expect(bodyNext.clicked).toBe(false);
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram upload stage detection', () => {
|
||||
it('does not treat a body-level Next button as upload preview when the visible dialog is an error', () => {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
document?: unknown;
|
||||
window?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
};
|
||||
|
||||
class MockHTMLElement {
|
||||
textContent = '';
|
||||
ariaLabel = '';
|
||||
children: unknown[] = [];
|
||||
querySelectorAll = (_selector: string) => this.children;
|
||||
querySelector = (_selector: string) => null as unknown;
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'aria-label') return this.ariaLabel || null;
|
||||
return null;
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { width: 100, height: 40 };
|
||||
}
|
||||
}
|
||||
|
||||
const bodyNext = new MockHTMLElement();
|
||||
bodyNext.ariaLabel = 'Next';
|
||||
|
||||
const errorDialog = new MockHTMLElement();
|
||||
errorDialog.textContent = 'Something went wrong. Please try again. Try again';
|
||||
|
||||
const body = new MockHTMLElement();
|
||||
body.children = [bodyNext];
|
||||
|
||||
const originalDocument = globalState.document;
|
||||
const originalWindow = globalState.window;
|
||||
const originalHTMLElement = globalState.HTMLElement;
|
||||
|
||||
globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
|
||||
globalState.document = {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === '[role="dialog"]') return [errorDialog];
|
||||
return [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
globalState.window = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
try {
|
||||
expect(eval(buildInspectUploadStageJs()) as { state: string; detail: string }).toEqual({
|
||||
state: 'failed',
|
||||
detail: 'Something went wrong. Please try again. Try again',
|
||||
});
|
||||
} finally {
|
||||
globalState.document = originalDocument;
|
||||
globalState.window = originalWindow;
|
||||
globalState.HTMLElement = originalHTMLElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram post registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
|
||||
apiContext: {
|
||||
asbdId: '',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: '',
|
||||
instagramAjax: '1036523242',
|
||||
webSessionId: '',
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the post command with a required-value media arg', () => {
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.timeoutSeconds).toBe(300);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content' && !arg.required && arg.positional)).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes via private API and returns the post URL', async () => {
|
||||
const imagePath = createTempImage('private-default.jpg');
|
||||
const privateSpy = vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockResolvedValueOnce({
|
||||
code: 'PRIVATEDEFAULT123',
|
||||
uploadIds: ['111'],
|
||||
});
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
const result = await cmd!.func!(page, { media: imagePath, content: 'private default' });
|
||||
|
||||
expect(privateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single image post shared successfully',
|
||||
url: 'https://www.instagram.com/p/PRIVATEDEFAULT123/',
|
||||
},
|
||||
]);
|
||||
privateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('publishes mixed-media posts via private API and preserves input order', async () => {
|
||||
const imagePath = createTempImage('mixed-default.jpg');
|
||||
const videoPath = createTempVideo('mixed-default.mp4');
|
||||
const privateSpy = vi.spyOn(privatePublish, 'publishMediaViaPrivateApi').mockResolvedValueOnce({
|
||||
code: 'MIXEDPRIVATE123',
|
||||
uploadIds: ['111', '222'],
|
||||
});
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
const result = await cmd!.func!(page, {
|
||||
media: `${imagePath},${videoPath}`,
|
||||
content: 'mixed private default',
|
||||
});
|
||||
|
||||
expect(privateSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mediaItems: [
|
||||
{ type: 'image', filePath: imagePath },
|
||||
{ type: 'video', filePath: videoPath },
|
||||
],
|
||||
caption: 'mixed private default',
|
||||
}));
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: '2-item mixed-media carousel post shared successfully',
|
||||
url: 'https://www.instagram.com/p/MIXEDPRIVATE123/',
|
||||
},
|
||||
]);
|
||||
privateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects missing --media before browser work', async () => {
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
content: 'missing media',
|
||||
})).rejects.toThrow('Argument "media" is required.');
|
||||
});
|
||||
|
||||
it('rejects empty or invalid --media inputs', async () => {
|
||||
const imagePath = createTempImage('invalid-media-image.jpg');
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: '',
|
||||
})).rejects.toThrow('Argument "media" is required.');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: `${imagePath},/tmp/does-not-exist.mp4`,
|
||||
})).rejects.toThrow('Media file not found');
|
||||
});
|
||||
|
||||
it('propagates private API errors directly', async () => {
|
||||
const imagePath = createTempImage('private-fail.jpg');
|
||||
vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockRejectedValueOnce(
|
||||
new CommandExecutionError('Instagram private publish configure failed: 400'),
|
||||
);
|
||||
const page = createPageMock([], {
|
||||
evaluate: vi.fn(async () => ({ ok: true })),
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/post');
|
||||
|
||||
await expect(cmd!.func!(page, {
|
||||
media: imagePath,
|
||||
content: 'should fail',
|
||||
})).rejects.toThrow('Instagram private publish configure failed: 400');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import {
|
||||
publishMediaViaPrivateApi,
|
||||
publishImagesViaPrivateApi,
|
||||
resolveInstagramPrivatePublishConfig,
|
||||
} from './_shared/private-publish.js';
|
||||
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
|
||||
|
||||
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
|
||||
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']);
|
||||
const MAX_MEDIA_ITEMS = 10;
|
||||
|
||||
type InstagramSuccessRow = {
|
||||
status: string;
|
||||
detail: string;
|
||||
url: string;
|
||||
};
|
||||
type InstagramPostMediaItem = {
|
||||
type: 'image' | 'video';
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
function requirePage(page: IPage | null): IPage {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for instagram post');
|
||||
return page;
|
||||
}
|
||||
|
||||
export function buildEnsureComposerOpenJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const path = window.location?.pathname || '';
|
||||
const onLoginRoute = /\\/accounts\\/login\\/?/.test(path);
|
||||
const hasLoginField = !!document.querySelector('input[name="username"], input[name="password"]');
|
||||
const hasLoginButton = Array.from(document.querySelectorAll('button, div[role="button"]')).some((el) => {
|
||||
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||
return text === 'log in' || text === 'login' || text === '登录';
|
||||
});
|
||||
|
||||
if (onLoginRoute || (hasLoginField && hasLoginButton)) {
|
||||
return { ok: false, reason: 'auth' };
|
||||
}
|
||||
|
||||
const alreadyOpen = document.querySelector('input[type="file"]');
|
||||
if (alreadyOpen) return { ok: true };
|
||||
|
||||
const labels = ['Create', 'New post', 'Post', '创建', '新帖子'];
|
||||
const nodes = Array.from(document.querySelectorAll('a, button, div[role="button"], svg[aria-label], [aria-label]'));
|
||||
for (const node of nodes) {
|
||||
const text = ((node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '')).trim();
|
||||
if (labels.some((label) => text.toLowerCase().includes(label.toLowerCase()))) {
|
||||
const clickable = node.closest('a, button, div[role="button"]') || node;
|
||||
if (clickable instanceof HTMLElement) {
|
||||
clickable.click();
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildPublishStatusProbeJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
const dialogText = dialogs
|
||||
.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim())
|
||||
.join(' ');
|
||||
const url = window.location.href;
|
||||
const visibleText = dialogText.toLowerCase();
|
||||
const sharingVisible = /sharing/.test(visibleText);
|
||||
const shared = /post shared|your post has been shared|已分享|已发布/.test(visibleText)
|
||||
|| /\\/p\\//.test(url);
|
||||
const failed = !shared && !sharingVisible && (
|
||||
/couldn['']t be shared|could not be shared|failed to share|share failed|无法分享|分享失败/.test(visibleText)
|
||||
|| (/something went wrong/.test(visibleText) && /try again/.test(visibleText))
|
||||
);
|
||||
const composerOpen = dialogs.some((dialog) =>
|
||||
!!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]')
|
||||
|| /write a caption|add location|advanced settings|select from computer|crop|filters|adjustments|sharing/.test((dialog.textContent || '').toLowerCase())
|
||||
);
|
||||
const settled = !shared && !composerOpen && !/sharing/.test(visibleText);
|
||||
return { ok: shared, failed, settled, url: /\\/p\\//.test(url) ? url : '' };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildInspectUploadStageJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
const visibleTexts = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim());
|
||||
const dialogText = visibleTexts.join(' ');
|
||||
const combined = dialogText.toLowerCase();
|
||||
const hasVisibleButtonInDialogs = (labels) => {
|
||||
return dialogs.some((dialog) =>
|
||||
Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => {
|
||||
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
const aria = (el.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
|
||||
return isVisible(el) && (labels.includes(text) || labels.includes(aria));
|
||||
})
|
||||
);
|
||||
};
|
||||
const hasCaption = dialogs.some((dialog) => !!dialog.querySelector('textarea, [contenteditable="true"]'));
|
||||
const hasPicker = hasVisibleButtonInDialogs(['Select from computer', '从电脑中选择']);
|
||||
const hasNext = hasVisibleButtonInDialogs(['Next', '下一步']);
|
||||
const hasPreviewUi = hasCaption
|
||||
|| (!hasPicker && hasNext)
|
||||
|| /crop|select crop|select zoom|open media gallery|filters|adjustments|裁剪|缩放|滤镜|调整/.test(combined);
|
||||
const failed = /something went wrong|please try again|couldn['']t upload|could not upload|upload failed|try again|出错|失败/.test(combined);
|
||||
if (hasPreviewUi) return { state: 'preview', detail: dialogText || '' };
|
||||
if (failed) return { state: 'failed', detail: dialogText || 'Something went wrong' };
|
||||
return { state: 'pending', detail: dialogText || '' };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildClickActionJs(labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): string {
|
||||
return `
|
||||
((labels, scope) => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
|
||||
const matchesScope = (dialog) => {
|
||||
if (!(dialog instanceof HTMLElement) || !isVisible(dialog)) return false;
|
||||
const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||
if (scope === 'caption') {
|
||||
return !!dialog.querySelector('textarea, [contenteditable="true"]')
|
||||
|| text.includes('write a caption')
|
||||
|| text.includes('add location')
|
||||
|| text.includes('add collaborators')
|
||||
|| text.includes('accessibility')
|
||||
|| text.includes('advanced settings');
|
||||
}
|
||||
if (scope === 'media') {
|
||||
return !!dialog.querySelector('input[type="file"]')
|
||||
|| text.includes('select from computer')
|
||||
|| text.includes('crop')
|
||||
|| text.includes('filters')
|
||||
|| text.includes('adjustments')
|
||||
|| text.includes('open media gallery')
|
||||
|| text.includes('select crop')
|
||||
|| text.includes('select zoom');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const containers = scope !== 'any'
|
||||
? Array.from(document.querySelectorAll('[role="dialog"]')).filter(matchesScope)
|
||||
: [document.body];
|
||||
|
||||
for (const container of containers) {
|
||||
const nodes = Array.from(container.querySelectorAll('button, div[role="button"]'));
|
||||
for (const node of nodes) {
|
||||
const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
|
||||
if (!text && !aria) continue;
|
||||
if (!labels.includes(text) && !labels.includes(aria)) continue;
|
||||
if (node instanceof HTMLElement && isVisible(node) && node.getAttribute('aria-disabled') !== 'true') {
|
||||
node.click();
|
||||
return { ok: true, label: text || aria };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
})(${JSON.stringify(labels)}, ${JSON.stringify(scope)})
|
||||
`;
|
||||
}
|
||||
|
||||
function validateMixedMediaItems(inputs: string[]): InstagramPostMediaItem[] {
|
||||
if (!inputs.length) {
|
||||
throw new ArgumentError(
|
||||
'Argument "media" is required.',
|
||||
'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4',
|
||||
);
|
||||
}
|
||||
if (inputs.length > MAX_MEDIA_ITEMS) {
|
||||
throw new ArgumentError(`Too many media items: ${inputs.length}`, `Instagram carousel posts support at most ${MAX_MEDIA_ITEMS} items`);
|
||||
}
|
||||
|
||||
const items = inputs.map((input) => {
|
||||
const resolved = path.resolve(String(input || '').trim());
|
||||
if (!resolved) {
|
||||
throw new ArgumentError('Media path cannot be empty');
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new ArgumentError(`Media file not found: ${resolved}`);
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
||||
return { type: 'image' as const, filePath: resolved };
|
||||
}
|
||||
if (SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
|
||||
return { type: 'video' as const, filePath: resolved };
|
||||
}
|
||||
throw new ArgumentError(`Unsupported media format: ${ext}`, 'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)');
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizePostMediaItems(kwargs: Record<string, unknown>): InstagramPostMediaItem[] {
|
||||
const media = String(kwargs.media ?? '').trim();
|
||||
return validateMixedMediaItems(media.split(',').map((part) => part.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function validateInstagramPostArgs(kwargs: Record<string, unknown>): void {
|
||||
const media = kwargs.media;
|
||||
if (media === undefined) {
|
||||
throw new ArgumentError(
|
||||
'Argument "media" is required.',
|
||||
'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function describePostDetail(mediaItems: InstagramPostMediaItem[]): string {
|
||||
if (mediaItems.every((item) => item.type === 'image')) {
|
||||
return mediaItems.length === 1
|
||||
? 'Single image post shared successfully'
|
||||
: `${mediaItems.length}-image carousel post shared successfully`;
|
||||
}
|
||||
return mediaItems.length === 1
|
||||
? 'Single mixed-media post shared successfully'
|
||||
: `${mediaItems.length}-item mixed-media carousel post shared successfully`;
|
||||
}
|
||||
|
||||
function buildInstagramSuccessResult(mediaItems: InstagramPostMediaItem[], url: string): InstagramSuccessRow[] {
|
||||
return [{
|
||||
status: '✅ Posted',
|
||||
detail: describePostDetail(mediaItems),
|
||||
url,
|
||||
}];
|
||||
}
|
||||
|
||||
async function resolveCurrentUserId(page: IPage): Promise<string> {
|
||||
const cookies = await page.getCookies({ domain: 'instagram.com' });
|
||||
return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || '';
|
||||
}
|
||||
|
||||
async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise<string> {
|
||||
if (currentUserId) {
|
||||
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
|
||||
const apiResult = await page.evaluate(`
|
||||
(async () => {
|
||||
const userId = ${JSON.stringify(currentUserId)};
|
||||
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: appId ? { 'X-IG-App-ID': appId } : {},
|
||||
},
|
||||
);
|
||||
if (!res.ok) return { ok: false };
|
||||
const data = await res.json();
|
||||
const username = data?.user?.username || '';
|
||||
return { ok: !!username, username };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
})()
|
||||
`) as { ok?: boolean; username?: string };
|
||||
|
||||
if (apiResult?.ok && apiResult.username) {
|
||||
return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
|
||||
const anchors = Array.from(document.querySelectorAll('a[href]'))
|
||||
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
|
||||
.map((el) => ({
|
||||
href: el.getAttribute('href') || '',
|
||||
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(),
|
||||
aria: (el.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim().toLowerCase(),
|
||||
}))
|
||||
.filter((el) => /^\\/[^/?#]+\\/$/.test(el.href));
|
||||
|
||||
const explicitProfile = anchors.find((el) => el.text === 'profile' || el.aria === 'profile')?.href || '';
|
||||
const path = explicitProfile;
|
||||
return { ok: !!path, path };
|
||||
})()
|
||||
`) as { ok?: boolean; path?: string };
|
||||
|
||||
if (!result?.ok || !result.path) return '';
|
||||
return new URL(result.path, INSTAGRAM_HOME_URL).toString();
|
||||
}
|
||||
|
||||
async function collectVisibleProfilePostPaths(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
|
||||
const hrefs = Array.from(document.querySelectorAll('a[href*="/p/"]'))
|
||||
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
|
||||
.map((el) => el.getAttribute('href') || '')
|
||||
.filter((href) => /^\\/(?:[^/?#]+\\/)?p\\/[^/?#]+\\/?$/.test(href))
|
||||
.filter((href, index, arr) => arr.indexOf(href) === index);
|
||||
|
||||
return { ok: hrefs.length > 0, hrefs };
|
||||
})()
|
||||
`) as { ok?: boolean; hrefs?: string[] };
|
||||
|
||||
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function captureExistingProfilePostPaths(page: IPage): Promise<Set<string>> {
|
||||
const currentUserId = await resolveCurrentUserId(page);
|
||||
if (!currentUserId) return new Set();
|
||||
|
||||
const profileUrl = await resolveProfileUrl(page, currentUserId);
|
||||
if (!profileUrl) return new Set();
|
||||
|
||||
try {
|
||||
await page.goto(profileUrl);
|
||||
await page.wait({ time: 3 });
|
||||
return new Set(await collectVisibleProfilePostPaths(page));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatestPostUrl(page: IPage, existingPostPaths: ReadonlySet<string>): Promise<string> {
|
||||
const currentUrl = await page.getCurrentUrl?.();
|
||||
if (currentUrl && /\/p\//.test(currentUrl)) return currentUrl;
|
||||
|
||||
const currentUserId = await resolveCurrentUserId(page);
|
||||
const profileUrl = await resolveProfileUrl(page, currentUserId);
|
||||
if (!profileUrl) return '';
|
||||
|
||||
await page.goto(profileUrl);
|
||||
await page.wait({ time: 4 });
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const hrefs = await collectVisibleProfilePostPaths(page);
|
||||
const href = hrefs.find((candidate) => !existingPostPaths.has(candidate)) || '';
|
||||
if (href) {
|
||||
return new URL(href, INSTAGRAM_HOME_URL).toString();
|
||||
}
|
||||
|
||||
if (attempt < 7) await page.wait({ time: 1 });
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function executePrivateInstagramPost(input: {
|
||||
page: IPage;
|
||||
mediaItems: InstagramPostMediaItem[];
|
||||
content: string;
|
||||
existingPostPaths: Set<string>;
|
||||
}): Promise<InstagramSuccessRow[]> {
|
||||
const privateConfig = await resolveInstagramPrivatePublishConfig(input.page);
|
||||
const privateResult = input.mediaItems.every((item) => item.type === 'image')
|
||||
? await publishImagesViaPrivateApi({
|
||||
page: input.page,
|
||||
imagePaths: input.mediaItems.map((item) => item.filePath),
|
||||
caption: input.content,
|
||||
apiContext: privateConfig.apiContext,
|
||||
jazoest: privateConfig.jazoest,
|
||||
})
|
||||
: await publishMediaViaPrivateApi({
|
||||
page: input.page,
|
||||
mediaItems: input.mediaItems,
|
||||
caption: input.content,
|
||||
apiContext: privateConfig.apiContext,
|
||||
jazoest: privateConfig.jazoest,
|
||||
});
|
||||
const url = privateResult.code
|
||||
? new URL(`/p/${privateResult.code}/`, INSTAGRAM_HOME_URL).toString()
|
||||
: await resolveLatestPostUrl(input.page, input.existingPostPaths);
|
||||
return buildInstagramSuccessResult(input.mediaItems, url);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'post',
|
||||
description: 'Post an Instagram feed image or mixed-media carousel',
|
||||
domain: 'www.instagram.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
timeoutSeconds: 300,
|
||||
args: [
|
||||
{ name: 'media', required: false, valueRequired: true, help: `Comma-separated media paths (images/videos, up to ${MAX_MEDIA_ITEMS})` },
|
||||
{ name: 'content', positional: true, required: false, help: 'Caption text' },
|
||||
],
|
||||
columns: ['status', 'detail', 'url'],
|
||||
validateArgs: validateInstagramPostArgs,
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
const browserPage = requirePage(page);
|
||||
const mediaItems = normalizePostMediaItems(kwargs as Record<string, unknown>);
|
||||
const content = String(kwargs.content ?? '').trim();
|
||||
const existingPostPaths = await captureExistingProfilePostPaths(browserPage);
|
||||
return executePrivateInstagramPost({
|
||||
page: browserPage,
|
||||
mediaItems,
|
||||
content,
|
||||
existingPostPaths,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import './reel.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-reel-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createPageMock(evaluateResults: unknown[], overrides: Partial<IPage> = {}): IPage {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram reel registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the reel command with a required-value video arg', () => {
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'video' && !arg.required && arg.valueRequired)).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && !arg.required)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing --video before browser work', async () => {
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
|
||||
await expect(cmd!.func!(page, { content: 'hello reel' })).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsupported video formats', async () => {
|
||||
const videoPath = createTempVideo('demo.mov');
|
||||
const page = createPageMock([]);
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
|
||||
await expect(cmd!.func!(page, { video: videoPath })).rejects.toThrow('Unsupported video format');
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads a reel video without caption and shares it', async () => {
|
||||
const videoPath = createTempVideo();
|
||||
const page = createPageMock([
|
||||
{ ok: false }, // dismiss residual dialogs
|
||||
{ ok: true }, // ensure composer open
|
||||
{ ok: true }, // composer upload input ready
|
||||
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]', '[data-opencli-reel-upload-index="1"]'] }, // resolve upload selector
|
||||
{ count: 1 }, // file bound to input
|
||||
{ state: 'preview', detail: 'Crop Back Next' }, // preview detected
|
||||
{ ok: true, label: 'OK' }, // dismiss reels nux
|
||||
{ ok: true, label: 'Next' }, // move from crop to edit
|
||||
{ state: 'edit' }, // edit stage
|
||||
{ ok: true, label: 'Next' }, // move from edit to composer
|
||||
{ state: 'composer' }, // composer stage
|
||||
{ ok: true, label: 'Share' }, // share
|
||||
{ ok: true, url: 'https://www.instagram.com/reel/REEL123/' }, // success
|
||||
]);
|
||||
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
const result = await cmd!.func!(page, { video: videoPath });
|
||||
|
||||
expect(page.setFileInput).toHaveBeenCalledWith([videoPath], '[data-opencli-reel-upload-index="0"]');
|
||||
expect(page.insertText).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single reel shared successfully',
|
||||
url: 'https://www.instagram.com/reel/REEL123/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies query-style local video filenames to a safe temp upload path before setFileInput', async () => {
|
||||
const videoPath = createTempVideo('demo.mp4?sign=abc&t=123video.MP4');
|
||||
const page = createPageMock([
|
||||
{ ok: false },
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] },
|
||||
{ count: 1 },
|
||||
{ state: 'preview', detail: 'Crop Back Next' },
|
||||
{ ok: true, label: 'OK' },
|
||||
{ ok: true, label: 'Next' },
|
||||
{ state: 'edit' },
|
||||
{ ok: true, label: 'Next' },
|
||||
{ state: 'composer' },
|
||||
{ ok: true, label: 'Share' },
|
||||
{ ok: true, url: 'https://www.instagram.com/reel/REELSAFE123/' },
|
||||
]);
|
||||
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
await cmd!.func!(page, { video: videoPath });
|
||||
|
||||
const uploadPaths = (page.setFileInput as any).mock.calls[0]?.[0] ?? [];
|
||||
expect(uploadPaths).toHaveLength(1);
|
||||
expect(uploadPaths[0]).not.toBe(videoPath);
|
||||
expect(String(uploadPaths[0])).toContain('opencli-instagram-video-real');
|
||||
expect(String(uploadPaths[0]).toLowerCase()).toContain('.mp4');
|
||||
});
|
||||
|
||||
it('uploads a reel video with caption and shares it', async () => {
|
||||
const videoPath = createTempVideo('captioned.mp4');
|
||||
const page = createPageMock([
|
||||
{ ok: false }, // dismiss residual dialogs
|
||||
{ ok: true }, // ensure composer open
|
||||
{ ok: true }, // composer upload input ready
|
||||
{ ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] }, // resolve upload selector
|
||||
{ count: 1 }, // file bound to input
|
||||
{ state: 'preview', detail: 'Crop Back Next' }, // preview detected
|
||||
{ ok: true, label: 'OK' }, // dismiss reels nux
|
||||
{ ok: true, label: 'Next' }, // move from crop to edit
|
||||
{ state: 'edit' }, // edit stage
|
||||
{ ok: true, label: 'Next' }, // move from edit to composer
|
||||
{ state: 'composer' }, // composer stage
|
||||
{ ok: true }, // focus caption editor
|
||||
{ ok: true }, // post-insert event dispatch
|
||||
{ ok: true }, // caption matches
|
||||
{ ok: true, label: 'Share' }, // share
|
||||
{ ok: true, url: 'https://www.instagram.com/reel/REEL456/' }, // success
|
||||
]);
|
||||
|
||||
const cmd = getRegistry().get('instagram/reel');
|
||||
const result = await cmd!.func!(page, { video: videoPath, content: 'hello reel' });
|
||||
|
||||
expect(page.insertText).toHaveBeenCalledWith('hello reel');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single reel shared successfully',
|
||||
url: 'https://www.instagram.com/reel/REEL456/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,873 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js';
|
||||
import type { BrowserCookie, IPage } from '../../types.js';
|
||||
import {
|
||||
buildClickActionJs,
|
||||
buildEnsureComposerOpenJs,
|
||||
buildInspectUploadStageJs,
|
||||
} from './post.js';
|
||||
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
|
||||
|
||||
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
|
||||
const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']);
|
||||
const INSTAGRAM_REEL_TIMEOUT_SECONDS = 600;
|
||||
|
||||
type InstagramReelSuccessRow = {
|
||||
status: string;
|
||||
detail: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ReelStageState = {
|
||||
state: 'crop' | 'edit' | 'composer' | 'failed' | 'pending';
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type PreparedVideoUpload = {
|
||||
originalPath: string;
|
||||
uploadPath: string;
|
||||
cleanupPath?: string;
|
||||
};
|
||||
|
||||
function requirePage(page: IPage | null): IPage {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for instagram reel');
|
||||
return page;
|
||||
}
|
||||
|
||||
async function gotoInstagramHome(page: IPage, forceReload = false): Promise<void> {
|
||||
if (forceReload) {
|
||||
await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_reset=${Date.now()}`);
|
||||
await page.wait({ time: 1 });
|
||||
}
|
||||
await page.goto(INSTAGRAM_HOME_URL);
|
||||
}
|
||||
|
||||
function validateVideoPath(input: unknown): string {
|
||||
const resolved = path.resolve(String(input || '').trim());
|
||||
if (!resolved) {
|
||||
throw new ArgumentError('Video path cannot be empty');
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new ArgumentError(`Video file not found: ${resolved}`);
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (!SUPPORTED_VIDEO_EXTENSIONS.has(ext)) {
|
||||
throw new ArgumentError(`Unsupported video format: ${ext}`, 'Supported formats: .mp4');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function validateInstagramReelArgs(kwargs: Record<string, unknown>): void {
|
||||
if (kwargs.video === undefined) {
|
||||
throw new ArgumentError(
|
||||
'Argument "video" is required.',
|
||||
'Provide --video /path/to/file.mp4',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstagramReelSuccessResult(url: string): InstagramReelSuccessRow[] {
|
||||
return [{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single reel shared successfully',
|
||||
url,
|
||||
}];
|
||||
}
|
||||
|
||||
function isRecoverableReelSessionError(error: unknown): boolean {
|
||||
if (!(error instanceof CommandExecutionError)) return false;
|
||||
return error.message === 'Instagram reel upload input not found'
|
||||
|| error.message === 'Instagram reel preview did not appear after upload'
|
||||
|| error.message === 'Instagram reel upload failed';
|
||||
}
|
||||
|
||||
function buildSafeTempVideoPath(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase() || '.mp4';
|
||||
return path.join(os.tmpdir(), `opencli-instagram-video-real${ext}`);
|
||||
}
|
||||
|
||||
function prepareVideoUpload(filePath: string): PreparedVideoUpload {
|
||||
const baseName = path.basename(filePath);
|
||||
if (/^[a-zA-Z0-9._-]+$/.test(baseName)) {
|
||||
return { originalPath: filePath, uploadPath: filePath };
|
||||
}
|
||||
const uploadPath = buildSafeTempVideoPath(filePath);
|
||||
fs.copyFileSync(filePath, uploadPath);
|
||||
return {
|
||||
originalPath: filePath,
|
||||
uploadPath,
|
||||
cleanupPath: uploadPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureComposerOpen(page: IPage): Promise<void> {
|
||||
const result = await page.evaluate(buildEnsureComposerOpenJs()) as { ok?: boolean; reason?: string };
|
||||
if (!result?.ok) {
|
||||
if (result?.reason === 'auth') {
|
||||
throw new AuthRequiredError('www.instagram.com', 'Instagram login required before posting a reel');
|
||||
}
|
||||
throw new CommandExecutionError('Failed to open Instagram reel composer');
|
||||
}
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const ready = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const inputs = Array.from(document.querySelectorAll('input[type="file"]'))
|
||||
.filter((el) => el instanceof HTMLInputElement)
|
||||
.filter((el) => {
|
||||
const dialog = el.closest('[role="dialog"]');
|
||||
return dialog instanceof HTMLElement && isVisible(dialog);
|
||||
});
|
||||
return { ok: inputs.length > 0 };
|
||||
})()
|
||||
`) as { ok?: boolean };
|
||||
if (ready?.ok) return;
|
||||
if (attempt < 11) await page.wait({ time: 0.5 });
|
||||
}
|
||||
throw new CommandExecutionError('Instagram reel upload input not found', 'Open the new-post composer in a logged-in browser session and retry');
|
||||
}
|
||||
|
||||
async function dismissResidualDialogs(page: IPage): Promise<void> {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||
.filter((el) => el instanceof HTMLElement && isVisible(el));
|
||||
for (const dialog of dialogs) {
|
||||
const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||
if (!text) continue;
|
||||
if (
|
||||
text.includes('post shared')
|
||||
|| text.includes('your post has been shared')
|
||||
|| text.includes('your reel has been shared')
|
||||
|| text.includes('video posts are now reels')
|
||||
|| text.includes('something went wrong')
|
||||
|| text.includes('sharing')
|
||||
|| text.includes('create new post')
|
||||
|| text.includes('new reel')
|
||||
|| text.includes('crop')
|
||||
|| text.includes('edit')
|
||||
) {
|
||||
const close = dialog.querySelector('[aria-label="Close"], button[aria-label="Close"], div[role="button"][aria-label="Close"]');
|
||||
if (close instanceof HTMLElement && isVisible(close)) {
|
||||
close.click();
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
})()
|
||||
`) as { ok?: boolean };
|
||||
|
||||
if (!result?.ok) return;
|
||||
await page.wait({ time: 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveUploadSelectors(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||
.filter((el) => el instanceof HTMLElement && isVisible(el));
|
||||
const roots = dialogs.length ? dialogs : [document.body];
|
||||
const selectors = [];
|
||||
let index = 0;
|
||||
|
||||
for (const root of roots) {
|
||||
const inputs = Array.from(root.querySelectorAll('input[type="file"]'));
|
||||
for (const input of inputs) {
|
||||
if (!(input instanceof HTMLInputElement)) continue;
|
||||
if (input.disabled) continue;
|
||||
const accept = (input.getAttribute('accept') || '').toLowerCase();
|
||||
if (accept && !accept.includes('video') && !accept.includes('.mp4')) continue;
|
||||
input.setAttribute('data-opencli-reel-upload-index', String(index));
|
||||
selectors.push('[data-opencli-reel-upload-index="' + index + '"]');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: selectors.length > 0, selectors };
|
||||
})()
|
||||
`) as { ok?: boolean; selectors?: string[] };
|
||||
|
||||
if (!result?.ok || !Array.isArray(result.selectors) || result.selectors.length === 0) {
|
||||
throw new CommandExecutionError(
|
||||
'Instagram reel upload input not found',
|
||||
'Open the new-post composer in a logged-in browser session and retry',
|
||||
);
|
||||
}
|
||||
return result.selectors;
|
||||
}
|
||||
|
||||
async function uploadVideo(page: IPage, videoPath: string, selector: string): Promise<void> {
|
||||
if (!page.setFileInput) {
|
||||
throw new CommandExecutionError(
|
||||
'Instagram reel upload requires Browser Bridge file upload support',
|
||||
'Use Browser Bridge or another browser mode that supports setFileInput',
|
||||
);
|
||||
}
|
||||
await page.setFileInput([videoPath], selector);
|
||||
}
|
||||
|
||||
async function readSelectedFileCount(page: IPage, selector: string): Promise<number | null> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const input = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!(input instanceof HTMLInputElement)) return { count: null };
|
||||
return { count: input.files?.length || 0 };
|
||||
})()
|
||||
`) as { count?: number | null };
|
||||
if (result?.count === null || result?.count === undefined) return null;
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
async function waitForVideoPreview(page: IPage, maxWaitSeconds = 20): Promise<void> {
|
||||
let lastDetail = '';
|
||||
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
|
||||
const result = await page.evaluate(buildInspectUploadStageJs()) as { state?: string; detail?: string };
|
||||
lastDetail = String(result?.detail || '').trim();
|
||||
if (result?.state === 'preview') return;
|
||||
if (result?.state === 'failed') {
|
||||
throw new CommandExecutionError(
|
||||
'Instagram reel upload failed',
|
||||
result.detail ? `Instagram rejected the reel upload: ${result.detail}` : 'Instagram rejected the reel upload before the preview stage',
|
||||
);
|
||||
}
|
||||
if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 });
|
||||
}
|
||||
const debugPath = path.join(os.tmpdir(), 'instagram_reel_preview_debug.png');
|
||||
await page.screenshot({ path: debugPath });
|
||||
throw new CommandExecutionError(
|
||||
'Instagram reel preview did not appear after upload',
|
||||
lastDetail
|
||||
? `Inspect ${debugPath}. Last visible dialog text: ${lastDetail}`
|
||||
: `Inspect ${debugPath} for the upload state`,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickAction(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise<string> {
|
||||
const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean; label?: string };
|
||||
if (!result?.ok) {
|
||||
throw new CommandExecutionError(`Instagram action button not found: ${labels.join(' / ')}`);
|
||||
}
|
||||
return result.label || labels[0] || '';
|
||||
}
|
||||
|
||||
async function clickActionMaybe(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise<boolean> {
|
||||
const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean };
|
||||
return !!result?.ok;
|
||||
}
|
||||
|
||||
function buildInspectReelStageJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
const text = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' ');
|
||||
const lower = text.toLowerCase();
|
||||
const hasVisibleButton = (labels) => dialogs.some((dialog) =>
|
||||
Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => {
|
||||
const value = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||
return isVisible(el) && labels.includes(value);
|
||||
})
|
||||
);
|
||||
if (/something went wrong|please try again|share failed|couldn['’]t be shared|could not be shared|失败|出错/.test(lower)) {
|
||||
return { state: 'failed', detail: text };
|
||||
}
|
||||
if (/new reel|write a caption|add location|tag people/.test(lower) && hasVisibleButton(['share'])) {
|
||||
return { state: 'composer', detail: text };
|
||||
}
|
||||
if (/edit|cover photo|trim|video has no audio/.test(lower) && hasVisibleButton(['next'])) {
|
||||
return { state: 'edit', detail: text };
|
||||
}
|
||||
if (/crop|select crop|open media gallery/.test(lower) && hasVisibleButton(['next'])) {
|
||||
return { state: 'crop', detail: text };
|
||||
}
|
||||
return { state: 'pending', detail: text };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
async function waitForReelStage(page: IPage, expected: ReelStageState['state'], maxWaitSeconds = 20): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) {
|
||||
const result = await page.evaluate(buildInspectReelStageJs()) as ReelStageState;
|
||||
if (result?.state === expected) return;
|
||||
if (result?.state === 'failed') {
|
||||
throw new CommandExecutionError(
|
||||
'Instagram reel editor did not appear',
|
||||
result.detail ? `Instagram reel flow failed: ${result.detail}` : 'Instagram reel flow failed before the next editor stage',
|
||||
);
|
||||
}
|
||||
if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 });
|
||||
}
|
||||
throw new CommandExecutionError(`Instagram reel ${expected} editor did not appear`);
|
||||
}
|
||||
|
||||
async function focusCaptionEditor(page: IPage): Promise<boolean> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
for (const dialog of dialogs) {
|
||||
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
return { ok: true, kind: 'textarea' };
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||||
|| dialog.querySelector('[contenteditable="true"]');
|
||||
if (editor instanceof HTMLElement && isVisible(editor)) {
|
||||
const lexical = editor.__lexicalEditor;
|
||||
try {
|
||||
if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') {
|
||||
const emptyState = {
|
||||
root: {
|
||||
children: [{
|
||||
children: [],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
}],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
const nextState = lexical.parseEditorState(JSON.stringify(emptyState));
|
||||
try {
|
||||
lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true });
|
||||
} catch {
|
||||
lexical.setEditorState(nextState);
|
||||
}
|
||||
} else {
|
||||
editor.textContent = '';
|
||||
}
|
||||
} catch {
|
||||
editor.textContent = '';
|
||||
}
|
||||
|
||||
editor.focus();
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
selection.addRange(range);
|
||||
}
|
||||
return { ok: true, kind: 'contenteditable' };
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
})()
|
||||
`) as { ok?: boolean };
|
||||
return !!result?.ok;
|
||||
}
|
||||
|
||||
async function captionMatches(page: IPage, content: string): Promise<boolean> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const target = ${JSON.stringify(content.trim())}.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const readLexicalText = (node) => {
|
||||
if (!node || typeof node !== 'object') return '';
|
||||
if (node.type === 'text' && typeof node.text === 'string') return node.text;
|
||||
if (!Array.isArray(node.children)) return '';
|
||||
if (node.type === 'root') return node.children.map((child) => readLexicalText(child)).join('\\n');
|
||||
if (node.type === 'paragraph') return node.children.map((child) => readLexicalText(child)).join('');
|
||||
return node.children.map((child) => readLexicalText(child)).join('');
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
for (const dialog of dialogs) {
|
||||
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
|
||||
if (textarea.value.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim() === target) {
|
||||
return { ok: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||||
|| dialog.querySelector('[contenteditable="true"]');
|
||||
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
|
||||
|
||||
const lexical = editor.__lexicalEditor;
|
||||
if (lexical && typeof lexical.getEditorState === 'function') {
|
||||
const currentState = lexical.getEditorState();
|
||||
const pendingState = lexical._pendingEditorState;
|
||||
const current = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : null;
|
||||
const pending = pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null;
|
||||
const currentText = readLexicalText(current && current.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
const pendingText = readLexicalText(pending && pending.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
if (currentText === target || pendingText === target) {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
const value = (editor.textContent || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
if (value === target) {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
})()
|
||||
`) as { ok?: boolean };
|
||||
return !!result?.ok;
|
||||
}
|
||||
|
||||
async function fillCaption(page: IPage, content: string): Promise<void> {
|
||||
const focused = await focusCaptionEditor(page);
|
||||
if (!focused) {
|
||||
throw new CommandExecutionError('Instagram reel caption editor did not appear');
|
||||
}
|
||||
if (page.insertText) {
|
||||
try {
|
||||
await page.insertText(content);
|
||||
await page.wait({ time: 0.3 });
|
||||
await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
for (const dialog of dialogs) {
|
||||
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
|
||||
textarea.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' }));
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
textarea.blur();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||||
|| dialog.querySelector('[contenteditable="true"]');
|
||||
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
|
||||
try {
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' }));
|
||||
} catch {
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
}
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
editor.blur();
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false };
|
||||
})()
|
||||
`);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to browser-side editor manipulation below.
|
||||
}
|
||||
}
|
||||
await page.evaluate(`
|
||||
((content) => {
|
||||
const createParagraph = (text) => ({
|
||||
children: text
|
||||
? [{ detail: 0, format: 0, mode: 'normal', style: '', text, type: 'text', version: 1 }]
|
||||
: [],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
});
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
for (const dialog of dialogs) {
|
||||
const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) {
|
||||
textarea.focus();
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', content);
|
||||
textarea.dispatchEvent(new ClipboardEvent('paste', {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
setter?.call(textarea, content);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
textarea.blur();
|
||||
return { ok: true, mode: 'textarea' };
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]')
|
||||
|| dialog.querySelector('[contenteditable="true"]');
|
||||
if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue;
|
||||
|
||||
editor.focus();
|
||||
const lexical = editor.__lexicalEditor;
|
||||
if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') {
|
||||
const currentState = lexical.getEditorState && lexical.getEditorState();
|
||||
const base = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : {};
|
||||
const lines = String(content).split(/\\r?\\n/);
|
||||
const paragraphs = lines.map((line) => createParagraph(line));
|
||||
base.root = {
|
||||
children: paragraphs.length ? paragraphs : [createParagraph('')],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const nextState = lexical.parseEditorState(JSON.stringify(base));
|
||||
try {
|
||||
lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true });
|
||||
} catch {
|
||||
lexical.setEditorState(nextState);
|
||||
}
|
||||
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
editor.blur();
|
||||
return { ok: true, mode: 'lexical' };
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
selection.addRange(range);
|
||||
}
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', content);
|
||||
editor.dispatchEvent(new ClipboardEvent('paste', {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
editor.blur();
|
||||
return { ok: true, mode: 'contenteditable' };
|
||||
}
|
||||
return { ok: false };
|
||||
})(${JSON.stringify(content)})
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensureCaptionFilled(page: IPage, content: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
if (await captionMatches(page, content)) return;
|
||||
if (attempt < 5) await page.wait({ time: 0.5 });
|
||||
}
|
||||
throw new CommandExecutionError('Instagram reel caption did not stick before sharing');
|
||||
}
|
||||
|
||||
function buildReelPublishStatusProbeJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el));
|
||||
const dialogText = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' ');
|
||||
const lower = dialogText.toLowerCase();
|
||||
const url = window.location.href;
|
||||
const sharingVisible = /sharing/.test(lower);
|
||||
const shared = /your reel has been shared|reel shared|已分享|已发布/.test(lower) || /\\/reel\\//.test(url);
|
||||
const failed = !shared && !sharingVisible && (
|
||||
/couldn['’]t be shared|could not be shared|share failed|无法分享|分享失败/.test(lower)
|
||||
|| (/something went wrong/.test(lower) && /try again/.test(lower))
|
||||
);
|
||||
const composerOpen = dialogs.some((dialog) =>
|
||||
!!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]')
|
||||
|| /new reel|cover photo|trim|select from computer|crop|sharing/.test((dialog.textContent || '').toLowerCase())
|
||||
);
|
||||
const settled = !shared && !composerOpen && !sharingVisible;
|
||||
return { ok: shared, failed, settled, url: /\\/reel\\//.test(url) ? url : '' };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
async function waitForPublishSuccess(page: IPage): Promise<string> {
|
||||
let settledStreak = 0;
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const result = await page.evaluate(buildReelPublishStatusProbeJs()) as { ok?: boolean; failed?: boolean; settled?: boolean; url?: string };
|
||||
if (result?.failed) {
|
||||
throw new CommandExecutionError('Instagram reel share failed');
|
||||
}
|
||||
if (result?.ok) {
|
||||
return result.url || '';
|
||||
}
|
||||
if (result?.settled) {
|
||||
settledStreak += 1;
|
||||
if (settledStreak >= 3) return '';
|
||||
} else {
|
||||
settledStreak = 0;
|
||||
}
|
||||
if (attempt < 119) await page.wait({ time: 1 });
|
||||
}
|
||||
throw new CommandExecutionError('Instagram reel share confirmation did not appear');
|
||||
}
|
||||
|
||||
async function resolveCurrentUserId(page: IPage): Promise<string> {
|
||||
const cookies = await page.getCookies({ domain: 'instagram.com' });
|
||||
return cookies.find((cookie: BrowserCookie) => cookie.name === 'ds_user_id')?.value || '';
|
||||
}
|
||||
|
||||
async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise<string> {
|
||||
if (currentUserId) {
|
||||
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
|
||||
const apiResult = await page.evaluate(`
|
||||
(async () => {
|
||||
const userId = ${JSON.stringify(currentUserId)};
|
||||
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: appId ? { 'X-IG-App-ID': appId } : {},
|
||||
},
|
||||
);
|
||||
if (!res.ok) return { ok: false };
|
||||
const data = await res.json();
|
||||
const username = data?.user?.username || '';
|
||||
return { ok: !!username, username };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
})()
|
||||
`) as { ok?: boolean; username?: string };
|
||||
|
||||
if (apiResult?.ok && apiResult.username) {
|
||||
return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function collectVisibleProfileMediaPaths(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const hrefs = Array.from(document.querySelectorAll('a[href*="/reel/"], a[href*="/p/"]'))
|
||||
.filter((el) => el instanceof HTMLAnchorElement && isVisible(el))
|
||||
.map((el) => el.getAttribute('href') || '')
|
||||
.filter((href) => /^\\/(?:[^/?#]+\\/)?(?:reel|p)\\/[^/?#]+\\/?$/.test(href))
|
||||
.filter((href, index, arr) => arr.indexOf(href) === index);
|
||||
return { hrefs };
|
||||
})()
|
||||
`) as { hrefs?: string[] };
|
||||
return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function captureExistingProfileMediaPaths(page: IPage): Promise<Set<string>> {
|
||||
const currentUserId = await resolveCurrentUserId(page);
|
||||
if (!currentUserId) return new Set();
|
||||
const profileUrl = await resolveProfileUrl(page, currentUserId);
|
||||
if (!profileUrl) return new Set();
|
||||
try {
|
||||
await page.goto(profileUrl);
|
||||
await page.wait({ time: 3 });
|
||||
return new Set(await collectVisibleProfileMediaPaths(page));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatestReelUrl(page: IPage, existingPaths: ReadonlySet<string>): Promise<string> {
|
||||
const currentUrl = await page.getCurrentUrl?.();
|
||||
if (currentUrl && /\/reel\//.test(currentUrl)) return currentUrl;
|
||||
|
||||
const currentUserId = await resolveCurrentUserId(page);
|
||||
const profileUrl = await resolveProfileUrl(page, currentUserId);
|
||||
if (!profileUrl) return '';
|
||||
|
||||
await page.goto(profileUrl);
|
||||
await page.wait({ time: 4 });
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const hrefs = await collectVisibleProfileMediaPaths(page);
|
||||
const href = hrefs.find((candidate) => candidate.includes('/reel/') && !existingPaths.has(candidate))
|
||||
|| hrefs.find((candidate) => !existingPaths.has(candidate))
|
||||
|| '';
|
||||
if (href) {
|
||||
return new URL(href, INSTAGRAM_HOME_URL).toString();
|
||||
}
|
||||
if (attempt < 7) await page.wait({ time: 1 });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'reel',
|
||||
description: 'Post an Instagram reel video',
|
||||
domain: 'www.instagram.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
timeoutSeconds: INSTAGRAM_REEL_TIMEOUT_SECONDS,
|
||||
args: [
|
||||
{ name: 'video', required: false, valueRequired: true, help: 'Path to a single .mp4 video file' },
|
||||
{ name: 'content', positional: true, required: false, help: 'Caption text' },
|
||||
],
|
||||
columns: ['status', 'detail', 'url'],
|
||||
validateArgs: validateInstagramReelArgs,
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
const browserPage = requirePage(page);
|
||||
const videoPath = validateVideoPath(kwargs.video);
|
||||
const content = String(kwargs.content ?? '').trim();
|
||||
const preparedUpload = prepareVideoUpload(videoPath);
|
||||
|
||||
const run = async (
|
||||
activePage: IPage,
|
||||
existingMediaPaths: ReadonlySet<string> = new Set(),
|
||||
): Promise<InstagramReelSuccessRow[]> => {
|
||||
if (typeof activePage.startNetworkCapture === 'function') {
|
||||
await activePage.startNetworkCapture('/rupload_igvideo/|/api/v1/|/reel/|/clips/|/media/|/configure|/upload');
|
||||
}
|
||||
await gotoInstagramHome(activePage, true);
|
||||
await activePage.wait({ time: 2 });
|
||||
await dismissResidualDialogs(activePage);
|
||||
await ensureComposerOpen(activePage);
|
||||
await activePage.wait({ time: 2 });
|
||||
const selectors = await resolveUploadSelectors(activePage);
|
||||
let uploaded = false;
|
||||
let uploadError: unknown;
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
await uploadVideo(activePage, preparedUpload.uploadPath, selector);
|
||||
const selectedFileCount = await readSelectedFileCount(activePage, selector);
|
||||
if (selectedFileCount === 0) {
|
||||
throw new CommandExecutionError('Instagram reel upload failed', 'The selected reel input never received the video file');
|
||||
}
|
||||
await waitForVideoPreview(activePage, 10);
|
||||
uploaded = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
}
|
||||
if (!uploaded) {
|
||||
throw uploadError instanceof Error
|
||||
? uploadError
|
||||
: new CommandExecutionError('Instagram reel preview did not appear after upload');
|
||||
}
|
||||
await clickActionMaybe(activePage, ['OK'], 'any');
|
||||
await clickAction(activePage, ['Next', '下一步'], 'media');
|
||||
await waitForReelStage(activePage, 'edit', 20);
|
||||
await clickAction(activePage, ['Next', '下一步'], 'media');
|
||||
await waitForReelStage(activePage, 'composer', 20);
|
||||
|
||||
if (content) {
|
||||
await fillCaption(activePage, content);
|
||||
await ensureCaptionFilled(activePage, content);
|
||||
}
|
||||
|
||||
await clickAction(activePage, ['Share', '分享'], 'caption');
|
||||
const sharedUrl = await waitForPublishSuccess(activePage);
|
||||
const url = sharedUrl || await resolveLatestReelUrl(activePage, existingMediaPaths);
|
||||
return buildInstagramReelSuccessResult(url);
|
||||
};
|
||||
|
||||
try {
|
||||
const existingMediaPaths = await captureExistingProfileMediaPaths(browserPage);
|
||||
try {
|
||||
return await run(browserPage, existingMediaPaths);
|
||||
} catch (error) {
|
||||
if (!isRecoverableReelSessionError(error)) throw error;
|
||||
return await run(browserPage, existingMediaPaths);
|
||||
}
|
||||
} finally {
|
||||
if (preparedUpload.cleanupPath) {
|
||||
fs.rmSync(preparedUpload.cleanupPath, { force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import * as privatePublish from './_shared/private-publish.js';
|
||||
import './story.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempFile(name: string, bytes = Buffer.from('story-media')): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-story-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createPageMock(evaluateResults: unknown[] = [], overrides: Partial<IPage> = {}): IPage {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
insertText: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentUrl: vi.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('instagram story registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers the story command with a required-value media arg', () => {
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.browser).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true);
|
||||
expect(cmd?.args.some((arg) => arg.name === 'content')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing --media before browser work', async () => {
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
|
||||
await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects multiple media inputs for a single story', async () => {
|
||||
const first = createTempFile('one.jpg');
|
||||
const second = createTempFile('two.mp4');
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
|
||||
await expect(cmd!.func!(page, { media: `${first},${second}` })).rejects.toThrow('single media');
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsupported story formats', async () => {
|
||||
const filePath = createTempFile('story.mov');
|
||||
const page = createPageMock();
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
|
||||
await expect(cmd!.func!(page, { media: filePath })).rejects.toThrow('Unsupported story media format');
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes a single image story through the private route', async () => {
|
||||
const imagePath = createTempFile('story.jpg');
|
||||
const page = createPageMock([
|
||||
{ appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' },
|
||||
{ ok: true, username: 'tsezi_ray' },
|
||||
], {
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
|
||||
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'claim',
|
||||
instagramAjax: 'ajax',
|
||||
webSessionId: 'session',
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({
|
||||
mediaPk: '1234567890',
|
||||
uploadId: '1234567890',
|
||||
});
|
||||
|
||||
const result = await cmd!.func!(page, { media: imagePath });
|
||||
|
||||
expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({
|
||||
page,
|
||||
mediaItem: { type: 'image', filePath: imagePath },
|
||||
content: '',
|
||||
}));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single story shared successfully',
|
||||
url: 'https://www.instagram.com/stories/tsezi_ray/1234567890/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes a single video story through the private route', async () => {
|
||||
const videoPath = createTempFile('story.mp4');
|
||||
const page = createPageMock([
|
||||
{ appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' },
|
||||
{ ok: true, username: 'tsezi_ray' },
|
||||
], {
|
||||
getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]),
|
||||
});
|
||||
const cmd = getRegistry().get('instagram/story');
|
||||
|
||||
vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({
|
||||
apiContext: {
|
||||
asbdId: '359341',
|
||||
csrfToken: 'csrf-token',
|
||||
igAppId: '936619743392459',
|
||||
igWwwClaim: 'claim',
|
||||
instagramAjax: 'ajax',
|
||||
webSessionId: 'session',
|
||||
},
|
||||
jazoest: '22047',
|
||||
});
|
||||
vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({
|
||||
mediaPk: '9988776655',
|
||||
uploadId: '9988776655',
|
||||
});
|
||||
|
||||
const result = await cmd!.func!(page, { media: videoPath });
|
||||
|
||||
expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({
|
||||
page,
|
||||
mediaItem: { type: 'video', filePath: videoPath },
|
||||
content: '',
|
||||
}));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
status: '✅ Posted',
|
||||
detail: 'Single video story shared successfully',
|
||||
url: 'https://www.instagram.com/stories/tsezi_ray/9988776655/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import {
|
||||
publishStoryViaPrivateApi,
|
||||
resolveInstagramPrivatePublishConfig,
|
||||
} from './_shared/private-publish.js';
|
||||
import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js';
|
||||
|
||||
const INSTAGRAM_HOME_URL = 'https://www.instagram.com/';
|
||||
const SUPPORTED_STORY_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const SUPPORTED_STORY_VIDEO_EXTENSIONS = new Set(['.mp4']);
|
||||
|
||||
type InstagramStoryMediaItem = {
|
||||
type: 'image' | 'video';
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type InstagramStorySuccessRow = {
|
||||
status: string;
|
||||
detail: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function requirePage(page: IPage | null): IPage {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for instagram story');
|
||||
return page;
|
||||
}
|
||||
|
||||
function validateInstagramStoryArgs(kwargs: Record<string, unknown>): void {
|
||||
if (kwargs.media === undefined) {
|
||||
throw new ArgumentError(
|
||||
'Argument "media" is required.',
|
||||
'Provide --media /path/to/file.jpg or --media /path/to/file.mp4',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoryMediaItem(kwargs: Record<string, unknown>): InstagramStoryMediaItem {
|
||||
const raw = String(kwargs.media ?? '').trim();
|
||||
const parts = raw.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
throw new ArgumentError(
|
||||
'Argument "media" is required.',
|
||||
'Provide --media /path/to/file.jpg or --media /path/to/file.mp4',
|
||||
);
|
||||
}
|
||||
if (parts.length > 1) {
|
||||
throw new ArgumentError(
|
||||
'Instagram story currently supports a single media item.',
|
||||
'Provide one image or one video path with --media',
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = path.resolve(parts[0]!);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new ArgumentError(`Story media file not found: ${resolved}`);
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (SUPPORTED_STORY_IMAGE_EXTENSIONS.has(ext)) {
|
||||
return { type: 'image', filePath: resolved };
|
||||
}
|
||||
if (SUPPORTED_STORY_VIDEO_EXTENSIONS.has(ext)) {
|
||||
return { type: 'video', filePath: resolved };
|
||||
}
|
||||
throw new ArgumentError(
|
||||
`Unsupported story media format: ${ext}`,
|
||||
'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)',
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCurrentUserId(page: IPage): Promise<string> {
|
||||
const cookies = await page.getCookies({ domain: 'instagram.com' });
|
||||
return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || '';
|
||||
}
|
||||
|
||||
async function resolveCurrentUsername(page: IPage, currentUserId = ''): Promise<string> {
|
||||
if (!currentUserId) return '';
|
||||
const runtimeInfo = await resolveInstagramRuntimeInfo(page);
|
||||
const apiResult = await page.evaluate(`
|
||||
(async () => {
|
||||
const userId = ${JSON.stringify(currentUserId)};
|
||||
const appId = ${JSON.stringify(runtimeInfo.appId || '')};
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/',
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: appId ? { 'X-IG-App-ID': appId } : {},
|
||||
},
|
||||
);
|
||||
if (!res.ok) return { ok: false };
|
||||
const data = await res.json();
|
||||
const username = data?.user?.username || '';
|
||||
return { ok: !!username, username };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
})()
|
||||
`) as { ok?: boolean; username?: string };
|
||||
|
||||
return apiResult?.ok && apiResult.username ? apiResult.username : '';
|
||||
}
|
||||
|
||||
function buildStorySuccessResult(mediaItem: InstagramStoryMediaItem, url: string): InstagramStorySuccessRow[] {
|
||||
return [{
|
||||
status: '✅ Posted',
|
||||
detail: mediaItem.type === 'video'
|
||||
? 'Single video story shared successfully'
|
||||
: 'Single story shared successfully',
|
||||
url,
|
||||
}];
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'story',
|
||||
description: 'Post a single Instagram story image or video',
|
||||
domain: 'www.instagram.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
timeoutSeconds: 300,
|
||||
args: [
|
||||
{ name: 'media', required: false, valueRequired: true, help: 'Path to a single story image or video file' },
|
||||
],
|
||||
columns: ['status', 'detail', 'url'],
|
||||
validateArgs: validateInstagramStoryArgs,
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
const browserPage = requirePage(page);
|
||||
const mediaItem = normalizeStoryMediaItem(kwargs as Record<string, unknown>);
|
||||
const currentUserId = await resolveCurrentUserId(browserPage);
|
||||
const privateConfig = await resolveInstagramPrivatePublishConfig(browserPage);
|
||||
const storyResult = await publishStoryViaPrivateApi({
|
||||
page: browserPage,
|
||||
mediaItem,
|
||||
content: '',
|
||||
apiContext: privateConfig.apiContext,
|
||||
jazoest: privateConfig.jazoest,
|
||||
currentUserId,
|
||||
});
|
||||
const username = await resolveCurrentUsername(browserPage, currentUserId);
|
||||
const mediaPk = storyResult.mediaPk || storyResult.uploadId;
|
||||
const url = username && mediaPk
|
||||
? new URL(`/stories/${username}/${mediaPk}/`, INSTAGRAM_HOME_URL).toString()
|
||||
: '';
|
||||
return buildStorySuccessResult(mediaItem, url);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './chat.js';
|
||||
|
||||
describe('xianyu chat helpers', () => {
|
||||
it('builds goofish im urls from ids', () => {
|
||||
expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe(
|
||||
'https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes numeric ids', () => {
|
||||
expect(__test__.normalizeNumericId('1038951278192', 'item_id', '1038951278192')).toBe('1038951278192');
|
||||
expect(__test__.normalizeNumericId(3650092411, 'user_id', '3650092411')).toBe('3650092411');
|
||||
});
|
||||
|
||||
it('rejects non-numeric ids', () => {
|
||||
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
|
||||
expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AuthRequiredError, SelectorError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
|
||||
function buildChatUrl(itemId: string, peerUserId: string): string {
|
||||
return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
|
||||
}
|
||||
|
||||
function buildExtractChatStateEvaluate(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const bodyText = document.body?.innerText || '';
|
||||
const requiresAuth = /请先登录|登录后/.test(bodyText);
|
||||
|
||||
const textarea = document.querySelector('textarea');
|
||||
const sendButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((btn) => clean(btn.textContent || '') === '发送');
|
||||
const topbar = document.querySelector('[class*="message-topbar"]');
|
||||
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
|
||||
.find((el) => el.closest('main'));
|
||||
const itemTitleNode =
|
||||
document.querySelector('[class*="container"] [class*="title"]')
|
||||
|| document.querySelector('[class*="item-main-info"] [class*="desc"]')
|
||||
|| document.querySelector('[class*="headSkuInfo"]')
|
||||
|| itemCard?.querySelector('[class*="title"]')
|
||||
|| itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');
|
||||
|
||||
const messageRoot = document.querySelector('#message-list-scrollable');
|
||||
const visibleMessages = Array.from(
|
||||
(messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
|
||||
).map((el) => clean(el.textContent || ''))
|
||||
.filter(Boolean)
|
||||
.filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
|
||||
.filter((text) => !/^消息\\d*\\+?$/.test(text))
|
||||
.slice(-20);
|
||||
|
||||
return {
|
||||
requiresAuth,
|
||||
title: clean(document.title || ''),
|
||||
peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
|
||||
peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
|
||||
item_title: clean(itemTitleNode?.textContent || ''),
|
||||
item_url: itemCard?.href || '',
|
||||
price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
|
||||
location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
|
||||
can_input: Boolean(textarea && !textarea.disabled),
|
||||
can_send: Boolean(sendButton),
|
||||
visible_messages: visibleMessages,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSendMessageEvaluate(text: string): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const textarea = document.querySelector('textarea');
|
||||
if (!textarea || textarea.disabled) {
|
||||
return { ok: false, reason: 'input-not-found' };
|
||||
}
|
||||
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (!setter) {
|
||||
return { ok: false, reason: 'textarea-setter-not-found' };
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
setter.call(textarea, ${JSON.stringify(text)});
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
const sendButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((btn) => clean(btn.textContent || '') === '发送');
|
||||
if (!sendButton) {
|
||||
return { ok: false, reason: 'send-button-not-found' };
|
||||
}
|
||||
|
||||
sendButton.click();
|
||||
return { ok: true };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'chat',
|
||||
description: '打开闲鱼聊一聊会话,并可选发送消息',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
|
||||
{ name: 'user_id', required: true, positional: true, help: '聊一聊对方的 user_id / peerUserId' },
|
||||
{ name: 'text', help: 'Message to send after opening the chat' },
|
||||
],
|
||||
columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192');
|
||||
const userId = normalizeNumericId(kwargs.user_id, 'user_id', '3650092411');
|
||||
const url = buildChatUrl(itemId, userId);
|
||||
const text = String(kwargs.text || '').trim();
|
||||
|
||||
await page.goto(url);
|
||||
await page.wait(2);
|
||||
|
||||
const state = await page.evaluate(buildExtractChatStateEvaluate()) as {
|
||||
requiresAuth?: boolean;
|
||||
title?: string;
|
||||
peer_name?: string;
|
||||
peer_masked_id?: string;
|
||||
item_title?: string;
|
||||
item_url?: string;
|
||||
price?: string;
|
||||
location?: string;
|
||||
can_input?: boolean;
|
||||
can_send?: boolean;
|
||||
visible_messages?: string[];
|
||||
};
|
||||
|
||||
if (state?.requiresAuth) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (!state?.can_input) {
|
||||
throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return [{
|
||||
status: 'ready',
|
||||
peer_name: state.peer_name || '',
|
||||
item_title: state.item_title || '',
|
||||
price: state.price || '',
|
||||
location: state.location || '',
|
||||
message: (state.visible_messages || []).slice(-1)[0] || '',
|
||||
peer_user_id: userId,
|
||||
item_id: itemId,
|
||||
url,
|
||||
item_url: state.item_url || '',
|
||||
}];
|
||||
}
|
||||
|
||||
const sent = await page.evaluate(buildSendMessageEvaluate(text)) as {
|
||||
ok?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
if (!sent?.ok) {
|
||||
throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
return [{
|
||||
status: 'sent',
|
||||
peer_name: state.peer_name || '',
|
||||
item_title: state.item_title || '',
|
||||
price: state.price || '',
|
||||
location: state.location || '',
|
||||
message: text,
|
||||
peer_user_id: userId,
|
||||
item_id: itemId,
|
||||
url,
|
||||
item_url: state.item_url || '',
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeNumericId,
|
||||
buildChatUrl,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { __test__ } from './item.js';
|
||||
import './item.js';
|
||||
|
||||
function createPageMock(evaluateResult: unknown): IPage {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
scrollTo: vi.fn().mockResolvedValue(undefined),
|
||||
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe('xianyu item helpers', () => {
|
||||
it('normalizes numeric item ids', () => {
|
||||
expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976');
|
||||
expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976');
|
||||
});
|
||||
|
||||
it('builds item urls', () => {
|
||||
expect(__test__.buildItemUrl('1040754408976')).toBe(
|
||||
'https://www.goofish.com/item?id=1040754408976',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid item ids', () => {
|
||||
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xianyu item command', () => {
|
||||
const command = getRegistry().get('xianyu/item');
|
||||
|
||||
it('throws AuthRequiredError on login wall before mtop is available', async () => {
|
||||
const page = createPageMock({ error: 'auth-required' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('throws EmptyResultError on verification or risk-control pages', async () => {
|
||||
const page = createPageMock({ error: 'blocked' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('keeps SelectorError for true mtop initialization failures', async () => {
|
||||
const page = createPageMock({ error: 'mtop-not-ready' });
|
||||
await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
|
||||
function buildItemUrl(itemId: string): string {
|
||||
return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`;
|
||||
}
|
||||
|
||||
function buildFetchItemEvaluate(itemId: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
|
||||
const extractRetCode = (ret) => {
|
||||
const first = Array.isArray(ret) ? ret[0] : '';
|
||||
return clean(first).split('::')[0] || '';
|
||||
};
|
||||
|
||||
const waitFor = async (predicate, timeoutMs = 5000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const bodyText = document.body?.innerText || '';
|
||||
if (/请先登录|登录后/.test(bodyText)) {
|
||||
return { error: 'auth-required' };
|
||||
}
|
||||
|
||||
if (/验证码|安全验证|异常访问/.test(bodyText)) {
|
||||
return { error: 'blocked' };
|
||||
}
|
||||
|
||||
await waitFor(() => window.lib?.mtop?.request);
|
||||
if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') {
|
||||
return { error: 'mtop-not-ready' };
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await window.lib.mtop.request({
|
||||
api: 'mtop.taobao.idle.pc.detail',
|
||||
data: { itemId: ${JSON.stringify(itemId)} },
|
||||
type: 'POST',
|
||||
v: '1.0',
|
||||
dataType: 'json',
|
||||
needLogin: false,
|
||||
needLoginPC: false,
|
||||
sessionOption: 'AutoLoginOnly',
|
||||
ecode: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const ret = error?.ret || [];
|
||||
return {
|
||||
error: 'mtop-request-failed',
|
||||
error_code: extractRetCode(ret),
|
||||
error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error),
|
||||
};
|
||||
}
|
||||
|
||||
const retCode = extractRetCode(response?.ret || []);
|
||||
if (retCode && retCode !== 'SUCCESS') {
|
||||
return {
|
||||
error: 'mtop-response-error',
|
||||
error_code: retCode,
|
||||
error_message: clean((response?.ret || []).join(' | ')),
|
||||
};
|
||||
}
|
||||
|
||||
const data = response?.data || {};
|
||||
const item = data.itemDO || {};
|
||||
const seller = data.sellerDO || {};
|
||||
const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : [];
|
||||
const findLabel = (name) => labels.find((label) => clean(label.propertyText) === name)?.text || '';
|
||||
const images = Array.isArray(item.imageInfos)
|
||||
? item.imageInfos.map((entry) => entry?.url).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
item_id: clean(item.itemId || ${JSON.stringify(itemId)}),
|
||||
title: clean(item.title || ''),
|
||||
description: clean(item.desc || ''),
|
||||
price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\\s*$/, ''),
|
||||
original_price: clean(item.originalPrice || ''),
|
||||
want_count: String(item.wantCnt ?? ''),
|
||||
collect_count: String(item.collectCnt ?? ''),
|
||||
browse_count: String(item.browseCnt ?? ''),
|
||||
status: clean(item.itemStatusStr || ''),
|
||||
condition: clean(findLabel('成色')),
|
||||
brand: clean(findLabel('品牌')),
|
||||
category: clean(findLabel('分类')),
|
||||
location: clean(seller.publishCity || seller.city || ''),
|
||||
seller_name: clean(seller.nick || seller.uniqueName || ''),
|
||||
seller_id: String(seller.sellerId || ''),
|
||||
seller_score: clean(seller.xianyuSummary || ''),
|
||||
reply_ratio_24h: clean(seller.replyRatio24h || ''),
|
||||
reply_interval: clean(seller.replyInterval || ''),
|
||||
item_url: ${JSON.stringify(buildItemUrl(itemId))},
|
||||
seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '',
|
||||
image_count: String(images.length),
|
||||
image_urls: images,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'item',
|
||||
description: '查看闲鱼商品详情',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
|
||||
],
|
||||
columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'],
|
||||
func: async (page, kwargs) => {
|
||||
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1040754408976');
|
||||
|
||||
await page.goto(buildItemUrl(itemId));
|
||||
await page.wait(2);
|
||||
|
||||
const result = await page.evaluate(buildFetchItemEvaluate(itemId)) as {
|
||||
error?: string;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
title?: string;
|
||||
item_id?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
if (result?.error === 'auth-required') {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (result?.error === 'blocked') {
|
||||
throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
|
||||
}
|
||||
|
||||
if (result?.error === 'mtop-not-ready') {
|
||||
throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
|
||||
}
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
|
||||
}
|
||||
|
||||
const errorCode = String(result.error_code || '');
|
||||
const errorMessage = String(result.error_message || '');
|
||||
if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new EmptyResultError('xianyu item', errorMessage || `Xianyu item detail request failed: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!String(result.title || '').trim()) {
|
||||
throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id');
|
||||
}
|
||||
|
||||
return [result];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
normalizeNumericId,
|
||||
buildItemUrl,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './search.js';
|
||||
|
||||
describe('xianyu search helpers', () => {
|
||||
it('normalizes limit into supported range', () => {
|
||||
expect(__test__.normalizeLimit(undefined)).toBe(20);
|
||||
expect(__test__.normalizeLimit(0)).toBe(1);
|
||||
expect(__test__.normalizeLimit(3.8)).toBe(3);
|
||||
expect(__test__.normalizeLimit(999)).toBe(__test__.MAX_LIMIT);
|
||||
});
|
||||
|
||||
it('builds search URLs with encoded queries', () => {
|
||||
expect(__test__.buildSearchUrl('笔记本电脑')).toBe(
|
||||
'https://www.goofish.com/search?q=%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%B5%E8%84%91',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts item ids from detail URLs', () => {
|
||||
expect(__test__.itemIdFromUrl('https://www.goofish.com/item?id=954988715389&categoryId=126854525')).toBe('954988715389');
|
||||
expect(__test__.itemIdFromUrl('https://www.goofish.com/search?q=test')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function normalizeLimit(value: unknown): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 20;
|
||||
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
|
||||
function buildSearchUrl(query: string): string {
|
||||
return `https://www.goofish.com/search?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
function itemIdFromUrl(url: string): string {
|
||||
const match = url.match(/[?&]id=(\d+)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function buildExtractResultsEvaluate(limit: number): string {
|
||||
return `
|
||||
(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const waitFor = async (predicate, timeoutMs = 8000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await wait(150);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const selectors = {
|
||||
card: 'a[href*="/item?id="]',
|
||||
title: '[class*="row1-wrap-title"], [class*="main-title"]',
|
||||
attrs: '[class*="row2-wrap-cpv"] span[class*="cpv--"]',
|
||||
priceWrap: '[class*="price-wrap"]',
|
||||
priceNum: '[class*="number"]',
|
||||
priceDec: '[class*="decimal"]',
|
||||
priceDesc: '[class*="price-desc"] [title], [class*="price-desc"] [style*="line-through"]',
|
||||
sellerWrap: '[class*="row4-wrap-seller"]',
|
||||
sellerText: '[class*="seller-text"]',
|
||||
badge: '[class*="credit-container"] [title], [class*="credit-container"] span',
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
const bodyText = document.body?.innerText || '';
|
||||
return Boolean(
|
||||
document.querySelector(selectors.card)
|
||||
|| /请先登录|登录后|验证码|安全验证|异常访问/.test(bodyText)
|
||||
|| /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText)
|
||||
);
|
||||
});
|
||||
|
||||
const bodyText = document.body?.innerText || '';
|
||||
const requiresAuth = /请先登录|登录后/.test(bodyText);
|
||||
const blocked = /验证码|安全验证|异常访问/.test(bodyText);
|
||||
const empty = /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText);
|
||||
|
||||
const items = Array.from(document.querySelectorAll(selectors.card))
|
||||
.slice(0, ${limit})
|
||||
.map((card) => {
|
||||
const href = card.href || card.getAttribute('href') || '';
|
||||
const title = clean(card.querySelector(selectors.title)?.textContent || '');
|
||||
const attrs = Array.from(card.querySelectorAll(selectors.attrs))
|
||||
.map((node) => clean(node.textContent || ''))
|
||||
.filter(Boolean);
|
||||
const priceWrap = card.querySelector(selectors.priceWrap);
|
||||
const priceNumber = clean(priceWrap?.querySelector(selectors.priceNum)?.textContent || '');
|
||||
const priceDecimal = clean(priceWrap?.querySelector(selectors.priceDec)?.textContent || '');
|
||||
const location = clean(card.querySelector(selectors.sellerWrap)?.querySelector(selectors.sellerText)?.textContent || '');
|
||||
const originalPriceNode = card.querySelector(selectors.priceDesc);
|
||||
const badgeNode = card.querySelector(selectors.badge);
|
||||
|
||||
return {
|
||||
title,
|
||||
url: href,
|
||||
item_id: '',
|
||||
price: clean('¥' + priceNumber + priceDecimal).replace(/^¥\\s*$/, ''),
|
||||
original_price: clean(originalPriceNode?.getAttribute('title') || originalPriceNode?.textContent || ''),
|
||||
condition: attrs[0] || '',
|
||||
brand: attrs[1] || '',
|
||||
extra: attrs.slice(2).join(' | '),
|
||||
location,
|
||||
badge: clean(badgeNode?.getAttribute('title') || badgeNode?.textContent || ''),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url);
|
||||
|
||||
return { requiresAuth, blocked, empty, items };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xianyu',
|
||||
name: 'search',
|
||||
description: '搜索闲鱼商品',
|
||||
domain: 'www.goofish.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
navigateBefore: false,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results to return' },
|
||||
],
|
||||
columns: ['item_id', 'rank', 'title', 'price', 'condition', 'brand', 'location', 'badge', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs.query || '').trim();
|
||||
const limit = normalizeLimit(kwargs.limit);
|
||||
|
||||
await page.goto(buildSearchUrl(query));
|
||||
await page.wait(2);
|
||||
await page.autoScroll({ times: 2 });
|
||||
|
||||
const payload = await page.evaluate(buildExtractResultsEvaluate(limit)) as {
|
||||
requiresAuth?: boolean;
|
||||
blocked?: boolean;
|
||||
empty?: boolean;
|
||||
items?: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
if (payload?.requiresAuth) {
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu search results require a logged-in browser session');
|
||||
}
|
||||
|
||||
if (payload?.blocked) {
|
||||
throw new EmptyResultError('xianyu search', 'Xianyu returned a verification page or blocked the current browser session');
|
||||
}
|
||||
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
if (!items.length && !payload?.empty) {
|
||||
throw new EmptyResultError('xianyu search', 'No item cards were found on the current Xianyu search page');
|
||||
}
|
||||
|
||||
return items.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
...item,
|
||||
item_id: itemIdFromUrl(item.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
MAX_LIMIT,
|
||||
normalizeLimit,
|
||||
buildSearchUrl,
|
||||
itemIdFromUrl,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArgumentError } from '../../errors.js';
|
||||
|
||||
export function normalizeNumericId(value: unknown, label: string, example: string): string {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!/^\d+$/.test(normalized)) {
|
||||
throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -39,8 +39,8 @@ describe('xiaohongshu search', () => {
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns true)
|
||||
true,
|
||||
// First evaluate: MutationObserver wait (login wall detected)
|
||||
'login_wall',
|
||||
]);
|
||||
|
||||
await expect(cmd!.func!(page, { query: '特斯拉', limit: 5 })).rejects.toThrow(
|
||||
@@ -61,21 +61,18 @@ describe('xiaohongshu search', () => {
|
||||
'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns false → no wall)
|
||||
false,
|
||||
// Second evaluate: main DOM extraction
|
||||
{
|
||||
loginWall: false,
|
||||
results: [
|
||||
{
|
||||
title: '某鱼买FSD被坑了4万',
|
||||
author: '随风',
|
||||
likes: '261',
|
||||
url: detailUrl,
|
||||
author_url: authorUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: main DOM extraction (returns array directly)
|
||||
[
|
||||
{
|
||||
title: '某鱼买FSD被坑了4万',
|
||||
author: '随风',
|
||||
likes: '261',
|
||||
url: detailUrl,
|
||||
author_url: authorUrl,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await cmd!.func!(page, { query: '特斯拉', limit: 1 });
|
||||
@@ -101,35 +98,32 @@ describe('xiaohongshu search', () => {
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: early login-wall check (returns false → no wall)
|
||||
false,
|
||||
// Second evaluate: main DOM extraction
|
||||
{
|
||||
loginWall: false,
|
||||
results: [
|
||||
{
|
||||
title: 'Result A',
|
||||
author: 'UserA',
|
||||
likes: '10',
|
||||
url: 'https://www.xiaohongshu.com/search_result/aaa',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
author: 'UserB',
|
||||
likes: '5',
|
||||
url: 'https://www.xiaohongshu.com/search_result/bbb',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: 'Result C',
|
||||
author: 'UserC',
|
||||
likes: '3',
|
||||
url: 'https://www.xiaohongshu.com/search_result/ccc',
|
||||
author_url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: main DOM extraction (returns array directly)
|
||||
[
|
||||
{
|
||||
title: 'Result A',
|
||||
author: 'UserA',
|
||||
likes: '10',
|
||||
url: 'https://www.xiaohongshu.com/search_result/aaa',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
author: 'UserB',
|
||||
likes: '5',
|
||||
url: 'https://www.xiaohongshu.com/search_result/bbb',
|
||||
author_url: '',
|
||||
},
|
||||
{
|
||||
title: 'Result C',
|
||||
author: 'UserC',
|
||||
likes: '3',
|
||||
url: 'https://www.xiaohongshu.com/search_result/ccc',
|
||||
author_url: '',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = (await cmd!.func!(page, { query: '测试', limit: 1 })) as any[];
|
||||
@@ -138,6 +132,25 @@ describe('xiaohongshu search', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
|
||||
});
|
||||
|
||||
it('waits for content via MutationObserver before extracting', async () => {
|
||||
const cmd = getRegistry().get('xiaohongshu/search');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
// First evaluate: MutationObserver wait (content appeared)
|
||||
'content',
|
||||
// Second evaluate: extraction (returns empty array)
|
||||
[],
|
||||
]);
|
||||
|
||||
const result = (await cmd!.func!(page, { query: '测试等待', limit: 5 })) as any[];
|
||||
expect(result).toHaveLength(0);
|
||||
// Only one navigation, no retry
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
// Two evaluate calls: wait + extraction
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
/**
|
||||
* Wait for search results or login wall using MutationObserver (max 5s).
|
||||
* Returns 'content' if note items appeared, 'login_wall' if login gate
|
||||
* detected, or 'timeout' if neither appeared within the deadline.
|
||||
*/
|
||||
const WAIT_FOR_CONTENT_JS = `
|
||||
new Promise((resolve) => {
|
||||
const detect = () => {
|
||||
if (document.querySelector('section.note-item')) return 'content';
|
||||
if (/登录后查看搜索结果/.test(document.body?.innerText || '')) return 'login_wall';
|
||||
return null;
|
||||
};
|
||||
const found = detect();
|
||||
if (found) return resolve(found);
|
||||
const observer = new MutationObserver(() => {
|
||||
const result = detect();
|
||||
if (result) { observer.disconnect(); resolve(result); }
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(() => { observer.disconnect(); resolve('timeout'); }, 5000);
|
||||
})
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract approximate publish date from a Xiaohongshu note URL.
|
||||
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
||||
@@ -42,15 +65,13 @@ cli({
|
||||
await page.goto(
|
||||
`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`
|
||||
);
|
||||
await page.wait(3);
|
||||
|
||||
// Early login-wall detection: XHS may show a login gate instead of
|
||||
// results. Check *before* autoScroll to avoid crashing on a page
|
||||
// that has no meaningful content to scroll through.
|
||||
const loginCheck = await page.evaluate(`
|
||||
(() => /登录后查看搜索结果/.test(document.body?.innerText || ''))()
|
||||
`);
|
||||
if (loginCheck) {
|
||||
// Wait for search results to render (or login wall to appear).
|
||||
// Uses MutationObserver to resolve as soon as content appears,
|
||||
// instead of a fixed delay + blind retry.
|
||||
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
||||
|
||||
if (waitResult === 'login_wall') {
|
||||
throw new AuthRequiredError(
|
||||
'www.xiaohongshu.com',
|
||||
'Xiaohongshu search results are blocked behind a login wall',
|
||||
@@ -62,8 +83,6 @@ cli({
|
||||
|
||||
const payload = await page.evaluate(`
|
||||
(() => {
|
||||
const loginWall = /登录后查看搜索结果/.test(document.body.innerText || '');
|
||||
|
||||
const normalizeUrl = (href) => {
|
||||
if (!href) return '';
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
@@ -107,20 +126,11 @@ cli({
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
loginWall,
|
||||
results,
|
||||
};
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
|
||||
if ((payload as any).loginWall) {
|
||||
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
||||
}
|
||||
|
||||
const data: any[] = Array.isArray((payload as any).results) ? (payload as any).results : [];
|
||||
const data: any[] = Array.isArray(payload) ? (payload as any[]) : [];
|
||||
return data
|
||||
.filter((item: any) => item.title)
|
||||
.slice(0, kwargs.limit)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, CommandExecutionError, TimeoutError } from '../../errors.js';
|
||||
import { __test__ } from './ask.js';
|
||||
import { askCommand } from './ask.js';
|
||||
|
||||
describe('yuanbao ask helpers', () => {
|
||||
describe('isOnYuanbao', () => {
|
||||
const fakePage = (url: string | Error): IPage =>
|
||||
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
|
||||
|
||||
it('returns true for yuanbao.tencent.com URLs', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/'))).toBe(true);
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/chat/abc'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-yuanbao domains', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage('https://example.com/?next=yuanbao.tencent.com'))).toBe(false);
|
||||
expect(await __test__.isOnYuanbao(fakePage('about:blank'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when evaluate throws', async () => {
|
||||
expect(await __test__.isOnYuanbao(fakePage(new Error('detached')))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes echoed prompt prefixes from transcript additions', () => {
|
||||
expect(__test__.sanitizeYuanbaoResponseText('你好\n你好,我是元宝。', '你好')).toBe('你好,我是元宝。');
|
||||
});
|
||||
|
||||
it('filters transient in-progress assistant placeholders', () => {
|
||||
expect(__test__.sanitizeYuanbaoResponseText('正在搜索资料', '张雪机车相关的股票有哪些?')).toBe('');
|
||||
});
|
||||
|
||||
it('normalizes boolean flags with explicit defaults', () => {
|
||||
expect(__test__.normalizeBooleanFlag(undefined, true)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag(undefined, false)).toBe(false);
|
||||
expect(__test__.normalizeBooleanFlag('true', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('1', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('yes', false)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('false', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores baseline lines and echoed prompts when collecting additions', () => {
|
||||
const response = __test__.collectYuanbaoTranscriptAdditions(
|
||||
['旧消息'],
|
||||
['旧消息', '你好', '你好\n你好,我是元宝。'],
|
||||
'你好',
|
||||
);
|
||||
|
||||
expect(response).toBe('你好,我是元宝。');
|
||||
});
|
||||
|
||||
it('prefers fresh assistant messages over echoed prompts and older messages', () => {
|
||||
const response = __test__.pickLatestYuanbaoAssistantCandidate(
|
||||
['旧回复', '你好', '你好!我是元宝,由腾讯推出的AI助手。'],
|
||||
1,
|
||||
'你好',
|
||||
);
|
||||
|
||||
expect(response).toBe('你好!我是元宝,由腾讯推出的AI助手。');
|
||||
});
|
||||
|
||||
it('converts assistant html tables to markdown tables via turndown', () => {
|
||||
const markdown = __test__.convertYuanbaoHtmlToMarkdown(`
|
||||
<h3>核心产业链概念股一览</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>细分赛道</th><th>核心标的</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>光模块</td><td>中际旭创</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`);
|
||||
|
||||
expect(markdown).toContain('### 核心产业链概念股一览');
|
||||
expect(markdown).toContain('| 细分赛道 | 核心标的 |');
|
||||
expect(markdown).toContain('| --- | --- |');
|
||||
expect(markdown).toContain('| 光模块 | 中际旭创 |');
|
||||
});
|
||||
|
||||
it('tracks stabilization by incrementing repeats and resetting on changes', () => {
|
||||
expect(__test__.updateStableState('', 0, '第一段')).toEqual({
|
||||
previousText: '第一段',
|
||||
stableCount: 0,
|
||||
});
|
||||
|
||||
expect(__test__.updateStableState('第一段', 0, '第一段')).toEqual({
|
||||
previousText: '第一段',
|
||||
stableCount: 1,
|
||||
});
|
||||
|
||||
expect(__test__.updateStableState('第一段', 1, '第二段')).toEqual({
|
||||
previousText: '第二段',
|
||||
stableCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createAskPageMock(overrides: {
|
||||
currentUrl?: string;
|
||||
hasLoginGate?: boolean;
|
||||
sendResult?: { ok?: boolean; reason?: string; detail?: string; action?: string };
|
||||
} = {}): IPage {
|
||||
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
|
||||
const hasLoginGate = overrides.hasLoginGate ?? false;
|
||||
const sendResult = overrides.sendResult;
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(async (script: string) => {
|
||||
if (script === 'window.location.href') return currentUrl;
|
||||
if (script.includes('微信扫码登录')) return hasLoginGate;
|
||||
if (script.includes('[dt-button-id="internet_search"]')) return { found: false, enabled: false };
|
||||
if (script.includes('[dt-button-id="deep_think"]')) return { found: false, enabled: false };
|
||||
if (script.includes('.agent-chat__list__item--ai')) return [];
|
||||
if (script.includes('const stopLines = new Set([')) return [];
|
||||
if (script.includes('Failed to insert the prompt into the Yuanbao composer.')) {
|
||||
return sendResult ?? { ok: true, action: 'click' };
|
||||
}
|
||||
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
}
|
||||
|
||||
describe('yuanbao ask command', () => {
|
||||
it('throws AuthRequiredError when Yuanbao shows a login gate before sending', async () => {
|
||||
const page = createAskPageMock({ hasLoginGate: true });
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when the prompt cannot be sent', async () => {
|
||||
const page = createAskPageMock({
|
||||
sendResult: {
|
||||
ok: false,
|
||||
reason: 'Yuanbao composer was not found.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws TimeoutError when no response arrives before timeout', async () => {
|
||||
const page = createAskPageMock({
|
||||
sendResult: { ok: true, action: 'click' },
|
||||
});
|
||||
|
||||
await expect(askCommand.func!(page, { prompt: '你好', timeout: '-1', search: true, think: false }))
|
||||
.rejects.toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import TurndownService from 'turndown';
|
||||
import { CommandExecutionError, TimeoutError } from '../../errors.js';
|
||||
import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js';
|
||||
|
||||
const YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS = 2;
|
||||
const YUANBAO_MIN_WAIT_MS = 8_000;
|
||||
const YUANBAO_STABLE_POLLS_REQUIRED = 3;
|
||||
|
||||
type YuanbaoSendResult = {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
reason?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type YuanbaoToggleState = {
|
||||
enabled: boolean;
|
||||
found: boolean;
|
||||
};
|
||||
|
||||
function sendFailure(reason?: string, detail?: string) {
|
||||
const suffix = detail ? ` Detail: ${detail}` : '';
|
||||
return new CommandExecutionError(
|
||||
`${reason || 'Unknown Yuanbao send failure.'}${suffix}`,
|
||||
'Make sure the Yuanbao chat composer is visible and ready before retrying.',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value == null || value === '') return fallback;
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function createYuanbaoTurndown(): TurndownService {
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
|
||||
td.addRule('linebreak', {
|
||||
filter: 'br',
|
||||
replacement: () => '\n',
|
||||
});
|
||||
|
||||
td.addRule('table', {
|
||||
filter: 'table',
|
||||
replacement: (content) => `\n\n${content}\n\n`,
|
||||
});
|
||||
|
||||
td.addRule('tableSection', {
|
||||
filter: ['thead', 'tbody', 'tfoot'],
|
||||
replacement: (content) => content,
|
||||
});
|
||||
|
||||
td.addRule('tableRow', {
|
||||
filter: 'tr',
|
||||
replacement: (content, node) => {
|
||||
const element = node as Element;
|
||||
const cells = Array.from(element.children);
|
||||
const isHeaderRow = element.parentElement?.tagName === 'THEAD'
|
||||
|| (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
|
||||
|
||||
const row = `${content}\n`;
|
||||
if (!isHeaderRow) return row;
|
||||
|
||||
const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
|
||||
return `${row}${separator}`;
|
||||
},
|
||||
});
|
||||
|
||||
td.addRule('tableCell', {
|
||||
filter: ['th', 'td'],
|
||||
replacement: (content, node) => {
|
||||
const element = node as Element;
|
||||
const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
|
||||
const prefix = index === 0 ? '| ' : ' ';
|
||||
return `${prefix}${content.trim()} |`;
|
||||
},
|
||||
});
|
||||
|
||||
return td;
|
||||
}
|
||||
|
||||
const yuanbaoTurndown = createYuanbaoTurndown();
|
||||
|
||||
export function convertYuanbaoHtmlToMarkdown(value: string): string {
|
||||
const markdown = yuanbaoTurndown.turndown(value || '');
|
||||
return markdown
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeYuanbaoResponseText(value: string, promptText: string): string {
|
||||
let sanitized = value
|
||||
.replace(/内容由AI生成,仅供参考/gi, '')
|
||||
.replace(/重新回答/gi, '')
|
||||
.trim();
|
||||
|
||||
if (/^(正在搜索资料|搜索资料中|正在思考|思考中)[.。…]*$/u.test(sanitized)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prompt = promptText.trim();
|
||||
if (!prompt) return sanitized;
|
||||
if (sanitized === prompt) return '';
|
||||
|
||||
for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n', ' ']) {
|
||||
const prefix = `${prompt}${separator}`;
|
||||
if (sanitized.startsWith(prefix)) {
|
||||
sanitized = sanitized.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function collectYuanbaoTranscriptAdditions(
|
||||
beforeLines: string[],
|
||||
currentLines: string[],
|
||||
promptText: string,
|
||||
): string {
|
||||
const beforeSet = new Set(beforeLines);
|
||||
const additions = currentLines
|
||||
.filter((line) => !beforeSet.has(line))
|
||||
.map((line) => sanitizeYuanbaoResponseText(line, promptText))
|
||||
.filter((line) => line && line !== promptText);
|
||||
|
||||
return additions.join('\n').trim();
|
||||
}
|
||||
|
||||
export function pickLatestYuanbaoAssistantCandidate(
|
||||
messages: string[],
|
||||
baselineCount: number,
|
||||
promptText: string,
|
||||
): string {
|
||||
const freshMessages = messages
|
||||
.slice(Math.max(0, baselineCount))
|
||||
.map((message) => sanitizeYuanbaoResponseText(message, promptText))
|
||||
.filter(Boolean);
|
||||
|
||||
for (let i = freshMessages.length - 1; i >= 0; i -= 1) {
|
||||
if (freshMessages[i] !== promptText.trim()) return freshMessages[i];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function updateStableState(previousText: string, stableCount: number, nextText: string) {
|
||||
if (!nextText) return { previousText: '', stableCount: 0 };
|
||||
if (nextText === previousText) return { previousText, stableCount: stableCount + 1 };
|
||||
return { previousText: nextText, stableCount: 0 };
|
||||
}
|
||||
|
||||
|
||||
function getTranscriptLinesScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const clean = (value) => (value || '')
|
||||
.replace(/\\u00a0/g, ' ')
|
||||
.replace(/\\n{3,}/g, '\\n\\n')
|
||||
.trim();
|
||||
|
||||
const root = (
|
||||
document.querySelector('.agent-dialogue__content--common')
|
||||
|| document.querySelector('.agent-dialogue__content')
|
||||
|| document.querySelector('.agent-dialogue')
|
||||
|| document.body
|
||||
).cloneNode(true);
|
||||
|
||||
const removableSelectors = [
|
||||
'.agent-dialogue__content--common__input',
|
||||
'.agent-dialogue__tool',
|
||||
'.agent-dialogue__content-copyright',
|
||||
'.index_chatLandingBox__G7hAT',
|
||||
'.index_chatLandingBoxMobile__J8i8v',
|
||||
'.index_chatLandingHintList__M69Lr',
|
||||
'.yb-nav',
|
||||
'.agent-dialogue__content--common__input .ql-toolbar',
|
||||
'.agent-dialogue__content--common__input .ql-container',
|
||||
'.agent-dialogue__content--common__input .ql-editor',
|
||||
'[role="dialog"]',
|
||||
'iframe',
|
||||
'button',
|
||||
'script',
|
||||
'style',
|
||||
'noscript',
|
||||
];
|
||||
|
||||
for (const selector of removableSelectors) {
|
||||
root.querySelectorAll(selector).forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
const stopLines = new Set([
|
||||
'元宝',
|
||||
'DeepSeek',
|
||||
'深度思考',
|
||||
'联网搜索',
|
||||
'工具',
|
||||
'登录',
|
||||
'安装电脑版',
|
||||
'内容由AI生成,仅供参考',
|
||||
'有问题,尽管问,shift+enter换行',
|
||||
'立即创建团队',
|
||||
'微信',
|
||||
'手机',
|
||||
'QQ',
|
||||
'微信扫码登录',
|
||||
'扫码默认已阅读并同意',
|
||||
'用户服务协议',
|
||||
'隐私协议',
|
||||
]);
|
||||
|
||||
const noisyPatterns = [
|
||||
/^支持文件格式[::]/,
|
||||
/^文件拖动到此处即可上传/,
|
||||
/^下载元宝电脑版/,
|
||||
];
|
||||
|
||||
return clean(root.innerText || root.textContent || '')
|
||||
.split('\\n')
|
||||
.map((line) => clean(line))
|
||||
.filter((line) => line
|
||||
&& line.length <= 4000
|
||||
&& !stopLines.has(line)
|
||||
&& !noisyPatterns.some((pattern) => pattern.test(line)));
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
async function getYuanbaoTranscriptLines(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(getTranscriptLinesScript());
|
||||
return Array.isArray(result) ? result.map(normalizeText).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getYuanbaoAssistantMessages(page: IPage): Promise<string[]> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const roots = Array.from(document.querySelectorAll('.agent-chat__list__item--ai'))
|
||||
.filter((node) => isVisible(node));
|
||||
|
||||
return roots.map((root) => {
|
||||
const doneContent = root.querySelector('.hyc-content-md-done');
|
||||
const markdownContent = doneContent || root.querySelector('.hyc-content-md');
|
||||
const speechContent = root.querySelector('.agent-chat__speech-text');
|
||||
const bubbleContent = root.querySelector('.agent-chat__bubble__content');
|
||||
const content = markdownContent || speechContent || bubbleContent;
|
||||
|
||||
if (content instanceof HTMLElement) {
|
||||
return content.innerHTML || content.textContent || '';
|
||||
}
|
||||
|
||||
return root instanceof HTMLElement ? (root.innerHTML || root.textContent || '') : '';
|
||||
}).filter(Boolean);
|
||||
})()`);
|
||||
|
||||
return Array.isArray(result)
|
||||
? result
|
||||
.map((value) => convertYuanbaoHtmlToMarkdown(typeof value === 'string' ? value : ''))
|
||||
.map(normalizeText)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function getYuanbaoInternetSearchState(page: IPage): Promise<YuanbaoToggleState> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (!(button instanceof HTMLElement)) return { found: false, enabled: false };
|
||||
|
||||
const attr = button.getAttribute('dt-internet-search') || '';
|
||||
const className = button.className || '';
|
||||
return {
|
||||
found: true,
|
||||
enabled: attr === 'openInternetSearch' || className.includes('index_v2_active__'),
|
||||
};
|
||||
})()`);
|
||||
|
||||
return result as YuanbaoToggleState;
|
||||
}
|
||||
|
||||
async function setYuanbaoInternetSearch(page: IPage, enabled: boolean): Promise<void> {
|
||||
const current = await getYuanbaoInternetSearchState(page);
|
||||
if (!current.found || current.enabled === enabled) return;
|
||||
|
||||
await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
})()`);
|
||||
|
||||
await page.wait(0.5);
|
||||
}
|
||||
|
||||
async function getYuanbaoDeepThinkState(page: IPage): Promise<YuanbaoToggleState> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (!(button instanceof HTMLElement)) return { found: false, enabled: false };
|
||||
|
||||
const className = button.className || '';
|
||||
return {
|
||||
found: true,
|
||||
enabled: className.includes('ThinkSelector_selected__'),
|
||||
};
|
||||
})()`);
|
||||
|
||||
return result as YuanbaoToggleState;
|
||||
}
|
||||
|
||||
async function setYuanbaoDeepThink(page: IPage, enabled: boolean): Promise<void> {
|
||||
const current = await getYuanbaoDeepThinkState(page);
|
||||
if (!current.found || current.enabled === enabled) return;
|
||||
|
||||
await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
})()`);
|
||||
|
||||
await page.wait(0.5);
|
||||
}
|
||||
|
||||
async function sendYuanbaoMessage(page: IPage, prompt: string): Promise<YuanbaoSendResult> {
|
||||
return await page.evaluate(`(async () => {
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const composer = Array.from(document.querySelectorAll('.ql-editor[contenteditable="true"], .ql-editor, [contenteditable="true"]'))
|
||||
.find(isVisible);
|
||||
|
||||
if (!(composer instanceof HTMLElement)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Yuanbao composer was not found.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
composer.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(composer);
|
||||
range.collapse(false);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
composer.textContent = '';
|
||||
document.execCommand('insertText', false, ${JSON.stringify(prompt)});
|
||||
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(prompt)}, inputType: 'insertText' }));
|
||||
await waitFor(200);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Failed to insert the prompt into the Yuanbao composer.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
const submit = Array.from(document.querySelectorAll('a[class*="send-btn"], button[class*="send-btn"]'))
|
||||
.find((node) => {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) return false;
|
||||
const className = node.className || '';
|
||||
if (typeof className === 'string' && className.includes('disabled')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (submit instanceof HTMLElement) {
|
||||
submit.click();
|
||||
return { ok: true, action: 'click' };
|
||||
}
|
||||
|
||||
composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
return { ok: true, action: 'enter' };
|
||||
})()`) as YuanbaoSendResult;
|
||||
}
|
||||
|
||||
async function waitForYuanbaoResponse(
|
||||
page: IPage,
|
||||
baselineAssistantCount: number,
|
||||
beforeLines: string[],
|
||||
prompt: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<string | null | 'blocked'> {
|
||||
const startTime = Date.now();
|
||||
let previousText = '';
|
||||
let stableCount = 0;
|
||||
let latestCandidate = '';
|
||||
|
||||
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
||||
await page.wait(YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const assistantMessages = await getYuanbaoAssistantMessages(page);
|
||||
const assistantCandidate = pickLatestYuanbaoAssistantCandidate(
|
||||
assistantMessages,
|
||||
baselineAssistantCount,
|
||||
prompt,
|
||||
);
|
||||
|
||||
const candidate = assistantCandidate || collectYuanbaoTranscriptAdditions(
|
||||
beforeLines,
|
||||
await getYuanbaoTranscriptLines(page),
|
||||
prompt,
|
||||
);
|
||||
|
||||
if (!candidate) continue;
|
||||
|
||||
latestCandidate = candidate;
|
||||
const nextState = updateStableState(previousText, stableCount, candidate);
|
||||
previousText = nextState.previousText;
|
||||
stableCount = nextState.stableCount;
|
||||
|
||||
const waitedLongEnough = Date.now() - startTime >= YUANBAO_MIN_WAIT_MS;
|
||||
if (waitedLongEnough && stableCount >= YUANBAO_STABLE_POLLS_REQUIRED) return candidate;
|
||||
}
|
||||
|
||||
return latestCandidate || null;
|
||||
}
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'yuanbao',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to Yuanbao web chat and wait for the assistant response',
|
||||
domain: YUANBAO_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
defaultFormat: 'plain',
|
||||
timeoutSeconds: 180,
|
||||
args: [
|
||||
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' },
|
||||
{ name: 'search', type: 'boolean', required: false, help: 'Enable Yuanbao internet search (default: true)', default: true },
|
||||
{ name: 'think', type: 'boolean', required: false, help: 'Enable Yuanbao deep thinking (default: false)', default: false },
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const prompt = kwargs.prompt as string;
|
||||
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
|
||||
const useSearch = normalizeBooleanFlag(kwargs.search, true);
|
||||
const useThink = normalizeBooleanFlag(kwargs.think, false);
|
||||
|
||||
await ensureYuanbaoPage(page);
|
||||
if (await hasLoginGate(page)) {
|
||||
throw authRequired('Yuanbao opened a login gate before sending the prompt.');
|
||||
}
|
||||
await setYuanbaoInternetSearch(page, useSearch);
|
||||
await setYuanbaoDeepThink(page, useThink);
|
||||
const beforeAssistantMessages = await getYuanbaoAssistantMessages(page);
|
||||
const beforeLines = await getYuanbaoTranscriptLines(page);
|
||||
const sendResult = await sendYuanbaoMessage(page, prompt);
|
||||
|
||||
if (!sendResult?.ok) {
|
||||
if (await hasLoginGate(page)) {
|
||||
throw authRequired('Yuanbao opened a login gate instead of accepting the prompt.');
|
||||
}
|
||||
throw sendFailure(sendResult?.reason, sendResult?.detail);
|
||||
}
|
||||
|
||||
const response = await waitForYuanbaoResponse(
|
||||
page,
|
||||
beforeAssistantMessages.length,
|
||||
beforeLines,
|
||||
prompt,
|
||||
timeout,
|
||||
);
|
||||
|
||||
if (response === 'blocked') {
|
||||
throw authRequired('Yuanbao opened a login gate instead of returning a chat response.');
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new TimeoutError(
|
||||
'yuanbao ask',
|
||||
timeout,
|
||||
'No Yuanbao response was observed before the timeout. Retry with --timeout, and verify the current browser session is still interactive.',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{ Role: 'User', Text: prompt },
|
||||
{ Role: 'Assistant', Text: response },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
collectYuanbaoTranscriptAdditions,
|
||||
convertYuanbaoHtmlToMarkdown,
|
||||
isOnYuanbao,
|
||||
normalizeBooleanFlag,
|
||||
pickLatestYuanbaoAssistantCandidate,
|
||||
sanitizeYuanbaoResponseText,
|
||||
updateStableState,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
import { newCommand } from './new.js';
|
||||
|
||||
function createNewPageMock(overrides: {
|
||||
currentUrl?: string;
|
||||
triggerAction?: 'clicked' | 'navigate';
|
||||
hasLoginGate?: boolean;
|
||||
composerText?: string;
|
||||
} = {}): IPage {
|
||||
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
|
||||
const triggerAction = overrides.triggerAction ?? 'clicked';
|
||||
const hasLoginGate = overrides.hasLoginGate ?? false;
|
||||
const composerText = overrides.composerText ?? '';
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockImplementation(async (script: string) => {
|
||||
if (script === 'window.location.href') return currentUrl;
|
||||
if (script.includes('微信扫码登录')) return hasLoginGate;
|
||||
if (script.includes('.ql-editor, [contenteditable="true"]')) return composerText;
|
||||
if (script.includes('const trigger = Array.from(document.querySelectorAll')) return triggerAction;
|
||||
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
|
||||
}),
|
||||
} as unknown as IPage;
|
||||
}
|
||||
|
||||
describe('yuanbao new command', () => {
|
||||
it('throws AuthRequiredError when Yuanbao shows a login gate', async () => {
|
||||
const page = createNewPageMock({ hasLoginGate: true });
|
||||
|
||||
await expect(newCommand.func!(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, ensureYuanbaoPage, hasLoginGate } from './shared.js';
|
||||
|
||||
async function getCurrentUrl(page: IPage): Promise<string> {
|
||||
const result = await page.evaluate('window.location.href').catch(() => '');
|
||||
return typeof result === 'string' ? result : '';
|
||||
}
|
||||
|
||||
async function getComposerText(page: IPage): Promise<string> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
const composer = document.querySelector('.ql-editor, [contenteditable="true"]');
|
||||
return composer ? (composer.textContent || '').trim() : '';
|
||||
})()`);
|
||||
|
||||
return typeof result === 'string' ? result.trim() : '';
|
||||
}
|
||||
|
||||
async function startNewYuanbaoChat(page: IPage): Promise<'clicked' | 'navigate' | 'blocked'> {
|
||||
await ensureYuanbaoPage(page);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const beforeUrl = await getCurrentUrl(page);
|
||||
const action = await page.evaluate(`(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
|
||||
const trigger = Array.from(document.querySelectorAll('.yb-common-nav__trigger[data-desc="new-chat"]'))
|
||||
.find((node) => isVisible(node));
|
||||
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.click();
|
||||
return 'clicked';
|
||||
}
|
||||
|
||||
return 'navigate';
|
||||
})()`) as 'clicked' | 'navigate';
|
||||
|
||||
if (action === 'navigate') {
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
return 'navigate';
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
if (await hasLoginGate(page)) return 'blocked';
|
||||
|
||||
const afterUrl = await getCurrentUrl(page);
|
||||
const composerText = await getComposerText(page);
|
||||
if (afterUrl !== beforeUrl || !composerText) return 'clicked';
|
||||
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
return 'navigate';
|
||||
}
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'yuanbao',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in Yuanbao web chat',
|
||||
domain: YUANBAO_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Action'],
|
||||
func: async (page: IPage) => {
|
||||
const action = await startNewYuanbaoChat(page);
|
||||
|
||||
if (action === 'blocked') {
|
||||
throw authRequired('Yuanbao opened a login gate instead of starting a new chat.');
|
||||
}
|
||||
|
||||
return [{
|
||||
Status: 'Success',
|
||||
Action: action === 'navigate' ? 'Reloaded Yuanbao homepage as fallback' : 'Clicked New chat',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
export const YUANBAO_DOMAIN = 'yuanbao.tencent.com';
|
||||
export const YUANBAO_URL = 'https://yuanbao.tencent.com/';
|
||||
|
||||
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing yuanbao.tencent.com browser session.';
|
||||
|
||||
/**
|
||||
* Reusable visibility check for injected browser scripts.
|
||||
* Embed in page.evaluate strings via `${IS_VISIBLE_JS}`.
|
||||
*/
|
||||
export const IS_VISIBLE_JS = `const isVisible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden';
|
||||
};`;
|
||||
|
||||
export function authRequired(message: string) {
|
||||
return new AuthRequiredError(YUANBAO_DOMAIN, `${message} ${SESSION_HINT}`);
|
||||
}
|
||||
|
||||
export async function isOnYuanbao(page: IPage): Promise<boolean> {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return hostname === YUANBAO_DOMAIN || hostname.endsWith(`.${YUANBAO_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureYuanbaoPage(page: IPage): Promise<void> {
|
||||
if (!(await isOnYuanbao(page))) {
|
||||
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasLoginGate(page: IPage): Promise<boolean> {
|
||||
const result = await page.evaluate(`(() => {
|
||||
const bodyText = document.body.innerText || '';
|
||||
const hasWechatLoginText = bodyText.includes('微信扫码登录');
|
||||
const hasWechatIframe = Array.from(document.querySelectorAll('iframe'))
|
||||
.some((frame) => (frame.getAttribute('src') || '').includes('open.weixin.qq.com/connect/qrconnect'));
|
||||
|
||||
return hasWechatLoginText || hasWechatIframe;
|
||||
})()`);
|
||||
|
||||
return Boolean(result);
|
||||
}
|
||||
@@ -125,6 +125,57 @@ describe('commanderAdapter boolean alias support', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('commanderAdapter value-required optional options', () => {
|
||||
const cmd: CliCommand = {
|
||||
site: 'instagram',
|
||||
name: 'post',
|
||||
description: 'Post to Instagram',
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'image', valueRequired: true, help: 'Single image path' },
|
||||
{ name: 'images', valueRequired: true, help: 'Comma-separated image paths' },
|
||||
{ name: 'content', positional: true, required: false, help: 'Caption text' },
|
||||
],
|
||||
validateArgs: (kwargs) => {
|
||||
if (!kwargs.image && !kwargs.images) {
|
||||
throw new Error('media required');
|
||||
}
|
||||
},
|
||||
func: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteCommand.mockReset();
|
||||
mockExecuteCommand.mockResolvedValue([]);
|
||||
mockRenderOutput.mockReset();
|
||||
delete process.env.OPENCLI_VERBOSE;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('requires a value when --image is present', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const siteCmd = program.command('instagram');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['node', 'opencli', 'instagram', 'post', '--image']),
|
||||
).rejects.toMatchObject({ code: 'commander.optionMissingArgument' });
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs validateArgs before executeCommand so missing media does not dispatch the browser command', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('instagram');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'instagram', 'post', 'caption only']);
|
||||
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('commanderAdapter command aliases', () => {
|
||||
const cmd: CliCommand = {
|
||||
site: 'notebooklm',
|
||||
|
||||
@@ -62,7 +62,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
subCmd.argument(bracket, arg.help ?? '');
|
||||
positionalArgs.push(arg);
|
||||
} else {
|
||||
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
const expectsValue = arg.required || arg.valueRequired;
|
||||
const flag = expectsValue ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
|
||||
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
||||
else subCmd.option(flag, arg.help ?? '');
|
||||
@@ -93,6 +94,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
|
||||
if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
|
||||
}
|
||||
cmd.validateArgs?.(kwargs);
|
||||
|
||||
const verbose = optionsRecord.verbose === true;
|
||||
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
|
||||
|
||||
@@ -139,6 +139,7 @@ export async function executeCommand(
|
||||
let kwargs: CommandArgs;
|
||||
try {
|
||||
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
||||
cmd.validateArgs?.(kwargs);
|
||||
} catch (err) {
|
||||
if (err instanceof ArgumentError) throw err;
|
||||
throw new ArgumentError(getErrorMessage(err));
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Arg {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
valueRequired?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
@@ -47,6 +48,7 @@ export interface CliCommand {
|
||||
source?: string;
|
||||
footerExtra?: (kwargs: CommandArgs) => string | undefined;
|
||||
requiredEnv?: RequiredEnv[];
|
||||
validateArgs?: (kwargs: CommandArgs) => void;
|
||||
/** Deprecation note shown in help / execution warnings. */
|
||||
deprecated?: boolean | string;
|
||||
/** Preferred replacement command, if any. */
|
||||
|
||||
@@ -14,6 +14,7 @@ export type SerializedArg = {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
valueRequired: boolean;
|
||||
positional: boolean;
|
||||
choices: string[];
|
||||
default: unknown;
|
||||
@@ -26,6 +27,7 @@ export function serializeArg(a: Arg): SerializedArg {
|
||||
name: a.name,
|
||||
type: a.type ?? 'string',
|
||||
required: !!a.required,
|
||||
valueRequired: !!a.valueRequired,
|
||||
positional: !!a.positional,
|
||||
choices: a.choices ?? [],
|
||||
default: a.default ?? null,
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface IPage {
|
||||
getFormState(): Promise<any>;
|
||||
wait(options: number | WaitOptions): Promise<void>;
|
||||
tabs(): Promise<any>;
|
||||
closeTab?(index?: number): Promise<void>;
|
||||
newTab?(): Promise<void>;
|
||||
selectTab(index: number): Promise<void>;
|
||||
networkRequests(includeStatic?: boolean): Promise<any>;
|
||||
consoleMessages(level?: string): Promise<any>;
|
||||
@@ -65,11 +67,18 @@ export interface IPage {
|
||||
getInterceptedRequests(): Promise<any[]>;
|
||||
waitForCapture(timeout?: number): Promise<void>;
|
||||
screenshot(options?: ScreenshotOptions): Promise<string>;
|
||||
startNetworkCapture?(pattern?: string): Promise<void>;
|
||||
readNetworkCapture?(): Promise<unknown[]>;
|
||||
/**
|
||||
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
||||
* Chrome reads the files directly — no base64 encoding or payload size limits.
|
||||
*/
|
||||
setFileInput?(files: string[], selector?: string): Promise<void>;
|
||||
/**
|
||||
* Insert text via native CDP Input.insertText into the currently focused element.
|
||||
* Useful for rich editors that ignore synthetic DOM value/text mutations.
|
||||
*/
|
||||
insertText?(text: string): Promise<void>;
|
||||
closeWindow?(): Promise<void>;
|
||||
/** Returns the current page URL, or null if unavailable. */
|
||||
getCurrentUrl?(): Promise<string | null>;
|
||||
|
||||
@@ -105,6 +105,15 @@ describe('login-required commands — graceful failure', () => {
|
||||
await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
// ── yuanbao (requires login) ──
|
||||
it('yuanbao new fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['yuanbao', 'new', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
it('yuanbao ask fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['yuanbao', 'ask', '你好', '-f', 'json']);
|
||||
}, 60_000);
|
||||
|
||||
// ── pixiv (requires login) ──
|
||||
it('pixiv ranking fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['pixiv', 'ranking', '--limit', '3', '-f', 'json']);
|
||||
|
||||
Reference in New Issue
Block a user