Files
catbus/docs/examples/user-items-backup.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

144 lines
6.0 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
# 备份某个用户发布的全部内容:元数据写成 items.jsonl,媒体(图片 / 视频)下载到 media/。
# 中断后用同样的参数再运行:已经下载过的内容跳过,从没下完的地方继续。
#
# 用法:
# docs/examples/user-items-backup.sh <platform> <user> [备份目录,默认 backup-<platform>]
#
# 例子:
# docs/examples/user-items-backup.sh bilibili "<space_url>"
# docs/examples/user-items-backup.sh xhs "<user_url>" ./xhs-backup
# docs/examples/user-items-backup.sh douyin me
# MAX=50 docs/examples/user-items-backup.sh x @<username>
#
# 环境变量:
# MAX 最多备份多少条,默认全部(user items --all)
# SLEEP 两次下载之间的间隔秒数,默认 3
# CATBUS catbus 命令,默认 catbus;从源码运行时可设为 "node /path/to/catbus/dist/cli/main.js"
#
# 备份目录的内容:
# items.jsonl 每行一个 Item(字段见 AGENTS.md 6.2)。每次运行都重新拉取;列表中途失败时与上次的合并,不丢数据
# media/ item download 下载的文件,文件名为 <platform>_<id>_<序号>.<ext>
# downloaded.txt 已经下载完的 item id,续跑时跳过
# failed.txt 下载失败的 item id 和原因(下次运行会重试)
#
# 平台的 item download 还没实现时,只备份元数据。遇到 RISK_CONTROL(退出码 5)时立即停下,
# 过一会儿再运行即可续跑;单条内容的其他错误(已删除、不可见等)记到 failed.txt,继续下一条。
#
# 依赖: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
user=$2
dir=${3:-backup-$platform}
command -v jq >/dev/null || { echo "需要 jq:https://jqlang.org/download/" >&2; exit 2; }
if [[ -n ${MAX:-} && ! $MAX =~ ^[1-9][0-9]*$ ]]; then
echo "MAX 要是正整数:$MAX" >&2
exit 2
fi
read -r -a CATBUS_CMD <<<"${CATBUS:-catbus}"
cb() { "${CATBUS_CMD[@]}" "$@"; }
envelope() {
local line
line=$(grep '^{' "$1" | tail -n 1 || true)
if [[ -n $line ]]; then printf '%s\n' "$line"; else echo '{}'; fi
}
err_field() { jq -r ".error.$1 // empty" <<<"$2"; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$dir/media"
touch "$dir/downloaded.txt"
# 1. 这个平台支不支持 user items / item download(platforms 只读注册表,不联网)
supports() {
cb platforms "$platform" | jq -e --arg r "$1" --arg a "$2" \
'any(.data.commands[]; .endpoint == "web" and .resource == $r and .action == $a and .status == "implemented")' >/dev/null
}
if ! supports user items; then
echo "$platform 的 user items 还没有实现,没法备份(catbus platforms $platform 查看支持的命令)" >&2
exit 4
fi
can_download=1
if ! supports item download; then
can_download=0
echo "$platform 的 item download 还没有实现,只备份元数据" >&2
fi
# 2. 元数据:user items 一次翻完(-o jsonl 边翻边输出)
paging=(--all)
[[ -z ${MAX:-} ]] || paging=(--limit "$MAX")
echo "拉取 $user 发布的内容…" >&2
rc=0
cb "$platform" user items "$user" "${paging[@]}" -o jsonl >"$tmp/items" 2>"$tmp/err" </dev/null || rc=$?
env=$(envelope "$tmp/err")
if [[ $rc -ne 0 ]]; then
got=$(grep -c . "$tmp/items" || true)
echo "拉取列表时出错($(err_field code "$env")):$(err_field message "$env")" >&2
[[ -z $(err_field hint "$env") ]] || echo "建议:$(err_field hint "$env")" >&2
# 没取到任何条目时直接退出;取到一部分时照样备份这一部分
if [[ $got -eq 0 ]]; then exit "$rc"; fi
echo "只取到 $got 条,先备份这些;之后再运行一次可以补全" >&2
# 列表不完整时不丢掉上次的元数据:新取到的在前,上次有、这次没取到的接在后面(按 id 去重)
if [[ -s $dir/items.jsonl ]]; then
jq -c -n 'reduce inputs as $x ({seen: {}, out: []};
if .seen[$x.id] then . else .seen[$x.id] = true | .out += [$x] end) | .out[]' \
"$tmp/items" "$dir/items.jsonl" >"$tmp/merged"
mv "$tmp/merged" "$tmp/items"
fi
fi
mv "$tmp/items" "$dir/items.jsonl"
total=$(grep -c . "$dir/items.jsonl" || true)
echo "元数据:$total 条 → $dir/items.jsonl" >&2
[[ $can_download == 1 ]] || exit "$rc"
# 3. 媒体:逐条 item download。传 url 而不是 id:小红书的 url 带 xsec_token,只给 id 取不到详情
: >"$dir/failed.txt"
done_count=0
skipped=0
first=1
while IFS=$'\t' read -r id url; do
if grep -Fxq -- "$id" "$dir/downloaded.txt"; then
skipped=$((skipped + 1))
continue
fi
[[ $first == 1 ]] || sleep "${SLEEP:-3}"
first=0
target=${url:-$id}
rc=0
cb "$platform" item download "$target" --dir "$dir/media" -q >"$tmp/out" 2>"$tmp/err" </dev/null || rc=$?
if [[ $rc -eq 0 ]]; then
files=$(jq -r '.data | length' "$tmp/out")
echo "$id" >>"$dir/downloaded.txt"
done_count=$((done_count + 1))
echo "[$((done_count + skipped))/$total] $id:$files 个文件" >&2
continue
fi
# 失败时信封在 stdout(-o json)
env=$(envelope "$tmp/out")
code=$(err_field code "$env")
message=$(err_field message "$env")
case $rc in
5) echo "被风控拦下($(jq -r '.error.detail.kind // "未知"' <<<"$env")):$message" >&2
echo "已下载 $((done_count + skipped))/$total 条。过一会儿用同样的参数重新运行,会从这里继续。" >&2
exit 5 ;;
3 | 6) echo "$id:$message${code:+($code)}" >&2
echo "登录态或网络有问题,先停下。解决后重新运行即可续跑。" >&2
exit "$rc" ;;
*) printf '%s\t%s\t%s\n' "$id" "${code:-ERROR}" "$message" >>"$dir/failed.txt"
echo "$id:下载失败(${code:-ERROR}):$message,继续下一条" >&2 ;;
esac
done < <(jq -r '[.id, (.url // "")] | @tsv' "$dir/items.jsonl")
failed=$(grep -c . "$dir/failed.txt" || true)
echo "完成:本次下载 $done_count 条,之前已下载 $skipped 条,失败 $failed 条 → $dir/media" >&2
[[ $failed -eq 0 ]] || { echo "失败的条目见 $dir/failed.txt,重新运行会再试一次" >&2; exit 1; }