Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "browser-pilot",
|
||||
"description": "Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility",
|
||||
"version": "1.10.0",
|
||||
"author": {
|
||||
"name": "Dev GOM",
|
||||
"url": "https://github.com/Dev-GOM/claude-code-marketplace"
|
||||
},
|
||||
"skills": [
|
||||
"./skills"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# browser-pilot
|
||||
|
||||
Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility
|
||||
309
plugin.lock.json
Normal file
309
plugin.lock.json
Normal file
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:Dev-GOM/claude-code-marketplace:plugins/browser-pilot",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "4efa229eee19c084e4c09e6633ef82485bfc263e",
|
||||
"treeHash": "9ce781902752f5a5275646ca73c877a51eb6d2567786b72371e0ee666ddad875",
|
||||
"generatedAt": "2025-11-28T10:10:17.589693Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "browser-pilot",
|
||||
"description": "Chrome DevTools Protocol (CDP) browser automation, web scraping, and crawling with React compatibility",
|
||||
"version": "1.10.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "a6b6d4720c9297fbdec226d7bf1785a8112c744b7ae0b88e62284b3a8d8b6016"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "4b52d22927abfab2bff34f8b2adf142058b2605251f571cbd2e419f88f24fa58"
|
||||
},
|
||||
{
|
||||
"path": "skills/SKILL.md",
|
||||
"sha256": "113530dfc519f84a65faaa311d0cef4634536df5f562bc928bef0274d51b32ba"
|
||||
},
|
||||
{
|
||||
"path": "skills/references/commands-reference.md",
|
||||
"sha256": "deb78776496e97fac9ab389a2424b8de683907a1d941fa0949a8ce01dacddee3"
|
||||
},
|
||||
{
|
||||
"path": "skills/references/selector-guide.md",
|
||||
"sha256": "65a872fa0f67b3191cba7934670334904e58817adfed19cd222d767399ae4feb"
|
||||
},
|
||||
{
|
||||
"path": "skills/references/interaction-map.md",
|
||||
"sha256": "b1de092948922f1619edd0eb1bef616c796cefb35bfca858615d645a1a60a755"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/package.json",
|
||||
"sha256": "a3eea0902133ffbb7c09d8e5ddfa04daeba2a3a3f0c41177968e3a75bd48d1ec"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/tsconfig.json",
|
||||
"sha256": "dbac69ac1ad7ecb10ff1dc9cae7059ffc1c7f24d5d7342409813799efe4f49a9"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/eslint.config.mjs",
|
||||
"sha256": "1147567cc49454b578398e51774ef908ac51bc069611bd9aaf66ed6b9293818f"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/utils.ts",
|
||||
"sha256": "dd109a1810185f336db050937b8cfbb403d0b3908778ae02c12b3a8fa6a7af57"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/browser.ts",
|
||||
"sha256": "19ec63fc421c737d8f4d972387c7ebaaf41bea87cb76e2fe5c9aa642d6d95059"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions.ts",
|
||||
"sha256": "dd37c8e62c782f8ae19bf2004078dd3f29c4f79080d90ac927b62575dad2a98e"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/client.ts",
|
||||
"sha256": "75985cc843baae6c624120bb9585ffd874e36cee9ad9e541c09e2d831c10268f"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/config.ts",
|
||||
"sha256": "00b62151b233701b6f077620125d9c2050056e11339bfefc9cb3b566f6f0b16a"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/map/generate-interaction-map.ts",
|
||||
"sha256": "7eb0140eaebf53eadfc5b8a5422201dfafb3b1599204ce5ade23603f7460bcbe"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/map/query-map.ts",
|
||||
"sha256": "46c3617d898cf73749528caf426ad6e38cd21f9de19708dd62a796d225d843be"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/data.ts",
|
||||
"sha256": "bce34a1ac5e7fc49c2b0d15706e5ffcd0db276bcde6288ee48012c110b2b1195"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/helpers.ts",
|
||||
"sha256": "483503eae2e810f88b000431457799f498e69ff364727a87a69998cccd8f5707"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/dialogs.ts",
|
||||
"sha256": "d34eec0a3c9d5ea6254efeeff13e5753a01623a22729e97bbb5985c32169892d"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/verify.ts",
|
||||
"sha256": "764ea4e0029787ff194a153ec43f4319ccfccb97263583f7aaf427cb070f8bb3"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/navigation.ts",
|
||||
"sha256": "633961ac587a9d8a1ad9df7fb49d857e647efc4a8431c126ac89d033aac3afe1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/network.ts",
|
||||
"sha256": "ea5ace071fb8569c22795487563a579b027b05c287e4498bca5a33c797e385f1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/input.ts",
|
||||
"sha256": "acc78b56750ca5edcdc6ba15f62dc7a29ab52ad1f10f225bc7685ecbc2b6146c"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/capture.ts",
|
||||
"sha256": "65e0a1cb263719bee7b289f50f58e0a7c04c3705e5ae9e38987c6ed580ccb5b1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/forms.ts",
|
||||
"sha256": "67a6a5df967f560a2136b257ba7119c7fb48559f7957ad8436c4973b1c4dc1dd"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/debugging.ts",
|
||||
"sha256": "20080fb55d16f24821aa5a2d620ff91b622932d7d52f19762a4740465a1dd4b4"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/emulation.ts",
|
||||
"sha256": "506b58b0ac69771788693c73631faad4f69cdc031ec56dad6f0deadad46c33c4"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/wait.ts",
|
||||
"sha256": "9aa1e410a023e9b0e3122cc9ee14ae8fefa807c5c24e333b355c4a8c47f3dd26"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/scroll.ts",
|
||||
"sha256": "636b3becbca5bfa66c63b0be5ee0dd1f5fc50a2bb45b1cdccc64c97b016cfeb6"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/tabs.ts",
|
||||
"sha256": "bd77b56f88c79c9792b46febb1c9effcecc49995f4ed9d449b08214aff2e3b95"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/interaction.ts",
|
||||
"sha256": "9e79e9bb2526fffa2f30b149055dd3a2cb7d665983cf37492bdcea58b3a21bd9"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cdp/actions/cookies.ts",
|
||||
"sha256": "e98bf8723a083b2f29a87c4615f81bbf8fd130c64abaa3156abb0cd90e9279a0"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/constants/index.ts",
|
||||
"sha256": "9cb84b6e98052b3d23c2beb5ecef14bddbdf509ba7bd4c8dfcd681dab8a1590e"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/utils/timestamp.ts",
|
||||
"sha256": "f995aef19dd989911e8d27c0120f17d6d556e6425d7e6db3b715e837eb82f11c"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/utils/logger.ts",
|
||||
"sha256": "985b830942754994ce13104095047c43d58a68a686b2cd6f868f10549f8958d1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/daemon-helper.ts",
|
||||
"sha256": "ffe42c17fa1d198ee6b1add5d2ce352ca155836b7f0f5a1878257513031822b1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/cli.ts",
|
||||
"sha256": "2bab2d5370eedbcf29b9bbabee9e56599d140411f450062a93d25dceee7bf287"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/focus.ts",
|
||||
"sha256": "bc977c0e3617c22daeab2283b90ce7f0e79514890f4587f08d021dd28fb1e09c"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/daemon.ts",
|
||||
"sha256": "bc9848f7cb0cf0123ada9edad54ab3a65989b7d5db68a30964abacf8f07e9174"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/data.ts",
|
||||
"sha256": "42bc1b2f00800f9a24110ddcce1067cebe588335e3b474d6d16e68c8a80692e8"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/selector-helper.ts",
|
||||
"sha256": "10ad7e8c5b746802014ab4608f70653890eba05ab5d9c138702b9b5454315981"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/dialogs.ts",
|
||||
"sha256": "cf0a667b9696a479f0fc5a6b77ca1cb18440e04f5d6549e56ffd42d4ed7e2b88"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/accessibility.ts",
|
||||
"sha256": "771180962ab7dc9a751441b927cbbb5991178a29e1c0e0ed59449b1c4ee5fa70"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/navigation.ts",
|
||||
"sha256": "01b5e2ac44e7b9244094f52426337349819e03c485ff8ca58a5d6fde58bbc9a2"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/network.ts",
|
||||
"sha256": "5e578c8b29239f9506daa4bc323b02dd5d66f99f5a4826de5cdf87d3e5a96528"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/capture.ts",
|
||||
"sha256": "b1e734d4bfd3731e4472a12c2d700826d34c9967a69fed9d6575222c2ff615e0"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/forms.ts",
|
||||
"sha256": "f8b958e96a225d96d7d9e3d9a44e575357b95bd047e9524eaeb3de794982d5a9"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/emulation.ts",
|
||||
"sha256": "dd130d9ab619de79c457cbec4aed3b006d1b33c021be0fa877decaab20aeb0ae"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/wait.ts",
|
||||
"sha256": "5a077ac6ff83e25f07fc571858a439c99f4430f53d2be222abb96526f11b4fed"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/scroll.ts",
|
||||
"sha256": "6e9e8db9ab8be035ee2e2449759868feceb04ba6a355ab25b7a488b7b99cbe3f"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/chain.ts",
|
||||
"sha256": "089722859c841463a376f35c2cc621b5117ff754f87c388c964b8cbd61e991e7"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/tabs.ts",
|
||||
"sha256": "9f6d8c55e13ce759f90bf701b9c2462d81656cc230db80a07b22053d5adb6dda"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/interaction.ts",
|
||||
"sha256": "82424470f754304ae321f3cd0777b4fb4434cd563b2634a3dc697dbf6c5164db"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/query.ts",
|
||||
"sha256": "0908d9ef05b229c0348b71ae1511e6322781f42ede28a19c0f78491d23790aec"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/system.ts",
|
||||
"sha256": "16543760c9a4b1f097a4b19b31bdd5a137fbff0ef3bb398f0510d6ff25fdaff9"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/console.ts",
|
||||
"sha256": "f81b7d246a96c41a15d85bc5af9e449156cebcf780d3591e11e91ce2ed789f4e"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/cli/commands/cookies.ts",
|
||||
"sha256": "49c946bbc040a7fd0b387a1eb4b92e152d82218e26fcdb0274d6558272079607"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/manager.ts",
|
||||
"sha256": "d73ea3290bfc8fa752dbcf2c3a0b283ec0e9d1689849bdde270b046fa5db89f8"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/client.ts",
|
||||
"sha256": "a377f3d24386cc08a29648aa2ced40ec27509eafa5037447f2674e31fc50df3e"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/protocol.ts",
|
||||
"sha256": "c3293fa4073152f76169b30440b7bf13a7e04337a0ca53fb067cf2c8fa3a2542"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/map-manager.ts",
|
||||
"sha256": "e32adcf83b9e22c486bd45ab07255eaf77e62911d0b54b9003dffd5fc5a0602d"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/server.ts",
|
||||
"sha256": "8d374b0b021ae5db412e5445e539f79aeadbe2f37df9c453a4de4abf7b9e6bd8"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/utility-handlers.ts",
|
||||
"sha256": "2195ec7c2a54c8ba1d605bfce52361f1f3d754303dbbd0335790a22b3caba0fb"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/capture-handlers.ts",
|
||||
"sha256": "60e736208c164cd8341b6c51ee21112e94774f9570ae17cbf65dfca32675154c"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/index.ts",
|
||||
"sha256": "264bee94f51371a75391bcb43be0b8c0a446ca0095a27f1cb58df30d8b58ba04"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/interaction-handlers.ts",
|
||||
"sha256": "dfc9492608b14c36711b5d747821ecfdbd5ba2707350f736823f876e28aff264"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/map-handlers.ts",
|
||||
"sha256": "520d96f88ec1a8534434b114d8e0b1caa54857bd7736d7654a52e0cc4e8cf4a1"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/data-handlers.ts",
|
||||
"sha256": "fa80d68e312df5eefb46b9f774405abefde0629758fa1ddced3333d01f8652fd"
|
||||
},
|
||||
{
|
||||
"path": "skills/scripts/src/daemon/handlers/navigation-handlers.ts",
|
||||
"sha256": "ad18d102c89bfdd2e5ea0d2e8fe936084bb381975a4c9d57c7d8741a6aab518c"
|
||||
}
|
||||
],
|
||||
"dirSha256": "9ce781902752f5a5275646ca73c877a51eb6d2567786b72371e0ee666ddad875"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
240
skills/SKILL.md
Normal file
240
skills/SKILL.md
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: browser-pilot
|
||||
description: |
|
||||
Chrome DevTools Protocol (CDP) browser automation, web scraping, crawling. 브라우저 자동화, 웹 스크래핑, 크롤링.
|
||||
|
||||
Features/기능: screenshot with region control 영역지정스크린샷, viewport control 뷰포트제어, PDF generation PDF생성, web scraping 웹스크래핑, data extraction 데이터추출, form filling 폼작성, login automation 로그인자동화, click/input 클릭/입력, element finder 요소찾기, tab management 탭관리, cookie control 쿠키제어, JavaScript execution JS실행, page navigation 페이지이동, wait for element 요소대기, scroll 스크롤, accessibility tree 접근성트리, console messages 콘솔메시지, network idle 네트워크대기, back/forward 뒤로/앞으로, reload 새로고침, file upload 파일업로드, React compatibility React호환성, Smart Mode with Interaction Map 스마트모드.
|
||||
|
||||
Selectors 셀렉터: CSS selectors (ID, class, attribute), XPath selectors with wildcard * (text-based, structural), XPath indexing (select N-th element with same text). Smart Mode: text-based element search with automatic selector generation.
|
||||
|
||||
Bot detection bypass 봇감지우회 (navigator.webdriver=false). Auto Chrome connection 자동크롬연결. Headless/headed mode. Daemon-based architecture 데몬기반. Interaction Map System 인터랙션맵. React/framework compatibility React/프레임워크호환성.
|
||||
---
|
||||
|
||||
# browser-pilot
|
||||
|
||||
## Purpose
|
||||
|
||||
Automate Chrome browser using Chrome DevTools Protocol (CDP) with a daemon-based architecture. Maintains persistent browser connection for instant command execution. Features Smart Mode with Interaction Map for reliable element targeting using text-based search instead of brittle selectors.
|
||||
|
||||
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use browser-pilot when tasks involve:
|
||||
- Browser automation, web scraping, data extraction
|
||||
- Screenshot capture, PDF generation
|
||||
- Form filling, login automation, element interaction
|
||||
- Tab management, cookie control, JavaScript execution
|
||||
- Tasks requiring text-based element selection ("click the 3rd Delete button")
|
||||
- Bot detection bypass requirements (navigator.webdriver = false)
|
||||
|
||||
## ⚠️ Important Guidelines
|
||||
|
||||
**When to Ask User:** Use AskUserQuestion tool if:
|
||||
- Task requirements unclear or ambiguous
|
||||
- Multiple implementation approaches possible
|
||||
- Element selectors not working despite troubleshooting
|
||||
- User intent uncertain (e.g., "automate this" without specifics)
|
||||
|
||||
**DO NOT** guess or assume user requirements. Always clarify first.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Chrome must be installed. Local scripts initialize automatically on session start (no manual setup required).
|
||||
|
||||
## Getting Help
|
||||
|
||||
All commands support `--help` for detailed options:
|
||||
|
||||
```bash
|
||||
# See all available commands
|
||||
node .browser-pilot/bp --help
|
||||
|
||||
# Get help for specific command
|
||||
node .browser-pilot/bp <command> --help
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Daemon-based design:**
|
||||
- Background daemon maintains persistent CDP connection
|
||||
- CLI commands communicate via IPC
|
||||
- Auto-starts on first command, stops at session end
|
||||
- 30-minute inactivity timeout
|
||||
|
||||
**Interaction Map System:**
|
||||
- Auto-generates JSON map of interactive elements on page load
|
||||
- Enables text-based search with automatic selector generation
|
||||
- Handles duplicates with indexing
|
||||
- 10-minute cache with auto-regeneration
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1. Extract Required Information
|
||||
|
||||
From user's request, identify:
|
||||
- Target URL(s) to visit
|
||||
- Actions to perform (screenshot, click, fill, etc.)
|
||||
- Element identifiers (text content, CSS selectors, or XPath)
|
||||
- Output file names (for screenshots/PDFs)
|
||||
- Data to extract or forms to fill
|
||||
|
||||
When information is missing or ambiguous, use AskUserQuestion tool.
|
||||
|
||||
### 2. Execute Commands
|
||||
|
||||
All commands use `.browser-pilot/bp` wrapper script. Replace placeholders with actual values.
|
||||
|
||||
**Navigation:**
|
||||
```bash
|
||||
node .browser-pilot/bp navigate -u <url>
|
||||
node .browser-pilot/bp back
|
||||
node .browser-pilot/bp forward
|
||||
node .browser-pilot/bp reload
|
||||
```
|
||||
|
||||
**Interaction (Smart Mode - Recommended):**
|
||||
```bash
|
||||
# Text-based element search (map auto-generated)
|
||||
# No quotes for single words
|
||||
node .browser-pilot/bp click --text Login --type button
|
||||
node .browser-pilot/bp fill --text Email -v <value>
|
||||
|
||||
# Use quotes when text contains spaces
|
||||
node .browser-pilot/bp click --text "Sign In" --type button
|
||||
node .browser-pilot/bp fill --text "Email Address" -v <value>
|
||||
|
||||
# Handle duplicates with indexing
|
||||
node .browser-pilot/bp click --text Delete --index 2
|
||||
|
||||
# Filter visible elements only
|
||||
node .browser-pilot/bp click --text Submit --viewport-only
|
||||
|
||||
# Type aliases (auto-expanded)
|
||||
node .browser-pilot/bp click --text Search --type input # Matches: input, input-text, input-search, etc.
|
||||
|
||||
# Tag-based filtering (HTML tag)
|
||||
node .browser-pilot/bp click --text Submit --tag button # Matches all <button> tags
|
||||
node .browser-pilot/bp fill --text Email --tag input -v user@example.com
|
||||
|
||||
# 3-stage fallback (automatic)
|
||||
# Stage 1: Type search (with alias expansion)
|
||||
# Stage 2: Tag search (if type fails)
|
||||
# Stage 3: Map regeneration + retry (up to 3 attempts)
|
||||
```
|
||||
|
||||
**Interaction (Direct Mode - fallback for unique IDs):**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "#login-button"
|
||||
node .browser-pilot/bp fill -s "input[name='email']" -v <value>
|
||||
```
|
||||
|
||||
**Capture:**
|
||||
```bash
|
||||
# Screenshots saved to .browser-pilot/screenshots/
|
||||
node .browser-pilot/bp screenshot -o <filename>.png
|
||||
|
||||
# Capture specific region
|
||||
node .browser-pilot/bp screenshot -o region.png --clip-x 100 --clip-y 200 --clip-width 800 --clip-height 600
|
||||
|
||||
# Set viewport size for responsive testing
|
||||
node .browser-pilot/bp set-viewport -w 375 -h 667 --scale 2 --mobile
|
||||
|
||||
# Get current viewport size
|
||||
node .browser-pilot/bp get-viewport
|
||||
|
||||
# Get screen and viewport information
|
||||
node .browser-pilot/bp get-screen-info
|
||||
|
||||
# PDFs saved to .browser-pilot/pdfs/
|
||||
node .browser-pilot/bp pdf -o <filename>.pdf
|
||||
```
|
||||
|
||||
**Chain Mode (multiple commands):**
|
||||
```bash
|
||||
# Basic chain (no quotes needed for single words)
|
||||
node .browser-pilot/bp chain navigate -u <url> click --text Submit extract -s .result
|
||||
|
||||
# With spaces (quotes required)
|
||||
node .browser-pilot/bp chain navigate -u <url> click --text "Sign In" fill --text Email -v <email>
|
||||
|
||||
# Login workflow
|
||||
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login
|
||||
|
||||
# Screenshot workflow
|
||||
node .browser-pilot/bp chain navigate -u <url> wait -s .content-loaded screenshot -o result.png
|
||||
```
|
||||
|
||||
**Chain-specific options:**
|
||||
- `--timeout <ms>`: Map wait timeout after navigation (default: 10000ms)
|
||||
- `--delay <ms>`: Fixed delay between commands (overrides random 300-800ms)
|
||||
|
||||
**Data Extraction:**
|
||||
```bash
|
||||
node .browser-pilot/bp extract -s <selector>
|
||||
node .browser-pilot/bp content
|
||||
node .browser-pilot/bp console
|
||||
node .browser-pilot/bp cookies
|
||||
```
|
||||
|
||||
**Other Actions:**
|
||||
```bash
|
||||
node .browser-pilot/bp wait -s <selector> -t <timeout-ms>
|
||||
node .browser-pilot/bp scroll -s <selector>
|
||||
node .browser-pilot/bp eval -e <javascript-expression>
|
||||
```
|
||||
|
||||
### 3. Query Interaction Map (when needed)
|
||||
|
||||
```bash
|
||||
# List all element types
|
||||
node .browser-pilot/bp query --list-types
|
||||
|
||||
# Find elements by text
|
||||
node .browser-pilot/bp query --text <text>
|
||||
|
||||
# Check map status
|
||||
node .browser-pilot/bp map-status
|
||||
|
||||
# Force regenerate map
|
||||
node .browser-pilot/bp regen-map
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **🌟 Use Smart Mode by default**: Text-based search (`--text`) is more stable than CSS selectors
|
||||
- Recommended: `click --text Login`
|
||||
- Fallback: `click -s #login-btn` (only for unique IDs)
|
||||
|
||||
2. **Maps auto-generate**: No manual map generation needed, happens on page load
|
||||
|
||||
3. **Handle duplicates with indexing**: `--index 2` selects 2nd match when multiple elements have same text
|
||||
|
||||
4. **Filter with type aliases**: `--type input` auto-expands to match `input`, `input-text`, `input-search`, etc.
|
||||
- Generic: `--type input` (matches all input types)
|
||||
- Specific: `--type input-search` (exact match only)
|
||||
|
||||
5. **Use tag-based search for flexibility**: `--tag button` matches all `<button>` elements regardless of type
|
||||
|
||||
6. **3-stage fallback is automatic**: If element not found, system automatically:
|
||||
- Tries type-based search (with alias expansion)
|
||||
- Falls back to tag-based search
|
||||
- Regenerates map and retries (up to 3 attempts)
|
||||
|
||||
7. **Verify element visibility**: `--viewport-only` ensures element is on screen
|
||||
|
||||
8. **Use Chain Mode for workflows**: Execute multiple commands in sequence for complex automation
|
||||
|
||||
9. **Check console for errors**: `node .browser-pilot/bp console` after actions fail
|
||||
|
||||
10. **Let daemon auto-manage**: Starts on first command, stops at session end
|
||||
|
||||
## References
|
||||
|
||||
Detailed documentation in `references/` folder (load as needed):
|
||||
|
||||
- **`references/commands-reference.md`**: Complete command list with all options and examples
|
||||
- **`references/interaction-map.md`**: Smart Mode system, map structure, and query API
|
||||
- **`references/selector-guide.md`**: Selector strategies, best practices, and troubleshooting
|
||||
|
||||
Load references when user needs detailed information about specific features, advanced usage patterns, or troubleshooting guidance.
|
||||
574
skills/references/commands-reference.md
Normal file
574
skills/references/commands-reference.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Browser Pilot Commands Reference
|
||||
|
||||
Complete reference for all Browser Pilot CLI commands using the project-local wrapper script.
|
||||
|
||||
## Command Entry Points
|
||||
|
||||
Browser Pilot uses the CLI wrapper script `.browser-pilot/bp`:
|
||||
|
||||
**Single command execution:**
|
||||
```bash
|
||||
node .browser-pilot/bp <command> [options]
|
||||
```
|
||||
|
||||
**Chain mode (multiple commands):**
|
||||
```bash
|
||||
node .browser-pilot/bp chain <command1> [args1] <command2> [args2] ...
|
||||
```
|
||||
|
||||
**Daemon management:**
|
||||
```bash
|
||||
node .browser-pilot/bp daemon-<action>
|
||||
```
|
||||
|
||||
## Single Command Execution
|
||||
|
||||
### Navigation Commands
|
||||
|
||||
**navigate** - Navigate to URL (auto-generates interaction map on page load)
|
||||
```bash
|
||||
node .browser-pilot/bp navigate -u "<url>"
|
||||
```
|
||||
|
||||
**back** - Navigate back in history
|
||||
```bash
|
||||
node .browser-pilot/bp back
|
||||
```
|
||||
|
||||
**forward** - Navigate forward in history
|
||||
```bash
|
||||
node .browser-pilot/bp forward
|
||||
```
|
||||
|
||||
**reload** - Reload current page (regenerates interaction map)
|
||||
```bash
|
||||
node .browser-pilot/bp reload
|
||||
```
|
||||
|
||||
### Interaction Commands
|
||||
|
||||
**click** - Click an element
|
||||
|
||||
Smart Mode (Recommended):
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "<text>" [options]
|
||||
|
||||
Options:
|
||||
--text <text> Text content to search for
|
||||
--index <number> Select nth match (1-based)
|
||||
--type <type> Element type filter (supports aliases: "input" → "input-*")
|
||||
--tag <tag> HTML tag filter (e.g., "button", "input")
|
||||
--viewport-only Only search visible elements
|
||||
--verify Verify action success
|
||||
|
||||
Examples:
|
||||
node .browser-pilot/bp click --text Submit
|
||||
node .browser-pilot/bp click --text "Sign In" --type button
|
||||
node .browser-pilot/bp click --text Delete --index 2
|
||||
node .browser-pilot/bp click --text Search --type input # Auto-expands to all input types
|
||||
node .browser-pilot/bp click --text Submit --tag button # Tag-based search
|
||||
```
|
||||
|
||||
Direct Mode (fallback for unique IDs):
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "<selector>"
|
||||
node .browser-pilot/bp click -u "<url>" -s "<selector>"
|
||||
```
|
||||
|
||||
**fill** - Fill input field
|
||||
|
||||
Smart Mode (Recommended):
|
||||
```bash
|
||||
node .browser-pilot/bp fill --text "<label>" -v "<value>" [options]
|
||||
|
||||
Options:
|
||||
--text <label> Label or placeholder text to search
|
||||
--type <type> Input type filter (supports aliases: "input" → "input-*")
|
||||
--tag <tag> HTML tag filter (e.g., "input", "textarea")
|
||||
--viewport-only Only search visible elements
|
||||
--verify Verify action success
|
||||
|
||||
Examples:
|
||||
node .browser-pilot/bp fill --text Email -v user@example.com
|
||||
node .browser-pilot/bp fill --text Password -v secret --type input-password
|
||||
node .browser-pilot/bp fill --text Email --tag input -v user@example.com # Tag-based search
|
||||
```
|
||||
|
||||
Direct Mode (fallback for unique IDs):
|
||||
```bash
|
||||
node .browser-pilot/bp fill -s "<selector>" -v "<value>"
|
||||
```
|
||||
|
||||
**hover** - Hover over element
|
||||
```bash
|
||||
node .browser-pilot/bp hover -s "<selector>"
|
||||
```
|
||||
|
||||
**press** - Press keyboard key
|
||||
```bash
|
||||
node .browser-pilot/bp press -k "<key>"
|
||||
|
||||
Keys: Enter, Tab, Escape, ArrowUp, ArrowDown, etc.
|
||||
```
|
||||
|
||||
**type** - Type text character by character
|
||||
```bash
|
||||
node .browser-pilot/bp type -t "<text>" -d <delay-ms>
|
||||
```
|
||||
|
||||
**upload** - Upload file to input element
|
||||
```bash
|
||||
node .browser-pilot/bp upload -s "<selector>" -f "<file-path>"
|
||||
```
|
||||
|
||||
### Data Extraction Commands
|
||||
|
||||
**extract** - Extract text content from element
|
||||
```bash
|
||||
node .browser-pilot/bp extract -s "<selector>"
|
||||
```
|
||||
|
||||
**content** - Get full page HTML
|
||||
```bash
|
||||
node .browser-pilot/bp content
|
||||
```
|
||||
|
||||
**console** - Get console messages with powerful filtering and formatting
|
||||
```bash
|
||||
node .browser-pilot/bp console [options]
|
||||
|
||||
Options:
|
||||
-u, --url <url> Navigate to URL before getting console messages
|
||||
|
||||
Level Filtering:
|
||||
-e, --errors-only Show only error messages
|
||||
-l, --level <level> Filter by level: error, warning, log, info, verbose
|
||||
--warnings Show only warning messages
|
||||
--logs Show only log messages
|
||||
|
||||
Message Limiting:
|
||||
--limit <number> Maximum number of messages to display
|
||||
--skip <number> Skip first N messages
|
||||
|
||||
Text Filtering:
|
||||
-f, --filter <pattern> Show only messages matching regex pattern
|
||||
-x, --exclude <pattern> Exclude messages matching regex pattern
|
||||
|
||||
Output Format:
|
||||
-j, --json Output in JSON format
|
||||
-t, --timestamp Show timestamps
|
||||
--no-color Disable colored output
|
||||
|
||||
File Output:
|
||||
-o, --output <file> Save output to file
|
||||
|
||||
Source Filtering:
|
||||
--url-filter <pattern> Filter by source URL (regex)
|
||||
|
||||
Examples:
|
||||
# Get all console messages
|
||||
node .browser-pilot/bp console
|
||||
|
||||
# Get only errors with timestamps
|
||||
node .browser-pilot/bp console -e -t
|
||||
|
||||
# Filter messages containing "API"
|
||||
node .browser-pilot/bp console -f "API"
|
||||
|
||||
# Get warnings and exclude messages containing "deprecated"
|
||||
node .browser-pilot/bp console --warnings -x "deprecated"
|
||||
|
||||
# Get first 10 log messages in JSON format
|
||||
node .browser-pilot/bp console --logs --limit 10 -j
|
||||
|
||||
# Save all console messages to file
|
||||
node .browser-pilot/bp console -o console-output.txt
|
||||
|
||||
# Get errors from specific source file
|
||||
node .browser-pilot/bp console -e --url-filter "app.js"
|
||||
|
||||
# Navigate and get console errors
|
||||
node .browser-pilot/bp console -u "http://localhost:3000" -e
|
||||
```
|
||||
|
||||
**cookies** - Get page cookies
|
||||
```bash
|
||||
node .browser-pilot/bp cookies
|
||||
```
|
||||
|
||||
### Capture Commands
|
||||
|
||||
**screenshot** - Capture screenshot (saved to `.browser-pilot/screenshots/`)
|
||||
```bash
|
||||
node .browser-pilot/bp screenshot -o "<filename>.png" [options]
|
||||
|
||||
Options:
|
||||
-u, --url <url> URL to capture (optional)
|
||||
-o, --output <path> Output filename (saved to .browser-pilot/screenshots/)
|
||||
--full-page Capture full page (default: true)
|
||||
--clip-x <x> Clip region X coordinate (pixels)
|
||||
--clip-y <y> Clip region Y coordinate (pixels)
|
||||
--clip-width <width> Clip region width (pixels)
|
||||
--clip-height <height> Clip region height (pixels)
|
||||
--clip-scale <scale> Clip region scale factor (default: 1)
|
||||
--headless Run in headless mode
|
||||
|
||||
Note: Clip region options take priority over --full-page. When clip options are specified,
|
||||
only the specified region will be captured regardless of --full-page setting.
|
||||
|
||||
Examples:
|
||||
# Full page screenshot
|
||||
node .browser-pilot/bp screenshot -o result.png --full-page
|
||||
# Saves to: .browser-pilot/screenshots/result.png
|
||||
|
||||
# Capture specific region (clip takes priority over full-page)
|
||||
node .browser-pilot/bp screenshot -o region.png --clip-x 100 --clip-y 200 --clip-width 800 --clip-height 600
|
||||
# Saves to: .browser-pilot/screenshots/region.png
|
||||
```
|
||||
|
||||
**pdf** - Generate PDF (saved to `.browser-pilot/pdfs/`)
|
||||
```bash
|
||||
node .browser-pilot/bp pdf -o "<filename>.pdf" [options]
|
||||
|
||||
Options:
|
||||
-u, --url <url> URL to capture (optional)
|
||||
-o, --output <path> Output filename (saved to .browser-pilot/pdfs/)
|
||||
--landscape Landscape orientation
|
||||
--headless Run in headless mode
|
||||
|
||||
Example:
|
||||
node .browser-pilot/bp pdf -o document.pdf --landscape
|
||||
# Saves to: .browser-pilot/pdfs/document.pdf
|
||||
```
|
||||
|
||||
**set-viewport** - Set browser viewport size (useful for responsive design testing)
|
||||
```bash
|
||||
node .browser-pilot/bp set-viewport -w <width> -h <height> [options]
|
||||
|
||||
Options:
|
||||
-w, --width <width> Viewport width in pixels (required)
|
||||
-h, --height <height> Viewport height in pixels (required)
|
||||
--scale <scale> Device scale factor (default: 1)
|
||||
--mobile Emulate mobile device (default: false)
|
||||
|
||||
Examples:
|
||||
# Desktop viewport
|
||||
node .browser-pilot/bp set-viewport -w 1920 -h 1080
|
||||
|
||||
# Mobile viewport (iPhone 12)
|
||||
node .browser-pilot/bp set-viewport -w 390 -h 844 --scale 3 --mobile
|
||||
|
||||
# Tablet viewport (iPad)
|
||||
node .browser-pilot/bp set-viewport -w 768 -h 1024 --scale 2
|
||||
```
|
||||
|
||||
**get-viewport** - Get current viewport size
|
||||
```bash
|
||||
node .browser-pilot/bp get-viewport
|
||||
|
||||
Output:
|
||||
=== Viewport Information ===
|
||||
Size: 1920x1080
|
||||
Scale: 1
|
||||
```
|
||||
|
||||
**get-screen-info** - Get screen and viewport information
|
||||
```bash
|
||||
node .browser-pilot/bp get-screen-info
|
||||
|
||||
Output:
|
||||
=== Screen Information ===
|
||||
Screen: 2560x1440 # Physical screen resolution
|
||||
Available: 2560x1392 # Available screen (excluding taskbar)
|
||||
Viewport: 1920x1080 # Current browser viewport
|
||||
Scale: 1 # Device pixel ratio
|
||||
```
|
||||
|
||||
### Tab Management Commands
|
||||
|
||||
**tabs** - List all open tabs
|
||||
```bash
|
||||
node .browser-pilot/bp tabs
|
||||
```
|
||||
|
||||
**new-tab** - Open new tab
|
||||
```bash
|
||||
node .browser-pilot/bp new-tab -u "<url>"
|
||||
```
|
||||
|
||||
**close-tab** - Close tab by index
|
||||
```bash
|
||||
node .browser-pilot/bp close-tab -i <index>
|
||||
```
|
||||
|
||||
**close** - Close browser
|
||||
```bash
|
||||
node .browser-pilot/bp close
|
||||
```
|
||||
|
||||
### Utility Commands
|
||||
|
||||
**eval** - Execute JavaScript in browser context
|
||||
```bash
|
||||
node .browser-pilot/bp eval -e "<javascript-expression>"
|
||||
|
||||
Example:
|
||||
node .browser-pilot/bp eval -e "document.title"
|
||||
```
|
||||
|
||||
**wait** - Wait for element to appear
|
||||
```bash
|
||||
node .browser-pilot/bp wait -s "<selector>" -t <timeout-ms>
|
||||
```
|
||||
|
||||
**scroll** - Scroll page or element
|
||||
```bash
|
||||
# Scroll to position
|
||||
node .browser-pilot/bp scroll -x <x-pos> -y <y-pos>
|
||||
|
||||
# Scroll element into view
|
||||
node .browser-pilot/bp scroll -s "<selector>"
|
||||
```
|
||||
|
||||
**select** - Select dropdown option
|
||||
```bash
|
||||
node .browser-pilot/bp select -s "<selector>" -v "<option-value>"
|
||||
```
|
||||
|
||||
**find** - Find elements matching selector
|
||||
```bash
|
||||
node .browser-pilot/bp find -s "<selector>"
|
||||
```
|
||||
|
||||
**query** - Query interaction map for elements
|
||||
```bash
|
||||
# List all element types with counts
|
||||
node .browser-pilot/bp query --list-types
|
||||
|
||||
# List all text contents (paginated, default 20)
|
||||
node .browser-pilot/bp query --list-texts
|
||||
|
||||
# List text contents with type filter
|
||||
node .browser-pilot/bp query --list-texts --type button
|
||||
|
||||
# Find elements by text
|
||||
node .browser-pilot/bp query --text "<text-content>"
|
||||
|
||||
# Find all elements of a type (paginated)
|
||||
node .browser-pilot/bp query --type <element-type>
|
||||
|
||||
# Type aliases (auto-expanded)
|
||||
node .browser-pilot/bp query --type input # Matches: input, input-text, input-search, etc.
|
||||
node .browser-pilot/bp query --type input-search # Exact match only
|
||||
|
||||
# Find by HTML tag
|
||||
node .browser-pilot/bp query --tag button # All <button> elements
|
||||
node .browser-pilot/bp query --text Submit --tag button
|
||||
|
||||
# Show detailed information
|
||||
node .browser-pilot/bp query --type button --verbose
|
||||
|
||||
# Pagination options
|
||||
node .browser-pilot/bp query --type button --limit 50 --offset 20
|
||||
|
||||
# Unlimited results
|
||||
node .browser-pilot/bp query --type button --limit 0
|
||||
|
||||
# Other options:
|
||||
# --index <n> Select nth match (1-based)
|
||||
# --viewport-only Only visible elements
|
||||
# --id <id> Direct element ID lookup
|
||||
```
|
||||
|
||||
**map-status** - Check interaction map status
|
||||
```bash
|
||||
node .browser-pilot/bp map-status
|
||||
```
|
||||
|
||||
**regen-map** - Force regenerate interaction map
|
||||
```bash
|
||||
node .browser-pilot/bp regen-map
|
||||
```
|
||||
|
||||
## Chain Mode
|
||||
|
||||
Execute multiple commands sequentially in a single call with automatic map synchronization.
|
||||
|
||||
**Syntax:**
|
||||
```bash
|
||||
node .browser-pilot/bp chain <command1> [args1] <command2> [args2] ...
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Auto-waits for page load and map generation after navigation
|
||||
- Supports Smart Mode (--text) for reliable element targeting
|
||||
- Adds random human-like delay (300-800ms) between commands
|
||||
- Stops execution if any command fails
|
||||
- Each command executes after the previous one completes
|
||||
|
||||
**Chain-specific Options:**
|
||||
```bash
|
||||
--timeout <ms> Timeout for waiting map ready after navigation (default: 10000ms)
|
||||
--delay <ms> Fixed delay between commands (overrides random 300-800ms delay)
|
||||
```
|
||||
|
||||
**Quote Rules:**
|
||||
- No quotes needed when values have no spaces
|
||||
- Use quotes when values contain spaces
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Basic chain (no quotes needed)
|
||||
node .browser-pilot/bp chain navigate -u <url> click --text Submit extract -s .result
|
||||
|
||||
# With spaces (quotes required)
|
||||
node .browser-pilot/bp chain navigate -u <url> click --text "Sign In" fill -s #email -v "user@example.com"
|
||||
|
||||
# Login workflow with Smart Mode
|
||||
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login
|
||||
|
||||
# Multi-step workflow with Korean text
|
||||
node .browser-pilot/bp chain navigate -u <url> click --text "메인 메뉴" click --text "설정 변경" click --text "저장"
|
||||
|
||||
# Screenshot workflow
|
||||
node .browser-pilot/bp chain navigate -u <url> wait -s .content-loaded screenshot -o result.png
|
||||
|
||||
# Custom timing
|
||||
node .browser-pilot/bp chain --timeout 15000 --delay 1000 navigate -u <url> click --text Submit
|
||||
```
|
||||
|
||||
## Daemon Commands
|
||||
|
||||
Daemon starts automatically on first command and stops automatically at session end.
|
||||
|
||||
**daemon-start** - Start daemon (auto-starts on first command)
|
||||
```bash
|
||||
node .browser-pilot/bp daemon-start
|
||||
```
|
||||
|
||||
**daemon-stop** - Stop daemon and close browser
|
||||
```bash
|
||||
node .browser-pilot/bp daemon-stop
|
||||
```
|
||||
|
||||
**daemon-restart** - Restart daemon
|
||||
```bash
|
||||
node .browser-pilot/bp daemon-restart
|
||||
```
|
||||
|
||||
**daemon-status** - Check daemon status
|
||||
```bash
|
||||
node .browser-pilot/bp daemon-status
|
||||
```
|
||||
|
||||
## System Maintenance Commands
|
||||
|
||||
**reinstall** - Reinstall Browser Pilot scripts
|
||||
|
||||
Removes the `.browser-pilot` directory to force complete reinstallation on next command. Useful when:
|
||||
- Installation or build is corrupted
|
||||
- Scripts are not updating properly
|
||||
- Troubleshooting persistent issues
|
||||
|
||||
```bash
|
||||
# Show confirmation prompt
|
||||
node .browser-pilot/bp reinstall
|
||||
|
||||
# Skip confirmation and reinstall immediately
|
||||
node .browser-pilot/bp reinstall --yes
|
||||
|
||||
# Quiet mode (no output)
|
||||
node .browser-pilot/bp reinstall --yes --quiet
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Stops the daemon if running
|
||||
2. Removes `.browser-pilot` directory completely
|
||||
3. Next command will trigger automatic reinstallation via SessionStart hook
|
||||
|
||||
**Options:**
|
||||
- `-y, --yes`: Skip confirmation prompt
|
||||
- `-q, --quiet`: Suppress output messages
|
||||
|
||||
**Example workflow:**
|
||||
```bash
|
||||
# Reinstall scripts
|
||||
node .browser-pilot/bp reinstall --yes
|
||||
|
||||
# Next command triggers automatic reinstallation
|
||||
node .browser-pilot/bp navigate -u "https://example.com"
|
||||
```
|
||||
|
||||
## Common Options
|
||||
|
||||
Most commands support:
|
||||
- `-u, --url <url>`: Navigate to URL before action
|
||||
- `--headless`: Run in headless mode (no visible browser)
|
||||
- `--timeout <ms>`: Custom timeout for operations
|
||||
|
||||
## Smart Mode vs Direct Mode
|
||||
|
||||
**🌟 Recommendation: Use Smart Mode by default for better reliability**
|
||||
|
||||
| Feature | Smart Mode (Recommended) | Direct Mode |
|
||||
|---------|--------------------------|-------------|
|
||||
| Selector | Text content | CSS or XPath |
|
||||
| Reliability | ⭐⭐⭐⭐⭐ High (stable) | ⭐⭐ Low (brittle) |
|
||||
| Duplicates | Auto indexing | Manual indexing |
|
||||
| Map Required | Yes (auto-generated) | No |
|
||||
| Speed | Medium | Fast |
|
||||
| Best For | Most cases, text-based UI | Unique IDs only |
|
||||
| Maintenance | Low (text rarely changes) | High (selectors break often) |
|
||||
|
||||
**When to use Direct Mode:**
|
||||
- Element has a unique, stable ID (e.g., `#user-profile-button`)
|
||||
- Performance-critical operations requiring maximum speed
|
||||
- Element has no visible text content
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: Success
|
||||
- `1`: General error
|
||||
- Non-zero: Command failed
|
||||
|
||||
## Examples
|
||||
|
||||
**Take screenshot:**
|
||||
```bash
|
||||
node .browser-pilot/bp screenshot -u "https://example.com" -o "example.png" --full-page
|
||||
```
|
||||
|
||||
**Login flow (Direct Mode):**
|
||||
```bash
|
||||
node .browser-pilot/bp navigate -u "https://example.com/login"
|
||||
node .browser-pilot/bp fill -s "#email" -v "user@example.com"
|
||||
node .browser-pilot/bp fill -s "#password" -v "secret"
|
||||
node .browser-pilot/bp click -s "#login-btn"
|
||||
```
|
||||
|
||||
**Login flow (Chain Mode):**
|
||||
```bash
|
||||
node .browser-pilot/bp chain navigate -u "https://example.com/login" fill -s "#email" -v "user@example.com" fill -s "#password" -v "secret" click -s "#login-btn"
|
||||
```
|
||||
|
||||
**Smart mode workflow (map auto-generated on navigate):**
|
||||
```bash
|
||||
node .browser-pilot/bp navigate -u "https://example.com"
|
||||
node .browser-pilot/bp click --text "Login" --type button
|
||||
node .browser-pilot/bp fill --text "Email" -v "user@example.com"
|
||||
node .browser-pilot/bp fill --text "Password" -v "secret"
|
||||
node .browser-pilot/bp click --text "Submit" --verify
|
||||
```
|
||||
|
||||
**Smart mode workflow (Chain Mode):**
|
||||
```bash
|
||||
node .browser-pilot/bp chain navigate -u "https://example.com" click --text "Login" --type button fill --text "Email" -v "user@example.com" fill --text "Password" -v "secret" click --text "Submit" --verify
|
||||
```
|
||||
|
||||
**Extract data:**
|
||||
```bash
|
||||
node .browser-pilot/bp navigate -u "https://example.com/products"
|
||||
node .browser-pilot/bp extract -s ".product-title"
|
||||
node .browser-pilot/bp extract -s ".product-price"
|
||||
```
|
||||
434
skills/references/interaction-map.md
Normal file
434
skills/references/interaction-map.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Interaction Map System
|
||||
|
||||
## Overview
|
||||
|
||||
The Interaction Map system provides reliable element targeting for browser automation by generating a structured JSON representation of all interactive elements on a webpage. This eliminates brittle CSS selectors and enables text-based element search with automatic selector generation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **Map Generator** (`src/cdp/map/generate-interaction-map.ts`)
|
||||
- Browser-side script that extracts all interactive elements
|
||||
- Generates multiple selector types for each element
|
||||
- Handles SVG elements, disabled states, React components
|
||||
|
||||
2. **Map Manager** (`src/daemon/map-manager.ts`)
|
||||
- Daemon-level automatic map generation on page load
|
||||
- 10-minute cache with auto-regeneration
|
||||
- URL-based cache validation
|
||||
- Event-driven DOM stabilization detection
|
||||
|
||||
3. **Map Query Module** (`src/cdp/map/query-map.ts`)
|
||||
- Loads and queries interaction maps
|
||||
- Searches by text, type, ID, visibility
|
||||
- Returns best selector with alternatives
|
||||
|
||||
4. **CLI Integration** (`src/cli/commands/interaction.ts`)
|
||||
- Smart Mode options: `--text`, `--index`, `--type`, `--viewport-only`
|
||||
- Automatic map querying before action execution
|
||||
- Fallback to alternative selectors on failure
|
||||
|
||||
### Automatic Map Generation
|
||||
|
||||
Maps are automatically generated when:
|
||||
- Navigating to a new page (`node .browser-pilot/bp navigate -u "<url>"`)
|
||||
- Page reload (`node .browser-pilot/bp reload`)
|
||||
- Cache expires (10 minutes)
|
||||
- Manual force generation (daemon command)
|
||||
|
||||
No manual map generation needed - the daemon handles it automatically.
|
||||
|
||||
Output location: `.browser-pilot/interaction-map.json`
|
||||
|
||||
## JSON Structure
|
||||
|
||||
Maps use a hybrid structure optimized for both direct access and search:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"timestamp": "2025-11-05T14:39:03.598+09:00",
|
||||
"viewport": {
|
||||
"width": 2560,
|
||||
"height": 1305
|
||||
},
|
||||
"elements": {
|
||||
"elem_0": {
|
||||
"id": "elem_0",
|
||||
"type": "button",
|
||||
"tag": "button",
|
||||
"text": "Submit",
|
||||
"value": null,
|
||||
"selectors": {
|
||||
"byText": "//button[contains(text(), 'Submit')]",
|
||||
"byId": "#submit-btn",
|
||||
"byCSS": "button.btn.btn-primary",
|
||||
"byRole": "[role='button']",
|
||||
"byAriaLabel": "[aria-label='Submit form']"
|
||||
},
|
||||
"attributes": {
|
||||
"id": "submit-btn",
|
||||
"class": "btn btn-primary",
|
||||
"disabled": false
|
||||
},
|
||||
"position": {
|
||||
"x": 1275,
|
||||
"y": 650
|
||||
},
|
||||
"visibility": {
|
||||
"inViewport": true,
|
||||
"visible": true,
|
||||
"obscured": false
|
||||
},
|
||||
"context": {
|
||||
"section": "Form"
|
||||
}
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"byText": {
|
||||
"Submit": ["elem_0", "elem_15"],
|
||||
"Delete": ["elem_5", "elem_6", "elem_7"]
|
||||
},
|
||||
"byType": {
|
||||
"button": ["elem_0", "elem_1", "elem_2"],
|
||||
"input-text": ["elem_10", "elem_11"]
|
||||
},
|
||||
"inViewport": ["elem_0", "elem_1", "elem_2", "elem_10"]
|
||||
},
|
||||
"statistics": {
|
||||
"total": 45,
|
||||
"byType": {
|
||||
"button": 12,
|
||||
"input-text": 5,
|
||||
"a": 8
|
||||
},
|
||||
"duplicates": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
**1. Key-Value Structure** (`elements`)
|
||||
- Direct ID access: `map.elements["elem_0"]`
|
||||
- Avoids array iteration for known IDs
|
||||
|
||||
**2. Indexes** (fast lookup)
|
||||
- `byText`: Maps text content → element IDs
|
||||
- `byType`: Maps element types → element IDs
|
||||
- `inViewport`: Array of visible element IDs
|
||||
|
||||
**3. Multiple Selectors**
|
||||
- `byText`: XPath with tag name (e.g., `//button[contains(text(), 'Submit')]`)
|
||||
- `byId`: CSS ID selector (highest priority)
|
||||
- `byCSS`: CSS class selector
|
||||
- `byRole`: ARIA role selector
|
||||
- `byAriaLabel`: ARIA label selector
|
||||
|
||||
**4. Automatic Indexing**
|
||||
- Duplicate text elements get indexed: `(//button[contains(text(), 'Delete')])[2]`
|
||||
- Enables "click the 3rd Delete button" functionality
|
||||
|
||||
**5. Auto-Caching**
|
||||
- 10-minute cache TTL
|
||||
- Automatically regenerates on expiration or navigation
|
||||
- URL-based validation to prevent stale maps
|
||||
|
||||
## Element Detection
|
||||
|
||||
### Interactive Element Types
|
||||
|
||||
The map generator detects:
|
||||
- Standard inputs: `<input>`, `<button>`, `<select>`, `<textarea>`
|
||||
- Links: `<a href="...">`
|
||||
- ARIA roles: `button`, `link`, `textbox`, `checkbox`, `radio`, etc.
|
||||
- Click handlers: Elements with `onclick`, React event handlers
|
||||
- Cursor style: `cursor: pointer`
|
||||
- Tab-navigable: `tabindex >= 0`
|
||||
|
||||
### Special Cases
|
||||
|
||||
**SVG Elements:**
|
||||
```typescript
|
||||
// Handles SVGAnimatedString className
|
||||
const className = typeof el.className === 'string'
|
||||
? el.className
|
||||
: (el.className.baseVal || '');
|
||||
```
|
||||
|
||||
**Disabled Buttons:**
|
||||
```typescript
|
||||
// Standard interactive elements included even if disabled
|
||||
const isStandardInteractive = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'A'].includes(tag);
|
||||
if (!isStandardInteractive && style.pointerEvents === 'none') {
|
||||
return false; // Skip
|
||||
}
|
||||
```
|
||||
|
||||
**React Components:**
|
||||
```typescript
|
||||
// Detect React event handlers
|
||||
const reactProps = Object.keys(el).filter(key => key.startsWith('__react'));
|
||||
const hasReactHandlers = reactProps.some(prop => {
|
||||
const value = el[prop];
|
||||
return value && typeof value === 'object' && value.onClick;
|
||||
});
|
||||
```
|
||||
|
||||
## Selector Generation
|
||||
|
||||
### Priority Order
|
||||
|
||||
Query system selects best selector with this priority:
|
||||
|
||||
1. **byId** (highest priority)
|
||||
- Most stable, unique identifier
|
||||
- Example: `#login-button`
|
||||
|
||||
2. **byText** (indexed for duplicates)
|
||||
- Tag-specific XPath: `//button[contains(text(), 'Submit')]`
|
||||
- With indexing: `(//button[contains(text(), 'Delete')])[2]`
|
||||
|
||||
3. **byCSS**
|
||||
- Safe classes only (alphanumeric, hyphens, underscores)
|
||||
- Example: `button.btn.btn-primary`
|
||||
- Skips generic tag-only selectors
|
||||
|
||||
4. **byRole**
|
||||
- ARIA role attribute
|
||||
- Example: `[role="button"]`
|
||||
|
||||
5. **byAriaLabel** (lowest priority)
|
||||
- ARIA label attribute
|
||||
- Example: `[aria-label="Submit form"]`
|
||||
|
||||
### Text-Based XPath
|
||||
|
||||
XPath selectors include tag names for precision:
|
||||
|
||||
**Before:** `//*[contains(text(), 'Submit')]`
|
||||
- Problem: Matches any element with that text (div, span, button, etc.)
|
||||
|
||||
**After:** `//button[contains(text(), 'Submit')]`
|
||||
- Solution: Only matches `<button>` elements
|
||||
- More precise, faster execution
|
||||
|
||||
## Query API
|
||||
|
||||
### Query Options
|
||||
|
||||
```typescript
|
||||
interface QueryOptions {
|
||||
text?: string; // Search by text content
|
||||
type?: string; // Filter by element type (supports aliases: "input" → "input-*")
|
||||
tag?: string; // Filter by HTML tag (e.g., "input", "button")
|
||||
index?: number; // Select nth match (1-based)
|
||||
viewportOnly?: boolean; // Only visible elements
|
||||
id?: string; // Direct ID lookup
|
||||
}
|
||||
```
|
||||
|
||||
**Type Aliases:**
|
||||
- Generic types auto-expand to match all subtypes
|
||||
- `type: "input"` → matches `input`, `input-text`, `input-search`, `input-password`, etc.
|
||||
- `type: "button"` → matches `button`, `button-submit`, `button-reset`, etc.
|
||||
- Specific types match exactly: `type: "input-search"` → only `input-search`
|
||||
|
||||
**Tag vs Type:**
|
||||
- `tag`: Filters by HTML tag name (e.g., `<input>`, `<button>`)
|
||||
- `type`: Filters by interaction map type classification (more specific, includes subtypes)
|
||||
- Use `tag` for broader matching, `type` for precise targeting
|
||||
|
||||
**3-Stage Fallback (Automatic):**
|
||||
When element not found, system automatically:
|
||||
1. Tries type-based search (with alias expansion)
|
||||
2. Falls back to tag-based search (if type specified)
|
||||
3. Regenerates map and retries (up to 3 attempts)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Direct ID lookup:**
|
||||
```typescript
|
||||
const results = queryMap(map, { id: 'elem_0' });
|
||||
// Returns: Single element with that ID
|
||||
```
|
||||
|
||||
**Text search:**
|
||||
```typescript
|
||||
const results = queryMap(map, { text: 'Delete' });
|
||||
// Returns: All elements containing "Delete"
|
||||
```
|
||||
|
||||
**Text + index:**
|
||||
```typescript
|
||||
const results = queryMap(map, { text: 'Delete', index: 2 });
|
||||
// Returns: Second element containing "Delete"
|
||||
```
|
||||
|
||||
**Type filter:**
|
||||
```typescript
|
||||
const results = queryMap(map, { type: 'button' });
|
||||
// Returns: All button elements
|
||||
```
|
||||
|
||||
**Text + type:**
|
||||
```typescript
|
||||
const results = queryMap(map, { text: 'Submit', type: 'button' });
|
||||
// Returns: Button elements containing "Submit"
|
||||
```
|
||||
|
||||
**Visibility filter:**
|
||||
```typescript
|
||||
const results = queryMap(map, { text: 'Add to Cart', viewportOnly: true });
|
||||
// Returns: Only "Add to Cart" elements currently visible
|
||||
```
|
||||
|
||||
### Fuzzy Search
|
||||
|
||||
When exact text match fails, falls back to fuzzy search:
|
||||
```typescript
|
||||
// Query: { text: 'menu' }
|
||||
// Matches: "메뉴로 돌아가기", "Main Menu", "menu button"
|
||||
// Case-insensitive, substring matching
|
||||
```
|
||||
|
||||
## CLI Smart Mode
|
||||
|
||||
### Click Command
|
||||
|
||||
```bash
|
||||
# Search by text
|
||||
node .browser-pilot/bp click --text "Submit"
|
||||
|
||||
# With index for duplicates
|
||||
node .browser-pilot/bp click --text "Delete" --index 2
|
||||
|
||||
# Filter by type
|
||||
node .browser-pilot/bp click --text "Add to Cart" --type button
|
||||
|
||||
# Visible elements only
|
||||
node .browser-pilot/bp click --text "Next" --viewport-only
|
||||
```
|
||||
|
||||
### Fill Command
|
||||
|
||||
```bash
|
||||
# Search input by label
|
||||
node .browser-pilot/bp fill --text "Username" -v "testuser"
|
||||
|
||||
# Filter by input type
|
||||
node .browser-pilot/bp fill --text "Password" -v "secret" --type input-password
|
||||
|
||||
# Visible inputs only
|
||||
node .browser-pilot/bp fill --text "Email" -v "test@example.com" --viewport-only
|
||||
```
|
||||
|
||||
## Cache Management
|
||||
|
||||
### Automatic Cache
|
||||
|
||||
Maps are cached for 10 minutes with automatic management:
|
||||
- Auto-generated on first page load
|
||||
- Auto-regenerated after 10 minutes
|
||||
- Auto-regenerated on navigation
|
||||
- URL validation prevents stale maps
|
||||
|
||||
Cache location: `.browser-pilot/map-cache.json`
|
||||
|
||||
### Manual Control (Daemon Commands)
|
||||
|
||||
Force regenerate map:
|
||||
```bash
|
||||
npm run bp:daemon-send -- --command MAP_GENERATE --params '{"force":true}'
|
||||
```
|
||||
|
||||
Query current map:
|
||||
```bash
|
||||
npm run bp:daemon-send -- --command MAP_QUERY --params '{"text":"Submit","type":"button"}'
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Let daemon auto-manage**
|
||||
- Maps generate automatically on page load
|
||||
- No manual generation needed
|
||||
|
||||
2. **Use text + index for duplicates**
|
||||
- Better than CSS classes that may change
|
||||
- More readable: "click 2nd Delete" vs complex selector
|
||||
|
||||
3. **Filter by type**
|
||||
- Narrows results when text is ambiguous
|
||||
- `--type button` excludes links, divs with same text
|
||||
|
||||
4. **Verify visibility**
|
||||
- `--viewport-only` ensures element is on screen
|
||||
- Avoids clicking hidden/off-screen elements
|
||||
|
||||
5. **Check map statistics**
|
||||
- Review duplicates count in map JSON
|
||||
- Helps determine if indexing is needed
|
||||
|
||||
6. **Fallback handling**
|
||||
- Smart Mode automatically tries alternative selectors
|
||||
- Check console for errors if action fails
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Element not found in map
|
||||
|
||||
**Cause:** Element may not be detected as interactive
|
||||
|
||||
**Solutions:**
|
||||
1. Check if element has click handler: Look for `onclick`, React handlers
|
||||
2. Verify cursor style: Should be `pointer` for clickable elements
|
||||
3. Check ARIA role: Element should have appropriate role
|
||||
4. Force regenerate map if recently added to page
|
||||
|
||||
### Wrong element selected
|
||||
|
||||
**Cause:** Multiple elements with same text
|
||||
|
||||
**Solutions:**
|
||||
1. Use `--index` to select specific match
|
||||
2. Add `--type` filter to narrow results
|
||||
3. Use `--viewport-only` to exclude off-screen elements
|
||||
4. Check element position in map JSON
|
||||
|
||||
### Map out of date
|
||||
|
||||
**Cause:** Page changed after map generation
|
||||
|
||||
**Solutions:**
|
||||
1. Maps auto-regenerate after 10 minutes
|
||||
2. Force regenerate with daemon command
|
||||
3. Check timestamp in map JSON
|
||||
4. Verify URL matches current page
|
||||
|
||||
### Cache not updating
|
||||
|
||||
**Cause:** URL changed but cache still returns old map
|
||||
|
||||
**Solutions:**
|
||||
1. Daemon validates URL before returning cache
|
||||
2. Force regenerate with `force:true` parameter
|
||||
3. Check cache file for URL mismatch
|
||||
4. Restart daemon if persists
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Current status (v1.3.0):
|
||||
- ✓ Automatic map generation on page load
|
||||
- ✓ Daemon-level map caching and management
|
||||
- ✓ Action verification with automatic retry
|
||||
- ✓ URL-based cache validation
|
||||
- ✓ Chain mode with automatic map synchronization
|
||||
- ✓ Handler architecture refactoring for maintainability
|
||||
|
||||
Planned improvements:
|
||||
- Visual map inspector tool
|
||||
- Map diff for debugging selector changes
|
||||
- Performance metrics and optimization
|
||||
- Additional daemon commands (wait-idle, sleep)
|
||||
447
skills/references/selector-guide.md
Normal file
447
skills/references/selector-guide.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# Selector Strategies Guide
|
||||
|
||||
## Selector Types Overview
|
||||
|
||||
| Selector Type | Stability | Speed | Best For |
|
||||
|--------------|-----------|-------|----------|
|
||||
| CSS ID | ⭐⭐⭐⭐⭐ | Fast | Unique IDs |
|
||||
| Smart Mode (text) | ⭐⭐⭐⭐ | Medium | Text-based UI |
|
||||
| XPath (text) | ⭐⭐⭐⭐ | Medium | Text content |
|
||||
| CSS Class | ⭐⭐ | Fast | Stable classes |
|
||||
| XPath (structure) | ⭐⭐ | Medium | DOM structure |
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Does element have unique ID?
|
||||
├─ YES → Use CSS: #element-id
|
||||
└─ NO ↓
|
||||
|
||||
Is element identified by text?
|
||||
├─ YES → Use Smart Mode: --text "Submit"
|
||||
└─ NO ↓
|
||||
|
||||
Does element have stable class?
|
||||
├─ YES → Use CSS: .stable-class
|
||||
└─ NO ↓
|
||||
|
||||
Can use ARIA attributes?
|
||||
├─ YES → Use XPath: //*[@role='button']
|
||||
└─ NO ↓
|
||||
|
||||
Use structural XPath
|
||||
```
|
||||
|
||||
## CSS Selectors
|
||||
|
||||
### By ID (Most Stable)
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "#login-button"
|
||||
node .browser-pilot/bp click -s "#user-menu"
|
||||
```
|
||||
|
||||
### By Class
|
||||
```bash
|
||||
node .browser-pilot/bp click -s ".submit-btn"
|
||||
node .browser-pilot/bp click -s ".modal .close-button"
|
||||
```
|
||||
|
||||
### By Attribute
|
||||
```bash
|
||||
node .browser-pilot/bp fill -s "input[name='email']" -v "test@example.com"
|
||||
node .browser-pilot/bp click -s "button[data-action='submit']"
|
||||
```
|
||||
|
||||
### Complex Selectors
|
||||
```bash
|
||||
# Direct child
|
||||
node .browser-pilot/bp click -s "div.modal > button.primary"
|
||||
|
||||
# Descendant
|
||||
node .browser-pilot/bp click -s "form .submit-button"
|
||||
|
||||
# Nth-child
|
||||
node .browser-pilot/bp click -s "ul > li:nth-child(3)"
|
||||
|
||||
# Multiple classes
|
||||
node .browser-pilot/bp click -s "button.btn.btn-primary.btn-lg"
|
||||
```
|
||||
|
||||
## XPath Selectors
|
||||
|
||||
### Text-Based (Most Reliable)
|
||||
|
||||
**With Tag Name (Recommended):**
|
||||
```bash
|
||||
# Specific tag with text
|
||||
node .browser-pilot/bp click -s "//button[contains(text(), 'Submit')]"
|
||||
node .browser-pilot/bp click -s "//a[contains(text(), 'Learn More')]"
|
||||
|
||||
# Exact text match
|
||||
node .browser-pilot/bp click -s "//button[text()='Sign In']"
|
||||
```
|
||||
|
||||
**With Wildcard (Avoid):**
|
||||
```bash
|
||||
# Searches all elements (slow, imprecise)
|
||||
node .browser-pilot/bp click -s "//*[contains(text(), 'Submit')]"
|
||||
```
|
||||
|
||||
### Indexed XPath (Duplicates)
|
||||
```bash
|
||||
# First match
|
||||
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[1]"
|
||||
|
||||
# Third match
|
||||
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[3]"
|
||||
|
||||
# Last match (if 5 exist)
|
||||
node .browser-pilot/bp click -s "(//button[contains(text(), 'Delete')])[5]"
|
||||
```
|
||||
|
||||
### Attribute-Based
|
||||
```bash
|
||||
# By type
|
||||
node .browser-pilot/bp fill -s "//*[@type='email']" -v "test@example.com"
|
||||
|
||||
# By role
|
||||
node .browser-pilot/bp click -s "//*[@role='button']"
|
||||
|
||||
# By data attribute
|
||||
node .browser-pilot/bp click -s "//*[@data-testid='submit']"
|
||||
|
||||
# Partial match
|
||||
node .browser-pilot/bp click -s "//*[contains(@href, 'checkout')]"
|
||||
```
|
||||
|
||||
### Structural XPath
|
||||
```bash
|
||||
# Parent-child
|
||||
node .browser-pilot/bp click -s "//div[@class='modal']//button[@type='submit']"
|
||||
|
||||
# Following sibling
|
||||
node .browser-pilot/bp click -s "//h1[contains(text(), 'Welcome')]/following-sibling::button"
|
||||
|
||||
# Preceding sibling
|
||||
node .browser-pilot/bp click -s "//button[contains(text(), 'Next')]/preceding-sibling::button"
|
||||
|
||||
# Parent
|
||||
node .browser-pilot/bp click -s "//button[@type='submit']/parent::form"
|
||||
```
|
||||
|
||||
## Smart Mode Selectors
|
||||
|
||||
### Basic Text Search
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Submit"
|
||||
node .browser-pilot/bp click --text "Add to Cart"
|
||||
node .browser-pilot/bp fill --text "Username" -v "test"
|
||||
```
|
||||
|
||||
### With Type Filter
|
||||
```bash
|
||||
# Only buttons
|
||||
node .browser-pilot/bp click --text "Submit" --type button
|
||||
|
||||
# Only links
|
||||
node .browser-pilot/bp click --text "Learn More" --type a
|
||||
|
||||
# Only text inputs
|
||||
node .browser-pilot/bp fill --text "Email" -v "test@example.com" --type input-text
|
||||
```
|
||||
|
||||
### Type Aliases (Auto-Expanded)
|
||||
```bash
|
||||
# Generic type (matches all subtypes)
|
||||
node .browser-pilot/bp click --text "Search" --type input
|
||||
# Expands to: input, input-text, input-search, input-password, etc.
|
||||
|
||||
# Specific type (exact match)
|
||||
node .browser-pilot/bp fill --text "Email" --type input-search -v "query"
|
||||
# Matches only: input-search
|
||||
|
||||
# Button aliases
|
||||
node .browser-pilot/bp click --text "Submit" --type button
|
||||
# Expands to: button, button-submit, button-reset, etc.
|
||||
```
|
||||
|
||||
### Tag-Based Search
|
||||
```bash
|
||||
# Filter by HTML tag (broader matching)
|
||||
node .browser-pilot/bp click --text "Submit" --tag button
|
||||
# Matches all <button> elements regardless of type
|
||||
|
||||
node .browser-pilot/bp fill --text "Email" --tag input -v "user@example.com"
|
||||
# Matches all <input> elements regardless of type
|
||||
|
||||
# Combined with text search
|
||||
node .browser-pilot/bp click --text "Next" --tag button --viewport-only
|
||||
```
|
||||
|
||||
**Tag vs Type:**
|
||||
- `--tag`: Matches HTML tag name (`<button>`, `<input>`)
|
||||
- `--type`: Matches interaction map classification (more specific)
|
||||
- Use `--tag` when type filtering fails or for broader matches
|
||||
|
||||
### With Indexing
|
||||
```bash
|
||||
# Second "Delete" button
|
||||
node .browser-pilot/bp click --text "Delete" --index 2 --type button
|
||||
|
||||
# First "Submit" button
|
||||
node .browser-pilot/bp click --text "Submit" --index 1
|
||||
```
|
||||
|
||||
### With Visibility Filter
|
||||
```bash
|
||||
# Only visible elements
|
||||
node .browser-pilot/bp click --text "Next" --viewport-only
|
||||
|
||||
# Visible "Add to Cart" buttons
|
||||
node .browser-pilot/bp click --text "Add to Cart" --viewport-only --type button
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Filling
|
||||
|
||||
**Direct mode (fast but brittle):**
|
||||
```bash
|
||||
node .browser-pilot/bp fill -s "#email" -v "user@example.com"
|
||||
node .browser-pilot/bp fill -s "#password" -v "secret"
|
||||
node .browser-pilot/bp click -s "#login-btn"
|
||||
```
|
||||
|
||||
**Smart mode (slower but reliable, map auto-generated on page load):**
|
||||
```bash
|
||||
node .browser-pilot/bp fill --text "Email" -v "user@example.com"
|
||||
node .browser-pilot/bp fill --text "Password" -v "secret"
|
||||
node .browser-pilot/bp click --text "Login" --type button
|
||||
```
|
||||
|
||||
**Chain mode (Direct):**
|
||||
```bash
|
||||
node .browser-pilot/bp chain navigate -u <url> fill -s #email -v <email> fill -s #password -v <password> click -s #login-btn
|
||||
```
|
||||
|
||||
**Chain mode (Smart - recommended):**
|
||||
```bash
|
||||
node .browser-pilot/bp chain navigate -u <url> fill --text Email -v <email> fill --text Password -v <password> click --text Login --type button
|
||||
```
|
||||
|
||||
**Note:** Chain mode auto-waits for map generation after navigation and adds human-like delays between commands.
|
||||
|
||||
### Clicking Nth Item
|
||||
|
||||
**CSS nth-child:**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "ul.products > li:nth-child(3) button"
|
||||
```
|
||||
|
||||
**XPath indexing:**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "(//ul[@class='products']//button)[3]"
|
||||
```
|
||||
|
||||
**Smart mode indexing:**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Add to Cart" --index 3
|
||||
```
|
||||
|
||||
### Modal Interactions
|
||||
|
||||
**CSS scoping:**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s ".modal .btn-primary"
|
||||
node .browser-pilot/bp click -s "#confirm-modal button.submit"
|
||||
```
|
||||
|
||||
**XPath scoping:**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "//div[@class='modal']//button[contains(text(), 'Confirm')]"
|
||||
```
|
||||
|
||||
**Smart mode:**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Confirm" --type button --viewport-only
|
||||
```
|
||||
|
||||
### Dynamic Content
|
||||
|
||||
**Wait then interact:**
|
||||
```bash
|
||||
node .browser-pilot/bp wait -s ".loading-complete" -t 5000
|
||||
node .browser-pilot/bp click --text "Load More" --viewport-only
|
||||
```
|
||||
|
||||
**Chain mode:**
|
||||
```bash
|
||||
node .browser-pilot/bp chain wait -s ".loading-complete" -t 5000 click --text "Load More" --viewport-only
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Prefer Stable Identifiers
|
||||
|
||||
**Good: Unique ID**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "#checkout-button"
|
||||
```
|
||||
|
||||
**Good: Data attribute**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "button[data-testid='submit']"
|
||||
```
|
||||
|
||||
**Avoid: Generated classes**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s ".btn-a7s9d2f" # Likely to change
|
||||
```
|
||||
|
||||
### 2. Use Smart Mode for Text-Based UI
|
||||
|
||||
**Good: Text content is stable**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Continue to Checkout"
|
||||
```
|
||||
|
||||
**Avoid: CSS classes for text buttons**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s ".checkout-btn-primary-lg"
|
||||
```
|
||||
|
||||
### 3. Scope Selectors When Possible
|
||||
|
||||
**Good: Scoped to container**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "#user-menu button.logout"
|
||||
```
|
||||
|
||||
**Avoid: Global selector**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "button.logout" # May match wrong button
|
||||
```
|
||||
|
||||
### 4. Handle Duplicates Explicitly
|
||||
|
||||
**Good: Specific index**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Delete" --index 2
|
||||
```
|
||||
|
||||
**Good: Scoped selector**
|
||||
```bash
|
||||
node .browser-pilot/bp click -s "#product-123 button.delete"
|
||||
```
|
||||
|
||||
**Avoid: Ambiguous selector**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Delete" # Which Delete button?
|
||||
```
|
||||
|
||||
### 5. Verify Element Visibility
|
||||
|
||||
**Good: Checks visibility**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Submit" --viewport-only
|
||||
```
|
||||
|
||||
**Consider: May be off-screen**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Submit"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Element not found"
|
||||
|
||||
**Solution 1: Use Smart Mode**
|
||||
```bash
|
||||
# Map auto-generates on page load, just use text search
|
||||
node .browser-pilot/bp click --text "Submit"
|
||||
```
|
||||
|
||||
**Solution 2: Wait for element**
|
||||
```bash
|
||||
node .browser-pilot/bp wait -s "#submit-button" -t 5000
|
||||
node .browser-pilot/bp click -s "#submit-button"
|
||||
```
|
||||
|
||||
**Solution 3: Check selector in DevTools**
|
||||
```javascript
|
||||
// In browser console:
|
||||
document.querySelector('#submit-button') // CSS
|
||||
$x("//button[contains(text(), 'Submit')]") // XPath
|
||||
```
|
||||
|
||||
### "Multiple elements match"
|
||||
|
||||
**Solution 1: Use indexing**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Delete" --index 2
|
||||
```
|
||||
|
||||
**Solution 2: Add more specificity**
|
||||
```bash
|
||||
# Before
|
||||
node .browser-pilot/bp click -s "button"
|
||||
|
||||
# After
|
||||
node .browser-pilot/bp click -s "#product-list button.delete"
|
||||
```
|
||||
|
||||
**Solution 3: Filter by type**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Submit" --type button
|
||||
```
|
||||
|
||||
### "Wrong element clicked"
|
||||
|
||||
**Solution 1: Inspect map**
|
||||
```bash
|
||||
# Check interaction map JSON
|
||||
cat .browser-pilot/interaction-map.json | jq '.indexes.byText'
|
||||
|
||||
# Find element IDs with your text
|
||||
# Verify positions and types
|
||||
```
|
||||
|
||||
**Solution 2: Use visibility filter**
|
||||
```bash
|
||||
node .browser-pilot/bp click --text "Add to Cart" --viewport-only
|
||||
```
|
||||
|
||||
**Solution 3: Be more specific**
|
||||
```bash
|
||||
# Before
|
||||
node .browser-pilot/bp click --text "Submit"
|
||||
|
||||
# After
|
||||
node .browser-pilot/bp click --text "Submit Order" --type button
|
||||
```
|
||||
|
||||
## Framework-Specific Tips
|
||||
|
||||
### React Applications
|
||||
- Use `data-testid` attributes if available
|
||||
- Text-based XPath works well (stable)
|
||||
- Smart Mode recommended for dynamic classes
|
||||
- Avoid CSS classes (often generated)
|
||||
|
||||
### Angular Applications
|
||||
- Use `ng-` attributes if available
|
||||
- Text-based selectors are stable
|
||||
- Smart Mode recommended
|
||||
- Avoid dynamic `_ngcontent` attributes
|
||||
|
||||
### Vue Applications
|
||||
- Use `data-` attributes if available
|
||||
- Text-based XPath works well
|
||||
- Smart Mode recommended
|
||||
- Avoid scoped CSS classes
|
||||
|
||||
### Plain HTML
|
||||
- CSS selectors work well
|
||||
- IDs and classes are usually stable
|
||||
- Direct mode is often sufficient
|
||||
- Use Smart Mode for dynamic content
|
||||
43
skills/scripts/eslint.config.mjs
Normal file
43
skills/scripts/eslint.config.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'*.backup/**'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint
|
||||
},
|
||||
rules: {
|
||||
// TypeScript 규칙
|
||||
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_'
|
||||
}],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
||||
// 일반 규칙
|
||||
'no-console': 'off', // CLI 도구이므로 console 사용 허용
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error'
|
||||
}
|
||||
}
|
||||
];
|
||||
47
skills/scripts/package.json
Normal file
47
skills/scripts/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "browser-pilot-cli",
|
||||
"version": "1.10.0",
|
||||
"description": "Chrome DevTools Protocol browser automation CLI",
|
||||
"main": "dist/cli/cli.js",
|
||||
"bin": {
|
||||
"cdp-browser": "./dist/cli/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"bp:run": "node dist/cli/cli.js",
|
||||
"bp:chain": "node dist/cli/cli.js chain",
|
||||
"bp:daemon-start": "node dist/cli/cli.js daemon-start",
|
||||
"bp:daemon-stop": "node dist/cli/cli.js daemon-stop",
|
||||
"bp:daemon-restart": "node dist/cli/cli.js daemon-restart",
|
||||
"bp:daemon-status": "node dist/cli/cli.js daemon-status",
|
||||
"bp:query": "node dist/cli/cli.js query",
|
||||
"bp:regen-map": "node dist/cli/cli.js regen-map",
|
||||
"bp:map-status": "node dist/cli/cli.js map-status"
|
||||
},
|
||||
"keywords": [
|
||||
"cdp",
|
||||
"chrome",
|
||||
"automation",
|
||||
"browser",
|
||||
"scraping"
|
||||
],
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
28
skills/scripts/src/cdp/actions.ts
Normal file
28
skills/scripts/src/cdp/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Core CDP actions for browser automation.
|
||||
*
|
||||
* This file serves as the main index for all action modules.
|
||||
* Modularized for better organization and maintainability.
|
||||
*/
|
||||
|
||||
// Export ActionResult type
|
||||
export type { ActionResult } from './actions/helpers';
|
||||
|
||||
// Re-export helper functions (excluding ActionResult to avoid conflict)
|
||||
export { sleep, checkConsoleErrors, ensureOutputPath } from './actions/helpers';
|
||||
|
||||
// Re-export modular actions
|
||||
export * from './actions/navigation';
|
||||
export * from './actions/interaction';
|
||||
export * from './actions/capture';
|
||||
export * from './actions/data';
|
||||
export * from './actions/cookies';
|
||||
export * from './actions/tabs';
|
||||
export * from './actions/forms';
|
||||
export * from './actions/input';
|
||||
export * from './actions/scroll';
|
||||
export * from './actions/wait';
|
||||
export * from './actions/debugging';
|
||||
export * from './actions/emulation';
|
||||
export * from './actions/dialogs';
|
||||
export * from './actions/network';
|
||||
179
skills/scripts/src/cdp/actions/capture.ts
Normal file
179
skills/scripts/src/cdp/actions/capture.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Capture actions (screenshot, PDF) for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ActionResult, ActionOptions, mergeOptions, ensureOutputPath } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { FS } from '../../constants';
|
||||
|
||||
// CDP Types for Page domain
|
||||
interface LayoutMetrics {
|
||||
contentSize: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScreenshotParams {
|
||||
clip?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScreenshotResult {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface PDFParams {
|
||||
printBackground: boolean;
|
||||
landscape: boolean;
|
||||
paperWidth: number;
|
||||
paperHeight: number;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
}
|
||||
|
||||
interface PDFResult {
|
||||
data: string;
|
||||
}
|
||||
|
||||
// PDF Constants
|
||||
const PDF_PAPER_LETTER_WIDTH = 8.5; // inches
|
||||
const PDF_PAPER_LETTER_HEIGHT = 11.0; // inches
|
||||
const PDF_DEFAULT_MARGIN = 0.4; // inches
|
||||
|
||||
export interface ClipOptions {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take screenshot.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param filename - Screenshot filename (automatically saved to .browser-pilot/screenshots/)
|
||||
* @param fullPage - Capture full page or viewport only
|
||||
* @param clip - Optional clip region (x, y, width, height, scale)
|
||||
* @param options - Action options
|
||||
*/
|
||||
export async function screenshot(
|
||||
browser: ChromeBrowser,
|
||||
filename: string,
|
||||
fullPage = true,
|
||||
clip?: ClipOptions,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
// Construct path within screenshots folder
|
||||
const screenshotPath = join(FS.SCREENSHOTS_DIR, filename);
|
||||
|
||||
if (opts.verbose) logger.info(`📸 Taking screenshot: ${screenshotPath}`);
|
||||
|
||||
// Enable Page domain
|
||||
await browser.sendCommand('Page.enable');
|
||||
|
||||
let params: ScreenshotParams = {};
|
||||
|
||||
// Clip region has priority over fullPage
|
||||
if (clip) {
|
||||
params = {
|
||||
clip: {
|
||||
x: clip.x,
|
||||
y: clip.y,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
scale: clip.scale || 1
|
||||
}
|
||||
};
|
||||
if (opts.verbose) {
|
||||
logger.info(` Region: (${clip.x}, ${clip.y}) ${clip.width}x${clip.height} scale=${clip.scale || 1}`);
|
||||
}
|
||||
} else if (fullPage) {
|
||||
// Get page dimensions
|
||||
const metrics = await browser.sendCommand<LayoutMetrics>('Page.getLayoutMetrics');
|
||||
const contentSize = metrics.contentSize;
|
||||
|
||||
params = {
|
||||
clip: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: contentSize.width,
|
||||
height: contentSize.height,
|
||||
scale: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const result = await browser.sendCommand<ScreenshotResult>('Page.captureScreenshot', params);
|
||||
|
||||
// Decode and save
|
||||
const imageData = Buffer.from(result.data, 'base64');
|
||||
|
||||
// Ensure output directory exists (creates .browser-pilot/screenshots/ if needed)
|
||||
const absolutePath = ensureOutputPath(screenshotPath);
|
||||
|
||||
writeFileSync(absolutePath, imageData);
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Screenshot saved: ${absolutePath}`);
|
||||
|
||||
return { success: true, path: absolutePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF from current page.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param filename - PDF filename (automatically saved to .browser-pilot/pdfs/)
|
||||
* @param landscape - Use landscape orientation
|
||||
* @param printBackground - Print background graphics
|
||||
* @param options - Action options
|
||||
*/
|
||||
export async function generatePdf(
|
||||
browser: ChromeBrowser,
|
||||
filename: string,
|
||||
landscape = false,
|
||||
printBackground = true,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
// Construct path within pdfs folder
|
||||
const pdfPath = join(FS.PDFS_DIR, filename);
|
||||
|
||||
if (opts.verbose) logger.info(`📄 Generating PDF: ${pdfPath}`);
|
||||
|
||||
await browser.sendCommand('Page.enable');
|
||||
|
||||
const params: PDFParams = {
|
||||
printBackground,
|
||||
landscape,
|
||||
paperWidth: PDF_PAPER_LETTER_WIDTH,
|
||||
paperHeight: PDF_PAPER_LETTER_HEIGHT,
|
||||
marginTop: PDF_DEFAULT_MARGIN,
|
||||
marginBottom: PDF_DEFAULT_MARGIN,
|
||||
marginLeft: PDF_DEFAULT_MARGIN,
|
||||
marginRight: PDF_DEFAULT_MARGIN
|
||||
};
|
||||
|
||||
const result = await browser.sendCommand<PDFResult>('Page.printToPDF', params);
|
||||
const pdfData = Buffer.from(result.data, 'base64');
|
||||
|
||||
// Ensure output directory exists (creates .browser-pilot/pdfs/ if needed)
|
||||
const absolutePath = ensureOutputPath(pdfPath);
|
||||
writeFileSync(absolutePath, pdfData);
|
||||
|
||||
if (opts.verbose) logger.info(`✅ PDF saved: ${absolutePath}`);
|
||||
|
||||
return { success: true, path: absolutePath };
|
||||
}
|
||||
122
skills/scripts/src/cdp/actions/cookies.ts
Normal file
122
skills/scripts/src/cdp/actions/cookies.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Cookie management actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// CDP Types for Network.Cookie
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: number;
|
||||
size?: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
session?: boolean;
|
||||
sameSite?: string;
|
||||
}
|
||||
|
||||
interface GetCookiesResult {
|
||||
cookies: Cookie[];
|
||||
}
|
||||
|
||||
interface SetCookieParams {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
expires?: number;
|
||||
sameSite?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cookies.
|
||||
*/
|
||||
export async function getCookies(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info('🍪 Getting cookies...');
|
||||
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
|
||||
const cookies = result.cookies || [];
|
||||
if (opts.verbose) logger.info(`✅ Retrieved ${cookies.length} cookie(s)`);
|
||||
return { success: true, cookies, count: cookies.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cookie.
|
||||
*/
|
||||
export async function setCookie(
|
||||
browser: ChromeBrowser,
|
||||
name: string,
|
||||
value: string,
|
||||
domain?: string,
|
||||
path = '/',
|
||||
secure = false,
|
||||
httpOnly = false,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🍪 Setting cookie: ${name}`);
|
||||
|
||||
const cookieParams: SetCookieParams = {
|
||||
name,
|
||||
value,
|
||||
path,
|
||||
secure,
|
||||
httpOnly,
|
||||
...(domain && { domain })
|
||||
};
|
||||
|
||||
await browser.sendCommand('Network.setCookie', cookieParams);
|
||||
if (opts.verbose) logger.info(`✅ Cookie set successfully`);
|
||||
return { success: true, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cookies.
|
||||
*/
|
||||
export async function deleteCookies(
|
||||
browser: ChromeBrowser,
|
||||
name?: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (name) {
|
||||
if (opts.verbose) logger.info(`🍪 Deleting cookie: ${name}`);
|
||||
// Get all cookies to find the domain
|
||||
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
|
||||
const cookies = result.cookies || [];
|
||||
|
||||
// Find matching cookies
|
||||
const matchingCookies = cookies.filter((c: Cookie) => c.name === name);
|
||||
|
||||
if (matchingCookies.length > 0) {
|
||||
for (const cookie of matchingCookies) {
|
||||
await browser.sendCommand('Network.deleteCookies', {
|
||||
name,
|
||||
domain: cookie.domain || ''
|
||||
});
|
||||
}
|
||||
if (opts.verbose) logger.info(`✅ Deleted ${matchingCookies.length} cookie(s) with name '${name}'`);
|
||||
} else {
|
||||
if (opts.verbose) logger.warn(`⚠️ Warning: Cookie '${name}' not found`);
|
||||
}
|
||||
} else {
|
||||
if (opts.verbose) logger.info('🍪 Deleting all cookies...');
|
||||
await browser.sendCommand('Network.clearBrowserCookies');
|
||||
if (opts.verbose) logger.info(`✅ All cookies deleted`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
207
skills/scripts/src/cdp/actions/data.ts
Normal file
207
skills/scripts/src/cdp/actions/data.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Data extraction and evaluation actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Evaluate JavaScript.
|
||||
*/
|
||||
export async function evaluate(
|
||||
browser: ChromeBrowser,
|
||||
script: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⚙️ Evaluating JavaScript...`);
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Evaluation complete`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, result: result.result?.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from element or body.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function extractText(
|
||||
browser: ChromeBrowser,
|
||||
selector?: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
if (selector) {
|
||||
logger.info(`📝 Extracting text from: ${selector}`);
|
||||
} else {
|
||||
logger.info(`📝 Extracting text from page body`);
|
||||
}
|
||||
}
|
||||
const script = selector
|
||||
? `(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
return findElement(selector)?.textContent || '';
|
||||
})()`
|
||||
: `document.body.textContent || ''`;
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const text = (result.result?.value as string) || '';
|
||||
if (opts.verbose) logger.info(`✅ Extracted ${text.length} characters`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data using multiple selectors.
|
||||
*/
|
||||
export async function extractData(
|
||||
browser: ChromeBrowser,
|
||||
selectors: Record<string, string>,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`📊 Extracting data with ${Object.keys(selectors).length} selectors`);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, selector] of Object.entries(selectors)) {
|
||||
try {
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (elements.length === 0) return null;
|
||||
if (elements.length === 1) return elements[0].innerText;
|
||||
return Array.from(elements).map(el => el.innerText);
|
||||
})()
|
||||
`;
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
data[key] = result.result?.value;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
data[key] = `Error: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Extracted data for ${Object.keys(data).length} keys`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page HTML content.
|
||||
*/
|
||||
export async function getContent(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info('📄 Getting page HTML content');
|
||||
|
||||
const script = `document.documentElement.outerHTML`;
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const content = (result.result?.value as string) || '';
|
||||
if (opts.verbose) logger.info(`✅ Retrieved ${content.length} characters of HTML`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content,
|
||||
length: content.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element property value.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function getElementProperty(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
propertyName: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Getting property '${propertyName}' from: ${selector}`);
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const propertyName = ${JSON.stringify(propertyName)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
return el[propertyName];
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
const errorMsg = result.exceptionDetails.exception?.description ||
|
||||
result.exceptionDetails.text ||
|
||||
'Unknown error';
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Get property failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMsg}`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Property '${propertyName}': ${result.result?.value}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selector,
|
||||
property: propertyName,
|
||||
value: result.result?.value
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Get property failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
165
skills/scripts/src/cdp/actions/debugging.ts
Normal file
165
skills/scripts/src/cdp/actions/debugging.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Debugging actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser, FormattedConsoleMessage } from '../browser';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Get console messages.
|
||||
*
|
||||
* Returns console messages that have been collected since the browser connected.
|
||||
* Messages are automatically collected when Log domain is enabled during connection.
|
||||
*/
|
||||
export async function getConsoleMessages(
|
||||
browser: ChromeBrowser,
|
||||
errorOnly = false,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info('📋 Getting console messages...');
|
||||
|
||||
// Get all collected messages from browser
|
||||
const allMessages = browser.getConsoleMessages();
|
||||
|
||||
// Filter by error level if requested
|
||||
const messages = errorOnly
|
||||
? allMessages.filter(msg => msg.level === 'error')
|
||||
: allMessages;
|
||||
|
||||
// Format messages for display
|
||||
const formattedMessages: FormattedConsoleMessage[] = messages.map(msg => ({
|
||||
level: msg.level,
|
||||
text: msg.text,
|
||||
timestamp: new Date(msg.timestamp).toISOString(),
|
||||
url: msg.url,
|
||||
lineNumber: msg.lineNumber
|
||||
}));
|
||||
|
||||
const errorCount = allMessages.filter(msg => msg.level === 'error').length;
|
||||
const warningCount = allMessages.filter(msg => msg.level === 'warning').length;
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✅ Retrieved ${formattedMessages.length} message(s) (${errorCount} errors, ${warningCount} warnings)`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: formattedMessages,
|
||||
count: formattedMessages.length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
logCount: allMessages.filter(msg => msg.level === 'log' || msg.level === 'info').length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessibility tree snapshot.
|
||||
*/
|
||||
export async function getAccessibilitySnapshot(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info('♿ Getting accessibility snapshot...');
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Accessibility.enable');
|
||||
const result = await browser.sendCommand<{ nodes: unknown[] }>('Accessibility.getFullAXTree');
|
||||
|
||||
const nodes = result.nodes || [];
|
||||
|
||||
const formattedNodes = nodes.slice(0, 50).map((node: unknown) => {
|
||||
const n = node as { role?: { value?: string }; name?: { value?: string }; description?: { value?: string } };
|
||||
return {
|
||||
role: n.role?.value,
|
||||
name: n.name?.value,
|
||||
description: n.description?.value
|
||||
};
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Retrieved ${nodes.length} accessibility nodes (showing first 50)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeCount: nodes.length,
|
||||
nodes: formattedNodes
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Get accessibility snapshot failed`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find element and return its information.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function findElement(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) return null;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
id: el.id,
|
||||
className: el.className,
|
||||
textContent: el.textContent?.substring(0, 100),
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
position: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
},
|
||||
attributes: Array.from(el.attributes).reduce((acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const elementInfo = result.result?.value as { tagName?: string; id?: string; className?: string; attributes?: Record<string, string> } | null | undefined;
|
||||
|
||||
if (!elementInfo) {
|
||||
if (opts.verbose) logger.info(`❌ Element not found: ${selector}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Element not found: ${selector}`
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Found <${elementInfo.tagName}> element`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selector,
|
||||
element: elementInfo
|
||||
};
|
||||
}
|
||||
141
skills/scripts/src/cdp/actions/dialogs.ts
Normal file
141
skills/scripts/src/cdp/actions/dialogs.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Dialog handling actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Handle JavaScript dialogs (alert, confirm, prompt).
|
||||
* Must be called BEFORE the dialog appears.
|
||||
*/
|
||||
export async function handleDialog(
|
||||
browser: ChromeBrowser,
|
||||
accept: boolean = true,
|
||||
promptText?: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`💬 Setting up dialog handler - accept: ${accept}, promptText: ${promptText || 'none'}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Enable Page domain for dialog events
|
||||
await browser.sendCommand('Page.enable');
|
||||
|
||||
// Set up dialog handler
|
||||
await browser.sendCommand('Page.setInterceptFileChooserDialog', {
|
||||
enabled: false
|
||||
});
|
||||
|
||||
// Note: CDP doesn't have a way to pre-register dialog handlers
|
||||
// This returns a handler configuration that should be used with Page.javascriptDialogOpening event
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Dialog handler configured`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accept,
|
||||
promptText: promptText || null,
|
||||
note: 'Dialog handler configured. Use getDialogMessage() to check for dialogs.'
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Dialog handler setup failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current dialog message if one is open.
|
||||
* This should be called in response to Page.javascriptDialogOpening event.
|
||||
*/
|
||||
export async function getDialogMessage(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`💬 Checking for dialog...`);
|
||||
|
||||
// This function is a placeholder for dialog detection
|
||||
// In real CDP usage, you'd listen for Page.javascriptDialogOpening events
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
// Check if there's an active dialog by trying to access document
|
||||
try {
|
||||
document.body;
|
||||
return null; // No dialog
|
||||
} catch (e) {
|
||||
return { blocked: true }; // Dialog is blocking
|
||||
}
|
||||
})()
|
||||
`;
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const dialogActive = result.result?.value !== null;
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(dialogActive ? `⚠️ Dialog is active` : `✅ No dialog active`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dialogActive
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept or dismiss a JavaScript dialog.
|
||||
*/
|
||||
export async function respondToDialog(
|
||||
browser: ChromeBrowser,
|
||||
accept: boolean = true,
|
||||
promptText?: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`💬 Responding to dialog - accept: ${accept}`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Page.handleJavaScriptDialog', {
|
||||
accept,
|
||||
promptText: promptText || ''
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Dialog ${accept ? 'accepted' : 'dismissed'}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accept,
|
||||
promptText: promptText || null
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Respond to dialog failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
184
skills/scripts/src/cdp/actions/emulation.ts
Normal file
184
skills/scripts/src/cdp/actions/emulation.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Emulation actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions, logActionError } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export interface ViewportOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulate media type or color scheme.
|
||||
*/
|
||||
export async function emulateMedia(
|
||||
browser: ChromeBrowser,
|
||||
mediaType?: 'screen' | 'print',
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference',
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`🎨 Emulating media - type: ${mediaType || 'none'}, colorScheme: ${colorScheme || 'none'}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Emulation.setEmulatedMedia', {
|
||||
media: mediaType || '',
|
||||
features: colorScheme ? [{
|
||||
name: 'prefers-color-scheme',
|
||||
value: colorScheme
|
||||
}] : []
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Media emulation set`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mediaType: mediaType || null,
|
||||
colorScheme: colorScheme || null
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Emulate media failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set viewport size.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param width - Viewport width in pixels
|
||||
* @param height - Viewport height in pixels
|
||||
* @param deviceScaleFactor - Device scale factor (default: 1)
|
||||
* @param mobile - Whether to emulate mobile device (default: false)
|
||||
* @param options - Action options
|
||||
*/
|
||||
export async function setViewportSize(
|
||||
browser: ChromeBrowser,
|
||||
width: number,
|
||||
height: number,
|
||||
deviceScaleFactor = 1,
|
||||
mobile = false,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`📐 Setting viewport size: ${width}x${height} (scale: ${deviceScaleFactor}, mobile: ${mobile})`);
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor,
|
||||
mobile
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Viewport size set to ${width}x${height}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor,
|
||||
mobile
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
logActionError('Set viewport size failed', error, opts.verbose);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current viewport size.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param options - Action options
|
||||
*/
|
||||
export async function getViewport(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`📏 Getting viewport size...`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio})',
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const viewport = JSON.parse(result.result.value as string);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✅ Viewport: ${viewport.width}x${viewport.height} (scale: ${viewport.devicePixelRatio})`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
viewport
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
logActionError('Get viewport failed', error, opts.verbose);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get screen and viewport information.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param options - Action options
|
||||
*/
|
||||
export async function getScreenInfo(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`📊 Getting screen information...`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({viewport: {width: window.innerWidth, height: window.innerHeight}, screen: {width: window.screen.width, height: window.screen.height, availWidth: window.screen.availWidth, availHeight: window.screen.availHeight}, devicePixelRatio: window.devicePixelRatio})',
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const screenInfo = JSON.parse(result.result.value as string);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✅ Screen: ${screenInfo.screen.width}x${screenInfo.screen.height}`);
|
||||
logger.info(` Viewport: ${screenInfo.viewport.width}x${screenInfo.viewport.height}`);
|
||||
logger.info(` Scale: ${screenInfo.devicePixelRatio}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...screenInfo
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
logActionError('Get screen info failed', error, opts.verbose);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
259
skills/scripts/src/cdp/actions/forms.ts
Normal file
259
skills/scripts/src/cdp/actions/forms.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Form actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { readFileSync, statSync } from 'fs';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Select option from dropdown.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function selectOption(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
value: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔽 Selecting option ${value} in: ${selector}`);
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const value = ${JSON.stringify(value)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Selected option: ${value}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, selector, value };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Select failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to toggle checkbox state.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param selector - Checkbox selector
|
||||
* @param targetState - Desired checkbox state (true = checked, false = unchecked)
|
||||
* @param actionName - Action name for logging ("check" or "uncheck")
|
||||
* @param options - Action options
|
||||
*/
|
||||
async function _toggleCheckbox(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
targetState: boolean,
|
||||
actionName: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
const emoji = targetState ? '☑️' : '☐';
|
||||
const stateName = targetState ? 'checked' : 'unchecked';
|
||||
|
||||
if (opts.verbose) logger.info(`${emoji} ${actionName}: ${selector}`);
|
||||
|
||||
// Step 1: Find element and get coordinates
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
if (el.type !== 'checkbox') throw new Error('Element is not a checkbox: ' + selector);
|
||||
|
||||
// Scroll element into view
|
||||
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
|
||||
// Get bounding box and calculate center point
|
||||
const box = el.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: box.left + box.width / 2,
|
||||
y: box.top + box.height / 2,
|
||||
checked: el.checked
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('❌ Element not found or error occurred');
|
||||
if (result.exceptionDetails) {
|
||||
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
}
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
const { x, y, checked: isChecked } = result.result.value as { x: number; y: number; checked: boolean };
|
||||
|
||||
// Step 2: Click only if current state differs from target state
|
||||
if (isChecked === targetState) {
|
||||
if (opts.verbose) logger.info(`✓ Checkbox already ${stateName}`);
|
||||
} else {
|
||||
if (opts.verbose) logger.info(`🖱️ Clicking checkbox at (${Math.round(x)}, ${Math.round(y)})`);
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Checkbox ${stateName}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, selector };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ ${actionName} failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check checkbox.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* Uses CDP click for proper React compatibility.
|
||||
*/
|
||||
export async function check(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
return _toggleCheckbox(browser, selector, true, 'Checking', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncheck checkbox.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* Uses CDP click for proper React compatibility.
|
||||
*/
|
||||
export async function uncheck(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
return _toggleCheckbox(browser, selector, false, 'Unchecking', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to input element.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function uploadFile(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
filePath: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`📁 Uploading file ${filePath} to: ${selector}`);
|
||||
|
||||
// File size validation (10MB limit)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const stats = statSync(filePath);
|
||||
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
const error = `File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE} bytes = 10MB)`;
|
||||
if (opts.verbose) logger.error(`❌ ${error}`);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const fileData = readFileSync(filePath, 'base64');
|
||||
const fileName = filePath.split(/[/\\]/).pop() || 'file';
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const fileData = ${JSON.stringify(fileData)};
|
||||
const fileName = ${JSON.stringify(fileName)};
|
||||
|
||||
${getFindElementScript()}
|
||||
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
if (el.tagName !== 'INPUT' || el.type !== 'file') {
|
||||
throw new Error('Element is not a file input');
|
||||
}
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
const file = new File(
|
||||
[Uint8Array.from(atob(fileData), c => c.charCodeAt(0))],
|
||||
fileName
|
||||
);
|
||||
dataTransfer.items.add(file);
|
||||
el.files = dataTransfer.files;
|
||||
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ File uploaded: ${fileName}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, selector, file: filePath };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Upload failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
225
skills/scripts/src/cdp/actions/helpers.ts
Normal file
225
skills/scripts/src/cdp/actions/helpers.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Helper functions for Browser Pilot actions.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { getOutputDir } from '../config';
|
||||
import { waitForNetworkIdle } from './wait';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TIMING, FS } from '../../constants';
|
||||
|
||||
// ActionResult interface - will be exported from main actions.ts
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Export for internal use within actions modules
|
||||
export type { ActionResult };
|
||||
|
||||
/**
|
||||
* CDP Runtime.evaluate response type
|
||||
*/
|
||||
export interface RuntimeEvaluateResult {
|
||||
result?: {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
};
|
||||
exceptionDetails?: {
|
||||
exception?: {
|
||||
description?: string;
|
||||
};
|
||||
text?: string;
|
||||
timestamp?: number;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level for error and warning reporting
|
||||
*/
|
||||
export type LogLevel = 'all' | 'errors-only' | 'none';
|
||||
|
||||
/**
|
||||
* Constants for error checking and timing
|
||||
*/
|
||||
const RECENT_MESSAGE_TIMEOUT_MS = TIMING.RECENT_MESSAGE_WINDOW;
|
||||
const NAVIGATION_WAIT_DELAY_MS = TIMING.NETWORK_IDLE_TIMEOUT;
|
||||
|
||||
/**
|
||||
* Constants for selector retry logic
|
||||
*/
|
||||
export const SELECTOR_RETRY_CONFIG = {
|
||||
MAX_ATTEMPTS: 3,
|
||||
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
|
||||
MAP_FOLDER: FS.OUTPUT_DIR
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Action options interface
|
||||
*/
|
||||
export interface ActionOptions {
|
||||
verbose?: boolean; // Enable/disable logging (default: true)
|
||||
logLevel?: LogLevel; // Log level for errors/warnings (default: 'all')
|
||||
waitForNavigation?: boolean; // Wait for page navigation after action (default: false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default action options
|
||||
*/
|
||||
export const DEFAULT_OPTIONS: ActionOptions = {
|
||||
verbose: true,
|
||||
logLevel: 'all',
|
||||
waitForNavigation: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Merge user options with defaults
|
||||
*/
|
||||
export function mergeOptions(options?: ActionOptions): Required<ActionOptions> {
|
||||
return {
|
||||
verbose: options?.verbose ?? (DEFAULT_OPTIONS.verbose as boolean),
|
||||
logLevel: options?.logLevel ?? (DEFAULT_OPTIONS.logLevel as LogLevel),
|
||||
waitForNavigation: options?.waitForNavigation ?? (DEFAULT_OPTIONS.waitForNavigation as boolean)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Sleep for specified milliseconds.
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check browser console and network for errors and warnings after an action.
|
||||
* @param browser - ChromeBrowser instance
|
||||
* @param logLevel - Log level ('all', 'errors-only', 'none')
|
||||
*/
|
||||
export function checkErrors(browser: ChromeBrowser, logLevel: LogLevel = 'all'): void {
|
||||
if (logLevel === 'none') {
|
||||
return; // Skip logging
|
||||
}
|
||||
|
||||
const messages = browser.getConsoleMessages();
|
||||
const networkErrors = browser.getNetworkErrors();
|
||||
|
||||
// Filter for errors and warnings from recent messages
|
||||
const recentMessages = messages.filter(msg => {
|
||||
const age = Date.now() - msg.timestamp;
|
||||
return age < RECENT_MESSAGE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
const recentNetworkErrors = networkErrors.filter(err => {
|
||||
const age = Date.now() - err.timestamp;
|
||||
return age < RECENT_MESSAGE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
const consoleErrors = recentMessages.filter(msg => msg.level === 'error');
|
||||
const consoleWarnings = recentMessages.filter(msg => msg.level === 'warning');
|
||||
|
||||
// Console Errors
|
||||
if (consoleErrors.length > 0) {
|
||||
logger.error(`\n❌ ${consoleErrors.length} console error(s) detected:`);
|
||||
consoleErrors.forEach((err, idx) => {
|
||||
logger.error(` ${idx + 1}. ${err.text}`);
|
||||
if (err.url) {
|
||||
logger.error(` at ${err.url}:${err.lineNumber || 0}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Console Warnings (only if logLevel is 'all')
|
||||
if (logLevel === 'all' && consoleWarnings.length > 0) {
|
||||
logger.warn(`\n⚠️ ${consoleWarnings.length} console warning(s) detected:`);
|
||||
consoleWarnings.forEach((warn, idx) => {
|
||||
logger.warn(` ${idx + 1}. ${warn.text}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Network Errors
|
||||
if (recentNetworkErrors.length > 0) {
|
||||
logger.error(`\n🌐 ${recentNetworkErrors.length} network error(s) detected:`);
|
||||
recentNetworkErrors.forEach((err, idx) => {
|
||||
logger.error(` ${idx + 1}. ${err.url}`);
|
||||
logger.error(` ${err.errorText}`);
|
||||
if (err.statusCode) {
|
||||
logger.error(` Status: ${err.statusCode}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use checkErrors instead
|
||||
*/
|
||||
export function checkConsoleErrors(browser: ChromeBrowser): void {
|
||||
checkErrors(browser, 'all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Wait for action completion (navigation + errors check).
|
||||
* Reduces code duplication across click, fill, and other interactive actions.
|
||||
*/
|
||||
export async function waitForActionComplete(
|
||||
browser: ChromeBrowser,
|
||||
opts: Required<ActionOptions>
|
||||
): Promise<void> {
|
||||
if (opts.waitForNavigation) {
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for page navigation...`);
|
||||
await waitForNetworkIdle(browser, TIMING.ACTION_DELAY_NAVIGATION, 0, { verbose: false });
|
||||
await sleep(NAVIGATION_WAIT_DELAY_MS); // Additional delay for errors to surface
|
||||
}
|
||||
|
||||
checkErrors(browser, opts.logLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log action error with consistent formatting
|
||||
* @param context - Error context (e.g., 'Get viewport failed')
|
||||
* @param error - Error object
|
||||
* @param verbose - Whether to log the error
|
||||
*/
|
||||
export function logActionError(context: string, error: unknown, verbose: boolean): void {
|
||||
if (!verbose) return;
|
||||
|
||||
logger.error(`❌ ${context}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Ensure output path (convert relative to .browser-pilot/).
|
||||
* Security: Prevents path traversal attacks and rejects absolute paths.
|
||||
* Uses getOutputDir() from config to get project-specific output directory.
|
||||
*/
|
||||
export function ensureOutputPath(path: string): string {
|
||||
// Reject absolute paths
|
||||
if (resolve(path) === path) {
|
||||
throw new Error('Absolute paths are not allowed. Use relative paths only.');
|
||||
}
|
||||
|
||||
// Get output directory from project config (auto-creates .browser-pilot/)
|
||||
const outputDir = getOutputDir();
|
||||
const absolutePath = resolve(outputDir, path);
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if (!absolutePath.startsWith(outputDir)) {
|
||||
throw new Error('Path traversal detected. Files must be within .browser-pilot directory.');
|
||||
}
|
||||
|
||||
// Ensure subdirectory exists (if path includes subdirectories)
|
||||
const dir = dirname(absolutePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
106
skills/scripts/src/cdp/actions/input.ts
Normal file
106
skills/scripts/src/cdp/actions/input.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Keyboard input actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Press keyboard key.
|
||||
* Uses CDP Input.dispatchKeyEvent for proper React compatibility.
|
||||
* Supports special keys like 'Enter', 'Escape', 'Tab', etc.
|
||||
*/
|
||||
export async function pressKey(
|
||||
browser: ChromeBrowser,
|
||||
key: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⌨️ Pressing key: ${key}`);
|
||||
|
||||
try {
|
||||
// Send keyDown event
|
||||
await browser.sendCommand('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: key
|
||||
});
|
||||
|
||||
// Send keyUp event
|
||||
await browser.sendCommand('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: key
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Key pressed: ${key}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, key };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Press key failed: ${key}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text character by character.
|
||||
* Uses CDP Input.insertText for proper React compatibility.
|
||||
* Supports delay between characters for typing simulation.
|
||||
*/
|
||||
export async function typeText(
|
||||
browser: ChromeBrowser,
|
||||
text: string,
|
||||
delay = 0,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`⌨️ Typing: "${text}"`);
|
||||
if (delay > 0) logger.info(` Delay: ${delay}ms per character`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (delay > 0) {
|
||||
// Type character by character with delay using CDP
|
||||
for (const char of text) {
|
||||
await browser.sendCommand('Input.insertText', {
|
||||
text: char
|
||||
});
|
||||
await sleep(delay);
|
||||
}
|
||||
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters with ${delay}ms delay`);
|
||||
} else {
|
||||
// Type all at once using CDP
|
||||
await browser.sendCommand('Input.insertText', {
|
||||
text: text
|
||||
});
|
||||
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters`);
|
||||
}
|
||||
|
||||
checkErrors(browser, opts.logLevel);
|
||||
return { success: true, text };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Type text failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
712
skills/scripts/src/cdp/actions/interaction.ts
Normal file
712
skills/scripts/src/cdp/actions/interaction.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* Interaction actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, waitForActionComplete, sleep, RuntimeEvaluateResult, SELECTOR_RETRY_CONFIG } from './helpers';
|
||||
import { findSelectorWithFallback } from '../map/query-map';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TIMING } from '../../constants';
|
||||
|
||||
/**
|
||||
* Click element core logic (without retry).
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
|
||||
*/
|
||||
async function clickCore(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
|
||||
|
||||
// Step 1: Find element and scroll into view
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
|
||||
// Scroll element into view
|
||||
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
|
||||
// Get bounding box and calculate center point
|
||||
const box = el.getBoundingClientRect();
|
||||
return {
|
||||
x: box.left + box.width / 2,
|
||||
y: box.top + box.height / 2,
|
||||
tag: el.tagName,
|
||||
text: el.textContent?.substring(0, 50) || '',
|
||||
visible: box.width > 0 && box.height > 0
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('❌ Element not found or error occurred');
|
||||
if (result.exceptionDetails) {
|
||||
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
}
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
|
||||
if (opts.verbose) {
|
||||
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
|
||||
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
|
||||
}
|
||||
|
||||
// Step 2: Dispatch CDP mouse events (Puppeteer way)
|
||||
if (opts.verbose) logger.info(`🖱️ Mouse down at (${Math.round(x)}, ${Math.round(y)})`);
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`🖱️ Mouse up at (${Math.round(x)}, ${Math.round(y)})`);
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Clicked: ${selector}`);
|
||||
|
||||
// Wait for navigation and check errors
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selector,
|
||||
coordinates: { x: Math.round(x), y: Math.round(y) },
|
||||
element: { tag, text }
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`❌ Click failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
await waitForActionComplete(browser, opts);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click element with automatic retry using interaction map fallback.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
|
||||
*
|
||||
* On failure, attempts to find alternative selectors from interaction map and retries.
|
||||
*/
|
||||
export async function click(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
try {
|
||||
// First attempt with provided selector
|
||||
return await clickCore(browser, selector, options);
|
||||
} catch (error: unknown) {
|
||||
// Check if map file exists
|
||||
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
|
||||
if (!existsSync(mapPath)) {
|
||||
if (opts.verbose) {
|
||||
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info('🔄 Attempting to find alternative selectors from map...');
|
||||
}
|
||||
|
||||
// Try to find alternative selectors
|
||||
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
|
||||
|
||||
try {
|
||||
// If original selector looks like an ID, try querying by ID
|
||||
if (selector.startsWith('#')) {
|
||||
const id = selector.slice(1);
|
||||
fallbackResult = findSelectorWithFallback(mapPath, { id });
|
||||
}
|
||||
// If selector contains text in XPath format, extract and search
|
||||
else if (selector.includes('contains(text()')) {
|
||||
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
|
||||
if (textMatch && textMatch[1]) {
|
||||
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
|
||||
}
|
||||
}
|
||||
} catch (mapError: unknown) {
|
||||
if (opts.verbose) {
|
||||
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
|
||||
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
|
||||
}
|
||||
throw error; // Rethrow original error
|
||||
}
|
||||
|
||||
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
|
||||
if (opts.verbose) {
|
||||
logger.info('⚠️ No alternative selectors found in map.');
|
||||
}
|
||||
throw error; // Rethrow original error
|
||||
}
|
||||
|
||||
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
|
||||
const maxRetries = Math.min(
|
||||
fallbackResult.alternatives.length,
|
||||
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
|
||||
);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const altSelector = fallbackResult.alternatives[i];
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await clickCore(browser, altSelector, options);
|
||||
} catch (_retryError: unknown) {
|
||||
if (i === maxRetries - 1) {
|
||||
// Last retry failed, throw original error
|
||||
if (opts.verbose) {
|
||||
logger.info('❌ All retry attempts exhausted.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Continue to next alternative
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but throw original error as fallback
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill input field core logic (without retry).
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
|
||||
* Uses CDP click + insertText for proper React compatibility.
|
||||
*/
|
||||
async function fillCore(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
value: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✍️ Filling input: ${selector}`);
|
||||
logger.info(` Value: "${value}"`);
|
||||
}
|
||||
|
||||
// Step 1: Find element, get coordinates, and clear existing value
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
|
||||
// Scroll element into view
|
||||
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
|
||||
// Get bounding box and calculate center point
|
||||
const box = el.getBoundingClientRect();
|
||||
|
||||
// Clear existing value
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
return {
|
||||
x: box.left + box.width / 2,
|
||||
y: box.top + box.height / 2,
|
||||
tag: el.tagName,
|
||||
type: el.type || 'text'
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('❌ Element not found or error occurred');
|
||||
if (result.exceptionDetails) {
|
||||
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
}
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
const { x, y, tag, type } = result.result.value as { x: number; y: number; tag: string; type: string };
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✓ Element found: <${tag.toLowerCase()} type="${type}">`);
|
||||
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)})`);
|
||||
}
|
||||
|
||||
// Step 2: Click to focus
|
||||
if (opts.verbose) logger.info(`🖱️ Clicking to focus...`);
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
// Small delay to ensure focus
|
||||
await sleep(TIMING.ACTION_DELAY_SHORT);
|
||||
|
||||
// Step 3: Insert text using CDP
|
||||
if (opts.verbose) logger.info(`⌨️ Inserting text: "${value}"`);
|
||||
|
||||
await browser.sendCommand('Input.insertText', {
|
||||
text: value
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Fill successful`);
|
||||
|
||||
// Wait for navigation and check errors
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return { success: true, selector, value };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`❌ Fill failed: ${selector}`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
await waitForActionComplete(browser, opts);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill input field with automatic retry using interaction map fallback.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
|
||||
* Uses CDP click + insertText for proper React compatibility.
|
||||
*
|
||||
* On failure, attempts to find alternative selectors from interaction map and retries.
|
||||
*/
|
||||
export async function fill(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
value: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
try {
|
||||
// First attempt with provided selector
|
||||
return await fillCore(browser, selector, value, options);
|
||||
} catch (error: unknown) {
|
||||
// Check if map file exists
|
||||
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
|
||||
if (!existsSync(mapPath)) {
|
||||
if (opts.verbose) {
|
||||
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info('🔄 Attempting to find alternative selectors from map...');
|
||||
}
|
||||
|
||||
// Try to find alternative selectors
|
||||
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
|
||||
|
||||
try {
|
||||
// If original selector looks like an ID, try querying by ID
|
||||
if (selector.startsWith('#')) {
|
||||
const id = selector.slice(1);
|
||||
fallbackResult = findSelectorWithFallback(mapPath, { id });
|
||||
}
|
||||
// If selector contains text in XPath format, extract and search
|
||||
else if (selector.includes('contains(text()')) {
|
||||
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
|
||||
if (textMatch && textMatch[1]) {
|
||||
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
|
||||
}
|
||||
}
|
||||
// For input fields, try to find by type
|
||||
else if (selector.includes('input')) {
|
||||
fallbackResult = findSelectorWithFallback(mapPath, { type: 'input' });
|
||||
}
|
||||
} catch (mapError: unknown) {
|
||||
if (opts.verbose) {
|
||||
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
|
||||
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
|
||||
}
|
||||
throw error; // Rethrow original error
|
||||
}
|
||||
|
||||
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
|
||||
if (opts.verbose) {
|
||||
logger.info('⚠️ No alternative selectors found in map.');
|
||||
}
|
||||
throw error; // Rethrow original error
|
||||
}
|
||||
|
||||
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
|
||||
const maxRetries = Math.min(
|
||||
fallbackResult.alternatives.length,
|
||||
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
|
||||
);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const altSelector = fallbackResult.alternatives[i];
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fillCore(browser, altSelector, value, options);
|
||||
} catch (_retryError: unknown) {
|
||||
if (i === maxRetries - 1) {
|
||||
// Last retry failed, throw original error
|
||||
if (opts.verbose) {
|
||||
logger.info('❌ All retry attempts exhausted.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Continue to next alternative
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but throw original error as fallback
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover over element.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* Uses CDP mouseMoved event for proper React compatibility.
|
||||
*/
|
||||
export async function hover(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Hovering: ${selector}`);
|
||||
|
||||
// Step 1: Find element and scroll into view
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
|
||||
// Scroll element into view
|
||||
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
|
||||
// Get bounding box and calculate center point
|
||||
const box = el.getBoundingClientRect();
|
||||
return {
|
||||
x: box.left + box.width / 2,
|
||||
y: box.top + box.height / 2,
|
||||
tag: el.tagName,
|
||||
text: el.textContent?.substring(0, 50) || '',
|
||||
visible: box.width > 0 && box.height > 0
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('❌ Element not found or error occurred');
|
||||
if (result.exceptionDetails) {
|
||||
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
}
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
|
||||
if (opts.verbose) {
|
||||
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
|
||||
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
|
||||
logger.info(`🖱️ Moving mouse to (${Math.round(x)}, ${Math.round(y)})`);
|
||||
}
|
||||
|
||||
// Step 2: Dispatch CDP mouse move event
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved',
|
||||
x,
|
||||
y
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Hover successful`);
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selector,
|
||||
coordinates: { x: Math.round(x), y: Math.round(y) },
|
||||
element: { tag, text }
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Hover failed: ${selector}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
await waitForActionComplete(browser, opts);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus element.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function focus(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Focusing: ${selector}`);
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
el.focus();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Focus successful`);
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return { success: true, selector };
|
||||
}
|
||||
|
||||
/**
|
||||
* Blur element.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function blur(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Blurring: ${selector}`);
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
el.blur();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Blur successful`);
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return { success: true, selector };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag and drop from one element to another.
|
||||
* Uses CDP mouse events for proper React/framework compatibility.
|
||||
*/
|
||||
export async function dragAndDrop(
|
||||
browser: ChromeBrowser,
|
||||
sourceSelector: string,
|
||||
targetSelector: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔍 Dragging ${sourceSelector} to ${targetSelector}`);
|
||||
|
||||
// Step 1: Get coordinates for both elements
|
||||
const script = `
|
||||
(function() {
|
||||
const sourceSelector = ${JSON.stringify(sourceSelector)};
|
||||
const targetSelector = ${JSON.stringify(targetSelector)};
|
||||
${getFindElementScript()}
|
||||
|
||||
const source = findElement(sourceSelector);
|
||||
const target = findElement(targetSelector);
|
||||
|
||||
if (!source) throw new Error('Source element not found: ' + sourceSelector);
|
||||
if (!target) throw new Error('Target element not found: ' + targetSelector);
|
||||
|
||||
// Scroll both into view
|
||||
source.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
const sourceRect = source.getBoundingClientRect();
|
||||
|
||||
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
source: {
|
||||
x: sourceRect.left + sourceRect.width / 2,
|
||||
y: sourceRect.top + sourceRect.height / 2,
|
||||
tag: source.tagName,
|
||||
text: source.textContent?.substring(0, 30) || ''
|
||||
},
|
||||
target: {
|
||||
x: targetRect.left + targetRect.width / 2,
|
||||
y: targetRect.top + targetRect.height / 2,
|
||||
tag: target.tagName,
|
||||
text: target.textContent?.substring(0, 30) || ''
|
||||
}
|
||||
};
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('❌ Element(s) not found');
|
||||
throw new Error('Could not find source or target element');
|
||||
}
|
||||
|
||||
const { source, target } = result.result.value as {
|
||||
source: { x: number; y: number; tag: string; text: string };
|
||||
target: { x: number; y: number; tag: string; text: string };
|
||||
};
|
||||
|
||||
if (opts.verbose) {
|
||||
logger.info(`✓ Source: <${source.tag.toLowerCase()}> "${source.text}" at (${Math.round(source.x)}, ${Math.round(source.y)})`);
|
||||
logger.info(`✓ Target: <${target.tag.toLowerCase()}> "${target.text}" at (${Math.round(target.x)}, ${Math.round(target.y)})`);
|
||||
}
|
||||
|
||||
// Step 2: Perform CDP drag operation
|
||||
if (opts.verbose) logger.info(`🖱️ Mouse down at source (${Math.round(source.x)}, ${Math.round(source.y)})`);
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x: source.x,
|
||||
y: source.y
|
||||
});
|
||||
|
||||
// Small delay to simulate drag start
|
||||
await sleep(TIMING.ACTION_DELAY_MEDIUM);
|
||||
|
||||
if (opts.verbose) logger.info(`🖱️ Dragging to target (${Math.round(target.x)}, ${Math.round(target.y)})`);
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved',
|
||||
button: 'left',
|
||||
x: target.x,
|
||||
y: target.y
|
||||
});
|
||||
|
||||
// Small delay before release
|
||||
await sleep(TIMING.ACTION_DELAY_MEDIUM);
|
||||
|
||||
if (opts.verbose) logger.info(`🖱️ Mouse up at target (${Math.round(target.x)}, ${Math.round(target.y)})`);
|
||||
|
||||
await browser.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
x: target.x,
|
||||
y: target.y
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Drag and drop successful`);
|
||||
await waitForActionComplete(browser, opts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceSelector,
|
||||
targetSelector,
|
||||
source: { x: Math.round(source.x), y: Math.round(source.y) },
|
||||
target: { x: Math.round(target.x), y: Math.round(target.y) }
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Drag and drop failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
await waitForActionComplete(browser, opts);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
217
skills/scripts/src/cdp/actions/navigation.ts
Normal file
217
skills/scripts/src/cdp/actions/navigation.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Navigation actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TIMING } from '../../constants';
|
||||
|
||||
/**
|
||||
* Navigate to URL.
|
||||
*/
|
||||
export async function navigate(
|
||||
browser: ChromeBrowser,
|
||||
url: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🧭 Navigating to: ${url}`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Page.navigate', { url });
|
||||
await sleep(TIMING.ACTION_DELAY_NAVIGATION); // Wait for initial page load
|
||||
|
||||
if (opts.verbose) logger.info(`✓ Page loaded: ${url}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return { success: true, url };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Navigation failed: ${url}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for page load complete.
|
||||
*/
|
||||
export async function waitForLoad(
|
||||
browser: ChromeBrowser,
|
||||
timeout = 30000,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for page load (timeout: ${timeout}ms)...`);
|
||||
|
||||
const script = `
|
||||
new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
const checkReady = () => {
|
||||
if (document.readyState === 'complete') {
|
||||
resolve(true);
|
||||
} else if (Date.now() - startTime > ${timeout}) {
|
||||
reject(new Error('Timeout waiting for page load'));
|
||||
} else {
|
||||
setTimeout(checkReady, ${TIMING.POLLING_INTERVAL_FAST});
|
||||
}
|
||||
};
|
||||
checkReady();
|
||||
})
|
||||
`;
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Page load complete`);
|
||||
|
||||
return { success: true, state: 'complete' };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Page load failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload page.
|
||||
*/
|
||||
export async function reload(
|
||||
browser: ChromeBrowser,
|
||||
hard = false,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🔄 Reloading page (hard: ${hard})...`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Page.reload', { ignoreCache: hard });
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Page reloaded`);
|
||||
|
||||
return { success: true, hardReload: hard };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Reload failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back in history.
|
||||
*/
|
||||
export async function goBack(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`◀️ Navigating back...`);
|
||||
|
||||
try {
|
||||
const history = await browser.sendCommand<{
|
||||
currentIndex: number;
|
||||
entries: Array<{ id: number; url: string; title: string }>;
|
||||
}>('Page.getNavigationHistory');
|
||||
const currentIndex = history.currentIndex || 0;
|
||||
|
||||
if (currentIndex > 0) {
|
||||
const previousEntry = history.entries[currentIndex - 1];
|
||||
await browser.sendCommand('Page.navigateToHistoryEntry', {
|
||||
entryId: previousEntry.id
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Navigated back to: ${previousEntry.url}`);
|
||||
|
||||
return { success: true, url: previousEntry.url };
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`⚠️ No previous page in history`);
|
||||
return { success: false, error: 'No previous page in history' };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Go back failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate forward in history.
|
||||
*/
|
||||
export async function goForward(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`▶️ Navigating forward...`);
|
||||
|
||||
try {
|
||||
const history = await browser.sendCommand<{
|
||||
currentIndex: number;
|
||||
entries: Array<{ id: number; url: string; title: string }>;
|
||||
}>('Page.getNavigationHistory');
|
||||
const currentIndex = history.currentIndex || 0;
|
||||
const totalEntries = history.entries?.length || 0;
|
||||
|
||||
if (currentIndex < totalEntries - 1) {
|
||||
const nextEntry = history.entries[currentIndex + 1];
|
||||
await browser.sendCommand('Page.navigateToHistoryEntry', {
|
||||
entryId: nextEntry.id
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Navigated forward to: ${nextEntry.url}`);
|
||||
|
||||
return { success: true, url: nextEntry.url };
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`⚠️ No next page in history`);
|
||||
return { success: false, error: 'No next page in history' };
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Go forward failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
194
skills/scripts/src/cdp/actions/network.ts
Normal file
194
skills/scripts/src/cdp/actions/network.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Network interception and mocking actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Set up network request interception.
|
||||
*/
|
||||
export async function enableRequestInterception(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🌐 Enabling network request interception...`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Fetch.enable', {
|
||||
patterns: [{ urlPattern: '*' }]
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Request interception enabled`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
note: 'Request interception enabled. Use interceptRequest() to handle requests.'
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Enable request interception failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable network request interception.
|
||||
*/
|
||||
export async function disableRequestInterception(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🌐 Disabling network request interception...`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Fetch.disable');
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Request interception disabled`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Disable request interception failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock a network request response.
|
||||
*/
|
||||
export async function mockRequest(
|
||||
browser: ChromeBrowser,
|
||||
urlPattern: string,
|
||||
responseBody: string,
|
||||
statusCode: number = 200,
|
||||
headers?: Record<string, string>,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🌐 Mocking request: ${urlPattern} -> ${statusCode}`);
|
||||
|
||||
try {
|
||||
// This is a simplified version - full implementation requires event handling
|
||||
await browser.sendCommand('Fetch.enable', {
|
||||
patterns: [{ urlPattern }]
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Mock configured for: ${urlPattern}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
urlPattern,
|
||||
statusCode,
|
||||
note: 'Mock configured. Use Fetch.continueRequest or Fetch.fulfillRequest in event handler.'
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Mock request failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block network requests matching pattern.
|
||||
*/
|
||||
export async function blockRequest(
|
||||
browser: ChromeBrowser,
|
||||
urlPattern: string,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🚫 Blocking requests matching: ${urlPattern}`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Network.enable');
|
||||
await browser.sendCommand('Network.setBlockedURLs', {
|
||||
urls: [urlPattern]
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Requests blocked: ${urlPattern}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
urlPattern,
|
||||
blocked: true
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Block request failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock all network requests.
|
||||
*/
|
||||
export async function unblockRequests(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`🌐 Unblocking all requests...`);
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Network.setBlockedURLs', {
|
||||
urls: []
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ All requests unblocked`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
blocked: false
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Unblock requests failed`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(` Error: ${error.message}`);
|
||||
} else {
|
||||
logger.error(` Error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
71
skills/scripts/src/cdp/actions/scroll.ts
Normal file
71
skills/scripts/src/cdp/actions/scroll.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Scroll actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Scroll page or element.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
* Note: x and y are both optional - you can scroll on just one axis if needed.
|
||||
*/
|
||||
export async function scroll(
|
||||
browser: ChromeBrowser,
|
||||
options?: { x?: number; y?: number; selector?: string } & ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
const x = options?.x ?? 0;
|
||||
const y = options?.y ?? 0;
|
||||
const selector = options?.selector;
|
||||
|
||||
if (opts.verbose) logger.info(`📜 Scrolling to (${x}, ${y})${selector ? ` on ${selector}` : ''}`);
|
||||
|
||||
const script = selector
|
||||
? `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const x = ${JSON.stringify(x)};
|
||||
const y = ${JSON.stringify(y)};
|
||||
${getFindElementScript()}
|
||||
const el = findElement(selector);
|
||||
if (!el) throw new Error('Element not found: ' + selector);
|
||||
el.scrollTo(x, y);
|
||||
return { x: el.scrollLeft, y: el.scrollTop };
|
||||
})()
|
||||
`
|
||||
: `
|
||||
(function() {
|
||||
const x = ${JSON.stringify(x)};
|
||||
const y = ${JSON.stringify(y)};
|
||||
window.scrollTo(x, y);
|
||||
return { x: window.scrollX, y: window.scrollY };
|
||||
})()
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Scrolled successfully`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
position: result.result?.value
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Scroll failed`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
checkErrors(browser, opts.logLevel);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
164
skills/scripts/src/cdp/actions/tabs.ts
Normal file
164
skills/scripts/src/cdp/actions/tabs.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tab management actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CDP } from '../../constants';
|
||||
|
||||
// Target interface (from CDP)
|
||||
interface Target {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new tab.
|
||||
*/
|
||||
export async function newTab(
|
||||
browser: ChromeBrowser,
|
||||
url = 'about:blank',
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`📑 Opening new tab: ${url}`);
|
||||
const result = await browser.sendCommand('Target.createTarget', { url });
|
||||
if (opts.verbose) logger.info(`✅ New tab created`);
|
||||
return {
|
||||
success: true,
|
||||
targetId: result.targetId,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tabs.
|
||||
*/
|
||||
export async function listTabs(
|
||||
browser: ChromeBrowser,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`📑 Listing all tabs...`);
|
||||
const debugPort = browser.debugPort;
|
||||
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
|
||||
const targets = await response.json() as Target[];
|
||||
|
||||
const pageTabs = targets
|
||||
.filter((t: Target) => t.type === 'page')
|
||||
.map((t: Target, index: number) => ({
|
||||
index,
|
||||
targetId: t.id,
|
||||
url: t.url,
|
||||
title: t.title
|
||||
}));
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Found ${pageTabs.length} tab(s)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tabs: pageTabs,
|
||||
count: pageTabs.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to tab.
|
||||
*/
|
||||
export async function switchTab(
|
||||
browser: ChromeBrowser,
|
||||
targetId?: string,
|
||||
index?: number,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
if (targetId) {
|
||||
logger.info(`📑 Switching to tab: ${targetId}`);
|
||||
} else if (index !== undefined) {
|
||||
logger.info(`📑 Switching to tab index: ${index}`);
|
||||
}
|
||||
}
|
||||
const debugPort = browser.debugPort;
|
||||
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
|
||||
const targets = await response.json() as Target[];
|
||||
|
||||
const pageTabs = targets.filter((t: Target) => t.type === 'page');
|
||||
let target: Target | undefined = undefined;
|
||||
|
||||
if (targetId) {
|
||||
target = pageTabs.find((t: Target) => t.id === targetId);
|
||||
} else if (index !== undefined) {
|
||||
target = pageTabs[index];
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
if (opts.verbose) logger.info(`❌ Target not found`);
|
||||
return { success: false, error: 'Target not found' };
|
||||
}
|
||||
|
||||
await browser.sendCommand('Target.activateTarget', { targetId: target.id });
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Switched to tab: ${target.title}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetId: target.id,
|
||||
url: target.url,
|
||||
title: target.title
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close tab.
|
||||
*/
|
||||
export async function closeTab(
|
||||
browser: ChromeBrowser,
|
||||
targetId?: string,
|
||||
index?: number,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) {
|
||||
if (targetId) {
|
||||
logger.info(`📑 Closing tab: ${targetId}`);
|
||||
} else if (index !== undefined) {
|
||||
logger.info(`📑 Closing tab index: ${index}`);
|
||||
}
|
||||
}
|
||||
const debugPort = browser.debugPort;
|
||||
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
|
||||
const targets = await response.json() as Target[];
|
||||
|
||||
const pageTabs = targets.filter((t: Target) => t.type === 'page');
|
||||
let target: Target | undefined = undefined;
|
||||
|
||||
if (targetId) {
|
||||
target = pageTabs.find((t: Target) => t.id === targetId);
|
||||
} else if (index !== undefined) {
|
||||
target = pageTabs[index];
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
if (opts.verbose) logger.info(`❌ Target not found`);
|
||||
return { success: false, error: 'Target not found' };
|
||||
}
|
||||
|
||||
await browser.sendCommand('Target.closeTarget', { targetId: target.id });
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Closed tab: ${target.title}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetId: target.id,
|
||||
message: `Closed tab: ${target.title}`
|
||||
};
|
||||
}
|
||||
248
skills/scripts/src/cdp/actions/verify.ts
Normal file
248
skills/scripts/src/cdp/actions/verify.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Verification utilities for browser actions
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { TIMING as GLOBAL_TIMING } from '../../constants';
|
||||
|
||||
// Timing constants for verification operations
|
||||
const TIMING = {
|
||||
DEFAULT_VERIFY_TIMEOUT: GLOBAL_TIMING.ACTION_DELAY_NAVIGATION,
|
||||
DOM_CHANGE_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
|
||||
NAVIGATION_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
|
||||
MIN_DOM_CHANGE_THRESHOLD: 10, // Minimum change in DOM size to consider significant
|
||||
} as const;
|
||||
|
||||
export interface VerifyOptions {
|
||||
checkDOMChange?: boolean; // Check for DOM mutations
|
||||
checkNavigation?: boolean; // Check for page navigation
|
||||
timeout?: number; // Wait timeout in ms (default: 1000)
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
success: boolean;
|
||||
reason?: string;
|
||||
domChanged?: boolean;
|
||||
navigated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element exists in the DOM
|
||||
*/
|
||||
export async function elementExists(
|
||||
browser: ChromeBrowser,
|
||||
selector: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
let element = null;
|
||||
|
||||
// Try XPath first
|
||||
if (selector.startsWith('//')) {
|
||||
const xpathResult = document.evaluate(
|
||||
selector,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
null
|
||||
);
|
||||
element = xpathResult.singleNodeValue;
|
||||
}
|
||||
// Try CSS selector
|
||||
else {
|
||||
element = document.querySelector(selector);
|
||||
}
|
||||
|
||||
return element !== null;
|
||||
})()
|
||||
`,
|
||||
returnByValue: true
|
||||
}) as { result?: { value?: boolean } };
|
||||
|
||||
return result?.result?.value === true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for DOM changes (using MutationObserver simulation)
|
||||
*/
|
||||
export async function waitForDOMChange(
|
||||
browser: ChromeBrowser,
|
||||
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Get initial DOM snapshot
|
||||
const initialSnapshot = await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: 'document.body.innerHTML.length',
|
||||
returnByValue: true
|
||||
}) as { result?: { value?: number } };
|
||||
const initialLength = initialSnapshot?.result?.value || 0;
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.DOM_CHANGE_CHECK_DELAY)));
|
||||
|
||||
// Get new DOM snapshot
|
||||
const finalSnapshot = await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: 'document.body.innerHTML.length',
|
||||
returnByValue: true
|
||||
}) as { result?: { value?: number } };
|
||||
const finalLength = finalSnapshot?.result?.value || 0;
|
||||
|
||||
// Check if DOM changed significantly
|
||||
return Math.abs(finalLength - initialLength) > TIMING.MIN_DOM_CHANGE_THRESHOLD;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for page navigation
|
||||
*/
|
||||
export async function checkNavigation(
|
||||
browser: ChromeBrowser,
|
||||
initialURL: string,
|
||||
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Wait a bit for navigation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.NAVIGATION_CHECK_DELAY)));
|
||||
|
||||
// Get current URL
|
||||
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
|
||||
targetId: (browser as unknown as { targetId: string }).targetId
|
||||
}) as { targetInfo: { url: string } };
|
||||
const currentURL = urlResult.targetInfo.url;
|
||||
|
||||
return currentURL !== initialURL;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that an action was successful
|
||||
*/
|
||||
export async function verifyAction(
|
||||
browser: ChromeBrowser,
|
||||
options: VerifyOptions = {}
|
||||
): Promise<VerificationResult> {
|
||||
const {
|
||||
checkDOMChange = true,
|
||||
checkNavigation: shouldCheckNavigation = true,
|
||||
timeout = TIMING.DEFAULT_VERIFY_TIMEOUT
|
||||
} = options;
|
||||
|
||||
const result: VerificationResult = {
|
||||
success: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Get initial URL
|
||||
let initialURL = '';
|
||||
if (shouldCheckNavigation) {
|
||||
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
|
||||
targetId: (browser as unknown as { targetId: string }).targetId
|
||||
}) as { targetInfo: { url: string } };
|
||||
initialURL = urlResult.targetInfo.url;
|
||||
}
|
||||
|
||||
// Check for DOM changes
|
||||
if (checkDOMChange) {
|
||||
result.domChanged = await waitForDOMChange(browser, timeout);
|
||||
}
|
||||
|
||||
// Check for navigation
|
||||
if (shouldCheckNavigation) {
|
||||
result.navigated = await checkNavigation(browser, initialURL, timeout);
|
||||
}
|
||||
|
||||
// Success if either DOM changed or navigation occurred
|
||||
result.success = !!(result.domChanged || result.navigated);
|
||||
|
||||
if (!result.success) {
|
||||
result.reason = 'No DOM changes or navigation detected';
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
reason: `Verification failed: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify element interactivity before action
|
||||
*/
|
||||
export async function verifyElementInteractive(
|
||||
browser: ChromeBrowser,
|
||||
selector: string
|
||||
): Promise<{ interactive: boolean; reason?: string }> {
|
||||
try {
|
||||
const result = await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
let element = null;
|
||||
|
||||
// Try XPath first
|
||||
if (selector.startsWith('//')) {
|
||||
const xpathResult = document.evaluate(
|
||||
selector,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
null
|
||||
);
|
||||
element = xpathResult.singleNodeValue;
|
||||
}
|
||||
// Try CSS selector
|
||||
else {
|
||||
element = document.querySelector(selector);
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
return { interactive: false, reason: 'Element not found' };
|
||||
}
|
||||
|
||||
// Check visibility
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return { interactive: false, reason: 'Element not visible' };
|
||||
}
|
||||
|
||||
// Check if disabled
|
||||
if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
|
||||
return { interactive: false, reason: 'Element is disabled' };
|
||||
}
|
||||
|
||||
// Check if element is in viewport
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
return { interactive: false, reason: 'Element has no size' };
|
||||
}
|
||||
|
||||
return { interactive: true };
|
||||
})()
|
||||
`,
|
||||
returnByValue: true
|
||||
}) as { result?: { value?: { interactive: boolean; reason?: string } } };
|
||||
|
||||
const value = result?.result?.value;
|
||||
if (value && typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return { interactive: false, reason: 'Verification failed' };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { interactive: false, reason: errorMessage };
|
||||
}
|
||||
}
|
||||
217
skills/scripts/src/cdp/actions/wait.ts
Normal file
217
skills/scripts/src/cdp/actions/wait.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Wait actions for Browser Pilot.
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../browser';
|
||||
import { getFindElementScript } from '../utils';
|
||||
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors, RuntimeEvaluateResult } from './helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TIMING, CDP } from '../../constants';
|
||||
|
||||
/**
|
||||
* Wait for specified milliseconds.
|
||||
*/
|
||||
export async function waitMilliseconds(
|
||||
browser: ChromeBrowser,
|
||||
ms: number,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for ${ms}ms...`);
|
||||
|
||||
await sleep(ms);
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Wait complete`);
|
||||
|
||||
return { success: true, waitedMs: ms };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for element to appear.
|
||||
* Supports both CSS selectors and XPath (when selector starts with '//').
|
||||
*/
|
||||
export async function waitFor(
|
||||
browser: ChromeBrowser,
|
||||
selector: string,
|
||||
timeout = TIMING.WAIT_FOR_NAVIGATION,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for: ${selector}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const script = `(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
${getFindElementScript()}
|
||||
return findElement(selector) !== null;
|
||||
})()`;
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (result.result?.value) {
|
||||
if (opts.verbose) logger.info(`✅ Element appeared: ${selector}`);
|
||||
checkErrors(browser, opts.logLevel);
|
||||
return { success: true, selector };
|
||||
}
|
||||
|
||||
await sleep(TIMING.POLLING_INTERVAL_FAST);
|
||||
}
|
||||
|
||||
if (opts.verbose) logger.info(`❌ Timeout waiting for: ${selector}`);
|
||||
throw new Error(`Timeout waiting for: ${selector}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for network to be idle.
|
||||
*/
|
||||
export async function waitForNetworkIdle(
|
||||
browser: ChromeBrowser,
|
||||
timeout: number = TIMING.WAIT_FOR_ELEMENT,
|
||||
_maxInflight = 0,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for network idle (timeout: ${timeout}ms)...`);
|
||||
|
||||
await browser.sendCommand('Network.enable');
|
||||
|
||||
const script = `
|
||||
new Promise((resolve) => {
|
||||
const waitForNavigationComplete = () => {
|
||||
if (performance.timing.loadEventEnd > 0) {
|
||||
setTimeout(() => resolve(true), ${timeout});
|
||||
} else {
|
||||
setTimeout(waitForNavigationComplete, ${TIMING.POLLING_INTERVAL_FAST});
|
||||
}
|
||||
};
|
||||
waitForNavigationComplete();
|
||||
})
|
||||
`;
|
||||
|
||||
try {
|
||||
await browser.sendCommand('Runtime.evaluate', {
|
||||
expression: script,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
if (opts.verbose) logger.info(`✅ Network idle`);
|
||||
|
||||
return { success: true, state: 'network_idle' };
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ Network idle wait failed`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for DOM to stabilize (no mutations for specified time).
|
||||
* Uses MutationObserver to detect when DOM changes stop.
|
||||
*/
|
||||
export async function waitForDomStable(
|
||||
browser: ChromeBrowser,
|
||||
stableTime: number = TIMING.NETWORK_IDLE_TIMEOUT,
|
||||
timeout: number = CDP.EVALUATION_TIMEOUT,
|
||||
options?: ActionOptions
|
||||
): Promise<ActionResult> {
|
||||
const opts = mergeOptions(options);
|
||||
|
||||
if (opts.verbose) logger.info(`⏳ Waiting for DOM to stabilize (stable: ${stableTime}ms, timeout: ${timeout}ms)...`);
|
||||
|
||||
const script = `
|
||||
new Promise((resolve, reject) => {
|
||||
const stableTime = ${stableTime};
|
||||
const timeout = ${timeout};
|
||||
let lastMutationTime = Date.now();
|
||||
let stabilityTimer = null;
|
||||
let timeoutTimer = null;
|
||||
|
||||
// Timeout handler
|
||||
timeoutTimer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve({ stable: false, reason: 'timeout' });
|
||||
}, timeout);
|
||||
|
||||
// Check if stable
|
||||
const checkStability = () => {
|
||||
const timeSinceLastMutation = Date.now() - lastMutationTime;
|
||||
if (timeSinceLastMutation >= stableTime) {
|
||||
clearTimeout(timeoutTimer);
|
||||
observer.disconnect();
|
||||
resolve({ stable: true, waitedMs: Date.now() - startTime });
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// MutationObserver to detect DOM changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Filter out trivial mutations (like class changes on same element)
|
||||
const significantMutations = mutations.filter(m => {
|
||||
// Ignore attribute changes unless they're critical
|
||||
if (m.type === 'attributes' && !['style', 'class'].includes(m.attributeName)) {
|
||||
return false;
|
||||
}
|
||||
// Count childList and subtree changes as significant
|
||||
return m.type === 'childList' || m.addedNodes.length > 0 || m.removedNodes.length > 0;
|
||||
});
|
||||
|
||||
if (significantMutations.length > 0) {
|
||||
lastMutationTime = Date.now();
|
||||
|
||||
// Reset stability timer
|
||||
if (stabilityTimer) clearTimeout(stabilityTimer);
|
||||
stabilityTimer = setTimeout(checkStability, stableTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
|
||||
// Initial stability check (in case DOM is already stable)
|
||||
stabilityTimer = setTimeout(checkStability, stableTime);
|
||||
})
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const data = result.result?.value as { stable: boolean; waitedMs?: number; reason?: string };
|
||||
|
||||
if (data.stable) {
|
||||
if (opts.verbose) logger.info(`✅ DOM stabilized (waited: ${data.waitedMs}ms)`);
|
||||
return { success: true, stable: true, waitedMs: data.waitedMs };
|
||||
} else {
|
||||
if (opts.verbose) logger.warn(`⚠️ DOM stabilization timeout (reason: ${data.reason})`);
|
||||
return { success: true, stable: false, reason: data.reason };
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (opts.verbose) {
|
||||
logger.error(`❌ DOM stability wait failed`);
|
||||
logger.error(` Error: ${errorMessage}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
580
skills/scripts/src/cdp/browser.ts
Normal file
580
skills/scripts/src/cdp/browser.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Chrome browser launcher and connection manager.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { homedir, platform } from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { CDPClient } from './client';
|
||||
import {
|
||||
getProjectConfig as _getProjectConfig,
|
||||
getProjectPort,
|
||||
updateProjectLastUsed,
|
||||
cleanupProjectIfNeeded,
|
||||
isPortAvailable
|
||||
} from './config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { TIMING, CDP } from '../constants';
|
||||
|
||||
interface Target {
|
||||
type: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
id?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// CDP Event Supporting Interfaces
|
||||
export interface StackTrace {
|
||||
callFrames?: Array<{
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
functionName?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RemoteObject {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Page Navigation Interfaces
|
||||
export interface PageNavigatedWithinDocumentPayload {
|
||||
frameId: string;
|
||||
url: string;
|
||||
navigationType: 'fragment' | 'historyApi' | 'other';
|
||||
}
|
||||
|
||||
// Console Message Interfaces
|
||||
export interface ConsoleMessage {
|
||||
level: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
stackTrace?: StackTrace;
|
||||
}
|
||||
|
||||
export interface FormattedConsoleMessage {
|
||||
level: string;
|
||||
text: string;
|
||||
timestamp: string; // ISO string format
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
// Network Error Interfaces
|
||||
export interface NetworkError {
|
||||
url: string;
|
||||
errorText: string;
|
||||
timestamp: number;
|
||||
statusCode?: number;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface NetworkFailedPayload {
|
||||
requestId: string;
|
||||
timestamp: number;
|
||||
type?: string;
|
||||
errorText: string;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkResponsePayload {
|
||||
requestId: string;
|
||||
response: {
|
||||
url: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Network Request Tracking Interface
|
||||
export interface NetworkRequestPayload {
|
||||
requestId: string;
|
||||
request: {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
timestamp: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// CDP Event Handler Type
|
||||
type CDPEventHandler =
|
||||
| ((params: LogEntryAddedPayload) => void)
|
||||
| ((params: ConsoleAPICalledPayload) => void)
|
||||
| ((params: ExceptionThrownPayload) => void)
|
||||
| ((params: NetworkRequestPayload) => void)
|
||||
| ((params: NetworkFailedPayload) => void)
|
||||
| ((params: NetworkResponsePayload) => void);
|
||||
|
||||
// CDP Event Payload Interfaces
|
||||
interface LogEntry {
|
||||
level?: 'verbose' | 'info' | 'warning' | 'error';
|
||||
text?: string;
|
||||
timestamp?: number;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
stackTrace?: StackTrace;
|
||||
}
|
||||
|
||||
interface LogEntryAddedPayload {
|
||||
entry: LogEntry;
|
||||
}
|
||||
|
||||
interface ConsoleAPICalledPayload {
|
||||
type?: string;
|
||||
args?: RemoteObject[];
|
||||
timestamp?: number;
|
||||
stackTrace?: StackTrace;
|
||||
}
|
||||
|
||||
interface ExceptionDetails {
|
||||
exception?: {
|
||||
description?: string;
|
||||
};
|
||||
text?: string;
|
||||
timestamp?: number;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
stackTrace?: StackTrace;
|
||||
}
|
||||
|
||||
interface ExceptionThrownPayload {
|
||||
exceptionDetails: ExceptionDetails;
|
||||
}
|
||||
|
||||
export class ChromeBrowser {
|
||||
private readonly headless: boolean;
|
||||
public debugPort: number | null = null;
|
||||
private chromeProcess: ChildProcess | null = null;
|
||||
public client: CDPClient | null = null;
|
||||
private consoleMessages: ConsoleMessage[] = [];
|
||||
private networkErrors: NetworkError[] = [];
|
||||
private readonly MAX_CONSOLE_MESSAGES = TIMING.MAP_CACHE_TTL / 600; // 1000 messages (10min / 600ms)
|
||||
private readonly MAX_NETWORK_ERRORS = TIMING.MAP_CACHE_TTL / 600; // 1000 errors
|
||||
private pendingRequests: Map<string, { url: string; timestamp: number; processed: boolean }> = new Map();
|
||||
private readonly REQUEST_TIMEOUT = TIMING.DAEMON_IDLE_TIMEOUT / 30; // ~60 seconds
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private eventListeners: Map<string, CDPEventHandler> = new Map();
|
||||
|
||||
constructor(headless = false) {
|
||||
this.headless = headless;
|
||||
// Debug port will be loaded from shared config in launch/connect methods
|
||||
}
|
||||
|
||||
/**
|
||||
* Add console message with size limit to prevent memory issues.
|
||||
*/
|
||||
private addConsoleMessage(message: ConsoleMessage): void {
|
||||
this.consoleMessages.push(message);
|
||||
|
||||
// Keep only the most recent messages
|
||||
if (this.consoleMessages.length > this.MAX_CONSOLE_MESSAGES) {
|
||||
this.consoleMessages = this.consoleMessages.slice(-this.MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add network error with size limit to prevent memory issues.
|
||||
*/
|
||||
private addNetworkError(error: NetworkError): void {
|
||||
this.networkErrors.push(error);
|
||||
|
||||
// Keep only the most recent errors
|
||||
if (this.networkErrors.length > this.MAX_NETWORK_ERRORS) {
|
||||
this.networkErrors = this.networkErrors.slice(-this.MAX_NETWORK_ERRORS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale pending requests to prevent memory leak.
|
||||
*/
|
||||
private cleanupStaleRequests(): void {
|
||||
const now = Date.now();
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
if (request.processed || (now - request.timestamp > this.REQUEST_TIMEOUT)) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Chrome executable path.
|
||||
*/
|
||||
private getChromePath(): string {
|
||||
const system = platform();
|
||||
let paths: string[] = [];
|
||||
|
||||
if (system === 'win32') {
|
||||
paths = [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
join(homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe')
|
||||
];
|
||||
} else if (system === 'darwin') {
|
||||
paths = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
];
|
||||
} else {
|
||||
paths = [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium'
|
||||
];
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Chrome not found. Please install Google Chrome.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to already running Chrome instance.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
// Get project port from shared config
|
||||
const port = await getProjectPort();
|
||||
|
||||
// Check if the port is in use (browser running)
|
||||
const portAvailable = await isPortAvailable(port);
|
||||
|
||||
if (!portAvailable) {
|
||||
// Port is in use, browser is running
|
||||
this.debugPort = port;
|
||||
logger.info(`Connecting to existing Chrome on port ${this.debugPort}...`);
|
||||
await this.connectToPage();
|
||||
updateProjectLastUsed();
|
||||
return;
|
||||
}
|
||||
|
||||
// No running browser found
|
||||
throw new Error(`No running browser found on port ${port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Chrome in debugging mode.
|
||||
* @param initialUrl - Optional initial URL to open (defaults to about:blank)
|
||||
*/
|
||||
async launch(initialUrl?: string): Promise<void> {
|
||||
// Get project port from shared config (auto-creates if not exists)
|
||||
this.debugPort = await getProjectPort();
|
||||
|
||||
const chromePath = this.getChromePath();
|
||||
const args = [
|
||||
`--remote-debugging-port=${this.debugPort}`,
|
||||
'--remote-allow-origins=*',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
`--user-data-dir=${join(homedir(), `.cdp_browser_profile_${this.debugPort}`)}`
|
||||
];
|
||||
|
||||
if (this.headless) {
|
||||
args.push('--headless=new', '--disable-gpu');
|
||||
}
|
||||
|
||||
// Add initial URL or default to blank page
|
||||
if (initialUrl) {
|
||||
args.push(initialUrl);
|
||||
logger.info(`Launching Chrome with initial URL: ${initialUrl}`);
|
||||
} else {
|
||||
args.push('about:blank');
|
||||
}
|
||||
|
||||
logger.info(`Launching Chrome on port ${this.debugPort} (headless: ${this.headless})...`);
|
||||
|
||||
this.chromeProcess = spawn(chromePath, args, {
|
||||
stdio: 'ignore',
|
||||
detached: true
|
||||
});
|
||||
|
||||
// Detach the process so it continues running when Node exits
|
||||
this.chromeProcess.unref();
|
||||
|
||||
// Update last used timestamp
|
||||
updateProjectLastUsed();
|
||||
|
||||
// Wait for Chrome to be ready by polling the JSON endpoint
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20; // 10 seconds (20 * 500ms)
|
||||
let connected = false;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await fetch(`http://${CDP.LOCALHOST}:${this.debugPort}/json/version`);
|
||||
if (response.ok) {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Connection may be refused while browser is starting up
|
||||
}
|
||||
attempts++;
|
||||
await this.sleep(TIMING.NETWORK_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
throw new Error(`Failed to connect to Chrome within the timeout period (${maxAttempts * TIMING.NETWORK_IDLE_TIMEOUT / TIMING.ACTION_DELAY_NAVIGATION} seconds).`);
|
||||
}
|
||||
|
||||
// Connect to page target
|
||||
await this.connectToPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a Chrome page target.
|
||||
*/
|
||||
private async connectToPage(): Promise<void> {
|
||||
try {
|
||||
// Get list of targets
|
||||
const url = `http://${CDP.LOCALHOST}:${this.debugPort}/json`;
|
||||
const response = await fetch(url);
|
||||
const targets = await response.json() as Target[];
|
||||
|
||||
// Find or create a page target
|
||||
let pageTarget = targets.find(t => t.type === 'page');
|
||||
|
||||
if (!pageTarget) {
|
||||
// Create new target
|
||||
const newUrl = `http://${CDP.LOCALHOST}:${this.debugPort}/json/new`;
|
||||
const newResponse = await fetch(newUrl);
|
||||
pageTarget = await newResponse.json() as Target;
|
||||
}
|
||||
|
||||
const wsUrl = pageTarget.webSocketDebuggerUrl;
|
||||
|
||||
logger.info(`Connecting to: ${wsUrl}`);
|
||||
this.client = new CDPClient(wsUrl);
|
||||
await this.client.connect();
|
||||
logger.info('Connected to Chrome DevTools Protocol');
|
||||
|
||||
// Enable Log domain to receive console messages
|
||||
await this.client.sendCommand('Log.enable');
|
||||
await this.client.sendCommand('Runtime.enable');
|
||||
// Enable Network domain to track network errors
|
||||
await this.client.sendCommand('Network.enable');
|
||||
|
||||
// Set up event listeners with references for cleanup
|
||||
const logEntryHandler = (params: LogEntryAddedPayload) => {
|
||||
const entry = params.entry;
|
||||
this.addConsoleMessage({
|
||||
level: entry.level || 'log',
|
||||
text: entry.text || '',
|
||||
timestamp: entry.timestamp || Date.now(),
|
||||
url: entry.url,
|
||||
lineNumber: entry.lineNumber,
|
||||
stackTrace: entry.stackTrace
|
||||
});
|
||||
};
|
||||
|
||||
const consoleApiHandler = (params: ConsoleAPICalledPayload) => {
|
||||
const args = params.args || [];
|
||||
const text = args.map((arg: RemoteObject) => arg.value || arg.description || '').join(' ');
|
||||
|
||||
this.addConsoleMessage({
|
||||
level: params.type || 'log',
|
||||
text: text,
|
||||
timestamp: params.timestamp || Date.now(),
|
||||
url: params.stackTrace?.callFrames?.[0]?.url,
|
||||
lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber
|
||||
});
|
||||
};
|
||||
|
||||
const exceptionHandler = (params: ExceptionThrownPayload) => {
|
||||
const exception = params.exceptionDetails;
|
||||
const text = exception.exception?.description || exception.text || 'Unknown error';
|
||||
|
||||
this.addConsoleMessage({
|
||||
level: 'error',
|
||||
text: text,
|
||||
timestamp: exception.timestamp || Date.now(),
|
||||
url: exception.url,
|
||||
lineNumber: exception.lineNumber,
|
||||
stackTrace: exception.stackTrace
|
||||
});
|
||||
};
|
||||
|
||||
const requestWillBeSentHandler = (params: NetworkRequestPayload) => {
|
||||
this.pendingRequests.set(params.requestId, {
|
||||
url: params.request.url,
|
||||
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
|
||||
processed: false
|
||||
});
|
||||
};
|
||||
|
||||
const loadingFailedHandler = (params: NetworkFailedPayload) => {
|
||||
const request = this.pendingRequests.get(params.requestId);
|
||||
if (request && !request.processed && !params.canceled) {
|
||||
this.addNetworkError({
|
||||
url: request.url,
|
||||
errorText: params.errorText,
|
||||
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
|
||||
requestId: params.requestId
|
||||
});
|
||||
request.processed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const responseReceivedHandler = (params: NetworkResponsePayload) => {
|
||||
const { requestId, response } = params;
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
|
||||
if (request && !request.processed && response.status >= 400) {
|
||||
this.addNetworkError({
|
||||
url: response.url,
|
||||
errorText: `HTTP ${response.status} ${response.statusText}`,
|
||||
timestamp: Date.now(),
|
||||
statusCode: response.status,
|
||||
requestId: requestId
|
||||
});
|
||||
request.processed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Register event listeners
|
||||
this.client.on('Log.entryAdded', logEntryHandler);
|
||||
this.eventListeners.set('Log.entryAdded', logEntryHandler);
|
||||
|
||||
this.client.on('Runtime.consoleAPICalled', consoleApiHandler);
|
||||
this.eventListeners.set('Runtime.consoleAPICalled', consoleApiHandler);
|
||||
|
||||
this.client.on('Runtime.exceptionThrown', exceptionHandler);
|
||||
this.eventListeners.set('Runtime.exceptionThrown', exceptionHandler);
|
||||
|
||||
this.client.on('Network.requestWillBeSent', requestWillBeSentHandler);
|
||||
this.eventListeners.set('Network.requestWillBeSent', requestWillBeSentHandler);
|
||||
|
||||
this.client.on('Network.loadingFailed', loadingFailedHandler);
|
||||
this.eventListeners.set('Network.loadingFailed', loadingFailedHandler);
|
||||
|
||||
this.client.on('Network.responseReceived', responseReceivedHandler);
|
||||
this.eventListeners.set('Network.responseReceived', responseReceivedHandler);
|
||||
|
||||
// Start periodic cleanup of stale requests
|
||||
this.cleanupInterval = setInterval(() => this.cleanupStaleRequests(), TIMING.POLLING_INTERVAL_SLOW * 10);
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to connect to Chrome: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CDP command.
|
||||
*/
|
||||
async sendCommand<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown
|
||||
): Promise<T> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected to Chrome');
|
||||
}
|
||||
return this.client.sendCommand<T>(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected console messages.
|
||||
*/
|
||||
getConsoleMessages(): ConsoleMessage[] {
|
||||
return [...this.consoleMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear console messages buffer.
|
||||
*/
|
||||
clearConsoleMessages(): void {
|
||||
this.consoleMessages = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected network errors.
|
||||
*/
|
||||
getNetworkErrors(): NetworkError[] {
|
||||
return [...this.networkErrors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear network errors buffer.
|
||||
*/
|
||||
clearNetworkErrors(): void {
|
||||
this.networkErrors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Close browser and cleanup.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
logger.info('Closing browser...');
|
||||
|
||||
// Stop cleanup interval
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
// Remove all event listeners
|
||||
for (const [event, handler] of this.eventListeners.entries()) {
|
||||
this.client?.off(event, handler);
|
||||
}
|
||||
this.eventListeners.clear();
|
||||
|
||||
if (this.client) {
|
||||
try {
|
||||
// Send Browser.close command to gracefully close the browser
|
||||
await this.client.sendCommand('Browser.close');
|
||||
logger.info('Browser closed via CDP command');
|
||||
} catch (_error) {
|
||||
logger.info('Could not close browser via CDP, it may already be closed');
|
||||
}
|
||||
|
||||
// Close WebSocket connection
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
// Force kill Chrome process if it's still running
|
||||
if (this.chromeProcess) {
|
||||
try {
|
||||
// Check if process is still alive
|
||||
if (!this.chromeProcess.killed) {
|
||||
this.chromeProcess.kill('SIGTERM');
|
||||
logger.info('Chrome process terminated (SIGTERM)');
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
await this.sleep(1000);
|
||||
|
||||
// Force kill if still alive
|
||||
if (!this.chromeProcess.killed) {
|
||||
this.chromeProcess.kill('SIGKILL');
|
||||
logger.info('Chrome process force-killed (SIGKILL)');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to kill Chrome process: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
this.chromeProcess = null;
|
||||
}
|
||||
|
||||
// Clear pending requests
|
||||
this.pendingRequests.clear();
|
||||
|
||||
// Clean up project config if autoCleanup is enabled
|
||||
cleanupProjectIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds.
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
144
skills/scripts/src/cdp/client.ts
Normal file
144
skills/scripts/src/cdp/client.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* CDP WebSocket Client for Chrome DevTools Protocol communication.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface CDPMessage {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface CDPResponse {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CDPEvent {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export class CDPClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageId = 0;
|
||||
private readonly wsUrl: string;
|
||||
|
||||
constructor(wsUrl: string) {
|
||||
super();
|
||||
this.wsUrl = wsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Chrome via WebSocket.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
// Set up global message handler for CDP events
|
||||
if (this.ws) {
|
||||
this.ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
// CDP events don't have 'id' field, only 'method' and 'params'
|
||||
if (!message.id && message.method) {
|
||||
this.emit('event', message as CDPEvent);
|
||||
this.emit(message.method, message.params);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CDP command and wait for response.
|
||||
*/
|
||||
async sendCommand<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown
|
||||
): Promise<T> {
|
||||
if (!this.ws) {
|
||||
throw new Error('Not connected to Chrome');
|
||||
}
|
||||
|
||||
this.messageId++;
|
||||
const message: CDPMessage = {
|
||||
id: this.messageId,
|
||||
method,
|
||||
params: params || {}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentMessageId = this.messageId;
|
||||
|
||||
const cleanup = () => {
|
||||
this.ws?.removeListener('message', messageHandler);
|
||||
};
|
||||
|
||||
const messageHandler = (data: WebSocket.Data) => {
|
||||
try {
|
||||
const response: CDPResponse = JSON.parse(data.toString());
|
||||
|
||||
if (response.id === currentMessageId) {
|
||||
cleanup();
|
||||
|
||||
if (response.error) {
|
||||
reject(new Error(`CDP Error: ${JSON.stringify(response.error)}`));
|
||||
} else {
|
||||
resolve((response.result || {}) as T);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore parse errors for other messages
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.ws) {
|
||||
reject(new Error('WebSocket connection lost'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.on('message', messageHandler);
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket connection.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch (_error) {
|
||||
// Ignore close errors
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
329
skills/scripts/src/cdp/config.ts
Normal file
329
skills/scripts/src/cdp/config.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Configuration management for browser debugging port and state.
|
||||
* Uses a shared config file in the plugin folder for multi-project support.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { createServer } from 'net';
|
||||
import { findProjectRoot } from './utils';
|
||||
import { CDP, FS } from '../constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface ProjectConfig {
|
||||
rootPath: string;
|
||||
port: number;
|
||||
outputDir: string;
|
||||
lastUsed: string | null;
|
||||
autoCleanup: boolean;
|
||||
autoRestore?: boolean; // Auto-restore last visited URL (default: true)
|
||||
}
|
||||
|
||||
export interface SharedBrowserPilotConfig {
|
||||
projects: {
|
||||
[projectName: string]: ProjectConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local timestamp string (same format as logger)
|
||||
* Format: YYYY-MM-DD HH:MM:SS.mmm
|
||||
*/
|
||||
function getLocalTimestamp(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared config file path in plugin skills folder
|
||||
* Uses hardcoded home directory path for reliability
|
||||
*/
|
||||
function getSharedConfigPath(): string {
|
||||
const { homedir } = require('os');
|
||||
const homeDir = homedir();
|
||||
return join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'dev-gom-plugins',
|
||||
'plugins',
|
||||
'browser-pilot',
|
||||
'skills',
|
||||
'browser-pilot-config.json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project name from root folder name
|
||||
*/
|
||||
function getProjectName(projectRoot: string): string {
|
||||
return basename(projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output directory for the current project
|
||||
* Creates .browser-pilot folder in project root
|
||||
*/
|
||||
export function getOutputDir(): string {
|
||||
const projectRoot = findProjectRoot();
|
||||
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
|
||||
|
||||
// Ensure .browser-pilot directory exists
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Always ensure .gitignore exists in .browser-pilot
|
||||
const gitignorePath = join(outputDir, '.gitignore');
|
||||
if (!existsSync(gitignorePath)) {
|
||||
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
|
||||
}
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load shared configuration from plugin folder
|
||||
* Auto-creates default config if not exists
|
||||
*/
|
||||
export function loadSharedConfig(): SharedBrowserPilotConfig {
|
||||
const configPath = getSharedConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
// Auto-create default config
|
||||
const defaultConfig: SharedBrowserPilotConfig = {
|
||||
projects: {}
|
||||
};
|
||||
saveSharedConfig(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load shared config', error);
|
||||
logger.warn('Returning empty config - existing project settings may be lost');
|
||||
logger.warn(`Config path: ${configPath}`);
|
||||
return {
|
||||
projects: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save shared configuration to plugin folder
|
||||
*/
|
||||
export function saveSharedConfig(config: SharedBrowserPilotConfig): void {
|
||||
const configPath = getSharedConfigPath();
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save shared config', error);
|
||||
logger.warn(`Config path: ${configPath}`);
|
||||
throw new Error('Configuration save failed. Please check file permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for current project
|
||||
* Auto-creates with available port if not exists
|
||||
*/
|
||||
export async function getProjectConfig(): Promise<ProjectConfig> {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
// Find existing config by rootPath (in case name changed)
|
||||
const existingEntry = Object.entries(sharedConfig.projects).find(
|
||||
([_, config]) => config.rootPath === projectRoot
|
||||
);
|
||||
|
||||
if (existingEntry) {
|
||||
const [existingName, config] = existingEntry;
|
||||
|
||||
// If name changed, update key
|
||||
if (existingName !== projectName) {
|
||||
delete sharedConfig.projects[existingName];
|
||||
sharedConfig.projects[projectName] = config;
|
||||
saveSharedConfig(sharedConfig);
|
||||
logger.info(`📝 Updated project name: ${existingName} → ${projectName}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Check if name already exists (different path)
|
||||
if (sharedConfig.projects[projectName]) {
|
||||
logger.warn(`⚠️ Project name "${projectName}" already exists with different path`);
|
||||
logger.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
|
||||
logger.warn(` Current: ${projectRoot}`);
|
||||
throw new Error(`Project name conflict: "${projectName}"`);
|
||||
}
|
||||
|
||||
// Create new project config with available port
|
||||
const basePort = parseInt(process.env.CDP_DEBUG_PORT || String(CDP.DEFAULT_PORT));
|
||||
|
||||
// Find next available port that's not used by any project
|
||||
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
|
||||
let port = basePort;
|
||||
|
||||
// Find first available port not in use by other projects
|
||||
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
|
||||
port++;
|
||||
if (port > basePort + CDP.PORT_RANGE_MAX) {
|
||||
throw new Error(`No available port found in range ${basePort}-${basePort + CDP.PORT_RANGE_MAX}`);
|
||||
}
|
||||
}
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
rootPath: projectRoot,
|
||||
port,
|
||||
outputDir: FS.OUTPUT_DIR,
|
||||
lastUsed: getLocalTimestamp(),
|
||||
autoCleanup: false // Default to false for safety
|
||||
};
|
||||
|
||||
// Save new project config
|
||||
sharedConfig.projects[projectName] = projectConfig;
|
||||
saveSharedConfig(sharedConfig);
|
||||
|
||||
logger.info(`📝 Created config for project: ${projectName}`);
|
||||
logger.info(` Path: ${projectRoot}`);
|
||||
logger.info(` Port: ${port}`);
|
||||
|
||||
return projectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last used timestamp for current project
|
||||
*/
|
||||
export function updateProjectLastUsed(): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
if (sharedConfig.projects[projectName]) {
|
||||
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
|
||||
saveSharedConfig(sharedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug port for current project
|
||||
*/
|
||||
export async function getProjectPort(): Promise<number> {
|
||||
const config = await getProjectConfig();
|
||||
return config.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up project config if autoCleanup is enabled
|
||||
*/
|
||||
export function cleanupProjectIfNeeded(): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
const projectConfig = sharedConfig.projects[projectName];
|
||||
if (projectConfig && projectConfig.autoCleanup) {
|
||||
delete sharedConfig.projects[projectName];
|
||||
saveSharedConfig(sharedConfig);
|
||||
logger.info(`🗑️ Auto-cleaned config for project: ${projectName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set autoCleanup flag for current project
|
||||
*/
|
||||
export function setAutoCleanup(enabled: boolean): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
if (sharedConfig.projects[projectName]) {
|
||||
sharedConfig.projects[projectName].autoCleanup = enabled;
|
||||
saveSharedConfig(sharedConfig);
|
||||
logger.info(`${enabled ? '✅' : '❌'} Auto-cleanup ${enabled ? 'enabled' : 'disabled'} for: ${projectName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset configuration for current project
|
||||
*/
|
||||
export function resetProjectConfig(): void {
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = getProjectName(projectRoot);
|
||||
const sharedConfig = loadSharedConfig();
|
||||
|
||||
delete sharedConfig.projects[projectName];
|
||||
saveSharedConfig(sharedConfig);
|
||||
|
||||
logger.info(`🗑️ Removed config for project: ${projectName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured projects
|
||||
*/
|
||||
export function listProjects(): void {
|
||||
const sharedConfig = loadSharedConfig();
|
||||
const projects = Object.entries(sharedConfig.projects);
|
||||
|
||||
if (projects.length === 0) {
|
||||
logger.info('No projects configured yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`\n📋 Configured Projects (${projects.length}):\n`);
|
||||
projects.forEach(([name, config]) => {
|
||||
logger.info(` ${name}`);
|
||||
logger.info(` ├─ Path: ${config.rootPath}`);
|
||||
logger.info(` ├─ Port: ${config.port}`);
|
||||
logger.info(` ├─ Output: ${config.outputDir}`);
|
||||
logger.info(` ├─ Auto-cleanup: ${config.autoCleanup ? 'Yes' : 'No'}`);
|
||||
logger.info(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is available (not in use)
|
||||
*/
|
||||
export async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
// Listen on 127.0.0.1 specifically (same as Chrome)
|
||||
server.listen(port, CDP.LOCALHOST);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from startPort
|
||||
*/
|
||||
export async function findAvailablePort(startPort = CDP.DEFAULT_PORT, maxAttempts = 10): Promise<number> {
|
||||
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
||||
}
|
||||
309
skills/scripts/src/cdp/map/generate-interaction-map.ts
Normal file
309
skills/scripts/src/cdp/map/generate-interaction-map.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Browser script to generate interaction map.
|
||||
* This script runs in the browser context via Runtime.evaluate.
|
||||
*/
|
||||
|
||||
export interface InteractionElement {
|
||||
id: string;
|
||||
type: string;
|
||||
tag: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
selectors: {
|
||||
byText?: string; // Text-based XPath: //*[contains(text(), '...')]
|
||||
byId?: string; // ID selector: #button-id
|
||||
byCSS?: string; // CSS selector: button.class-name
|
||||
byRole?: string; // Role-based: button, input, etc.
|
||||
byAriaLabel?: string; // ARIA label: [aria-label="..."]
|
||||
};
|
||||
attributes: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
visibility: {
|
||||
inViewport: boolean;
|
||||
visible: boolean;
|
||||
obscured: boolean;
|
||||
};
|
||||
context?: {
|
||||
parent?: string;
|
||||
section?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the browser script that finds all interactive elements.
|
||||
* Returns a string that can be executed via Runtime.evaluate.
|
||||
*/
|
||||
export function getInteractionMapScript(): string {
|
||||
return `
|
||||
(function() {
|
||||
const elements = [];
|
||||
let idCounter = 0;
|
||||
|
||||
// Helper: Escape text for XPath (handles both single and double quotes)
|
||||
function escapeXPath(text) {
|
||||
// If no quotes, use single quotes
|
||||
if (!text.includes("'") && !text.includes('"')) {
|
||||
return "'" + text + "'";
|
||||
}
|
||||
// If only single quotes, use double quotes
|
||||
if (text.includes("'") && !text.includes('"')) {
|
||||
return '"' + text + '"';
|
||||
}
|
||||
// If only double quotes, use single quotes
|
||||
if (!text.includes("'") && text.includes('"')) {
|
||||
return "'" + text + "'";
|
||||
}
|
||||
// Both present: use concat()
|
||||
const parts = text.split("'");
|
||||
const escaped = parts.map((part, i) => {
|
||||
if (i === 0) return "'" + part + "'";
|
||||
return "\\"'\\"," + "'" + part + "'";
|
||||
}).join(',');
|
||||
return "concat(" + escaped + ")";
|
||||
}
|
||||
|
||||
// Helper: Generate Browser Pilot compatible selectors
|
||||
function getBrowserPilotSelectors(el) {
|
||||
const selectors = {};
|
||||
|
||||
// 1. Text-based XPath (most stable for Browser Pilot)
|
||||
const text = el.textContent?.trim();
|
||||
if (text && text.length > 0 && text.length <= 50) {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
selectors.byText = "//" + tagName + "[contains(text(), " + escapeXPath(text) + ")]";
|
||||
}
|
||||
|
||||
// 2. ID selector (best if available)
|
||||
if (el.id) {
|
||||
selectors.byId = '#' + el.id;
|
||||
}
|
||||
|
||||
// 3. CSS selector (with safe classes only)
|
||||
if (el.className) {
|
||||
const className = typeof el.className === 'string'
|
||||
? el.className
|
||||
: (el.className.baseVal || '');
|
||||
|
||||
if (className && className.trim) {
|
||||
const classes = className.trim().split(/\\s+/)
|
||||
.filter(cls => /^[a-zA-Z0-9_-]+$/.test(cls))
|
||||
.slice(0, 3);
|
||||
|
||||
if (classes.length > 0) {
|
||||
selectors.byCSS = el.tagName.toLowerCase() + '.' + classes.join('.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback CSS selector (tag only)
|
||||
if (!selectors.byCSS) {
|
||||
selectors.byCSS = el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
// 4. Role-based selector
|
||||
const role = el.getAttribute('role');
|
||||
if (role) {
|
||||
selectors.byRole = '[role="' + role + '"]';
|
||||
}
|
||||
|
||||
// 5. ARIA label selector
|
||||
const ariaLabel = el.getAttribute('aria-label');
|
||||
if (ariaLabel) {
|
||||
// For CSS selectors, escape both quotes properly
|
||||
const escapedLabel = ariaLabel.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'").replace(/"/g, '\\\\"');
|
||||
selectors.byAriaLabel = "[aria-label='" + escapedLabel + "']";
|
||||
}
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
// Helper: Check if element is actually visible and interactive
|
||||
function isInteractive(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Check visibility
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check size
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check pointer events (but allow standard interactive elements even if disabled)
|
||||
const isStandardInteractive = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'A'].includes(el.tagName);
|
||||
if (style.pointerEvents === 'none' && !isStandardInteractive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper: Check if element is obscured
|
||||
function isObscured(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const topElement = document.elementFromPoint(centerX, centerY);
|
||||
|
||||
return topElement !== el && !el.contains(topElement);
|
||||
}
|
||||
|
||||
// Helper: Get parent context
|
||||
function getContext(el) {
|
||||
const context = {};
|
||||
|
||||
// Find parent with aria-label or role
|
||||
let parent = el.parentElement;
|
||||
while (parent && parent !== document.body) {
|
||||
if (parent.getAttribute('aria-label')) {
|
||||
context.section = parent.getAttribute('aria-label');
|
||||
break;
|
||||
}
|
||||
if (parent.getAttribute('role') === 'group' || parent.getAttribute('role') === 'region') {
|
||||
context.parent = parent.tagName.toLowerCase();
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// Helper: Check if element has React/Vue event handlers
|
||||
function hasEventHandlers(el) {
|
||||
// React event handlers
|
||||
if (el._reactProps?.onClick) return true;
|
||||
if (el.__reactEventHandlers$?.onClick) return true;
|
||||
|
||||
// Check for React fiber props
|
||||
const keys = Object.keys(el);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (key.startsWith('__reactProps') || key.startsWith('__reactEventHandlers')) {
|
||||
const props = el[key];
|
||||
if (props && props.onClick) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find all interactive elements
|
||||
const selectors = [
|
||||
// Standard interactive elements
|
||||
'button',
|
||||
'a[href]',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
|
||||
// ARIA roles (only interactive ones)
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="radiogroup"]',
|
||||
'[role="combobox"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="switch"]',
|
||||
'[role="slider"]',
|
||||
|
||||
// Event handlers
|
||||
'[onclick]',
|
||||
'[onmousedown]',
|
||||
'[onmouseup]'
|
||||
];
|
||||
|
||||
const foundElements = new Set();
|
||||
|
||||
// Collect elements by selector
|
||||
selectors.forEach(selector => {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
if (!foundElements.has(el) && isInteractive(el)) {
|
||||
foundElements.add(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Additional: Find clickable elements by cursor:pointer or React handlers
|
||||
// Check ALL elements for cursor:pointer or event handlers
|
||||
document.querySelectorAll('*').forEach(el => {
|
||||
if (foundElements.has(el)) return;
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
|
||||
// Include if has cursor:pointer AND is interactive
|
||||
if (style.cursor === 'pointer' && isInteractive(el)) {
|
||||
foundElements.add(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// Include if has React event handlers (only check button-like classes for performance)
|
||||
if (el.className && typeof el.className === 'string') {
|
||||
const hasButtonClass = /button|btn|card|item|clickable/.test(el.className);
|
||||
if (hasButtonClass && hasEventHandlers(el) && isInteractive(el)) {
|
||||
foundElements.add(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Process found elements
|
||||
Array.from(foundElements).forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Calculate absolute position (viewport coordinates + scroll offset)
|
||||
const absoluteX = Math.round(rect.left + window.pageXOffset + rect.width / 2);
|
||||
const absoluteY = Math.round(rect.top + window.pageYOffset + rect.height / 2);
|
||||
|
||||
// Check if element is currently in viewport
|
||||
const inViewport = (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
);
|
||||
|
||||
const element = {
|
||||
id: 'elem_' + (idCounter++),
|
||||
type: el.tagName.toLowerCase() === 'input' ? 'input-' + (el.type || 'text') :
|
||||
el.tagName.toLowerCase() === 'select' ? 'select' :
|
||||
el.tagName.toLowerCase() === 'textarea' ? 'textarea' :
|
||||
el.getAttribute('role') ? 'role-' + el.getAttribute('role') :
|
||||
el.tagName.toLowerCase(),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: el.textContent?.trim().substring(0, 100) || null,
|
||||
value: el.value || null,
|
||||
selectors: getBrowserPilotSelectors(el),
|
||||
attributes: {
|
||||
id: el.id || null,
|
||||
class: el.className || null,
|
||||
name: el.getAttribute('name') || null,
|
||||
type: el.getAttribute('type') || null,
|
||||
role: el.getAttribute('role') || null,
|
||||
'aria-label': el.getAttribute('aria-label') || null,
|
||||
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
|
||||
placeholder: el.getAttribute('placeholder') || null
|
||||
},
|
||||
position: {
|
||||
x: absoluteX,
|
||||
y: absoluteY
|
||||
},
|
||||
visibility: {
|
||||
inViewport: inViewport,
|
||||
visible: true,
|
||||
obscured: inViewport ? isObscured(el) : false
|
||||
},
|
||||
context: getContext(el)
|
||||
};
|
||||
|
||||
elements.push(element);
|
||||
});
|
||||
|
||||
return elements;
|
||||
})()
|
||||
`;
|
||||
}
|
||||
386
skills/scripts/src/cdp/map/query-map.ts
Normal file
386
skills/scripts/src/cdp/map/query-map.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Query interaction map to find elements by various criteria
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { InteractionElement } from './generate-interaction-map';
|
||||
import { SELECTOR_RETRY_CONFIG } from '../actions/helpers';
|
||||
import { getOutputDir } from '../config';
|
||||
import { CDP, TIMING } from '../../constants';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Re-export for protocol usage
|
||||
export type { InteractionElement };
|
||||
|
||||
export interface InteractionMap {
|
||||
url: string;
|
||||
timestamp: string;
|
||||
ready?: boolean; // Map is fully generated and ready for use
|
||||
viewport: { width: number; height: number };
|
||||
elements: Record<string, InteractionElement>;
|
||||
indexes: {
|
||||
byText: Record<string, string[]>;
|
||||
byType: Record<string, string[]>;
|
||||
inViewport: string[];
|
||||
};
|
||||
statistics: {
|
||||
total: number;
|
||||
byType: Record<string, number>;
|
||||
duplicates: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
text?: string; // Search by text content
|
||||
type?: string; // Filter by element type (supports aliases: "input" → "input-*")
|
||||
tag?: string; // Filter by HTML tag (e.g., "input", "button")
|
||||
index?: number; // Select nth match (1-based)
|
||||
viewportOnly?: boolean; // Only visible elements
|
||||
id?: string; // Direct ID lookup
|
||||
listTypes?: boolean; // List all element types with counts
|
||||
listTexts?: boolean; // List all text contents
|
||||
limit?: number; // Maximum results to return (default: 20)
|
||||
offset?: number; // Number of results to skip (default: 0)
|
||||
verbose?: boolean; // Include detailed information
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
element: InteractionElement;
|
||||
selector: string; // Best selector to use
|
||||
alternatives: string[]; // Alternative selectors
|
||||
}
|
||||
|
||||
/**
|
||||
* Load interaction map from file with ready flag check
|
||||
* @param mapPath Optional path to map file
|
||||
* @param waitForReady If true, poll until map is ready (default: false)
|
||||
* @param timeout Maximum wait time in milliseconds (default: 10000)
|
||||
*/
|
||||
export function loadMap(
|
||||
mapPath?: string,
|
||||
waitForReady: boolean = false,
|
||||
timeout: number = 10000
|
||||
): InteractionMap {
|
||||
const defaultPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
const filePath = mapPath || defaultPath;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Map file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const map = JSON.parse(content) as InteractionMap;
|
||||
|
||||
// If not waiting for ready or already ready, return immediately
|
||||
if (!waitForReady || map.ready === true) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Poll until ready or timeout
|
||||
const startTime = Date.now();
|
||||
const pollInterval = TIMING.POLLING_INTERVAL_FAST;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Sleep using platform-appropriate command
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`ping -n 1 -w ${pollInterval} ${CDP.LOCALHOST} >nul`, { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync(`sleep ${pollInterval / TIMING.ACTION_DELAY_NAVIGATION}`, { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, just continue polling
|
||||
}
|
||||
|
||||
// Re-read map
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Map file disappeared during polling: ${filePath}`);
|
||||
}
|
||||
|
||||
const newContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const newMap = JSON.parse(newContent) as InteractionMap;
|
||||
|
||||
if (newMap.ready === true) {
|
||||
return newMap;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Map did not become ready within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select best selector for an element
|
||||
* Priority: byId > byText(indexed) > byCSS > byRole > byAriaLabel
|
||||
*/
|
||||
export function selectBestSelector(element: InteractionElement): string {
|
||||
const { selectors } = element;
|
||||
|
||||
if (selectors.byId) {
|
||||
return selectors.byId;
|
||||
}
|
||||
|
||||
if (selectors.byText) {
|
||||
return selectors.byText;
|
||||
}
|
||||
|
||||
if (selectors.byCSS && selectors.byCSS !== element.tag) {
|
||||
// Skip generic tag-only selectors
|
||||
return selectors.byCSS;
|
||||
}
|
||||
|
||||
if (selectors.byRole) {
|
||||
return selectors.byRole;
|
||||
}
|
||||
|
||||
if (selectors.byAriaLabel) {
|
||||
return selectors.byAriaLabel;
|
||||
}
|
||||
|
||||
// Fallback to CSS
|
||||
return selectors.byCSS || element.tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all alternative selectors for an element
|
||||
*/
|
||||
export function getAlternativeSelectors(element: InteractionElement): string[] {
|
||||
const alternatives: string[] = [];
|
||||
const { selectors } = element;
|
||||
|
||||
if (selectors.byId) alternatives.push(selectors.byId);
|
||||
if (selectors.byText) alternatives.push(selectors.byText);
|
||||
if (selectors.byCSS) alternatives.push(selectors.byCSS);
|
||||
if (selectors.byRole) alternatives.push(selectors.byRole);
|
||||
if (selectors.byAriaLabel) alternatives.push(selectors.byAriaLabel);
|
||||
|
||||
return alternatives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand type alias to include all matching types
|
||||
* Examples:
|
||||
* - "input" → ["input", "input-text", "input-search", "input-password", ...]
|
||||
* - "button" → ["button", "button-submit", "button-reset", ...]
|
||||
* - "input-search" → ["input-search"] (exact match, no expansion)
|
||||
*/
|
||||
export function expandTypeAlias(type: string, availableTypes: string[]): string[] {
|
||||
// If type contains a hyphen, it's a specific type (no expansion)
|
||||
if (type.includes('-')) {
|
||||
return [type];
|
||||
}
|
||||
|
||||
// Escape regex special characters to prevent regex injection
|
||||
const escapedType = escapeRegex(type);
|
||||
|
||||
// Expand to include all types starting with the alias
|
||||
const pattern = new RegExp(`^${escapedType}(-.*)?$`);
|
||||
const matches = availableTypes.filter(t => pattern.test(t));
|
||||
|
||||
// If no matches found, return original type
|
||||
return matches.length > 0 ? matches : [type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query map for elements matching criteria
|
||||
*/
|
||||
export function queryMap(map: InteractionMap, options: QueryOptions): QueryResult[] {
|
||||
let candidateIds: string[] = [];
|
||||
|
||||
// Direct ID lookup
|
||||
if (options.id) {
|
||||
const element = map.elements[options.id];
|
||||
if (!element) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
element,
|
||||
selector: selectBestSelector(element),
|
||||
alternatives: getAlternativeSelectors(element)
|
||||
}];
|
||||
}
|
||||
|
||||
// Text-based search
|
||||
if (options.text) {
|
||||
candidateIds = map.indexes.byText[options.text] || [];
|
||||
if (candidateIds.length === 0) {
|
||||
// Fuzzy search: find texts containing the query
|
||||
const matchingTexts = Object.keys(map.indexes.byText).filter(text =>
|
||||
options.text && text.toLowerCase().includes(options.text.toLowerCase())
|
||||
);
|
||||
matchingTexts.forEach(text => {
|
||||
candidateIds.push(...map.indexes.byText[text]);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No text filter: start with all elements
|
||||
candidateIds = Object.keys(map.elements);
|
||||
}
|
||||
|
||||
// Type filter (with alias expansion)
|
||||
if (options.type) {
|
||||
const availableTypes = Object.keys(map.indexes.byType);
|
||||
const expandedTypes = expandTypeAlias(options.type, availableTypes);
|
||||
|
||||
const typeIds = expandedTypes.flatMap(type => map.indexes.byType[type] || []);
|
||||
|
||||
candidateIds = candidateIds.filter(id => typeIds.includes(id));
|
||||
|
||||
// Log type expansion if expansion occurred
|
||||
if (expandedTypes.length > 1) {
|
||||
logger.debug(`Type alias "${options.type}" expanded to: ${expandedTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag filter (HTML tag name)
|
||||
if (options.tag) {
|
||||
const tagLower = options.tag.toLowerCase();
|
||||
candidateIds = candidateIds.filter(id => {
|
||||
const element = map.elements[id];
|
||||
return element && element.tag.toLowerCase() === tagLower;
|
||||
});
|
||||
}
|
||||
|
||||
// Viewport filter
|
||||
if (options.viewportOnly) {
|
||||
const viewportIds = map.indexes.inViewport;
|
||||
candidateIds = candidateIds.filter(id => viewportIds.includes(id));
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
candidateIds = Array.from(new Set(candidateIds));
|
||||
|
||||
// Convert IDs to QueryResults
|
||||
const results: QueryResult[] = candidateIds.map(id => {
|
||||
const element = map.elements[id];
|
||||
return {
|
||||
element,
|
||||
selector: selectBestSelector(element),
|
||||
alternatives: getAlternativeSelectors(element)
|
||||
};
|
||||
});
|
||||
|
||||
// Apply pagination (limit/offset)
|
||||
const limit = options.limit !== undefined ? options.limit : 20;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
// Index selection (takes priority over pagination)
|
||||
if (options.index !== undefined && options.index > 0) {
|
||||
const selected = results[options.index - 1];
|
||||
return selected ? [selected] : [];
|
||||
}
|
||||
|
||||
// Apply offset and limit
|
||||
if (limit === 0) {
|
||||
// 0 means unlimited
|
||||
return results.slice(offset);
|
||||
}
|
||||
return results.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find element and return best selector
|
||||
* @param mapPath Path to map file
|
||||
* @param options Query options
|
||||
* @param waitForReady If true, wait for map to be ready before querying (default: true)
|
||||
* @param timeout Maximum wait time in milliseconds (default: 10000)
|
||||
*/
|
||||
export function findSelector(
|
||||
mapPath: string | undefined,
|
||||
options: QueryOptions,
|
||||
waitForReady: boolean = true,
|
||||
timeout: number = 10000
|
||||
): string | null {
|
||||
try {
|
||||
const map = loadMap(mapPath, waitForReady, timeout);
|
||||
const results = queryMap(map, options);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the best selector of the first result
|
||||
return results[0].selector;
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error querying map', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find element with fallback to alternatives
|
||||
*/
|
||||
export function findSelectorWithFallback(
|
||||
mapPath: string | undefined,
|
||||
options: QueryOptions
|
||||
): { selector: string; alternatives: string[] } | null {
|
||||
try {
|
||||
const map = loadMap(mapPath);
|
||||
const results = queryMap(map, options);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
selector: results[0].selector,
|
||||
alternatives: results[0].alternatives
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error querying map', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all element types with counts from map
|
||||
*/
|
||||
export function listTypes(map: InteractionMap): Record<string, number> {
|
||||
return map.statistics.byType;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all text contents with their types from map
|
||||
*/
|
||||
export function listTexts(
|
||||
map: InteractionMap,
|
||||
options?: { type?: string; limit?: number; offset?: number }
|
||||
): Array<{ text: string; type: string; count: number }> {
|
||||
const limit = options?.limit !== undefined ? options.limit : 20;
|
||||
const offset = options?.offset || 0;
|
||||
const typeFilter = options?.type;
|
||||
|
||||
const textList: Array<{ text: string; type: string; count: number }> = [];
|
||||
|
||||
// Iterate through text index
|
||||
for (const [text, elementIds] of Object.entries(map.indexes.byText)) {
|
||||
// Get first element to determine type
|
||||
const firstElement = map.elements[elementIds[0]];
|
||||
if (!firstElement) continue;
|
||||
|
||||
// Apply type filter if specified
|
||||
if (typeFilter && firstElement.type !== typeFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textList.push({
|
||||
text,
|
||||
type: firstElement.type,
|
||||
count: elementIds.length
|
||||
});
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if (limit === 0) {
|
||||
return textList.slice(offset);
|
||||
}
|
||||
return textList.slice(offset, offset + limit);
|
||||
}
|
||||
179
skills/scripts/src/cdp/utils.ts
Normal file
179
skills/scripts/src/cdp/utils.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Utility functions for Browser Pilot
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, normalize, resolve } from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { TIMING } from '../constants';
|
||||
|
||||
interface ProjectConfig {
|
||||
rootPath: string;
|
||||
port: number;
|
||||
outputDir: string;
|
||||
lastUsed: string | null;
|
||||
autoCleanup: boolean;
|
||||
}
|
||||
|
||||
interface SharedBrowserPilotConfig {
|
||||
projects: {
|
||||
[projectName: string]: ProjectConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared config file path in plugin skills folder
|
||||
* Uses hardcoded home directory path for reliability
|
||||
*/
|
||||
function getSharedConfigPath(): string {
|
||||
const { homedir } = require('os');
|
||||
const homeDir = homedir();
|
||||
return join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'marketplaces',
|
||||
'dev-gom-plugins',
|
||||
'plugins',
|
||||
'browser-pilot',
|
||||
'skills',
|
||||
'browser-pilot-config.json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load shared configuration from plugin folder
|
||||
*/
|
||||
function loadSharedConfig(): SharedBrowserPilotConfig {
|
||||
const configPath = getSharedConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return { projects: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (_error) {
|
||||
return { projects: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two paths for equality (cross-platform, case-insensitive on Windows)
|
||||
*/
|
||||
function pathsEqual(path1: string, path2: string): boolean {
|
||||
return normalize(resolve(path1)).toLowerCase() ===
|
||||
normalize(resolve(path2)).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project root directory.
|
||||
*
|
||||
* Strategy (in order of priority):
|
||||
* 1. CLAUDE_PROJECT_DIR environment variable
|
||||
* 2. Shared config file (if running from scripts folder)
|
||||
*
|
||||
* No fallback to process.cwd() - requires explicit project configuration.
|
||||
*/
|
||||
export function findProjectRoot(): string {
|
||||
// 1. Environment variable has highest priority
|
||||
if (process.env.CLAUDE_PROJECT_DIR) {
|
||||
return process.env.CLAUDE_PROJECT_DIR;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
// 2. If running from scripts folder, check shared config
|
||||
// More robust check: compare exact path (cross-platform, case-insensitive)
|
||||
const scriptsDir = join(__dirname, '..', '..');
|
||||
if (pathsEqual(cwd, scriptsDir)) {
|
||||
try {
|
||||
const config = loadSharedConfig();
|
||||
const projects = Object.values(config.projects);
|
||||
|
||||
if (projects.length === 1) {
|
||||
// Only one project configured, use it
|
||||
return projects[0].rootPath;
|
||||
} else if (projects.length > 1) {
|
||||
// Multiple projects: use the most recently used one
|
||||
const sorted = projects.sort((a, b) => {
|
||||
// Handle invalid dates: treat as 0 to ensure predictable sorting
|
||||
const aTime = new Date(a.lastUsed || 0).getTime();
|
||||
const bTime = new Date(b.lastUsed || 0).getTime();
|
||||
return (isNaN(bTime) ? 0 : bTime) - (isNaN(aTime) ? 0 : aTime);
|
||||
});
|
||||
return sorted[0].rootPath;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load shared config: ${error}`);
|
||||
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set and no projects in shared config');
|
||||
}
|
||||
}
|
||||
|
||||
// No fallback to process.cwd() - require explicit project configuration
|
||||
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the findElement helper function as a JavaScript string
|
||||
* for injection into browser context.
|
||||
*
|
||||
* Supports:
|
||||
* - CSS selectors: 'button.primary'
|
||||
* - XPath selectors: '//button[@id="submit"]'
|
||||
* - XPath with indexing: '(//button[text()="Click"])[2]'
|
||||
*/
|
||||
export function getFindElementScript(): string {
|
||||
return `
|
||||
function findElement(sel) {
|
||||
if (sel.startsWith('//') || sel.startsWith('(//')) {
|
||||
// XPath selector - check for indexing pattern: (...)[N]
|
||||
const indexMatch = sel.match(/^\\((.*)\\)\\[(\\d+)\\]$/);
|
||||
|
||||
if (indexMatch) {
|
||||
// Has indexing: (//xpath)[N]
|
||||
const xpath = indexMatch[1];
|
||||
const index = parseInt(indexMatch[2]) - 1; // XPath is 1-based, JS is 0-based
|
||||
|
||||
const result = document.evaluate(
|
||||
xpath,
|
||||
document,
|
||||
null,
|
||||
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
|
||||
null
|
||||
);
|
||||
|
||||
return result.snapshotItem(index);
|
||||
} else {
|
||||
// No indexing - return first match
|
||||
const result = document.evaluate(
|
||||
sel,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
null
|
||||
);
|
||||
return result.singleNodeValue;
|
||||
}
|
||||
} else {
|
||||
// CSS selector
|
||||
return document.querySelector(sel);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-like random delay to avoid bot detection
|
||||
* @param minMs Minimum delay in milliseconds (default: ACTION_DELAY_MEDIUM * 3)
|
||||
* @param maxMs Maximum delay in milliseconds (default: ACTION_DELAY_LONG + ACTION_DELAY_MEDIUM * 3)
|
||||
* @returns Promise that resolves after the delay
|
||||
*/
|
||||
export function humanDelay(
|
||||
minMs: number = TIMING.ACTION_DELAY_MEDIUM * 3,
|
||||
maxMs: number = TIMING.ACTION_DELAY_LONG + TIMING.ACTION_DELAY_MEDIUM * 3
|
||||
): Promise<void> {
|
||||
const delayMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
|
||||
return new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
58
skills/scripts/src/cli/cli.ts
Normal file
58
skills/scripts/src/cli/cli.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* CDP Browser CLI - Chrome DevTools Protocol browser automation tool.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { registerNavigationCommands } from './commands/navigation';
|
||||
import { registerInteractionCommands } from './commands/interaction';
|
||||
import { registerFormsCommands } from './commands/forms';
|
||||
import { registerCaptureCommands } from './commands/capture';
|
||||
import { registerTabsCommands } from './commands/tabs';
|
||||
import { registerCookiesCommands } from './commands/cookies';
|
||||
import { registerConsoleCommands } from './commands/console';
|
||||
import { registerNetworkCommands } from './commands/network';
|
||||
import { registerEmulationCommands } from './commands/emulation';
|
||||
import { registerDialogsCommands } from './commands/dialogs';
|
||||
import { registerScrollCommands } from './commands/scroll';
|
||||
import { registerWaitCommands } from './commands/wait';
|
||||
import { registerDataCommands } from './commands/data';
|
||||
import { registerFocusCommands } from './commands/focus';
|
||||
import { registerAccessibilityCommands } from './commands/accessibility';
|
||||
import { registerDaemonCommands } from './commands/daemon';
|
||||
import { registerChainCommands } from './commands/chain';
|
||||
import { registerQueryCommands } from './commands/query';
|
||||
import { registerSystemCommands } from './commands/system';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('cdp-browser')
|
||||
.description('Chrome DevTools Protocol browser automation CLI')
|
||||
.version('1.0.0')
|
||||
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: cdp-browser navigate --help');
|
||||
|
||||
// Register all command groups
|
||||
registerDaemonCommands(program); // Daemon management first
|
||||
registerSystemCommands(program); // System maintenance commands
|
||||
registerChainCommands(program); // Chain mode for sequential execution
|
||||
registerNavigationCommands(program);
|
||||
registerInteractionCommands(program);
|
||||
registerFormsCommands(program);
|
||||
registerCaptureCommands(program);
|
||||
registerTabsCommands(program);
|
||||
registerCookiesCommands(program);
|
||||
registerConsoleCommands(program);
|
||||
registerNetworkCommands(program);
|
||||
registerEmulationCommands(program);
|
||||
registerDialogsCommands(program);
|
||||
registerScrollCommands(program);
|
||||
registerWaitCommands(program);
|
||||
registerDataCommands(program);
|
||||
registerFocusCommands(program);
|
||||
registerAccessibilityCommands(program);
|
||||
registerQueryCommands(program); // Query interaction map
|
||||
|
||||
// Parse command line arguments
|
||||
program.parse();
|
||||
30
skills/scripts/src/cli/commands/accessibility.ts
Normal file
30
skills/scripts/src/cli/commands/accessibility.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export function registerAccessibilityCommands(program: Command) {
|
||||
// Get accessibility snapshot
|
||||
program
|
||||
.command('accessibility')
|
||||
.description('Get accessibility tree snapshot (ARIA roles, labels, and screen reader info)')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.getAccessibilitySnapshot(browser);
|
||||
console.log(`Accessibility nodes: ${result.nodeCount}`);
|
||||
console.log('First 50 nodes:', JSON.stringify(result.nodes, null, 2));
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Command execution failed', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
237
skills/scripts/src/cli/commands/capture.ts
Normal file
237
skills/scripts/src/cli/commands/capture.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
|
||||
/**
|
||||
* Type guard for viewport response data
|
||||
*/
|
||||
function isViewportResponse(data: unknown): data is { viewport: { width: number; height: number; devicePixelRatio: number } } {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'viewport' in data &&
|
||||
typeof (data as Record<string, unknown>).viewport === 'object' &&
|
||||
(data as Record<string, unknown>).viewport !== null &&
|
||||
'width' in ((data as Record<string, unknown>).viewport as object) &&
|
||||
'height' in ((data as Record<string, unknown>).viewport as object) &&
|
||||
'devicePixelRatio' in ((data as Record<string, unknown>).viewport as object) &&
|
||||
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).width === 'number' &&
|
||||
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).height === 'number' &&
|
||||
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).devicePixelRatio === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for screen info response data
|
||||
*/
|
||||
function isScreenInfoResponse(data: unknown): data is {
|
||||
viewport: { width: number; height: number };
|
||||
screen: { width: number; height: number; availWidth: number; availHeight: number };
|
||||
devicePixelRatio: number;
|
||||
} {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
|
||||
const d = data as Record<string, unknown>;
|
||||
|
||||
// Check viewport
|
||||
if (typeof d.viewport !== 'object' || d.viewport === null) return false;
|
||||
const viewport = d.viewport as Record<string, unknown>;
|
||||
if (typeof viewport.width !== 'number' || typeof viewport.height !== 'number') return false;
|
||||
|
||||
// Check screen
|
||||
if (typeof d.screen !== 'object' || d.screen === null) return false;
|
||||
const screen = d.screen as Record<string, unknown>;
|
||||
if (
|
||||
typeof screen.width !== 'number' ||
|
||||
typeof screen.height !== 'number' ||
|
||||
typeof screen.availWidth !== 'number' ||
|
||||
typeof screen.availHeight !== 'number'
|
||||
) return false;
|
||||
|
||||
// Check devicePixelRatio
|
||||
if (typeof d.devicePixelRatio !== 'number') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerCaptureCommands(program: Command) {
|
||||
// Screenshot command
|
||||
program
|
||||
.command('screenshot')
|
||||
.description('Capture screenshot of webpage (saved to .browser-pilot/screenshots/)')
|
||||
.option('-u, --url <url>', 'URL to capture (optional, uses current page if not specified)')
|
||||
.option('-o, --output <path>', 'Output file path', 'screenshot.png')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.option('--full-page', 'Capture full page', true)
|
||||
.option('--clip-x <x>', 'Clip region X coordinate (pixels)')
|
||||
.option('--clip-y <y>', 'Clip region Y coordinate (pixels)')
|
||||
.option('--clip-width <width>', 'Clip region width (pixels)')
|
||||
.option('--clip-height <height>', 'Clip region height (pixels)')
|
||||
.option('--clip-scale <scale>', 'Clip region scale factor (default: 1)', '1')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Navigate if URL provided
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url });
|
||||
}
|
||||
|
||||
// Build screenshot params
|
||||
const params: Record<string, unknown> = {
|
||||
filename: options.output,
|
||||
fullPage: options.fullPage
|
||||
};
|
||||
|
||||
// Add clip options if provided
|
||||
if (options.clipX && options.clipY && options.clipWidth && options.clipHeight) {
|
||||
params.clipX = parseFloat(options.clipX);
|
||||
params.clipY = parseFloat(options.clipY);
|
||||
params.clipWidth = parseFloat(options.clipWidth);
|
||||
params.clipHeight = parseFloat(options.clipHeight);
|
||||
params.clipScale = parseFloat(options.clipScale);
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
const response = await executeViaDaemon('screenshot', params);
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; path: string };
|
||||
console.log('Screenshot saved:', data.path);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Screenshot failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Set viewport size command
|
||||
program
|
||||
.command('set-viewport')
|
||||
.description('Set browser viewport size')
|
||||
.requiredOption('-w, --width <width>', 'Viewport width in pixels')
|
||||
.requiredOption('-h, --height <height>', 'Viewport height in pixels')
|
||||
.option('--scale <scale>', 'Device scale factor (default: 1)', '1')
|
||||
.option('--mobile', 'Emulate mobile device', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('set-viewport', {
|
||||
width: parseInt(options.width),
|
||||
height: parseInt(options.height),
|
||||
deviceScaleFactor: parseFloat(options.scale),
|
||||
mobile: options.mobile
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { width: number; height: number };
|
||||
console.log(`Viewport size set to: ${data.width}x${data.height}`);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Set viewport failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get viewport command
|
||||
program
|
||||
.command('get-viewport')
|
||||
.description('Get current viewport size')
|
||||
.action(async () => {
|
||||
try {
|
||||
const response = await executeViaDaemon('get-viewport', {});
|
||||
|
||||
if (response.success) {
|
||||
if (!isViewportResponse(response.data)) {
|
||||
console.error('Get viewport failed: Invalid response format');
|
||||
process.exit(1);
|
||||
}
|
||||
const data = response.data;
|
||||
console.log('=== Viewport Information ===');
|
||||
console.log(`Size: ${data.viewport.width}x${data.viewport.height}`);
|
||||
console.log(`Scale: ${data.viewport.devicePixelRatio}`);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Get viewport failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get screen info command
|
||||
program
|
||||
.command('get-screen-info')
|
||||
.description('Get screen and viewport information')
|
||||
.action(async () => {
|
||||
try {
|
||||
const response = await executeViaDaemon('get-screen-info', {});
|
||||
|
||||
if (response.success) {
|
||||
if (!isScreenInfoResponse(response.data)) {
|
||||
console.error('Get screen info failed: Invalid response format');
|
||||
process.exit(1);
|
||||
}
|
||||
const data = response.data;
|
||||
console.log('=== Screen Information ===');
|
||||
console.log(`Screen: ${data.screen.width}x${data.screen.height}`);
|
||||
console.log(`Available: ${data.screen.availWidth}x${data.screen.availHeight}`);
|
||||
console.log(`Viewport: ${data.viewport.width}x${data.viewport.height}`);
|
||||
console.log(`Scale: ${data.devicePixelRatio}`);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Get screen info failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate PDF command
|
||||
program
|
||||
.command('pdf')
|
||||
.description('Generate PDF from webpage (saved to .browser-pilot/pdfs/)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.option('-o, --output <path>', 'Output file path', 'page.pdf')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.option('--landscape', 'Use landscape orientation', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Navigate if URL provided
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url });
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
const response = await executeViaDaemon('pdf', {
|
||||
filename: options.output,
|
||||
landscape: options.landscape
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; path: string };
|
||||
console.log('PDF saved:', data.path);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('PDF generation failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
438
skills/scripts/src/cli/commands/chain.ts
Normal file
438
skills/scripts/src/cli/commands/chain.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
import { findSelector } from '../../cdp/map/query-map';
|
||||
import { findSelectorWithRetry } from './selector-helper';
|
||||
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
|
||||
import { getOutputDir } from '../../cdp/config';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* List of all available commands
|
||||
*/
|
||||
const AVAILABLE_COMMANDS = [
|
||||
// Navigation
|
||||
'navigate', 'back', 'forward', 'reload',
|
||||
// Interaction
|
||||
'click', 'hover', 'press', 'type', 'upload',
|
||||
// Forms
|
||||
'fill', 'select', 'check', 'uncheck',
|
||||
// Capture
|
||||
'screenshot', 'pdf',
|
||||
// Tabs
|
||||
'tabs', 'new-tab', 'close-tab', 'switch-tab', 'close',
|
||||
// Cookies
|
||||
'cookies', 'set-cookie', 'delete-cookies',
|
||||
// Console
|
||||
'console',
|
||||
// Scroll
|
||||
'scroll',
|
||||
// Wait
|
||||
'wait', 'wait-idle', 'sleep',
|
||||
// Data extraction
|
||||
'extract', 'content', 'extract-data', 'find', 'get-property',
|
||||
// Focus
|
||||
'focus', 'blur',
|
||||
// Accessibility
|
||||
'accessibility',
|
||||
// Network
|
||||
'block-url', 'unblock-urls',
|
||||
// Dialogs
|
||||
'dialog', 'enable-interception', 'disable-interception',
|
||||
// Emulation
|
||||
'emulate-media',
|
||||
// Drag
|
||||
'drag'
|
||||
];
|
||||
|
||||
interface ChainCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw arguments into command groups
|
||||
*/
|
||||
function parseChainArgs(rawArgs: string[]): ChainCommand[] {
|
||||
const commands: ChainCommand[] = [];
|
||||
let currentCommand: ChainCommand | null = null;
|
||||
|
||||
for (const arg of rawArgs) {
|
||||
// Check if this is a command name
|
||||
if (AVAILABLE_COMMANDS.includes(arg)) {
|
||||
// Save previous command
|
||||
if (currentCommand) {
|
||||
commands.push(currentCommand);
|
||||
}
|
||||
// Start new command
|
||||
currentCommand = {
|
||||
command: arg,
|
||||
args: []
|
||||
};
|
||||
} else if (currentCommand) {
|
||||
// Add argument to current command
|
||||
currentCommand.args.push(arg);
|
||||
} else {
|
||||
// Argument before any command (error)
|
||||
throw new Error(`Unexpected argument "${arg}" before any command`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last command
|
||||
if (currentCommand) {
|
||||
commands.push(currentCommand);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command arguments into params object
|
||||
*/
|
||||
function parseCommandArgs(command: string, args: string[]): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
// Long option: --option value or --option=value
|
||||
if (arg.startsWith('--')) {
|
||||
const optionName = arg.slice(2);
|
||||
|
||||
// Check for --option=value format
|
||||
if (optionName.includes('=')) {
|
||||
const [key, value] = optionName.split('=', 2);
|
||||
params[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if next arg is a value (doesn't start with -)
|
||||
const nextArg = args[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
// Parse value type
|
||||
let value: unknown = nextArg;
|
||||
|
||||
// Try to parse as number
|
||||
const num = Number(nextArg);
|
||||
if (!isNaN(num)) {
|
||||
value = num;
|
||||
}
|
||||
// Check for boolean strings
|
||||
else if (nextArg === 'true') {
|
||||
value = true;
|
||||
} else if (nextArg === 'false') {
|
||||
value = false;
|
||||
}
|
||||
|
||||
params[optionName] = value;
|
||||
i++; // Skip next arg
|
||||
} else {
|
||||
// Boolean flag
|
||||
params[optionName] = true;
|
||||
}
|
||||
}
|
||||
// Short option: -s value or -s=value
|
||||
else if (arg.startsWith('-') && arg.length > 1) {
|
||||
const optionName = arg.slice(1);
|
||||
|
||||
// Check for -s=value format
|
||||
if (optionName.includes('=')) {
|
||||
const [key, value] = optionName.split('=', 2);
|
||||
params[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if next arg is a value
|
||||
const nextArg = args[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
// Parse value type
|
||||
let value: unknown = nextArg;
|
||||
|
||||
const num = Number(nextArg);
|
||||
if (!isNaN(num)) {
|
||||
value = num;
|
||||
} else if (nextArg === 'true') {
|
||||
value = true;
|
||||
} else if (nextArg === 'false') {
|
||||
value = false;
|
||||
}
|
||||
|
||||
params[optionName] = value;
|
||||
i++;
|
||||
} else {
|
||||
params[optionName] = true;
|
||||
}
|
||||
}
|
||||
// Positional argument
|
||||
else {
|
||||
// Store as _args array
|
||||
if (!params._args) {
|
||||
params._args = [];
|
||||
}
|
||||
(params._args as unknown[]).push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed params to daemon-compatible format
|
||||
*/
|
||||
function convertParamsForDaemon(command: string, params: Record<string, unknown>): Record<string, unknown> {
|
||||
const converted: Record<string, unknown> = { ...params };
|
||||
|
||||
// Map common option names
|
||||
const mappings: Record<string, string> = {
|
||||
'u': 'url',
|
||||
's': 'selector',
|
||||
'v': 'value',
|
||||
't': 'timeout',
|
||||
'k': 'key',
|
||||
'o': 'output',
|
||||
'f': 'file',
|
||||
'i': 'index',
|
||||
'x': 'x',
|
||||
'y': 'y',
|
||||
'e': 'expression',
|
||||
'd': 'delay'
|
||||
};
|
||||
|
||||
// Apply mappings
|
||||
for (const [short, long] of Object.entries(mappings)) {
|
||||
if (short in converted && !(long in converted)) {
|
||||
converted[long] = converted[short];
|
||||
delete converted[short];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Smart Mode options for click/fill
|
||||
if (command === 'click' || command === 'fill') {
|
||||
// Convert kebab-case to camelCase
|
||||
if ('viewport-only' in converted) {
|
||||
converted.viewportOnly = converted['viewport-only'];
|
||||
delete converted['viewport-only'];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove _args if present
|
||||
delete converted._args;
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
export function registerChainCommands(program: Command) {
|
||||
program
|
||||
.command('chain [args...]')
|
||||
.description('Execute multiple commands in sequence with automatic map synchronization\n' +
|
||||
' Format: chain <cmd1> [opts1] <cmd2> [opts2] ...\n' +
|
||||
' Examples:\n' +
|
||||
' • No quotes: chain navigate -u http://example.com click --text Submit screenshot -o result.png\n' +
|
||||
' • With quotes (when values have spaces): chain click --text "Sign In" fill -s #email -v "user@example.com"\n' +
|
||||
' • Supports Smart Mode (--text) for click/fill commands\n' +
|
||||
' • Auto-waits for page load and map generation after navigation\n' +
|
||||
' • Adds random human-like delay (300-800ms) between commands')
|
||||
.option('--timeout <ms>', 'Timeout for waiting map ready after navigation (default: 10000ms)', parseInt, 10000)
|
||||
.option('--delay <ms>', 'Fixed delay between commands in milliseconds (overrides random 300-800ms delay)', parseInt)
|
||||
.allowUnknownOption()
|
||||
.action(async (args: string[] = [], options, _cmd) => {
|
||||
try {
|
||||
// Extract chain-level options
|
||||
const chainTimeout = options.timeout || 10000;
|
||||
const chainDelay = options.delay;
|
||||
|
||||
// Use provided args array
|
||||
const rawArgs = args;
|
||||
|
||||
if (rawArgs.length === 0) {
|
||||
console.error('Error: No commands provided');
|
||||
console.log('Usage: npm run bp:chain -- <command1> [options1] <command2> [options2] ...');
|
||||
console.log('Example: npm run bp:chain -- navigate -u "http://example.com" click --text "Submit"');
|
||||
console.log('Options:');
|
||||
console.log(' --timeout <ms> Timeout for waiting map ready (default: 10000ms)');
|
||||
console.log(' --delay <ms> Delay between commands (default: random 300-800ms)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse chain arguments
|
||||
const commands = parseChainArgs(rawArgs);
|
||||
|
||||
console.log(`Executing ${commands.length} command(s) in sequence...\n`);
|
||||
|
||||
// Execute commands sequentially
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const { command, args } = commands[i];
|
||||
|
||||
console.log(`[${i + 1}/${commands.length}] Executing: ${command} ${args.join(' ')}`);
|
||||
|
||||
// Parse command arguments
|
||||
const params = parseCommandArgs(command, args);
|
||||
const daemonParams = convertParamsForDaemon(command, params);
|
||||
|
||||
// Smart Mode: query map for click/fill commands with text option
|
||||
if ((command === 'click' || command === 'fill') && daemonParams.text && !daemonParams.selector) {
|
||||
console.log(`⏳ Waiting for map to be ready (timeout: ${chainTimeout}ms)...`);
|
||||
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
|
||||
// Get current URL for debugging
|
||||
let currentUrl = 'unknown';
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(mapPath)) {
|
||||
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
|
||||
currentUrl = mapData.url || 'unknown';
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors, just use 'unknown'
|
||||
}
|
||||
|
||||
// Try to find selector with retry on failure
|
||||
let selector = findSelector(
|
||||
mapPath,
|
||||
{
|
||||
text: daemonParams.text as string,
|
||||
index: daemonParams.index as number | undefined,
|
||||
type: daemonParams.type as string | undefined,
|
||||
viewportOnly: daemonParams.viewportOnly as boolean | undefined
|
||||
},
|
||||
true, // waitForReady
|
||||
chainTimeout // timeout
|
||||
);
|
||||
|
||||
// Fallback: regenerate map if element not found
|
||||
if (!selector) {
|
||||
selector = await findSelectorWithRetry({
|
||||
text: daemonParams.text as string,
|
||||
index: daemonParams.index as number | undefined,
|
||||
type: daemonParams.type as string | undefined,
|
||||
viewportOnly: daemonParams.viewportOnly as boolean | undefined
|
||||
}, 'element');
|
||||
|
||||
if (!selector) {
|
||||
console.error(` in URL: ${currentUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ Map ready, found selector: ${selector}`);
|
||||
}
|
||||
daemonParams.selector = selector;
|
||||
delete daemonParams.text;
|
||||
delete daemonParams.index;
|
||||
delete daemonParams.type;
|
||||
delete daemonParams.viewportOnly;
|
||||
}
|
||||
|
||||
// Execute via daemon
|
||||
const response = await executeViaDaemon(command, daemonParams, { verbose: false });
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`✗ Command failed: ${command}`);
|
||||
console.error(`Error: ${response.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Success: ${command}`);
|
||||
|
||||
// Wait for map generation to complete after navigation-triggering commands
|
||||
const navigationCommands = ['navigate', 'click', 'back', 'forward', 'reload'];
|
||||
if (navigationCommands.includes(command)) {
|
||||
console.log('⏳ Waiting for interaction map to be ready...');
|
||||
|
||||
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
const startTime = Date.now();
|
||||
const mapTimeout = chainTimeout;
|
||||
|
||||
// For navigate command: verify URL matches target
|
||||
const expectedUrl = command === 'navigate' && daemonParams.url
|
||||
? String(daemonParams.url)
|
||||
: null;
|
||||
|
||||
// Poll map file until ready: true AND URL matches (for navigate)
|
||||
while (Date.now() - startTime < mapTimeout) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(mapPath)) {
|
||||
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
|
||||
|
||||
// Check if map is ready
|
||||
if (mapData.ready !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For navigate: verify URL matches exactly
|
||||
if (expectedUrl) {
|
||||
const mapUrl = String(mapData.url || '');
|
||||
|
||||
// Normalize URLs (remove trailing slash, compare as lowercase)
|
||||
const normalizedExpected = expectedUrl.replace(/\/$/, '').toLowerCase();
|
||||
const normalizedMap = mapUrl.replace(/\/$/, '').toLowerCase();
|
||||
|
||||
// Exact match required
|
||||
if (normalizedExpected !== normalizedMap) {
|
||||
// URL mismatch - keep waiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Map is ready and URL matches (if applicable)
|
||||
console.log(`✓ Interaction map ready (${mapData.statistics?.total || 0} elements)`);
|
||||
if (expectedUrl) {
|
||||
console.log(` → URL verified: ${mapData.url}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors, continue polling
|
||||
}
|
||||
|
||||
// Wait 100ms before next check
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Display result if present
|
||||
if (response.data) {
|
||||
const data = response.data as Record<string, unknown>;
|
||||
|
||||
// Display relevant info based on command
|
||||
if (command === 'navigate' && data.url) {
|
||||
console.log(` → Navigated to: ${data.url}`);
|
||||
} else if (command === 'click' && data.selector) {
|
||||
console.log(` → Clicked: ${data.selector}`);
|
||||
} else if (command === 'fill' && data.selector) {
|
||||
console.log(` → Filled: ${data.selector}`);
|
||||
} else if (command === 'extract' && data.text) {
|
||||
console.log(` → Extracted: ${data.text}`);
|
||||
} else if (command === 'screenshot' && data.path) {
|
||||
console.log(` → Saved to: ${data.path}`);
|
||||
} else if (command === 'tabs') {
|
||||
console.log(` → ${JSON.stringify(data, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// Add delay before next command (except for last command)
|
||||
if (i < commands.length - 1) {
|
||||
let delayMs: number;
|
||||
if (chainDelay !== undefined) {
|
||||
delayMs = chainDelay;
|
||||
} else {
|
||||
// Random human-like delay (300-800ms)
|
||||
delayMs = Math.floor(Math.random() * (800 - 300 + 1)) + 300;
|
||||
}
|
||||
console.log(`⏱️ Waiting ${delayMs}ms before next command...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ All ${commands.length} command(s) completed successfully`);
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chain execution error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
230
skills/scripts/src/cli/commands/console.ts
Normal file
230
skills/scripts/src/cli/commands/console.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Command } from 'commander';
|
||||
import { FormattedConsoleMessage } from '../../cdp/browser';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
import { TIMING } from '../../constants';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface ConsoleOptions {
|
||||
url?: string;
|
||||
errorsOnly?: boolean;
|
||||
level?: string;
|
||||
warnings?: boolean;
|
||||
logs?: boolean;
|
||||
limit?: string;
|
||||
skip?: string;
|
||||
filter?: string;
|
||||
exclude?: string;
|
||||
json?: boolean;
|
||||
timestamp?: boolean;
|
||||
noColor?: boolean;
|
||||
output?: string;
|
||||
urlFilter?: string;
|
||||
}
|
||||
|
||||
export function registerConsoleCommands(program: Command) {
|
||||
// Get console messages
|
||||
program
|
||||
.command('console')
|
||||
.description('Retrieve console messages from the page with powerful filtering and formatting options')
|
||||
.option('-u, --url <url>', 'Navigate to URL before getting console messages')
|
||||
|
||||
// Level filtering options
|
||||
.option('-e, --errors-only', 'Show only error messages', false)
|
||||
.option('-l, --level <level>', 'Filter by level: error, warning, log, info, verbose')
|
||||
.option('--warnings', 'Show only warning messages', false)
|
||||
.option('--logs', 'Show only log messages', false)
|
||||
|
||||
// Message limiting options
|
||||
.option('--limit <number>', 'Maximum number of messages to display')
|
||||
.option('--skip <number>', 'Skip first N messages')
|
||||
|
||||
// Text filtering options
|
||||
.option('-f, --filter <pattern>', 'Show only messages matching regex pattern')
|
||||
.option('-x, --exclude <pattern>', 'Exclude messages matching regex pattern')
|
||||
|
||||
// Output format options
|
||||
.option('-j, --json', 'Output in JSON format', false)
|
||||
.option('-t, --timestamp', 'Show timestamps', false)
|
||||
.option('--no-color', 'Disable colored output')
|
||||
|
||||
// File output
|
||||
.option('-o, --output <file>', 'Save output to file')
|
||||
|
||||
// Source filtering
|
||||
.option('--url-filter <pattern>', 'Filter by source URL (regex)')
|
||||
|
||||
.action(async (options: ConsoleOptions) => {
|
||||
try {
|
||||
// Navigate if URL provided
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url });
|
||||
// Wait a bit for console messages to appear
|
||||
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION));
|
||||
}
|
||||
|
||||
// Get console messages
|
||||
const response = await executeViaDaemon('console', { errorsOnly: options.errorsOnly });
|
||||
|
||||
if (response.success) {
|
||||
const result = response.data as { count: number; errorCount: number; warningCount: number; logCount: number; messages: FormattedConsoleMessage[] };
|
||||
|
||||
// Apply filters
|
||||
let filteredMessages = filterMessages(result.messages, options);
|
||||
|
||||
// Generate output
|
||||
const output = formatOutput(filteredMessages, result, options);
|
||||
|
||||
// Save to file or print to console
|
||||
if (options.output) {
|
||||
const outputPath = path.resolve(options.output);
|
||||
fs.writeFileSync(outputPath, output, 'utf-8');
|
||||
console.log(`Console messages saved to: ${outputPath}`);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
if (!options.json && !options.output) {
|
||||
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
|
||||
}
|
||||
} else {
|
||||
console.error('Console retrieval failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter messages based on provided options
|
||||
*/
|
||||
function filterMessages(messages: FormattedConsoleMessage[], options: ConsoleOptions): FormattedConsoleMessage[] {
|
||||
let filtered = [...messages];
|
||||
|
||||
// Level filtering
|
||||
if (options.level) {
|
||||
const level = options.level.toLowerCase();
|
||||
filtered = filtered.filter(msg => msg.level.toLowerCase() === level);
|
||||
} else if (options.warnings) {
|
||||
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'warning');
|
||||
} else if (options.logs) {
|
||||
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'log');
|
||||
}
|
||||
// errorsOnly is handled by daemon
|
||||
|
||||
// Text filtering
|
||||
if (options.filter) {
|
||||
const regex = new RegExp(options.filter, 'i');
|
||||
filtered = filtered.filter(msg => regex.test(msg.text));
|
||||
}
|
||||
|
||||
if (options.exclude) {
|
||||
const regex = new RegExp(options.exclude, 'i');
|
||||
filtered = filtered.filter(msg => !regex.test(msg.text));
|
||||
}
|
||||
|
||||
// URL filtering
|
||||
if (options.urlFilter) {
|
||||
const regex = new RegExp(options.urlFilter, 'i');
|
||||
filtered = filtered.filter(msg => msg.url && regex.test(msg.url));
|
||||
}
|
||||
|
||||
// Skip messages
|
||||
const skip = options.skip ? parseInt(options.skip, 10) : 0;
|
||||
if (skip > 0) {
|
||||
filtered = filtered.slice(skip);
|
||||
}
|
||||
|
||||
// Limit messages
|
||||
const limit = options.limit ? parseInt(options.limit, 10) : undefined;
|
||||
if (limit && limit > 0) {
|
||||
filtered = filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format output based on options
|
||||
*/
|
||||
function formatOutput(
|
||||
messages: FormattedConsoleMessage[],
|
||||
result: { count: number; errorCount: number; warningCount: number; logCount: number },
|
||||
options: ConsoleOptions
|
||||
): string {
|
||||
if (options.json) {
|
||||
return JSON.stringify({
|
||||
total: result.count,
|
||||
filtered: messages.length,
|
||||
counts: {
|
||||
errors: result.errorCount,
|
||||
warnings: result.warningCount,
|
||||
logs: result.logCount
|
||||
},
|
||||
messages: messages
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// Text format
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push(`\n=== Console Messages (Total: ${result.count}, Filtered: ${messages.length}) ===`);
|
||||
lines.push(`Errors: ${result.errorCount}, Warnings: ${result.warningCount}, Logs: ${result.logCount}\n`);
|
||||
|
||||
if (messages.length === 0) {
|
||||
lines.push('No console messages found.');
|
||||
} else {
|
||||
messages.forEach((msg: FormattedConsoleMessage) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Timestamp
|
||||
if (options.timestamp) {
|
||||
parts.push(`[${msg.timestamp}]`);
|
||||
}
|
||||
|
||||
// Level with color
|
||||
const levelStr = `[${msg.level.toUpperCase()}]`;
|
||||
if (options.noColor === false) {
|
||||
const coloredLevel = colorizeLevel(levelStr, msg.level);
|
||||
parts.push(coloredLevel);
|
||||
} else {
|
||||
parts.push(levelStr);
|
||||
}
|
||||
|
||||
// Location
|
||||
if (msg.url) {
|
||||
parts.push(`(${msg.url}:${msg.lineNumber || '?'})`);
|
||||
}
|
||||
|
||||
// Message text
|
||||
parts.push(msg.text);
|
||||
|
||||
lines.push(parts.join(' '));
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Colorize level string based on message level
|
||||
*/
|
||||
function colorizeLevel(levelStr: string, level: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
error: '\x1b[31m', // Red
|
||||
warning: '\x1b[33m', // Yellow
|
||||
log: '\x1b[36m', // Cyan
|
||||
info: '\x1b[34m', // Blue
|
||||
verbose: '\x1b[90m', // Gray
|
||||
};
|
||||
|
||||
const reset = '\x1b[0m';
|
||||
const color = colors[level.toLowerCase()] || '';
|
||||
|
||||
return `${color}${levelStr}${reset}`;
|
||||
}
|
||||
92
skills/scripts/src/cli/commands/cookies.ts
Normal file
92
skills/scripts/src/cli/commands/cookies.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerCookiesCommands(program: Command) {
|
||||
// Get cookies command
|
||||
program
|
||||
.command('cookies')
|
||||
.description('Retrieve all cookies from the current page (or navigate to a URL first with -u)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(options.headless);
|
||||
try {
|
||||
// Try to connect to existing browser first, launch new one if failed
|
||||
try {
|
||||
await browser.connect();
|
||||
} catch {
|
||||
await browser.launch();
|
||||
}
|
||||
// Only navigate if URL is provided
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.getCookies(browser);
|
||||
console.log(`Found ${result.count} cookies:`);
|
||||
console.log(JSON.stringify(result.cookies, null, 2));
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Set cookie
|
||||
program
|
||||
.command('set-cookie')
|
||||
.description('Set a cookie with specified name and value (supports domain, path, secure, and httpOnly options)')
|
||||
.requiredOption('-n, --name <name>', 'Cookie name')
|
||||
.requiredOption('-v, --value <value>', 'Cookie value')
|
||||
.option('-d, --domain <domain>', 'Cookie domain')
|
||||
.option('-p, --path <path>', 'Cookie path', '/')
|
||||
.option('--secure', 'Secure cookie', false)
|
||||
.option('--http-only', 'HTTP only cookie', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.setCookie(
|
||||
browser,
|
||||
options.name,
|
||||
options.value,
|
||||
options.domain,
|
||||
options.path,
|
||||
options.secure,
|
||||
options.httpOnly
|
||||
);
|
||||
console.log('Cookie set:', result.cookie);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete cookies
|
||||
program
|
||||
.command('delete-cookies')
|
||||
.description('Delete cookies by name (deletes all cookies if no name is specified with -n)')
|
||||
.option('-n, --name <name>', 'Cookie name to delete (deletes all if not specified)')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.deleteCookies(browser, options.name);
|
||||
console.log(result.message);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
137
skills/scripts/src/cli/commands/daemon.ts
Normal file
137
skills/scripts/src/cli/commands/daemon.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Daemon management commands
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { DaemonManager } from '../../daemon/manager';
|
||||
|
||||
export function registerDaemonCommands(program: Command) {
|
||||
// Start daemon
|
||||
program
|
||||
.command('daemon-start')
|
||||
.description('Start Browser Pilot daemon (persistent background browser)')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.start({ verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop daemon
|
||||
program
|
||||
.command('daemon-stop')
|
||||
.description('Stop Browser Pilot daemon and close browser')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.option('-f, --force', 'Force kill the daemon')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.stop({ verbose: !options.quiet, force: options.force });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart daemon
|
||||
program
|
||||
.command('daemon-restart')
|
||||
.description('Restart Browser Pilot daemon')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.restart({ verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Daemon status
|
||||
program
|
||||
.command('daemon-status')
|
||||
.description('Check daemon status and browser info')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
const state = await manager.getStatus({ verbose: !options.quiet });
|
||||
process.exit(state ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Query interaction map
|
||||
program
|
||||
.command('daemon-query-map')
|
||||
.description('Query interaction map by text, type, or ID')
|
||||
.option('-t, --text <text>', 'Search by text content')
|
||||
.option('-T, --type <type>', 'Filter by element type (button, link, input, etc)')
|
||||
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
|
||||
.option('--id <id>', 'Direct ID lookup')
|
||||
.option('-v, --viewport-only', 'Only visible elements in viewport')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (options.text) params.text = options.text;
|
||||
if (options.type) params.type = options.type;
|
||||
if (options.index) params.index = options.index;
|
||||
if (options.id) params.id = options.id;
|
||||
if (options.viewportOnly) params.viewportOnly = true;
|
||||
|
||||
await manager.queryMap(params, { verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate interaction map
|
||||
program
|
||||
.command('daemon-generate-map')
|
||||
.description('Generate interaction map for current page (use -f to force)')
|
||||
.option('-f, --force', 'Force regeneration (ignore cache)')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (options.force) params.force = true;
|
||||
|
||||
await manager.generateMap(params, { verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get map status
|
||||
program
|
||||
.command('daemon-map-status')
|
||||
.description('Get interaction map status (URL, element count, cache)')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
const manager = new DaemonManager();
|
||||
try {
|
||||
await manager.getMapStatus({ verbose: !options.quiet });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
160
skills/scripts/src/cli/commands/data.ts
Normal file
160
skills/scripts/src/cli/commands/data.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
|
||||
export function registerDataCommands(program: Command) {
|
||||
// Extract text command
|
||||
program
|
||||
.command('extract')
|
||||
.description('Extract text from element (use -s for selector)')
|
||||
.option('-u, --url <url>', 'URL to extract from (optional, uses current page if not specified)')
|
||||
.option('-s, --selector <selector>', 'CSS selector (optional)')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(options.headless);
|
||||
try {
|
||||
// Try to connect to existing browser first, launch new one if failed
|
||||
try {
|
||||
await browser.connect();
|
||||
} catch {
|
||||
await browser.launch();
|
||||
}
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.extractText(browser, options.selector);
|
||||
console.log('Extracted text:', result.text);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Evaluate command
|
||||
program
|
||||
.command('eval')
|
||||
.description('Execute JavaScript on page (requires -e/--expression)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-e, --expression <script>', 'JavaScript expression to evaluate')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Navigate if URL provided
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url });
|
||||
}
|
||||
|
||||
// Execute JavaScript
|
||||
const response = await executeViaDaemon('eval', { expression: options.expression });
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { result: unknown };
|
||||
console.log('Result:', data.result);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Eval failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get content command
|
||||
program
|
||||
.command('content')
|
||||
.description('Get page HTML content')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.getContent(browser);
|
||||
console.log('HTML content length:', result.length);
|
||||
console.log(result.content);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Extract data
|
||||
program
|
||||
.command('extract-data')
|
||||
.description('Extract data using multiple selectors (requires -s/--selectors)')
|
||||
.requiredOption('-s, --selectors <json>', 'JSON object of key-selector pairs')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const selectors = JSON.parse(options.selectors);
|
||||
const result = await actions.extractData(browser, selectors);
|
||||
console.log('Extracted data:', JSON.stringify(result.data, null, 2));
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Find element
|
||||
program
|
||||
.command('find')
|
||||
.description('Find element and return info (requires -s/--selector)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.findElement(browser, options.selector);
|
||||
console.log('Element info:', JSON.stringify(result.element, null, 2));
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get element property
|
||||
program
|
||||
.command('get-property')
|
||||
.description('Get element property value (requires -s and -p)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector')
|
||||
.requiredOption('-p, --property <property>', 'Property name')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.getElementProperty(browser, options.selector, options.property);
|
||||
console.log(`${options.property}:`, result.value);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
29
skills/scripts/src/cli/commands/dialogs.ts
Normal file
29
skills/scripts/src/cli/commands/dialogs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerDialogsCommands(program: Command) {
|
||||
// Dialog response command
|
||||
program
|
||||
.command('dialog')
|
||||
.description('Respond to JavaScript dialogs (alert/confirm/prompt) by accepting, dismissing, or entering text for prompts')
|
||||
.option('-a, --accept', 'Accept dialog (default: true)', true)
|
||||
.option('-d, --dismiss', 'Dismiss dialog')
|
||||
.option('-t, --text <text>', 'Text for prompt dialog')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const accept = !options.dismiss;
|
||||
const result = await actions.respondToDialog(browser, accept, options.text);
|
||||
console.log('Dialog', result.accept ? 'accepted' : 'dismissed');
|
||||
if (result.promptText) {
|
||||
console.log('Prompt text:', result.promptText);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
28
skills/scripts/src/cli/commands/emulation.ts
Normal file
28
skills/scripts/src/cli/commands/emulation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerEmulationCommands(program: Command) {
|
||||
// Emulate media command
|
||||
program
|
||||
.command('emulate-media')
|
||||
.description('Emulate media type (screen/print) or color scheme (light/dark/no-preference) for testing responsive designs and dark mode')
|
||||
.option('-m, --media <type>', 'Media type: screen or print')
|
||||
.option('-c, --color-scheme <scheme>', 'Color scheme: light, dark, or no-preference')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.emulateMedia(
|
||||
browser,
|
||||
options.media as 'screen' | 'print' | undefined,
|
||||
options.colorScheme as 'light' | 'dark' | 'no-preference' | undefined
|
||||
);
|
||||
console.log('Emulated media:', result.mediaType || 'none', 'colorScheme:', result.colorScheme || 'none');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
53
skills/scripts/src/cli/commands/focus.ts
Normal file
53
skills/scripts/src/cli/commands/focus.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerFocusCommands(program: Command) {
|
||||
// Focus element
|
||||
program
|
||||
.command('focus')
|
||||
.description('Set focus on a specific element (for keyboard input)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.focus(browser, options.selector);
|
||||
console.log('Focused:', result.selector);
|
||||
console.log('Browser will stay open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Blur element
|
||||
program
|
||||
.command('blur')
|
||||
.description('Remove focus from an element (deactivate active element)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector')
|
||||
.option('-u, --url <url>', 'Navigate to URL first')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.blur(browser, options.selector);
|
||||
console.log('Blurred:', result.selector);
|
||||
console.log('Browser will stay open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
81
skills/scripts/src/cli/commands/forms.ts
Normal file
81
skills/scripts/src/cli/commands/forms.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerFormsCommands(program: Command) {
|
||||
// Select option command
|
||||
program
|
||||
.command('select')
|
||||
.description('Select option from dropdown (requires -s and -v)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector of select element')
|
||||
.requiredOption('-v, --value <value>', 'Option value to select')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(options.headless);
|
||||
try {
|
||||
try { await browser.connect(); } catch { await browser.launch(); }
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.selectOption(browser, options.selector, options.value);
|
||||
console.log('Selected:', result.value, 'in', result.selector);
|
||||
console.log('Browser will stay open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Check checkbox command
|
||||
program
|
||||
.command('check')
|
||||
.description('Check a checkbox (requires -s/--selector)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(options.headless);
|
||||
try {
|
||||
try { await browser.connect(); } catch { await browser.launch(); }
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.check(browser, options.selector);
|
||||
console.log('Checked:', result.selector);
|
||||
console.log('Browser will stay open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Uncheck checkbox command
|
||||
program
|
||||
.command('uncheck')
|
||||
.description('Uncheck a checkbox (requires -s/--selector)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(options.headless);
|
||||
try {
|
||||
try { await browser.connect(); } catch { await browser.launch(); }
|
||||
if (options.url) {
|
||||
await actions.navigate(browser, options.url);
|
||||
await actions.waitForLoad(browser);
|
||||
}
|
||||
const result = await actions.uncheck(browser, options.selector);
|
||||
console.log('Unchecked:', result.selector);
|
||||
console.log('Browser will stay open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
243
skills/scripts/src/cli/commands/interaction.ts
Normal file
243
skills/scripts/src/cli/commands/interaction.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
import { findSelectorWithRetry } from './selector-helper';
|
||||
import { getOutputDir } from '../../cdp/config';
|
||||
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
|
||||
import * as path from 'path';
|
||||
|
||||
export function registerInteractionCommands(program: Command) {
|
||||
// Click command
|
||||
program
|
||||
.command('click')
|
||||
.description('Click an element (use -s for selector or --text for smart mode)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.option('-s, --selector <selector>', 'CSS selector to click (direct mode)')
|
||||
.option('--text <text>', 'Text content to search for (smart mode)')
|
||||
.option('--index <number>', 'Select nth match (1-based, for duplicate text)', parseInt)
|
||||
.option('--type <type>', 'Element type filter (e.g., button, input)')
|
||||
.option('--viewport-only', 'Only search visible elements', false)
|
||||
.option('--verify', 'Verify action success', false)
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
|
||||
}
|
||||
|
||||
let selector = options.selector;
|
||||
|
||||
// Smart mode: query map if text option provided
|
||||
if (options.text && !selector) {
|
||||
console.log(`🔍 Searching for: "${options.text}"${options.index ? ` (match #${options.index})` : ''}`);
|
||||
console.log(`📁 Map path: ${path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME)}`);
|
||||
|
||||
selector = await findSelectorWithRetry({
|
||||
text: options.text,
|
||||
index: options.index,
|
||||
type: options.type,
|
||||
viewportOnly: options.viewportOnly
|
||||
}, 'element');
|
||||
|
||||
if (!selector) {
|
||||
console.error(' Try using --selector for direct mode');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Found element with selector: ${selector}`);
|
||||
}
|
||||
|
||||
// Validate selector
|
||||
if (!selector) {
|
||||
console.error('❌ No selector provided. Use either:');
|
||||
console.error(' --selector <selector> (direct mode)');
|
||||
console.error(' --text <text> (smart mode)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const response = await executeViaDaemon('click', {
|
||||
selector,
|
||||
verify: options.verify
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('✓ Clicked:', selector);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('❌ Click failed:', response.error);
|
||||
}
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Fill command
|
||||
program
|
||||
.command('fill')
|
||||
.description('Fill an input field (requires -v/--value)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.option('-s, --selector <selector>', 'CSS selector of input field (direct mode)')
|
||||
.option('--label <label>', 'Label or placeholder text to search for (smart mode)')
|
||||
.option('--type <type>', 'Input type filter (e.g., input-text, input-password)', 'input')
|
||||
.option('--viewport-only', 'Only search visible elements', false)
|
||||
.requiredOption('-v, --value <value>', 'Value to fill')
|
||||
.option('--verify', 'Verify action success', false)
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
|
||||
}
|
||||
|
||||
let selector = options.selector;
|
||||
|
||||
// Smart mode: query map if label option provided
|
||||
if (options.label && !selector) {
|
||||
console.log(`🔍 Searching for input: "${options.label}"`);
|
||||
|
||||
selector = await findSelectorWithRetry({
|
||||
text: options.label,
|
||||
type: options.type,
|
||||
viewportOnly: options.viewportOnly
|
||||
}, 'input field');
|
||||
|
||||
if (!selector) {
|
||||
console.error(' Try using --selector for direct mode');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Found input field with selector: ${selector}`);
|
||||
}
|
||||
|
||||
// Validate selector
|
||||
if (!selector) {
|
||||
console.error('❌ No selector provided. Use either:');
|
||||
console.error(' --selector <selector> (direct mode)');
|
||||
console.error(' --label <label> (smart mode)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const response = await executeViaDaemon('fill', {
|
||||
selector,
|
||||
value: options.value,
|
||||
verify: options.verify
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('✓ Filled:', selector, 'with:', options.value);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('❌ Fill failed:', response.error);
|
||||
}
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Hover command
|
||||
program
|
||||
.command('hover')
|
||||
.description('Hover over an element (requires -s/--selector)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector to hover')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (options.url) {
|
||||
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
|
||||
}
|
||||
|
||||
const response = await executeViaDaemon('hover', { selector: options.selector });
|
||||
|
||||
if (response.success) {
|
||||
console.log('✓ Hovered over:', options.selector);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('❌ Hover failed:', response.error);
|
||||
}
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Press key command
|
||||
program
|
||||
.command('press')
|
||||
.description('Press a keyboard key (requires -k/--key, e.g., Enter, Tab, Escape)')
|
||||
.requiredOption('-k, --key <key>', 'Key to press (e.g., Enter, Tab, Escape)')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('press', { key: options.key });
|
||||
|
||||
if (response.success) {
|
||||
console.log('✓ Pressed key:', options.key);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('❌ Press failed:', response.error);
|
||||
}
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Type text command
|
||||
program
|
||||
.command('type')
|
||||
.description('Type text character by character (requires -t/--text)')
|
||||
.requiredOption('-t, --text <text>', 'Text to type')
|
||||
.option('-d, --delay <ms>', 'Delay between characters (ms)', parseInt, 0)
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('type', {
|
||||
text: options.text,
|
||||
delay: options.delay
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('✓ Typed text:', options.text);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('❌ Type failed:', response.error);
|
||||
}
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload file command (not implemented in server yet, skip for now)
|
||||
program
|
||||
.command('upload')
|
||||
.description('Upload file to input element (requires -s/--selector and -f/--file)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector of file input')
|
||||
.requiredOption('-f, --file <path>', 'File path to upload')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async () => {
|
||||
console.error('Upload command not yet implemented in daemon mode');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Drag and drop command (not implemented in server yet, skip for now)
|
||||
program
|
||||
.command('drag')
|
||||
.description('Drag and drop element (requires --from and --to selectors)')
|
||||
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
|
||||
.requiredOption('--from <selector>', 'Source element selector')
|
||||
.requiredOption('--to <selector>', 'Target element selector')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async () => {
|
||||
console.error('Drag command not yet implemented in daemon mode');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
104
skills/scripts/src/cli/commands/navigation.ts
Normal file
104
skills/scripts/src/cli/commands/navigation.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
|
||||
export function registerNavigationCommands(program: Command) {
|
||||
// Navigate command
|
||||
program
|
||||
.command('navigate')
|
||||
.description('Navigate to a URL (requires -u/--url)')
|
||||
.requiredOption('-u, --url <url>', 'URL to navigate to')
|
||||
.option('--headless', 'Run in headless mode', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('navigate', { url: options.url });
|
||||
|
||||
if (response.success) {
|
||||
console.log('Navigated to:', options.url);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Navigation failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Go back command
|
||||
program
|
||||
.command('back')
|
||||
.description('Navigate back in browser history')
|
||||
.action(async () => {
|
||||
try {
|
||||
const response = await executeViaDaemon('back', {});
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; url?: string; error?: string };
|
||||
if (data.success) {
|
||||
console.log('Navigated back to:', data.url);
|
||||
} else {
|
||||
console.log(data.error);
|
||||
}
|
||||
} else {
|
||||
console.error('Back navigation failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Go forward command
|
||||
program
|
||||
.command('forward')
|
||||
.description('Navigate forward in history')
|
||||
.action(async () => {
|
||||
try {
|
||||
const response = await executeViaDaemon('forward', {});
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; url?: string; error?: string };
|
||||
if (data.success) {
|
||||
console.log('Navigated forward to:', data.url);
|
||||
} else {
|
||||
console.log(data.error);
|
||||
}
|
||||
} else {
|
||||
console.error('Forward navigation failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Reload command
|
||||
program
|
||||
.command('reload')
|
||||
.description('Reload the current page')
|
||||
.option('--hard', 'Hard reload (ignore cache)', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('reload', { hard: options.hard });
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; hardReload: boolean };
|
||||
console.log('Page reloaded (hard:', data.hardReload, ')');
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Reload failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
77
skills/scripts/src/cli/commands/network.ts
Normal file
77
skills/scripts/src/cli/commands/network.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerNetworkCommands(program: Command) {
|
||||
// Block URL command
|
||||
program
|
||||
.command('block-url')
|
||||
.description('Block network requests matching a URL pattern (e.g., "*.jpg", "*ads*", "*analytics*")')
|
||||
.requiredOption('-p, --pattern <pattern>', 'URL pattern to block (e.g., "*.jpg", "*ads*")')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.blockRequest(browser, options.pattern);
|
||||
console.log('Blocked URL pattern:', result.urlPattern);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Unblock URLs command
|
||||
program
|
||||
.command('unblock-urls')
|
||||
.description('Remove all network request blocks and allow all URLs to load')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
await actions.unblockRequests(browser);
|
||||
console.log('All URL blocks removed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable request interception
|
||||
program
|
||||
.command('enable-interception')
|
||||
.description('Enable network request interception for monitoring and modifying HTTP requests')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.enableRequestInterception(browser);
|
||||
console.log('Request interception enabled');
|
||||
console.log('Note:', result.note);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable request interception
|
||||
program
|
||||
.command('disable-interception')
|
||||
.description('Disable network request interception and return to normal browsing mode')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
await actions.disableRequestInterception(browser);
|
||||
console.log('Request interception disabled');
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
199
skills/scripts/src/cli/commands/query.ts
Normal file
199
skills/scripts/src/cli/commands/query.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
|
||||
export function registerQueryCommands(program: Command) {
|
||||
// Query interaction map
|
||||
program
|
||||
.command('query')
|
||||
.description('Search interaction map for elements (by text, type, tag, or ID with pagination support)')
|
||||
.option('-t, --text <text>', 'Search by text content')
|
||||
.option('--type <type>', 'Filter by element type (supports aliases: "input" → "input-*")')
|
||||
.option('--tag <tag>', 'Filter by HTML tag (e.g., "input", "button")')
|
||||
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
|
||||
.option('--viewport-only', 'Only search visible elements in viewport', false)
|
||||
.option('--id <id>', 'Direct element ID lookup')
|
||||
.option('--list-types', 'List all element types with counts')
|
||||
.option('--list-texts', 'List all text contents')
|
||||
.option('--limit <number>', 'Maximum results to return (default: 20, 0=unlimited)', parseInt)
|
||||
.option('--offset <number>', 'Number of results to skip (default: 0)', parseInt)
|
||||
.option('--verbose', 'Include detailed information', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Build query parameters
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (options.text) params.text = options.text;
|
||||
if (options.type) params.type = options.type;
|
||||
if (options.tag) params.tag = options.tag;
|
||||
if (options.index) params.index = options.index;
|
||||
if (options.viewportOnly) params.viewportOnly = true;
|
||||
if (options.id) params.id = options.id;
|
||||
if (options.listTypes) params.listTypes = true;
|
||||
if (options.listTexts) params.listTexts = true;
|
||||
if (options.limit !== undefined) params.limit = options.limit;
|
||||
if (options.offset !== undefined) params.offset = options.offset;
|
||||
if (options.verbose) params.verbose = true;
|
||||
|
||||
// Execute query
|
||||
const response = await executeViaDaemon('query-map', params);
|
||||
|
||||
if (response.success) {
|
||||
const result = response.data as {
|
||||
count: number;
|
||||
results: Array<{
|
||||
selector: string;
|
||||
alternatives: string[];
|
||||
element: {
|
||||
tag: string;
|
||||
text: string | undefined;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
}>;
|
||||
types?: Record<string, number>;
|
||||
texts?: Array<{ text: string; type: string; count: number }>;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
// Handle listTypes output
|
||||
if (options.listTypes && result.types) {
|
||||
console.log(`\n=== Element Types ===`);
|
||||
const sortedTypes = Object.entries(result.types).sort((a, b) => b[1] - a[1]);
|
||||
sortedTypes.forEach(([type, count]) => {
|
||||
console.log(`${type}: ${count}`);
|
||||
});
|
||||
console.log(`\nTotal: ${result.total} elements`);
|
||||
}
|
||||
// Handle listTexts output
|
||||
else if (options.listTexts && result.texts) {
|
||||
const limit = options.limit !== undefined ? options.limit : 20;
|
||||
const showingText = limit === 0 ? 'all' : `${result.count}/${result.total}`;
|
||||
console.log(`\n=== Text Contents (showing ${showingText}) ===\n`);
|
||||
|
||||
result.texts.forEach((item, idx) => {
|
||||
const num = (options.offset || 0) + idx + 1;
|
||||
const textPreview = item.text.length > 50 ? item.text.substring(0, 50) + '...' : item.text;
|
||||
console.log(`${num}. "${textPreview}" (${item.type}${item.count > 1 ? `, ${item.count} matches` : ''})`);
|
||||
});
|
||||
|
||||
if (limit > 0 && result.total && result.total > result.count + (options.offset || 0)) {
|
||||
console.log(`\nUse --limit to see more, or --type to filter`);
|
||||
}
|
||||
}
|
||||
// Handle regular query output
|
||||
else {
|
||||
const showingText = result.total ? `${result.count}/${result.total}` : `${result.count}`;
|
||||
console.log(`\n=== Query Results (${showingText}) ===`);
|
||||
|
||||
if (result.count === 0) {
|
||||
console.log('\nNo elements found matching your query.');
|
||||
} else {
|
||||
result.results.forEach((item, idx) => {
|
||||
const num = (options.offset || 0) + idx + 1;
|
||||
console.log(`\n[${num}]`);
|
||||
if (!options.verbose) {
|
||||
// Compact format
|
||||
const textPreview = item.element.text ?
|
||||
(item.element.text.length > 50 ? item.element.text.substring(0, 50) + '...' : item.element.text) : '';
|
||||
console.log(` <${item.element.tag}> ${textPreview ? `"${textPreview}"` : '(no text)'}`);
|
||||
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
|
||||
console.log(` Selector: ${item.selector}`);
|
||||
} else {
|
||||
// Verbose format
|
||||
console.log(` Tag: <${item.element.tag}>`);
|
||||
if (item.element.text) {
|
||||
const text = item.element.text.substring(0, 100);
|
||||
console.log(` Text: "${text}${item.element.text.length > 100 ? '...' : ''}"`);
|
||||
}
|
||||
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
|
||||
console.log(` 📍 Best Selector: ${item.selector}`);
|
||||
|
||||
if (item.alternatives.length > 0 && item.alternatives.length <= 3) {
|
||||
console.log(` Alternatives:`);
|
||||
item.alternatives.forEach(alt => console.log(` - ${alt}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.total && result.total > result.count + (options.offset || 0)) {
|
||||
console.log(`\nUse --limit and --offset for pagination, or --verbose for details`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Query failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get map status
|
||||
program
|
||||
.command('map-status')
|
||||
.description('Check interaction map status (URL, element count, cache validity, timestamp)')
|
||||
.action(async () => {
|
||||
try {
|
||||
const response = await executeViaDaemon('get-map-status', {});
|
||||
|
||||
if (response.success) {
|
||||
const status = response.data as {
|
||||
exists: boolean;
|
||||
url: string | null;
|
||||
timestamp: string | null;
|
||||
elementCount: number;
|
||||
cacheValid: boolean;
|
||||
};
|
||||
|
||||
console.log(`\n=== Interaction Map Status ===`);
|
||||
console.log(`Map exists: ${status.exists ? 'Yes' : 'No'}`);
|
||||
if (status.exists) {
|
||||
console.log(`URL: ${status.url || 'Unknown'}`);
|
||||
console.log(`Timestamp: ${status.timestamp || 'Unknown'}`);
|
||||
console.log(`Element count: ${status.elementCount}`);
|
||||
console.log(`Cache valid: ${status.cacheValid ? 'Yes (< 10 minutes)' : 'No (expired or not cached)'}`);
|
||||
}
|
||||
|
||||
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Status retrieval failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Force regenerate map
|
||||
program
|
||||
.command('regen-map')
|
||||
.description('Force rebuild interaction map (use when page content changes or map is stale)')
|
||||
.action(async () => {
|
||||
try {
|
||||
console.log('Regenerating interaction map...');
|
||||
const response = await executeViaDaemon('generate-map', { force: true });
|
||||
|
||||
if (response.success) {
|
||||
const map = response.data as { url: string; timestamp: string; elementCount: number };
|
||||
console.log(`✓ Map regenerated successfully`);
|
||||
console.log(` URL: ${map.url}`);
|
||||
console.log(` Timestamp: ${map.timestamp}`);
|
||||
console.log(` Total elements: ${map.elementCount}`);
|
||||
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Map regeneration failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
34
skills/scripts/src/cli/commands/scroll.ts
Normal file
34
skills/scripts/src/cli/commands/scroll.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Command } from 'commander';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
|
||||
export function registerScrollCommands(program: Command) {
|
||||
// Scroll command
|
||||
program
|
||||
.command('scroll')
|
||||
.description('Scroll the page or a specific element to coordinates (x, y) or use a CSS selector to scroll an element into view')
|
||||
.requiredOption('-x, --x <pixels>', 'Horizontal scroll position', parseInt)
|
||||
.requiredOption('-y, --y <pixels>', 'Vertical scroll position', parseInt)
|
||||
.option('-s, --selector <selector>', 'CSS selector to scroll (optional)')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const response = await executeViaDaemon('scroll', {
|
||||
x: options.x,
|
||||
y: options.y,
|
||||
selector: options.selector
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data as { success: boolean; position: { x: number; y: number } };
|
||||
console.log('Scrolled to:', data.position);
|
||||
console.log('Browser will stay open. Use "daemon-stop" to close it.');
|
||||
} else {
|
||||
console.error('Scroll failed:', response.error);
|
||||
}
|
||||
|
||||
process.exit(response.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
71
skills/scripts/src/cli/commands/selector-helper.ts
Normal file
71
skills/scripts/src/cli/commands/selector-helper.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Selector helper utilities with automatic map regeneration fallback
|
||||
*/
|
||||
|
||||
import { findSelector } from '../../cdp/map/query-map';
|
||||
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
|
||||
import { getOutputDir } from '../../cdp/config';
|
||||
import { executeViaDaemon } from '../daemon-helper';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface SelectorQueryParams {
|
||||
text: string;
|
||||
index?: number;
|
||||
type?: string;
|
||||
viewportOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find selector with automatic map regeneration fallback
|
||||
*
|
||||
* This function queries the interaction map for an element matching the given criteria.
|
||||
* If the element is not found, it automatically regenerates the map and retries once.
|
||||
*
|
||||
* @param params - Selector query parameters (text, index, type, viewportOnly)
|
||||
* @param elementType - Type of element being searched (for logging, e.g., "element", "input field")
|
||||
* @returns Selector string or null if not found after retry
|
||||
*/
|
||||
export async function findSelectorWithRetry(
|
||||
params: SelectorQueryParams,
|
||||
elementType: string = 'element'
|
||||
): Promise<string | null> {
|
||||
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
|
||||
// First attempt
|
||||
let selector = findSelector(mapPath, params);
|
||||
|
||||
// Fallback: regenerate map if element not found
|
||||
if (!selector) {
|
||||
console.log(`⚠️ ${elementType.charAt(0).toUpperCase() + elementType.slice(1)} not found in map, regenerating map and retrying...`);
|
||||
|
||||
try {
|
||||
// Execute generate-map via daemon (force: true to regenerate)
|
||||
const regenResponse = await executeViaDaemon('generate-map', { force: true }, { verbose: false });
|
||||
|
||||
if (!regenResponse.success) {
|
||||
console.error(`✗ Failed to regenerate map: ${regenResponse.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`🔄 Map regenerated, retrying selector search...`);
|
||||
|
||||
// Allow file system to flush (especially important on Windows)
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Retry finding selector
|
||||
selector = findSelector(mapPath, params);
|
||||
|
||||
if (!selector) {
|
||||
console.error(`❌ ${elementType.charAt(0).toUpperCase() + elementType.slice(1)} still not found after map regeneration`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`✓ Found ${elementType} after map regeneration: ${selector}`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Error during map regeneration: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
222
skills/scripts/src/cli/commands/system.ts
Normal file
222
skills/scripts/src/cli/commands/system.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* System maintenance commands
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { DaemonManager } from '../../daemon/manager';
|
||||
import { loadSharedConfig, saveSharedConfig } from '../../cdp/config';
|
||||
import { getLocalTimestamp } from '../../utils/timestamp';
|
||||
import { logger } from '../../utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Show usage error and exit
|
||||
*/
|
||||
function showUsageAndExit(message: string, usage: string): never {
|
||||
console.error(`❌ Error: ${message}`);
|
||||
console.error(`Usage: ${usage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate project root path
|
||||
*/
|
||||
function validateProjectRoot(projectRoot: string): string {
|
||||
// Normalize path to prevent path traversal attacks
|
||||
const normalized = path.normalize(projectRoot);
|
||||
|
||||
// Check if path exists
|
||||
if (!fs.existsSync(normalized)) {
|
||||
throw new Error(`Project root does not exist: ${normalized}`);
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
const stats = fs.statSync(normalized);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Project root is not a directory: ${normalized}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate port number
|
||||
*/
|
||||
function parsePort(value: string): number {
|
||||
const port = parseInt(value, 10);
|
||||
if (isNaN(port)) {
|
||||
throw new Error('Port must be a valid number');
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export function registerSystemCommands(program: Command) {
|
||||
// Reinstall command
|
||||
program
|
||||
.command('reinstall')
|
||||
.description('Reinstall Browser Pilot scripts (removes .browser-pilot directory)')
|
||||
.option('-y, --yes', 'Skip confirmation prompt')
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
||||
const browserPilotDir = path.join(projectRoot, '.browser-pilot');
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(browserPilotDir)) {
|
||||
if (!options.quiet) {
|
||||
console.log('✨ .browser-pilot directory not found. Nothing to reinstall.');
|
||||
console.log('Run any command to initialize Browser Pilot.');
|
||||
}
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmation prompt (skip if --yes flag provided)
|
||||
if (!options.yes) {
|
||||
console.log('⚠️ This will remove the .browser-pilot directory and stop the daemon.');
|
||||
console.log('📁 Directory: ' + browserPilotDir);
|
||||
console.log('');
|
||||
console.log('Next command will trigger automatic reinstallation.');
|
||||
console.log('');
|
||||
console.log('Use --yes flag to skip this prompt.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop daemon if running
|
||||
const manager = new DaemonManager();
|
||||
if (await manager.isRunning()) {
|
||||
if (!options.quiet) {
|
||||
console.log('🛑 Stopping daemon...');
|
||||
}
|
||||
try {
|
||||
await manager.stop({ verbose: false, force: true });
|
||||
if (!options.quiet) {
|
||||
console.log('✓ Daemon stopped');
|
||||
}
|
||||
} catch (stopError) {
|
||||
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
|
||||
console.error('⚠️ Failed to stop daemon:', errorMessage);
|
||||
console.error('Continuing anyway, but manual cleanup may be needed.');
|
||||
if (stopError instanceof Error && stopError.stack) {
|
||||
logger.debug(`Stack trace: ${stopError.stack}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!options.quiet) {
|
||||
console.log('✓ Daemon not running');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove .browser-pilot directory
|
||||
if (!options.quiet) {
|
||||
console.log('🗑️ Removing .browser-pilot directory...');
|
||||
}
|
||||
fs.rmSync(browserPilotDir, { recursive: true, force: true });
|
||||
|
||||
if (!options.quiet) {
|
||||
console.log('✨ Browser Pilot reinstalled successfully!');
|
||||
console.log('');
|
||||
console.log('Run any command to initialize Browser Pilot:');
|
||||
console.log(' node .browser-pilot/bp navigate -u "https://example.com"');
|
||||
console.log('');
|
||||
console.log('Note: The .browser-pilot directory will be recreated automatically.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('❌ Error during reinstall:', errorMessage);
|
||||
if (error instanceof Error && error.stack) {
|
||||
logger.debug(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Change port command
|
||||
program
|
||||
.command('change-port')
|
||||
.description('Change Chrome DevTools Protocol port for current project')
|
||||
.option('-p, --port <number>', 'New port number', parsePort)
|
||||
.option('-q, --quiet', 'Suppress output')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
||||
const projectName = path.basename(projectRoot);
|
||||
|
||||
if (!options.port) {
|
||||
showUsageAndExit('Port number is required', 'change-port -p <port>');
|
||||
}
|
||||
|
||||
const newPort = options.port;
|
||||
|
||||
// Validate port number
|
||||
if (isNaN(newPort) || newPort < 1024 || newPort > 65535) {
|
||||
console.error('❌ Error: Invalid port number');
|
||||
console.error('Port must be between 1024 and 65535');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load config
|
||||
const config = loadSharedConfig();
|
||||
const projectConfig = config.projects[projectName];
|
||||
|
||||
if (!projectConfig) {
|
||||
console.error('❌ Error: Project configuration not found');
|
||||
console.error('Run any Browser Pilot command to initialize the project first');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPort = projectConfig.port;
|
||||
|
||||
// Stop daemon if running
|
||||
const manager = new DaemonManager();
|
||||
if (await manager.isRunning()) {
|
||||
if (!options.quiet) {
|
||||
console.log('🛑 Stopping daemon on port ' + oldPort + '...');
|
||||
}
|
||||
try {
|
||||
await manager.stop({ verbose: false, force: true });
|
||||
if (!options.quiet) {
|
||||
console.log('✓ Daemon stopped');
|
||||
}
|
||||
} catch (stopError) {
|
||||
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
|
||||
console.error('⚠️ Failed to stop daemon:', errorMessage);
|
||||
console.error('Continuing anyway, but daemon may still be running on old port.');
|
||||
if (stopError instanceof Error && stopError.stack) {
|
||||
logger.debug(`Stack trace: ${stopError.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update port in config
|
||||
projectConfig.port = newPort;
|
||||
projectConfig.lastUsed = getLocalTimestamp();
|
||||
saveSharedConfig(config);
|
||||
|
||||
if (!options.quiet) {
|
||||
console.log('✅ Port changed successfully!');
|
||||
console.log('');
|
||||
console.log('Old port: ' + oldPort);
|
||||
console.log('New port: ' + newPort);
|
||||
console.log('');
|
||||
console.log('The daemon will use the new port on next command.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('❌ Error changing port:', errorMessage);
|
||||
if (error instanceof Error && error.stack) {
|
||||
logger.debug(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
118
skills/scripts/src/cli/commands/tabs.ts
Normal file
118
skills/scripts/src/cli/commands/tabs.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { DaemonManager } from '../../daemon/manager';
|
||||
|
||||
export function registerTabsCommands(program: Command) {
|
||||
// List tabs command
|
||||
program
|
||||
.command('tabs')
|
||||
.description('List all open tabs with their index numbers, titles, and URLs')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.listTabs(browser);
|
||||
const tabs = result.tabs as Array<{ index: number; title: string; url: string; targetId: string }>;
|
||||
console.log(`Found ${result.count} tabs:`);
|
||||
tabs.forEach((tab) => {
|
||||
console.log(`[${tab.index}] ${tab.title} - ${tab.url}`);
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New tab command
|
||||
program
|
||||
.command('new-tab')
|
||||
.description('Open a new tab in the browser (optionally navigate to a specific URL with -u)')
|
||||
.option('-u, --url <url>', 'URL to open', 'about:blank')
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.newTab(browser, options.url);
|
||||
console.log('New tab opened:', result.targetId);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Close tab command
|
||||
program
|
||||
.command('close-tab')
|
||||
.description('Close a specific tab by its index number (use "tabs" command to see index numbers)')
|
||||
.requiredOption('-i, --index <number>', 'Tab index to close', parseInt)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.closeTab(browser, undefined, options.index);
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Switch tab
|
||||
program
|
||||
.command('switch-tab')
|
||||
.description('Switch to a different tab by its index number (use "tabs" command to see index numbers)')
|
||||
.requiredOption('-i, --index <index>', 'Tab index', parseInt)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.switchTab(browser, options.index);
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Close browser command
|
||||
program
|
||||
.command('close')
|
||||
.description('Close the browser completely and stop the daemon process')
|
||||
.action(async () => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
const daemonManager = new DaemonManager();
|
||||
|
||||
try {
|
||||
// Close browser first
|
||||
await browser.connect();
|
||||
await browser.close();
|
||||
console.log('✓ Browser closed');
|
||||
|
||||
// Then stop daemon
|
||||
if (await daemonManager.isRunning()) {
|
||||
await daemonManager.stop({ verbose: true });
|
||||
console.log('✓ Daemon stopped');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// Try to stop daemon even if browser close failed
|
||||
try {
|
||||
if (await daemonManager.isRunning()) {
|
||||
await daemonManager.stop({ verbose: true });
|
||||
console.log('✓ Daemon stopped');
|
||||
}
|
||||
} catch (daemonError) {
|
||||
console.error('Warning: Could not stop daemon:', daemonError);
|
||||
}
|
||||
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
62
skills/scripts/src/cli/commands/wait.ts
Normal file
62
skills/scripts/src/cli/commands/wait.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Command } from 'commander';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
export function registerWaitCommands(program: Command) {
|
||||
// Wait for element command
|
||||
program
|
||||
.command('wait')
|
||||
.description('Wait for a specific element to appear in the DOM using a CSS selector with optional timeout')
|
||||
.requiredOption('-s, --selector <selector>', 'CSS selector to wait for')
|
||||
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 30000)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.waitFor(browser, options.selector, options.timeout);
|
||||
console.log('Element found:', result.selector);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait milliseconds
|
||||
program
|
||||
.command('sleep')
|
||||
.description('Pause execution for a specified duration in milliseconds (useful for waiting between actions or for animations to complete)')
|
||||
.requiredOption('-t, --time <ms>', 'Milliseconds to wait', parseInt)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.waitMilliseconds(browser, options.time);
|
||||
console.log(`Waited ${result.waitedMs}ms`);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for network idle
|
||||
program
|
||||
.command('wait-idle')
|
||||
.description('Wait for all network requests to complete and the page to become idle (useful after navigation or dynamic content loading)')
|
||||
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 5000)
|
||||
.action(async (options) => {
|
||||
const browser = new ChromeBrowser(false);
|
||||
try {
|
||||
await browser.connect();
|
||||
const result = await actions.waitForNetworkIdle(browser, options.timeout);
|
||||
console.log('Network is idle:', result.state);
|
||||
console.log('Browser remains open. Use "close" command to close it.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
68
skills/scripts/src/cli/daemon-helper.ts
Normal file
68
skills/scripts/src/cli/daemon-helper.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Helper functions for daemon-based CLI commands
|
||||
*/
|
||||
|
||||
import { IPCClient } from '../daemon/client';
|
||||
import { DaemonManager } from '../daemon/manager';
|
||||
import { IPCResponse } from '../daemon/protocol';
|
||||
import { TIMING } from '../constants';
|
||||
|
||||
/**
|
||||
* Execute command via daemon (auto-start if needed)
|
||||
*/
|
||||
export async function executeViaDaemon(
|
||||
command: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: {
|
||||
timeout?: number;
|
||||
verbose?: boolean;
|
||||
autoStart?: boolean;
|
||||
} = {}
|
||||
): Promise<IPCResponse> {
|
||||
const { timeout = TIMING.WAIT_FOR_NAVIGATION, verbose = true, autoStart = true } = options;
|
||||
|
||||
// Ensure daemon is running
|
||||
if (autoStart) {
|
||||
const manager = new DaemonManager();
|
||||
|
||||
// If navigate command and daemon not running, pass initial URL
|
||||
const isRunning = await manager.isRunning();
|
||||
const initialUrl = (command === 'navigate' && !isRunning && params.url)
|
||||
? params.url as string
|
||||
: undefined;
|
||||
|
||||
await manager.ensureRunning({ verbose, initialUrl });
|
||||
}
|
||||
|
||||
// Send command to daemon
|
||||
const client = new IPCClient();
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest(command, params, timeout);
|
||||
return response;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and display command result
|
||||
*/
|
||||
export function displayResult(response: IPCResponse, verbose: boolean = true): void {
|
||||
if (!verbose) return;
|
||||
|
||||
if (response.success) {
|
||||
// Display success result based on data type
|
||||
const data = response.data;
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
// Pretty print objects
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else if (data !== undefined) {
|
||||
// Print primitive values
|
||||
console.log(data);
|
||||
}
|
||||
} else {
|
||||
console.error('Error:', response.error);
|
||||
}
|
||||
}
|
||||
208
skills/scripts/src/constants/index.ts
Normal file
208
skills/scripts/src/constants/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Browser Pilot Constants
|
||||
* 모든 매직 넘버, URL, 타이밍 등을 중앙에서 관리
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chrome DevTools Protocol 관련 상수
|
||||
* @property DEFAULT_PORT - 기본 디버깅 포트 (9222)
|
||||
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
|
||||
* @property LOCALHOST - 로컬 호스트 주소
|
||||
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
|
||||
* @property NAVIGATION_TIMEOUT - 페이지 네비게이션 타임아웃 (30초)
|
||||
* @property EVALUATION_TIMEOUT - 스크립트 실행 타임아웃 (10초)
|
||||
*/
|
||||
export const CDP = {
|
||||
DEFAULT_PORT: 9222,
|
||||
PORT_RANGE_MAX: 100,
|
||||
LOCALHOST: '127.0.0.1',
|
||||
WS_TIMEOUT: 30000, // 30 seconds
|
||||
NAVIGATION_TIMEOUT: 30000, // 30 seconds
|
||||
EVALUATION_TIMEOUT: 10000, // 10 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 파일 시스템 관련 상수
|
||||
* @property OUTPUT_DIR - 출력 디렉토리 (.browser-pilot)
|
||||
* @property SCREENSHOTS_DIR - 스크린샷 디렉토리 (screenshots)
|
||||
* @property PDFS_DIR - PDF 디렉토리 (pdfs)
|
||||
* @property INTERACTION_MAP_FILE - Interaction Map 파일명
|
||||
* @property MAP_CACHE_FILE - Map 캐시 파일명
|
||||
* @property DAEMON_PID_FILE - 데몬 PID 파일명
|
||||
* @property DAEMON_SOCKET - 데몬 소켓 파일명
|
||||
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
|
||||
*/
|
||||
export const FS = {
|
||||
OUTPUT_DIR: '.browser-pilot',
|
||||
SCREENSHOTS_DIR: 'screenshots',
|
||||
PDFS_DIR: 'pdfs',
|
||||
INTERACTION_MAP_FILE: 'interaction-map.json',
|
||||
MAP_CACHE_FILE: 'map-cache.json',
|
||||
DAEMON_PID_FILE: 'daemon.pid',
|
||||
DAEMON_SOCKET: 'daemon.sock',
|
||||
GITIGNORE_CONTENT: `# Browser Pilot generated files
|
||||
*
|
||||
`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
|
||||
* @property DEFAULT_WAIT_TIMEOUT - 기본 대기 타임아웃 (30초)
|
||||
* @property NETWORK_IDLE_TIMEOUT - 네트워크 idle 체크 간격 (500ms)
|
||||
* @property MAP_CACHE_TTL - Map 캐시 유효 기간 (10분)
|
||||
* @property DAEMON_IDLE_TIMEOUT - 데몬 idle 타임아웃 (30분)
|
||||
* @property DAEMON_PING_INTERVAL - 데몬 ping 간격 (5초)
|
||||
* @property SCREENSHOT_DELAY - 스크린샷 딜레이 (100ms)
|
||||
* @property HOOK_INPUT_TIMEOUT - Hook stdin 읽기 타임아웃 (100ms)
|
||||
* @property ACTION_DELAY_SHORT - 짧은 액션 딜레이 (50ms)
|
||||
* @property ACTION_DELAY_MEDIUM - 표준 액션 딜레이 (100ms)
|
||||
* @property ACTION_DELAY_LONG - 긴 액션 딜레이 (500ms)
|
||||
* @property ACTION_DELAY_NAVIGATION - 네비게이션/페이지 로드 딜레이 (1초)
|
||||
* @property POLLING_INTERVAL_FAST - 빠른 폴링 간격 (100ms)
|
||||
* @property POLLING_INTERVAL_STANDARD - 표준 폴링 간격 (500ms)
|
||||
* @property POLLING_INTERVAL_SLOW - 느린 폴링 간격 (1초)
|
||||
* @property WAIT_FOR_ELEMENT - 엘리먼트 대기 타임아웃 (5초)
|
||||
* @property WAIT_FOR_NAVIGATION - 네비게이션 대기 타임아웃 (30초)
|
||||
* @property WAIT_FOR_LOAD_STATE - 로드 상태 대기 타임아웃 (30초)
|
||||
* @property RECENT_MESSAGE_WINDOW - 최근 에러/경고 감지 윈도우 (5초)
|
||||
*/
|
||||
export const TIMING = {
|
||||
DEFAULT_WAIT_TIMEOUT: 30000, // 30 seconds
|
||||
NETWORK_IDLE_TIMEOUT: 500, // 500ms
|
||||
MAP_CACHE_TTL: 600000, // 10 minutes
|
||||
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
|
||||
DAEMON_PING_INTERVAL: 5000, // 5 seconds
|
||||
SCREENSHOT_DELAY: 100, // 100ms
|
||||
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
|
||||
|
||||
// Action delays
|
||||
ACTION_DELAY_SHORT: 50, // 50ms - very short delay
|
||||
ACTION_DELAY_MEDIUM: 100, // 100ms - standard action delay
|
||||
ACTION_DELAY_LONG: 500, // 500ms - longer action delay
|
||||
ACTION_DELAY_NAVIGATION: 1000, // 1s - navigation/page load delay
|
||||
|
||||
// Polling intervals
|
||||
POLLING_INTERVAL_FAST: 100, // 100ms - fast polling
|
||||
POLLING_INTERVAL_STANDARD: 500, // 500ms - standard polling
|
||||
POLLING_INTERVAL_SLOW: 1000, // 1s - slow polling
|
||||
|
||||
// Wait timeouts
|
||||
WAIT_FOR_ELEMENT: 5000, // 5s - wait for element
|
||||
WAIT_FOR_NAVIGATION: 30000, // 30s - wait for navigation
|
||||
WAIT_FOR_LOAD_STATE: 30000, // 30s - wait for load state
|
||||
|
||||
// Message/Error detection windows
|
||||
RECENT_MESSAGE_WINDOW: 5000, // 5s - recent error/warning detection window
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 시간 단위 변환 상수
|
||||
* @property MS_PER_SECOND - 1초당 밀리초 (1000)
|
||||
* @property MS_PER_MINUTE - 1분당 밀리초 (60000)
|
||||
* @property MS_PER_HOUR - 1시간당 밀리초 (3600000)
|
||||
*/
|
||||
export const TIME_CONVERSION = {
|
||||
MS_PER_SECOND: 1000,
|
||||
MS_PER_MINUTE: 60000,
|
||||
MS_PER_HOUR: 3600000,
|
||||
} as const;
|
||||
|
||||
// HTTP Status
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_ERROR: 500,
|
||||
} as const;
|
||||
|
||||
// Screenshot
|
||||
export const SCREENSHOT = {
|
||||
DEFAULT_FORMAT: 'png' as const,
|
||||
DEFAULT_QUALITY: 80,
|
||||
FULL_PAGE: true,
|
||||
} as const;
|
||||
|
||||
// PDF
|
||||
export const PDF = {
|
||||
DEFAULT_FORMAT: 'A4' as const,
|
||||
DEFAULT_MARGIN: {
|
||||
top: '1cm',
|
||||
right: '1cm',
|
||||
bottom: '1cm',
|
||||
left: '1cm',
|
||||
},
|
||||
PRINT_BACKGROUND: true,
|
||||
} as const;
|
||||
|
||||
// Interaction Map
|
||||
export const INTERACTION_MAP = {
|
||||
CACHE_TTL: 600000, // 10 minutes
|
||||
MAX_ELEMENTS: 10000,
|
||||
SELECTOR_PRIORITY: ['byId', 'byText', 'byCSS', 'byRole', 'byAriaLabel'] as const,
|
||||
} as const;
|
||||
|
||||
// Daemon
|
||||
export const DAEMON = {
|
||||
IPC_TIMEOUT: 5000, // 5 seconds
|
||||
MAX_RETRIES: 3,
|
||||
RETRY_DELAY: 1000, // 1 second
|
||||
IDLE_CHECK_INTERVAL: 60000, // 1 minute
|
||||
} as const;
|
||||
|
||||
// Browser
|
||||
export const BROWSER = {
|
||||
USER_AGENT_OVERRIDE: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
DEFAULT_VIEWPORT: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
HEADLESS: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 환경 변수 이름 상수
|
||||
* @property CDP_DEBUG_PORT - Chrome 디버깅 포트 환경 변수명
|
||||
* @property CLAUDE_PROJECT_DIR - Claude 프로젝트 디렉토리 환경 변수명
|
||||
* @property CLAUDE_PLUGIN_ROOT - Claude 플러그인 루트 환경 변수명
|
||||
*/
|
||||
export const ENV = {
|
||||
CDP_DEBUG_PORT: 'CDP_DEBUG_PORT',
|
||||
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
|
||||
CLAUDE_PLUGIN_ROOT: 'CLAUDE_PLUGIN_ROOT',
|
||||
} as const;
|
||||
|
||||
// Error Messages
|
||||
export const ERROR_MESSAGES = {
|
||||
PROJECT_ROOT_NOT_FOUND: '[browser-pilot] Could not determine project root',
|
||||
ELEMENT_NOT_FOUND: 'Element not found',
|
||||
TIMEOUT: 'Operation timed out',
|
||||
NAVIGATION_FAILED: 'Navigation failed',
|
||||
DAEMON_NOT_RUNNING: 'Daemon is not running',
|
||||
DAEMON_START_FAILED: 'Failed to start daemon',
|
||||
PORT_NOT_AVAILABLE: 'No available port found',
|
||||
CONFIG_LOAD_FAILED: 'Failed to load configuration',
|
||||
INVALID_SELECTOR: 'Invalid selector',
|
||||
} as const;
|
||||
|
||||
// Success Messages
|
||||
export const SUCCESS_MESSAGES = {
|
||||
NAVIGATION_COMPLETE: 'Navigation complete',
|
||||
ELEMENT_CLICKED: 'Element clicked',
|
||||
FORM_FILLED: 'Form filled',
|
||||
SCREENSHOT_SAVED: 'Screenshot saved',
|
||||
PDF_GENERATED: 'PDF generated',
|
||||
DAEMON_STARTED: 'Daemon started',
|
||||
DAEMON_STOPPED: 'Daemon stopped',
|
||||
} as const;
|
||||
|
||||
// Regex Patterns
|
||||
export const PATTERNS = {
|
||||
XPATH: /^\/\//,
|
||||
CSS_ID: /^#[a-zA-Z0-9_-]+$/,
|
||||
CSS_CLASS: /^\.[a-zA-Z0-9_-]+$/,
|
||||
URL: /^https?:\/\//,
|
||||
} as const;
|
||||
212
skills/scripts/src/daemon/client.ts
Normal file
212
skills/scripts/src/daemon/client.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* IPC Client for Browser Pilot Daemon
|
||||
* Used by CLI commands to communicate with the daemon
|
||||
*/
|
||||
|
||||
import { Socket, connect } from 'net';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getOutputDir } from '../cdp/config';
|
||||
import {
|
||||
IPCRequest,
|
||||
IPCResponse,
|
||||
IPCError,
|
||||
IPCErrorCodes,
|
||||
SOCKET_PATH_PREFIX,
|
||||
DEFAULT_TIMEOUT,
|
||||
getProjectSocketName
|
||||
} from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class IPCClient {
|
||||
private socket: Socket | null = null;
|
||||
private socketPath: string;
|
||||
private pendingRequests: Map<string, {
|
||||
resolve: (response: IPCResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}> = new Map();
|
||||
private buffer: string = '';
|
||||
|
||||
constructor() {
|
||||
const outputDir = getOutputDir();
|
||||
this.socketPath = this.getSocketPath(outputDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket path (platform-specific, project-unique)
|
||||
*/
|
||||
private getSocketPath(outputDir: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: project-specific named pipe
|
||||
const socketName = getProjectSocketName();
|
||||
return `\\\\.\\pipe\\${socketName}`;
|
||||
} else {
|
||||
// Unix domain socket (already project-specific via outputDir)
|
||||
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to daemon
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.socket && !this.socket.destroyed) {
|
||||
return; // Already connected
|
||||
}
|
||||
|
||||
// Check if socket file exists (Unix only)
|
||||
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
|
||||
throw new IPCError('Daemon not running (socket file not found)', IPCErrorCodes.DAEMON_NOT_RUNNING);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = connect(this.socketPath);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.setupSocket();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
reject(new IPCError(`Connection failed: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup socket event handlers
|
||||
*/
|
||||
private setupSocket(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('data', (data) => {
|
||||
this.buffer += data.toString();
|
||||
|
||||
// Process complete JSON messages (delimited by newline)
|
||||
const messages = this.buffer.split('\n');
|
||||
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
try {
|
||||
const response: IPCResponse = JSON.parse(message);
|
||||
this.handleResponse(response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse response', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
logger.error('Socket error', error);
|
||||
this.rejectAllPending(new IPCError(`Socket error: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.socket = null;
|
||||
this.rejectAllPending(new IPCError('Connection closed', IPCErrorCodes.CONNECTION_ERROR));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response from daemon
|
||||
*/
|
||||
private handleResponse(response: IPCResponse): void {
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
|
||||
if (!pending) {
|
||||
logger.warn(`Received response for unknown request: ${response.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(response.id);
|
||||
|
||||
if (response.success) {
|
||||
pending.resolve(response);
|
||||
} else {
|
||||
pending.reject(new IPCError(response.error || 'Command failed', IPCErrorCodes.COMMAND_FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all pending requests
|
||||
*/
|
||||
private rejectAllPending(error: Error): void {
|
||||
for (const [_id, pending] of this.pendingRequests.entries()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request to daemon
|
||||
*/
|
||||
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DEFAULT_TIMEOUT): Promise<IPCResponse> {
|
||||
await this.connect();
|
||||
|
||||
if (!this.socket || this.socket.destroyed) {
|
||||
throw new IPCError('Not connected to daemon', IPCErrorCodes.CONNECTION_ERROR);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const request: IPCRequest = {
|
||||
id: requestId,
|
||||
command,
|
||||
params,
|
||||
timeout
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new IPCError(`Request timeout after ${timeout}ms`, IPCErrorCodes.TIMEOUT));
|
||||
}, timeout);
|
||||
|
||||
// Store pending request
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timeoutHandle
|
||||
});
|
||||
|
||||
// Send request
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.socket!.write(JSON.stringify(request) + '\n', (error) => {
|
||||
if (error) {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new IPCError(`Failed to send request: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection
|
||||
*/
|
||||
close(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.rejectAllPending(new IPCError('Client closed', IPCErrorCodes.CONNECTION_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running
|
||||
*/
|
||||
static isDaemonRunning(): boolean {
|
||||
const outputDir = getOutputDir();
|
||||
const socketPath = process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\${SOCKET_PATH_PREFIX}`
|
||||
: join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
|
||||
return existsSync(socketPath);
|
||||
}
|
||||
}
|
||||
83
skills/scripts/src/daemon/handlers/capture-handlers.ts
Normal file
83
skills/scripts/src/daemon/handlers/capture-handlers.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Capture command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { HandlerContext } from './navigation-handlers';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
/**
|
||||
* Handle screenshot command
|
||||
*/
|
||||
export async function handleScreenshot(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const filename = params.filename as string | undefined;
|
||||
const fullPage = params.fullPage !== false; // Default true
|
||||
|
||||
// Parse clip options if provided
|
||||
let clip: actions.ClipOptions | undefined;
|
||||
if (params.clipX !== undefined && params.clipY !== undefined &&
|
||||
params.clipWidth !== undefined && params.clipHeight !== undefined) {
|
||||
clip = {
|
||||
x: params.clipX as number,
|
||||
y: params.clipY as number,
|
||||
width: params.clipWidth as number,
|
||||
height: params.clipHeight as number,
|
||||
scale: params.clipScale as number | undefined
|
||||
};
|
||||
}
|
||||
|
||||
return actions.screenshot(context.browser, filename || 'screenshot.png', fullPage, clip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set viewport size command
|
||||
*/
|
||||
export async function handleSetViewport(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const width = params.width as number;
|
||||
const height = params.height as number;
|
||||
const deviceScaleFactor = (params.deviceScaleFactor as number) || 1;
|
||||
const mobile = (params.mobile as boolean) || false;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('Width and height are required for viewport');
|
||||
}
|
||||
|
||||
return actions.setViewportSize(context.browser, width, height, deviceScaleFactor, mobile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get viewport command
|
||||
*/
|
||||
export async function handleGetViewport(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return actions.getViewport(context.browser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get screen info command
|
||||
*/
|
||||
export async function handleGetScreenInfo(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return actions.getScreenInfo(context.browser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PDF generation command
|
||||
*/
|
||||
export async function handlePdf(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const filename = params.filename as string | undefined;
|
||||
const landscape = params.landscape as boolean | undefined;
|
||||
return actions.generatePdf(context.browser, filename || 'page.pdf', landscape || false);
|
||||
}
|
||||
56
skills/scripts/src/daemon/handlers/data-handlers.ts
Normal file
56
skills/scripts/src/daemon/handlers/data-handlers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Data extraction command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { HandlerContext } from './navigation-handlers';
|
||||
import * as actions from '../../cdp/actions';
|
||||
|
||||
/**
|
||||
* Handle extract command (text extraction)
|
||||
*/
|
||||
export async function handleExtract(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const selector = params.selector as string | undefined;
|
||||
|
||||
// If selectors object provided, use extractData for multiple selectors
|
||||
if (params.selectors && typeof params.selectors === 'object') {
|
||||
return actions.extractData(context.browser, params.selectors as Record<string, string>);
|
||||
}
|
||||
|
||||
// Otherwise use extractText for single selector
|
||||
return actions.extractText(context.browser, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle content command (get page HTML)
|
||||
*/
|
||||
export async function handleContent(
|
||||
context: HandlerContext,
|
||||
_params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return actions.getContent(context.browser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle find command (find element)
|
||||
*/
|
||||
export async function handleFind(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const selector = params.selector as string;
|
||||
return actions.findElement(context.browser, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle JavaScript evaluation command
|
||||
*/
|
||||
export async function handleEval(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const expression = params.expression as string;
|
||||
return actions.evaluate(context.browser, expression);
|
||||
}
|
||||
55
skills/scripts/src/daemon/handlers/index.ts
Normal file
55
skills/scripts/src/daemon/handlers/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Unified exports for all Browser Pilot Daemon handlers
|
||||
*/
|
||||
|
||||
// Export handler context type
|
||||
export type { HandlerContext } from './navigation-handlers';
|
||||
|
||||
// Navigation handlers
|
||||
export {
|
||||
handleNavigate,
|
||||
handleBack,
|
||||
handleForward,
|
||||
handleReload
|
||||
} from './navigation-handlers';
|
||||
|
||||
// Interaction handlers
|
||||
export {
|
||||
handleClick,
|
||||
handleFill,
|
||||
handleHover,
|
||||
handlePress,
|
||||
handleType
|
||||
} from './interaction-handlers';
|
||||
|
||||
// Capture handlers
|
||||
export {
|
||||
handleScreenshot,
|
||||
handlePdf,
|
||||
handleSetViewport,
|
||||
handleGetViewport,
|
||||
handleGetScreenInfo
|
||||
} from './capture-handlers';
|
||||
|
||||
// Data handlers
|
||||
export {
|
||||
handleExtract,
|
||||
handleContent,
|
||||
handleFind,
|
||||
handleEval
|
||||
} from './data-handlers';
|
||||
|
||||
// Map handlers
|
||||
export {
|
||||
handleQueryMap,
|
||||
handleGenerateMap,
|
||||
handleGetMapStatus
|
||||
} from './map-handlers';
|
||||
|
||||
// Utility handlers
|
||||
export {
|
||||
handleScroll,
|
||||
handleWait,
|
||||
handleConsole,
|
||||
handleStatus
|
||||
} from './utility-handlers';
|
||||
293
skills/scripts/src/daemon/handlers/interaction-handlers.ts
Normal file
293
skills/scripts/src/daemon/handlers/interaction-handlers.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Interaction command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import { HandlerContext } from './navigation-handlers';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Page change tracker for monitoring action effects
|
||||
*/
|
||||
interface PageChangeTracker {
|
||||
urlBefore: string;
|
||||
urlAfter: string | null;
|
||||
navigationDetected: boolean;
|
||||
domChangeDetected: boolean;
|
||||
networkActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector query parameters for Smart Mode
|
||||
*/
|
||||
interface SelectorQueryParams {
|
||||
text: string;
|
||||
index?: number;
|
||||
type?: string;
|
||||
tag?: string;
|
||||
viewportOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get current URL from browser
|
||||
*/
|
||||
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
|
||||
try {
|
||||
const result = await browser.sendCommand<{ result: { value: string } }>(
|
||||
'Runtime.evaluate',
|
||||
{ expression: 'window.location.href', returnByValue: true }
|
||||
);
|
||||
return result.result?.value || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Execute action with automatic state tracking
|
||||
*/
|
||||
async function executeActionWithTracking<T>(
|
||||
browser: ChromeBrowser,
|
||||
actionFn: () => Promise<T>
|
||||
): Promise<{ result: T; tracker: PageChangeTracker }> {
|
||||
// Capture state before action
|
||||
const urlBefore = await getCurrentUrl(browser);
|
||||
|
||||
const pageChangeTracker: PageChangeTracker = {
|
||||
urlBefore,
|
||||
urlAfter: null,
|
||||
navigationDetected: false,
|
||||
domChangeDetected: false,
|
||||
networkActive: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute action
|
||||
const result = await actionFn();
|
||||
|
||||
// Capture state after action
|
||||
const urlAfter = await getCurrentUrl(browser);
|
||||
pageChangeTracker.urlAfter = urlAfter;
|
||||
pageChangeTracker.navigationDetected = urlBefore !== urlAfter;
|
||||
|
||||
return { result, tracker: pageChangeTracker };
|
||||
} finally {
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Find selector with 3-stage fallback logic
|
||||
* Stage 1: Type-based search (with alias expansion)
|
||||
* Stage 2: Tag-based search
|
||||
* Stage 3: Map regeneration + retry (max 3 attempts)
|
||||
*/
|
||||
async function findSelectorWithRetry(
|
||||
context: HandlerContext,
|
||||
params: SelectorQueryParams
|
||||
): Promise<string> {
|
||||
const { findSelector } = await import('../../cdp/map/query-map');
|
||||
const { SELECTOR_RETRY_CONFIG } = await import('../../cdp/actions/helpers');
|
||||
const { getOutputDir } = await import('../../cdp/config');
|
||||
const path = await import('path');
|
||||
|
||||
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
logger.debug(`🔍 Smart Mode: querying map for text="${params.text}"`);
|
||||
|
||||
let foundSelector: string | null = null;
|
||||
let attemptCount = 0;
|
||||
const maxAttempts = 3;
|
||||
const originalType = params.type;
|
||||
|
||||
while (!foundSelector && attemptCount < maxAttempts) {
|
||||
attemptCount++;
|
||||
|
||||
// Stage 1: Try with type (with alias expansion)
|
||||
if (params.type && !params.tag) {
|
||||
logger.debug(`[Attempt ${attemptCount}] Type-based search: "${params.type}"`);
|
||||
foundSelector = findSelector(mapPath, {
|
||||
text: params.text,
|
||||
index: params.index,
|
||||
type: params.type,
|
||||
viewportOnly: params.viewportOnly
|
||||
});
|
||||
|
||||
if (foundSelector) {
|
||||
logger.debug(`✓ Found selector with type search: ${foundSelector}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Stage 2: Fallback to tag-based search
|
||||
if (originalType) {
|
||||
const baseTag = originalType.split('-')[0];
|
||||
logger.debug(`[Attempt ${attemptCount}] Type failed, trying tag: "${baseTag}"`);
|
||||
|
||||
foundSelector = findSelector(mapPath, {
|
||||
text: params.text,
|
||||
index: params.index,
|
||||
tag: baseTag,
|
||||
viewportOnly: params.viewportOnly
|
||||
});
|
||||
|
||||
if (foundSelector) {
|
||||
logger.debug(`✓ Found selector with tag search: ${foundSelector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No type specified, just search
|
||||
foundSelector = findSelector(mapPath, {
|
||||
text: params.text,
|
||||
index: params.index,
|
||||
type: params.type,
|
||||
tag: params.tag,
|
||||
viewportOnly: params.viewportOnly
|
||||
});
|
||||
|
||||
if (foundSelector) {
|
||||
logger.debug(`✓ Found selector: ${foundSelector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Regenerate map and retry
|
||||
if (!foundSelector && context.mapManager && attemptCount < maxAttempts) {
|
||||
logger.warn(`[Attempt ${attemptCount}] Element not found, regenerating map...`);
|
||||
|
||||
await context.mapManager.generateMap(context.browser, true);
|
||||
logger.debug('🔄 Map regenerated, retrying...');
|
||||
}
|
||||
}
|
||||
|
||||
// Final check
|
||||
if (!foundSelector) {
|
||||
let errorMsg = `Element not found after ${attemptCount} attempt(s): "${params.text}"\n`;
|
||||
errorMsg += '\n💡 Troubleshooting:\n';
|
||||
errorMsg += `- Check text is exact: --text "${params.text}"\n`;
|
||||
if (params.type) {
|
||||
const baseTag = params.type.split('-')[0];
|
||||
errorMsg += `- Try tag search: --tag ${baseTag}\n`;
|
||||
}
|
||||
errorMsg += `- List available elements: node .browser-pilot/bp query --list-texts\n`;
|
||||
errorMsg += `- Remove filters: try searching without --type or --viewport-only\n`;
|
||||
|
||||
logger.error(`❌ ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return foundSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click command with smart mode support
|
||||
*/
|
||||
export async function handleClick(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
let selector = params.selector as string | undefined;
|
||||
|
||||
// Smart Mode: if text provided, query map
|
||||
if (params.text && !selector) {
|
||||
selector = await findSelectorWithRetry(context, {
|
||||
text: params.text as string,
|
||||
index: params.index as number | undefined,
|
||||
type: params.type as string | undefined,
|
||||
tag: params.tag as string | undefined,
|
||||
viewportOnly: params.viewportOnly as boolean | undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (!selector) {
|
||||
throw new Error('No selector provided');
|
||||
}
|
||||
|
||||
// Execute with tracking
|
||||
const { result, tracker } = await executeActionWithTracking(
|
||||
context.browser,
|
||||
() => actions.click(context.browser, selector)
|
||||
);
|
||||
|
||||
// Always regenerate map after click (DOM may have changed, URL may or may not change)
|
||||
logger.debug(`🔄 Regenerating map after click (URL: ${tracker.urlBefore} → ${tracker.urlAfter})`);
|
||||
if (context.mapManager) {
|
||||
await context.mapManager.generateMapSerially(context.browser, false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fill command with smart mode support
|
||||
*/
|
||||
export async function handleFill(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
let selector = params.selector as string | undefined;
|
||||
const value = params.value as string;
|
||||
|
||||
// Smart Mode: if text provided, query map
|
||||
if (params.text && !selector) {
|
||||
selector = await findSelectorWithRetry(context, {
|
||||
text: params.text as string,
|
||||
index: params.index as number | undefined,
|
||||
type: params.type as string | undefined,
|
||||
tag: params.tag as string | undefined,
|
||||
viewportOnly: params.viewportOnly as boolean | undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (!selector) {
|
||||
throw new Error('No selector provided');
|
||||
}
|
||||
|
||||
// Execute with tracking
|
||||
const { result, tracker } = await executeActionWithTracking(
|
||||
context.browser,
|
||||
() => actions.fill(context.browser, selector, value)
|
||||
);
|
||||
|
||||
// Always regenerate map after fill (DOM may have changed, URL may or may not change)
|
||||
logger.debug(`🔄 Regenerating map after fill (URL: ${tracker.urlBefore} → ${tracker.urlAfter})`);
|
||||
if (context.mapManager) {
|
||||
await context.mapManager.generateMapSerially(context.browser, false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hover command
|
||||
*/
|
||||
export async function handleHover(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const selector = params.selector as string;
|
||||
return actions.hover(context.browser, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle press (keyboard key) command
|
||||
*/
|
||||
export async function handlePress(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const key = params.key as string;
|
||||
return actions.pressKey(context.browser, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle type (text input) command
|
||||
*/
|
||||
export async function handleType(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const text = params.text as string;
|
||||
const delay = params.delay as number | undefined;
|
||||
return actions.typeText(context.browser, text, delay);
|
||||
}
|
||||
227
skills/scripts/src/daemon/handlers/map-handlers.ts
Normal file
227
skills/scripts/src/daemon/handlers/map-handlers.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Interaction Map command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { HandlerContext, saveLastUrl } from './navigation-handlers';
|
||||
import { loadMap, queryMap, listTypes, listTexts } from '../../cdp/map/query-map';
|
||||
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
MapQueryParams,
|
||||
MapQueryResult,
|
||||
MapGenerateParams,
|
||||
MapGenerateResult,
|
||||
MapStatusResult
|
||||
} from '../protocol';
|
||||
|
||||
/**
|
||||
* Handle query-map command with 3-stage fallback logic
|
||||
*/
|
||||
export async function handleQueryMap(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<MapQueryResult> {
|
||||
const queryParams = params as MapQueryParams;
|
||||
|
||||
// Load map
|
||||
const mapPath = join(context.outputDir, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
|
||||
let currentMap = loadMap(mapPath);
|
||||
|
||||
// Handle listTypes request
|
||||
if (queryParams.listTypes) {
|
||||
const types = listTypes(currentMap);
|
||||
return {
|
||||
count: Object.keys(types).length,
|
||||
results: [],
|
||||
types,
|
||||
total: currentMap.statistics.total
|
||||
};
|
||||
}
|
||||
|
||||
// Handle listTexts request
|
||||
if (queryParams.listTexts) {
|
||||
const texts = listTexts(currentMap, {
|
||||
type: queryParams.type,
|
||||
limit: queryParams.limit,
|
||||
offset: queryParams.offset
|
||||
});
|
||||
return {
|
||||
count: texts.length,
|
||||
results: [],
|
||||
texts,
|
||||
total: Object.keys(currentMap.indexes.byText).length
|
||||
};
|
||||
}
|
||||
|
||||
// 3-stage fallback logic (max 3 attempts)
|
||||
let results: ReturnType<typeof queryMap> = [];
|
||||
let attemptCount = 0;
|
||||
const maxAttempts = 3;
|
||||
const originalType = queryParams.type;
|
||||
|
||||
while (results.length === 0 && attemptCount < maxAttempts) {
|
||||
attemptCount++;
|
||||
|
||||
// Stage 1: Try with type (with alias expansion)
|
||||
if (queryParams.type && !queryParams.tag) {
|
||||
logger.debug(`[Attempt ${attemptCount}] Trying type-based search: "${queryParams.type}"`);
|
||||
results = queryMap(currentMap, queryParams);
|
||||
|
||||
if (results.length > 0) {
|
||||
logger.debug(`✓ Found ${results.length} element(s) with type search`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Stage 2: Fallback to tag-based search
|
||||
// Extract base tag from type (e.g., "input-search" → "input")
|
||||
if (originalType) {
|
||||
const baseTag = originalType.split('-')[0];
|
||||
logger.debug(`[Attempt ${attemptCount}] Type search failed, trying tag-based search: "${baseTag}"`);
|
||||
|
||||
const tagParams = { ...queryParams, type: undefined, tag: baseTag };
|
||||
results = queryMap(currentMap, tagParams);
|
||||
|
||||
if (results.length > 0) {
|
||||
logger.debug(`✓ Found ${results.length} element(s) with tag search`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No type specified, just query
|
||||
results = queryMap(currentMap, queryParams);
|
||||
|
||||
if (results.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Regenerate map and retry
|
||||
if (results.length === 0 && context.mapManager && attemptCount < maxAttempts) {
|
||||
logger.warn(`[Attempt ${attemptCount}] No elements found, regenerating map and retrying...`);
|
||||
|
||||
await context.mapManager.generateMap(context.browser, true);
|
||||
logger.debug('🔄 Map regenerated, reloading and retrying...');
|
||||
|
||||
// Wait for map to be ready before continuing
|
||||
currentMap = loadMap(mapPath, true, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total count only once at the end
|
||||
const allResults = queryMap(currentMap, { ...queryParams, limit: 0 });
|
||||
|
||||
// Final check: no results found after all attempts
|
||||
if (results.length === 0 && !queryParams.listTypes && !queryParams.listTexts) {
|
||||
// Build detailed error message with edge case handling
|
||||
let errorMsg = 'No elements found matching query criteria after ' + attemptCount + ' attempt(s).\n';
|
||||
|
||||
errorMsg += '\n💡 Troubleshooting tips:\n';
|
||||
|
||||
if (queryParams.text) {
|
||||
errorMsg += `- Try searching without quotes: --text ${queryParams.text.replace(/"/g, '')}\n`;
|
||||
errorMsg += `- Try partial text: --text "${queryParams.text.substring(0, Math.min(10, queryParams.text.length))}"\n`;
|
||||
errorMsg += `- List all texts: node .browser-pilot/bp query --list-texts\n`;
|
||||
}
|
||||
|
||||
if (queryParams.type) {
|
||||
const baseTag = queryParams.type.split('-')[0];
|
||||
errorMsg += `- Try tag-based search: --tag ${baseTag}\n`;
|
||||
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
|
||||
errorMsg += `- Remove type filter and search by text only\n`;
|
||||
}
|
||||
|
||||
if (queryParams.tag) {
|
||||
errorMsg += `- Try type-based search: --type ${queryParams.tag}\n`;
|
||||
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
|
||||
}
|
||||
|
||||
if (!queryParams.text && !queryParams.type && !queryParams.tag) {
|
||||
errorMsg += `- Specify search criteria: --text, --type, or --tag\n`;
|
||||
errorMsg += `- List all elements: node .browser-pilot/bp query --list-types\n`;
|
||||
}
|
||||
|
||||
errorMsg += `- Force map regeneration: node .browser-pilot/bp regen-map\n`;
|
||||
errorMsg += `- Check if element is in viewport: --viewport-only (or remove if used)\n`;
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Return all results in MapQueryResult format
|
||||
return {
|
||||
count: results.length,
|
||||
results: results.map(result => ({
|
||||
selector: result.selector,
|
||||
alternatives: result.alternatives,
|
||||
element: {
|
||||
tag: result.element.tag,
|
||||
text: result.element.text,
|
||||
position: result.element.position
|
||||
}
|
||||
})),
|
||||
total: allResults.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generate-map command
|
||||
*/
|
||||
export async function handleGenerateMap(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<MapGenerateResult> {
|
||||
if (!context.mapManager) {
|
||||
throw new Error('MapManager not initialized');
|
||||
}
|
||||
|
||||
const generateParams = params as MapGenerateParams;
|
||||
const force = generateParams.force ?? false;
|
||||
|
||||
// Get current URL before generation
|
||||
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: 'window.location.href',
|
||||
returnByValue: true
|
||||
});
|
||||
const currentUrl = urlResult.result?.value || 'unknown';
|
||||
|
||||
// Check if we can use cache
|
||||
const cached = !force && context.mapManager.isCacheValid(currentUrl);
|
||||
|
||||
// Generate map
|
||||
const map = await context.mapManager.generateMap(context.browser, force);
|
||||
|
||||
// Save last visited URL
|
||||
if (currentUrl !== 'unknown') {
|
||||
await saveLastUrl(context.outputDir, currentUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: map.url,
|
||||
elementCount: map.statistics.total,
|
||||
timestamp: map.timestamp,
|
||||
cached
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get-map-status command
|
||||
*/
|
||||
export async function handleGetMapStatus(
|
||||
context: HandlerContext,
|
||||
_params: Record<string, unknown>
|
||||
): Promise<MapStatusResult> {
|
||||
if (!context.mapManager) {
|
||||
throw new Error('MapManager not initialized');
|
||||
}
|
||||
|
||||
// Get current URL
|
||||
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: 'window.location.href',
|
||||
returnByValue: true
|
||||
});
|
||||
const currentUrl = urlResult.result?.value || 'unknown';
|
||||
|
||||
// Get map status
|
||||
return context.mapManager.getMapStatus(currentUrl);
|
||||
}
|
||||
184
skills/scripts/src/daemon/handlers/navigation-handlers.ts
Normal file
184
skills/scripts/src/daemon/handlers/navigation-handlers.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Navigation command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ChromeBrowser } from '../../cdp/browser';
|
||||
import { MapManager } from '../map-manager';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Handler context containing dependencies
|
||||
*/
|
||||
export interface HandlerContext {
|
||||
browser: ChromeBrowser;
|
||||
mapManager?: MapManager;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get current URL from browser
|
||||
*/
|
||||
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
|
||||
try {
|
||||
const result = await browser.sendCommand<{ result: { value: string } }>(
|
||||
'Runtime.evaluate',
|
||||
{ expression: 'window.location.href', returnByValue: true }
|
||||
);
|
||||
return result.result?.value || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Save last visited URL to file
|
||||
*/
|
||||
export async function saveLastUrl(outputDir: string, url: string): Promise<void> {
|
||||
try {
|
||||
const lastUrlPath = path.join(outputDir, 'last-url.txt');
|
||||
await fs.promises.writeFile(lastUrlPath, url, 'utf-8');
|
||||
logger.debug(`💾 Saved last URL: ${url}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to save last URL: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Load last visited URL from file
|
||||
*/
|
||||
export async function loadLastUrl(outputDir: string): Promise<string | null> {
|
||||
try {
|
||||
const lastUrlPath = path.join(outputDir, 'last-url.txt');
|
||||
const url = await fs.promises.readFile(lastUrlPath, 'utf-8');
|
||||
const trimmedUrl = url.trim();
|
||||
logger.debug(`📂 Loaded last URL: ${trimmedUrl}`);
|
||||
return trimmedUrl || null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// File not found is an expected case, no need to log a warning
|
||||
return null;
|
||||
}
|
||||
logger.warn(`Failed to load last URL: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Wait for map to be ready for a specific URL
|
||||
*/
|
||||
async function waitForMapReady(
|
||||
context: HandlerContext,
|
||||
expectedUrl: string,
|
||||
_timeout: number
|
||||
): Promise<void> {
|
||||
logger.debug(`⏳ Waiting for map generation (URL: ${expectedUrl})...`);
|
||||
|
||||
if (!context.mapManager) {
|
||||
logger.warn('MapManager not available, skipping map generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if map exists and has correct URL
|
||||
const mapStatus = await context.mapManager.getMapStatus(expectedUrl);
|
||||
|
||||
if (!mapStatus.exists || mapStatus.url !== expectedUrl) {
|
||||
// Map doesn't exist or has wrong URL - generate new map
|
||||
logger.debug(`🔨 Generating new map for: ${expectedUrl}`);
|
||||
await context.mapManager.generateMapSerially(context.browser, false);
|
||||
// Above await completes only when map generation is fully done
|
||||
}
|
||||
|
||||
logger.debug(`✅ Map ready for: ${expectedUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigate command
|
||||
*/
|
||||
export async function handleNavigate(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const url = params.url as string;
|
||||
const result = await actions.navigate(context.browser, url);
|
||||
|
||||
// Navigation always changes URL, wait for map
|
||||
logger.info(`🔄 Navigating to: ${url}`);
|
||||
await waitForMapReady(context, url, 10000);
|
||||
|
||||
// Save last visited URL
|
||||
await saveLastUrl(context.outputDir, url);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle back command
|
||||
*/
|
||||
export async function handleBack(
|
||||
context: HandlerContext,
|
||||
_params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const result = await actions.goBack(context.browser);
|
||||
|
||||
// Get new URL after navigation
|
||||
const newUrl = await getCurrentUrl(context.browser);
|
||||
logger.info(`🔄 Navigated back to: ${newUrl}`);
|
||||
await waitForMapReady(context, newUrl, 10000);
|
||||
|
||||
// Save last visited URL
|
||||
if (newUrl !== 'unknown') {
|
||||
await saveLastUrl(context.outputDir, newUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle forward command
|
||||
*/
|
||||
export async function handleForward(
|
||||
context: HandlerContext,
|
||||
_params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const result = await actions.goForward(context.browser);
|
||||
|
||||
// Get new URL after navigation
|
||||
const newUrl = await getCurrentUrl(context.browser);
|
||||
logger.info(`🔄 Navigated forward to: ${newUrl}`);
|
||||
await waitForMapReady(context, newUrl, 10000);
|
||||
|
||||
// Save last visited URL
|
||||
if (newUrl !== 'unknown') {
|
||||
await saveLastUrl(context.outputDir, newUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reload command
|
||||
*/
|
||||
export async function handleReload(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const hard = params.hard as boolean | undefined;
|
||||
|
||||
// Get current URL before reload
|
||||
const currentUrl = await getCurrentUrl(context.browser);
|
||||
const result = await actions.reload(context.browser, hard || false);
|
||||
|
||||
// Reload stays on same URL, wait for map
|
||||
logger.info(`🔄 Reloading page: ${currentUrl}`);
|
||||
await waitForMapReady(context, currentUrl, 10000);
|
||||
|
||||
// Save last visited URL
|
||||
if (currentUrl !== 'unknown') {
|
||||
await saveLastUrl(context.outputDir, currentUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
79
skills/scripts/src/daemon/handlers/utility-handlers.ts
Normal file
79
skills/scripts/src/daemon/handlers/utility-handlers.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Utility command handlers for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { HandlerContext } from './navigation-handlers';
|
||||
import * as actions from '../../cdp/actions';
|
||||
import { DaemonState } from '../protocol';
|
||||
|
||||
/**
|
||||
* Handle scroll command
|
||||
*/
|
||||
export async function handleScroll(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const x = params.x as number;
|
||||
const y = params.y as number;
|
||||
return actions.scroll(context.browser, { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wait command
|
||||
*/
|
||||
export async function handleWait(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const duration = params.duration as number | undefined;
|
||||
if (duration) {
|
||||
// Simple sleep implementation
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
return { success: true, duration };
|
||||
} else {
|
||||
return actions.waitForLoad(context.browser);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle console command
|
||||
*/
|
||||
export async function handleConsole(
|
||||
context: HandlerContext,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const errorsOnly = params.errorsOnly as boolean | undefined;
|
||||
const result = await actions.getConsoleMessages(context.browser, errorsOnly);
|
||||
|
||||
if (params.clear) {
|
||||
context.browser.clearConsoleMessages();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status command
|
||||
*/
|
||||
export async function handleStatus(
|
||||
context: HandlerContext,
|
||||
_params: Record<string, unknown>,
|
||||
startTime: number,
|
||||
lastActivity: number
|
||||
): Promise<DaemonState> {
|
||||
const currentUrl = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: 'window.location.href',
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
currentUrl: currentUrl.result?.value || null,
|
||||
targetId: null, // CDP client doesn't expose targetId directly
|
||||
debugPort: context.browser.debugPort,
|
||||
consoleMessageCount: context.browser.getConsoleMessages().length,
|
||||
networkErrorCount: context.browser.getNetworkErrors().length,
|
||||
uptime: Date.now() - startTime,
|
||||
lastActivity: lastActivity
|
||||
};
|
||||
}
|
||||
596
skills/scripts/src/daemon/manager.ts
Normal file
596
skills/scripts/src/daemon/manager.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Daemon Process Manager
|
||||
* Handles starting, stopping, and checking status of the Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import { join, basename } from 'path';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import * as net from 'net';
|
||||
import { getOutputDir, loadSharedConfig, saveSharedConfig } from '../cdp/config';
|
||||
import { IPCClient } from './client';
|
||||
import {
|
||||
PID_FILENAME,
|
||||
SOCKET_PATH_PREFIX,
|
||||
DaemonState,
|
||||
MapQueryParams,
|
||||
MapQueryResult,
|
||||
MapGenerateParams,
|
||||
MapGenerateResult,
|
||||
MapStatusResult,
|
||||
getProjectSocketName
|
||||
} from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getLocalTimestamp } from '../utils/timestamp';
|
||||
import { TIMING, DAEMON } from '../constants';
|
||||
|
||||
export class DaemonManager {
|
||||
private outputDir: string;
|
||||
private pidPath: string;
|
||||
private socketPath: string;
|
||||
private cachedPid: { pid: number | null; timestamp: number } | null = null;
|
||||
private readonly PID_CACHE_TTL = 1000; // 1 second
|
||||
|
||||
constructor() {
|
||||
this.outputDir = getOutputDir();
|
||||
this.pidPath = join(this.outputDir, PID_FILENAME);
|
||||
this.socketPath = this.getSocketPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket path (platform-specific, project-unique)
|
||||
*/
|
||||
private getSocketPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: project-specific named pipe
|
||||
const socketName = getProjectSocketName();
|
||||
return `\\\\.\\pipe\\${socketName}`;
|
||||
} else {
|
||||
// Unix domain socket (already project-specific via outputDir)
|
||||
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon process with retry and port fallback
|
||||
*/
|
||||
async start(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
|
||||
const { verbose = true, initialUrl } = options;
|
||||
|
||||
// Check if already running
|
||||
if (await this.isRunning()) {
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon is already running');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log('🚀 Starting Browser Pilot Daemon...');
|
||||
}
|
||||
|
||||
// Get path to server.js (compiled output)
|
||||
const serverPath = join(__dirname, 'server.js');
|
||||
|
||||
if (!existsSync(serverPath)) {
|
||||
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
|
||||
}
|
||||
|
||||
// Try starting with retry logic
|
||||
const maxRetries = 2;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Prepare environment variables
|
||||
const env = { ...process.env };
|
||||
if (initialUrl) {
|
||||
env.BP_INITIAL_URL = initialUrl;
|
||||
if (verbose && attempt === 1) {
|
||||
logger.info(`Setting initial URL: ${initialUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn daemon as detached process
|
||||
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore', // Don't inherit stdio
|
||||
cwd: process.cwd(),
|
||||
env // Pass environment variables
|
||||
});
|
||||
|
||||
// Detach the process so it continues running when parent exits
|
||||
daemon.unref();
|
||||
|
||||
// Wait a bit for daemon to start
|
||||
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
|
||||
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon started successfully');
|
||||
}
|
||||
return; // Success!
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (verbose) {
|
||||
console.log(`⚠️ Attempt ${attempt}/${maxRetries} failed: ${lastError.message}`);
|
||||
}
|
||||
|
||||
// Stop any partially started daemon
|
||||
if (await this.isRunning()) {
|
||||
if (verbose) {
|
||||
console.log('🛑 Stopping partially started daemon...');
|
||||
}
|
||||
try {
|
||||
await this.stop({ verbose: false, force: true });
|
||||
} catch (stopError) {
|
||||
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
|
||||
logger.warn(`Failed to stop partially started daemon: ${errorMessage}`);
|
||||
// Continue to next retry
|
||||
}
|
||||
}
|
||||
|
||||
// On last retry, try changing port
|
||||
if (attempt === maxRetries) {
|
||||
if (verbose) {
|
||||
console.log('🔄 Attempting automatic port change...');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.changePortAutomatically(verbose);
|
||||
|
||||
// One more attempt with new port
|
||||
if (verbose) {
|
||||
console.log('🚀 Retrying with new port...');
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
if (initialUrl) {
|
||||
env.BP_INITIAL_URL = initialUrl;
|
||||
}
|
||||
|
||||
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: process.cwd(),
|
||||
env
|
||||
});
|
||||
|
||||
daemon.unref();
|
||||
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
|
||||
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon started successfully with new port');
|
||||
}
|
||||
return; // Success with new port!
|
||||
} catch (portChangeError) {
|
||||
if (verbose) {
|
||||
console.log(`⚠️ Port change also failed: ${(portChangeError as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit before retrying
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
throw new Error(`Failed to start daemon after ${maxRetries} attempts. Last error: ${lastError?.message || 'Unknown'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change port automatically to find available port
|
||||
*/
|
||||
private async changePortAutomatically(verbose: boolean): Promise<void> {
|
||||
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const projectName = basename(projectRoot);
|
||||
const config = loadSharedConfig();
|
||||
const projectConfig = config.projects[projectName];
|
||||
|
||||
if (!projectConfig) {
|
||||
throw new Error('Project configuration not found');
|
||||
}
|
||||
|
||||
const oldPort = projectConfig.port;
|
||||
const newPort = await this.findAvailablePort(oldPort);
|
||||
|
||||
if (verbose) {
|
||||
console.log(`📍 Changing port: ${oldPort} → ${newPort}`);
|
||||
}
|
||||
|
||||
projectConfig.port = newPort;
|
||||
projectConfig.lastUsed = getLocalTimestamp();
|
||||
saveSharedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find available port starting from base + 1
|
||||
*/
|
||||
private async findAvailablePort(basePort: number): Promise<number> {
|
||||
const MAX_PORTS = 100;
|
||||
const timeout = 10000; // 10 seconds total timeout
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let port = basePort + 1; port < basePort + MAX_PORTS; port++) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error('Timeout while searching for available port');
|
||||
}
|
||||
|
||||
if (await this.isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available ports found in range ${basePort + 1}-${basePort + MAX_PORTS}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if port is available
|
||||
*/
|
||||
private async isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try {
|
||||
server.close();
|
||||
} catch (error) {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false); // Timeout = not available
|
||||
}, 2000);
|
||||
|
||||
server.once('error', () => {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop daemon process
|
||||
*/
|
||||
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
|
||||
const { verbose = true, force = false } = options;
|
||||
|
||||
if (!(await this.isRunning())) {
|
||||
if (verbose) {
|
||||
console.log('⚠️ Daemon is not running');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log('🛑 Stopping Browser Pilot Daemon...');
|
||||
}
|
||||
|
||||
try {
|
||||
// Try graceful shutdown via IPC first
|
||||
if (!force) {
|
||||
const client = new IPCClient();
|
||||
await client.sendRequest('shutdown', {}, DAEMON.IPC_TIMEOUT);
|
||||
client.close();
|
||||
|
||||
// Wait for daemon to stop
|
||||
await this.waitForStop(DAEMON.IPC_TIMEOUT);
|
||||
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon stopped gracefully');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
if (verbose) {
|
||||
logger.warn('Graceful shutdown failed, forcing...');
|
||||
}
|
||||
}
|
||||
|
||||
// Force kill if graceful shutdown failed
|
||||
const pid = await this.getPid();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_SLOW));
|
||||
|
||||
// Check if still running
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
// Still running, force kill
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (_error) {
|
||||
// Process is gone, good
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon stopped (forced)');
|
||||
}
|
||||
} catch (_error) {
|
||||
// Process already gone
|
||||
if (verbose) {
|
||||
console.log('✓ Daemon stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up PID file
|
||||
if (existsSync(this.pidPath)) {
|
||||
unlinkSync(this.pidPath);
|
||||
}
|
||||
|
||||
// Clean up socket file (Unix only)
|
||||
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||
unlinkSync(this.socketPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart daemon
|
||||
*/
|
||||
async restart(options: { verbose?: boolean } = {}): Promise<void> {
|
||||
await this.stop(options);
|
||||
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION)); // Wait a bit
|
||||
await this.start(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon status
|
||||
*/
|
||||
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
if (!(await this.isRunning())) {
|
||||
if (verbose) {
|
||||
console.log('❌ Daemon is not running');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
const response = await client.sendRequest('status', {}, DAEMON.IPC_TIMEOUT);
|
||||
client.close();
|
||||
|
||||
const state = response.data as DaemonState;
|
||||
|
||||
if (verbose) {
|
||||
console.log('\n📊 Daemon Status:');
|
||||
console.log(` Connected: ${state.connected ? '✓' : '✗'}`);
|
||||
console.log(` Current URL: ${state.currentUrl || 'N/A'}`);
|
||||
console.log(` Debug Port: ${state.debugPort || 'N/A'}`);
|
||||
console.log(` Console Messages: ${state.consoleMessageCount}`);
|
||||
console.log(` Network Errors: ${state.networkErrorCount}`);
|
||||
console.log(` Uptime: ${Math.floor(state.uptime / TIMING.ACTION_DELAY_NAVIGATION)}s`);
|
||||
console.log(` Last Activity: ${new Date(state.lastActivity).toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
logger.error('Failed to get daemon status', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running
|
||||
*/
|
||||
async isRunning(): Promise<boolean> {
|
||||
const pid = await this.getPid();
|
||||
if (!pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Signal 0 checks if process exists without killing it
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// Process doesn't exist, clean up stale PID file and invalidate cache
|
||||
this.cachedPid = null;
|
||||
if (existsSync(this.pidPath)) {
|
||||
unlinkSync(this.pidPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon PID from PID file (with caching, async for non-blocking I/O)
|
||||
*/
|
||||
private async getPid(): Promise<number | null> {
|
||||
// Use cached value if available and fresh
|
||||
if (this.cachedPid && Date.now() - this.cachedPid.timestamp < this.PID_CACHE_TTL) {
|
||||
return this.cachedPid.pid;
|
||||
}
|
||||
|
||||
if (!existsSync(this.pidPath)) {
|
||||
this.cachedPid = { pid: null, timestamp: Date.now() };
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const pidStr = await readFile(this.pidPath, 'utf-8');
|
||||
const pid = parseInt(pidStr.trim(), 10);
|
||||
const result = isNaN(pid) ? null : pid;
|
||||
this.cachedPid = { pid: result, timestamp: Date.now() };
|
||||
return result;
|
||||
} catch (_error) {
|
||||
this.cachedPid = { pid: null, timestamp: Date.now() };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to start
|
||||
*/
|
||||
private async waitForDaemon(timeout: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await this.isRunning()) {
|
||||
// Also check if socket is available
|
||||
if (existsSync(this.socketPath) || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to start within timeout period');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to stop
|
||||
*/
|
||||
private async waitForStop(timeout: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (!(await this.isRunning())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to stop within timeout period');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure daemon is running (auto-start if needed)
|
||||
*/
|
||||
async ensureRunning(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
|
||||
if (!(await this.isRunning())) {
|
||||
await this.start(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query interaction map for elements
|
||||
*/
|
||||
async queryMap(params: MapQueryParams, options: { verbose?: boolean } = {}): Promise<MapQueryResult> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
await this.ensureRunning({ verbose: false });
|
||||
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
const response = await client.sendRequest('query-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE);
|
||||
client.close();
|
||||
|
||||
const result = response.data as MapQueryResult;
|
||||
|
||||
if (verbose) {
|
||||
console.log('\n🔍 Map Query Result:');
|
||||
console.log(` Total matches: ${result.count}`);
|
||||
if (result.count > 0) {
|
||||
const firstResult = result.results[0];
|
||||
console.log(` Best Selector: ${firstResult.selector}`);
|
||||
console.log(` Element: ${firstResult.element.tag} - "${firstResult.element.text || '(no text)'}"`);
|
||||
console.log(` Position: (${firstResult.element.position.x}, ${firstResult.element.position.y})`);
|
||||
if (firstResult.alternatives.length > 0) {
|
||||
console.log(` Alternatives: ${firstResult.alternatives.length} available`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
logger.error('Map query failed', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate interaction map for current page
|
||||
*/
|
||||
async generateMap(params: MapGenerateParams, options: { verbose?: boolean } = {}): Promise<MapGenerateResult> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
await this.ensureRunning({ verbose: false });
|
||||
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
const response = await client.sendRequest('generate-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE + DAEMON.IPC_TIMEOUT);
|
||||
client.close();
|
||||
|
||||
const result = response.data as MapGenerateResult;
|
||||
|
||||
if (verbose) {
|
||||
console.log('\n🗺️ Interaction Map Generated:');
|
||||
console.log(` URL: ${result.url}`);
|
||||
console.log(` Elements: ${result.elementCount}`);
|
||||
console.log(` Timestamp: ${result.timestamp}`);
|
||||
console.log(` Cached: ${result.cached ? '✓' : '✗'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
logger.error('Map generation failed', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interaction map status
|
||||
*/
|
||||
async getMapStatus(options: { verbose?: boolean } = {}): Promise<MapStatusResult> {
|
||||
const { verbose = true } = options;
|
||||
|
||||
await this.ensureRunning({ verbose: false });
|
||||
|
||||
try {
|
||||
const client = new IPCClient();
|
||||
const response = await client.sendRequest('get-map-status', {}, DAEMON.IPC_TIMEOUT);
|
||||
client.close();
|
||||
|
||||
const result = response.data as MapStatusResult;
|
||||
|
||||
if (verbose) {
|
||||
console.log('\n📊 Interaction Map Status:');
|
||||
console.log(` Exists: ${result.exists ? '✓' : '✗'}`);
|
||||
if (result.exists) {
|
||||
console.log(` URL: ${result.url || 'N/A'}`);
|
||||
console.log(` Elements: ${result.elementCount}`);
|
||||
console.log(` Timestamp: ${result.timestamp || 'N/A'}`);
|
||||
console.log(` Cache Valid: ${result.cacheValid ? '✓' : '✗ (expired)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
logger.error('Failed to get map status', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
527
skills/scripts/src/daemon/map-manager.ts
Normal file
527
skills/scripts/src/daemon/map-manager.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Interaction Map Manager for Browser Pilot Daemon
|
||||
* Handles automatic map generation, caching, and lifecycle management
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { ChromeBrowser } from '../cdp/browser';
|
||||
import { getInteractionMapScript, InteractionElement } from '../cdp/map/generate-interaction-map';
|
||||
import { InteractionMap } from '../cdp/map/query-map';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger, getLocalTimestamp } from '../utils/logger';
|
||||
import { FS, TIMING } from '../constants';
|
||||
|
||||
/**
|
||||
* Map cache metadata for a single URL
|
||||
*/
|
||||
interface MapCacheEntry {
|
||||
url: string;
|
||||
timestamp: string;
|
||||
elementCount: number;
|
||||
mapFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map cache file structure
|
||||
*/
|
||||
interface MapCacheFile {
|
||||
version: string;
|
||||
maps: MapCacheEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map generation event data
|
||||
*/
|
||||
export interface MapGenerationEvent {
|
||||
url: string;
|
||||
timestamp: string;
|
||||
elementCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for map management
|
||||
*/
|
||||
const MAP_CONFIG = {
|
||||
CACHE_MAX_AGE_MS: TIMING.MAP_CACHE_TTL,
|
||||
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
|
||||
CACHE_FILENAME: FS.MAP_CACHE_FILE,
|
||||
MAP_FOLDER: FS.OUTPUT_DIR,
|
||||
CACHE_VERSION: '1.0.0',
|
||||
DEBOUNCE_MS: TIMING.NETWORK_IDLE_TIMEOUT,
|
||||
MAP_GENERATION_DELAY_MS: TIMING.ACTION_DELAY_NAVIGATION
|
||||
} as const;
|
||||
|
||||
export class MapManager extends EventEmitter {
|
||||
private outputDir: string;
|
||||
private mapPath: string;
|
||||
private cachePath: string;
|
||||
private currentCache: MapCacheFile | null = null;
|
||||
private lastGenerationTime: number = 0;
|
||||
private generationDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private isGenerating: boolean = false;
|
||||
private currentGenerationPromise: Promise<InteractionMap> | null = null;
|
||||
|
||||
constructor(outputDir: string) {
|
||||
super();
|
||||
this.outputDir = outputDir;
|
||||
this.mapPath = join(outputDir, MAP_CONFIG.MAP_FILENAME);
|
||||
this.cachePath = join(outputDir, MAP_CONFIG.CACHE_FILENAME);
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing cache
|
||||
this.currentCache = this.loadCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate interaction map for current page
|
||||
*/
|
||||
async generateMap(browser: ChromeBrowser, force: boolean = false): Promise<InteractionMap> {
|
||||
this.isGenerating = true;
|
||||
|
||||
// Get current URL
|
||||
const urlResult = await browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: 'window.location.href',
|
||||
returnByValue: true
|
||||
});
|
||||
const url = urlResult.result?.value || 'unknown';
|
||||
|
||||
// Emit generation start event
|
||||
this.emit('generation-start', { url });
|
||||
|
||||
// Check if we should generate (unless forced)
|
||||
if (!force && !this.shouldGenerateMapForUrl(url)) {
|
||||
const cachedMap = this.loadMapFromFile();
|
||||
if (cachedMap && cachedMap.url === url && cachedMap.ready === true) {
|
||||
this.isGenerating = false;
|
||||
this.emit('generation-complete', {
|
||||
url: cachedMap.url,
|
||||
timestamp: cachedMap.timestamp,
|
||||
elementCount: cachedMap.statistics.total
|
||||
});
|
||||
return cachedMap;
|
||||
}
|
||||
}
|
||||
|
||||
// Write placeholder map with ready: false immediately
|
||||
const placeholderMap: InteractionMap = {
|
||||
url,
|
||||
timestamp: getLocalTimestamp(),
|
||||
ready: false,
|
||||
viewport: { width: 0, height: 0 },
|
||||
elements: {},
|
||||
indexes: { byText: {}, byType: {}, inViewport: [] },
|
||||
statistics: { total: 0, byType: {}, duplicates: 0 }
|
||||
};
|
||||
this.saveMapToFile(placeholderMap);
|
||||
|
||||
// Get viewport size
|
||||
const viewportResult = await browser.sendCommand<{
|
||||
layoutViewport: { clientWidth: number; clientHeight: number };
|
||||
}>('Page.getLayoutMetrics', {});
|
||||
const viewport = {
|
||||
width: viewportResult.layoutViewport.clientWidth,
|
||||
height: viewportResult.layoutViewport.clientHeight
|
||||
};
|
||||
|
||||
// Execute script to find all interactive elements
|
||||
const script = getInteractionMapScript();
|
||||
|
||||
interface RuntimeEvaluateResult {
|
||||
result?: {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
};
|
||||
exceptionDetails?: {
|
||||
exception?: {
|
||||
description?: string;
|
||||
};
|
||||
text?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: script,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
// Check for script execution errors
|
||||
if (result.exceptionDetails) {
|
||||
const errorMsg = result.exceptionDetails.exception?.description ||
|
||||
result.exceptionDetails.text ||
|
||||
'Unknown script error';
|
||||
logger.error('Map generation script error:', errorMsg);
|
||||
throw new Error(`Failed to extract interactive elements: ${errorMsg}`);
|
||||
}
|
||||
|
||||
if (!result.result || !result.result.value) {
|
||||
logger.error('Unexpected result structure:', JSON.stringify(result, null, 2));
|
||||
throw new Error('Failed to extract interactive elements: No value returned');
|
||||
}
|
||||
|
||||
const elementsArray = result.result.value as InteractionElement[];
|
||||
|
||||
// Generate statistics
|
||||
const byType: Record<string, number> = {};
|
||||
const textCounts: Record<string, number> = {};
|
||||
|
||||
elementsArray.forEach(el => {
|
||||
byType[el.type] = (byType[el.type] || 0) + 1;
|
||||
if (el.text) {
|
||||
textCounts[el.text] = (textCounts[el.text] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const duplicates = Object.values(textCounts).filter(count => count > 1).length;
|
||||
|
||||
// Add indexed selectors for duplicates
|
||||
elementsArray.forEach(el => {
|
||||
if (el.text && textCounts[el.text] > 1 && el.selectors.byText) {
|
||||
const sameTextElements = elementsArray.filter(e => e.text === el.text);
|
||||
const index = sameTextElements.indexOf(el) + 1;
|
||||
el.selectors.byText = `(${el.selectors.byText})[${index}]`;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert array to key-value structure
|
||||
const elements: Record<string, InteractionElement> = {};
|
||||
const textIndex: Record<string, string[]> = {};
|
||||
const typeIndex: Record<string, string[]> = {};
|
||||
const inViewportIds: string[] = [];
|
||||
|
||||
elementsArray.forEach(el => {
|
||||
// Add to elements map
|
||||
elements[el.id] = el;
|
||||
|
||||
// Build text index
|
||||
if (el.text) {
|
||||
if (!textIndex[el.text]) {
|
||||
textIndex[el.text] = [];
|
||||
}
|
||||
textIndex[el.text].push(el.id);
|
||||
}
|
||||
|
||||
// Build type index
|
||||
if (!typeIndex[el.type]) {
|
||||
typeIndex[el.type] = [];
|
||||
}
|
||||
typeIndex[el.type].push(el.id);
|
||||
|
||||
// Build viewport index
|
||||
if (el.visibility.inViewport) {
|
||||
inViewportIds.push(el.id);
|
||||
}
|
||||
});
|
||||
|
||||
const timestamp = getLocalTimestamp();
|
||||
|
||||
// Build map object with all data collected
|
||||
const map: InteractionMap = {
|
||||
url,
|
||||
timestamp,
|
||||
ready: true, // All data collected, ready to write
|
||||
viewport,
|
||||
elements,
|
||||
indexes: {
|
||||
byText: textIndex,
|
||||
byType: typeIndex,
|
||||
inViewport: inViewportIds
|
||||
},
|
||||
statistics: {
|
||||
total: elementsArray.length,
|
||||
byType,
|
||||
duplicates
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Save complete map to file in one write
|
||||
this.saveMapToFile(map);
|
||||
|
||||
// Update cache metadata
|
||||
this.updateCacheEntry(url, timestamp, map.statistics.total);
|
||||
|
||||
// Update last generation time
|
||||
this.lastGenerationTime = Date.now();
|
||||
|
||||
// Emit generation complete event
|
||||
this.emit('generation-complete', {
|
||||
url: map.url,
|
||||
timestamp: map.timestamp,
|
||||
elementCount: map.statistics.total
|
||||
});
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
this.emit('generation-error', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate map with lock to prevent concurrent executions
|
||||
* Returns a promise that resolves when map generation is complete
|
||||
*/
|
||||
async generateMapSerially(browser: ChromeBrowser, force: boolean = false): Promise<void> {
|
||||
// If already generating and not forced, return existing promise
|
||||
if (this.currentGenerationPromise && !force) {
|
||||
logger.debug('Map generation already in progress, waiting for completion...');
|
||||
await this.currentGenerationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing debounce timer (legacy support)
|
||||
if (this.generationDebounceTimer) {
|
||||
logger.debug(`Canceling previous map generation timer`);
|
||||
clearTimeout(this.generationDebounceTimer);
|
||||
}
|
||||
|
||||
// Generate with lock to prevent concurrent execution
|
||||
try {
|
||||
logger.debug('Generating map with lock...');
|
||||
this.currentGenerationPromise = this.generateMap(browser, force);
|
||||
await this.currentGenerationPromise;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Map generation failed: ${errorMessage}`);
|
||||
this.emit('generation-error', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.currentGenerationPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if map should be generated for a URL
|
||||
*/
|
||||
private shouldGenerateMapForUrl(url: string): boolean {
|
||||
if (!this.currentCache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find cache entry for this URL
|
||||
const entry = this.currentCache.maps.find(m => m.url === url);
|
||||
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if cache is still valid
|
||||
const cacheAge = Date.now() - new Date(entry.timestamp).getTime();
|
||||
return cacheAge > MAP_CONFIG.CACHE_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cached map is valid for a URL
|
||||
*/
|
||||
isCacheValid(url: string): boolean {
|
||||
return !this.shouldGenerateMapForUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map status for a URL
|
||||
*/
|
||||
getMapStatus(url: string): {
|
||||
exists: boolean;
|
||||
url: string | null;
|
||||
timestamp: string | null;
|
||||
elementCount: number;
|
||||
cacheValid: boolean;
|
||||
} {
|
||||
const mapExists = existsSync(this.mapPath);
|
||||
|
||||
if (!mapExists || !this.currentCache) {
|
||||
return {
|
||||
exists: false,
|
||||
url: null,
|
||||
timestamp: null,
|
||||
elementCount: 0,
|
||||
cacheValid: false
|
||||
};
|
||||
}
|
||||
|
||||
const entry = this.currentCache.maps.find(m => m.url === url);
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
exists: mapExists,
|
||||
url: null,
|
||||
timestamp: null,
|
||||
elementCount: 0,
|
||||
cacheValid: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
url: entry.url,
|
||||
timestamp: entry.timestamp,
|
||||
elementCount: entry.elementCount,
|
||||
cacheValid: this.isCacheValid(url)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load map cache from file
|
||||
*/
|
||||
private loadCache(): MapCacheFile {
|
||||
if (!existsSync(this.cachePath)) {
|
||||
return {
|
||||
version: MAP_CONFIG.CACHE_VERSION,
|
||||
maps: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(this.cachePath, 'utf-8');
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
|
||||
// Type guard to validate cache structure
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'version' in parsed &&
|
||||
'maps' in parsed &&
|
||||
Array.isArray((parsed as { maps: unknown }).maps)
|
||||
) {
|
||||
return parsed as MapCacheFile;
|
||||
}
|
||||
|
||||
// Invalid structure, return empty cache
|
||||
return {
|
||||
version: MAP_CONFIG.CACHE_VERSION,
|
||||
maps: []
|
||||
};
|
||||
} catch (_error: unknown) {
|
||||
logger.warn('Failed to load map cache, starting fresh');
|
||||
return {
|
||||
version: MAP_CONFIG.CACHE_VERSION,
|
||||
maps: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save map cache to file
|
||||
*/
|
||||
private saveCache(cache: MapCacheFile): void {
|
||||
try {
|
||||
writeFileSync(this.cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to save map cache: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache entry for a URL
|
||||
*/
|
||||
private updateCacheEntry(url: string, timestamp: string, elementCount: number): void {
|
||||
if (!this.currentCache) {
|
||||
this.currentCache = this.loadCache();
|
||||
}
|
||||
|
||||
// Remove old entry for this URL
|
||||
this.currentCache.maps = this.currentCache.maps.filter(m => m.url !== url);
|
||||
|
||||
// Add new entry
|
||||
this.currentCache.maps.push({
|
||||
url,
|
||||
timestamp,
|
||||
elementCount,
|
||||
mapFile: MAP_CONFIG.MAP_FILENAME
|
||||
});
|
||||
|
||||
// Save updated cache
|
||||
this.saveCache(this.currentCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load map from file
|
||||
*/
|
||||
private loadMapFromFile(): InteractionMap | null {
|
||||
if (!existsSync(this.mapPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(this.mapPath, 'utf-8');
|
||||
return JSON.parse(data) as InteractionMap;
|
||||
} catch (_error: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save map to file
|
||||
*/
|
||||
private saveMapToFile(map: InteractionMap): void {
|
||||
try {
|
||||
writeFileSync(this.mapPath, JSON.stringify(map, null, 2), 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to save map file: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for ongoing map generation to complete
|
||||
* @param timeout Maximum wait time in milliseconds (default: 10000)
|
||||
* @returns true if generation completed successfully, false if timeout
|
||||
*/
|
||||
async waitForGeneration(timeout: number = TIMING.WAIT_FOR_LOAD_STATE): Promise<boolean> {
|
||||
if (!this.isGenerating && !this.currentGenerationPromise) {
|
||||
return true; // Already ready
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
logger.warn('Map generation timeout');
|
||||
this.removeListener('generation-complete', onComplete);
|
||||
this.removeListener('generation-error', onError);
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
const onComplete = () => {
|
||||
clearTimeout(timer);
|
||||
this.removeListener('generation-error', onError);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
clearTimeout(timer);
|
||||
this.removeListener('generation-complete', onComplete);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
this.once('generation-complete', onComplete);
|
||||
this.once('generation-error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set ready flag in existing map file
|
||||
* Called by action handlers to invalidate map before action execution
|
||||
* @param ready Ready state to set (typically false to invalidate)
|
||||
*/
|
||||
setMapReady(ready: boolean): void {
|
||||
try {
|
||||
const map = this.loadMapFromFile();
|
||||
if (!map) {
|
||||
return; // No map to update
|
||||
}
|
||||
|
||||
map.ready = ready;
|
||||
this.saveMapToFile(map);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to update map ready flag: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
265
skills/scripts/src/daemon/protocol.ts
Normal file
265
skills/scripts/src/daemon/protocol.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* IPC Protocol definitions for Browser Pilot Daemon
|
||||
*/
|
||||
|
||||
// Type imports for protocol definitions (used by type definitions below)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { ConsoleMessage, NetworkError, FormattedConsoleMessage } from '../cdp/browser';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { QueryOptions, QueryResult, InteractionMap, InteractionElement } from '../cdp/map/query-map';
|
||||
|
||||
/**
|
||||
* IPC Request from CLI to Daemon
|
||||
*/
|
||||
export interface IPCRequest {
|
||||
id: string;
|
||||
command: string;
|
||||
params: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC Response from Daemon to CLI
|
||||
*/
|
||||
export interface IPCResponse {
|
||||
id: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daemon state information
|
||||
*/
|
||||
export interface DaemonState {
|
||||
connected: boolean;
|
||||
currentUrl: string | null;
|
||||
targetId: string | null;
|
||||
debugPort: number | null;
|
||||
consoleMessageCount: number;
|
||||
networkErrorCount: number;
|
||||
uptime: number;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command-specific parameter interfaces
|
||||
*/
|
||||
|
||||
export interface NavigateParams {
|
||||
url: string;
|
||||
waitForLoad?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ClickParams {
|
||||
selector: string;
|
||||
waitForSelector?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface FillParams {
|
||||
selector: string;
|
||||
value: string;
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
export interface ScrollParams {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface EvaluateParams {
|
||||
expression: string;
|
||||
returnByValue?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenshotParams {
|
||||
filename?: string;
|
||||
fullPage?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface ConsoleParams {
|
||||
errorsOnly?: boolean;
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
export interface WaitParams {
|
||||
selector?: string;
|
||||
timeout?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map-related parameter interfaces
|
||||
*/
|
||||
|
||||
export interface MapQueryParams extends QueryOptions {
|
||||
// Extends QueryOptions which already has: text, type, index, viewportOnly, id
|
||||
}
|
||||
|
||||
export interface MapGenerateParams {
|
||||
force?: boolean;
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command result interfaces
|
||||
*/
|
||||
|
||||
export interface ConsoleResult {
|
||||
messages: FormattedConsoleMessage[];
|
||||
count: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
logCount: number;
|
||||
}
|
||||
|
||||
export interface NavigateResult {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map-related result interfaces
|
||||
*/
|
||||
|
||||
export interface MapQueryResultItem {
|
||||
selector: string;
|
||||
alternatives: string[];
|
||||
element: {
|
||||
tag: string;
|
||||
text: string | undefined;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface MapQueryResult {
|
||||
count: number;
|
||||
results: MapQueryResultItem[];
|
||||
// Optional fields for list operations
|
||||
types?: Record<string, number>; // For listTypes
|
||||
texts?: Array<{ text: string; type: string; count: number }>; // For listTexts
|
||||
total?: number; // Total count before pagination
|
||||
}
|
||||
|
||||
export interface MapStatusResult {
|
||||
exists: boolean;
|
||||
url: string | null;
|
||||
timestamp: string | null;
|
||||
elementCount: number;
|
||||
cacheValid: boolean;
|
||||
}
|
||||
|
||||
export interface MapGenerateResult {
|
||||
success: boolean;
|
||||
url: string;
|
||||
elementCount: number;
|
||||
timestamp: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol constants
|
||||
*/
|
||||
|
||||
export const SOCKET_PATH_PREFIX = 'daemon';
|
||||
export const PID_FILENAME = 'daemon.pid';
|
||||
export const STATE_FILENAME = 'daemon-state.json';
|
||||
export const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
||||
export const IDLE_SHUTDOWN_TIMEOUT = 1800000; // 30 minutes
|
||||
|
||||
/**
|
||||
* Get project-specific socket name
|
||||
* Uses project folder name + path hash to create unique socket for each project
|
||||
*/
|
||||
export function getProjectSocketName(): string {
|
||||
const { basename } = require('path');
|
||||
const { findProjectRoot } = require('../cdp/utils');
|
||||
const { createHash } = require('crypto');
|
||||
|
||||
const projectRoot = findProjectRoot();
|
||||
const projectName = basename(projectRoot)
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace special chars with hyphen
|
||||
.toLowerCase();
|
||||
|
||||
// Add hash of full path to prevent collision
|
||||
const hash = createHash('sha256')
|
||||
.update(projectRoot)
|
||||
.digest('hex')
|
||||
.substring(0, 8); // Use first 8 chars for brevity
|
||||
|
||||
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol errors
|
||||
*/
|
||||
|
||||
export class IPCError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'IPCError';
|
||||
}
|
||||
}
|
||||
|
||||
export const IPCErrorCodes = {
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
DAEMON_NOT_RUNNING: 'DAEMON_NOT_RUNNING',
|
||||
DAEMON_ALREADY_RUNNING: 'DAEMON_ALREADY_RUNNING',
|
||||
BROWSER_NOT_CONNECTED: 'BROWSER_NOT_CONNECTED',
|
||||
COMMAND_FAILED: 'COMMAND_FAILED',
|
||||
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||
CONNECTION_ERROR: 'CONNECTION_ERROR'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Daemon command constants
|
||||
*/
|
||||
export const DAEMON_COMMANDS = {
|
||||
// Navigation
|
||||
NAVIGATE: 'navigate',
|
||||
BACK: 'back',
|
||||
FORWARD: 'forward',
|
||||
RELOAD: 'reload',
|
||||
// Interaction
|
||||
CLICK: 'click',
|
||||
FILL: 'fill',
|
||||
HOVER: 'hover',
|
||||
PRESS: 'press',
|
||||
TYPE: 'type',
|
||||
// Capture
|
||||
SCREENSHOT: 'screenshot',
|
||||
PDF: 'pdf',
|
||||
// Data
|
||||
EXTRACT: 'extract',
|
||||
CONTENT: 'content',
|
||||
FIND: 'find',
|
||||
EVAL: 'eval',
|
||||
// Console
|
||||
CONSOLE: 'console',
|
||||
// Wait
|
||||
WAIT: 'wait',
|
||||
WAIT_IDLE: 'wait-idle',
|
||||
SLEEP: 'sleep',
|
||||
// Scroll
|
||||
SCROLL: 'scroll',
|
||||
// Daemon management
|
||||
DAEMON_STATUS: 'daemon-status',
|
||||
DAEMON_STOP: 'daemon-stop',
|
||||
// Map operations
|
||||
QUERY_MAP: 'query-map',
|
||||
GENERATE_MAP: 'generate-map',
|
||||
GET_MAP_STATUS: 'get-map-status'
|
||||
} as const;
|
||||
|
||||
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];
|
||||
880
skills/scripts/src/daemon/server.ts
Normal file
880
skills/scripts/src/daemon/server.ts
Normal file
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* Browser Pilot Daemon Server
|
||||
* Maintains persistent CDP connection and handles IPC requests from CLI
|
||||
*/
|
||||
|
||||
import { createServer, Server, Socket } from 'net';
|
||||
import { join, basename } from 'path';
|
||||
import { existsSync, unlinkSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { ChromeBrowser } from '../cdp/browser';
|
||||
import { getOutputDir, loadSharedConfig } from '../cdp/config';
|
||||
import { RuntimeEvaluateResult } from '../cdp/actions/helpers';
|
||||
import { waitForDomStable } from '../cdp/actions/wait';
|
||||
import {
|
||||
IPCRequest,
|
||||
IPCResponse,
|
||||
IPCError,
|
||||
IPCErrorCodes,
|
||||
SOCKET_PATH_PREFIX,
|
||||
PID_FILENAME,
|
||||
IDLE_SHUTDOWN_TIMEOUT,
|
||||
getProjectSocketName
|
||||
} from './protocol';
|
||||
import { MapManager } from './map-manager';
|
||||
import { logger } from '../utils/logger';
|
||||
import { TIME_CONVERSION } from '../constants';
|
||||
import * as handlers from './handlers';
|
||||
import { loadLastUrl } from './handlers/navigation-handlers';
|
||||
|
||||
export class DaemonServer {
|
||||
private server: Server | null = null;
|
||||
private browser: ChromeBrowser | null = null;
|
||||
private socketPath: string;
|
||||
private pidPath: string;
|
||||
private outputDir: string;
|
||||
private idleTimeout: NodeJS.Timeout | null = null;
|
||||
private lastActivity: number = Date.now();
|
||||
private startTime: number = Date.now();
|
||||
private isShuttingDown: boolean = false;
|
||||
private shutdownPromise: Promise<void> | null = null;
|
||||
private mapManager: MapManager | null = null;
|
||||
private pendingNetworkRequests: Set<string> = new Set();
|
||||
private mapGenerationInProgress: boolean = false;
|
||||
private activeSockets: Set<Socket> = new Set();
|
||||
private initialUrl: string | undefined;
|
||||
private readonly MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
constructor() {
|
||||
this.outputDir = getOutputDir();
|
||||
this.socketPath = this.getSocketPath();
|
||||
this.pidPath = join(this.outputDir, PID_FILENAME);
|
||||
this.mapManager = new MapManager(this.outputDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket path (platform-specific, project-unique)
|
||||
*/
|
||||
private getSocketPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: project-specific named pipe
|
||||
const socketName = getProjectSocketName();
|
||||
return `\\\\.\\pipe\\${socketName}`;
|
||||
} else {
|
||||
// Unix domain socket (already project-specific via outputDir)
|
||||
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Enable file logging for daemon
|
||||
const logFile = join(this.outputDir, 'daemon.log');
|
||||
logger.enableFileLogging(logFile);
|
||||
logger.info('🚀 Browser Pilot Daemon starting...');
|
||||
logger.info(`Log file: ${logFile}`);
|
||||
|
||||
// Store initial URL from environment
|
||||
this.initialUrl = process.env.BP_INITIAL_URL;
|
||||
|
||||
// Check if already running
|
||||
if (this.isAlreadyRunning()) {
|
||||
throw new IPCError('Daemon already running', IPCErrorCodes.DAEMON_ALREADY_RUNNING);
|
||||
}
|
||||
|
||||
// Clean up stale socket file (Unix only)
|
||||
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||
unlinkSync(this.socketPath);
|
||||
}
|
||||
|
||||
// Initialize browser connection
|
||||
logger.info('Starting Browser Pilot Daemon...');
|
||||
this.browser = new ChromeBrowser(false);
|
||||
|
||||
try {
|
||||
// Try to connect to existing browser first
|
||||
await this.browser.connect();
|
||||
logger.info('Connected to existing Chrome instance');
|
||||
} catch (_error) {
|
||||
// If no browser running, launch new one
|
||||
if (this.initialUrl) {
|
||||
logger.info(`Launching new Chrome instance with initial URL: ${this.initialUrl}`);
|
||||
await this.browser.launch(this.initialUrl);
|
||||
this.initialUrl = undefined; // Clear after use
|
||||
} else {
|
||||
logger.info('Launching new Chrome instance...');
|
||||
await this.browser.launch();
|
||||
}
|
||||
logger.info('Chrome launched successfully');
|
||||
}
|
||||
|
||||
// Set up Page domain for navigation events
|
||||
await this.setupPageDomain();
|
||||
|
||||
// Set up Network tracking for auto-wait
|
||||
await this.setupNetworkTracking();
|
||||
|
||||
// Auto-restore last visited URL if enabled
|
||||
await this.autoRestoreUrl();
|
||||
|
||||
// Create IPC server
|
||||
this.server = createServer((socket) => this.handleConnection(socket));
|
||||
|
||||
// Start listening
|
||||
this.server.listen(this.socketPath, () => {
|
||||
logger.info(`IPC server listening on ${this.socketPath}`);
|
||||
this.writePidFile();
|
||||
this.startIdleTimer();
|
||||
logger.info('Browser Pilot Daemon is ready');
|
||||
});
|
||||
|
||||
// Handle server errors
|
||||
this.server.on('error', (error) => {
|
||||
logger.error('Server error', error);
|
||||
|
||||
// For EADDRINUSE, exit immediately to allow DaemonManager retry logic
|
||||
if ('code' in error && error.code === 'EADDRINUSE') {
|
||||
logger.error('Address already in use. Exiting for retry...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
this.shutdown();
|
||||
});
|
||||
|
||||
// Setup graceful shutdown
|
||||
// Use async wrapper to properly await shutdown completion
|
||||
process.on('SIGINT', () => {
|
||||
this.shutdown().catch((error) => {
|
||||
logger.error('Error during SIGINT shutdown', error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
this.shutdown().catch((error) => {
|
||||
logger.error('Error during SIGTERM shutdown', error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-restore last visited URL if enabled
|
||||
*/
|
||||
private async autoRestoreUrl(): Promise<void> {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
// Load shared config
|
||||
const config = loadSharedConfig();
|
||||
const projectRoot = process.cwd();
|
||||
const projectName = basename(projectRoot);
|
||||
const projectConfig = config.projects[projectName];
|
||||
|
||||
// Check if autoRestore is enabled (default: true)
|
||||
const autoRestore = projectConfig?.autoRestore !== false;
|
||||
|
||||
if (!autoRestore) {
|
||||
logger.debug('Auto-restore disabled, skipping URL restoration');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load last visited URL
|
||||
const lastUrl = await loadLastUrl(this.outputDir);
|
||||
|
||||
if (!lastUrl) {
|
||||
logger.debug('No last URL found, skipping restoration');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`🔄 Auto-restoring last visited URL: ${lastUrl}`);
|
||||
|
||||
// Navigate to last URL
|
||||
await this.browser.sendCommand('Page.navigate', { url: lastUrl });
|
||||
|
||||
logger.info('✅ URL restored successfully');
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to auto-restore URL: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Page domain for navigation events
|
||||
*/
|
||||
private async setupPageDomain(): Promise<void> {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
await this.browser.sendCommand('Page.enable');
|
||||
|
||||
// Listen for frame navigation to auto-clear console
|
||||
this.browser.client?.on('Page.frameNavigated', (params: { frame: { id: string; parentId?: string; url: string } }) => {
|
||||
// Only process main frame navigation (no parent)
|
||||
if (!params.frame.parentId) {
|
||||
logger.info(`🔄 Main frame navigated to: ${params.frame.url}`);
|
||||
if (this.browser) {
|
||||
this.browser.clearConsoleMessages();
|
||||
this.browser.clearNetworkErrors();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for page load complete to ensure stable DOM
|
||||
this.browser.client?.on('Page.loadEventFired', async () => {
|
||||
logger.info('📄 Page load complete');
|
||||
await this.generateMapAfterStabilization();
|
||||
});
|
||||
|
||||
// Listen for SPA navigation (History API usage)
|
||||
this.browser.client?.on('Page.navigatedWithinDocument', async (params: {
|
||||
frameId: string;
|
||||
url: string;
|
||||
navigationType: 'fragment' | 'historyApi' | 'other';
|
||||
}) => {
|
||||
// Ignore fragment navigation (same page anchor links)
|
||||
if (params.navigationType === 'fragment') {
|
||||
logger.debug(`🔗 Fragment navigation ignored: ${params.url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA routing detected (History API: pushState/replaceState)
|
||||
logger.info(`🔄 SPA navigation detected (${params.navigationType}): ${params.url}`);
|
||||
|
||||
// Clear console/network errors for new route
|
||||
if (this.browser) {
|
||||
this.browser.clearConsoleMessages();
|
||||
this.browser.clearNetworkErrors();
|
||||
}
|
||||
|
||||
// Generate map after DOM stabilization (skip loadEventFired for SPA)
|
||||
await this.generateMapAfterStabilization(true);
|
||||
});
|
||||
|
||||
logger.info('Page navigation listeners enabled (full page + SPA)');
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Could not enable Page domain: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup network request tracking
|
||||
*/
|
||||
private async setupNetworkTracking(): Promise<void> {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
await this.browser.sendCommand('Network.enable');
|
||||
|
||||
this.browser.client?.on('Network.requestWillBeSent', (params: {
|
||||
requestId: string;
|
||||
type: string;
|
||||
request: { url: string };
|
||||
}) => {
|
||||
logger.debug(`📡 Network request: ${params.type} → ${params.request?.url || 'unknown'}`);
|
||||
|
||||
if (params.type === 'XHR' || params.type === 'Fetch') {
|
||||
this.pendingNetworkRequests.add(params.requestId);
|
||||
logger.info(`📤 XHR/Fetch started: ${params.request?.url || 'unknown'} (${this.pendingNetworkRequests.size} pending)`);
|
||||
}
|
||||
});
|
||||
|
||||
this.browser.client?.on('Network.responseReceived', (params: {
|
||||
requestId: string;
|
||||
}) => {
|
||||
if (this.pendingNetworkRequests.has(params.requestId)) {
|
||||
this.pendingNetworkRequests.delete(params.requestId);
|
||||
logger.info(`📥 XHR/Fetch completed (${this.pendingNetworkRequests.size} pending)`);
|
||||
}
|
||||
});
|
||||
|
||||
this.browser.client?.on('Network.loadingFailed', (params: {
|
||||
requestId: string;
|
||||
}) => {
|
||||
if (this.pendingNetworkRequests.has(params.requestId)) {
|
||||
this.pendingNetworkRequests.delete(params.requestId);
|
||||
logger.info(`❌ XHR/Fetch failed (${this.pendingNetworkRequests.size} pending)`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Network tracking enabled');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Could not enable Network tracking: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate map after DOM stabilization
|
||||
* @param skipLoadEvent Skip waiting for Page.loadEventFired (for SPA navigation)
|
||||
*/
|
||||
private async generateMapAfterStabilization(skipLoadEvent: boolean = false): Promise<void> {
|
||||
if (!this.mapManager || !this.browser) return;
|
||||
|
||||
// Prevent concurrent map generation
|
||||
if (this.mapGenerationInProgress) {
|
||||
logger.debug(`⏭️ Skipping map generation (already in progress)`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.mapGenerationInProgress = true;
|
||||
|
||||
try {
|
||||
logger.debug(`🔨 Map generation requested (skipLoadEvent: ${skipLoadEvent})`);
|
||||
|
||||
// Mark map as not ready while generating (for chain commands)
|
||||
if (this.mapManager) {
|
||||
this.mapManager.setMapReady(false);
|
||||
logger.debug('📝 Map marked as not ready (generating...)');
|
||||
}
|
||||
|
||||
// Wait for Page.loadEventFired only for full page loads
|
||||
if (!skipLoadEvent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const onLoad = () => {
|
||||
this.browser?.client?.off('Page.loadEventFired', onLoad);
|
||||
logger.debug('✓ Page load event fired');
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Add listener
|
||||
this.browser?.client?.once('Page.loadEventFired', onLoad);
|
||||
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
this.browser?.client?.off('Page.loadEventFired', onLoad);
|
||||
logger.warn('⚠️ Page load event timeout, continuing anyway');
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
} else {
|
||||
logger.info('⏭️ Skipping Page.loadEventFired (SPA navigation)');
|
||||
// Wait for React/Vue to start making network requests after SPA navigation
|
||||
logger.info('⏳ Waiting for SPA to start network requests (100ms)...');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Wait for network idle (all XHR/Fetch requests complete)
|
||||
logger.info('⏳ Waiting for network idle...');
|
||||
const networkIdleStart = Date.now();
|
||||
const networkIdleTimeout = 10000; // 10s max wait
|
||||
|
||||
while (this.pendingNetworkRequests.size > 0) {
|
||||
if (Date.now() - networkIdleStart > networkIdleTimeout) {
|
||||
logger.warn(`⚠️ Network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (this.pendingNetworkRequests.size === 0) {
|
||||
logger.info(`✓ Network idle (waited ${Date.now() - networkIdleStart}ms)`);
|
||||
}
|
||||
|
||||
// Wait for browser to be idle (React/Vue rendering complete)
|
||||
logger.info('⏳ Waiting for browser idle (rendering complete)...');
|
||||
|
||||
const idleScript = `
|
||||
new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
// Browser supports requestIdleCallback
|
||||
const idleId = requestIdleCallback(() => {
|
||||
resolve({ waited: Date.now() - startTime });
|
||||
}, { timeout: 2000 });
|
||||
|
||||
// Safety timeout
|
||||
setTimeout(() => {
|
||||
cancelIdleCallback(idleId);
|
||||
resolve({ waited: Date.now() - startTime, timeout: true });
|
||||
}, 3000);
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback (Safari)
|
||||
setTimeout(() => {
|
||||
resolve({ waited: Date.now() - startTime, fallback: true });
|
||||
}, 0);
|
||||
}
|
||||
})
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
|
||||
expression: idleScript,
|
||||
awaitPromise: true,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const data = result.result?.value as { waited: number; timeout?: boolean; fallback?: boolean };
|
||||
if (data.timeout) {
|
||||
logger.info(`✓ Browser idle timeout (waited ${data.waited}ms)`);
|
||||
} else if (data.fallback) {
|
||||
logger.info(`✓ Browser idle fallback (waited ${data.waited}ms)`);
|
||||
} else {
|
||||
logger.info(`✓ Browser idle (waited ${data.waited}ms)`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`⚠️ Browser idle check failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Wait for DOM to stabilize (100ms of no mutations)
|
||||
await waitForDomStable(this.browser, 100, 10000, { verbose: false });
|
||||
logger.info('✓ DOM stabilized');
|
||||
|
||||
// Check again for pending network requests (may have started during DOM stabilization)
|
||||
if (this.pendingNetworkRequests.size > 0) {
|
||||
logger.info(`⏳ Waiting for network requests triggered during DOM stabilization (${this.pendingNetworkRequests.size} pending)...`);
|
||||
const postDomNetworkStart = Date.now();
|
||||
const postDomNetworkTimeout = 10000;
|
||||
|
||||
while (this.pendingNetworkRequests.size > 0) {
|
||||
if (Date.now() - postDomNetworkStart > postDomNetworkTimeout) {
|
||||
logger.warn(`⚠️ Post-DOM network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (this.pendingNetworkRequests.size === 0) {
|
||||
logger.info(`✓ Post-DOM network idle (waited ${Date.now() - postDomNetworkStart}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('✓ Generating interaction map...');
|
||||
|
||||
// Generate map with debounce
|
||||
await this.mapManager.generateMapSerially(this.browser, false).catch((error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`⚠️ Auto map generation failed: ${errorMessage}`);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`⚠️ DOM stabilization failed: ${errorMessage}`);
|
||||
} finally {
|
||||
// Release lock
|
||||
this.mapGenerationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is already running
|
||||
*/
|
||||
private isAlreadyRunning(): boolean {
|
||||
if (!existsSync(this.pidPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pidStr = readFileSync(this.pidPath, 'utf-8');
|
||||
const pid = parseInt(pidStr, 10);
|
||||
|
||||
// Check if process with this PID exists
|
||||
process.kill(pid, 0); // Signal 0 checks existence without killing
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// Process doesn't exist, clean up stale PID file
|
||||
unlinkSync(this.pidPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write PID file
|
||||
*/
|
||||
private writePidFile(): void {
|
||||
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start idle timer for auto-shutdown
|
||||
*/
|
||||
private startIdleTimer(): void {
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset idle timer
|
||||
*/
|
||||
private resetIdleTimer(): void {
|
||||
if (this.idleTimeout) {
|
||||
clearTimeout(this.idleTimeout);
|
||||
}
|
||||
|
||||
this.idleTimeout = setTimeout(() => {
|
||||
const idleTime = Date.now() - this.lastActivity;
|
||||
const idleSeconds = Math.floor(idleTime / TIME_CONVERSION.MS_PER_SECOND);
|
||||
logger.info(`⏱️ Idle for ${idleSeconds}s, shutting down...`);
|
||||
this.shutdown();
|
||||
}, IDLE_SHUTDOWN_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client connection
|
||||
*/
|
||||
private handleConnection(socket: Socket): void {
|
||||
logger.debug('🔗 Client connected');
|
||||
|
||||
// Track active socket
|
||||
this.activeSockets.add(socket);
|
||||
|
||||
let buffer = '';
|
||||
|
||||
socket.on('data', async (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Check buffer size to prevent memory exhaustion
|
||||
if (buffer.length > this.MAX_MESSAGE_SIZE) {
|
||||
logger.error('Message size exceeds limit, closing connection');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process complete JSON messages (delimited by newline)
|
||||
const messages = buffer.split('\n');
|
||||
buffer = messages.pop() || ''; // Keep incomplete message in buffer
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
try {
|
||||
const request: IPCRequest = JSON.parse(message);
|
||||
|
||||
// Validate request structure
|
||||
if (!request.id || !request.command) {
|
||||
throw new Error('Invalid request structure: missing id or command');
|
||||
}
|
||||
|
||||
const response = await this.handleRequest(request);
|
||||
socket.write(JSON.stringify(response) + '\n');
|
||||
} catch (error) {
|
||||
const errorResponse: IPCResponse = {
|
||||
id: 'unknown',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
socket.write(JSON.stringify(errorResponse) + '\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
logger.info('Client disconnected');
|
||||
this.activeSockets.delete(socket);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.error('Socket error', error);
|
||||
this.activeSockets.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC request
|
||||
*/
|
||||
private async handleRequest(request: IPCRequest): Promise<IPCResponse> {
|
||||
this.lastActivity = Date.now();
|
||||
this.resetIdleTimer();
|
||||
|
||||
logger.debug(`📨 Received command: ${request.command}`);
|
||||
|
||||
if (!this.browser) {
|
||||
return {
|
||||
id: request.id,
|
||||
success: false,
|
||||
error: 'Browser not connected'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
|
||||
// Create handler context
|
||||
const context: handlers.HandlerContext = {
|
||||
browser: this.browser,
|
||||
mapManager: this.mapManager || undefined,
|
||||
outputDir: this.outputDir
|
||||
};
|
||||
|
||||
switch (request.command) {
|
||||
// Navigation commands
|
||||
case 'navigate':
|
||||
result = await handlers.handleNavigate(context, request.params);
|
||||
break;
|
||||
case 'back':
|
||||
result = await handlers.handleBack(context, request.params);
|
||||
break;
|
||||
case 'forward':
|
||||
result = await handlers.handleForward(context, request.params);
|
||||
break;
|
||||
case 'reload':
|
||||
result = await handlers.handleReload(context, request.params);
|
||||
break;
|
||||
|
||||
// Interaction commands
|
||||
case 'click':
|
||||
result = await handlers.handleClick(context, request.params);
|
||||
break;
|
||||
case 'fill':
|
||||
result = await handlers.handleFill(context, request.params);
|
||||
break;
|
||||
case 'hover':
|
||||
result = await handlers.handleHover(context, request.params);
|
||||
break;
|
||||
case 'press':
|
||||
result = await handlers.handlePress(context, request.params);
|
||||
break;
|
||||
case 'type':
|
||||
result = await handlers.handleType(context, request.params);
|
||||
break;
|
||||
|
||||
// Capture commands
|
||||
case 'screenshot':
|
||||
result = await handlers.handleScreenshot(context, request.params);
|
||||
break;
|
||||
case 'pdf':
|
||||
result = await handlers.handlePdf(context, request.params);
|
||||
break;
|
||||
case 'set-viewport':
|
||||
result = await handlers.handleSetViewport(context, request.params);
|
||||
break;
|
||||
case 'get-viewport':
|
||||
result = await handlers.handleGetViewport(context, request.params);
|
||||
break;
|
||||
case 'get-screen-info':
|
||||
result = await handlers.handleGetScreenInfo(context, request.params);
|
||||
break;
|
||||
|
||||
// Data commands
|
||||
case 'extract':
|
||||
result = await handlers.handleExtract(context, request.params);
|
||||
break;
|
||||
case 'content':
|
||||
result = await handlers.handleContent(context, request.params);
|
||||
break;
|
||||
case 'find':
|
||||
result = await handlers.handleFind(context, request.params);
|
||||
break;
|
||||
case 'eval':
|
||||
result = await handlers.handleEval(context, request.params);
|
||||
break;
|
||||
|
||||
// Map commands
|
||||
case 'query-map':
|
||||
result = await handlers.handleQueryMap(context, request.params);
|
||||
break;
|
||||
case 'generate-map':
|
||||
result = await handlers.handleGenerateMap(context, request.params);
|
||||
break;
|
||||
case 'get-map-status':
|
||||
result = await handlers.handleGetMapStatus(context, request.params);
|
||||
break;
|
||||
|
||||
// Utility commands
|
||||
case 'scroll':
|
||||
result = await handlers.handleScroll(context, request.params);
|
||||
break;
|
||||
case 'wait':
|
||||
result = await handlers.handleWait(context, request.params);
|
||||
break;
|
||||
case 'console':
|
||||
result = await handlers.handleConsole(context, request.params);
|
||||
break;
|
||||
case 'status':
|
||||
result = await handlers.handleStatus(context, request.params, this.startTime, this.lastActivity);
|
||||
break;
|
||||
|
||||
// Daemon management
|
||||
case 'shutdown':
|
||||
setImmediate(() => this.shutdown());
|
||||
result = { message: 'Daemon shutting down...' };
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IPCError(`Unknown command: ${request.command}`, IPCErrorCodes.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
return {
|
||||
id: request.id,
|
||||
success: true,
|
||||
data: result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Command failed: ${request.command}`, error);
|
||||
return {
|
||||
id: request.id,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Return existing shutdown promise if already shutting down
|
||||
if (this.shutdownPromise) {
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
this.shutdownPromise = this._doShutdown();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shutdown implementation
|
||||
*/
|
||||
private async _doShutdown(): Promise<void> {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
|
||||
logger.info('Shutting down Browser Pilot Daemon...');
|
||||
|
||||
// Stop idle timer
|
||||
if (this.idleTimeout) {
|
||||
clearTimeout(this.idleTimeout);
|
||||
}
|
||||
|
||||
// Remove process signal listeners
|
||||
process.removeAllListeners('SIGINT');
|
||||
process.removeAllListeners('SIGTERM');
|
||||
|
||||
// Close browser first
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
logger.info('Browser closed');
|
||||
} catch (error) {
|
||||
logger.error('Error closing browser', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Force close all active socket connections
|
||||
if (this.activeSockets.size > 0) {
|
||||
logger.info(`Closing ${this.activeSockets.size} active socket connection(s)...`);
|
||||
for (const socket of this.activeSockets) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
logger.error('Error destroying socket', error);
|
||||
}
|
||||
}
|
||||
this.activeSockets.clear();
|
||||
logger.info('All socket connections closed');
|
||||
}
|
||||
|
||||
// Close IPC server (wait for all connections to close with timeout)
|
||||
if (this.server) {
|
||||
const server = this.server;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.warn('IPC server close timed out after 2 seconds. Continuing shutdown.');
|
||||
resolve();
|
||||
}, 2000);
|
||||
|
||||
server.close((err?: Error) => {
|
||||
clearTimeout(timeout);
|
||||
if (err) {
|
||||
logger.error('Error closing IPC server', err);
|
||||
} else {
|
||||
logger.info('IPC server closed');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up socket file (Unix only) - safe after server.close() completes
|
||||
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
|
||||
try {
|
||||
unlinkSync(this.socketPath);
|
||||
logger.info('Socket file removed');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to remove socket file: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove PID file
|
||||
if (existsSync(this.pidPath)) {
|
||||
unlinkSync(this.pidPath);
|
||||
logger.info('PID file removed');
|
||||
}
|
||||
|
||||
// Remove interaction map cache files
|
||||
const mapPath = join(this.outputDir, 'interaction-map.json');
|
||||
const mapCachePath = join(this.outputDir, 'map-cache.json');
|
||||
|
||||
if (existsSync(mapPath)) {
|
||||
try {
|
||||
unlinkSync(mapPath);
|
||||
logger.info('Interaction map cache removed');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to remove interaction map: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(mapCachePath)) {
|
||||
try {
|
||||
unlinkSync(mapCachePath);
|
||||
logger.info('Map cache metadata removed');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to remove map cache metadata: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove shutdown request flag (if exists from SessionEnd)
|
||||
// This flag is created by SessionEnd (cleanup-config.js) to track daemon shutdown
|
||||
const shutdownFlagPath = join(this.outputDir, 'daemon-to-stop.pid');
|
||||
if (existsSync(shutdownFlagPath)) {
|
||||
try {
|
||||
unlinkSync(shutdownFlagPath);
|
||||
logger.info('Shutdown request flag removed');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Failed to remove shutdown flag: ${errorMsg}`);
|
||||
|
||||
// Fallback: Mark as COMPLETED so next SessionStart knows shutdown succeeded
|
||||
// Even if file can't be deleted (Windows file lock), marking it prevents force-kill attempt
|
||||
try {
|
||||
writeFileSync(shutdownFlagPath, `COMPLETED:${process.pid}`, 'utf-8');
|
||||
logger.info('Marked shutdown flag as COMPLETED (deletion failed due to file lock)');
|
||||
} catch (_writeError) {
|
||||
logger.error('Failed to mark shutdown flag as COMPLETED');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Daemon shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current browser instance (for testing)
|
||||
*/
|
||||
get currentBrowser(): ChromeBrowser | null {
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose client property for Page event listener
|
||||
*/
|
||||
get client() {
|
||||
return this.browser?.client;
|
||||
}
|
||||
}
|
||||
|
||||
// Start daemon if run directly
|
||||
if (require.main === module) {
|
||||
const daemon = new DaemonServer();
|
||||
daemon.start().catch((error) => {
|
||||
logger.error('Failed to start daemon', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
242
skills/scripts/src/utils/logger.ts
Normal file
242
skills/scripts/src/utils/logger.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Logger utility for CLI commands
|
||||
* Provides consistent logging with verbosity control
|
||||
*/
|
||||
|
||||
import { writeFileSync, appendFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3,
|
||||
VERBOSE = 4
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
level?: LogLevel;
|
||||
prefix?: string;
|
||||
timestamp?: boolean;
|
||||
logFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default log level from environment variable
|
||||
* Set BP_LOG_LEVEL=DEBUG to change log level
|
||||
*/
|
||||
function getDefaultLogLevel(): LogLevel {
|
||||
const envLevel = process.env.BP_LOG_LEVEL?.toUpperCase();
|
||||
switch (envLevel) {
|
||||
case 'ERROR': return LogLevel.ERROR;
|
||||
case 'WARN': return LogLevel.WARN;
|
||||
case 'INFO': return LogLevel.INFO;
|
||||
case 'DEBUG': return LogLevel.DEBUG;
|
||||
case 'VERBOSE': return LogLevel.VERBOSE;
|
||||
default: return LogLevel.INFO;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp in local time with milliseconds
|
||||
* Shared timestamp format for consistency across logger and interaction maps
|
||||
* Example: 2025-11-05 13:45:23.123
|
||||
*/
|
||||
export function getLocalTimestamp(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private level: LogLevel;
|
||||
private prefix: string;
|
||||
private timestamp: boolean;
|
||||
private logFile: string | null;
|
||||
|
||||
constructor(options: LoggerOptions = {}) {
|
||||
this.level = options.level ?? getDefaultLogLevel();
|
||||
this.prefix = options.prefix ?? '[browser-pilot]';
|
||||
this.timestamp = options.timestamp ?? false;
|
||||
this.logFile = options.logFile ?? null;
|
||||
|
||||
// Initialize log file if specified
|
||||
if (this.logFile) {
|
||||
this.initLogFile();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize log file (create or clear)
|
||||
*/
|
||||
private initLogFile(): void {
|
||||
if (!this.logFile) return;
|
||||
|
||||
try {
|
||||
// Create directory if needed
|
||||
const fs = require('fs');
|
||||
const dir = dirname(this.logFile);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create empty log file
|
||||
writeFileSync(this.logFile, `=== Browser Pilot Daemon Log ===\nStarted: ${getLocalTimestamp()}\n\n`, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write log message to file
|
||||
*/
|
||||
private writeToFile(message: string): void {
|
||||
if (!this.logFile) return;
|
||||
|
||||
try {
|
||||
appendFileSync(this.logFile, message + '\n', 'utf-8');
|
||||
} catch (_error) {
|
||||
// Silently fail - don't break logging
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable file logging
|
||||
*/
|
||||
enableFileLogging(filePath: string): void {
|
||||
this.logFile = filePath;
|
||||
this.timestamp = true; // Always enable timestamp for file logging
|
||||
this.initLogFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable file logging
|
||||
*/
|
||||
disableFileLogging(): void {
|
||||
this.logFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable timestamp in logs
|
||||
*/
|
||||
enableTimestamp(): void {
|
||||
this.timestamp = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable timestamp in logs
|
||||
*/
|
||||
disableTimestamp(): void {
|
||||
this.timestamp = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp in local time
|
||||
* Example: 2025-11-05 13:45:23
|
||||
*/
|
||||
private getTimestamp(): string {
|
||||
return getLocalTimestamp();
|
||||
}
|
||||
|
||||
private formatMessage(level: string, message: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (this.timestamp) {
|
||||
parts.push(`[${this.getTimestamp()}]`);
|
||||
}
|
||||
|
||||
if (this.prefix) {
|
||||
parts.push(this.prefix);
|
||||
}
|
||||
|
||||
parts.push(`[${level}]`, message);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown): void {
|
||||
if (this.level >= LogLevel.ERROR) {
|
||||
const formattedMsg = this.formatMessage('ERROR', message);
|
||||
console.error(formattedMsg);
|
||||
this.writeToFile(formattedMsg);
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMsg = ' ' + error.message;
|
||||
console.error(errorMsg);
|
||||
this.writeToFile(errorMsg);
|
||||
|
||||
if (this.level >= LogLevel.VERBOSE && error.stack) {
|
||||
const stackMsg = ' Stack: ' + error.stack;
|
||||
console.error(stackMsg);
|
||||
this.writeToFile(stackMsg);
|
||||
}
|
||||
} else if (error) {
|
||||
const errorStr = ' ' + String(error);
|
||||
console.error(errorStr);
|
||||
this.writeToFile(errorStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
if (this.level >= LogLevel.WARN) {
|
||||
const formatted = this.formatMessage('WARN', message);
|
||||
console.warn(formatted);
|
||||
this.writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
if (this.level >= LogLevel.INFO) {
|
||||
const formatted = this.formatMessage('INFO', message);
|
||||
console.log(formatted);
|
||||
this.writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
if (this.level >= LogLevel.DEBUG) {
|
||||
const formatted = this.formatMessage('DEBUG', message);
|
||||
console.log(formatted);
|
||||
this.writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
verbose(message: string): void {
|
||||
if (this.level >= LogLevel.VERBOSE) {
|
||||
const formatted = this.formatMessage('VERBOSE', message);
|
||||
console.log(formatted);
|
||||
this.writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
if (this.level >= LogLevel.INFO) {
|
||||
const formatted = this.formatMessage('✓', message);
|
||||
console.log(formatted);
|
||||
this.writeToFile(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
getLevel(): LogLevel {
|
||||
return this.level;
|
||||
}
|
||||
}
|
||||
|
||||
// Default logger instance
|
||||
export const logger = new Logger();
|
||||
|
||||
// Factory function for creating custom loggers
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
return new Logger(options);
|
||||
}
|
||||
48
skills/scripts/src/utils/timestamp.ts
Normal file
48
skills/scripts/src/utils/timestamp.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Timestamp utilities for local time formatting
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get ISO 8601 timestamp (recommended for logs)
|
||||
* Format: 2025-11-08T23:17:47.123Z
|
||||
* Example: 2025-11-08T23:17:47.123Z
|
||||
*/
|
||||
export function getISOTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local timestamp string in format: YYYY-MM-DD HH:MM:SS
|
||||
* Example: 2025-11-08 23:17:47
|
||||
*/
|
||||
export function getLocalTimestamp(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local timestamp with timezone information
|
||||
* Format: YYYY-MM-DD HH:MM:SS (UTC+X)
|
||||
* Example: 2025-11-08 23:17:47 (UTC+9)
|
||||
*/
|
||||
export function getLocalTimestampWithTZ(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
// Get timezone offset in hours
|
||||
const offset = -now.getTimezoneOffset() / 60;
|
||||
const offsetStr = offset >= 0 ? `+${offset}` : String(offset);
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} (UTC${offsetStr})`;
|
||||
}
|
||||
21
skills/scripts/tsconfig.json
Normal file
21
skills/scripts/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user