@clawhub-ddd1988-c64d2708a8
Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs,...
---
name: qq-music-web
description: "Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs, random play, like/unlike, playlist management (list/create/add-to), and browser-target discovery across platforms."
metadata:
openclaw:
emoji: "🎵"
---
# QQ Music Control
Use this skill to control QQ Music (y.qq.com) through a browser DevTools/CDP endpoint.
## What it supports
- Cross-platform: Windows, macOS, Linux
- Cross-browser: Chrome, Chromium, Edge, Brave, Arc, or any browser exposing a DevTools/CDP endpoint
- Transport: play, pause, toggle, next, previous
- Search & play: songs, artists, albums
- Liked songs: play all, play random, like/unlike current track
- Playlists: list created playlists, create new playlists, add current song to a playlist, play a playlist by ID
- Mode control: list loop, single loop, shuffle, sequential
- Status: current track, artist, time, play state
- Screenshot capture
## Requirements
- **Node.js 18+** (uses built-in `fetch` and `WebSocket`)
- A Chromium-based browser with remote debugging enabled (see setup below)
- A QQ Music account logged in at `y.qq.com` (needed for liked songs, playlists, and like/unlike)
## Setup guide
The skill communicates with the browser via the Chrome DevTools Protocol (CDP). You need to launch your browser with remote debugging enabled so the skill can connect.
### Step 1: Launch browser with remote debugging
Pick one port (e.g. `9222`) and launch your browser with that port. Only one instance can bind to a port.
#### Windows
**Chrome:**
```
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
```
**Edge:**
```
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222
```
**Brave:**
```
"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe" --remote-debugging-port=9222
```
> On Windows you can also create a desktop shortcut with the flag appended.
#### macOS
**Chrome:**
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
```
**Edge:**
```bash
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --remote-debugging-port=9222
```
**Brave:**
```bash
/Applications/Brave\ Browser.app/Contents/MacOS/Brave\ Browser --remote-debugging-port=9222
```
#### Linux
```bash
google-chrome --remote-debugging-port=9222
# or
chromium-browser --remote-debugging-port=9222
# or
brave-browser --remote-debugging-port=9222
```
> **Tip:** Close all existing instances of the browser before launching with the flag, or use a separate profile:
> `--user-data-dir=/tmp/qq-music-profile --remote-debugging-port=9222`
### Step 2: Log in to QQ Music
1. Open `https://y.qq.com/` in the browser you just launched.
2. Log in with your QQ / WeChat account.
3. Optionally open `https://y.qq.com/n/ryqq_v2/player` in another tab for a dedicated player view.
### Step 3: Verify the connection
```bash
node qq-music-ctl.js tabs
```
You should see your browser tabs listed, including the QQ Music ones.
### Step 4 (optional): OpenClaw configuration
If using this skill via OpenClaw and you want the agent to call the script directly:
1. Ensure `plugins.allow` includes `browser` (if using OpenClaw's built-in browser tool as fallback).
2. Add `*.qq.com` and `*.y.qq.com` to `browser.ssrfPolicy.hostnameAllowlist` if SSRF policy is active.
3. Set `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: true` if the CDP endpoint is on localhost.
## Controller script
All actions go through the bundled script:
```bash
node qq-music-ctl.js <action> [args...]
```
All output is JSON on stdout. Exit code 0 = success, 1 = error.
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `QQ_MUSIC_DEVTOOLS_URL` | _(auto-discover)_ | Explicit DevTools base URL, e.g. `http://127.0.0.1:9222` |
| `QQ_MUSIC_DEVTOOLS_HOST` | `127.0.0.1` | Host to probe for DevTools endpoints |
| `QQ_MUSIC_DEVTOOLS_PORTS` | `19011,9222,9223,9224,9225,9333` | Comma-separated ports to probe |
| `QQ_MUSIC_SCREENSHOT_PATH` | `qq-music-screenshot.png` | Default screenshot output path |
| `QQ_MUSIC_PROBE_TIMEOUT_MS` | `1200` | Per-endpoint probe timeout in ms |
| `QQ_MUSIC_PAGE_WAIT_MS` | `3500` | Wait time after page navigation in ms |
## Action reference
### Playback control
| Action | Description |
|---|---|
| `play` | Resume playback (idempotent) |
| `pause` | Pause playback (idempotent) |
| `toggle` | Toggle play/pause |
| `next` | Next track |
| `prev` | Previous track |
| `status` | Current track, artist, time, duration, play state |
### Search & play
| Action | Description |
|---|---|
| `search <keyword>` | Search for a song and play best match |
| `search-artist <name>` | Search for an artist and open their page |
| `play-artist-all-songs <name>` | Play all songs by an artist |
| `search-album <name>` | Search for an album and play it |
### Liked songs
| Action | Description |
|---|---|
| `play-liked` | Play all liked songs (clicks "播放全部") |
| `play-liked-random` | Randomly play one liked song from the visible page |
| `like` | Like current song (idempotent; returns `already_liked` if already liked) |
| `unlike` | Unlike current song (idempotent; returns `already_unliked` if not liked) |
### Playlists
| Action | Description |
|---|---|
| `list-playlists` | List all created playlists with name, song count, and numeric ID |
| `create-playlist <name>` | Create a new playlist (max 20 characters) |
| `add-to-playlist <name>` | Add the currently playing song to a playlist by name |
| `play-playlist <id>` | Play a playlist by its numeric ID |
### Play mode
| Action | Description |
|---|---|
| `mode` | Show current play mode |
| `mode list` | Set to list loop (列表循环) |
| `mode single` | Set to single loop (单曲循环) |
| `mode random` | Set to shuffle (随机播放) |
| `mode order` | Set to sequential (顺序循环) |
### Utility
| Action | Description |
|---|---|
| `screenshot [path]` | Capture a screenshot of the QQ Music tab |
| `tabs` | List all detectable browser tabs |
| `init` | Open QQ Music if no tab exists |
## How it works
1. **Endpoint discovery**: The script probes localhost ports for a DevTools HTTP endpoint (`/json/version` + `/json/list`). It prefers the endpoint that already has QQ Music tabs open.
2. **Tab selection**: Player-tab (`/player` URL) is preferred for transport controls (play/pause/next/prev/status). A separate browse-tab is used for search, navigation, and playlist operations.
3. **DOM automation**: All interactions use `Runtime.evaluate` over CDP to run JavaScript in the page context. No Puppeteer or Playwright dependency.
4. **No external dependencies**: The script is a single file using only Node.js built-ins (`fs`, `WebSocket`, `fetch`). No `npm install` needed.
## Selection rules
- Prefer the player tab for transport controls.
- Prefer the browse tab for search and playlist discovery.
- If there is no QQ Music tab, `init` opens a blank tab and navigates to `https://y.qq.com/`.
- For song search, the first exact or containing title match wins; otherwise the first visible result is played.
- For liked songs, random play picks from the currently visible page (~10 songs; the web version does not expose all liked songs without scrolling).
- For `add-to-playlist`, if a newly created playlist is not yet visible in the player's menu, the player page is automatically reloaded to refresh the cache and retry.
- `like` and `unlike` are idempotent and report the current state.
- `create-playlist` accepts names up to 20 characters (QQ Music web limit).
## Limitations
- The QQ Music web version shows at most ~10 liked songs per page. `play-liked` uses the "播放全部" button which queues all liked songs in the player, but `play-liked-random` can only pick from the visible ~10.
- System audio volume control is out of scope (OS-level, not browser-controlled).
- Some features (like VIP-only songs) depend on the user's QQ Music subscription.
- The skill does not handle QQ Music login; the user must log in manually first.
## Troubleshooting
- **"No DevTools endpoint found"**: Make sure the browser is running with `--remote-debugging-port=<port>` and no other instance is using that port.
- **"Player not found"**: Play a song first (via `search` or `play-liked`) to make the player tab appear.
- **Timeouts**: Increase `QQ_MUSIC_PAGE_WAIT_MS` for slow connections, or `QQ_MUSIC_PROBE_TIMEOUT_MS` for slow endpoint discovery.
- **"CDP connection closed"**: The page may have navigated or crashed. Retry the command.
## Notes
- The skill does not assume a specific browser brand or OS.
- The skill does not hardcode any personal paths, usernames, or tokens.
- If the browser exposes multiple DevTools endpoints, the controller probes common ports and prefers the one with QQ Music tabs.
FILE:qq-music-ctl.js
#!/usr/bin/env node
/**
* QQ Music browser controller.
*
* Cross-platform and browser-agnostic as long as the browser exposes a
* DevTools / CDP endpoint.
*
* Usage:
* node qq-music-ctl.js <action> [args...]
*
* Environment:
* QQ_MUSIC_DEVTOOLS_URL Explicit DevTools base URL, e.g. http://127.0.0.1:9222
* QQ_MUSIC_DEVTOOLS_HOST Host to probe (default: 127.0.0.1)
* QQ_MUSIC_DEVTOOLS_PORTS Comma-separated probe ports (default: 19011,9222,9223,9224,9225,9333)
* QQ_MUSIC_SCREENSHOT_PATH Output path for screenshots (default: qq-music-screenshot.png)
* QQ_MUSIC_PROBE_TIMEOUT_MS Probe timeout per endpoint (default: 1200)
* QQ_MUSIC_PAGE_WAIT_MS Wait after navigation (default: 3500)
*/
const fs = require('fs');
const DEFAULT_HOST = process.env.QQ_MUSIC_DEVTOOLS_HOST || '127.0.0.1';
const DEFAULT_PORTS = parsePortList(process.env.QQ_MUSIC_DEVTOOLS_PORTS || '19011,9222,9223,9224,9225,9333');
const SCREENSHOT_PATH = process.env.QQ_MUSIC_SCREENSHOT_PATH || 'qq-music-screenshot.png';
const PROBE_TIMEOUT_MS = Number(process.env.QQ_MUSIC_PROBE_TIMEOUT_MS || 1200);
const PAGE_WAIT_MS = Number(process.env.QQ_MUSIC_PAGE_WAIT_MS || 3500);
function parsePortList(value) {
return [...new Set(String(value).split(',').map(s => Number(s.trim())).filter(n => Number.isInteger(n) && n > 0))];
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function timeoutError(label) {
return new Error(`label timed out`);
}
async function fetchJson(url, timeoutMs = PROBE_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP res.status`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
function baseOrigin(input) {
const url = new URL(input);
return url.origin;
}
function scoreEndpoint(entry) {
const list = entry.list || [];
const urls = list.map(t => t.url || '');
let score = 0;
if (urls.some(u => u.includes('y.qq.com'))) score += 100;
if (urls.some(u => u.includes('/player'))) score += 30;
if (list.some(t => t.type === 'page')) score += 10;
return score;
}
async function discoverEndpoint() {
const candidates = [];
if (process.env.QQ_MUSIC_DEVTOOLS_URL) candidates.push(baseOrigin(process.env.QQ_MUSIC_DEVTOOLS_URL));
for (const port of DEFAULT_PORTS) candidates.push(`http://DEFAULT_HOST:port`);
const seen = new Set();
const discovered = [];
for (const baseUrl of candidates) {
if (seen.has(baseUrl)) continue;
seen.add(baseUrl);
try {
const [version, list] = await Promise.all([
fetchJson(`baseUrl/json/version`),
fetchJson(`baseUrl/json/list`),
]);
discovered.push({ baseUrl, version, list });
} catch {
// ignore and continue probing
}
}
if (!discovered.length) {
throw new Error(
`No DevTools endpoint found. Set QQ_MUSIC_DEVTOOLS_URL or start a browser with remote debugging. ` +
`Probed ports: DEFAULT_PORTS.join(', ')`
);
}
discovered.sort((a, b) => scoreEndpoint(b) - scoreEndpoint(a));
return discovered[0];
}
function pageTargets(entry) {
return (entry.list || []).filter(t => t.type === 'page');
}
function firstTarget(list, predicate) {
return list.find(predicate) || null;
}
function isQQMusicTarget(target) {
return target && typeof target.url === 'string' && target.url.includes('y.qq.com');
}
function isPlayerTarget(target) {
return isQQMusicTarget(target) && target.url.includes('/player');
}
function isBrowseTarget(target) {
return isQQMusicTarget(target) && !target.url.includes('/player');
}
function prettyUrl(target) {
return target ? target.url : '';
}
function connectCDP(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let seq = 0;
let closed = false;
const pending = new Map();
function failAll(err) {
if (closed) return;
closed = true;
for (const { reject: rej, timer } of pending.values()) {
clearTimeout(timer);
rej(err);
}
pending.clear();
}
function send(method, params = {}) {
if (closed) return Promise.reject(new Error('CDP session closed'));
return new Promise((resolveSend, rejectSend) => {
const id = ++seq;
const timer = setTimeout(() => {
pending.delete(id);
rejectSend(timeoutError(method));
}, 10000);
pending.set(id, { resolve: resolveSend, reject: rejectSend, timer });
ws.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const res = await send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
return res.result ? res.result.value : undefined;
}
ws.onopen = () => resolve({ ws, send, evaluate, close: () => { closed = true; ws.close(); } });
ws.onmessage = evt => {
const msg = JSON.parse(evt.data);
if (!msg.id || !pending.has(msg.id)) return;
const item = pending.get(msg.id);
pending.delete(msg.id);
clearTimeout(item.timer);
if (msg.error) item.reject(new Error(msg.error.message || 'CDP command failed'));
else item.resolve(msg.result);
};
ws.onerror = err => failAll(new Error(err.message || 'CDP connection error'));
ws.onclose = () => failAll(new Error('CDP connection closed'));
});
}
async function browserSession(entry) {
const url = entry.version.webSocketDebuggerUrl;
if (!url) throw new Error('Browser-level WebSocket URL not available. Target.createTarget may not work.');
return connectCDP(url);
}
async function pageSession(target) {
return connectCDP(target.webSocketDebuggerUrl);
}
function output(obj) {
console.log(JSON.stringify(obj, null, 2));
}
async function createTarget(entry, url = 'about:blank') {
const browser = await browserSession(entry);
try {
const result = await browser.send('Target.createTarget', { url });
return result.targetId;
} finally {
browser.close();
}
}
async function openOrReuseBrowseTarget(entry) {
const pages = pageTargets(entry);
const browse = firstTarget(pages, isBrowseTarget);
if (browse) return browse;
const anyQQ = firstTarget(pages, isQQMusicTarget);
if (anyQQ) return anyQQ;
const blank = firstTarget(pages, t => t.url === 'about:blank' || t.url.startsWith('chrome://'));
if (blank) return blank;
const newTargetId = await createTarget(entry, 'about:blank');
const refreshed = await fetchJson(`entry.baseUrl/json/list`);
return firstTarget(refreshed, t => t.id === newTargetId) || firstTarget(refreshed, t => t.url === 'about:blank') || null;
}
function songQueryJS(keyword) {
const q = JSON.stringify(String(keyword || '').trim().toLowerCase());
return `
(function() {
const want = q;
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No search results' });
function clean(s) { return String(s || '').trim().toLowerCase().replace(/\s+/g, ''); }
function titleOf(item) {
const el = item.querySelector('.songlist__songname_txt a[title]');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function artistOf(item) {
const el = item.querySelector('.songlist__artist a');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function play(item) {
const btn = item.querySelector('.list_menu__play');
if (btn) { btn.click(); return 'play-btn'; }
const song = item.querySelector('.songlist__songname_txt');
if (song) { song.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); return 'dblclick'; }
return 'none';
}
let chosen = items[0];
if (want) {
const exact = items.find(item => clean(titleOf(item)) === want);
const contains = items.find(item => clean(titleOf(item)).includes(want));
chosen = exact || contains || items[0];
}
const name = titleOf(chosen);
const artist = artistOf(chosen);
const method = play(chosen);
return JSON.stringify({ ok: true, song: name, artist, results: items.length, method });
})()
`;
}
function firstVisibleSongJS() {
return `
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No songs found' });
const idx = Math.floor(Math.random() * items.length);
const item = items[idx];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
const song = nameEl ? String(nameEl.title || nameEl.textContent || '').trim() : '';
const artist = artistEl ? String(artistEl.title || artistEl.textContent || '').trim() : '';
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song, artist, index: idx, total: items.length });
})()
`;
}
function playlistPlayJS() {
return `
(function() {
const playAll = document.querySelector('.mod_btn_green');
if (playAll) {
playAll.click();
const items = Array.from(document.querySelectorAll('.songlist__item'));
const first = items[0] ? items[0].querySelector('.songlist__songname_txt a[title]') : null;
return JSON.stringify({ ok: true, action: 'play_all', firstSong: first ? String(first.title || '').trim() : '', total: items.length });
}
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'Playlist empty or not found' });
const btn = items[0].querySelector('.list_menu__play');
if (btn) btn.click(); else items[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, action: 'first_song', total: items.length });
})()
`;
}
async function actionTabs() {
const entry = await discoverEndpoint();
output({
browser: entry.version.Browser || entry.version['Browser'] || '',
baseUrl: entry.baseUrl,
tabs: pageTargets(entry).map(t => ({
id: t.id,
title: t.title,
url: t.url,
isPlayer: isPlayerTarget(t),
isQQMusic: isQQMusicTarget(t),
})),
});
}
async function actionInit() {
const entry = await discoverEndpoint();
const browse = await openOrReuseBrowseTarget(entry);
if (!browse) throw new Error('No browser tab available');
output({ ok: true, baseUrl: entry.baseUrl, targetId: browse.id, url: prettyUrl(browse) });
}
async function withPlayer(fn) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ error: 'Player not found. Play a song first.' });
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function withBrowse(fn) {
const entry = await discoverEndpoint();
const target = await openOrReuseBrowseTarget(entry);
if (!target) throw new Error('No browser tab available');
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function actionStatus() {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ status: 'no_player', msg: 'QQ Music player not open.' });
const session = await pageSession(target);
try {
const result = await session.evaluate(`
(function() {
const infoEl = document.querySelector('.player_music__info');
const nameEl = infoEl ? infoEl.querySelector('a:first-child') : null;
const artistEl = infoEl ? infoEl.querySelector('a.playlist__author') : null;
const timeEl = document.querySelector('.player_music__time');
const playBtn = document.querySelector('.btn_big_play');
const isPlaying = playBtn ? playBtn.classList.contains('btn_big_play--pause') : null;
const activeSong = document.querySelector('.songlist__item--active .songlist__songname_txt a[title]');
const activeArtist = document.querySelector('.songlist__item--active .songlist__artist a');
let time = '';
let duration = '';
if (timeEl) {
const parts = timeEl.textContent.trim().split('/');
time = (parts[0] || '').trim();
duration = (parts[1] || '').trim();
}
return JSON.stringify({
song: (nameEl ? nameEl.textContent.trim() : '') || (activeSong ? String(activeSong.title || '').trim() : ''),
artist: (artistEl ? artistEl.textContent.trim() : '') || (activeArtist ? String(activeArtist.title || '').trim() : ''),
time,
duration,
isPlaying,
status: isPlaying === true ? 'playing' : isPlaying === false ? 'paused' : 'unknown'
});
})()
`);
output(JSON.parse(result));
} finally {
session.close();
}
}
async function actionPlay() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
const wasPlaying = btn.classList.contains('btn_big_play--pause');
if (!wasPlaying) btn.click();
return JSON.stringify({ ok: true, action: wasPlaying ? 'already_playing' : 'resumed' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPause() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
const wasPlaying = btn.classList.contains('btn_big_play--pause');
if (wasPlaying) btn.click();
return JSON.stringify({ ok: true, action: wasPlaying ? 'paused' : 'already_paused' });
})()
`);
output(JSON.parse(result));
});
}
async function actionToggle() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
const wasPlaying = btn.classList.contains('btn_big_play--pause');
btn.click();
return JSON.stringify({ ok: true, action: wasPlaying ? 'pause' : 'play' });
})()
`);
output(JSON.parse(result));
});
}
async function actionNext() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_next');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'next' }); }
return JSON.stringify({ ok: false, msg: 'Next button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPrev() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_prev');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'prev' }); }
return JSON.stringify({ ok: false, msg: 'Prev button not found' });
})()
`);
output(JSON.parse(result));
});
}
function normalizeMusicText(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/[·•]/g, '')
.replace(/[()()\[\]【】{}]/g, '');
}
async function waitForEvalResult(session, buildEvalJs, { timeoutMs = 12000, intervalMs = 350, label = 'condition' } = {}) {
const deadline = Date.now() + timeoutMs;
let last = null;
while (Date.now() < deadline) {
try {
const raw = await session.evaluate(buildEvalJs());
last = JSON.parse(raw);
} catch (err) {
last = { ok: false, stage: 'evaluate_error', error: err.message || String(err) };
}
if (last && last.ok) return last;
await sleep(intervalMs);
}
const error = new Error(`label timed out`);
error.last = last;
throw error;
}
function buildArtistSearchEval(keyword) {
const want = JSON.stringify(normalizeMusicText(keyword));
return `
(function() {
const want = want;
const norm = s => String(s || '')
.trim()
.toLowerCase()
.replace(/\\s+/g, '')
.replace(/[·•]/g, '')
.replace(/[()()\\[\\]【】{}]/g, '');
const selectors = [
'.search_result__singer a',
'.singer_list__item a',
'.mod_singer_list a',
'a[href*="/singer/"]',
'a[href*="/ryqq/singer/"]'
];
const seen = new Set();
const candidates = Array.from(document.querySelectorAll(selectors.join(','))).filter(el => {
const href = String(el.href || el.getAttribute('href') || '').trim();
const text = norm(el.title || el.textContent || el.getAttribute('aria-label') || '');
if (!href && !text) return false;
const key = href + '|' + text;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const match = candidates.find(el => {
const text = norm(el.title || el.textContent || el.getAttribute('aria-label') || '');
return want && text && (text === want || text.includes(want) || want.includes(text));
});
if (!match) {
return JSON.stringify({
ok: false,
stage: 'searching',
count: candidates.length
});
}
const rawHref = match.href || match.getAttribute('href') || '';
let href = '';
try {
href = rawHref ? new URL(rawHref, location.href).href : '';
} catch {
href = rawHref;
}
return JSON.stringify({
ok: true,
name: String(match.title || match.textContent || match.getAttribute('aria-label') || '').trim(),
href,
count: candidates.length
});
})()
`;
}
function buildPlayAllEval() {
return `
(function() {
const norm = s => String(s || '').trim();
const selectors = [
'.mod_btn_green',
'.btn_green',
'.songlist__play',
'[title*="播放全部"]',
'[title*="全部播放"]',
'[aria-label*="播放全部"]',
'[aria-label*="全部播放"]'
];
const candidates = Array.from(document.querySelectorAll(selectors.join(',')));
const button = candidates.find(el => {
const text = norm(el.title || el.textContent || el.getAttribute('aria-label') || '');
return text.includes('播放全部') || text.includes('全部播放') || text.includes('播放歌手热门歌曲') || (text.includes('播放') && text.includes('全部'));
});
if (!button) {
return JSON.stringify({ ok: false, stage: 'play_all_not_found', count: candidates.length });
}
button.scrollIntoView({ block: 'center' });
button.click();
return JSON.stringify({
ok: true,
action: 'play_all_clicked',
label: norm(button.title || button.textContent || button.getAttribute('aria-label') || '')
});
})()
`;
}
async function openArtistPage(session, keyword) {
const query = String(keyword || '').trim();
if (!query) throw new Error('Artist keyword is required');
const searchUrl = `https://y.qq.com/n/ryqq/search?w=encodeURIComponent(query)&t=singer`;
await session.send('Page.navigate', { url: searchUrl });
await sleep(800);
const result = await waitForEvalResult(
session,
() => buildArtistSearchEval(query),
{ timeoutMs: 15000, intervalMs: 400, label: `search artist query` }
);
if (!result.href) {
throw new Error(`Artist link not found for query`);
}
await session.send('Page.navigate', { url: result.href });
await sleep(1000);
return result;
}
async function actionSearch(keyword, type = 'song') {
await withBrowse(async session => {
const typeMap = { song: 'song', album: 'album' };
const t = typeMap[type] || 'song';
const url = `https://y.qq.com/n/ryqq/search?w=encodeURIComponent(String(keyword || '').trim())&t=t`;
await session.send('Page.navigate', { url });
await sleep(PAGE_WAIT_MS);
if (type === 'album') {
const playAll = await session.evaluate(buildPlayAllEval());
const parsedPlayAll = JSON.parse(playAll);
if (parsedPlayAll.ok) {
output({ ok: true, scope: 'album', ...parsedPlayAll });
return;
}
const result = await session.evaluate(`
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No results' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '', fallback: 'first_song' });
})()
`);
output(JSON.parse(result));
return;
}
const result = await session.evaluate(songQueryJS(keyword));
output(JSON.parse(result));
});
}
async function actionSearchArtist(keyword) {
await withBrowse(async session => {
const artist = await openArtistPage(session, keyword);
output({
ok: true,
action: 'opened_artist_page',
artist: artist.name,
href: artist.href,
count: artist.count,
});
});
}
async function actionPlayArtistAllSongs(keyword) {
await withBrowse(async session => {
const artist = await openArtistPage(session, keyword);
const result = await waitForEvalResult(
session,
buildPlayAllEval,
{ timeoutMs: 15000, intervalMs: 450, label: `play all songs for artist.name || String(keyword || '').trim()` }
);
output({
ok: true,
action: 'play_artist_all_songs',
artist: artist.name,
href: artist.href,
...result,
});
});
}
async function actionPlayLiked(random = false) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: 'https://y.qq.com/n/ryqq_v2/profile/like/song' });
await sleep(PAGE_WAIT_MS);
if (random) {
const result = await session.evaluate(firstVisibleSongJS());
output(JSON.parse(result));
} else {
// Click "播放全部" to queue all liked songs
const playAllResult = await session.evaluate(buildPlayAllEval());
const parsed = JSON.parse(playAllResult);
if (parsed.ok) {
output({ ok: true, action: 'play_all_liked', ...parsed });
} else {
// Fallback: play first song
const result = await session.evaluate(`
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No liked songs found' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '', index: 0, total: items.length });
})()
`);
output(JSON.parse(result));
}
}
});
}
async function actionPlayPlaylist(playlistId) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: `https://y.qq.com/n/ryqq/playlist/encodeURIComponent(String(playlistId || '').trim())` });
await sleep(PAGE_WAIT_MS);
const result = await session.evaluate(playlistPlayJS());
output(JSON.parse(result));
});
}
async function actionLike() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_like');
if (!btn) return JSON.stringify({ ok: false, msg: 'Like button not found' });
const wasLiked = btn.classList.contains('btn_big_like--like');
if (wasLiked) return JSON.stringify({ ok: true, action: 'already_liked', liked: true });
btn.click();
return JSON.stringify({ ok: true, action: 'liked', liked: true });
})()
`);
output(JSON.parse(result));
});
}
async function actionUnlike() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_like');
if (!btn) return JSON.stringify({ ok: false, msg: 'Like button not found' });
const wasLiked = btn.classList.contains('btn_big_like--like');
if (!wasLiked) return JSON.stringify({ ok: true, action: 'already_unliked', liked: false });
btn.click();
return JSON.stringify({ ok: true, action: 'unliked', liked: false });
})()
`);
output(JSON.parse(result));
});
}
async function actionListPlaylists() {
await withBrowse(async session => {
await session.send('Page.navigate', { url: 'https://y.qq.com/n/ryqq_v2/profile/create' });
await sleep(PAGE_WAIT_MS);
const result = await waitForEvalResult(
session,
() => `
(function() {
const items = Array.from(document.querySelectorAll('.playlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No playlists found' });
const playlists = items.map(item => {
const titleEl = item.querySelector('.playlist__title');
const numberEl = item.querySelector('.playlist__number');
const linkEl = item.querySelector('a[href*="playlist"]');
const href = linkEl ? String(linkEl.href || '') : '';
const parts = href.split('/');
const id = parts[parts.length - 1] || '';
return {
name: titleEl ? titleEl.textContent.trim() : '',
count: numberEl ? numberEl.textContent.trim() : '',
id: id,
};
});
return JSON.stringify({ ok: true, playlists });
})()
`,
{ timeoutMs: 15000, intervalMs: 500, label: 'list playlists' }
);
output(result);
});
}
async function actionCreatePlaylist(name) {
const playlistName = String(name || '').trim();
if (!playlistName) throw new Error('Playlist name is required');
await withBrowse(async session => {
await session.send('Page.navigate', { url: 'https://y.qq.com/n/ryqq_v2/profile/create' });
await sleep(PAGE_WAIT_MS);
// Click "新建歌单" button
await session.evaluate(`
(function() {
const btn = document.querySelector('.js_create_new');
if (btn) btn.click();
})()
`);
await sleep(1000);
// Fill in name and confirm
const nameEscaped = JSON.stringify(playlistName);
const result = await session.evaluate(`
(function() {
const input = document.querySelector('#new_playlist');
if (!input) return JSON.stringify({ ok: false, msg: 'Create dialog not found' });
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeInputValueSetter.call(input, nameEscaped);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
const confirmBtn = document.querySelector('.popup__ft .mod_btn_green');
if (!confirmBtn) return JSON.stringify({ ok: false, msg: 'Confirm button not found' });
confirmBtn.click();
return JSON.stringify({ ok: true, action: 'created', name: nameEscaped });
})()
`);
output(JSON.parse(result));
});
}
async function addToPlaylistAttempt(playerTarget, want) {
const session = await pageSession(playerTarget);
try {
// Click add button on the currently playing song
const raw = await session.evaluate(`
(function() {
const playing = document.querySelector('.songlist__item--playing');
if (!playing) return JSON.stringify({ ok: false, msg: 'No song playing' });
const addBtn = playing.querySelector('.list_menu__add');
if (addBtn) addBtn.click();
return JSON.stringify({ ok: true, clicked: !!addBtn });
})()
`);
const clickResult = JSON.parse(raw);
if (!clickResult.ok) return clickResult;
} finally {
session.close();
}
await sleep(1000);
const session2 = await pageSession(playerTarget);
try {
const raw2 = await session2.evaluate(`
(function() {
const want = JSON.stringify(want);
const menu = document.querySelector('.mod_operate_menu');
if (!menu) return JSON.stringify({ ok: false, msg: 'Add-to-playlist menu not found' });
const items = Array.from(menu.querySelectorAll('.operate_menu__item .operate_menu__link'));
const match = items.find(a => a.textContent.trim().toLowerCase() === want);
if (!match) {
const available = items.map(a => a.textContent.trim());
return JSON.stringify({ ok: false, msg: 'Playlist not found', available });
}
match.click();
return JSON.stringify({ ok: true, action: 'added', playlist: match.textContent.trim() });
})()
`);
return JSON.parse(raw2);
} finally {
session2.close();
}
}
async function actionAddToPlaylist(playlistName) {
const want = String(playlistName || '').trim().toLowerCase();
if (!want) throw new Error('Playlist name is required');
const entry = await discoverEndpoint();
const playerTarget = firstTarget(pageTargets(entry), isPlayerTarget);
if (!playerTarget) return output({ error: 'Player not found. Play a song first.' });
let result = await addToPlaylistAttempt(playerTarget, want);
// If playlist not found, reload player to refresh playlist cache and retry
if (!result.ok && result.msg === 'Playlist not found') {
const reloadSession = await pageSession(playerTarget);
try {
await reloadSession.evaluate('location.reload()');
} finally {
reloadSession.close();
}
await sleep(PAGE_WAIT_MS);
result = await addToPlaylistAttempt(playerTarget, want);
}
output(result);
}
async function actionScreenshot(pathArg) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget) || firstTarget(pageTargets(entry), isBrowseTarget);
if (!target) return output({ error: 'No QQ Music tab found.' });
const session = await pageSession(target);
try {
await sleep(1000);
const result = await session.send('Page.captureScreenshot', { format: 'png' });
const outPath = pathArg || SCREENSHOT_PATH;
const buf = Buffer.from(result.data, 'base64');
fs.writeFileSync(outPath, buf);
output({ ok: true, path: outPath, bytes: buf.length });
} finally {
session.close();
}
}
const PLAY_MODES = {
'list': { class: 'btn_big_style_list', label: '列表循环' },
'single': { class: 'btn_big_style_single', label: '单曲循环' },
'random': { class: 'btn_big_style_random', label: '随机播放' },
'order': { class: 'btn_big_style_order', label: '顺序循环' },
};
const MODE_CYCLE = ['list', 'single', 'random', 'order'];
function detectCurrentMode(className) {
for (const [key, val] of Object.entries(PLAY_MODES)) {
if (className.includes(val.class)) return key;
}
return null;
}
async function actionMode(targetMode) {
await withPlayer(async session => {
if (targetMode && !PLAY_MODES[targetMode]) {
return output({ ok: false, msg: `Unknown mode: targetMode. Valid: Object.keys(PLAY_MODES).join(', ')` });
}
const current = await session.evaluate(`
(() => {
const el = document.querySelector('[class*=btn_big_style]');
if (!el) return JSON.stringify({ error: 'Mode button not found' });
return JSON.stringify({ className: el.className, title: el.title });
})()
`);
const info = JSON.parse(current);
if (info.error) return output({ ok: false, msg: info.error });
const currentMode = detectCurrentMode(info.className);
if (!targetMode) {
return output({ ok: true, mode: currentMode, label: PLAY_MODES[currentMode]?.label || info.title });
}
if (currentMode === targetMode) {
return output({ ok: true, mode: currentMode, label: PLAY_MODES[currentMode].label, action: 'already_set' });
}
const maxClicks = MODE_CYCLE.length;
for (let i = 0; i < maxClicks; i++) {
const result = await session.evaluate(`
(() => {
const el = document.querySelector('[class*=btn_big_style]');
if (!el) return JSON.stringify({ error: 'Mode button not found' });
el.click();
return new Promise(r => setTimeout(() => {
r(JSON.stringify({ className: el.className, title: el.title }));
}, 500));
})()
`);
const after = JSON.parse(result);
if (after.error) return output({ ok: false, msg: after.error });
const newMode = detectCurrentMode(after.className);
if (newMode === targetMode) {
return output({ ok: true, mode: newMode, label: PLAY_MODES[newMode].label, action: 'switched', clicks: i + 1 });
}
}
return output({ ok: false, msg: `Failed to switch to targetMode after maxClicks clicks` });
});
}
function printHelp() {
output({
usage: 'node qq-music-ctl.js <action> [args...]',
actions: ['play','pause','toggle','next','prev','status','mode [list|single|random|order]','search <keyword>','search-artist <artist>','play-artist-all-songs <artist>','search-album <album>','play-liked','play-liked-random','play-playlist <id>','like','unlike','list-playlists','create-playlist <name>','add-to-playlist <name>','screenshot [path]','tabs','init'],
});
}
async function main() {
const action = process.argv[2];
const args = process.argv.slice(3);
if (!action || action === '--help' || action === '-h') {
return printHelp();
}
switch (action) {
case 'play': return actionPlay();
case 'pause': return actionPause();
case 'toggle': return actionToggle();
case 'next': return actionNext();
case 'prev': return actionPrev();
case 'status': return actionStatus();
case 'search': return actionSearch(args.join(' '), 'song');
case 'search-artist': return actionSearchArtist(args.join(' '));
case 'play-artist-all-songs': return actionPlayArtistAllSongs(args.join(' '));
case 'search-album': return actionSearch(args.join(' '), 'album');
case 'play-liked': return actionPlayLiked(false);
case 'play-liked-random': return actionPlayLiked(true);
case 'play-playlist': return actionPlayPlaylist(args[0]);
case 'mode': return actionMode(args[0] || '');
case 'like': return actionLike();
case 'unlike': return actionUnlike();
case 'list-playlists': return actionListPlaylists();
case 'create-playlist': return actionCreatePlaylist(args.join(' '));
case 'add-to-playlist': return actionAddToPlaylist(args.join(' '));
case 'screenshot': return actionScreenshot(args[0]);
case 'tabs': return actionTabs();
case 'init': return actionInit();
default:
return printHelp();
}
}
main().catch(err => {
output({ error: err.message || String(err) });
process.exit(1);
});
Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs,...
---
name: qq-music
description: "Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs, random play, like current song, playlist playback, screenshots, and browser-target discovery across platforms."
metadata:
openclaw:
emoji: "🎵"
---
# QQ Music Control
Use this skill to control QQ Music (y.qq.com) through a browser DevTools/CDP endpoint.
## What it supports
- Cross-platform operation on Windows, macOS, and Linux
- Cross-browser operation as long as the browser exposes a DevTools/CDP endpoint
- QQ Music search and playback flows
- Liked songs / favorites playback
- Playlist playback
- Pause / resume / next / previous
- Like current track
- Status lookup
- Screenshot capture
## Requirements
- A browser with remote debugging enabled, or a browser/profile already exposing a DevTools endpoint
- QQ Music logged in for playlist / liked-song actions
- A browser tab on `y.qq.com` or the ability to open one
## Controller script
All actions go through the bundled script:
```bash
node qq-music-ctl.js <action> [args...]
```
If the browser is not already connected, set one of these:
- `QQ_MUSIC_DEVTOOLS_URL` — explicit DevTools base URL, e.g. `http://127.0.0.1:9222`
- `QQ_MUSIC_DEVTOOLS_PORTS` — comma-separated ports to probe
- `QQ_MUSIC_DEVTOOLS_HOST` — host to probe
## Action map
| Action | Meaning |
|---|---|
| `play` | Resume playback |
| `pause` | Pause playback |
| `toggle` | Toggle play/pause |
| `next` | Next track |
| `prev` | Previous track |
| `status` | Current track/status |
| `search <keyword>` | Search song and play best match |
| `search-artist <name>` | Search artist and play top result |
| `search-album <name>` | Search album and play top result |
| `play-liked` | Play liked songs from the start |
| `play-liked-random` | Randomly play one liked song |
| `play-playlist <id>` | Play playlist by ID |
| `like` | Like current song |
| `screenshot [path]` | Capture a screenshot |
| `tabs` | List detectable browser tabs |
| `init` | Open QQ Music if needed |
## Selection rules
- Prefer the player tab when doing transport controls.
- Prefer the browse tab for search and playlist discovery.
- If there is no QQ Music tab, open a blank tab and navigate to `https://y.qq.com/`.
- For song search, the first exact or containing title match wins; otherwise use the first visible result.
- For liked songs, random play uses the currently visible page of liked songs.
## Notes
- The skill does not assume a specific browser brand.
- The skill does not assume Windows paths or `cmd /c`.
- If the browser exposes multiple DevTools endpoints, the controller probes common ports and prefers the endpoint that already has QQ Music tabs.
- Avoid hardcoding personal paths, usernames, tokens, or host-specific secrets in examples or bundled code.
- System audio volume is out of scope for this skill.
FILE:qq-music-ctl.js
#!/usr/bin/env node
/**
* QQ Music browser controller.
*
* Cross-platform and browser-agnostic as long as the browser exposes a
* DevTools / CDP endpoint.
*
* Usage:
* node qq-music-ctl.js <action> [args...]
*
* Environment:
* QQ_MUSIC_DEVTOOLS_URL Explicit DevTools base URL, e.g. http://127.0.0.1:9222
* QQ_MUSIC_DEVTOOLS_HOST Host to probe (default: 127.0.0.1)
* QQ_MUSIC_DEVTOOLS_PORTS Comma-separated probe ports (default: 19011,9222,9223,9224,9225,9333)
* QQ_MUSIC_SCREENSHOT_PATH Output path for screenshots (default: qq-music-screenshot.png)
* QQ_MUSIC_PROBE_TIMEOUT_MS Probe timeout per endpoint (default: 1200)
* QQ_MUSIC_PAGE_WAIT_MS Wait after navigation (default: 3500)
*/
const fs = require('fs');
const DEFAULT_HOST = process.env.QQ_MUSIC_DEVTOOLS_HOST || '127.0.0.1';
const DEFAULT_PORTS = parsePortList(process.env.QQ_MUSIC_DEVTOOLS_PORTS || '19011,9222,9223,9224,9225,9333');
const SCREENSHOT_PATH = process.env.QQ_MUSIC_SCREENSHOT_PATH || 'qq-music-screenshot.png';
const PROBE_TIMEOUT_MS = Number(process.env.QQ_MUSIC_PROBE_TIMEOUT_MS || 1200);
const PAGE_WAIT_MS = Number(process.env.QQ_MUSIC_PAGE_WAIT_MS || 3500);
function parsePortList(value) {
return [...new Set(String(value).split(',').map(s => Number(s.trim())).filter(n => Number.isInteger(n) && n > 0))];
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function timeoutError(label) {
return new Error(`label timed out`);
}
async function fetchJson(url, timeoutMs = PROBE_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP res.status`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
function baseOrigin(input) {
const url = new URL(input);
return url.origin;
}
function scoreEndpoint(entry) {
const list = entry.list || [];
const urls = list.map(t => t.url || '');
let score = 0;
if (urls.some(u => u.includes('y.qq.com'))) score += 100;
if (urls.some(u => u.includes('/player'))) score += 30;
if (list.some(t => t.type === 'page')) score += 10;
return score;
}
async function discoverEndpoint() {
const candidates = [];
if (process.env.QQ_MUSIC_DEVTOOLS_URL) candidates.push(baseOrigin(process.env.QQ_MUSIC_DEVTOOLS_URL));
for (const port of DEFAULT_PORTS) candidates.push(`http://DEFAULT_HOST:port`);
const seen = new Set();
const discovered = [];
for (const baseUrl of candidates) {
if (seen.has(baseUrl)) continue;
seen.add(baseUrl);
try {
const [version, list] = await Promise.all([
fetchJson(`baseUrl/json/version`),
fetchJson(`baseUrl/json/list`),
]);
discovered.push({ baseUrl, version, list });
} catch {
// ignore and continue probing
}
}
if (!discovered.length) {
throw new Error(
`No DevTools endpoint found. Set QQ_MUSIC_DEVTOOLS_URL or start a browser with remote debugging. ` +
`Probed ports: DEFAULT_PORTS.join(', ')`
);
}
discovered.sort((a, b) => scoreEndpoint(b) - scoreEndpoint(a));
return discovered[0];
}
function pageTargets(entry) {
return (entry.list || []).filter(t => t.type === 'page');
}
function firstTarget(list, predicate) {
return list.find(predicate) || null;
}
function isQQMusicTarget(target) {
return target && typeof target.url === 'string' && target.url.includes('y.qq.com');
}
function isPlayerTarget(target) {
return isQQMusicTarget(target) && target.url.includes('/player');
}
function isBrowseTarget(target) {
return isQQMusicTarget(target) && !target.url.includes('/player');
}
function prettyUrl(target) {
return target ? target.url : '';
}
function connectCDP(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let seq = 0;
let closed = false;
const pending = new Map();
function failAll(err) {
if (closed) return;
closed = true;
for (const { reject: rej, timer } of pending.values()) {
clearTimeout(timer);
rej(err);
}
pending.clear();
}
function send(method, params = {}) {
if (closed) return Promise.reject(new Error('CDP session closed'));
return new Promise((resolveSend, rejectSend) => {
const id = ++seq;
const timer = setTimeout(() => {
pending.delete(id);
rejectSend(timeoutError(method));
}, 10000);
pending.set(id, { resolve: resolveSend, reject: rejectSend, timer });
ws.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const res = await send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
return res.result ? res.result.value : undefined;
}
ws.onopen = () => resolve({ ws, send, evaluate, close: () => { closed = true; ws.close(); } });
ws.onmessage = evt => {
const msg = JSON.parse(evt.data);
if (!msg.id || !pending.has(msg.id)) return;
const item = pending.get(msg.id);
pending.delete(msg.id);
clearTimeout(item.timer);
if (msg.error) item.reject(new Error(msg.error.message || 'CDP command failed'));
else item.resolve(msg.result);
};
ws.onerror = err => failAll(new Error(err.message || 'CDP connection error'));
ws.onclose = () => failAll(new Error('CDP connection closed'));
});
}
async function browserSession(entry) {
return connectCDP(entry.version.webSocketDebuggerUrl);
}
async function pageSession(target) {
return connectCDP(target.webSocketDebuggerUrl);
}
function output(obj) {
console.log(JSON.stringify(obj, null, 2));
}
function jsonString(expr) {
return `JSON.stringify(expr)`;
}
async function createTarget(entry, url = 'about:blank') {
const browser = await browserSession(entry);
try {
const result = await browser.send('Target.createTarget', { url });
return result.targetId;
} finally {
browser.close();
}
}
async function openOrReuseBrowseTarget(entry) {
const pages = pageTargets(entry);
const browse = firstTarget(pages, isBrowseTarget);
if (browse) return browse;
const anyQQ = firstTarget(pages, isQQMusicTarget);
if (anyQQ) return anyQQ;
const blank = firstTarget(pages, t => t.url === 'about:blank' || t.url.startsWith('chrome://'));
if (blank) return blank;
const newTargetId = await createTarget(entry, 'about:blank');
const refreshed = await fetchJson(`entry.baseUrl/json/list`);
return firstTarget(refreshed, t => t.id === newTargetId) || firstTarget(refreshed, t => t.url === 'about:blank') || null;
}
async function openMusicPage(entry) {
const target = await openOrReuseBrowseTarget(entry);
if (!target) throw new Error('No browser tab available');
const session = await pageSession(target);
try {
await session.send('Page.navigate', { url: 'https://y.qq.com/' });
await sleep(PAGE_WAIT_MS);
} finally {
session.close();
}
}
function songQueryJS(keyword) {
const q = JSON.stringify(String(keyword || '').trim().toLowerCase());
return `
(function() {
const want = q;
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return false, msg: "No search results"')};
function clean(s) { return String(s || '').trim().toLowerCase().replace(/\s+/g, ''); }
function titleOf(item) {
const el = item.querySelector('.songlist__songname_txt a[title]');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function artistOf(item) {
const el = item.querySelector('.songlist__artist a');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function play(item) {
const btn = item.querySelector('.list_menu__play');
if (btn) { btn.click(); return 'play-btn'; }
const song = item.querySelector('.songlist__songname_txt');
if (song) { song.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); return 'dblclick'; }
return 'none';
}
let chosen = items[0];
if (want) {
const exact = items.find(item => clean(titleOf(item)) === want);
const contains = items.find(item => clean(titleOf(item)).includes(want));
chosen = exact || contains || items[0];
}
const name = titleOf(chosen);
const artist = artistOf(chosen);
const method = play(chosen);
return JSON.stringify({ ok: true, song: name, artist, results: items.length, method });
})()
`;
}
function firstVisibleSongJS() {
return `
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No songs found' });
const idx = Math.floor(Math.random() * items.length);
const item = items[idx];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
const song = nameEl ? String(nameEl.title || nameEl.textContent || '').trim() : '';
const artist = artistEl ? String(artistEl.title || artistEl.textContent || '').trim() : '';
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song, artist, index: idx, total: items.length });
})()
`;
}
function playlistPlayJS() {
return `
(function() {
const playAll = document.querySelector('.mod_btn_green');
if (playAll) {
playAll.click();
const items = Array.from(document.querySelectorAll('.songlist__item'));
const first = items[0] ? items[0].querySelector('.songlist__songname_txt a[title]') : null;
return JSON.stringify({ ok: true, action: 'play_all', firstSong: first ? String(first.title || '').trim() : '', total: items.length });
}
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'Playlist empty or not found' });
const btn = items[0].querySelector('.list_menu__play');
if (btn) btn.click(); else items[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, action: 'first_song', total: items.length });
})()
`;
}
async function actionTabs() {
const entry = await discoverEndpoint();
output({
browser: entry.version.Browser || entry.version['Browser'] || '',
baseUrl: entry.baseUrl,
tabs: pageTargets(entry).map(t => ({
id: t.id,
title: t.title,
url: t.url,
isPlayer: isPlayerTarget(t),
isQQMusic: isQQMusicTarget(t),
})),
});
}
async function actionInit() {
const entry = await discoverEndpoint();
const browse = await openOrReuseBrowseTarget(entry);
if (!browse) throw new Error('No browser tab available');
output({ ok: true, baseUrl: entry.baseUrl, targetId: browse.id, url: prettyUrl(browse) });
}
async function withPlayer(fn) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ error: 'Player not found. Play a song first.' });
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function withBrowse(fn) {
const entry = await discoverEndpoint();
const target = await openOrReuseBrowseTarget(entry);
if (!target) throw new Error('No browser tab available');
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function actionStatus() {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ status: 'no_player', msg: 'QQ Music player not open.' });
const session = await pageSession(target);
try {
const result = await session.evaluate(`
(function() {
const nameEl = document.querySelector('.player_music__name a, .player_music__name, .mod_player_song__name a');
const artistEl = document.querySelector('.player_music__artist a, .player_music__artist, .mod_player_song__artist a');
const timeEl = document.querySelector('.player_music__time_now');
const durationEl = document.querySelector('.player_music__time_max, .player_music__duration');
const playBtn = document.querySelector('.btn_big_play');
const isPlaying = playBtn ? playBtn.classList.contains('btn_big_play--pause') : null;
const activeSong = document.querySelector('.songlist__item--active .songlist__songname_txt a[title]');
const activeArtist = document.querySelector('.songlist__item--active .songlist__artist a');
return JSON.stringify({
song: (nameEl ? nameEl.textContent.trim() : '') || (activeSong ? String(activeSong.title || '').trim() : ''),
artist: (artistEl ? artistEl.textContent.trim() : '') || (activeArtist ? String(activeArtist.title || '').trim() : ''),
time: timeEl ? timeEl.textContent.trim() : '',
duration: durationEl ? durationEl.textContent.trim() : '',
isPlaying,
status: isPlaying === true ? 'playing' : isPlaying === false ? 'paused' : 'unknown'
});
})()
`);
output(JSON.parse(result));
} finally {
session.close();
}
}
async function actionPlay() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
if (!btn.classList.contains('btn_big_play--pause')) btn.click();
return JSON.stringify({ ok: true, action: btn.classList.contains('btn_big_play--pause') ? 'already_playing' : 'resumed' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPause() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
if (btn.classList.contains('btn_big_play--pause')) btn.click();
return JSON.stringify({ ok: true, action: btn.classList.contains('btn_big_play--pause') ? 'already_playing' : 'paused' });
})()
`);
output(JSON.parse(result));
});
}
async function actionToggle() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
const wasPlaying = btn.classList.contains('btn_big_play--pause');
btn.click();
return JSON.stringify({ ok: true, action: wasPlaying ? 'pause' : 'play' });
})()
`);
output(JSON.parse(result));
});
}
async function actionNext() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_next, [class*=btn_next], [title="下一首"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'next' }); }
const btns = document.querySelectorAll('.player_music__btn');
for (const b of btns) {
if (b.title && b.title.includes('下一首')) { b.click(); return JSON.stringify({ ok: true, action: 'next' }); }
}
return JSON.stringify({ ok: false, msg: 'Next button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPrev() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_prev, [class*=btn_prev], [title="上一首"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'prev' }); }
const btns = document.querySelectorAll('.player_music__btn');
for (const b of btns) {
if (b.title && b.title.includes('上一首')) { b.click(); return JSON.stringify({ ok: true, action: 'prev' }); }
}
return JSON.stringify({ ok: false, msg: 'Prev button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionSearch(keyword, type = 'song') {
await withBrowse(async session => {
const typeMap = { song: 'song', artist: 'singer', album: 'album' };
const t = typeMap[type] || 'song';
const url = `https://y.qq.com/n/ryqq/search?w=encodeURIComponent(String(keyword || '').trim())&t=t`;
await session.send('Page.navigate', { url });
await sleep(PAGE_WAIT_MS);
if (type === 'artist') {
const result = await session.evaluate(`
(function() {
const artistLinks = document.querySelectorAll('.singer_list__item a, .search_result__singer a, .mod_singer_list a');
if (artistLinks.length > 0) {
const href = artistLinks[0].href || '';
const name = String(artistLinks[0].textContent || '').trim();
return JSON.stringify({ found: true, href, name });
}
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (items.length > 0) {
const nameEl = items[0].querySelector('.songlist__songname_txt a[title]');
const playBtn = items[0].querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else items[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ found: true, played: nameEl ? String(nameEl.title || '').trim() : '', method: 'first_song' });
}
return JSON.stringify({ found: false });
})()
`);
output(JSON.parse(result));
return;
}
if (type === 'album') {
const result = await session.evaluate(`
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No results' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '' });
})()
`);
output(JSON.parse(result));
return;
}
const result = await session.evaluate(songQueryJS(keyword));
output(JSON.parse(result));
});
}
async function actionPlayLiked(random = false) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: 'https://y.qq.com/n/ryqq_v2/profile/like/song' });
await sleep(PAGE_WAIT_MS);
const result = await session.evaluate(random ? firstVisibleSongJS() : `
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No liked songs found' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '', index: 0, total: items.length });
})()
`);
output(JSON.parse(result));
});
}
async function actionPlayPlaylist(playlistId) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: `https://y.qq.com/n/ryqq/playlist/encodeURIComponent(String(playlistId || '').trim())` });
await sleep(PAGE_WAIT_MS);
const result = await session.evaluate(playlistPlayJS());
output(JSON.parse(result));
});
}
async function actionLike() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.player_music__btn_like, [class*=btn_like], [title="我喜欢"], [title="收藏"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'liked' }); }
const all = document.querySelectorAll('a, button, i, span');
for (const el of all) {
if (el.title && (el.title.includes('喜欢') || el.title.includes('收藏'))) {
el.click();
return JSON.stringify({ ok: true, action: 'liked', title: el.title });
}
}
return JSON.stringify({ ok: false, msg: 'Like button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionScreenshot(pathArg) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget) || firstTarget(pageTargets(entry), isBrowseTarget);
if (!target) return output({ error: 'No QQ Music tab found.' });
const session = await pageSession(target);
try {
await sleep(1000);
const result = await session.send('Page.captureScreenshot', { format: 'png' });
const outPath = pathArg || SCREENSHOT_PATH;
const buf = Buffer.from(result.data, 'base64');
fs.writeFileSync(outPath, buf);
output({ ok: true, path: outPath, bytes: buf.length });
} finally {
session.close();
}
}
function printHelp() {
output({
usage: 'node qq-music-ctl.js <action> [args...]',
actions: ['play','pause','toggle','next','prev','status','search <keyword>','search-artist <artist>','search-album <album>','play-liked','play-liked-random','play-playlist <id>','like','screenshot [path]','tabs','init'],
});
}
async function main() {
const action = process.argv[2];
const args = process.argv.slice(3);
if (!action || action === '--help' || action === '-h') {
return printHelp();
}
switch (action) {
case 'play': return actionPlay();
case 'pause': return actionPause();
case 'toggle': return actionToggle();
case 'next': return actionNext();
case 'prev': return actionPrev();
case 'status': return actionStatus();
case 'search': return actionSearch(args.join(' '), 'song');
case 'search-artist': return actionSearch(args.join(' '), 'artist');
case 'search-album': return actionSearch(args.join(' '), 'album');
case 'play-liked': return actionPlayLiked(false);
case 'play-liked-random': return actionPlayLiked(true);
case 'play-playlist': return actionPlayPlaylist(args[0]);
case 'like': return actionLike();
case 'screenshot': return actionScreenshot(args[0]);
case 'tabs': return actionTabs();
case 'init': return actionInit();
default:
return printHelp();
}
}
main().catch(err => {
output({ error: err.message || String(err) });
process.exit(1);
});