feat: Introduce Netease Music CLI with CDP enabler
This commit is contained in:
1 parent
c3efc5b492
commit
0374b77d16
12 files changed
+482
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# NeteaseMusic Desktop Adapter (网易云音乐)
|
||||
|
||||
Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Launch with remote debugging port:
|
||||
```bash
|
||||
/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `neteasemusic status` | Check CDP connection |
|
||||
| `neteasemusic playing` | Current song info (title, artist, album) |
|
||||
| `neteasemusic play` | Play / Pause toggle |
|
||||
| `neteasemusic next` | Skip to next song |
|
||||
| `neteasemusic prev` | Go to previous song |
|
||||
| `neteasemusic search "query"` | Search songs, artists |
|
||||
| `neteasemusic playlist` | Show current playback queue |
|
||||
| `neteasemusic like` | Like / unlike current song |
|
||||
| `neteasemusic lyrics` | Get lyrics of current song |
|
||||
| `neteasemusic volume [0-100]` | Get or set volume |
|
||||
@@ -0,0 +1,31 @@
|
||||
# 网易云音乐桌面端适配器
|
||||
|
||||
通过 Chrome DevTools Protocol (CDP) 在终端中控制 **网易云音乐**。该应用基于 Chromium Embedded Framework (CEF)。
|
||||
|
||||
## 前置条件
|
||||
|
||||
通过远程调试端口启动:
|
||||
```bash
|
||||
/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `neteasemusic status` | 检查 CDP 连接 |
|
||||
| `neteasemusic playing` | 当前播放歌曲信息 |
|
||||
| `neteasemusic play` | 播放 / 暂停切换 |
|
||||
| `neteasemusic next` | 下一首 |
|
||||
| `neteasemusic prev` | 上一首 |
|
||||
| `neteasemusic search "关键词"` | 搜索歌曲 |
|
||||
| `neteasemusic playlist` | 显示当前播放列表 |
|
||||
| `neteasemusic like` | 喜欢 / 取消喜欢 |
|
||||
| `neteasemusic lyrics` | 获取当前歌词 |
|
||||
| `neteasemusic volume [0-100]` | 获取或设置音量 |
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const likeCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'like',
|
||||
description: 'Like/unlike the currently playing song',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const result = await page.evaluate(`
|
||||
(function() {
|
||||
// The like/heart button in the player bar
|
||||
const btn = document.querySelector('.m-playbar .icn-love, .m-playbar [class*="like"], .m-player [class*="love"], [data-action="like"]');
|
||||
if (!btn) return 'Like button not found';
|
||||
|
||||
const wasLiked = btn.classList.contains('loved') || btn.classList.contains('active') || btn.getAttribute('data-liked') === 'true';
|
||||
btn.click();
|
||||
return wasLiked ? 'Unliked' : 'Liked';
|
||||
})()
|
||||
`);
|
||||
|
||||
return [{ Status: result }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const lyricsCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'lyrics',
|
||||
description: 'Get the lyrics of the currently playing song',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Line'],
|
||||
func: async (page: IPage) => {
|
||||
// Try to open lyrics panel if not visible
|
||||
await page.evaluate(`
|
||||
(function() {
|
||||
const btn = document.querySelector('.m-playbar .icn-lyric, [class*="lyric-btn"], [data-action="lyric"]');
|
||||
if (btn) btn.click();
|
||||
})()
|
||||
`);
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
const lyrics = await page.evaluate(`
|
||||
(function() {
|
||||
// Look for lyrics container
|
||||
const selectors = [
|
||||
'.m-lyric p, .m-lyric [class*="line"]',
|
||||
'[class*="lyric-content"] p',
|
||||
'.listlyric li',
|
||||
'[class*="lyric"] [class*="line"]',
|
||||
'.j-lyric p',
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const nodes = document.querySelectorAll(sel);
|
||||
if (nodes.length > 0) {
|
||||
return Array.from(nodes).map(n => (n.textContent || '').trim()).filter(l => l.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try the body text for any lyrics-like content
|
||||
return [];
|
||||
})()
|
||||
`);
|
||||
|
||||
if (lyrics.length === 0) {
|
||||
return [{ Line: 'No lyrics found. Try opening the lyrics panel first.' }];
|
||||
}
|
||||
|
||||
return lyrics.map((line: string) => ({ Line: line }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const nextCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'next',
|
||||
description: 'Skip to the next song',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const clicked = await page.evaluate(`
|
||||
(function() {
|
||||
const btn = document.querySelector('.m-playbar .btnfwd, .m-playbar [class*="next"], .m-player .btn-next, [data-action="next"]');
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!clicked) {
|
||||
// Fallback: Ctrl+Right is common next-track shortcut
|
||||
await page.pressKey('Control+ArrowRight');
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
return [{ Status: 'Skipped to next song' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const playCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'play',
|
||||
description: 'Toggle play/pause for the current song',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
// Click the play/pause button or use Space key
|
||||
const clicked = await page.evaluate(`
|
||||
(function() {
|
||||
const btn = document.querySelector('.m-playbar .btnp, .m-playbar [class*="play"], .m-player .btn-play, [data-action="play"]');
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!clicked) {
|
||||
// Fallback: use Space key which is the universal play/pause shortcut
|
||||
await page.pressKey('Space');
|
||||
}
|
||||
|
||||
return [{ Status: 'Play/Pause toggled' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const playingCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'playing',
|
||||
description: 'Get the currently playing song info',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Title', 'Artist', 'Album', 'Duration', 'Progress'],
|
||||
func: async (page: IPage) => {
|
||||
const info = await page.evaluate(`
|
||||
(function() {
|
||||
// NeteaseMusic player bar is at the bottom
|
||||
const selectors = {
|
||||
title: '.m-playbar .j-song .name, .m-playbar .song .name, [class*="playing"] .name, .m-player .name',
|
||||
artist: '.m-playbar .j-song .by, .m-playbar .song .by, [class*="playing"] .artist, .m-player .by',
|
||||
album: '.m-playbar .j-song .album, [class*="playing"] .album',
|
||||
time: '.m-playbar .j-dur, .m-playbar .time, .m-player .time',
|
||||
progress: '.m-playbar .barbg .rng, .m-playbar [role="progressbar"], .m-player [class*="progress"]',
|
||||
};
|
||||
|
||||
function getText(sel) {
|
||||
for (const s of sel.split(',')) {
|
||||
const el = document.querySelector(s.trim());
|
||||
if (el) return (el.textContent || el.innerText || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const title = getText(selectors.title);
|
||||
const artist = getText(selectors.artist);
|
||||
const album = getText(selectors.album);
|
||||
const time = getText(selectors.time);
|
||||
|
||||
// Try to get playback progress from the progress bar width
|
||||
let progress = '';
|
||||
const bar = document.querySelector('.m-playbar .barbg .rng, [class*="progress"] [class*="played"]');
|
||||
if (bar) {
|
||||
const style = bar.getAttribute('style') || '';
|
||||
const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
|
||||
if (match) progress = match[1] + '%';
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
// Fallback: try document title which often contains "songName - NeteaseMusic"
|
||||
const docTitle = document.title;
|
||||
if (docTitle && !docTitle.includes('NeteaseMusic')) {
|
||||
return { Title: docTitle, Artist: '', Album: '', Duration: '', Progress: '' };
|
||||
}
|
||||
return { Title: 'No song playing', Artist: '—', Album: '—', Duration: '—', Progress: '—' };
|
||||
}
|
||||
|
||||
return { Title: title, Artist: artist, Album: album, Duration: time, Progress: progress };
|
||||
})()
|
||||
`);
|
||||
|
||||
return [info];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const playlistCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'playlist',
|
||||
description: 'Show the current playback queue / playlist',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Index', 'Title', 'Artist'],
|
||||
func: async (page: IPage) => {
|
||||
// Open the playlist panel (usually a button at the bottom bar)
|
||||
await page.evaluate(`
|
||||
(function() {
|
||||
const btn = document.querySelector('.m-playbar .icn-list, .m-playbar [class*="playlist"], [data-action="playlist"], .m-playbar .btnlist');
|
||||
if (btn) btn.click();
|
||||
})()
|
||||
`);
|
||||
|
||||
await page.wait(1);
|
||||
|
||||
const items = await page.evaluate(`
|
||||
(function() {
|
||||
const results = [];
|
||||
// Playlist panel items
|
||||
const rows = document.querySelectorAll('.m-playlist li, [class*="playlist-panel"] li, .listlyric li, .j-playlist li');
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const nameEl = row.querySelector('.name, [class*="name"], a, span:first-child');
|
||||
const artistEl = row.querySelector('.by, [class*="artist"], .ar');
|
||||
|
||||
const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : (row.textContent || '').trim();
|
||||
const artist = artistEl ? (artistEl.textContent || '').trim() : '';
|
||||
|
||||
if (title && title.length > 0) {
|
||||
results.push({ Index: i + 1, Title: title.substring(0, 80), Artist: artist });
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (items.length === 0) {
|
||||
return [{ Index: 0, Title: 'Playlist is empty or panel not open', Artist: '—' }];
|
||||
}
|
||||
return items;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const prevCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'prev',
|
||||
description: 'Go back to the previous song',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page: IPage) => {
|
||||
const clicked = await page.evaluate(`
|
||||
(function() {
|
||||
const btn = document.querySelector('.m-playbar .btnbak, .m-playbar [class*="prev"], .m-player .btn-prev, [data-action="prev"]');
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!clicked) {
|
||||
await page.pressKey('Control+ArrowLeft');
|
||||
}
|
||||
|
||||
await page.wait(1);
|
||||
return [{ Status: 'Went to previous song' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const searchCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'search',
|
||||
description: 'Search for songs, artists, albums, or playlists',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [{ name: 'query', required: true, positional: true, help: 'Search query' }],
|
||||
columns: ['Index', 'Title', 'Artist'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const query = kwargs.query as string;
|
||||
|
||||
// Focus and fill the search box
|
||||
await page.evaluate(`
|
||||
(function(q) {
|
||||
const input = document.querySelector('.m-search input, #srch, [class*="search"] input, input[type="search"]');
|
||||
if (!input) throw new Error('Search input not found');
|
||||
input.focus();
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, q);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
})(${JSON.stringify(query)})
|
||||
`);
|
||||
|
||||
await page.pressKey('Enter');
|
||||
await page.wait(2);
|
||||
|
||||
// Scrape results
|
||||
const results = await page.evaluate(`
|
||||
(function() {
|
||||
const items = [];
|
||||
// Song list items in search results
|
||||
const rows = document.querySelectorAll('.srchsongst li, .m-table tbody tr, [class*="songlist"] [class*="item"], table tbody tr');
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (i >= 20) return;
|
||||
const nameEl = row.querySelector('.sn, .name a, [class*="songName"], td:nth-child(2) a, b[title]');
|
||||
const artistEl = row.querySelector('.ar, .artist, [class*="artist"], td:nth-child(4) a, td:nth-child(3) a');
|
||||
|
||||
const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : '';
|
||||
const artist = artistEl ? (artistEl.getAttribute('title') || artistEl.textContent || '').trim() : '';
|
||||
|
||||
if (title) items.push({ Index: i + 1, Title: title, Artist: artist });
|
||||
});
|
||||
|
||||
return items;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (results.length === 0) {
|
||||
return [{ Index: 0, Title: `No results for "${query}"`, Artist: '—' }];
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'status',
|
||||
description: 'Check CDP connection to NeteaseMusic Desktop',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const volumeCommand = cli({
|
||||
site: 'neteasemusic',
|
||||
name: 'volume',
|
||||
description: 'Get or set the volume level (0-100)',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'level', required: false, positional: true, help: 'Volume level 0-100 (omit to read current)' },
|
||||
],
|
||||
columns: ['Status', 'Volume'],
|
||||
func: async (page: IPage, kwargs: any) => {
|
||||
const level = kwargs.level as string | undefined;
|
||||
|
||||
if (!level) {
|
||||
// Read current volume
|
||||
const vol = await page.evaluate(`
|
||||
(function() {
|
||||
const bar = document.querySelector('.m-playbar .vol .barbg .rng, [class*="volume"] [class*="progress"], [class*="volume"] [class*="played"]');
|
||||
if (bar) {
|
||||
const style = bar.getAttribute('style') || '';
|
||||
const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
const vol = document.querySelector('.m-playbar .j-vol, [class*="volume-value"]');
|
||||
if (vol) return vol.textContent.trim();
|
||||
|
||||
return 'Unknown';
|
||||
})()
|
||||
`);
|
||||
|
||||
return [{ Status: 'Current', Volume: vol + '%' }];
|
||||
}
|
||||
|
||||
// Set volume by clicking on the volume bar at the right position
|
||||
const targetVol = Math.max(0, Math.min(100, parseInt(level, 10)));
|
||||
|
||||
await page.evaluate(`
|
||||
(function(target) {
|
||||
const bar = document.querySelector('.m-playbar .vol .barbg, [class*="volume-bar"], [class*="volume"] [class*="track"]');
|
||||
if (!bar) return;
|
||||
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const x = rect.left + (rect.width * target / 100);
|
||||
const y = rect.top + rect.height / 2;
|
||||
|
||||
bar.dispatchEvent(new MouseEvent('click', {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
}));
|
||||
})(${targetVol})
|
||||
`);
|
||||
|
||||
return [{ Status: 'Set', Volume: targetVol + '%' }];
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user