🍪 🍪 exit 0なのに成果0——ChromeのCookieをSQLiteで直読みしてセッション死活を正確に知る — リーダー×
🍪

🍪 exit 0なのに成果0——ChromeのCookieをSQLiteで直読みしてセッション死活を正確に知る

#automation#claudecode#副業2026-08-14 · 約33

月10万の大学生から副業掛け持ちで月60万、会社都合で解雇されて収入0、そこからClaude Codeで自律環境を組み直して今は月商120万です。この連載は「個人開発の量産」というテーマで、私が実際に踏み抜いてきた穴を共有しています。

今回は、「ジョブが毎日走っているのに成果が0件のまま誰にも気づかれなかった」 という話です。


なぜこの仕組みが効くのか

ジョブは沈黙する。exit 0は成功を意味しない

自動化の怖さは、壊れても音がしないことです。

私のInstagram自動いいねシステム(social-autolike)のジョブは、2026-08-09の朝まで正常に動いていました——少なくともそう見えていました。ig-2というアカウントに紐づくレーンは、1日12回スケジュール実行されていた。ログにはその都度「完了」と記録されていた。監視のダッシュボードにはエラーも飛んでいなかった。

実態は、いいね件数=0、フォロー件数=0。一件も何もしていない状態が、何日も続いていました。

原因は単純です。ig-2のアカウントはログアウト状態にあった。Instagramのセッションが切れていた。でも、ジョブはログアウト画面を前にして「対象が尽きた」「条件に合うアカウントがなかった」として exit 0 を返し続けていたのです。

DOM判定はInstagramのログアウト画面を素通りさせた

当時のジョブにはログイン判定のコードがありました。social-autolike/src/run.jsneedLogin() は、大まかに言うと次の2条件でログインが必要と判断していました。

  • URLに /login を含む
  • 本文に「ログイン」という語があり、かつ article / [role=feed] / video / main / [data-e2e] のいずれの要素も存在しない

問題は2番目の条件の後半です。

Instagramのログアウト画面には main 要素があります。これは今も変わらず存在する、ページのセマンティック構造として配置された要素です。DOM判定が「main が0件なら要ログイン」という発想で組まれていたため、ログアウト画面を開いても main が1件見つかってしまい、判定はすり抜けていました。

セレクタは「ログイン画面っぽくないか」を測っている。でも実際に知りたいのは「今このブラウザでログインしているか」という認証状態そのものです。この2つは別物で、UIの構造が変われば前者は簡単にすり抜ける。

Cookieは認証状態そのものである

Chromeのセッション管理は、Default/Network/Cookies(Chromeのバージョンによっては Default/Cookies)というSQLiteデータベースファイルに記録されています。Instagramのログイン状態は sessionid という名前のCookieで管理されており、このCookieが instagram.com ドメインに対して存在するなら、そのプロファイルはログイン済みです。

DOMとの決定的な違いは、これはUIの見た目ではなく、認証の事実そのものだということです。Instagramがどれだけデザインを変えても、ログアウト画面にどんな要素を追加しても、sessionid Cookieの有無はログイン状態と1対1で対応しています。

Cookieを一次情報にするというのは、「推測をやめて事実を見る」という転換です。

この話があなたに関係する理由

Playwright、Puppeteer、browser-use、あるいはClaude Codeで組んだ自動化——形は何であれ、ChromeやChromiumのプロファイルを使ってWebサービスにアクセスしている自動化は、全部同じ問題を抱え得ます。

ジョブが「成果=0件でexit 0」を返すとき、それが「本当に対象が無かった」のか「ログアウトしていて何も見えていなかった」のかを、あなたは今どうやって区別しているでしょうか。

監視グラフの数字が0に張り付いていても、エラーが飛んでいなければ気づきにくい。気づいたとき、すでに数日ぶんの成果が消えています。私の場合、ig-2は何日ぶん失っていたかすら正確にはわかりませんでした。

今回実装した profile-session-guard.sh は、この問いに答えるためのスクリプトです。ChromeのCookie DBを直接読んで「セッションが生きているか」を事前に確認し、切れているなら通知してレーンを止める。以下でその全体像を説明します。


全体の流れ

概観:4つのステップ

スクリプトがやることは大きく4段階です。

[設定ファイル3本]
  accounts.json
  ig-reply-accounts.json      ─→ [プロファイルdir一覧を解決]
  post-accounts.json               + 重複を1件に集約
  + 固定2件(別リポ)
         ↓
[各プロファイルdirでCookie DBを探す]
  Default/Network/Cookies
  Default/Cookies             ─→ [見つかったら mktemp でコピー]
  Network/Cookies
  Cookies
         ↓
[sqlite3 でCookieを検索]
  platform別のSQLクエリ      ─→ [OK / EXPIRED / UNKNOWN を判定]
  コピーは終了時に削除
         ↓
[結果を集計してstateファイルと照合]
  切れ方が前回と同じ → 通知スキップ
  新しい切れ          → Discord通知 + exit 1
  全員OK              → exit 0

順を追って見ていきます。

プロファイルdirを設定ファイルから解決する

スクリプトの最初の仕事は「どのプロファイルを検査するか」を確定することです。

かつての実装はプロファイルdirのリストをスクリプト内に直書きしていました。その結果、作った翌日に metrics-hub/profiles/tiktoktiktok2 という、実際のジョブから使われていない古いdirが「ログイン切れ」として誤報されたのです。実際のTikTokレーンは social-autolike/accounts.jsonreuseProfile フィールドで別のdirを使っていました。直書きリストは作成した瞬間から腐り始めます。

現在の実装は、ジョブが実際に参照している設定ファイルから解決します。

load_accounts() {
  local lane platform dir
  if ! "$JQ" -r --arg root "$ROOT" \
    '.accounts[] | [.id, .platform, (.reuseProfile // ($root + "/profiles/" + .id))] | @tsv' \
    "$ACCOUNTS_CONFIG" >"$SOURCE_FILE"; then
    log "unknown resolver: accounts.json could not be read"
    return 1
  fi

  while IFS=$'\t' read -r lane platform dir; do
    add_candidate "$dir" "$platform" "$lane"
  done <"$SOURCE_FILE"
}

jq クエリの .reuseProfile // ($root + "/profiles/" + .id) がポイントです。設定ファイルに reuseProfile が書かれていればそのパスを使い、なければ profiles/<id> というデフォルトパスにフォールバックする。これはジョブ本体の src/lib.jsprofileDir() と同じ解決ルールです。設定ファイルが正本なので、ジョブが参照するdirとスクリプトが検査するdirが一致し続けます。

同様の処理を ig-reply-accounts.json(自動返信レーン用)と post-accounts.json(投稿レーン用)にも行い、さらに別リポの固定2件を追加します。

add_candidate "~/dev/bokuwalily-sns/profiles/ig-post" "instagram" "bokuwalily-sns:ig-post"
add_candidate "~/dev/brand-404/profiles/ig-o81" "instagram" "brand-404:ig-o81"

同一のプロファイルdirを複数のレーンが参照するケースがあります(ig-1-liveig-autoreply:ig-1ig-1 が両方使うなど)。これを重複して検査するのは無駄なので、canonical パスで重複排除してから検査ループに入ります。ただしレーン名は捨てず、後でログと通知文に「どのレーンが止まるか」を書くために保持します。

[2026-08-09 07:10:05] resolved 22 unique existing profiles

直書きでは存在しないdirも検査対象に含まれていましたが、add_candidate[ ! -d "$dir" ] を確認してスキップするので、まだ作られていないdirは検査もエラーも出ません。

Cookie DBを見つけてコピーする

各プロファイルdirに対して find_cookie_db 関数が走ります。

find_cookie_db() {
  local dir="$1" candidate
  COOKIE_DB=""
  for candidate in \
    "$dir/Default/Network/Cookies" \
    "$dir/Default/Cookies" \
    "$dir/Network/Cookies" \
    "$dir/Cookies"; do
    if [ -f "$candidate" ]; then
      COOKIE_DB="$candidate"
      return 0
    fi
  done
  return 1
}

ChromeはバージョンによってCookieファイルの配置が異なります。Default/Network/Cookies が現行ですが、古いプロファイルや一部のChromiumベースブラウザでは Default/CookiesNetwork/Cookies にある場合もある。4パターンを試して最初に見つかったものを使います。

見つかったら 必ず mktemp でコピーしてから読みます。

TEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)
if [ -z "$TEMP_COOKIE_DB" ] || ! /bin/cp "$COOKIE_DB" "$TEMP_COOKIE_DB" 2>/dev/null; then
  CHECK_DETAIL="Cookies DB copy failed"
  [ -n "$TEMP_COOKIE_DB" ] && /bin/rm -f "$TEMP_COOKIE_DB"
  TEMP_COOKIE_DB=""
  return
fi

Chromeが起動していてCookie DBを掴んでいる場合、直接 sqlite3 で開こうとするとロックエラーになります。コピーを取ってから読むことで、Chromeの稼働状態に関係なく読み出せます。コピーは trap cleanup EXIT で登録したクリーンアップ関数が確実に消します。

platformごとにSQLクエリを切り替える

Cookie DBはSQLiteなので、コピーに対して sqlite3 でクエリを投げます。重要なのは、platformによって確認すべきCookie名が異なることです。

cookie_query() {
  case "$1" in
    x)
      printf "%s" "select count(*) from cookies where name='auth_token' and (host_key like '%x.com%' or host_key like '%twitter.com%');"
      ;;
    instagram)
      printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';"
      ;;
    threads)
      printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%threads%';"
      ;;
    tiktok)
      printf "%s" "select count(*) from cookies where name in ('sessionid','sessionid_ss','sid_tt') and host_key like '%tiktok%';"
      ;;
  esac
}

TikTokが3つのCookie名を IN で検索している理由は、実測で判明した事実に基づきます。TikTokのセッション管理はCookieの組み合わせが環境やアカウントによって異なり、稼働中のアカウント tt-1 が実際に3種すべてを持っていることが確認されました。もし sessionid 1本だけで判定すれば、sessionid_sssid_tt だけを持っているアカウントを「ログアウト」と誤判定します。生きているアカウントを止めることは、ログアウトを見落とすよりもむしろ悪い。

count(*) が返ってくるので、0 なら EXPIRED、1 以上なら当該platformはOKです。複数のplatformを1つのプロファイルが兼ねる場合(instagram,threads のような複合プロファイル)は、1つでも切れていた時点で EXPIRED になります。

count=$(/usr/bin/sqlite3 "$TEMP_COOKIE_DB" "$query" 2>/dev/null)
sqlite_rc=$?
if [ "$sqlite_rc" -ne 0 ] || ! printf '%s' "$count" | /usr/bin/grep -Eq '^[0-9]+$'; then
  unknown=1
  CHECK_DETAIL="sqlite3 failed for $platform"
elif [ "$count" -eq 0 ]; then
  CHECK_STATUS="EXPIRED"
  CHECK_DETAIL="required cookie missing for $platform"
  break
fi

sqlite3 の終了コードと出力値の両方を検証しているのは、DBが壊れていたり想定外のフォーマットだったりするケースへの対処です。この場合は EXPIRED ではなく UNKNOWN として処理し、誤報を避けます。17レーンが全停止するほうが、1レーンの切れを見逃すより損害が大きい。

状態の変化だけ通知する

22プロファイルを全部検査し終わったら集計します。

log "check complete ok=$ok expired=$expired unknown=$unknown"

ここでそのまま通知を飛ばすと、毎回同じアカウントが切れたままでも1日2回通知が来続けます。 通知が多すぎると本物の緊急通知が埋もれる——これは別の問題として実際に経験した話で、本来 need-login として届くべきアラートがダイジェストに落ちていたことがありました。

そのため、前回の切れリストを state ファイルに保存し、内容が変わったときだけ通知します。

previous=""
[ -f "$STATE_FILE" ] && previous=$(/bin/cat "$STATE_FILE" 2>/dev/null)

if [ -n "$broken" ]; then
  if [ "$broken" = "$previous" ]; then
    log "login missing unchanged ($broken); notification skipped"
  else
    body="🔑 ログイン切れ: $broken — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要"
    if notify "$body"; then
      log "notified login missing ($broken)"
    fi
  fi
  write_state "$broken"
  exit 1
fi

write_state ""
exit 0

通知文に $broken を含めていますが、これは単なるアカウント名ではなく ig-2(ig-2, ig-autoreply:ig-2) という形式です。切れたプロファイルより「止まるレーンがどれか」のほうが人間の判断材料として有用なので、どのレーンが参照しているかを括弧内に示しています。

launchdには07:10と19:10の1日2回で登録してあります。NiceValue=10 / ProcessType=Background / LowPriorityIO=true の3キーで低優先度化しており、他の処理に割り込みません。

実装の詳細

deduplicate_profiles:同じdirを複数レーンが参照する現実

ig-1-live というプロファイルdirは、ig-autoreply:ig-1ig-1 という2つのレーンから同時に参照されています。2回検査するのは無駄ですが、「どのレーンが止まるか」という情報は捨てたくない。この2要件を同時に満たすのが deduplicate_profiles です。

deduplicate_profiles() {
  LC_ALL=C sort -t "$TAB" -k1,1 -k3,3 "$CANDIDATES_FILE" | \
    awk -F '\t' '
      {
        dir = $1; platform = $2; lane = $3
        if (!(dir in seen)) {
          seen[dir] = 1
          order[++count] = dir
          platforms[dir] = platform
          lanes[dir] = lane
        } else {
          if (index("," platforms[dir] ",", "," platform ",") == 0) {
            platforms[dir] = platforms[dir] "," platform
          }
          if (index("," lanes[dir] ",", "," lane ",") == 0) {
            lanes[dir] = lanes[dir] ", " lane
          }
        }
      }
      END {
        for (i = 1; i <= count; i++) {
          dir = order[i]
          printf "%s\t%s\t%s\n", dir, platforms[dir], lanes[dir]
        }
      }
    ' >"$PROFILES_FILE"
}

index("," platforms[dir] ",", "," platform ",") == 0 というチェックが「すでに同じplatformが含まれていないか」の確認です。前後にカンマを足してから検索することで部分一致を防いでいます——instagram を検索するとき instagram,threads の中の instagram をちゃんと1件として数えるための工夫です。order 配列で挿入順を保持し、END ブロックで順番どおりに出力しているのは、ログの読みやすさのためです。

Discordへ届く通知本文は ig-2(ig-2, ig-autoreply:ig-2) という形式になります。プロファイル名ではなくレーン名を括弧内に出すのは、「ig-2が切れた」より「ig-autoreplyのig-2が止まる」のほうが人間の次の行動が明確になるからです。プロファイル名を見ても「どのジョブのどのエントリポイントか」は即座にわかりません。レーン名ならコード上の起点に1対1で対応しています。

メインループ:BROKEN_FILEに積んで後で集計する

22プロファイルを順番に処理するループはシンプルです。

while IFS=$'\t' read -r dir platforms lanes; do
  [ -n "$dir" ] || continue
  name=${dir##*/}
  check_profile "$dir" "$platforms"

  case "$CHECK_STATUS" in
    OK)
      log "ok $name ($platforms; lanes=$lanes; dir=$dir)"
      ok=$((ok + 1))
      ;;
    EXPIRED)
      log "login missing $name ($platforms; lanes=$lanes; dir=$dir)"
      printf '%s(%s)\n' "$name" "$lanes" >>"$BROKEN_FILE"
      expired=$((expired + 1))
      ;;
    *)
      log "unknown $name ($platforms; lanes=$lanes; dir=$dir): $CHECK_DETAIL"
      unknown=$((unknown + 1))
      ;;
  esac
done <"$PROFILES_FILE"

EXPIRED になったプロファイルは即座に通知を送らず、BROKEN_FILE という一時ファイルに1行ずつ書き込みます。全件処理が終わってから集計して1回で通知するためです。プロファイルが22件あって4件切れていたとき、1件ごとに通知が飛べばDiscordに4連投になります。ログイン切れは頻度が低い代わりに緊急性が高い——通知が複数来ること自体が「複数のアラートが来ている」として認識の邪魔をします。

BROKEN_FILEをループ後に while IFS= read -r item でセミコロン区切りの文字列に畳むのも同じ発想です。1件の通知本文で「どれとどれが切れているか」を伝えます。

trap cleanup EXIT:6個のtempファイルを確実に消す

cleanup() {
  local file
  for file in "$TEMP_COOKIE_DB" "$CANDIDATES_FILE" "$PROFILES_FILE" \
              "$SOURCE_FILE" "$BROKEN_FILE"; do
    [ -n "$file" ] && /bin/rm -f "$file"
  done
}
trap cleanup EXIT
trap 'exit 130' HUP INT TERM

このスクリプトは mktemp を最大27回呼びます(固定の4ファイル+プロファイルごとのCookieコピー最大22件+write_state内の1件)。trap cleanup EXIT がなければ set -u による未定義変数参照や sqlite3 のクラッシュで中断したとき /tmp にCookieファイルのコピーが残ります。Cookieは認証情報なので /tmp に残存させるのはまずい。

HUP INT TERM のトラップが exit 130 を返しているのは、Ctrl-Cやlaunchdからの停止シグナルで exit 0 を返さないためです。launchdは exit 0 を「正常終了」と記録します。強制停止と正常終了を区別して、ログで後から「launchdが止めた」か「スクリプトが正常完了した」かを判断できるようにしています。

write_state の atomic write

write_state() {
  local value="$1" temp_state
  temp_state="$STATE_FILE.tmp.$$"
  printf '%s' "$value" >"$temp_state" && /bin/mv "$temp_state" "$STATE_FILE"
}

> で直接 $STATE_FILE を上書きするのではなく、$STATE_FILE.tmp.$$ に書いてから mv する理由は、書き込みと読み込みの競合を防ぐためです。launchdで1日2回起動している場合、手動実行との重複や将来的なinterval短縮で同時実行が起きたとき、> の直書きは「書き込み途中の空ファイルを別プロセスが読む」状態を作ります。mv はinode単位の操作でatomicなので、読み手は「前の完全な値」か「新しい完全な値」しか見ません。ファイル名に $$(プロセスID)を含めているのは、複数プロセスが同時にtempファイルを作ったときの衝突を防ぐためです。


私が詰まった話

詰まり①:直書きリストは翌朝に腐っていた

最初のバージョンはプロファイルdirを配列で直書きしていました。

# 初期実装(削除済み・擬似コード)
PROFILES=(
  "~/dev/social-autolike/profiles/ig-1"
  "~/dev/social-autolike/profiles/ig-2"
  "~/dev/social-autolike/profiles/tt-1"
  "~/dev/metrics-hub/profiles/tiktok"    # ← 遺物
  "~/dev/metrics-hub/profiles/tiktok2"   # ← 遺物
)

症状:翌朝の07:10実行ログに metrics-hub/profiles/tiktoktiktok2 がEXPIREDとして上がり、Discordにアラートが飛んだ。

原因:TikTokレーン tt-1〜3 の実態は accounts.jsonreuseProfile フィールドで social-autolike/profiles/tt-N を指していました。metrics-hub/profiles/tiktok は以前の開発サイクルで使っていたdirで、現在どのジョブからも参照されていない。私が「使っていると思って」列挙したdirと、ジョブが実際に使っているdirは別のものでした。

もう一つ見落としていたのは逆方向の問題です。直書きリストには ig-autoreply:ig-1 が参照している旧 metrics-hub/profiles/instagram入っていなかった。誤報を1件出した同じロジックが、別の見落としを1件隠していました。直書きは誤報と見落としを同時に作る。

直し方accounts.json.reuseProfile // ($root + "/profiles/" + .id) という jq クエリで動的解決する。ジョブ本体の src/lib.js が持つ profileDir() と同一規則なので、設定ファイルを変更したとき自動的にスクリプト側も追随します。修正後の初回実行で「22件解決 / ok 20 / expired 2」という結果が出て、誤報していた2件が消え、代わりに見落としていた旧 metrics-hub/profiles/instagram の失効が新たに検出されました。


詰まり②:TikTok を sessionid 1本で見たら生きた垢を止めた

TikTok の判定を書いたとき、他のplatformと同じ発想で書きました。

-- 初期実装
select count(*) from cookies where name='sessionid' and host_key like '%tiktok%';

これを稼働中の tt-1 に当てたところ、count=0 が返ってきてEXPIRED判定になりました。

症状:TikTokのアクティブなアカウントがログイン切れとして検出される。

原因:Cookieコピーに対して sqlite3 で直接確認したところ、tt-1 には sessionid という行がありませんでした。実際に入っていたのは sessionid_sssid_tt の2行でした。

name           | host_key
sessionid_ss   | .tiktok.com
sid_tt         | .tiktok.com

TikTokのCookie構成は環境やアカウントの属性によって違い、sessionid だけを持つプロファイル・sessionid_ss だけのプロファイル・3種すべてを持つプロファイルが混在していました。1種類に決め打ちすると、それを持っていない正常なアカウントを誤検知します。

直し方IN ('sessionid','sessionid_ss','sid_tt') で束ねて「いずれか1行でも存在すればOK」にする。重要なのは「3種とも必要」ではなく「3種のうち少なくとも1種があれば有効」という判定方向です。「生きているのにNGにする」エラーは「切れているのにOKにする」エラーより悪い——前者は動いている機械を自分で止める行為だからです。


詰まり③:Chromeが起動中だとsqlite3がSQLITE_BUSYを返した

最初はコピーなしでCookie DBをそのまま読もうとしていました。

# コピーなし版(初期実装)
count=$(/usr/bin/sqlite3 "$COOKIE_DB" "$query" 2>/dev/null)
echo "rc=$?"  # → rc=5 (SQLITE_BUSY)

症状:Chromeが起動している時間帯に実行すると、すべてのプロファイルがUNKNOWNになる。

原因:ChromeはCookies DBに対してWALモードのロックを持っており、外部プロセスが直接開こうとするとSQLITE_BUSY(エラーコード5)を返します。07:10の定期実行は朝のChromeが立ち上がっている時間帯に重なることが多く、ほぼ毎回コケていました。

sqlite3 のエラーを UNKNOWN にするロジックがあったため、全プロファイルがUNKNOWN扱いになって通知も飛ばず、ログを見るまで気づきませんでした。UNKNOWN設計が誤報を防いでくれた一方で、「UNKNOWNが22件並んでいる」という状態を毎日生成していたことになります。

直し方mktemp でファイルシステム上の別コピーを作り、コピーに対してクエリを投げる。コピーはスナップショットなのでChromeのロック状態と無関係です。コピーが失敗した場合は CHECK_DETAIL="Cookies DB copy failed" として UNKNOWN で続行し、その1件だけを飛ばして残りのプロファイルの検査は継続します。


詰まり④:unfollow の give-up 機構がログアウト中に台帳を焼こうとした

これはセッション切れを見落とすことで、どれだけ深い穴に落ちるかという話です。

unfollow-core.js には累計6回の失敗で相手をフォロー台帳から物理削除する機能があります(quarantineの永久ループを断ち切るためのgive-up設計です)。同じ日、unfollowジョブを1日1回→1日4回に増やす変更も入りました。

ig-2sessionid を失っていました。ログアウト状態でアンフォロー操作をしようとすると、InstagramはログインAPIへリダイレクトする。ジョブにとっては「操作が失敗した」に見えます。1日4回のペースで失敗カウントが積み上がるとしたら、**threshold の6に達するのは2日も要りません。**台帳行が削除されると、その相手は二度とアンフォローできなくなります——記録そのものが消えているからです。

症状(直前で止めたため実害なし):セッション切れのアカウントでジョブが走り続け、失敗カウントが急増している。

原因の連鎖:ログアウト → 全操作が「失敗」扱い → give-upカウントが増加 → threshold超過で台帳行を削除。問題は「失敗」の原因が「環境側(ログアウト)」か「対象側(本当に操作できない相手)」かを区別していなかったことです。

直し方unfollow-sweep.js の起動直後、ブラウザを開く前にCookieを確認し、ログアウト確定なら台帳にも quarantine にも1バイトも書かず即終了する。

const loginState = await hasValidSessionCookie(profileDir, 'instagram')
if (shouldAbortForLogin(loginState)) {
  await stop({ stopReason: 'need-login' })
  return
}

shouldAbortForLogin は純粋関数で true(Cookie確認済み)→ 続行 / false(ログアウト確定)→ 中断 / null(判定不能)→ 続行 の3値を返します。null で続行するのは、「判定できなかった」と「ログアウトしている」は別の事象だからです。ここで止めると、Cookieが読めないだけで生きているアカウントが全停止します。

教訓:「失敗したものを捨てる」機構を設計するとき、失敗の原因が環境側(ログアウト・レート制限・ネットワーク断)である場合を切り分けなければ、正しいデータを捨てることになります。give-up と health-check は必ずセットで設計する。


詰まり⑤:通知が多すぎて本物のログイン切れ通知が埋もれた

profile-session-guard.sh を動かすより前の話です。Discordの通知ルーターに いいね\d+件 という DENY パターンが入っていました——成果レポートをダイジェストに落とすためです。

問題は、ログイン切れを検出したジョブが いいね0件 (need-login) というメッセージを送ったとき、いいね\d+件 のパターンが いいね0件 にもマッチしてダイジェストへ落としていたことです。\d+ はゼロ以上の数字にマッチする——「0件」も「1000件」も同じパターンで飲まれます。

症状:ジョブがログイン切れを報告しているのに、Discordのalerts channelに何も来ない。ダイジェストの中に埋まっていた。

直し方need-login / 未ログイン / 再ログイン / セッション切れ / GUIログイン / 凍結 / permanently をHARD_ACTIONとして全DENYルールより先に評価する形へ変更する。ログイン切れは人間が手動でGUIを操作しなければ絶対に回復しない種類のアラートです。通知が来なければ対処が始まらない。「本人が手を動かさなければ解決しない」系のアラートは、ルーターのDENYに飲まれることを絶対に許してはいけない。

つまずきポイント

前半・中段で個別の経緯を詳しく書きました。ここでは同じ構造を踏んでいる方が自分のコードを点検できるよう、詰まりのパターンを箇条書きで網羅します。

DOM・URL判定の限界

  • Instagramのログアウト画面には main 要素が存在する。 needLogin() が「article / [role=feed] / video / main / [data-e2e] のいずれも0件」を条件にしていたため、ログアウト画面でも main が1件ヒットして判定をすり抜けた。ig-2 はその状態で1日12回走り、いいね0・フォロー0のまま何日も誰にも気づかれなかった。
  • URLに /login が含まれないログアウト状態がある。 リダイレクトのタイミング次第、あるいはサービスがログアウト後に別画面を見せる場合、URL判定も外れる。
  • 「ログイン画面っぽいか」と「ログインしているか」は別の問いだ。 前者はUIの副産物で、サービス側のデザイン変更で即座にすり抜ける。後者は認証状態そのものであり、Cookieが正本。
  • DOM判定を消す必要はない。 hasValidSessionCookie が false を返したら即中断し、それ以外は既存の DOM 判定に委ねる。Cookie 判定と DOM 判定は OR ではなく、前段のゲートとして配置する。

プロファイルパス解決の落とし穴

  • 直書きリストは作成した瞬間から腐り始める。 ~/dev/metrics-hub/profiles/tiktoktiktok2 をスクリプト内配列に列挙したが、実際の TikTok レーン tt-1〜3 は accounts.jsonreuseProfile フィールドで ~/dev/social-autolike/profiles/tt-N を参照していた。作成翌日の07:10実行でこの2件がEXPIREDとして誤報された。
  • 誤報と見落としは同時に発生する。 使われていないdirを誤EXPIREDにした同じロジックが、ig-autoreply:ig-1 が参照していた ~/dev/metrics-hub/profiles/instagram の失効を見落としていた。直書きは「自分が思っている構成」であって「実際に動いている構成」ではない。
  • reuseProfile フィールドによる差し替えを考慮していないと必ずズレる。 profiles/<id> というデフォルトパスを決め打ちしたリストは、reuseProfile で別dirに差し替わっているアカウントを見逃す。
  • 設定ファイルから動的解決する場合、ジョブ本体の解決規則と一致させる。 profile-session-guard.shload_accounts() で使っている jq クエリ .reuseProfile // ($root + "/profiles/" + .id) は、src/lib.jsprofileDir() と同一規則。これが一致しないとスクリプトとジョブが別々のdirを見る。

TikTok固有の落とし穴

  • sessionid 1本だけで判定すると稼働中のアカウントを止める。 稼働中の tt-1 に対して select count(*) from cookies where name='sessionid' and host_key like '%tiktok%' を実行したところ count=0。実際に入っていたのは sessionid_sssid_tt の2行だった。
  • 3種のどれが入っているかはアカウントと環境によって異なる。 sessionid だけのプロファイル・sessionid_ss だけのプロファイル・3種すべて持つプロファイルが混在する。IN ('sessionid','sessionid_ss','sid_tt') で「いずれか1種でもあればOK」にするのが実測に基づく正解。
  • 「3種とも必要」にすると正常なアカウントを偽陰性で止める。 「生きているのにNG」は「切れているのにOK」より悪い。前者は動いている機械を自分で止める行為。

SQLite・ファイル操作の落とし穴

  • Chrome起動中に Cookie DB を直接開くとSQLITE_BUSY(終了コード5)が返る。 コピーなし実装では07:10の定期実行でほぼ毎回全プロファイルがUNKNOWNになっていた。 mktemp でスナップショットを取ってから読むと Chromeのロック状態に関係なく読み出せる。
  • mktemp 自体が失敗することがある。 /tmp が満杯・マウント失敗など。TEMP_COOKIE_DB が空文字のまま sqlite3 に渡すと予期しないファイルを作る。[ -z "$TEMP_COOKIE_DB" ] チェックは必須。
  • Cookieファイルのパスはバージョンで変わる。 Default/Network/Cookies(現行Chrome)・Default/CookiesNetwork/CookiesCookies の4パターンを順に試して最初に見つかったものを使わないと、古いプロファイルや一部の Chromium 系ブラウザで DB が見つからない。
  • Cookieの値は暗号化されているので復号しようとしない。 Chrome は AES-256-GCM で暗号化している。行の存在(count > 0)だけを確認する。値を読もうとしてもバイナリが返るだけ。
  • sqlite3 の終了コードと出力値の両方を検証する。 DB が壊れている・想定外フォーマットのとき終了コードが0でも空文字が返ることがある。grep -Eq '^[0-9]+$' で数値チェックをセットで行う。

通知・監視の落とし穴

  • \d+ は0件を含む。 通知ルーターに いいね\d+件 の DENY パターンがあると いいね0件 (need-login) もダイジェストに落ちる。alerts channel に何も届かず、ログを掘るまでログイン切れに気づかなかった実例。
  • 同じ切れが続くと1日2回×N日で通知が飽和する。 state ファイルに前回の切れ一覧を保存し、内容が同じなら通知を送らない設計にしないと、毎回同じアカウントで鳴り続けて「いつものやつ」として無視されるようになる。
  • プロファイル名より止まるレーン名のほうが人間の判断材料になる。 ig-2 が切れた という通知より ig-2(ig-2, ig-autoreply:ig-2) が切れた のほうが、次に何をすべきかが即座にわかる。プロファイル名はファイルシステムの都合であり、人間はレーン名のほうがジョブとの対応を理解している。

give-up機構との組み合わせの落とし穴

  • ログアウト中に「失敗累積→台帳削除」機構が動くと正しいデータが消える。 unfollow-core.jsUNFOLLOW_GIVEUP_FAILS = 6 はquarantineの永久ループを断つための設計だが、ログアウト状態では全アンフォロー操作が「失敗」扱いになる。1日4回のジョブが走れば2日以内に累計6回に達し、台帳行が物理削除される。削除されると二度と復元できない。
  • 失敗の原因が「環境側」か「対象側」かを区別していないと捨ててはいけないデータを捨てる。 ログアウト・レート制限・ネットワーク断は「環境が壊れている」であって「相手を解除できない」とは別物。give-up 設計とセッション health-check は必ずペアで実装する。
  • 判定不能(null)はログアウト確定(false)と同じ扱いにしてはいけない。 Cookieが読めない理由はさまざまある。「判定できなかった」と「ログアウトしている」を同一視すると、Cookie DBが見つからないだけで全レーンが止まる。

ベストプラクティス

1. 死活の一次情報をCookieに置く

DOM・URL・ページタイトルはUIの副産物。認証状態を知りたいなら認証状態そのものを見る。cookie_query() が投げる SQL は1行でログイン状態を確定させる。

# instagram の例
printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';"

UI がどう変わっても、sessionid Cookie の有無は Instagram のログイン状態と1対1で対応する。

2. 必ずmktempでコピーしてから読む

TEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)
[ -z "$TEMP_COOKIE_DB" ] || /bin/cp "$COOKIE_DB" "$TEMP_COOKIE_DB" 2>/dev/null || return
# ...クエリ実行...
/bin/rm -f "$TEMP_COOKIE_DB"

コピーはスナップショットなのでChromeのWALロックに関係なく読める。Cookieは認証情報なので trap cleanup EXIT で確実に消す。

3. プロファイルパスはジョブ本体と同じ規則で動的解決する

"$JQ" -r --arg root "$ROOT" \
  '.accounts[] | [.id, .platform, (.reuseProfile // ($root + "/profiles/" + .id))] | @tsv' \
  "$ACCOUNTS_CONFIG"

設定ファイルを変えたとき、スクリプト側が自動追随する。解決規則を src/lib.jsprofileDir() と一致させることが鍵。直書きした瞬間から腐り始める。

4. TikTokはIN検索で3種まとめて「いずれか1種あればOK」にする

select count(*) from cookies
where name in ('sessionid','sessionid_ss','sid_tt')
  and host_key like '%tiktok%';

実測で tt-1 は sessionid_sssid_tt の2行を持ち、sessionid は存在しなかった。1種に決め打ちすると環境差で偽陰性を出す。

5. 判定できないときはUNKNOWNで続行しEXPIREDにしない

if [ "$sqlite_rc" -ne 0 ] || ! printf '%s' "$count" | /usr/bin/grep -Eq '^[0-9]+$'; then
  unknown=1
  CHECK_DETAIL="sqlite3 failed for $platform"
  continue  # EXPIREDにはしない
fi

未対応platform・DBが壊れている・mktempが失敗した場合はEXPIREDではなくUNKNOWNで処理する。17レーンが全停止するほうが1レーンの切れを見逃すより損害が大きい環境では、fail-safeはUNKNOWN=続行になる。

6. give-upとhealth-checkをセットで設計する

破壊的な操作(台帳削除・quarantine書き込み)の前に、自分が正常に動ける状態かを確認するゲートを置く。

const loginState = await hasValidSessionCookie(profileDir, 'instagram')
if (shouldAbortForLogin(loginState)) {
  await stop({ stopReason: 'need-login' })
  return  // 台帳に1バイトも書かない
}

shouldAbortForLogin は純粋関数で true(有効)→続行 / false(ログアウト確定)→中断 / null(判定不能)→続行 の3値。判定不能は続行——ここで止めると Cookieが読めないだけで全停止する。

7. 状態の変化だけ通知する

[ -f "$STATE_FILE" ] && previous=$(/bin/cat "$STATE_FILE" 2>/dev/null)

if [ "$broken" = "$previous" ]; then
  log "login missing unchanged ($broken); notification skipped"
else
  notify "🔑 ログイン切れ: $broken ..."
fi
write_state "$broken"

同じ切れが続く間は通知を飛ばさない。通知が多すぎると本物のアラートが埋もれる——これは別の実害として経験した話。

8. HARD_ACTIONパターンを全DENYルールより先に評価する

通知ルーターに以下をHARD_ACTIONとして最上位に登録する。

need-login / 未ログイン / 再ログイン / セッション切れ /
GUIログイン / 凍結 / permanently / sessionid.{0,10}失効

いいね\d+件 の DENY パターンが いいね0件 (need-login) を飲んでいた実例があった。「本人がGUIを操作しなければ絶対に回復しない」種類のアラートが埋もれると、気づくまでの全日数が失われた成果になる。

9. 通知文にレーン名を含める

🔑 ログイン切れ: ig-2(ig-2, ig-autoreply:ig-2) — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要

プロファイル名 ig-2 だけでは「どのジョブが止まるか」がわからない。レーン名を括弧に入れると、受け取った人間の次の行動が明確になる。deduplicate時にレーン名を捨てないのはこのため。

10. 同一dirを複数レーンが参照するなら1回だけ検査してレーン名は保持する

ig-1-liveig-1ig-autoreply:ig-1 の両レーンが参照している場合、2回検査は無駄かつレーン情報が分散する。canonical パスで重複排除し、レーン名はカンマ区切りで保持する。

lanes[dir] = lanes[dir] ", " lane  # ig-1, ig-autoreply:ig-1

11. 存在しないdirはスキップしてエラーにも通知にも出さない

if [ ! -d "$dir" ]; then
  log "skip missing $dir ($platform; lane=$lane)"
  return 0
fi

まだ作られていない・削除されたプロファイルをUNKNOWNで処理すると、環境構築途中のレーンが毎回ノイズを出す。存在確認してから候補に追加する。

12. write_stateはatomic writeにする

temp_state="$STATE_FILE.tmp.$$"
printf '%s' "$value" >"$temp_state" && /bin/mv "$temp_state" "$STATE_FILE"

> で直接上書きすると「書き込み途中の空ファイル」を別プロセスが読む競合を作る。mv はinode単位のatomic操作。プロセスID $$ をtempファイル名に含めることで複数プロセス同時実行時の衝突も防ぐ。

13. Cookieパスは4パターン順番に試す

for candidate in \
  "$dir/Default/Network/Cookies" \
  "$dir/Default/Cookies" \
  "$dir/Network/Cookies" \
  "$dir/Cookies"; do
  [ -f "$candidate" ] && COOKIE_DB="$candidate" && return 0
done

Chromeのバージョンや一部の Chromium 系ブラウザでパスが違う。1パターン決め打ちにするとプロファイルの一部で常にDB未検出になる。


まとめ

セッション死活の判定を「ページがどう見えるか」から「Cookieが実在するか」へ移す——この転換は、技術的な実装の話であると同時に、「何を一次情報にするか」という設計思想の話です。

ig-2 が何日もいいね0件で走り続けた原因は、判定が「認証状態の推測」に依存していたことでした。main 要素が1件あれば「たぶんログインしている」と結論づけていた。Cookieを直接読めば1クエリで確定できたのに、その問いを立てていなかった。

profile-session-guard.sh が最初の実行で出した数字は resolved 22 unique existing profiles / ok=20 / expired=2 でした。直書きリストで誤報していた2件が消え、直書きでは見落としていた別の失効が1件新たに検出された。誤報と見落としを同時に作る——これは比喩ではなく実際に起きたことです。

自動化環境が大きくなるほど、個別の失敗は「成果が少ない」としか見えなくなります。エラーが音を立てなくなる。何もしていない状態が「問題なし」として記録される。このスクリプトが解いているのは技術的な問題だけでなく、「壊れても音がしない」という構造の問題です。

セッションが切れたとき、人間に届くまでの時間がそのまま失われた成果になります。Cookieを一次情報にすること、通知を変化分だけ送ること、give-upとhealth-checkをセットで設計すること——この3つを同じ日に4箇所へ独立に実装することになったのも、気づいてしまえば「全部同じ構造の問題」だったからです。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!