@jackwener/opencli 0.9.8 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +35 -58
  6. package/README.zh-CN.md +36 -60
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -12
  16. package/dist/browser/index.js +2 -12
  17. package/dist/browser/mcp.d.ts +9 -21
  18. package/dist/browser/mcp.js +70 -285
  19. package/dist/browser/page.d.ts +36 -7
  20. package/dist/browser/page.js +212 -81
  21. package/dist/browser.test.js +10 -231
  22. package/dist/cli-manifest.json +561 -14
  23. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  24. package/dist/clis/apple-podcasts/episodes.js +28 -0
  25. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  26. package/dist/clis/apple-podcasts/search.js +29 -0
  27. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  28. package/dist/clis/apple-podcasts/top.js +34 -0
  29. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  30. package/dist/clis/apple-podcasts/utils.js +30 -0
  31. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  33. package/dist/clis/chatwise/history.js +18 -1
  34. package/dist/clis/discord-app/channels.js +33 -21
  35. package/dist/clis/neteasemusic/like.d.ts +1 -0
  36. package/dist/clis/neteasemusic/like.js +25 -0
  37. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  38. package/dist/clis/neteasemusic/lyrics.js +47 -0
  39. package/dist/clis/neteasemusic/next.d.ts +1 -0
  40. package/dist/clis/neteasemusic/next.js +26 -0
  41. package/dist/clis/neteasemusic/play.d.ts +1 -0
  42. package/dist/clis/neteasemusic/play.js +26 -0
  43. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  44. package/dist/clis/neteasemusic/playing.js +59 -0
  45. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  46. package/dist/clis/neteasemusic/playlist.js +46 -0
  47. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  48. package/dist/clis/neteasemusic/prev.js +25 -0
  49. package/dist/clis/neteasemusic/search.d.ts +1 -0
  50. package/dist/clis/neteasemusic/search.js +52 -0
  51. package/dist/clis/neteasemusic/status.d.ts +1 -0
  52. package/dist/clis/neteasemusic/status.js +16 -0
  53. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  54. package/dist/clis/neteasemusic/volume.js +54 -0
  55. package/dist/clis/twitter/accept.d.ts +1 -0
  56. package/dist/clis/twitter/accept.js +202 -0
  57. package/dist/clis/twitter/followers.js +30 -22
  58. package/dist/clis/twitter/following.js +19 -14
  59. package/dist/clis/twitter/notifications.js +29 -22
  60. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  61. package/dist/clis/twitter/reply-dm.js +181 -0
  62. package/dist/clis/twitter/search.js +50 -12
  63. package/dist/clis/weread/book.d.ts +1 -0
  64. package/dist/clis/weread/book.js +26 -0
  65. package/dist/clis/weread/highlights.d.ts +1 -0
  66. package/dist/clis/weread/highlights.js +23 -0
  67. package/dist/clis/weread/notebooks.d.ts +1 -0
  68. package/dist/clis/weread/notebooks.js +21 -0
  69. package/dist/clis/weread/notes.d.ts +1 -0
  70. package/dist/clis/weread/notes.js +29 -0
  71. package/dist/clis/weread/ranking.d.ts +1 -0
  72. package/dist/clis/weread/ranking.js +28 -0
  73. package/dist/clis/weread/search.d.ts +1 -0
  74. package/dist/clis/weread/search.js +25 -0
  75. package/dist/clis/weread/shelf.d.ts +1 -0
  76. package/dist/clis/weread/shelf.js +24 -0
  77. package/dist/clis/weread/utils.d.ts +20 -0
  78. package/dist/clis/weread/utils.js +72 -0
  79. package/dist/clis/weread/utils.test.d.ts +1 -0
  80. package/dist/clis/weread/utils.test.js +85 -0
  81. package/dist/daemon.d.ts +13 -0
  82. package/dist/daemon.js +187 -0
  83. package/dist/doctor.d.ts +10 -65
  84. package/dist/doctor.js +49 -602
  85. package/dist/doctor.test.js +30 -170
  86. package/dist/main.js +12 -41
  87. package/dist/pipeline/executor.test.js +1 -0
  88. package/dist/pipeline/steps/browser.js +2 -2
  89. package/dist/pipeline/steps/intercept.js +1 -2
  90. package/dist/runtime.d.ts +1 -4
  91. package/dist/runtime.js +1 -4
  92. package/dist/setup.d.ts +6 -0
  93. package/dist/setup.js +46 -160
  94. package/dist/types.d.ts +6 -0
  95. package/extension/dist/background.js +484 -0
  96. package/extension/icons/icon-128.png +0 -0
  97. package/extension/icons/icon-16.png +0 -0
  98. package/extension/icons/icon-32.png +0 -0
  99. package/extension/icons/icon-48.png +0 -0
  100. package/extension/manifest.json +31 -0
  101. package/extension/package.json +16 -0
  102. package/extension/src/background.ts +370 -0
  103. package/extension/src/cdp.ts +125 -0
  104. package/extension/src/protocol.ts +57 -0
  105. package/extension/store-assets/screenshot-1280x800.png +0 -0
  106. package/extension/tsconfig.json +15 -0
  107. package/extension/vite.config.ts +18 -0
  108. package/package.json +5 -5
  109. package/src/browser/daemon-client.ts +113 -0
  110. package/src/browser/discover.ts +18 -232
  111. package/src/browser/errors.ts +30 -100
  112. package/src/browser/index.ts +2 -13
  113. package/src/browser/mcp.ts +81 -282
  114. package/src/browser/page.ts +223 -83
  115. package/src/browser.test.ts +9 -239
  116. package/src/clis/apple-podcasts/episodes.ts +28 -0
  117. package/src/clis/apple-podcasts/search.ts +29 -0
  118. package/src/clis/apple-podcasts/top.ts +34 -0
  119. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  120. package/src/clis/apple-podcasts/utils.ts +37 -0
  121. package/src/clis/chatgpt/README.md +1 -1
  122. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  123. package/src/clis/chatwise/history.ts +15 -1
  124. package/src/clis/discord-app/channels.ts +33 -21
  125. package/src/clis/neteasemusic/README.md +31 -0
  126. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  127. package/src/clis/neteasemusic/like.ts +28 -0
  128. package/src/clis/neteasemusic/lyrics.ts +53 -0
  129. package/src/clis/neteasemusic/next.ts +30 -0
  130. package/src/clis/neteasemusic/play.ts +30 -0
  131. package/src/clis/neteasemusic/playing.ts +62 -0
  132. package/src/clis/neteasemusic/playlist.ts +51 -0
  133. package/src/clis/neteasemusic/prev.ts +29 -0
  134. package/src/clis/neteasemusic/search.ts +58 -0
  135. package/src/clis/neteasemusic/status.ts +18 -0
  136. package/src/clis/neteasemusic/volume.ts +61 -0
  137. package/src/clis/twitter/accept.ts +213 -0
  138. package/src/clis/twitter/followers.ts +36 -29
  139. package/src/clis/twitter/following.ts +25 -20
  140. package/src/clis/twitter/notifications.ts +34 -27
  141. package/src/clis/twitter/reply-dm.ts +193 -0
  142. package/src/clis/twitter/search.ts +53 -13
  143. package/src/clis/weread/book.ts +28 -0
  144. package/src/clis/weread/highlights.ts +25 -0
  145. package/src/clis/weread/notebooks.ts +23 -0
  146. package/src/clis/weread/notes.ts +31 -0
  147. package/src/clis/weread/ranking.ts +29 -0
  148. package/src/clis/weread/search.ts +26 -0
  149. package/src/clis/weread/shelf.ts +26 -0
  150. package/src/clis/weread/utils.test.ts +104 -0
  151. package/src/clis/weread/utils.ts +74 -0
  152. package/src/daemon.ts +217 -0
  153. package/src/doctor.test.ts +32 -193
  154. package/src/doctor.ts +58 -669
  155. package/src/main.ts +11 -34
  156. package/src/pipeline/executor.test.ts +1 -0
  157. package/src/pipeline/steps/browser.ts +2 -2
  158. package/src/pipeline/steps/intercept.ts +1 -2
  159. package/src/runtime.ts +2 -6
  160. package/src/setup.ts +47 -183
  161. package/src/types.ts +1 -0
  162. package/tests/e2e/public-commands.test.ts +68 -1
  163. package/dist/clis/grok/debug.d.ts +0 -1
  164. package/dist/clis/grok/debug.js +0 -45
  165. package/src/clis/grok/debug.ts +0 -49
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch, formatDuration, formatDate } from './utils.js';
4
+
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'episodes',
8
+ description: 'List recent episodes of an Apple Podcast (use ID from search)',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
13
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
14
+ ],
15
+ columns: ['title', 'duration', 'date'],
16
+ func: async (_page, args) => {
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 200));
18
+ // results[0] is the podcast itself; the rest are episodes
19
+ const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
20
+ const episodes = (data.results ?? []).filter((r: any) => r.kind === 'podcast-episode');
21
+ if (!episodes.length) throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search <keyword>');
22
+ return episodes.slice(0, limit).map((ep: any) => ({
23
+ title: ep.trackName,
24
+ duration: formatDuration(ep.trackTimeMillis),
25
+ date: formatDate(ep.releaseDate),
26
+ }));
27
+ },
28
+ });
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch } from './utils.js';
4
+
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'search',
8
+ description: 'Search Apple Podcasts',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
13
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
14
+ ],
15
+ columns: ['id', 'title', 'author', 'episodes', 'genre'],
16
+ func: async (_page, args) => {
17
+ const term = encodeURIComponent(args.keyword);
18
+ const limit = Math.max(1, Math.min(Number(args.limit), 25));
19
+ const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
20
+ if (!data.results?.length) throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
21
+ return data.results.map((p: any) => ({
22
+ id: p.collectionId,
23
+ title: p.collectionName,
24
+ author: p.artistName,
25
+ episodes: p.trackCount ?? '-',
26
+ genre: p.primaryGenreName ?? '-',
27
+ }));
28
+ },
29
+ });
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+
4
+ // Apple Marketing Tools RSS API — public, no key required
5
+ const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2';
6
+
7
+ cli({
8
+ site: 'apple-podcasts',
9
+ name: 'top',
10
+ description: 'Top podcasts chart on Apple Podcasts',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: false,
13
+ args: [
14
+ { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
15
+ { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
16
+ ],
17
+ columns: ['rank', 'title', 'author', 'id'],
18
+ func: async (_page, args) => {
19
+ const limit = Math.max(1, Math.min(Number(args.limit), 100));
20
+ const country = String(args.country || 'us').trim().toLowerCase();
21
+ const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
22
+ const resp = await fetch(url);
23
+ if (!resp.ok) throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
24
+ const data = await resp.json();
25
+ const results = data?.feed?.results;
26
+ if (!results?.length) throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
27
+ return results.map((p: any, i: number) => ({
28
+ rank: i + 1,
29
+ title: p.name,
30
+ author: p.artistName,
31
+ id: p.id,
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, itunesFetch } from './utils.js';
3
+
4
+ describe('formatDuration', () => {
5
+ it('formats typical duration in ms', () => {
6
+ expect(formatDuration(3661000)).toBe('61:01');
7
+ });
8
+
9
+ it('pads single-digit seconds', () => {
10
+ expect(formatDuration(65000)).toBe('1:05');
11
+ });
12
+
13
+ it('formats exact minutes', () => {
14
+ expect(formatDuration(3600000)).toBe('60:00');
15
+ });
16
+
17
+ it('rounds fractional milliseconds', () => {
18
+ expect(formatDuration(3600500)).toBe('60:01');
19
+ });
20
+
21
+ it('returns dash for zero', () => {
22
+ expect(formatDuration(0)).toBe('-');
23
+ });
24
+
25
+ it('returns dash for NaN', () => {
26
+ expect(formatDuration(NaN)).toBe('-');
27
+ });
28
+ });
29
+
30
+ describe('formatDate', () => {
31
+ it('extracts YYYY-MM-DD from ISO string', () => {
32
+ expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
33
+ });
34
+
35
+ it('handles date-only string', () => {
36
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
37
+ });
38
+
39
+ it('returns dash for empty string', () => {
40
+ expect(formatDate('')).toBe('-');
41
+ });
42
+
43
+ it('returns dash for undefined', () => {
44
+ expect(formatDate(undefined as any)).toBe('-');
45
+ });
46
+ });
47
+
48
+ describe('itunesFetch', () => {
49
+ beforeEach(() => {
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it('returns parsed JSON on success', async () => {
54
+ const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
55
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ json: () => Promise.resolve(mockData),
58
+ }));
59
+
60
+ const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
61
+ expect(result).toEqual(mockData);
62
+ });
63
+
64
+ it('throws CliError on HTTP error', async () => {
65
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
66
+ ok: false,
67
+ status: 403,
68
+ }));
69
+
70
+ await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
71
+ });
72
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+
8
+ import { CliError } from '../../errors.js';
9
+
10
+ const BASE = 'https://itunes.apple.com';
11
+
12
+ export async function itunesFetch(path: string): Promise<any> {
13
+ const resp = await fetch(`${BASE}${path}`);
14
+ if (!resp.ok) {
15
+ throw new CliError(
16
+ 'FETCH_ERROR',
17
+ `iTunes API HTTP ${resp.status}`,
18
+ 'Check your search term or podcast ID',
19
+ );
20
+ }
21
+ return resp.json();
22
+ }
23
+
24
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
25
+ export function formatDuration(ms: number): string {
26
+ if (!ms || !Number.isFinite(ms)) return '-';
27
+ const totalSec = Math.round(ms / 1000);
28
+ const m = Math.floor(totalSec / 60);
29
+ const s = totalSec % 60;
30
+ return `${m}:${String(s).padStart(2, '0')}`;
31
+ }
32
+
33
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
34
+ export function formatDate(iso: string): string {
35
+ if (!iso) return '-';
36
+ return iso.slice(0, 10);
37
+ }
@@ -35,7 +35,7 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
35
35
  ## How It Works
36
36
 
37
37
  - **AppleScript mode**: Uses `osascript` and `pbcopy`/`pbpaste` for clipboard-based text transfer. No remote debugging port needed.
38
- - **CDP mode**: Connects via Playwright to the Electron renderer process for direct DOM manipulation.
38
+ - **CDP mode**: Connects via Chrome DevTools Protocol to the Electron renderer process for direct DOM manipulation.
39
39
 
40
40
  ## Limitations
41
41
 
@@ -35,7 +35,7 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
35
35
  ## 工作原理
36
36
 
37
37
  - **AppleScript 模式**:使用 `osascript` 和 `pbcopy`/`pbpaste` 进行剪贴板文本传输,无需远程调试端口。
38
- - **CDP 模式**:通过 Playwright 连接到 Electron 渲染进程,直接操作 DOM。
38
+ - **CDP 模式**:通过 Chrome DevTools Protocol 连接到 Electron 渲染进程,直接操作 DOM。
39
39
 
40
40
  ## 限制
41
41
 
@@ -42,6 +42,20 @@ export const historyCommand = cli({
42
42
  return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
43
43
  }
44
44
 
45
- return items;
45
+ const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
46
+ const numericOnly = /^[\d\s]+$/;
47
+ const modelPath = /^[\w.-]+\/[\w.-]/;
48
+ const seen = new Set<string>();
49
+ const deduped = items.filter((item: { Index: number; Title: string }) => {
50
+ const t = item.Title.trim();
51
+ if (dateHeaders.test(t)) return false;
52
+ if (numericOnly.test(t)) return false;
53
+ if (modelPath.test(t)) return false;
54
+ if (seen.has(t)) return false;
55
+ seen.add(t);
56
+ return true;
57
+ }).map((item: { Index: number; Title: string }, i: number) => ({ Index: i + 1, Title: item.Title }));
58
+
59
+ return deduped;
46
60
  },
47
61
  });
@@ -14,28 +14,40 @@ export const channelsCommand = cli({
14
14
  const channels = await page.evaluate(`
15
15
  (function() {
16
16
  const results = [];
17
- // Discord channel list items
18
- const items = document.querySelectorAll('[data-list-item-id*="channels___"], [class*="containerDefault_"]');
19
-
20
- items.forEach((item, i) => {
21
- const nameEl = item.querySelector('[class*="name_"], [class*="channelName"]');
22
- const name = nameEl ? nameEl.textContent.trim() : (item.textContent || '').trim().substring(0, 50);
23
-
24
- if (!name || name.length < 1) return;
25
-
26
- // Detect channel type from icon or aria-label
27
- const iconEl = item.querySelector('[class*="icon"]');
28
- let type = 'Text';
29
- if (iconEl) {
30
- const cls = iconEl.className || '';
31
- if (cls.includes('voice') || cls.includes('speaker')) type = 'Voice';
32
- else if (cls.includes('forum')) type = 'Forum';
33
- else if (cls.includes('announcement')) type = 'Announcement';
34
- }
35
-
36
- results.push({ Index: i + 1, Channel: name, Type: type });
17
+
18
+ // Discord channel links: <a> tags with href like /channels/GUILD/CHANNEL
19
+ const links = document.querySelectorAll('a[href*="/channels/"][data-list-item-id^="channels___"]');
20
+
21
+ links.forEach(function(el) {
22
+ var label = el.getAttribute('aria-label') || '';
23
+ if (!label) return;
24
+
25
+ // Skip categories
26
+ if (/[((]category[))]/i.test(label)) return;
27
+
28
+ // Strip any leading status prefix before the first comma (e.g. "unread, ", locale-agnostic)
29
+ var commaIdx = label.search(/[,,]/);
30
+ var cleaned = commaIdx !== -1 ? label.slice(commaIdx + 1).trimStart() : label;
31
+
32
+ // Extract name and type from "name (type)" or "name(type)"
33
+ var m = cleaned.match(/^(.+?)\s*[((](.+?)[))]\s*$/);
34
+ // If no type annotation found, skip — real channels always have "(Type channel)" in aria-label
35
+ if (!m) return;
36
+ var name = m[1].trim();
37
+ var rawType = m[2].toLowerCase();
38
+
39
+ // Discord channel names are ASCII-only; skip placeholder entries (e.g. locked channels)
40
+ if (!name || !/^[\x20-\x7E]+$/.test(name)) return;
41
+
42
+ var type = 'Text';
43
+ if (rawType.includes('voice')) type = 'Voice';
44
+ else if (rawType.includes('forum')) type = 'Forum';
45
+ else if (rawType.includes('announcement')) type = 'Announcement';
46
+ else if (rawType.includes('stage')) type = 'Stage';
47
+
48
+ results.push({ Index: results.length + 1, Channel: name, Type: type });
37
49
  });
38
-
50
+
39
51
  return results;
40
52
  })()
41
53
  `);
@@ -0,0 +1,31 @@
1
+ # NeteaseMusic Desktop Adapter (网易云音乐)
2
+
3
+ Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF).
4
+
5
+ ## Prerequisites
6
+
7
+ Launch with remote debugging port:
8
+ ```bash
9
+ /Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
10
+ ```
11
+
12
+ ## Setup
13
+
14
+ ```bash
15
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ | Command | Description |
21
+ |---------|-------------|
22
+ | `neteasemusic status` | Check CDP connection |
23
+ | `neteasemusic playing` | Current song info (title, artist, album) |
24
+ | `neteasemusic play` | Play / Pause toggle |
25
+ | `neteasemusic next` | Skip to next song |
26
+ | `neteasemusic prev` | Go to previous song |
27
+ | `neteasemusic search "query"` | Search songs, artists |
28
+ | `neteasemusic playlist` | Show current playback queue |
29
+ | `neteasemusic like` | Like / unlike current song |
30
+ | `neteasemusic lyrics` | Get lyrics of current song |
31
+ | `neteasemusic volume [0-100]` | Get or set volume |
@@ -0,0 +1,31 @@
1
+ # 网易云音乐桌面端适配器
2
+
3
+ 通过 Chrome DevTools Protocol (CDP) 在终端中控制 **网易云音乐**。该应用基于 Chromium Embedded Framework (CEF)。
4
+
5
+ ## 前置条件
6
+
7
+ 通过远程调试端口启动:
8
+ ```bash
9
+ /Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
10
+ ```
11
+
12
+ ## 配置
13
+
14
+ ```bash
15
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
16
+ ```
17
+
18
+ ## 命令
19
+
20
+ | 命令 | 说明 |
21
+ |------|------|
22
+ | `neteasemusic status` | 检查 CDP 连接 |
23
+ | `neteasemusic playing` | 当前播放歌曲信息 |
24
+ | `neteasemusic play` | 播放 / 暂停切换 |
25
+ | `neteasemusic next` | 下一首 |
26
+ | `neteasemusic prev` | 上一首 |
27
+ | `neteasemusic search "关键词"` | 搜索歌曲 |
28
+ | `neteasemusic playlist` | 显示当前播放列表 |
29
+ | `neteasemusic like` | 喜欢 / 取消喜欢 |
30
+ | `neteasemusic lyrics` | 获取当前歌词 |
31
+ | `neteasemusic volume [0-100]` | 获取或设置音量 |
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const likeCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'like',
7
+ description: 'Like/unlike the currently playing song',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Status'],
13
+ func: async (page: IPage) => {
14
+ const result = await page.evaluate(`
15
+ (function() {
16
+ // The like/heart button in the player bar
17
+ const btn = document.querySelector('.m-playbar .icn-love, .m-playbar [class*="like"], .m-player [class*="love"], [data-action="like"]');
18
+ if (!btn) return 'Like button not found';
19
+
20
+ const wasLiked = btn.classList.contains('loved') || btn.classList.contains('active') || btn.getAttribute('data-liked') === 'true';
21
+ btn.click();
22
+ return wasLiked ? 'Unliked' : 'Liked';
23
+ })()
24
+ `);
25
+
26
+ return [{ Status: result }];
27
+ },
28
+ });
@@ -0,0 +1,53 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const lyricsCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'lyrics',
7
+ description: 'Get the lyrics of the currently playing song',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Line'],
13
+ func: async (page: IPage) => {
14
+ // Try to open lyrics panel if not visible
15
+ await page.evaluate(`
16
+ (function() {
17
+ const btn = document.querySelector('.m-playbar .icn-lyric, [class*="lyric-btn"], [data-action="lyric"]');
18
+ if (btn) btn.click();
19
+ })()
20
+ `);
21
+
22
+ await page.wait(1);
23
+
24
+ const lyrics = await page.evaluate(`
25
+ (function() {
26
+ // Look for lyrics container
27
+ const selectors = [
28
+ '.m-lyric p, .m-lyric [class*="line"]',
29
+ '[class*="lyric-content"] p',
30
+ '.listlyric li',
31
+ '[class*="lyric"] [class*="line"]',
32
+ '.j-lyric p',
33
+ ];
34
+
35
+ for (const sel of selectors) {
36
+ const nodes = document.querySelectorAll(sel);
37
+ if (nodes.length > 0) {
38
+ return Array.from(nodes).map(n => (n.textContent || '').trim()).filter(l => l.length > 0);
39
+ }
40
+ }
41
+
42
+ // Fallback: try the body text for any lyrics-like content
43
+ return [];
44
+ })()
45
+ `);
46
+
47
+ if (lyrics.length === 0) {
48
+ return [{ Line: 'No lyrics found. Try opening the lyrics panel first.' }];
49
+ }
50
+
51
+ return lyrics.map((line: string) => ({ Line: line }));
52
+ },
53
+ });
@@ -0,0 +1,30 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const nextCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'next',
7
+ description: 'Skip to the next song',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Status'],
13
+ func: async (page: IPage) => {
14
+ const clicked = await page.evaluate(`
15
+ (function() {
16
+ const btn = document.querySelector('.m-playbar .btnfwd, .m-playbar [class*="next"], .m-player .btn-next, [data-action="next"]');
17
+ if (btn) { btn.click(); return true; }
18
+ return false;
19
+ })()
20
+ `);
21
+
22
+ if (!clicked) {
23
+ // Fallback: Ctrl+Right is common next-track shortcut
24
+ await page.pressKey('Control+ArrowRight');
25
+ }
26
+
27
+ await page.wait(1);
28
+ return [{ Status: 'Skipped to next song' }];
29
+ },
30
+ });
@@ -0,0 +1,30 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const playCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'play',
7
+ description: 'Toggle play/pause for the current song',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Status'],
13
+ func: async (page: IPage) => {
14
+ // Click the play/pause button or use Space key
15
+ const clicked = await page.evaluate(`
16
+ (function() {
17
+ const btn = document.querySelector('.m-playbar .btnp, .m-playbar [class*="play"], .m-player .btn-play, [data-action="play"]');
18
+ if (btn) { btn.click(); return true; }
19
+ return false;
20
+ })()
21
+ `);
22
+
23
+ if (!clicked) {
24
+ // Fallback: use Space key which is the universal play/pause shortcut
25
+ await page.pressKey('Space');
26
+ }
27
+
28
+ return [{ Status: 'Play/Pause toggled' }];
29
+ },
30
+ });
@@ -0,0 +1,62 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const playingCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'playing',
7
+ description: 'Get the currently playing song info',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Title', 'Artist', 'Album', 'Duration', 'Progress'],
13
+ func: async (page: IPage) => {
14
+ const info = await page.evaluate(`
15
+ (function() {
16
+ // NeteaseMusic player bar is at the bottom
17
+ const selectors = {
18
+ title: '.m-playbar .j-song .name, .m-playbar .song .name, [class*="playing"] .name, .m-player .name',
19
+ artist: '.m-playbar .j-song .by, .m-playbar .song .by, [class*="playing"] .artist, .m-player .by',
20
+ album: '.m-playbar .j-song .album, [class*="playing"] .album',
21
+ time: '.m-playbar .j-dur, .m-playbar .time, .m-player .time',
22
+ progress: '.m-playbar .barbg .rng, .m-playbar [role="progressbar"], .m-player [class*="progress"]',
23
+ };
24
+
25
+ function getText(sel) {
26
+ for (const s of sel.split(',')) {
27
+ const el = document.querySelector(s.trim());
28
+ if (el) return (el.textContent || el.innerText || '').trim();
29
+ }
30
+ return '';
31
+ }
32
+
33
+ const title = getText(selectors.title);
34
+ const artist = getText(selectors.artist);
35
+ const album = getText(selectors.album);
36
+ const time = getText(selectors.time);
37
+
38
+ // Try to get playback progress from the progress bar width
39
+ let progress = '';
40
+ const bar = document.querySelector('.m-playbar .barbg .rng, [class*="progress"] [class*="played"]');
41
+ if (bar) {
42
+ const style = bar.getAttribute('style') || '';
43
+ const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
44
+ if (match) progress = match[1] + '%';
45
+ }
46
+
47
+ if (!title) {
48
+ // Fallback: try document title which often contains "songName - NeteaseMusic"
49
+ const docTitle = document.title;
50
+ if (docTitle && !docTitle.includes('NeteaseMusic')) {
51
+ return { Title: docTitle, Artist: '', Album: '', Duration: '', Progress: '' };
52
+ }
53
+ return { Title: 'No song playing', Artist: '—', Album: '—', Duration: '—', Progress: '—' };
54
+ }
55
+
56
+ return { Title: title, Artist: artist, Album: album, Duration: time, Progress: progress };
57
+ })()
58
+ `);
59
+
60
+ return [info];
61
+ },
62
+ });
@@ -0,0 +1,51 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const playlistCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'playlist',
7
+ description: 'Show the current playback queue / playlist',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Index', 'Title', 'Artist'],
13
+ func: async (page: IPage) => {
14
+ // Open the playlist panel (usually a button at the bottom bar)
15
+ await page.evaluate(`
16
+ (function() {
17
+ const btn = document.querySelector('.m-playbar .icn-list, .m-playbar [class*="playlist"], [data-action="playlist"], .m-playbar .btnlist');
18
+ if (btn) btn.click();
19
+ })()
20
+ `);
21
+
22
+ await page.wait(1);
23
+
24
+ const items = await page.evaluate(`
25
+ (function() {
26
+ const results = [];
27
+ // Playlist panel items
28
+ const rows = document.querySelectorAll('.m-playlist li, [class*="playlist-panel"] li, .listlyric li, .j-playlist li');
29
+
30
+ rows.forEach((row, i) => {
31
+ const nameEl = row.querySelector('.name, [class*="name"], a, span:first-child');
32
+ const artistEl = row.querySelector('.by, [class*="artist"], .ar');
33
+
34
+ const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : (row.textContent || '').trim();
35
+ const artist = artistEl ? (artistEl.textContent || '').trim() : '';
36
+
37
+ if (title && title.length > 0) {
38
+ results.push({ Index: i + 1, Title: title.substring(0, 80), Artist: artist });
39
+ }
40
+ });
41
+
42
+ return results;
43
+ })()
44
+ `);
45
+
46
+ if (items.length === 0) {
47
+ return [{ Index: 0, Title: 'Playlist is empty or panel not open', Artist: '—' }];
48
+ }
49
+ return items;
50
+ },
51
+ });
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const prevCommand = cli({
5
+ site: 'neteasemusic',
6
+ name: 'prev',
7
+ description: 'Go back to the previous song',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Status'],
13
+ func: async (page: IPage) => {
14
+ const clicked = await page.evaluate(`
15
+ (function() {
16
+ const btn = document.querySelector('.m-playbar .btnbak, .m-playbar [class*="prev"], .m-player .btn-prev, [data-action="prev"]');
17
+ if (btn) { btn.click(); return true; }
18
+ return false;
19
+ })()
20
+ `);
21
+
22
+ if (!clicked) {
23
+ await page.pressKey('Control+ArrowLeft');
24
+ }
25
+
26
+ await page.wait(1);
27
+ return [{ Status: 'Went to previous song' }];
28
+ },
29
+ });