Your Cron Job Exits 0 and Does Nothing: Reading Chrome's Cookie SQLite to Know If a Session Is Actually Alive

작성자

카테고리:

← 피드로
DEV Community · Lily · 2026-08-17 개발(SW)

An automation job can fail for days without making a single sound. Mine did: one Instagram lane ran twelve times a day, logged “done” every time, raised zero errors — and liked exactly nothing. This post is about that failure mode and how I fixed the detection, in a series where I share the holes I’ve actually fallen into while mass-producing personal projects.

I went from ¥100k/month as a university student, to ¥600k/month juggling side gigs, to zero income after being laid off, and then rebuilt an autonomous environment with Claude Code — now at ¥1.2M/month in revenue.

This time, the story is: “the job ran every day, produced zero results, and nobody noticed.”

Why this approach works

Jobs fail silently. exit 0 does not mean success

The scary thing about automation is that when it breaks, it makes no noise.

The job for my Instagram auto-like system (social-autolike) was working normally until the morning of 2026-08-09 — or at least it looked that way. The lane tied to the account ig-2 was scheduled to run 12 times a day. Each run logged “complete.” No errors reached the monitoring dashboard.

The reality: likes = 0, follows = 0. It had been doing absolutely nothing for days.

The cause was simple. The ig-2 account was logged out. Its Instagram session had expired. But the job, staring at a logout screen, kept returning exit 0 on the grounds that “there were no targets left” or “no accounts matched the criteria.”

DOM checks walked right past Instagram’s logout screen

The job did have login-detection code at the time. needLogin() in social-autolike/src/run.js decided that login was required based on roughly these two conditions:

  • The URL contains /login
  • The body contains the word “ログイン” (login) AND none of the elements article / [role=feed] / video / main / [data-e2e] exist

The problem is the second half of that second condition.

Instagram’s logout screen has a main element. It’s still there today — an element placed as part of the page’s semantic structure. Because the DOM check was built on the idea that “zero main elements means login is needed,” opening the logout screen still found one main, and the check slipped through.

The selector measures “does this look like a login screen?” But what I actually want to know is the authentication state itself: “is this browser currently logged in?” Those are two different things, and the former slips through the moment the UI’s structure changes.

The cookie is the authentication state

Chrome’s session management is recorded in a SQLite database file at Default/Network/Cookies (or Default/Cookies, depending on the Chrome version). Instagram’s login state is managed by a cookie named sessionid, and if that cookie exists for the instagram.com domain, that profile is logged in.

The decisive difference from the DOM is that this is not the appearance of the UI — it is the fact of authentication. No matter how much Instagram redesigns things, no matter what elements they add to the logout screen, the presence of the sessionid cookie maps one-to-one to login state.

Making cookies your primary source is a shift from guessing to observing the fact.

Why this matters to you

Playwright, Puppeteer, browser-use, or something you wired together with Claude Code — whatever the shape, any automation that accesses a web service using a Chrome or Chromium profile can have this exact problem.

When a job returns “0 results, exit 0,” how do you currently distinguish “there genuinely were no targets” from “it was logged out and couldn’t see anything”?

Even if the number on your monitoring graph is pinned at zero, it’s hard to notice as long as no errors fire. By the time you do notice, days’ worth of results are already gone. In my case, I couldn’t even determine exactly how many days ig-2 had lost.

The profile-session-guard.sh I built this time is a script for answering that question. It reads Chrome’s cookie DB directly to confirm up front whether the session is alive, and if it’s expired, it notifies and stops the lane. Below is the full picture.

The overall flow

Overview: four steps

What the script does breaks into four stages.

[設定ファイル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

Enter fullscreen mode Exit fullscreen mode

(Translation of the diagram: three config files → resolve the list of profile dirs, collapsing duplicates; then look for the cookie DB in each profile dir and copy it with mktemp when found; then query cookies with sqlite3 using a per-platform SQL query to decide OK / EXPIRED / UNKNOWN, deleting the copy on exit; then aggregate the results and compare against the state file — same breakage as last time → skip the notification, new breakage → Discord notification + exit 1, everyone OK → exit 0.)

Let’s walk through it in order.

Resolving profile dirs from config files

The script’s first job is to determine which profiles to inspect.

The earlier implementation hardcoded the list of profile dirs inside the script. As a result, the very next day it falsely reported metrics-hub/profiles/tiktok and tiktok2 — old dirs not used by any actual job — as “logged out.” The real TikTok lanes were using different dirs via the reuseProfile field in social-autolike/accounts.json. A hardcoded list starts rotting the moment you write it.

The current implementation resolves them from the config files the jobs actually reference.

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"
}

Enter fullscreen mode Exit fullscreen mode

The key is the .reuseProfile // ($root + "/profiles/" + .id) part of the jq query. If reuseProfile is written in the config file, use that path; otherwise fall back to the default path profiles/<id>. This is the same resolution rule as profileDir() in the job’s own src/lib.js. Because the config file is the source of truth, the dir the job references and the dir the script inspects stay in sync.

The same processing is applied to ig-reply-accounts.json (for the auto-reply lanes) and post-accounts.json (for the posting lanes), plus two fixed entries from a separate repo.

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"

Enter fullscreen mode Exit fullscreen mode

There are cases where multiple lanes reference the same profile dir (for example, ig-1-live is used by both ig-autoreply:ig-1 and ig-1). Inspecting it twice is wasteful, so duplicates are removed by canonical path before entering the inspection loop. Lane names are not discarded, though — they’re retained so the log and notification text can say which lane will stop.

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

Enter fullscreen mode Exit fullscreen mode

The hardcoded version included dirs that didn’t even exist in the inspection targets, but add_candidate checks [ ! -d "$dir" ] and skips, so dirs that haven’t been created yet produce neither an inspection nor an error.

Finding and copying the cookie DB

The find_cookie_db function runs against each profile dir.

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
}

Enter fullscreen mode Exit fullscreen mode

Chrome places the cookie file differently depending on the version. Default/Network/Cookies is current, but older profiles and some Chromium-based browsers may have it at Default/Cookies or Network/Cookies. It tries four patterns and uses the first one it finds.

Once found, always copy it with mktemp before reading.

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

Enter fullscreen mode Exit fullscreen mode

If Chrome is running and holding the cookie DB, trying to open it directly with sqlite3 produces a lock error. Taking a copy first lets you read it regardless of whether Chrome is running. The copy is reliably removed by the cleanup function registered via trap cleanup EXIT.

Switching the SQL query per platform

The cookie DB is SQLite, so we throw a sqlite3 query at the copy. What matters is that the cookie name to check differs by platform.

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
}

Enter fullscreen mode Exit fullscreen mode

The reason TikTok searches three cookie names with IN is based on a fact discovered by measurement. TikTok’s session management uses different cookie combinations depending on the environment and account, and the live account tt-1 was confirmed to actually hold all three. If I judged on sessionid alone, an account holding only sessionid_ss or sid_tt would be misjudged as “logged out.” Stopping a live account is worse than missing a logout.

Since count(*) comes back, 0 means EXPIRED and 1 or more means that platform is OK. When one profile covers multiple platforms (a composite profile like instagram,threads), it becomes EXPIRED the moment even one of them is broken.

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

Enter fullscreen mode Exit fullscreen mode

Validating both the sqlite3 exit code and the output value handles cases where the DB is corrupted or returns an unexpected format. In those cases it’s treated as UNKNOWN rather than EXPIRED, avoiding false alarms. Having 17 lanes shut down entirely does more damage than missing one lane’s expiration.

Notify only on state changes

After inspecting all 22 profiles, it aggregates.

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

Enter fullscreen mode Exit fullscreen mode

If you fired a notification right here, you’d get two notifications a day even while the same account stays broken. Too many notifications bury the real emergency alerts — that’s a separate problem I actually experienced, where an alert that should have arrived as need-login had been dropped into the digest.

So the previous list of broken profiles is saved in a state file, and it only notifies when the contents change.

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

Enter fullscreen mode Exit fullscreen mode

The notification text includes $broken, but that isn’t just an account name — it’s a format like ig-2(ig-2, ig-autoreply:ig-2). “Which lanes stop” is more useful to a human than “which profile broke,” so the referencing lanes are shown in parentheses.

It’s registered with launchd twice a day, at 07:10 and 19:10. The three keys NiceValue=10 / ProcessType=Background / LowPriorityIO=true make it low priority so it doesn’t interrupt other processing.

Implementation details

deduplicate_profiles: the reality of multiple lanes sharing one dir

The profile dir ig-1-live is referenced simultaneously by two lanes, ig-autoreply:ig-1 and ig-1. Inspecting it twice is wasteful, but I don’t want to throw away the information about “which lanes will stop.” deduplicate_profiles satisfies both requirements at once.

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"
}

Enter fullscreen mode Exit fullscreen mode

The check index("," platforms[dir] ",", "," platform ",") == 0 verifies “isn’t this platform already included?” Adding commas front and back before searching prevents partial matches — it’s how searching for instagram correctly counts the instagram inside instagram,threads as one entry. The order array preserves insertion order, and the END block outputs in that order, for log readability.

The notification body that reaches Discord takes the form ig-2(ig-2, ig-autoreply:ig-2). Putting the lane name, not the profile name, in parentheses is because “ig-autoreply’s ig-2 will stop” makes the human’s next action clearer than “ig-2 broke.” Looking at a profile name doesn’t immediately tell you which job’s which entry point it is. A lane name maps one-to-one to a starting point in the code.

The main loop: pile into BROKEN_FILE and aggregate later

The loop that processes 22 profiles in order is simple.

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"

Enter fullscreen mode Exit fullscreen mode

Profiles that come back EXPIRED don’t send a notification immediately — they’re written line by line into a temp file, BROKEN_FILE. That way everything is aggregated after all processing finishes and sent as a single notification. With 22 profiles and 4 broken, notifying one at a time would mean four consecutive posts to Discord. Login expiration is infrequent but urgent — receiving multiple notifications itself gets in the way of comprehension, registering as “multiple alerts are coming in.”

Folding BROKEN_FILE into a semicolon-separated string after the loop with while IFS= read -r item follows the same thinking. A single notification body conveys “which ones are broken.”

trap cleanup EXIT: reliably deleting six temp files

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

Enter fullscreen mode Exit fullscreen mode

This script calls mktemp up to 27 times (4 fixed files + up to 22 per-profile cookie copies + 1 inside write_state). Without trap cleanup EXIT, an abort from an undefined-variable reference under set -u or a sqlite3 crash would leave copies of cookie files in /tmp. Cookies are credentials, so leaving them lying around in /tmp is bad.

The HUP INT TERM trap returns exit 130 so that Ctrl-C or a stop signal from launchd doesn’t return exit 0. launchd records exit 0 as “normal termination.” Distinguishing forced stops from normal completion lets you determine later from the log whether “launchd stopped it” or “the script completed normally.”

Atomic write in write_state

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

Enter fullscreen mode Exit fullscreen mode

The reason for writing to $STATE_FILE.tmp.$$ and then mv-ing, rather than overwriting $STATE_FILE directly with >, is to prevent a write/read race. When launching twice a day via launchd, if concurrent execution happens through overlap with a manual run or a future shortened interval, a direct > creates a state where “another process reads a half-written empty file.” mv is an inode-level operation and therefore atomic, so a reader only ever sees either “the previous complete value” or “the new complete value.” Including $$ (the process ID) in the filename prevents collisions when multiple processes create temp files at the same time.

Where I got stuck

Stuck #1: the hardcoded list had rotted by the next morning

The first version hardcoded profile dirs in an array.

# 初期実装(削除済み・擬似コード)
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"   # ← 遺物
)

Enter fullscreen mode Exit fullscreen mode

(The two commented lines read “relic.”)

Symptom: the next morning’s 07:10 run log listed metrics-hub/profiles/tiktok and tiktok2 as EXPIRED, and an alert flew to Discord.

Cause: the actual TikTok lanes tt-1 through tt-3 pointed at social-autolike/profiles/tt-N via the reuseProfile field in accounts.json. metrics-hub/profiles/tiktok was a dir used in an earlier development cycle, referenced by no job today. The dirs I enumerated “thinking they were in use” and the dirs the jobs actually use were different things.

The other thing I overlooked was the reverse problem. The hardcoded list did not include the old metrics-hub/profiles/instagram referenced by ig-autoreply:ig-1. The same logic that produced one false alarm was hiding one separate miss. Hardcoding creates false alarms and misses at the same time.

The fix: resolve dynamically with the jq query .reuseProfile // ($root + "/profiles/" + .id) against accounts.json. It’s the same rule as profileDir() in the job’s own src/lib.js, so when the config file changes, the script side follows automatically. The first run after the fix produced “22 resolved / ok 20 / expired 2” — the 2 false alarms disappeared, and instead the expiration of the previously-missed old metrics-hub/profiles/instagram was newly detected.

Stuck #2: judging TikTok on sessionid alone stopped a live account

When I wrote the TikTok check, I wrote it with the same thinking as the other platforms.

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

Enter fullscreen mode Exit fullscreen mode

(The comment reads “initial implementation.”)

Applying this to the live tt-1, count=0 came back and it was judged EXPIRED.

Symptom: an active TikTok account is detected as logged out.

Cause: checking the cookie copy directly with sqlite3 showed that tt-1 had no row named sessionid. What it actually contained were two rows, sessionid_ss and sid_tt.

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

Enter fullscreen mode Exit fullscreen mode

TikTok’s cookie composition differs by environment and account attributes: profiles with only sessionid, profiles with only sessionid_ss, and profiles with all three were all mixed together. Betting on one specific kind falsely flags healthy accounts that don’t have it.

The fix: bundle them with IN ('sessionid','sessionid_ss','sid_tt') so that “OK if even one row exists.” What matters is the direction of the judgment: not “all three are required” but “valid if at least one of the three is present.” The error of “marking a live one NG” is worse than “marking an expired one OK” — because the former is the act of stopping a machine that’s running, by your own hand.

Stuck #3: sqlite3 returned SQLITE_BUSY while Chrome was running

At first I tried to read the cookie DB directly without a copy.

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

Enter fullscreen mode Exit fullscreen mode

(The comment reads “no-copy version (initial implementation).”)

Symptom: when run during hours when Chrome is up, every profile becomes UNKNOWN.

Cause: Chrome holds a WAL-mode lock on the Cookies DB, and an external process trying to open it directly gets SQLITE_BUSY (error code 5). The scheduled 07:10 run often overlaps with the morning hours when Chrome is running, so it failed nearly every time.

Because there was logic turning sqlite3 errors into UNKNOWN, every profile was treated as UNKNOWN, no notification fired, and I didn’t notice until I looked at the log. The UNKNOWN design protected me from false alarms, but it also meant I was generating a state of “22 UNKNOWNs lined up” every single day.

The fix: make a separate copy on the filesystem with mktemp and throw the query at the copy. The copy is a snapshot, so it’s unrelated to Chrome’s lock state. If the copy fails, it continues as UNKNOWN with CHECK_DETAIL="Cookies DB copy failed", skipping only that one entry and continuing the inspection of the remaining profiles.

Stuck #4: unfollow’s give-up mechanism tried to burn the ledger while logged out

This is about how deep a hole you can fall into by missing a session expiration.

unfollow-core.js has a feature that physically deletes a target from the follow ledger after 6 cumulative failures (a give-up design to break out of quarantine’s infinite loop). On the same day, a change also went in raising the unfollow job from once a day to four times a day.

ig-2 had lost its sessionid. When you try to perform an unfollow operation while logged out, Instagram redirects to the login API. To the job, this looks like “the operation failed.” If the failure count piles up at a pace of four times a day, reaching the threshold of 6 doesn’t even take two days. Once a ledger row is deleted, that target can never be unfollowed again — because the record itself is gone.

Symptom (no actual damage, since I stopped it just in time): the job keeps running on an account with an expired session, and the failure count is climbing rapidly.

The causal chain: logged out → every operation treated as “failure” → give-up count increases → threshold exceeded → ledger row deleted. The problem was not distinguishing whether the cause of “failure” was “the environment side (logged out)” or “the target side (a target that genuinely can’t be operated on).”

The fix: immediately after unfollow-sweep.js starts, before opening the browser, check the cookie, and if logout is confirmed, exit immediately without writing a single byte to either the ledger or quarantine.

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

Enter fullscreen mode Exit fullscreen mode

shouldAbortForLogin is a pure function returning three values: true (cookie confirmed) → continue / false (logout confirmed) → abort / null (undeterminable) → continue. It continues on null because “couldn’t determine” and “is logged out” are different events. Stopping here would shut down every live account merely because a cookie couldn’t be read.

Lesson: when designing a “discard what failed” mechanism, if you don’t separate out the case where the cause of failure is on the environment side (logout, rate limiting, network outage), you’ll discard correct data. Always design give-up and health-check as a set.

Stuck #5: too many notifications buried the real login-expiration notification

This predates running profile-session-guard.sh. My Discord notification router had a DENY pattern of いいね\d+件 (“N likes”) — to drop result reports into the digest.

The problem was that when a job that detected a login expiration sent the message いいね0件 (need-login) (“0 likes (need-login)”), the いいね\d+件 pattern also matched いいね0件 and dropped it into the digest. \d+ matches zero as much as anything else — “0 items” and “1000 items” are both swallowed by the same pattern.

Symptom: the job is reporting a login expiration, but nothing arrives in the Discord alerts channel. It was buried in the digest.

The fix: change it so that need-login / 未ログイン / 再ログイン / セッション切れ / GUIログイン / 凍結 / permanently are treated as HARD_ACTION and evaluated before every DENY rule. Login expiration is the kind of alert that can never be recovered from unless a human manually operates a GUI. If the notification doesn’t arrive, nothing starts. Alerts of the “nothing gets solved unless the person moves their hands” variety must never be allowed to be swallowed by the router’s DENY rules.

Pitfalls

I’ve written the individual histories in detail in the first and middle sections. Here I’ll enumerate the stumbling patterns as bullet points so anyone hitting the same structure can inspect their own code.

The limits of DOM and URL checks

  • Instagram’s logout screen has a main element. Because needLogin() used the condition “zero of article / [role=feed] / video / main / [data-e2e],” even on the logout screen one main was hit and the check slipped through. ig-2 ran 12 times a day in that state, with 0 likes and 0 follows, unnoticed by anyone for days.
  • There are logged-out states where the URL doesn’t contain /login. Depending on redirect timing, or if the service shows a different screen after logout, the URL check misses too.
  • “Does this look like a login screen?” and “am I logged in?” are different questions. The former is a by-product of the UI and slips through instantly when the service changes its design. The latter is the authentication state itself, and the cookie is the source of truth.
  • You don’t need to delete the DOM check. If hasValidSessionCookie returns false, abort immediately; otherwise defer to the existing DOM check. The cookie check and the DOM check aren’t an OR — the cookie check is placed as a gate in front.

Traps in profile path resolution

  • A hardcoded list starts rotting the moment you create it. I enumerated ~/dev/metrics-hub/profiles/tiktok and tiktok2 in an in-script array, but the actual TikTok lanes tt-1 through tt-3 referenced ~/dev/social-autolike/profiles/tt-N via the reuseProfile field in accounts.json. The 07:10 run the day after creation falsely reported those 2 as EXPIRED.
  • False alarms and misses happen simultaneously. The same logic that falsely marked an unused dir EXPIRED was missing the expiration of ~/dev/metrics-hub/profiles/instagram, referenced by ig-autoreply:ig-1. Hardcoding is “the configuration I think exists,” not “the configuration that’s actually running.”
  • If you don’t account for substitution via the reuseProfile field, you will drift. A list that hardcodes the default path profiles/<id> misses accounts swapped to a different dir via reuseProfile.
  • When resolving dynamically from config files, match the job’s own resolution rule. The jq query .reuseProfile // ($root + "/profiles/" + .id) used in load_accounts() in profile-session-guard.sh is the same rule as profileDir() in src/lib.js. If those don’t match, the script and the job look at different dirs.

TikTok-specific traps

  • Judging on sessionid alone stops live accounts. Running select count(*) from cookies where name='sessionid' and host_key like '%tiktok%' against the live tt-1 gave count=0. What it actually contained were two rows, sessionid_ss and sid_tt.
  • Which of the three is present differs by account and environment. Profiles with only sessionid, profiles with only sessionid_ss, and profiles with all three are mixed together. Using IN ('sessionid','sessionid_ss','sid_tt') for “OK if any one of them exists” is the measurement-based correct answer.
  • Requiring all three stops healthy accounts with a false negative. “Live but marked NG” is worse than “expired but marked OK.” The former is the act of stopping a running machine yourself.

SQLite and file-operation traps

  • Opening the cookie DB directly while Chrome is running returns SQLITE_BUSY (exit code 5). In the no-copy implementation, the scheduled 07:10 run turned nearly every profile UNKNOWN almost every time. Taking a snapshot with mktemp first lets you read regardless of Chrome’s lock state.
  • mktemp itself can fail. A full /tmp, a mount failure, and so on. Passing TEMP_COOKIE_DB to sqlite3 while it’s still an empty string creates an unexpected file. The [ -z "$TEMP_COOKIE_DB" ] check is mandatory.
  • The cookie file’s path changes with the version. Unless you try the four patterns Default/Network/Cookies (current Chrome), Default/Cookies, Network/Cookies, and Cookies in order and use the first one found, the DB won’t be found on older profiles and some Chromium-family browsers.
  • Cookie values are encrypted, so don’t try to decrypt them. Chrome encrypts with AES-256-GCM. Confirm only the existence of the row (count > 0). Trying to read the value just gets you binary.
  • Validate both the sqlite3 exit code and the output value. When the DB is corrupted or in an unexpected format, an empty string can be returned even with exit code 0. Pair it with a numeric check via grep -Eq '^[0-9]+$'.

Notification and monitoring traps

  • \d+ includes 0. If the notification router has a DENY pattern of いいね\d+件, then いいね0件 (need-login) also drops into the digest. A real case where nothing reached the alerts channel and I didn’t notice the login expiration until I dug through the logs.
  • When the same breakage persists, notifications saturate at 2/day × N days. Unless you save the previous list of breakages in a state file and design it not to notify when the contents are identical, it keeps ringing with the same account every time until it gets ignored as “the usual one.”
  • The name of the lane that stops is more useful to a human than the profile name. ig-2(ig-2, ig-autoreply:ig-2) is broken immediately tells you what to do next, more than ig-2 is broken does. Profile names are a filesystem convenience; humans understand the correspondence to jobs better through lane names.

Traps in combination with the give-up mechanism

  • If a “accumulate failures → delete from ledger” mechanism runs while logged out, correct data is destroyed. UNFOLLOW_GIVEUP_FAILS = 6 in unfollow-core.js is designed to break quarantine’s infinite loop, but in a logged-out state every unfollow operation is treated as a “failure.” If the job runs four times a day, it reaches 6 cumulative failures within two days and the ledger row is physically deleted. Once deleted, it can never be restored.
  • If you don’t distinguish whether the cause of failure is “the environment side” or “the target side,” you’ll discard data you must not discard. Logout, rate limiting, and network outages mean “the environment is broken,” which is a different thing from “this target can’t be unfollowed.” Always implement give-up design and session health-check as a pair.
  • Undeterminable (null) must not be treated the same as confirmed logout (false). There are many reasons a cookie can’t be read. Equating “couldn’t determine” with “is logged out” stops every lane just because the cookie DB wasn’t found.

Best practices

1. Put the primary source for liveness in the cookie

The DOM, the URL, and the page title are by-products of the UI. If you want to know the authentication state, look at the authentication state itself. The SQL that cookie_query() throws settles the login state in one line.

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

Enter fullscreen mode Exit fullscreen mode

(The comment reads “example for instagram.”)

No matter how the UI changes, the presence of the sessionid cookie maps one-to-one to Instagram’s login state.

2. Always copy with mktemp before reading

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"

Enter fullscreen mode Exit fullscreen mode

(The comment reads “…run the query…”)

The copy is a snapshot, so it’s readable regardless of Chrome’s WAL lock. Cookies are credentials, so delete them reliably with trap cleanup EXIT.

3. Resolve profile paths dynamically using the same rule as the job itself

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

Enter fullscreen mode Exit fullscreen mode

When the config file changes, the script side follows automatically. The key is matching the resolution rule to profileDir() in src/lib.js. It starts rotting the moment you hardcode it.

4. For TikTok, bundle all three with an IN search: “OK if any one is present”

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

Enter fullscreen mode Exit fullscreen mode

By measurement, tt-1 had the two rows sessionid_ss and sid_tt, and sessionid did not exist. Betting on one kind produces false negatives from environmental differences.

5. When you can’t determine, continue as UNKNOWN — don’t mark it 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

Enter fullscreen mode Exit fullscreen mode

(The comment reads “don’t make it EXPIRED.”)

Unsupported platforms, a corrupted DB, or a failed mktemp are handled as UNKNOWN rather than EXPIRED. In an environment where 17 lanes shutting down entirely does more damage than missing one lane’s expiration, the fail-safe becomes UNKNOWN = continue.

6. Design give-up and health-check as a set

Before a destructive operation (ledger deletion, quarantine writes), place a gate that confirms you’re in a state where you can operate correctly.

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

Enter fullscreen mode Exit fullscreen mode

(The comment reads “don’t write a single byte to the ledger.”)

shouldAbortForLogin is a pure function with three values: true (valid) → continue / false (logout confirmed) → abort / null (undeterminable) → continue. Undeterminable continues — stopping here would shut everything down merely because a cookie couldn’t be read.

7. Notify only on state changes

[ -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"

Enter fullscreen mode Exit fullscreen mode

Don’t fire notifications while the same breakage persists. Too many notifications bury the real alerts — a separate case of actual damage I experienced.

8. Evaluate HARD_ACTION patterns before every DENY rule

Register the following at the top of the notification router as HARD_ACTION.

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

Enter fullscreen mode Exit fullscreen mode

There was a real case where the いいね\d+件 DENY pattern swallowed いいね0件 (need-login). When an alert of the “can never be recovered from unless the person operates a GUI” variety gets buried, every day until you notice is results lost.

9. Include lane names in the notification text

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

Enter fullscreen mode Exit fullscreen mode

The profile name ig-2 alone doesn’t tell you which job stops. Putting the lane names in parentheses makes the receiving human’s next action clear. This is why lane names aren’t discarded during deduplication.

10. If multiple lanes reference the same dir, inspect once but keep the lane names

When ig-1-live is referenced by both the ig-1 and ig-autoreply:ig-1 lanes, inspecting twice is wasteful and scatters the lane information. Deduplicate by canonical path, and keep the lane names comma-separated.

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

Enter fullscreen mode Exit fullscreen mode

11. Skip nonexistent dirs — no error, no notification

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

Enter fullscreen mode Exit fullscreen mode

Handling profiles that haven’t been created yet, or have been deleted, as UNKNOWN makes lanes still under construction emit noise every run. Confirm existence before adding to the candidates.

12. Make write_state an atomic write

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

Enter fullscreen mode Exit fullscreen mode

Overwriting directly with > creates a race where another process reads “a half-written empty file.” mv is an inode-level atomic operation. Including the process ID $$ in the temp filename also prevents collisions when multiple processes run simultaneously.

13. Try the four cookie paths in order

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

Enter fullscreen mode Exit fullscreen mode

The path differs by Chrome version and across some Chromium-family browsers. Betting on one pattern means the DB is permanently undetected on some profiles.

Summary

Moving session-liveness detection from “how the page looks” to “does the cookie actually exist” — this shift is a story about implementation technique and, at the same time, about a design philosophy: what you treat as your primary source.

The reason ig-2 ran for days with 0 likes was that the check depended on guessing the authentication state. If one main element existed, it concluded “probably logged in.” Reading the cookie directly would have settled it in one query — I just hadn’t asked that question.

The numbers profile-session-guard.sh produced on its first run were resolved 22 unique existing profiles / ok=20 / expired=2. The 2 false alarms from the hardcoded list disappeared, and one separate expiration the hardcoded version had missed was newly detected. Creating false alarms and misses at the same time — that’s not a metaphor, it’s what actually happened.

The larger an automation environment gets, the more an individual failure looks like nothing more than “results are low.” Errors stop making noise. A state of doing nothing gets recorded as “no problems.” What this script solves isn’t only a technical problem — it’s the structural problem that breaking makes no sound.

When a session expires, the time until it reaches a human is exactly the results lost. Making cookies the primary source, sending notifications only on changes, designing give-up and health-check as a set — the reason I ended up implementing these three independently in four places on the same day is that, once you see it, they’re all the same structural problem.

I’ve put the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day procedure into a paid note.

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

Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

원문에서 계속 ↗