「Fork The World」 01:Meting API
by Randall · 15 Aug 2026·fork / music
← /u/randall/blog
by Randall · 15 Aug 2026·fork / music
网易云音乐 / QQ 音乐 / 酷狗音乐 / 汽水音乐 / 百度音乐 / 酷我音乐 / YouTube Music / Spotify / Apple Music —— 聚合搜索、歌单、专辑、歌手、音频、封面、逐行与逐字歌词,一份 Worker,一套 REST API。
我想开一个新系列,叫「Fork The World」。
这里的“复刻”不是把别人的网页照着抄一遍,也不是套个差不多的 UI 就宣布完成。对我来说,真正值得复刻的是一个产品背后的能力边界:它怎么组织数据,怎么处理状态,怎么面对失败,又怎么让另一个程序稳定地使用它。
Meting 是这个系列的第一个项目。
选音乐并不是因为它简单,恰恰是因为它足够麻烦:同一首歌在九个平台有九套 ID;搜索结果的字段、顺序和质量完全不同;歌单接口会偷偷分页;音频地址会过期;会员、地区和出口 IP 都可能让同一请求得到不同结果;歌词还有 LRC、YRC、QRC、KRC 和各家的私有格式。
如果我能把这些东西压进一套稳定的模型里,后面的网页、桌面端、手机客户端就不必再理解九个平台。
项目最初基于 Meting-API 和 @meting/core,但走到今天,运行时、协议、缓存、登录、管理后台和大部分平台适配都已经重新做了一遍。出于一些现实原因,我自己的实现仓库现在是 private,这些原因不在文章里展开。这篇也不是源码发布或一键部署教程,只完整记录它是怎么长出来的、里面做了什么,以及我踩过的坑。
线上可以直接看到结果:
一句话:Meting 是一层音乐协议翻译器。
上面接网页、App 或其他服务,下面接九个结构完全不同的音乐平台。客户端只需要认识 track、album、artist、playlist、stream、artwork 和 lyrics,平台差异全部留在服务端解决。
它不是另一个音乐平台,也不保存一份“全网曲库”。元数据、音频可用性和账号权益仍然来自各上游;平台拒绝的资源,它不会假装能播,更不会绕过会员、地区或 DRM 限制。
当前支持的源如下:
这里有一个刻意的取舍:聚合搜索会去重,但不做跨平台备用音源。
网易云的一首歌播放失败,不会背着用户换成 QQ 音乐里“看起来同名”的另一首。不同平台可能是不同母带、不同版本,甚至只是标题相同。我的 API 可以告诉客户端每个平台有什么,但不替用户偷偷做版权和版本选择。
请求路径看起来很短,真正复杂的是每层的职责必须干净:
主要技术选型没有太多花活:
这套东西全部运行在 RandallFlare 的 Workers 数据面上,底层仍然是 workerd。代码保持标准的 Workers 入口,因此换到 Cloudflare Workers 也只需要同样的 bindings 和 nodejs_compat。
type 参数,重做成真正的 REST API#早期 Meting 的调用方式很直接:
/api?server=tencent&type=playlist&id=7713574197
/api?server=netease&type=url&id=347230
/api?server=kugou&type=lrc&id=...
这对一个播放器插件够用,但当它开始承担网页、账号池、缓存和未来手机客户端的后端时,问题就出来了:
type=song 返回的是数组还是对象?playlist 里只有歌曲,还是也应该有封面、介绍、创建人和统计?所以我保留旧 dispatcher 作为内部平台适配层,对外重新设计了 V2:
除了入口和 sources 列表,其他 V2 资源都要求:
Authorization: Bearer <token>
响应统一成三块:
错误也不再是一个随缘的 { error: true },而是 application/problem+json:
/docs 的 Swagger 不是只列路径,而是把成功响应、分页、媒体字节、Range、鉴权和错误结构都写进 OpenAPI 3.1。接口文档本身就是契约,不能只放几个“应该长这样”的示意 JSON。
V1 最后没有继续伪装成可用。/api、/api/v1 和 /demo 统一返回 410 Gone,并通过 Link: rel="successor-version" 指向 V2。删除实现会让旧客户端只看到 404;保留 410,至少能明确告诉它:你找的东西曾经存在,但现在应该迁移了。
最开始我以为聚合搜索就是 Promise.all,后来发现“同时请求九个平台”只完成了最容易的 20%。
真正的搜索链路是:
默认 source=all。每个平台独立计时、独立失败,聚合请求的单源 deadline 是 3.5 秒;只搜一个平台时放宽到 8 秒。某个源连续失败或返回 401 / 403 / 429,会在当前 isolate 内短暂退避,避免一页搜索把已经坏掉的上游继续打穿。
相关度不是依赖上游原始顺序。我会先对查询、标题、歌手和专辑做 NFKC 归一化,再按“标题完全相等、标题前缀、标题包含、歌手命中、专辑命中、分词命中”逐层加权。分数相同时才回到 source 顺序和上游顺序。
去重优先看 ISRC;没有 ISRC 时,再比较标准化后的标题、第一歌手和时长,时长容差是 8 秒。它不能保证理解“重制版”和“现场版”的全部语义,但至少不会让搜索第一页出现九个一模一样的结果。
为了让网页首屏快一点,还有一个 mode=fast:
GET /api/v2/tracks?query=Lemon&source=all&mode=fast&view=compact
它给第一屏约 2.2 秒预算,先返回已经完成的源,并在 meta.complete / meta.partial 里明确告诉客户端这是不是完整结果。剩余 source 继续通过 waitUntil 跑完,再把完整结果写回 D1。
这里我后来又补了一条看起来反常、实际很重要的规则:如果预算到了,但一个可展示结果都没有,就继续等第一个非空 source,而不是准时返回一张空白页。 性能指标不能比用户看到的东西更重要。
每次响应还会返回:
meta.sources[].status:fulfilled / rejected / pendingmeta.sources[].durationMs:单源耗时meta.duplicatesRemoved:去重数量Server-Timing:浏览器 Performance 面板可直接看到各上游时间view=compact:只取播放器首屏真正需要的字段做 V2 的直接导火索之一,是 QQ 音乐歌单 7713574197。
这个歌单明明不止 30 首,上游却永远只返回 30 首。原因不是缓存,也不是前端截断,而是 QQ 的歌单接口有默认分页,而且旧请求只拿了第一页。
最后我没有在外面机械地翻页拼接,而是改用带完整 comm 上下文的 musicu POST 请求,把 song_begin 设为 0、song_num 拉到 1000,同时要求返回 order list 和完整 song list。这样歌单第一次进入 adapter 时就是完整数据,后面的 V2 分页只负责面向客户端分页,不再把上游第一页误当总集。
同时,歌单也从“歌曲数组”变成了完整资源:
playlist
├── id / source / sourceUrl
├── name / cover / description
├── creator
网易云、QQ、Spotify、Apple Music、YouTube Music、汽水和 @meting/core 几套完全不同的返回结构,最终都会落到这个模型里。拿不到的字段就是 null,不拿错误字段硬凑。
这也直接改变了 RMusic 添加歌单的体验:用户只需要选平台、贴 ID,名称、封面、介绍和创建人都由 V2 自动解析;本地保存的是歌单引用,不再要求手填一份很快过期的副本。
搜索和详情大多是普通 JSON;播放则同时碰上会员、临时签名、Cookie、地域、Range 和 CDN。
以 QQ 音乐为例,拿到歌曲不代表拿得到 vkey。我见过最典型的错误是:
tencent: vkey 全部 quality 都被拒
它可能表示:
因此 stream 不是“拼一个 CDN URL”。它会按 auto / lossless / high / standard / low 选择音质,逐级验证,确认上游真的给出资源后再代理给客户端。错误会说明当前 source、账号和被拒位置,而不是统一吞成 500。
账号选择也做成了池:
这里的“下一个账号”仍然只发生在同一平台内部。QQ 失败可以换另一个 QQ 账号,不会换成网易云的一首同名歌曲。
四个平台目前都有自己的生命周期:
| 平台 | 自动维护方式 |
|---|---|
| QQ 音乐 | 保存完整登录凭证和 refresh 信息,临近过期时续签 |
| 网易云音乐 | 对仍有效的会话做保活;彻底失效后重新扫码 |
| 酷狗音乐 | 续签登录 Token 并回写账号状态 |
Worker 同时导出 scheduled(),可以直接接 Scheduled Trigger;在 RandallFlare 里也可以用 HTTP cron 调每个平台的维护入口。刷新不是每小时无脑换 Cookie,而是先看过期时间、最近尝试和账号状态,需要时才真正请求上游。
最早我把 Cookie 同步交给另一个 Cookie-Keeper。很快就发现这条链路不靠谱:状态在两个服务里,刷新失败不知道该看谁,文件和环境变量又不适合保存会轮换的登录凭证。
最后做法很简单:登录、刷新、账号池和播放选择全部放回 Meting。
/admin 是统一管理入口。使用主 METING_TOKEN 登录一次后,服务端签发 12 小时 HMAC 会话,放进 __Host- 前缀的 HttpOnly + Secure + SameSite=Strict Cookie。主 Token 不写进 URL,也不会留在页面里。管理请求还会检查同源、专用请求头和 CSP,反向代理场景则显式识别可信的 forwarded origin。
管理页里可以:
临时 Token 明文只展示一次,D1 里只保存 SHA-256 摘要和首尾 hint。它和主 Token 一样可以访问音乐资源,但不能登录后台、不能扫码,也不能管理任何账号。
公开首页则不展示详细登录状态。没有账号的平台连状态卡都不出现;账号数量、会员和错误只留在管理员页面。一个 API 文档页没有理由向访客广播我的音乐账号运行情况。
QQ 登录中最折磨人的不是二维码,而是授权阶段偶发的:
authorize 网络连接错误 (Network connection lost.)
authorize 未返回 code
二维码已经确认,授权 code 却可能因为 edge 到 graph.qq.com 的链路丢失而拿不到。实现里最后加了可达路由、候选 host 和重试,但 fallback 也不能“能连上就算成功”——HTTP 404 的 CNAME 没有授权结果,必须继续尝试或明确失败。
waiting 背后可能是第二套状态机#汽水更绕。扫码后页面可能一直是 waiting,手机端又提示“请使用汽水音乐 App”;即使 App 确认,服务端还可能返回 2046:
为保障你的账号安全,请完成二次验证
这不是二维码轮询坏了,而是账号进入短信二次验证。最后登录页把“生成二维码、扫码、App 确认、二次验证、Cookie 落库”拆成显式状态,遇到 2046 就继续发送和检查验证码,不能把它当成普通等待,更不能提前宣布登录成功。
这类接口最容易写出“Happy Path Demo”:自己的账号扫一次能用,就以为登录完成了。真正放进长期运行的服务里,异常状态才是主体。
逐行歌词很简单:
[00:18.20]梦ならばどれほどよかったでしょう
逐字歌词完全不是一回事:
我的做法是把它们全部归一化成 Enhanced LRC:行时间仍用 [],每个词的边界用 <>,最后再放一个行尾 sentinel。普通播放器可以忽略词时间继续逐行滚动,支持 karaoke 的播放器则能精确高亮到字。
YRC ──────────────┐
QRC → 解密/解压 ──┤
KRC → 解码/解压 ──┼──> Enhanced LRC ──> RMusic karaoke renderer
Soda word timing ─┤
跨源 YRC 匹配 ────┘
对于没有原生逐字歌词的 source,会根据标题、歌手和时长去匹配网易云的 YRC。这个 fallback 有两条底线:找不到就降级,不伪造;拿到的如果只是普通逐行 LRC,就不能写进 lrcpword 缓存,否则一次错误匹配会让后面所有请求永久失去逐字歌词。
所以 V2 里把两种资源做成同一路径的不同表示:
GET /api/v2/lyrics/tencent/0039MnYb0qxYhV
GET /api/v2/lyrics/tencent/0039MnYb0qxYhV?granularity=word
这比再发明一个 type=lrcpword 更符合资源语义,也让文档和客户端都简单一点。
音乐 API 的延迟很容易被误判成 Worker 冷启动。实际上,慢样本大多来自目标音乐站、签名流程和音频 CDN。解决办法不是把一切塞进一个 cache,而是按数据形态分层。
request
│
├─ 8 MiB isolate LRU hit ──> response
│
├─ D1 fresh hit ───────────> response
│
├─ D1 stale hit ───────────> response + background revalidate
│
└─ upstream ───────────────> response + waitUntil(D1 put)
缓存 key 会规范查询参数顺序和搜索词空白,并明确排除 token、auth、refresh,避免同一份资源因为凭证不同复制几十份。同一 isolate 中相同 key 的并发 miss 还会合并到一个 in-flight Promise,防止热门请求同时击穿上游。
当前 TTL:
每份缓存响应有 ETag,客户端带 If-None-Match 可以直接拿 304;需要主动更新时加 refresh=true。x-cache-source 会明确告诉我这次来自 memory、D1、stale 还是重新请求,而不是靠感觉猜缓存有没有工作。
媒体字节走 R2,音频尤其要处理 Range:
第一次普通请求只发一个上游 fetch,ReadableStream.tee() 一份给用户、一份异步写 R2。第一次 Range miss 则会双发:Range 请求优先服务播放器,另一条全量请求在背景填充 R2。代价是首个 miss 多一份上游流量,收益是后续拖动进度条都能直接从 R2 按区间读。
只有成功且有 body 的响应才入桶。VIP 拒绝、过期签名和 4xx / 5xx 绝不能缓存,否则五分钟的上游故障会被放大成一份长期稳定的错误对象。
这部分是 Meting 成为「Fork The World」第一项工程的另一个原因:它也是我给 RandallFlare 找的真实迁移测试床。
最隐蔽的兼容问题来自 node:crypto。
@meting/core 的网易云实现会调用:
createCipheriv('aes-128-ecb', key, null)
Node 接受 ECB 模式的 null IV,因为 ECB 本来就不用 IV;workerd 的 nodejs_compat 却会先校验参数并拒绝它。于是代码在 Node / Bun 正常,到 Worker 里直接炸。
最后我给 esbuild 写了一个 resolve plugin:所有 crypto / node:crypto import 先进入本地 shim,只有 shim 自己再访问真正的 node:crypto。shim 遇到 ECB + null 时把它改成 workerd 接受的形式,其余调用完全透传。
其他 bare builtin 也统一改写成 node:*:
crypto -> local node-crypto-shim -> node:crypto
buffer -> node:buffer
url -> node:url
zlib -> node:zlib
最终产物是一份约 700 KB 的 ESM Worker bundle。没有常驻 Bun 进程,没有本地 Cookie 文件,也没有应用自己监听 443;部署平台只负责把 D1、R2、env 和 Cron 绑进来。
仓库变成 private 以后,RandallFlare 的构建器通过只读 SSH Deploy Key 拉代码,不需要把一个长期 PAT 塞进构建环境。每次发布前仍然走同一套门槛:
npm run lint
npm test
npm run build
登录状态机、V2 路由、缓存、歌单、平台鉴权和 OpenAPI 都有独立测试。很多测试并不是一开始设计出来的,而是某个平台真实翻过一次车以后留下的护栏。
症状:歌单详情显示总数远大于 30,API 数组却固定 30。
根因:旧接口默认第一页,客户端分页被误当成完整歌单。
最终方案:改用 musicu POST + 完整 comm,上游一次取足,再由 V2 自己分页。
vkey 所有 quality 都为空#症状:歌曲能搜到,封面和歌词正常,音频却统一 403。
根因:账号权益、凭证过期、客户端 Cookie 字段或地区限制;不是每次都是代码坏了。
最终方案:按音质探测,验证账号状态,刷新当前账号,同平台切换候选,并把真正拒绝原因返回出来。
症状:手机已经确认,网页最后一步仍然报网络断开或拿不到 code。
根因:扫码状态和 OAuth code 交换是两段链路;后者在 edge 出口上不稳定。
最终方案:候选 host、可达路由、受控重试和严格结果校验。HTTP 404 不算 fallback 成功。
waiting,或突然返回 2046#症状:扫码确认后网页不动,或者提示需要二次验证。
根因:二维码确认、App 身份确认和短信风控是不同状态,不能只轮询一个“是否扫码”。
最终方案:把状态机完整呈现,支持发送 / 检查验证码,只有拿到可用 Cookie 才落库。
mode=fast 很准时,但偶尔返回空数组#症状:2.2 秒准时响应,页面却什么也没有;几秒后缓存里又出现结果。
根因:冷 isolate 下最快的平台也可能刚好错过预算。
最终方案:预算到期后若已有内容就返回;若一个结果都没有,则等第一个非空 source 或全部 deadline 结束。
症状:播放器每次 seek 都 miss;缓存对象只有一小段,后续区间还可能越界。
根因:浏览器第一次请求通常就是 Range,把这段直接当完整对象写入是错的。
最终方案:Range 给用户,全量 fetch 在 waitUntil 里填桶;普通请求则 tee 一次完成。
症状:调用很方便,但 Token 会进入浏览器历史、日志、Referer、截图和缓存 key。
根因:把短期 demo 的 query 鉴权沿用到了完整资源图。
最终方案:V2 资源链接永远不携带秘密,统一使用 Bearer;浏览器原生媒体标签需要无 header 播放时,由 RMusic 的服务端签名适配层解决。
音乐代理天然容易变成一个公共出口,所以我给它定了几条硬边界:
/api/v2 和 /api/v2/sources 可以公开,用来发现协议;真实资源必须 Token。这不意味着“有 Token 就绝对没人能滥用”。任何给客户端的长期凭证都可能被提取,所以 RMusic 的方向是登录后播放、设备密钥登录、服务端保存用户收藏 / 歌单 / 播放记录,并让浏览器拿到的是站点会话和短期签名,而不是 Meting 的主密钥。
Meting 不是为了仅仅停留在Swagger文档阶段。它现在已经是 music.bigrandall.io 的底座:
用户搜索
-> RMusic 请求聚合或指定 source
-> V2 返回统一 track model
-> 用户选择结果
-> RMusic 通过受保护的媒体适配层取 stream / artwork / lyrics
-> Meting 选账号、取上游、处理 Range 并缓存
前端因此不需要维护九套 if/else:
source 参数不同;这也是为什么我坚持先把 V2 修完整,再全面改前端。让页面兼容一堆旧接口很快,但以后每加一个客户端都要再还一遍债;让 API 先成为稳定的资源层,网页只是第一个消费者,未来的手机客户端也可以直接复用。
player.js、签名和 n 参数会变化,需要持续跟进。我反而觉得,把这些限制写清楚比做一张“全平台全功能”的表更重要。聚合层最大的危险不是失败,而是把不同失败都包装成一个看似成功的假象。
做完 Meting 之后,我对“复刻”这件事有了三个标准:
Meting 最开始真的只是一个“输入 server / type / id,返回一段 JSON”的小服务。后来为了让一首歌稳定地在网页里响起来,我不得不依次理解搜索、歌单分页、OAuth、Cookie 生命周期、会员权益、歌词格式、流式响应、Range、对象缓存、REST、OpenAPI 和管理会话。
表面上是在做音乐 API,实际上是在把一个依赖变成基础设施。
这就是「Fork The World」好玩的地方:选一个每天都在用、但从来不真正属于自己的能力,把它拆开,理解它,然后用自己的方式重新接起来。
Meting 是第一个。
不会是最后一个。
@meting/core —— 国内平台 adapter 的基础。workerd —— 让同一份 Workers 代码能运行在 RandallFlare 和 Cloudflare Workers 风格环境中。这个项目里我做过最重要的选择,大概是这些:
每一个选择都不是“最佳实践”四个字自动给出的答案,而是在真实请求、真实播放器和真实失败里被逼出来的。
最后留下来的,也不只是一套能播歌的接口,而是一条我可以继续往网页、手机和更多终端延伸的音乐基础设施。
| source | 平台 | 登录方式 | 播放能力与边界 |
|---|
netease | 网易云音乐 | 内置二维码,多账号 | 按账号实际权益取流;会话彻底失效后需重新扫码 |
tencent | QQ 音乐 | 内置二维码,多账号 | 支持会员账号优先、凭证续签和账号切换 |
kugou | 酷狗音乐 | 内置二维码,多账号 | Token 续签,逐字 KRC 歌词 |
soda | 汽水音乐 | 内置二维码,多账号 | Cookie 保活;风控时可能要求二次验证 |
baidu | 百度 / 千千音乐 | 静态 Cookie(可选) | 搜索、曲目、歌单、音频与歌词 |
kuwo | 酷我音乐 | 静态 Cookie(可选) | 搜索、曲目、歌单、音频与歌词 |
ytmusic | YouTube Music | Premium Cookie(可选) | 解析播放器响应与签名参数;结果受账号和地区影响 |
spotify |
| 层 | 技术 | 为什么 |
|---|
| Runtime | workerd + nodejs_compat | 与 Workers 语义一致,也能吃下 @meting/core 的 Node 依赖 |
| 入口与路由 | 标准 fetch() + 手写 router | 路由规模可控,少一层框架和兼容负担 |
| 国内基础 adapter | @meting/core | 保留成熟的搜索、曲目和部分平台协议实现 |
| 扩展 adapter | 原生 fetch + Web Crypto / node:crypto | QQ、汽水、YouTube Music、Spotify、Apple 等各自处理 |
| 热缓存 | lru-cache | 每 isolate 8 MiB,吸收高频 JSON 和歌词请求 |
| 持久状态 | D1 | JSON 缓存、二维码会话、账号池、Token 都是小型结构化数据 |
| 媒体缓存 | R2 | 音频、封面和歌词按对象保存,天然适合 Range |
| 文档 | OpenAPI 3.1 + Swagger UI | 请求、响应、媒体和错误结构都能成为机器可读契约 |
| 构建 | esbuild |
| 请求 | 作用 |
|---|
GET /api/v2 | API 入口和资源发现,公开 |
GET /api/v2/sources | 音乐源及能力列表,公开 |
GET /api/v2/tracks?query=Lemon&source=all | 聚合搜索;也可限定单个平台 |
GET /api/v2/tracks/{source}?ids=1,2,3 | 最多 50 首曲目的批量查询 |
GET /api/v2/tracks/{source}/{id} | 单曲元数据 |
GET /api/v2/albums/{source}/{id} | 专辑信息与分页曲目 |
GET /api/v2/artists/{source}/{id} | 歌手信息与代表曲目 |
GET /api/v2/artists/{source}/{id}/albums | 歌手发行目录 |
GET /api/v2/playlists/{source}/{id} | 歌单信息、创建人、统计与曲目 |
GET /api/v2/playlists/{source}/{id}/tracks | 歌单曲目子资源 |
GET /api/v2/streams/{source}/{id} |
{
"data": [
{
"id": "536622304",
"source": "netease",
"title": "Lemon",
"artists": [{ "id": null, "name": "米津玄师" }],
"album": { "id": null, "name": "Lemon" },
"durationMs": 256000,
{
"type": "about:blank",
"title": "HTTPException",
"status": 401,
"detail": "V2 API token 无效",
"instance": "/api/v2/tracks",
"apiVersion": "2"
}| 汽水音乐 | 保活并接收轮换 Cookie;遇到风控进入二次验证 |
| 资源 | fresh | stale-while-revalidate |
|---|
| 搜索 | 5 分钟 | 10 分钟 |
| 歌单 | 30 分钟 | 60 分钟 |
| 单曲 / 专辑 / 歌手 / 播放选项 | 24 小时 | 24 小时 |
| 榜单 / 新发行 / 推荐 / 发现 | 10 分钟 | 20 分钟 |
| 区域 | 原来的服务端版本 | Worker 版本 |
|---|
| 入口 | Bun.serve(...) | export default { fetch, scheduled } |
| 配置 | process.env | buildConfig(env) bindings |
| Cookie | 文件与环境变量 | 四平台扫码账号池 + D1;旧平台静态 env |
| 日志 | pino / pretty | 轻量 console JSON |
| HTTPS | 应用内 TLS | edge 终止 TLS |
| 路由 | 框架路由 | 小型手写 router + middleware |
| 构建 | 直接运行源码 | esbuild 单 ESM bundle |
__Host- Cookie、同源校验、CSP、frame-ancestors 'none' 和 X-Frame-Options: DENY。| Spotify |
| Client Credentials |
| 公共目录元数据;播放只使用官方 preview |
apple | Apple Music | MusicKit 或免密 iTunes fallback | 完整目录或 iTunes 降级;播放只使用官方 preview |
| 把用户代码和依赖打成一份 ESM Worker bundle |
| 可 Range 的音频流 |
GET /api/v2/streams/{source}/{id}/options | 可用音质与播放限制 |
GET /api/v2/artworks/{source}/{id} | 封面图片 |
GET /api/v2/lyrics/{source}/{id} | 逐行歌词;granularity=word 获取逐字歌词 |
GET /api/v2/charts | 榜单 |
GET /api/v2/new-releases | 新发行 |
GET /api/v2/recommendations | 每日目录混合推荐 |
GET /api/v2/discovery | 首页发现内容 |