音游歌曲批量导入网易云

在中二上玩了一批曲子,想整份搬进网易云听。手动加几首还行,曲目一多就完全没法干了。于是拆成两步:先拿到玩过的曲名列表,再用脚本在网易云 Web 端自动「搜索 → 收藏 → 丢进歌单」。

整件事cursor大概两轮对话就跑通了。下面按过程写,最后补一点方案对比和感想。


先看一眼:手动加一首要几步

在中二成绩页复制歌曲名:

QQ20260717-102214.png

切到网易云 Web:

  1. 搜索框粘贴歌名
  2. 点搜索
  3. 点结果行的收藏
  4. 在弹层里选目标歌单(我这里叫「中二」)

QQ20260717-102037.png

打过的歌不少,不可能一首首点。问题就很清楚了:

步骤做什么
1拿到中二玩过的曲名列表
2在网易云上批量完成「搜索 + 加入歌单」

第一步:拿到玩过的曲名列表

先问了一下 GPT,得到一条很省事的路线:用查分器代理导入成绩。
image-20260717103840688.png

官方文档在这:
https://maimai.diving-fish.com/manual/docs/update-records/proxy

按文档用代理软件导入,完成后导出 CSV:

image-20260717104120857.png

打开 CSV:

image-20260717104240111.png

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

image-20260717104303586.png

查分器本体是开源项目 maimaidx-prober,后面技术分析里再提一眼它的流程。


第二步:网易云 Web 上用脚本批量收藏

网上搜了一圈,客户端没有「按列表一键导入」这类能力。我对 Web 比较熟,就直接走控制台跑 JS,模拟点击。

1. 抠关键 DOM

分类找了几块 HTML:搜索框、搜索按钮、结果行收藏、弹层里的歌单列表。

image-20260717104834302.png
image-20260717105055762.png
image-20260717105135441.png

不必整页甩给 AI。网易云搜索页 HTML 上万行,token 和时间都浪费;只喂「操作链路上那几块」就够写选择器了。

2. 提示词大概长这样

····(省略粘贴的 HTML 块)

我有一个音乐列表,需要你自动在搜索框输入歌名,点击搜索,
再点击收藏按钮,点击弹出窗口中的目标收藏列表「中二」完成收藏。
写一个 JS 脚本给我,我直接在页面上运行。
我还需要提供什么?

image-20260717105259450.png

3. 小改两刀就够用

第一版歌单是 JS 数组。我实际粘贴的是「一行一个歌名」的大段文本,就让它改成按行 split

image-20260717105401059.png
image-20260717105431534.png

4. 控制台跑起来

打开 music.163.com,登录,F12 → Console,粘贴脚本回车,效果演示:

动画.gif


踩坑:找不到搜索框

第一次失败报错:找不到搜索框 #m-search-input。元素明明在页面上,原因是 DevTools 控制台挂在了子 frame(堆栈里能看到 music-corona.min.js),子文档里没有顶栏搜索框。

处理方式二选一即可:

  1. 控制台左上角把 frame 切到 top / music.163.com
  2. 脚本里从 window.top 遍历可访问的 iframe,跨文档 querySelector

第二轮对话把跨 frame 查找补上之后,流程就稳了。


技术侧:这件事本质上在干什么

表面上是「点网页」,底下还是浏览器里的 HTTP 请求 + DOM 渲染。页面是引擎画出来的;自动化既可以点 DOM,也可以直接打接口,只是成本和风险不一样。

image-20260717110421937.png

曲名从哪来:查分器在干什么

项目:Diving-Fish/maimaidx-prober

用别人写好的轮子:代理抓成绩、上传、再导出。项目流程大致如下:

image-20260717114323739.png

想快速摸清仓库结构,也可以丢给 DeepWiki 一类工具辅助看:
https://deepwiki.com/Diving-Fish/maimaidx-prober

批量进网易云:几条路怎么选

方案做法我怎么看
UI 自动化(Web 控制台脚本)模拟搜索 / 收藏点击选了这条:会一点前端就能写,改起来快
客户端 / 手机端按键精灵类坐标或控件点击能用,但不熟,调试成本高
逆向网易云 API搜歌 + 加歌单接口直打Web 有加密,虽有轮子,对这个一次性需求不划算

image-20260717104724046.png

懒人最优解往往是:用现成轮子解决「数据从哪来」,用 AI 补「最后一公里自动化」。


几个问答

Q:为什么不把整个网易云页面丢给 AI?
没必要。只给操作链路上必要的 HTML。整页一万七千多行,又贵又慢。

image-20260717104953374.png

Q:AI 一次就能写对吗?
主流程一次对话基本就出来了;第二次主要是 iframe 作用域问题,属于环境坑,不是需求没说清。

Q:脚本稳不稳?
能跑,但不完美。同名曲、搜不到、收藏弹层结构变化,都还没认真处理,文末会提。


写在最后

好轮子解决主路径,AI 补最后一公里。
我这边有效对话其实就一轮半:第一轮写出脚本,第二轮修 frame。关键不是「会不会写 JS」,而是知道最小输入是什么。这里是关键 DOM 片段 + 操作步骤,喂准了,目标就达得成。

AI 写脚本也有烦的地方:防御性代码偏多,一行能搞定的事经常铺成五六行。以前有点强迫症的我会受不了。后来想起码农高天那个比方:程序员有点像盖楼的人,房子盖得好,住进去载歌载舞时没人记得你;一旦出事,挨骂的一定是你。

我也有过「下班回家优化两个小时,最后还是退回能跑那一版」的经历。不是说别追求质量,而是质量要花在别人能感知、懂行的人能看懂的地方。对这种一次性小脚本,能跑、可复现、知道边界,往往比「写得漂亮」更值钱。「代码和人,有一个能跑就行」,这句话的含金量还在涨。

脚本本身也还有完善空间,例如:

  • 怎么确认搜到的就是目标曲(同名、翻唱、伴奏)
  • 搜不到时如何记录失败列表
  • 收藏成功有没有可靠反馈

本文更多是抛砖引玉:展示一条「查分器导出 + Web 自动化」的短路径,以及怎么用最少上下文驱动 AI 干活。

写 blog 本身也是复盘。把过程写清楚,往往比把事做完还多花一层时间,很少有时间可以复盘总结,还是要鞭策自己养成输出的习惯。


参考

附录

用到的工具

  • 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();
})();

标签: none

添加新评论