Blog/教程/

跨平台选品 Agent:TikTok Shop、小红书、抖音一次查清

用 Python 写一个跨平台选品 Agent:同一个商品词,查 TikTok Shop 商品、小红书笔记和商品、抖音视频,统计在代码里算,GPT-6.1 Sol 只负责写一页选品简报。

跨平台选品 Agent 教程封面,覆盖 TikTok Shop、小红书和抖音

第一次跑「portable blender / 便携榨汁杯」这组简报,TikTok Shop 搜 portable blender 只回了 10 个商品,个个有销量,前三名占了 96% 的销量。看上去像是被几家卖家垄断的赛道。可同一个请求重试一次,就回了 30 个完全不同的商品,还带翻页 token;拉满两页 60 个商品后,前三名的份额其实是 65% 左右。第一次那 10 个只是个「兜底短列表」,不是市场本身。

这篇教程做的是一个跨平台选品 Agent:输入一个商品词,它去 TikTok Shop 看商品(价格、销量、评分),去小红书看笔记和商城商品,再去抖音看视频,每个平台导出一份 CSV,最后让 LLM 根据代码算好的数字写一页选品简报。适合要在备货、上新之前先看证据的卖家和 Agent 开发者。实际做下来,大部分功夫花在识别第一次那种「坏样本」上。

先说结论

  • 一把 SandBase API Key 搞定五个数据端点:TikTok Shop 搜索和商品详情、小红书笔记搜索和商品搜索、抖音视频搜索。2026-10-03 这五个端点在目录里都标为 Free。
  • 计数、中位数全在 Python 里算。openai/gpt-6.1-sol 只写文字,另有一段检查会把文字里不在统计结果中的数字挑出来。六份简报每份输入 284~290 token、输出 269~485 token,约 $0.003~$0.005 一份。
  • 最后一轮测试里,TikTok Shop 搜索 21 次调用有 10 次回的是兜底短列表(5 或 10 个商品,has_more: false)。代码会重试,并且绝不把它混进完整的一页里;如果重试全都是短列表,简报就只能基于短列表,同时提示 LLM 把不足 30 的样本标为「样本偏少」。
  • 小红书的中位数在相隔几分钟的几次运行之间波动很大(同一个词 160.5~354)。一次运行只是一个样本,别当测量值用。

简报里有什么

本文查的是 TikTok Shop 美国站,所以这一侧用英文词搜;小红书和抖音用中文词。我测了两组:「portable blender / 便携榨汁杯」和「sunscreen / 防晒霜」。两种语言的词搜出来的不是同一批商品,所以跨平台只比「形状」(集中度、价格带、互动量级),不比绝对总数。

信号端点代码算什么
商品、价格、销量、评分tiktok/shop-web/search-products-list有销量的商品数、价格中位数、总销量、前三名销量占比、有评价商品的评分中位数
销冠的星级分布tiktok/shop-web/product-detail-v21、2 星评价占比
相关笔记xiaohongshu/app-v2/search-notes笔记数、点赞+收藏+评论中位数、收藏中位数、近 90 天笔记数、视频笔记数
小红书商城商品xiaohongshu/app-v2/search-products商品卡数量、标价中位数
短视频douyin/search/video-search-v2视频数、点赞中位数、点赞+收藏+评论+分享中位数、近 90 天视频数
文字简报openai/gpt-6.1-sol,走 /v1/chat/completions120~180 词,只用算好的数字

正式运行中每次发 11~13 个 HTTP 请求,墙钟时间 55 秒左右。

数据边界

这里用的是公开、只读数据,需要一把 SandBase API Key。SandBase 不是 TikTok、小红书或抖音的官方合作方。不碰私密账号、私信、商家后台数据、订单,也不做任何账号操作。CSV 里只留笔记和视频的 id 与计数,不存作者和正文。

需求用什么
上新前调研公开商品、笔记、视频SandBase 公开数据端点(本文)
自己店铺的订单、库存、广告TikTok Shop Partner Center
自己的小红书账号或店铺小红书开放平台
自己的抖音账号数据抖音开放平台

哪些端点靠得住

候选端点在 SandBase registry 里都是启用状态,2026-10-03(UTC)实测都能跑通:

端点实测情况是否采用
tiktok/shop-web/search-products-list完整页 30 个商品,带 has_more 和 page_token;但经常回兜底短列表用,加重试
tiktok/shop-web/product-detail(v1)能完成,但 global_data.product_info 只有空的 seller_id,没有评分不用
tiktok/shop-web/product-detail-v2有星级分布和类目路径;偶发 HTTP 503 + upstream error 400用,可选
xiaohongshu/app-v2/search-notes每页 20 条笔记,带计数和时间戳用
xiaohongshu/app-v2/search-products每次 20 张卡,其中 18~19 张是带 price_info 的商品卡用
douyin/search/video-search-v2每页 7~8 张卡,每个视频带 statistics用

说下商品详情的失败。之前有测试提过这个接口会报「region mismatch」,我一次也没见到这句话。我见到的是:同一个美区商品 id,有时成功,有时回 HTTP 503 和 upstream error 400。当天大约 20 次练习运行里,有 4 次重试三遍后放弃了星级分布;最后正式的 6 次全部成功。所以代码把它当可选项,失败了简报里就不出现星级字段。还有一种「区域」问题容易跟它混淆:同一个商品传 region: "GB"(c0788442-4d80-49e6-92fc-a1f69ac2d21c),返回的是 completed,但带着 region_supported: false 和 supported_regions(ID、JP、MX、MY、PH、SG、TH、US、VN),尽管参考页列了 GB。代码只用 US;要换区域,先看 region_supported。

搜索接口的参考页写明 search_word 必填,另有 offset、page_token 和 region(默认 US):

SandBase 的 tiktok/shop-web/search-products-list 端点参考页,显示 POST /v1/api/tiktok/shop-web/search-products-list 以及 offset、page_token、region、search_word 字段 截图:search-products-list 参考页给出 POST 路由和 Agent 发送的四个字段;示例里 outputs[0].data 是空对象,所以下文的字段都来自实测调用(2026-10-03 截取)。

实测记录:2026-10-03(UTC)

输入就是上面两组通用商品词,TikTok 区域为 US。每个请求都是 POST https://api.sandbase.ai/v1/api/<vendor>/<path>,带 Authorization: Bearer $SANDBASE_API_KEY,JSON body 只放该端点自己的字段,程序只读 outputs[0].data。下面出现的业务字段名都来自这些调用,属于实测观察,不是文档保证,所以代码一律用 .get() 读。

curl -s https://api.sandbase.ai/v1/api/tiktok/shop-web/search-products-list \
  -H "Authorization: Bearer $SANDBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"search_word": "portable blender", "region": "US", "offset": 0, "page_token": ""}'

某次运行的第一次调用(788a0b0d-6927-4c25-9bac-0046471254fb)回的是短列表,重试(02a0296a-504e-4aeb-916d-93ba80a96a49)拿到完整页。截短后保留一张品牌自营商品卡:

{"code": 0, "message": "success", "data": {
  "has_more": true,
  "load_more_params": {"api_source": 2, "offset": 30, "page_token": "20261003043528F72A01878A90B7109715"},
  "products": [{
    "product_id": "1732254901028033219",
    "brand_info": {"brand_name": "ZHENMI"},
    "sold_info": {"sold_count": 24647},
    "rate_info": {"review_count": "2394", "score": 4.1},
    "product_price_info": {"currency_name": "USD", "sale_price_decimal": "32.99", "origin_price_decimal": "99.99"}
  }]}}

短列表长这样:products 只有 10 个,has_more: false,load_more_params 里 offset: 0、page_token 为空。不管我传什么 offset 和 token,这个词每次回的都是同样 10 个商品 id(防晒霜则固定是 5 个)。代码就靠这个特征识别它。

product-detail-v2 传 {"product_id": "1731581114818794403", "region": "US"}(f5d014ea-b8bb-494a-8c64-c505b58eb6d9),返回一个 components_map 列表,其中 product_info 组件里有:

{"reviews_info": {"review_ratings": {"overall_score": 4.3, "review_count": "12280",
  "rating_result": {"1": "1355", "2": "348", "3": "619", "4": "1086", "5": "8872"}}}}

bread_crumbs 组件是 TikTok Shop、Household Appliances、Kitchen Appliances、Juicers & Blenders。整个响应约 487 KB,代码只留星级分布。

search-notes 传 {"keyword": "便携榨汁杯", "page": 1, "search_id": "", "search_session_id": ""}(ace7c7d1-cf41-4638-8088-fca7eea678ba),search_id 和 search_session_id 跟 data 平级返回,data.items[].note 有 20 条。去掉 id、正文和时间戳后的一条:

{"type": "video", "liked_count": 2885, "collected_count": 5744, "comments_count": 20,
 "shared_count": 600, "timestamp": "<unix 秒级时间戳,已隐去>"}

把这两个 id 带回去请求 page: 2,探测时拿到了 20 条新笔记;不带的话第 2 页会和第 1 页重叠。即使带上,两页有时也只有 30~35 条不重复的笔记,所以代码按 id 去重。

SandBase 的 xiaohongshu/app-v2/search-notes 端点参考页,列出 keyword、page、search_id、search_session_id 截图:search-notes 参考页写明 page,以及要从首次响应里取回的 search_id 和 search_session_id,Agent 翻第 2 页就是这么传的(2026-10-03 截取)。

search-products 传 {"keyword": "便携榨汁杯"}(0922a59c-2179-48af-a249-4395c022e7e3),卡片在 data.module.data 下,20 张里 19 张的 card_name 是 cosmos_search_goods_card。一张品牌商品卡:

{"title": "小米·米家随行便携榨汁杯2 家用小型多功能水果奶昔电动搅拌果汁机",
 "price_info": {"price": 89.9, "origin_price": 89.9, "foreign_price": "≈$13.57", "symbol": "$"}}

price 看起来是人民币。foreign_price 是换算值,币种在几次运行里变过(一次英镑,一次美元),代码不用它。销量只以「12k+ sold」这样的文字标签出现,我也没算。

video-search-v2 传 {"keyword": "便携榨汁杯", "cursor": 0}(65d1a688-2f06-4401-8744-619eb8894eda),数据直接放在 outputs[0].data 里,没有另外四个端点那层 code/data 包装。business_config.has_more 为 1,business_config.next_page 里有 cursor: 8 和 search_id,翻页还要带上 business_config.backtrace。8 张卡里 7 张是视频,1 张是相关搜索。一个视频的统计:

{"digg_count": 233, "comment_count": 61, "share_count": 122, "collect_count": 54, "play_count": 0}

我看过的每张卡 play_count 都是 0,所以简报不用播放量。

完整代码

单文件,200 行左右,只依赖标准库和 requests。运行:python3 product_brief.py "portable blender" "便携榨汁杯"。

#!/usr/bin/env python3
"""Cross-platform product research brief: TikTok Shop + Xiaohongshu + Douyin for one product.

Usage: python3 product_brief.py "portable blender" "便携榨汁杯"
Writes tiktok_shop.csv, xiaohongshu_notes.csv, douyin_videos.csv and brief.md.
"""
import csv
import json
import os
import re
import sys
import time
from statistics import median

import requests

BASE = "https://api.sandbase.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}"}
NOW = time.time()


def call(model: str, params: dict, retries: int = 2) -> dict:
    """POST /v1/api/<model>; return outputs[0].data or raise with status/error."""
    for attempt in range(retries + 1):
        try:
            resp = requests.post(f"{BASE}/api/{model}", headers=HEADERS, json=params, timeout=120)
            body = resp.json()
        except requests.RequestException as err:  # dropped connection or truncated body
            if attempt == retries:
                raise RuntimeError(f"{model}: {err}") from err
            time.sleep(3 * (attempt + 1))
            continue
        if resp.status_code >= 500 and attempt < retries:
            time.sleep(3 * (attempt + 1))  # upstream hiccup: back off, then retry
            continue
        outputs = body.get("outputs") or []
        if resp.status_code != 200 or body.get("status") != "completed" or not outputs:
            raise RuntimeError(f"{model}: HTTP {resp.status_code} {body.get('status')} {body.get('error')}")
        return outputs[0].get("data") or {}
    raise RuntimeError(f"{model}: retries exhausted")


def num(x) -> float:
    """Counts arrive as ints or numeric strings; anything else counts as 0."""
    try:
        return float(x)
    except (TypeError, ValueError):
        return 0.0


def med(values):
    values = [v for v in values if v is not None]
    return round(median(values), 2) if values else None


def tiktok_shop(keyword: str, pages: int = 2, region: str = "US") -> list[dict]:
    rows, offset, token = [], 0, ""
    for _ in range(pages):
        for _attempt in range(4):  # a short fallback set (has_more false) comes back often; retry it
            data = call("tiktok/shop-web/search-products-list",
                        {"search_word": keyword, "region": region, "offset": offset, "page_token": token})
            page = data.get("data") or {}
            if page.get("has_more"):
                break
            time.sleep(2)
        if rows and not page.get("has_more"):
            break  # don't mix the fallback set into a full first page
        for p in page.get("products") or []:
            price = p.get("product_price_info") or {}
            rate = p.get("rate_info") or {}
            rows.append({"product_id": p.get("product_id"), "title": (p.get("title") or "")[:80],
                         "brand": (p.get("brand_info") or {}).get("brand_name") or "",
                         "price": num(price.get("sale_price_decimal")), "currency": price.get("currency_name"),
                         "sold": int(num((p.get("sold_info") or {}).get("sold_count"))),
                         "rating": num(rate.get("score")), "reviews": int(num(rate.get("review_count")))})
        more = page.get("load_more_params") or {}
        if not page.get("has_more") or not more.get("page_token"):
            break
        offset, token = more.get("offset", offset + 30), more["page_token"]
    return rows


def tiktok_star_mix(product_id: str, region: str = "US") -> dict:
    """Star histogram of one listing from product-detail-v2; returns {} if the call fails."""
    try:
        data = call("tiktok/shop-web/product-detail-v2", {"product_id": product_id, "region": region})
    except RuntimeError as err:
        print(f"  product-detail-v2 skipped: {err}")
        return {}
    comps = {c.get("component_name"): c.get("component_data") or {}
             for c in (data.get("data") or {}).get("components_map") or [] if isinstance(c, dict)}
    hist = ((comps.get("product_info") or {}).get("reviews_info") or {}).get("review_ratings", {}).get("rating_result") or {}
    total = sum(num(v) for v in hist.values())
    low = num(hist.get("1")) + num(hist.get("2"))
    return {"top_listing_star_reviews": int(total),
            "top_listing_low_star_share": round(low / total, 3) if total else None}


def xhs_notes(keyword: str, pages: int = 2) -> list[dict]:
    rows, ids = [], {"search_id": "", "search_session_id": ""}
    for page in range(1, pages + 1):
        data = call("xiaohongshu/app-v2/search-notes", {"keyword": keyword, "page": page, **ids})
        ids = {k: data.get(k) or "" for k in ids}  # paging tokens sit next to `data`
        for item in (data.get("data") or {}).get("items") or []:
            n = item.get("note") or {}
            if n.get("id"):
                rows.append({"note_id": n["id"], "type": n.get("type"), "likes": int(num(n.get("liked_count"))),
                             "saves": int(num(n.get("collected_count"))),
                             "comments": int(num(n.get("comments_count"))),
                             "age_days": round((NOW - num(n.get("timestamp"))) / 86400)})
    return list({r["note_id"]: r for r in rows}.values())  # drop duplicates across pages


def xhs_goods_prices(keyword: str) -> list[float]:
    data = call("xiaohongshu/app-v2/search-products", {"keyword": keyword})
    cards = ((data.get("data") or {}).get("module") or {}).get("data") or []
    return [num((c.get("content") or {}).get("price_info", {}).get("price"))
            for c in cards if c.get("card_name") == "cosmos_search_goods_card"]


def douyin_videos(keyword: str, pages: int = 3) -> list[dict]:
    rows, params = [], {"keyword": keyword, "cursor": 0}
    for _ in range(pages):
        data = call("douyin/search/video-search-v2", params)
        for card in data.get("business_data") or []:
            a = (card.get("data") or {}).get("aweme_info") or {}
            s = a.get("statistics") or {}
            if a.get("aweme_id"):
                rows.append({"aweme_id": a["aweme_id"], "likes": int(num(s.get("digg_count"))),
                             "comments": int(num(s.get("comment_count"))), "shares": int(num(s.get("share_count"))),
                             "saves": int(num(s.get("collect_count"))),
                             "age_days": round((NOW - num(a.get("create_time"))) / 86400)})
        cfg = data.get("business_config") or {}
        nxt = cfg.get("next_page") or {}
        if not cfg.get("has_more") or not nxt:
            break
        params = {"keyword": keyword, "cursor": nxt.get("cursor"), "search_id": nxt.get("search_id", ""),
                  "backtrace": cfg.get("backtrace", "")}
    return list({r["aweme_id"]: r for r in rows}.values())


def summarize(tt, star, notes, prices, videos) -> dict:
    sold = sorted((r["sold"] for r in tt), reverse=True)
    rated = [r["rating"] for r in tt if r["reviews"] > 0]
    eng = lambda r: r["likes"] + r["saves"] + r["comments"]  # noqa: E731
    return {
        "tiktok_shop": {"listings": len(tt), "with_sales": sum(1 for s in sold if s > 0),
                        "median_price_usd": med([r["price"] for r in tt]), "total_sold": sum(sold),
                        "top3_sold_share": round(sum(sold[:3]) / sum(sold), 3) if sum(sold) else None,
                        "median_rating_reviewed": med(rated), **star},
        "xiaohongshu": {"notes": len(notes), "median_engagement": med([eng(n) for n in notes]),
                        "median_saves": med([n["saves"] for n in notes]),
                        "posted_last_90d": sum(1 for n in notes if n["age_days"] <= 90),
                        "video_notes": sum(1 for n in notes if n["type"] == "video"),
                        "goods_cards": len(prices), "median_goods_price_cny": med(prices)},
        "douyin": {"videos": len(videos), "median_likes": med([v["likes"] for v in videos]),
                   "median_engagement": med([eng(v) + v["shares"] for v in videos]),
                   "posted_last_90d": sum(1 for v in videos if v["age_days"] <= 90)},
    }


def narrate(stats: dict, en_kw: str, zh_kw: str) -> tuple[str, dict]:
    prompt = (f"Product: '{en_kw}' (TikTok Shop US) / '{zh_kw}' (Xiaohongshu, Douyin). "
              "Write a 120-180 word stocking brief: demand signal, competition, price band, risks, and one "
              "next check. Sample sizes are listings, notes, goods_cards and videos; call one under 30 thin. "
              "Use only numbers that appear in this JSON; do not compute new ones.\n"
              + json.dumps(stats, ensure_ascii=False))
    resp = requests.post(f"{BASE}/chat/completions", headers=HEADERS, timeout=180,
                         json={"model": "openai/gpt-6.1-sol", "max_completion_tokens": 800,
                               "messages": [{"role": "user", "content": prompt}]})
    resp.raise_for_status()
    body = resp.json()
    return body["choices"][0]["message"]["content"] or "", body.get("usage") or {}


def unknown_numbers(text: str, stats: dict) -> list[str]:
    """Numbers in the narrative that are not in the stats JSON (percent forms of shares allowed)."""
    known = set()
    for v in re.findall(r"\d+(?:\.\d+)?", json.dumps(stats)):
        known |= {v, v.rstrip("0").rstrip(".") if "." in v else v}
        if float(v) < 1:  # shares may be quoted as percentages
            known |= {f"{float(v) * 100:.1f}".rstrip("0").rstrip("."), str(round(float(v) * 100))}
    found = [n.replace(",", "") for n in re.findall(r"\d[\d,]*(?:\.\d+)?", text)]
    return sorted({n for n in found if n not in known})


def write_csv(path: str, rows: list[dict]):
    with open(path, "w", newline="", encoding="utf-8-sig") as f:
        if rows:
            w = csv.DictWriter(f, fieldnames=list(rows[0]))
            w.writeheader()
            w.writerows(rows)


if __name__ == "__main__":
    en_kw, zh_kw = sys.argv[1], sys.argv[2]
    tt = tiktok_shop(en_kw)
    top = max(tt, key=lambda r: r["sold"]) if tt else None
    star = tiktok_star_mix(top["product_id"]) if top else {}
    notes, prices, videos = xhs_notes(zh_kw), xhs_goods_prices(zh_kw), douyin_videos(zh_kw)
    write_csv("tiktok_shop.csv", tt)
    write_csv("xiaohongshu_notes.csv", notes)
    write_csv("douyin_videos.csv", videos)
    stats = summarize(tt, star, notes, prices, videos)
    text, usage = narrate(stats, en_kw, zh_kw)
    flags = unknown_numbers(text, stats)
    with open("brief.md", "w", encoding="utf-8") as f:
        f.write(f"# Product brief: {en_kw} / {zh_kw}\n\n```json\n{json.dumps(stats, ensure_ascii=False, indent=2)}\n```\n\n")
        f.write(text + "\n\n" + (f"Check these numbers: {', '.join(flags)}\n" if flags else "Numbers check: OK\n"))
    print(json.dumps(stats, ensure_ascii=False, indent=2))
    print(text)
    print("unverified numbers:", flags or "none", "| usage:", usage)

改代码之前,先知道这几个取舍:

  • 用中位数,不用平均数。 防晒霜里有个标着「COMING SOON」的商品,价格 3000 美元、销量 0。平均价会被它明显拉高,中位数几乎不动。
  • 兜底短列表的处理。 has_more 为 false 的页最多试四次。第 1 页是完整的、第 2 页却一直是短列表时,循环直接停,不把那 5 个或 10 个无关的兜底商品塞进样本。如果第 1 页一直没恢复,简报就建立在短列表上,所以 prompt 要求模型标出样本偏少。代价是:真正很短的第 2 页也会被丢掉。对宽泛的商品词,我没遇到过这种情况。
  • 两边的「互动」口径不一样。 小红书是点赞+收藏+评论,抖音还加了分享。两个中位数不能当同一个指标比。
  • 数字检查。 unknown_numbers 把 LLM 文字里的每个数字拿去跟统计 JSON 对,允许把占比写成百分数。最后六次运行一个都没标出来。但它分辨不了「数字对、句子放错」这种情况。

为什么让 GPT-6.1 Sol 写文字

在我们的 Claude Sonnet 5.5 和 GPT-6.1 Sol 工具调用对比里,GPT-6.1 Sol 短任务 30 次全对,单任务成本中位数约 $0.0066。那次测试也说明了模型容易在哪翻车:对原始工具输出做平均和计数。所以这里模型根本看不到原始商品列表,它只拿到每个平台十来个数字的 JSON,负责写成文字。

SandBase 的 openai/gpt-6.1-sol 模型页,显示输入每百万 token $2.00、输出每百万 token $10.00 截图:GPT-6.1 Sol 模型页标价为输入 $2.00 / 百万 token、输出 $10.00 / 百万 token,下面的成本就按这个算(2026-10-03 截取)。

正式的六份简报,prompt 284~290 token,completion 269~485 token,其中推理 token 0~213。按 $2/M 输入、$10/M 输出算(目录里另有 prompt 超过 272K 的更高档,这里远远用不到),每份 $0.0033~$0.0054,六份合计 $0.027。数据调用按当天标价不花钱:下面的抖音模型页写的是 Free,另外四个数据端点当天 GET /v1/models/<model> 返回的 base_price 也都是 "0"。这是 2026-10-03 的标价,不是承诺。

SandBase 的 douyin/search/video-search-v2 模型页,显示基础价格 Free、同步执行、8 个输入字段 截图:douyin/search/video-search-v2 模型页显示基础价格 Free、同步执行、8 个输入字段,包括 Agent 翻页用的 cursor、search_id 和 backtrace(2026-10-03 截取)。

每组词跑三次的结果

2026-10-03 UTC 04:33~04:38,每组词各跑三次。三次之间的范围:

指标portable blender / 便携榨汁杯sunscreen / 防晒霜
TikTok 保留的商品数60、60、6060、60、30
TikTok 价格中位数(美元)16.09~16.8620.91~23.20
TikTok 前三名销量占比0.636~0.6540.421~0.615
TikTok 评分中位数(有评价的商品)4.45~4.54.7
销冠 1、2 星占比0.1390.04~0.138
小红书去重后笔记数30~4032~40
小红书互动中位数160.5~35452~79.5
小红书商品标价中位数(人民币)57.99~59.954~103.25
抖音视频数16~2120
抖音点赞中位数101~1528,887~8,899

TikTok Shop 只要拿到完整样本就很稳,跑一次像样的结果,价格带和集中度就能用。防晒霜有一次销冠换了个商品,低星占比从 0.138 变成 0.04,所以这个数字旁边一定要写明是哪个商品。小红书最飘:「便携榨汁杯」的互动中位数在每隔两分钟的三次运行里依次是 263.5、354、160.5,练习运行里更是 61~354。抖音变化小一些,但结果偏旧:约 20 个视频里只有 3~5 个是近 90 天发的,因为默认按相关度排序。

这是程序写的一份简报片段(就是上面 02a0296a... 那次搜索所在的运行,LLM 调用 3eb63d10-25c2-412a-90b2-a6de9dab4770,输入 284、输出 485 token):

Competition: TikTok sales are concentrated: top-listing concentration, measured by top3_sold_share, is 0.654. Enter cautiously rather than assuming demand is evenly accessible. Price band: The data establish median price anchors, not a defensible range: USD 16.86 on TikTok and CNY 59.9 on Xiaohongshu. Xiaohongshu’s 19 goods_cards are a thin sample.

数字检查结果是 OK。文字偏保守,甚至有点过于保守,但没有编数字。简报是英文的;中文 prompt 我没测过。

局限和下一步

测试范围先说清楚:两组商品词,TikTok Shop 只测了 US,同一天正式跑 6 次、练习约 20 次。其他 TikTok 区域、长尾词、几十次以上的调用量都没测。三个平台的搜索排序都有个性化,也随时间变,所以简报描述的是「那一刻搜索展示了什么」,不是市场全貌。

可以往这几个方向扩:

动手的话,先拿一把 SandBase API Key,LLM 这一步参考 Chat Completions 指南。

常见问题

一个关键词能同时查 TikTok Shop、小红书和抖音吗?

不太行。TikTok Shop 美区商品是英文的,所以这边用英文词,小红书和抖音用中文词。两个词搜出来的不是同一批商品,跨平台比集中度和价格带,别比总数。

TikTok Shop 为什么只回了 5 个或 10 个商品?

那是兜底短列表:has_more 为 false,page_token 为空,不管传什么 offset 都是同一批商品 id。最后一轮 21 次搜索调用里出现了 10 次。程序每页最多请求四次;6 次正式运行里有 1 次第 2 页始终没恢复,简报只保留了 30 个商品。

小红书搜索会给笔记总数吗?

我看到的响应里没有。简报统计的是实际拿到的笔记(两页,最多 40 条,去重后),这是样本量,不代表话题有多大。

一份简报多少钱?

按 2026-10-03 的标价,五个数据端点都是 Free。GPT-6.1 Sol 调用最多用了 290 输入、485 输出 token,约 $0.0054。每天给 20 个商品各出一份,LLM 费用大约 $0.11。大批量跑之前先看一眼模型页。

为什么不让 LLM 直接读原始商品数据?

模型最容易在算数上出错,而一个 TikTok 商品详情响应就有约 487 KB。Python 把数算好,模型围绕这些数写字,检查环节再把不在统计里的数字挑出来。