Blog/开发者工具/

抖音星图达人评估 API 教程:投放前对比达人画像、表现与报价 | SandBase

品牌投放前怎么比达人?用 SandBase 先拿到星图 kolId,再读基础信息、粉丝画像、近期视频表现和报价,把 2~3 个候选摆在一张表里。

暗色电影感 3D 渲染:三张玻璃质感的达人资料卡,分别连着受众环形图、上升柱状图和价签,汇聚到前方的一架对比天平

品牌方甩过来三个抖音账号,只问一句:这条植入给谁?光看粉丝数是答不出来的。你得知道每个号的粉丝到底是谁、最近的视频跑得怎么样、商单和自然流量差多少、报价又是多少。这些商业维度的数据,在抖音体系里放在巨量星图——官方的达人营销撮合平台。这篇教程用 SandBase 的星图端点,把候选达人的星图数据拉下来并排对比,交给 Agent 或分析师写推荐意见。

本文挂在 抖音公开数据 API 总览 下面。如果你要的是达人发过的视频和播放量,而不是商业视图,请看 抖音达人视频研究教程。这篇只管一件事:投放决策。

不需要抖音或星图账号,用 SandBase API key 鉴权即可。本文用到的抖音端点(包括星图这几个)目前在 SandBase 目录里标的是 Free。星图参考页的描述里另有一行按次价格,kol 详情类接口还会提到企业账号。SandBase 的当前状态以目录为准,正式使用前请先确认。

参考页只保证响应信封。下文所有业务字段名都来自我自己的调用(测试于 2026-10-01,UTC),仅作示意,属于实测观察,不是文档保证。

先说结论

  • 星图端点要的是 kolId,不是抖音的 sec_user_id。先用 douyin/xingtu/xingtu-kolid-by-sec-user-id 换出来。
  • kol-base-info-v1、kol-fans-portrait-v1、kol-video-performance-v1 分别给出达人分类、粉丝构成,以及近期自然视频和商单视频的播放表现。
  • kol-service-price-v1 返回星图报价单,kol-cp-info-v1 返回预期播放量和 CPM、CPE 估算。
  • 我测了三个公开媒体品牌号(日食记、中国国家地理、一条),全部解析成功,详情调用全部完成。其中一个号的表现汇总是空的,所以代码会退回到原始视频播放量。

为什么要看星图数据

抖音公开主页只告诉你粉丝数和获赞数。谈商单要的是另一套数字:粉丝的年龄、城市线级、八大人群构成,商单视频的播放中位数,以及每种视频形式的挂牌价。这些星图只开放给广告主看,SandBase 的 douyin/xingtu 系列按 kolId 读取。

你的需求用什么
用星图公开挂牌数据筛选、对比达人SandBase douyin/xingtu 端点
下单、签约、给达人发 brief星图官方广告主后台
私有投放结果或仅账号可见的数据两条公开路线都不行

说白了,这是做调研,不是下单。SandBase 帮你读到挂牌数据,真正下单还是在星图里完成。

流程一览

  1. 找到账号:douyin/search/user-search-v2(keyword、cursor),记下 sec_user_id。
  2. 换星图 kolId:douyin/xingtu/xingtu-kolid-by-sec-user-id(sec_user_id)。
  3. 读基础信息:douyin/xingtu/kol-base-info-v1(kolId、platformChannel)。
  4. 读粉丝画像:douyin/xingtu/kol-fans-portrait-v1(kolId)。
  5. 读近期视频表现:douyin/xingtu/kol-video-performance-v1(kolId、onlyAssign)。
  6. 读报价和成本估算:douyin/xingtu/kol-service-price-v1(kolId、platformChannel)加 douyin/xingtu/kol-cp-info-v1(kolId)。

SandBase 抖音 API 目录页,列出抖音端点,选中端点显示 Available 和 Free SandBase 上的抖音目录页:共 262 个端点,路径写成 GET /apis/v1/douyin/...,右侧选中的综合搜索 V1 标着 Available、Free。本教程调用的是 Model API 的 POST /v1/api/douyin/... 路由。

目录页展示的是 GET /apis/v1/douyin/<path>。下面的代码用的是端点参考页里的 Model API:POST /v1/api/douyin/<path>,JSON body 里只放该端点自己的参数。复制示例时,POST 方法和 /v1/api/ 前缀都别改。

第 0 步:一个通用调用函数

import os
import statistics
import time
import requests

API = "https://api.sandbase.ai/v1/api"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}",
    "Content-Type": "application/json",
}

def call(path: str, payload: dict, retries: int = 2) -> dict:
    for attempt in range(retries + 1):
        try:
            resp = requests.post(f"{API}/{path}", headers=HEADERS, json=payload, timeout=90)
            resp.raise_for_status()
            break
        except requests.RequestException:
            if attempt == retries:
                raise
            time.sleep(2 * (attempt + 1))
    body = resp.json()
    if body.get("status") != "completed":
        raise RuntimeError(body.get("error", {}).get("message", f"{path} did not complete"))
    # Documented shape is outputs[0].data; also accept a top-level `output`.
    output = body.get("output")
    if output is None and body.get("outputs"):
        output = body["outputs"][0].get("data", {})
    output = output or {}
    status = (output.get("base_resp") or {}).get("status_code", 0)
    if status != 0:
        raise RuntimeError(f"{path}: base_resp.status_code={status}")
    return output

参考页写的是 outputs[0].data,我最后那轮调用也全是这个结构。之所以还兼容顶层 output,是因为 SandBase 其他平台端点返回过这种结构,两路都读更稳。

测试里还有两处细节。星图的每个返回里都带一个 base_resp,正常时 status_code 为 0,所以函数把非 0 当失败处理。重试也不是摆设:下文引用的那轮运行里,有一次 kol-fans-portrait-v1 直接回了 HTTP 错误,提示 upstream error 400: Request failed. Please retry,第二次就成功了(run e5c76e47-6ace-47d8-b2ef-317fef873e74)。

第 1 步:换出星图 kolId

def resolve_kol(keyword: str) -> dict:
    found = call("douyin/search/user-search-v2", {"keyword": keyword, "cursor": 0})
    users = (found.get("data") or {}).get("user_list") or []
    exact = [u for u in users if u.get("nick_name") == keyword]
    if len(exact) != 1:
        # no silent fallback: zero or several exact matches need a human decision
        raise LookupError(f"{keyword}: {len(exact)} exact nickname matches; confirm the account first")
    sec_user_id = exact[0]["user_id"]  # holds a sec_user_id (MS4wLjABAAAA...)
    kol = call("douyin/xingtu/xingtu-kolid-by-sec-user-id", {"sec_user_id": sec_user_id})
    if not kol.get("id"):
        raise LookupError(f"{keyword} is not registered on Xingtu")
    return {"keyword": keyword, "sec_user_id": sec_user_id,
            "kol_id": kol["id"], "nick_name": kol.get("nick_name"), "tag": kol.get("tag")}

以美食视频品牌日食记为例:搜索(run 2bd433e3-58f2-445c-93e8-9299d9d2071d)返回的 user_list 里,user_id 字段装的其实是 sec_user_id。换 id 的调用(run 44a9f085-3832-4b92-a183-d5259d43e059)返回星图记录:

{
  "id": "6677767167234015243",
  "core_user_id": "64117131714",
  "nick_name": "日食记",
  "tag": "美食",
  "base_resp": {"status_code": 0, "status_message": "Success"}
}

这个 id 就是后面所有调用要的 kolId。中国国家地理和一条走同样的路子也都成功了(run a6517c08-7b7d-4c19-b932-f83f4c578a8c、e2c448b7-33ed-49d9-9864-cebad3305633)。

为什么不直接搜星图?douyin/xingtu/search-kol-v2 传 keyword 就能返回带 kolId 的 authors,能用。但搜「一条」时,第一页把别的达人排在了这个媒体号前面。而且它的请求 schema 里没有翻页参数,返回里却带着含 has_more 的 pagination 对象。相比之下,先搜抖音用户、再按昵称精确匹配更可控。如果你手上已经有 uid 或抖音号,可以用 xingtu-kolid-by-uid、xingtu-kolid-by-unique-id,用法一样。

SandBase 抖音星图 kolid by sec_user_id 端点参考页 xingtu-kolid-by-sec-user-id 参考页:POST /v1/api/douyin/xingtu/xingtu-kolid-by-sec-user-id,只有一个必填参数 sec_user_id;文档里的响应示例 outputs[0].data 是空的。

第 2 步:基础信息和粉丝画像

def base_info(kol_id: str) -> dict:
    b = call("douyin/xingtu/kol-base-info-v1", {"kolId": kol_id, "platformChannel": "_1"})
    return {
        "followers": b.get("follower"),
        "tags_level_two": b.get("tags_level_two"),       # JSON string in my runs
        "content_themes": (b.get("content_theme_labels") or [])[:5],
        "has_mcn": bool(b.get("mcn_id") and b.get("mcn_id") != "0"),
        "is_star": b.get("is_star"),
    }

def fans_portrait(kol_id: str) -> dict:
    p = call("douyin/xingtu/kol-fans-portrait-v1", {"kolId": kol_id})
    return {d.get("type_display"): d.get("description")
            for d in p.get("distributions", []) if d.get("description")}

platformChannel 是必填,但参考页的说明写到「supports the following parameters:」就断了,没列出取值。我传的是 "_1",也就是抖音短视频渠道;试过 "1",返回一样。其他取值我没验证,参考页补上列表之前,建议就用 _1。

基础信息里有 follower、content_theme_labels、tags_level_two 和 MCN 相关字段。注意 tags、tags_level_two 是 JSON 字符串,不是数组。记录里还有一段 MCN 介绍,可能带商务联系方式,所以函数只留一个「有没有 MCN」的布尔值。做共享报告时也建议这么处理。

粉丝画像返回 distributions 列表,每一项有 type_display 标签、distribution_list 原始键值计数和一句 description。日食记的节选(run e5c76e47-6ace-47d8-b2ef-317fef873e74):

{
  "distributions": [
    {"type_display": "年龄分布", "description": "24到30岁居多,占比30%"},
    {"type_display": "城市等级分布", "description": "一线居多,占比37%"},
    {"type_display": "八大人群分布", "description": "新锐白领居多,占比26%"},
    {"type_display": "性别分布", "description": "男性居多,占比50%"}
  ]
}

这些一句话描述直接塞进 prompt 很方便,但取整很粗。性别那行写着「男性居多,占比 50%」,而 v2 的同类端点 xingtu-v2/author-fans-distribution(run 82bcca0d-4e4b-4b8f-9ca8-75ca62aabdad)给出的原始计数是男 6,169,425、女 6,155,171,基本五五开。如果 brief 很看重性别比例,就自己从 distribution_list 算。

另外还有 kol-audience-portrait-v1,描述的是观众而不是粉丝。日食记的观众(run 1d0456b8-5e68-48ce-9a45-0aac0f434471)更年轻:18~23 岁最多(32%),人群以 Z 世代为首;而粉丝是 24~30 岁、新锐白领居多。这个差异挺有用——它告诉你谁在看,谁在关注。

第 3 步:近期视频表现

def video_performance(kol_id: str) -> dict:
    v = call("douyin/xingtu/kol-video-performance-v1", {"kolId": kol_id, "onlyAssign": False})
    desc = v.get("data_description") or {}
    items = v.get("latest_item_info") or []
    plays = [i.get("play") for i in items if isinstance(i.get("play"), int)]
    sponsored = v.get("latest_star_item_info") or []
    return {
        "play_median": (desc.get("play_medium") or {}).get("rate"),
        "interaction_rate": (desc.get("interaction") or {}).get("rate"),
        "recent_items": len(items),
        "recent_play_median": statistics.median(plays) if plays else None,
        "sponsored_items": len(sponsored),
        "sponsored_play_median": (desc.get("play_medium_enrollment") or {}).get("rate"),
    }

我看到的返回分三块。data_description 是汇总指标(play_medium、interaction、video_view_rate),每项有一个 rate 和若干对比值;带 _enrollment 后缀的看起来对应星图商单视频。latest_item_info 列出 15 条近期视频,带 play、like、comment、share、duration、item_date。latest_star_item_info 是近期商单视频,结构相同。

日食记(run c6d7e6f1-8615-406c-a776-98266ecf1111)的播放中位数约 179 万,商单视频中位数约 38.6 万。自然和商单的这个落差,是我会第一个拿给品牌方看的数字。

坑在这里:中国国家地理(run c4a11532-3bcb-4fe9-a4f7-e448172bb999)的 data_description 每一项都是空对象,视频列表却好好的。所以函数会从 latest_item_info 自己算 recent_play_median,汇总值一律用 .get() 读。千万别让一个空汇总在对比表里变成 0。

SandBase 抖音星图 kol-video-performance-v1 端点参考页 kol-video-performance-v1 参考页:必填 kolId 和布尔值 onlyAssign。和 platformChannel 一样,取值说明写到「as follows:」就截断了。

第 4 步:报价单和成本估算

def pricing(kol_id: str) -> dict:
    p = call("douyin/xingtu/kol-service-price-v1", {"kolId": kol_id, "platformChannel": "_1"})
    card = {x.get("desc"): x.get("price") for x in p.get("price_info", [])
            if x.get("enable") and x.get("task_category") == 1}
    cp = call("douyin/xingtu/kol-cp-info-v1", {"kolId": kol_id})
    return {
        "rate_card": card,
        "industry_tags": p.get("industry_tags", []),
        "expect_vv": (cp.get("expect_vv") or {}).get("value"),
        "expect_cpm_60s": (cp.get("expect_cpm") or {}).get("cpm_60"),
        "expect_cpe_60s": (cp.get("expect_cpe") or {}).get("cpe_60"),
    }

price_info 是服务项列表,每项有 desc(比如植入视频、定制视频)、price、enable、settlement_desc 和 task_category。我看到的返回里,还夹着几项 task_category 不同、价格象征性的广告推送附加项,以及一项没有价格的「按自然播放量结算」。按 enable 加 task_category == 1 过滤,就只剩视频形式本身。

日食记的节选(run 8bc1807b-f27a-4cd6-883a-1d66b8ac5b3d):

{
  "industry_tags": ["食品饮料-水饮冲调", "3C及电器-大家电", "母婴宠物-宠物生活"],
  "price_info": [
    {"desc": "植入视频", "price": 180000, "settlement_desc": "固定价格", "task_category": 1, "enable": true},
    {"desc": "定制视频", "price": 300000, "settlement_desc": "固定价格", "task_category": 1, "enable": true}
  ]
}

kol-cp-info-v1(run be7df1c1-692c-427d-9aa9-7ccbd7d26028)返回 expect_vv,以及按 21_60、60 分档的 expect_cpm、expect_cpe。单位文档没写,我自己核了一下:用植入视频价格除以 expect_vv 再乘 100,000,三个号都能精确还原 cpm_21_60。也就是说,这个 CPM 看起来是「每千次预期播放多少分」,植入视频对应 21~60 秒档。60 秒档和定制视频在三个号里对上了两个,日食记没对上。这两点都只是我从数据里推出来的,真要拿 CPM 做决策,建议自己用报价和预期播放算一遍。

SandBase 抖音星图 kol-service-price-v1 端点参考页 kol-service-price-v1 参考页:POST /v1/api/douyin/xingtu/kol-service-price-v1,必填 kolId 和 platformChannel。描述里另有企业账号说明和一行按次价格;这个端点目前在 SandBase 目录里标的是 Free。

串起来:给候选名单做对比

def evaluate(keywords: list[str]) -> list[dict]:
    rows = []
    for kw in keywords:
        try:
            kol = resolve_kol(kw)
        except (LookupError, RuntimeError) as e:
            print("skip:", kw, e)
            continue
        kid = kol["kol_id"]
        rows.append({**kol, **base_info(kid), "fans": fans_portrait(kid),
                     **video_performance(kid), **pricing(kid)})
    return rows

rows = evaluate(["日食记", "中国国家地理", "一条"])

我拿三个公开媒体品牌号原样跑了一遍,一共 21 次成功调用,外加那一次重试。结果取整如下:

账号粉丝近期播放中位数植入视频报价预期播放粉丝画像
日食记(美食)约 1,254 万约 414 万(15 条)约 18 万元约 311 万24~30 岁,新锐白领
中国国家地理(自然科学)约 564 万约 78 万约 8 千元约 29 万31~40 岁,新锐白领
一条(人文生活)约 666 万约 30 万约 15 万元约 31 万31~40 岁,新锐白领

「近期播放中位数」这一列,是我用列出的 15 条视频自己算的,三个号口径一致。它和 play_medium 汇总值(日食记约 179 万)对不上,因为星图汇总用的时间窗口和算法在返回里没有说明。对比时选一种口径,从头用到尾。

单看数字是这样的:中国国家地理的单次预期播放成本最低,而且低得多;日食记覆盖面最大;一条的报价和日食记接近,预期播放却只有它的十分之一左右。但最后拍板的是 brief。比如家居品牌可能还是会选一条,因为它的内容主题有家居装修、家居生活;三个号的 industry_tags 组合也各不相同。把这几行数据连同 brief 一起丢给模型,让它写出引用具体字段的推荐理由,而不是只挑 CPM 最低的那个。

可选:粉丝趋势和 xingtu-v2 系列

kol-daily-fans-v1 接收 kolId、startDate、endDate(格式 yyyy-MM-dd)。日食记 2026-09-01 到 2026-09-28 这段(run f67341bd-80a0-4387-90a5-928d54f669e3)返回 28 个每日 fans_cnt,从约 1,259 万缓慢降到约 1,255 万。为覆盖面付钱之前,粉丝盘子是在涨还是在掉,值得心里有数。

douyin/xingtu-v2 系列覆盖同样的数据,参数是 snake_case。author-base-info、author-fans-distribution、author-spread-info、author-cp-info 都能把同一个 kolId 当 o_author_id 用,我测试时都成功了(run 8cd17f09-cadc-487c-b34d-8a4cf3245497、82bcca0d-4e4b-4b8f-9ca8-75ca62aabdad、59c050f8-3b65-4117-a30d-169c2a6136fa、194b74e9-78f2-4711-9d4e-19bd00b07e51)。author-cp-info 和 kol-cp-info-v1 返回的数值完全一样。两个系列选一个用到底就好。

文档保证 vs. 实测观察

项目状态
POST /v1/api/douyin/xingtu/xingtu-kolid-by-sec-user-id,参数 sec_user_id参考页有文档
kol-base-info-v1、kol-service-price-v1,参数 kolId + platformChannel有文档(渠道取值未列出)
kol-video-performance-v1,参数 kolId + onlyAssign有文档
信封 id / status / model / outputs[0].data有文档
platformChannel: "_1" 对应抖音短视频仅实测
base_resp.status_code、换 id 返回的 id 即 kolId仅实测
distributions[].type_display / description / distribution_list仅实测
data_description、latest_item_info、latest_star_item_info仅实测(有一个号汇总为空)
price_info[].desc / price / task_category;expect_cpm 单位仅实测;单位为推断

典型场景

商单达人初筛

输入 3~10 个候选昵称,输出每个达人一行对比数据。用上面整条链路。

受众匹配检查

拿产品的目标人群去对粉丝画像里的年龄、城市线级、八大人群,再和观众画像比一比,看实际是谁在看。端点:kol-fans-portrait-v1、kol-audience-portrait-v1。

商单播放折损

对比商单视频和自然视频的播放中位数,看一条付费视频大概会少多少覆盖。端点:kol-video-performance-v1。

续约前复盘

续约前拉一下每日粉丝趋势和最新报价单。端点:kol-daily-fans-v1、kol-service-price-v1。

实用提示

  • kolId 换一次就缓存。 同一个号在我所有调用里都没变过。
  • 昵称要精确匹配。 搜索会带出很多相似账号。以新世相为例,抖音搜索和星图搜索给出的粉丝数也不一样,粉丝数请固定用一个来源。
  • 汇总值要防御式读取。 data_description 可能是空的,退回原始视频列表。
  • 比例敏感时用原始计数,别信一句话描述,性别那个例子就是教训。
  • 联系方式别落库。 MCN 介绍里可能有商务联系方式,不要存也不要展示。
  • 上游偶发错误要重试。 我那一轮就有一次调用靠第二次才成功。
  • 只读公开挂牌数据。 不能下单,也读不到私有投放结果。

常见问题

需要星图广告主账号吗? 不需要。用 SANDBASE_API_KEY 向 SandBase 鉴权即可。真正下单还是要去星图。

收费吗? 本文用到的抖音端点(包括星图这几个)目前在 SandBase 目录里标的是 Free。星图参考页描述里另有一行按次价格,正式使用前请以目录页当前状态为准。

达人没入驻星图怎么办? 换 id 的接口不会返回 id,resolve_kol 抛出 LookupError,evaluate 会跳过这个号。

platformChannel 传什么? 我测试时 "_1"(抖音短视频)可用,"1" 返回相同数据。参考页暂时没列取值。

报价就是最终成交价吗? 那是调用时的挂牌报价,实际成交可能不同,当起点看就好。

写在最后

有一个抖音昵称,就能拼出达人的商业画像:先换出星图 kolId,再读基础信息、粉丝画像、近期表现和报价单,就得到一组可以对着 brief 比较的数据。我测的三个媒体号里,最便宜的覆盖和最大的覆盖落在不同账号上——这正是这套数据要帮你做的取舍。更多抖音端点见 抖音公开数据 API 总览。