Skip to content

Instantly share code, notes, and snippets.

@splincode
Last active August 30, 2026 18:11
Show Gist options
  • Select an option

  • Save splincode/7f490c668bdbf321f4405da640c253b9 to your computer and use it in GitHub Desktop.

Select an option

Save splincode/7f490c668bdbf321f4405da640c253b9 to your computer and use it in GitHub Desktop.
Скрипт получает фолловеров со всех страниц, исключает уже существующие подписки и останавливается при первой ошибке.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
TARGET="${1:-splincode}"
DELAY_MIN="${DELAY_MIN:-5}"
DELAY_MAX="${DELAY_MAX:-8}"
# Максимум новых подписок за один запуск.
# 0 = без ограничения.
MAX_FOLLOWS="${MAX_FOLLOWS:-100}"
# YES=1 отключает вопрос перед запуском.
YES="${YES:-0}"
API_VERSION="${GITHUB_API_VERSION:-2022-11-28}"
STATE_DIR="${STATE_DIR:-${XDG_STATE_HOME:-${HOME}/.local/state}/follow-followers}"
command -v gh >/dev/null 2>&1 || {
echo "Ошибка: GitHub CLI не установлен."
exit 1
}
gh auth status -h github.com >/dev/null 2>&1 || {
echo "Ошибка: сначала авторизуйся через gh auth login."
exit 1
}
for VALUE in "$DELAY_MIN" "$DELAY_MAX" "$MAX_FOLLOWS"; do
if [[ ! "$VALUE" =~ ^[0-9]+$ ]]; then
echo "Ошибка: DELAY_MIN, DELAY_MAX и MAX_FOLLOWS должны быть целыми неотрицательными числами."
exit 1
fi
done
if ((DELAY_MIN > DELAY_MAX)); then
echo "Ошибка: DELAY_MIN не может быть больше DELAY_MAX."
exit 1
fi
mkdir -p "$STATE_DIR"
gh_api() {
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: ${API_VERSION}" \
"$@"
}
random_delay() {
local RANGE=$((DELAY_MAX - DELAY_MIN + 1))
echo $((RANDOM % RANGE + DELAY_MIN))
}
CURRENT_USER="$(gh_api user --jq '.login')"
STATE_PREFIX="${STATE_DIR}/${CURRENT_USER}--${TARGET}"
SUCCESS=0
FAILED=0
PROCESSED=0
SKIPPED_ALREADY=0
SKIPPED_UNFOLLOWABLE=0
SKIPPED_SELF=0
PAGES=0
checkpoint_file() {
local RELATION="$1"
echo "${STATE_PREFIX}--${RELATION}.cursor"
}
load_cursor() {
local RELATION="$1"
local FILE
FILE="$(checkpoint_file "$RELATION")"
if [[ -s "$FILE" ]]; then
cat "$FILE"
fi
}
save_cursor() {
local RELATION="$1"
local CURSOR="$2"
local FILE
FILE="$(checkpoint_file "$RELATION")"
printf '%s\n' "$CURSOR" >"$FILE"
}
clear_cursor() {
local RELATION="$1"
rm -f "$(checkpoint_file "$RELATION")"
}
fetch_page() {
local RELATION="$1"
local CURSOR="$2"
local QUERY
local JQ_FILTER
local -a ARGS
QUERY="
query(\$target: String!, \$after: String) {
user(login: \$target) {
${RELATION}(first: 100, after: \$after) {
totalCount
nodes {
login
viewerCanFollow
viewerIsFollowing
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"
JQ_FILTER="
.data.user as \$user |
if \$user == null then
[\"ERROR\", \"TARGET_NOT_FOUND\"] | @tsv
else
\$user.${RELATION} as \$connection |
([
\"META\",
(\$connection.totalCount | tostring),
(\$connection.pageInfo.hasNextPage | tostring),
(\$connection.pageInfo.endCursor // \"\")
] | @tsv),
(\$connection.nodes[] | [
\"USER\",
.login,
(.viewerCanFollow | tostring),
(.viewerIsFollowing | tostring)
] | @tsv)
end
"
ARGS=(
api
graphql
-f "target=${TARGET}"
-f "query=${QUERY}"
)
if [[ -n "$CURSOR" ]]; then
ARGS+=(
-f "after=${CURSOR}"
)
fi
gh "${ARGS[@]}" --jq "$JQ_FILTER"
}
follow_user() {
local USERNAME="$1"
local DELAY
if ((PROCESSED > 0)); then
DELAY="$(random_delay)"
echo "Пауза: ${DELAY} сек."
sleep "$DELAY"
fi
PROCESSED=$((PROCESSED + 1))
if ((MAX_FOLLOWS > 0)); then
printf '[%d/%d] Подписка на @%s... ' \
"$PROCESSED" \
"$MAX_FOLLOWS" \
"$USERNAME"
else
printf '[%d] Подписка на @%s... ' \
"$PROCESSED" \
"$USERNAME"
fi
if gh_api \
--method PUT \
"user/following/${USERNAME}" \
--silent; then
echo "OK"
SUCCESS=$((SUCCESS + 1))
return 0
fi
echo "ERROR"
FAILED=$((FAILED + 1))
return 1
}
process_relation() {
local RELATION="$1"
local LABEL="$2"
local CURSOR
local PAGE
local KIND
local FIELD1
local FIELD2
local FIELD3
local TOTAL_COUNT=""
local HAS_NEXT="false"
local END_CURSOR=""
local PAGE_COMPLETE
local USERNAME
local CAN_FOLLOW
local IS_FOLLOWING
CURSOR="$(load_cursor "$RELATION")"
echo
echo "Обрабатываю ${LABEL} @${TARGET}..."
if [[ -n "$CURSOR" ]]; then
echo "Продолжаю с сохраненной позиции."
fi
while true; do
if ((MAX_FOLLOWS > 0 && SUCCESS >= MAX_FOLLOWS)); then
return 0
fi
if ! PAGE="$(fetch_page "$RELATION" "$CURSOR")"; then
echo "Ошибка: не удалось получить страницу ${LABEL}."
return 1
fi
PAGES=$((PAGES + 1))
PAGE_COMPLETE=1
HAS_NEXT="false"
END_CURSOR=""
while IFS=$'\t' read -r \
KIND \
FIELD1 \
FIELD2 \
FIELD3; do
[[ -z "$KIND" ]] && continue
case "$KIND" in
ERROR)
if [[ "$FIELD1" == "TARGET_NOT_FOUND" ]]; then
echo "Ошибка: пользователь @${TARGET} не найден."
else
echo "Ошибка GraphQL: ${FIELD1}"
fi
return 1
;;
META)
TOTAL_COUNT="$FIELD1"
HAS_NEXT="$FIELD2"
END_CURSOR="$FIELD3"
echo "Всего в ${LABEL}: ${TOTAL_COUNT}"
;;
USER)
USERNAME="$FIELD1"
CAN_FOLLOW="$FIELD2"
IS_FOLLOWING="$FIELD3"
if [[ "$USERNAME" == "$CURRENT_USER" ]]; then
SKIPPED_SELF=$((SKIPPED_SELF + 1))
continue
fi
if [[ "$IS_FOLLOWING" == "true" ]]; then
SKIPPED_ALREADY=$((SKIPPED_ALREADY + 1))
continue
fi
if [[ "$CAN_FOLLOW" != "true" ]]; then
SKIPPED_UNFOLLOWABLE=$((SKIPPED_UNFOLLOWABLE + 1))
continue
fi
if ((MAX_FOLLOWS > 0 && SUCCESS >= MAX_FOLLOWS)); then
PAGE_COMPLETE=0
break
fi
if ! follow_user "$USERNAME"; then
echo
echo "Скрипт остановлен после ошибки подписки."
echo "Позиция текущей страницы не сохранена."
return 1
fi
;;
esac
done <<<"$PAGE"
if ((PAGE_COMPLETE == 0)); then
return 0
fi
# Cursor сохраняем только после полной обработки страницы.
#
# Если остановились посреди страницы из-за MAX_FOLLOWS,
# следующий запуск повторно прочитает эту страницу.
# Уже обработанные аккаунты отфильтруются через
# viewerIsFollowing.
if [[ "$HAS_NEXT" == "true" && -n "$END_CURSOR" ]]; then
save_cursor "$RELATION" "$END_CURSOR"
CURSOR="$END_CURSOR"
else
clear_cursor "$RELATION"
echo "Раздел ${LABEL} обработан полностью."
return 0
fi
done
}
echo "Активный аккаунт: @${CURRENT_USER}"
echo "Целевой аккаунт: @${TARGET}"
echo
echo "Пауза между подписками: случайно от ${DELAY_MIN} до ${DELAY_MAX} секунд."
if ((MAX_FOLLOWS > 0)); then
echo "Максимум новых подписок за запуск: ${MAX_FOLLOWS}"
else
echo "Лимит новых подписок отключен."
fi
echo "Прогресс хранится в: ${STATE_DIR}"
if [[ "$YES" != "1" ]]; then
echo
read -r -p "Продолжить? [y/N] " ANSWER
if [[ ! "$ANSWER" =~ ^[Yy]$ ]]; then
echo "Отменено."
exit 0
fi
fi
STATUS=0
# Сначала followers.
#
# Загружается только одна страница (до 100 пользователей),
# после чего сразу начинаются подписки.
if process_relation \
"followers" \
"фолловерах"; then
# Если лимит еще не достигнут,
# переходим к following целевого аккаунта.
if ((MAX_FOLLOWS == 0 || SUCCESS < MAX_FOLLOWS)); then
if ! process_relation \
"following" \
"подписках"; then
STATUS=1
fi
fi
else
STATUS=1
fi
echo
echo "Готово."
echo "Получено страниц: ${PAGES}"
echo "Успешных подписок: ${SUCCESS}"
echo "Уже был подписан: ${SKIPPED_ALREADY}"
echo "Недоступны для подписки: ${SKIPPED_UNFOLLOWABLE}"
echo "Собственный аккаунт пропущен: ${SKIPPED_SELF}"
echo "Ошибок: ${FAILED}"
if ((MAX_FOLLOWS > 0 && SUCCESS >= MAX_FOLLOWS)); then
echo
echo "Достигнут лимит ${MAX_FOLLOWS}."
echo "Следующий запуск продолжит с сохраненной позиции."
fi
exit "$STATUS"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment