Files
catbus/docs/examples/comments-export.sh
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

145 lines
5.9 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 导出某条内容的全部评论到 jsonl:一直翻到没有更多;中途被风控或断网时停下,
# 把游标存起来,下次用同样的参数再运行就从断点继续。
#
# 用法:
# docs/examples/comments-export.sh <platform> <item> [输出文件,默认 comments-<platform>.jsonl]
#
# 例子:
# docs/examples/comments-export.sh bilibili BV1xx411c7mD
# docs/examples/comments-export.sh xhs "<note_url>" note-comments.jsonl
# REPLIES=1 docs/examples/comments-export.sh douyin "<video_url>"
#
# 环境变量:
# PAGE 每次调用取多少条,默认 200
# SLEEP 两次调用之间的间隔秒数,默认 5(小红书的评论接口请求太密会要求人机验证)
# REPLIES=1 评论导完后,再用 comment replies --all 导出每条评论的回复,写到 <输出文件>.replies.jsonl
# CATBUS catbus 命令,默认 catbus;从源码运行时可设为 "node /path/to/catbus/dist/cli/main.js"
#
# 为什么不一次 --all:一次调用中途失败时,摘要信封的 page 为 null,拿不到续翻的游标。
# 所以分批调用(每批 --limit $PAGE,catbus 在批内自动翻页),批与批之间把 page.cursor 存进 <输出文件>.cursor。
# 遇到 RISK_CONTROL(退出码 5)或 NETWORK(退出码 6)时停下;这一批的结果丢掉,续翻时从存下的游标重取,
# 不会重复也不会漏。全部导出后删除 .cursor 文件。要从头导出,先删掉输出文件和 .cursor 文件。
#
# 退出码:0 全部导出;5 被风控拦下(可续翻);6 网络错误(可续翻);其余同 catbus(见 AGENTS.md 6.4)。
#
# 依赖:bash、jq。
set -euo pipefail
usage() {
sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' >&2
exit 2
}
[[ $# -ge 2 && -n $1 && -n $2 ]] || usage
platform=$1
item=$2
out=${3:-comments-$platform.jsonl}
page=${PAGE:-200}
state=$out.cursor
[[ $page =~ ^[1-9][0-9]*$ ]] || { echo "PAGE 要是正整数:$page" >&2; exit 2; }
command -v jq >/dev/null || { echo "需要 jq:https://jqlang.org/download/" >&2; exit 2; }
read -r -a CATBUS_CMD <<<"${CATBUS:-catbus}"
cb() { "${CATBUS_CMD[@]}" "$@"; }
# -o jsonl 时,摘要信封是 stderr 里最后一个 JSON 行;没有时返回 {}
envelope() {
local line
line=$(grep '^{' "$1" | tail -n 1 || true)
if [[ -n $line ]]; then printf '%s\n' "$line"; else echo '{}'; fi
}
count() { grep -c . "$1" || true; }
# 按退出码说明失败原因;$3 为 1 时提示可以续翻
explain() {
local rc=$1 env=$2 resumable=$3 code message hint
code=$(jq -r '.error.code // "ERROR"' <<<"$env")
message=$(jq -r '.error.message // "未知错误"' <<<"$env")
hint=$(jq -r '.error.hint // empty' <<<"$env")
case $rc in
5) echo "被风控拦下($(jq -r '.error.detail.kind // "未知"' <<<"$env")):$message" >&2 ;;
6) echo "网络错误:$message(检查代理:catbus config get proxy)" >&2 ;;
3) echo "需要登录:$message${hint:+。登录:$hint}" >&2 ;;
*) echo "出错($code,退出码 $rc):$message${hint:+。建议:$hint}" >&2 ;;
esac
if [[ $resumable == 1 ]]; then
echo "已导出 $(count "$out") 条。过一会儿用同样的参数重新运行,会从断点继续。" >&2
fi
}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cursor=
finished=0
if [[ -s $state ]]; then
cursor=$(cat "$state")
echo "从上次的断点继续(游标 $cursor)" >&2
elif [[ -s $out ]]; then
echo "$out 已存在且没有断点:评论已经导出过了,跳过(要重新导出,先删掉它)" >&2
finished=1
fi
touch "$out"
[[ $finished == 1 ]] || : >"$tmp/started"
batch=0
while [[ $finished == 0 ]]; do
[[ $batch -eq 0 ]] || sleep "${SLEEP:-5}"
batch=$((batch + 1))
args=("$platform" comment list "$item" --limit "$page" -o jsonl)
# 写成 --cursor=<值>:游标是不透明字符串,可能以 - 开头
[[ -z $cursor ]] || args+=("--cursor=$cursor")
rc=0
cb "${args[@]}" >"$tmp/batch" 2>"$tmp/err" </dev/null || rc=$?
env=$(envelope "$tmp/err")
if [[ $rc -ne 0 ]]; then
# 游标只在成功的批次之后更新,所以这一批丢掉即可:续翻时从 $cursor 重取
if [[ -n $cursor ]]; then printf '%s' "$cursor" >"$state"; fi
# 风控和网络错误过一会儿可能恢复;其余错误(未登录、参数错、平台不支持)要先改命令或登录。游标都保留着
resumable=0
[[ $rc -ne 5 && $rc -ne 6 ]] || resumable=1
explain "$rc" "$env" "$resumable"
exit "$rc"
fi
cat "$tmp/batch" >>"$out"
echo "第 $batch 批:$(count "$tmp/batch") 条,累计 $(count "$out") 条" >&2
has_more=$(jq -r '.page.has_more // false' <<<"$env")
next=$(jq -r '.page.cursor // empty' <<<"$env")
if [[ $has_more != true || -z $next || $next == "$cursor" ]]; then
finished=1
else
cursor=$next
printf '%s' "$cursor" >"$state"
fi
done
rm -f "$state"
[[ ! -e $tmp/started ]] || echo "评论导出完成:$(count "$out") 条 → $out" >&2
[[ ${REPLIES:-} == 1 ]] || exit 0
# 回复:对每条有回复的评论调一次 comment replies --all(单条评论的回复一般不多,失败时整条重跑即可)
replies=$out.replies.jsonl
: >"$replies"
jq -r 'select((.stats.replies // 0) > 0) | .id' "$out" | sort -u >"$tmp/ids"
echo "导出 $(count "$tmp/ids") 条评论的回复 → $replies" >&2
while read -r cid; do
sleep "${SLEEP:-5}"
rc=0
cb "$platform" comment replies "$item" "$cid" --all -o jsonl >"$tmp/batch" 2>"$tmp/err" </dev/null || rc=$?
cat "$tmp/batch" >>"$replies"
if [[ $rc -eq 2 || $rc -eq 4 ]]; then
echo "$platform 不支持 comment replies:$(envelope "$tmp/err" | jq -r '.error.message // empty')" >&2
break
fi
if [[ $rc -ne 0 ]]; then
echo "导出评论 $cid 的回复时中断,已导出的回复在 $replies:" >&2
explain "$rc" "$(envelope "$tmp/err")" 0
exit "$rc"
fi
done <"$tmp/ids"
echo "回复导出完成:$(count "$replies") 条" >&2