Suno 从官网下载的歌曲会带上 C2PA 内容凭据(Content Credentials),标明这是用 Suno 生成的 AI 内容。官方提供了一个免费、不需要 Key 的检测接口。 这篇写一个批量检查脚本:可以检查一个目录里的文件,也可以检查公开链接。上传前先在本地用 ffprobe 确认文件是音频、大小不超过 100MB;请求串行发送,每次间隔 2 秒,遇到 429 按
Retry-After等待。结果写进c2pa_report.csv。
先看清成品(box 上真实调用了 Suno 官方接口)
- 我用 ffmpeg 自己合成的测试音 →
no_suno_provenance。- 文本文件 → 本地就拦下了,没有上传。
- 无法下载的链接 →
fetch_failed。- http 或内网地址 →
url_not_allowed。- pytest 2 项通过。
合规提醒:截至本文写作时(2026-10),Suno 没有公开的、可自助开通的生成 API。网上流传的“Suno API”封装大多是通过模拟登录或抓取 cookie 调用内部接口,违反服务条款,还可能导致封号,本文一律不用。歌曲请在 Suno 官方网页或 App 里生成,本文只处理两件事:一是调用 Suno 官方公开的免费 C2PA 检测接口,二是对你下载的文件做本地后期处理。权利以 Suno 帮助中心为准:免费计划生成的歌只能个人非商用;在 Pro 或 Premier 订阅期间创作的歌可以商用。歌词和参考素材不得侵犯他人版权,也不要模仿真实歌手的声音冒充本人。在中国境内发布,要遵守《生成式人工智能服务管理暂行办法》,并按《人工智能生成合成内容标识办法》标注“AI 生成”。本文不列订阅价格。
运行说明:box 上没有 Suno 生成的歌,所以
verified_suno这个结果没有真实测到,只在单元测试里用假数据覆盖了。其他几种结果都来自真实请求。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
suno_c2pa.py |
批量检测本地文件和公开链接,写出 CSV |
test_c2pa.py |
测试 429 重试、非音频文件不上传 |
samples/ |
自己合成的 my_tone.mp3,以及一个 notes.txt |
需要准备哪些环境?
Python 3.9 以上,pip install requests pytest,并安装 ffmpeg(用到其中的 ffprobe)。不需要 Key。如果要大批量调用,可以设置 CONTACT 环境变量,把联系方式写进 User-Agent。
接口要点(来自 Suno 官方页面)
- 上传文件:
POST https://studio-api.prod.suno.com/api/c2pa/detect,multipart 字段名是file。 - 公开链接:
POST …/api/c2pa/detect/url,请求体{"url": "https://…"},链接必须直接指向媒体文件。 - 返回:JSON 里的
verdict取值为verified_suno、no_suno_provenance或inconclusive,同时附带凭据详情。 - 限制:文件上限 100MB,接口有限流,官方的说法是“请合理使用”。
- 适用范围:只对新下载的歌有效,之前下载的旧文件不带凭据。另外,检测结果只返回给调用者本人,不会公开。
文件结构
51-suno-A-src/
├── suno_c2pa.py
├── test_c2pa.py
└── samples/
├── my_tone.mp3
└── notes.txt
第 1 步:代码
"""用 Suno 官方免费的 C2PA 检测接口,批量检查音频是否带 Suno 内容凭据。
接口说明见 https://suno.com/suno-credentials :上传文件 POST /api/c2pa/detect(字段 file),
或者用公开链接 POST /api/c2pa/detect/url。verdict 取值:verified_suno / no_suno_provenance / inconclusive。
文件上限 100MB,接口有限流:本脚本串行请求、每次间隔 2 秒、遇到 429 按 Retry-After 等待。"""
import csv, json, os, pathlib, subprocess, sys, time
import requests
API = os.environ.get("SUNO_C2PA_API") or "https://studio-api.prod.suno.com/api/c2pa"
UA = "binbho-tutorial-suno-c2pa/1.0" + (f" ({os.environ['CONTACT']})" if os.environ.get("CONTACT") else "")
MAX = 100 * 1024 * 1024
GAP = float(os.environ.get("C2PA_GAP", "2"))
def is_audio(path):
"""接口本身不校验文件类型(实测上传 txt 也返回 no_suno_provenance),所以先用 ffprobe 本地确认。"""
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name",
"-of", "csv=p=0", str(path)], capture_output=True, text=True)
return r.returncode == 0 and r.stdout.strip() != ""
def _post(s, url, **kw):
for _ in range(3):
r = s.post(url, timeout=120, **kw)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "10"))); continue
return r
return r
def check_file(s, path):
p = pathlib.Path(path)
if p.stat().st_size > MAX:
return {"verdict": "local_skip", "detail": "超过 100MB"}
if not is_audio(p):
return {"verdict": "local_skip", "detail": "不是音频文件(ffprobe 未发现音轨)"}
with open(p, "rb") as f:
r = _post(s, f"{API}/detect", files={"file": (p.name, f)})
return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"verdict": "error", "detail": r.text[:120]}
def check_url(s, url):
r = _post(s, f"{API}/detect/url", json={"url": url})
return r.json()
def main(targets, out="c2pa_report.csv"):
s = requests.Session(); s.headers["User-Agent"] = UA
items = []
for t in targets:
if t.startswith("https://") or t.startswith("http://"):
items.append(t)
elif pathlib.Path(t).is_dir():
items += sorted(str(p) for p in pathlib.Path(t).iterdir() if p.is_file())
else:
items.append(t)
rows = []
for i, t in enumerate(items):
d = check_url(s, t) if "://" in t else check_file(s, t)
verdict = d.get("verdict") or d.get("error_type", "error")
rows.append([t, verdict, d.get("manifest_present", ""), d.get("detail", "")])
print(f"{verdict:<20} {t} {d.get('detail', '')}")
if i < len(items) - 1 and not str(verdict).startswith("local"):
time.sleep(GAP)
with open(out, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f); w.writerow(["target", "verdict", "manifest_present", "detail"]); w.writerows(rows)
print("报告:", out)
return rows
if __name__ == "__main__":
main(sys.argv[1:])
第 2 步:测试
import json, subprocess, pathlib
import suno_c2pa as m
class FakeResp:
def __init__(self, code, body, headers=None):
self.status_code, self._b = code, body
self.headers = {"content-type": "application/json", **(headers or {})}
self.text = json.dumps(body)
def json(self): return self._b
class FakeSession:
def __init__(self, seq): self.seq, self.calls, self.headers = list(seq), [], {}
def post(self, url, **kw): self.calls.append(url); return self.seq.pop(0)
def test_retry_on_429(monkeypatch, tmp_path):
monkeypatch.setattr(m.time, "sleep", lambda s: None)
s = FakeSession([FakeResp(429, {}, {"Retry-After": "1"}), FakeResp(200, {"verdict": "verified_suno", "manifest_present": True})])
a = tmp_path / "a.mp3"
subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", "sine=d=0.5", str(a)], check=True)
assert m.check_file(s, a)["verdict"] == "verified_suno" and len(s.calls) == 2
def test_non_audio_never_uploaded(tmp_path):
t = tmp_path / "x.txt"; t.write_text("hi")
s = FakeSession([])
assert m.check_file(s, t)["verdict"] == "local_skip" and s.calls == []
第 3 步:真实运行
$ python suno_c2pa.py samples https://example.com/not-audio.mp3 "http://localhost/a.mp3"
no_suno_provenance samples/my_tone.mp3 No Suno content credentials found.
local_skip samples/notes.txt 不是音频文件(ffprobe 未发现音轨)
fetch_failed https://example.com/not-audio.mp3 Could not fetch audio from that URL.
url_not_allowed http://localhost/a.mp3 That URL cannot be used. Provide a public https URL.
报告: c2pa_report.csv
$ python -m pytest -q
2 passed in 0.26s
URL 接口出错时返回的是 HTTP 400,内容是真实的原样记录:
{"status_code": 400, "detail": "Could not fetch audio from that URL.", "error_type": "fetch_failed", "retryable": false, …}
{"status_code": 400, "detail": "That URL cannot be used. Provide a public https URL.", "error_type": "url_not_allowed", "retryable": false, …}
调试示例
上传文本文件也返回了“没有 Suno 凭据”。 我先用 curl 把一个写着 hello 的 txt 文件传给 /detect,接口返回的是 HTTP 200 和 {"verdict": "no_suno_provenance", …},并没有报“这不是音频”。如果直接拿这个结果当结论,就会把一份不是音频的文件,记成一首“确认不是 Suno 生成的歌”。所以脚本在上传前先用 ffprobe 检查有没有音轨,不是音频就标为 local_skip,这样也少占用一次限流额度。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
Suno 下载的歌检测结果是 no_suno_provenance |
文件是凭据功能上线之前下载的,或者经过了重新编码或元数据清理 | 重新从 Suno 下载原文件再检测 |
inconclusive |
凭据不完整或已损坏 | 用原始下载文件重新检测 |
url_not_allowed |
不是公开的 https 链接 | 换成直接指向媒体文件的公开 https 链接 |
| 429 | 请求太频繁 | 加大 C2PA_GAP,按 Retry-After 等待 |
模板规则
- 只用官方的检测接口,不用非官方的生成封装。
- 上传前先在本地校验文件类型和大小,请求串行并控制频率。
- 检测原始下载文件,不检测后期处理过的成品。
项目资源
| 用途 | 地址 |
|---|---|
| Suno 内容凭据与检测接口说明 | https://suno.com/suno-credentials |
| Suno 帮助:Suno 是否拥有我的音乐 | https://help.suno.com/en/articles/2416769 |
| Suno 帮助:免费计划的权利 | https://help.suno.com/en/articles/9601601 |
| Suno 帮助:付费订阅的权利 | https://help.suno.com/en/articles/9601665 |
检测结果会交给 C 篇的发布检查清单使用:上架之前,先确认手上这个文件是 Suno 的原始下载。