Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d3d136c8 |
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger opencli-website rebuild
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger website rebuild
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
|
||||
repository: jackwener/opencli-website
|
||||
|
||||
@@ -19,6 +19,3 @@ docs/.vitepress/cache
|
||||
.windsurf
|
||||
.claude
|
||||
.cortex
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
|
||||
@@ -23,31 +23,11 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Why opencli?
|
||||
|
||||
There are many great browser automation tools. Here's when opencli is the right choice:
|
||||
|
||||
| Your need | Best tool | Why |
|
||||
|-----------|-----------|-----|
|
||||
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
|
||||
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
|
||||
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
|
||||
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
|
||||
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
|
||||
|
||||
**What makes opencli different:**
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
|
||||
- **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
|
||||
|
||||
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -145,7 +125,6 @@ Run `opencli list` for the live registry.
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **devto** | `top` `tag` `user` | Public |
|
||||
| **dictionary** | `search` `synonyms` `examples` | Public |
|
||||
| **arxiv** | `search` `paper` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
@@ -186,6 +165,7 @@ OpenCLI acts as a universal hub for your existing command-line tools. It provide
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker command-line interface | `opencli docker ps` |
|
||||
| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` |
|
||||
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
|
||||
+2
-22
@@ -25,31 +25,11 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker` 等本地 CLI
|
||||
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh`、`docker`、`kubectl` 等本地 CLI
|
||||
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 为什么选 opencli?
|
||||
|
||||
浏览器自动化工具很多,opencli 适合什么场景?
|
||||
|
||||
| 你的需求 | 最佳工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
|
||||
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
|
||||
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
|
||||
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
|
||||
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
|
||||
|
||||
**opencli 的核心差异:**
|
||||
|
||||
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
|
||||
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
|
||||
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
|
||||
|
||||
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
@@ -147,7 +127,6 @@ npm install -g @jackwener/opencli@latest
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
|
||||
| **ctrip** | `search` | 浏览器 |
|
||||
| **devto** | `top` `tag` `user` | 公开 |
|
||||
| **dictionary** | `search` `synonyms` `examples` | 公开 |
|
||||
| **arxiv** | `search` `paper` | 公开 |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
|
||||
@@ -188,6 +167,7 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **kubectl** | Kubernetes CLI | `opencli kubectl get pods` |
|
||||
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Getting Started', link: '/guide/getting-started' },
|
||||
{ text: 'Installation', link: '/guide/installation' },
|
||||
{ text: 'Comparison', link: '/comparison' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
@@ -81,7 +80,6 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
|
||||
{ text: 'Dev.to', link: '/adapters/browser/devto' },
|
||||
{ text: 'Dictionary', link: '/adapters/browser/dictionary' },
|
||||
{ text: 'BBC', link: '/adapters/browser/bbc' },
|
||||
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
|
||||
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Dictionary
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `api.dictionaryapi.dev`
|
||||
|
||||
Search the open dictionary to quickly fetch native definitions, part of speech contexts, and phonetic pronunciations directly in your IDE terminal.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli dictionary search` | Fetch the exact definition of a word |
|
||||
| `opencli dictionary synonyms` | Find related synonyms for a word |
|
||||
| `opencli dictionary examples` | Read real-world sentence usage examples |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Look up a complex term
|
||||
opencli dictionary search serendipity
|
||||
|
||||
# Discover phonetics
|
||||
opencli dictionary search ephemeral
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — utilizes the fast, open JSON definitions API.
|
||||
@@ -1,27 +0,0 @@
|
||||
# JD.com
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `item.jd.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Get product details by SKU
|
||||
opencli jd item 100291143898
|
||||
|
||||
# Limit detail images
|
||||
opencli jd item 100291143898 --images 5
|
||||
|
||||
# JSON output
|
||||
opencli jd item 100291143898 -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** jd.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -1,30 +0,0 @@
|
||||
# Web
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: any URL
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli web read <url>` | Fetch any web page and export as Markdown |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Read a web page and save as Markdown
|
||||
opencli web read https://example.com/article
|
||||
|
||||
# Custom output directory
|
||||
opencli web read https://example.com/article --output ./my-articles
|
||||
|
||||
# Skip image download
|
||||
opencli web read https://example.com/article --download-images false
|
||||
|
||||
# JSON output
|
||||
opencli web read https://example.com/article -f json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -45,7 +45,6 @@ Run `opencli list` for the live registry.
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
|
||||
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
|
||||
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
|
||||
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# Comparison Guide
|
||||
|
||||
OpenCLI occupies a specific niche in the browser automation ecosystem. This guide honestly evaluates where opencli excels, where it's a viable option, and where other tools are a better fit.
|
||||
|
||||
## At a Glance
|
||||
|
||||
| Tool | Approach | Best for |
|
||||
|------|----------|----------|
|
||||
| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps |
|
||||
| **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation |
|
||||
| **Crawl4AI** | Async web crawler | Large-scale data crawling |
|
||||
| **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure |
|
||||
| **agent-browser** | Browser primitive CLI | Token-efficient AI agent browsing |
|
||||
| **Stagehand** | AI browser framework | Developer-friendly browser automation |
|
||||
| **Skyvern** | Visual AI automation | Cross-site generalized workflows |
|
||||
|
||||
## Scenario Comparison
|
||||
|
||||
### 1. Scheduled Batch Data Extraction
|
||||
|
||||
> "I want to pull trending posts from Bilibili/Reddit/HackerNews every hour into my pipeline."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | One command, structured JSON output, zero runtime cost. Runs in cron/CI without tokens or API keys. |
|
||||
| Crawl4AI | Good | Strong for large-scale crawling, but requires writing extraction logic per site. |
|
||||
| Firecrawl | Viable | Managed service with clean output, but costs scale with volume. |
|
||||
| Browser-Use / Stagehand | Poor | LLM inference on every run is slow, expensive, and non-deterministic for repeated tasks. |
|
||||
|
||||
**Why opencli wins here:** A command like `opencli bilibili hot -f json` returns the same structured schema every time, costs nothing to run, and finishes in seconds. For recurring data extraction from known sites, pre-built adapters beat LLM-driven approaches on cost, speed, and reliability.
|
||||
|
||||
### 2. AI Agent Site Operations
|
||||
|
||||
> "My AI agent needs to search Twitter, read Reddit threads, or post to Xiaohongshu."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Structured JSON output, fast deterministic execution, hundreds of commands ready to use. |
|
||||
| agent-browser | Good | Token-efficient browser primitives, but requires LLM reasoning for every step. |
|
||||
| Browser-Use | Viable | General-purpose, but each operation costs tokens and takes 10-60s. |
|
||||
| Stagehand | Viable | Good DX, but same LLM-per-action cost model. |
|
||||
|
||||
**Why opencli wins here:** When your agent needs `twitter search "AI news" -f json`, a deterministic command that returns in seconds is strictly better than an LLM clicking through a webpage. The agent saves tokens for reasoning, not navigation.
|
||||
|
||||
### 3. Authenticated Operations (Login-Required Sites)
|
||||
|
||||
> "I need to access my bookmarks, post content, or interact with sites that require login."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | Reuses your Chrome login session via Browser Bridge. No credentials stored or transmitted. |
|
||||
| Browser-Use | Viable | Can use browser profiles, but credential management is manual. |
|
||||
| Firecrawl | Poor | Cloud service cannot access your authenticated sessions. |
|
||||
| Crawl4AI | Poor | Requires manual cookie/session injection. |
|
||||
|
||||
**Why opencli wins here:** The Browser Bridge extension reuses your existing Chrome login state in real-time. You log in once in Chrome, and opencli commands work immediately. No OAuth setup, no API keys, no credential files.
|
||||
|
||||
### 4. General Web Browsing & Exploration
|
||||
|
||||
> "I need to explore an unknown website, fill forms, or navigate complex multi-step flows."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| Browser-Use | Best | LLM-driven, handles arbitrary websites and flows. |
|
||||
| Stagehand | Best | Clean API for `act()`, `extract()`, `observe()` on any page. |
|
||||
| agent-browser | Good | Token-efficient primitives for AI agents. |
|
||||
| Skyvern | Good | Visual AI that generalizes across sites. |
|
||||
| **opencli** | Poor | Only works with sites that have pre-built adapters. Cannot handle arbitrary websites. |
|
||||
|
||||
**opencli is not the right tool here.** If you need to explore unknown websites or handle one-off tasks on sites without adapters, use an LLM-driven browser tool. opencli trades generality for determinism and cost.
|
||||
|
||||
### 5. Desktop App Control
|
||||
|
||||
> "I want to script Cursor, ChatGPT, Notion, or other Electron apps from the terminal."
|
||||
|
||||
| Tool | Fit | Notes |
|
||||
|------|-----|-------|
|
||||
| **opencli** | Best | 8 desktop adapters via CDP + AppleScript. The only CLI tool with this capability. |
|
||||
| All others | N/A | Browser automation tools cannot control desktop applications. |
|
||||
|
||||
**This is unique to opencli.** No other tool in this comparison can send a prompt to ChatGPT desktop, extract code from Cursor, or write to Notion pages via CLI.
|
||||
|
||||
## Key Trade-offs
|
||||
|
||||
### opencli's Strengths
|
||||
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
|
||||
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
|
||||
- **Speed** — Adapter commands return in seconds, not minutes.
|
||||
- **Broad platform coverage** — 50+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
|
||||
- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
|
||||
|
||||
### opencli's Limitations
|
||||
|
||||
- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter.
|
||||
- **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible.
|
||||
- **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback.
|
||||
|
||||
## Complementary Usage
|
||||
|
||||
opencli works best alongside general-purpose browser tools, not as a replacement:
|
||||
|
||||
```
|
||||
Has adapter? ──yes──▶ opencli (fast, free, deterministic)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
One-off task? ──yes──▶ Browser-Use / Stagehand (LLM-driven)
|
||||
│
|
||||
no
|
||||
│
|
||||
▼
|
||||
Recurring? ──yes──▶ Write an opencli adapter, then use opencli
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](./developer/architecture.md)
|
||||
- [Writing a YAML Adapter](./developer/yaml-adapter.md)
|
||||
- [Writing a TypeScript Adapter](./developer/ts-adapter.md)
|
||||
- [Testing Guide](./developer/testing.md)
|
||||
- [AI Workflow](./developer/ai-workflow.md)
|
||||
- [Contributing Guide](./developer/contributing.md)
|
||||
@@ -28,8 +28,6 @@ total=0
|
||||
|
||||
for adapter_dir in "$SRC_DIR"/*/; do
|
||||
adapter_name="$(basename "$adapter_dir")"
|
||||
# Skip internal directories (e.g., _shared)
|
||||
[[ "$adapter_name" == _* ]] && continue
|
||||
total=$((total + 1))
|
||||
|
||||
# Check if doc exists in browser/ or desktop/ subdirectories
|
||||
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Shared API analysis helpers used by both explore.ts and record.ts.
|
||||
*
|
||||
* Extracts common logic for:
|
||||
* - URL pattern normalization
|
||||
* - Array path discovery in JSON responses
|
||||
* - Field role detection
|
||||
* - Auth indicator inference
|
||||
* - Capability name inference
|
||||
* - Strategy inference
|
||||
*/
|
||||
|
||||
import {
|
||||
VOLATILE_PARAMS,
|
||||
SEARCH_PARAMS,
|
||||
PAGINATION_PARAMS,
|
||||
FIELD_ROLES,
|
||||
} from './constants.js';
|
||||
|
||||
// ── URL pattern normalization ───────────────────────────────────────────────
|
||||
|
||||
/** Normalize a full URL into a pattern (replace IDs, strip volatile params). */
|
||||
export function urlToPattern(url: string): string {
|
||||
try {
|
||||
const p = new URL(url);
|
||||
const pathNorm = p.pathname
|
||||
.replace(/\/\d+/g, '/{id}')
|
||||
.replace(/\/[0-9a-fA-F]{8,}/g, '/{hex}')
|
||||
.replace(/\/BV[a-zA-Z0-9]{10}/g, '/{bvid}');
|
||||
const params: string[] = [];
|
||||
p.searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); });
|
||||
return `${p.host}${pathNorm}${params.length ? '?' + params.sort().map(k => `${k}={}`).join('&') : ''}`;
|
||||
} catch { return url; }
|
||||
}
|
||||
|
||||
// ── Array discovery in JSON responses ───────────────────────────────────────
|
||||
|
||||
export interface ArrayDiscovery {
|
||||
path: string;
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
/** Find the best (largest) array of objects in a JSON response body. */
|
||||
export function findArrayPath(obj: unknown, depth = 0): ArrayDiscovery | null {
|
||||
if (depth > 5 || !obj || typeof obj !== 'object') return null;
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length >= 2 && obj.some(i => i && typeof i === 'object' && !Array.isArray(i))) {
|
||||
return { path: '', items: obj };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let best: ArrayDiscovery | null = null;
|
||||
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const found = findArrayPath(val, depth + 1);
|
||||
if (found) {
|
||||
const fullPath = found.path ? `${key}.${found.path}` : key;
|
||||
const candidate = { path: fullPath, items: found.items };
|
||||
if (!best || candidate.items.length > best.items.length) best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Field flattening & role detection ───────────────────────────────────────
|
||||
|
||||
/** Flatten nested object keys up to maxDepth. */
|
||||
export function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[] {
|
||||
if (maxDepth <= 0 || !obj || typeof obj !== 'object') return [];
|
||||
const names: string[] = [];
|
||||
const record = obj as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
const full = prefix ? `${prefix}.${key}` : key;
|
||||
names.push(full);
|
||||
const val = record[key];
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) names.push(...flattenFields(val, full, maxDepth - 1));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Detect semantic field roles (title, url, author, etc.) from sample fields. */
|
||||
export function detectFieldRoles(sampleFields: string[]): Record<string, string> {
|
||||
const detectedFields: Record<string, string> = {};
|
||||
for (const [role, aliases] of Object.entries(FIELD_ROLES)) {
|
||||
for (const f of sampleFields) {
|
||||
if (aliases.includes(f.split('.').pop()?.toLowerCase() ?? '')) {
|
||||
detectedFields[role] = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return detectedFields;
|
||||
}
|
||||
|
||||
// ── Capability name inference ───────────────────────────────────────────────
|
||||
|
||||
/** Infer a CLI capability name from a URL. */
|
||||
export function inferCapabilityName(url: string, goal?: string): string {
|
||||
if (goal) return goal;
|
||||
const u = url.toLowerCase();
|
||||
if (u.includes('hot') || u.includes('popular') || u.includes('ranking') || u.includes('trending')) return 'hot';
|
||||
if (u.includes('search')) return 'search';
|
||||
if (u.includes('feed') || u.includes('timeline') || u.includes('dynamic')) return 'feed';
|
||||
if (u.includes('comment') || u.includes('reply')) return 'comments';
|
||||
if (u.includes('history')) return 'history';
|
||||
if (u.includes('profile') || u.includes('userinfo') || u.includes('/me')) return 'me';
|
||||
if (u.includes('favorite') || u.includes('collect') || u.includes('bookmark')) return 'favorite';
|
||||
try {
|
||||
const segs = new URL(url).pathname
|
||||
.split('/')
|
||||
.filter(s => s && !s.match(/^\d+$/) && !s.match(/^[0-9a-f]{8,}$/i) && !s.match(/^v\d+$/));
|
||||
if (segs.length) return segs[segs.length - 1].replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
} catch {}
|
||||
return 'data';
|
||||
}
|
||||
|
||||
// ── Strategy inference ──────────────────────────────────────────────────────
|
||||
|
||||
/** Infer auth strategy from detected indicators. */
|
||||
export function inferStrategy(authIndicators: string[]): string {
|
||||
if (authIndicators.includes('signature')) return 'intercept';
|
||||
if (authIndicators.includes('bearer') || authIndicators.includes('csrf')) return 'header';
|
||||
return 'cookie';
|
||||
}
|
||||
|
||||
// ── Auth indicator detection ────────────────────────────────────────────────
|
||||
|
||||
/** Detect auth indicators from HTTP headers. */
|
||||
export function detectAuthFromHeaders(headers?: Record<string, string>): string[] {
|
||||
if (!headers) return [];
|
||||
const indicators: string[] = [];
|
||||
const keys = Object.keys(headers).map(k => k.toLowerCase());
|
||||
if (keys.some(k => k === 'authorization')) indicators.push('bearer');
|
||||
if (keys.some(k => k.startsWith('x-csrf') || k.startsWith('x-xsrf'))) indicators.push('csrf');
|
||||
if (keys.some(k => k.startsWith('x-s') || k === 'x-t' || k === 'x-s-common')) indicators.push('signature');
|
||||
return indicators;
|
||||
}
|
||||
|
||||
/** Detect auth indicators from URL and response body (heuristic). */
|
||||
export function detectAuthFromContent(url: string, body: unknown): string[] {
|
||||
const indicators: string[] = [];
|
||||
if (body && typeof body === 'object') {
|
||||
const keys = Object.keys(body as object).map(k => k.toLowerCase());
|
||||
if (keys.some(k => k.includes('sign') || k === 'w_rid' || k.includes('token'))) {
|
||||
indicators.push('signature');
|
||||
}
|
||||
}
|
||||
if (url.includes('/wbi/') || url.includes('w_rid=')) indicators.push('signature');
|
||||
if (url.includes('bearer') || url.includes('access_token')) indicators.push('bearer');
|
||||
return indicators;
|
||||
}
|
||||
|
||||
// ── Query param classification ──────────────────────────────────────────────
|
||||
|
||||
/** Extract non-volatile query params and classify them. */
|
||||
export function classifyQueryParams(url: string): {
|
||||
params: string[];
|
||||
hasSearch: boolean;
|
||||
hasPagination: boolean;
|
||||
hasLimit: boolean;
|
||||
} {
|
||||
const params: string[] = [];
|
||||
try { new URL(url).searchParams.forEach((_v, k) => { if (!VOLATILE_PARAMS.has(k)) params.push(k); }); } catch {}
|
||||
return {
|
||||
params,
|
||||
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
|
||||
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
||||
import { STEALTH_GUARD } from './browser/stealth.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
@@ -169,8 +170,7 @@ describe('stealth anti-detection', () => {
|
||||
|
||||
it('includes guard flag to prevent double-injection', () => {
|
||||
const js = generateStealthJs();
|
||||
// Guard uses a non-enumerable property on a built-in prototype
|
||||
expect(js).toContain("EventTarget.prototype");
|
||||
expect(js).toContain(STEALTH_GUARD);
|
||||
// Guard should check early and return 'skipped'
|
||||
expect(js).toContain("return 'skipped'");
|
||||
// Normal path returns 'applied'
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DomSnapshotOptions {
|
||||
export interface SnapshotOptions {
|
||||
/** Extra pixels beyond viewport to include (default 800) */
|
||||
viewportExpand?: number;
|
||||
/** Maximum DOM depth to traverse (default 50) */
|
||||
@@ -175,7 +175,7 @@ export function getFormStateJs(): string {
|
||||
* - `|iframe|` — iframe content
|
||||
* - `|table|` — markdown table rendering
|
||||
*/
|
||||
export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
|
||||
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
|
||||
const viewportExpand = opts.viewportExpand ?? 800;
|
||||
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
|
||||
const interactiveOnly = opts.interactiveOnly ?? false;
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
*/
|
||||
|
||||
export { Page } from './page.js';
|
||||
export { BrowserBridge } from './mcp.js';
|
||||
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
||||
export { CDPBridge } from './cdp.js';
|
||||
export { isDaemonRunning } from './daemon-client.js';
|
||||
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
export { generateStealthJs } from './stealth.js';
|
||||
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
||||
export type { SnapshotOptions } from './dom-snapshot.js';
|
||||
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { __test__ as cdpTest } from './cdp.js';
|
||||
|
||||
@@ -117,3 +117,6 @@ export class BrowserBridge {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use BrowserBridge instead */
|
||||
export const PlaywrightMCP = BrowserBridge;
|
||||
|
||||
+10
-24
@@ -9,6 +9,9 @@
|
||||
* Inspired by puppeteer-extra-plugin-stealth.
|
||||
*/
|
||||
|
||||
/** Guard flag set on `window` to prevent double-injection. */
|
||||
export const STEALTH_GUARD = '__opencli_stealth_applied';
|
||||
|
||||
/**
|
||||
* Return a self-contained JS string that, when evaluated in a page context,
|
||||
* applies all stealth patches. Safe to call multiple times — the guard flag
|
||||
@@ -17,25 +20,16 @@
|
||||
export function generateStealthJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
// Guard: prevent double-injection across separate CDP evaluations.
|
||||
// We cannot use a closure variable (each eval is a fresh scope), and
|
||||
// window properties / Symbols are discoverable by anti-bot scripts.
|
||||
// Instead, stash the flag in a non-enumerable getter on a built-in
|
||||
// prototype that fingerprinters are unlikely to scan.
|
||||
const _gProto = EventTarget.prototype;
|
||||
const _gKey = '__lsn'; // looks like an internal listener cache
|
||||
if (_gProto[_gKey]) return 'skipped';
|
||||
try {
|
||||
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
|
||||
} catch {}
|
||||
// Guard: skip if already applied
|
||||
if (window.${STEALTH_GUARD}) return 'skipped';
|
||||
// Use defineProperty so the guard flag is non-enumerable (not a detection vector).
|
||||
Object.defineProperty(window, '${STEALTH_GUARD}', { value: true, configurable: true });
|
||||
|
||||
// 1. navigator.webdriver → false
|
||||
// 1. navigator.webdriver → undefined
|
||||
// Most common check; Playwright/Puppeteer/CDP set this to true.
|
||||
// Real Chrome returns false (not undefined) — returning undefined is
|
||||
// itself a detection signal for advanced fingerprinters.
|
||||
try {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false,
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
@@ -125,17 +119,9 @@ export function generateStealthJs(): string {
|
||||
// We override the stack property getter on Error.prototype to filter them.
|
||||
// Note: Error.prepareStackTrace is V8/Node-only and not available in
|
||||
// browser page context, so we use a property descriptor approach instead.
|
||||
// We use generic protocol patterns instead of product-specific names to
|
||||
// also catch our own injected code frames without leaking identifiers.
|
||||
try {
|
||||
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
|
||||
const _cdpPatterns = [
|
||||
'puppeteer_evaluation_script',
|
||||
'pptr:',
|
||||
'debugger://',
|
||||
'__playwright',
|
||||
'__puppeteer',
|
||||
];
|
||||
const _cdpPatterns = ['puppeteer_evaluation_script', 'pptr:', 'debugger://', '__opencli'];
|
||||
if (_origDescriptor && _origDescriptor.get) {
|
||||
Object.defineProperty(Error.prototype, 'stack', {
|
||||
get: function () {
|
||||
|
||||
+23
-1
@@ -46,7 +46,29 @@ export interface ManifestEntry {
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
import type { YamlCliDefinition } from './yaml-schema.js';
|
||||
interface YamlArgDefinition {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
description?: string;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
interface YamlCliDefinition {
|
||||
site?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
strategy?: string;
|
||||
browser?: boolean;
|
||||
args?: Record<string, YamlArgDefinition>;
|
||||
columns?: string[];
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeout?: number;
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Shared command factories for Electron/desktop app adapters.
|
||||
* Eliminates duplicate screenshot/status/new/dump implementations
|
||||
* across cursor, codex, chatwise, etc.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Factory: capture DOM HTML + accessibility snapshot.
|
||||
*/
|
||||
export function makeScreenshotCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
|
||||
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
|
||||
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: check CDP connection status.
|
||||
*/
|
||||
export function makeStatusCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'status',
|
||||
description: `Check active CDP connection to ${label}`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: start a new session via Cmd/Ctrl+N.
|
||||
*/
|
||||
export function makeNewCommand(site: string, displayName?: string) {
|
||||
const label = displayName ?? site;
|
||||
return cli({
|
||||
site,
|
||||
name: 'new',
|
||||
description: `Start a new ${label} session`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: dump DOM + snapshot for reverse-engineering.
|
||||
*/
|
||||
export function makeDumpCommand(site: string) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'dump',
|
||||
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['action', 'files'],
|
||||
func: async (page: IPage) => {
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
|
||||
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ cli({
|
||||
columns: ['id', 'title', 'authors', 'published'],
|
||||
func: async (_page, args) => {
|
||||
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
||||
const query = encodeURIComponent(`all:${args.query}`);
|
||||
const query = encodeURIComponent(`all:${args.keyword}`);
|
||||
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
||||
const entries = parseEntries(xml);
|
||||
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { apiGet, payloadData, getSelfUid } from './utils.js';
|
||||
import { apiGet, payloadData } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -15,12 +15,9 @@ cli({
|
||||
func: async (page, kwargs) => {
|
||||
const { limit = 20, page: pageNum = 1 } = kwargs;
|
||||
|
||||
// Get current user's UID
|
||||
const uid = await getSelfUid(page);
|
||||
|
||||
// Get default favorite folder ID
|
||||
const foldersPayload = await apiGet(page, '/x/v3/fav/folder/created/list-all', {
|
||||
params: { up_mid: uid },
|
||||
params: { up_mid: 0 },
|
||||
signed: true,
|
||||
});
|
||||
const folders = payloadData(foldersPayload)?.list ?? [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
|
||||
|
||||
@@ -15,7 +14,7 @@ cli({
|
||||
],
|
||||
columns: ['mid', 'name', 'sign', 'following', 'fans'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
// 1. Resolve UID (default to self)
|
||||
const uid = kwargs.uid
|
||||
@@ -31,7 +30,7 @@ cli({
|
||||
);
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const list = payload.data?.list || [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { apiGet } from './utils.js';
|
||||
|
||||
@@ -14,7 +13,7 @@ cli({
|
||||
],
|
||||
columns: ['index', 'from', 'to', 'content'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||||
if (!page) throw new Error('Requires browser');
|
||||
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
|
||||
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
|
||||
|
||||
@@ -25,7 +24,7 @@ cli({
|
||||
})()`);
|
||||
|
||||
if (!cid) {
|
||||
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
}
|
||||
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
@@ -36,12 +35,12 @@ cli({
|
||||
});
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||||
}
|
||||
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
if (subtitles.length === 0) {
|
||||
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
|
||||
throw new Error('此视频没有发现外挂或智能字幕。');
|
||||
}
|
||||
|
||||
// 4. 选择目标字幕语言
|
||||
@@ -51,7 +50,7 @@ cli({
|
||||
|
||||
const targetSubUrl = target.subtitle_url;
|
||||
if (!targetSubUrl || targetSubUrl === '') {
|
||||
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||||
}
|
||||
|
||||
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
|
||||
@@ -82,12 +81,12 @@ cli({
|
||||
const items = await page.evaluate(fetchJs);
|
||||
|
||||
if (items?.error) {
|
||||
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||||
}
|
||||
|
||||
const finalItems = items?.data || [];
|
||||
if (!Array.isArray(finalItems)) {
|
||||
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
|
||||
throw new Error('解析到的字幕列表对象不符合数组格式');
|
||||
}
|
||||
|
||||
// 6. 数据映射
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
|
||||
@@ -112,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
|
||||
});
|
||||
const results = payload?.data?.result ?? [];
|
||||
if (results.length > 0) return String(results[0].mid);
|
||||
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
|
||||
throw new Error(`Cannot resolve UID for: ${input}`);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
requirePage, navigateToChat, fetchRecommendList,
|
||||
clickCandidateInList, typeAndSendMessage, verbose,
|
||||
} from './utils.js';
|
||||
} from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
|
||||
import { requirePage, navigateToChat, fetchFriendList } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
||||
import { requirePage, navigateTo, bossFetch, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 exchange — request phone/wechat exchange with a candidate.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage, verbose,
|
||||
} from './utils.js';
|
||||
} from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 invite — send interview invitation to a candidate.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job list — list my published jobs via boss API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
|
||||
import { ArgumentError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
const LABEL_MAP: Record<string, number> = {
|
||||
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
|
||||
@@ -45,7 +44,7 @@ cli({
|
||||
if (entry) {
|
||||
labelId = entry[1];
|
||||
} else {
|
||||
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +53,7 @@ cli({
|
||||
await navigateToChat(page);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
|
||||
if (!friend) throw new EmptyResultError('boss candidate search');
|
||||
if (!friend) throw new Error('未找到该候选人');
|
||||
|
||||
const friendName = friend.name || '候选人';
|
||||
const action = remove ? 'deleteMark' : 'addMark';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchRecommendList, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* .position-content → job being discussed + expectation
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 job search — browser cookie API.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './utils.js';
|
||||
import { requirePage, navigateTo, bossFetch, assertOk, verbose } from './common.js';
|
||||
|
||||
/** City name → BOSS Zhipin city code mapping */
|
||||
const CITY_CODES: Record<string, string> = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
requirePage, navigateToChat, findFriendByUid,
|
||||
clickCandidateInList, typeAndSendMessage,
|
||||
} from './common.js';
|
||||
import { EmptyResultError, SelectorError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
@@ -30,21 +29,21 @@ cli({
|
||||
await navigateToChat(page, 3);
|
||||
|
||||
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
|
||||
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
|
||||
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
|
||||
|
||||
const numericUid = friend.uid;
|
||||
const friendName = friend.name || '候选人';
|
||||
|
||||
const clicked = await clickCandidateInList(page, numericUid);
|
||||
if (!clicked) {
|
||||
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
||||
}
|
||||
|
||||
await page.wait({ time: 2 });
|
||||
|
||||
const sent = await typeAndSendMessage(page, kwargs.text);
|
||||
if (!sent) {
|
||||
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
throw new Error('找不到消息输入框');
|
||||
}
|
||||
|
||||
await page.wait({ time: 1 });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* BOSS直聘 stats — job statistics overview.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './utils.js';
|
||||
import { requirePage, navigateToChat, bossFetch, fetchFriendList, verbose } from './common.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getVisibleChatMessages } from './ax.js';
|
||||
|
||||
@@ -17,10 +16,6 @@ export const askCommand = cli({
|
||||
],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
|
||||
const text = kwargs.text as string;
|
||||
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
@@ -13,10 +12,6 @@ export const newCommand = cli({
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
|
||||
try {
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.5'");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getVisibleChatMessages } from './ax.js';
|
||||
|
||||
@@ -14,10 +13,6 @@ export const readCommand = cli({
|
||||
args: [],
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
|
||||
try {
|
||||
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
|
||||
execSync("osascript -e 'delay 0.3'");
|
||||
@@ -29,7 +24,7 @@ export const readCommand = cli({
|
||||
|
||||
return [{ Role: 'Assistant', Text: messages[messages.length - 1] }];
|
||||
} catch (err: any) {
|
||||
throw new CommandExecutionError("Failed to read from ChatGPT: " + err.message);
|
||||
throw new Error("Failed to read from ChatGPT: " + err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError, ConfigError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
@@ -13,15 +12,11 @@ export const statusCommand = cli({
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage | null) => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim();
|
||||
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
|
||||
} catch {
|
||||
throw new CommandExecutionError('Error querying ChatGPT application state');
|
||||
return [{ Status: 'Error querying application state' }];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
@@ -27,14 +26,14 @@ export const askCommand = cli({
|
||||
`);
|
||||
|
||||
// Send message
|
||||
const injected = await page.evaluate(`
|
||||
await page.evaluate(`
|
||||
(function(text) {
|
||||
let composer = document.querySelector('textarea');
|
||||
if (!composer) {
|
||||
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
|
||||
composer = editables.length > 0 ? editables[editables.length - 1] : null;
|
||||
}
|
||||
if (!composer) return false;
|
||||
if (!composer) throw new Error('Could not find input');
|
||||
composer.focus();
|
||||
if (composer.tagName === 'TEXTAREA') {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
@@ -43,10 +42,8 @@ export const askCommand = cli({
|
||||
} else {
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
return true;
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected) throw new SelectorError('ChatWise input element');
|
||||
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
@@ -45,7 +44,7 @@ export const modelCommand = cli({
|
||||
return [{ Status: 'Active', Model: currentModel }];
|
||||
} else {
|
||||
// Try to switch model
|
||||
const opened = await page.evaluate(`
|
||||
await page.evaluate(`
|
||||
(function(target) {
|
||||
const selectors = [
|
||||
'[class*="model"]',
|
||||
@@ -55,12 +54,11 @@ export const modelCommand = cli({
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) { el.click(); return true; }
|
||||
if (el) { el.click(); return; }
|
||||
}
|
||||
return false;
|
||||
throw new Error('Could not find model selector');
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
if (!opened) throw new SelectorError('ChatWise model selector');
|
||||
|
||||
await page.wait(0.5);
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation');
|
||||
export const newCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in ChatWise',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
// ChatWise uses standard Electron shortcuts
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise');
|
||||
export const screenshotCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'screenshot',
|
||||
description: 'Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: 'Output file path (default: /tmp/chatwise-snapshot)' },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const basePath = (kwargs.output as string) || '/tmp/chatwise-snapshot';
|
||||
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = basePath + '-dom.html';
|
||||
const snapPath = basePath + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
@@ -14,7 +13,7 @@ export const sendCommand = cli({
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const text = kwargs.text as string;
|
||||
|
||||
const injected = await page.evaluate(`
|
||||
await page.evaluate(`
|
||||
(function(text) {
|
||||
// ChatWise input can be textarea or contenteditable
|
||||
let composer = document.querySelector('textarea');
|
||||
@@ -23,7 +22,7 @@ export const sendCommand = cli({
|
||||
composer = editables.length > 0 ? editables[editables.length - 1] : null;
|
||||
}
|
||||
|
||||
if (!composer) return false;
|
||||
if (!composer) throw new Error('Could not find ChatWise input element');
|
||||
|
||||
composer.focus();
|
||||
|
||||
@@ -35,10 +34,8 @@ export const sendCommand = cli({
|
||||
} else {
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
return true;
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected) throw new SelectorError('ChatWise input element');
|
||||
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');
|
||||
export const statusCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'status',
|
||||
description: 'Check active CDP connection to ChatWise Desktop',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Connected',
|
||||
Url: url,
|
||||
Title: title,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
@@ -27,17 +26,15 @@ export const askCommand = cli({
|
||||
`);
|
||||
|
||||
// Inject and send
|
||||
const injected = await page.evaluate(`
|
||||
await page.evaluate(`
|
||||
(function(text) {
|
||||
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
|
||||
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
|
||||
if (!composer) return false;
|
||||
if (!composer) throw new Error('Could not find Codex input');
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
return true;
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected) throw new SelectorError('Codex input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
|
||||
+27
-2
@@ -1,3 +1,28 @@
|
||||
import { makeDumpCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export const dumpCommand = makeDumpCommand('codex');
|
||||
export const dumpCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'dump',
|
||||
description: 'Dump the DOM and Accessibility tree of Codex for reverse-engineering',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['action', 'files'],
|
||||
func: async (page) => {
|
||||
// Extract full HTML
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/codex-dom.html', dom);
|
||||
|
||||
// Get accessibility snapshot
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
+28
-2
@@ -1,3 +1,29 @@
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const newCommand = makeNewCommand('codex', 'Codex conversation');
|
||||
export const newCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'new',
|
||||
description: 'Start a new Codex conversation thread / isolated workspace',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status', 'Action'],
|
||||
func: async (page) => {
|
||||
// According to research, Cmd+N / Ctrl+N spins up a new thread
|
||||
const isMac = process.platform === 'darwin';
|
||||
const newThreadKey = isMac ? 'Meta+N' : 'Control+N';
|
||||
|
||||
// Simulate keyboard shortcut
|
||||
await page.pressKey(newThreadKey);
|
||||
|
||||
// Wait a brief moment for UI animation
|
||||
await page.wait(1);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
Action: `Pressed ${newThreadKey} to trigger New Thread`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const screenshotCommand = makeScreenshotCommand('codex', 'Codex');
|
||||
export const screenshotCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'screenshot',
|
||||
description: 'Capture a snapshot of the current Codex window (DOM + Accessibility tree)',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: 'Output file path (default: /tmp/codex-snapshot.txt)' },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const outputPath = (kwargs.output as string) || '/tmp/codex-snapshot.txt';
|
||||
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
|
||||
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
@@ -14,7 +13,7 @@ export const sendCommand = cli({
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const textToInsert = kwargs.text as string;
|
||||
|
||||
const injected = await page.evaluate(`
|
||||
await page.evaluate(`
|
||||
(function(text) {
|
||||
let composer = document.querySelector('textarea, [contenteditable="true"]');
|
||||
|
||||
@@ -23,14 +22,14 @@ export const sendCommand = cli({
|
||||
composer = editables[editables.length - 1];
|
||||
}
|
||||
|
||||
if (!composer) return false;
|
||||
if (!composer) {
|
||||
throw new Error('Could not find Composer input element in Codex UI');
|
||||
}
|
||||
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})
|
||||
`);
|
||||
if (!injected) throw new SelectorError('Codex Composer input element');
|
||||
|
||||
// Wait for the UI to register the input
|
||||
await page.wait(0.5);
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const statusCommand = makeStatusCommand('codex', 'OpenAI Codex App');
|
||||
export const statusCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'status',
|
||||
description: 'Check active CDP connection to OpenAI Codex App',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Connected',
|
||||
Url: url,
|
||||
Title: title,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
@@ -34,7 +33,7 @@ export const askCommand = cli({
|
||||
})(${JSON.stringify(text)})`
|
||||
);
|
||||
|
||||
if (!injected) throw new SelectorError('Cursor input element');
|
||||
if (!injected) throw new Error('Could not find input element.');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const composerCommand = cli({
|
||||
@@ -34,7 +33,7 @@ export const composerCommand = cli({
|
||||
);
|
||||
|
||||
if (!typed) {
|
||||
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
}
|
||||
|
||||
await page.wait(0.5);
|
||||
|
||||
+27
-2
@@ -1,3 +1,28 @@
|
||||
import { makeDumpCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export const dumpCommand = makeDumpCommand('cursor');
|
||||
export const dumpCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'dump',
|
||||
description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['action', 'files'],
|
||||
func: async (page) => {
|
||||
// Extract full HTML
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/cursor-dom.html', dom);
|
||||
|
||||
// Get accessibility snapshot
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
+20
-2
@@ -1,3 +1,21 @@
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const newCommand = makeNewCommand('cursor', 'Cursor chat or Composer');
|
||||
export const newCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'new',
|
||||
description: 'Start a new Cursor chat or Composer session',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
// Use keyboard shortcut — most robust approach, avoids brittle DOM selectors
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1);
|
||||
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { EmptyResultError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
@@ -40,7 +39,7 @@ export const readCommand = cli({
|
||||
`);
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
throw new EmptyResultError('cursor read', 'No conversation history found in Cursor.');
|
||||
throw new Error('No conversation history found in Cursor.');
|
||||
}
|
||||
|
||||
return history;
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
function makeScreenshotCommand(site: string) {
|
||||
return cli({
|
||||
site,
|
||||
name: 'screenshot',
|
||||
description: `Capture a snapshot of the current ${site} window (DOM + Accessibility tree)`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
|
||||
],
|
||||
columns: ['Status', 'File'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
|
||||
|
||||
// Get both the accessibility snapshot and the raw DOM HTML
|
||||
const snap = await page.snapshot({ compact: true });
|
||||
const html = await page.evaluate('document.documentElement.outerHTML');
|
||||
|
||||
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
|
||||
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
|
||||
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{ Status: 'Success', File: htmlPath },
|
||||
{ Status: 'Success', File: snapPath },
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const screenshotCursor = makeScreenshotCommand('cursor');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SelectorError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
@@ -30,7 +29,7 @@ export const sendCommand = cli({
|
||||
);
|
||||
|
||||
if (!injected) {
|
||||
throw new SelectorError('Cursor Composer input element');
|
||||
throw new Error('Could not find Cursor Composer input element.');
|
||||
}
|
||||
|
||||
// Submit the command. In Cursor, Enter usually submits the chat.
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const statusCommand = makeStatusCommand('cursor', 'Cursor AI Editor');
|
||||
export const statusCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'status',
|
||||
description: 'Check active CDP connection to Cursor AI Editor',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI, // Interactive UI manipulation
|
||||
browser: true,
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Connected',
|
||||
Url: url,
|
||||
Title: title,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
site: dictionary
|
||||
name: examples
|
||||
description: Read real-world example sentences utilizing the word
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to get example sentences for
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
example: "${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, example]
|
||||
@@ -1,27 +0,0 @@
|
||||
site: dictionary
|
||||
name: search
|
||||
description: Search the Free Dictionary API for definitions, parts of speech, and pronunciations.
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to define (e.g., serendipity)
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
phonetic: "${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}"
|
||||
type: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}"
|
||||
definition: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, phonetic, type, definition]
|
||||
@@ -1,25 +0,0 @@
|
||||
site: dictionary
|
||||
name: synonyms
|
||||
description: Find synonyms for a specific word
|
||||
domain: api.dictionaryapi.dev
|
||||
strategy: public
|
||||
browser: false
|
||||
|
||||
args:
|
||||
word:
|
||||
type: string
|
||||
required: true
|
||||
positional: true
|
||||
description: Word to find synonyms for (e.g., serendipity)
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}"
|
||||
|
||||
- map:
|
||||
word: "${{ item.word }}"
|
||||
synonyms: "${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}"
|
||||
|
||||
- limit: 1
|
||||
|
||||
columns: [word, synonyms]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadDoubanBookHot } from './utils.js';
|
||||
import { loadDoubanBookHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadDoubanMovieHot } from './utils.js';
|
||||
import { loadDoubanMovieHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { searchDouban } from './utils.js';
|
||||
import { searchDouban } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'douban',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 20, 50));
|
||||
}
|
||||
|
||||
async function ensureDoubanReady(page: IPage): Promise<void> {
|
||||
const state = await page.evaluate(`
|
||||
(() => {
|
||||
const title = (document.title || '').trim();
|
||||
const href = (location.href || '').trim();
|
||||
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
|
||||
return { blocked, title, href };
|
||||
})()
|
||||
`);
|
||||
if (state?.blocked) {
|
||||
throw new CliError(
|
||||
'AUTH_REQUIRED',
|
||||
'Douban requires a logged-in browser session before these commands can load data.',
|
||||
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://book.douban.com/chart');
|
||||
await page.wait(4);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const books = [];
|
||||
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
|
||||
try {
|
||||
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
|
||||
const title = normalize(titleEl?.textContent);
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
|
||||
|
||||
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
|
||||
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
|
||||
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
|
||||
.map((node) => normalize(node.textContent))
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
books.push({
|
||||
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
|
||||
title,
|
||||
rating: parseFloat(ratingText) || 0,
|
||||
quote,
|
||||
author: infoParts[0] || '',
|
||||
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
|
||||
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
|
||||
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return books.slice(0, ${safeLimit});
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://movie.douban.com/chart');
|
||||
await page.wait(4);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const results = [];
|
||||
for (const el of Array.from(document.querySelectorAll('.item'))) {
|
||||
const titleEl = el.querySelector('.pl2 a');
|
||||
const title = normalize(titleEl?.textContent);
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
|
||||
|
||||
const info = normalize(el.querySelector('.pl2 p')?.textContent);
|
||||
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
const releaseIndex = (() => {
|
||||
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
|
||||
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
|
||||
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
|
||||
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
title,
|
||||
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
|
||||
quote: normalize(el.querySelector('.inq')?.textContent),
|
||||
director: directorPart.replace(/^导演:\\s*/, ''),
|
||||
year: yearMatch?.[0] || '',
|
||||
region: regionPart,
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
if (results.length >= ${safeLimit}) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
|
||||
await page.wait(2);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const type = ${JSON.stringify(type)};
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const seen = new Set();
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
const items = Array.from(document.querySelectorAll('.item-root'));
|
||||
|
||||
const results = [];
|
||||
for (const el of items) {
|
||||
const titleEl = el.querySelector('.title-text, .title a, a[title]');
|
||||
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
|
||||
if (!url.includes('/subject/') || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
|
||||
const abstract = normalize(
|
||||
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
|
||||
);
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
|
||||
type,
|
||||
title,
|
||||
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
|
||||
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
if (results.length >= ${safeLimit}) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
+1
-165
@@ -1,173 +1,9 @@
|
||||
/**
|
||||
* Douban adapter utilities.
|
||||
* Douban movie adapter utilities.
|
||||
*/
|
||||
|
||||
import { CliError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 20, 50));
|
||||
}
|
||||
|
||||
async function ensureDoubanReady(page: IPage): Promise<void> {
|
||||
const state = await page.evaluate(`
|
||||
(() => {
|
||||
const title = (document.title || '').trim();
|
||||
const href = (location.href || '').trim();
|
||||
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
|
||||
return { blocked, title, href };
|
||||
})()
|
||||
`);
|
||||
if (state?.blocked) {
|
||||
throw new CliError(
|
||||
'AUTH_REQUIRED',
|
||||
'Douban requires a logged-in browser session before these commands can load data.',
|
||||
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://book.douban.com/chart');
|
||||
await page.wait(4);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const books = [];
|
||||
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
|
||||
try {
|
||||
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
|
||||
const title = normalize(titleEl?.textContent);
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;
|
||||
|
||||
const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
|
||||
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
|
||||
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
|
||||
.map((node) => normalize(node.textContent))
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
books.push({
|
||||
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
|
||||
title,
|
||||
rating: parseFloat(ratingText) || 0,
|
||||
quote,
|
||||
author: infoParts[0] || '',
|
||||
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
|
||||
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
|
||||
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return books.slice(0, ${safeLimit});
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://movie.douban.com/chart');
|
||||
await page.wait(4);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(() => {
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const results = [];
|
||||
for (const el of Array.from(document.querySelectorAll('.item'))) {
|
||||
const titleEl = el.querySelector('.pl2 a');
|
||||
const title = normalize(titleEl?.textContent);
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;
|
||||
|
||||
const info = normalize(el.querySelector('.pl2 p')?.textContent);
|
||||
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
const releaseIndex = (() => {
|
||||
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
|
||||
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
|
||||
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
|
||||
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
title,
|
||||
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
|
||||
quote: normalize(el.querySelector('.inq')?.textContent),
|
||||
director: directorPart.replace(/^导演:\\s*/, ''),
|
||||
year: yearMatch?.[0] || '',
|
||||
region: regionPart,
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
if (results.length >= ${safeLimit}) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
|
||||
await page.wait(2);
|
||||
await ensureDoubanReady(page);
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const type = ${JSON.stringify(type)};
|
||||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const seen = new Set();
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
const items = Array.from(document.querySelectorAll('.item-root'));
|
||||
|
||||
const results = [];
|
||||
for (const el of items) {
|
||||
const titleEl = el.querySelector('.title-text, .title a, a[title]');
|
||||
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
|
||||
let url = titleEl?.getAttribute('href') || '';
|
||||
if (!title || !url) continue;
|
||||
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
|
||||
if (!url.includes('/subject/') || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
|
||||
const abstract = normalize(
|
||||
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
|
||||
);
|
||||
results.push({
|
||||
rank: results.length + 1,
|
||||
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
|
||||
type,
|
||||
title,
|
||||
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
|
||||
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
|
||||
url,
|
||||
cover: el.querySelector('img')?.getAttribute('src') || '',
|
||||
});
|
||||
if (results.length >= ${safeLimit}) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's Douban ID from movie.douban.com/mine page
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './utils.js';
|
||||
import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './common.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'doubao-app',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { clickNewChatScript } from './utils.js';
|
||||
import { clickNewChatScript } from './common.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'doubao-app',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { readMessagesScript } from './utils.js';
|
||||
import { readMessagesScript } from './common.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'doubao-app',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { injectTextScript, clickSendScript } from './utils.js';
|
||||
import { injectTextScript, clickSendScript } from './common.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'doubao-app',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './utils.js';
|
||||
import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './common.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'doubao',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './utils.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, startNewDoubaoChat } from './common.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'doubao',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './utils.js';
|
||||
import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './common.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'doubao',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './utils.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, sendDoubaoMessage } from './common.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'doubao',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './utils.js';
|
||||
import { DOUBAO_DOMAIN, DOUBAO_CHAT_URL, getDoubaoPageState } from './common.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'doubao',
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { __test__ } from './ask.js';
|
||||
|
||||
describe('grok ask helpers', () => {
|
||||
describe('isOnGrok', () => {
|
||||
const fakePage = (url: string | Error): IPage =>
|
||||
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
|
||||
|
||||
it('returns true for grok.com URLs', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://grok.com/'))).toBe(true);
|
||||
expect(await __test__.isOnGrok(fakePage('https://grok.com/chat/abc123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for grok.com subdomains', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://api.grok.com/v1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-grok domains', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
|
||||
expect(await __test__.isOnGrok(fakePage('https://example.com/?next=grok.com'))).toBe(false);
|
||||
expect(await __test__.isOnGrok(fakePage('about:blank'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when evaluate throws (detached tab)', async () => {
|
||||
expect(await __test__.isOnGrok(fakePage(new Error('detached')))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes boolean flags for explicit web routing', () => {
|
||||
expect(__test__.normalizeBooleanFlag(true)).toBe(true);
|
||||
expect(__test__.normalizeBooleanFlag('true')).toBe(true);
|
||||
|
||||
+12
-25
@@ -53,19 +53,6 @@ function updateStableState(previousText: string, stableCount: number, nextText:
|
||||
return { previousText: nextText, stableCount: 0 };
|
||||
}
|
||||
|
||||
/** Check whether the tab is already on grok.com (any path). */
|
||||
async function isOnGrok(page: IPage): Promise<boolean> {
|
||||
// catch handles blank tabs (about:blank) or detached pages
|
||||
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 === 'grok.com' || hostname.endsWith('.grok.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDefaultAsk(
|
||||
page: IPage,
|
||||
prompt: string,
|
||||
@@ -73,17 +60,21 @@ async function runDefaultAsk(
|
||||
newChat: boolean,
|
||||
) {
|
||||
if (newChat) {
|
||||
// Explicitly start a fresh conversation via the homepage
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(2);
|
||||
await tryStartFreshChat(page);
|
||||
await page.evaluate(`(() => {
|
||||
const btn = [...document.querySelectorAll('a, button')].find(b => {
|
||||
const t = (b.textContent || '').trim().toLowerCase();
|
||||
return t.includes('new') || b.getAttribute('href') === '/';
|
||||
});
|
||||
if (btn) btn.click();
|
||||
})()`);
|
||||
await page.wait(2);
|
||||
} else if (!(await isOnGrok(page))) {
|
||||
// First invocation or tab was recycled — navigate to Grok
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
await page.goto(GROK_URL);
|
||||
await page.wait(3);
|
||||
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
const sendResult = await page.evaluate(`(async () => {
|
||||
try {
|
||||
@@ -258,14 +249,11 @@ async function runExplicitWebAsk(
|
||||
timeoutMs: number,
|
||||
newChat: boolean,
|
||||
) {
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
|
||||
if (newChat) {
|
||||
// Navigate to homepage and start a fresh conversation
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
await tryStartFreshChat(page);
|
||||
await page.wait(2);
|
||||
} else if (!(await isOnGrok(page))) {
|
||||
// First invocation or tab was recycled — navigate to Grok
|
||||
await page.goto(GROK_URL, { settleMs: 2000 });
|
||||
}
|
||||
|
||||
const baselineBubbles = await getBubbleTexts(page);
|
||||
@@ -330,5 +318,4 @@ export const __test__ = {
|
||||
updateStableState,
|
||||
normalizeBooleanFlag,
|
||||
normalizeBubbleText,
|
||||
isOnGrok,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { JikePost, getPostDataJs } from './utils.js';
|
||||
import { JikePost, getPostDataJs } from './shared.js';
|
||||
|
||||
/**
|
||||
* 即刻首页动态流适配器
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { JikePost, getPostDataJs } from './utils.js';
|
||||
import { JikePost, getPostDataJs } from './shared.js';
|
||||
|
||||
/**
|
||||
* 即刻搜索适配器
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { ArgumentError, CommandExecutionError } from '../../errors.js';
|
||||
|
||||
// ── Filter value mappings ──────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +64,7 @@ function mapFilterValues(input: unknown, mapping: Record<string, string>, label:
|
||||
const resolved = values.map(value => {
|
||||
const key = value.toLowerCase();
|
||||
const mapped = mapping[key];
|
||||
if (!mapped) throw new ArgumentError(`Unsupported ${label}: ${value}`);
|
||||
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
|
||||
return mapped;
|
||||
});
|
||||
return [...new Set(resolved)];
|
||||
@@ -215,7 +214,7 @@ async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]>
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
|
||||
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
@@ -253,7 +252,7 @@ async function fetchJobCards(
|
||||
})()`);
|
||||
|
||||
if (!batch || batch.error) {
|
||||
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
|
||||
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
|
||||
}
|
||||
|
||||
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
|
||||
@@ -388,7 +387,7 @@ cli({
|
||||
const location = (kwargs.location ?? '').trim();
|
||||
const keywords = String(kwargs.query ?? '').trim();
|
||||
|
||||
if (!keywords) throw new ArgumentError('query is required');
|
||||
if (!keywords) throw new Error('query is required');
|
||||
|
||||
const searchParams = new URLSearchParams({ keywords });
|
||||
if (location) searchParams.set('location', location);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
||||
|
||||
interface TimelinePost {
|
||||
rank?: number;
|
||||
@@ -511,11 +510,11 @@ cli({
|
||||
}
|
||||
|
||||
if (sawLoginWall && posts.length === 0) {
|
||||
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
|
||||
throw new Error('LinkedIn timeline requires an active signed-in browser session');
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
|
||||
throw new Error('No LinkedIn timeline posts found. Make sure your LinkedIn home feed is visible in the browser.');
|
||||
}
|
||||
|
||||
return posts.slice(0, limit).map((post, index) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { buildMediumTagUrl, loadMediumPosts } from './utils.js';
|
||||
import { buildMediumTagUrl, loadMediumPosts } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'medium',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { buildMediumSearchUrl, loadMediumPosts } from './utils.js';
|
||||
import { buildMediumSearchUrl, loadMediumPosts } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'medium',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export function buildMediumTagUrl(topic?: string): string {
|
||||
@@ -14,7 +13,7 @@ export function buildMediumUserUrl(username: string): string {
|
||||
}
|
||||
|
||||
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
|
||||
if (!page) throw new Error('Requires browser session');
|
||||
await page.goto(url);
|
||||
await page.wait(5);
|
||||
const data = await page.evaluate(`
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { buildMediumUserUrl, loadMediumPosts } from './utils.js';
|
||||
import { buildMediumUserUrl, loadMediumPosts } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'medium',
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* - Indented output showing conversation threads
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CommandExecutionError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
@@ -177,9 +176,9 @@ cli({
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
|
||||
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
|
||||
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
|
||||
if (!data || typeof data !== 'object') throw new Error('Failed to fetch post data');
|
||||
if (!Array.isArray(data) && data.error) throw new Error(data.error);
|
||||
if (!Array.isArray(data)) throw new Error('Unexpected response');
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogArticle } from './utils.js';
|
||||
import { loadSinaBlogArticle } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogHot } from './utils.js';
|
||||
import { loadSinaBlogHot } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { loadSinaBlogUser } from './utils.js';
|
||||
import { loadSinaBlogUser } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'sinablog',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { buildSubstackBrowseUrl, loadSubstackFeed } from './utils.js';
|
||||
import { buildSubstackBrowseUrl, loadSubstackFeed } from './shared.js';
|
||||
|
||||
cli({
|
||||
site: 'substack',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user