音游歌曲批量导入网易云
音游歌曲批量导入网易云
在中二上玩了一批曲子,想整份搬进网易云听。手动加几首还行,曲目一多就完全没法干了。于是拆成两步:先拿到玩过的曲名列表,再用脚本在网易云 Web 端自动「搜索 → 收藏 → 丢进歌单」。
整件事cursor大概两轮对话就跑通了。下面按过程写,最后补一点方案对比和感想。
先看一眼:手动加一首要几步
在中二成绩页复制歌曲名:

切到网易云 Web:
- 搜索框粘贴歌名
- 点搜索
- 点结果行的收藏
- 在弹层里选目标歌单(我这里叫「中二」)

打过的歌不少,不可能一首首点。问题就很清楚了:
| 步骤 | 做什么 |
|---|---|
| 1 | 拿到中二玩过的曲名列表 |
| 2 | 在网易云上批量完成「搜索 + 加入歌单」 |
第一步:拿到玩过的曲名列表
先问了一下 GPT,得到一条很省事的路线:用查分器代理导入成绩。
官方文档在这:
https://maimai.diving-fish.com/manual/docs/update-records/proxy
按文档用代理软件导入,完成后导出 CSV:

打开 CSV:

稍微清洗一下(去重、只要歌名列),就得到全部曲名:

查分器本体是开源项目 maimaidx-prober,后面技术分析里再提一眼它的流程。
第二步:网易云 Web 上用脚本批量收藏
网上搜了一圈,客户端没有「按列表一键导入」这类能力。我对 Web 比较熟,就直接走控制台跑 JS,模拟点击。
1. 抠关键 DOM
分类找了几块 HTML:搜索框、搜索按钮、结果行收藏、弹层里的歌单列表。



不必整页甩给 AI。网易云搜索页 HTML 上万行,token 和时间都浪费;只喂「操作链路上那几块」就够写选择器了。
2. 提示词大概长这样
····(省略粘贴的 HTML 块)
我有一个音乐列表,需要你自动在搜索框输入歌名,点击搜索,
再点击收藏按钮,点击弹出窗口中的目标收藏列表「中二」完成收藏。
写一个 JS 脚本给我,我直接在页面上运行。
我还需要提供什么?
3. 小改两刀就够用
第一版歌单是 JS 数组。我实际粘贴的是「一行一个歌名」的大段文本,就让它改成按行 split:


4. 控制台跑起来
打开 music.163.com,登录,F12 → Console,粘贴脚本回车,效果演示:

踩坑:找不到搜索框
第一次失败报错:找不到搜索框 #m-search-input。元素明明在页面上,原因是 DevTools 控制台挂在了子 frame(堆栈里能看到 music-corona.min.js),子文档里没有顶栏搜索框。
处理方式二选一即可:
- 控制台左上角把 frame 切到 top / music.163.com
- 脚本里从
window.top遍历可访问的 iframe,跨文档querySelector
第二轮对话把跨 frame 查找补上之后,流程就稳了。
技术侧:这件事本质上在干什么
表面上是「点网页」,底下还是浏览器里的 HTTP 请求 + DOM 渲染。页面是引擎画出来的;自动化既可以点 DOM,也可以直接打接口,只是成本和风险不一样。

曲名从哪来:查分器在干什么
项目:Diving-Fish/maimaidx-prober
用别人写好的轮子:代理抓成绩、上传、再导出。项目流程大致如下:

想快速摸清仓库结构,也可以丢给 DeepWiki 一类工具辅助看:
https://deepwiki.com/Diving-Fish/maimaidx-prober
批量进网易云:几条路怎么选
| 方案 | 做法 | 我怎么看 |
|---|---|---|
| UI 自动化(Web 控制台脚本) | 模拟搜索 / 收藏点击 | 选了这条:会一点前端就能写,改起来快 |
| 客户端 / 手机端按键精灵类 | 坐标或控件点击 | 能用,但不熟,调试成本高 |
| 逆向网易云 API | 搜歌 + 加歌单接口直打 | Web 有加密,虽有轮子,对这个一次性需求不划算 |

懒人最优解往往是:用现成轮子解决「数据从哪来」,用 AI 补「最后一公里自动化」。
几个问答
Q:为什么不把整个网易云页面丢给 AI?
没必要。只给操作链路上必要的 HTML。整页一万七千多行,又贵又慢。

Q:AI 一次就能写对吗?
主流程一次对话基本就出来了;第二次主要是 iframe 作用域问题,属于环境坑,不是需求没说清。
Q:脚本稳不稳?
能跑,但不完美。同名曲、搜不到、收藏弹层结构变化,都还没认真处理,文末会提。
写在最后
好轮子解决主路径,AI 补最后一公里。
我这边有效对话其实就一轮半:第一轮写出脚本,第二轮修 frame。关键不是「会不会写 JS」,而是知道最小输入是什么。这里是关键 DOM 片段 + 操作步骤,喂准了,目标就达得成。
AI 写脚本也有烦的地方:防御性代码偏多,一行能搞定的事经常铺成五六行。以前有点强迫症的我会受不了。后来想起码农高天那个比方:程序员有点像盖楼的人,房子盖得好,住进去载歌载舞时没人记得你;一旦出事,挨骂的一定是你。
我也有过「下班回家优化两个小时,最后还是退回能跑那一版」的经历。不是说别追求质量,而是质量要花在别人能感知、懂行的人能看懂的地方。对这种一次性小脚本,能跑、可复现、知道边界,往往比「写得漂亮」更值钱。「代码和人,有一个能跑就行」,这句话的含金量还在涨。
脚本本身也还有完善空间,例如:
- 怎么确认搜到的就是目标曲(同名、翻唱、伴奏)
- 搜不到时如何记录失败列表
- 收藏成功有没有可靠反馈
本文更多是抛砖引玉:展示一条「查分器导出 + Web 自动化」的短路径,以及怎么用最少上下文驱动 AI 干活。
写 blog 本身也是复盘。把过程写清楚,往往比把事做完还多花一层时间,很少有时间可以复盘总结,还是要鞭策自己养成输出的习惯。
参考
- maimaidx-prober:https://github.com/Diving-Fish/maimaidx-prober
- 查分器代理导入文档:https://maimai.diving-fish.com/manual/docs/update-records/proxy
- AI 分析仓库:https://deepwiki.com/Diving-Fish/maimaidx-prober
附录
用到的工具
- Reqable(抓包 / 代理相关流程里用到)
- Cursor(写脚本、改脚本)
- 浏览器 DevTools Console
JS 脚本
(() => {
// ========== 直接粘贴歌单:一行一个歌名 ==========
const SONG_TEXT = `
Campus mode!!
`;
const SONG_LIST = SONG_TEXT.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
const PLAYLIST_NAME = "中二"; // 收藏目标歌单名
const DELAY = {
afterType: 400,
afterSearch: 1800,
afterFav: 800,
betweenSongs: 1000,
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 网易云页面常把内容放在 iframe,需跨 frame 查找 */
function collectDocs(win = window, out = []) {
try {
if (win.document) out.push(win.document);
} catch (_) { /* cross-origin */ }
try {
for (let i = 0; i < win.frames.length; i++) {
collectDocs(win.frames[i], out);
}
} catch (_) { /* cross-origin */ }
return out;
}
function docs() {
const list = collectDocs(window.top || window);
// 当前 console 所在 frame 也可能有元素
if (!list.includes(document)) list.unshift(document);
return list;
}
function q(selector) {
for (const d of docs()) {
const el = d.querySelector(selector);
if (el) return el;
}
return null;
}
function qq(selector) {
const out = [];
for (const d of docs()) {
out.push(...d.querySelectorAll(selector));
}
return out;
}
function setInputValue(input, value) {
const proto = (input.ownerDocument.defaultView || window).HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "a" }));
}
function findSearchInput() {
return (
q("#m-search-input") ||
q("input.srch") ||
q(".pgsrch input[type=text]")
);
}
function clickSearch(input) {
const root = input?.closest?.(".pgsrch") || null;
const btn =
(root && root.querySelector("a.btn[title='搜索'], a.btn")) ||
q(".pgsrch a.btn[title='搜索']") ||
q(".pgsrch a.btn");
if (!btn) throw new Error("找不到搜索按钮");
btn.click();
}
function getFirstSongRow() {
return (
q("#m-search .srchsongst .item") ||
q(".n-srcrt .srchsongst .item") ||
q(".srchsongst .item") ||
q(".m-search .item") ||
qq(".item.f-cb.h-flag, .srchsongst .item").find((el) =>
el.querySelector("[data-res-action='fav'], .icn-fav, a[href*='/song?id=']")
) ||
null
);
}
function clickFavInRow(row) {
const fav =
row.querySelector("span.icn-fav") ||
row.querySelector("[data-res-action='fav']");
if (!fav) throw new Error("找不到收藏按钮");
fav.click();
}
function clickPlaylistByName(name) {
const candidates = qq(
".m-layer .item, .n-layer .item, .zdlg .item, .u-collect .item, .m-addto .item, .sysmsg .item"
);
const all = candidates.length
? candidates
: qq(".item").filter((el) => el.querySelector(".name, p.name, a.s-fc0"));
const hit = all.find((el) => {
const text = (el.querySelector(".name") || el).textContent || "";
return text.includes(name);
});
if (!hit) {
const listItems = all.filter((el) => !/创建新歌单|新建/.test(el.textContent || ""));
if (listItems[1]) {
console.warn(
`未找到名为「${name}」的歌单,改为点击第 2 个:`,
listItems[1].textContent.trim()
);
(listItems[1].querySelector("a") || listItems[1]).click();
return;
}
throw new Error(`找不到歌单「${name}」,且列表不足 2 个`);
}
(hit.querySelector("a") || hit).click();
}
async function searchAndFav(keyword) {
const input = findSearchInput();
if (!input) {
throw new Error(
"找不到搜索框 #m-search-input。请在控制台顶部把 frame 切到 top(或包含搜索框的那一帧)后再运行"
);
}
input.focus();
setInputValue(input, keyword);
await sleep(DELAY.afterType);
input.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
})
);
clickSearch(input);
await sleep(DELAY.afterSearch);
const row = getFirstSongRow();
if (!row) throw new Error(`未搜到结果:${keyword}`);
const title = (row.querySelector(".text b, .sn .text") || row).textContent.trim();
console.log(`→ 准备收藏:${title}`);
clickFavInRow(row);
await sleep(DELAY.afterFav);
clickPlaylistByName(PLAYLIST_NAME);
console.log(`✓ 已尝试加入「${PLAYLIST_NAME}」:${keyword}`);
}
async function run() {
const input = findSearchInput();
console.log(
`开始批量收藏,共 ${SONG_LIST.length} 首;搜索框:`,
input ? "已找到" : "未找到",
input
);
if (!input) {
console.error(
"提示:DevTools 控制台左上角有 frame 下拉框,请选 top / music.163.com,不要选到 corona 子 frame"
);
return;
}
for (let i = 0; i < SONG_LIST.length; i++) {
const song = SONG_LIST[i];
console.log(`[${i + 1}/${SONG_LIST.length}] ${song}`);
try {
await searchAndFav(song);
} catch (e) {
console.error(`✗ 失败:${song}`, e);
}
await sleep(DELAY.betweenSongs);
}
console.log("全部处理完毕");
}
run();
})();