JS 刮削源接入
JS 刮削源是在 Aduoer App 内本地运行的单文件 JavaScript 插件。它接收歌曲或艺人信息,通过 App 提供的 fetch 查询第三方服务,并返回统一格式的歌曲候选或艺人资料
刮削源适合补充以下内容:
- 歌曲标题、歌手、专辑
- 专辑封面 URL
- 逐行/逐字歌词、翻译、罗马音
- 独立艺人头像、背景图和简介
歌曲刮削和艺人刮削是同一种 JS 刮削源的两类能力,共用安装方式、请求约定和运行环境,脚本可以实现其中一种,也可以同时实现两种
刮削源不等同于 Wow 音乐源。Wow 是需要独立部署的完整音乐服务,JS 刮削源负责为已有歌曲或艺人查找资料候选
安全提示
脚本由用户主动添加并在本机执行。只安装你信任的脚本,并认真检查脚本声明的 @match 网络域名。Aduoer 会限制脚本的网络范围和资源用量,但 JavaScriptCore 不是用于运行恶意代码的完整安全沙箱
快速开始
创建一个 UTF-8 编码的 .js 文件,按需要复制下方的歌曲刮削示例或艺人刮削示例,替换为实际使用的 API 地址和字段映射
在 Aduoer 中打开“设置 → 服务接口 → 刮削源”,点击右上角添加按钮:
- 选择“本地”,导入
.js文件;或选择“网络”,填写可以直接下载脚本的 HTTP(S) URL - App 解析可选头部、检查 JavaScript 语法和声明能力对应的入口
- 检查列表中显示的名称、脚本版本、更新时间和网络域名
- Pro 用户可启用脚本并调整顺序;免费用户可以添加和维护脚本,但不能启用或执行
网络来源通过原 URL 更新;本地来源可以在 App 编辑器或 Files App 中修改。更新头部的 @version 便于用户识别脚本版本
文件要求
- 一个来源只能包含一个
.js文件 - 文件必须使用 UTF-8,最大约 512 KiB
- 不支持
import、require或外部模块 - 不提供 DOM、
window、Node.js API 或文件系统 API - JSDoc 头部可选;如果提供,必须位于文件开头且内容合法。歌曲能力需定义全局
scrape方法,artist能力需定义全局scrapeArtist方法 - App 安装或更新脚本时会检查头部、JavaScript 语法和声明能力对应的入口,不会执行刮削入口或发起脚本的网络请求
头部声明
JSDoc 头部不是添加脚本的必要条件。完全省略头部时,Aduoer 使用以下默认信息:
- 名称:
Untitled - 版本:
none - 能力:
metadata、artwork、lyrics - 网络白名单:空
因此,无头脚本可以进行本地计算,但任何 fetch 请求都会被拒绝。需要访问网络时必须添加完整头部并通过 @match 明确授权目标地址
完整示例
/**
* @name xx音乐
* @version 2026.09.04
* @description 查询歌曲元数据和歌词
* @icon https://example.com/favicon.ico
* @capability metadata
* @capability artwork
* @capability lyrics
* @match https://example.com/*
* @match https://a.example.com/*
*/属性说明
| 属性 | 头部内必填 | 可重复 | 说明 |
|---|---|---|---|
@name | 是 | 否 | 列表中显示的脚本名称 |
@version | 是 | 否 | 脚本自身版本,由脚本维护者管理,与 App 版本无关 |
@capability | 是 | 是 | 脚本可返回的内容类型,至少声明一项 |
@match | 是 | 是 | 脚本允许请求的 URL 范围,至少声明一项 |
@description | 否 | 否 | 脚本用途的简短说明 |
@icon | 否 | 否 | HTTP(S) 图标地址 |
只解析文件开头的第一段 /** ... */。如果提供头部,单值属性重复、必填属性缺失或非法 match 会让脚本不可用。未知属性和当前 App 不认识的 @capability 值会被忽略,不影响已知能力的使用
@capability
每项能力单独写一行:
* @capability metadata
* @capability artwork
* @capability lyrics支持的值:
| 能力 | 入口 | 可返回字段 |
|---|---|---|
metadata | scrape(request) | 歌曲 title、artist、album,以及匹配信息 isrc、durationMs |
artwork | scrape(request) | 歌曲/专辑封面 coverUrl |
lyrics | scrape(request) | lyrics.original、lyrics.translation、lyrics.romanized |
artist | scrapeArtist(request) | 艺人 name、aliases、providerIds、avatar、backgroundUrl、description、genres |
前三项属于歌曲刮削,artist 属于艺人刮削,仅能提供艺人头像的脚本也声明 artist,歌曲的 artwork 不代表艺人刮削能力
当前 App 只校验和调用它认识的能力对应的入口,新增能力与已有能力可以同时声明,未知能力不会导致整个来源不可用。如果所有声明的能力都不认识,脚本仍可导入、更新和同步,但不参与当前版本的刮削任务,也不会被自动当作歌曲刮削源
未知能力的声明仍保留在原始脚本中,支持该能力的客户端可正常识别。JavaScript 语法、已知能力的入口和网络白名单仍需通过校验
Aduoer 会根据当前缺失字段跳过不相关来源。例如只缺歌词时,不调用仅声明 metadata 的脚本。脚本返回未声明能力对应的字段时,App 可以忽略这些字段
主歌词直接声明在 lyrics.original 中,APP 会自动判断是否为逐行或逐字歌词
@match
@match 是强制网络白名单,不是页面匹配规则。脚本只能通过 fetch 请求这里声明的地址:
* @match https://api.example.com/*
* @match *://*.example.net/v1/*规则:
- 必须包含 scheme、host 和 path
- scheme 支持
http、https或*;*只代表 HTTP 和 HTTPS - host 可使用左侧子域通配,例如
*.example.com;它同时匹配根域和子域,但不会匹配badexample.com - path 可使用
*匹配零个或多个字符 - 每次 HTTP 重定向后的目标也必须匹配,否则请求失败
file:、data:、blob:、loopback、.local、link-local 和私有网段始终禁止,即使模式看起来匹配
按最小权限填写域名。不要为了省事使用过宽的模式。候选中的 coverUrl、avatar 和 backgroundUrl 由 App 图片管线加载,不会赋予脚本访问该域名的权限;脚本自己获取图片或接口数据时仍需要对应 @match
刮削入口
按声明的能力实现对应全局方法,两类入口均返回包含 candidates 数组的对象
// 歌曲刮削:声明 metadata、artwork、lyrics 中的任意一项
async function scrape(request) {
return { candidates: [] };
}
// 艺人刮削:声明 artist
async function scrapeArtist(request) {
return { candidates: [] };
}仅支持艺人的脚本可以只实现 scrapeArtist,同时支持两类刮削的脚本分别实现两个入口,App 按任务类型调用,不会用歌曲候选中的封面替代独立艺人资料
入口可以返回普通对象或 Promise,抛出异常或返回 rejected Promise 时,App 只向用户显示 error.message,不会显示 JavaScript 堆栈
throw new Error(`Search failed with HTTP ${response.status}`);请使用简短的错误说明,不要包含 Cookie、Token、完整响应正文或其他敏感内容
歌曲刮削示例
以下示例声明歌曲元数据、封面和歌词能力,通过 scrape(request) 返回歌曲候选
/**
* @name 示例歌曲刮削源
* @version 1.0.0
* @description 从示例 API 查询歌曲信息
* @icon https://api.example.com/favicon.ico
* @capability metadata
* @capability artwork
* @capability lyrics
* @match https://api.example.com/*
*/
async function scrape(request) {
const keyword = [request.input.title, request.input.artist]
.filter(Boolean)
.join(' ') || request.input.fileName;
// 示例 API 接受客户端语言,参数映射和语言回退由脚本处理
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(keyword)}&language=${encodeURIComponent(request.language)}`,
{
method: 'GET',
headers: { Accept: 'application/json' }
}
);
if (!response.ok) {
throw new Error(`Search failed with HTTP ${response.status}`);
}
const data = await response.json();
return {
candidates: (data.items || []).slice(0, 5).map((item) => ({
sourceId: String(item.id),
title: String(item.title || ''),
artist: String(item.artist || ''),
album: String(item.album || ''),
coverUrl: item.coverUrl || null,
isrc: item.isrc || null,
durationMs: item.durationMs || null,
lyrics: item.lyrics ? {
original: item.lyrics.original || null,
translation: item.lyrics.translation || null,
romanized: item.lyrics.romanized || null
} : null
}))
};
}艺人刮削示例
/**
* @name 示例艺人资料源
* @version 1.0.0
* @capability artist
* @match https://api.example.com/*
*/
async function scrapeArtist(request) {
// 示例 API 接受 BCP 47 语言标记,其他上游的参数映射和语言回退由脚本处理
const response = await fetch(
`https://api.example.com/artists/search?name=${encodeURIComponent(request.input.name)}&language=${encodeURIComponent(request.language)}`
);
if (!response.ok) throw new Error(`艺人查询失败:HTTP ${response.status}`);
const data = await response.json();
return {
candidates: (data.items || []).slice(0, 5).map(item => ({
sourceId: String(item.id),
name: item.name,
aliases: item.aliases || [],
providerIds: item.musicBrainzId ? { musicbrainz: item.musicBrainzId } : {},
avatar: item.avatar || null,
backgroundUrl: item.backgroundUrl || null,
description: item.description || null,
genres: item.genres || []
}))
};
}请求参数
请求对象
歌曲和艺人请求使用相同的外层字段,只有 input 类型不同
type ScrapeRequest<TInput> = {
aduoerVersion: string;
requestId: string;
language: string;
input: TInput;
};
type SongScrapeRequest = ScrapeRequest<ScrapeInput>;
type ArtistScrapeRequest = ScrapeRequest<ArtistScrapeInput>;| 字段 | 类型 | 说明 |
|---|---|---|
aduoerVersion | string | 当前运行的 Aduoer App 版本 |
requestId | string | 本次调用的临时标识,可用于关联查询日志 |
language | string | 客户端语言,优先使用 App 语言设置,跟随系统时使用系统首选语言 |
input | ScrapeInput 或 ArtistScrapeInput | 当前任务的搜索和匹配信息 |
language 使用 BCP 47 风格标记,例如 en、ja、de-DE、zh-Hans、zh-Hant,脚本自行映射为上游支持的语言参数,并自行决定缺少对应语言内容时的回退策略,App 不会替换语言后再次调用 JS 入口,也不要求响应声明内容语言
请求和响应没有独立的协议版本字段,App 不会向脚本暴露调用场景、自动/手动用途、缺失字段、候选数量上限、音乐源 ID、本机文件路径、用户账号、CloudKit 标识或鉴权凭据
歌曲 input
type ScrapeInput = {
fileName: string;
title: string | null;
artist: string | null;
album: string | null;
albumArtist: string | null;
isrc: string | null;
durationMs: number;
fileSize: number | null;
codec: string | null;
format: string | null;
};| 字段 | 说明 |
|---|---|
fileName | 原始或合成的文件名搜索依据,始终为字符串 |
title | 已知歌曲标题 |
artist | 已知歌手,多歌手使用英文逗号分隔 |
album | 已知专辑名称 |
albumArtist | 已知专辑歌手,多歌手使用英文逗号分隔 |
isrc | 已知 ISRC,可能为空 |
durationMs | 时长,单位毫秒,未知时为 0 |
fileSize | 文件大小,单位字节,仅部分文件来源提供 |
codec | 编解码器名称,例如 flac、aac |
format | 容器或文件格式 |
不要把 null 直接传给只接受字符串的第三方接口,推荐搜索词回退
const keyword = [request.input.title, request.input.artist]
.filter(Boolean)
.join(' ') || request.input.fileName;艺人 input
type ArtistScrapeInput = {
name: string;
aliases: string[];
providerIds: Record<string, string>;
album?: string;
trackTitle?: string;
};| 字段 | 说明 |
|---|---|
name | 本地艺人名称 |
aliases | 已确认的别名,可能为空数组 |
providerIds | 已确认的外部 ID,可能包含 applemusic、itunes、musicbrainz、theaudiodb、wikidata 和 js:<来源 UUID>,可能为空对象 |
album | 代表歌曲的专辑名,可用于同名艺人消歧,缺失时省略 |
trackTitle | 代表歌曲标题,可用于消歧,缺失时省略 |
响应
响应对象
type ScrapeResponse<TCandidate> = {
candidates: TCandidate[];
};
type SongScrapeResponse = ScrapeResponse<ScrapeCandidate>;
type ArtistScrapeResponse = ScrapeResponse<ArtistScrapeCandidate>;两个入口都必须返回包含 candidates 的对象,没有结果时返回空数组,不要直接返回数组、单个候选或 JSON 字符串
return { candidates: [] };Candidate
歌曲候选 ScrapeCandidate
type ScrapeCandidate = {
sourceId: string;
title: string;
artist: string;
album: string;
coverUrl?: string | null;
isrc?: string | null;
durationMs?: number | null;
lyrics?: CandidateLyrics | null;
};
type CandidateLyrics = {
original?: string | null;
translation?: string | null;
romanized?: string | null;
};| 字段 | 必填 | 说明 |
|---|---|---|
sourceId | 是 | 第三方 provider 内稳定、非空的歌曲 ID |
title | 是 | 非空歌曲标题 |
artist | 是 | 歌手,未知时返回空字符串,多歌手使用英文逗号分隔 |
album | 是 | 专辑,未知时返回空字符串 |
coverUrl | 否 | HTTP(S) 专辑封面 URL |
isrc | 否 | ISRC,App 会移除分隔符并转为大写 |
durationMs | 否 | 非负有限数字,单位毫秒 |
lyrics | 否 | 歌词对象,没有歌词时省略或返回 null |
歌词字段
| 字段 | 内容 |
|---|---|
original | 原文歌词,支持逐行 LRC 和逐字时间轴文本 |
translation | 与原文时间轴对应的翻译歌词 |
romanized | 罗马音歌词 |
将 QRC、YRC、增强 LRC 等逐字时间轴文本放入 lyrics.original,Aduoer 会识别其格式并转换为逐字歌词
艺人候选 ArtistScrapeCandidate
type ArtistScrapeCandidate = {
sourceId: string;
name: string;
aliases?: string[] | null;
providerIds?: Record<string, string> | null;
avatar?: string | null;
backgroundUrl?: string | null;
description?: string | null;
genres?: string[] | null;
};| 字段 | 必填 | 说明 |
|---|---|---|
sourceId | 是 | 第三方 provider 内稳定、非空的艺人 ID |
name | 是 | 非空艺人名 |
aliases | 否 | 同一艺人的真实别名,不要将任意搜索词填作别名 |
providerIds | 否 | 已确认的跨来源艺人 ID,不接受推测的 ID |
avatar | 否 | 独立艺人头像的 HTTP(S) URL,不应填入专辑封面 |
backgroundUrl | 否 | 艺人背景图的 HTTP(S) URL |
description | 否 | 纯文本简介,不含 HTML,直接返回字符串,缺失时省略或返回 null,最大约 2 MiB |
genres | 否 | 风格名称数组 |
artist 能力覆盖以上字段,脚本按上游实际能力部分返回,简介使用顶层 description,无需返回嵌套对象或语言字段
候选匹配与限制
- 脚本最多返回 5 个最相关候选,App 只处理前 5 项,整个响应最大约 5 MiB
- 歌曲候选由 App 按标题、歌手、专辑、ISRC 和时长评分,评分相同时保留脚本推荐顺序,脚本不要返回
confidence或伪造provider - 艺人候选按名称、别名精确匹配或已知外部 ID 匹配接纳,已有 ID 冲突时拒绝,采用第一个匹配的候选,脚本应自行消歧并把最准确的结果放在前面
- 未知 JSON 字段会被忽略,不影响反序列化,包括旧脚本仍返回的
artistCovers,客户端不读取或保存该字段 - 已定义字段的类型错误、非有限数值或响应结构错误会使脚本停用
fetch API
Aduoer 注入的 fetch 支持 Promise,因此可以使用 await 或 then/catch:
fetch('https://api.example.com/search')
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => ({ candidates: data.items }))
.catch((error) => {
throw new Error(`Example provider failed: ${error.message}`);
});请求签名:
fetch(url, {
method?: string,
headers?: Record<string, string> | Array<[string, string]>,
body?: string | object | ArrayBuffer | ArrayBufferView | null,
signal?: AbortSignal
}): Promise<Response>普通对象 Body 会自动编码为 JSON;未设置时自动补充 Content-Type: application/json; charset=utf-8
Response 提供:
response.ok: boolean
response.status: number
response.url: string
response.headers: Headers
response.text(): Promise<string>
response.json(): Promise<unknown>
response.arrayBuffer(): Promise<ArrayBuffer>HTTP 4xx 和 5xx 会正常 resolve,和浏览器 fetch 一样,应由脚本检查 response.ok。网络失败、取消、白名单拒绝或资源超限才会 reject
请求头
可以设置 Accept、Content-Type、Referer、User-Agent 和第三方 API 所需的普通请求头。不能设置 Host、Content-Length、Connection、代理相关头或 Cookie 相关受保护头
Aduoer 使用临时网络会话,不共享 Safari 或 App 的 Cookie、缓存和系统凭据。首版没有 cookie jar
取消
const controller = new AbortController();
const request = fetch('https://api.example.com/slow', {
signal: controller.signal
});
controller.abort();
await request; // reject: The operation was aborted.关闭手动匹配页、切换歌曲或 provider 超时也会取消尚未结束的 fetch
资源限制
- 每次
scrape或scrapeArtist最多调用 16 次 fetch - 单个响应最大约 5 MiB
- HTTP 重定向最多 5 次,且每次都重新检查
@match - 每个 JS 源的一次调用最多执行 10 秒;自动和手动模式统一限制,来源之间不共享计时
- 超时后 App 会停止等待结果、取消尚未结束的 fetch 并停用该来源;这不是 JavaScriptCore 死循环线程的强制 kill
- 不支持流式 Body、Blob、FormData 和 cookie jar
不要在一个调用中为大量候选逐个串行请求详情。优先使用批量接口,或只处理最相关的 5 个候选并合理并发
调用流程
歌曲刮削
自动及批量刮削分为元数据阶段和歌词回退阶段:
已启用 JS 来源(用户排序)
→ Apple Music
→ iTunes
→ MusicBrainz
→ 缺少歌词时,LRCLIB 使用已补齐的歌曲信息查询歌词每个来源应用结果后,App 会重新计算缺失字段;在元数据阶段接受到结果后,后续来源和 LRCLIB 会使用更新后的标题、歌手、专辑、时长和 ISRC 查询。标题、歌手、封面和歌词等本次目标全部满足后,流程立即结束,不再调用后续来源。因此脚本应只返回真实可靠的字段,不要用占位文字伪装已补齐
手动完整元数据匹配会并发调用可用的 JS 来源、Apple Music、iTunes 和 MusicBrainz,不调用 LRCLIB。手动歌词搜索会并发调用声明 lyrics 能力的 JS 来源和 LRCLIB。某个来源返回后,它的候选立即展示、去重和评分,不会等待较慢来源;其他来源仍可继续加载并追加结果
艺人刮削
App 按用户排序调用已启用且声明 artist 的 JS 刮削源,再由内建艺人来源补齐缺失资料,来源之间只补充尚未取得的字段
JS 入口始终接收客户端 language,脚本自行完成语言适配后返回 description,App 按请求语言缓存结果,切换客户端语言后可重新查询
错误处理
以下错误会使来源停用
- 头部声明、JavaScript 语法或声明能力对应的入口错误
- 返回值不符合响应结构
- 输出字段类型错误或超过资源限制
- provider 达到独立的 10 秒总超时