Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa55c88069 | |||
| b32fe1cbc3 | |||
| 981cc1bc5e | |||
| cd63231b7e | |||
| 685658f7bd | |||
| cd6f7a1f7e | |||
| 4ce0345c9a | |||
| 3cc2cb5504 | |||
| abac070ce4 | |||
| 79fbac844e | |||
| 7e776e2bd5 | |||
| bde1c53a3e | |||
| 5e667b9c2f | |||
| 64e3a2d627 | |||
| 849d9faea1 | |||
| 29ea5ce059 | |||
| 12c4b8853b | |||
| cfad003220 | |||
| abfd4b902c | |||
| 2d52abde7c | |||
| f102501e4a | |||
| 2a983b6b8d | |||
| c114a9d7f1 | |||
| d2e179ced5 | |||
| c806f795cc | |||
| de5495bdd7 | |||
| d6222ff932 | |||
| e0395ce5ed | |||
| 4c8c6e8be7 | |||
| 7b5bdfa7d5 | |||
| 546c0b997a | |||
| 1e34e7e6d3 | |||
| 9ae9eb3fc6 | |||
| 612c0ab1af | |||
| 68840fc85c | |||
| 8263a06a85 | |||
| eb2c3fdf89 | |||
| 43ed0ace59 | |||
| de962eb5fb | |||
| d1da293ef9 | |||
| 25bd872a24 | |||
| ff3e5c6887 | |||
| 2e66e3183c | |||
| a1bcb23239 | |||
| 6024af3aa0 | |||
| 1393ce3327 | |||
| 0fe3b9b921 | |||
| 375beaa744 | |||
| 341c42c62f | |||
| 50b71c0936 | |||
| 981c167a0b | |||
| 2463689105 | |||
| c714254d8f | |||
| 8e7490407c | |||
| 14dcd2bc5f | |||
| e9b9beedfe | |||
| 59de5fb3f5 | |||
| 7555f14369 | |||
| 2652fa40e5 | |||
| a7c367a61b | |||
| fbec2f6f5d | |||
| c2a5cbe90e | |||
| 34e20d33f2 | |||
| 1c496bb85f | |||
| 0c845d58c8 | |||
| 7f55950fed | |||
| 1576396a21 | |||
| 77193a0003 | |||
| 05b7f1bccf | |||
| 9889a6db11 | |||
| 61ea05bff7 | |||
| e781d40408 | |||
| c230f3e5ad | |||
| 3b2f88b2cf | |||
| 7eec7ce89f | |||
| 788b069c02 | |||
| 433ad3a56a | |||
| 50508b954e | |||
| 35a843b8bd | |||
| 486e513d07 | |||
| 6c64f617c6 | |||
| cd186bddd3 | |||
| 6486a42def | |||
| b308d5594a |
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: cross-project-adapter-migration
|
||||
description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update."
|
||||
---
|
||||
|
||||
# Cross-Project Adapter Migration
|
||||
|
||||
> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。
|
||||
|
||||
## When to Use
|
||||
|
||||
- 用户说"把 xxx-cli 的命令迁移过来"
|
||||
- 用户说"看看 xxx 项目有什么可以借鉴的"
|
||||
- 用户说"对齐 xxx-cli 的功能"
|
||||
- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)(adapter 开发决策树)
|
||||
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 源项目分析
|
||||
|
||||
### 1.1 克隆 & 理解源项目
|
||||
|
||||
```bash
|
||||
# 克隆源项目到 /tmp 做分析
|
||||
git clone <source_repo_url> /tmp/<source-cli>
|
||||
```
|
||||
|
||||
分析重点:
|
||||
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README)
|
||||
- **认证方式**:Cookie?API Key?OAuth?浏览器自动化?
|
||||
- **数据源**:公开 API?GraphQL?页面抓取?
|
||||
- **输出字段**:每个命令返回哪些数据字段
|
||||
|
||||
### 1.2 生成命令清单
|
||||
|
||||
列出源项目所有命令,包括:
|
||||
|
||||
| 命令 | 类型 | API/方法 | 输出字段 |
|
||||
|------|------|---------|---------|
|
||||
| `xxx feed` | Read | `GET /api/feed` | title, author, time |
|
||||
| `xxx post` | Write | `POST /api/tweet` | status, id |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 功能对比矩阵
|
||||
|
||||
### 2.1 查看 opencli 现有命令
|
||||
|
||||
```bash
|
||||
ls src/clis/<site>/ # 查看已有适配器
|
||||
opencli list | grep <site> # 确认已注册命令
|
||||
```
|
||||
|
||||
### 2.2 生成对比矩阵
|
||||
|
||||
对每个源项目命令,标注三种状态:
|
||||
|
||||
| 功能 | 源项目 | opencli 现有 | 行动 |
|
||||
|------|--------|-------------|------|
|
||||
| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** |
|
||||
| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 |
|
||||
| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** |
|
||||
| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 |
|
||||
|
||||
### 2.3 筛选迁移目标
|
||||
|
||||
去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类:
|
||||
|
||||
**筛选原则**:
|
||||
- ✅ 高使用频率的命令优先
|
||||
- ✅ 已有但不完整的命令标记为"增强"
|
||||
- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过
|
||||
- ❌ 与现有功能完全重复的跳过
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 批量实现
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
|
||||
|
||||
### 3.1 选择实现方式
|
||||
|
||||
基于决策树分类:
|
||||
|
||||
| 类别 | 方式 | 适用条件 |
|
||||
|------|------|---------|
|
||||
| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS |
|
||||
| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 |
|
||||
| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 |
|
||||
| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API |
|
||||
|
||||
### 3.2 实现顺序
|
||||
|
||||
**先 Read 后 Write,先 YAML 后 TS**:
|
||||
|
||||
1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行)
|
||||
2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的)
|
||||
3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API)
|
||||
|
||||
### 3.3 实现模板
|
||||
|
||||
#### YAML Read 适配器模板(Cookie 策略)
|
||||
|
||||
```yaml
|
||||
site: <site>
|
||||
name: <command>
|
||||
description: <描述>
|
||||
domain: www.<site>.com
|
||||
strategy: cookie
|
||||
browser: true
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.<site>.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const res = await fetch('<api_endpoint>', { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d.data?.items || []).map(item => ({
|
||||
title: item.title,
|
||||
// ... map source fields
|
||||
}));
|
||||
})()
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title]
|
||||
```
|
||||
|
||||
#### TS Write 适配器模板(UI 策略)
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: '<site>',
|
||||
name: '<command>',
|
||||
description: '<描述>',
|
||||
strategy: Strategy.UI,
|
||||
args: [{ name: 'target', required: true, help: '<参数说明>' }],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto(`https://www.<site>.com/${kwargs.target}`);
|
||||
await page.wait({ text: '<expected_text>', timeout: 10 });
|
||||
|
||||
// 获取 snapshot 找到目标按钮
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
// 点击按钮 ...
|
||||
|
||||
return [{ status: 'success', message: '<action> completed' }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3.4 公共模式复用
|
||||
|
||||
迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/<site>.ts` 工具文件:
|
||||
|
||||
```typescript
|
||||
// src/<site>.ts
|
||||
export async function fetchWithAuth(page, url) { ... }
|
||||
export function parseItem(raw) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 验证 & 发布
|
||||
|
||||
### 4.1 构建验证
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit # TypeScript 编译检查
|
||||
opencli list | grep <site> # 确认所有命令已注册
|
||||
```
|
||||
|
||||
### 4.2 运行验证(关键!)
|
||||
|
||||
每个新命令必须实际运行:
|
||||
|
||||
```bash
|
||||
# Read 命令
|
||||
opencli <site> <command> --limit 3 -f json
|
||||
opencli <site> <command> --limit 3 -v # verbose 查看 pipeline
|
||||
|
||||
# Write 命令(谨慎!会实际操作)
|
||||
opencli <site> <command> <test_target>
|
||||
```
|
||||
|
||||
### 4.3 更新文档
|
||||
|
||||
迁移完成后必须更新以下文件:
|
||||
|
||||
1. **README.md** — 在对应平台区域添加新命令示例
|
||||
2. **SKILL.md** — 在 Commands Reference 中添加新命令
|
||||
|
||||
### 4.4 提交 & 推送
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(<site>): migrate <N> commands from <source-cli>
|
||||
|
||||
- Phase A: <N> YAML adapters (read operations)
|
||||
- Phase B: <N> TS adapters (write operations)
|
||||
- Source: <source_repo_url>"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] 源项目命令清单已生成
|
||||
- [ ] 对比矩阵已确认,高价值缺失命令已筛选
|
||||
- [ ] 用户确认迁移范围
|
||||
- [ ] Phase A: YAML Read 适配器已完成
|
||||
- [ ] Phase B: TS Read 适配器已完成
|
||||
- [ ] Phase C: TS Write 适配器已完成
|
||||
- [ ] `npx tsc --noEmit` 编译通过
|
||||
- [ ] 所有新命令已实际运行验证
|
||||
- [ ] README.md 已更新
|
||||
- [ ] SKILL.md 已更新
|
||||
- [ ] 已 commit + push
|
||||
|
||||
## 实战案例参考
|
||||
|
||||
### rdt-cli → opencli Reddit(2026-03-16)
|
||||
|
||||
- **源项目**: `rdt-cli`(25 个 Python 命令)
|
||||
- **筛选结果**: 13 个高价值命令
|
||||
- **实现**: 7 个 YAML(read) + 6 个 TS(write)
|
||||
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15(+275%)
|
||||
|
||||
### twitter-cli → opencli Twitter(2026-03-16)
|
||||
|
||||
- **源项目**: `twitter-cli`(20+ Python 命令)
|
||||
- **筛选结果**: 11 个待实现
|
||||
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetch,Write 用 `Strategy.UI`
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: Migrate commands from an external CLI project into opencli adapters
|
||||
---
|
||||
|
||||
// turbo-all
|
||||
|
||||
## Steps
|
||||
|
||||
1. Clone the source CLI project for analysis:
|
||||
```bash
|
||||
git clone <source_repo_url> /tmp/<source-cli>
|
||||
```
|
||||
|
||||
2. Analyze source project: list all commands, auth method, API endpoints, and output fields.
|
||||
|
||||
3. Check existing opencli adapters for the target site:
|
||||
```bash
|
||||
ls src/clis/<site>/
|
||||
opencli list | grep <site>
|
||||
```
|
||||
|
||||
4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate.
|
||||
|
||||
5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis/<site>/<name>.yaml`.
|
||||
|
||||
6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis/<site>/<name>.ts`.
|
||||
|
||||
7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis/<site>/<name>.ts`.
|
||||
|
||||
8. Verify build:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
9. Verify all commands are registered:
|
||||
```bash
|
||||
opencli list | grep <site>
|
||||
```
|
||||
|
||||
10. Run each new command to verify it works:
|
||||
```bash
|
||||
opencli <site> <command> --limit 3 -f json
|
||||
```
|
||||
|
||||
11. Update README.md with new command examples in the appropriate platform section.
|
||||
|
||||
12. Update SKILL.md Commands Reference with new commands.
|
||||
|
||||
13. Commit and push:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(<site>): migrate <N> commands from <source-cli>"
|
||||
git push
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Setup Chrome + xvfb
|
||||
description: Install real Chrome and xvfb virtual display for headed browser testing
|
||||
|
||||
outputs:
|
||||
chrome-path:
|
||||
description: Path to the installed Chrome binary
|
||||
value: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install real Chrome (stable)
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
id: setup-chrome
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Verify Chrome installation
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
|
||||
${{ steps.setup-chrome.outputs.chrome-path }} --version
|
||||
|
||||
- name: Install xvfb for headed mode
|
||||
shell: bash
|
||||
run: sudo apt-get install -y xvfb
|
||||
@@ -2,12 +2,16 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1' # Weekly Monday 08:00 UTC — smoke tests
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
# ── Fast gate: typecheck + build ──
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -15,6 +19,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -24,3 +29,54 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (shard ${{ matrix.shard }}/2)
|
||||
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
# ── Smoke tests (scheduled / manual only) ──
|
||||
smoke-test:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/smoke/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: E2E Headed Chrome
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-headed:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome + xvfb
|
||||
uses: ./.github/actions/setup-chrome
|
||||
id: setup-chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests (headed Chrome + xvfb)
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
|
||||
npx vitest run tests/e2e/ --reporter=verbose
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish Any Commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish
|
||||
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.mcp.json
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Connecting OpenCLI via CDP (Remote/Headless Servers)
|
||||
|
||||
If you cannot use the Playwright MCP Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**.
|
||||
|
||||
Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
|
||||
|
||||
This guide is broken down into three phases:
|
||||
1. **Preparation**: Start Chrome with CDP enabled locally.
|
||||
2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**.
|
||||
3. **Execution**: Run OpenCLI on your server.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Preparation (Local Machine)
|
||||
|
||||
First, you need to start a Chrome browser on your local machine with remote debugging enabled.
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--remote-debugging-port=9222 ^
|
||||
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
> **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok).
|
||||
|
||||
Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Remote Access Methods
|
||||
|
||||
Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions.
|
||||
|
||||
### Method A: SSH Tunnel (Recommended)
|
||||
|
||||
If your local machine has SSH access to the remote server, this is the most secure and straightforward method.
|
||||
|
||||
Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`:
|
||||
|
||||
```bash
|
||||
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
|
||||
```
|
||||
|
||||
Leave this SSH session running in the background.
|
||||
|
||||
### Method B: Reverse Proxy (ngrok / frp / socat)
|
||||
|
||||
If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`.
|
||||
|
||||
Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok:
|
||||
|
||||
```bash
|
||||
ngrok http 9222
|
||||
```
|
||||
|
||||
This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Execution (Remote Server)
|
||||
|
||||
Now switch to your **Remote Server** where OpenCLI is installed.
|
||||
|
||||
Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands.
|
||||
|
||||
### If you used Method A (SSH Tunnel):
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
### If you used Method B (Reverse Proxy like ngrok):
|
||||
|
||||
```bash
|
||||
# Use the URL you copied from ngrok earlier
|
||||
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
> *Tip: OpenCLI automatically requests the `/json/version` HTTP endpoint to discover the underlying WebSocket URL if you provide a standard HTTP/HTTPS address.*
|
||||
|
||||
If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# 通过 CDP 远程连接 OpenCLI (服务器/无头环境)
|
||||
|
||||
如果你无法使用 Playwright MCP Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
|
||||
|
||||
出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。
|
||||
|
||||
本指南将整个过程拆分为三个阶段:
|
||||
1. **阶段一:准备工作**(在本地启动允许 CDP 调试的 Chrome)。
|
||||
2. **阶段二:建立网络隧道**(通过 **SSH反向隧道** 或 **反向代理工具**,将本地的 CDP 端口暴露给服务器)。
|
||||
3. **阶段三:执行命令**(在服务器端运行 OpenCLI)。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:准备工作 (本地电脑)
|
||||
|
||||
首先,你需要在你的本地电脑上,通过命令行参数启动一个开启了远程调试端口的 Chrome 实例。
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--remote-debugging-port=9222 ^
|
||||
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
> **注意**:此处增加的 `--remote-allow-origins="*"` 参数对于较新版本的 Chrome 来说通常是[必需的],以允许来自反向代理(如 ngrok)的跨域 WebSocket 连接请求。
|
||||
|
||||
待这个新的浏览器实例打开后,**手工登录那些你打算使用的网站**(如 bilibili.com、zhihu.com 等),这可以让该浏览器的运行资料(Profile)保留上这些网站登录用的 Cookie。
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:建立网络隧道
|
||||
|
||||
现在你的本地已经有了一个监听在 `9222` 端口的 CDP 服务,接下来,选择以下任意一种方式将其实际暴露给你的远端服务器。
|
||||
|
||||
### 方法 A:SSH 反向端口转发 (推荐)
|
||||
|
||||
如果你的本地电脑可以直连远程服务器的 SSH,那么这是最简单且最安全的做法。
|
||||
|
||||
在你的 **本地电脑** 终端上直接运行这条 ssh 命令,将远程服务器的 `9222` 端口反向映射回本地的 `9222` 端口:
|
||||
|
||||
```bash
|
||||
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
|
||||
```
|
||||
|
||||
保持此 SSH 会话在后台运行即可。
|
||||
|
||||
### 方法 B:反向代理 / 内网穿透 (ngrok / frp / socat)
|
||||
|
||||
如果因为 NAT 或防火墙等因素导致无法直连 SSH 服务器,你可以使用 `ngrok` 等内网穿透工具。
|
||||
|
||||
在 **本地电脑** 运行 ngrok 将本地的 `9222` 端口暴露到公网:
|
||||
|
||||
```bash
|
||||
ngrok http 9222
|
||||
```
|
||||
|
||||
此时终端里会打印出一段专属的转发 URL 地址(如:`https://abcdef.ngrok.app`)。**复制这一段 URL 地址备用**。
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:执行命令 (远程服务器)
|
||||
|
||||
现在,所有的准备工作已结束。请切换到你已安装好 OpenCLI 的 **远程服务器** 终端上。
|
||||
|
||||
根据你在上方阶段二所选择的隧道方案,在终端中配置对应的 `OPENCLI_CDP_ENDPOINT` 环境变量:
|
||||
|
||||
### 若使用 方法 A (SSH 反向隧道):
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
|
||||
opencli doctor # 查看并验证连接是否通畅
|
||||
opencli bilibili hot --limit 5 # 执行目标命令
|
||||
```
|
||||
|
||||
### 若使用 方法 B (Ngrok 等反向代理):
|
||||
|
||||
```bash
|
||||
# 将刚刚使用 ngrok 得到的地址填入这里
|
||||
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
|
||||
opencli doctor # 查看并验证连接是否通畅
|
||||
opencli bilibili hot --limit 5 # 执行目标命令
|
||||
```
|
||||
|
||||
> *Tip: 如果你填写的是一个普通 HTTP/HTTPS 的 URL 地址,OpenCLI 会自动尝试抓取该地址下的 `/json/version` 节点,来动态解析并连接真正底层依赖的 WebSocket 地址。*
|
||||
|
||||
如果你想在此服务器上永久启用该配置,可以将对应的 `export` 语句追加进入你的 `~/.bashrc` 或 `~/.zshrc` 配置文件中。
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: How to CLI-ify and automate any Electron Desktop Application via CDP
|
||||
---
|
||||
|
||||
# CLI-ifying Electron Applications (Skill Guide)
|
||||
|
||||
Based on the successful extraction and automation of **Antigravity** and **OpenAI Codex** desktop apps, this guide serves as the standard operating procedure (SOP) for adapting ANY Electron-based application into an OpenCLI adapter.
|
||||
|
||||
## 核心原理 (Core Concept)
|
||||
Electron 应用本质上是运行在本地的 Chromium 浏览器实例。只要在启动应用时暴露了调试端口(CDP,Chrome DevTools Protocol),我们就可以利用 Playwright MCP 直接穿透其 UI 层,获取并操控包括 React/Vue 组件、Shadow DOM 等在内的所有底层状态,实现“从应用外挂入自动化脚本”。
|
||||
|
||||
### 启动 Target App
|
||||
要在本地操作任何 Electron 应用,必须先要求用户使用以下参数注入调试端点:
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## 标准适配模式:The 5-Command Pattern
|
||||
|
||||
适配一个新的 App,必须在 `src/clis/<app_name>/` 下实现这 5 个标准化指令:
|
||||
|
||||
### 1. `status.ts` (连接测试)
|
||||
负责确认应用监听正确。
|
||||
- **机制**: 直接 `export const statusCommand = cli({...})`
|
||||
- **核心代码**: 获取 `window.location.href` 与 `document.title`。
|
||||
- **注意**: 必须指明 `domain: 'localhost'` 和 `browser: true`。
|
||||
|
||||
### 2. `dump.ts` (逆向工程核心)
|
||||
很多现代 App DOM 极其庞大且混淆。**千万不要直接猜选择器**。
|
||||
首先编写 dump 脚本,将当前页面的 DOM 与 Accessibility Tree 导出到 `/tmp/`,方便用 AI (或者 `grep`) 提取精确的容器名称和 Class。
|
||||
```typescript
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/app-dom.html', dom);
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/app-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
```
|
||||
|
||||
### 3. `send.ts` (高级注入技巧)
|
||||
Electron 应用常常使用极端复杂的富文本编辑器(如 Monaco, Lexical, ProseMirror)。直接修改元素的 `value` 常常会被 React 状态机忽略。
|
||||
- **最佳实践**: 使用 `document.execCommand('insertText')` 完美模拟真实的人类复制粘贴输入流,完全穿透 React state。
|
||||
```javascript
|
||||
// 寻路机制:优先尝试寻找 contenteditable
|
||||
let composer = document.querySelector('[contenteditable="true"]');
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, "你好");
|
||||
```
|
||||
- **提交快捷键**: `await page.pressKey('Enter')`。
|
||||
|
||||
### 4. `read.ts` (上下文解析)
|
||||
不要提取整个页面的文本。应该利用 `dump.ts` 抓取出来的特征寻找真正的“对话容器”。
|
||||
- **技巧**: 检查带有语义化结构的数据,例如 `[role="log"]`、`[data-testid="conversation"]` 或是 `[data-content-search-turn-key]`。
|
||||
- **格式化**: 拼接抓取出的文本转粗暴渲染成 Markdown 返回,这样不仅你和人类能读懂,LLM 后续作为 Agent 也能精准切分。
|
||||
|
||||
### 5. `new.ts` / Action Macros (底层事件模拟)
|
||||
许多图形界面操作难以找到按钮实例,但它们通常响应原生快捷键。
|
||||
- **最佳实践**: 模拟系统级快捷键直接驱动 `(Meta+N / Control+N)`。
|
||||
```typescript
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1); // 等待重渲染
|
||||
```
|
||||
|
||||
## 全局环境变量
|
||||
为了让 Core Framework 挂载到我们指定的端口,必须在执行指令前(或在 README 中指导用户)注入目标环境端口:
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## 踩坑避雷 (Pitfalls & Gotchas)
|
||||
1. **端口占用 (EADDRINUSE)**: 确保同一时间只能有一个 App 占据一个端口。如果同时测试 Antigravity (9224) 且你要测试别的 App (9222),要将 CDP Endpoint 分配开来。
|
||||
2. **TypeScript 抽象**: OpenCLI 内部封装了 `IPage` 类型(`src/types.ts`),不是原生的 Playwright Page。要用 `page.pressKey()` 和 `page.evaluate()`,而非 `page.keyboard.press()`。
|
||||
3. **延时等待**: DOM 发生剧烈变化后,一定要加上 `await page.wait(0.5)` 到 `1.0` 给框架反应的时间。不要立刻 return 导致连接 prematurely 阻断。
|
||||
@@ -1,28 +1,190 @@
|
||||
BSD 3-Clause License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2025, jackwener
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
1. Definitions.
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 jackwener
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery
|
||||
> **Make any website or Electron App your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · 80+ commands · 19 sites
|
||||
|
||||
[中文文档](./README.zh-CN.md)
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website** into a command-line interface. **59 commands** across **18 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube, coupang — powered by browser session reuse and AI-native discovery.
|
||||
A CLI tool that turns **any website** or **Electron app** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
|
||||
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +24,8 @@ A CLI tool that turns **any website** into a command-line interface. **59 comman
|
||||
- [Built-in Commands](#built-in-commands)
|
||||
- [Output Formats](#output-formats)
|
||||
- [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
|
||||
- [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Releasing New Versions](#releasing-new-versions)
|
||||
- [License](#license)
|
||||
@@ -29,10 +34,12 @@ A CLI tool that turns **any website** into a command-line interface. **59 comman
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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.
|
||||
- **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
|
||||
- **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.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -42,15 +49,38 @@ A CLI tool that turns **any website** into a command-line interface. **59 comman
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
OpenCLI connects to your browser through the Playwright MCP Bridge extension.
|
||||
It prefers an existing local/global `@playwright/mcp` install and falls back to `npx -y @playwright/mcp@latest` automatically when no local MCP server is found.
|
||||
|
||||
### Playwright MCP Bridge Extension Setup
|
||||
|
||||
1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
|
||||
2. Obtain your token by clicking the extension icon in the browser toolbar or from the extension settings page.
|
||||
2. Run `opencli setup` — discovers the token, distributes it to your tools, and verifies connectivity:
|
||||
|
||||
**You must configure this token in BOTH your MCP configuration AND system environment variables.**
|
||||
```bash
|
||||
opencli setup
|
||||
```
|
||||
|
||||
First, add it to your MCP client config (e.g. Claude/Cursor):
|
||||
The interactive TUI will:
|
||||
- 🔍 Auto-discover `PLAYWRIGHT_MCP_EXTENSION_TOKEN` from Chrome (no manual copy needed)
|
||||
- ☑️ Show all detected tools (Codex, Cursor, Claude Code, Gemini CLI, etc.)
|
||||
- ✏️ Update only the files you select (Space to toggle, Enter to confirm)
|
||||
- 🔌 Auto-verify browser connectivity after writing configs
|
||||
|
||||
> **Tip**: Use `opencli doctor` for ongoing diagnosis and maintenance:
|
||||
> ```bash
|
||||
> opencli doctor # Read-only token & config diagnosis
|
||||
> opencli doctor --live # Also test live browser connectivity
|
||||
> opencli doctor --fix # Fix mismatched configs (interactive)
|
||||
> opencli doctor --fix -y # Fix all configs non-interactively
|
||||
> ```
|
||||
|
||||
**Alternative: CDP Mode (For Servers/Headless)**
|
||||
If you cannot install the browser extension (e.g. running OpenCLI on a remote headless server), you can connect OpenCLI to your local Chrome via CDP using SSH tunnels or reverse proxies. See the [CDP Connection Guide](./CDP.md) for detailed instructions.
|
||||
|
||||
<details>
|
||||
<summary>Manual setup (alternative)</summary>
|
||||
|
||||
Add token to your MCP client config (e.g. Claude/Cursor):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -66,17 +96,13 @@ First, add it to your MCP client config (e.g. Claude/Cursor):
|
||||
}
|
||||
```
|
||||
|
||||
And, so that `opencli` commands can use it directly in the terminal, export it in your shell environment (e.g. `~/.zshrc`):
|
||||
Export in shell (e.g. `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
|
||||
```
|
||||
|
||||
After configuring, run `opencli doctor` to verify your token is correctly set up across all locations:
|
||||
|
||||
```bash
|
||||
opencli doctor
|
||||
```
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -84,6 +110,7 @@ opencli doctor
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
opencli setup # One-time: configure Playwright MCP token
|
||||
```
|
||||
|
||||
Then use directly:
|
||||
@@ -116,26 +143,39 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **bilibili** | `hot` `search` `me` `favorite` ... (11 commands) | 🔐 Browser |
|
||||
| **zhihu** | `hot` `search` `question` | 🔐 Browser |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 Browser |
|
||||
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
|
||||
| **weibo** | `hot` | 🔐 Browser |
|
||||
| **boss** | `search` | 🔐 Browser |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **youtube** | `search` | 🔐 Browser |
|
||||
| **yahoo-finance** | `quote` | 🔐 Browser |
|
||||
| **reuters** | `search` | 🔐 Browser |
|
||||
| **smzdm** | `search` | 🔐 Browser |
|
||||
| **ctrip** | `search` | 🔐 Browser |
|
||||
| **github** | `search` | 🌐 Public |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 Public / 🔐 Browser |
|
||||
| **hackernews** | `top` | 🌐 Public |
|
||||
| **bbc** | `news` | 🌐 Public |
|
||||
**19 sites · 80+ commands** — run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Count | Mode |
|
||||
|------|----------|:-----:|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ Desktop |
|
||||
| **bbc** | `news` | 1 | 🌐 Public |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
|
||||
| **boss** | `search` `detail` | 2 | 🔐 Browser |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ Desktop |
|
||||
| **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
|
||||
| **ctrip** | `search` | 1 | 🔐 Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ Desktop |
|
||||
| **github** | `search` | 1 | 🌐 Public |
|
||||
| **hackernews** | `top` | 1 | 🌐 Public |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 Public |
|
||||
| **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser |
|
||||
| **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser |
|
||||
| **boss** | `search` `detail` | 2 | 🔐 Browser |
|
||||
| **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
|
||||
| **bbc** | `news` | 1 | 🌐 Public |
|
||||
| **ctrip** | `search` | 1 | 🔐 Browser |
|
||||
| **github** | `search` | 1 | 🌐 Public |
|
||||
| **hackernews** | `top` | 1 | 🌐 Public |
|
||||
| **linkedin** | `search` | 1 | 🔐 Browser |
|
||||
| **reuters** | `search` | 1 | 🔐 Browser |
|
||||
| **smzdm** | `search` | 1 | 🔐 Browser |
|
||||
| **weibo** | `hot` | 1 | 🔐 Browser |
|
||||
| **yahoo-finance** | `quote` | 1 | 🔐 Browser |
|
||||
|
||||
## Output Formats
|
||||
|
||||
@@ -176,6 +216,24 @@ opencli cascade https://api.example.com/data
|
||||
|
||||
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
|
||||
|
||||
## Testing
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
||||
|
||||
- Current test coverage (unit + E2E tests across 19 sites)
|
||||
- How to run tests locally
|
||||
- How to add tests when creating new adapters
|
||||
- CI/CD pipeline with sharding
|
||||
- Headless browser mode (`OPENCLI_HEADLESS=1`)
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
npm run build
|
||||
npx vitest run # All tests
|
||||
npx vitest run src/ # Unit tests only
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Failed to connect to Playwright MCP Bridge"**
|
||||
@@ -185,6 +243,8 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page to prove you are human.
|
||||
- **Node API errors**
|
||||
- Make sure you are using Node.js >= 18. Some dependencies require modern Node APIs.
|
||||
- **Token issues**
|
||||
- Run `opencli doctor` to diagnose token configuration across all tools.
|
||||
|
||||
## Releasing New Versions
|
||||
|
||||
@@ -198,4 +258,4 @@ The CI will automatically build, create a GitHub release, and publish to npm.
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](./LICENSE)
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
+72
-34
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站变成你的命令行工具。**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口
|
||||
> **把任何网站或 Electron 应用变成你的命令行工具。**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 80+ 命令 · 19 站点
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang — 复用浏览器登录态,AI 驱动探索。
|
||||
OpenCLI 将任何网站或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等 [19 个站点](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
|
||||
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +26,7 @@ OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个
|
||||
- [内置命令](#内置命令)
|
||||
- [输出格式](#输出格式)
|
||||
- [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
|
||||
- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
|
||||
- [常见问题排查](#常见问题排查)
|
||||
- [版本发布](#版本发布)
|
||||
- [License](#license)
|
||||
@@ -29,8 +35,10 @@ OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个
|
||||
|
||||
## 亮点
|
||||
|
||||
- **59 个命令,18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等 19 个站点,80+ 命令
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **自修复配置** — `opencli setup` 自动发现 Token;`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
@@ -42,15 +50,38 @@ OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
|
||||
它会优先复用本地或全局已安装的 `@playwright/mcp`,如果没有嗅探到可用 MCP server,则会自动回退到 `npx -y @playwright/mcp@latest` 启动。
|
||||
|
||||
### Playwright MCP Bridge 扩展配置
|
||||
|
||||
1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
|
||||
2. 在浏览器插件栏点击该插件,或者在插件设置页获取你的 Extension Token。
|
||||
2. 运行 `opencli setup` — 自动发现 Token、分发到各工具、验证连通性:
|
||||
|
||||
**你必须将这个 Token 同时配置到你的 MCP 配置文件 AND 环境变量中。**
|
||||
```bash
|
||||
opencli setup
|
||||
```
|
||||
|
||||
首先,配置你的 MCP 客户端(如 Claude/Cursor 等):
|
||||
交互式 TUI 会:
|
||||
- 🔍 从 Chrome 自动发现 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(无需手动复制)
|
||||
- ☑️ 显示所有支持的工具(Codex、Cursor、Claude Code、Gemini CLI 等)
|
||||
- ✏️ 只更新你选中的文件(空格切换,回车确认)
|
||||
- 🔌 完成后自动验证浏览器连通性
|
||||
|
||||
> **Tip**:后续诊断和维护用 `opencli doctor`:
|
||||
> ```bash
|
||||
> opencli doctor # 只读 Token 与配置诊断
|
||||
> opencli doctor --live # 额外测试浏览器连通性
|
||||
> opencli doctor --fix # 修复不一致的配置(交互确认)
|
||||
> opencli doctor --fix -y # 无交互直接修复所有配置
|
||||
> ```
|
||||
|
||||
**备选方案:CDP 模式 (适用于服务器/无头环境)**
|
||||
如果你无法安装浏览器扩展(比如在远程无头服务器上运行 OpenCLI),你可以通过 SSH 隧道或反向代理,利用 CDP (Chrome DevTools Protocol) 连接到本地的 Chrome 浏览器。详细指南请参考 [CDP 连接教程](./CDP.zh-CN.md)。
|
||||
|
||||
<details>
|
||||
<summary>手动配置(备选方案)</summary>
|
||||
|
||||
配置你的 MCP 客户端(如 Claude/Cursor 等):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -66,17 +97,13 @@ OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
|
||||
}
|
||||
```
|
||||
|
||||
并且,为了让 `opencli` 命令行也能直接使用它,你必须在你的终端系统环境变量中导出它(建议写进 `~/.zshrc` 或 `~/.bashrc`):
|
||||
在终端环境变量中导出(建议写进 `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
|
||||
```
|
||||
|
||||
配置完成后,运行 `opencli doctor` 检测你的 Token 是否在所有位置都正确配置:
|
||||
|
||||
```bash
|
||||
opencli doctor
|
||||
```
|
||||
</details>
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -84,6 +111,7 @@ opencli doctor
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
opencli setup # 首次使用:配置 Playwright MCP token
|
||||
```
|
||||
|
||||
直接使用:
|
||||
@@ -116,26 +144,33 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## 内置命令
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **bilibili** | `hot` `search` `me` `favorite` ...(共11个) | 🔐 浏览器 |
|
||||
| **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 |
|
||||
| **weibo** | `hot` | 🔐 浏览器 |
|
||||
| **boss** | `search` | 🔐 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
|
||||
| **youtube** | `search` | 🔐 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 🔐 浏览器 |
|
||||
| **reuters** | `search` | 🔐 浏览器 |
|
||||
| **smzdm** | `search` | 🔐 浏览器 |
|
||||
| **ctrip** | `search` | 🔐 浏览器 |
|
||||
| **github** | `search` | 🌐 公共 API |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 公共 API / 🔐 浏览器 |
|
||||
| **hackernews** | `top` | 🌐 公共 API |
|
||||
| **bbc** | `news` | 🌐 公共 API |
|
||||
**19 个站点 · 80+ 命令** — 运行 `opencli list` 查看完整注册表。
|
||||
|
||||
| 站点 | 命令 | 数量 | 模式 |
|
||||
|------|------|:----:|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ 桌面端 |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ 桌面端 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 浏览器 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 公开 |
|
||||
| **youtube** | `search` `video` `transcript` | 3 | 🔐 浏览器 |
|
||||
| **zhihu** | `hot` `search` `question` | 3 | 🔐 浏览器 |
|
||||
| **boss** | `search` `detail` | 2 | 🔐 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 2 | 🔐 浏览器 |
|
||||
| **bbc** | `news` | 1 | 🌐 公共 API |
|
||||
| **ctrip** | `search` | 1 | 🔐 浏览器 |
|
||||
| **github** | `search` | 1 | 🌐 公共 API |
|
||||
| **hackernews** | `top` | 1 | 🌐 公共 API |
|
||||
| **linkedin** | `search` | 1 | 🔐 浏览器 |
|
||||
| **reuters** | `search` | 1 | 🔐 浏览器 |
|
||||
| **smzdm** | `search` | 1 | 🔐 浏览器 |
|
||||
| **weibo** | `hot` | 1 | 🔐 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 1 | 🔐 浏览器 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -185,6 +220,9 @@ opencli cascade https://api.example.com/data
|
||||
- Chrome 里的登录态可能已经过期(甚至被要求过滑动验证码)。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
- 确保 Node.js 版本 `>= 18`。旧版不支持我们使用的现代核心库 API。
|
||||
- **Token 问题**
|
||||
- 运行 `opencli doctor` 诊断所有工具的 Token 配置状态。
|
||||
- 使用 `opencli doctor --live` 测试浏览器连通性。
|
||||
|
||||
## 版本发布
|
||||
|
||||
@@ -198,4 +236,4 @@ git push --follow-tags
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](./LICENSE)
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 0.5.1
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
|
||||
version: 0.7.3
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
|
||||
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
|
||||
---
|
||||
|
||||
# OpenCLI
|
||||
|
||||
> Make any website your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
||||
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
||||
|
||||
> [!CAUTION]
|
||||
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)!**
|
||||
@@ -34,7 +34,8 @@ npm update -g @jackwener/opencli
|
||||
|
||||
Browser commands require:
|
||||
1. Chrome browser running **(logged into target sites)**
|
||||
2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension installed and configured
|
||||
2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension installed
|
||||
3. Run `opencli setup` to auto-discover token and configure all tools
|
||||
|
||||
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
|
||||
|
||||
@@ -67,7 +68,7 @@ opencli zhihu question --id 34816524 # 问题详情和回答
|
||||
opencli xiaohongshu search --keyword "美食" # 搜索笔记
|
||||
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
|
||||
opencli xiaohongshu feed --limit 10 # 推荐 Feed
|
||||
opencli xiaohongshu me # 我的信息
|
||||
opencli xiaohongshu me # 我的信息
|
||||
opencli xiaohongshu user --uid xxx # 用户主页
|
||||
|
||||
# 雪球 Xueqiu (browser)
|
||||
@@ -85,15 +86,32 @@ opencli github search --keyword "cli" # 搜索仓库
|
||||
opencli twitter trending --limit 10 # 热门话题
|
||||
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
|
||||
opencli twitter search --keyword "AI" # 搜索推文
|
||||
opencli twitter profile --username elonmusk # 用户资料
|
||||
opencli twitter profile elonmusk # 用户资料
|
||||
opencli twitter timeline --limit 20 # 时间线
|
||||
opencli twitter thread 1234567890 # 推文 thread(原文 + 回复)
|
||||
opencli twitter article 1891511252174299446 # 推文长文内容
|
||||
opencli twitter follow elonmusk # 关注用户
|
||||
opencli twitter unfollow elonmusk # 取消关注
|
||||
opencli twitter bookmark https://x.com/... # 收藏推文
|
||||
opencli twitter unbookmark https://x.com/... # 取消收藏
|
||||
|
||||
# Reddit (browser)
|
||||
opencli reddit hot --limit 10 # 热门帖子
|
||||
opencli reddit hot --subreddit programming # 指定子版块
|
||||
opencli reddit frontpage --limit 10 # 首页
|
||||
opencli reddit search --keyword "AI" # 搜索
|
||||
opencli reddit subreddit --name rust # 子版块浏览
|
||||
opencli reddit frontpage --limit 10 # 首页 /r/all
|
||||
opencli reddit popular --limit 10 # /r/popular 热门
|
||||
opencli reddit search --query "AI" --sort top --time week # 搜索(支持排序+时间过滤)
|
||||
opencli reddit subreddit --name rust --sort top --time month # 子版块浏览(支持时间过滤)
|
||||
opencli reddit read --post_id 1abc123 # 阅读帖子 + 评论
|
||||
opencli reddit user --username spez # 用户资料(karma、注册时间)
|
||||
opencli reddit user-posts --username spez # 用户发帖历史
|
||||
opencli reddit user-comments --username spez # 用户评论历史
|
||||
opencli reddit upvote --post_id xxx --direction up # 投票(up/down/none)
|
||||
opencli reddit save --post_id xxx # 收藏帖子
|
||||
opencli reddit comment --post_id xxx --text "Great!" # 发表评论
|
||||
opencli reddit subscribe --subreddit python # 订阅子版块
|
||||
opencli reddit saved --limit 10 # 我的收藏
|
||||
opencli reddit upvoted --limit 10 # 我的赞
|
||||
|
||||
# V2EX (public + browser)
|
||||
opencli v2ex hot --limit 10 # 热门话题
|
||||
@@ -114,9 +132,13 @@ opencli weibo hot --limit 10 # 微博热搜
|
||||
|
||||
# BOSS直聘 (browser)
|
||||
opencli boss search --query "AI agent" # 搜索职位
|
||||
opencli boss detail --securityId xxx # 职位详情
|
||||
|
||||
# YouTube (browser)
|
||||
opencli youtube search --query "rust" # 搜索视频
|
||||
opencli youtube video --url "https://www.youtube.com/watch?v=xxx" # 视频元数据(标题、播放量、描述等)
|
||||
opencli youtube transcript --url "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录
|
||||
opencli youtube transcript --url "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式
|
||||
|
||||
# Yahoo Finance (browser)
|
||||
opencli yahoo-finance quote --symbol AAPL # 股票行情
|
||||
@@ -129,6 +151,15 @@ opencli smzdm search --keyword "耳机" # 搜索好价
|
||||
|
||||
# 携程 (browser)
|
||||
opencli ctrip search --query "三亚" # 搜索目的地
|
||||
|
||||
# Antigravity (Electron/CDP)
|
||||
opencli antigravity status # 检查 CDP 连接
|
||||
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
|
||||
opencli antigravity read # 读取整个聊天记录面板
|
||||
opencli antigravity new # 清空聊天、开启新对话
|
||||
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
||||
opencli antigravity model claude # 切换底层模型
|
||||
opencli antigravity watch # 流式监听增量消息
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
@@ -139,6 +170,11 @@ opencli list --json # JSON output
|
||||
opencli list -f yaml # YAML output
|
||||
opencli validate # Validate all CLI definitions
|
||||
opencli validate bilibili # Validate specific site
|
||||
opencli setup # Interactive token setup (auto-discover + TUI checkbox)
|
||||
opencli doctor # Diagnose token & extension config across all tools
|
||||
opencli doctor --live # Also test live browser connectivity
|
||||
opencli doctor --fix # Fix mismatched configs (interactive confirmation)
|
||||
opencli doctor --fix -y # Fix all configs non-interactively
|
||||
```
|
||||
|
||||
### AI Agent Workflow
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# Testing Guide
|
||||
|
||||
> 面向开发者和 AI Agent 的测试参考手册。
|
||||
|
||||
## 目录
|
||||
|
||||
- [测试架构](#测试架构)
|
||||
- [当前覆盖范围](#当前覆盖范围)
|
||||
- [本地运行测试](#本地运行测试)
|
||||
- [如何添加新测试](#如何添加新测试)
|
||||
- [CI/CD 流水线](#cicd-流水线)
|
||||
- [浏览器模式](#浏览器模式)
|
||||
- [站点兼容性](#站点兼容性)
|
||||
|
||||
---
|
||||
|
||||
## 测试架构
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试)
|
||||
│ ├── management.test.ts # 管理命令(list, validate, verify, help)
|
||||
│ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
|
||||
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
|
||||
│ └── api-health.test.ts # 外部 API 可用性检测
|
||||
src/
|
||||
├── *.test.ts # 单元测试(已有 8 个)
|
||||
```
|
||||
|
||||
| 层 | 位置 | 运行方式 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
|
||||
|
||||
---
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(8 个文件)
|
||||
|
||||
| 文件 | 覆盖内容 |
|
||||
|---|---|
|
||||
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
|
||||
| `engine.test.ts` | 命令发现与执行 |
|
||||
| `registry.test.ts` | 命令注册与策略分配 |
|
||||
| `output.test.ts` | 输出格式渲染 |
|
||||
| `doctor.test.ts` | Token 诊断 |
|
||||
| `coupang.test.ts` | 数据归一化 |
|
||||
| `pipeline/template.test.ts` | 模板表达式求值 |
|
||||
| `pipeline/transform.test.ts` | 数据变换步骤 |
|
||||
|
||||
### E2E 测试(~52 个用例)
|
||||
|
||||
| 文件 | 覆盖站点/功能 | 测试数 |
|
||||
|---|---|---|
|
||||
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
|
||||
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
|
||||
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
|
||||
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
|
||||
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
|
||||
|
||||
### 烟雾测试
|
||||
|
||||
公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
|
||||
|
||||
---
|
||||
|
||||
## 本地运行测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试(单元 + E2E)
|
||||
npx vitest run
|
||||
|
||||
# 烟雾测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- 无 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 时,opencli 自动启动一个独立浏览器实例
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态 + 设置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`,手动跑对应测试
|
||||
|
||||
---
|
||||
|
||||
## 如何添加新测试
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
|
||||
1. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
|
||||
2. 根据 adapter 类型,在对应文件加一个 `it()` block:
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const { stdout, code } = await runCli(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 但可公开访问 → tests/e2e/browser-public.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const data = await tryBrowserCommand(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'producthunt trending');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 且需登录 → tests/e2e/browser-auth.test.ts
|
||||
it('producthunt me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['producthunt', 'me', '-f', 'json'], 'producthunt me');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试。
|
||||
|
||||
### 新增内部模块
|
||||
|
||||
在 `src/` 下对应位置创建 `*.test.ts`。
|
||||
|
||||
### 决策流程图
|
||||
|
||||
```
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
↓ true
|
||||
公开数据? → tests/e2e/browser-public.test.ts
|
||||
↓ 需登录
|
||||
tests/e2e/browser-auth.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 流水线
|
||||
|
||||
### ci.yml(主流水线)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **build** | push/PR to main,dev | typecheck + build |
|
||||
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
|
||||
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
|
||||
|
||||
### e2e-headed.yml(E2E 测试)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
|
||||
|
||||
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
|
||||
|
||||
### Sharding
|
||||
|
||||
单元测试使用 vitest 内置 shard:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 浏览器模式
|
||||
|
||||
opencli 根据 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 环境变量自动选择模式:
|
||||
|
||||
| 条件 | 模式 | MCP 参数 | 使用场景 |
|
||||
|---|---|---|---|
|
||||
| Token 已设置 | Extension 模式 | `--extension` | 本地用户,连接已登录的 Chrome |
|
||||
| Token 未设置 | Standalone 模式 | (无特殊 flag) | CI 或无扩展环境,自启浏览器 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
|
||||
|
||||
| 站点 | CI 状态 | 限制原因 |
|
||||
|---|---|---|
|
||||
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
|
||||
| yahoo-finance | ✅ 返回数据 | 无限制 |
|
||||
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
|
||||
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
|
||||
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
|
||||
|
||||
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
|
||||
Generated
+4
-7
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.5.2",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.5.2",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "0.9.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
@@ -894,7 +895,6 @@
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1563,7 +1563,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1806,7 +1805,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -1848,7 +1846,6 @@
|
||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.115.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.5.2",
|
||||
"version": "0.9.4",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -16,10 +16,11 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/main.ts",
|
||||
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
|
||||
"build-manifest": "node dist/build-manifest.js || true",
|
||||
"clean-yaml": "find dist/clis -name '*.yaml' -o -name '*.yml' 2>/dev/null | xargs rm -f",
|
||||
"copy-yaml": "find src/clis -name '*.yaml' -o -name '*.yml' | while read f; do d=\"dist/${f#src/}\"; mkdir -p \"$(dirname \"$d\")\"; cp \"$f\" \"$d\"; done",
|
||||
"build-manifest": "node dist/build-manifest.js",
|
||||
"clean-yaml": "node scripts/clean-yaml.cjs",
|
||||
"copy-yaml": "node scripts/copy-yaml.cjs",
|
||||
"start": "node dist/main.js",
|
||||
"postinstall": "node scripts/postinstall.js || true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
@@ -34,7 +35,7 @@
|
||||
"playwright"
|
||||
],
|
||||
"author": "jackwener",
|
||||
"license": "BSD-3-Clause",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jackwener/opencli.git"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Clean YAML files from dist/clis/ before copying fresh ones.
|
||||
*/
|
||||
const { readdirSync, rmSync, existsSync, statSync } = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function walk(dir) {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const f of readdirSync(dir)) {
|
||||
const fp = path.join(dir, f);
|
||||
if (statSync(fp).isDirectory()) {
|
||||
walk(fp);
|
||||
} else if (/\.ya?ml$/.test(f)) {
|
||||
rmSync(fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk('dist/clis');
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copy YAML files from src/clis/ to dist/clis/.
|
||||
*/
|
||||
const { readdirSync, copyFileSync, mkdirSync, existsSync, statSync } = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function walk(src, dst) {
|
||||
if (!existsSync(src)) return;
|
||||
for (const f of readdirSync(src)) {
|
||||
const sp = path.join(src, f);
|
||||
const dp = path.join(dst, f);
|
||||
if (statSync(sp).isDirectory()) {
|
||||
walk(sp, dp);
|
||||
} else if (/\.ya?ml$/.test(f)) {
|
||||
mkdirSync(path.dirname(dp), { recursive: true });
|
||||
copyFileSync(sp, dp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk('src/clis', 'dist/clis');
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* postinstall script — automatically install shell completion files.
|
||||
*
|
||||
* Detects the user's default shell and writes the completion script to the
|
||||
* standard system completion directory so that tab-completion works immediately
|
||||
* after `npm install -g`.
|
||||
*
|
||||
* Supported shells: bash, zsh, fish.
|
||||
*
|
||||
* This script is intentionally plain Node.js (no TypeScript, no imports from
|
||||
* the main source tree) so that it can run without a build step.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
|
||||
// ── Completion script content ──────────────────────────────────────────────
|
||||
|
||||
const BASH_COMPLETION = `# Bash completion for opencli (auto-installed)
|
||||
_opencli_completions() {
|
||||
local cur words cword
|
||||
_get_comp_words_by_ref -n : cur words cword
|
||||
|
||||
local completions
|
||||
completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
|
||||
|
||||
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
|
||||
__ltrim_colon_completions "$cur"
|
||||
}
|
||||
complete -F _opencli_completions opencli
|
||||
`;
|
||||
|
||||
const ZSH_COMPLETION = `#compdef opencli
|
||||
# Zsh completion for opencli (auto-installed)
|
||||
_opencli() {
|
||||
local -a completions
|
||||
local cword=$((CURRENT - 1))
|
||||
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
|
||||
compadd -a completions
|
||||
}
|
||||
_opencli
|
||||
`;
|
||||
|
||||
const FISH_COMPLETION = `# Fish completion for opencli (auto-installed)
|
||||
complete -c opencli -f -a '(
|
||||
set -l tokens (commandline -cop)
|
||||
set -l cursor (count (commandline -cop))
|
||||
opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
|
||||
)'
|
||||
`;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function detectShell() {
|
||||
const shell = process.env.SHELL || '';
|
||||
if (shell.includes('zsh')) return 'zsh';
|
||||
if (shell.includes('bash')) return 'bash';
|
||||
if (shell.includes('fish')) return 'fish';
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure fpath contains the custom completions directory in .zshrc.
|
||||
*
|
||||
* Key detail: the fpath line MUST appear BEFORE the first `compinit` call,
|
||||
* otherwise compinit won't scan our completions directory. This is critical
|
||||
* for oh-my-zsh users (source $ZSH/oh-my-zsh.sh calls compinit internally).
|
||||
*/
|
||||
function ensureZshFpath(completionsDir, zshrcPath) {
|
||||
const fpathLine = `fpath=(${completionsDir} $fpath)`;
|
||||
const autoloadLine = `autoload -Uz compinit && compinit`;
|
||||
const marker = '# opencli completion';
|
||||
|
||||
if (!existsSync(zshrcPath)) {
|
||||
writeFileSync(zshrcPath, `${marker}\n${fpathLine}\n${autoloadLine}\n`, 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = readFileSync(zshrcPath, 'utf8');
|
||||
|
||||
// Already configured — nothing to do
|
||||
if (content.includes(completionsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first line that triggers compinit (direct call or oh-my-zsh source)
|
||||
const lines = content.split('\n');
|
||||
let insertIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
// Skip comment-only lines
|
||||
if (trimmed.startsWith('#')) continue;
|
||||
if (/compinit/.test(trimmed) || /source\s+.*oh-my-zsh\.sh/.test(trimmed)) {
|
||||
insertIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertIdx !== -1) {
|
||||
// Insert fpath BEFORE the compinit / oh-my-zsh source line
|
||||
lines.splice(insertIdx, 0, marker, fpathLine);
|
||||
writeFileSync(zshrcPath, lines.join('\n'), 'utf8');
|
||||
} else {
|
||||
// No compinit found — append fpath + compinit at the end
|
||||
let addition = `\n${marker}\n${fpathLine}\n${autoloadLine}\n`;
|
||||
appendFileSync(zshrcPath, addition, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
// Skip in CI environments
|
||||
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only install completion for global installs and npm link
|
||||
const isGlobal = process.env.npm_config_global === 'true';
|
||||
if (!isGlobal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shell = detectShell();
|
||||
if (!shell) {
|
||||
// Cannot determine shell; silently skip
|
||||
return;
|
||||
}
|
||||
|
||||
const home = homedir();
|
||||
|
||||
try {
|
||||
switch (shell) {
|
||||
case 'zsh': {
|
||||
const completionsDir = join(home, '.zsh', 'completions');
|
||||
const completionFile = join(completionsDir, '_opencli');
|
||||
ensureDir(completionsDir);
|
||||
writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
|
||||
|
||||
// Ensure fpath is set up in .zshrc
|
||||
const zshrcPath = join(home, '.zshrc');
|
||||
ensureZshFpath(completionsDir, zshrcPath);
|
||||
|
||||
console.log(`✓ Zsh completion installed to ${completionFile}`);
|
||||
console.log(` Restart your shell or run: source ~/.zshrc`);
|
||||
break;
|
||||
}
|
||||
case 'bash': {
|
||||
// Try system-level first, fall back to user-level
|
||||
const userCompDir = join(home, '.bash_completion.d');
|
||||
const completionFile = join(userCompDir, 'opencli');
|
||||
ensureDir(userCompDir);
|
||||
writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
|
||||
|
||||
// Ensure .bashrc sources the completion directory
|
||||
const bashrcPath = join(home, '.bashrc');
|
||||
if (existsSync(bashrcPath)) {
|
||||
const content = readFileSync(bashrcPath, 'utf8');
|
||||
if (!content.includes('.bash_completion.d/opencli')) {
|
||||
appendFileSync(bashrcPath,
|
||||
`\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Bash completion installed to ${completionFile}`);
|
||||
console.log(` Restart your shell or run: source ~/.bashrc`);
|
||||
break;
|
||||
}
|
||||
case 'fish': {
|
||||
const completionsDir = join(home, '.config', 'fish', 'completions');
|
||||
const completionFile = join(completionsDir, 'opencli.fish');
|
||||
ensureDir(completionsDir);
|
||||
writeFileSync(completionFile, FISH_COMPLETION, 'utf8');
|
||||
|
||||
console.log(`✓ Fish completion installed to ${completionFile}`);
|
||||
console.log(` Restart your shell to activate.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Completion install is best-effort; never fail the package install
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`Warning: Could not install shell completion: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+3
-3
@@ -56,7 +56,7 @@ export async function wbiSign(
|
||||
const mixinKey = getMixinKey(imgKey, subKey);
|
||||
const wts = Math.floor(Date.now() / 1000);
|
||||
const sorted: Record<string, string> = {};
|
||||
const allParams = { ...params, wts: String(wts) };
|
||||
const allParams: Record<string, any> = { ...params, wts: String(wts) };
|
||||
for (const key of Object.keys(allParams).sort()) {
|
||||
sorted[key] = String(allParams[key]).replace(/[!'()*]/g, '');
|
||||
}
|
||||
@@ -84,10 +84,10 @@ export async function apiGet(
|
||||
}
|
||||
|
||||
export async function fetchJson(page: IPage, url: string): Promise<any> {
|
||||
const escapedUrl = url.replace(/"/g, '\\"');
|
||||
const urlJs = JSON.stringify(url);
|
||||
return page.evaluate(`
|
||||
async () => {
|
||||
const res = await fetch("${escapedUrl}", { credentials: "include" });
|
||||
const res = await fetch(${urlJs}, { credentials: "include" });
|
||||
return await res.json();
|
||||
}
|
||||
`);
|
||||
|
||||
+195
-18
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PlaywrightMCP, __test__ } from './browser.js';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
||||
|
||||
afterEach(() => {
|
||||
__test__.resetMcpServerPathCache();
|
||||
__test__.setMcpDiscoveryTestHooks();
|
||||
delete process.env.OPENCLI_MCP_SERVER_PATH;
|
||||
});
|
||||
|
||||
describe('browser helpers', () => {
|
||||
it('creates JSON-RPC requests with unique ids', () => {
|
||||
@@ -49,28 +55,199 @@ describe('browser helpers', () => {
|
||||
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
});
|
||||
|
||||
it('builds Playwright MCP args with kebab-case executable path', () => {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
it('builds extension MCP args in local mode (no CI)', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds standalone MCP args in CI mode', () => {
|
||||
const savedCI = process.env.CI;
|
||||
process.env.CI = 'true';
|
||||
try {
|
||||
// CI mode: no --extension — browser launches in standalone headed mode
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/chromium',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--executable-path',
|
||||
'/usr/bin/chromium',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a direct node launch spec when a local MCP path is available', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpLaunchSpec({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/google-chrome',
|
||||
})).toEqual({
|
||||
command: 'node',
|
||||
args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
|
||||
usedNpxFallback: false,
|
||||
});
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to npx bootstrap when no MCP path is available', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpLaunchSpec({
|
||||
mcpPath: null,
|
||||
})).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
usedNpxFallback: true,
|
||||
});
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('times out slow promises', async () => {
|
||||
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
|
||||
process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
|
||||
const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
|
||||
const execSync = vi.fn();
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
|
||||
});
|
||||
|
||||
it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
|
||||
const execSync = vi.fn();
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
|
||||
expect(execSync).not.toHaveBeenCalled();
|
||||
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to npm root -g when runtime prefix lookup misses', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
||||
const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
|
||||
const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
|
||||
const execSync = vi.fn((command: string) => {
|
||||
if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
|
||||
throw new Error(`unexpected command: ${String(command)}`);
|
||||
});
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
|
||||
expect(execSync).toHaveBeenCalledOnce();
|
||||
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
||||
expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when new global discovery paths are unavailable', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: runtimeExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const existsSync = vi.fn(() => false);
|
||||
const execSync = vi.fn((command: string) => {
|
||||
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
|
||||
throw new Error(`missing command: ${String(command)}`);
|
||||
});
|
||||
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
||||
|
||||
try {
|
||||
expect(__test__.findMcpServerPath()).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaywrightMCP state', () => {
|
||||
|
||||
-692
@@ -1,692 +0,0 @@
|
||||
/**
|
||||
* Browser interaction via Playwright MCP Bridge extension.
|
||||
* Connects to an existing Chrome browser through the extension.
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { formatSnapshot } from './snapshotFormatter.js';
|
||||
import { PKG_VERSION } from './version.js';
|
||||
import { normalizeEvaluateSource } from './pipeline/template.js';
|
||||
import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
|
||||
import { withTimeoutMs } from './runtime.js';
|
||||
|
||||
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
||||
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
||||
const INITIAL_TABS_TIMEOUT_MS = 1500;
|
||||
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
||||
let _cachedMcpServerPath: string | null | undefined;
|
||||
|
||||
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
|
||||
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
||||
|
||||
type ConnectFailureInput = {
|
||||
kind: ConnectFailureKind;
|
||||
timeout: number;
|
||||
hasExtensionToken: boolean;
|
||||
tokenFingerprint?: string | null;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
rawMessage?: string;
|
||||
};
|
||||
|
||||
export function getTokenFingerprint(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
||||
const stderr = input.stderr?.trim();
|
||||
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
||||
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
||||
|
||||
if (input.kind === 'missing-token') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
||||
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
||||
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-not-installed') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
||||
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
||||
'If Chrome shows an approval dialog, click Allow.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-timeout') {
|
||||
const likelyCause = input.hasExtensionToken
|
||||
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
|
||||
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
||||
return new Error(
|
||||
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
||||
`${likelyCause} If a browser prompt is visible, click Allow.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'mcp-init') {
|
||||
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
||||
}
|
||||
|
||||
if (input.kind === 'process-exit') {
|
||||
return new Error(
|
||||
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
||||
}
|
||||
|
||||
function inferConnectFailureKind(args: {
|
||||
hasExtensionToken: boolean;
|
||||
stderr: string;
|
||||
rawMessage?: string;
|
||||
exited?: boolean;
|
||||
}): ConnectFailureKind {
|
||||
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
||||
|
||||
if (!args.hasExtensionToken)
|
||||
return 'missing-token';
|
||||
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
||||
return 'extension-not-installed';
|
||||
if (args.rawMessage?.startsWith('MCP init failed:'))
|
||||
return 'mcp-init';
|
||||
if (args.exited)
|
||||
return 'process-exit';
|
||||
return 'extension-timeout';
|
||||
}
|
||||
|
||||
// JSON-RPC helpers
|
||||
let _nextId = 1;
|
||||
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
|
||||
const id = _nextId++;
|
||||
return {
|
||||
id,
|
||||
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
||||
};
|
||||
}
|
||||
|
||||
import type { IPage } from './types.js';
|
||||
|
||||
/**
|
||||
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
||||
*/
|
||||
export class Page implements IPage {
|
||||
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
|
||||
|
||||
async call(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
const resp = await this._request(method, params);
|
||||
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
|
||||
// Extract text content from MCP result
|
||||
const result = resp.result;
|
||||
if (result?.content) {
|
||||
const textParts = result.content.filter((c: any) => c.type === 'text');
|
||||
if (textParts.length === 1) {
|
||||
let text = textParts[0].text;
|
||||
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
||||
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
||||
const codeMarker = text.indexOf('### Ran Playwright code');
|
||||
if (codeMarker !== -1) {
|
||||
text = text.slice(0, codeMarker).trim();
|
||||
}
|
||||
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
||||
const resultMarker = text.indexOf('### Result\n');
|
||||
if (resultMarker !== -1) {
|
||||
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
||||
}
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- High-level methods ---
|
||||
|
||||
async goto(url: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
|
||||
}
|
||||
|
||||
async evaluate(js: string): Promise<any> {
|
||||
// Normalize IIFE format to function format expected by MCP browser_evaluate
|
||||
const normalized = normalizeEvaluateSource(js);
|
||||
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
||||
}
|
||||
|
||||
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
||||
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
|
||||
}
|
||||
|
||||
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
|
||||
} else {
|
||||
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
|
||||
}
|
||||
|
||||
async consoleMessages(level: string = 'info'): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
||||
}
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
||||
}
|
||||
|
||||
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
||||
const times = options.times ?? 3;
|
||||
const delayMs = options.delayMs ?? 2000;
|
||||
const js = `
|
||||
async () => {
|
||||
const maxTimes = ${times};
|
||||
const maxWaitMs = ${delayMs};
|
||||
for (let i = 0; i < maxTimes; i++) {
|
||||
const lastHeight = document.body.scrollHeight;
|
||||
window.scrollTo(0, lastHeight);
|
||||
await new Promise(resolve => {
|
||||
let timeoutId;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.body.scrollHeight > lastHeight) {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
setTimeout(resolve, 100); // Small debounce for rendering
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, maxWaitMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
await this.evaluate(js);
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}));
|
||||
}
|
||||
|
||||
async getInterceptedRequests(): Promise<any[]> {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return result || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright MCP process manager.
|
||||
*/
|
||||
export class PlaywrightMCP {
|
||||
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
||||
private static _cleanupRegistered = false;
|
||||
|
||||
private static _registerGlobalCleanup() {
|
||||
if (this._cleanupRegistered) return;
|
||||
this._cleanupRegistered = true;
|
||||
const cleanup = () => {
|
||||
for (const inst of this._activeInsts) {
|
||||
if (inst._proc && !inst._proc.killed) {
|
||||
try { inst._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
||||
}
|
||||
|
||||
private _proc: ChildProcess | null = null;
|
||||
private _buffer = '';
|
||||
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
||||
private _initialTabIdentities: string[] = [];
|
||||
private _closingPromise: Promise<void> | null = null;
|
||||
private _state: PlaywrightMCPState = 'idle';
|
||||
|
||||
private _page: Page | null = null;
|
||||
|
||||
get state(): PlaywrightMCPState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
if (!this._proc?.stdin?.writable) {
|
||||
reject(new Error('Playwright MCP process is not writable'));
|
||||
return;
|
||||
}
|
||||
const { id, message } = createJsonRpcRequest(method, params);
|
||||
this._pending.set(id, { resolve, reject });
|
||||
this._proc.stdin.write(message, (err) => {
|
||||
if (!err) return;
|
||||
this._pending.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _rejectPendingRequests(error: Error): void {
|
||||
const pending = [...this._pending.values()];
|
||||
this._pending.clear();
|
||||
for (const waiter of pending) waiter.reject(error);
|
||||
}
|
||||
|
||||
private _resetAfterFailedConnect(): void {
|
||||
const proc = this._proc;
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._buffer = '';
|
||||
this._initialTabIdentities = [];
|
||||
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
if (proc && !proc.killed) {
|
||||
try { proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async connect(opts: { timeout?: number } = {}): Promise<Page> {
|
||||
if (this._state === 'connected' && this._page) return this._page;
|
||||
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
||||
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
||||
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
||||
|
||||
const mcpPath = findMcpServerPath();
|
||||
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
||||
|
||||
PlaywrightMCP._registerGlobalCleanup();
|
||||
PlaywrightMCP._activeInsts.add(this);
|
||||
this._state = 'connecting';
|
||||
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
||||
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
||||
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
||||
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'idle';
|
||||
clearTimeout(timer);
|
||||
this._resetAfterFailedConnect();
|
||||
reject(formatBrowserConnectError({
|
||||
kind,
|
||||
timeout,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
tokenFingerprint,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: extra.exitCode,
|
||||
rawMessage: extra.rawMessage,
|
||||
}));
|
||||
};
|
||||
|
||||
const settleSuccess = (pageToResolve: Page) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'connected';
|
||||
clearTimeout(timer);
|
||||
resolve(pageToResolve);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debugLog('Connection timed out');
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
}));
|
||||
}, timeout * 1000);
|
||||
|
||||
const mcpArgs = buildMcpArgs({
|
||||
mcpPath,
|
||||
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
||||
});
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
||||
}
|
||||
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
||||
|
||||
this._proc = spawn('node', mcpArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Increase max listeners to avoid warnings
|
||||
this._proc.setMaxListeners(20);
|
||||
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
||||
|
||||
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
||||
this._page = page;
|
||||
|
||||
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
this._buffer += chunk.toString();
|
||||
const lines = this._buffer.split('\n');
|
||||
this._buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
debugLog(`RECV: ${line}`);
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (typeof parsed?.id === 'number') {
|
||||
const waiter = this._pending.get(parsed.id);
|
||||
if (waiter) {
|
||||
this._pending.delete(parsed.id);
|
||||
waiter.resolve(parsed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(`Parse error: ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
||||
debugLog(`STDERR: ${text}`);
|
||||
});
|
||||
this._proc.on('error', (err) => {
|
||||
debugLog(`Subprocess error: ${err.message}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
||||
settleError('process-exit', { rawMessage: err.message });
|
||||
});
|
||||
this._proc.on('close', (code) => {
|
||||
debugLog(`Subprocess closed with code ${code}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
||||
if (!settled) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
exited: true,
|
||||
}), { exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize: send initialize request
|
||||
debugLog('Waiting for initialize response...');
|
||||
this._sendRequest('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
||||
}).then((resp) => {
|
||||
debugLog('Got initialize response');
|
||||
if (resp.error) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
rawMessage: `MCP init failed: ${resp.error.message}`,
|
||||
}), { rawMessage: resp.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
||||
debugLog(`SEND: ${initializedMsg.trim()}`);
|
||||
this._proc?.stdin?.write(initializedMsg);
|
||||
|
||||
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
|
||||
debugLog('Fetching initial tabs count...');
|
||||
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
|
||||
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
||||
this._initialTabIdentities = extractTabIdentities(tabs);
|
||||
settleSuccess(page);
|
||||
}).catch((err) => {
|
||||
debugLog(`Tabs fetch error: ${err.message}`);
|
||||
settleSuccess(page);
|
||||
});
|
||||
}).catch((err) => {
|
||||
debugLog(`Init promise rejected: ${err.message}`);
|
||||
settleError('mcp-init', { rawMessage: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._closingPromise) return this._closingPromise;
|
||||
if (this._state === 'closed') return;
|
||||
this._state = 'closing';
|
||||
this._closingPromise = (async () => {
|
||||
try {
|
||||
// Extension mode opens bridge/session tabs that we can clean up best-effort.
|
||||
if (this._page && this._proc && !this._proc.killed) {
|
||||
try {
|
||||
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
||||
const tabEntries = extractTabEntries(tabs);
|
||||
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
||||
for (const index of tabsToClose) {
|
||||
try { await this._page.closeTab(index); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (this._proc && !this._proc.killed) {
|
||||
this._proc.kill('SIGTERM');
|
||||
const exited = await new Promise<boolean>((res) => {
|
||||
let done = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
res(value);
|
||||
};
|
||||
this._proc?.once('exit', () => finish(true));
|
||||
setTimeout(() => finish(false), 3000);
|
||||
});
|
||||
if (!exited && this._proc && !this._proc.killed) {
|
||||
try { this._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._state = 'closed';
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
}
|
||||
})();
|
||||
return this._closingPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((tab: any, index: number) => ({
|
||||
index,
|
||||
identity: [
|
||||
tab?.id ?? '',
|
||||
tab?.url ?? '',
|
||||
tab?.title ?? '',
|
||||
tab?.name ?? '',
|
||||
].join('|'),
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
|
||||
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
|
||||
if (mcpMatch) {
|
||||
return {
|
||||
index: parseInt(mcpMatch[1], 10),
|
||||
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
|
||||
};
|
||||
}
|
||||
// Legacy format: "Tab 0 ..."
|
||||
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
index: parseInt(legacyMatch[1], 10),
|
||||
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is { index: number; identity: string } => entry !== null);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTabIdentities(raw: any): string[] {
|
||||
return extractTabEntries(raw).map(tab => tab.identity);
|
||||
}
|
||||
|
||||
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
|
||||
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
|
||||
const remaining = new Map<string, number>();
|
||||
for (const identity of initialIdentities) {
|
||||
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const tabsToClose: number[] = [];
|
||||
for (const tab of currentTabs) {
|
||||
const count = remaining.get(tab.identity) ?? 0;
|
||||
if (count > 0) {
|
||||
remaining.set(tab.identity, count - 1);
|
||||
continue;
|
||||
}
|
||||
tabsToClose.push(tab.index);
|
||||
}
|
||||
|
||||
return tabsToClose.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
function appendLimited(current: string, chunk: string, limit: number): string {
|
||||
const next = current + chunk;
|
||||
if (next.length <= limit) return next;
|
||||
return next.slice(-limit);
|
||||
}
|
||||
|
||||
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
||||
const args = [input.mcpPath, '--extension'];
|
||||
if (input.executablePath) {
|
||||
args.push('--executable-path', input.executablePath);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
createJsonRpcRequest,
|
||||
extractTabEntries,
|
||||
diffTabIndexes,
|
||||
appendLimited,
|
||||
buildMcpArgs,
|
||||
withTimeoutMs,
|
||||
};
|
||||
|
||||
function findMcpServerPath(): string | null {
|
||||
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
||||
|
||||
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
||||
if (envMcp && fs.existsSync(envMcp)) {
|
||||
_cachedMcpServerPath = envMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check local node_modules first (@playwright/mcp is the modern package)
|
||||
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (fs.existsSync(localMcp)) {
|
||||
_cachedMcpServerPath = localMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check project-relative path
|
||||
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (fs.existsSync(projectMcp)) {
|
||||
_cachedMcpServerPath = projectMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check common locations
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.npm', '_npx'),
|
||||
path.join(os.homedir(), 'node_modules', '.bin'),
|
||||
'/usr/local/lib/node_modules',
|
||||
];
|
||||
|
||||
// Try npx resolution (legacy package name)
|
||||
try {
|
||||
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
||||
if (result && fs.existsSync(result)) {
|
||||
_cachedMcpServerPath = result;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try which
|
||||
try {
|
||||
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
if (result && fs.existsSync(result)) {
|
||||
_cachedMcpServerPath = result;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Search in common npx cache
|
||||
for (const base of candidates) {
|
||||
if (!fs.existsSync(base)) continue;
|
||||
try {
|
||||
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
if (found) {
|
||||
_cachedMcpServerPath = found;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_cachedMcpServerPath = null;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* MCP server path discovery and argument building.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
let _cachedMcpServerPath: string | null | undefined;
|
||||
let _existsSync = fs.existsSync;
|
||||
let _execSync = execSync;
|
||||
|
||||
export function resetMcpServerPathCache(): void {
|
||||
_cachedMcpServerPath = undefined;
|
||||
}
|
||||
|
||||
export function setMcpDiscoveryTestHooks(input?: {
|
||||
existsSync?: typeof fs.existsSync;
|
||||
execSync?: typeof execSync;
|
||||
}): void {
|
||||
_existsSync = input?.existsSync ?? fs.existsSync;
|
||||
_execSync = input?.execSync ?? execSync;
|
||||
}
|
||||
|
||||
export function findMcpServerPath(): string | null {
|
||||
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
||||
|
||||
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
||||
if (envMcp && _existsSync(envMcp)) {
|
||||
_cachedMcpServerPath = envMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check local node_modules first (@playwright/mcp is the modern package)
|
||||
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(localMcp)) {
|
||||
_cachedMcpServerPath = localMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check project-relative path
|
||||
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(projectMcp)) {
|
||||
_cachedMcpServerPath = projectMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check global npm/yarn locations derived from current Node runtime.
|
||||
const nodePrefix = path.resolve(path.dirname(process.execPath), '..');
|
||||
const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules');
|
||||
const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js');
|
||||
if (_existsSync(globalMcp)) {
|
||||
_cachedMcpServerPath = globalMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
// Check npm global root directly.
|
||||
try {
|
||||
const npmRootGlobal = _execSync('npm root -g 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js');
|
||||
if (npmRootGlobal && _existsSync(npmGlobalMcp)) {
|
||||
_cachedMcpServerPath = npmGlobalMcp;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Check common locations
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.npm', '_npx'),
|
||||
path.join(os.homedir(), 'node_modules', '.bin'),
|
||||
'/usr/local/lib/node_modules',
|
||||
];
|
||||
|
||||
// Try npx resolution (legacy package name)
|
||||
try {
|
||||
const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
||||
if (result && _existsSync(result)) {
|
||||
_cachedMcpServerPath = result;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try which
|
||||
try {
|
||||
const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
if (result && _existsSync(result)) {
|
||||
_cachedMcpServerPath = result;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Search in common npx cache
|
||||
for (const base of candidates) {
|
||||
if (!_existsSync(base)) continue;
|
||||
try {
|
||||
const found = _execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
if (found) {
|
||||
_cachedMcpServerPath = found;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_cachedMcpServerPath = null;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
|
||||
*
|
||||
* Starting with Chrome 144, users can enable remote debugging from
|
||||
* chrome://inspect#remote-debugging without any command-line flags.
|
||||
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
|
||||
* in the user data directory, which we read to construct the WebSocket endpoint.
|
||||
*/
|
||||
export function discoverChromeEndpoint(): string | null {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// User-specified Chrome data dir takes highest priority
|
||||
if (process.env.CHROME_USER_DATA_DIR) {
|
||||
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
|
||||
}
|
||||
|
||||
// Standard Chrome/Edge user data dirs per platform
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
||||
candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
|
||||
} else {
|
||||
candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
|
||||
candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
|
||||
}
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
const lines = content.split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const port = parseInt(lines[0], 10);
|
||||
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
|
||||
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
|
||||
return `ws://127.0.0.1:${port}${browserPath}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
|
||||
const envVal = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (envVal === '1' || envVal?.toLowerCase() === 'true') {
|
||||
const autoDiscovered = discoverChromeEndpoint();
|
||||
return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
|
||||
}
|
||||
|
||||
if (envVal) {
|
||||
return { endpoint: envVal, requestedCdp: true };
|
||||
}
|
||||
|
||||
// Fallback to auto-discovery if not explicitly set
|
||||
const autoDiscovered = discoverChromeEndpoint();
|
||||
if (autoDiscovered) {
|
||||
return { endpoint: autoDiscovered, requestedCdp: true };
|
||||
}
|
||||
|
||||
return { requestedCdp: false };
|
||||
}
|
||||
|
||||
function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
|
||||
if (input?.cdpEndpoint) {
|
||||
args.push('--cdp-endpoint', input.cdpEndpoint);
|
||||
return args;
|
||||
}
|
||||
|
||||
// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
|
||||
if (!process.env.CI) {
|
||||
args.push('--extension');
|
||||
}
|
||||
|
||||
// CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
|
||||
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
||||
if (input?.executablePath) {
|
||||
args.push('--executable-path', input.executablePath);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
||||
return [input.mcpPath, ...buildRuntimeArgs(input)];
|
||||
}
|
||||
|
||||
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
|
||||
command: string;
|
||||
args: string[];
|
||||
usedNpxFallback: boolean;
|
||||
} {
|
||||
const runtimeArgs = buildRuntimeArgs(input);
|
||||
if (input.mcpPath) {
|
||||
return {
|
||||
command: 'node',
|
||||
args: [input.mcpPath, ...runtimeArgs],
|
||||
usedNpxFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
|
||||
usedNpxFallback: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Browser connection error classification and formatting.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
|
||||
|
||||
export type ConnectFailureInput = {
|
||||
kind: ConnectFailureKind;
|
||||
timeout: number;
|
||||
hasExtensionToken: boolean;
|
||||
tokenFingerprint?: string | null;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
rawMessage?: string;
|
||||
};
|
||||
|
||||
export function getTokenFingerprint(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
||||
const stderr = input.stderr?.trim();
|
||||
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
||||
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
||||
|
||||
if (input.kind === 'cdp-connection-failed') {
|
||||
return new Error(
|
||||
`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
|
||||
`Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
|
||||
`If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'missing-token') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
||||
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
||||
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-not-installed') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
||||
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
||||
'If Chrome shows an approval dialog, click Allow.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-timeout') {
|
||||
const likelyCause = input.hasExtensionToken
|
||||
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
|
||||
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
||||
return new Error(
|
||||
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
||||
`${likelyCause} If a browser prompt is visible, click Allow.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'mcp-init') {
|
||||
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
||||
}
|
||||
|
||||
if (input.kind === 'process-exit') {
|
||||
return new Error(
|
||||
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
||||
}
|
||||
|
||||
export function inferConnectFailureKind(args: {
|
||||
hasExtensionToken: boolean;
|
||||
stderr: string;
|
||||
rawMessage?: string;
|
||||
exited?: boolean;
|
||||
isCdpMode?: boolean;
|
||||
}): ConnectFailureKind {
|
||||
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
||||
|
||||
if (args.isCdpMode) {
|
||||
if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
|
||||
if (args.exited) return 'cdp-connection-failed';
|
||||
return 'cdp-connection-failed';
|
||||
}
|
||||
|
||||
if (!args.hasExtensionToken)
|
||||
return 'missing-token';
|
||||
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
||||
return 'extension-not-installed';
|
||||
if (args.rawMessage?.startsWith('MCP init failed:'))
|
||||
return 'mcp-init';
|
||||
if (args.exited)
|
||||
return 'process-exit';
|
||||
return 'extension-timeout';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Browser module — public API re-exports.
|
||||
*
|
||||
* This barrel replaces the former monolithic browser.ts.
|
||||
* External code should import from './browser/index.js' (or './browser.js' via Node resolution).
|
||||
*/
|
||||
|
||||
export { Page } from './page.js';
|
||||
export { PlaywrightMCP } from './mcp.js';
|
||||
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
|
||||
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
||||
export { resolveCdpEndpoint } from './discover.js';
|
||||
|
||||
// Test-only helpers — exposed for unit tests
|
||||
import { createJsonRpcRequest } from './mcp.js';
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
|
||||
import { withTimeoutMs } from '../runtime.js';
|
||||
|
||||
export const __test__ = {
|
||||
createJsonRpcRequest,
|
||||
extractTabEntries,
|
||||
diffTabIndexes,
|
||||
appendLimited,
|
||||
buildMcpArgs,
|
||||
buildMcpLaunchSpec,
|
||||
findMcpServerPath,
|
||||
resetMcpServerPathCache,
|
||||
setMcpDiscoveryTestHooks,
|
||||
withTimeoutMs,
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Playwright MCP process manager.
|
||||
* Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import type { IPage } from '../types.js';
|
||||
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
|
||||
import { PKG_VERSION } from '../version.js';
|
||||
import { Page } from './page.js';
|
||||
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
||||
import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
|
||||
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
|
||||
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
||||
const INITIAL_TABS_TIMEOUT_MS = 1500;
|
||||
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
||||
|
||||
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
||||
|
||||
// JSON-RPC helpers
|
||||
let _nextId = 1;
|
||||
export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
|
||||
const id = _nextId++;
|
||||
return {
|
||||
id,
|
||||
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright MCP process manager.
|
||||
*/
|
||||
export class PlaywrightMCP {
|
||||
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
||||
private static _cleanupRegistered = false;
|
||||
|
||||
private static _registerGlobalCleanup() {
|
||||
if (this._cleanupRegistered) return;
|
||||
this._cleanupRegistered = true;
|
||||
const cleanup = () => {
|
||||
for (const inst of this._activeInsts) {
|
||||
if (inst._proc && !inst._proc.killed) {
|
||||
try { inst._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
||||
}
|
||||
|
||||
private _proc: ChildProcess | null = null;
|
||||
private _buffer = '';
|
||||
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
||||
private _initialTabIdentities: string[] = [];
|
||||
private _closingPromise: Promise<void> | null = null;
|
||||
private _state: PlaywrightMCPState = 'idle';
|
||||
|
||||
private _page: Page | null = null;
|
||||
|
||||
get state(): PlaywrightMCPState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
if (!this._proc?.stdin?.writable) {
|
||||
reject(new Error('Playwright MCP process is not writable'));
|
||||
return;
|
||||
}
|
||||
const { id, message } = createJsonRpcRequest(method, params);
|
||||
this._pending.set(id, { resolve, reject });
|
||||
this._proc.stdin.write(message, (err) => {
|
||||
if (!err) return;
|
||||
this._pending.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _rejectPendingRequests(error: Error): void {
|
||||
const pending = [...this._pending.values()];
|
||||
this._pending.clear();
|
||||
for (const waiter of pending) waiter.reject(error);
|
||||
}
|
||||
|
||||
private _resetAfterFailedConnect(): void {
|
||||
const proc = this._proc;
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._buffer = '';
|
||||
this._initialTabIdentities = [];
|
||||
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
if (proc && !proc.killed) {
|
||||
try { proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
||||
if (this._state === 'connected' && this._page) return this._page;
|
||||
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
||||
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
||||
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
||||
|
||||
const mcpPath = findMcpServerPath();
|
||||
|
||||
PlaywrightMCP._registerGlobalCleanup();
|
||||
PlaywrightMCP._activeInsts.add(this);
|
||||
this._state = 'connecting';
|
||||
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
|
||||
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
||||
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
||||
const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
|
||||
const useExtension = !requestedCdp;
|
||||
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'idle';
|
||||
clearTimeout(timer);
|
||||
this._resetAfterFailedConnect();
|
||||
reject(formatBrowserConnectError({
|
||||
kind,
|
||||
timeout,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
tokenFingerprint,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: extra.exitCode,
|
||||
rawMessage: extra.rawMessage,
|
||||
}));
|
||||
};
|
||||
|
||||
const settleSuccess = (pageToResolve: Page) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._state = 'connected';
|
||||
clearTimeout(timer);
|
||||
resolve(pageToResolve);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debugLog('Connection timed out');
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
isCdpMode: requestedCdp,
|
||||
}));
|
||||
}, timeout * 1000);
|
||||
|
||||
const launchSpec = buildMcpLaunchSpec({
|
||||
mcpPath,
|
||||
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
||||
cdpEndpoint,
|
||||
});
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
|
||||
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
||||
if (launchSpec.usedNpxFallback) {
|
||||
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
|
||||
}
|
||||
}
|
||||
debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
|
||||
|
||||
this._proc = spawn(launchSpec.command, launchSpec.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Increase max listeners to avoid warnings
|
||||
this._proc.setMaxListeners(20);
|
||||
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
||||
|
||||
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
||||
this._page = page;
|
||||
|
||||
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
this._buffer += chunk.toString();
|
||||
const lines = this._buffer.split('\n');
|
||||
this._buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
debugLog(`RECV: ${line}`);
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (typeof parsed?.id === 'number') {
|
||||
const waiter = this._pending.get(parsed.id);
|
||||
if (waiter) {
|
||||
this._pending.delete(parsed.id);
|
||||
waiter.resolve(parsed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(`Parse error: ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
||||
debugLog(`STDERR: ${text}`);
|
||||
});
|
||||
this._proc.on('error', (err) => {
|
||||
debugLog(`Subprocess error: ${err.message}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
||||
settleError('process-exit', { rawMessage: err.message });
|
||||
});
|
||||
this._proc.on('close', (code) => {
|
||||
debugLog(`Subprocess closed with code ${code}`);
|
||||
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
||||
if (!settled) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
exited: true,
|
||||
isCdpMode: requestedCdp,
|
||||
}), { exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize: send initialize request
|
||||
debugLog('Waiting for initialize response...');
|
||||
this._sendRequest('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
||||
}).then((resp: any) => {
|
||||
debugLog('Got initialize response');
|
||||
if (resp.error) {
|
||||
settleError(inferConnectFailureKind({
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
rawMessage: `MCP init failed: ${resp.error.message}`,
|
||||
isCdpMode: requestedCdp,
|
||||
}), { rawMessage: resp.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
||||
debugLog(`SEND: ${initializedMsg.trim()}`);
|
||||
this._proc?.stdin?.write(initializedMsg);
|
||||
|
||||
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
|
||||
debugLog('Fetching initial tabs count...');
|
||||
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
|
||||
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
||||
this._initialTabIdentities = extractTabIdentities(tabs);
|
||||
settleSuccess(page);
|
||||
}).catch((err: Error) => {
|
||||
debugLog(`Tabs fetch error: ${err.message}`);
|
||||
settleSuccess(page);
|
||||
});
|
||||
}).catch((err: Error) => {
|
||||
debugLog(`Init promise rejected: ${err.message}`);
|
||||
settleError('mcp-init', { rawMessage: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._closingPromise) return this._closingPromise;
|
||||
if (this._state === 'closed') return;
|
||||
this._state = 'closing';
|
||||
this._closingPromise = (async () => {
|
||||
try {
|
||||
// Extension mode opens bridge/session tabs that we can clean up best-effort.
|
||||
if (this._page && this._proc && !this._proc.killed) {
|
||||
try {
|
||||
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
||||
const tabEntries = extractTabEntries(tabs);
|
||||
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
||||
for (const index of tabsToClose) {
|
||||
try { await this._page.closeTab(index); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (this._proc && !this._proc.killed) {
|
||||
this._proc.kill('SIGTERM');
|
||||
const exited = await new Promise<boolean>((res) => {
|
||||
let done = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
res(value);
|
||||
};
|
||||
this._proc?.once('exit', () => finish(true));
|
||||
setTimeout(() => finish(false), 3000);
|
||||
});
|
||||
if (!exited && this._proc && !this._proc.killed) {
|
||||
try { this._proc.kill('SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
||||
this._page = null;
|
||||
this._proc = null;
|
||||
this._state = 'closed';
|
||||
PlaywrightMCP._activeInsts.delete(this);
|
||||
}
|
||||
})();
|
||||
return this._closingPromise;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
||||
*/
|
||||
|
||||
import { formatSnapshot } from '../snapshotFormatter.js';
|
||||
import { normalizeEvaluateSource } from '../pipeline/template.js';
|
||||
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
||||
import type { IPage } from '../types.js';
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
|
||||
/**
|
||||
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
||||
*/
|
||||
export class Page implements IPage {
|
||||
constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
|
||||
|
||||
async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
const resp = await this._request(method, params);
|
||||
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
|
||||
// Extract text content from MCP result
|
||||
const result = resp.result as any;
|
||||
|
||||
if (result?.isError) {
|
||||
const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
|
||||
throw new BrowserConnectError(
|
||||
errorText,
|
||||
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.content) {
|
||||
const textParts = result.content.filter((c: any) => c.type === 'text');
|
||||
if (textParts.length >= 1) {
|
||||
let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
|
||||
|
||||
// Some versions of the MCP return error text without the `isError` boolean flag
|
||||
if (typeof text === 'string' && text.trim().startsWith('### Error')) {
|
||||
throw new BrowserConnectError(
|
||||
text.trim(),
|
||||
'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
||||
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
||||
const codeMarker = text.indexOf('### Ran Playwright code');
|
||||
if (codeMarker !== -1) {
|
||||
text = text.slice(0, codeMarker).trim();
|
||||
}
|
||||
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
||||
const resultMarker = text.indexOf('### Result\n');
|
||||
if (resultMarker !== -1) {
|
||||
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
||||
}
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- High-level methods ---
|
||||
|
||||
async goto(url: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
|
||||
}
|
||||
|
||||
async evaluate(js: string): Promise<any> {
|
||||
// Normalize IIFE format to function format expected by MCP browser_evaluate
|
||||
const normalized = normalizeEvaluateSource(js);
|
||||
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
||||
}
|
||||
|
||||
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
||||
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
|
||||
}
|
||||
|
||||
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
|
||||
} else {
|
||||
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
|
||||
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
|
||||
}
|
||||
|
||||
async consoleMessages(level: string = 'info'): Promise<any> {
|
||||
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
||||
}
|
||||
|
||||
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
|
||||
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
||||
}
|
||||
|
||||
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
||||
const times = options.times ?? 3;
|
||||
const delayMs = options.delayMs ?? 2000;
|
||||
const js = `
|
||||
async () => {
|
||||
const maxTimes = ${times};
|
||||
const maxWaitMs = ${delayMs};
|
||||
for (let i = 0; i < maxTimes; i++) {
|
||||
const lastHeight = document.body.scrollHeight;
|
||||
window.scrollTo(0, lastHeight);
|
||||
await new Promise(resolve => {
|
||||
let timeoutId;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.body.scrollHeight > lastHeight) {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
setTimeout(resolve, 100); // Small debounce for rendering
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, maxWaitMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
await this.evaluate(js);
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}));
|
||||
}
|
||||
|
||||
async getInterceptedRequests(): Promise<any[]> {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return result || [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Browser tab management helpers: extract, diff, and cleanup tab state.
|
||||
*/
|
||||
|
||||
export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((tab: Record<string, unknown>, index: number) => ({
|
||||
index,
|
||||
identity: [
|
||||
tab?.id ?? '',
|
||||
tab?.url ?? '',
|
||||
tab?.title ?? '',
|
||||
tab?.name ?? '',
|
||||
].join('|'),
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
|
||||
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
|
||||
if (mcpMatch) {
|
||||
return {
|
||||
index: parseInt(mcpMatch[1], 10),
|
||||
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
|
||||
};
|
||||
}
|
||||
// Legacy format: "Tab 0 ..."
|
||||
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
index: parseInt(legacyMatch[1], 10),
|
||||
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is { index: number; identity: string } => entry !== null);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function extractTabIdentities(raw: unknown): string[] {
|
||||
return extractTabEntries(raw).map(tab => tab.identity);
|
||||
}
|
||||
|
||||
export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
|
||||
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
|
||||
const remaining = new Map<string, number>();
|
||||
for (const identity of initialIdentities) {
|
||||
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const tabsToClose: number[] = [];
|
||||
for (const tab of currentTabs) {
|
||||
const count = remaining.get(tab.identity) ?? 0;
|
||||
if (count > 0) {
|
||||
remaining.set(tab.identity, count - 1);
|
||||
continue;
|
||||
}
|
||||
tabsToClose.push(tab.index);
|
||||
}
|
||||
|
||||
return tabsToClose.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
export function appendLimited(current: string, chunk: string, limit: number): string {
|
||||
const next = current + chunk;
|
||||
if (next.length <= limit) return next;
|
||||
return next.slice(-limit);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ interface ManifestEntry {
|
||||
type?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}>;
|
||||
@@ -140,6 +141,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
|
||||
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
|
||||
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
|
||||
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
|
||||
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
|
||||
|
||||
let defaultVal: any = undefined;
|
||||
if (defaultMatch) {
|
||||
@@ -156,6 +158,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
|
||||
type: typeMatch?.[1] ?? 'str',
|
||||
default: defaultVal,
|
||||
required: requiredMatch?.[1] === 'true',
|
||||
positional: positionalMatch?.[1] === 'true' || undefined,
|
||||
help: helpMatch?.[1] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Antigravity CLI Adapter
|
||||
|
||||
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
|
||||
|
||||
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
|
||||
|
||||
Turn your local Antigravity desktop application into a programmable AI node via Chrome DevTools Protocol (CDP). This allows you to compose complex LLM workflows entirely through the terminal by manipulating the actual UI natively, bypassing any API restrictions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-port` flag:
|
||||
|
||||
\`\`\`bash
|
||||
# Start Antigravity in the background
|
||||
/Applications/Antigravity.app/Contents/MacOS/Electron \\
|
||||
--remote-debugging-port=9224 \\
|
||||
--remote-allow-origins="*"
|
||||
\`\`\`
|
||||
|
||||
*(Note: Depending on your installation, the executable might be named differently, e.g., \`Antigravity\` instead of \`Electron\`.)*
|
||||
|
||||
Next, set the target port in your terminal session to tell OpenCLI where to connect:
|
||||
|
||||
\`\`\`bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
\`\`\`
|
||||
|
||||
## Available Commands
|
||||
|
||||
### \`opencli antigravity status\`
|
||||
Check the Chromium CDP connection. Returns the current window title and active internal URL.
|
||||
|
||||
### \`opencli antigravity send <message>\`
|
||||
Send a text prompt to the AI. Automatically locates the Lexical editor input box, types the prompt securely, and hits Enter.
|
||||
|
||||
### \`opencli antigravity read\`
|
||||
Scrape the entire current conversation history block as pure text. Useful for feeding the context to another script.
|
||||
|
||||
### \`opencli antigravity new\`
|
||||
Click the "New Conversation" button to instantly clear the UI state and start fresh.
|
||||
|
||||
### \`opencli antigravity extract-code\`
|
||||
Extract any multi-line code blocks from the current conversation view. Ideal for automated script extraction (e.g. \`opencli antigravity extract-code > script.sh\`).
|
||||
|
||||
### \`opencli antigravity model <name>\`
|
||||
Quickly target and switch the active LLM engine. Example: \`opencli antigravity model claude\` or \`opencli antigravity model gemini\`.
|
||||
|
||||
### \`opencli antigravity watch\`
|
||||
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Antigravity CLI Adapter (探针插件)
|
||||
|
||||
🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
|
||||
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
|
||||
|
||||
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
|
||||
|
||||
通过 Chrome DevTools Protocol (CDP),将你本地运行的 Antigravity 桌面客户端转变为一个完全可编程的 AI 节点。这让你可以在命令行终端中直接操控它的 UI 界面,实现真正的“零 API 限制”本地自动化大模型工作流调度。
|
||||
|
||||
## 开发准备
|
||||
|
||||
首先,**请在终端启动 Antigravity 桌面版**,并附加上允许远程调试(CDP)的内核启动参数:
|
||||
|
||||
\`\`\`bash
|
||||
# 在后台启动并驻留
|
||||
/Applications/Antigravity.app/Contents/MacOS/Electron \\
|
||||
--remote-debugging-port=9224 \\
|
||||
--remote-allow-origins="*"
|
||||
\`\`\`
|
||||
|
||||
*(注意:如果你打包的应用重命名过主构建,可能需要把 `Electron` 换成实际的可执行文件名,如 `Antigravity`)*
|
||||
|
||||
接下来,在你想执行 CLI 命令的另一个新终端板块里,声明要连入的本地调试端口环境变量:
|
||||
|
||||
\`\`\`bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
\`\`\`
|
||||
|
||||
## 全部指令一览
|
||||
|
||||
### \`opencli antigravity status\`
|
||||
快速检查当前探针与内核 CDP 的连接状态。会返回底层的当前 URL 和网页 Title。
|
||||
|
||||
### \`opencli antigravity send <message>\`
|
||||
给 Agent 发送消息。它会自动定位到底部的 Lexical 输入框,安全地注入你的指定文本然后模拟回车发送。
|
||||
|
||||
### \`opencli antigravity read\`
|
||||
全量抓取当前的对话面板,将所有历史聊天记录作为一整块纯文本取回。
|
||||
|
||||
### \`opencli antigravity new\`
|
||||
模拟点击侧边栏顶部的“开启新对话”按钮,瞬间清空并重置 Agent 的上下文状态。
|
||||
|
||||
### \`opencli antigravity extract-code\`
|
||||
从当前的 Agent 聊天记录中单独提取所有的多行代码块。非常适合自动化脚手架开发(例如直接重定向输出写入本地文件:\`opencli antigravity extract-code > script.sh\`)。
|
||||
|
||||
### \`opencli antigravity model <name>\`
|
||||
切换大模型引擎。只需传入关键词(比如:\`opencli antigravity model claude\` 或 \`model gemini\`),它会自动帮你点开模型选择菜单并模拟点击。
|
||||
|
||||
### \`opencli antigravity watch\`
|
||||
开启一个长连接流式监听。通过持续轮询 DOM 的变化量,它能像流式 API 一样,在终端实时向你推送 Agent 刚刚打出的那一行最新回复,直到你按 Ctrl+C 中止。
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: How to automate Antigravity using OpenCLI
|
||||
---
|
||||
|
||||
# Antigravity Automation Skill
|
||||
|
||||
This skill allows AI agents to control the [Antigravity](https://github.com/chengazhen/Antigravity) desktop app (and any Electron app with CDP enabled) programmatically via OpenCLI.
|
||||
|
||||
## Requirements
|
||||
The target Electron application MUST be launched with the remote-debugging-port flag:
|
||||
\`\`\`bash
|
||||
/Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224 --remote-allow-origins="*"
|
||||
\`\`\`
|
||||
|
||||
The agent must configure the endpoint environment variable locally before invoking standard commands:
|
||||
\`\`\`bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
\`\`\`
|
||||
|
||||
## High-Level Capabilities
|
||||
1. **Send Messages (`opencli antigravity send <message>`)**: Type and send a message directly into the chat UI.
|
||||
2. **Read History (`opencli antigravity read`)**: Scrape the raw chat transcript from the main UI container.
|
||||
3. **Extract Code (`opencli antigravity extract-code`)**: Automatically isolate and extract source code text blocks from the AI's recent answers.
|
||||
4. **Switch Models (`opencli antigravity model <name>`)**: Instantly toggle the active LLM (e.g., \`gemini\`, \`claude\`).
|
||||
5. **Clear Context (`opencli antigravity new`)**: Start a fresh conversation.
|
||||
|
||||
## Examples for Automated Workflows
|
||||
|
||||
### Generating and Saving Code
|
||||
\`\`\`bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
opencli antigravity send "Write a python script to fetch HN top stories"
|
||||
# wait ~10-15 seconds for output to render
|
||||
opencli antigravity extract-code > hn_fetcher.py
|
||||
\`\`\`
|
||||
|
||||
### Reading Real-time Logs
|
||||
Agents can run long-running streaming watch instances:
|
||||
\`\`\`bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
opencli antigravity watch
|
||||
\`\`\`
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export const dumpCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'dump',
|
||||
description: 'Dump the DOM to help AI understand the UI',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['htmlFile', 'snapFile'],
|
||||
func: async (page) => {
|
||||
// Extract HTML
|
||||
const html = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/antigravity-dom.html', html);
|
||||
|
||||
// Extract Snapshot
|
||||
let snapFile = '';
|
||||
try {
|
||||
const snap = await page.snapshot({ raw: true });
|
||||
snapFile = '/tmp/antigravity-snapshot.json';
|
||||
fs.writeFileSync(snapFile, JSON.stringify(snap, null, 2));
|
||||
} catch (e) {
|
||||
snapFile = 'Failed';
|
||||
}
|
||||
|
||||
return [{ htmlFile: '/tmp/antigravity-dom.html', snapFile }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const extractCodeCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'extract-code',
|
||||
description: 'Extract multi-line code blocks from the current Antigravity conversation',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['code'],
|
||||
func: async (page) => {
|
||||
const blocks = await page.evaluate(`
|
||||
async () => {
|
||||
// Find standard pre/code blocks
|
||||
let elements = Array.from(document.querySelectorAll('pre code'));
|
||||
|
||||
// Fallback to Monaco editor content inside the UI
|
||||
if (elements.length === 0) {
|
||||
elements = Array.from(document.querySelectorAll('.monaco-editor'));
|
||||
}
|
||||
|
||||
// Generic fallback to any code tag that spans multiple lines
|
||||
if (elements.length === 0) {
|
||||
elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
|
||||
}
|
||||
|
||||
return elements.map(el => el.innerText).filter(text => text.trim().length > 0);
|
||||
}
|
||||
`);
|
||||
|
||||
return blocks.map((code: string) => ({ code }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'model',
|
||||
description: 'Switch the active LLM model in Antigravity',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true }
|
||||
],
|
||||
columns: ['status'],
|
||||
func: async (page, kwargs) => {
|
||||
const targetName = kwargs.name.toLowerCase();
|
||||
|
||||
await page.evaluate(`
|
||||
async () => {
|
||||
const targetModelName = ${JSON.stringify(targetName)};
|
||||
|
||||
// 1. Locate the model selector dropdown trigger
|
||||
const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
|
||||
if (!trigger) throw new Error('Could not find the model selector trigger in the UI');
|
||||
trigger.click();
|
||||
|
||||
// 2. Wait a brief moment for React to mount the Portal/Dialog
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// 3. Find the option spanning target text
|
||||
const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
|
||||
const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
|
||||
if (!target) {
|
||||
// If not found, click the trigger again to close it safely
|
||||
trigger.click();
|
||||
throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.');
|
||||
}
|
||||
|
||||
// 4. Click the closest parent that handles the row action
|
||||
const optionNode = target.closest('.cursor-pointer') || target;
|
||||
optionNode.click();
|
||||
}
|
||||
`);
|
||||
|
||||
await page.wait(0.5);
|
||||
return [{ status: `Model switched to: ${kwargs.name}` }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation / clear context in Antigravity',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['status'],
|
||||
func: async (page) => {
|
||||
await page.evaluate(`
|
||||
async () => {
|
||||
const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
|
||||
if (!btn) throw new Error('Could not find New Conversation button');
|
||||
|
||||
// In case it's disabled, we must check, but we'll try to click it anyway
|
||||
btn.click();
|
||||
}
|
||||
`);
|
||||
|
||||
// Give it a moment to reset the UI
|
||||
await page.wait(0.5);
|
||||
|
||||
return [{ status: 'Successfully started a new conversation' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'read',
|
||||
description: 'Read the latest chat messages from Antigravity AI',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'last', help: 'Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)' }
|
||||
],
|
||||
columns: ['role', 'content'],
|
||||
func: async (page, kwargs) => {
|
||||
// We execute a script inside Antigravity's Chromium environment to extract the text
|
||||
// of the entire conversation pane.
|
||||
const rawText = await page.evaluate(`
|
||||
async () => {
|
||||
const container = document.getElementById('conversation');
|
||||
if (!container) throw new Error('Could not find conversation container');
|
||||
|
||||
// Extract the full visible text of the conversation
|
||||
// In Electron/Chromium, innerText preserves basic visual line breaks nicely
|
||||
return container.innerText;
|
||||
}
|
||||
`);
|
||||
|
||||
// We can do simple heuristic parsing based on typical visual markers if needed.
|
||||
// For now, we return the entire text blob, or just the last 2000 characters if it's too long.
|
||||
const cleanText = String(rawText).trim();
|
||||
return [{
|
||||
role: 'history',
|
||||
content: cleanText
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'send',
|
||||
description: 'Send a message to Antigravity AI via the internal Lexical editor',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'message', help: 'The message text to send', required: true, positional: true }
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
const text = kwargs.message;
|
||||
|
||||
// We use evaluate to focus and insert text because Lexical editors maintain
|
||||
// absolute control over their DOM and don't respond to raw node.textContent.
|
||||
// document.execCommand simulates a native paste/typing action perfectly.
|
||||
await page.evaluate(`
|
||||
async () => {
|
||||
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
||||
if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
|
||||
const editor = container.querySelector('[data-lexical-editor="true"]');
|
||||
if (!editor) throw new Error('Could not find Antigravity input box');
|
||||
|
||||
editor.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
||||
}
|
||||
`);
|
||||
|
||||
// Wait for the React/Lexical state to flush the new input
|
||||
await page.wait(0.5);
|
||||
|
||||
// Press Enter to submit the message
|
||||
await page.pressKey('Enter');
|
||||
|
||||
return [{ status: 'Sent successfully', message: text }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'status',
|
||||
description: 'Check Antigravity CDP connection and get current page state',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['status', 'url', 'title'],
|
||||
func: async (page) => {
|
||||
return {
|
||||
status: 'Connected',
|
||||
url: await page.evaluate('window.location.href'),
|
||||
title: await page.evaluate('document.title'),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const watchCommand = cli({
|
||||
site: 'antigravity',
|
||||
name: 'watch',
|
||||
description: 'Stream new chat messages from Antigravity in real-time',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
timeoutSeconds: 86400, // Run for up to 24 hours
|
||||
columns: [], // We use direct stdout streaming
|
||||
func: async (page) => {
|
||||
console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
|
||||
|
||||
let lastLength = 0;
|
||||
|
||||
// Loop until process gets killed
|
||||
while (true) {
|
||||
const text = await page.evaluate(`
|
||||
async () => {
|
||||
const container = document.getElementById('conversation');
|
||||
return container ? container.innerText : '';
|
||||
}
|
||||
`);
|
||||
|
||||
const currentLength = text.length;
|
||||
if (currentLength > lastLength) {
|
||||
// Delta mode
|
||||
const newSegment = text.substring(lastLength);
|
||||
if (newSegment.trim().length > 0) {
|
||||
process.stdout.write(newSegment);
|
||||
}
|
||||
lastLength = currentLength;
|
||||
} else if (currentLength < lastLength) {
|
||||
// The conversation was cleared or updated significantly
|
||||
lastLength = currentLength;
|
||||
console.log('\\n--- Conversation Cleared/Changed ---\\n');
|
||||
process.stdout.write(text);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Barchart unusual options activity (options flow).
|
||||
* Shows high volume/OI ratio trades that may indicate institutional activity.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'flow',
|
||||
description: 'Barchart unusual options activity / options flow',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'type', type: 'str', default: 'all', help: 'Filter: all, call, or put', choices: ['all', 'call', 'put'] },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
||||
],
|
||||
columns: [
|
||||
'symbol', 'type', 'strike', 'expiration', 'last',
|
||||
'volume', 'openInterest', 'volOiRatio', 'iv',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const optionType = kwargs.type || 'all';
|
||||
const limit = kwargs.limit ?? 20;
|
||||
|
||||
await page.goto('https://www.barchart.com/options/unusual-activity/stocks');
|
||||
await page.wait(5);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const limit = ${limit};
|
||||
const typeFilter = '${optionType}'.toLowerCase();
|
||||
|
||||
// Wait for CSRF token to appear (Angular may inject it after initial render)
|
||||
let csrf = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
if (csrf) break;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
if (!csrf) return { error: 'no-csrf' };
|
||||
|
||||
const headers = { 'X-CSRF-TOKEN': csrf };
|
||||
const fields = [
|
||||
'baseSymbol','strikePrice','expirationDate','optionType',
|
||||
'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
|
||||
].join(',');
|
||||
|
||||
// Fetch extra rows when filtering by type since server-side filter doesn't work
|
||||
const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
|
||||
|
||||
// Try unusual_activity first, fall back to mostActive (unusual_activity is
|
||||
// empty outside market hours)
|
||||
const lists = [
|
||||
'options.unusual_activity.stocks.us',
|
||||
'options.mostActive.us',
|
||||
];
|
||||
|
||||
for (const list of lists) {
|
||||
try {
|
||||
const url = '/proxies/core-api/v1/options/get?list=' + list
|
||||
+ '&fields=' + fields
|
||||
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
||||
+ '&raw=1&limit=' + fetchLimit;
|
||||
|
||||
const resp = await fetch(url, { credentials: 'include', headers });
|
||||
if (!resp.ok) continue;
|
||||
const d = await resp.json();
|
||||
let items = d?.data || [];
|
||||
if (items.length === 0) continue;
|
||||
|
||||
// Apply client-side type filter
|
||||
if (typeFilter !== 'all') {
|
||||
items = items.filter(i => {
|
||||
const t = ((i.raw || i).optionType || '').toLowerCase();
|
||||
return t === typeFilter;
|
||||
});
|
||||
}
|
||||
return items.slice(0, limit).map(i => {
|
||||
const r = i.raw || i;
|
||||
return {
|
||||
symbol: r.baseSymbol || r.symbol,
|
||||
type: r.optionType,
|
||||
strike: r.strikePrice,
|
||||
expiration: r.expirationDate,
|
||||
last: r.lastPrice,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
volOiRatio: r.volumeOpenInterestRatio,
|
||||
iv: r.volatility,
|
||||
};
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
return [];
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
if (data.error === 'no-csrf') {
|
||||
throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data.slice(0, limit).map(r => ({
|
||||
symbol: r.symbol || '',
|
||||
type: r.type || '',
|
||||
strike: r.strike,
|
||||
expiration: r.expiration ?? null,
|
||||
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
volOiRatio: r.volOiRatio != null ? Number(Number(r.volOiRatio).toFixed(2)) : null,
|
||||
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Barchart options greeks overview — IV, delta, gamma, theta, vega, rho
|
||||
* for near-the-money options on a given symbol.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'greeks',
|
||||
description: 'Barchart options greeks overview (IV, delta, gamma, theta, vega)',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL)' },
|
||||
{ name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of near-the-money strikes per type' },
|
||||
],
|
||||
columns: [
|
||||
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
|
||||
'volume', 'openInterest', 'expiration',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const symbol = kwargs.symbol.toUpperCase().trim();
|
||||
const expiration = kwargs.expiration ?? '';
|
||||
const limit = kwargs.limit ?? 10;
|
||||
|
||||
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
|
||||
await page.wait(4);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const sym = '${symbol}';
|
||||
const expDate = '${expiration}';
|
||||
const limit = ${limit};
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const headers = { 'X-CSRF-TOKEN': csrf };
|
||||
|
||||
try {
|
||||
const fields = [
|
||||
'strikePrice','lastPrice','volume','openInterest',
|
||||
'volatility','delta','gamma','theta','vega','rho',
|
||||
'expirationDate','optionType','percentFromLast',
|
||||
].join(',');
|
||||
|
||||
let url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
|
||||
+ '&fields=' + fields + '&raw=1';
|
||||
if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
|
||||
const resp = await fetch(url, { credentials: 'include', headers });
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
let items = d?.data || [];
|
||||
|
||||
if (!expDate) {
|
||||
const expirations = items
|
||||
.map(i => (i.raw || i).expirationDate || null)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const aTime = Date.parse(a);
|
||||
const bTime = Date.parse(b);
|
||||
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
|
||||
if (Number.isNaN(aTime)) return 1;
|
||||
if (Number.isNaN(bTime)) return -1;
|
||||
return aTime - bTime;
|
||||
});
|
||||
const nearestExpiration = expirations[0];
|
||||
if (nearestExpiration) {
|
||||
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate calls and puts, sort by distance from current price
|
||||
const calls = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
const puts = items
|
||||
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
|
||||
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
||||
.slice(0, limit);
|
||||
|
||||
return [...calls, ...puts].map(i => {
|
||||
const r = i.raw || i;
|
||||
return {
|
||||
type: r.optionType,
|
||||
strike: r.strikePrice,
|
||||
last: r.lastPrice,
|
||||
iv: r.volatility,
|
||||
delta: r.delta,
|
||||
gamma: r.gamma,
|
||||
theta: r.theta,
|
||||
vega: r.vega,
|
||||
rho: r.rho,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
expiration: r.expirationDate,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
return [];
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || !Array.isArray(data)) return [];
|
||||
|
||||
return data.map(r => ({
|
||||
type: r.type || '',
|
||||
strike: r.strike,
|
||||
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
||||
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
||||
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
|
||||
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
|
||||
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
|
||||
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
|
||||
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
expiration: r.expiration ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Barchart options chain — strike, bid/ask, volume, OI, greeks, IV.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'options',
|
||||
description: 'Barchart options chain with greeks, IV, volume, and open interest',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL)' },
|
||||
{ name: 'type', type: 'str', default: 'Call', help: 'Option type: Call or Put', choices: ['Call', 'Put'] },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max number of strikes to return' },
|
||||
],
|
||||
columns: [
|
||||
'strike', 'bid', 'ask', 'last', 'change', 'volume', 'openInterest',
|
||||
'iv', 'delta', 'gamma', 'theta', 'vega', 'expiration',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const symbol = kwargs.symbol.toUpperCase().trim();
|
||||
const optType = kwargs.type || 'Call';
|
||||
const limit = kwargs.limit ?? 20;
|
||||
|
||||
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
|
||||
await page.wait(4);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const sym = '${symbol}';
|
||||
const type = '${optType}';
|
||||
const limit = ${limit};
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const headers = { 'X-CSRF-TOKEN': csrf };
|
||||
|
||||
// API: options chain with greeks
|
||||
try {
|
||||
const fields = [
|
||||
'strikePrice','bidPrice','askPrice','lastPrice','priceChange',
|
||||
'volume','openInterest','volatility',
|
||||
'delta','gamma','theta','vega',
|
||||
'expirationDate','optionType','percentFromLast',
|
||||
].join(',');
|
||||
|
||||
const url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
|
||||
+ '&fields=' + fields + '&raw=1';
|
||||
const resp = await fetch(url, { credentials: 'include', headers });
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
let items = d?.data || [];
|
||||
|
||||
// Filter by type
|
||||
items = items.filter(i => {
|
||||
const t = (i.raw || i).optionType || '';
|
||||
return t.toLowerCase() === type.toLowerCase();
|
||||
});
|
||||
|
||||
// Sort by closeness to current price
|
||||
items.sort((a, b) => {
|
||||
const aD = Math.abs((a.raw || a).percentFromLast || 999);
|
||||
const bD = Math.abs((b.raw || b).percentFromLast || 999);
|
||||
return aD - bD;
|
||||
});
|
||||
|
||||
return items.slice(0, limit).map(i => {
|
||||
const r = i.raw || i;
|
||||
return {
|
||||
strike: r.strikePrice,
|
||||
bid: r.bidPrice,
|
||||
ask: r.askPrice,
|
||||
last: r.lastPrice,
|
||||
change: r.priceChange,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
iv: r.volatility,
|
||||
delta: r.delta,
|
||||
gamma: r.gamma,
|
||||
theta: r.theta,
|
||||
vega: r.vega,
|
||||
expiration: r.expirationDate,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
return [];
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || !Array.isArray(data)) return [];
|
||||
|
||||
return data.map(r => ({
|
||||
strike: r.strike,
|
||||
bid: r.bid != null ? Number(Number(r.bid).toFixed(2)) : null,
|
||||
ask: r.ask != null ? Number(Number(r.ask).toFixed(2)) : null,
|
||||
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
||||
change: r.change != null ? Number(Number(r.change).toFixed(2)) : null,
|
||||
volume: r.volume,
|
||||
openInterest: r.openInterest,
|
||||
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
||||
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
|
||||
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
|
||||
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
|
||||
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
|
||||
expiration: r.expiration ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Barchart stock quote — price, volume, market cap, P/E, EPS, and key metrics.
|
||||
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'barchart',
|
||||
name: 'quote',
|
||||
description: 'Barchart stock quote with price, volume, and key metrics',
|
||||
domain: 'www.barchart.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' },
|
||||
],
|
||||
columns: [
|
||||
'symbol', 'name', 'price', 'change', 'changePct',
|
||||
'open', 'high', 'low', 'prevClose', 'volume',
|
||||
'avgVolume', 'marketCap', 'peRatio', 'eps',
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const symbol = kwargs.symbol.toUpperCase().trim();
|
||||
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/overview`);
|
||||
await page.wait(4);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const sym = '${symbol}';
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
// Strategy 1: internal proxy API with CSRF token
|
||||
try {
|
||||
const fields = [
|
||||
'symbol','symbolName','lastPrice','priceChange','percentChange',
|
||||
'highPrice','lowPrice','openPrice','previousPrice','volume','averageVolume',
|
||||
'marketCap','peRatio','earningsPerShare','tradeTime',
|
||||
].join(',');
|
||||
const url = '/proxies/core-api/v1/quotes/get?symbol=' + encodeURIComponent(sym) + '&fields=' + fields;
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRF-TOKEN': csrf },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
const row = d?.data?.[0] || null;
|
||||
if (row) {
|
||||
return { source: 'api', row };
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Strategy 2: parse from DOM
|
||||
try {
|
||||
const priceEl = document.querySelector('span.last-change');
|
||||
const price = priceEl ? priceEl.textContent.trim() : null;
|
||||
|
||||
// Change values are sibling spans inside .pricechangerow > .last-change
|
||||
const changeParent = priceEl?.parentElement;
|
||||
const changeSpans = changeParent ? changeParent.querySelectorAll('span') : [];
|
||||
let change = null;
|
||||
let changePct = null;
|
||||
for (const s of changeSpans) {
|
||||
const t = s.textContent.trim();
|
||||
if (s === priceEl) continue;
|
||||
if (t.includes('%')) changePct = t.replace(/[()]/g, '');
|
||||
else if (t.match(/^[+-]?[\\d.]+$/)) change = t;
|
||||
}
|
||||
|
||||
// Financial data rows
|
||||
const rows = document.querySelectorAll('.financial-data-row');
|
||||
const fdata = {};
|
||||
for (const row of rows) {
|
||||
const spans = row.querySelectorAll('span');
|
||||
if (spans.length >= 2) {
|
||||
const label = spans[0].textContent.trim();
|
||||
const valSpan = row.querySelector('span.right span:not(.ng-hide)');
|
||||
fdata[label] = valSpan ? valSpan.textContent.trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Day high/low from row chart
|
||||
const dayLow = document.querySelector('.bc-quote-row-chart .small-6:first-child .inline:not(.ng-hide)');
|
||||
const dayHigh = document.querySelector('.bc-quote-row-chart .text-right .inline:not(.ng-hide)');
|
||||
const openEl = document.querySelector('.mark span');
|
||||
const openText = openEl ? openEl.textContent.trim().replace('Open ', '') : null;
|
||||
|
||||
const name = document.querySelector('h1 span.symbol');
|
||||
|
||||
return {
|
||||
source: 'dom',
|
||||
row: {
|
||||
symbol: sym,
|
||||
symbolName: name ? name.textContent.trim() : sym,
|
||||
lastPrice: price,
|
||||
priceChange: change,
|
||||
percentChange: changePct,
|
||||
open: openText,
|
||||
highPrice: dayHigh ? dayHigh.textContent.trim() : null,
|
||||
lowPrice: dayLow ? dayLow.textContent.trim() : null,
|
||||
previousClose: fdata['Previous Close'] || null,
|
||||
volume: fdata['Volume'] || null,
|
||||
averageVolume: fdata['Average Volume'] || null,
|
||||
marketCap: null,
|
||||
peRatio: null,
|
||||
earningsPerShare: null,
|
||||
}
|
||||
};
|
||||
} catch(e) {
|
||||
return { error: 'Could not fetch quote for ' + sym + ': ' + e.message };
|
||||
}
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || data.error) return [];
|
||||
|
||||
const r = data.row || {};
|
||||
// API returns formatted strings like "+1.41" and "+0.56%"; use raw if available
|
||||
const raw = r.raw || {};
|
||||
|
||||
return [{
|
||||
symbol: r.symbol || symbol,
|
||||
name: r.symbolName || r.name || symbol,
|
||||
price: r.lastPrice ?? null,
|
||||
change: r.priceChange ?? null,
|
||||
changePct: r.percentChange ?? null,
|
||||
open: r.openPrice ?? r.open ?? null,
|
||||
high: r.highPrice ?? null,
|
||||
low: r.lowPrice ?? null,
|
||||
prevClose: r.previousPrice ?? r.previousClose ?? null,
|
||||
volume: r.volume ?? null,
|
||||
avgVolume: r.averageVolume ?? null,
|
||||
marketCap: r.marketCap ?? null,
|
||||
peRatio: r.peRatio ?? null,
|
||||
eps: r.earningsPerShare ?? null,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
|
||||
*
|
||||
* Uses securityId from search results to call the detail API.
|
||||
* Returns: job description, skills, welfare, boss info, company info, address.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'detail',
|
||||
description: 'BOSS直聘查看职位详情',
|
||||
domain: 'www.zhipin.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
|
||||
],
|
||||
columns: [
|
||||
'name', 'salary', 'experience', 'degree', 'city', 'district',
|
||||
'description', 'skills', 'welfare',
|
||||
'boss_name', 'boss_title', 'active_time',
|
||||
'company', 'industry', 'scale', 'stage',
|
||||
'address', 'url',
|
||||
],
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
if (!page) throw new Error('Browser page required');
|
||||
|
||||
const securityId = kwargs.security_id;
|
||||
|
||||
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
|
||||
await page.goto('https://www.zhipin.com/web/geek/job');
|
||||
await page.wait({ time: 1 });
|
||||
|
||||
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
|
||||
|
||||
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
|
||||
console.error(`[opencli:boss] Fetching job detail...`);
|
||||
}
|
||||
|
||||
const evaluateScript = `
|
||||
async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new window.XMLHttpRequest();
|
||||
xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = 15000;
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
|
||||
}
|
||||
} else {
|
||||
reject(new Error('XHR HTTP Status: ' + xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('XHR Network Error'));
|
||||
xhr.ontimeout = () => reject(new Error('XHR Timeout'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await page.evaluate(evaluateScript);
|
||||
} catch (e: any) {
|
||||
throw new Error('API evaluate failed: ' + e.message);
|
||||
}
|
||||
|
||||
if (data.code !== 0) {
|
||||
if (data.code === 37) {
|
||||
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
||||
}
|
||||
throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
|
||||
}
|
||||
|
||||
const zpData = data.zpData || {};
|
||||
const jobInfo = zpData.jobInfo || {};
|
||||
const bossInfo = zpData.bossInfo || {};
|
||||
const brandComInfo = zpData.brandComInfo || {};
|
||||
|
||||
if (!jobInfo.jobName) {
|
||||
throw new Error('该职位信息不存在或已下架');
|
||||
}
|
||||
|
||||
return [{
|
||||
name: jobInfo.jobName || '',
|
||||
salary: jobInfo.salaryDesc || '',
|
||||
experience: jobInfo.experienceName || '',
|
||||
degree: jobInfo.degreeName || '',
|
||||
city: jobInfo.locationName || '',
|
||||
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
|
||||
description: jobInfo.postDescription || '',
|
||||
skills: (jobInfo.showSkills || []).join(', '),
|
||||
welfare: (brandComInfo.labels || []).join(', '),
|
||||
boss_name: bossInfo.name || '',
|
||||
boss_title: bossInfo.title || '',
|
||||
active_time: bossInfo.activeTimeDesc || '',
|
||||
company: brandComInfo.brandName || bossInfo.brandName || '',
|
||||
industry: brandComInfo.industryName || '',
|
||||
scale: brandComInfo.scaleName || '',
|
||||
stage: brandComInfo.stageName || '',
|
||||
address: jobInfo.address || '',
|
||||
url: jobInfo.encryptId
|
||||
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
|
||||
: '',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -81,7 +81,7 @@ cli({
|
||||
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
||||
],
|
||||
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
|
||||
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
||||
func: async (page: IPage | null, kwargs) => {
|
||||
if (!page) throw new Error('Browser page required');
|
||||
|
||||
@@ -191,6 +191,7 @@ cli({
|
||||
degree: j.jobDegree,
|
||||
skills: (j.skills || []).join(','),
|
||||
boss: j.bossName + ' · ' + j.bossTitle,
|
||||
security_id: j.securityId || '',
|
||||
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
||||
});
|
||||
addedInBatch++;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# OpenAI Codex Adapter for OpenCLI
|
||||
|
||||
Control the **OpenAI Codex Desktop App** headless or headfully via Chrome DevTools Protocol (CDP).
|
||||
Because Codex is built on Electron, OpenCLI can directly drive its internal UI, automate slash commands, and manipulate its AI agent threads.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. You must have the official OpenAI Codex app installed.
|
||||
2. Launch it via the terminal and expose the remote debugging port:
|
||||
```bash
|
||||
# macOS
|
||||
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Export the CDP endpoint in your shell:
|
||||
```bash
|
||||
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Diagnostics
|
||||
- `opencli codex status`: Checks connection and reads the current active window URL/title.
|
||||
- `opencli codex dump`: Dumps the full UI DOM and Accessibility tree into `/tmp` (ideal for building AI automation tools on top of it).
|
||||
|
||||
### Agent Manipulation
|
||||
- `opencli codex new`: Simulates `Cmd+N` to start a completely fresh and isolated Git Worktree thread context.
|
||||
- `opencli codex send "message"`: Robustly finds the active Thread Composer and injects your text.
|
||||
- *Pro-tip*: You can trigger internal shortcuts by sending them, e.g., `opencli codex send "/review"` or `opencli codex send "$imagegen draw a cat"`.
|
||||
- `opencli codex read`: Extracts the entire current thread history and AI reasoning logs into readable text.
|
||||
- `opencli codex extract-diff`: Automatically scrapes any visual Patch chunks and Code Diffs the AI generated inside the review UI.
|
||||
@@ -0,0 +1,33 @@
|
||||
# OpenAI Codex 桌面端适配器 (OpenCLI)
|
||||
|
||||
利用 CDP 协议,直接从命令行/外部脚本接管和操控 **OpenAI Codex 官方桌面版**。
|
||||
因为官方 Codex 是基于 Electron 构建的“多 Agent 协作中心”,通过本适配器,你可以让 AI 自动控制另一个 AI 完成工作,甚至自动截取代码审查的 Diff!
|
||||
|
||||
## 前置环境准备
|
||||
|
||||
1. 你必须下载并安装了官方原版的 OpenAI Codex 客户端。
|
||||
2. 必须通过命令行挂载 CDP 调试端口启动它:
|
||||
```bash
|
||||
# macOS 启动示例
|
||||
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## 配置指南
|
||||
|
||||
在你要运行命令的终端里导出环境变量:
|
||||
```bash
|
||||
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## 核心指令
|
||||
|
||||
### 探查与调试
|
||||
- `opencli codex status`: 检查是否成功连上内部 Chromium,获取上下文 Title。
|
||||
- `opencli codex dump`: 强制剥离整个 App 的内部 DOM 树和无障碍视图并保存到 `/tmp`,是编写复杂自动化 RPA 脚本的终极利刃。
|
||||
|
||||
### 自动化执行
|
||||
- `opencli codex new`: 模拟按下 `Cmd+N`。建立一个彻底干净、隔离了 Git Worktree 的全线并行 Thread。
|
||||
- `opencli codex send "要发送的话"`: 强行跨越 Shadow Root 找到对应的富文本编辑器并注入提词。
|
||||
- *高阶技巧*: 你可以直接发送内置宏!例如 `opencli codex send "/review"` 就能触发本工作流的代码审查,或者 `opencli codex send "$imagegen"` 触发技能。
|
||||
- `opencli codex read`: 完整抓取并提取整个当前 Thread 里的思考过程和对话日志。
|
||||
- `opencli codex extract-diff`: 专门用于拦截并提取由 AI 建议的 `+` / `-` 代码 Patch 修改块,直接输出结构化数据!
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const extractDiffCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'extract-diff',
|
||||
description: 'Extract visual code review diff patches from Codex',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['File', 'Diff'],
|
||||
func: async (page) => {
|
||||
const diffs = await page.evaluate(`
|
||||
(function() {
|
||||
const results = [];
|
||||
// Assuming diffs are rendered with standard diff classes or monaco difference editors
|
||||
const diffBlocks = document.querySelectorAll('.diff-editor, .monaco-diff-editor, [data-testid="diff-view"]');
|
||||
|
||||
diffBlocks.forEach((block, index) => {
|
||||
// Very roughly scrape text representing additions/deletions mapped from the inner wrapper
|
||||
results.push({
|
||||
File: block.getAttribute('data-filename') || \`DiffBlock_\${index+1}\`,
|
||||
Diff: block.innerText || block.textContent
|
||||
});
|
||||
});
|
||||
|
||||
// If no structured diffs found, try to find any code blocks labeled as patches
|
||||
if (results.length === 0) {
|
||||
const codeBlocks = document.querySelectorAll('pre code.language-diff, pre code.language-patch');
|
||||
codeBlocks.forEach((code, index) => {
|
||||
results.push({
|
||||
File: \`Patch_\${index+1}\`,
|
||||
Diff: code.innerText || code.textContent
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (diffs.length === 0) {
|
||||
return [{ File: 'No diffs found', Diff: 'Try running opencli codex send "/review" first' }];
|
||||
}
|
||||
|
||||
return diffs;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'model',
|
||||
description: 'Get or switch the currently active AI model in Codex Desktop',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. gpt-4)' }
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const desiredModel = kwargs.model_name as string | undefined;
|
||||
|
||||
if (!desiredModel) {
|
||||
// Just read the current model. We traverse iframes/webviews if needed.
|
||||
const currentModel = await page.evaluate(`
|
||||
(function() {
|
||||
// Look for any typical model switcher selectors in the DOM
|
||||
let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
|
||||
|
||||
if (!m && document.querySelector('webview, iframe')) {
|
||||
// Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[].
|
||||
return 'Unknown (Likely inside a WebView, please focus the Chat tab)';
|
||||
}
|
||||
return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found';
|
||||
})()
|
||||
`);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Active',
|
||||
Model: currentModel,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Try to switch model (click dropdown, type/select model)
|
||||
const success = await page.evaluate(`
|
||||
(function(targetModel) {
|
||||
const dropdown = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
|
||||
if (!dropdown) return 'Dropdown not found';
|
||||
|
||||
dropdown.click();
|
||||
return 'Dropdown clicked. Generic interaction initiated.';
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: success,
|
||||
Model: desiredModel,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
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`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'read',
|
||||
description: 'Read the contents of the current Codex conversation thread',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Thread_Content'],
|
||||
func: async (page) => {
|
||||
const historyText = await page.evaluate(`
|
||||
(function() {
|
||||
// Precise Codex selector for chat messages
|
||||
const turns = Array.from(document.querySelectorAll('[data-content-search-turn-key]'));
|
||||
if (turns.length > 0) {
|
||||
return turns.map(t => t.innerText || t.textContent).join('\\n\\n---\\n\\n');
|
||||
}
|
||||
|
||||
// Fallback robust scraping heuristic for chat history panes
|
||||
const threadContainer = document.querySelector('[role="log"], [data-testid="conversation"], .thread-container, .messages-list, main');
|
||||
|
||||
if (threadContainer) {
|
||||
return threadContainer.innerText || threadContainer.textContent;
|
||||
}
|
||||
|
||||
// If specific containers fail, just dump the whole body's readable text minus the navigation
|
||||
return document.body.innerText;
|
||||
})()
|
||||
`);
|
||||
|
||||
return [
|
||||
{
|
||||
Thread_Content: historyText,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'send',
|
||||
description: 'Send text/commands to the Codex AI composer',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [{ name: 'text', required: true, positional: true, help: 'Text, command (e.g. /review), or skill (e.g. $imagegen)' }],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page, kwargs) => {
|
||||
const textToInsert = kwargs.text as string;
|
||||
|
||||
// We use evaluate to inject text bypassing complex nested shadow roots or contenteditables
|
||||
await page.evaluate(`
|
||||
(function(text) {
|
||||
// Attempt 1: Look for standard textarea/composer input
|
||||
let composer = document.querySelector('textarea, [contenteditable="true"]');
|
||||
|
||||
// Basic heuristic: prioritize elements that are deeply nested, visible, and have 'composer' or 'input' classes
|
||||
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
|
||||
if (editables.length > 0) {
|
||||
composer = editables[editables.length - 1]; // Often the active input is appended near the end
|
||||
}
|
||||
|
||||
if (!composer) {
|
||||
throw new Error('Could not find Composer input element in Codex UI');
|
||||
}
|
||||
|
||||
composer.focus();
|
||||
|
||||
// This handles Lexical/ProseMirror/Monaco rich-text editors effectively by mimicking human paste/type deeply.
|
||||
document.execCommand('insertText', false, text);
|
||||
})(${JSON.stringify(textToInsert)})
|
||||
`);
|
||||
|
||||
// Simulate Enter key to submit
|
||||
await page.pressKey('Enter');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
InjectedText: textToInsert,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'status',
|
||||
description: 'Check active CDP connection to OpenAI Codex App',
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const composerCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'composer',
|
||||
description: 'Send a prompt directly into Cursor Composer (Cmd+I shortcut)',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Composer' }],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const textToInsert = kwargs.text as string;
|
||||
|
||||
const injected = await page.evaluate(
|
||||
`(async function() {
|
||||
let isComposerVisible = document.querySelector('.composer-bar') !== null || document.querySelector('#composer-toolbar-section') !== null;
|
||||
return isComposerVisible;
|
||||
})()`
|
||||
);
|
||||
|
||||
if (!injected) {
|
||||
await page.pressKey('Meta+I');
|
||||
await page.wait(1.0);
|
||||
} else {
|
||||
// Just focus it if it's open but unfocused (we can't easily know if it's focused without triggering something)
|
||||
await page.pressKey('Meta+I');
|
||||
await page.wait(0.2);
|
||||
const isStillVisible = await page.evaluate('document.querySelector(".composer-bar") !== null');
|
||||
if (!isStillVisible) {
|
||||
await page.pressKey('Meta+I'); // Re-open
|
||||
await page.wait(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
const typed = await page.evaluate(
|
||||
`(function(text) {
|
||||
let composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input');
|
||||
|
||||
if (!composer) {
|
||||
composer = document.activeElement;
|
||||
if (!composer || !composer.isContentEditable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`
|
||||
);
|
||||
|
||||
if (!typed) {
|
||||
throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
}
|
||||
|
||||
// Submit the command. In Cursor Composer, Enter usually submits if it's not a multi-line edit.
|
||||
// Sometimes Cmd+Enter is needed? We'll just submit standard Enter.
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
await page.wait(1);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success (Composer)',
|
||||
InjectedText: textToInsert,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const extractCodeCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'extract-code',
|
||||
description: 'Extract multi-line code blocks from the current Cursor conversation',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Code'],
|
||||
func: async (page: IPage) => {
|
||||
const blocks = await page.evaluate(`
|
||||
(function() {
|
||||
// Find standard pre/code blocks
|
||||
let elements = Array.from(document.querySelectorAll('pre code, .markdown-root pre'));
|
||||
|
||||
// Fallback to Monaco editor content inside the UI
|
||||
if (elements.length === 0) {
|
||||
elements = Array.from(document.querySelectorAll('.monaco-editor'));
|
||||
}
|
||||
|
||||
// Generic fallback to any code tag that spans multiple lines
|
||||
if (elements.length === 0) {
|
||||
elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
|
||||
}
|
||||
|
||||
return elements.map(el => el.innerText || el.textContent || '').filter(text => text.trim().length > 0);
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return [{ Code: 'No code blocks found in Cursor.' }];
|
||||
}
|
||||
|
||||
return blocks.map((code: string) => ({ Code: code }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'model',
|
||||
description: 'Get or switch the currently active AI model in Cursor',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. claude-3.5-sonnet)' }
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const desiredModel = kwargs.model_name as string | undefined;
|
||||
|
||||
if (!desiredModel) {
|
||||
// Just read the current model
|
||||
const currentModel = await page.evaluate(`
|
||||
(function() {
|
||||
const m = document.querySelector('.composer-unified-dropdown-model span, [class*="unifiedmodeldropdown"] span');
|
||||
return m ? m.textContent.trim() : 'Unknown or Not Found';
|
||||
})()
|
||||
`);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Active',
|
||||
Model: currentModel,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Try to switch model (click dropdown, type/select model)
|
||||
const success = await page.evaluate(`
|
||||
(function(targetModel) {
|
||||
const dropdown = document.querySelector('.composer-unified-dropdown-model, [class*="unifiedmodeldropdown"]');
|
||||
if (!dropdown) return 'Dropdown not found';
|
||||
|
||||
dropdown.click();
|
||||
// After clicking, the DOM usually spawns a popup list.
|
||||
// Because it's hard to predict exactly how the list renders,
|
||||
// a simple scriptable approach is just to click it, and hope we can select it via UI.
|
||||
// In many React apps, clicking it opens a menu, and clicking the item works.
|
||||
return 'Dropdown opened. Automated switching is not fully generic. Please implement precise list navigation depending on DOM.';
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: success,
|
||||
Model: desiredModel,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'new',
|
||||
description: 'Start a new Cursor chat or Composer session',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const success = await page.evaluate(`
|
||||
(function() {
|
||||
const newChatButton = document.querySelector('[aria-label="New Chat"], [aria-label="New Chat (⌘N)"], .agent-sidebar-new-agent-button');
|
||||
if (newChatButton) {
|
||||
newChatButton.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Could not find New Chat button in Cursor DOM.');
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
return [{ Status: 'Success' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'read',
|
||||
description: 'Read the current Cursor chat/composer conversation history',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Role', 'Text'],
|
||||
func: async (page: IPage) => {
|
||||
const history = await page.evaluate(`
|
||||
(function() {
|
||||
const messages = Array.from(document.querySelectorAll('[data-message-role]'));
|
||||
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return messages.map(msg => {
|
||||
const role = msg.getAttribute('data-message-role');
|
||||
let text = '';
|
||||
|
||||
// Try to get structured markdown root for AI, or lexical text for human
|
||||
const markdownRoot = msg.querySelector('.markdown-root');
|
||||
if (markdownRoot) {
|
||||
text = markdownRoot.innerText || markdownRoot.textContent;
|
||||
} else {
|
||||
text = msg.innerText || msg.textContent;
|
||||
}
|
||||
|
||||
return {
|
||||
Role: role === 'human' ? 'User' : 'Assistant',
|
||||
Text: text.trim()
|
||||
};
|
||||
});
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
throw new Error('No conversation history found in Cursor.');
|
||||
}
|
||||
|
||||
return history;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'send',
|
||||
description: 'Send a prompt directly into Cursor Composer/Chat',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Cursor' }],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const textToInsert = kwargs.text as string;
|
||||
|
||||
const injected = await page.evaluate(
|
||||
`(function(text) {
|
||||
// Find the Lexical editor input for Composer or Chat
|
||||
let composer = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]');
|
||||
|
||||
if (!composer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`
|
||||
);
|
||||
|
||||
if (!injected) {
|
||||
throw new Error('Could not find Cursor Composer input element.');
|
||||
}
|
||||
|
||||
// Submit the command. In Cursor, Enter usually submits the chat.
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
await page.wait(1);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
InjectedText: textToInsert,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
// ── Filter value mappings ──────────────────────────────────────────────
|
||||
|
||||
const EXPERIENCE_LEVELS: Record<string, string> = {
|
||||
internship: '1',
|
||||
entry: '2',
|
||||
'entry-level': '2',
|
||||
associate: '3',
|
||||
mid: '4',
|
||||
senior: '4',
|
||||
'mid-senior': '4',
|
||||
'mid-senior-level': '4',
|
||||
director: '5',
|
||||
executive: '6',
|
||||
};
|
||||
|
||||
const JOB_TYPES: Record<string, string> = {
|
||||
'full-time': 'F',
|
||||
fulltime: 'F',
|
||||
full: 'F',
|
||||
'part-time': 'P',
|
||||
parttime: 'P',
|
||||
part: 'P',
|
||||
contract: 'C',
|
||||
temporary: 'T',
|
||||
temp: 'T',
|
||||
volunteer: 'V',
|
||||
internship: 'I',
|
||||
other: 'O',
|
||||
};
|
||||
|
||||
const DATE_POSTED: Record<string, string> = {
|
||||
any: 'on',
|
||||
month: 'r2592000',
|
||||
'past-month': 'r2592000',
|
||||
week: 'r604800',
|
||||
'past-week': 'r604800',
|
||||
day: 'r86400',
|
||||
'24h': 'r86400',
|
||||
'past-24h': 'r86400',
|
||||
};
|
||||
|
||||
const REMOTE_TYPES: Record<string, string> = {
|
||||
onsite: '1',
|
||||
'on-site': '1',
|
||||
hybrid: '3',
|
||||
remote: '2',
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCsvArg(value: unknown): string[] {
|
||||
if (value === undefined || value === null || value === '') return [];
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function mapFilterValues(input: unknown, mapping: Record<string, string>, label: string): string[] {
|
||||
const values = parseCsvArg(input);
|
||||
const resolved = values.map(value => {
|
||||
const key = value.toLowerCase();
|
||||
const mapped = mapping[key];
|
||||
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
|
||||
return mapped;
|
||||
});
|
||||
return [...new Set(resolved)];
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: unknown): string {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function decodeLinkedinRedirect(url: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname === '/redir/redirect/') {
|
||||
return parsed.searchParams.get('url') || url;
|
||||
}
|
||||
} catch {}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ── Voyager query builder (runs in Node, NOT inside page.evaluate) ────
|
||||
|
||||
interface SearchInput {
|
||||
keywords: string;
|
||||
location: string;
|
||||
limit: number;
|
||||
start: number;
|
||||
companyIds: string[];
|
||||
experienceLevels: string[];
|
||||
jobTypes: string[];
|
||||
datePostedValues: string[];
|
||||
remoteTypes: string[];
|
||||
}
|
||||
|
||||
function buildVoyagerSearchQuery(input: SearchInput): string {
|
||||
const hasFilters =
|
||||
input.companyIds.length ||
|
||||
input.experienceLevels.length ||
|
||||
input.jobTypes.length ||
|
||||
input.datePostedValues.length ||
|
||||
input.remoteTypes.length;
|
||||
|
||||
const parts = [
|
||||
'origin:' + (hasFilters ? 'JOB_SEARCH_PAGE_JOB_FILTER' : 'JOB_SEARCH_PAGE_OTHER_ENTRY'),
|
||||
'keywords:' + input.keywords,
|
||||
];
|
||||
if (input.location) {
|
||||
parts.push('locationUnion:(seoLocation:(location:' + input.location + '))');
|
||||
}
|
||||
const filters: string[] = [];
|
||||
if (input.companyIds.length) filters.push('company:List(' + input.companyIds.join(',') + ')');
|
||||
if (input.experienceLevels.length) filters.push('experience:List(' + input.experienceLevels.join(',') + ')');
|
||||
if (input.jobTypes.length) filters.push('jobType:List(' + input.jobTypes.join(',') + ')');
|
||||
if (input.datePostedValues.length) filters.push('timePostedRange:List(' + input.datePostedValues.join(',') + ')');
|
||||
if (input.remoteTypes.length) filters.push('workplaceType:List(' + input.remoteTypes.join(',') + ')');
|
||||
if (filters.length) parts.push('selectedFilters:(' + filters.join(',') + ')');
|
||||
parts.push('spellCorrectionEnabled:true');
|
||||
return '(' + parts.join(',') + ')';
|
||||
}
|
||||
|
||||
function buildVoyagerUrl(input: SearchInput, offset: number, count: number): string {
|
||||
const params = new URLSearchParams({
|
||||
decorationId: 'com.linkedin.voyager.dash.deco.jobs.search.JobSearchCardsCollection-220',
|
||||
count: String(count),
|
||||
q: 'jobSearch',
|
||||
});
|
||||
const query = encodeURIComponent(buildVoyagerSearchQuery(input))
|
||||
.replace(/%3A/gi, ':')
|
||||
.replace(/%2C/gi, ',')
|
||||
.replace(/%28/gi, '(')
|
||||
.replace(/%29/gi, ')');
|
||||
return '/voyager/api/voyagerJobsDashJobCards?' + params.toString() + '&query=' + query + '&start=' + offset;
|
||||
}
|
||||
|
||||
// ── Company ID resolution (requires DOM interaction) ──────────────────
|
||||
|
||||
async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]> {
|
||||
const rawValues = parseCsvArg(input);
|
||||
const ids = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
||||
for (const value of rawValues) {
|
||||
if (/^\d+$/.test(value)) ids.add(value);
|
||||
else names.push(value);
|
||||
}
|
||||
|
||||
if (!names.length) return [...ids];
|
||||
|
||||
const resolved = await page.evaluate(`(async () => {
|
||||
const targets = ${JSON.stringify(names)};
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const normalize = (v) => (v || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
||||
|
||||
// Open "All filters" panel to expose company filter inputs
|
||||
const allBtn = [...document.querySelectorAll('button')]
|
||||
.find(b => ((b.innerText || '').trim().replace(/\\s+/g, ' ')) === 'All filters');
|
||||
if (allBtn) { allBtn.click(); await sleep(300); }
|
||||
|
||||
const getCompanyMap = () => {
|
||||
const map = {};
|
||||
for (const el of document.querySelectorAll('input[name="company-filter-value"]')) {
|
||||
const text = (el.parentElement?.innerText || el.closest('label')?.innerText || '')
|
||||
.replace(/\\s+/g, ' ').trim().replace(/\\s*Filter by.*$/i, '').trim();
|
||||
if (text) map[normalize(text)] = el.value;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const match = (map, name) => {
|
||||
const n = normalize(name);
|
||||
if (map[n]) return map[n];
|
||||
const k = Object.keys(map).find(e => e === n || e.includes(n) || n.includes(e));
|
||||
return k ? map[k] : null;
|
||||
};
|
||||
|
||||
const results = {};
|
||||
let map = getCompanyMap();
|
||||
|
||||
for (const name of targets) {
|
||||
let found = match(map, name);
|
||||
if (!found) {
|
||||
const inp = [...document.querySelectorAll('input')]
|
||||
.find(el => el.getAttribute('aria-label') === 'Add a company');
|
||||
if (inp) {
|
||||
inp.focus();
|
||||
inp.value = name;
|
||||
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
|
||||
await sleep(1200);
|
||||
map = getCompanyMap();
|
||||
found = match(map, name);
|
||||
inp.value = '';
|
||||
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
results[name] = found || null;
|
||||
}
|
||||
return results;
|
||||
})()`);
|
||||
|
||||
const unresolved: string[] = [];
|
||||
for (const name of names) {
|
||||
const id = resolved?.[name];
|
||||
if (id) ids.add(id);
|
||||
else unresolved.push(name);
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// ── Voyager API fetch (runs inside page context for cookie access) ────
|
||||
|
||||
async function fetchJobCards(
|
||||
page: IPage,
|
||||
input: SearchInput,
|
||||
): Promise<Array<Record<string, any>>> {
|
||||
const MAX_BATCH = 25;
|
||||
const allJobs: Array<Record<string, any>> = [];
|
||||
let offset = input.start;
|
||||
|
||||
while (allJobs.length < input.limit) {
|
||||
const count = Math.min(MAX_BATCH, input.limit - allJobs.length);
|
||||
const apiPath = buildVoyagerUrl(input, offset, count);
|
||||
|
||||
const batch = await page.evaluate(`(async () => {
|
||||
const jsession = document.cookie.split(';').map(p => p.trim())
|
||||
.find(p => p.startsWith('JSESSIONID='))?.slice('JSESSIONID='.length);
|
||||
if (!jsession) return { error: 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.' };
|
||||
|
||||
const csrf = jsession.replace(/^"|"$/g, '');
|
||||
const res = await fetch(${JSON.stringify(apiPath)}, {
|
||||
credentials: 'include',
|
||||
headers: { 'csrf-token': csrf, 'x-restli-protocol-version': '2.0.0' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: 'LinkedIn API error: HTTP ' + res.status + ' ' + text.slice(0, 200) };
|
||||
}
|
||||
return res.json();
|
||||
})()`);
|
||||
|
||||
if (!batch || batch.error) {
|
||||
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
|
||||
}
|
||||
|
||||
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
|
||||
if (elements.length === 0) break;
|
||||
|
||||
for (const element of elements) {
|
||||
const card = element?.jobCardUnion?.jobPostingCard;
|
||||
if (!card) continue;
|
||||
|
||||
// Extract job ID from URN fields
|
||||
const jobId = [card.jobPostingUrn, card.jobPosting?.entityUrn, card.entityUrn]
|
||||
.filter(Boolean)
|
||||
.map(s => String(s).match(/(\d+)/)?.[1])
|
||||
.find(Boolean) ?? '';
|
||||
|
||||
// Extract listed date
|
||||
const listedItem = (card.footerItems || []).find((i: any) => i?.type === 'LISTED_DATE' && i?.timeAt);
|
||||
const listed = listedItem?.timeAt ? new Date(listedItem.timeAt).toISOString().slice(0, 10) : '';
|
||||
|
||||
allJobs.push({
|
||||
title: card.jobPostingTitle || card.title?.text || '',
|
||||
company: card.primaryDescription?.text || '',
|
||||
location: card.secondaryDescription?.text || '',
|
||||
listed,
|
||||
salary: card.tertiaryDescription?.text || '',
|
||||
url: jobId ? 'https://www.linkedin.com/jobs/view/' + jobId : '',
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.length < count) break;
|
||||
offset += elements.length;
|
||||
}
|
||||
|
||||
return allJobs.slice(0, input.limit).map((item, index) => ({
|
||||
rank: input.start + index + 1,
|
||||
...item,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Job detail enrichment (--details flag) ────────────────────────────
|
||||
|
||||
async function enrichJobDetails(
|
||||
page: IPage,
|
||||
jobs: Array<Record<string, any>>,
|
||||
): Promise<Array<Record<string, any>>> {
|
||||
const enriched: Array<Record<string, any>> = [];
|
||||
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i];
|
||||
console.error(`[opencli:linkedin] Fetching details ${i + 1}/${jobs.length}: ${job.title}`);
|
||||
|
||||
if (!job.url) {
|
||||
enriched.push({ ...job, description: '', apply_url: '' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(job.url);
|
||||
await page.wait({ text: 'About the job', timeout: 8 });
|
||||
|
||||
// Expand "Show more" button if present
|
||||
await page.evaluate(`(() => {
|
||||
const norm = (v) => (v || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
||||
const section = [...document.querySelectorAll('div, section, article')]
|
||||
.find(el => norm(el.querySelector('h1,h2,h3,h4')?.textContent || '') === 'about the job');
|
||||
const btn = [...(section?.querySelectorAll('button, a[role="button"]') || [])]
|
||||
.find(el => /more/.test(norm(el.textContent || '')) || /more/.test(norm(el.getAttribute('aria-label') || '')));
|
||||
if (btn) btn.click();
|
||||
})()`);
|
||||
await page.wait(1);
|
||||
|
||||
// Extract description and apply URL
|
||||
const detail = await page.evaluate(`(() => {
|
||||
const norm = (v) => (v || '').replace(/\\s+/g, ' ').trim();
|
||||
// Find the most specific (shortest) container with "About the job" heading
|
||||
// Shortest = most specific DOM node, avoiding outer wrappers that include unrelated text
|
||||
const candidates = [...document.querySelectorAll('div, section, article')]
|
||||
.map(el => ({
|
||||
heading: norm(el.querySelector('h1,h2,h3,h4')?.textContent || ''),
|
||||
text: norm(el.innerText || ''),
|
||||
}))
|
||||
.filter(c => c.text && c.heading.toLowerCase() === 'about the job' && c.text.length > 'About the job'.length)
|
||||
.sort((a, b) => a.text.length - b.text.length);
|
||||
|
||||
const description = candidates[0]?.text.replace(/^About the job\\s*/i, '') || '';
|
||||
const applyLink = [...document.querySelectorAll('a[href]')]
|
||||
.map(a => ({ href: a.href || '', text: norm(a.textContent || ''), aria: norm(a.getAttribute('aria-label') || '') }))
|
||||
.find(a => /apply/i.test(a.text) || /apply/i.test(a.aria));
|
||||
|
||||
return { description, applyUrl: applyLink?.href || '' };
|
||||
})()`);
|
||||
|
||||
enriched.push({
|
||||
...job,
|
||||
description: normalizeWhitespace(detail?.description),
|
||||
apply_url: decodeLinkedinRedirect(String(detail?.applyUrl ?? '')),
|
||||
});
|
||||
} catch {
|
||||
enriched.push({ ...job, description: '', apply_url: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
// ── CLI registration ──────────────────────────────────────────────────
|
||||
|
||||
cli({
|
||||
site: 'linkedin',
|
||||
name: 'search',
|
||||
description: 'Search LinkedIn jobs',
|
||||
domain: 'www.linkedin.com',
|
||||
strategy: Strategy.HEADER,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', required: true, help: 'Job search keywords' },
|
||||
{ name: 'location', type: 'string', required: false, help: 'Location text such as San Francisco Bay Area' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Number of jobs to return (max 100)' },
|
||||
{ name: 'start', type: 'int', default: 0, help: 'Result offset for pagination' },
|
||||
{ name: 'details', type: 'bool', default: false, help: 'Include full job description and apply URL (slower)' },
|
||||
{ name: 'company', type: 'string', required: false, help: 'Comma-separated company names or LinkedIn company IDs' },
|
||||
{ name: 'experience_level', type: 'string', required: false, help: 'Comma-separated: internship, entry, associate, mid-senior, director, executive' },
|
||||
{ name: 'job_type', type: 'string', required: false, help: 'Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other' },
|
||||
{ name: 'date_posted', type: 'string', required: false, help: 'One of: any, month, week, 24h' },
|
||||
{ name: 'remote', type: 'string', required: false, help: 'Comma-separated: on-site, hybrid, remote' },
|
||||
],
|
||||
columns: ['rank', 'title', 'company', 'location', 'listed', 'salary', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = Math.max(1, Math.min(kwargs.limit ?? 10, 100));
|
||||
const start = Math.max(0, kwargs.start ?? 0);
|
||||
const includeDetails = Boolean(kwargs.details);
|
||||
const location = (kwargs.location ?? '').trim();
|
||||
const keywords = String(kwargs.query ?? '').trim();
|
||||
|
||||
if (!keywords) throw new Error('query is required');
|
||||
|
||||
const searchParams = new URLSearchParams({ keywords });
|
||||
if (location) searchParams.set('location', location);
|
||||
|
||||
await page.goto(`https://www.linkedin.com/jobs/search/?${searchParams.toString()}`);
|
||||
await page.wait({ text: 'Jobs', timeout: 10 });
|
||||
const companyIds = await resolveCompanyIds(page, kwargs.company);
|
||||
|
||||
const input: SearchInput = {
|
||||
keywords,
|
||||
location,
|
||||
limit,
|
||||
start,
|
||||
companyIds,
|
||||
experienceLevels: mapFilterValues(kwargs.experience_level, EXPERIENCE_LEVELS, 'experience_level'),
|
||||
jobTypes: mapFilterValues(kwargs.job_type, JOB_TYPES, 'job_type'),
|
||||
datePostedValues: mapFilterValues(kwargs.date_posted, DATE_POSTED, 'date_posted'),
|
||||
remoteTypes: mapFilterValues(kwargs.remote, REMOTE_TYPES, 'remote'),
|
||||
};
|
||||
|
||||
const data = await fetchJobCards(page, input);
|
||||
|
||||
if (!includeDetails) return data;
|
||||
return enrichJobDetails(page, data);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'comment',
|
||||
description: 'Post a comment on a Reddit post',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
||||
{ name: 'text', type: 'string', required: true, help: 'Comment text' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let postId = ${JSON.stringify(kwargs.post_id)};
|
||||
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
||||
if (urlMatch) postId = urlMatch[1];
|
||||
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
||||
? postId : 't3_' + postId;
|
||||
|
||||
const text = ${JSON.stringify(kwargs.text)};
|
||||
|
||||
// Get modhash
|
||||
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
const modhash = me?.data?.modhash || '';
|
||||
|
||||
const res = await fetch('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'parent=' + encodeURIComponent(fullname)
|
||||
+ '&text=' + encodeURIComponent(text)
|
||||
+ '&api_type=json'
|
||||
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
||||
});
|
||||
|
||||
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
||||
const data = await res.json();
|
||||
const errors = data?.json?.errors;
|
||||
if (errors && errors.length > 0) {
|
||||
return { ok: false, message: errors.map(e => e.join(': ')).join('; ') };
|
||||
}
|
||||
return { ok: true, message: 'Comment posted on ' + fullname };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
site: reddit
|
||||
name: popular
|
||||
description: Reddit Popular posts (/r/popular)
|
||||
domain: reddit.com
|
||||
strategy: cookie
|
||||
browser: true
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
|
||||
columns: [rank, title, subreddit, score, comments, url]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const limit = ${{ args.limit }};
|
||||
const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => ({
|
||||
title: c.data.title,
|
||||
subreddit: c.data.subreddit_name_prefixed,
|
||||
score: c.data.score,
|
||||
comments: c.data.num_comments,
|
||||
author: c.data.author,
|
||||
url: 'https://www.reddit.com' + c.data.permalink,
|
||||
}));
|
||||
})()
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
subreddit: ${{ item.subreddit }}
|
||||
score: ${{ item.score }}
|
||||
comments: ${{ item.comments }}
|
||||
url: ${{ item.url }}
|
||||
- limit: ${{ args.limit }}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Reddit post reader with threaded comment tree.
|
||||
*
|
||||
* Replaces the original flat read.yaml with recursive comment traversal:
|
||||
* - Top-K comments by score at each level
|
||||
* - Configurable depth and replies-per-level
|
||||
* - Indented output showing conversation threads
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'read',
|
||||
description: 'Read a Reddit post and its comments',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'post_id', required: true, help: 'Post ID (e.g. 1abc123) or full URL' },
|
||||
{ name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
|
||||
{ name: 'limit', type: 'int', default: 25, help: 'Number of top-level comments' },
|
||||
{ name: 'depth', type: 'int', default: 2, help: 'Max reply depth (1=no replies, 2=one level of replies, etc.)' },
|
||||
{ name: 'replies', type: 'int', default: 5, help: 'Max replies shown per comment at each level (sorted by score)' },
|
||||
{ name: 'max_length', type: 'int', default: 2000, help: 'Max characters per comment body (min 100)' },
|
||||
],
|
||||
columns: ['type', 'author', 'score', 'text'],
|
||||
func: async (page, kwargs) => {
|
||||
const sort = kwargs.sort ?? 'best';
|
||||
const limit = Math.max(1, kwargs.limit ?? 25);
|
||||
const maxDepth = Math.max(1, kwargs.depth ?? 2);
|
||||
const maxReplies = Math.max(1, kwargs.replies ?? 5);
|
||||
const maxLength = Math.max(100, kwargs.max_length ?? 2000);
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(2);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async function() {
|
||||
var postId = ${JSON.stringify(kwargs.post_id)};
|
||||
var urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
||||
if (urlMatch) postId = urlMatch[1];
|
||||
|
||||
var sort = ${JSON.stringify(sort)};
|
||||
var limit = ${limit};
|
||||
var maxDepth = ${maxDepth};
|
||||
var maxReplies = ${maxReplies};
|
||||
var maxLength = ${maxLength};
|
||||
|
||||
// Request more from API than top-level limit to get inline replies
|
||||
// depth param tells Reddit how deep to inline replies vs "more" stubs
|
||||
var apiLimit = Math.max(limit * 3, 100);
|
||||
var res = await fetch(
|
||||
'/comments/' + postId + '.json?sort=' + sort + '&limit=' + apiLimit + '&depth=' + (maxDepth + 1) + '&raw_json=1',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) return { error: 'Reddit API returned HTTP ' + res.status };
|
||||
|
||||
var data;
|
||||
try { data = await res.json(); } catch(e) { return { error: 'Failed to parse response' }; }
|
||||
if (!Array.isArray(data) || data.length < 2) return { error: 'Unexpected response format' };
|
||||
|
||||
var results = [];
|
||||
|
||||
// Post
|
||||
var post = data[0] && data[0].data && data[0].data.children && data[0].data.children[0] && data[0].data.children[0].data;
|
||||
if (post) {
|
||||
var body = post.selftext || '';
|
||||
if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
|
||||
results.push({
|
||||
type: 'POST',
|
||||
author: post.author || '[deleted]',
|
||||
score: post.score || 0,
|
||||
text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
|
||||
});
|
||||
}
|
||||
|
||||
// Recursive comment walker
|
||||
// depth 0 = top-level comments; maxDepth is exclusive,
|
||||
// so --depth 1 means top-level only, --depth 2 means one reply level, etc.
|
||||
function walkComment(node, depth) {
|
||||
if (!node || node.kind !== 't1') return;
|
||||
var d = node.data;
|
||||
var body = d.body || '';
|
||||
if (body.length > maxLength) body = body.slice(0, maxLength) + '...';
|
||||
|
||||
// Indent prefix: apply to every line so multiline bodies stay aligned
|
||||
var indent = '';
|
||||
for (var i = 0; i < depth; i++) indent += ' ';
|
||||
var prefix = depth === 0 ? '' : indent + '> ';
|
||||
var indentedBody = depth === 0
|
||||
? body
|
||||
: body.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
|
||||
|
||||
results.push({
|
||||
type: depth === 0 ? 'L0' : 'L' + depth,
|
||||
author: d.author || '[deleted]',
|
||||
score: d.score || 0,
|
||||
text: indentedBody,
|
||||
});
|
||||
|
||||
// Count all available replies (for accurate "more" count)
|
||||
var t1Children = [];
|
||||
var moreCount = 0;
|
||||
if (d.replies && d.replies.data && d.replies.data.children) {
|
||||
var children = d.replies.data.children;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
if (children[i].kind === 't1') {
|
||||
t1Children.push(children[i]);
|
||||
} else if (children[i].kind === 'more') {
|
||||
moreCount += children[i].data.count || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At depth cutoff: don't recurse, but show all replies as hidden
|
||||
if (depth + 1 >= maxDepth) {
|
||||
var totalHidden = t1Children.length + moreCount;
|
||||
if (totalHidden > 0) {
|
||||
var cutoffIndent = '';
|
||||
for (var j = 0; j <= depth; j++) cutoffIndent += ' ';
|
||||
results.push({
|
||||
type: 'L' + (depth + 1),
|
||||
author: '',
|
||||
score: '',
|
||||
text: cutoffIndent + '[+' + totalHidden + ' more replies]',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score descending, take top N
|
||||
t1Children.sort(function(a, b) { return (b.data.score || 0) - (a.data.score || 0); });
|
||||
var toProcess = Math.min(t1Children.length, maxReplies);
|
||||
for (var i = 0; i < toProcess; i++) {
|
||||
walkComment(t1Children[i], depth + 1);
|
||||
}
|
||||
|
||||
// Show hidden count (skipped replies + "more" stubs)
|
||||
var hidden = t1Children.length - toProcess + moreCount;
|
||||
if (hidden > 0) {
|
||||
var moreIndent = '';
|
||||
for (var j = 0; j <= depth; j++) moreIndent += ' ';
|
||||
results.push({
|
||||
type: 'L' + (depth + 1),
|
||||
author: '',
|
||||
score: '',
|
||||
text: moreIndent + '[+' + hidden + ' more replies]',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Walk top-level comments
|
||||
var topLevel = data[1].data.children || [];
|
||||
var t1TopLevel = [];
|
||||
for (var i = 0; i < topLevel.length; i++) {
|
||||
if (topLevel[i].kind === 't1') t1TopLevel.push(topLevel[i]);
|
||||
}
|
||||
|
||||
// Top-level are already sorted by Reddit (sort param), take top N
|
||||
for (var i = 0; i < Math.min(t1TopLevel.length, limit); i++) {
|
||||
walkComment(t1TopLevel[i], 0);
|
||||
}
|
||||
|
||||
// Count remaining
|
||||
var moreTopLevel = topLevel.filter(function(c) { return c.kind === 'more'; })
|
||||
.reduce(function(sum, c) { return sum + (c.data.count || 0); }, 0);
|
||||
var hiddenTopLevel = Math.max(0, t1TopLevel.length - limit) + moreTopLevel;
|
||||
if (hiddenTopLevel > 0) {
|
||||
results.push({
|
||||
type: '',
|
||||
author: '',
|
||||
score: '',
|
||||
text: '[+' + hiddenTopLevel + ' more top-level comments]',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'save',
|
||||
description: 'Save or unsave a Reddit post',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
||||
{ name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let postId = ${JSON.stringify(kwargs.post_id)};
|
||||
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
||||
if (urlMatch) postId = urlMatch[1];
|
||||
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
||||
? postId : 't3_' + postId;
|
||||
|
||||
const undo = ${kwargs.undo ? 'true' : 'false'};
|
||||
const endpoint = undo ? '/api/unsave' : '/api/save';
|
||||
|
||||
// Get modhash
|
||||
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
const modhash = me?.data?.modhash || '';
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'id=' + encodeURIComponent(fullname)
|
||||
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
||||
});
|
||||
|
||||
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
||||
return { ok: true, message: (undo ? 'Unsaved' : 'Saved') + ' ' + fullname };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'saved',
|
||||
description: 'Browse your saved Reddit posts',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 15 },
|
||||
],
|
||||
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
// Get current username
|
||||
const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
const username = me?.name || me?.data?.name;
|
||||
if (!username) return { error: 'Not logged in — cannot determine username' };
|
||||
|
||||
const limit = ${kwargs.limit};
|
||||
const res = await fetch('/user/' + username + '/saved.json?limit=' + limit + '&raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => ({
|
||||
title: c.data.title || c.data.body?.slice(0, 100) || '-',
|
||||
subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
|
||||
score: c.data.score || 0,
|
||||
comments: c.data.num_comments || 0,
|
||||
url: 'https://www.reddit.com' + (c.data.permalink || ''),
|
||||
}));
|
||||
} catch (e) {
|
||||
return { error: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result?.error) throw new Error(result.error);
|
||||
return (result || []).slice(0, kwargs.limit);
|
||||
}
|
||||
});
|
||||
+37
-11
@@ -9,26 +9,52 @@ args:
|
||||
query:
|
||||
type: string
|
||||
required: true
|
||||
subreddit:
|
||||
type: string
|
||||
default: ""
|
||||
description: "Search within a specific subreddit"
|
||||
sort:
|
||||
type: string
|
||||
default: relevance
|
||||
description: "Sort order: relevance, hot, top, new, comments"
|
||||
time:
|
||||
type: string
|
||||
default: all
|
||||
description: "Time filter: hour, day, week, month, year, all"
|
||||
limit:
|
||||
type: int
|
||||
default: 15
|
||||
|
||||
columns: [title, subreddit, author, upvotes, comments, url]
|
||||
columns: [title, subreddit, author, score, comments, url]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const q = encodeURIComponent('${{ args.query }}');
|
||||
const res = await fetch('/search.json?q=' + q + '&limit=${{ args.limit }}', { credentials: 'include' });
|
||||
const j = await res.json();
|
||||
return j?.data?.children || [];
|
||||
const q = encodeURIComponent(${{ args.query | json }});
|
||||
const sub = ${{ args.subreddit | json }};
|
||||
const sort = ${{ args.sort | json }};
|
||||
const time = ${{ args.time | json }};
|
||||
const limit = ${{ args.limit }};
|
||||
const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json';
|
||||
const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit
|
||||
+ '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1';
|
||||
const res = await fetch(basePath + '?' + params, { credentials: 'include' });
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => ({
|
||||
title: c.data.title,
|
||||
subreddit: c.data.subreddit_name_prefixed,
|
||||
author: c.data.author,
|
||||
score: c.data.score,
|
||||
comments: c.data.num_comments,
|
||||
url: 'https://www.reddit.com' + c.data.permalink,
|
||||
}));
|
||||
})()
|
||||
- map:
|
||||
title: ${{ item.data.title }}
|
||||
subreddit: ${{ item.data.subreddit_name_prefixed }}
|
||||
author: ${{ item.data.author }}
|
||||
upvotes: ${{ item.data.score }}
|
||||
comments: ${{ item.data.num_comments }}
|
||||
url: https://www.reddit.com${{ item.data.permalink }}
|
||||
title: ${{ item.title }}
|
||||
subreddit: ${{ item.subreddit }}
|
||||
author: ${{ item.author }}
|
||||
score: ${{ item.score }}
|
||||
comments: ${{ item.comments }}
|
||||
url: ${{ item.url }}
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
@@ -12,7 +12,11 @@ args:
|
||||
sort:
|
||||
type: string
|
||||
default: hot
|
||||
description: "Sorting method: hot, new, top, rising"
|
||||
description: "Sorting method: hot, new, top, rising, controversial"
|
||||
time:
|
||||
type: string
|
||||
default: all
|
||||
description: "Time filter for top/controversial: hour, day, week, month, year, all"
|
||||
limit:
|
||||
type: int
|
||||
default: 15
|
||||
@@ -23,10 +27,16 @@ pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
let sub = '${{ args.name }}';
|
||||
let sub = ${{ args.name | json }};
|
||||
if (sub.startsWith('r/')) sub = sub.slice(2);
|
||||
const sort = '${{ args.sort }}';
|
||||
const res = await fetch('/r/' + sub + '/' + sort + '.json?limit=${{ args.limit }}', { credentials: 'include' });
|
||||
const sort = ${{ args.sort | json }};
|
||||
const time = ${{ args.time | json }};
|
||||
const limit = ${{ args.limit }};
|
||||
let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1';
|
||||
if ((sort === 'top' || sort === 'controversial') && time) {
|
||||
url += '&t=' + time;
|
||||
}
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const j = await res.json();
|
||||
return j?.data?.children || [];
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'subscribe',
|
||||
description: 'Subscribe or unsubscribe to a subreddit',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'subreddit', type: 'string', required: true, help: 'Subreddit name (e.g. python)' },
|
||||
{ name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let sub = ${JSON.stringify(kwargs.subreddit)};
|
||||
if (sub.startsWith('r/')) sub = sub.slice(2);
|
||||
|
||||
const undo = ${kwargs.undo ? 'true' : 'false'};
|
||||
const action = undo ? 'unsub' : 'sub';
|
||||
|
||||
// Get modhash
|
||||
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
const modhash = me?.data?.modhash || '';
|
||||
|
||||
const res = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'sr_name=' + encodeURIComponent(sub)
|
||||
+ '&action=' + action
|
||||
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
||||
});
|
||||
|
||||
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
||||
const label = undo ? 'Unsubscribed from' : 'Subscribed to';
|
||||
return { ok: true, message: label + ' r/' + sub };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'upvote',
|
||||
description: 'Upvote or downvote a Reddit post',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'post_id', type: 'string', required: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
||||
{ name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let postId = ${JSON.stringify(kwargs.post_id)};
|
||||
// Extract ID from URL if needed
|
||||
const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
|
||||
if (urlMatch) postId = urlMatch[1];
|
||||
// Build fullname
|
||||
const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
|
||||
? postId : 't3_' + postId;
|
||||
|
||||
const dir = ${JSON.stringify(kwargs.direction)};
|
||||
const direction = dir === 'down' ? -1 : dir === 'none' ? 0 : 1;
|
||||
|
||||
// Get modhash from Reddit config
|
||||
const configEl = document.getElementById('config');
|
||||
let modhash = '';
|
||||
if (configEl) {
|
||||
modhash = configEl.querySelector('[name="uh"]')?.getAttribute('content') || '';
|
||||
}
|
||||
if (!modhash) {
|
||||
// Try fetching from /api/me.json
|
||||
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
modhash = me?.data?.modhash || '';
|
||||
}
|
||||
|
||||
const res = await fetch('/api/vote', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'id=' + encodeURIComponent(fullname)
|
||||
+ '&dir=' + direction
|
||||
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
||||
});
|
||||
|
||||
if (!res.ok) return { ok: false, message: 'HTTP ' + res.status };
|
||||
|
||||
const labels = { '1': 'Upvoted', '-1': 'Downvoted', '0': 'Vote removed' };
|
||||
return { ok: true, message: (labels[String(direction)] || 'Voted') + ' ' + fullname };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
return [{ status: result.ok ? 'success' : 'failed', message: result.message }];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'reddit',
|
||||
name: 'upvoted',
|
||||
description: 'Browse your upvoted Reddit posts',
|
||||
domain: 'reddit.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 15 },
|
||||
],
|
||||
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
// Get current username
|
||||
const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
|
||||
const me = await meRes.json();
|
||||
const username = me?.name || me?.data?.name;
|
||||
if (!username) return { error: 'Not logged in — cannot determine username' };
|
||||
|
||||
const limit = ${kwargs.limit};
|
||||
const res = await fetch('/user/' + username + '/upvoted.json?limit=' + limit + '&raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => ({
|
||||
title: c.data.title || '-',
|
||||
subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
|
||||
score: c.data.score || 0,
|
||||
comments: c.data.num_comments || 0,
|
||||
url: 'https://www.reddit.com' + (c.data.permalink || ''),
|
||||
}));
|
||||
} catch (e) {
|
||||
return { error: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result?.error) throw new Error(result.error);
|
||||
return (result || []).slice(0, kwargs.limit);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
site: reddit
|
||||
name: user-comments
|
||||
description: View a Reddit user's comment history
|
||||
domain: reddit.com
|
||||
strategy: cookie
|
||||
browser: true
|
||||
|
||||
args:
|
||||
username:
|
||||
type: string
|
||||
required: true
|
||||
limit:
|
||||
type: int
|
||||
default: 15
|
||||
|
||||
columns: [subreddit, score, body, url]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const username = ${{ args.username | json }};
|
||||
const name = username.startsWith('u/') ? username.slice(2) : username;
|
||||
const limit = ${{ args.limit }};
|
||||
const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => {
|
||||
let body = c.data.body || '';
|
||||
if (body.length > 300) body = body.slice(0, 300) + '...';
|
||||
return {
|
||||
subreddit: c.data.subreddit_name_prefixed,
|
||||
score: c.data.score,
|
||||
body: body,
|
||||
url: 'https://www.reddit.com' + c.data.permalink,
|
||||
};
|
||||
});
|
||||
})()
|
||||
- map:
|
||||
subreddit: ${{ item.subreddit }}
|
||||
score: ${{ item.score }}
|
||||
body: ${{ item.body }}
|
||||
url: ${{ item.url }}
|
||||
- limit: ${{ args.limit }}
|
||||
@@ -0,0 +1,43 @@
|
||||
site: reddit
|
||||
name: user-posts
|
||||
description: View a Reddit user's submitted posts
|
||||
domain: reddit.com
|
||||
strategy: cookie
|
||||
browser: true
|
||||
|
||||
args:
|
||||
username:
|
||||
type: string
|
||||
required: true
|
||||
limit:
|
||||
type: int
|
||||
default: 15
|
||||
|
||||
columns: [title, subreddit, score, comments, url]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const username = ${{ args.username | json }};
|
||||
const name = username.startsWith('u/') ? username.slice(2) : username;
|
||||
const limit = ${{ args.limit }};
|
||||
const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
return (d?.data?.children || []).map(c => ({
|
||||
title: c.data.title,
|
||||
subreddit: c.data.subreddit_name_prefixed,
|
||||
score: c.data.score,
|
||||
comments: c.data.num_comments,
|
||||
url: 'https://www.reddit.com' + c.data.permalink,
|
||||
}));
|
||||
})()
|
||||
- map:
|
||||
title: ${{ item.title }}
|
||||
subreddit: ${{ item.subreddit }}
|
||||
score: ${{ item.score }}
|
||||
comments: ${{ item.comments }}
|
||||
url: ${{ item.url }}
|
||||
- limit: ${{ args.limit }}
|
||||
@@ -0,0 +1,39 @@
|
||||
site: reddit
|
||||
name: user
|
||||
description: View a Reddit user profile
|
||||
domain: reddit.com
|
||||
strategy: cookie
|
||||
browser: true
|
||||
|
||||
args:
|
||||
username:
|
||||
type: string
|
||||
required: true
|
||||
|
||||
columns: [field, value]
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.reddit.com
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const username = ${{ args.username | json }};
|
||||
const name = username.startsWith('u/') ? username.slice(2) : username;
|
||||
const res = await fetch('/user/' + name + '/about.json?raw_json=1', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const d = await res.json();
|
||||
const u = d?.data || d || {};
|
||||
const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-';
|
||||
return [
|
||||
{ field: 'Username', value: 'u/' + (u.name || name) },
|
||||
{ field: 'Post Karma', value: String(u.link_karma || 0) },
|
||||
{ field: 'Comment Karma', value: String(u.comment_karma || 0) },
|
||||
{ field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) },
|
||||
{ field: 'Account Created', value: created },
|
||||
{ field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' },
|
||||
{ field: 'Verified', value: u.verified ? '✅ Yes' : 'No' },
|
||||
];
|
||||
})()
|
||||
- map:
|
||||
field: ${{ item.field }}
|
||||
value: ${{ item.value }}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'article',
|
||||
description: 'Fetch a Twitter Article (long-form content) and export as Markdown',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'tweet_id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
||||
],
|
||||
columns: ['title', 'author', 'content', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// Extract tweet ID from URL if needed
|
||||
let tweetId = kwargs.tweet_id;
|
||||
const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
|
||||
if (urlMatch) tweetId = urlMatch[1];
|
||||
|
||||
// Navigate to the tweet page for cookie context
|
||||
await page.goto(`https://x.com/i/status/${tweetId}`);
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`
|
||||
async () => {
|
||||
const tweetId = "${tweetId}";
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes'
|
||||
};
|
||||
|
||||
const variables = JSON.stringify({
|
||||
tweetId: tweetId,
|
||||
withCommunity: false,
|
||||
includePromotedContent: false,
|
||||
withVoice: false,
|
||||
});
|
||||
const features = JSON.stringify({
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
});
|
||||
const fieldToggles = JSON.stringify({
|
||||
withArticleRichContentState: true,
|
||||
withArticlePlainText: true,
|
||||
});
|
||||
|
||||
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
||||
async function resolveQueryId(operationName, fallbackId) {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data[operationName];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const scripts = performance.getEntriesByType('resource')
|
||||
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
||||
.map(r => r.name);
|
||||
for (const scriptUrl of scripts.slice(0, 15)) {
|
||||
try {
|
||||
const text = await (await fetch(scriptUrl)).text();
|
||||
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
||||
const m = text.match(re);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
const queryId = await resolveQueryId('TweetResultByRestId', '7xflPyRiUxGVbJd4uWmbfg');
|
||||
const url = '/i/api/graphql/' + queryId + '/TweetResultByRestId?variables='
|
||||
+ encodeURIComponent(variables)
|
||||
+ '&features=' + encodeURIComponent(features)
|
||||
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
||||
|
||||
const resp = await fetch(url, {headers, credentials: 'include'});
|
||||
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'};
|
||||
const d = await resp.json();
|
||||
|
||||
const result = d.data?.tweetResult?.result;
|
||||
if (!result) return {error: 'Article not found'};
|
||||
|
||||
// Unwrap TweetWithVisibilityResults
|
||||
const tw = result.tweet || result;
|
||||
const legacy = tw.legacy || {};
|
||||
const user = tw.core?.user_results?.result;
|
||||
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
||||
|
||||
// Extract article content
|
||||
const articleResults = tw.article?.article_results?.result;
|
||||
if (!articleResults) {
|
||||
// Fallback: return note_tweet text if present
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
if (noteText) {
|
||||
return [{
|
||||
title: '(Note Tweet)',
|
||||
author: screenName,
|
||||
content: noteText,
|
||||
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
||||
}];
|
||||
}
|
||||
return {error: 'Tweet ' + tweetId + ' has no article content'};
|
||||
}
|
||||
|
||||
const title = articleResults.title || '(Untitled)';
|
||||
const contentState = articleResults.content_state || {};
|
||||
const blocks = contentState.blocks || [];
|
||||
|
||||
// Convert draft.js blocks to Markdown
|
||||
const parts = [];
|
||||
let orderedCounter = 0;
|
||||
for (const block of blocks) {
|
||||
const blockType = block.type || 'unstyled';
|
||||
if (blockType === 'atomic') continue;
|
||||
const text = block.text || '';
|
||||
if (!text) continue;
|
||||
if (blockType !== 'ordered-list-item') orderedCounter = 0;
|
||||
|
||||
if (blockType === 'header-one') parts.push('# ' + text);
|
||||
else if (blockType === 'header-two') parts.push('## ' + text);
|
||||
else if (blockType === 'header-three') parts.push('### ' + text);
|
||||
else if (blockType === 'blockquote') parts.push('> ' + text);
|
||||
else if (blockType === 'unordered-list-item') parts.push('- ' + text);
|
||||
else if (blockType === 'ordered-list-item') {
|
||||
orderedCounter++;
|
||||
parts.push(orderedCounter + '. ' + text);
|
||||
}
|
||||
else if (blockType === 'code-block') parts.push('\`\`\`\\n' + text + '\\n\`\`\`');
|
||||
else parts.push(text);
|
||||
}
|
||||
|
||||
return [{
|
||||
title,
|
||||
author: screenName,
|
||||
content: parts.join('\\n\\n') || legacy.full_text || '',
|
||||
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
||||
}];
|
||||
}
|
||||
`);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
||||
}
|
||||
|
||||
return result || [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'bookmark',
|
||||
description: 'Bookmark a tweet',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to bookmark' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let bookmarkBtn = null;
|
||||
let removeBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already bookmarked
|
||||
removeBtn = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (removeBtn) {
|
||||
return { ok: true, message: 'Tweet is already bookmarked.' };
|
||||
}
|
||||
|
||||
bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
|
||||
if (bookmarkBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!bookmarkBtn) {
|
||||
return { ok: false, message: 'Could not find Bookmark button. Are you logged in?' };
|
||||
}
|
||||
|
||||
bookmarkBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Tweet successfully bookmarked.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Bookmark action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
|
||||
|
||||
const FEATURES = {
|
||||
rweb_video_screen_enabled: false,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: false,
|
||||
rweb_tipjar_consumption_enabled: false,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
content_disclosure_indicator_enabled: true,
|
||||
content_disclosure_ai_generated_indicator_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
|
||||
interface BookmarkTweet {
|
||||
id: string;
|
||||
author: string;
|
||||
name: string;
|
||||
text: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildBookmarksUrl(count: number, cursor?: string | null): string {
|
||||
const vars: Record<string, any> = {
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
};
|
||||
if (cursor) vars.cursor = cursor;
|
||||
|
||||
return `/i/api/graphql/${BOOKMARKS_QUERY_ID}/Bookmarks`
|
||||
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
||||
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
||||
}
|
||||
|
||||
function extractBookmarkTweet(result: any, seen: Set<string>): BookmarkTweet | null {
|
||||
if (!result) return null;
|
||||
const tw = result.tweet || result;
|
||||
const legacy = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
||||
seen.add(tw.rest_id);
|
||||
|
||||
const user = tw.core?.user_results?.result;
|
||||
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
||||
const displayName = user?.legacy?.name || user?.core?.name || '';
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
|
||||
return {
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
name: displayName,
|
||||
text: noteText || legacy.full_text || '',
|
||||
likes: legacy.favorite_count || 0,
|
||||
retweets: legacy.retweet_count || 0,
|
||||
created_at: legacy.created_at || '',
|
||||
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBookmarks(data: any, seen: Set<string>): { tweets: BookmarkTweet[]; nextCursor: string | null } {
|
||||
const tweets: BookmarkTweet[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
const instructions =
|
||||
data?.data?.bookmark_timeline_v2?.timeline?.instructions
|
||||
|| data?.data?.bookmark_timeline?.timeline?.instructions
|
||||
|| [];
|
||||
|
||||
for (const inst of instructions) {
|
||||
for (const entry of inst.entries || []) {
|
||||
const content = entry.content;
|
||||
|
||||
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
||||
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value;
|
||||
continue;
|
||||
}
|
||||
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
||||
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
const direct = extractBookmarkTweet(content?.itemContent?.tweet_results?.result, seen);
|
||||
if (direct) {
|
||||
tweets.push(direct);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const item of content?.items || []) {
|
||||
const nested = extractBookmarkTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
||||
if (nested) tweets.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tweets, nextCursor };
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'bookmarks',
|
||||
description: 'Fetch Twitter/X bookmarks',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20 },
|
||||
],
|
||||
columns: ['author', 'text', 'likes', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit || 20;
|
||||
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(3);
|
||||
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
const queryId = await page.evaluate(`async () => {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data['Bookmarks'];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const scripts = performance.getEntriesByType('resource')
|
||||
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
||||
.map(r => r.name);
|
||||
for (const scriptUrl of scripts.slice(0, 15)) {
|
||||
try {
|
||||
const text = await (await fetch(scriptUrl)).text();
|
||||
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"Bookmarks"/;
|
||||
const m = text.match(re);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}`) || BOOKMARKS_QUERY_ID;
|
||||
|
||||
const headers = JSON.stringify({
|
||||
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes',
|
||||
});
|
||||
|
||||
const allTweets: BookmarkTweet[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
||||
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
||||
const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId);
|
||||
|
||||
const data = await page.evaluate(`async () => {
|
||||
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
||||
return r.ok ? await r.json() : { error: r.status };
|
||||
}`);
|
||||
|
||||
if (data?.error) {
|
||||
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const { tweets, nextCursor } = parseBookmarks(data, seen);
|
||||
allTweets.push(...tweets);
|
||||
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return allTweets.slice(0, limit);
|
||||
},
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
site: twitter
|
||||
name: bookmarks
|
||||
description: 获取 Twitter 书签列表
|
||||
domain: x.com
|
||||
browser: true
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
description: Number of bookmarks to return (default 20)
|
||||
|
||||
pipeline:
|
||||
- navigate: https://x.com/i/bookmarks
|
||||
- wait: 2
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) throw new Error('No ct0 cookie. Hint: Not logged into x.com.');
|
||||
const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
|
||||
const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};
|
||||
|
||||
const count = Math.min(${{ args.limit }}, 100);
|
||||
const variables = JSON.stringify({count, includePromotedContent: false});
|
||||
const features = JSON.stringify({
|
||||
rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: false, rweb_tipjar_consumption_enabled: false,
|
||||
verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
content_disclosure_indicator_enabled: true, content_disclosure_ai_generated_indicator_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false
|
||||
});
|
||||
const url = '/i/api/graphql/Fy0QMy4q_aZCpkO0PnyLYw/Bookmarks?variables=' + encodeURIComponent(variables) + '&features=' + encodeURIComponent(features);
|
||||
const resp = await fetch(url, {headers: _h, credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + '. Hint: queryId may have changed.');
|
||||
const d = await resp.json();
|
||||
|
||||
const instructions = d.data?.bookmark_timeline_v2?.timeline?.instructions || d.data?.bookmark_timeline?.timeline?.instructions || [];
|
||||
let tweets = [], seen = new Set();
|
||||
for (const inst of instructions) {
|
||||
for (const entry of (inst.entries || [])) {
|
||||
const r = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (!r) continue;
|
||||
const tw = r.tweet || r;
|
||||
const l = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) continue;
|
||||
seen.add(tw.rest_id);
|
||||
const u = tw.core?.user_results?.result;
|
||||
const nt = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
const screenName = u?.legacy?.screen_name || u?.core?.screen_name;
|
||||
tweets.push({
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
name: u?.legacy?.name || u?.core?.name,
|
||||
url: 'https://x.com/' + (screenName || '_') + '/status/' + tw.rest_id,
|
||||
text: nt || l.full_text || '',
|
||||
likes: l.favorite_count,
|
||||
retweets: l.retweet_count,
|
||||
created_at: l.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
return tweets;
|
||||
})()
|
||||
|
||||
- map:
|
||||
author: ${{ item.author }}
|
||||
text: ${{ item.text }}
|
||||
likes: ${{ item.likes }}
|
||||
url: ${{ item.url }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [author, text, likes, url]
|
||||
@@ -15,7 +15,6 @@ cli({
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
console.log(`Navigating to tweet: ${kwargs.url}`);
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'follow',
|
||||
description: 'Follow a Twitter user',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let followBtn = null;
|
||||
let unfollowTestId = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already following (button shows screen_name-unfollow)
|
||||
unfollowTestId = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (unfollowTestId) {
|
||||
return { ok: true, message: 'Already following @${username}.' };
|
||||
}
|
||||
|
||||
// Look for the Follow button
|
||||
followBtn = document.querySelector('[data-testid$="-follow"]');
|
||||
if (followBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!followBtn) {
|
||||
return { ok: false, message: 'Could not find Follow button. Are you logged in?' };
|
||||
}
|
||||
|
||||
followBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Successfully followed @${username}.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Follow action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
@@ -37,8 +36,8 @@ cli({
|
||||
await page.goto(`https://x.com/${targetUser}`);
|
||||
await page.wait(3);
|
||||
|
||||
// 2. Inject interceptor for Followers GraphQL API (or user_flow.json)
|
||||
await page.installInterceptor('graphql');
|
||||
// 2. Inject interceptor for the followers GraphQL API
|
||||
await page.installInterceptor('Followers');
|
||||
|
||||
// 3. Click the followers link inside the profile page
|
||||
await page.evaluate(`() => {
|
||||
@@ -53,24 +52,14 @@ cli({
|
||||
|
||||
// 4. Retrieve data from opencli's registered interceptors
|
||||
const allRequests = await page.getInterceptedRequests();
|
||||
const requestList = Array.isArray(allRequests) ? allRequests : [];
|
||||
|
||||
// Debug: Force dump all intercepted XHRs that match followers
|
||||
if (!allRequests || allRequests.length === 0) {
|
||||
console.log('No GraphQL requests captured by the interceptor backend.');
|
||||
if (requestList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Intercepted keys:', allRequests.map((r: any) => {
|
||||
try {
|
||||
const u = new URL(r.url); return u.pathname;
|
||||
} catch (e) {
|
||||
return r.url;
|
||||
}
|
||||
}));
|
||||
|
||||
const requests = allRequests.filter((r: any) => r.url.includes('Followers'));
|
||||
const requests = requestList.filter((r: any) => r?.url?.includes('Followers'));
|
||||
if (!requests || requests.length === 0) {
|
||||
console.log('No specific Followers requests captured. Check keys printed above.');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
@@ -53,15 +52,14 @@ cli({
|
||||
|
||||
// 4. Retrieve data from opencli's registered interceptors
|
||||
const requests = await page.getInterceptedRequests();
|
||||
const requestList = Array.isArray(requests) ? requests : [];
|
||||
|
||||
// Debug: Force dump all intercepted XHRs that match following
|
||||
if (!requests || requests.length === 0) {
|
||||
console.log('No Following requests captured by the interceptor backend.');
|
||||
if (requestList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
for (const req of requestList) {
|
||||
try {
|
||||
let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
if (!instructions) continue;
|
||||
|
||||
@@ -15,7 +15,6 @@ cli({
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
console.log(`Navigating to tweet: ${kwargs.url}`);
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
@@ -13,13 +12,16 @@ cli({
|
||||
],
|
||||
columns: ['id', 'action', 'author', 'text', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// Install the interceptor before loading the notifications page so we
|
||||
// capture the initial timeline request triggered during page load.
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(2);
|
||||
await page.installInterceptor('NotificationsTimeline');
|
||||
|
||||
// 1. Navigate to notifications
|
||||
await page.goto('https://x.com/notifications');
|
||||
await page.wait(5);
|
||||
|
||||
// 2. Inject interceptor
|
||||
await page.installInterceptor('NotificationsTimeline');
|
||||
|
||||
// 3. Trigger API by scrolling (if we need to load more)
|
||||
await page.autoScroll({ times: 2, delayMs: 2000 });
|
||||
|
||||
@@ -28,9 +30,10 @@ cli({
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const req of requests) {
|
||||
try {
|
||||
let instructions = [];
|
||||
let instructions: any[] = [];
|
||||
if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
|
||||
instructions = req.data.data.viewer.timeline_response.timeline.instructions;
|
||||
} else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
||||
@@ -75,14 +78,16 @@ cli({
|
||||
if (item.__typename === 'TimelineNotification') {
|
||||
// Greet likes, retweet, mentions
|
||||
text = item.rich_message?.text || item.message?.text || '';
|
||||
author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
|
||||
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
||||
author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
|
||||
urlStr = item.notification_url?.url || '';
|
||||
actionText = item.notification_icon || 'Activity';
|
||||
|
||||
// If there's an attached tweet
|
||||
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
||||
if (targetTweet) {
|
||||
text += ' | ' + (targetTweet.legacy?.full_text || '');
|
||||
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
|
||||
text += text && targetText ? ' | ' + targetText : targetText;
|
||||
if (!urlStr) {
|
||||
urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
|
||||
}
|
||||
@@ -91,18 +96,22 @@ cli({
|
||||
// Direct mention/reply
|
||||
const tweet = item.tweet_result?.result;
|
||||
author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
||||
text = tweet?.legacy?.full_text || item.message?.text || '';
|
||||
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
||||
actionText = 'Mention/Reply';
|
||||
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
||||
} else if (item.__typename === 'Tweet') {
|
||||
author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
||||
text = item.legacy?.full_text || '';
|
||||
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
||||
actionText = 'Mention';
|
||||
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
||||
}
|
||||
|
||||
const id = item.id || item.rest_id || entryId;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
|
||||
results.push({
|
||||
id: item.id || item.rest_id || entryId,
|
||||
id,
|
||||
action: actionText,
|
||||
author: author,
|
||||
text: text,
|
||||
|
||||
+114
-46
@@ -3,59 +3,127 @@ import { cli, Strategy } from '../../registry.js';
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'profile',
|
||||
description: 'Fetch tweets from a user profile',
|
||||
description: 'Fetch a Twitter user profile (bio, stats, etc.)',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', required: true },
|
||||
{ name: 'limit', type: 'int', default: 15 },
|
||||
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
||||
],
|
||||
columns: ['id', 'text', 'likes', 'views', 'url'],
|
||||
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
|
||||
func: async (page, kwargs) => {
|
||||
// Navigate to user profile via search for reliability
|
||||
await page.goto(`https://x.com/search?q=from:${kwargs.username}&f=live`);
|
||||
await page.wait(5);
|
||||
let username = (kwargs.username || '').replace(/^@/, '');
|
||||
|
||||
// Inject XHR interceptor
|
||||
await page.installInterceptor('SearchTimeline');
|
||||
|
||||
// Trigger API by scrolling
|
||||
await page.autoScroll({ times: 3, delayMs: 2000 });
|
||||
|
||||
// Retrieve data
|
||||
const requests = await page.getInterceptedRequests();
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
try {
|
||||
const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
|
||||
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
if (!addEntries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('tweet-')) continue;
|
||||
|
||||
let tweet = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (!tweet) continue;
|
||||
|
||||
if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
|
||||
tweet = tweet.tweet;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: tweet.rest_id,
|
||||
text: tweet.legacy?.full_text || '',
|
||||
likes: tweet.legacy?.favorite_count || 0,
|
||||
views: tweet.views?.count || '0',
|
||||
url: `https://x.com/i/status/${tweet.rest_id}`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
// If no username, detect the logged-in user
|
||||
if (!username) {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
}`);
|
||||
if (!href) throw new Error('Could not detect logged-in user. Are you logged in?');
|
||||
username = href.replace('/', '');
|
||||
}
|
||||
|
||||
return results.slice(0, kwargs.limit);
|
||||
// Navigate directly to the user's profile page (gives us cookie context)
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`
|
||||
async () => {
|
||||
const screenName = "${username}";
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes'
|
||||
};
|
||||
|
||||
const variables = JSON.stringify({
|
||||
screen_name: screenName,
|
||||
withSafetyModeUserFields: true,
|
||||
});
|
||||
const features = JSON.stringify({
|
||||
hidden_profile_subscriptions_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
subscriptions_verification_info_is_identity_verified_enabled: true,
|
||||
subscriptions_verification_info_verified_since_enabled: true,
|
||||
highlights_tweets_tab_ui_enabled: true,
|
||||
responsive_web_twitter_article_notes_tab_enabled: true,
|
||||
subscriptions_feature_can_gift_premium: true,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
});
|
||||
|
||||
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
||||
async function resolveQueryId(operationName, fallbackId) {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data[operationName];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const scripts = performance.getEntriesByType('resource')
|
||||
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
||||
.map(r => r.name);
|
||||
for (const scriptUrl of scripts.slice(0, 15)) {
|
||||
try {
|
||||
const text = await (await fetch(scriptUrl)).text();
|
||||
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
||||
const m = text.match(re);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
|
||||
const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
|
||||
+ encodeURIComponent(variables)
|
||||
+ '&features=' + encodeURIComponent(features);
|
||||
|
||||
const resp = await fetch(url, {headers, credentials: 'include'});
|
||||
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
|
||||
const d = await resp.json();
|
||||
|
||||
const result = d.data?.user?.result;
|
||||
if (!result) return {error: 'User @' + screenName + ' not found'};
|
||||
|
||||
const legacy = result.legacy || {};
|
||||
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
|
||||
|
||||
return [{
|
||||
screen_name: legacy.screen_name || screenName,
|
||||
name: legacy.name || '',
|
||||
bio: legacy.description || '',
|
||||
location: legacy.location || '',
|
||||
url: expandedUrl,
|
||||
followers: legacy.followers_count || 0,
|
||||
following: legacy.friends_count || 0,
|
||||
tweets: legacy.statuses_count || 0,
|
||||
likes: legacy.favourites_count || 0,
|
||||
verified: result.is_blue_verified || legacy.verified || false,
|
||||
created_at: legacy.created_at || '',
|
||||
}];
|
||||
}
|
||||
`);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
||||
}
|
||||
|
||||
return result || [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,14 +13,17 @@ cli({
|
||||
],
|
||||
columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// Install the interceptor before opening the target page so we don't miss
|
||||
// the initial SearchTimeline request fired during hydration.
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(2);
|
||||
await page.installInterceptor('SearchTimeline');
|
||||
|
||||
// 1. Navigate to the search page
|
||||
const q = encodeURIComponent(kwargs.query);
|
||||
await page.goto(`https://x.com/search?q=${q}&f=top`);
|
||||
await page.wait(5);
|
||||
|
||||
// 2. Inject XHR interceptor
|
||||
await page.installInterceptor('SearchTimeline');
|
||||
|
||||
// 3. Trigger API by scrolling
|
||||
await page.autoScroll({ times: 3, delayMs: 2000 });
|
||||
|
||||
@@ -29,11 +32,13 @@ cli({
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const req of requests) {
|
||||
try {
|
||||
const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
|
||||
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
if (!addEntries) continue;
|
||||
const insts = req.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
|
||||
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries')
|
||||
|| insts.find((i: any) => i.entries && Array.isArray(i.entries));
|
||||
if (!addEntries?.entries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('tweet-')) continue;
|
||||
@@ -45,11 +50,13 @@ cli({
|
||||
if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
|
||||
tweet = tweet.tweet;
|
||||
}
|
||||
if (!tweet.rest_id || seen.has(tweet.rest_id)) continue;
|
||||
seen.add(tweet.rest_id);
|
||||
|
||||
results.push({
|
||||
id: tweet.rest_id,
|
||||
author: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
|
||||
text: tweet.legacy?.full_text || '',
|
||||
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
||||
likes: tweet.legacy?.favorite_count || 0,
|
||||
views: tweet.views?.count || '0',
|
||||
url: `https://x.com/i/status/${tweet.rest_id}`
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
||||
|
||||
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
||||
|
||||
const FEATURES = {
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
};
|
||||
|
||||
const FIELD_TOGGLES = { withArticleRichContentState: true, withArticlePlainText: false };
|
||||
|
||||
// ── Pure functions (type-safe, testable) ───────────────────────────────
|
||||
|
||||
interface ThreadTweet {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
in_reply_to?: string;
|
||||
created_at?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildTweetDetailUrl(tweetId: string, cursor?: string | null): string {
|
||||
const vars: Record<string, any> = {
|
||||
focalTweetId: tweetId,
|
||||
referrer: 'tweet',
|
||||
with_rux_injections: false,
|
||||
includePromotedContent: false,
|
||||
rankingMode: 'Recency',
|
||||
withCommunity: true,
|
||||
withQuickPromoteEligibilityTweetFields: true,
|
||||
withBirdwatchNotes: true,
|
||||
withVoice: true,
|
||||
};
|
||||
if (cursor) vars.cursor = cursor;
|
||||
|
||||
return `/i/api/graphql/${TWEET_DETAIL_QUERY_ID}/TweetDetail`
|
||||
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
||||
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`
|
||||
+ `&fieldToggles=${encodeURIComponent(JSON.stringify(FIELD_TOGGLES))}`;
|
||||
}
|
||||
|
||||
function extractTweet(r: any, seen: Set<string>): ThreadTweet | null {
|
||||
if (!r) return null;
|
||||
const tw = r.tweet || r;
|
||||
const l = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
||||
seen.add(tw.rest_id);
|
||||
|
||||
const u = tw.core?.user_results?.result;
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
||||
|
||||
return {
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
text: noteText || l.full_text || '',
|
||||
likes: l.favorite_count || 0,
|
||||
retweets: l.retweet_count || 0,
|
||||
in_reply_to: l.in_reply_to_status_id_str || undefined,
|
||||
created_at: l.created_at,
|
||||
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTweetDetail(data: any, seen: Set<string>): { tweets: ThreadTweet[]; nextCursor: string | null } {
|
||||
const tweets: ThreadTweet[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
const instructions =
|
||||
data?.data?.threaded_conversation_with_injections_v2?.instructions
|
||||
|| data?.data?.tweetResult?.result?.timeline?.instructions
|
||||
|| [];
|
||||
|
||||
for (const inst of instructions) {
|
||||
for (const entry of inst.entries || []) {
|
||||
// Cursor entries
|
||||
const c = entry.content;
|
||||
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
||||
if (c.cursorType === 'Bottom' || c.cursorType === 'ShowMore') nextCursor = c.value;
|
||||
continue;
|
||||
}
|
||||
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
||||
nextCursor = c?.itemContent?.value || c?.value || nextCursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct tweet entry
|
||||
const tw = extractTweet(c?.itemContent?.tweet_results?.result, seen);
|
||||
if (tw) tweets.push(tw);
|
||||
|
||||
// Conversation module (nested replies)
|
||||
for (const item of c?.items || []) {
|
||||
const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
||||
if (nested) tweets.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tweets, nextCursor };
|
||||
}
|
||||
|
||||
// ── CLI definition ────────────────────────────────────────────────────
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'thread',
|
||||
description: 'Get a tweet thread (original + all replies)',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'tweet_id', type: 'string', required: true },
|
||||
{ name: 'limit', type: 'int', default: 50 },
|
||||
],
|
||||
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
let tweetId = kwargs.tweet_id;
|
||||
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
||||
if (urlMatch) tweetId = urlMatch[1];
|
||||
|
||||
// Navigate to x.com for cookie context
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(3);
|
||||
|
||||
// Extract CSRF token — the only thing we need from the browser
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
// Build auth headers in TypeScript
|
||||
const headers = JSON.stringify({
|
||||
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes',
|
||||
});
|
||||
|
||||
// Paginate — fetch in browser, parse in TypeScript
|
||||
const allTweets: ThreadTweet[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const apiUrl = buildTweetDetailUrl(tweetId, cursor);
|
||||
|
||||
// Browser-side: just fetch + return JSON (3 lines)
|
||||
const data = await page.evaluate(`async () => {
|
||||
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
||||
return r.ok ? await r.json() : { error: r.status };
|
||||
}`);
|
||||
|
||||
if (data?.error) {
|
||||
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Tweet not found or queryId expired`);
|
||||
break;
|
||||
}
|
||||
|
||||
// TypeScript-side: type-safe parsing + cursor extraction
|
||||
const { tweets, nextCursor } = parseTweetDetail(data, seen);
|
||||
allTweets.push(...tweets);
|
||||
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return allTweets.slice(0, kwargs.limit);
|
||||
},
|
||||
});
|
||||
+204
-36
@@ -1,50 +1,218 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
||||
|
||||
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
||||
|
||||
const FEATURES = {
|
||||
rweb_video_screen_enabled: false,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: true,
|
||||
responsive_web_jetfuel_frame: false,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: false,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
|
||||
// ── Pure functions (type-safe, testable) ───────────────────────────────
|
||||
|
||||
interface TimelineTweet {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
replies: number;
|
||||
views: number;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
|
||||
const vars: Record<string, any> = {
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
latestControlAvailable: true,
|
||||
requestContext: 'launch',
|
||||
withCommunity: true,
|
||||
};
|
||||
if (cursor) vars.cursor = cursor;
|
||||
|
||||
return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
|
||||
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
||||
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
||||
}
|
||||
|
||||
function extractTweet(result: any, seen: Set<string>): TimelineTweet | null {
|
||||
if (!result) return null;
|
||||
const tw = result.tweet || result;
|
||||
const l = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
||||
seen.add(tw.rest_id);
|
||||
|
||||
const u = tw.core?.user_results?.result;
|
||||
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
|
||||
|
||||
return {
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
text: noteText || l.full_text || '',
|
||||
likes: l.favorite_count || 0,
|
||||
retweets: l.retweet_count || 0,
|
||||
replies: l.reply_count || 0,
|
||||
views,
|
||||
created_at: l.created_at || '',
|
||||
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTweet[]; nextCursor: string | null } {
|
||||
const tweets: TimelineTweet[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
const instructions =
|
||||
data?.data?.home?.home_timeline_urt?.instructions || [];
|
||||
|
||||
for (const inst of instructions) {
|
||||
for (const entry of inst.entries || []) {
|
||||
const c = entry.content;
|
||||
|
||||
// Cursor entries
|
||||
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
||||
if (c.cursorType === 'Bottom') nextCursor = c.value;
|
||||
continue;
|
||||
}
|
||||
if (entry.entryId?.startsWith('cursor-bottom-')) {
|
||||
nextCursor = c?.value || nextCursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single tweet entry
|
||||
const tweetResult = c?.itemContent?.tweet_results?.result;
|
||||
if (tweetResult) {
|
||||
// Skip promoted content
|
||||
if (c?.itemContent?.promotedMetadata) continue;
|
||||
const tw = extractTweet(tweetResult, seen);
|
||||
if (tw) tweets.push(tw);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Conversation module (grouped tweets)
|
||||
for (const item of c?.items || []) {
|
||||
const nested = item.item?.itemContent?.tweet_results?.result;
|
||||
if (nested) {
|
||||
if (item.item?.itemContent?.promotedMetadata) continue;
|
||||
const tw = extractTweet(nested, seen);
|
||||
if (tw) tweets.push(tw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tweets, nextCursor };
|
||||
}
|
||||
|
||||
// ── CLI definition ────────────────────────────────────────────────────
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'timeline',
|
||||
description: 'Twitter Home Timeline',
|
||||
description: 'Fetch Twitter Home Timeline',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20 },
|
||||
],
|
||||
columns: ['responseType', 'first'],
|
||||
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
// Inject the fetch interceptor manually to see exactly what happens
|
||||
await page.evaluate(`
|
||||
() => {
|
||||
window.__intercept_data = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
||||
const res = await origFetch.apply(this, args);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (u.includes('HomeTimeline')) {
|
||||
const clone = res.clone();
|
||||
const j = await clone.json();
|
||||
window.__intercept_data.push(j);
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 0);
|
||||
return res;
|
||||
};
|
||||
const limit = kwargs.limit || 20;
|
||||
|
||||
// Navigate to x.com for cookie context
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(3);
|
||||
|
||||
// Extract CSRF token
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
// Dynamically resolve queryId
|
||||
const queryId = await page.evaluate(`async () => {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data['HomeTimeline'];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}`) || HOME_TIMELINE_QUERY_ID;
|
||||
|
||||
// Build auth headers
|
||||
const headers = JSON.stringify({
|
||||
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes',
|
||||
});
|
||||
|
||||
// Paginate — fetch in browser, parse in TypeScript
|
||||
const allTweets: TimelineTweet[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
||||
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
|
||||
const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
|
||||
.replace(HOME_TIMELINE_QUERY_ID, queryId);
|
||||
|
||||
const data = await page.evaluate(`async () => {
|
||||
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
||||
return r.ok ? await r.json() : { error: r.status };
|
||||
}`);
|
||||
|
||||
if (data?.error) {
|
||||
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
|
||||
break;
|
||||
}
|
||||
`);
|
||||
|
||||
// trigger scroll
|
||||
for(let i=0; i<3; i++) {
|
||||
await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
|
||||
await page.wait(2);
|
||||
|
||||
const { tweets, nextCursor } = parseHomeTimeline(data, seen);
|
||||
allTweets.push(...tweets);
|
||||
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
// extract
|
||||
const data = await page.evaluate('() => window.__intercept_data');
|
||||
if (!data || data.length === 0) return [{responseType: 'no data captured'}];
|
||||
|
||||
return [{responseType: `captured ${data.length} responses`, first: JSON.stringify(data[0]).substring(0,300)}];
|
||||
}
|
||||
|
||||
return allTweets.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,9 +25,15 @@ pipeline:
|
||||
credentials: 'include',
|
||||
headers: { 'x-twitter-active-user': 'yes', 'x-csrf-token': csrfToken, 'authorization': 'Bearer ' + bearerToken }
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + '. Hint: trending endpoint may require login or API shape changed.');
|
||||
const data = await res.json();
|
||||
const trends = data?.timeline?.instructions?.[1]?.addEntries?.entries || [];
|
||||
return trends.filter(e => e.content?.timelineModule).flatMap(e => e.content.timelineModule.items || []).map(t => t?.item?.content?.trend).filter(Boolean);
|
||||
const instructions = data?.timeline?.instructions || [];
|
||||
const entries = instructions.flatMap(inst => inst?.addEntries?.entries || inst?.entries || []);
|
||||
return entries
|
||||
.filter(e => e.content?.timelineModule)
|
||||
.flatMap(e => e.content.timelineModule.items || [])
|
||||
.map(t => t?.item?.content?.trend)
|
||||
.filter(Boolean);
|
||||
})()
|
||||
|
||||
- map:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'unbookmark',
|
||||
description: 'Remove a tweet from bookmarks',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to unbookmark' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let removeBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if not bookmarked
|
||||
const bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
|
||||
if (bookmarkBtn) {
|
||||
return { ok: true, message: 'Tweet is not bookmarked (already removed).' };
|
||||
}
|
||||
|
||||
removeBtn = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (removeBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!removeBtn) {
|
||||
return { ok: false, message: 'Could not find Remove Bookmark button. Are you logged in?' };
|
||||
}
|
||||
|
||||
removeBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid="bookmark"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Tweet successfully removed from bookmarks.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Unbookmark action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'unfollow',
|
||||
description: 'Unfollow a Twitter user',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let unfollowBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already not following
|
||||
const followBtn = document.querySelector('[data-testid$="-follow"]');
|
||||
if (followBtn) {
|
||||
return { ok: true, message: 'Not following @${username} (already unfollowed).' };
|
||||
}
|
||||
|
||||
unfollowBtn = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (unfollowBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!unfollowBtn) {
|
||||
return { ok: false, message: 'Could not find Unfollow button. Are you logged in?' };
|
||||
}
|
||||
|
||||
// Click the unfollow button — this opens a confirmation dialog
|
||||
unfollowBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Confirm the unfollow in the dialog
|
||||
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid$="-follow"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Successfully unfollowed @${username}.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Unfollow action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { fetchPageProps, formatDuration, formatDate } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xiaoyuzhou',
|
||||
name: 'episode',
|
||||
description: 'View details of a Xiaoyuzhou podcast episode',
|
||||
domain: 'www.xiaoyuzhoufm.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
|
||||
columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
|
||||
func: async (_page, args) => {
|
||||
const pageProps = await fetchPageProps(`/episode/${args.id}`);
|
||||
const ep = pageProps.episode;
|
||||
if (!ep) throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
|
||||
return [{
|
||||
title: ep.title,
|
||||
podcast: ep.podcast?.title,
|
||||
duration: formatDuration(ep.duration),
|
||||
plays: ep.playCount,
|
||||
comments: ep.commentCount,
|
||||
likes: ep.clapCount,
|
||||
date: formatDate(ep.pubDate),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { fetchPageProps, formatDuration, formatDate } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xiaoyuzhou',
|
||||
name: 'podcast-episodes',
|
||||
description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)',
|
||||
domain: 'www.xiaoyuzhoufm.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
|
||||
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' },
|
||||
],
|
||||
columns: ['eid', 'title', 'duration', 'plays', 'date'],
|
||||
func: async (_page, args) => {
|
||||
const pageProps = await fetchPageProps(`/podcast/${args.id}`);
|
||||
const podcast = pageProps.podcast;
|
||||
if (!podcast) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
||||
const allEpisodes = podcast.episodes ?? [];
|
||||
const requestedLimit = Number(args.limit);
|
||||
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
|
||||
throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
|
||||
}
|
||||
const limit = Math.min(requestedLimit, allEpisodes.length);
|
||||
const episodes = allEpisodes.slice(0, limit);
|
||||
return episodes.map((ep: any) => ({
|
||||
eid: ep.eid,
|
||||
title: ep.title,
|
||||
duration: formatDuration(ep.duration),
|
||||
plays: ep.playCount,
|
||||
date: formatDate(ep.pubDate),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
import { fetchPageProps, formatDate } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xiaoyuzhou',
|
||||
name: 'podcast',
|
||||
description: 'View a Xiaoyuzhou podcast profile',
|
||||
domain: 'www.xiaoyuzhoufm.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
|
||||
columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
|
||||
func: async (_page, args) => {
|
||||
const pageProps = await fetchPageProps(`/podcast/${args.id}`);
|
||||
const p = pageProps.podcast;
|
||||
if (!p) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
||||
return [{
|
||||
title: p.title,
|
||||
author: p.author,
|
||||
description: p.brief,
|
||||
subscribers: p.subscriptionCount,
|
||||
episodes: p.episodeCount,
|
||||
updated: formatDate(p.latestEpisodePubDate),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { formatDuration, formatDate, fetchPageProps } from './utils.js';
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats typical duration', () => {
|
||||
expect(formatDuration(3890)).toBe('64:50');
|
||||
});
|
||||
|
||||
it('formats zero seconds', () => {
|
||||
expect(formatDuration(0)).toBe('0:00');
|
||||
});
|
||||
|
||||
it('pads single-digit seconds', () => {
|
||||
expect(formatDuration(65)).toBe('1:05');
|
||||
});
|
||||
|
||||
it('formats exact minutes', () => {
|
||||
expect(formatDuration(3600)).toBe('60:00');
|
||||
});
|
||||
|
||||
it('rounds floating-point seconds', () => {
|
||||
expect(formatDuration(3890.7)).toBe('64:51');
|
||||
});
|
||||
|
||||
it('returns dash for NaN', () => {
|
||||
expect(formatDuration(NaN)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns dash for negative', () => {
|
||||
expect(formatDuration(-1)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('extracts YYYY-MM-DD from ISO string', () => {
|
||||
expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13');
|
||||
});
|
||||
|
||||
it('handles date-only string', () => {
|
||||
expect(formatDate('2025-01-01')).toBe('2025-01-01');
|
||||
});
|
||||
|
||||
it('returns dash for undefined/empty', () => {
|
||||
expect(formatDate('')).toBe('-');
|
||||
expect(formatDate(undefined as any)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPageProps', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('extracts pageProps from valid HTML', async () => {
|
||||
const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(mockHtml),
|
||||
}));
|
||||
|
||||
const result = await fetchPageProps('/podcast/abc123');
|
||||
expect(result).toEqual({ podcast: { title: 'Test' } });
|
||||
});
|
||||
|
||||
it('throws on HTTP error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve('Not Found'),
|
||||
}));
|
||||
|
||||
await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
|
||||
});
|
||||
|
||||
it('throws when __NEXT_DATA__ is missing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('<html><body>No data here</body></html>'),
|
||||
}));
|
||||
|
||||
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
|
||||
});
|
||||
|
||||
it('throws when pageProps is empty', async () => {
|
||||
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(mockHtml),
|
||||
}));
|
||||
|
||||
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
|
||||
});
|
||||
|
||||
it('throws on malformed JSON in __NEXT_DATA__', async () => {
|
||||
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(mockHtml),
|
||||
}));
|
||||
|
||||
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
|
||||
});
|
||||
|
||||
it('handles multiline JSON in __NEXT_DATA__', async () => {
|
||||
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
|
||||
{
|
||||
"props": {
|
||||
"pageProps": {
|
||||
"episode": {"title": "Multiline Test"}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(mockHtml),
|
||||
}));
|
||||
|
||||
const result = await fetchPageProps('/episode/abc');
|
||||
expect(result).toEqual({ episode: { title: 'Multiline Test' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared Xiaoyuzhou utilities — page data extraction and formatting.
|
||||
*
|
||||
* Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
|
||||
* <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
|
||||
* instead of using their authenticated API.
|
||||
*/
|
||||
|
||||
import { CliError } from '../../errors.js';
|
||||
|
||||
/**
|
||||
* Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
|
||||
* @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
|
||||
*/
|
||||
export async function fetchPageProps(path: string): Promise<any> {
|
||||
const url = `https://www.xiaoyuzhoufm.com${path}`;
|
||||
// Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new CliError(
|
||||
'FETCH_ERROR',
|
||||
`HTTP ${resp.status} for ${path}`,
|
||||
'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
|
||||
);
|
||||
}
|
||||
const html = await resp.text();
|
||||
// [\s\S]*? for multiline safety (JSON may span lines)
|
||||
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||
if (!match) {
|
||||
throw new CliError(
|
||||
'PARSE_ERROR',
|
||||
'Failed to extract __NEXT_DATA__',
|
||||
'Page structure may have changed',
|
||||
);
|
||||
}
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(match[1]); }
|
||||
catch { throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed'); }
|
||||
const pageProps = parsed.props?.pageProps;
|
||||
if (!pageProps || Object.keys(pageProps).length === 0) {
|
||||
throw new CliError(
|
||||
'NOT_FOUND',
|
||||
'Resource not found',
|
||||
'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
|
||||
);
|
||||
}
|
||||
return pageProps;
|
||||
}
|
||||
|
||||
/** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '-';
|
||||
seconds = Math.round(seconds);
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return '-';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user