#!/usr/bin/env bash # ============================================================================== # 微信视频号助手 · WeChat Channels CLI # ---------------------------------------------------------------------------- # 功能: # 1. 自动识别运行平台(macOS / Linux)与依赖环境 # 2. 探测本机微信客户端是否在线,并读取当前登录的微信账号基本信息 # 3. 询问用户是否登录(回车确认),发起微信 OAuth 授权 # · 微信在线 → 走客户端「一键快捷授权」(免扫码) # · 微信离线 → 自动降级为终端二维码扫码登录 # 4. 登录成功后拉取视频号资料:头像、昵称、粉丝数、作品数、认证状态、粉丝趋势等 # # 依赖:curl(必需)、jq 或 python3(必需,二选一) # 可选:qrencode / chafa / viu / imgcat(更炫的终端渲染) # # 用法(云端一键运行): # curl -fsSL channel.agenticlab.sh | bash # curl -fsSL channel.agenticlab.sh | bash -s -- --qr # 带参数 # # 本地运行: # ./wxchannels.sh 正常交互运行 # ./wxchannels.sh --yes 跳过确认,直接登录 # ./wxchannels.sh --qr 强制走二维码登录 # ./wxchannels.sh --logout 清除本地会话 # ./wxchannels.sh --json 仅输出 JSON 结果(脚本友好) # ./wxchannels.sh --no-color 关闭颜色 # ./wxchannels.sh --no-shell 登录后不进入交互控制台 # ./wxchannels.sh --relogin 忽略已有会话,强制重新授权 # ./wxchannels.sh --debug 打印调试信息 # ============================================================================== set -uo pipefail # ------------------------------------------------------------------ 常量配置 -- readonly APP_NAME="Agentic Lab 测试cli登录工具" readonly APP_VER="2.0.0" readonly WX_APPID="wx20d84d087938fd46" readonly WX_SCOPE="snsapi_login" readonly REDIRECT_URI="https://channels.weixin.qq.com/platform/oauth-callback.html?dark=1" readonly API_BASE="https://channels.weixin.qq.com/cgi-bin/mmfinderassistant-bin" readonly API_MICRO="https://channels.weixin.qq.com/micro/content/cgi-bin/mmfinderassistant-bin" readonly LOCAL_HOST="localhost.weixin.qq.com" LOCAL_PORTS=(14013 14014 14015 13013 13014 13015) readonly UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" # 运行方式检测:curl … | bash 时 $0 是 "bash",标准输入被脚本本身占用 PIPED=0 if [[ ! -f "${BASH_SOURCE[0]:-}" || "${0}" == "bash" || "${0}" == "-bash" || "${0}" == "sh" || "${0}" == "-" ]]; then PIPED=1 fi if [[ $PIPED -eq 1 ]]; then SELF_CMD="curl -fsSL channel.agenticlab.sh | bash -s --"; else SELF_CMD="$0"; fi # 交互输入一律走 /dev/tty —— 管道执行时 stdin 是脚本本体,不能用来读用户输入 TTY_OK=0 { exec 9/dev/null && TTY_OK=1 rd() { # rd <变量名>:从终端读一行,读不到就返回空 local __v="" if [[ $TTY_OK -eq 1 ]]; then IFS= read -r __v <&9 2>/dev/null || __v=""; fi printf -v "$1" '%s' "$__v" } WORKDIR="${WXCH_HOME:-$HOME/.wxchannels}" COOKIE_JAR="$WORKDIR/cookies.txt" TMPDIR_RUN="" # ------------------------------------------------------------------ 运行参数 -- OPT_YES=0; OPT_QR=0; OPT_JSON=0; OPT_DEBUG=0; OPT_COLOR=1; OPT_LOGOUT=0 OPT_SHELL=1; OPT_RELOGIN=0; SHOW_HELP=0 for arg in "$@"; do case "$arg" in -y|--yes) OPT_YES=1 ;; -q|--qr) OPT_QR=1 ;; -j|--json) OPT_JSON=1; OPT_COLOR=0 ;; -d|--debug) OPT_DEBUG=1 ;; --no-color) OPT_COLOR=0 ;; --logout) OPT_LOGOUT=1 ;; -s|--shell) OPT_SHELL=1 ;; --no-shell) OPT_SHELL=0 ;; --relogin) OPT_RELOGIN=1 ;; -h|--help) SHOW_HELP=1 ;; *) echo "未知参数:$arg(--help 查看用法)" >&2; exit 2 ;; esac done [[ -t 1 ]] || OPT_COLOR=0 [[ -n "${NO_COLOR:-}" ]] && OPT_COLOR=0 if [[ $SHOW_HELP -eq 1 ]]; then cat <<'HELPEOF' Agentic Lab · 测试 CLI 登录工具 Build By JMR 云端一键运行: curl -fsSL channel.agenticlab.sh | bash 带参数运行(注意 -s -- ): curl -fsSL channel.agenticlab.sh | bash -s -- --qr 参数: -y, --yes 跳过登录确认,直接授权 -q, --qr 强制走二维码登录 -s, --shell 登录后进入交互控制台(默认开启) --no-shell 登录后只打印资料,不进控制台 --relogin 忽略本地会话,强制重新授权 --logout 退出登录并清除本地会话 -j, --json 只输出 JSON(脚本友好,不进控制台) --no-color 关闭颜色 -d, --debug 打印调试信息 -h, --help 显示本帮助 控制台功能: 数据 账号总览 / 粉丝趋势 / 粉丝画像 / 通知消息 / 合集列表 内容 作品列表 / 作品详情 / 评论管理 修改 置顶 / 可见范围 / 评论权限 / 删除作品(均需二次确认) 其他 API 调试台 / 操作日志 / 退出登录 依赖:curl,以及 jq 或 python3(二选一) 可选:qrencode(终端直接画二维码) 会话文件:~/.wxchannels/cookies.txt 操作日志:~/.wxchannels/oplog.txt HELPEOF exit 0 fi # -------------------------------------------------------------------- 调色板 -- if [[ $OPT_COLOR -eq 1 ]]; then C_RST=$'\033[0m'; C_B=$'\033[1m'; C_DIM=$'\033[2m'; C_IT=$'\033[3m' C_GRN=$'\033[38;5;48m'; C_RED=$'\033[38;5;203m'; C_YLW=$'\033[38;5;221m' C_CYA=$'\033[38;5;51m'; C_MAG=$'\033[38;5;207m'; C_BLU=$'\033[38;5;75m' C_GRY=$'\033[38;5;244m'; C_WHT=$'\033[38;5;255m'; C_ORG=$'\033[38;5;215m' HIDE_CUR=$'\033[?25l'; SHOW_CUR=$'\033[?25h' else C_RST=""; C_B=""; C_DIM=""; C_IT=""; C_GRN=""; C_RED=""; C_YLW="" C_CYA=""; C_MAG=""; C_BLU=""; C_GRY=""; C_WHT=""; C_ORG="" HIDE_CUR=""; SHOW_CUR="" fi # 渐变色带(青 → 紫),用于 banner GRAD=(51 45 39 75 111 147 183 213 207 201) # ------------------------------------------------------------------ 基础输出 -- say() { [[ $OPT_JSON -eq 1 ]] || printf '%b\n' "$*"; } sayn() { [[ $OPT_JSON -eq 1 ]] || printf '%b' "$*"; } ok() { say " ${C_GRN}✔${C_RST} $*"; } fail() { say " ${C_RED}✘${C_RST} $*"; } warn() { say " ${C_YLW}!${C_RST} $*"; } info() { say " ${C_CYA}›${C_RST} $*"; } dbg() { [[ $OPT_DEBUG -eq 1 ]] && printf '%b\n' "${C_GRY}[debug] $*${C_RST}" >&2; return 0; } die() { [[ $OPT_JSON -eq 1 ]] && printf '{"ok":false,"error":%s}\n' "$(json_str "$*")" ; \ say "\n ${C_RED}${C_B}✘ $*${C_RST}\n"; cleanup; exit 1; } hr() { local w=${1:-62} i=0 out="" for ((i=0;i —— 用法 spin_start "文案" ; ... ; spin_stop 0/1 "结果文案" SPIN_PID="" spin_start() { [[ $OPT_JSON -eq 1 || $OPT_COLOR -eq 0 ]] && { sayn " · $1 ... "; return 0; } local msg="$1" printf '%s' "$HIDE_CUR" ( local f=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') i=0 while :; do printf '\r %s%s%s %s' "$C_CYA" "${f[$((i%10))]}" "$C_RST" "$msg" i=$((i+1)); sleep 0.08 done ) 2>/dev/null & SPIN_PID=$! } spin_stop() { local code="$1" msg="${2:-}" if [[ -n "$SPIN_PID" ]]; then kill "$SPIN_PID" 2>/dev/null; wait "$SPIN_PID" 2>/dev/null; SPIN_PID=""; fi [[ $OPT_JSON -eq 1 ]] && return 0 if [[ $OPT_COLOR -eq 1 ]]; then printf '\r\033[2K%s' "$SHOW_CUR"; else printf '\n'; fi if [[ "$code" == "0" ]]; then ok "$msg"; else fail "$msg"; fi } cleanup() { [[ -n "$SPIN_PID" ]] && { kill "$SPIN_PID" 2>/dev/null; wait "$SPIN_PID" 2>/dev/null; } [[ $OPT_COLOR -eq 1 ]] && printf '%s' "$SHOW_CUR" [[ -n "$TMPDIR_RUN" && -d "$TMPDIR_RUN" ]] && rm -rf "$TMPDIR_RUN" [[ $TTY_OK -eq 1 ]] && exec 9<&- 2>/dev/null return 0 } trap 'cleanup' EXIT trap 'echo; say " ${C_YLW}已取消${C_RST}"; cleanup; exit 130' INT TERM # ---------------------------------------------------------------- JSON 工具 -- JSON_ENGINE="" json_str() { local v="${1-}" [[ -z "$v" ]] && { printf '""'; return; } printf '%s' "$v" | tr -d '\n\r' | sed 's/\\/\\\\/g; s/"/\\"/g; s/^/"/; s/$/"/' } # jget path 形如 data.finderUser.fansCount jget() { local body="$1" path="$2" case "$JSON_ENGINE" in jq) printf '%s' "$body" | jq -r --arg p "$path" ' reduce ($p|split(".")[]) as $k (.; if . == null then null elif ($k|test("^[0-9]+$")) then .[($k|tonumber)] else .[$k] end) | if . == null then "" elif type=="object" or type=="array" then tojson else tostring end ' 2>/dev/null ;; py) WXP="$path" python3 -c ' import sys,json,os p=os.environ["WXP"].split(".") try: d=json.load(sys.stdin) except Exception: print(""); sys.exit() for k in p: try: d = d[int(k)] if isinstance(d,list) else d[k] except Exception: print(""); sys.exit() if d is None: print("") elif isinstance(d,(dict,list)): print(json.dumps(d,ensure_ascii=False)) elif isinstance(d,bool): print("true" if d else "false") else: print(d) ' <<<"$body" 2>/dev/null ;; esac } jlen() { local body="$1" path="$2" case "$JSON_ENGINE" in jq) printf '%s' "$body" | jq -r --arg p "$path" ' (reduce ($p|split(".")[]) as $k (.; if .==null then null else .[$k] end)) | if .==null then 0 else length end' 2>/dev/null ;; py) WXP="$path" python3 -c ' import sys,json,os p=os.environ["WXP"].split(".") try: d=json.load(sys.stdin) except Exception: print(0); sys.exit() for k in p: try: d=d[k] except Exception: print(0); sys.exit() print(len(d) if d is not None else 0) ' <<<"$body" 2>/dev/null ;; esac } # ------------------------------------------------------------------ 小工具 -- # --- 终端宽度:中文/emoji 占 2 列,需在 UTF-8 locale 下才能按字符切分 --- UTF8_OK=0 init_locale() { local probe="测试" l [[ ${#probe} -eq 2 ]] && { UTF8_OK=1; return; } for l in "${LC_ALL:-}" "${LANG:-}" C.UTF-8 C.utf8 en_US.UTF-8 zh_CN.UTF-8; do [[ -z "$l" ]] && continue case "$l" in *UTF-8|*utf8|*UTF8) ;; *) continue ;; esac export LC_ALL="$l" [[ ${#probe} -eq 2 ]] && { UTF8_OK=1; return; } done UTF8_OK=0 } # 显示宽度:UTF-8 下 (字符数 + (字节数-字符数)/2) 对 CJK/emoji 精确 dwidth() { local s="${1-}" nc nb if [[ $UTF8_OK -eq 0 ]]; then printf '%d' "${#s}"; return; fi nc=${#s} local LC_ALL=C nb=${#s} printf '%d' $(( nc + (nb - nc) / 2 )) } # 右侧补空格到 N 显示列 dpad() { local s="${1-}" n="${2:-0}" w pad w="$(dwidth "$s")"; pad=$(( n - w )); (( pad < 0 )) && pad=0 printf '%s%*s' "$s" "$pad" "" } # 按显示列截断(超出加省略号),并补齐到 N 列 dfit() { local s="${1-}" n="${2:-10}" out="" w=0 i c cw if [[ $UTF8_OK -eq 0 ]]; then printf '%s' "$(printf '%-*.*s' "$n" "$n" "$s")"; return; fi for ((i=0;i<${#s};i++)); do c="${s:i:1}"; cw="$(dwidth "$c")" if (( w + cw > n - 1 )); then out+="…"; w=$((w+1)); break; fi out+="$c"; w=$((w+cw)) done printf '%s%*s' "$out" "$(( n - w < 0 ? 0 : n - w ))" "" } now_ms() { if date +%s%3N >/dev/null 2>&1 && [[ "$(date +%s%3N)" != *N* ]]; then date +%s%3N else printf '%s000' "$(date +%s)"; fi } rand_hex() { LC_ALL=C tr -dc 'a-f0-9' /dev/null | head -c "${1:-32}" || printf '%032d' "$RANDOM"; } rand_num() { printf '%d%04d' "$(date +%s | tail -c 7)" "$((RANDOM % 10000))"; } # 千分位 comma() { local n="${1:-0}" [[ "$n" =~ ^[0-9]+$ ]] || { printf '%s' "$n"; return; } printf '%s' "$n" | sed -e :a -e 's/\(.*[0-9]\)\([0-9]\{3\}\)/\1,\2/;ta' } # 中文单位:1085 → 1085 ; 23456 → 2.3万 human_cn() { local n="${1:-0}" [[ "$n" =~ ^[0-9]+$ ]] || { printf '%s' "$n"; return; } if (( n >= 100000000 )); then awk -v v="$n" 'BEGIN{printf "%.2f亿", v/100000000}' elif (( n >= 10000 )); then awk -v v="$n" 'BEGIN{printf "%.1f万", v/10000}' else printf '%s' "$n"; fi } DEV_ID="$(rand_hex 32)" AID="$(rand_num)" # --------------------------------------------------------------- 环境探测 -- OS_KIND=""; OS_NAME=""; OS_VER=""; OS_ARCH=""; WX_PROC=0 detect_os() { OS_ARCH="$(uname -m 2>/dev/null || echo unknown)" case "$(uname -s 2>/dev/null)" in Darwin) OS_KIND="macos" OS_VER="$(sw_vers -productVersion 2>/dev/null || echo '?')" OS_NAME="macOS ${OS_VER}" ;; Linux) OS_KIND="linux" if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 OS_NAME="$(. /etc/os-release; echo "${PRETTY_NAME:-$NAME}")" else OS_NAME="Linux $(uname -r)" fi grep -qi microsoft /proc/version 2>/dev/null && OS_NAME="$OS_NAME (WSL)" ;; MINGW*|MSYS*|CYGWIN*) OS_KIND="windows"; OS_NAME="Windows (POSIX 兼容层)" ;; *) OS_KIND="unknown"; OS_NAME="$(uname -s 2>/dev/null || echo Unknown)" ;; esac } detect_wx_process() { WX_PROC=0 case "$OS_KIND" in macos) pgrep -x "WeChat" >/dev/null 2>&1 && WX_PROC=1 [[ $WX_PROC -eq 0 ]] && pgrep -x "Weixin" >/dev/null 2>&1 && WX_PROC=1 ;; linux) pgrep -f "[Ww]e[Cc]hat" >/dev/null 2>&1 && WX_PROC=1 [[ $WX_PROC -eq 0 ]] && pgrep -f "[Ww]eixin" >/dev/null 2>&1 && WX_PROC=1 ;; windows) command -v tasklist >/dev/null 2>&1 && tasklist 2>/dev/null | grep -qi "wechat.exe\|weixin.exe" && WX_PROC=1 ;; esac } check_deps() { command -v curl >/dev/null 2>&1 || die "缺少依赖 curl,请先安装(macOS 自带 / Linux: apt install curl)" if command -v jq >/dev/null 2>&1; then JSON_ENGINE="jq" elif command -v python3 >/dev/null 2>&1; then JSON_ENGINE="py" else die "缺少 JSON 解析器,请安装 jq 或 python3" fi } # ------------------------------------------------------------ HTTP 请求封装 -- # 本地客户端接口(微信 PC/Mac 客户端监听 127.0.0.1 的 https 服务) local_api() { local port="$1" path="$2" body="$3" out="" local -a base=( -sS --max-time 4 --resolve "${LOCAL_HOST}:${port}:127.0.0.1" -H "Content-Type: application/json" -H "Origin: https://open.weixin.qq.com" -H "Referer: https://open.weixin.qq.com/" -H "User-Agent: ${UA}" --data "$body" ) out="$(curl "${base[@]}" "https://${LOCAL_HOST}:${port}${path}" 2>/dev/null)" # 部分旧版客户端本地服务使用自签证书,证书校验失败时降级重试 if [[ -z "$out" ]]; then out="$(curl -k "${base[@]}" "https://${LOCAL_HOST}:${port}${path}" 2>/dev/null)" fi printf '%s' "$out" } # 视频号助手接口 finder_api() { local path="$1" body="$2" base="${3:-$API_BASE}" page="${4:-https://channels.weixin.qq.com/platform}" local url="${base}${path}?_aid=${AID}&_rid=$(rand_hex 8)-$(rand_hex 8)" dbg "POST ${path} <- ${body}" local resp resp="$(curl -sS --max-time 20 \ -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/plain, */*" \ -H "Origin: https://channels.weixin.qq.com" \ -H "Referer: ${page}" \ -H "User-Agent: ${UA}" \ -H "X-WECHAT-UIN: 0000000000" \ -H "finger-print-device-id: ${DEV_ID}" \ --data "$body" "$url" 2>/dev/null)" dbg "RESP ${path} -> ${resp:0:400}" printf '%s' "$resp" } # 通用业务参数 biz_body() { local extra="${1:-}" local ts; ts="$(now_ms)" if [[ -n "$extra" ]]; then printf '{%s,"timestamp":"%s","_log_finder_uin":"","_log_finder_id":"%s","rawKeyBuff":"","pluginSessionId":null,"scene":7,"reqScene":7}' \ "$extra" "$ts" "${FINDER_USERNAME:-}" else printf '{"timestamp":"%s","_log_finder_uin":"","_log_finder_id":"%s","rawKeyBuff":"","pluginSessionId":null,"scene":7,"reqScene":7}' \ "$ts" "${FINDER_USERNAME:-}" fi } # ======================================================== 1. 微信在线探测 ==== WX_ONLINE=0; WX_PORT=""; WX_NICK=""; WX_AVATAR=""; WX_AUTH_UUID="" probe_wechat() { local payload payload=$(printf '{"apiname":"qrconnectchecklogin","jsdata":{"appid":"%s","scope":"%s","redirect_uri":"%s","state":"cli"}}' \ "$WX_APPID" "$WX_SCOPE" "$REDIRECT_URI") local port resp errcode for port in "${LOCAL_PORTS[@]}"; do dbg "probing port $port" resp="$(local_api "$port" "/api/check-login" "$payload")" [[ -z "$resp" ]] && continue errcode="$(jget "$resp" "errcode")" [[ "$errcode" != "0" ]] && { dbg "port $port errcode=$errcode"; continue; } WX_NICK="$(jget "$resp" "jsdata.nickname")" WX_AVATAR="$(jget "$resp" "jsdata.headimgurl")" WX_AUTH_UUID="$(jget "$resp" "jsdata.authorize_uuid")" [[ -z "$WX_AUTH_UUID" ]] && continue WX_PORT="$port"; WX_ONLINE=1 return 0 done return 1 } # 刷新 authorize_uuid(uuid 有时效,授权前重新拿一次更稳) refresh_auth_uuid() { [[ -z "$WX_PORT" ]] && return 1 local payload resp payload=$(printf '{"apiname":"qrconnectchecklogin","jsdata":{"appid":"%s","scope":"%s","redirect_uri":"%s","state":"cli"}}' \ "$WX_APPID" "$WX_SCOPE" "$REDIRECT_URI") resp="$(local_api "$WX_PORT" "/api/check-login" "$payload")" local u; u="$(jget "$resp" "jsdata.authorize_uuid")" [[ -n "$u" ]] && { WX_AUTH_UUID="$u"; return 0; } return 1 } # ======================================================== 2. OAuth 取 code === OAUTH_CODE="" # 2A. 客户端快捷授权 oauth_via_client() { refresh_auth_uuid || true local payload resp errcode url payload=$(printf '{"apiname":"qrconnectfastauthorize","jsdata":{"data":"{\\"x\\":555,\\"y\\":379.5}","appid":"%s","scope":"%s","redirect_uri":"%s","state":"cli","authorize_uuid":"%s"}}' \ "$WX_APPID" "$WX_SCOPE" "$REDIRECT_URI" "$WX_AUTH_UUID") resp="$(local_api "$WX_PORT" "/api/authorize" "$payload")" dbg "authorize resp: $resp" [[ -z "$resp" ]] && return 1 errcode="$(jget "$resp" "errcode")" if [[ "$errcode" != "0" ]]; then OAUTH_ERR="授权被拒绝或已超时" return 1 fi url="$(jget "$resp" "jsdata.redirect_url")" OAUTH_CODE="$(printf '%s' "$url" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')" [[ -n "$OAUTH_CODE" ]] } # 2B. 二维码扫码授权(微信未在线 / 快捷授权失败时降级) render_qr() { [[ $OPT_JSON -eq 1 ]] && return 1 local content="$1" png="$2" if command -v qrencode >/dev/null 2>&1; then say "" qrencode -t ANSIUTF8 -m 2 "$content" 2>/dev/null | sed 's/^/ /' && return 0 fi # 退化:下载官方二维码图片,尝试终端渲染或调用系统看图 curl -sS --max-time 10 -H "User-Agent: ${UA}" -o "$png" "$3" 2>/dev/null if [[ -s "$png" ]]; then if command -v chafa >/dev/null 2>&1; then chafa --size=40x20 "$png" 2>/dev/null | sed 's/^/ /'; return 0 elif command -v viu >/dev/null 2>&1; then viu -w 40 "$png" 2>/dev/null | sed 's/^/ /'; return 0 elif command -v imgcat >/dev/null 2>&1; then imgcat "$png" 2>/dev/null; return 0 fi warn "无法在终端渲染二维码,已保存到:${C_WHT}${png}${C_RST}" case "$OS_KIND" in macos) open "$png" >/dev/null 2>&1 && info "已用系统看图打开" ;; linux) command -v xdg-open >/dev/null 2>&1 && xdg-open "$png" >/dev/null 2>&1 && info "已用系统看图打开" ;; esac return 0 fi warn "二维码获取失败,可手动在浏览器打开:${C_WHT}${content}${C_RST}" return 1 } oauth_via_qrcode() { local html uuid ts ts="$(now_ms)" local qurl="https://open.weixin.qq.com/connect/qrconnect?appid=${WX_APPID}&scope=${WX_SCOPE}&redirect_uri=$(urlenc "$REDIRECT_URI")&state=cli&login_type=jssdk&self_redirect=true&style=white&ts=${ts}" spin_start "正在申请登录二维码" html="$(curl -sS --max-time 15 -H "User-Agent: ${UA}" -H "Referer: https://channels.weixin.qq.com/" "$qurl" 2>/dev/null)" uuid="$(printf '%s' "$html" | sed -n 's#.*/connect/qrcode/\([A-Za-z0-9_-]\{8,\}\).*#\1#p' | head -1)" [[ -z "$uuid" ]] && uuid="$(printf '%s' "$html" | sed -n 's/.*uuid=\([A-Za-z0-9_-]\{8,\}\).*/\1/p' | head -1)" if [[ -z "$uuid" ]]; then spin_stop 1 "二维码申请失败"; return 1; fi spin_stop 0 "二维码已生成 ${C_GRY}(uuid: ${uuid})${C_RST}" render_qr "https://open.weixin.qq.com/connect/confirm?uuid=${uuid}" \ "$TMPDIR_RUN/qr.png" \ "https://open.weixin.qq.com/connect/qrcode/${uuid}" say "" say " ${C_B}${C_YLW}请用微信扫描上方二维码并确认登录${C_RST} ${C_GRY}(5 分钟内有效,Ctrl+C 取消)${C_RST}" say "" local deadline=$(( $(date +%s) + 300 )) last="" local scanned=0 while (( $(date +%s) < deadline )); do local r code errc r="$(curl -sS --max-time 35 -H "User-Agent: ${UA}" -H "Referer: https://open.weixin.qq.com/" \ "https://lp.open.weixin.qq.com/connect/l/qrconnect?uuid=${uuid}&_=$(now_ms)${last}" 2>/dev/null)" errc="$(printf '%s' "$r" | sed -n "s/.*wx_errcode=\([0-9]*\).*/\1/p")" dbg "longpoll errcode=$errc raw=${r:0:120}" case "$errc" in 405) code="$(printf '%s' "$r" | sed -n "s/.*wx_code='\([^']*\)'.*/\1/p")" if [[ -n "$code" ]]; then [[ $scanned -eq 1 ]] && printf '\r\033[2K' ok "扫码确认成功" OAUTH_CODE="$code"; return 0 fi ;; 404) if [[ $scanned -eq 0 ]]; then scanned=1; ok "已扫描,请在手机上点击「确认登录」"; fi last="&last=404" ;; 403) fail "用户在手机上取消了登录"; return 1 ;; 402) fail "二维码已过期,请重新运行脚本"; return 1 ;; 408|"") : ;; # 等待中 *) dbg "未知 errcode: $errc" ;; esac sleep 1 done fail "等待扫码超时" return 1 } urlenc() { local s="$1" out="" c i for ((i=0;i<${#s};i++)); do c="${s:i:1}" case "$c" in [a-zA-Z0-9.~_-]) out+="$c" ;; *) out+="$(printf '%%%02X' "'$c")" ;; esac done printf '%s' "$out" } # ==================================================== 3. 换取会话 + 登录号 === FINDER_USERNAME=""; FINDER_NICK=""; FINDER_AVATAR=""; FINDER_UNIQ="" exchange_session() { local body resp err body="$(printf '{"oauthCode":"%s","timestamp":"%s","_log_finder_uin":"","_log_finder_id":"","rawKeyBuff":"","pluginSessionId":null,"scene":7,"reqScene":7}' \ "$OAUTH_CODE" "$(now_ms)")" resp="$(finder_api "/auth/auth_with_oauth" "$body" "$API_BASE" "https://channels.weixin.qq.com/login.html")" err="$(jget "$resp" "errCode")" [[ "$err" == "0" ]] || { LAST_ERR="$(jget "$resp" "errMsg")"; return 1; } return 0 } FINDER_JSON="" list_finders() { local resp err n resp="$(finder_api "/auth/auth_finder_list" "$(biz_body)" "$API_BASE" "https://channels.weixin.qq.com/login.html")" err="$(jget "$resp" "errCode")" [[ "$err" == "0" ]] || { LAST_ERR="$(jget "$resp" "errMsg")"; return 1; } FINDER_JSON="$resp" n="$(jlen "$resp" "data.finderList")" [[ "${n:-0}" -ge 1 ]] || { LAST_ERR="该微信号下没有可管理的视频号"; return 1; } return 0 } choose_finder() { local n idx=0 n="$(jlen "$FINDER_JSON" "data.finderList")" if [[ "$n" -eq 1 || $OPT_YES -eq 1 || $OPT_JSON -eq 1 || $TTY_OK -eq 0 ]]; then idx=0 else say "" say " ${C_B}检测到 ${C_CYA}${n}${C_RST}${C_B} 个可管理的视频号:${C_RST}" local i for ((i=0;i=1 && sel<=n )) && idx=$((sel-1)) fi FINDER_USERNAME="$(jget "$FINDER_JSON" "data.finderList.${idx}.finderUsername")" FINDER_NICK="$(jget "$FINDER_JSON" "data.finderList.${idx}.nickname")" FINDER_AVATAR="$(jget "$FINDER_JSON" "data.finderList.${idx}.headImgUrl")" FINDER_UNIQ="$(jget "$FINDER_JSON" "data.finderList.${idx}.uniqId")" [[ -n "$FINDER_USERNAME" ]] } login_finder() { local resp err resp="$(finder_api "/auth/login_finder" \ "$(biz_body "$(printf '"finderUsername":"%s"' "$FINDER_USERNAME")")" \ "$API_BASE" "https://channels.weixin.qq.com/login.html")" err="$(jget "$resp" "errCode")" [[ "$err" == "0" ]] || { LAST_ERR="$(jget "$resp" "errMsg")"; return 1; } return 0 } # =========================================================== 4. 拉取资料 ==== AUTH_DATA="" fetch_profile() { AUTH_DATA="$(finder_api "/auth/auth_data" "$(biz_body)")" local err; err="$(jget "$AUTH_DATA" "errCode")" [[ "$err" == "0" ]] || { LAST_ERR="$(jget "$AUTH_DATA" "errMsg")"; return 1; } return 0 } FANS_TREND="" fetch_fans_trend() { local end start end=$(( $(date +%s) / 86400 * 86400 )) start=$(( end - 86400*7 )) FANS_TREND="$(finder_api "/statistic/fans_trend" \ "$(biz_body "$(printf '"startTs":"%s","endTs":"%s","interval":3' "$start" "$end")")")" } POST_LIST="" fetch_posts() { POST_LIST="$(finder_api "/post/post_list" \ "$(biz_body '"pageSize":5,"currentPage":1,"userpageType":11,"stickyOrder":false')" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" } # 认证类型说明 acct_desc() { case "${1:-}" in 1) printf '个人号' ;; 2) printf '企业号' ;; 3) printf '机构号' ;; *) printf '未知(%s)' "${1:-?}" ;; esac } auth_icon_desc() { case "${1:-0}" in 0) printf '未认证' ;; 1) printf '蓝V·企业认证' ;; 2) printf '黄V·个人认证' ;; *) printf '认证类型 %s' "$1" ;; esac } # 迷你趋势条形图 sparkline() { local -a vals=("$@") local n=${#vals[@]} ((n==0)) && return 0 # 归一化到 [min,max] 区间,让小幅波动也能看出形状 local max="${vals[0]}" min="${vals[0]}" v for v in "${vals[@]}"; do (( v > max )) && max=$v (( v < min )) && min=$v done local span=$(( max - min )) local blocks=(▁ ▂ ▃ ▄ ▅ ▆ ▇ █) out="" for v in "${vals[@]}"; do local i if (( span == 0 )); then i=3; else i=$(( (v - min) * 7 / span )); fi out+="${blocks[$i]}" done printf '%s' "$out" } # 卡片行 _dashes() { local i o=""; for ((i=0;i<${1:-58};i++)); do o+="─"; done; printf '%s' "$o"; } card_top() { say " ${C_MAG}╭$(_dashes 58)╮${C_RST}"; } card_bot() { say " ${C_MAG}╰$(_dashes 58)╯${C_RST}"; } # =============================================================== 结果展示 ==== render_result() { local nick avatar fans feeds uniq acct authicon cover master admin nick="$(jget "$AUTH_DATA" "data.finderUser.nickname")" avatar="$(jget "$AUTH_DATA" "data.finderUser.headImgUrl")" fans="$(jget "$AUTH_DATA" "data.finderUser.fansCount")"; fans="${fans:-0}" feeds="$(jget "$AUTH_DATA" "data.finderUser.feedsCount")"; feeds="${feeds:-0}" uniq="$(jget "$AUTH_DATA" "data.finderUser.uniqId")" acct="$(jget "$AUTH_DATA" "data.finderUser.acctType")" authicon="$(jget "$AUTH_DATA" "data.finderUser.authIconType")" admin="$(jget "$AUTH_DATA" "data.finderUser.adminNickname")" master="$(jget "$AUTH_DATA" "data.finderUser.isMasterFinder")" local wxnick; wxnick="$(jget "$AUTH_DATA" "data.userAttr.nickname")" if [[ $OPT_JSON -eq 1 ]]; then printf '{"ok":true,"os":%s,"osKind":%s,"wechatOnline":%s,"wechat":{"nickname":%s},"channel":{"nickname":%s,"uniqId":%s,"avatar":%s,"fansCount":%s,"feedsCount":%s,"acctType":%s,"authIconType":%s,"authDesc":%s,"admin":%s,"isMaster":%s,"finderUsername":%s}}\n' \ "$(json_str "$OS_NAME")" "$(json_str "$OS_KIND")" \ "$([[ $WX_ONLINE -eq 1 ]] && echo true || echo false)" \ "$(json_str "${wxnick:-$WX_NICK}")" \ "$(json_str "$nick")" "$(json_str "$uniq")" "$(json_str "$avatar")" \ "${fans:-0}" "${feeds:-0}" "${acct:-0}" "${authicon:-0}" \ "$(json_str "$(auth_icon_desc "${authicon:-0}")")" \ "$(json_str "$admin")" "${master:-false}" "$(json_str "$FINDER_USERNAME")" return 0 fi say "" hr 60 say "" say " ${C_GRN}${C_B}✔ 登录成功${C_RST} ${C_GRY}会话已保存至 ${COOKIE_JAR}${C_RST}" say "" card_top printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_B$C_WHT" "$nick" "$C_RST" "$C_GRY" "@${uniq}" "$C_RST" say " ${C_MAG}│${C_RST}" # 三宫格数据 printf ' %s│%s %s%s%s%s%s%s%s%s%s\n' \ "$C_MAG" "$C_RST" \ "$C_CYA$C_B" "$(dpad "$(human_cn "$fans")" 16)" "$C_RST" \ "$C_YLW$C_B" "$(dpad "$(human_cn "$feeds")" 16)" "$C_RST" \ "$C_GRN$C_B" "$(dpad "$(acct_desc "$acct")" 16)" "$C_RST" printf ' %s│%s %s%s%s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" \ "$(dpad "粉丝数" 16)" "$(dpad "作品数" 16)" "$(dpad "账号类型" 16)" "$C_RST" say " ${C_MAG}│${C_RST}" kv() { printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "$1" 12)" "$C_RST" "$C_WHT" "$2" "$C_RST"; } kv "粉丝精确值" "$(comma "$fans")" kv "认证状态" "$(auth_icon_desc "${authicon:-0}")" kv "管理员" "${admin}$([[ "$master" == "true" ]] && printf ' %s(主体号)%s' "$C_GRN" "$C_RST")" kv "绑定微信" "${wxnick:-$WX_NICK}" # 粉丝趋势 local totals totals="$(jget "$FANS_TREND" "data.total")" if [[ -n "$totals" && "$totals" != "[]" ]]; then local arr; arr="$(printf '%s' "$totals" | tr -d '[]' | tr ',' ' ')" # shellcheck disable=SC2086 local spark; spark="$(sparkline $arr)" local first last delta sign col first="$(printf '%s' "$arr" | awk '{print $1}')" last="$(printf '%s' "$arr" | awk '{print $NF}')" delta=$(( ${last:-0} - ${first:-0} )) if (( delta > 0 )); then sign="+"; col="$C_GRN" elif (( delta < 0 )); then sign=""; col="$C_RED" else sign="±"; col="$C_GRY"; fi say " ${C_MAG}│${C_RST}" printf ' %s│%s %s%s%s %s%s%s %s%s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "近7日粉丝" 12)" "$C_RST" \ "$C_CYA" "$spark" "$C_RST" "$col" "$sign" "$delta" "$C_RST" fi # 最新作品 local pn; pn="$(jlen "$POST_LIST" "data.list")" if [[ "${pn:-0}" -gt 0 ]]; then say " ${C_MAG}│${C_RST}" printf ' %s│%s %s最新作品%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" local i for ((i=0; i/dev/null || date -r "$ts" '+%m-%d' 2>/dev/null || echo '')" printf ' %s│%s %s%s%s %s%s%s %s▶%s %-6s %s♥%s %-5s %s💬%s %s\n' \ "$C_MAG" "$C_RST" "$C_GRY" "${when}" "$C_RST" "$C_WHT" "$(dfit "$title" 24)" "$C_RST" \ "$C_BLU" "$C_RST" "$(human_cn "${rc:-0}")" \ "$C_RED" "$C_RST" "$(human_cn "${lc:-0}")" \ "$C_ORG" "$C_RST" "$(human_cn "${cc:-0}")" done fi card_bot say "" say " ${C_GRY}提示:会话有效期内可直接访问 ${C_RST}${C_BLU}https://channels.weixin.qq.com/platform${C_RST}" say " ${C_GRY}退出登录:${C_RST}${C_WHT}${SELF_CMD} --logout${C_RST}" say "" } # ============================================================ 交互式控制台 ==== # 可见范围枚举:1=公开 2=仅关注者 3=仅自己 # 置顶操作枚举:0=无操作 1=置顶 2=取消置顶 # 评论开关枚举:0=开启 1=关闭 POSTS_JSON=""; POSTS_PAGE=1; POSTS_SIZE=10 OPLOG="$WORKDIR/oplog.txt" ui_clear() { [[ $OPT_COLOR -eq 1 && $OPT_JSON -eq 0 ]] && printf '\033[H\033[2J\033[3J'; return 0; } pause_key() { say "" sayn " ${C_GRY}按 [回车] 返回菜单…${C_RST}" local _x; rd _x } ask() { # ask <提示> <默认值> -> echo 用户输入 local p="$1" d="${2:-}" v="" if [[ -n "$d" ]]; then sayn " ${C_CYA}${p}${C_RST} ${C_GRY}[${d}]${C_RST}: " else sayn " ${C_CYA}${p}${C_RST}: "; fi rd v [[ -z "$v" ]] && v="$d" printf '%s' "$v" } confirm() { # confirm <文案> -> 0=同意 local v sayn " ${C_YLW}${C_B}$1${C_RST} ${C_GRY}(y/N)${C_RST}: " rd v v="$(printf %s "$v" | tr '[:upper:]' '[:lower:]')" [[ "$v" == "y" || "$v" == "yes" ]] } oplog() { printf '%s\t%s\n' "$(date '+%F %T')" "$*" >> "$OPLOG" 2>/dev/null; } # 统一处理写操作返回 apply_result() { # apply_result <成功文案> <日志> local resp="$1" okmsg="$2" logmsg="$3" e m e="$(jget "$resp" errCode)"; m="$(jget "$resp" errMsg)" if [[ "$e" == "0" ]]; then ok "${okmsg}"; oplog "OK ${logmsg}"; return 0 fi fail "操作失败 ${C_GRY}errCode=${e:-?} ${m}${C_RST}"; oplog "ERR ${logmsg} errCode=${e:-?} ${m}" return 1 } # 漂亮打印任意 JSON pretty_json() { local body="$1" if [[ "$JSON_ENGINE" == "jq" ]]; then printf '%s' "$body" | jq -C . 2>/dev/null | sed 's/^/ /' && return 0 fi printf '%s' "$body" | python3 -m json.tool 2>/dev/null | sed 's/^/ /' && return 0 printf ' %s\n' "${body:0:2000}" } # 从多个候选字段里取第一个非空值:jpick ... jpick() { local body="$1" base="$2"; shift 2 local k v for k in "$@"; do v="$(jget "$body" "${base}.${k}")" [[ -n "$v" && "$v" != "null" ]] && { printf '%s' "$v"; return 0; } done printf '' } # ---------------------------------------------------------------- 状态条 ---- console_header() { local nick fans feeds nick="$(jget "$AUTH_DATA" data.finderUser.nickname)" fans="$(jget "$AUTH_DATA" data.finderUser.fansCount)" feeds="$(jget "$AUTH_DATA" data.finderUser.feedsCount)" say "" say " ${C_MAG}╭$(_dashes 58)╮${C_RST}" printf ' %s│%s %s%s%s %s粉丝%s %s%s%s %s作品%s %s%s%s %s● 已登录%s\n' \ "$C_MAG" "$C_RST" "$C_B$C_WHT" "$(dfit "${nick:-未知}" 18)" "$C_RST" \ "$C_GRY" "$C_RST" "$C_CYA" "$(dpad "$(human_cn "${fans:-0}")" 8)" "$C_RST" \ "$C_GRY" "$C_RST" "$C_YLW" "$(dpad "${feeds:-0}" 5)" "$C_RST" \ "$C_GRN" "$C_RST" say " ${C_MAG}╰$(_dashes 58)╯${C_RST}" } # ---------------------------------------------------------------- 主菜单 ---- console_menu() { ui_clear banner console_header say "" say " ${C_BLU}${C_B}▎数据${C_RST}" say " ${C_YLW}1${C_RST} 账号总览 ${C_YLW}2${C_RST} 粉丝趋势 ${C_YLW}3${C_RST} 粉丝画像" say " ${C_YLW}4${C_RST} 通知消息 ${C_YLW}5${C_RST} 合集列表" say "" say " ${C_BLU}${C_B}▎内容${C_RST}" say " ${C_YLW}6${C_RST} 作品列表 ${C_YLW}7${C_RST} 作品详情 ${C_YLW}8${C_RST} 评论管理" say "" say " ${C_ORG}${C_B}▎修改${C_RST} ${C_GRY}(均需二次确认)${C_RST}" say " ${C_YLW}9${C_RST} 置顶 / 取消置顶 ${C_YLW}10${C_RST} 可见范围 ${C_YLW}11${C_RST} 评论权限" say " ${C_RED}12${C_RST} ${C_RED}删除作品(危险)${C_RST}" say "" say " ${C_BLU}${C_B}▎其他${C_RST}" say " ${C_YLW}a${C_RST} API 调试台 ${C_YLW}r${C_RST} 刷新数据 ${C_YLW}o${C_RST} 操作日志" say " ${C_YLW}L${C_RST} 退出登录 ${C_YLW}q${C_RST} 退出程序" hr 60 } # ============================================================= 各功能实现 ==== # --- 1 账号总览 --- act_overview() { spin_start "刷新账号资料"; fetch_profile; fetch_fans_trend; fetch_posts; spin_stop 0 "已刷新" render_result } # --- 2 粉丝趋势 --- act_fans_trend() { local days end start resp days="$(ask "查看最近几天(1-30)" "7")" [[ "$days" =~ ^[0-9]+$ ]] || days=7 (( days < 1 )) && days=1; (( days > 30 )) && days=30 end=$(( $(date +%s) / 86400 * 86400 )) start=$(( end - 86400*days )) spin_start "拉取 ${days} 天粉丝趋势" resp="$(finder_api "/statistic/fans_trend" \ "$(biz_body "$(printf '"startTs":"%s","endTs":"%s","interval":3' "$start" "$end")")")" [[ "$(jget "$resp" errCode)" == "0" ]] && spin_stop 0 "完成" || { spin_stop 1 "失败:$(jget "$resp" errMsg)"; return 1; } local tot add red arr tot="$(jget "$resp" data.total)"; add="$(jget "$resp" data.add)"; red="$(jget "$resp" data.reduce)" say "" card_top arr="$(printf '%s' "$tot" | tr -d '[]' | tr ',' ' ')" # shellcheck disable=SC2086 printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "总量走势" 10)" "$C_RST" "$C_CYA" "$(sparkline $arr)" "$C_RST" # shellcheck disable=SC2086 printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "每日新增" 10)" "$C_RST" "$C_GRN" "$(sparkline $(printf '%s' "$add" | tr -d '[]' | tr ',' ' '))" "$C_RST" # shellcheck disable=SC2086 printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "每日流失" 10)" "$C_RST" "$C_RED" "$(sparkline $(printf '%s' "$red" | tr -d '[]' | tr ',' ' '))" "$C_RST" say " ${C_MAG}│${C_RST}" local f l d sign col f="$(printf '%s' "$arr" | awk '{print $1}')"; l="$(printf '%s' "$arr" | awk '{print $NF}')" d=$(( ${l:-0} - ${f:-0} )) if (( d > 0 )); then sign="+"; col="$C_GRN"; elif (( d < 0 )); then sign=""; col="$C_RED"; else sign="±"; col="$C_GRY"; fi printf ' %s│%s %s%s%s %s → %s %s%s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "区间变化" 10)" "$C_RST" \ "${f:-0}" "${l:-0}" "$col" "$sign" "$d" "$C_RST" # 各来源渠道 local n; n="$(jlen "$resp" data.fansDataByTabtype)" if [[ "${n:-0}" -gt 0 ]]; then say " ${C_MAG}│${C_RST}" printf ' %s│%s %s来源渠道%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" local i for ((i=0;i 0 )); then c2="$C_GRN"; sgn="+" elif (( last_n < 0 )); then c2="$C_RED"; fi fi printf ' %s│%s %s%s%s %s %s%s%s%s\n' "$C_MAG" "$C_RST" "$C_WHT" "$(dpad "${tn}" 12)" "$C_RST" \ "$(dpad "${last_t:-0}" 8)" "$c2" "$sgn" "${last_n:-0}" "$C_RST" done fi card_bot } # --- 3 粉丝画像(响应结构未在抓包中出现,原样输出) --- act_fans_portrait() { spin_start "拉取粉丝画像" local resp; resp="$(finder_api "/statistic/fans_portrait" "$(biz_body)")" [[ "$(jget "$resp" errCode)" == "0" ]] && spin_stop 0 "完成" || { spin_stop 1 "失败:$(jget "$resp" errMsg)"; return 1; } say ""; info "${C_GRY}该接口返回结构随版本变化,以下为原始数据${C_RST}"; say "" pretty_json "$resp" } # --- 4 通知消息 --- act_notifications() { spin_start "拉取通知列表" local resp; resp="$(finder_api "/notification/notification_list" \ "$(biz_body '"pageSize":20,"currentPage":1,"reqType":1')")" [[ "$(jget "$resp" errCode)" == "0" ]] && spin_stop 0 "完成" || { spin_stop 1 "失败:$(jget "$resp" errMsg)"; return 1; } local n; n="$(jlen "$resp" data.list)" say "" if [[ "${n:-0}" -eq 0 ]]; then info "暂无通知"; return 0; fi card_top local i for ((i=0;i local page="${1:-1}" POSTS_JSON="$(finder_api "/post/post_list" \ "$(biz_body "$(printf '"pageSize":%s,"currentPage":%s,"userpageType":11,"stickyOrder":false' "$POSTS_SIZE" "$page")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" [[ "$(jget "$POSTS_JSON" errCode)" == "0" ]] } visible_desc() { case "${1:-}" in 1) printf '公开' ;; 2) printf '仅关注者' ;; 3) printf '仅自己' ;; *) printf '未知(%s)' "${1:-?}" ;; esac } fmt_date() { # fmt_date [格式] local ts="${1:-}" f="${2:-+%m-%d %H:%M}" [[ "$ts" =~ ^[0-9]+$ ]] || { printf ' -- '; return; } date -d "@$ts" "$f" 2>/dev/null || date -r "$ts" "$f" 2>/dev/null || printf ' -- ' } render_post_table() { local n; n="$(jlen "$POSTS_JSON" data.list)" local total; total="$(jget "$POSTS_JSON" data.totalCount)" say "" printf ' %s%s %s %s %s %s %s %s%s\n' "$C_GRY" "$(dpad "#" 3)" "$(dpad "日期" 12)" \ "$(dpad "标题" 24)" "$(dpad "播放" 9)" "$(dpad "赞" 7)" "$(dpad "评论" 7)" "$(dpad "状态" 12)" "$C_RST" hr 60 local i for ((i=0;i 1 )) && POSTS_PAGE=$((POSTS_PAGE-1)) ;; ''|q|Q) return 0 ;; *) if [[ "$c" =~ ^[0-9]+$ ]]; then show_post_detail $((c-1)); pause_key; fi ;; esac done } # 选择一条作品,回显索引到全局 SEL_IDX SEL_IDX="" select_post() { SEL_IDX="" spin_start "拉取作品列表" if fetch_post_page "$POSTS_PAGE"; then spin_stop 0 "完成"; else spin_stop 1 "失败"; return 1; fi render_post_table say "" local c n; n="$(jlen "$POSTS_JSON" data.list)" c="$(ask "请输入作品序号(回车取消)" "")" [[ "$c" =~ ^[0-9]+$ ]] || return 1 (( c >= 1 && c <= n )) || { warn "序号超出范围"; return 1; } SEL_IDX=$((c-1)) return 0 } post_field() { jget "$POSTS_JSON" "data.list.${1}.${2}"; } show_post_detail() { local i="$1" local d ts rc lc cc fc fav vt st obj exp dur full fast avg d="$(post_field "$i" desc.description)" ts="$(post_field "$i" createTime)" rc="$(post_field "$i" readCount)"; lc="$(post_field "$i" likeCount)" cc="$(post_field "$i" commentCount)"; fc="$(post_field "$i" forwardCount)" fav="$(post_field "$i" favCount)"; vt="$(post_field "$i" visibleType)" st="$(post_field "$i" stickyOpStatus)"; obj="$(post_field "$i" objectId)" exp="$(post_field "$i" exportId)" full="$(post_field "$i" fullPlayRate)"; fast="$(post_field "$i" fastFlipRate)" avg="$(post_field "$i" avgPlayTimeSec)"; dur="$(post_field "$i" desc.media.0.videoPlayLen)" say "" card_top printf ' %s│%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_B$C_WHT" "$(dfit "${d:-(无描述)}" 54)" "$C_RST" say " ${C_MAG}│${C_RST}" kv2() { printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "$1" 12)" "$C_RST" "$C_WHT" "$2" "$C_RST"; } kv2 "发布时间" "$(fmt_date "$ts" '+%Y-%m-%d %H:%M')" kv2 "时长" "${dur:-?} 秒" kv2 "可见范围" "$(visible_desc "$vt")" kv2 "置顶状态" "$([[ "$st" == "2" ]] && printf '已置顶' || printf '未置顶')" say " ${C_MAG}│${C_RST}" printf ' %s│%s %s播放 %s%s%s %s赞 %s%s%s %s评论 %s%s%s\n' "$C_MAG" "$C_RST" \ "$C_GRY" "$C_BLU$C_B" "$(dpad "$(comma "${rc:-0}")" 10)" "$C_RST" \ "$C_GRY" "$C_RED$C_B" "$(dpad "$(comma "${lc:-0}")" 8)" "$C_RST" \ "$C_GRY" "$C_ORG$C_B" "$(comma "${cc:-0}")" "$C_RST" printf ' %s│%s %s转发 %s%s%s %s收藏 %s%s%s\n' "$C_MAG" "$C_RST" \ "$C_GRY" "$C_GRN$C_B" "$(dpad "$(comma "${fc:-0}")" 10)" "$C_RST" \ "$C_GRY" "$C_YLW$C_B" "$(comma "${fav:-0}")" "$C_RST" [[ -n "$full$fast$avg" ]] && { say " ${C_MAG}│${C_RST}" [[ -n "$avg" ]] && kv2 "平均播放" "${avg} 秒" [[ -n "$full" ]] && kv2 "完播率" "${full}" [[ -n "$fast" ]] && kv2 "快划率" "${fast}" } say " ${C_MAG}│${C_RST}" printf ' %s│%s %s%s%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$(dpad "objectId" 12)" "$C_RST" "$C_GRY" "${obj:0:44}…" "$C_RST" card_bot CUR_OBJECT_ID="$obj"; CUR_EXPORT_ID="$exp" } act_post_detail() { select_post || return 0 show_post_detail "$SEL_IDX" } # --- 8 评论管理 --- # 抓包中没有出现 comment_list 的真实响应,字段名按前端 JS 还原: # data.comment[] / data.commentCount / data.lastBuff,条目字段做多候选兼容 act_comments() { select_post || return 0 local exp; exp="$(post_field "$SEL_IDX" exportId)" spin_start "拉取评论" local resp; resp="$(finder_api "/comment/comment_list" \ "$(biz_body "$(printf '"lastBuff":"","exportId":"%s","commentSelection":false,"forMcn":false' "$exp")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" [[ "$(jget "$resp" errCode)" == "0" ]] && spin_stop 0 "完成" || { spin_stop 1 "失败:$(jget "$resp" errMsg)"; return 1; } local root="data.comment" n n="$(jlen "$resp" "$root")" if [[ "${n:-0}" -eq 0 ]]; then root="data.commentList"; n="$(jlen "$resp" "$root")"; fi if [[ "${n:-0}" -eq 0 ]]; then say ""; info "这条作品还没有评论" [[ $OPT_DEBUG -eq 1 ]] && pretty_json "$resp" return 0 fi say "" say " ${C_GRY}共 $(jget "$resp" data.commentCount) 条评论(本页 ${n} 条)${C_RST}" say "" local i for ((i=0;i= 1 && c <= n )) || { warn "序号超出范围"; return 0; } local idx=$((c-1)) cid ctext cid="$(jpick "$resp" "${root}.${idx}" commentId id)" ctext="$(jpick "$resp" "${root}.${idx}" content commentContent text)" [[ -z "$cid" ]] && { fail "无法取得 commentId,已中止"; return 1; } say "" warn "即将删除评论:${C_WHT}$(dfit "$ctext" 40)${C_RST}" confirm "确认删除?此操作不可撤销" || { info "已取消"; return 0; } spin_start "删除中" local r; r="$(finder_api "/comment/del_comment" \ "$(biz_body "$(printf '"exportId":"%s","commentId":"%s"' "$exp" "$cid")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" spin_stop 0 "请求已发送" apply_result "$r" "评论已删除" "del_comment cid=${cid}" } # --- 9 置顶 / 取消置顶 --- act_sticky() { select_post || return 0 local exp st title exp="$(post_field "$SEL_IDX" exportId)" st="$(post_field "$SEL_IDX" stickyOpStatus)" title="$(post_field "$SEL_IDX" desc.description)" local op oplabel if [[ "$st" == "2" ]]; then op=2; oplabel="取消置顶"; else op=1; oplabel="置顶"; fi say "" info "作品:${C_WHT}$(dfit "$title" 40)${C_RST}" info "当前:${C_WHT}$([[ "$st" == "2" ]] && printf '已置顶' || printf '未置顶')${C_RST} → 将执行 ${C_YLW}${oplabel}${C_RST}" confirm "确认${oplabel}?" || { info "已取消"; return 0; } spin_start "提交中" local r; r="$(finder_api "/post/update_sticky_status" \ "$(biz_body "$(printf '"exportId":"%s","stickyOp":%s' "$exp" "$op")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" spin_stop 0 "请求已发送" apply_result "$r" "已${oplabel}" "update_sticky_status op=${op}" } # --- 10 可见范围 --- act_visible() { select_post || return 0 local obj vt title obj="$(post_field "$SEL_IDX" objectId)" vt="$(post_field "$SEL_IDX" visibleType)" title="$(post_field "$SEL_IDX" desc.description)" say "" info "作品:${C_WHT}$(dfit "$title" 40)${C_RST}" info "当前可见范围:${C_WHT}$(visible_desc "$vt")${C_RST}" say "" say " ${C_YLW}1${C_RST} 公开 ${C_YLW}2${C_RST} 仅关注者 ${C_YLW}3${C_RST} 仅自己可见" local c; c="$(ask "选择新的可见范围(回车取消)" "")" [[ "$c" =~ ^[123]$ ]] || { info "已取消"; return 0; } [[ "$c" == "$vt" ]] && { info "与当前设置相同,无需修改"; return 0; } confirm "确认改为「$(visible_desc "$c")」?" || { info "已取消"; return 0; } spin_start "提交中" local r; r="$(finder_api "/post/post_update_visible" \ "$(biz_body "$(printf '"objectId":"%s","visibleType":%s' "$obj" "$c")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" spin_stop 0 "请求已发送" apply_result "$r" "可见范围已改为 $(visible_desc "$c")" "post_update_visible visibleType=${c}" } # --- 11 评论权限 --- act_comment_auth() { select_post || return 0 local obj cclose title obj="$(post_field "$SEL_IDX" objectId)" cclose="$(post_field "$SEL_IDX" commentClose)" title="$(post_field "$SEL_IDX" desc.description)" say "" info "作品:${C_WHT}$(dfit "$title" 40)${C_RST}" info "当前评论:${C_WHT}$([[ "${cclose:-0}" == "1" ]] && printf '已关闭' || printf '开启中')${C_RST}" say "" say " ${C_YLW}1${C_RST} 开启评论 ${C_YLW}2${C_RST} 关闭评论" local c; c="$(ask "选择(回车取消)" "")" local flag label case "$c" in 1) flag=0; label="开启评论" ;; 2) flag=1; label="关闭评论" ;; *) info "已取消"; return 0 ;; esac confirm "确认${label}?" || { info "已取消"; return 0; } spin_start "提交中" local r; r="$(finder_api "/post/post_update_comment_auth" \ "$(biz_body "$(printf '"objectId":"%s","commentFlag":%s,"commentSelectionFlag":0' "$obj" "$flag")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" spin_stop 0 "请求已发送" apply_result "$r" "已${label}" "post_update_comment_auth commentFlag=${flag}" } # --- 12 删除作品(危险) --- act_delete_post() { select_post || return 0 local obj title rc obj="$(post_field "$SEL_IDX" objectId)" title="$(post_field "$SEL_IDX" desc.description)" rc="$(post_field "$SEL_IDX" readCount)" say "" say " ${C_RED}${C_B}╭$(_dashes 58)╮${C_RST}" printf ' %s│%s %s⚠ 危险操作:删除作品%s\n' "$C_RED" "$C_RST" "$C_RED$C_B" "$C_RST" printf ' %s│%s %s%s%s\n' "$C_RED" "$C_RST" "$C_WHT" "$(dfit "$title" 52)" "$C_RST" printf ' %s│%s %s累计播放 %s 次,删除后无法恢复%s\n' "$C_RED" "$C_RST" "$C_GRY" "$(comma "${rc:-0}")" "$C_RST" say " ${C_RED}${C_B}╰$(_dashes 58)╯${C_RST}" say "" local t; t="$(ask "确认请输入大写 DELETE(其他任意键取消)" "")" [[ "$t" == "DELETE" ]] || { info "已取消,未做任何修改"; return 0; } confirm "最后确认一次,真的删除吗?" || { info "已取消"; return 0; } spin_start "删除中" local r; r="$(finder_api "/post/post_delete" \ "$(biz_body "$(printf '"objectId":"%s"' "$obj")")" \ "$API_MICRO" "https://channels.weixin.qq.com/micro/content/iframe/post-card.html")" spin_stop 0 "请求已发送" apply_result "$r" "作品已删除" "post_delete objectId=${obj:0:24}" } # --- a API 调试台 --- act_api_console() { say "" card_top printf ' %s│%s %sAPI 调试台%s %s直接调用 mmfinderassistant-bin 接口%s\n' "$C_MAG" "$C_RST" "$C_B$C_WHT" "$C_RST" "$C_GRY" "$C_RST" printf ' %s│%s %s示例路径:/statistic/dashboard /post/get_post_info%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" printf ' %s│%s %s额外参数用 JSON 片段,如:\"objectId\":\"export/xxx\"%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" card_bot say "" local path extra base path="$(ask "接口路径(以 / 开头,回车返回)" "")" [[ -z "$path" ]] && return 0 case "$path" in /*) ;; *) warn "路径必须以 / 开头"; return 0 ;; esac extra="$(ask "额外 JSON 字段(可留空)" "")" base="$(ask "走 micro 前缀? y=micro / n=主域" "n")" [[ "$base" == "y" || "$base" == "Y" ]] && base="$API_MICRO" || base="$API_BASE" spin_start "请求 ${path}" local r; r="$(finder_api "$path" "$(biz_body "$extra")" "$base")" spin_stop 0 "已返回 $(printf '%s' "$r" | wc -c | tr -d ' ') 字节" say "" pretty_json "$r" oplog "API ${path} ${extra}" } # --- o 操作日志 --- act_oplog() { say "" if [[ ! -s "$OPLOG" ]]; then info "还没有任何写操作记录"; return 0; fi card_top tail -20 "$OPLOG" | while IFS= read -r line; do printf ' %s│%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$line" "$C_RST" done card_bot say " ${C_GRY}完整日志:${OPLOG}${C_RST}" } # --- L 退出登录 --- act_logout() { confirm "确认退出登录并清除本地会话?" || { info "已取消"; return 1; } finder_api "/auth/auth_logout" "$(biz_body)" >/dev/null 2>&1 rm -f "$COOKIE_JAR" ok "已退出登录,本地会话已清除" oplog "logout" return 0 } # ============================================================ 控制台主循环 == console_loop() { local c while :; do console_menu sayn " ${C_YLW}${C_B}❯${C_RST} " rd c case "$c" in 1) act_overview; pause_key ;; 2) act_fans_trend; pause_key ;; 3) act_fans_portrait; pause_key ;; 4) act_notifications; pause_key ;; 5) act_collections; pause_key ;; 6) act_post_list ;; 7) act_post_detail; pause_key ;; 8) act_comments; pause_key ;; 9) act_sticky; pause_key ;; 10) act_visible; pause_key ;; 11) act_comment_auth; pause_key ;; 12) act_delete_post; pause_key ;; a|A) act_api_console; pause_key ;; o|O) act_oplog; pause_key ;; r|R) spin_start "刷新"; fetch_profile; fetch_fans_trend; fetch_posts; spin_stop 0 "已刷新" ;; l|L) if act_logout; then pause_key; return 0; else pause_key; fi ;; q|Q|'') ui_clear; say ""; say " ${C_GRY}再见 —— Agentic Lab · Build By JMR${C_RST}"; say ""; return 0 ;; *) warn "无效选项:${c}"; sleep 0.6 ;; esac done } # ================================================================== 主流程 ==== main() { mkdir -p "$WORKDIR" 2>/dev/null || die "无法创建工作目录 $WORKDIR" chmod 700 "$WORKDIR" 2>/dev/null TMPDIR_RUN="$(mktemp -d "${TMPDIR:-/tmp}/wxch.XXXXXX")" || die "无法创建临时目录" if [[ $OPT_LOGOUT -eq 1 ]]; then rm -f "$COOKIE_JAR" say " ${C_GRN}✔${C_RST} 本地会话已清除" exit 0 fi banner # ---------- 会话复用:已有登录态则直接进控制台 ---------- init_locale check_deps detect_os if [[ $OPT_RELOGIN -eq 0 && -s "$COOKIE_JAR" ]]; then spin_start "检测到本地会话,正在校验有效性" if fetch_profile; then FINDER_USERNAME="$(jget "$AUTH_DATA" data.finderUser.finderUsername)" FINDER_NICK="$(jget "$AUTH_DATA" data.finderUser.nickname)" spin_stop 0 "会话有效 ${C_GRY}(${FINDER_NICK})${C_RST}" fetch_fans_trend; fetch_posts render_result if [[ $OPT_SHELL -eq 1 && $OPT_JSON -eq 0 && $TTY_OK -eq 1 ]]; then pause_key; console_loop; fi return 0 fi spin_stop 1 "本地会话已失效,需要重新授权" rm -f "$COOKIE_JAR" fi # ---------- 步骤 1:环境体检 ---------- step "1/4" "环境体检" detect_os detect_wx_process ok "操作系统 ${C_WHT}${OS_NAME}${C_RST} ${C_GRY}(${OS_ARCH})${C_RST}" ok "解析引擎 ${C_WHT}$([[ "$JSON_ENGINE" == "jq" ]] && echo "jq" || echo "python3")${C_RST} ${C_GRY}curl $(curl --version 2>/dev/null | head -1 | awk '{print $2}')${C_RST}" case "$OS_KIND" in macos) info "平台适配 ${C_GRY}macOS 桌面端授权链路${C_RST}" ;; linux) info "平台适配 ${C_GRY}Linux / WSL 桌面端授权链路${C_RST}" ;; *) warn "平台 ${OS_NAME} 未经充分验证,将尽力运行" ;; esac if [[ $WX_PROC -eq 1 ]]; then ok "微信进程 ${C_GRN}已在运行${C_RST}" else warn "微信进程 ${C_GRY}未检测到(可能是无 GUI 环境)${C_RST}"; fi # ---------- 步骤 2:探测微信在线状态 ---------- step "2/4" "探测微信在线状态" if [[ $OPT_QR -eq 1 ]]; then warn "已指定 ${C_WHT}--qr${C_RST},跳过本地客户端探测" else spin_start "正在探测本地微信客户端" if probe_wechat; then spin_stop 0 "微信客户端在线" say "" card_top printf ' %s│%s %s微信状态%s %s● 在线%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" "$C_GRN" "$C_RST" printf ' %s│%s %s微信昵称%s %s%s%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" "$C_B$C_WHT" "${WX_NICK:-未知}" "$C_RST" printf ' %s│%s %s授权通道%s %s已建立%s\n' "$C_MAG" "$C_RST" "$C_GRY" "$C_RST" "$C_GRN" "$C_RST" card_bot else spin_stop 1 "未探测到在线的微信客户端" warn "可能原因:微信未启动 / 未登录 / 版本过旧 / 当前环境无桌面端" info "将自动降级为 ${C_WHT}二维码扫码登录${C_RST}" fi fi # ---------- 步骤 3:确认并发起 OAuth ---------- step "3/4" "微信 OAuth 授权" local mode_desc if [[ $WX_ONLINE -eq 1 ]]; then mode_desc="将以 ${C_B}${C_WHT}${WX_NICK}${C_RST} 的身份免扫码登录视频号后台" else mode_desc="将生成二维码,需用微信扫码授权" fi say " ${mode_desc}" if [[ $OPT_YES -eq 0 && $OPT_JSON -eq 0 && $TTY_OK -eq 1 ]]; then say "" sayn " ${C_YLW}${C_B}▶ 按 [回车] 开始登录,输入 n 退出:${C_RST} " local ans=""; rd ans ans="$(printf %s "$ans" | tr '[:upper:]' '[:lower:]')" case "$ans" in n|no|q|quit|exit) say ""; say " ${C_GRY}已取消,未发起任何授权请求。${C_RST}"; say ""; exit 0 ;; esac fi OAUTH_ERR="" local got=1 if [[ $WX_ONLINE -eq 1 ]]; then spin_start "正在向微信客户端申请授权" if oauth_via_client; then spin_stop 0 "客户端快捷授权成功" got=0 else spin_stop 1 "客户端授权失败${OAUTH_ERR:+:$OAUTH_ERR}" warn "自动降级为二维码登录" WX_ONLINE=0 fi fi if [[ $got -ne 0 ]]; then [[ $OPT_JSON -eq 1 ]] && die "微信客户端未在线,--json 模式不支持扫码登录(请先登录微信桌面端)" oauth_via_qrcode && got=0 fi [[ $got -eq 0 ]] || die "未能获取授权 code,登录中止" # ---------- 步骤 4:换取会话 & 拉取资料 ---------- step "4/4" "建立会话并拉取视频号资料" LAST_ERR="" rm -f "$COOKIE_JAR" spin_start "正在用 code 换取登录态" if exchange_session; then spin_stop 0 "会话建立成功" else spin_stop 1 "换取登录态失败${LAST_ERR:+:$LAST_ERR}"; die "auth_with_oauth 失败,code 可能已过期,请重试"; fi spin_start "正在拉取可管理的视频号列表" if list_finders; then local cnt; cnt="$(jlen "$FINDER_JSON" "data.finderList")" spin_stop 0 "找到 ${C_WHT}${cnt}${C_RST} 个视频号" else spin_stop 1 "获取视频号列表失败${LAST_ERR:+:$LAST_ERR}" die "该微信可能尚未创建视频号,请先在手机微信中开通" fi choose_finder || die "视频号选择失败" spin_start "正在登录视频号 ${C_WHT}${FINDER_NICK}${C_RST}" if login_finder; then spin_stop 0 "已切换到 ${C_WHT}${FINDER_NICK}${C_RST}" else spin_stop 1 "登录视频号失败${LAST_ERR:+:$LAST_ERR}"; die "login_finder 失败"; fi spin_start "正在读取账号资料" if fetch_profile; then spin_stop 0 "资料读取完成" else spin_stop 1 "资料读取失败${LAST_ERR:+:$LAST_ERR}"; die "auth_data 失败"; fi spin_start "正在读取粉丝趋势与最新作品" fetch_fans_trend fetch_posts spin_stop 0 "统计数据读取完成" render_result if [[ $OPT_SHELL -eq 1 && $OPT_JSON -eq 0 && $TTY_OK -eq 1 ]]; then pause_key console_loop fi } main "$@"