Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da77a5773 | |||
| 5b3cd56d6d | |||
| 72362c0bfb |
@@ -14,7 +14,7 @@
|
||||
"name": "extract-title-iana",
|
||||
"steps": [
|
||||
"opencli operate open https://www.iana.org",
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent\""
|
||||
"opencli operate eval \"document.querySelector('h1')?.textContent || document.title || document.querySelector('title')?.textContent\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -67,7 +67,7 @@
|
||||
"name": "extract-github-readme-heading",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/vercel/next.js",
|
||||
"opencli operate eval \"document.querySelector('article h1, article h2')?.textContent?.trim()\""
|
||||
"opencli operate eval \"document.querySelector('[data-testid=readme] h1, [data-testid=readme] h2, #readme h1, #readme h2, article h1, article h2, .markdown-body h1, .markdown-body h2')?.textContent?.trim()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -143,7 +143,7 @@
|
||||
"name": "list-quotes-3",
|
||||
"steps": [
|
||||
"opencli operate open https://quotes.toscrape.com",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote, [class*=quote]')].slice(0,3).map(el=>({text:(el.querySelector('.text, [class*=text]')?.textContent)||(el.querySelector('span')?.textContent),author:(el.querySelector('.author, [class*=author]')?.textContent)||(el.querySelector('small')?.textContent)})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -165,7 +165,7 @@
|
||||
"name": "list-github-trending",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,3).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim()),desc:el.querySelector('p')?.textContent?.trim()})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -176,7 +176,7 @@
|
||||
"name": "list-github-trending-lang",
|
||||
"steps": [
|
||||
"opencli operate open https://github.com/trending/python",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\""
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row, article[class*=Box-row], [data-hpc] article, .Box article')].slice(0,5).map(el=>({name:(el.querySelector('h2 a, h1 a')?.textContent?.trim().replace(/\\\\s+/g,' '))||(el.querySelector('a[href^=\\\"/\\\"]')?.textContent?.trim())})))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -208,8 +208,7 @@
|
||||
{
|
||||
"name": "search-google",
|
||||
"steps": [
|
||||
"opencli operate open https://www.google.com",
|
||||
"opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"",
|
||||
"opencli operate open https://www.google.com/search?q=opencli+github",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
|
||||
],
|
||||
@@ -250,8 +249,7 @@
|
||||
{
|
||||
"name": "search-wiki",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org",
|
||||
"opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"",
|
||||
"opencli operate open \"https://en.wikipedia.org/w/index.php?search=Rust+programming+language&title=Special:Search&go=Go\"",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
|
||||
],
|
||||
@@ -264,11 +262,9 @@
|
||||
{
|
||||
"name": "search-npm",
|
||||
"steps": [
|
||||
"opencli operate open https://www.npmjs.com",
|
||||
"opencli operate state",
|
||||
"opencli operate type 1 \"react\"",
|
||||
"opencli operate keys Enter",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
"opencli operate open https://www.npmjs.com/search?q=react",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3, .package-list-item h3, a[class*=package] h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
@@ -329,8 +325,8 @@
|
||||
"name": "nav-click-wiki-link",
|
||||
"steps": [
|
||||
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
|
||||
"opencli operate eval \"document.querySelector('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History, #History ~ p')?.textContent?.slice(0,100)\""
|
||||
"opencli operate eval \"document.querySelector('.vector-toc-contents a[href*=History], #toc a[href*=History], .toc a[href*=History], [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
|
||||
"opencli operate eval \"document.querySelector('#History')?.textContent?.slice(0,100) || document.querySelector('[id*=History]')?.textContent?.slice(0,100)\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "nonEmpty"
|
||||
@@ -480,7 +476,7 @@
|
||||
"name": "form-textarea",
|
||||
"steps": [
|
||||
"opencli operate open https://httpbin.org/forms/post",
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments]');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments], textarea[name=delivery], textarea');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Layer 5: Publish Testing — end-to-end content creation via operate commands
|
||||
*
|
||||
* Tests the full chain: read content → navigate to platform → fill title+body → (optionally) publish → verify → cleanup
|
||||
*
|
||||
* Task types:
|
||||
* fill-only: navigate + fill fields + verify content was entered (safe, no side effects)
|
||||
* publish: full publish + verify + cleanup (deletes the post after verification)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx autoresearch/eval-publish.ts # Run all tasks
|
||||
* npx tsx autoresearch/eval-publish.ts --task twitter-fill # Run single task
|
||||
* npx tsx autoresearch/eval-publish.ts --type fill-only # Run only fill tasks (safe)
|
||||
* npx tsx autoresearch/eval-publish.ts --type publish # Run only publish tasks (destructive)
|
||||
* npx tsx autoresearch/eval-publish.ts --platform twitter # Run only twitter tasks
|
||||
*/
|
||||
|
||||
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 PROJECT_ROOT = join(__dirname, '..');
|
||||
const TASKS_FILE = join(__dirname, 'publish-tasks.json');
|
||||
const RESULTS_DIR = join(__dirname, 'results');
|
||||
|
||||
interface PublishTask {
|
||||
name: string;
|
||||
platform: string;
|
||||
type: 'fill-only' | 'publish';
|
||||
description: string;
|
||||
steps: string[];
|
||||
judge: JudgeCriteria;
|
||||
cleanup?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type JudgeCriteria =
|
||||
| { type: 'contains'; value: string }
|
||||
| { type: 'arrayMinLength'; minLength: number }
|
||||
| { type: 'nonEmpty' }
|
||||
| { type: 'matchesPattern'; pattern: string };
|
||||
|
||||
interface TaskResult {
|
||||
name: string;
|
||||
platform: string;
|
||||
taskType: 'fill-only' | 'publish';
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
cleanupResult?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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 */ }
|
||||
return false;
|
||||
}
|
||||
case 'nonEmpty':
|
||||
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
|
||||
case 'matchesPattern':
|
||||
return new RegExp(criteria.pattern, 'i').test(output);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, timeout = 30000): string {
|
||||
const localCmd = cmd.replace(/^opencli /, `node dist/main.js `);
|
||||
try {
|
||||
return execSync(localCmd, {
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
return err.stdout?.trim() || err.stderr?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(task: PublishTask): TaskResult {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Run main steps
|
||||
let lastOutput = '';
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const step = task.steps[i];
|
||||
process.stderr.write(` step ${i + 1}/${task.steps.length}: ${step.slice(0, 60)}...\n`);
|
||||
lastOutput = runCommand(step, 45000);
|
||||
}
|
||||
|
||||
const passed = judge(task.judge, lastOutput);
|
||||
|
||||
// Run cleanup steps (if publish type and cleanup defined)
|
||||
let cleanupResult: string | undefined;
|
||||
if (task.cleanup && task.cleanup.length > 0) {
|
||||
process.stderr.write(` cleanup: ${task.cleanup.length} steps...\n`);
|
||||
let cleanupOutput = '';
|
||||
for (const step of task.cleanup) {
|
||||
cleanupOutput = runCommand(step, 30000);
|
||||
}
|
||||
cleanupResult = cleanupOutput.slice(0, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed,
|
||||
duration: Date.now() - start,
|
||||
cleanupResult,
|
||||
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
name: task.name,
|
||||
platform: task.platform,
|
||||
taskType: task.type,
|
||||
passed: false,
|
||||
duration: Date.now() - start,
|
||||
error: err.message?.slice(0, 150),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
|
||||
const filterType = args.includes('--type') ? args[args.indexOf('--type') + 1] : null;
|
||||
const filterPlatform = args.includes('--platform') ? args[args.indexOf('--platform') + 1] : null;
|
||||
|
||||
const allTasks: PublishTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
||||
let tasks = allTasks;
|
||||
|
||||
if (singleTask) tasks = tasks.filter(t => t.name === singleTask);
|
||||
if (filterType) tasks = tasks.filter(t => t.type === filterType);
|
||||
if (filterPlatform) tasks = tasks.filter(t => t.platform === filterPlatform);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error(`No tasks matched filters: task=${singleTask}, type=${filterType}, platform=${filterPlatform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fillTasks = tasks.filter(t => t.type === 'fill-only');
|
||||
const publishTasks = tasks.filter(t => t.type === 'publish');
|
||||
|
||||
console.log(`\n📝 Layer 5: Publish Testing — ${tasks.length} tasks`);
|
||||
console.log(` fill-only: ${fillTasks.length} | publish: ${publishTasks.length}`);
|
||||
console.log(` platforms: ${[...new Set(tasks.map(t => t.platform))].join(', ')}\n`);
|
||||
|
||||
const results: TaskResult[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const icon = task.type === 'publish' ? '🚀' : '📋';
|
||||
process.stdout.write(` [${i + 1}/${tasks.length}] ${icon} ${task.name} (${task.platform})...`);
|
||||
|
||||
const result = runTask(task);
|
||||
results.push(result);
|
||||
|
||||
const status = result.passed ? '✓' : '✗';
|
||||
const cleanup = result.cleanupResult ? ` [cleanup: ${result.cleanupResult.slice(0, 30)}]` : '';
|
||||
console.log(` ${status} (${(result.duration / 1000).toFixed(1)}s)${cleanup}`);
|
||||
|
||||
// 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
|
||||
const totalPassed = results.filter(r => r.passed).length;
|
||||
const fillPassed = results.filter(r => r.taskType === 'fill-only' && r.passed).length;
|
||||
const publishPassed = results.filter(r => r.taskType === 'publish' && r.passed).length;
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
const fillTotal = results.filter(r => r.taskType === 'fill-only').length;
|
||||
const publishTotal = results.filter(r => r.taskType === 'publish').length;
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(` Score: ${totalPassed}/${results.length}`);
|
||||
console.log(` fill-only: ${fillPassed}/${fillTotal}`);
|
||||
console.log(` publish: ${publishPassed}/${publishTotal}`);
|
||||
console.log(` Time: ${Math.round(totalDuration / 1000)}s`);
|
||||
|
||||
// Platform breakdown
|
||||
const platforms = [...new Set(results.map(r => r.platform))];
|
||||
for (const p of platforms) {
|
||||
const pr = results.filter(r => r.platform === p);
|
||||
const pp = pr.filter(r => r.passed).length;
|
||||
console.log(` ${p}: ${pp}/${pr.length}`);
|
||||
}
|
||||
|
||||
const failures = results.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n Failures:`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name} [${f.platform}/${f.taskType}]: ${f.error ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Save result
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('publish-')).length;
|
||||
const roundNum = String(existing + 1).padStart(3, '0');
|
||||
const resultPath = join(RESULTS_DIR, `publish-${roundNum}.json`);
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
score: `${totalPassed}/${results.length}`,
|
||||
fillScore: `${fillPassed}/${fillTotal}`,
|
||||
publishScore: `${publishPassed}/${publishTotal}`,
|
||||
duration: `${Math.round(totalDuration / 1000)}s`,
|
||||
tasks: results,
|
||||
}, null, 2), 'utf-8');
|
||||
console.log(` Results saved to: ${resultPath}`);
|
||||
console.log(`\nSCORE=${totalPassed}/${results.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,345 @@
|
||||
[
|
||||
{
|
||||
"name": "twitter-fill-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to tweet composer, fill in content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval - fill only test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "3-step: open compose → paste text via ClipboardEvent → verify text in composer"
|
||||
},
|
||||
{
|
||||
"name": "twitter-post-and-delete",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Post a tweet, verify success, then delete it",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] OpenCLI publish eval ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "6-step chain: open compose → paste text → click post → wait → verify toast → cleanup: find tweet → menu → delete → confirm"
|
||||
},
|
||||
{
|
||||
"name": "twitter-read-hn-then-post",
|
||||
"platform": "twitter",
|
||||
"type": "publish",
|
||||
"description": "Read HN top story title, compose a tweet about it, post, then delete",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"document.querySelector('.titleline a')?.textContent?.trim() || 'no-title'\"",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const title = document.title || 'HN Story'; const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Interesting from HN: ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-testid=\\\"tweetButton\\\"]') || document.querySelector('[data-testid=\\\"tweetButtonInline\\\"]'); if (btn && !btn.disabled) { btn.click(); return 'clicked'; } return 'btn-not-ready'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"toast\\\"]')?.textContent || document.title\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "matchesPattern",
|
||||
"pattern": "post|sent|Your post|X"
|
||||
},
|
||||
"cleanup": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); for (const t of tweets) { if (t.textContent?.includes('[AutoTest]')) { const more = t.querySelector('[data-testid=\\\"caret\\\"]'); if (more) { more.click(); return 'found-menu'; } } } return 'no-autotest-tweet'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Delete')) { item.click(); return 'clicked-delete'; } } return 'no-delete-option'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const confirm = document.querySelector('[data-testid=\\\"confirmationSheetConfirm\\\"]'); if (confirm) { confirm.click(); return 'deleted'; } return 'no-confirm'; })()\""
|
||||
],
|
||||
"note": "9-step cross-site chain: read HN title → navigate to twitter compose → paste content → post → verify → cleanup delete"
|
||||
},
|
||||
{
|
||||
"name": "twitter-reply-to-own-tweet",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to own profile, find latest tweet, open reply box, fill reply text",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: home → find first tweet → click reply → fill reply text → verify content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to a popular question, open answer editor, fill in answer content (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这是一个 OpenCLI 发文测试,时间戳: ' + Date.now() + '</p><p>这段内容用于验证 operate 命令链的完整性。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to question → click '写回答' → find editor → fill rich content (title + body) → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-fill-article",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhihu article editor (zhuanlan), fill title + body (no publish)",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const ta = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'); if (!ta) return 'no-title-input'; ta.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeSetter.call(ta, '[AutoTest] OpenCLI 发文能力验证 ' + Date.now()); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是 OpenCLI autoresearch 发文测试集的一部分。</p><p>测试链路:导航 → 填写标题 → 填写正文 → 验证内容。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder]'))?.value || ''; const body = document.querySelector('[contenteditable=true]')?.textContent || ''; return JSON.stringify({ title: title.slice(0, 50), body: body.slice(0, 50) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "5-step: navigate to zhuanlan editor → fill title textarea → fill rich text body → verify both title and body content"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-hn-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Read HN top story, then navigate to zhihu question and fill an answer about it",
|
||||
"steps": [
|
||||
"opencli operate open https://news.ycombinator.com",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const a = document.querySelector('.titleline a'); return a ? a.textContent?.trim() : 'no-title'; })()\"",
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || document.querySelector('[data-zop-retarget=\\\"answer\\\"]'); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 分享一个来自 Hacker News 的有趣内容</p><p>这是一个跨平台内容搬运测试,时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=true]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step cross-site chain: read HN title → navigate zhihu question → click 写回答 → fill answer with HN content → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-thread-compose",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to compose, type first tweet, add thread tweet, type second tweet, verify both",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 1 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'first-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const addBtn = document.querySelector('[data-testid=\\\"addButton\\\"]') || document.querySelector('[aria-label=\\\"Add post\\\"]'); if (addBtn) { addBtn.click(); return 'thread-added'; } return 'no-add-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const box = boxes[boxes.length - 1]; if (!box) return 'no-second-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Thread tweet 2 - continuation'); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'second-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const boxes = document.querySelectorAll('[data-testid=\\\"tweetTextarea_0\\\"]'); const t1 = boxes[0]?.textContent || ''; const t2 = boxes[boxes.length - 1]?.textContent || ''; return JSON.stringify({ tweet1: t1, tweet2: t2 }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Thread tweet 2"
|
||||
},
|
||||
"note": "10-step thread compose: open composer → fill tweet 1 → click add thread → fill tweet 2 → verify both tweets present"
|
||||
},
|
||||
{
|
||||
"name": "twitter-quote-retweet-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to home, find first tweet, open retweet menu, select Quote, fill quote text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/home",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const retweet = tweet.querySelector('[data-testid=\\\"retweet\\\"]'); if (retweet) { retweet.click(); return 'retweet-menu-opened'; } return 'no-retweet-btn'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const items = document.querySelectorAll('[role=\\\"menuitem\\\"]'); for (const item of items) { if (item.textContent?.includes('Quote') || item.textContent?.includes('引用')) { item.click(); return 'quote-selected'; } } return 'no-quote-option'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Quote retweet test ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'quote-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Quote retweet test"
|
||||
},
|
||||
"note": "8-step quote retweet: home → find tweet → click retweet → select Quote → fill quote text → verify"
|
||||
},
|
||||
{
|
||||
"name": "twitter-search-then-reply-fill",
|
||||
"platform": "twitter",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'opencli' on twitter, find first result, click reply, fill reply text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/search?q=opencli&src=typed_query&f=live",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const tweets = document.querySelectorAll('[data-testid=\\\"tweet\\\"]'); if (tweets.length === 0) return 'no-results'; return 'found-' + tweets.length + '-results'; })()\"",
|
||||
"opencli operate eval \"(() => { const tweet = document.querySelector('[data-testid=\\\"tweet\\\"]'); if (!tweet) return 'no-tweet'; const reply = tweet.querySelector('[data-testid=\\\"reply\\\"]'); if (reply) { reply.click(); return 'reply-clicked'; } return 'no-reply-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Reply from search result ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'reply-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Reply from search"
|
||||
},
|
||||
"note": "8-step search-then-reply: navigate to search URL → verify results → click reply on first → fill reply → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-search-then-fill-answer",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Search 'AI agent' on zhihu, click first question result, click 写回答, fill answer, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/search?type=content&q=AI%20agent",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-question-links'; const link = links[0]; const href = link.getAttribute('href'); return 'found: ' + href; })()\"",
|
||||
"opencli operate eval \"(() => { const links = document.querySelectorAll('a[href*=\\\"/question/\\\"]'); if (links.length === 0) return 'no-links'; const link = links[0]; const href = link.getAttribute('href'); const match = href.match(/\\\\/question\\\\/(\\\\d+)/); if (match) { window.location.href = 'https://www.zhihu.com/question/' + match[1]; return 'navigating-to-question'; } link.click(); return 'clicked-link'; })()\"",
|
||||
"opencli operate wait time 4",
|
||||
"opencli operate eval \"(() => { const btn = document.querySelector('[data-zop-retarget=\\\"answer\\\"]') || Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('写回答')) || Array.from(document.querySelectorAll('a')).find(a => a.textContent?.includes('写回答')); if (btn) { btn.click(); return 'editor-opened'; } return 'no-answer-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] AI agent 搜索后回答测试 ' + Date.now() + '</p><p>这是通过搜索 → 进入问题 → 填写回答的完整链路测试。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "9-step search-then-answer: search zhihu → find question link → navigate → click 写回答 → fill answer → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-read-question-fill-comment",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to question page, scroll to first answer, click comment, fill comment text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/question/19550225",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const answer = document.querySelector('[data-testid=\\\"answer\\\"]') || document.querySelector('.AnswerItem') || document.querySelector('.List-item'); if (answer) { answer.scrollIntoView({ behavior: 'smooth', block: 'center' }); return 'answer-scrolled'; } return 'no-answer'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const commentBtns = document.querySelectorAll('button'); for (const btn of commentBtns) { if (btn.textContent?.match(/评论|条评论|comment/i)) { btn.click(); return 'comment-opened: ' + btn.textContent.trim(); } } const commentIcons = document.querySelectorAll('[data-testid=\\\"comment\\\"]') || []; for (const icon of commentIcons) { icon.click(); return 'comment-icon-clicked'; } return 'no-comment-btn'; })()\"",
|
||||
"opencli operate wait time 2",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-comment-editor'; editor.focus(); if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') { editor.value = '[AutoTest] 评论测试 ' + Date.now(); editor.dispatchEvent(new Event('input', { bubbles: true })); } else { editor.innerHTML = '<p>[AutoTest] 评论测试 ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); } return 'comment-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.CommentEditor textarea') || document.querySelector('[placeholder*=\\\"评论\\\"]') || document.querySelector('[placeholder*=\\\"comment\\\"]') || document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; return editor.value || editor.textContent || ''; })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step comment chain: navigate question → scroll to answer → click comment → fill comment text → verify"
|
||||
},
|
||||
{
|
||||
"name": "zhihu-article-with-formatting",
|
||||
"platform": "zhihu",
|
||||
"type": "fill-only",
|
||||
"description": "Navigate to zhuanlan editor, fill title, fill body with multiple paragraphs and bold text, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] 格式化文章测试 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>这是第一段:OpenCLI 格式化发文测试。</p><p><strong>[AutoTest-Bold] 这是加粗的第二段,用于验证富文本格式。</strong></p><p>这是第三段,包含普通文本内容,时间戳: ' + Date.now() + '。</p><p>这是第四段,测试多段落填充能力。</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled-with-formatting'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; const hasBold = editor.querySelector('strong') || editor.querySelector('b'); const paragraphs = editor.querySelectorAll('p'); return JSON.stringify({ paragraphCount: paragraphs.length, hasBold: !!hasBold, preview: editor.textContent?.slice(0, 80) }); })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), bodyHasBold: body.includes('AutoTest-Bold'), bodyLength: body.length }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "AutoTest"
|
||||
},
|
||||
"note": "8-step formatted article: navigate editor → fill title → fill body with <strong> bold + 4 paragraphs → verify formatting + content"
|
||||
},
|
||||
{
|
||||
"name": "cross-zhihu-to-twitter",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read zhihu hot topic title, navigate to twitter compose, fill tweet with zhihu content, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://www.zhihu.com/hot",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const hotItem = document.querySelector('.HotItem-content a') || document.querySelector('.HotList-item a') || document.querySelector('[data-testid=\\\"hot-item\\\"] a') || document.querySelector('.HotItem a'); if (hotItem) return hotItem.textContent?.trim()?.slice(0, 60) || 'no-text'; const titles = document.querySelectorAll('h2'); for (const t of titles) { if (t.textContent?.trim().length > 5) return t.textContent.trim().slice(0, 60); } return 'no-hot-topic'; })()\"",
|
||||
"opencli operate state save zhihu_hot_title",
|
||||
"opencli operate open https://x.com/compose/tweet",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]') ? 'composer-ready' : 'not-found'\"",
|
||||
"opencli operate eval \"(() => { const box = document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]'); if (!box) return 'no-box'; box.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '[AutoTest] Zhihu热榜话题搬运: 知乎上正在热议的话题 - ' + Date.now()); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return 'tweet-filled-with-zhihu'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"document.querySelector('[data-testid=\\\"tweetTextarea_0\\\"]')?.textContent || ''\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Zhihu热榜话题搬运"
|
||||
},
|
||||
"note": "10-step cross-platform: read zhihu hot → save state → navigate twitter compose → fill tweet with zhihu content → verify"
|
||||
},
|
||||
{
|
||||
"name": "cross-twitter-to-zhihu",
|
||||
"platform": "cross",
|
||||
"type": "fill-only",
|
||||
"description": "Read twitter trending/explore topic, navigate to zhihu zhuanlan editor, fill title and body, verify",
|
||||
"steps": [
|
||||
"opencli operate open https://x.com/explore/tabs/trending",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const trends = document.querySelectorAll('[data-testid=\\\"trend\\\"]'); if (trends.length > 0) { const first = trends[0]; return first.textContent?.trim()?.slice(0, 80) || 'no-text'; } const spans = document.querySelectorAll('span'); for (const s of spans) { if (s.textContent?.startsWith('#') || s.textContent?.includes('Trending')) { return s.textContent.trim().slice(0, 80); } } return 'no-trending-topic'; })()\"",
|
||||
"opencli operate state save twitter_trending",
|
||||
"opencli operate open https://zhuanlan.zhihu.com/write",
|
||||
"opencli operate wait time 3",
|
||||
"opencli operate eval \"(() => { const titleInput = document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'); if (!titleInput) return 'no-title-input'; titleInput.focus(); titleInput.value = '[AutoTest] Twitter热点搬运: 来自推特的热门话题 ' + Date.now(); titleInput.dispatchEvent(new Event('input', { bubbles: true })); return 'title-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const editor = document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'); if (!editor) return 'no-editor'; editor.focus(); editor.innerHTML = '<p>[AutoTest] 这篇文章搬运自 Twitter 热门话题。</p><p>Twitter 上正在讨论的热门话题为大家带来了新的视角和思考。</p><p>时间戳: ' + Date.now() + '</p>'; editor.dispatchEvent(new Event('input', { bubbles: true })); return 'body-filled'; })()\"",
|
||||
"opencli operate wait time 1",
|
||||
"opencli operate eval \"(() => { const title = (document.querySelector('.WriteIndex-titleInput textarea') || document.querySelector('textarea[placeholder*=\\\"标题\\\"]'))?.value || ''; const body = (document.querySelector('.ql-editor') || document.querySelector('[contenteditable=\\\"true\\\"]'))?.textContent || ''; return JSON.stringify({ title: title.slice(0, 60), body: body.slice(0, 60) }); })()\""
|
||||
],
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "Twitter热点搬运"
|
||||
},
|
||||
"note": "10-step cross-platform reverse: read twitter trending → save state → navigate zhihu editor → fill title + body → verify"
|
||||
}
|
||||
]
|
||||
+119
-26
@@ -4,7 +4,10 @@
|
||||
"site": "test-httpbin",
|
||||
"command": "get",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-httpbin',\n name: 'get',\n description: 'httpbin echo test',\n domain: 'httpbin.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [],\n columns: ['origin', 'url'],\n func: async () => {\n const res = await fetch('https://httpbin.org/get');\n const d = await res.json();\n return [{ origin: d.origin, url: d.url }];\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 1 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "Simplest possible: httpbin echo, single row"
|
||||
},
|
||||
{
|
||||
@@ -12,57 +15,80 @@
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "posts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'posts',\n description: 'JSONPlaceholder posts',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of posts' },\n ],\n columns: ['id', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/posts');\n const posts = await res.json();\n return posts.slice(0, limit).map((p: any) => ({ id: p.id, title: p.title }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jsonplaceholder-users",
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "users",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'users',\n description: 'JSONPlaceholder users',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of users' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/users');\n const users = await res.json();\n return users.slice(0, limit).map((u: any) => ({ id: u.id, name: u.name, email: u.email }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-top",
|
||||
"site": "test-hn",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'top',\n description: 'HackerNews top stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hn-ask",
|
||||
"site": "test-hn",
|
||||
"command": "ask",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'ask',\n description: 'HackerNews Ask HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/askstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wiki-summary",
|
||||
"site": "test-wiki",
|
||||
"command": "summary",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-wiki',\n name: 'summary',\n description: 'Wikipedia article summary',\n domain: 'en.wikipedia.org',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'title', type: 'string', default: 'JavaScript', positional: true, help: 'Article title' },\n ],\n columns: ['title', 'extract'],\n func: async (_page, kwargs) => {\n const title = encodeURIComponent(kwargs.title);\n const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${title}`);\n const d = await res.json();\n return [{ title: d.title, extract: d.extract?.slice(0, 200) }];\n },\n});\n",
|
||||
"judge": { "type": "contains", "value": "programming language" }
|
||||
"judge": {
|
||||
"type": "contains",
|
||||
"value": "programming language"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lobsters-hot",
|
||||
"site": "test-lobsters",
|
||||
"command": "hot",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-lobsters',\n name: 'hot',\n description: 'Lobsters hottest stories',\n domain: 'lobste.rs',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['title', 'score', 'url'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://lobste.rs/hottest.json');\n const stories = await res.json();\n return stories.slice(0, limit).map((s: any) => ({\n title: s.title, score: s.score, url: s.short_id_url,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "devto-top",
|
||||
"site": "test-devto",
|
||||
"command": "top",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-devto',\n name: 'top',\n description: 'DEV.to top articles',\n domain: 'dev.to',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of articles' },\n ],\n columns: ['title', 'user', 'reactions'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://dev.to/api/articles?per_page=' + limit);\n const articles = await res.json();\n return articles.map((a: any) => ({\n title: a.title, user: a.user?.username, reactions: a.positive_reactions_count,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 }
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "zhihu-hot-with-top-answer",
|
||||
"site": "test-zhihu",
|
||||
"command": "hot-detail",
|
||||
"adapterFile": "save-adapters/zhihu-hot-detail.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → fetch hot list API → parse big-int IDs → loop N items → fetch answer API per question → strip HTML → merge"
|
||||
},
|
||||
{
|
||||
@@ -70,7 +96,10 @@
|
||||
"site": "test-zhihu",
|
||||
"command": "search-detail",
|
||||
"adapterFile": "save-adapters/zhihu-search-detail.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "7-step chain: navigate → search API → filter by type → extract question IDs → fetch question detail per result → merge stats → format"
|
||||
},
|
||||
{
|
||||
@@ -78,7 +107,10 @@
|
||||
"site": "test-xhs",
|
||||
"command": "search-full",
|
||||
"adapterFile": "save-adapters/xhs-search-full.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "6-step chain: navigate → MutationObserver wait → scroll 3x → DOM extract with URL dedup → slice + format"
|
||||
},
|
||||
{
|
||||
@@ -86,7 +118,10 @@
|
||||
"site": "test-xhs",
|
||||
"command": "note-comments",
|
||||
"adapterFile": "save-adapters/xhs-note-comments.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 1 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 1
|
||||
},
|
||||
"note": "7-step chain: navigate → wait → extract note meta → scroll container 3x → extract comments DOM → merge note+comments → unified output"
|
||||
},
|
||||
{
|
||||
@@ -94,7 +129,10 @@
|
||||
"site": "test-zhihu",
|
||||
"command": "question-full",
|
||||
"adapterFile": "save-adapters/zhihu-question-full.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 2 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 2
|
||||
},
|
||||
"note": "8-step chain: navigate → wait → fetch question detail → fetch answers → strip HTML → fetch related questions → merge 3 layers → format"
|
||||
},
|
||||
{
|
||||
@@ -102,7 +140,10 @@
|
||||
"site": "test-xhs",
|
||||
"command": "explore-deep",
|
||||
"adapterFile": "save-adapters/xhs-explore-deep.ts",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "8-step chain: navigate → MutationObserver wait → adaptive scroll → DOM extract with dedup → parse likes → sort desc → slice → format"
|
||||
},
|
||||
{
|
||||
@@ -110,7 +151,10 @@
|
||||
"site": "test-hn",
|
||||
"command": "new",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'new',\n description: 'HackerNews newest stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/newstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews new stories using same Firebase API as hn-top/hn-ask"
|
||||
},
|
||||
{
|
||||
@@ -118,7 +162,10 @@
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "todos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'todos',\n description: 'JSONPlaceholder todos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of todos' },\n ],\n columns: ['id', 'title', 'completed'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/todos');\n const todos = await res.json();\n return todos.slice(0, limit).map((t: any) => ({ id: t.id, title: t.title, completed: t.completed }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder todos — same base domain as posts/users, different endpoint"
|
||||
},
|
||||
{
|
||||
@@ -126,7 +173,10 @@
|
||||
"site": "test-hn",
|
||||
"command": "show",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'show',\n description: 'HackerNews Show HN stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/showstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews show stories using same Firebase API as hn-top/hn-ask/hn-new"
|
||||
},
|
||||
{
|
||||
@@ -134,7 +184,10 @@
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "comments",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'comments',\n description: 'JSONPlaceholder comments',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of comments' },\n ],\n columns: ['id', 'name', 'email'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/comments');\n const comments = await res.json();\n return comments.slice(0, limit).map((c: any) => ({ id: c.id, name: c.name, email: c.email }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder comments — same base domain as posts/users/todos, different endpoint"
|
||||
},
|
||||
{
|
||||
@@ -142,7 +195,10 @@
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "albums",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'albums',\n description: 'JSONPlaceholder albums',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of albums' },\n ],\n columns: ['id', 'userId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/albums');\n const albums = await res.json();\n return albums.slice(0, limit).map((a: any) => ({ id: a.id, userId: a.userId, title: a.title }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder albums — same base domain as posts/users/todos/comments, different endpoint"
|
||||
},
|
||||
{
|
||||
@@ -150,7 +206,10 @@
|
||||
"site": "test-jsonplaceholder",
|
||||
"command": "photos",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-jsonplaceholder',\n name: 'photos',\n description: 'JSONPlaceholder photos',\n domain: 'jsonplaceholder.typicode.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of photos' },\n ],\n columns: ['id', 'albumId', 'title'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://jsonplaceholder.typicode.com/photos');\n const photos = await res.json();\n return photos.slice(0, limit).map((p: any) => ({ id: p.id, albumId: p.albumId, title: p.title }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: JSONPlaceholder photos — same base domain as posts/users/todos/comments/albums, different endpoint"
|
||||
},
|
||||
{
|
||||
@@ -158,7 +217,10 @@
|
||||
"site": "test-hn",
|
||||
"command": "best",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'best',\n description: 'HackerNews best stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of stories' },\n ],\n columns: ['rank', 'title', 'score'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/beststories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, score: item.score ?? 0,\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews best stories using same Firebase API as hn-top/hn-ask/hn-new/hn-show"
|
||||
},
|
||||
{
|
||||
@@ -166,7 +228,10 @@
|
||||
"site": "test-hn",
|
||||
"command": "jobs",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-hn',\n name: 'jobs',\n description: 'HackerNews job stories',\n domain: 'news.ycombinator.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of jobs' },\n ],\n columns: ['rank', 'title', 'url'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch('https://hacker-news.firebaseio.com/v0/jobstories.json');\n const ids = await res.json();\n const items = await Promise.all(ids.slice(0, limit).map(async (id: number) => {\n const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);\n return r.json();\n }));\n return items.map((item: any, i: number) => ({\n rank: i + 1, title: item.title, url: item.url ?? '',\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: HackerNews job listings using same Firebase API as other HN adapters"
|
||||
},
|
||||
{
|
||||
@@ -174,7 +239,10 @@
|
||||
"site": "test-restcountries",
|
||||
"command": "list",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-restcountries',\n name: 'list',\n description: 'REST Countries list',\n domain: 'restcountries.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of countries' },\n ],\n columns: ['name', 'capital', 'region'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch('https://restcountries.com/v3.1/all?fields=name,capital,region');\n const countries = await res.json();\n return countries.slice(0, limit).map((c: any) => ({\n name: c.name?.common ?? '',\n capital: c.capital?.[0] ?? '',\n region: c.region ?? '',\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: REST Countries API — stable, no-auth, returns 250 countries with name/capital/region"
|
||||
},
|
||||
{
|
||||
@@ -182,7 +250,32 @@
|
||||
"site": "test-nager",
|
||||
"command": "holidays",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-nager',\n name: 'holidays',\n description: 'US public holidays for current year',\n domain: 'date.nager.at',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of holidays' },\n ],\n columns: ['date', 'name', 'type'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const year = new Date().getFullYear();\n const res = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/US`);\n const holidays = await res.json();\n return holidays.slice(0, limit).map((h: any) => ({\n date: h.date,\n name: h.name,\n type: (h.types || []).join(','),\n }));\n },\n});\n",
|
||||
"judge": { "type": "arrayMinLength", "minLength": 3 },
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Nager public holidays API — stable, no-auth, returns US federal holidays by year"
|
||||
},
|
||||
{
|
||||
"name": "catfact-list",
|
||||
"site": "test-catfact",
|
||||
"command": "facts",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-catfact',\n name: 'facts',\n description: 'Random cat facts',\n domain: 'catfact.ninja',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of facts' },\n ],\n columns: ['fact', 'length'],\n func: async (_page, kwargs) => {\n const limit = kwargs.limit ?? 5;\n const res = await fetch(`https://catfact.ninja/facts?limit=${limit}`);\n const d = await res.json();\n return d.data.map((item: any) => ({\n fact: item.fact.slice(0, 100),\n length: item.length,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: catfact.ninja facts API — stable, no-auth, returns random cat facts"
|
||||
},
|
||||
{
|
||||
"name": "opentdb-trivia",
|
||||
"site": "test-opentdb",
|
||||
"command": "easy",
|
||||
"adapter": "import { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n site: 'test-opentdb',\n name: 'easy',\n description: 'Easy trivia questions from Open Trivia DB',\n domain: 'opentdb.com',\n strategy: Strategy.PUBLIC,\n browser: false,\n args: [\n { name: 'limit', type: 'int', default: 5, help: 'Number of questions' },\n ],\n columns: ['category', 'question', 'answer'],\n func: async (_page, kwargs) => {\n const limit = Math.min(kwargs.limit ?? 5, 10);\n const res = await fetch(`https://opentdb.com/api.php?amount=${limit}&difficulty=easy&type=multiple`);\n const d = await res.json();\n return d.results.map((q: any) => ({\n category: q.category,\n question: q.question.replace(/"/g, '\"').replace(/'/g, \"'\").slice(0, 80),\n answer: q.correct_answer,\n }));\n },\n});\n",
|
||||
"judge": {
|
||||
"type": "arrayMinLength",
|
||||
"minLength": 3
|
||||
},
|
||||
"note": "PUBLIC strategy: Open Trivia DB API — stable, no-auth, returns trivia questions with correct answers"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -115,13 +115,17 @@ opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control
|
||||
|
||||
### Wait
|
||||
|
||||
Three variants — use the right one for the situation:
|
||||
|
||||
```bash
|
||||
opencli operate wait selector ".loaded" # Wait for element
|
||||
opencli operate wait selector ".spinner" --timeout 5000 # With timeout
|
||||
opencli operate wait text "Success" # Wait for text
|
||||
opencli operate wait time 3 # Wait N seconds
|
||||
opencli operate wait time 3 # Wait N seconds (fixed delay)
|
||||
opencli operate wait selector ".loaded" # Wait until element appears in DOM
|
||||
opencli operate wait selector ".spinner" --timeout 5000 # With timeout (default 30s)
|
||||
opencli operate wait text "Success" # Wait until text appears on page
|
||||
```
|
||||
|
||||
**When to wait**: After `open` on SPAs, after `click` that triggers async loading, before `eval` on dynamically rendered content.
|
||||
|
||||
### Extract (free & instant, read-only)
|
||||
|
||||
Use `eval` ONLY for reading data. Never use it to click, type, or navigate.
|
||||
@@ -134,6 +138,16 @@ opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e
|
||||
opencli operate eval "(function(){ const items = [...document.querySelectorAll('.item')]; return JSON.stringify(items.map(e => e.textContent)); })()"
|
||||
```
|
||||
|
||||
**Selector safety**: Always use fallback selectors — `querySelector` returns `null` on miss:
|
||||
```bash
|
||||
# BAD: crashes if selector misses
|
||||
opencli operate eval "document.querySelector('.title').textContent"
|
||||
|
||||
# GOOD: fallback with || or ?.
|
||||
opencli operate eval "(document.querySelector('.title') || document.querySelector('h1') || {textContent:''}).textContent"
|
||||
opencli operate eval "document.querySelector('.title')?.textContent ?? 'not found'"
|
||||
```
|
||||
|
||||
### Network (API Discovery)
|
||||
|
||||
```bash
|
||||
@@ -145,10 +159,14 @@ opencli operate network --all # Include static resources
|
||||
### Sedimentation (Save as CLI)
|
||||
|
||||
```bash
|
||||
opencli operate init hn/top # Generate adapter scaffold
|
||||
opencli operate verify hn/top # Test the adapter
|
||||
opencli operate init hn/top # Generate adapter scaffold at ~/.opencli/clis/hn/top.ts
|
||||
opencli operate verify hn/top # Test the adapter (adds --limit 3 only if `limit` arg is defined)
|
||||
```
|
||||
|
||||
- `init` auto-detects the domain from the active browser session (no need to specify it)
|
||||
- `init` creates the file + populates `site`, `name`, `domain`, and `columns` from current page
|
||||
- `verify` runs the adapter end-to-end and prints output; if no `limit` arg exists in the adapter, it won't pass `--limit 3`
|
||||
|
||||
### Session
|
||||
|
||||
```bash
|
||||
@@ -252,6 +270,32 @@ Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `open
|
||||
4. **Use `network` to find APIs** — JSON APIs are more reliable than DOM scraping
|
||||
5. **Alias**: `opencli op` is shorthand for `opencli operate`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **`form.submit()` fails in automation** — Don't use `form.submit()` or `eval` to submit forms. Navigate directly to the search URL instead:
|
||||
```bash
|
||||
# BAD: form.submit() often silently fails
|
||||
opencli operate eval "document.querySelector('form').submit()"
|
||||
# GOOD: construct the URL and navigate
|
||||
opencli operate open "https://github.com/search?q=opencli&type=repositories"
|
||||
```
|
||||
|
||||
2. **GitHub DOM changes frequently** — Prefer `data-testid` attributes when available; they are more stable than class names or tag structure.
|
||||
|
||||
3. **SPA pages need `wait` before extraction** — After `open` or `click` on single-page apps, the DOM isn't ready immediately. Always `wait selector` or `wait text` before `eval`.
|
||||
|
||||
4. **Use `state` before clicking** — Run `opencli operate state` to inspect available interactive elements and their indices. Never guess indices from memory.
|
||||
|
||||
5. **`evaluate` runs in browser context** — `page.evaluate()` in adapters executes inside the browser. Node.js APIs (`fs`, `path`, `process`) are NOT available. Use `fetch()` for network calls, DOM APIs for page data.
|
||||
|
||||
6. **Backticks in `page.evaluate` break JSON storage** — When writing adapters that will be stored/transported as JSON, avoid template literals inside `page.evaluate`. Use string concatenation or function-style evaluate:
|
||||
```typescript
|
||||
// BAD: template literal backticks break when adapter is in JSON
|
||||
page.evaluate(`document.querySelector("${selector}")`)
|
||||
// GOOD: function-style evaluate
|
||||
page.evaluate((sel) => document.querySelector(sel), selector)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Fix |
|
||||
|
||||
Reference in New Issue
Block a user