Last active
August 30, 2026 18:11
-
-
Save splincode/7f490c668bdbf321f4405da640c253b9 to your computer and use it in GitHub Desktop.
Скрипт получает фолловеров со всех страниц, исключает уже существующие подписки и останавливается при первой ошибке.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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