Skill: vinxu/castreader-openclaw-skill (version 2.1.1)

--- name: castreader description: > Read any web page aloud with natural AI voices. Extract article text from any URL and convert it to audio (MP3). Use when the user wants to: listen to a webpage, read an article aloud, convert URL to audio, text-to-speech for a link, read this page to me, listen to this article. version: 2.1.0 metadata: openclaw: emoji: "🔊" requires: anyBins: ["node"] os: ["darwin", "linux", "win32"] homepage: https://castreader.ai/openclaw --- # CastReader — Read Any Web Page Aloud ## Setup (once per session) ``` cd <skill-directory> && npm install --silent 2>/dev/null ``` ## How to find target (chatId) User messages look like: `[Telegram username id:8716240840 ...]` The number after `id:` is the target. You MUST use this number in every `message` tool call. Example: target is `"8716240840"`. ## When user sends a URL, follow these steps: ### Step 1: Extract article ``` node scripts/read-url.js "<url>" 0 ``` Returns: `{ title, language, totalParagraphs, totalCharacters, paragraphs[] }` ### Step 2: Show info + ask user to choose Reply with this text: ``` 📖 {title} 🌐 {language} · 📝 {totalParagraphs} paragraphs · 📊 {totalCharacters} chars 📋 Summary: {write 2-3 sentence summary from paragraphs} Reply a number to choose: 1️⃣ Listen to full article (~{totalCharacters} chars, ~{Math.ceil(totalCharacters / 200)} sec to generate) 2️⃣ Listen to summary only (~{summary_char_count} chars, ~{Math.ceil(summary_char_count / 200)} sec to generate) ``` **STOP. Wait for user to reply 1 or 2.** ### Step 3a: User chose 1 (full article) Reply: `🎙️ Generating full audio (~{totalCharacters} chars, ~{Math.ceil(totalCharacters / 200)} seconds)...` ``` node scripts/read-url.js "<url>" all ``` Then send the audio file using the `message` tool: ```json {"action":"send", "target":"<chatId>", "channel":"telegram", "filePath":"<audioFile>", "caption":"🔊 {title}"} ``` Reply: `✅ Done!` ### Step 3b: User chose 2 (summary only) Reply: `🎙️ Generating summary audio...` Save the SAME summary text you showed in Step 2 to a file and generate: ``` echo "<summary text>" > /tmp/castreader-summary.txt node scripts/generate-text.js /tmp/castreader-summary.txt <language> ``` Then send the audio file using the `message` tool: ```json {"action":"send", "target":"<chatId>", "channel":"telegram", "filePath":"/tmp/castreader-summary.mp3", "caption":"📋 Summary: {title}"} ``` Reply: `✅ Done!` ## Rules - ALWAYS extract first (index=0), show info, wait for user choice. Never skip. - ALWAYS send audio files using the `message` tool with `target` (numeric chatId) and `channel` ("telegram"). Never just print the file path. - Do NOT use built-in TTS tools. ONLY use `read-url.js` and `generate-text.js`. - Do NOT use web_fetch. ONLY use `read-url.js`.

path: README.md
content: # CastReader — Read Any Web Page Aloud | OpenClaw Skill

[![OpenClaw Skill](https://img.shields.io/badge/OpenClaw-Skill-blue)](https://clawhub.com/castreader)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)]()

**Turn any URL into audio.** CastReader extracts article text from web pages — including sites where other tools fail — and converts it to natural-sounding speech with Kokoro TTS. No API key required.

## Why CastReader?

Every other TTS skill on ClawHub (kokoro-tts, openai-tts, mac-tts, etc.) does the same thing: takes **plain text** and speaks it. But when a user says *"read this article for me"* and pastes a URL, plain-text TTS skills can't help — they have no way to extract the content.

CastReader is the **only skill that handles the full pipeline**: URL in, audio out. It includes a battle-tested extraction engine with dedicated parsers for 15+ platforms, so it works on pages that break generic readability tools.

## Supported Platforms

| Platform | Challenge | CastReader's Approach |
|----------|-----------|----------------------|
| **Kindle Cloud Reader** | Scrambled custom fonts, no readable text in DOM | OCR + glyph mapping to decode font subsets |
| **WeRead (微信读书)** | Text rendered on Canvas, not in DOM | Intercepts fetch API to capture chapter data |
| **Notion** | Complex nested block-based DOM | Dedicated block parser |
| **Google Docs** | Custom rendering engine, no standard HTML | Specialized extractor for Docs DOM |
| **Medium** | Paywall markup, lazy loading | Clean article extraction |
| **Substack** | Newsletter formatting | Structured content parser |
| **arXiv** | LaTeX-rendered papers | Academic content extraction |
| **Wikipedia** | Complex infoboxes, references, citations | Content-focused extraction |
| **ChatGPT / Claude / Gemini** | Dynamic SPA, markdown rendering | AI response extraction with language detection |
| **Douban (豆包) / DeepSeek / Kimi** | Chinese AI platforms | Platform-specific extractors |
| **Feishu (飞书) / Yuque (语雀) / DingTalk** | Chinese productivity tools | Dedicated extractors |
| **Fanqie Novel (番茄小说)** | Novel reader with anti-scraping | Canvas text extraction |
| **Any other website** | Generic articles, blogs, docs | Visible-Text-Block algorithm (Readability + Boilerpipe + JusText fusion) |

## Installation

```bash
clawhub install castreader
```

**Requirements:** Node.js 18+

## Usage

### Extract text from a URL

```bash
node scripts/read-url.js https://en.wikipedia.org/wiki/Text-to-speech 0
```

Returns structured JSON with article info and all paragraph texts (no audio generated):

```json
{
  "title": "Text-to-speech - Wikipedia",
  "language": "en",
  "totalParagraphs": 42,
  "totalCharacters": 18500,
  "paragraphs": ["Speech synthesis is the artificial production of human speech...", "..."]
}
```

### Generate full article audio

```bash
node scripts/read-url.js https://en.wikipedia.org/wiki/Text-to-speech all
```

Extracts content + generates a single MP3 file for the entire article:

```json
{
  "title": "Text-to-speech - Wikipedia",
  "language": "en",
  "totalParagraphs": 42,
  "totalCharacters": 18500,
  "audioFile": "/tmp/castreader-abc123/full.mp3",
  "fileSizeBytes": 2450000
}
```

### Generate summary audio

```bash
echo "Your summary text here..." > /tmp/summary.txt
node scripts/generate-text.js /tmp/summary.txt en
```

Generates audio from any text file:

```json
{
  "audioFile": "/tmp/summary.mp3",
  "fileSizeBytes": 284588
}
```

### Read aloud in the browser (with highlighting)

```bash
node scripts/read-aloud.js https://notion.so/my-page
```

Opens the URL in your browser and triggers CastReader to read with real-time paragraph-level highlighting. Requires the [CastReader Chrome extension](https://chromewebstore.google.com/detail/castreader-tts-reader/foammmkhpbeladledijkdljlechlclpb) installed.

## Messaging Platform Flow (Telegram/Slack/Discord)

When a user sends a URL, CastReader follows a two-step flow:

```
User: https://example.com/article

Bot: 📖 Article Title
     🌐 English · 📝 12 paragraphs · 📊 2,450 chars

     📋 Summary:
     This article explores how AI is transforming industries...

     Reply a number to choose:
     1️⃣ Listen to full article (~2,450 chars, ~12 sec to generate)
     2️⃣ Listen to summary only (~150 chars, ~1 sec to generate)

User: 1

Bot: 🎙️ Generating full audio (~2,450 chars, ~12 seconds)...
Bot: [🔊 full.mp3]
Bot: ✅ Done!
```

**Environment variables:**

| Variable | Default | Description |
|----------|---------|-------------|
| `CASTREADER_VOICE` | `af_heart` | TTS voice selection |
| `CASTREADER_SPEED` | `1.5` | Playback speed |
| `CASTREADER_API_URL` | `http://api.castreader.ai:8123` | API endpoint |

## Comparison with Other TTS Skills

| Feature | CastReader | kokoro-tts | openai-tts | mac-tts |
|---------|-----------|------------|------------|---------|
| URL to audio | Yes | No | No | No |
| Web content extraction | Yes (15+ platforms) | No | No | No |
| Canvas/font-scrambled sites | Yes | No | No | No |
| Full article or summary | Yes | N/A | N/A | N/A |
| Plain text to speech | Yes | Yes | Yes | Yes |
| Paragraph highlighting | Yes | No | No | No |
| API key required | No | No | Yes | No |
| Languages | 40+ | 40+ | 50+ | System voices |
| Cost | Free | Free | Paid | Free |

## How It Works

1. **Extract** — The extraction engine analyzes the page DOM using a 3-tier pipeline:
   - Tier 1: Platform-specific extractors (Kindle, WeRead, Notion, etc.)
   - Tier 2: Learned CSS selector rules from automated evaluation
   - Tier 3: Visible-Text-Block algorithm — a fusion of Readability.js, Boilerpipe, JusText, and CETD techniques
2. **Generate** — Extracted text is sent to the Kokoro TTS API, which returns natural speech with word-level timestamps
3. **Deliver** — Full article audio as a single MP3, or summary-only audio for quick listening

## Links

- **Website:** [castreader.ai](https://castreader.ai)
- **OpenClaw page:** [castreader.ai/openclaw](https://castreader.ai/openclaw)
- **Chrome Web Store:** [CastReader Extension](https://chromewebstore.google.com/detail/castreader-tts-reader/foammmkhpbeladledijkdljlechlclpb)
- **Edge Add-ons:** [CastReader for Edge](https://microsoftedge.microsoft.com/addons/detail/niidajfbelfcgnkmnpcmdlioclhljaaj)
- **ClawHub:** [clawhub.com/castreader](https://clawhub.com/castreader)

## License

MIT


path: package-lock.json
content: {
  "name": "castreader-openclaw-skill",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "castreader-openclaw-skill",
      "version": "1.0.0",
      "dependencies": {
        "puppeteer": "^23.0.0"
      }
    },
    "node_modules/@babel/code-frame": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@babel/helper-validator-identifier": "^7.28.5",
        "js-tokens": "^4.0.0",
        "picocolors": "^1.1.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.28.5",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@puppeteer/browsers": {
      "version": "2.6.1",
      "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "debug": "^4.4.0",
        "extract-zip": "^2.0.1",
        "progress": "^2.0.3",
        "proxy-agent": "^6.5.0",
        "semver": "^7.6.3",
        "tar-fs": "^3.0.6",
        "unbzip2-stream": "^1.4.3",
        "yargs": "^17.7.2"
      },
      "bin": {
        "browsers": "lib/cjs/main-cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@tootallnate/quickjs-emscripten": {
      "version": "0.23.0",
      "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "25.5.0",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "undici-types": "~7.18.0"
      }
    },
    "node_modules/@types/yauzl": {
      "version": "2.10.3",
      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "@types/node": "*"
      }
    },
    "node_modules/agent-base": {
      "version": "7.1.4",
      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/ansi-regex": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/ansi-styles": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    },
    "node_modules/argparse": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Python-2.0"
    },
    "node_modules/ast-types": {
      "version": "0.13.4",
      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.0.1"
      },
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/b4a": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "peerDependencies": {
        "react-native-b4a": "*"
      },
      "peerDependenciesMeta": {
        "react-native-b4a": {
          "optional": true
        }
      }
    },
    "node_modules/bare-events": {
      "version": "2.8.2",
      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "peerDependencies": {
        "bare-abort-controller": "*"
      },
      "peerDependenciesMeta": {
        "bare-abort-controller": {
          "optional": true
        }
      }
    },
    "node_modules/bare-fs": {
      "version": "4.5.5",
      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "bare-events": "^2.5.4",
        "bare-path": "^3.0.0",
        "bare-stream": "^2.6.4",
        "bare-url": "^2.2.2",
        "fast-fifo": "^1.3.2"
      },
      "engines": {
        "bare": ">=1.16.0"
      },
      "peerDependencies": {
        "bare-buffer": "*"
      },
      "peerDependenciesMeta": {
        "bare-buffer": {
          "optional": true
        }
      }
    },
    "node_modules/bare-os": {
      "version": "3.7.1",
      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "engines": {
        "bare": ">=1.14.0"
      }
    },
    "node_modules/bare-path": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "bare-os": "^3.0.1"
      }
    },
    "node_modules/bare-stream": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "streamx": "^2.21.0",
        "teex": "^1.0.1"
      },
      "peerDependencies": {
        "bare-buffer": "*",
        "bare-events": "*"
      },
      "peerDependenciesMeta": {
        "bare-buffer": {
          "optional": true
        },
        "bare-events": {
          "optional": true
        }
      }
    },
    "node_modules/bare-url": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "bare-path": "^3.0.0"
      }
    },
    "node_modules/base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/basic-ftp": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      }
    },
    "node_modules/buffer": {
      "version": "5.7.1",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "base64-js": "^1.3.1",
        "ieee754": "^1.1.13"
      }
    },
    "node_modules/buffer-crc32": {
      "version": "0.2.13",
      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "*"
      }
    },
    "node_modules/callsites": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/chromium-bidi": {
      "version": "0.11.0",
      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "mitt": "3.0.1",
        "zod": "3.23.8"
      },
      "peerDependencies": {
        "devtools-protocol": "*"
      }
    },
    "node_modules/cliui": {
      "version": "8.0.1",
      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "string-width": "^4.2.0",
        "strip-ansi": "^6.0.1",
        "wrap-ansi": "^7.0.0"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/cosmiconfig": {
      "version": "9.0.1",
      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "env-paths": "^2.2.1",
        "import-fresh": "^3.3.0",
        "js-yaml": "^4.1.0",
        "parse-json": "^5.2.0"
      },
      "engines": {
        "node": ">=14"
      },
      "funding": {
        "url": "https://github.com/sponsors/d-fischer"
      },
      "peerDependencies": {
        "typescript": ">=4.9.5"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/data-uri-to-buffer": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/degenerator": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ast-types": "^0.13.4",
        "escodegen": "^2.1.0",
        "esprima": "^4.0.1"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/devtools-protocol": {
      "version": "0.0.1367902",
      "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "peer": true
    },
    "node_modules/emoji-regex": {
      "version": "8.0.0",
      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/end-of-stream": {
      "version": "1.4.5",
      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "once": "^1.4.0"
      }
    },
    "node_modules/env-paths": {
      "version": "2.2.1",
      "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/error-ex": {
      "version": "1.3.4",
      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "is-arrayish": "^0.2.1"
      }
    },
    "node_modules/escalade": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/escodegen": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "dependencies": {
        "esprima": "^4.0.1",
        "estraverse": "^5.2.0",
        "esutils": "^2.0.2"
      },
      "bin": {
        "escodegen": "bin/escodegen.js",
        "esgenerate": "bin/esgenerate.js"
      },
      "engines": {
        "node": ">=6.0"
      },
      "optionalDependencies": {
        "source-map": "~0.6.1"
      }
    },
    "node_modules/esprima": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "bin": {
        "esparse": "bin/esparse.js",
        "esvalidate": "bin/esvalidate.js"
      },
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/estraverse": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/esutils": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/events-universal": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "bare-events": "^2.7.0"
      }
    },
    "node_modules/extract-zip": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "dependencies": {
        "debug": "^4.1.1",
        "get-stream": "^5.1.0",
        "yauzl": "^2.10.0"
      },
      "bin": {
        "extract-zip": "cli.js"
      },
      "engines": {
        "node": ">= 10.17.0"
      },
      "optionalDependencies": {
        "@types/yauzl": "^2.9.1"
      }
    },
    "node_modules/fast-fifo": {
      "version": "1.3.2",
      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/fd-slicer": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "pend": "~1.2.0"
      }
    },
    "node_modules/get-caller-file": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": "6.* || 8.* || >= 10.*"
      }
    },
    "node_modules/get-stream": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "pump": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/get-uri": {
      "version": "6.0.5",
      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "basic-ftp": "^5.0.2",
        "data-uri-to-buffer": "^6.0.2",
        "debug": "^4.3.4"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/http-proxy-agent": {
      "version": "7.0.2",
      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.0",
        "debug": "^4.3.4"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/https-proxy-agent": {
      "version": "7.0.6",
      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.2",
        "debug": "4"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/ieee754": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "BSD-3-Clause"
    },
    "node_modules/import-fresh": {
      "version": "3.3.1",
      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "parent-module": "^1.0.0",
        "resolve-from": "^4.0.0"
      },
      "engines": {
        "node": ">=6"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/ip-address": {
      "version": "10.1.0",
      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 12"
      }
    },
    "node_modules/is-arrayish": {
      "version": "0.2.1",
      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/is-fullwidth-code-point": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/js-tokens": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/js-yaml": {
      "version": "4.1.1",
      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "argparse": "^2.0.1"
      },
      "bin": {
        "js-yaml": "bin/js-yaml.js"
      }
    },
    "node_modules/json-parse-even-better-errors": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/lines-and-columns": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/lru-cache": {
      "version": "7.18.3",
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/mitt": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/netmask": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4.0"
      }
    },
    "node_modules/once": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "wrappy": "1"
      }
    },
    "node_modules/pac-proxy-agent": {
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@tootallnate/quickjs-emscripten": "^0.23.0",
        "agent-base": "^7.1.2",
        "debug": "^4.3.4",
        "get-uri": "^6.0.1",
        "http-proxy-agent": "^7.0.0",
        "https-proxy-agent": "^7.0.6",
        "pac-resolver": "^7.0.1",
        "socks-proxy-agent": "^8.0.5"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/pac-resolver": {
      "version": "7.0.1",
      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "degenerator": "^5.0.0",
        "netmask": "^2.0.2"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/parent-module": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "callsites": "^3.0.0"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/parse-json": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.0.0",
        "error-ex": "^1.3.1",
        "json-parse-even-better-errors": "^2.3.0",
        "lines-and-columns": "^1.1.6"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/pend": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/progress": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/proxy-agent": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.2",
        "debug": "^4.3.4",
        "http-proxy-agent": "^7.0.1",
        "https-proxy-agent": "^7.0.6",
        "lru-cache": "^7.14.1",
        "pac-proxy-agent": "^7.1.0",
        "proxy-from-env": "^1.1.0",
        "socks-proxy-agent": "^8.0.5"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/pump": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "end-of-stream": "^1.1.0",
        "once": "^1.3.1"
      }
    },
    "node_modules/puppeteer": {
      "version": "23.11.1",
      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.11.1.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "< 24.15.0 is no longer supported",
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@puppeteer/browsers": "2.6.1",
        "chromium-bidi": "0.11.0",
        "cosmiconfig": "^9.0.0",
        "devtools-protocol": "0.0.1367902",
        "puppeteer-core": "23.11.1",
        "typed-query-selector": "^2.12.0"
      },
      "bin": {
        "puppeteer": "lib/cjs/puppeteer/node/cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/puppeteer-core": {
      "version": "23.11.1",
      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "@puppeteer/browsers": "2.6.1",
        "chromium-bidi": "0.11.0",
        "debug": "^4.4.0",
        "devtools-protocol": "0.0.1367902",
        "typed-query-selector": "^2.12.0",
        "ws": "^8.18.0"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/require-directory": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/resolve-from": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/smart-buffer": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 6.0.0",
        "npm": ">= 3.0.0"
      }
    },
    "node_modules/socks": {
      "version": "2.8.7",
      "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ip-address": "^10.0.1",
        "smart-buffer": "^4.2.0"
      },
      "engines": {
        "node": ">= 10.0.0",
        "npm": ">= 3.0.0"
      }
    },
    "node_modules/socks-proxy-agent": {
      "version": "8.0.5",
      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.2",
        "debug": "^4.3.4",
        "socks": "^2.8.3"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/source-map": {
      "version": "0.6.1",
      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "optional": true,
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/streamx": {
      "version": "2.23.0",
      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "events-universal": "^1.0.0",
        "fast-fifo": "^1.3.2",
        "text-decoder": "^1.1.0"
      }
    },
    "node_modules/string-width": {
      "version": "4.2.3",
      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "emoji-regex": "^8.0.0",
        "is-fullwidth-code-point": "^3.0.0",
        "strip-ansi": "^6.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-ansi": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/tar-fs": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "pump": "^3.0.0",
        "tar-stream": "^3.1.5"
      },
      "optionalDependencies": {
        "bare-fs": "^4.0.1",
        "bare-path": "^3.0.0"
      }
    },
    "node_modules/tar-stream": {
      "version": "3.1.8",
      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "b4a": "^1.6.4",
        "bare-fs": "^4.5.5",
        "fast-fifo": "^1.2.0",
        "streamx": "^2.15.0"
      }
    },
    "node_modules/teex": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "streamx": "^2.12.5"
      }
    },
    "node_modules/text-decoder": {
      "version": "1.2.7",
      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "b4a": "^1.6.4"
      }
    },
    "node_modules/through": {
      "version": "2.3.8",
      "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD"
    },
    "node_modules/typed-query-selector": {
      "version": "2.12.1",
      "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/unbzip2-stream": {
      "version": "1.4.3",
      "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "buffer": "^5.2.1",
        "through": "^2.3.8"
      }
    },
    "node_modules/undici-types": {
      "version": "7.18.2",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true
    },
    "node_modules/wrap-ansi": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^4.0.0",
        "string-width": "^4.1.0",
        "strip-ansi": "^6.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
      }
    },
    "node_modules/wrappy": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/ws": {
      "version": "8.19.0",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/y18n": {
      "version": "5.0.8",
      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/yargs": {
      "version": "17.7.2",
      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "cliui": "^8.0.1",
        "escalade": "^3.1.1",
        "get-caller-file": "^2.0.5",
        "require-directory": "^2.1.1",
        "string-width": "^4.2.3",
        "y18n": "^5.0.5",
        "yargs-parser": "^21.1.1"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/yargs-parser": {
      "version": "21.1.1",
      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/yauzl": {
      "version": "2.10.0",
      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "buffer-crc32": "~0.2.3",
        "fd-slicer": "~1.1.0"
      }
    },
    "node_modules/zod": {
      "version": "3.23.8",
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/colinhacks"
      }
    }
  }
}


path: package.json
content: {
  "name": "castreader-openclaw-skill",
  "version": "1.0.0",
  "description": "OpenClaw skill for reading web pages aloud with Castreader TTS",
  "private": true,
  "scripts": {
    "extract": "node scripts/extract.js",
    "generate-paragraph": "node scripts/generate-paragraph.js",
    "read-aloud": "node scripts/read-aloud.js"
  },
  "dependencies": {
    "puppeteer": "^23.0.0"
  }
}


path: references/castreader-api.md
content: # Castreader API Reference

## TTS API

### Endpoint
`POST {baseUrl}/api/captioned_speech_partly`

Default baseUrl: `http://api.castreader.ai:8123`

### Request Body
```json
{
  "model": "kokoro",
  "input": "Text to convert to speech",
  "voice": "af_heart",
  "response_format": "mp3",
  "return_timestamps": true,
  "speed": 1.5,
  "stream": false,
  "language": "en"
}
```

### Response
```json
{
  "audio": "<base64 encoded MP3>",
  "timestamps": [
    { "word": "Hello", "start_time": 0.0, "end_time": 0.3 },
    { "word": "world", "start_time": 0.35, "end_time": 0.7 }
  ],
  "duration": 5.2,
  "processed_text": "Text that was processed",
  "unprocessed_text": "Remaining text for next call"
}
```

### Key Behavior
- **Partly processing**: Each call only processes a portion of the input text
- `unprocessed_text` contains the remaining text — loop until empty
- Timestamps may use `start/end` or `start_time/end_time` format
- Audio is base64-encoded MP3

### Retry Logic
- Retry on HTTP 502/503/504 (up to 3 times)
- Exponential backoff: 2s × attempt number

## Chrome Extension Messages

### Content Script → Background
| Message Type | Payload | Response |
|---|---|---|
| `TTS_GENERATE` | `{ input, voice?, speed?, language? }` | `{ audioUrl, timestamps, duration }` |
| `GET_SETTINGS` | — | `{ apiBaseUrl, voice, speed, language }` |

### Popup/Background → Content Script
| Message Type | Payload | Effect |
|---|---|---|
| `START_READING` | — | Start extraction + TTS playback |
| `STOP_READING` | — | Stop playback, hide player |
| `TOGGLE_READING` | — | Toggle play/pause/restart |
| `GET_STATUS` | — | Returns current status + playback state |

## Extraction Pipeline

3-layer extraction:
1. **Special extractors** — Site-specific (WeRead, Kindle, Notion, Google Docs, etc.)
2. **Site rules** — CSS selector rules from `site-rules.json`
3. **Visible-Text-Block** — Generic algorithm (text density + link density + context-sensitive classification)

### Bundle API
After injecting `extractor-bundle.js`:
```javascript
const result = await window.__castreaderExtract();
// result: { success, title, paragraphs: [{text, tagName, className}], language, method }
```


path: scripts/demo.js
content: #!/usr/bin/env node

/**
 * demo.js — Visual demo of CastReader OpenClaw Skill
 *
 * Simulates a Telegram-style conversation:
 *   User sends URL → Agent extracts → sends paragraph audio + text
 *
 * Usage: node scripts/demo.js [url]
 */

const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');

const BLUE = '\x1b[34m';
const GREEN = '\x1b[32m';
const CYAN = '\x1b[36m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
const BG_BLUE = '\x1b[44m\x1b[37m';
const BG_GREEN = '\x1b[42m\x1b[30m';

function sleep(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

function typewrite(text, delay = 30) {
  for (const char of text) {
    process.stdout.write(char);
    sleep(delay);
  }
  console.log();
}

function printUser(text) {
  console.log();
  console.log(`${BG_BLUE} You ${RESET}  ${text}`);
}

function printBot(text) {
  console.log(`${BG_GREEN} CastReader ${RESET}  ${text}`);
}

function printBotTyping() {
  process.stdout.write(`${BG_GREEN} CastReader ${RESET}  ${DIM}typing...${RESET}`);
  sleep(800);
  process.stdout.write('\r\x1b[K');
}

function printDivider() {
  console.log(`${DIM}${'─'.repeat(60)}${RESET}`);
}

async function main() {
  const url = process.argv[2] || 'https://alistapart.com/blog/post/successful-or-unsuccessful-the-post-good-design-vocabulary/';

  console.clear();
  console.log();
  console.log(`${BOLD}  CastReader × OpenClaw — Demo${RESET}`);
  console.log(`${DIM}  Simulating a Telegram conversation${RESET}`);
  printDivider();

  // User sends URL
  sleep(1000);
  printUser(`Read this article for me:`);
  printUser(`${CYAN}${url}${RESET}`);
  console.log();

  sleep(1500);

  // Bot starts extracting
  printBotTyping();
  printBot(`${DIM}Extracting content...${RESET}`);
  console.log();

  // Actually run extract.js
  const extractScript = path.resolve(__dirname, 'extract.js');
  let extractResult;
  try {
    const output = execFileSync('node', [extractScript, url], {
      encoding: 'utf-8',
      timeout: 60000,
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    extractResult = JSON.parse(output);
  } catch (err) {
    printBot(`Sorry, I couldn't extract content from this page.`);
    process.exit(1);
  }

  if (!extractResult.success || !extractResult.paragraphs?.length) {
    printBot(`No readable content found on this page.`);
    process.exit(1);
  }

  sleep(500);
  printBot(`${BOLD}${extractResult.title}${RESET}`);
  printBot(`${DIM}${extractResult.totalParagraphs} paragraphs · ${extractResult.totalCharacters} chars · ${extractResult.language}${RESET}`);
  console.log();
  sleep(1000);

  // Generate audio for first 3 paragraphs (demo)
  const maxParas = Math.min(3, extractResult.paragraphs.length);
  printBot(`${DIM}Generating audio for ${maxParas} paragraphs...${RESET}`);
  console.log();

  const generateScript = path.resolve(__dirname, 'generate-audio.js');
  const outputDir = `/tmp/castreader-demo-${Date.now()}`;
  let manifest;
  try {
    // We'll run generate-audio but only use first few paragraphs
    const output = execFileSync('node', [generateScript, url, outputDir], {
      encoding: 'utf-8',
      timeout: 120000,
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    manifest = JSON.parse(output);
  } catch (err) {
    printBot(`Audio generation failed.`);
    process.exit(1);
  }

  // Display paragraphs as Telegram messages
  const displayCount = Math.min(maxParas, manifest.paragraphs.length);
  for (let i = 0; i < displayCount; i++) {
    const para = manifest.paragraphs[i];
    sleep(1200);
    printBotTyping();

    // Show paragraph text (truncated for display)
    const displayText = para.text.length > 120
      ? para.text.substring(0, 120) + '...'
      : para.text;

    printBot(`${BOLD}[${para.index}/${manifest.totalParagraphs}]${RESET} ${displayText}`);

    if (para.audioFile && fs.existsSync(para.audioFile)) {
      const sizeKB = (para.fileSizeBytes / 1024).toFixed(0);
      printBot(`${GREEN}🔊 ${path.basename(para.audioFile)}${RESET} ${DIM}(${sizeKB} KB)${RESET}`);
    }
    console.log();
  }

  if (manifest.totalParagraphs > displayCount) {
    sleep(800);
    printBot(`${DIM}... and ${manifest.totalParagraphs - displayCount} more paragraphs${RESET}`);
    console.log();
  }

  // Show full audio
  sleep(1000);
  printDivider();
  printBot(`${GREEN}📎 full.mp3${RESET} ${DIM}(${manifest.fullAudioSizeMB} MB — all ${manifest.totalParagraphs} paragraphs combined)${RESET}`);
  console.log();

  // Summary
  sleep(500);
  printDivider();
  console.log();
  console.log(`${BOLD}  Demo complete!${RESET}`);
  console.log(`${DIM}  Audio files saved to: ${outputDir}/${RESET}`);
  console.log(`${DIM}  Play: open ${outputDir}/001.mp3${RESET}`);
  console.log();
}

main();


path: scripts/extract.js
content: #!/usr/bin/env node

/**
 * extract.js — Extract clean article text from a web page
 *
 * Usage: node extract.js <url>
 * Output: JSON to stdout
 *
 * Uses Castreader's 3-layer extraction pipeline:
 *   1. Special extractors (15+ site-specific)
 *   2. Site rules (CSS selector rules, auto-learned)
 *   3. Visible-Text-Block (generic fallback)
 */

const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');

const BUNDLE_PATH = path.resolve(__dirname, '../dist/extractor-bundle.js');

async function extract(url) {
  // Verify bundle exists
  if (!fs.existsSync(BUNDLE_PATH)) {
    console.error('Error: Extractor bundle not found at', BUNDLE_PATH);
    console.error('Reinstall: clawhub install castreader');
    process.exit(1);
  }

  const bundle = fs.readFileSync(BUNDLE_PATH, 'utf-8');

  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  try {
    const page = await browser.newPage();

    // Set a reasonable viewport and user agent
    await page.setViewport({ width: 1280, height: 800 });
    await page.setUserAgent(
      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
    );

    // Navigate and wait for content
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });

    // Inject extractor bundle (defines window.__castreaderExtract)
    await page.evaluate(bundle);

    // Run extraction
    const result = await page.evaluate(async () => {
      const fn = window.__castreaderExtract;
      if (!fn) throw new Error('Extractor not loaded');
      return await fn();
    });

    return {
      success: result.success,
      title: result.title,
      paragraphs: result.paragraphs.map((p) => p.text),
      language: result.language,
      method: result.method,
      totalParagraphs: result.totalParagraphs,
      totalCharacters: result.totalCharacters,
      error: result.error || undefined,
    };
  } finally {
    await browser.close();
  }
}

// CLI entry point
const url = process.argv[2];
if (!url) {
  console.error('Usage: node extract.js <url>');
  process.exit(1);
}

extract(url)
  .then((result) => {
    console.log(JSON.stringify(result, null, 2));
  })
  .catch((err) => {
    console.error('Extraction failed:', err.message);
    process.exit(1);
  });


path: scripts/generate-paragraph.js
content: #!/usr/bin/env node

/**
 * generate-paragraph.js — Generate audio for a single paragraph by index
 *
 * Usage:
 *   node generate-paragraph.js <extract-json-file> <paragraph-index>
 *
 * The extract-json-file is the output of extract.js (saved to a file).
 * paragraph-index is 1-based (1 = first paragraph).
 *
 * Output:
 *   Writes <index>.mp3 next to the extract JSON file.
 *   Prints JSON to stdout: { index, totalParagraphs, text, audioFile, fileSizeBytes }
 *
 * Environment variables:
 *   CASTREADER_API_URL  — TTS API endpoint (default: http://api.castreader.ai:8123)
 *   CASTREADER_VOICE    — TTS voice (default: af_heart)
 *   CASTREADER_SPEED    — Playback speed (default: 1.5)
 */

const fs = require('fs');
const path = require('path');

const API_URL = process.env.CASTREADER_API_URL || 'http://api.castreader.ai:8123';
const [REDACTED_SECRET] || '';
const VOICE = process.env.CASTREADER_VOICE || 'af_heart';
const SPEED = parseFloat(process.env.CASTREADER_SPEED || '1.5');
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function generateTTS(text, language) {
  const audioChunks = [];
  let remaining = text;

  while (remaining && remaining.trim().length > 0) {
    const body = {
      model: 'kokoro',
      input: remaining,
      voice: VOICE,
      response_format: 'mp3',
      return_timestamps: true,
      speed: SPEED,
      stream: false,
      language: language || 'en',
    };

    let data = null;
    let lastError = null;

    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
      try {
        const headers = { 'Content-Type': 'application/json', accept: 'application/json' };
        if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;

        const response = await fetch(`${API_URL}/api/captioned_speech_partly`, {
          method: 'POST',
          headers,
          body: JSON.stringify(body),
        });

        if (!response.ok) {
          const errText = await response.text().catch(() => '');
          lastError = new Error(`HTTP ${response.status}: ${errText}`);
          if (response.status >= 502 && response.status <= 504 && attempt < MAX_RETRIES) {
            await sleep(RETRY_DELAY_MS * attempt);
            continue;
          }
          throw lastError;
        }

        data = await response.json();
        break;
      } catch (err) {
        lastError = err;
        if (attempt < MAX_RETRIES && err.message?.includes('fetch')) {
          await sleep(RETRY_DELAY_MS * attempt);
          continue;
        }
        throw err;
      }
    }

    if (!data || (!data.audio && !data.audioUrl)) {
      throw lastError || new Error('No audio data received');
    }

    const audioBase64 = data.audio || data.audioUrl?.replace(/^data:audio\/\w+;base64,/, '');
    if (audioBase64) {
      audioChunks.push(Buffer.from(audioBase64, 'base64'));
    }

    remaining = data.unprocessed_text || '';
  }

  return Buffer.concat(audioChunks);
}

async function main() {
  const extractFile = process.argv[2];
  const paraIndex = parseInt(process.argv[3] || '1', 10);

  if (!extractFile || !fs.existsSync(extractFile)) {
    console.error('Usage: node generate-paragraph.js <extract-json-file> <paragraph-index>');
    console.error('  extract-json-file: path to the JSON output from extract.js');
    console.error('  paragraph-index: 1-based paragraph number');
    process.exit(1);
  }

  const extract = JSON.parse(fs.readFileSync(extractFile, 'utf-8'));

  if (!extract.success || !extract.paragraphs?.length) {
    console.error('Extract file has no paragraphs');
    process.exit(1);
  }

  const total = extract.paragraphs.length;

  if (paraIndex < 1 || paraIndex > total) {
    console.error(`Paragraph index ${paraIndex} out of range (1-${total})`);
    process.exit(1);
  }

  const text = extract.paragraphs[paraIndex - 1];
  if (!text || text.trim().length === 0) {
    console.error(`Paragraph ${paraIndex} is empty`);
    process.exit(1);
  }

  const outputDir = path.dirname(extractFile);
  const paddedIndex = String(paraIndex).padStart(3, '0');
  const audioFile = path.join(outputDir, `${paddedIndex}.mp3`);

  const audio = await generateTTS(text, extract.language);
  fs.writeFileSync(audioFile, audio);

  console.log(JSON.stringify({
    index: paraIndex,
    totalParagraphs: total,
    title: extract.title,
    language: extract.language,
    text: text.trim(),
    audioFile: path.resolve(audioFile),
    fileSizeBytes: audio.length,
  }));
}

main().catch((err) => {
  console.error('Error:', err.message);
  process.exit(1);
});


path: scripts/generate-text.js
content: #!/usr/bin/env node

/**
 * generate-text.js — Generate audio from a text file
 *
 * Usage:
 *   node scripts/generate-text.js <text-file> [language]
 *
 * Reads text from file, generates TTS audio, writes MP3 next to it.
 * Prints JSON: { audioFile, fileSizeBytes }
 */

const fs = require('fs');
const path = require('path');

const API_URL = process.env.CASTREADER_API_URL || 'http://api.castreader.ai:8123';
const [REDACTED_SECRET] || '';
const VOICE = process.env.CASTREADER_VOICE || 'af_heart';
const SPEED = parseFloat(process.env.CASTREADER_SPEED || '1.5');
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function generateTTS(text, language) {
  const audioChunks = [];
  let remaining = text;

  while (remaining && remaining.trim().length > 0) {
    const body = {
      model: 'kokoro',
      input: remaining,
      voice: VOICE,
      response_format: 'mp3',
      return_timestamps: true,
      speed: SPEED,
      stream: false,
      language: language || 'en',
    };

    let data = null;
    let lastError = null;

    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
      try {
        const headers = { 'Content-Type': 'application/json', accept: 'application/json' };
        if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;

        const response = await fetch(`${API_URL}/api/captioned_speech_partly`, {
          method: 'POST',
          headers,
          body: JSON.stringify(body),
        });

        if (!response.ok) {
          const errText = await response.text().catch(() => '');
          lastError = new Error(`HTTP ${response.status}: ${errText}`);
          if (response.status >= 502 && response.status <= 504 && attempt < MAX_RETRIES) {
            await sleep(RETRY_DELAY_MS * attempt);
            continue;
          }
          throw lastError;
        }

        data = await response.json();
        break;
      } catch (err) {
        lastError = err;
        if (attempt < MAX_RETRIES && err.message?.includes('fetch')) {
          await sleep(RETRY_DELAY_MS * attempt);
          continue;
        }
        throw err;
      }
    }

    if (!data || (!data.audio && !data.audioUrl)) {
      throw lastError || new Error('No audio data received');
    }

    const audioBase64 = data.audio || data.audioUrl?.replace(/^data:audio\/\w+;base64,/, '');
    if (audioBase64) {
      audioChunks.push(Buffer.from(audioBase64, 'base64'));
    }

    remaining = data.unprocessed_text || '';
  }

  return Buffer.concat(audioChunks);
}

async function main() {
  const textFile = process.argv[2];
  const language = process.argv[3] || 'en';

  if (!textFile || !fs.existsSync(textFile)) {
    console.error('Usage: node scripts/generate-text.js <text-file> [language]');
    process.exit(1);
  }

  const text = fs.readFileSync(textFile, 'utf-8').trim();
  if (!text) {
    console.error('Text file is empty');
    process.exit(1);
  }

  const audioFile = textFile.replace(/\.[^.]+$/, '') + '.mp3';

  const audio = await generateTTS(text, language);
  fs.writeFileSync(audioFile, audio);

  console.log(JSON.stringify({
    audioFile: path.resolve(audioFile),
    fileSizeBytes: audio.length,
  }));
}

main().catch((err) => {
  console.error('Error:', err.message);
  process.exit(1);
});


path: scripts/read-aloud.js
content: #!/usr/bin/env node

/**
 * read-aloud.js — Open URL in browser and trigger Castreader to read aloud
 *
 * Usage: node read-aloud.js <url>
 *
 * Launches Chrome with the Castreader extension loaded, navigates to the URL,
 * and triggers the extension to read the page aloud with highlighting.
 *
 * Environment variables:
 *   CASTREADER_EXT_PATH — Path to built extension directory
 *                         (default: ../../.output/chrome-mv3)
 *   CDP_URL             — Connect to existing browser via CDP instead of launching new one
 *   CHROME_PROFILE      — Chrome user data directory for persistent sessions
 */

const path = require('path');
const fs = require('fs');
const puppeteer = require('puppeteer');

const DEFAULT_EXT_PATH = path.resolve(__dirname, '../../.output/chrome-mv3');
const EXT_PATH = process.env.CASTREADER_EXT_PATH || DEFAULT_EXT_PATH;
const CHROME_PROFILE = process.env.CHROME_PROFILE || path.resolve(__dirname, '../.chrome-profile');

/**
 * Try connecting to an existing browser via CDP.
 */
async function tryConnectCDP() {
  const cdpUrl = process.env.CDP_URL;
  if (!cdpUrl) return null;

  process.stderr.write(`Connecting to browser at ${cdpUrl}...\n`);
  try {
    const browser = await puppeteer.connect({
      browserWSEndpoint: cdpUrl.startsWith('ws') ? cdpUrl : undefined,
      browserURL: cdpUrl.startsWith('http') ? cdpUrl : undefined,
    });
    return browser;
  } catch (err) {
    process.stderr.write(`Failed to connect to CDP: ${err.message}\n`);
    return null;
  }
}

/**
 * Launch a new Chrome instance with the Castreader extension loaded.
 */
async function launchWithExtension() {
  if (!fs.existsSync(path.join(EXT_PATH, 'manifest.json'))) {
    console.error(`Error: Extension not found at ${EXT_PATH}`);
    console.error('Run: pnpm build  (in project root)');
    process.exit(1);
  }

  process.stderr.write(`Launching Chrome with extension from ${EXT_PATH}...\n`);

  const browser = await puppeteer.launch({
    headless: false,
    protocolTimeout: 120_000,
    args: [
      `--disable-extensions-except=${EXT_PATH}`,
      `--load-extension=${EXT_PATH}`,
      '--no-first-run',
      '--no-default-browser-check',
      '--disable-popup-blocking',
      '--autoplay-policy=no-user-gesture-required',
      '--disable-blink-features=AutomationControlled',
    ],
    userDataDir: CHROME_PROFILE,
  });

  return browser;
}

/**
 * Find the Castreader extension ID by checking service worker targets.
 */
async function findExtensionId(browser) {
  for (let attempt = 0; attempt < 10; attempt++) {
    await new Promise((r) => setTimeout(r, 1000));
    const targets = browser.targets();
    const extTarget = targets.find(
      (t) => t.type() === 'service_worker' && t.url().includes('chrome-extension://'),
    );
    if (extTarget) {
      const match = extTarget.url().match(/chrome-extension:\/\/([a-z]+)\//);
      if (match) return match[1];
    }
  }

  // Fallback: check chrome://extensions page
  const page = await browser.newPage();
  await page.goto('chrome://extensions/', { waitUntil: 'domcontentloaded' });
  await new Promise((r) => setTimeout(r, 2000));

  const extId = await page.evaluate(() => {
    const manager = document.querySelector('extensions-manager');
    if (!manager?.shadowRoot) return null;
    const itemList = manager.shadowRoot.querySelector('extensions-item-list');
    if (!itemList?.shadowRoot) return null;
    const items = itemList.shadowRoot.querySelectorAll('extensions-item');
    for (const item of items) {
      const name = item.shadowRoot?.querySelector('#name')?.textContent?.trim();
      if (name && (name.toLowerCase().includes('castreader') || name.toLowerCase().includes('readout'))) {
        return item.getAttribute('id');
      }
    }
    for (const item of items) {
      const id = item.getAttribute('id');
      if (id && id.length === 32) return id;
    }
    return null;
  });

  await page.close();
  if (extId) return extId;

  throw new Error('Could not find Castreader extension ID');
}

/**
 * Trigger reading by connecting to the extension's background service worker
 * and calling chrome.tabs.sendMessage to the target tab.
 */
async function triggerReadingViaBgSW(browser, targetPage, extId) {
  // Find the background service worker target
  const targets = browser.targets();
  const swTarget = targets.find(
    (t) => t.type() === 'service_worker' && t.url().includes(`chrome-extension://${extId}/`),
  );

  if (!swTarget) {
    return { success: false, error: 'Background service worker not found' };
  }

  // Connect to the background service worker
  const swCdp = await swTarget.createCDPSession();
  try {
    await swCdp.send('Runtime.enable');

    process.stderr.write(`  Sending TOGGLE_READING to active tab...\n`);

    // In the background SW, query the active tab (doesn't need "tabs" permission)
    // and send TOGGLE_READING. We make sure the target page is focused first.
    const { result, exceptionDetails } = await swCdp.send('Runtime.evaluate', {
      expression: `
        (async () => {
          // Get the currently active tab — doesn't require "tabs" permission
          const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
          if (!activeTab || !activeTab.id) {
            const allTabs = await chrome.tabs.query({});
            return JSON.stringify({ success: false, error: 'No active tab found', tabCount: allTabs.length });
          }
          try {
            const response = await chrome.tabs.sendMessage(activeTab.id, { type: 'TOGGLE_READING' });
            return JSON.stringify({ success: true, tabId: activeTab.id, response });
          } catch (e) {
            // Content script not loaded — try injecting it
            try {
              await chrome.scripting.executeScript({
                target: { tabId: activeTab.id },
                files: ['content-scripts/content.js'],
              });
              await new Promise(r => setTimeout(r, 500));
              const response = await chrome.tabs.sendMessage(activeTab.id, { type: 'TOGGLE_READING' });
              return JSON.stringify({ success: true, tabId: activeTab.id, response, injected: true });
            } catch (e2) {
              return JSON.stringify({ success: false, tabId: activeTab.id, error: e2.message });
            }
          }
        })()
      `,
      awaitPromise: true,
      returnByValue: true,
    });

    if (exceptionDetails) {
      return { success: false, error: exceptionDetails.text || JSON.stringify(exceptionDetails) };
    }

    try {
      return JSON.parse(result.value);
    } catch {
      return { success: true, raw: result.value };
    }
  } finally {
    await swCdp.detach();
  }
}

async function main() {
  const url = process.argv[2];
  if (!url) {
    console.error('Usage: node read-aloud.js <url>');
    process.exit(1);
  }

  // Try CDP first, otherwise launch with extension
  let browser = await tryConnectCDP();
  const isConnected = !!browser;

  if (!browser) {
    browser = await launchWithExtension();
  }

  try {
    // Find extension ID
    process.stderr.write('Finding Castreader extension...\n');
    const extId = await findExtensionId(browser);
    process.stderr.write(`Extension ID: ${extId}\n`);

    // Open the target URL in a new tab
    process.stderr.write(`Navigating to ${url}...\n`);
    const targetPage = await browser.newPage();
    await targetPage.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });

    // Wait for content script to load
    process.stderr.write('Waiting for content script to load...\n');
    await new Promise((r) => setTimeout(r, 3000));

    // Bring target page to front so it's the active tab
    await targetPage.bringToFront();
    await new Promise((r) => setTimeout(r, 500));

    // Trigger reading via background service worker CDP
    process.stderr.write('Triggering read aloud via background SW...\n');
    const triggerResult = await triggerReadingViaBgSW(browser, targetPage, extId);

    process.stderr.write(`Trigger result: ${JSON.stringify(triggerResult)}\n`);

    if (!triggerResult.success) {
      console.error('Failed to trigger reading:', triggerResult.error);
      if (triggerResult.contexts) {
        console.error('Available contexts:', JSON.stringify(triggerResult.contexts, null, 2));
      }
      process.exit(1);
    }

    // Monitor playback status
    process.stderr.write('Reading started. Monitoring...\n');

    for (let i = 0; i < 60; i++) {
      await new Promise((r) => setTimeout(r, 3000));

      const status = await targetPage.evaluate(() => {
        const player = document.getElementById('castreader-floating-player');
        if (!player) return 'no-player';
        return 'playing';
      }).catch(() => 'unknown');

      process.stderr.write(`  [${i * 3}s] Status: ${status}\n`);

      if (status === 'no-player' && i > 5) {
        process.stderr.write('Floating player disappeared. Reading may have finished.\n');
        break;
      }
    }

    console.log(JSON.stringify({
      success: true,
      url,
      extensionId: extId,
      message: 'Read-aloud triggered in browser',
    }));

  } finally {
    if (isConnected) {
      browser.disconnect();
    } else {
      process.stderr.write('\nBrowser will remain open. Press Ctrl+C to exit.\n');
      await new Promise(() => {});
    }
  }
}

main().catch((err) => {
  console.error('Error:', err.message);
  process.exit(1);
});


path: scripts/read-url.js
content: #!/usr/bin/env node

/**
 * read-url.js — Extract article from URL and generate audio
 *
 * Usage:
 *   node scripts/read-url.js <url> <mode>
 *
 * Modes:
 *   0    — Extract only, no TTS. Returns article info + paragraph texts.
 *   all  — Extract + generate audio for ALL paragraphs (full article).
 *   1-N  — Extract + generate audio for one paragraph (1-based).
 *
 * Output JSON (mode=0):
 *   { title, language, totalParagraphs, totalCharacters, paragraphs[], current: null }
 *
 * Output JSON (mode=all):
 *   { title, language, totalParagraphs, totalCharacters, audioFile, fileSizeBytes }
 *
 * Output JSON (mode=1+):
 *   { title, language, totalParagraphs, totalCharacters, current: { index, text, audioFile, fileSizeBytes }, hasNext }
 */

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');

const API_URL = process.env.CASTREADER_API_URL || 'http://api.castreader.ai:8123';
const [REDACTED_SECRET] || '';
const VOICE = process.env.CASTREADER_VOICE || 'af_heart';
const SPEED = parseFloat(process.env.CASTREADER_SPEED || '1.5');
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

function urlHash(url) {
  return crypto.createHash('md5').update(url).digest('hex').slice(0, 10);
}

async function generateTTS(text, language) {
  const audioChunks = [];
  let remaining = text;

  while (remaining && remaining.trim().length > 0) {
    const body = {
      model: 'kokoro',
      input: remaining,
      voice: VOICE,
      response_format: 'mp3',
      return_timestamps: true,
      speed: SPEED,
      stream: false,
      language: language || 'en',
    };

    let data = null;
    let lastError = null;

    for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
      try {
        const headers = { 'Content-Type': 'application/json', accept: 'application/json' };
        if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;

        const response = await fetch(`${API_URL}/api/captioned_speech_partly`, {
          method: 'POST',
          headers,
          body: JSON.stringify(body),
        });

        if (!response.ok) {
          const errText = await response.text().catch(() => '');
          lastError = new Error(`HTTP ${response.status}: ${errText}`);
          if (response.status >= 502 && response.status <= 504 && attempt < MAX_RETRIES) {
            await sleep(RETRY_DELAY_MS * attempt);
            continue;
          }
          throw lastError;
        }

        data = await response.json();
        break;
      } catch (err) {
        lastError = err;
        if (attempt < MAX_RETRIES && err.message?.includes('fetch')) {
          await sleep(RETRY_DELAY_MS * attempt);
          continue;
        }
        throw err;
      }
    }

    if (!data || (!data.audio && !data.audioUrl)) {
      throw lastError || new Error('No audio data received');
    }

    const audioBase64 = data.audio || data.audioUrl?.replace(/^data:audio\/\w+;base64,/, '');
    if (audioBase64) {
      audioChunks.push(Buffer.from(audioBase64, 'base64'));
    }

    remaining = data.unprocessed_text || '';
  }

  return Buffer.concat(audioChunks);
}

async function main() {
  const url = process.argv[2];
  const modeArg = process.argv[3] || '1';

  if (!url) {
    console.error('Usage: node scripts/read-url.js <url> <0|all|paragraph-index>');
    process.exit(1);
  }

  // Stable output dir based on URL hash
  const hash = urlHash(url);
  const outputDir = path.join('/tmp', `castreader-${hash}`);
  fs.mkdirSync(outputDir, { recursive: true });

  const extractFile = path.join(outputDir, 'extract.json');

  // Step 1: Extract (or reuse cache)
  let extract;
  if (fs.existsSync(extractFile)) {
    extract = JSON.parse(fs.readFileSync(extractFile, 'utf-8'));
  } else {
    const extractScript = path.resolve(__dirname, 'extract.js');
    const output = execFileSync('node', [extractScript, url], {
      encoding: 'utf-8',
      timeout: 60000,
    });
    extract = JSON.parse(output);
    fs.writeFileSync(extractFile, JSON.stringify(extract, null, 2));
  }

  if (!extract.success || !extract.paragraphs?.length) {
    console.error('No content extracted');
    process.exit(1);
  }

  const total = extract.paragraphs.length;

  // Mode: 0 = extract-only
  if (modeArg === '0') {
    console.log(JSON.stringify({
      title: extract.title,
      language: extract.language,
      totalParagraphs: total,
      totalCharacters: extract.totalCharacters,
      paragraphs: extract.paragraphs.map(p => p.trim()),
      current: null,
    }));
    return;
  }

  // Mode: all = generate audio for entire article
  if (modeArg === 'all') {
    const fullText = extract.paragraphs.join('\n\n');
    const audioFile = path.join(outputDir, 'full.mp3');

    process.stderr.write(`Generating audio for ${total} paragraphs, ${extract.totalCharacters} chars...\n`);
    const audio = await generateTTS(fullText, extract.language);
    fs.writeFileSync(audioFile, audio);

    console.log(JSON.stringify({
      title: extract.title,
      language: extract.language,
      totalParagraphs: total,
      totalCharacters: extract.totalCharacters,
      audioFile: path.resolve(audioFile),
      fileSizeBytes: audio.length,
    }));
    return;
  }

  // Mode: paragraph index (1-based)
  const paraIndex = parseInt(modeArg, 10);

  if (isNaN(paraIndex) || paraIndex < 1 || paraIndex > total) {
    console.error(`Index ${modeArg} out of range (1-${total})`);
    process.exit(1);
  }

  const text = extract.paragraphs[paraIndex - 1];
  const paddedIndex = String(paraIndex).padStart(3, '0');
  const audioFile = path.join(outputDir, `${paddedIndex}.mp3`);

  const audio = await generateTTS(text, extract.language);
  fs.writeFileSync(audioFile, audio);

  console.log(JSON.stringify({
    title: extract.title,
    language: extract.language,
    totalParagraphs: total,
    totalCharacters: extract.totalCharacters,
    current: {
      index: paraIndex,
      text: text.trim(),
      audioFile: path.resolve(audioFile),
      fileSizeBytes: audio.length,
    },
    hasNext: paraIndex < total,
  }));
}

main().catch((err) => {
  console.error('Error:', err.message);
  process.exit(1);
});


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)