@clawhub-esanle-75c281c211
Download purchased tracks from Beatport using the openclaw headless browser tool (CDP). Handles login, authentication via NextAuth, enabling downloads in hea...
---
name: beatport-dl-with-browser-tool
description: Download purchased tracks from Beatport using the openclaw headless browser tool (CDP). Handles login, authentication via NextAuth, enabling downloads in headless Chrome, and saving files locally. Use when the user asks to download music, tracks, or files from Beatport, or manage their Beatport purchases/library. Triggers on phrases like "download from beatport", "beatport download", "download my tracks", "get my beatport music".
---
# Beatport Download via Browser Tool
Download purchased Beatport tracks through the openclaw headless browser using CDP (Chrome DevTools Protocol).
## Prerequisites
- openclaw browser running on `127.0.0.1:9222`
- Beatport credentials (username + password)
- `ws` module at `/opt/homebrew/lib/node_modules/openclaw/node_modules/ws`
- Node.js runtime
## Authentication Flow
Beatport uses a dual-auth system:
1. **account.beatport.com** — Django session (`sessionid` cookie)
2. **www.beatport.com** — NextAuth (`__Secure-next-auth.session-token` cookie)
### Login Steps
1. Navigate to `https://account.beatport.com/` via CDP `Page.navigate`
2. Fill username/password via `Runtime.evaluate` (use native input setters to bypass React controlled inputs)
3. Submit the login form
4. On the www.beatport.com tab, sign in via NextAuth:
```javascript
// In browser context on www.beatport.com
fetch("/api/auth/csrf").then(r => r.json()).then(csrf => {
const fd = new URLSearchParams();
fd.append("csrfToken", csrf.csrfToken);
fd.append("username", "USER");
fd.append("password", "PASS");
fd.append("callbackUrl", "https://www.beatport.com/");
// Create hidden form and submit (fetch redirect fails cross-origin)
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/auth/signin/beatport";
form.style.display = "none";
for (const [k, v] of Object.entries(Object.fromEntries(fd))) {
const inp = document.createElement("input");
inp.type = "hidden"; inp.name = k; inp.value = v;
form.appendChild(inp);
}
document.body.appendChild(form);
form.submit();
});
```
5. Verify login: `Account menu` button should appear in navbar (no `Create Account or Log In` button)
## Key URLs
| Page | URL | Purpose |
|------|-----|---------|
| Cart | `https://www.beatport.com/cart` | Items pending purchase |
| Library | `https://www.beatport.com/library` | Purchased tracks (may show Upgrade for free accounts) |
| Downloads | `https://www.beatport.com/library/downloads` | Download queue |
| Checkout | `https://www.beatport.com/checkout` | Payment page |
**Note:** `/my-beatport/downloads` and `/my-beatport/collection` return 404. The correct paths are `/library` and `/library/downloads`.
## Enabling Downloads in Headless Chrome
Headless Chrome cancels downloads by default. Enable via CDP on the **browser-level** WebSocket:
```javascript
// Browser-level WS: ws://127.0.0.1:9222/devtools/browser/<id>
ws.send(JSON.stringify({
id: 1,
method: "Browser.setDownloadBehavior",
params: {
behavior: "allowAndName",
downloadPath: "/path/to/download/dir/",
eventsEnabled: true
}
}));
```
Get browser ID from `http://127.0.0.1:9222/json/version` → `webSocketDebuggerUrl`.
## Downloading Tracks
### Step 1: Add tracks to download queue
On `/library`, each track has a re-download icon (`svg[data-testid='icon-re-download']`). Click each one to add to the download queue:
```javascript
var icons = document.querySelectorAll("svg[data-testid='icon-re-download']");
icons.forEach(function(icon, i) {
setTimeout(function() { icon.closest("button, div").click(); }, i * 500);
});
```
### Step 2: Download from queue page
Navigate to `/library/downloads`. All queued tracks appear with a "Download All" button.
### Step 3: Click Download All
Enable browser downloads first (see above), then click:
```javascript
var btn = [...document.querySelectorAll("button")].find(b => b.innerText.includes("Download All"));
if (btn) btn.click();
```
The download arrives as a zip file (e.g. `beatport_tracks_2026-04.zip`).
### Step 4: Unzip and clean up
```bash
cd /path/to/download/dir
unzip -o beatport_tracks_*.zip -d tmp/
mv tmp/*.mp3 .
rm -rf tmp/ beatport_tracks_*.zip
```
### Download URL Format
```
https://zips.beatport.com/v1/download?token=<JWT_TOKEN>
```
The token is single-use and expires quickly. Always capture fresh from events.
### Download URL Format
```
https://zips.beatport.com/v1/download?token=<JWT_TOKEN>
```
The token is single-use and expires quickly. Always capture it fresh from the `Page.downloadWillBegin` event.
## API Access
### Access Token
```bash
curl -s -H "Cookie: <cookies>" \
"https://www.beatport.com/_next/data/<buildId>/en/library/downloads.json" \
| jq -r '.pageProps.accessToken'
```
### Library Data
```bash
curl -s -H "Cookie: <cookies>" \
"https://www.beatport.com/_next/data/<buildId>/en/library.json" \
| jq '.pageProps.dehydratedState.queries[].state.data.results[] | {name, id, artists}'
```
### Build ID
```bash
curl -s "https://www.beatport.com/" | grep -o '"buildId":"[^"]*"' | head -1
```
Current buildId (subject to change): `PWoDyRo_P5V8lNYu_92bX`
## Common Pitfalls
1. **Cross-domain navigation fails with `Page.navigate`** — Use `location.href = "..."` via `Runtime.evaluate` instead
2. **React controlled inputs don't respond to `.value =`** — Use native input value setter:
```javascript
var input = document.querySelector("input[name=username]");
var nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
nativeSetter.call(input, "username");
input.dispatchEvent(new Event("input", { bubbles: true }));
```
3. **Node.js string escaping in `-e`** — Use `String.raw\`...\`` template literals, or write code to a file and run with `node file.js`
4. **Free account download limit** — 20 downloads per track. "Unlimited re-downloads" requires Beatport Streaming subscription
5. **CDP exec timeout** — openclaw kills long-running node processes (~10s). Keep CDP operations short; use `background: true` + `process poll` for longer waits
6. **`curl` path** — Use `/usr/bin/curl`, not `/opt/homebrew/bin/curl` (may not exist)
## CDP Helper Pattern
Write scripts to files to avoid shell escaping issues:
```javascript
// scripts/beatport-cdp.js
const WS = require("/opt/homebrew/lib/node_modules/openclaw/node_modules/ws");
const http = require("http");
function getPage(filter) {
return new Promise((resolve) => {
http.get("http://127.0.0.1:9222/json", (res) => {
let body = "";
res.on("data", (c) => body += c);
res.on("end", () => {
const pages = JSON.parse(body).filter(p => p.type === "page");
resolve(filter ? pages.find(filter) || pages[0] : pages[0]);
});
});
});
}
function cdpEval(ws, expression) {
return new Promise((resolve) => {
ws.send(JSON.stringify({ id: Date.now(), method: "Runtime.evaluate", params: { expression, returnByValue: true } }));
ws.on("message", (m) => {
const d = JSON.parse(m.toString());
if (d.id && d.result) { resolve(d.result); }
});
});
}
async function screenshot(ws, path) {
return new Promise((resolve) => {
ws.send(JSON.stringify({ id: Date.now(), method: "Page.captureScreenshot", params: { format: "png" } }));
ws.on("message", (m) => {
const d = JSON.parse(m.toString());
if (d.id && d.result && d.result.data) {
require("fs").writeFileSync(path, Buffer.from(d.result.data, "base64"));
resolve();
}
});
});
}
module.exports = { getPage, cdpEval, screenshot };
```
## Format Compatibility
- **CDJ-2000**: MP3 or WAV
- Beatport download options: MP3, WAV, AIFF, FLAC
- Default is MP3; select WAV/AIFF on cart page or account settings if needed for CDJ compatibility
FILE:scripts/beatport-cdp.js
const WS = require("/opt/homebrew/lib/node_modules/openclaw/node_modules/ws");
const http = require("http");
const fs = require("fs");
/**
* Get a page from the CDP debugger
*/
function getPage(filterFn) {
return new Promise((resolve, reject) => {
http.get("http://127.0.0.1:9222/json", (res) => {
let body = "";
res.on("data", (c) => body += c);
res.on("end", () => {
const pages = JSON.parse(body).filter(p => p.type === "page");
if (filterFn) {
resolve(pages.find(filterFn) || pages[0]);
} else {
resolve(pages[0]);
}
});
}).on("error", reject);
});
}
/**
* Get the browser-level WebSocket URL
*/
function getBrowserWs() {
return new Promise((resolve, reject) => {
http.get("http://127.0.0.1:9222/json/version", (res) => {
let body = "";
res.on("data", (c) => body += c);
res.on("end", () => {
const data = JSON.parse(body);
resolve(data.webSocketDebuggerUrl);
});
}).on("error", reject);
});
}
/**
* Connect to a page's WebSocket
*/
function connectPage(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WS(wsUrl);
ws.on("open", () => resolve(ws));
ws.on("error", reject);
});
}
/**
* Run JavaScript in browser context and return the value
*/
function evalJs(ws, expression) {
return new Promise((resolve, reject) => {
const id = Date.now();
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.id === id) {
ws.removeListener("message", handler);
if (d.result && d.result.type === "undefined") {
resolve(null);
} else if (d.result && d.result.value !== undefined) {
resolve(d.result.value);
} else if (d.result && d.result.type === "error") {
reject(new Error(d.result.description || "Eval error"));
} else {
resolve(d.result);
}
}
};
ws.on("message", handler);
ws.send(JSON.stringify({
id,
method: "Runtime.evaluate",
params: { expression, returnByValue: true }
}));
});
}
/**
* Navigate to a URL (using location.href for cross-domain support)
*/
function navigate(ws, url) {
return new Promise((resolve, reject) => {
const id = Date.now();
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.id === id) {
ws.removeListener("message", handler);
resolve();
}
};
ws.on("message", handler);
ws.send(JSON.stringify({
id,
method: "Runtime.evaluate",
params: { expression: `location.href = "url"` }
}));
});
}
/**
* Wait for page load event
*/
function waitForLoad(ws, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.method === "Page.loadEventFired") {
ws.removeListener("message", handler);
resolve();
}
};
ws.on("message", handler);
setTimeout(() => {
ws.removeListener("message", handler);
reject(new Error("Timeout waiting for load"));
}, timeoutMs);
});
}
/**
* Take a screenshot and save to file
*/
function screenshot(ws, path) {
return new Promise((resolve, reject) => {
const id = Date.now();
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.id === id) {
ws.removeListener("message", handler);
if (d.result && d.result.data) {
const buf = Buffer.from(d.result.data, "base64");
fs.writeFileSync(path, buf);
resolve(path);
} else {
reject(new Error("No screenshot data"));
}
}
};
ws.on("message", handler);
ws.send(JSON.stringify({
id,
method: "Page.captureScreenshot",
params: { format: "png" }
}));
});
}
/**
* Enable downloads to a specific directory (browser-level)
*/
async function enableDownloads(downloadPath) {
const browserWsUrl = await getBrowserWs();
return new Promise((resolve, reject) => {
const ws = new WS(browserWsUrl);
ws.on("open", () => {
ws.send(JSON.stringify({
id: 1,
method: "Browser.setDownloadBehavior",
params: {
behavior: "allowAndName",
downloadPath: downloadPath,
eventsEnabled: true
}
}));
});
ws.on("message", (m) => {
const d = JSON.parse(m.toString());
if (d.id === 1) {
ws.close();
resolve(d.result);
}
});
ws.on("error", reject);
});
}
/**
* Get all cookies for a domain
*/
function getCookies(ws, urls) {
return new Promise((resolve, reject) => {
const id = Date.now();
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.id === id) {
ws.removeListener("message", handler);
resolve(d.result.cookies);
}
};
ws.on("message", handler);
ws.send(JSON.stringify({
id,
method: "Network.getAllCookies",
params: urls ? { urls } : {}
}));
});
}
/**
* Login to Beatport via account.beatport.com
*/
async function login(ws, username, password) {
// Navigate to login page
await navigate(ws, "https://account.beatport.com/");
await waitForLoad(ws);
// Fill form using native input setter (bypasses React controlled inputs)
await evalJs(ws, `
var userInput = document.querySelector("input[name=username]") || document.querySelector("input[id=id_username]");
if (userInput) {
var nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
nativeSetter.call(userInput, "username");
userInput.dispatchEvent(new Event("input", { bubbles: true }));
userInput.dispatchEvent(new Event("change", { bubbles: true }));
}
`);
await evalJs(ws, `
var passInput = document.querySelector("input[name=password]") || document.querySelector("input[id=id_password]");
if (passInput) {
var nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
nativeSetter.call(passInput, "password");
passInput.dispatchEvent(new Event("input", { bubbles: true }));
passInput.dispatchEvent(new Event("change", { bubbles: true }));
}
`);
// Submit
await evalJs(ws, `
var form = document.querySelector("form");
if (form) form.submit();
`);
await waitForLoad(ws, 5000);
}
/**
* Capture a download URL by clicking "Download All" and intercepting
* the Page.downloadWillBegin event
*/
function captureDownloadUrl(ws) {
return new Promise((resolve, reject) => {
const id = Date.now();
ws.send(JSON.stringify({ id: 0, method: "Page.enable" }));
ws.send(JSON.stringify({ id: 1, method: "Page.setDownloadBehavior", params: { behavior: "deny" } }));
const handler = (m) => {
const d = JSON.parse(m.toString());
if (d.method === "Page.downloadWillBegin") {
ws.removeListener("message", handler);
resolve(d.params.url);
}
};
ws.on("message", handler);
});
}
/**
* Click "Download All" button on library/downloads page
*/
async function clickDownloadAll(ws) {
await evalJs(ws, `
var btn = [...document.querySelectorAll("button")].find(b => b.innerText.includes("Download All"));
if (btn) btn.click();
!!btn;
`);
}
module.exports = {
getPage, getBrowserWs, connectPage, evalJs, navigate,
waitForLoad, screenshot, enableDownloads, getCookies, login,
captureDownloadUrl, clickDownloadAll
};
Download music from YouTube channels/playlists and convert to 320kbps MP3. Supports batch processing, resume interrupted downloads, and concurrent downloading.
---
name: bootleg-link
description: Download music from YouTube channels/playlists and convert to 320kbps MP3. Supports batch processing, resume interrupted downloads, and concurrent downloading.
metadata: {"clawdbot":{"emoji":"🎵","os":["linux","macos"],"requires":{"bins":["yt-dlp","ffmpeg"]}}}
---
# Bootleg-Link Skill
DJ music downloader - Downloads music from YouTube channels/playlists and converts to high-quality MP3.
## What It Does
Download music from YouTube channels or playlists, convert to 320kbps MP3 with metadata, and manage batch downloads with resume capability.
## Features
- **Batch Download**: Process multiple channels/playlists from a text file
- **Resume Support**: Skip already downloaded videos using `--download-archive`
- **High Quality**: Convert to 320kbps MP3 with thumbnail embedding
- **Smart Deduplication**: Automatically skip previously downloaded content
- **Concurrent Download**: Utilize multiple CPU cores for parallel downloads
## Usage
### Single Channel/Playlist
```bash
bootleg-link "https://www.youtube.com/@VeryHouseMusic"
```
### Batch Mode
```bash
bootleg-link --batch /path/to/channels.txt
```
### Resume Interrupted Download
```bash
bootleg-link --batch /path/to/output/directory
```
### Custom Output Directory
```bash
bootleg-link "https://www.youtube.com/@ChannelName" -o /path/to/output
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BOOTLEG_OUTPUT_DIR` | `/mnt/e/bootleg-link-downloader/output` | Default output directory |
| `BOOTLEG_QUALITY` | `320` | MP3 bitrate (128, 192, 256, 320) |
| `BOOTLEG_CONCURRENCY` | `8` | Number of concurrent downloads |
| `BOOTLEG_ARCHIVE_FILE` | `download-archive.txt` | File to track downloaded videos |
### Link File Format (`.bootleg-link.txt`)
```
https://www.youtube.com/@Channel1
https://www.youtube.com/playlist?list=PLxxx
https://www.youtube.com/@Channel2
```
## Advanced Options
### CPU-Based Concurrency
Automatically adjusts based on CPU cores:
| CPU Cores | Videos | Fragments per Video |
|-----------|--------|---------------------|
| 4 | 2 | 2 |
| 8 | 4 | 4 |
| 16 | 8 | 4 |
| 32+ | 16 | 8 |
Use `-N` for concurrent videos and `--concurrent-fragments` for per-video parallelism.
### Custom Filename Template
```bash
--output-template "%(title)s.%(ext)s"
```
### Audio Format Options
```bash
--format bestaudio --extractor-args "youtube:player_client=android"
```
## Troubleshooting
### JS Challenge Error
If YouTube JS challenge decryption fails, it falls back to format 251 (opus 128kbps) - still good quality.
### Network Timeout
Auto-retries on timeout. For persistent issues, increase `--socket-timeout`.
### Rate Limiting
Add `--sleep-interval` between downloads:
```bash
bootleg-link --batch . --sleep-interval 5
```
## Dependencies
- **yt-dlp**: YouTube downloader
- **ffmpeg**: Audio conversion
- **mutagen** (optional): For metadata embedding
## Installation
```bash
# Install dependencies
pip install yt-dlp mutagen
# Make executable (if provided as script)
chmod +x bootleg-link
```
## Example Output
```
[download] Downloading channel: VeryHouseMusic
[download] Channel has 9926 videos
[download] Already downloaded: 388 / 9926 (3.9%)
[download] Starting download of remaining 9538 videos...
[download] Output: /mnt/e/bootleg-link-downloader/output/house(VeryHouseMusic Dump)/
```
## Notes
- Uses `--download-archive` to track downloaded videos
- Automatically creates playlist-named subdirectories
- Embeds thumbnail as album art in MP3 files
- Run again with same output dir to resume seamlessly
FILE:scripts/bootleg-ssh.sh
#!/bin/bash
# bootleg-ssh.sh - SSH through alternative methods
set -e
usage() {
echo "Bootleg SSH - Alternative SSH connection methods"
echo "Usage: $0 [method] [options]"
echo ""
echo "Methods:"
echo " http-proxy - SSH through HTTP CONNECT proxy"
echo " websocket - SSH through WebSocket tunnel"
echo " port-443 - SSH on port 443 (HTTPS)"
echo " dns-tunnel - SSH through DNS tunnel (requires iodine)"
echo ""
echo "Examples:"
echo " $0 http-proxy --proxy proxy.example.com:8080 user@server"
echo " $0 port-443 [email protected]"
exit 1
}
method_http_proxy() {
local proxy="$1"
local target="$2"
if [ -z "$proxy" ] || [ -z "$target" ]; then
echo "Error: Missing proxy or target"
usage
fi
echo "Connecting via HTTP proxy: $proxy"
ssh -o ProxyCommand="nc -X connect -x $proxy %h %p" "$target"
}
method_websocket() {
local ws_url="$1"
local target="$2"
if [ -z "$ws_url" ] || [ -z "$target" ]; then
echo "Error: Missing WebSocket URL or target"
usage
fi
echo "Connecting via WebSocket: $ws_url"
# This requires websocat to be installed
if ! command -v websocat &> /dev/null; then
echo "Error: websocat not installed. Install with: cargo install websocat"
exit 1
fi
# Extract host and port from target
local host=$(echo "$target" | cut -d@ -f2 | cut -d: -f1)
local port=$(echo "$target" | cut -d: -f2)
port=-22
echo "Creating WebSocket to TCP bridge..."
websocat "$ws_url" "tcp:$host:$port"
}
method_port_443() {
local target="$1"
if [ -z "$target" ]; then
echo "Error: Missing target"
usage
fi
echo "Connecting on port 443 (HTTPS)"
ssh -p 443 "$target"
}
method_dns_tunnel() {
local dns_server="$1"
if [ -z "$dns_server" ]; then
echo "Error: Missing DNS server"
usage
fi
echo "Setting up DNS tunnel to $dns_server"
echo "Note: This requires iodine server setup on the remote side"
if ! command -v iodine &> /dev/null; then
echo "Error: iodine not installed. Install with: sudo apt-get install iodine"
exit 1
fi
# Start iodine client
sudo iodine -f -r "$dns_server"
echo "Once connected, SSH to 10.0.0.1"
echo "ssh [email protected]"
}
# Main script
if [ $# -lt 1 ]; then
usage
fi
METHOD="$1"
shift
case "$METHOD" in
http-proxy)
method_http_proxy "$@"
;;
websocket)
method_websocket "$@"
;;
port-443)
method_port_443 "$@"
;;
dns-tunnel)
method_dns_tunnel "$@"
;;
*)
echo "Error: Unknown method: $METHOD"
usage
;;
esacExecute US and HK stock trades via Tiger Brokers API. Use when user wants to buy or sell stocks, manage investment portfolio, place orders for US ETFs or HK...
---
name: tiger-trade
description: Execute US and HK stock trades via Tiger Brokers API. Use when user wants to buy or sell stocks, manage investment portfolio, place orders for US ETFs or HK stocks, or check account balance. Requires tiger-config.json with tiger_id account and private_key_pk8.
---
# Tiger Trade
Execute trades via Tiger Brokers API.
## Setup
Create config file at `~/.tiger-config.json `:
```json
{
"tiger_id": "your_tiger_id",
"account": "your_account",
"private_key_pk8": "your_private_key"
}
```
## Check Stock Prices
Use Tiger Broker website to get current prices:
```
https://www.itiger.com/hant/stock/02800
https://www.itiger.com/hant/stock/AAPL
```
Replace the stock code (02800 or AAPL) with any stock
## Quick Trade
```python
import json
from tigeropen.tiger_open_config import TigerOpenClientConfig
from tigeropen.trade.trade_client import TradeClient
from tigeropen.trade.request.model import PlaceModifyOrderParams
from tigeropen.common.consts import OrderType
with open('~/.tiger-config.json', 'r') as f:
config = json.load(f)
client_config = TigerOpenClientConfig()
client_config.tiger_id = config['tiger_id']
client_config.account = config['account']
client_config.private_key = config['private_key_pk8']
client_config.sandbox = False
client = TradeClient(client_config)
# Place stock order
contracts = client.get_contracts(['02800'])
if contracts:
order_params = PlaceModifyOrderParams()
order_params.account = config['account']
order_params.contract = contracts[0]
order_params.action = 'BUY'
order_params.order_type = OrderType.LMT.value
order_params.limit_price = 26.80 # Get from itiger.com
order_params.quantity = 10000
result = client.place_order(order_params)
print(result)
```
## Order Types
- `LMT` = Limit order
- `MKT` = Market order
## Actions
- `BUY` - 买入
- `SELL` - 卖出
FILE:_meta.json
{
"ownerId": "kn78xxw8r3tz0dcajntdj55q3980x0cz",
"slug": "tiger-trade",
"version": "1.0.2",
"publishedAt": 1771916289285
}Analyze YouTube notifications for investment and trading insights. Use when user wants investment advice from YouTube, analyzing stock crypto or financial co...
--- name: youtube-notification-analysis description: Analyze YouTube notifications for investment and trading insights. Use when user wants investment advice from YouTube, analyzing stock crypto or financial content, executing /ytb_trade command, or getting video subtitles via yt-dlp and whisper-cpp. Workflow is open YouTube click notification bell extract video IDs get subtitles or download plus whisper-cpp analyze then execute trades. --- # YouTube Notification Analysis Analyze YouTube notifications for investment insights. ## Workflow 1. **Open YouTube**: `browser action=open profile=openclaw targetUrl=https://www.youtube.com` 2. **Click notification bell**: Find ref="e8" button 3. **Extract video IDs**: From snapshot, find investment-related videos 4. **Get subtitles**: - First try: `yt-dlp --write-subs --skip-download --sub-lang zh-Hans,en <video_url>` - If no subtitles: Download video + whisper-cpp analysis 5. **Analyze**: Summarize investment recommendations 6. **Execute trades**: Use tiger-trade skill ## Subtitle Extraction ```bash # Try yt-dlp first yt-dlp --write-subs --skip-download --sub-lang zh-Hans,en "https://www.youtube.com/watch?v=VIDEO_ID" -o /tmp/sub # If no subtitles, download + whisper yt-dlp -f best "https://www.youtube.com/watch?v=VIDEO_ID" -o /tmp/video.mp4 whisper-cpp/bin/main -m whisper-cpp/models/ggml-base.bin -f /tmp/video.mp4 --language ZH ``` ## Investment Analysis Focus on investment and trading related videos from YouTube notifications. Analyze content for stock, crypto, macro finance, and market trends. ## Logging All logs saved to `/tmp/youtube_investment_*.log`