Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f998e4ff0 | |||
| 36b06e9f32 | |||
| f0273f94bb | |||
| 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 | |||
| e781d40408 | |||
| c230f3e5ad |
@@ -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
|
||||
```
|
||||
@@ -3,3 +3,4 @@ dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.mcp.json
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · 80+ commands · 19 sites
|
||||
|
||||
[中文文档](./README.zh-CN.md)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website** into a command-line interface — bilibili, zhihu, xiaohongshu, twitter, reddit, and many more — powered by browser session reuse and AI-native discovery.
|
||||
A CLI tool that turns **any website** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,8 +32,9 @@ A CLI tool that turns **any website** into a command-line interface — bilibili
|
||||
|
||||
- **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
|
||||
|
||||
@@ -47,7 +48,7 @@ OpenCLI connects to your browser through the Playwright MCP Bridge extension.
|
||||
### Playwright MCP Bridge Extension Setup
|
||||
|
||||
1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
|
||||
2. Run `opencli setup` — it auto-discovers your token and lets you choose which tools to configure:
|
||||
2. Run `opencli setup` — discovers the token, distributes it to your tools, and verifies connectivity:
|
||||
|
||||
```bash
|
||||
opencli setup
|
||||
@@ -57,6 +58,15 @@ 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
|
||||
> ```
|
||||
|
||||
<details>
|
||||
<summary>Manual setup (alternative)</summary>
|
||||
@@ -85,12 +95,6 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
|
||||
|
||||
</details>
|
||||
|
||||
Verify with `opencli doctor` — shows colored status for all config locations:
|
||||
|
||||
```bash
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install via npm (recommended)
|
||||
@@ -130,26 +134,29 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 🔐 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 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
|
||||
| **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 |
|
||||
| **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
|
||||
|
||||
@@ -194,7 +201,7 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
||||
|
||||
- Current test coverage (unit + ~52 E2E tests across all 18 sites)
|
||||
- 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
|
||||
@@ -232,4 +239,4 @@ The CI will automatically build, create a GitHub release, and publish to npm.
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](./LICENSE)
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
+39
-31
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站变成你的命令行工具。**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 80+ 命令 · 19 站点
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twitter、Reddit 等众多站点 — 复用浏览器登录态,AI 驱动探索。
|
||||
OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等 [19 个站点](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
---
|
||||
|
||||
@@ -29,8 +29,9 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi
|
||||
|
||||
## 亮点
|
||||
|
||||
- **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等众多站点
|
||||
- **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等 19 个站点,80+ 命令
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **自修复配置** — `opencli setup` 自动发现 Token;`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
@@ -46,7 +47,7 @@ OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
|
||||
### Playwright MCP Bridge 扩展配置
|
||||
|
||||
1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
|
||||
2. 运行 `opencli setup` — 自动发现 Token 并让你选择要配置哪些工具:
|
||||
2. 运行 `opencli setup` — 自动发现 Token、分发到各工具、验证连通性:
|
||||
|
||||
```bash
|
||||
opencli setup
|
||||
@@ -56,6 +57,15 @@ opencli setup
|
||||
- 🔍 从 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 # 无交互直接修复所有配置
|
||||
> ```
|
||||
|
||||
<details>
|
||||
<summary>手动配置(备选方案)</summary>
|
||||
@@ -84,12 +94,6 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
|
||||
|
||||
</details>
|
||||
|
||||
配置后运行 `opencli doctor` 检查所有位置的 Token 状态:
|
||||
|
||||
```bash
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### npm 全局安装(推荐)
|
||||
@@ -129,26 +133,29 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## 内置命令
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 🔐 浏览器 |
|
||||
| **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 | 🔐 浏览器 |
|
||||
| **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 | 🔐 浏览器 |
|
||||
| **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 | 🔐 浏览器 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -200,6 +207,7 @@ opencli cascade https://api.example.com/data
|
||||
- 确保 Node.js 版本 `>= 18`。旧版不支持我们使用的现代核心库 API。
|
||||
- **Token 问题**
|
||||
- 运行 `opencli doctor` 诊断所有工具的 Token 配置状态。
|
||||
- 使用 `opencli doctor --live` 测试浏览器连通性。
|
||||
|
||||
## 版本发布
|
||||
|
||||
@@ -213,4 +221,4 @@ git push --follow-tags
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](./LICENSE)
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 0.6.0
|
||||
description: "OpenCLI — Make any website 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
|
||||
@@ -68,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)
|
||||
@@ -86,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 # 热门话题
|
||||
@@ -115,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 # 股票行情
|
||||
@@ -141,8 +162,10 @@ 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 config across all tools
|
||||
opencli doctor --fix -y # Auto-fix all config files (non-interactive)
|
||||
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
|
||||
|
||||
Generated
+25
-68
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.6.2",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "0.7.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
@@ -18,9 +19,9 @@
|
||||
"opencli": "dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/mcp": "^0.0.68",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.0"
|
||||
@@ -29,6 +30,22 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"../opencli-mcp/packages/playwright-mcp": {
|
||||
"name": "opencli-mcp",
|
||||
"version": "0.0.68",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.0-alpha-1771104257000",
|
||||
"playwright-core": "1.59.0-alpha-1771104257000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright-mcp": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -559,23 +576,6 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/mcp": {
|
||||
"version": "0.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
|
||||
"integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.0-alpha-1771104257000",
|
||||
"playwright-core": "1.59.0-alpha-1771104257000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright-mcp": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
@@ -1543,6 +1543,10 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/opencli-mcp": {
|
||||
"resolved": "../opencli-mcp/packages/playwright-mcp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1571,53 +1575,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.0-alpha-1771104257000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
|
||||
"integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.0-alpha-1771104257000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.0-alpha-1771104257000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
|
||||
"integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.10",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -17,9 +17,10 @@
|
||||
"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",
|
||||
"clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
|
||||
"copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
|
||||
"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"
|
||||
@@ -46,9 +47,9 @@
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/mcp": "^0.0.68",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.0"
|
||||
|
||||
@@ -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();
|
||||
+1
-1
@@ -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, '');
|
||||
}
|
||||
|
||||
+100
-2
@@ -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', () => {
|
||||
@@ -109,6 +115,98 @@ describe('browser helpers', () => {
|
||||
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 opencli-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/opencli-mcp/packages/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/opencli-mcp/packages/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/opencli-mcp/packages/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', () => {
|
||||
|
||||
-700
@@ -1,700 +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 useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
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] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
||||
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
||||
}
|
||||
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];
|
||||
if (!process.env.CI) {
|
||||
// Local: always connect to user's running Chrome via MCP Bridge extension
|
||||
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 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,126 @@
|
||||
/**
|
||||
* 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 (opencli-mcp is the modern package)
|
||||
const localMcp = path.resolve('node_modules', 'opencli-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', 'opencli-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, 'opencli-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, 'opencli-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=opencli-mcp which opencli-mcp 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 opencli-mcp 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 "*opencli*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
if (found) {
|
||||
_cachedMcpServerPath = found;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_cachedMcpServerPath = null;
|
||||
return _cachedMcpServerPath;
|
||||
}
|
||||
|
||||
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
||||
const args = [input.mcpPath];
|
||||
if (!process.env.CI) {
|
||||
// Local: always connect to user's running Chrome via MCP Bridge extension
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Browser connection error classification and formatting.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export type ConnectFailureKind = 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
|
||||
|
||||
export type ConnectFailureInput = {
|
||||
kind: ConnectFailureKind;
|
||||
timeout: number;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
rawMessage?: string;
|
||||
};
|
||||
|
||||
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
||||
const stderr = input.stderr?.trim();
|
||||
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
||||
|
||||
if (input.kind === 'extension-not-installed') {
|
||||
return new Error(
|
||||
'Failed to connect to OpenCLI MCP Bridge: the browser extension did not attach.\n\n' +
|
||||
'Make sure Chrome is running and the "OpenCLI MCP Bridge" extension is installed and enabled in Developer Mode.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-timeout') {
|
||||
return new Error(
|
||||
`Timed out connecting to OpenCLI MCP Bridge (${input.timeout}s).\n\n` +
|
||||
`Make sure Chrome is running with the OpenCLI MCP Bridge extension enabled.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'mcp-init') {
|
||||
return new Error(`Failed to initialize OpenCLI MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
||||
}
|
||||
|
||||
if (input.kind === 'process-exit') {
|
||||
return new Error(
|
||||
`OpenCLI 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: {
|
||||
stderr: string;
|
||||
rawMessage?: string;
|
||||
exited?: boolean;
|
||||
}): ConnectFailureKind {
|
||||
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
||||
|
||||
if (haystack.includes('extension connection timeout') || haystack.includes('opencli mcp bridge') || 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,29 @@
|
||||
/**
|
||||
* 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 { formatBrowserConnectError } from './errors.js';
|
||||
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
||||
|
||||
// Test-only helpers — exposed for unit tests
|
||||
import { createJsonRpcRequest } from './mcp.js';
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { buildMcpArgs, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
|
||||
import { withTimeoutMs } from '../runtime.js';
|
||||
|
||||
export const __test__ = {
|
||||
createJsonRpcRequest,
|
||||
extractTabEntries,
|
||||
diffTabIndexes,
|
||||
appendLimited,
|
||||
buildMcpArgs,
|
||||
findMcpServerPath,
|
||||
resetMcpServerPathCache,
|
||||
setMcpDiscoveryTestHooks,
|
||||
withTimeoutMs,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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 { formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
||||
import { findMcpServerPath, buildMcpArgs } 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();
|
||||
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 ?? 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 useExtension = true; // Always true in dev config or local for opencli-mcp
|
||||
|
||||
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,
|
||||
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({
|
||||
stderr: stderrBuffer,
|
||||
}));
|
||||
}, timeout * 1000);
|
||||
|
||||
const mcpArgs = buildMcpArgs({
|
||||
mcpPath,
|
||||
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
||||
});
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] Mode: extension`);
|
||||
}
|
||||
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({
|
||||
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: any) => {
|
||||
debugLog('Got initialize response');
|
||||
if (resp.error) {
|
||||
settleError(inferConnectFailureKind({
|
||||
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: 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,152 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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?.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 || [];
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,6 +30,7 @@ cli({
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const req of requests) {
|
||||
try {
|
||||
let instructions: any[] = [];
|
||||
@@ -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,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { groupTranscriptSegments, formatGroupedTranscript } from './transcript-group.js';
|
||||
|
||||
describe('groupTranscriptSegments', () => {
|
||||
it('groups segments by sentence boundaries', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: 'Hello there.' },
|
||||
{ start: 2, text: 'How are you doing today?' },
|
||||
{ start: 5, text: 'I am' },
|
||||
{ start: 6, text: 'doing well.' },
|
||||
];
|
||||
const result = groupTranscriptSegments(segments);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].text).toBe('Hello there.');
|
||||
expect(result[1].text).toBe('How are you doing today?');
|
||||
expect(result[2].text).toBe('I am doing well.');
|
||||
});
|
||||
|
||||
it('flushes on large time gaps', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: 'First part' },
|
||||
{ start: 2, text: 'still first' },
|
||||
{ start: 25, text: 'second part after gap' },
|
||||
];
|
||||
const result = groupTranscriptSegments(segments);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].text).toBe('First part still first');
|
||||
expect(result[1].text).toBe('second part after gap');
|
||||
});
|
||||
|
||||
it('respects 30s max group span for unpunctuated text', () => {
|
||||
// Simulate CJK captions without punctuation
|
||||
const segments = Array.from({ length: 20 }, (_, i) => ({
|
||||
start: i * 2,
|
||||
text: `segment${i}`,
|
||||
}));
|
||||
const result = groupTranscriptSegments(segments);
|
||||
// 20 segments * 2s = 40s total, should be split into at least 2 groups
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
// No single group should span more than ~30s
|
||||
for (const g of result) {
|
||||
const words = g.text.split(' ');
|
||||
// With 2s per segment and 30s max, each group should have at most ~16 segments
|
||||
expect(words.length).toBeLessThanOrEqual(16);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects speaker changes via >> markers', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: '>> How are you?' },
|
||||
{ start: 3, text: '>> I am fine.' },
|
||||
];
|
||||
const result = groupTranscriptSegments(segments);
|
||||
expect(result.some(g => g.speakerChange)).toBe(true);
|
||||
expect(result.some(g => g.speaker !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes CJK sentence-ending punctuation', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: '你好世界。' },
|
||||
{ start: 2, text: '这是测试' },
|
||||
{ start: 4, text: '内容。' },
|
||||
];
|
||||
const result = groupTranscriptSegments(segments);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].text).toBe('你好世界。');
|
||||
expect(result[1].text).toBe('这是测试 内容。');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(groupTranscriptSegments([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGroupedTranscript', () => {
|
||||
it('formats timestamps correctly', () => {
|
||||
const segments = [
|
||||
{ start: 65, text: 'One minute five.', speakerChange: false },
|
||||
{ start: 3661, text: 'One hour one minute.', speakerChange: false },
|
||||
];
|
||||
const { rows } = formatGroupedTranscript(segments);
|
||||
expect(rows[0].timestamp).toBe('1:05');
|
||||
expect(rows[1].timestamp).toBe('1:01:01');
|
||||
});
|
||||
|
||||
it('inserts chapter headings at correct positions', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: 'Intro text.', speakerChange: false },
|
||||
{ start: 60, text: 'Chapter content.', speakerChange: false },
|
||||
];
|
||||
const chapters = [{ title: 'Introduction', start: 0 }, { title: 'Main', start: 50 }];
|
||||
const { rows } = formatGroupedTranscript(segments, chapters);
|
||||
expect(rows[0].text).toBe('[Chapter] Introduction');
|
||||
expect(rows[1].text).toBe('Intro text.');
|
||||
expect(rows[2].text).toBe('[Chapter] Main');
|
||||
expect(rows[3].text).toBe('Chapter content.');
|
||||
});
|
||||
|
||||
it('labels speakers', () => {
|
||||
const segments = [
|
||||
{ start: 0, text: 'Hello.', speakerChange: true, speaker: 0 },
|
||||
{ start: 5, text: 'Hi there.', speakerChange: true, speaker: 1 },
|
||||
];
|
||||
const { rows } = formatGroupedTranscript(segments);
|
||||
expect(rows[0].speaker).toBe('Speaker 1');
|
||||
expect(rows[1].speaker).toBe('Speaker 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Transcript grouping: sentence merging, speaker detection, and chapter support.
|
||||
* Ported and simplified from Defuddle's YouTube extractor.
|
||||
*
|
||||
* Raw segments (2-3 second fragments) are grouped into readable paragraphs:
|
||||
* - Sentence boundaries: merge until sentence-ending punctuation (.!?)
|
||||
* - Speaker turns: detect ">>" markers from YouTube auto-captions
|
||||
* - Chapters: optional chapter headings inserted at appropriate timestamps
|
||||
*/
|
||||
|
||||
// Include CJK sentence-ending punctuation: 。!? (fullwidth: .!?)
|
||||
const SENTENCE_END = /[.!?\u3002\uFF01\uFF1F\uFF0E]["'\u2019\u201D)]*\s*$/;
|
||||
const QUESTION_END = /[?\uFF1F]["'\u2019\u201D)]*\s*$/;
|
||||
const TRANSCRIPT_GROUP_GAP_SECONDS = 20;
|
||||
const TURN_MERGE_MAX_WORDS = 80;
|
||||
const TURN_MERGE_MAX_SPAN_SECONDS = 45;
|
||||
const SHORT_UTTERANCE_MAX_WORDS = 3;
|
||||
const FIRST_GROUP_MERGE_MIN_WORDS = 8;
|
||||
|
||||
export interface RawSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface GroupedSegment {
|
||||
start: number;
|
||||
text: string;
|
||||
speakerChange: boolean;
|
||||
speaker?: number;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
title: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw transcript segments into readable blocks.
|
||||
* If speaker markers (>>) are present, groups by speaker turn.
|
||||
* Otherwise, groups by sentence boundaries.
|
||||
*/
|
||||
export function groupTranscriptSegments(
|
||||
segments: { start: number; text: string }[],
|
||||
): GroupedSegment[] {
|
||||
if (segments.length === 0) return [];
|
||||
const hasSpeakerMarkers = segments.some(s => /^>>/.test(s.text));
|
||||
return hasSpeakerMarkers ? groupBySpeaker(segments) : groupBySentence(segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format grouped segments + chapters into a final text output.
|
||||
*/
|
||||
export function formatGroupedTranscript(
|
||||
segments: GroupedSegment[],
|
||||
chapters: Chapter[] = [],
|
||||
): { rows: Array<{ timestamp: string; speaker: string; text: string }>; plainText: string } {
|
||||
const sortedChapters = [...chapters].sort((a, b) => a.start - b.start);
|
||||
let chapterIdx = 0;
|
||||
|
||||
const rows: Array<{ timestamp: string; speaker: string; text: string }> = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
// Insert chapter headings
|
||||
while (chapterIdx < sortedChapters.length && sortedChapters[chapterIdx].start <= segment.start) {
|
||||
const title = sortedChapters[chapterIdx].title;
|
||||
rows.push({ timestamp: fmtTime(sortedChapters[chapterIdx].start), speaker: '', text: `[Chapter] ${title}` });
|
||||
if (textParts.length > 0) textParts.push('');
|
||||
textParts.push(`### ${title}`);
|
||||
textParts.push('');
|
||||
chapterIdx++;
|
||||
}
|
||||
|
||||
const timestamp = fmtTime(segment.start);
|
||||
const speaker = segment.speaker !== undefined ? `Speaker ${segment.speaker + 1}` : '';
|
||||
|
||||
rows.push({ timestamp, speaker, text: segment.text });
|
||||
|
||||
if (segment.speakerChange && textParts.length > 0) {
|
||||
textParts.push('');
|
||||
}
|
||||
textParts.push(`${timestamp} ${segment.text}`);
|
||||
}
|
||||
|
||||
return { rows, plainText: textParts.join('\n') };
|
||||
}
|
||||
|
||||
function fmtTime(sec: number): string {
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Sentence grouping ─────────────────────────────────────────────────────
|
||||
|
||||
// Max time span (seconds) for a single group when no sentence boundaries are found.
|
||||
// Prevents unbounded merging for languages without punctuation (Chinese, etc.).
|
||||
const MAX_GROUP_SPAN_SECONDS = 30;
|
||||
|
||||
function groupBySentence(
|
||||
segments: { start: number; text: string }[],
|
||||
): GroupedSegment[] {
|
||||
const groups: GroupedSegment[] = [];
|
||||
let buffer = '';
|
||||
let bufferStart = 0;
|
||||
let lastStart = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (buffer.trim()) {
|
||||
groups.push({ start: bufferStart, text: buffer.trim(), speakerChange: false });
|
||||
buffer = '';
|
||||
}
|
||||
};
|
||||
|
||||
for (const seg of segments) {
|
||||
// Large gap between segments — always flush
|
||||
if (buffer && seg.start - lastStart > TRANSCRIPT_GROUP_GAP_SECONDS) {
|
||||
flush();
|
||||
}
|
||||
// Time-based flush: prevent unbounded groups for unpunctuated languages
|
||||
if (buffer && seg.start - bufferStart > MAX_GROUP_SPAN_SECONDS) {
|
||||
flush();
|
||||
}
|
||||
if (!buffer) bufferStart = seg.start;
|
||||
buffer += (buffer ? ' ' : '') + seg.text;
|
||||
lastStart = seg.start;
|
||||
if (SENTENCE_END.test(seg.text)) flush();
|
||||
}
|
||||
flush();
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── Speaker grouping ──────────────────────────────────────────────────────
|
||||
|
||||
function groupBySpeaker(
|
||||
segments: { start: number; text: string }[],
|
||||
): GroupedSegment[] {
|
||||
type Turn = {
|
||||
start: number;
|
||||
segments: { start: number; text: string }[];
|
||||
speakerChange: boolean;
|
||||
speaker?: number;
|
||||
};
|
||||
|
||||
const turns: Turn[] = [];
|
||||
let currentTurn: Turn | null = null;
|
||||
let speakerIndex = -1;
|
||||
let prevSegText = '';
|
||||
|
||||
for (const seg of segments) {
|
||||
const isSpeakerChange = /^>>/.test(seg.text);
|
||||
const cleanText = seg.text.replace(/^>>\s*/, '').replace(/^-\s+/, '');
|
||||
|
||||
const prevEndsWithComma = /,\s*$/.test(prevSegText);
|
||||
const prevEndedSentence = (SENTENCE_END.test(prevSegText) || !prevSegText) && !prevEndsWithComma;
|
||||
const isRealSpeakerChange = isSpeakerChange && prevEndedSentence;
|
||||
|
||||
if (isRealSpeakerChange) {
|
||||
if (currentTurn) turns.push(currentTurn);
|
||||
speakerIndex = (speakerIndex + 1) % 2;
|
||||
currentTurn = {
|
||||
start: seg.start,
|
||||
segments: [{ start: seg.start, text: cleanText }],
|
||||
speakerChange: true,
|
||||
speaker: speakerIndex,
|
||||
};
|
||||
} else {
|
||||
if (!currentTurn) {
|
||||
currentTurn = { start: seg.start, segments: [], speakerChange: false };
|
||||
}
|
||||
currentTurn.segments.push({ start: seg.start, text: cleanText });
|
||||
}
|
||||
prevSegText = cleanText;
|
||||
}
|
||||
if (currentTurn) turns.push(currentTurn);
|
||||
|
||||
splitAffirmativeTurns(turns);
|
||||
|
||||
const groups: GroupedSegment[] = [];
|
||||
for (const turn of turns) {
|
||||
const sentenceGroups = turn.speaker === undefined
|
||||
? groupBySentence(turn.segments)
|
||||
: mergeSentenceGroupsWithinTurn(groupBySentence(turn.segments));
|
||||
for (let i = 0; i < sentenceGroups.length; i++) {
|
||||
groups.push({
|
||||
...sentenceGroups[i],
|
||||
speakerChange: i === 0 && turn.speakerChange,
|
||||
speaker: turn.speaker,
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function splitAffirmativeTurns(turns: Array<{
|
||||
start: number;
|
||||
segments: { start: number; text: string }[];
|
||||
speakerChange: boolean;
|
||||
speaker?: number;
|
||||
}>): void {
|
||||
const affirmativePattern = /^(mhm|yeah|yes|yep|right|okay|ok|absolutely|sure|exactly|uh-huh|mm-hmm)[.!,]?\s+/i;
|
||||
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
const turn = turns[i];
|
||||
if (turn.speaker === undefined || turn.segments.length === 0) continue;
|
||||
|
||||
const firstSeg = turn.segments[0];
|
||||
const match = affirmativePattern.exec(firstSeg.text);
|
||||
if (!match) continue;
|
||||
if (/,\s*$/.test(match[0])) continue;
|
||||
|
||||
const remainder = firstSeg.text.slice(match[0].length).trim();
|
||||
const restSegments = turn.segments.slice(1);
|
||||
const restWords = countWords(remainder) + restSegments.reduce((sum, s) => sum + countWords(s.text), 0);
|
||||
if (restWords < 30) continue;
|
||||
|
||||
const affirmativeText = match[0].trimEnd();
|
||||
const newRestSegments = remainder
|
||||
? [{ start: firstSeg.start, text: remainder }, ...restSegments]
|
||||
: restSegments;
|
||||
|
||||
turns.splice(i, 1, {
|
||||
start: turn.start,
|
||||
segments: [{ start: firstSeg.start, text: affirmativeText }],
|
||||
speakerChange: turn.speakerChange,
|
||||
speaker: turn.speaker,
|
||||
}, {
|
||||
start: newRestSegments[0].start,
|
||||
segments: newRestSegments,
|
||||
speakerChange: true,
|
||||
speaker: turn.speaker === 0 ? 1 : 0,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSentenceGroupsWithinTurn(groups: GroupedSegment[]): GroupedSegment[] {
|
||||
if (groups.length <= 1) return groups;
|
||||
|
||||
const merged: GroupedSegment[] = [];
|
||||
let current = { ...groups[0] };
|
||||
let currentIsFirstInTurn = true;
|
||||
|
||||
for (let i = 1; i < groups.length; i++) {
|
||||
const next = groups[i];
|
||||
if (shouldMergeSentenceGroups(current, next, currentIsFirstInTurn)) {
|
||||
current.text = `${current.text} ${next.text}`;
|
||||
continue;
|
||||
}
|
||||
merged.push(current);
|
||||
current = { ...next };
|
||||
currentIsFirstInTurn = false;
|
||||
}
|
||||
merged.push(current);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function shouldMergeSentenceGroups(
|
||||
current: { start: number; text: string },
|
||||
next: { start: number; text: string },
|
||||
currentIsFirstInTurn: boolean,
|
||||
): boolean {
|
||||
const currentWords = countWords(current.text);
|
||||
const nextWords = countWords(next.text);
|
||||
|
||||
if (isShortStandaloneUtterance(current.text, currentWords)
|
||||
|| isShortStandaloneUtterance(next.text, nextWords)) return false;
|
||||
if (currentIsFirstInTurn && currentWords < FIRST_GROUP_MERGE_MIN_WORDS) return false;
|
||||
if (QUESTION_END.test(current.text) || QUESTION_END.test(next.text)) return false;
|
||||
if (currentWords + nextWords > TURN_MERGE_MAX_WORDS) return false;
|
||||
if (next.start - current.start > TURN_MERGE_MAX_SPAN_SECONDS) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isShortStandaloneUtterance(text: string, words?: number): boolean {
|
||||
const w = words ?? countWords(text);
|
||||
return w > 0 && w <= SHORT_UTTERANCE_MAX_WORDS && SENTENCE_END.test(text);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* YouTube transcript — uses InnerTube player API with Android client context.
|
||||
*
|
||||
* The Web client's caption URLs require a PoToken (proof of origin) generated
|
||||
* by BotGuard at runtime. The Android client returns caption URLs that work
|
||||
* without PoToken — same approach used by youtube-transcript-api (Python).
|
||||
*
|
||||
* Modes:
|
||||
* --mode grouped (default): sentences merged, speaker detection, chapters
|
||||
* --mode raw: every caption segment as-is with precise timestamps
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { parseVideoId } from './utils.js';
|
||||
import {
|
||||
groupTranscriptSegments,
|
||||
formatGroupedTranscript,
|
||||
type RawSegment,
|
||||
type Chapter,
|
||||
} from './transcript-group.js';
|
||||
|
||||
cli({
|
||||
site: 'youtube',
|
||||
name: 'transcript',
|
||||
description: 'Get YouTube video transcript/subtitles',
|
||||
domain: 'www.youtube.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'url', required: true, help: 'YouTube video URL or video ID' },
|
||||
{ name: 'lang', required: false, help: 'Language code (e.g. en, zh-Hans). Omit to auto-select' },
|
||||
{ name: 'mode', required: false, default: 'grouped', help: 'Output mode: grouped (readable paragraphs) or raw (every segment)' },
|
||||
],
|
||||
// columns intentionally omitted — raw and grouped modes return different schemas,
|
||||
// so we let the renderer auto-detect columns from the data keys.
|
||||
func: async (page, kwargs) => {
|
||||
const videoId = parseVideoId(kwargs.url);
|
||||
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
await page.goto(videoUrl);
|
||||
await page.wait(3);
|
||||
|
||||
const lang = kwargs.lang || '';
|
||||
const mode = kwargs.mode || 'grouped';
|
||||
|
||||
// Step 1: Get caption track URL via Android InnerTube API
|
||||
const captionData = await page.evaluate(`
|
||||
(async () => {
|
||||
const cfg = window.ytcfg?.data_ || {};
|
||||
const apiKey = cfg.INNERTUBE_API_KEY;
|
||||
if (!apiKey) return { error: 'INNERTUBE_API_KEY not found on page' };
|
||||
|
||||
const resp = await fetch('/youtubei/v1/player?key=' + apiKey + '&prettyPrint=false', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context: { client: { clientName: 'ANDROID', clientVersion: '20.10.38' } },
|
||||
videoId: ${JSON.stringify(videoId)}
|
||||
})
|
||||
});
|
||||
|
||||
if (!resp.ok) return { error: 'InnerTube player API returned HTTP ' + resp.status };
|
||||
const data = await resp.json();
|
||||
|
||||
const renderer = data.captions?.playerCaptionsTracklistRenderer;
|
||||
if (!renderer?.captionTracks?.length) {
|
||||
return { error: 'No captions available for this video' };
|
||||
}
|
||||
|
||||
const tracks = renderer.captionTracks;
|
||||
const available = tracks.map(t => t.languageCode + (t.kind === 'asr' ? ' (auto)' : ''));
|
||||
|
||||
const langPref = ${JSON.stringify(lang)};
|
||||
let track = null;
|
||||
if (langPref) {
|
||||
track = tracks.find(t => t.languageCode === langPref)
|
||||
|| tracks.find(t => t.languageCode.startsWith(langPref));
|
||||
}
|
||||
if (!track) {
|
||||
track = tracks.find(t => t.kind !== 'asr') || tracks[0];
|
||||
}
|
||||
|
||||
return {
|
||||
captionUrl: track.baseUrl,
|
||||
language: track.languageCode,
|
||||
kind: track.kind || 'manual',
|
||||
available,
|
||||
requestedLang: langPref || null,
|
||||
langMatched: !!(langPref && track.languageCode === langPref),
|
||||
langPrefixMatched: !!(langPref && track.languageCode !== langPref && track.languageCode.startsWith(langPref))
|
||||
};
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!captionData || typeof captionData === 'string') {
|
||||
throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
|
||||
}
|
||||
if (captionData.error) {
|
||||
throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
|
||||
}
|
||||
|
||||
// Warn if --lang was specified but not matched
|
||||
if (captionData.requestedLang && !captionData.langMatched && !captionData.langPrefixMatched) {
|
||||
console.error(`Warning: --lang "${captionData.requestedLang}" not found. Using "${captionData.language}" instead. Available: ${captionData.available.join(', ')}`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch caption XML and parse segments
|
||||
const segments: RawSegment[] = await page.evaluate(`
|
||||
(async () => {
|
||||
const resp = await fetch(${JSON.stringify(captionData.captionUrl)});
|
||||
const xml = await resp.text();
|
||||
|
||||
if (!xml?.length) {
|
||||
return { error: 'Caption URL returned empty response' };
|
||||
}
|
||||
|
||||
function getAttr(tag, name) {
|
||||
const needle = name + '="';
|
||||
const idx = tag.indexOf(needle);
|
||||
if (idx === -1) return '';
|
||||
const valStart = idx + needle.length;
|
||||
const valEnd = tag.indexOf('"', valStart);
|
||||
if (valEnd === -1) return '';
|
||||
return tag.substring(valStart, valEnd);
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
const isFormat3 = xml.includes('<p t="');
|
||||
const marker = isFormat3 ? '<p ' : '<text ';
|
||||
const endMarker = isFormat3 ? '</p>' : '</text>';
|
||||
const results = [];
|
||||
let pos = 0;
|
||||
|
||||
while (true) {
|
||||
const tagStart = xml.indexOf(marker, pos);
|
||||
if (tagStart === -1) break;
|
||||
let contentStart = xml.indexOf('>', tagStart);
|
||||
if (contentStart === -1) break;
|
||||
contentStart += 1;
|
||||
const tagEnd = xml.indexOf(endMarker, contentStart);
|
||||
if (tagEnd === -1) break;
|
||||
|
||||
const attrStr = xml.substring(tagStart + marker.length, contentStart - 1);
|
||||
const content = xml.substring(contentStart, tagEnd);
|
||||
|
||||
let startSec, durSec;
|
||||
if (isFormat3) {
|
||||
startSec = (parseFloat(getAttr(attrStr, 't')) || 0) / 1000;
|
||||
durSec = (parseFloat(getAttr(attrStr, 'd')) || 0) / 1000;
|
||||
} else {
|
||||
startSec = parseFloat(getAttr(attrStr, 'start')) || 0;
|
||||
durSec = parseFloat(getAttr(attrStr, 'dur')) || 0;
|
||||
}
|
||||
|
||||
// Strip inner tags (e.g. <s> in srv3 format) and decode entities
|
||||
const text = decodeEntities(content.replace(/<[^>]+>/g, '')).split('\\\\n').join(' ').trim();
|
||||
if (text) {
|
||||
results.push({ start: startSec, end: startSec + durSec, text });
|
||||
}
|
||||
|
||||
pos = tagEnd + endMarker.length;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { error: 'Parsed 0 segments from caption XML' };
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!Array.isArray(segments)) {
|
||||
throw new Error((segments as any)?.error || 'Failed to parse caption segments');
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
throw new Error('No caption segments found');
|
||||
}
|
||||
|
||||
// Step 3: Fetch chapters (for grouped mode)
|
||||
let chapters: Chapter[] = [];
|
||||
if (mode === 'grouped') {
|
||||
try {
|
||||
const chapterData = await page.evaluate(`
|
||||
(async () => {
|
||||
const cfg = window.ytcfg?.data_ || {};
|
||||
const apiKey = cfg.INNERTUBE_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
|
||||
const resp = await fetch('/youtubei/v1/next?key=' + apiKey + '&prettyPrint=false', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context: { client: { clientName: 'WEB', clientVersion: '2.20240101.00.00' } },
|
||||
videoId: ${JSON.stringify(videoId)}
|
||||
})
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
|
||||
const chapters = [];
|
||||
|
||||
// Try chapterRenderer from player bar
|
||||
const panels = data.playerOverlays?.playerOverlayRenderer
|
||||
?.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer
|
||||
?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap;
|
||||
|
||||
if (Array.isArray(panels)) {
|
||||
for (const panel of panels) {
|
||||
const markers = panel.value?.chapters;
|
||||
if (!Array.isArray(markers)) continue;
|
||||
for (const marker of markers) {
|
||||
const ch = marker.chapterRenderer;
|
||||
if (!ch) continue;
|
||||
const title = ch.title?.simpleText || '';
|
||||
const startMs = ch.timeRangeStartMillis;
|
||||
if (title && typeof startMs === 'number') {
|
||||
chapters.push({ title, start: startMs / 1000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chapters.length > 0) return chapters;
|
||||
|
||||
// Fallback: macroMarkersListItemRenderer from engagement panels
|
||||
const engPanels = data.engagementPanels;
|
||||
if (!Array.isArray(engPanels)) return [];
|
||||
for (const ep of engPanels) {
|
||||
const content = ep.engagementPanelSectionListRenderer?.content;
|
||||
const items = content?.macroMarkersListRenderer?.contents;
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
const renderer = item.macroMarkersListItemRenderer;
|
||||
if (!renderer) continue;
|
||||
const t = renderer.title?.simpleText || '';
|
||||
const ts = renderer.timeDescription?.simpleText || '';
|
||||
if (!t || !ts) continue;
|
||||
const parts = ts.split(':').map(Number);
|
||||
let secs = null;
|
||||
if (parts.length === 3 && parts.every(n => !isNaN(n))) secs = parts[0]*3600 + parts[1]*60 + parts[2];
|
||||
else if (parts.length === 2 && parts.every(n => !isNaN(n))) secs = parts[0]*60 + parts[1];
|
||||
if (secs !== null) chapters.push({ title: t, start: secs });
|
||||
}
|
||||
}
|
||||
return chapters;
|
||||
})()
|
||||
`);
|
||||
if (Array.isArray(chapterData)) {
|
||||
chapters = chapterData;
|
||||
}
|
||||
} catch {
|
||||
// Chapters are optional — proceed without them
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Format output based on mode
|
||||
if (mode === 'raw') {
|
||||
// Precise timestamps in seconds with decimals, matching bilibili/subtitle format
|
||||
return segments.map((seg, i) => ({
|
||||
index: i + 1,
|
||||
start: Number(seg.start).toFixed(2) + 's',
|
||||
end: Number(seg.end).toFixed(2) + 's',
|
||||
text: seg.text,
|
||||
}));
|
||||
}
|
||||
|
||||
// Grouped mode: merge sentences, detect speakers, insert chapters
|
||||
const grouped = groupTranscriptSegments(
|
||||
segments.map(s => ({ start: s.start, text: s.text })),
|
||||
);
|
||||
const { rows } = formatGroupedTranscript(grouped, chapters);
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared YouTube utilities — URL parsing, video ID extraction, etc.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract a YouTube video ID from a URL or bare video ID string.
|
||||
* Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
|
||||
*/
|
||||
export function parseVideoId(input: string): string {
|
||||
if (!input.startsWith('http')) return input;
|
||||
|
||||
try {
|
||||
const parsed = new URL(input);
|
||||
if (parsed.searchParams.has('v')) {
|
||||
return parsed.searchParams.get('v')!;
|
||||
}
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
return parsed.pathname.slice(1).split('/')[0];
|
||||
}
|
||||
// Handle /shorts/xxx, /embed/xxx, /live/xxx, /v/xxx
|
||||
const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live|v)\/([^/?]+)/);
|
||||
if (pathMatch) return pathMatch[2];
|
||||
} catch {
|
||||
// Not a valid URL — treat entire input as video ID
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page.
|
||||
*/
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { parseVideoId } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'youtube',
|
||||
name: 'video',
|
||||
description: 'Get YouTube video metadata (title, views, description, etc.)',
|
||||
domain: 'www.youtube.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'url', required: true, help: 'YouTube video URL or video ID' },
|
||||
],
|
||||
columns: ['field', 'value'],
|
||||
func: async (page, kwargs) => {
|
||||
const videoId = parseVideoId(kwargs.url);
|
||||
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
await page.goto(videoUrl);
|
||||
await page.wait(3);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const player = window.ytInitialPlayerResponse;
|
||||
const yt = window.ytInitialData;
|
||||
if (!player) return { error: 'ytInitialPlayerResponse not found' };
|
||||
|
||||
const details = player.videoDetails || {};
|
||||
const microformat = player.microformat?.playerMicroformatRenderer || {};
|
||||
|
||||
// Try to get full description from ytInitialData
|
||||
let fullDescription = details.shortDescription || '';
|
||||
try {
|
||||
const contents = yt?.contents?.twoColumnWatchNextResults
|
||||
?.results?.results?.contents;
|
||||
if (contents) {
|
||||
for (const c of contents) {
|
||||
const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
|
||||
if (desc) { fullDescription = desc; break; }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Get like count if available
|
||||
let likes = '';
|
||||
try {
|
||||
const contents = yt?.contents?.twoColumnWatchNextResults
|
||||
?.results?.results?.contents;
|
||||
if (contents) {
|
||||
for (const c of contents) {
|
||||
const buttons = c.videoPrimaryInfoRenderer?.videoActions
|
||||
?.menuRenderer?.topLevelButtons;
|
||||
if (buttons) {
|
||||
for (const b of buttons) {
|
||||
const toggle = b.segmentedLikeDislikeButtonViewModel
|
||||
?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
|
||||
?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
|
||||
if (toggle?.title) { likes = toggle.title; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Get publish date
|
||||
const publishDate = microformat.publishDate
|
||||
|| microformat.uploadDate
|
||||
|| details.publishDate || '';
|
||||
|
||||
// Get category
|
||||
const category = microformat.category || '';
|
||||
|
||||
// Get channel subscriber count if available
|
||||
let subscribers = '';
|
||||
try {
|
||||
const contents = yt?.contents?.twoColumnWatchNextResults
|
||||
?.results?.results?.contents;
|
||||
if (contents) {
|
||||
for (const c of contents) {
|
||||
const owner = c.videoSecondaryInfoRenderer?.owner
|
||||
?.videoOwnerRenderer?.subscriberCountText?.simpleText;
|
||||
if (owner) { subscribers = owner; break; }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
title: details.title || '',
|
||||
channel: details.author || '',
|
||||
channelId: details.channelId || '',
|
||||
videoId: details.videoId || '',
|
||||
views: details.viewCount || '',
|
||||
likes,
|
||||
subscribers,
|
||||
duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
|
||||
publishDate,
|
||||
category,
|
||||
description: fullDescription,
|
||||
keywords: (details.keywords || []).join(', '),
|
||||
isLive: details.isLiveContent || false,
|
||||
thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
|
||||
};
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
// Return as field/value pairs for table display
|
||||
return Object.entries(data).map(([field, value]) => ({
|
||||
field,
|
||||
value: String(value),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Shell tab-completion support for opencli.
|
||||
*
|
||||
* Provides:
|
||||
* - Shell script generators for bash, zsh, and fish
|
||||
* - Dynamic completion logic that returns candidates for the current cursor position
|
||||
*/
|
||||
|
||||
import { getRegistry } from './registry.js';
|
||||
import { CliError } from './errors.js';
|
||||
|
||||
// ── Dynamic completion logic ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Built-in (non-dynamic) top-level commands.
|
||||
*/
|
||||
const BUILTIN_COMMANDS = [
|
||||
'list',
|
||||
'validate',
|
||||
'verify',
|
||||
'explore',
|
||||
'probe', // alias for explore
|
||||
'synthesize',
|
||||
'generate',
|
||||
'cascade',
|
||||
'doctor',
|
||||
'setup',
|
||||
'completion',
|
||||
];
|
||||
|
||||
/**
|
||||
* Return completion candidates given the current command-line words and cursor index.
|
||||
*
|
||||
* @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name)
|
||||
* @param cursor - 1-based position of the word being completed (1 = first arg)
|
||||
*/
|
||||
export function getCompletions(words: string[], cursor: number): string[] {
|
||||
// cursor === 1 → completing the first argument (site name or built-in command)
|
||||
if (cursor <= 1) {
|
||||
const sites = new Set<string>();
|
||||
for (const [, cmd] of getRegistry()) {
|
||||
sites.add(cmd.site);
|
||||
}
|
||||
return [...BUILTIN_COMMANDS, ...sites].sort();
|
||||
}
|
||||
|
||||
const site = words[0];
|
||||
|
||||
// If the first word is a built-in command, no further completion
|
||||
if (BUILTIN_COMMANDS.includes(site)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// cursor === 2 → completing the sub-command name under a site
|
||||
if (cursor === 2) {
|
||||
const subcommands: string[] = [];
|
||||
for (const [, cmd] of getRegistry()) {
|
||||
if (cmd.site === site) {
|
||||
subcommands.push(cmd.name);
|
||||
}
|
||||
}
|
||||
return subcommands.sort();
|
||||
}
|
||||
|
||||
// cursor >= 3 → no further completion
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Shell script generators ────────────────────────────────────────────────
|
||||
|
||||
export function bashCompletionScript(): string {
|
||||
return `# Bash completion for opencli
|
||||
# Add to ~/.bashrc: eval "$(opencli completion bash)"
|
||||
_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
|
||||
`;
|
||||
}
|
||||
|
||||
export function zshCompletionScript(): string {
|
||||
return `# Zsh completion for opencli
|
||||
# Add to ~/.zshrc: eval "$(opencli completion zsh)"
|
||||
_opencli() {
|
||||
local -a completions
|
||||
local cword=$((CURRENT - 1))
|
||||
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
|
||||
compadd -a completions
|
||||
}
|
||||
compdef _opencli opencli
|
||||
`;
|
||||
}
|
||||
|
||||
export function fishCompletionScript(): string {
|
||||
return `# Fish completion for opencli
|
||||
# Add to ~/.config/fish/config.fish: opencli completion fish | source
|
||||
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
|
||||
)'
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the completion script for the requested shell.
|
||||
*/
|
||||
export function printCompletionScript(shell: string): void {
|
||||
switch (shell) {
|
||||
case 'bash':
|
||||
process.stdout.write(bashCompletionScript());
|
||||
break;
|
||||
case 'zsh':
|
||||
process.stdout.write(zshCompletionScript());
|
||||
break;
|
||||
case 'fish':
|
||||
process.stdout.write(fishCompletionScript());
|
||||
break;
|
||||
default:
|
||||
throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
|
||||
}
|
||||
}
|
||||
+51
-93
@@ -2,87 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readTokenFromShellContent,
|
||||
renderBrowserDoctorReport,
|
||||
upsertShellToken,
|
||||
readTomlConfigToken,
|
||||
upsertTomlConfigToken,
|
||||
upsertJsonConfigToken,
|
||||
} from './doctor.js';
|
||||
|
||||
describe('shell token helpers', () => {
|
||||
it('reads token from shell export', () => {
|
||||
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('appends token export when missing', () => {
|
||||
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
||||
});
|
||||
|
||||
it('replaces token export when present', () => {
|
||||
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toml token helpers', () => {
|
||||
it('reads token from playwright env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
|
||||
`;
|
||||
expect(readTomlConfigToken(content)).toBe('abc123');
|
||||
});
|
||||
|
||||
it('updates token inside existing env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'new');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
|
||||
it('creates env section when missing', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'abc123');
|
||||
expect(next).toContain('[mcp_servers.playwright.env]');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('json token helpers', () => {
|
||||
it('writes token into standard mcpServers config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
|
||||
it('writes token into opencode mcp config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
playwright: {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctor report rendering', () => {
|
||||
@@ -91,40 +16,73 @@ describe('doctor report rendering', () => {
|
||||
it('renders OK-style report when tokens match', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: 'abc123',
|
||||
extensionFingerprint: 'fp1',
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[OK] Extension installed (Chrome)');
|
||||
expect(text).toContain('[OK] Environment token: configured');
|
||||
expect(text).toContain('[OK] /tmp/mcp.json');
|
||||
expect(text).toContain('configured (fp1)');
|
||||
expect(text).toContain('configured');
|
||||
});
|
||||
|
||||
it('renders MISMATCH-style report when fingerprints differ', () => {
|
||||
it('renders MISSING-style report when components are not installed', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: null,
|
||||
extensionFingerprint: null,
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
extensionInstalled: false,
|
||||
extensionBrowsers: [],
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
||||
expect(text).toContain('configured (fp2)');
|
||||
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
||||
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
||||
expect(text).toContain('[OK] Environment token: configured');
|
||||
expect(text).toContain('[OK] /tmp/.zshrc');
|
||||
expect(text).toContain('configured');
|
||||
expect(text).toContain('[OK] Token Configuration: Not required for OpenCLI MCP');
|
||||
});
|
||||
|
||||
it('renders connectivity OK when live test succeeds', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
extensionToken: 'abc123',
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [],
|
||||
configs: [],
|
||||
recommendedToken: 'abc123',
|
||||
connectivity: { ok: true, durationMs: 1234 },
|
||||
warnings: [],
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
||||
});
|
||||
|
||||
it('renders connectivity WARN when not tested', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
extensionToken: 'abc123',
|
||||
extensionInstalled: true,
|
||||
extensionBrowsers: ['Chrome'],
|
||||
shellFiles: [],
|
||||
configs: [],
|
||||
recommendedToken: 'abc123',
|
||||
warnings: [],
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+209
-209
@@ -1,12 +1,12 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import chalk from 'chalk';
|
||||
import type { IPage } from './types.js';
|
||||
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
||||
import { PlaywrightMCP } from './browser/index.js';
|
||||
import { browserSession } from './runtime.js';
|
||||
|
||||
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
||||
@@ -16,6 +16,7 @@ const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'
|
||||
export type DoctorOptions = {
|
||||
fix?: boolean;
|
||||
yes?: boolean;
|
||||
live?: boolean;
|
||||
shellRc?: string;
|
||||
configPaths?: string[];
|
||||
token?: string;
|
||||
@@ -26,7 +27,6 @@ export type ShellFileStatus = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
};
|
||||
|
||||
export type McpConfigFormat = 'json' | 'toml';
|
||||
@@ -36,21 +36,26 @@ export type McpConfigStatus = {
|
||||
exists: boolean;
|
||||
format: McpConfigFormat;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
writable: boolean;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
export type ConnectivityResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type DoctorReport = {
|
||||
cliVersion?: string;
|
||||
envToken: string | null;
|
||||
envFingerprint: string | null;
|
||||
extensionToken: string | null;
|
||||
extensionFingerprint: string | null;
|
||||
extensionInstalled: boolean;
|
||||
extensionBrowsers: string[];
|
||||
shellFiles: ShellFileStatus[];
|
||||
configs: McpConfigStatus[];
|
||||
recommendedToken: string | null;
|
||||
recommendedFingerprint: string | null;
|
||||
connectivity?: ConnectivityResult;
|
||||
warnings: string[];
|
||||
issues: string[];
|
||||
};
|
||||
@@ -70,9 +75,9 @@ function statusLine(status: ReportStatus, text: string): string {
|
||||
return `${colorLabel(status)} ${text}`;
|
||||
}
|
||||
|
||||
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
||||
function tokenSummary(token: string | null): string {
|
||||
if (!token) return chalk.dim('missing');
|
||||
return `configured ${chalk.dim(`(${fingerprint})`)}`;
|
||||
return `configured`;
|
||||
}
|
||||
|
||||
export function shortenPath(p: string): string {
|
||||
@@ -101,6 +106,15 @@ export function getDefaultShellRcPath(): string {
|
||||
return path.join(os.homedir(), '.zshrc');
|
||||
}
|
||||
|
||||
function isFishConfig(filePath: string): boolean {
|
||||
return filePath.endsWith('config.fish') || filePath.includes('/fish/');
|
||||
}
|
||||
|
||||
/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
|
||||
function isOpenCodeConfig(filePath: string): boolean {
|
||||
return filePath.includes('opencode');
|
||||
}
|
||||
|
||||
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
@@ -126,81 +140,7 @@ export function readTokenFromShellContent(content: string): string | null {
|
||||
return m?.[3] ?? null;
|
||||
}
|
||||
|
||||
export function upsertShellToken(content: string, token: string): string {
|
||||
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
||||
if (!content.trim()) return `${nextLine}\n`;
|
||||
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
||||
token
|
||||
}"`);
|
||||
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
||||
}
|
||||
|
||||
function readJsonConfigToken(content: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return readTokenFromJsonObject(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenFromJsonObject(parsed: any): string | null {
|
||||
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof direct === 'string' && direct) return direct;
|
||||
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof opencode === 'string' && opencode) return opencode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function upsertJsonConfigToken(content: string, token: string): string {
|
||||
const parsed = content.trim() ? JSON.parse(content) : {};
|
||||
if (parsed?.mcpServers) {
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
} else {
|
||||
parsed.mcp = parsed.mcp ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
}
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function readTomlConfigToken(content: string): string | null {
|
||||
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
||||
if (!sectionMatch) return null;
|
||||
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
||||
return tokenMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function upsertTomlConfigToken(content: string, token: string): string {
|
||||
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
||||
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
||||
if (envSectionRe.test(content)) {
|
||||
return content.replace(envSectionRe, (section) => {
|
||||
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
||||
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
||||
}
|
||||
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
||||
if (baseSectionRe.test(content)) {
|
||||
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
||||
}
|
||||
|
||||
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
||||
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
||||
}
|
||||
|
||||
export function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
@@ -226,17 +166,17 @@ function canWrite(filePath: string): boolean {
|
||||
function readConfigStatus(filePath: string): McpConfigStatus {
|
||||
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
|
||||
if (!fileExists(filePath)) {
|
||||
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
||||
return { path: filePath, exists: false, format, token: null, writable: canWrite(filePath) };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
||||
// Deprecated token extraction.
|
||||
const token = null;
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
format,
|
||||
token,
|
||||
fingerprint: getTokenFingerprint(token ?? undefined),
|
||||
writable: canWrite(filePath),
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -245,19 +185,41 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
||||
exists: true,
|
||||
format,
|
||||
token: null,
|
||||
fingerprint: null,
|
||||
writable: canWrite(filePath),
|
||||
parseError: error?.message ?? String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
|
||||
* directories across all browser base paths. Falls back to ['Default'] if none found.
|
||||
*/
|
||||
function enumerateProfiles(baseDirs: string[]): string[] {
|
||||
const profiles = new Set<string>();
|
||||
for (const base of baseDirs) {
|
||||
if (!fileExists(base)) continue;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
|
||||
profiles.add(entry.name);
|
||||
}
|
||||
}
|
||||
} catch { /* permission denied, etc. */ }
|
||||
}
|
||||
return profiles.size > 0 ? [...profiles].sort() : ['Default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the auth token stored by the Playwright MCP Bridge extension
|
||||
* by scanning Chrome's LevelDB localStorage files directly.
|
||||
*
|
||||
* Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
|
||||
* with a pure-Node fallback on Windows.
|
||||
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
||||
* extension ID near base64url token values. This works reliably across
|
||||
* platforms because LevelDB's internal encoding can split ASCII strings
|
||||
* like "auth-token" and the extension ID across byte boundaries, making
|
||||
* text-based tools like `strings` + `grep` unreliable.
|
||||
*/
|
||||
export function discoverExtensionToken(): string | null {
|
||||
const home = os.homedir();
|
||||
@@ -267,6 +229,8 @@ export function discoverExtensionToken(): string | null {
|
||||
if (platform === 'darwin') {
|
||||
bases.push(
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
||||
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
||||
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
||||
@@ -274,6 +238,8 @@ export function discoverExtensionToken(): string | null {
|
||||
} else if (platform === 'linux') {
|
||||
bases.push(
|
||||
path.join(home, '.config', 'google-chrome'),
|
||||
path.join(home, '.config', 'google-chrome-unstable'),
|
||||
path.join(home, '.config', 'google-chrome-beta'),
|
||||
path.join(home, '.config', 'chromium'),
|
||||
path.join(home, '.config', 'microsoft-edge'),
|
||||
);
|
||||
@@ -281,12 +247,13 @@ export function discoverExtensionToken(): string | null {
|
||||
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
bases.push(
|
||||
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
||||
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
|
||||
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
|
||||
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
||||
// Token is 43 chars of base64url (from 32 random bytes)
|
||||
const profiles = enumerateProfiles(bases);
|
||||
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
||||
|
||||
for (const base of bases) {
|
||||
@@ -294,14 +261,6 @@ export function discoverExtensionToken(): string | null {
|
||||
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
||||
if (!fileExists(dir)) continue;
|
||||
|
||||
// Fast path: use strings + grep to find candidate files and extract token
|
||||
if (platform !== 'win32') {
|
||||
const token = extractTokenViaStrings(dir, tokenRe);
|
||||
if (token) return token;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Slow path (Windows): read binary files directly
|
||||
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
||||
if (token) return token;
|
||||
}
|
||||
@@ -310,39 +269,20 @@ export function discoverExtensionToken(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
|
||||
try {
|
||||
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
||||
// after the extension ID, and filter for base64url token pattern.
|
||||
//
|
||||
// LevelDB `strings` output for the extension's auth-token entry:
|
||||
// auth-token ← key name
|
||||
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
||||
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
||||
//
|
||||
// We get the line immediately after any EXTENSION_ID mention and check
|
||||
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
||||
const shellDir = dir.replace(/'/g, "'\\''");
|
||||
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
||||
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
||||
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
||||
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
||||
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
||||
`done 2>/dev/null`;
|
||||
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
||||
|
||||
// Take the first non-empty line
|
||||
for (const line of result.split('\n')) {
|
||||
const token = line.trim();
|
||||
if (token && validateBase64urlToken(token)) return token;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
||||
// LevelDB fragments strings across byte boundaries, so we can't search
|
||||
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
||||
// search for a short prefix of the extension ID that reliably appears as
|
||||
// contiguous bytes, then scan a window around each match for a base64url
|
||||
// token value.
|
||||
//
|
||||
// Observed LevelDB layout near the auth-token entry:
|
||||
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
||||
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
||||
//
|
||||
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
||||
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
||||
const keyBuf = Buffer.from('auth-token');
|
||||
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
@@ -351,7 +291,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
||||
.map(f => path.join(dir, f));
|
||||
} catch { return null; }
|
||||
|
||||
// Sort by mtime descending
|
||||
// Sort by mtime descending so we find the freshest token first
|
||||
files.sort((a, b) => {
|
||||
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
||||
});
|
||||
@@ -360,14 +300,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
||||
let data: Buffer;
|
||||
try { data = fs.readFileSync(file); } catch { continue; }
|
||||
|
||||
// Quick check: does file contain both the extension ID and auth-token key?
|
||||
const extPos = data.indexOf(extIdBuf);
|
||||
if (extPos === -1) continue;
|
||||
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
||||
if (keyPos === -1) continue;
|
||||
// Quick check: file must contain at least the prefix
|
||||
if (data.indexOf(extIdPrefix) === -1) continue;
|
||||
|
||||
// Scan for token value after auth-token key
|
||||
// Strategy 1: scan after each occurrence of the extension ID prefix
|
||||
// for base64url tokens within a 500-byte window
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const pos = data.indexOf(extIdPrefix, idx);
|
||||
if (pos === -1) break;
|
||||
|
||||
const scanStart = pos;
|
||||
const scanEnd = Math.min(data.length, pos + 500);
|
||||
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
||||
const m = window.match(tokenRe);
|
||||
if (m && validateBase64urlToken(m[1])) {
|
||||
// Make sure this isn't another extension ID that happens to match
|
||||
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
||||
}
|
||||
idx = pos + 1;
|
||||
}
|
||||
|
||||
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
||||
const keyBuf = Buffer.from('auth-token');
|
||||
idx = 0;
|
||||
while (true) {
|
||||
const kp = data.indexOf(keyBuf, idx);
|
||||
if (kp === -1) break;
|
||||
@@ -393,14 +349,83 @@ function validateBase64urlToken(token: string): boolean {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
||||
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
||||
*/
|
||||
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
||||
const home = os.homedir();
|
||||
const platform = os.platform();
|
||||
const browserDirs: Array<{ name: string; base: string }> = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
||||
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
|
||||
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
|
||||
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
||||
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
||||
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
||||
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
|
||||
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
|
||||
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
||||
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
browserDirs.push(
|
||||
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
||||
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
|
||||
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
|
||||
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = enumerateProfiles(browserDirs.map(d => d.base));
|
||||
const foundBrowsers: string[] = [];
|
||||
|
||||
for (const { name, base } of browserDirs) {
|
||||
for (const profile of profiles) {
|
||||
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
||||
if (fileExists(extDir)) {
|
||||
foundBrowsers.push(name);
|
||||
break; // one match per browser is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test token connectivity by attempting a real MCP connection.
|
||||
* Connects, does the JSON-RPC handshake, and immediately closes.
|
||||
*/
|
||||
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
||||
const timeout = opts?.timeout ?? 8;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const mcp = new PlaywrightMCP();
|
||||
await mcp.connect({ timeout });
|
||||
await mcp.close();
|
||||
return { ok: true, durationMs: Date.now() - start };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
||||
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
||||
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
||||
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
|
||||
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
|
||||
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = readTokenFromShellContent(content);
|
||||
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
||||
return { path: filePath, exists: true, token };
|
||||
});
|
||||
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
||||
const configs = configPaths.map(readConfigStatus);
|
||||
@@ -418,55 +443,60 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
const uniqueTokens = [...new Set(allTokens)];
|
||||
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
||||
|
||||
// Check extension installation
|
||||
const extInstall = checkExtensionInstalled();
|
||||
|
||||
// Connectivity test (only when --live)
|
||||
let connectivity: ConnectivityResult | undefined;
|
||||
if (opts.live) {
|
||||
connectivity = await checkTokenConnectivity();
|
||||
}
|
||||
|
||||
const report: DoctorReport = {
|
||||
cliVersion: opts.cliVersion,
|
||||
envToken,
|
||||
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
||||
extensionToken,
|
||||
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
||||
extensionInstalled: extInstall.installed,
|
||||
extensionBrowsers: extInstall.browsers,
|
||||
shellFiles,
|
||||
configs,
|
||||
recommendedToken,
|
||||
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
||||
connectivity,
|
||||
warnings: [],
|
||||
issues: [],
|
||||
};
|
||||
|
||||
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
||||
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
||||
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
||||
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
||||
if (!extInstall.installed) report.issues.push('OpenCLI MCP Bridge extension is not installed in any browser.');
|
||||
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
||||
for (const config of configs) {
|
||||
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
||||
}
|
||||
if (!recommendedToken) {
|
||||
report.warnings.push('No token source found.');
|
||||
//
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
const tokenFingerprints = [
|
||||
report.extensionFingerprint,
|
||||
report.envFingerprint,
|
||||
...report.shellFiles.map(shell => shell.fingerprint),
|
||||
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
||||
].filter((value): value is string => !!value);
|
||||
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
||||
const hasMismatch = uniqueFingerprints.length > 1;
|
||||
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
||||
|
||||
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
||||
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
||||
const installDetail = report.extensionInstalled
|
||||
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
||||
: 'Extension not installed in any browser';
|
||||
lines.push(statusLine(installStatus, installDetail));
|
||||
|
||||
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
||||
const extStatus: ReportStatus = 'OK';
|
||||
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken)}`));
|
||||
|
||||
const envStatus: ReportStatus = 'OK';
|
||||
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken)}`));
|
||||
|
||||
for (const shell of report.shellFiles) {
|
||||
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
const shellStatus: ReportStatus = 'OK';
|
||||
const tool = toolName(shell.path);
|
||||
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
||||
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
||||
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token)}`));
|
||||
}
|
||||
const existingConfigs = report.configs.filter(config => config.exists);
|
||||
const missingConfigCount = report.configs.length - existingConfigs.length;
|
||||
@@ -477,21 +507,31 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
? 'WARN'
|
||||
: !config.token
|
||||
? 'MISSING'
|
||||
: hasMismatch
|
||||
? 'MISMATCH'
|
||||
: 'OK';
|
||||
: 'OK';
|
||||
const tool = toolName(config.path);
|
||||
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
||||
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
||||
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token)}${parseSuffix}`));
|
||||
}
|
||||
} else {
|
||||
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
|
||||
}
|
||||
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
||||
lines.push('');
|
||||
|
||||
// Connectivity result
|
||||
if (report.connectivity) {
|
||||
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
||||
const connDetail = report.connectivity.ok
|
||||
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
||||
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
||||
lines.push(statusLine(connStatus, connDetail));
|
||||
} else {
|
||||
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
||||
}
|
||||
|
||||
lines.push(statusLine(
|
||||
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
||||
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
||||
'OK',
|
||||
`Token Configuration: Not required for OpenCLI MCP`,
|
||||
));
|
||||
if (report.issues.length) {
|
||||
lines.push('', chalk.yellow('Issues:'));
|
||||
@@ -520,46 +560,6 @@ export function writeFileWithMkdir(filePath: string, content: string): void {
|
||||
}
|
||||
|
||||
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
||||
const token = opts.token ?? report.recommendedToken;
|
||||
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
|
||||
const fp = getTokenFingerprint(token);
|
||||
|
||||
const plannedWrites: string[] = [];
|
||||
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
||||
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
|
||||
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
|
||||
for (const config of report.configs) {
|
||||
if (!config.writable) continue;
|
||||
if (config.fingerprint === fp) continue; // already correct
|
||||
plannedWrites.push(config.path);
|
||||
}
|
||||
|
||||
if (plannedWrites.length === 0) {
|
||||
console.log(chalk.green('All config files are already up to date.'));
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
|
||||
if (!ok) return [];
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
if (plannedWrites.includes(shellPath)) {
|
||||
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
||||
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
||||
written.push(shellPath);
|
||||
}
|
||||
|
||||
for (const config of report.configs) {
|
||||
if (!plannedWrites.includes(config.path)) continue;
|
||||
if (config.parseError) continue;
|
||||
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
||||
const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
||||
writeFileWithMkdir(config.path, next);
|
||||
written.push(config.path);
|
||||
}
|
||||
|
||||
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
return written;
|
||||
console.log(chalk.green('OpenCLI MCP Bridge does not require token configuration!'));
|
||||
return [];
|
||||
}
|
||||
|
||||
+13
-5
@@ -14,6 +14,8 @@ import yaml from 'js-yaml';
|
||||
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
||||
import type { IPage } from './types.js';
|
||||
import { executePipeline } from './pipeline.js';
|
||||
import { log } from './logger.js';
|
||||
import { AdapterLoadError } from './errors.js';
|
||||
|
||||
/** Set of TS module paths that have been loaded */
|
||||
const _loadedModules = new Set<string>();
|
||||
@@ -84,7 +86,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
process.stderr.write(`Warning: failed to load manifest ${manifestPath}: ${err.message}\n`);
|
||||
log.warn(`Failed to load manifest ${manifestPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +103,13 @@ async function discoverClisFromFs(dir: string): Promise<void> {
|
||||
const filePath = path.join(siteDir, file);
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
||||
registerYamlCli(filePath, site);
|
||||
} else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
|
||||
} else if (
|
||||
(file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
||||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
|
||||
) {
|
||||
promises.push(
|
||||
import(`file://${filePath}`).catch((err: any) => {
|
||||
process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`);
|
||||
log.warn(`Failed to load module ${filePath}: ${err.message}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -155,7 +160,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
|
||||
|
||||
registerCommand(cmd);
|
||||
} catch (err: any) {
|
||||
process.stderr.write(`Warning: failed to load ${filePath}: ${err.message}\n`);
|
||||
log.warn(`Failed to load ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +182,10 @@ export async function executeCommand(
|
||||
await import(`file://${modulePath}`);
|
||||
_loadedModules.add(modulePath);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to load adapter module ${modulePath}: ${err.message}`);
|
||||
throw new AdapterLoadError(
|
||||
`Failed to load adapter module ${modulePath}: ${err.message}`,
|
||||
'Check that the adapter file exists and has no syntax errors.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// After loading, the module's cli() call will have updated the registry
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unified error types for opencli.
|
||||
*
|
||||
* All errors thrown by the framework should extend CliError so that
|
||||
* the top-level handler in main.ts can render consistent, helpful output.
|
||||
*/
|
||||
|
||||
export class CliError extends Error {
|
||||
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
|
||||
readonly code: string;
|
||||
/** Human-readable hint on how to fix the problem */
|
||||
readonly hint?: string;
|
||||
|
||||
constructor(code: string, message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = 'CliError';
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserConnectError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('BROWSER_CONNECT', message, hint);
|
||||
this.name = 'BrowserConnectError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AdapterLoadError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('ADAPTER_LOAD', message, hint);
|
||||
this.name = 'AdapterLoadError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CommandExecutionError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('COMMAND_EXEC', message, hint);
|
||||
this.name = 'CommandExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('CONFIG', message, hint);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Unified logging for opencli.
|
||||
*
|
||||
* All framework output (warnings, debug info, errors) should go through
|
||||
* this module so that verbosity levels are respected consistently.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
function isVerbose(): boolean {
|
||||
return !!process.env.OPENCLI_VERBOSE;
|
||||
}
|
||||
|
||||
function isDebug(): boolean {
|
||||
return !!process.env.DEBUG?.includes('opencli');
|
||||
}
|
||||
|
||||
export const log = {
|
||||
/** Informational message (always shown) */
|
||||
info(msg: string): void {
|
||||
process.stderr.write(`${chalk.blue('ℹ')} ${msg}\n`);
|
||||
},
|
||||
|
||||
/** Warning (always shown) */
|
||||
warn(msg: string): void {
|
||||
process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
|
||||
},
|
||||
|
||||
/** Error (always shown) */
|
||||
error(msg: string): void {
|
||||
process.stderr.write(`${chalk.red('✖')} ${msg}\n`);
|
||||
},
|
||||
|
||||
/** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
|
||||
verbose(msg: string): void {
|
||||
if (isVerbose()) {
|
||||
process.stderr.write(`${chalk.dim('[verbose]')} ${msg}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Debug output (only when DEBUG includes 'opencli') */
|
||||
debug(msg: string): void {
|
||||
if (isDebug()) {
|
||||
process.stderr.write(`${chalk.dim('[debug]')} ${msg}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Step-style debug (for pipeline steps, etc.) */
|
||||
step(stepNum: number, total: number, op: string, preview: string = ''): void {
|
||||
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
|
||||
},
|
||||
|
||||
/** Step result summary */
|
||||
stepResult(summary: string): void {
|
||||
process.stderr.write(` ${chalk.dim(`→ ${summary}`)}\n`);
|
||||
},
|
||||
};
|
||||
+75
-11
@@ -11,9 +11,11 @@ import chalk from 'chalk';
|
||||
import { discoverClis, executeCommand } from './engine.js';
|
||||
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
|
||||
import { render as renderOutput } from './output.js';
|
||||
import { PlaywrightMCP } from './browser.js';
|
||||
import { PlaywrightMCP } from './browser/index.js';
|
||||
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
||||
import { PKG_VERSION } from './version.js';
|
||||
import { getCompletions, printCompletionScript } from './completion.js';
|
||||
import { CliError } from './errors.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -22,6 +24,27 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
||||
|
||||
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
||||
|
||||
// ── Fast-path: handle --get-completions before commander parses ─────────
|
||||
// Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
|
||||
const getCompIdx = process.argv.indexOf('--get-completions');
|
||||
if (getCompIdx !== -1) {
|
||||
const rest = process.argv.slice(getCompIdx + 1);
|
||||
let cursor: number | undefined;
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--cursor' && i + 1 < rest.length) {
|
||||
cursor = parseInt(rest[i + 1], 10);
|
||||
i++; // skip the value
|
||||
} else {
|
||||
words.push(rest[i]);
|
||||
}
|
||||
}
|
||||
if (cursor === undefined) cursor = words.length;
|
||||
const candidates = getCompletions(words, cursor);
|
||||
process.stdout.write(candidates.join('\n') + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
|
||||
|
||||
@@ -62,10 +85,18 @@ program.command('list').description('List all available CLI commands').option('-
|
||||
});
|
||||
|
||||
program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name')
|
||||
.action(async (target) => { const { validateClisWithTarget, renderValidationReport } = await import('./validate.js'); console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target))); });
|
||||
.action(async (target) => {
|
||||
const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
|
||||
console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
|
||||
});
|
||||
|
||||
program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false)
|
||||
.action(async (target, opts) => { const { verifyClis, renderVerifyReport } = await import('./verify.js'); const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); console.log(renderVerifyReport(r)); process.exitCode = r.ok ? 0 : 1; });
|
||||
.action(async (target, opts) => {
|
||||
const { verifyClis, renderVerifyReport } = await import('./verify.js');
|
||||
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
|
||||
console.log(renderVerifyReport(r));
|
||||
process.exitCode = r.ok ? 0 : 1;
|
||||
});
|
||||
|
||||
program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
|
||||
.action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: PlaywrightMCP, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
|
||||
@@ -92,12 +123,13 @@ program.command('doctor')
|
||||
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
||||
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
||||
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
||||
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
||||
.option('--shell-rc <path>', 'Shell startup file to update')
|
||||
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
||||
.action(async (opts) => {
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
||||
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
||||
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
||||
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
||||
console.log(renderBrowserDoctorReport(report));
|
||||
if (opts.fix) {
|
||||
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
||||
@@ -119,6 +151,13 @@ program.command('setup')
|
||||
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
|
||||
});
|
||||
|
||||
program.command('completion')
|
||||
.description('Output shell completion script')
|
||||
.argument('<shell>', 'Shell type: bash, zsh, or fish')
|
||||
.action((shell) => {
|
||||
printCompletionScript(shell);
|
||||
});
|
||||
|
||||
// ── Dynamic site commands ──────────────────────────────────────────────────
|
||||
|
||||
const registry = getRegistry();
|
||||
@@ -129,18 +168,37 @@ for (const [, cmd] of registry) {
|
||||
if (!siteCmd) { siteCmd = program.command(cmd.site).description(`${cmd.site} commands`); siteGroups.set(cmd.site, siteCmd); }
|
||||
const subCmd = siteCmd.command(cmd.name).description(cmd.description);
|
||||
|
||||
// Register positional args first, then named options
|
||||
const positionalArgs: typeof cmd.args = [];
|
||||
for (const arg of cmd.args) {
|
||||
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
|
||||
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
||||
else subCmd.option(flag, arg.help ?? '');
|
||||
if (arg.positional) {
|
||||
const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
|
||||
subCmd.argument(bracket, arg.help ?? '');
|
||||
positionalArgs.push(arg);
|
||||
} else {
|
||||
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
|
||||
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
||||
else subCmd.option(flag, arg.help ?? '');
|
||||
}
|
||||
}
|
||||
subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
|
||||
|
||||
subCmd.action(async (actionOpts) => {
|
||||
subCmd.action(async (...actionArgs: any[]) => {
|
||||
// Commander passes positional args first, then options object, then the Command
|
||||
const actionOpts = actionArgs[positionalArgs.length] ?? {};
|
||||
const startTime = Date.now();
|
||||
const kwargs: Record<string, any> = {};
|
||||
// Collect positional args
|
||||
for (let i = 0; i < positionalArgs.length; i++) {
|
||||
const arg = positionalArgs[i];
|
||||
const v = actionArgs[i];
|
||||
if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
|
||||
else if (arg.default != null) kwargs[arg.name] = arg.default;
|
||||
}
|
||||
// Collect named options
|
||||
for (const arg of cmd.args) {
|
||||
if (arg.positional) continue;
|
||||
const v = actionOpts[arg.name]; if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
|
||||
else if (arg.default != null) kwargs[arg.name] = arg.default;
|
||||
}
|
||||
@@ -155,8 +213,14 @@ for (const [, cmd] of registry) {
|
||||
}
|
||||
renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) });
|
||||
} catch (err: any) {
|
||||
if (actionOpts.verbose && err.stack) { console.error(chalk.red(err.stack)); }
|
||||
else { console.error(chalk.red(`Error: ${err.message ?? err}`)); }
|
||||
if (err instanceof CliError) {
|
||||
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
|
||||
if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
|
||||
} else if (actionOpts.verbose && err.stack) {
|
||||
console.error(chalk.red(err.stack));
|
||||
} else {
|
||||
console.error(chalk.red(`Error: ${err.message ?? err}`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
+2
-1
@@ -82,7 +82,8 @@ function renderCsv(data: any, opts: RenderOptions): void {
|
||||
for (const row of rows) {
|
||||
console.log(columns.map(c => {
|
||||
const v = String(row[c] ?? '');
|
||||
return v.includes(',') || v.includes('"') ? `"${v.replace(/"/g, '""')}"` : v;
|
||||
return v.includes(',') || v.includes('"') || v.includes('\n')
|
||||
? `"${v.replace(/"/g, '""')}"` : v;
|
||||
}).join(','));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { stepFetch } from './steps/fetch.js';
|
||||
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
||||
import { stepIntercept } from './steps/intercept.js';
|
||||
import { stepTap } from './steps/tap.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
export interface PipelineContext {
|
||||
args?: Record<string, any>;
|
||||
@@ -57,7 +58,7 @@ export async function executePipeline(
|
||||
if (handler) {
|
||||
data = await handler(page, params, data, args);
|
||||
} else {
|
||||
if (debug) process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
|
||||
if (debug) log.warn(`Unknown step: ${op}`);
|
||||
}
|
||||
|
||||
if (debug) debugStepResult(op, data);
|
||||
@@ -73,21 +74,21 @@ function debugStepStart(stepNum: number, total: number, op: string, params: any)
|
||||
} else if (params && typeof params === 'object' && !Array.isArray(params)) {
|
||||
preview = ` (${Object.keys(params).join(', ')})`;
|
||||
}
|
||||
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
|
||||
log.step(stepNum, total, op, preview);
|
||||
}
|
||||
|
||||
function debugStepResult(op: string, data: any): void {
|
||||
if (data === null || data === undefined) {
|
||||
process.stderr.write(` ${chalk.dim('→ (no data)')}\n`);
|
||||
log.stepResult('(no data)');
|
||||
} else if (Array.isArray(data)) {
|
||||
process.stderr.write(` ${chalk.dim(`→ ${data.length} items`)}\n`);
|
||||
log.stepResult(`${data.length} items`);
|
||||
} else if (typeof data === 'object') {
|
||||
const keys = Object.keys(data).slice(0, 5);
|
||||
process.stderr.write(` ${chalk.dim(`→ dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`)}\n`);
|
||||
log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`);
|
||||
} else if (typeof data === 'string') {
|
||||
const p = data.slice(0, 60).replace(/\n/g, '\\n');
|
||||
process.stderr.write(` ${chalk.dim(`→ "${p}${data.length > 60 ? '...' : ''}"`)}\n`);
|
||||
log.stepResult(`"${p}${data.length > 60 ? '...' : ''}"`);
|
||||
} else {
|
||||
process.stderr.write(` ${chalk.dim(`→ ${typeof data}`)}\n`);
|
||||
log.stepResult(`${typeof data}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,53 +6,53 @@
|
||||
import type { IPage } from '../../types.js';
|
||||
import { render, normalizeEvaluateSource } from '../template.js';
|
||||
|
||||
export async function stepNavigate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const url = render(params, { args, data });
|
||||
await page.goto(String(url));
|
||||
await page!.goto(String(url));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stepClick(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
await page.click(String(render(params, { args, data })).replace(/^@/, ''));
|
||||
export async function stepClick(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
await page!.click(String(render(params, { args, data })).replace(/^@/, ''));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stepType(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
export async function stepType(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
if (typeof params === 'object' && params) {
|
||||
const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, '');
|
||||
const text = String(render(params.text ?? '', { args, data }));
|
||||
await page.typeText(ref, text);
|
||||
if (params.submit) await page.pressKey('Enter');
|
||||
await page!.typeText(ref, text);
|
||||
if (params.submit) await page!.pressKey('Enter');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stepWait(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
if (typeof params === 'number') await page.wait(params);
|
||||
export async function stepWait(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
if (typeof params === 'number') await page!.wait(params);
|
||||
else if (typeof params === 'object' && params) {
|
||||
if ('text' in params) {
|
||||
await page.wait({
|
||||
await page!.wait({
|
||||
text: String(render(params.text, { args, data })),
|
||||
timeout: params.timeout
|
||||
});
|
||||
} else if ('time' in params) await page.wait(Number(params.time));
|
||||
} else if (typeof params === 'string') await page.wait(Number(render(params, { args, data })));
|
||||
} else if ('time' in params) await page!.wait(Number(params.time));
|
||||
} else if (typeof params === 'string') await page!.wait(Number(render(params, { args, data })));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stepPress(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
await page.pressKey(String(render(params, { args, data })));
|
||||
export async function stepPress(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
await page!.pressKey(String(render(params, { args, data })));
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stepSnapshot(page: IPage, params: any, _data: any, _args: Record<string, any>): Promise<any> {
|
||||
export async function stepSnapshot(page: IPage | null, params: any, _data: any, _args: Record<string, any>): Promise<any> {
|
||||
const opts = (typeof params === 'object' && params) ? params : {};
|
||||
return page.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false });
|
||||
return page!.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false });
|
||||
}
|
||||
|
||||
export async function stepEvaluate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const js = String(render(params, { args, data }));
|
||||
let result = await page.evaluate(normalizeEvaluateSource(js));
|
||||
let result = await page!.evaluate(normalizeEvaluateSource(js));
|
||||
// MCP may return JSON as a string — auto-parse it
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { IPage } from '../../types.js';
|
||||
import { render } from '../template.js';
|
||||
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
|
||||
|
||||
export async function stepIntercept(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const cfg = typeof params === 'object' ? params : {};
|
||||
const trigger = cfg.trigger ?? '';
|
||||
const capturePattern = cfg.capture ?? '';
|
||||
@@ -16,28 +16,28 @@ export async function stepIntercept(page: IPage, params: any, data: any, args: R
|
||||
if (!capturePattern) return data;
|
||||
|
||||
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
|
||||
await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
|
||||
await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
|
||||
|
||||
// Step 2: Execute the trigger action
|
||||
if (trigger.startsWith('navigate:')) {
|
||||
const url = render(trigger.slice('navigate:'.length), { args, data });
|
||||
await page.goto(String(url));
|
||||
await page!.goto(String(url));
|
||||
} else if (trigger.startsWith('evaluate:')) {
|
||||
const js = trigger.slice('evaluate:'.length);
|
||||
const { normalizeEvaluateSource } = await import('../template.js');
|
||||
await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
|
||||
await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
|
||||
} else if (trigger.startsWith('click:')) {
|
||||
const ref = render(trigger.slice('click:'.length), { args, data });
|
||||
await page.click(String(ref).replace(/^@/, ''));
|
||||
await page!.click(String(ref).replace(/^@/, ''));
|
||||
} else if (trigger === 'scroll') {
|
||||
await page.scroll('down');
|
||||
await page!.scroll('down');
|
||||
}
|
||||
|
||||
// Step 3: Wait a bit for network requests to fire
|
||||
await page.wait(Math.min(timeout, 3));
|
||||
await page!.wait(Math.min(timeout, 3));
|
||||
|
||||
// Step 4: Retrieve captured data
|
||||
const matchingResponses = await page.evaluate(generateReadInterceptedJs());
|
||||
const matchingResponses = await page!.evaluate(generateReadInterceptedJs());
|
||||
|
||||
// Step 5: Select from response if specified
|
||||
let result = matchingResponses.length === 1 ? matchingResponses[0] :
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { IPage } from '../../types.js';
|
||||
import { render } from '../template.js';
|
||||
import { generateTapInterceptorJs } from '../../interceptor.js';
|
||||
|
||||
export async function stepTap(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
export async function stepTap(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const cfg = typeof params === 'object' ? params : {};
|
||||
const storeName = String(render(cfg.store ?? '', { args, data }));
|
||||
const actionName = String(render(cfg.action ?? '', { args, data }));
|
||||
@@ -96,5 +96,5 @@ export async function stepTap(page: IPage, params: any, data: any, args: Record<
|
||||
}
|
||||
`;
|
||||
|
||||
return page.evaluate(js);
|
||||
return page!.evaluate(js);
|
||||
}
|
||||
|
||||
+2
-8
@@ -17,6 +17,7 @@ export interface Arg {
|
||||
type?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
@@ -41,18 +42,11 @@ export interface InternalCliCommand extends CliCommand {
|
||||
_lazy?: boolean;
|
||||
_modulePath?: string;
|
||||
}
|
||||
export interface CliOptions {
|
||||
export interface CliOptions extends Partial<Omit<CliCommand, 'args' | 'description'>> {
|
||||
site: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
strategy?: Strategy;
|
||||
browser?: boolean;
|
||||
args?: Arg[];
|
||||
columns?: string[];
|
||||
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
||||
pipeline?: any[];
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
const _registry = new Map<string, CliCommand>();
|
||||
|
||||
|
||||
+23
-156
@@ -1,169 +1,36 @@
|
||||
/**
|
||||
* setup.ts — Interactive Playwright MCP token setup
|
||||
* setup.ts — OpenCLI MCP token setup
|
||||
*
|
||||
* Discovers the extension token, shows an interactive checkbox
|
||||
* for selecting which config files to update, and applies changes.
|
||||
* OpenCLI MCP is now tokenless. This file simply informs the user
|
||||
* that token configuration is no longer required.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import chalk from 'chalk';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import {
|
||||
type DoctorReport,
|
||||
PLAYWRIGHT_TOKEN_ENV,
|
||||
discoverExtensionToken,
|
||||
fileExists,
|
||||
getDefaultShellRcPath,
|
||||
runBrowserDoctor,
|
||||
shortenPath,
|
||||
toolName,
|
||||
upsertJsonConfigToken,
|
||||
upsertShellToken,
|
||||
upsertTomlConfigToken,
|
||||
writeFileWithMkdir,
|
||||
} from './doctor.js';
|
||||
import { getTokenFingerprint } from './browser.js';
|
||||
import { type CheckboxItem, checkboxPrompt } from './tui.js';
|
||||
import { checkTokenConnectivity } from './doctor.js';
|
||||
|
||||
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
|
||||
console.log();
|
||||
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
|
||||
console.log(chalk.bold(' opencli setup') + chalk.dim(' — OpenCLI MCP configuration'));
|
||||
console.log();
|
||||
console.log(` ${chalk.green('✓')} Configuration complete.`);
|
||||
console.log(` ${chalk.dim('OpenCLI MCP Bridge no longer requires token configuration.')}`);
|
||||
console.log();
|
||||
|
||||
// Step 1: Discover token
|
||||
let token = opts.token ?? null;
|
||||
|
||||
if (!token) {
|
||||
const extensionToken = discoverExtensionToken();
|
||||
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
||||
|
||||
if (extensionToken && envToken && extensionToken === envToken) {
|
||||
token = extensionToken;
|
||||
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
|
||||
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
|
||||
} else if (extensionToken) {
|
||||
token = extensionToken;
|
||||
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
if (envToken && envToken !== extensionToken) {
|
||||
console.log(` ${chalk.yellow('!')} Environment has different token ` +
|
||||
chalk.dim(`(${getTokenFingerprint(envToken)})`));
|
||||
}
|
||||
} else if (envToken) {
|
||||
token = envToken;
|
||||
console.log(` ${chalk.green('✓')} Token from environment variable ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
// Auto-verify browser connectivity
|
||||
console.log(chalk.dim(' Verifying browser connectivity...'));
|
||||
try {
|
||||
const result = await checkTokenConnectivity({ timeout: 5 });
|
||||
if (result.ok) {
|
||||
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
|
||||
} else {
|
||||
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
|
||||
console.log(chalk.dim(' To use opencli, make sure Chrome is running with Developer Mode'));
|
||||
console.log(chalk.dim(' and the OpenCLI MCP Bridge extension is enabled.'));
|
||||
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
||||
}
|
||||
} else {
|
||||
console.log(` ${chalk.green('✓')} Using provided token ` +
|
||||
chalk.dim(`(${getTokenFingerprint(token)})`));
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
|
||||
console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
|
||||
console.log();
|
||||
const rl = createInterface({ input, output });
|
||||
const answer = await rl.question(' Token: ');
|
||||
rl.close();
|
||||
token = answer.trim();
|
||||
if (!token) {
|
||||
console.log(chalk.red('\n No token provided. Aborting.\n'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
|
||||
console.log();
|
||||
|
||||
// Step 2: Scan all config locations
|
||||
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
|
||||
|
||||
// Step 3: Build checkbox items
|
||||
const items: CheckboxItem[] = [];
|
||||
|
||||
// Shell file
|
||||
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
||||
const shellStatus = report.shellFiles[0];
|
||||
const shellFp = shellStatus?.fingerprint;
|
||||
const shellOk = shellFp === fingerprint;
|
||||
const shellTool = toolName(shellPath) || 'Shell';
|
||||
items.push({
|
||||
label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
|
||||
value: `shell:${shellPath}`,
|
||||
checked: !shellOk,
|
||||
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
|
||||
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
|
||||
});
|
||||
|
||||
// Config files
|
||||
for (const config of report.configs) {
|
||||
const fp = config.fingerprint;
|
||||
const ok = fp === fingerprint;
|
||||
const tool = toolName(config.path);
|
||||
items.push({
|
||||
label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
|
||||
value: `config:${config.path}`,
|
||||
checked: false, // let user explicitly select which tools to configure
|
||||
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
|
||||
statusColor: ok ? 'green' : 'yellow',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Show interactive checkbox
|
||||
console.clear();
|
||||
const selected = await checkboxPrompt(items, {
|
||||
title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
|
||||
});
|
||||
|
||||
if (selected.length === 0) {
|
||||
console.log(chalk.dim(' No changes made.\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Apply changes
|
||||
const written: string[] = [];
|
||||
let wroteShell = false;
|
||||
|
||||
for (const sel of selected) {
|
||||
if (sel.startsWith('shell:')) {
|
||||
const p = sel.slice('shell:'.length);
|
||||
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
||||
writeFileWithMkdir(p, upsertShellToken(before, token));
|
||||
written.push(p);
|
||||
wroteShell = true;
|
||||
} else if (sel.startsWith('config:')) {
|
||||
const p = sel.slice('config:'.length);
|
||||
const config = report.configs.find(c => c.path === p);
|
||||
if (config && config.parseError) continue;
|
||||
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
|
||||
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
|
||||
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
||||
writeFileWithMkdir(p, next);
|
||||
written.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
|
||||
// Step 6: Summary
|
||||
if (written.length > 0) {
|
||||
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
|
||||
for (const p of written) {
|
||||
const tool = toolName(p);
|
||||
console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
|
||||
}
|
||||
if (wroteShell) {
|
||||
console.log();
|
||||
console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.yellow(' No files were changed.'));
|
||||
} catch {
|
||||
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
|
||||
console.log(chalk.dim(' Start Chrome to begin using opencli.'));
|
||||
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function padRight(s: string, n: number): string {
|
||||
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Tests for snapshotFormatter.ts: Playwright MCP snapshot tree filtering.
|
||||
*
|
||||
* Uses sanitized excerpts from real websites (GitHub, Bilibili, Twitter)
|
||||
* to validate noise filtering, annotation stripping, and output quality.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatSnapshot } from './snapshotFormatter.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: sanitized excerpts from real Playwright MCP snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GitHub dashboard navigation bar (generic-heavy, refs, /url: lines) */
|
||||
const GITHUB_NAV = `\
|
||||
- generic [ref=e2]:
|
||||
- region
|
||||
- generic [ref=e3]:
|
||||
- link "Skip to content" [ref=e4] [cursor=pointer]:
|
||||
- /url: "#start-of-content"
|
||||
- banner "Global Navigation Menu" [ref=e8]:
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- button "Open menu" [ref=e12] [cursor=pointer]:
|
||||
- img [ref=e13]
|
||||
- link "Homepage" [ref=e15] [cursor=pointer]:
|
||||
- /url: /
|
||||
- img [ref=e16]
|
||||
- generic [ref=e18]:
|
||||
- navigation "Breadcrumbs" [ref=e19]:
|
||||
- list [ref=e20]:
|
||||
- listitem [ref=e21]:
|
||||
- link "Dashboard" [ref=e22] [cursor=pointer]:
|
||||
- /url: https://github.com/
|
||||
- generic [ref=e23]: Dashboard
|
||||
- button "Search or jump to…" [ref=e26] [cursor=pointer]:
|
||||
- generic [ref=e27]:
|
||||
- generic:
|
||||
- img
|
||||
- generic [ref=e28]:
|
||||
- generic:
|
||||
- text: Type
|
||||
- generic: /
|
||||
- text: to search`;
|
||||
|
||||
/** GitHub repo list sidebar (repetitive structure) */
|
||||
const GITHUB_REPOS = `\
|
||||
- navigation "Repositories" [ref=e79]:
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- heading "Top repositories" [level=2] [ref=e82]
|
||||
- link "New" [ref=e83] [cursor=pointer]:
|
||||
- /url: /new
|
||||
- generic [ref=e84]:
|
||||
- generic:
|
||||
- img
|
||||
- generic [ref=e85]: New
|
||||
- search "Top repositories" [ref=e86]:
|
||||
- textbox "Find a repository…" [ref=e87]
|
||||
- list [ref=e88]:
|
||||
- listitem [ref=e89]:
|
||||
- generic [ref=e90]:
|
||||
- link "Repository" [ref=e91] [cursor=pointer]:
|
||||
- /url: /jackwener/twitter-cli
|
||||
- img "Repository" [ref=e92]
|
||||
- link "jackwener/twitter-cli" [ref=e94] [cursor=pointer]:
|
||||
- /url: /jackwener/twitter-cli
|
||||
- listitem [ref=e95]:
|
||||
- generic [ref=e96]:
|
||||
- link "Repository" [ref=e97] [cursor=pointer]:
|
||||
- /url: /jackwener/opencli
|
||||
- img "Repository" [ref=e98]
|
||||
- link "jackwener/opencli" [ref=e100] [cursor=pointer]:
|
||||
- /url: /jackwener/opencli`;
|
||||
|
||||
/** Bilibili nav bar (Chinese text, multiple link categories) */
|
||||
const BILIBILI_NAV = `\
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- list [ref=e6]:
|
||||
- listitem [ref=e7]:
|
||||
- link "首页" [ref=e8] [cursor=pointer]:
|
||||
- /url: //www.bilibili.com
|
||||
- img [ref=e9]
|
||||
- generic [ref=e11]: 首页
|
||||
- listitem [ref=e12]:
|
||||
- link "番剧" [ref=e13] [cursor=pointer]:
|
||||
- /url: //www.bilibili.com/anime/
|
||||
- listitem [ref=e14]:
|
||||
- link "直播" [ref=e15] [cursor=pointer]:
|
||||
- /url: //live.bilibili.com
|
||||
- generic [ref=e32]:
|
||||
- textbox "冷知识 金廷26年胜率100%" [ref=e34]
|
||||
- img [ref=e36] [cursor=pointer]`;
|
||||
|
||||
/** Bilibili video card (deeply nested generic wrappers, view counts) */
|
||||
const BILIBILI_VIDEO = `\
|
||||
- generic [ref=e363]:
|
||||
- link "超酷时刻 即将到来 3.3万 40 16:24" [ref=e364] [cursor=pointer]:
|
||||
- /url: https://www.bilibili.com/video/BV1zVw5zoEFt
|
||||
- generic [ref=e365]:
|
||||
- img "超酷时刻 即将到来" [ref=e368]
|
||||
- generic:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic:
|
||||
- img
|
||||
- generic: 3.3万
|
||||
- generic:
|
||||
- img
|
||||
- generic: "40"
|
||||
- generic: 16:24
|
||||
- generic [ref=e370]:
|
||||
- heading "超酷时刻 即将到来" [level=3] [ref=e371]:
|
||||
- link "超酷时刻 即将到来" [ref=e372] [cursor=pointer]:
|
||||
- /url: https://www.bilibili.com/video/BV1zVw5zoEFt
|
||||
- link "Tesla特斯拉中国 · 13小时前" [ref=e374] [cursor=pointer]:
|
||||
- /url: //space.bilibili.com/491190876
|
||||
- img [ref=e375]
|
||||
- generic "Tesla特斯拉中国" [ref=e379]
|
||||
- generic [ref=e380]: · 13小时前`;
|
||||
|
||||
/** Empty paragraph blocks (Bilibili bottom section) */
|
||||
const BILIBILI_EMPTY = `\
|
||||
- generic [ref=e576]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic:
|
||||
- paragraph
|
||||
- paragraph
|
||||
- paragraph
|
||||
- generic [ref=e577]:
|
||||
- generic:
|
||||
- generic:
|
||||
- generic:
|
||||
- paragraph
|
||||
- paragraph
|
||||
- paragraph`;
|
||||
|
||||
/** Twitter-style feed item (simulated based on common patterns) */
|
||||
const TWITTER_TWEET = `\
|
||||
- main [ref=e100]:
|
||||
- region "Timeline" [ref=e101]:
|
||||
- article [ref=e200]:
|
||||
- generic [ref=e201]:
|
||||
- generic [ref=e202]:
|
||||
- link "@elonmusk" [ref=e203] [cursor=pointer]:
|
||||
- /url: /elonmusk
|
||||
- img "@elonmusk" [ref=e204]
|
||||
- generic [ref=e205]:
|
||||
- generic [ref=e206]: Elon Musk
|
||||
- generic [ref=e207]: @elonmusk
|
||||
- generic [ref=e208]:
|
||||
- generic [ref=e209]: This is a very long tweet that goes on and on about various things including technology, space, and other random topics that make this text exceed any reasonable length limit we might want to set for display purposes in a CLI interface.
|
||||
- generic [ref=e210]:
|
||||
- button "Reply" [ref=e211] [cursor=pointer]:
|
||||
- img [ref=e212]
|
||||
- generic [ref=e213]: "42"
|
||||
- button "Retweet" [ref=e214] [cursor=pointer]:
|
||||
- img [ref=e215]
|
||||
- generic [ref=e216]: "1.2K"
|
||||
- button "Like" [ref=e217] [cursor=pointer]:
|
||||
- img [ref=e218]
|
||||
- generic [ref=e219]: "5.3K"
|
||||
- separator [ref=e300]`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatSnapshot', () => {
|
||||
describe('basic behavior', () => {
|
||||
it('returns empty string for empty/null input', () => {
|
||||
expect(formatSnapshot('')).toBe('');
|
||||
expect(formatSnapshot(null as any)).toBe('');
|
||||
expect(formatSnapshot(undefined as any)).toBe('');
|
||||
});
|
||||
|
||||
it('strips [ref=...] and [cursor=...] annotations', () => {
|
||||
const input = '- button "Click me" [ref=e42] [cursor=pointer]';
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('[cursor=');
|
||||
expect(result).toContain('button "Click me"');
|
||||
});
|
||||
|
||||
it('removes /url: metadata lines', () => {
|
||||
const input = `\
|
||||
- link "Home" [ref=e1] [cursor=pointer]:
|
||||
- /url: https://example.com
|
||||
- generic [ref=e2]: Home`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).not.toContain('/url:');
|
||||
expect(result).not.toContain('https://example.com');
|
||||
});
|
||||
|
||||
it('assigns sequential [@N] refs to interactive elements', () => {
|
||||
const input = `\
|
||||
- button "Save" [ref=e1]
|
||||
- link "Cancel" [ref=e2]
|
||||
- textbox "Name" [ref=e3]`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).toContain('[@1] button "Save"');
|
||||
expect(result).toContain('[@2] link "Cancel"');
|
||||
expect(result).toContain('[@3] textbox "Name"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noise filtering', () => {
|
||||
it('removes generic nodes without text', () => {
|
||||
const input = `\
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- button "Click" [ref=e3]`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).not.toMatch(/^generic/m);
|
||||
expect(result).toContain('button "Click"');
|
||||
});
|
||||
|
||||
it('keeps generic nodes WITH text content', () => {
|
||||
const input = '- generic [ref=e23]: Dashboard';
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).toContain('generic: Dashboard');
|
||||
});
|
||||
|
||||
it('removes img nodes without alt text', () => {
|
||||
const input = `\
|
||||
- img [ref=e13]
|
||||
- img "Profile photo" [ref=e14]`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).not.toContain('img\n');
|
||||
expect(result).toContain('img "Profile photo"');
|
||||
});
|
||||
|
||||
it('removes separator nodes', () => {
|
||||
const input = '- separator [ref=e304]';
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('removes presentation/none roles', () => {
|
||||
const input = `\
|
||||
- presentation [ref=e1]
|
||||
- none [ref=e2]
|
||||
- button "OK" [ref=e3]`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).not.toContain('presentation');
|
||||
expect(result).not.toContain('none');
|
||||
expect(result).toContain('button "OK"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty container pruning', () => {
|
||||
it('prunes containers with no visible children', () => {
|
||||
const input = `\
|
||||
- list [ref=e88]:
|
||||
- listitem [ref=e89]:
|
||||
- generic [ref=e90]:
|
||||
- img [ref=e91]`;
|
||||
// After filtering: generic (no text) → removed, img (no alt) → removed
|
||||
// listitem becomes empty → pruned, list becomes empty → pruned
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('keeps containers with visible children', () => {
|
||||
const input = `\
|
||||
- list [ref=e1]:
|
||||
- listitem [ref=e2]:
|
||||
- link "Home" [ref=e3]`;
|
||||
const result = formatSnapshot(input);
|
||||
expect(result).toContain('list');
|
||||
expect(result).toContain('listitem');
|
||||
expect(result).toContain('link "Home"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxDepth option', () => {
|
||||
it('limits output to specified depth', () => {
|
||||
const input = `\
|
||||
- main [ref=e1]:
|
||||
- heading "Dashboard" [ref=e2]
|
||||
- navigation [ref=e3]:
|
||||
- list [ref=e4]:
|
||||
- link "Deep link" [ref=e5]`;
|
||||
const result = formatSnapshot(input, { maxDepth: 2 });
|
||||
expect(result).toContain('main');
|
||||
expect(result).toContain('heading "Dashboard"');
|
||||
// navigation is pruned: its only child list is empty after link is excluded by maxDepth
|
||||
expect(result).not.toContain('navigation');
|
||||
expect(result).not.toContain('Deep link');
|
||||
});
|
||||
|
||||
it('handles maxDepth=0 correctly (was a bug)', () => {
|
||||
const input = `\
|
||||
- heading "Title" [ref=e1]
|
||||
- link "Sub" [ref=e2]`;
|
||||
const result = formatSnapshot(input, { maxDepth: 0 });
|
||||
expect(result).toContain('heading "Title"');
|
||||
expect(result).not.toContain('Sub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive mode', () => {
|
||||
it('keeps interactive elements and landmarks', () => {
|
||||
const result = formatSnapshot(GITHUB_NAV, { interactive: true });
|
||||
// Interactive elements should be present
|
||||
expect(result).toContain('button');
|
||||
expect(result).toContain('link');
|
||||
// Landmarks preserved
|
||||
expect(result).toContain('banner');
|
||||
expect(result).toContain('navigation');
|
||||
});
|
||||
|
||||
it('filters non-interactive, non-landmark, textless nodes', () => {
|
||||
const input = `\
|
||||
- main [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- button "Save" [ref=e4]
|
||||
- generic [ref=e5]: some text content`;
|
||||
const result = formatSnapshot(input, { interactive: true });
|
||||
expect(result).toContain('main');
|
||||
expect(result).toContain('button "Save"');
|
||||
// generic with text is kept
|
||||
expect(result).toContain('generic: some text content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode', () => {
|
||||
it('strips bracket annotations and collapses whitespace', () => {
|
||||
const input = '- button "Save" [ref=e1] [cursor=pointer] [level=2]';
|
||||
const result = formatSnapshot(input, { compact: true });
|
||||
// ref/cursor already stripped, but [level=...] should also go in compact
|
||||
expect(result).not.toContain('[level=');
|
||||
expect(result).toContain('button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxTextLength option', () => {
|
||||
it('truncates long content lines', () => {
|
||||
const input = '- heading "This is a very long heading that should be truncated at some point" [ref=e1]';
|
||||
const result = formatSnapshot(input, { maxTextLength: 30 });
|
||||
expect(result.length).toBeLessThanOrEqual(35); // some tolerance for ellipsis
|
||||
expect(result).toContain('…');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-world snapshot integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GitHub snapshot', () => {
|
||||
it('drastically reduces nav bar output', () => {
|
||||
const raw = GITHUB_NAV;
|
||||
const rawLineCount = raw.split('\n').length;
|
||||
const result = formatSnapshot(raw);
|
||||
const resultLineCount = result.split('\n').length;
|
||||
|
||||
// Should significantly reduce line count
|
||||
expect(resultLineCount).toBeLessThan(rawLineCount);
|
||||
|
||||
// Key content preserved
|
||||
expect(result).toContain('link "Skip to content"');
|
||||
expect(result).toContain('banner "Global Navigation Menu"');
|
||||
expect(result).toContain('link "Dashboard"');
|
||||
expect(result).toContain('button "Search or jump to…"');
|
||||
|
||||
// Noise removed
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('/url:');
|
||||
});
|
||||
|
||||
it('preserves repo list structure', () => {
|
||||
const result = formatSnapshot(GITHUB_REPOS);
|
||||
expect(result).toContain('navigation "Repositories"');
|
||||
expect(result).toContain('heading "Top repositories"');
|
||||
expect(result).toContain('textbox "Find a repository…"');
|
||||
expect(result).toContain('link "jackwener/twitter-cli"');
|
||||
expect(result).toContain('link "jackwener/opencli"');
|
||||
expect(result).toContain('img "Repository"');
|
||||
|
||||
// No refs or urls
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('/url:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bilibili snapshot', () => {
|
||||
it('cleans nav bar with Chinese text', () => {
|
||||
const result = formatSnapshot(BILIBILI_NAV);
|
||||
expect(result).toContain('link "首页"');
|
||||
expect(result).toContain('link "番剧"');
|
||||
expect(result).toContain('link "直播"');
|
||||
expect(result).toContain('textbox "冷知识 金廷26年胜率100%"');
|
||||
expect(result).not.toContain('[ref=');
|
||||
});
|
||||
|
||||
it('handles video card with deeply nested wrappers', () => {
|
||||
const result = formatSnapshot(BILIBILI_VIDEO);
|
||||
expect(result).toContain('link "超酷时刻 即将到来 3.3万 40 16:24"');
|
||||
expect(result).toContain('heading "超酷时刻 即将到来"');
|
||||
expect(result).toContain('generic "Tesla特斯拉中国"');
|
||||
|
||||
// Deeply nested view count generics with text are kept
|
||||
expect(result).toContain('3.3万');
|
||||
});
|
||||
|
||||
it('prunes empty paragraph blocks', () => {
|
||||
const result = formatSnapshot(BILIBILI_EMPTY);
|
||||
// All content is generic (no text) and empty paragraphs
|
||||
// After noise filtering, everything should be pruned
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Twitter snapshot', () => {
|
||||
it('preserves tweet structure', () => {
|
||||
const result = formatSnapshot(TWITTER_TWEET);
|
||||
expect(result).toContain('main');
|
||||
expect(result).toContain('region "Timeline"');
|
||||
expect(result).toContain('link "@elonmusk"');
|
||||
expect(result).toContain('button "Reply"');
|
||||
expect(result).toContain('button "Like"');
|
||||
expect(result).not.toContain('separator');
|
||||
});
|
||||
|
||||
it('truncates long tweet text with maxTextLength', () => {
|
||||
const result = formatSnapshot(TWITTER_TWEET, { maxTextLength: 60 });
|
||||
// The long tweet text should be truncated
|
||||
expect(result).toContain('…');
|
||||
// But short elements are unaffected
|
||||
expect(result).toContain('button "Reply"');
|
||||
});
|
||||
|
||||
it('interactive mode keeps only buttons and links', () => {
|
||||
const result = formatSnapshot(TWITTER_TWEET, { interactive: true });
|
||||
expect(result).toContain('link "@elonmusk"');
|
||||
expect(result).toContain('button "Reply"');
|
||||
expect(result).toContain('button "Retweet"');
|
||||
expect(result).toContain('button "Like"');
|
||||
// Structural landmarks kept
|
||||
expect(result).toContain('main');
|
||||
expect(result).toContain('region "Timeline"');
|
||||
expect(result).toContain('article');
|
||||
});
|
||||
|
||||
it('combined options: interactive + maxDepth', () => {
|
||||
// With maxDepth: 2 and interactive, depth > 2 is filtered.
|
||||
// article at depth 2 has only generic children (noise-filtered),
|
||||
// so article gets pruned by container pruning, which cascades up.
|
||||
const result = formatSnapshot(TWITTER_TWEET, { interactive: true, maxDepth: 2 });
|
||||
expect(result).toContain('main');
|
||||
expect(result).not.toContain('button "Reply"');
|
||||
expect(result).not.toContain('link "@elonmusk"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduction ratios on real data', () => {
|
||||
it('achieves significant reduction on GitHub nav', () => {
|
||||
const rawLines = GITHUB_NAV.split('\n').length;
|
||||
const formatted = formatSnapshot(GITHUB_NAV);
|
||||
const formattedLines = formatted.split('\n').filter(l => l.trim()).length;
|
||||
// Expect at least 40% reduction
|
||||
expect(formattedLines).toBeLessThan(rawLines * 0.6);
|
||||
});
|
||||
|
||||
it('achieves significant reduction on Bilibili video card', () => {
|
||||
const rawLines = BILIBILI_VIDEO.split('\n').length;
|
||||
const formatted = formatSnapshot(BILIBILI_VIDEO);
|
||||
const formattedLines = formatted.split('\n').filter(l => l.trim()).length;
|
||||
// Expect at least 30% reduction
|
||||
expect(formattedLines).toBeLessThan(rawLines * 0.7);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-page snapshot fixture tests (loaded from __fixtures__/)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('full-page snapshots from fixtures', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fixturesDir = path.join(__dirname, '__fixtures__');
|
||||
|
||||
function loadFixture(name: string): string | null {
|
||||
const p = path.join(fixturesDir, name);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return fs.readFileSync(p, 'utf-8');
|
||||
}
|
||||
|
||||
it('GitHub: significant reduction and clean output', () => {
|
||||
const raw = loadFixture('snapshot_github.txt');
|
||||
if (!raw) return;
|
||||
const rawLines = raw.split('\n').length;
|
||||
const result = formatSnapshot(raw);
|
||||
const resultLines = result.split('\n').filter((l: string) => l.trim()).length;
|
||||
|
||||
// Should achieve > 50% reduction on GitHub dashboard (heavy generic noise)
|
||||
expect(resultLines).toBeLessThan(rawLines * 0.5);
|
||||
|
||||
// No annotations remain
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('[cursor=');
|
||||
expect(result).not.toContain('/url:');
|
||||
|
||||
// Key content preserved
|
||||
expect(result).toContain('link "Skip to content"');
|
||||
expect(result).toContain('banner "Global Navigation Menu"');
|
||||
expect(result).toContain('heading "Dashboard"');
|
||||
});
|
||||
|
||||
it('Bilibili: significant reduction and Chinese text preserved', () => {
|
||||
const raw = loadFixture('snapshot_bilibili.txt');
|
||||
if (!raw) return;
|
||||
const rawLines = raw.split('\n').length;
|
||||
const result = formatSnapshot(raw);
|
||||
const resultLines = result.split('\n').filter((l: string) => l.trim()).length;
|
||||
|
||||
// Should achieve > 40% reduction on Bilibili (lots of imgs and generics)
|
||||
expect(resultLines).toBeLessThan(rawLines * 0.6);
|
||||
|
||||
// No annotations remain
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('[cursor=');
|
||||
|
||||
// Chinese text preserved
|
||||
expect(result).toContain('link "首页"');
|
||||
expect(result).toContain('link "番剧"');
|
||||
});
|
||||
|
||||
it('Twitter/X: significant reduction and tweet structure preserved', () => {
|
||||
const raw = loadFixture('snapshot_twitter.txt');
|
||||
if (!raw) return;
|
||||
const rawLines = raw.split('\n').length;
|
||||
const result = formatSnapshot(raw);
|
||||
const resultLines = result.split('\n').filter((l: string) => l.trim()).length;
|
||||
|
||||
// Should achieve > 40% reduction on Twitter/X
|
||||
expect(resultLines).toBeLessThan(rawLines * 0.6);
|
||||
|
||||
// No annotations remain
|
||||
expect(result).not.toContain('[ref=');
|
||||
expect(result).not.toContain('[cursor=');
|
||||
expect(result).not.toContain('/url:');
|
||||
|
||||
// Key structure preserved
|
||||
expect(result).toContain('main');
|
||||
});
|
||||
|
||||
it('GitHub interactive mode: drastic reduction', () => {
|
||||
const raw = loadFixture('snapshot_github.txt');
|
||||
if (!raw) return;
|
||||
const result = formatSnapshot(raw, { interactive: true });
|
||||
const resultLines = result.split('\n').filter((l: string) => l.trim()).length;
|
||||
|
||||
// Interactive mode should be much more aggressive
|
||||
expect(resultLines).toBeLessThan(200);
|
||||
|
||||
// Interactive elements still present
|
||||
expect(result).toContain('button');
|
||||
expect(result).toContain('link');
|
||||
expect(result).toContain('textbox');
|
||||
});
|
||||
|
||||
it('Bilibili maxDepth=3: shallow view', () => {
|
||||
const raw = loadFixture('snapshot_bilibili.txt');
|
||||
if (!raw) return;
|
||||
const result = formatSnapshot(raw, { maxDepth: 3 });
|
||||
const resultLines = result.split('\n').filter((l: string) => l.trim()).length;
|
||||
|
||||
// Depth-limited should be very compact
|
||||
expect(resultLines).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+401
-15
@@ -1,35 +1,268 @@
|
||||
/**
|
||||
* Aria snapshot formatter: parses Playwright MCP snapshot text into clean format.
|
||||
*
|
||||
* Multi-pass pipeline:
|
||||
* 1. Parse & filter: strip annotations, metadata, noise roles, ads, decorators
|
||||
* 2. Deduplicate: generic/text child matching parent label
|
||||
* 3. Deduplicate: heading + link with identical labels
|
||||
* 4. Deduplicate: nested identical links
|
||||
* 5. Prune: empty containers (iterative bottom-up)
|
||||
* 6. Collapse: single-child containers
|
||||
*/
|
||||
|
||||
export interface FormatOptions {
|
||||
interactive?: boolean;
|
||||
compact?: boolean;
|
||||
maxDepth?: number;
|
||||
maxTextLength?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_TEXT_LENGTH = 200;
|
||||
|
||||
// Roles that are pure noise and should always be filtered
|
||||
const NOISE_ROLES = new Set([
|
||||
'none', 'presentation', 'separator', 'paragraph', 'tooltip', 'status',
|
||||
]);
|
||||
|
||||
// Roles whose entire subtree should be removed (footer boilerplate, etc.)
|
||||
const SUBTREE_NOISE_ROLES = new Set([
|
||||
'contentinfo',
|
||||
]);
|
||||
|
||||
// Roles considered interactive (clickable/typeable)
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button', 'link', 'textbox', 'checkbox', 'radio',
|
||||
'combobox', 'tab', 'menuitem', 'option', 'switch',
|
||||
'slider', 'spinbutton', 'searchbox',
|
||||
]);
|
||||
|
||||
// Structural landmark roles kept even in interactive mode
|
||||
const LANDMARK_ROLES = new Set([
|
||||
'main', 'navigation', 'banner', 'heading', 'search',
|
||||
'region', 'list', 'listitem', 'article', 'complementary',
|
||||
'group', 'toolbar', 'tablist',
|
||||
]);
|
||||
|
||||
// Container roles eligible for pruning and collapse
|
||||
const CONTAINER_ROLES = new Set([
|
||||
'list', 'listitem', 'group', 'toolbar', 'tablist',
|
||||
'navigation', 'region', 'complementary',
|
||||
'search', 'article', 'paragraph', 'figure',
|
||||
]);
|
||||
|
||||
// Decorator / separator text that adds no semantic value
|
||||
const DECORATOR_TEXT = new Set(['•', '·', '|', '—', '-', '/', '\\']);
|
||||
|
||||
// Ad-related URL patterns
|
||||
const AD_URL_PATTERNS = [
|
||||
'googleadservices.com/pagead/',
|
||||
'alb.reddit.com/cr?',
|
||||
'doubleclick.net/',
|
||||
'cm.bilibili.com/cm/api/fees/',
|
||||
];
|
||||
|
||||
// Boilerplate button labels to filter (back-to-top, etc.)
|
||||
const BOILERPLATE_LABELS = [
|
||||
'回到顶部', 'back to top', 'scroll to top', 'go to top',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse role and text from a trimmed snapshot line.
|
||||
* Handles quoted labels and trailing text after colon correctly,
|
||||
* including lines wrapped in single quotes by Playwright.
|
||||
*/
|
||||
function parseLine(trimmed: string): { role: string; text: string; hasText: boolean; trailingText: string } {
|
||||
// Unwrap outer single quotes if present (Playwright wraps lines with special chars)
|
||||
let line = trimmed;
|
||||
if (line.startsWith("'") && line.endsWith("':")) {
|
||||
line = line.slice(1, -2) + ':';
|
||||
} else if (line.startsWith("'") && line.endsWith("'")) {
|
||||
line = line.slice(1, -1);
|
||||
}
|
||||
|
||||
// Role is the first word
|
||||
const roleMatch = line.match(/^([a-zA-Z]+)\b/);
|
||||
const role = roleMatch ? roleMatch[1].toLowerCase() : '';
|
||||
|
||||
// Extract quoted text content (the semantic label)
|
||||
const textMatch = line.match(/"([^"]*)"/);
|
||||
const text = textMatch ? textMatch[1] : '';
|
||||
|
||||
// For trailing text: strip annotations and quoted strings first, then check after last colon
|
||||
// This avoids matching colons inside quoted labels like "Account: user@email.com"
|
||||
let stripped = line;
|
||||
// Remove all quoted strings
|
||||
stripped = stripped.replace(/"[^"]*"/g, '""');
|
||||
// Remove all bracket annotations
|
||||
stripped = stripped.replace(/\[[^\]]*\]/g, '');
|
||||
|
||||
const colonIdx = stripped.lastIndexOf(':');
|
||||
let trailingText = '';
|
||||
if (colonIdx !== -1) {
|
||||
const afterColon = stripped.slice(colonIdx + 1).trim();
|
||||
if (afterColon.length > 0) {
|
||||
// Get the actual trailing text from original line at same position
|
||||
const origColonIdx = line.lastIndexOf(':');
|
||||
if (origColonIdx !== -1) {
|
||||
trailingText = line.slice(origColonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, text, hasText: text.length > 0 || trailingText.length > 0, trailingText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ALL bracket annotations from a content line, preserving quoted strings.
|
||||
* Handles both double-quoted and outer single-quoted lines from Playwright.
|
||||
*/
|
||||
function stripAnnotations(content: string): string {
|
||||
// Unwrap outer single quotes first
|
||||
let line = content;
|
||||
if (line.startsWith("'") && (line.endsWith("':") || line.endsWith("'"))) {
|
||||
if (line.endsWith("':")) {
|
||||
line = line.slice(1, -2) + ':';
|
||||
} else {
|
||||
line = line.slice(1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Split by double quotes to protect quoted content
|
||||
const parts = line.split('"');
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
// Only strip annotations from non-quoted parts (even indices)
|
||||
parts[i] = parts[i].replace(/\s*\[[^\]]*\]/g, '');
|
||||
}
|
||||
let result = parts.join('"').replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a metadata-only line (like /url: ...).
|
||||
*/
|
||||
function isMetadataLine(trimmed: string): boolean {
|
||||
return /^\/[a-zA-Z]+:/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text content is purely decorative (separators, dots, etc.)
|
||||
*/
|
||||
function isDecoratorText(text: string): boolean {
|
||||
return DECORATOR_TEXT.has(text.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is ad-related based on its text content.
|
||||
*/
|
||||
function isAdNode(text: string, trailingText: string): boolean {
|
||||
const t = (text + ' ' + trailingText).toLowerCase();
|
||||
if (t.includes('sponsored') || t.includes('advertisement')) return true;
|
||||
if (t.includes('广告')) return true;
|
||||
// Check for ad tracking URLs in the label
|
||||
for (const pattern of AD_URL_PATTERNS) {
|
||||
if (text.includes(pattern) || trailingText.includes(pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is boilerplate UI (back-to-top, etc.)
|
||||
*/
|
||||
function isBoilerplateNode(text: string): boolean {
|
||||
const t = text.toLowerCase();
|
||||
return BOILERPLATE_LABELS.some(label => t.includes(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role is noise that should be filtered.
|
||||
*/
|
||||
function isNoiseNode(role: string, hasText: boolean, text: string, trailingText: string): boolean {
|
||||
if (NOISE_ROLES.has(role)) return true;
|
||||
// generic without text is a wrapper
|
||||
if (role === 'generic' && !hasText) return true;
|
||||
// img without alt text is noise
|
||||
if (role === 'img' && !hasText) return true;
|
||||
// Decorator-only text nodes
|
||||
if ((role === 'generic' || role === 'text') && hasText) {
|
||||
const content = trailingText || text;
|
||||
if (isDecoratorText(content)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
depth: number;
|
||||
content: string;
|
||||
role: string;
|
||||
text: string;
|
||||
trailingText: string;
|
||||
isInteractive: boolean;
|
||||
isLandmark: boolean;
|
||||
isSubtreeSkip: boolean; // ad nodes or boilerplate — skip entire subtree
|
||||
}
|
||||
|
||||
export function formatSnapshot(raw: string, opts: FormatOptions = {}): string {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
const lines = raw.split('\n');
|
||||
const result: string[] = [];
|
||||
let refCounter = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const maxTextLen = opts.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH;
|
||||
const lines = raw.split('\n');
|
||||
|
||||
// === Pass 1: Parse, filter, and collect entries ===
|
||||
const entries: Entry[] = [];
|
||||
let refCounter = 0;
|
||||
let skipUntilDepth = -1; // When >= 0, skip all nodes at depth > this value
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const indent = line.length - line.trimStart().length;
|
||||
const depth = Math.floor(indent / 2);
|
||||
if (opts.maxDepth && depth > opts.maxDepth) continue;
|
||||
|
||||
// If we're in a subtree skip zone, check depth
|
||||
if (skipUntilDepth >= 0) {
|
||||
if (depth > skipUntilDepth) continue; // still inside subtree
|
||||
skipUntilDepth = -1; // exited subtree
|
||||
}
|
||||
|
||||
let content = line.trimStart();
|
||||
|
||||
// Skip non-interactive elements in interactive mode
|
||||
if (opts.interactive) {
|
||||
const interactiveRoles = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'tab', 'menuitem', 'option'];
|
||||
const role = content.split(/[\s[]/)[0]?.toLowerCase() ?? '';
|
||||
if (!interactiveRoles.some(r => role.includes(r)) && depth > 1) continue;
|
||||
// Strip leading "- "
|
||||
if (content.startsWith('- ')) {
|
||||
content = content.slice(2);
|
||||
}
|
||||
|
||||
// Compact: strip verbose role descriptions
|
||||
// Skip metadata lines
|
||||
if (isMetadataLine(content)) continue;
|
||||
|
||||
// Apply maxDepth filter
|
||||
if (opts.maxDepth !== undefined && depth > opts.maxDepth) continue;
|
||||
|
||||
const { role, text, hasText, trailingText } = parseLine(content);
|
||||
|
||||
// Skip noise nodes
|
||||
if (isNoiseNode(role, hasText, text, trailingText)) continue;
|
||||
|
||||
// Skip subtree noise roles (contentinfo footer, etc.) — skip entire subtree
|
||||
if (SUBTREE_NOISE_ROLES.has(role)) {
|
||||
skipUntilDepth = depth;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip annotations
|
||||
content = stripAnnotations(content);
|
||||
|
||||
// Check if node should trigger subtree skip (ads, boilerplate)
|
||||
const isSubtreeSkip = isAdNode(text, trailingText) || isBoilerplateNode(text);
|
||||
|
||||
// Interactive mode filter
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role);
|
||||
const isLandmark = LANDMARK_ROLES.has(role);
|
||||
|
||||
if (opts.interactive && !isInteractive && !isLandmark && !hasText) continue;
|
||||
|
||||
// Compact mode
|
||||
if (opts.compact) {
|
||||
content = content
|
||||
.replace(/\s*\[.*?\]\s*/g, ' ')
|
||||
@@ -37,15 +270,168 @@ export function formatSnapshot(raw: string, opts: FormatOptions = {}): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Text truncation
|
||||
if (maxTextLen > 0 && content.length > maxTextLen) {
|
||||
content = content.slice(0, maxTextLen) + '…';
|
||||
}
|
||||
|
||||
// Assign refs to interactive elements
|
||||
const interactivePattern = /^(button|link|textbox|checkbox|radio|combobox|tab|menuitem|option)\b/i;
|
||||
if (interactivePattern.test(content)) {
|
||||
if (isInteractive) {
|
||||
refCounter++;
|
||||
content = `[@${refCounter}] ${content}`;
|
||||
}
|
||||
|
||||
result.push(' '.repeat(depth) + content);
|
||||
entries.push({ depth, content, role, text, trailingText, isInteractive, isLandmark, isSubtreeSkip });
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
// === Pass 2: Remove subtree-skip nodes (ads, boilerplate, contentinfo) ===
|
||||
let noAds: Entry[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.isSubtreeSkip) {
|
||||
const skipDepth = entry.depth;
|
||||
i++;
|
||||
while (i < entries.length && entries[i].depth > skipDepth) {
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
noAds.push(entry);
|
||||
}
|
||||
|
||||
// === Pass 3: Deduplicate child generic/text matching parent label ===
|
||||
let deduped: Entry[] = [];
|
||||
for (let i = 0; i < noAds.length; i++) {
|
||||
const entry = noAds[i];
|
||||
|
||||
if (entry.role === 'generic' || entry.role === 'text') {
|
||||
let parent: Entry | undefined;
|
||||
for (let j = deduped.length - 1; j >= 0; j--) {
|
||||
if (deduped[j].depth < entry.depth) {
|
||||
parent = deduped[j];
|
||||
break;
|
||||
}
|
||||
if (deduped[j].depth === entry.depth) break;
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
const childText = entry.trailingText || entry.text;
|
||||
if (childText && parent.text && childText === parent.text) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deduped.push(entry);
|
||||
}
|
||||
|
||||
// === Pass 4: Deduplicate heading + child link with identical label ===
|
||||
// Pattern: heading "Title": → link "Title": (same text) → skip the link
|
||||
const deduped2: Entry[] = [];
|
||||
for (let i = 0; i < deduped.length; i++) {
|
||||
const entry = deduped[i];
|
||||
|
||||
if (entry.role === 'heading' && entry.text) {
|
||||
const next = deduped[i + 1];
|
||||
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
|
||||
// Keep the heading, skip the link. But preserve link's children re-parented.
|
||||
deduped2.push(entry);
|
||||
i++; // skip the link
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
deduped2.push(entry);
|
||||
}
|
||||
|
||||
// === Pass 5: Deduplicate nested identical links ===
|
||||
const deduped3: Entry[] = [];
|
||||
for (let i = 0; i < deduped2.length; i++) {
|
||||
const entry = deduped2[i];
|
||||
|
||||
if (entry.role === 'link' && entry.text) {
|
||||
const next = deduped2[i + 1];
|
||||
if (next && next.role === 'link' && next.text === entry.text && next.depth === entry.depth + 1) {
|
||||
continue; // Skip parent, keep child
|
||||
}
|
||||
}
|
||||
|
||||
deduped3.push(entry);
|
||||
}
|
||||
|
||||
// === Pass 6: Iteratively prune empty containers (bottom-up) ===
|
||||
let current = deduped3;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const next: Entry[] = [];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const entry = current[i];
|
||||
if (CONTAINER_ROLES.has(entry.role) && !entry.text && !entry.trailingText) {
|
||||
let hasChildren = false;
|
||||
for (let j = i + 1; j < current.length; j++) {
|
||||
if (current[j].depth <= entry.depth) break;
|
||||
if (current[j].depth > entry.depth) {
|
||||
hasChildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasChildren) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
next.push(entry);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
// === Pass 7: Collapse single-child containers ===
|
||||
const collapsed: Entry[] = [];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const entry = current[i];
|
||||
|
||||
if (CONTAINER_ROLES.has(entry.role) && !entry.text && !entry.trailingText) {
|
||||
let childCount = 0;
|
||||
let childIdx = -1;
|
||||
for (let j = i + 1; j < current.length; j++) {
|
||||
if (current[j].depth <= entry.depth) break;
|
||||
if (current[j].depth === entry.depth + 1) {
|
||||
childCount++;
|
||||
if (childCount === 1) childIdx = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (childCount === 1 && childIdx !== -1) {
|
||||
const child = current[childIdx];
|
||||
let hasGrandchildren = false;
|
||||
for (let j = childIdx + 1; j < current.length; j++) {
|
||||
if (current[j].depth <= child.depth) break;
|
||||
if (current[j].depth > child.depth) {
|
||||
hasGrandchildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasGrandchildren) {
|
||||
const mergedContent = entry.content.replace(/:$/, '') + ' > ' + child.content;
|
||||
collapsed.push({
|
||||
...entry,
|
||||
content: mergedContent,
|
||||
role: child.role,
|
||||
text: child.text,
|
||||
trailingText: child.trailingText,
|
||||
isInteractive: child.isInteractive,
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapsed.push(entry);
|
||||
}
|
||||
|
||||
return collapsed.map(e => ' '.repeat(e.depth) + e.content).join('\n');
|
||||
}
|
||||
|
||||
+19
-4
@@ -11,8 +11,22 @@ const KNOWN_STEP_NAMES = new Set([
|
||||
'intercept', 'tap',
|
||||
]);
|
||||
|
||||
export function validateClisWithTarget(dirs: string[], target?: string): any {
|
||||
const results: any[] = [];
|
||||
export interface FileValidationResult {
|
||||
path: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
ok: boolean;
|
||||
results: FileValidationResult[];
|
||||
errors: number;
|
||||
warnings: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
|
||||
const results: FileValidationResult[] = [];
|
||||
let errors = 0; let warnings = 0; let files = 0;
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
@@ -35,7 +49,7 @@ export function validateClisWithTarget(dirs: string[], target?: string): any {
|
||||
return { ok: errors === 0, results, errors, warnings, files };
|
||||
}
|
||||
|
||||
function validateYamlFile(filePath: string): any {
|
||||
function validateYamlFile(filePath: string): FileValidationResult {
|
||||
const errors: string[] = []; const warnings: string[] = [];
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -64,7 +78,7 @@ function validateYamlFile(filePath: string): any {
|
||||
return { path: filePath, errors, warnings };
|
||||
}
|
||||
|
||||
export function renderValidationReport(report: any): string {
|
||||
export function renderValidationReport(report: ValidationReport): string {
|
||||
const lines = [`opencli validate: ${report.ok ? 'PASS' : 'FAIL'}`, `Checked ${report.results.length} CLI(s) in ${report.files} file(s)`, `Errors: ${report.errors} Warnings: ${report.warnings}`];
|
||||
for (const r of report.results) {
|
||||
if (r.errors.length > 0 || r.warnings.length > 0) {
|
||||
@@ -75,3 +89,4 @@ export function renderValidationReport(report: any): string {
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -6,13 +6,27 @@
|
||||
* to the `opencli test` command or CI pipelines.
|
||||
*/
|
||||
|
||||
import { validateClisWithTarget, renderValidationReport } from './validate.js';
|
||||
import { validateClisWithTarget, renderValidationReport, type ValidationReport } from './validate.js';
|
||||
|
||||
export async function verifyClis(opts: any): Promise<any> {
|
||||
export interface VerifyOptions {
|
||||
builtinClis: string;
|
||||
userClis: string;
|
||||
target?: string;
|
||||
smoke?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyReport {
|
||||
ok: boolean;
|
||||
validation: ValidationReport;
|
||||
smoke: null;
|
||||
}
|
||||
|
||||
export async function verifyClis(opts: VerifyOptions): Promise<VerifyReport> {
|
||||
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
||||
return { ok: report.ok, validation: report, smoke: null };
|
||||
}
|
||||
|
||||
export function renderVerifyReport(report: any): string {
|
||||
export function renderVerifyReport(report: VerifyReport): string {
|
||||
return renderValidationReport(report.validation);
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -5,8 +5,7 @@
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": false,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
+17
-1
@@ -2,6 +2,22 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['src/**/*.test.ts'],
|
||||
sequence: { groupOrder: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'e2e',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
maxWorkers: 2,
|
||||
sequence: { groupOrder: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user