Skip to content

JS 刮削源接入 ​

JS 刮削源是在 Aduoer App 内本地运行的单文件 JavaScript 插件。它接收歌曲或艺人信息,通过 App 提供的 fetch 查询第三方服务,并返回统一格式的歌曲候选或艺人资料

刮削源适合补充以下内容:

  • 歌曲标题、歌手、专辑
  • 专辑封面 URL
  • 逐行/逐字歌词、翻译、罗马音
  • 独立艺人头像、背景图和简介

歌曲刮削和艺人刮削是同一种 JS 刮削源的两类能力,共用安装方式、请求约定和运行环境,脚本可以实现其中一种,也可以同时实现两种

刮削源不等同于 Wow 音乐源。Wow 是需要独立部署的完整音乐服务,JS 刮削源负责为已有歌曲或艺人查找资料候选

安全提示

脚本由用户主动添加并在本机执行。只安装你信任的脚本,并认真检查脚本声明的 @match 网络域名。Aduoer 会限制脚本的网络范围和资源用量,但 JavaScriptCore 不是用于运行恶意代码的完整安全沙箱

快速开始 ​

创建一个 UTF-8 编码的 .js 文件,按需要复制下方的歌曲刮削示例或艺人刮削示例,替换为实际使用的 API 地址和字段映射

在 Aduoer 中打开“设置 → 服务接口 → 刮削源”,点击右上角添加按钮:

  1. 选择“本地”,导入 .js 文件;或选择“网络”,填写可以直接下载脚本的 HTTP(S) URL
  2. App 解析可选头部、检查 JavaScript 语法和声明能力对应的入口
  3. 检查列表中显示的名称、脚本版本、更新时间和网络域名
  4. 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 明确授权目标地址

完整示例 ​

js
/**
 * @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 ​

每项能力单独写一行:

js
 * @capability   metadata
 * @capability   artwork
 * @capability   lyrics

支持的值:

能力入口可返回字段
metadatascrape(request)歌曲 title、artist、album,以及匹配信息 isrc、durationMs
artworkscrape(request)歌曲/专辑封面 coverUrl
lyricsscrape(request)lyrics.original、lyrics.translation、lyrics.romanized
artistscrapeArtist(request)艺人 name、aliases、providerIds、avatar、backgroundUrl、description、genres

前三项属于歌曲刮削,artist 属于艺人刮削,仅能提供艺人头像的脚本也声明 artist,歌曲的 artwork 不代表艺人刮削能力

当前 App 只校验和调用它认识的能力对应的入口,新增能力与已有能力可以同时声明,未知能力不会导致整个来源不可用。如果所有声明的能力都不认识,脚本仍可导入、更新和同步,但不参与当前版本的刮削任务,也不会被自动当作歌曲刮削源

未知能力的声明仍保留在原始脚本中,支持该能力的客户端可正常识别。JavaScript 语法、已知能力的入口和网络白名单仍需通过校验

Aduoer 会根据当前缺失字段跳过不相关来源。例如只缺歌词时,不调用仅声明 metadata 的脚本。脚本返回未声明能力对应的字段时,App 可以忽略这些字段

主歌词直接声明在 lyrics.original 中,APP 会自动判断是否为逐行或逐字歌词

@match ​

@match 是强制网络白名单,不是页面匹配规则。脚本只能通过 fetch 请求这里声明的地址:

js
 * @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 数组的对象

js
// 歌曲刮削:声明 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 堆栈

js
throw new Error(`Search failed with HTTP ${response.status}`);

请使用简短的错误说明,不要包含 Cookie、Token、完整响应正文或其他敏感内容

歌曲刮削示例 ​

以下示例声明歌曲元数据、封面和歌词能力,通过 scrape(request) 返回歌曲候选

js
/**
 * @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
    }))
  };
}

艺人刮削示例 ​

js
/**
 * @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 类型不同

ts
type ScrapeRequest<TInput> = {
  aduoerVersion: string;
  requestId: string;
  language: string;
  input: TInput;
};

type SongScrapeRequest = ScrapeRequest<ScrapeInput>;
type ArtistScrapeRequest = ScrapeRequest<ArtistScrapeInput>;
字段类型说明
aduoerVersionstring当前运行的 Aduoer App 版本
requestIdstring本次调用的临时标识,可用于关联查询日志
languagestring客户端语言,优先使用 App 语言设置,跟随系统时使用系统首选语言
inputScrapeInput 或 ArtistScrapeInput当前任务的搜索和匹配信息

language 使用 BCP 47 风格标记,例如 en、ja、de-DE、zh-Hans、zh-Hant,脚本自行映射为上游支持的语言参数,并自行决定缺少对应语言内容时的回退策略,App 不会替换语言后再次调用 JS 入口,也不要求响应声明内容语言

请求和响应没有独立的协议版本字段,App 不会向脚本暴露调用场景、自动/手动用途、缺失字段、候选数量上限、音乐源 ID、本机文件路径、用户账号、CloudKit 标识或鉴权凭据

歌曲 input ​

ts
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 直接传给只接受字符串的第三方接口,推荐搜索词回退

js
const keyword = [request.input.title, request.input.artist]
  .filter(Boolean)
  .join(' ') || request.input.fileName;

艺人 input ​

ts
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代表歌曲标题,可用于消歧,缺失时省略

响应 ​

响应对象 ​

ts
type ScrapeResponse<TCandidate> = {
  candidates: TCandidate[];
};

type SongScrapeResponse = ScrapeResponse<ScrapeCandidate>;
type ArtistScrapeResponse = ScrapeResponse<ArtistScrapeCandidate>;

两个入口都必须返回包含 candidates 的对象,没有结果时返回空数组,不要直接返回数组、单个候选或 JSON 字符串

js
return { candidates: [] };

Candidate ​

歌曲候选 ScrapeCandidate ​

ts
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 ​

ts
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:

js
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}`);
  });

请求签名:

ts
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 提供:

ts
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

取消 ​

js
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 个候选并合理并发

调用流程 ​

歌曲刮削 ​

自动及批量刮削分为元数据阶段和歌词回退阶段:

text
已启用 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 秒总超时

Released under the MIT License.