{"slug": "three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags", "title": "Three rules my Bluesky non-mutual unfollow script uses to avoid churn flags", "summary": "A developer built a Bluesky unfollow script that prunes non-mutual follows while avoiding churn flags. The script enforces a 14-day grace period before unfollowing, caps daily unfollows at 30, and only targets accounts added via the developer's own follow log, ignoring manually followed accounts. The rules are designed to avoid triggering Bluesky's moderation for follow-unfollow churn.", "body_md": "I'm growing a Bluesky account to distribute content for three AI directory sites — [aiappdex.com](https://aiappdex.com), [findindiegame.com](https://findindiegame.com), [ossfind.com](https://ossfind.com). The [follow growth ramp I built in July](https://dev.to/morinaga/how-i-built-the-bluesky-follow-growth-automation-with-ramp-up-and-quality-filters-27kp) adds 20–30 targeted accounts per day. The problem: not everyone follows back, and a list of 2,000 followed accounts where 1,400 are non-mutual is noisy — and, more concretely, resembles what spam accounts do.\n\nAT Protocol does track follow-unfollow patterns. Rapid follow-then-unfollow churn is the primary signal low-quality growth tools produce, and Bluesky moderation flags accounts for it. So when I built the pruner, I wrote the safety rules in first and the core logic second.\n\nNon-mutual doesn't mean never-will-follow. A newly followed account might not have seen the notification yet, or might check Bluesky infrequently. I settled on 14 days as the minimum grace period because most active users check notifications at least weekly, and I'm not following accounts with millions of followers — my targets are developers and tech-adjacent people who post irregularly.\n\n``` js\nconst cutoff = now.getTime() - graceDays * 24 * 3600 * 1000;\nreturn followEntries.filter((e) => {\n  const t = Date.parse(e.followed_at ?? \"\");\n  return Number.isFinite(t) && t <= cutoff;\n});\n```\n\nAn account I followed on July 1 isn't eligible until July 15. If they follow back on July 14, `viewer.followedBy`\n\nflips to true and they're kept permanently regardless of the eligibility window. The grace period is configurable via `GRACE_DAYS`\n\nenv var; I'm keeping it at the default 14.\n\nEven within eligible accounts, the script never unfollows more than 30 per calendar day. The count is read from a persistent unfollow log file (`data/bluesky-unfollow-log.jsonl`\n\n), filtered to today's JST date:\n\n``` js\nconst doneToday = logEntries.filter(\n  (e) => e.action === \"unfollowed\" &&\n    jstDate(new Date(e.unfollowed_at)) === today\n).length;\nreturn Math.max(0, cap - doneToday);\n```\n\nI'm counting in JST because my follow growth script also uses JST, and I want both numbers on the same calendar page when I audit them. If I'm following 25 per day and unfollowing 30 per day (net -5), something is wrong with my targeting. Consistent timezone makes that arithmetic immediate.\n\nWhy 30 as the cap? It's roughly the same order of magnitude as my daily follow rate. If the script malfunctions and attempts to unfollow the entire follow list at once, it burns 30 on day one, then pauses. The cap resets the next calendar day and I notice before significant damage accumulates.\n\nThe riskiest design mistake a pruner can make is unfollowing accounts you followed manually — people you're genuinely interested in who didn't follow back, which is entirely normal Bluesky behavior. The script avoids this by treating `data/bluesky-follow-log.jsonl`\n\nas the strict source of eligible candidates:\n\n``` js\nconst followEntries = await readLog(FOLLOW_LOG_PATH);\nconst candidates = eligibleEntries(followEntries, processedDids, now, GRACE_DAYS);\n```\n\nIf a DID isn't in `bluesky-follow-log.jsonl`\n\n, the script never evaluates it. Accounts I followed via the Bluesky web app or desktop client are completely invisible to the pruner. The log is append-only and stays permanent — an unfollowed account's entry remains in the log, which means the follow growth script won't re-follow it in a later run.\n\nAccounts that return no profile from the AT Protocol (deleted, suspended, or deactivated) get unfollowed immediately without waiting for the 14-day window. A deleted account can't follow back, so waiting serves no purpose.\n\n```\nexport function decide(profile) {\n  if (!profile) return \"unfollow\"; // account gone; dead follow record\n  if (profile.viewer?.followedBy) return \"keep-mutual\";\n  if (!profile.viewer?.following) return \"skip-not-following\"; // already unfollowed manually\n  return \"unfollow\";\n}\n```\n\nThe `skip-not-following`\n\nbranch handles accounts I unfollowed manually before the script ran — `viewer.following`\n\nbeing false means the follow record is already gone, so the script skips silently rather than trying to delete a record that doesn't exist.\n\nThe script runs as a separate GitHub Actions workflow from the follow growth script. Commit messages from the unfollow log updates include `[skip bluesky-follow]`\n\nto prevent cross-workflow loops — the follow growth workflow checks for that tag and bails if it's present.\n\nRun sequence each day: follow growth runs first (adds new follows), then the pruner runs (evaluates accounts against the now-updated log). The daily cap resets at JST midnight, so both scripts start each day from zero.\n\nTwo weeks in, I don't have useful impact numbers yet. Most of my follows are still inside the 14-day grace window, so the pruner is mostly evaluating eligibility rather than actually unfollowing. The real data — how many non-mutuals shake out after 14 days — should be visible in another week. I don't know if 14 days is too conservative or about right; I'll adjust based on what the log shows.\n\n*Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.*", "url": "https://wpnews.pro/news/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags", "canonical_source": "https://dev.to/morinaga/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags-47ee", "published_at": "2026-07-29 08:16:43+00:00", "updated_at": "2026-07-29 08:36:21.968133+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Bluesky", "AT Protocol", "aiappdex.com", "findindiegame.com", "ossfind.com"], "alternates": {"html": "https://wpnews.pro/news/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags", "markdown": "https://wpnews.pro/news/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags.md", "text": "https://wpnews.pro/news/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags.txt", "jsonld": "https://wpnews.pro/news/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags.jsonld"}}