I went from ¥100k/month as a student, to ¥600k juggling side gigs, to zero after a layoff, and then spent six months building an autonomous Claude Code environment that brought me back to ¥1.2M/month. What I learned along the way wasn't a string of automation wins. It was a pile of lessons about what happens when automation breaks.
On August 28, 2026, the same Instagram carousel showed up three times on my profile. Shortcodes DckjvpPoFRt, DckN-yYkIBsJ, and DclaPYooPjB — posted at 13:58, 17:55, and 21:54 JST. All three times, the job logged "post not found on profile." All three times, a direct check against the Instagram API showed the post had gone through.
The operation succeeded, and the check called it a failure — a false negative that repeated a side effect three times over.
Why this setup works at all
"Building an environment" means driving the number of times I have to touch anything toward zero.
For Instagram, launchd fires a script on schedule, and that script runs caption generation, posting, landing verification, and ledger write-back end to end with no human in the loop. Around 171 jobs run side by side in total, and the Instagram carousel post is one lane. I never have to go check "did today's post go out?" — the ok:true entry in the ledger and the Discord notification tell me instead.
The reason this is an "environment" rather than "work" is that the environment also decides when to retry.
If landing verification (_verify_landed()) returns ok:false, the same job runs again at the next scheduled slot. Instead of a human going "oh, that failed, let me try again," the scheduler re-runs it automatically. Zero time, zero effort — as long as the verdict is correct.
When the verdict is wrong, the mechanism runs in reverse. If the post succeeded but ok:false keeps coming back, the scheduler interprets that as "still not posted" and sends the same content again. The side effect (the post) is already done, but the verification layer insists it isn't. Let retries run in that state and identical posts pile up without limit.
"Producing more" is worse than "stopping"
A false positive — "it actually failed, but we called it a success" — stops the shipping line. That's a visible failure. Someone notices and investigates.
A false negative — "it actually succeeded, but we called it a failure" — increases shipments. That's an invisible failure. The ledger fills with ok:false, and on the surface it looks like "posts are running late." Meanwhile the same post is being mass-produced, and the script calmly schedules the next retry.
For an operation with side effects, the damage from a false negative is not "a failed check" but "a duplicated side effect." Every wrong verdict leaves another trace in the real world. In Instagram's case, that trace was three identical carousels lined up on my profile.
My learning notes record it this way: "[[false-positive-stops-the-line]] was the 'a false alarm halts shipping' pattern. This one is a false alarm that increases shipping. Worse than stopping."
The condition under which an environment works
171 jobs run efficiently because each one can accurately judge whether its own work is done. If the verdict is accurate, a successful job runs once, and only failed jobs get re-executed.
When the verdict breaks, that premise collapses. Successful jobs get treated as failures and re-run too. If the side effect is idempotent (same result no matter how many times you do it), no problem — but a social media post is not idempotent. Send once, one post goes public. Send three times, three go public.
Before asking "how many retries should this have?", you have to ask "is this operation idempotent?" Attaching three retries to a non-idempotent operation is a declaration that you'll tolerate the side effect up to three times. My retry-design reference (retry-and-giveup-design.md) says "throwing the same failure three times doesn't change the result," and it's the same point — when the problem is in the input to the verdict itself, adding attempts changes nothing.
The full sequence
Here's the timeline of what happened on the day of the incident.
[launchd: 13:58 JST]
│
▼
ig_autopost.py
│
├─ (1) キャプション生成・投稿実行
│ IG カルーセル送信 ─────────────► 成功 ✓ (DckjvpPoFRt)
│
└─ (2) 着地確認: _verify_landed()
│
├─ プロフィール DOM 取得
│ キャプション先頭12文字: "👾 個人開発の量産..."
│ IG レンダリング: <img alt="👾"> ← inner_text に出ない
│ 照合文字列(DOM側): " 個人開発の量産 v" ← ズレる
│
└─ 照合NG → ok: false → 台帳: 「投稿されていない」
│
▼
[launchd: 17:55 JST] ← スケジューラ「まだ未投稿」と判断・再実行
│
├─ 同じキャプションで投稿 ──────────────► 重複1本目 (DckN-yYkIBsJ)
└─ _verify_landed() → ok: false ← また偽陰性
│
▼
[launchd: 21:54 JST] ← さらに再実行
├─ 投稿 ───────────────────────────────► 重複2本目 (DclaPYooPjB)
└─ _verify_landed() → ok: true ← ようやく成功判定
Three posts in eight hours, all the same carousel. The script exited all three times believing it had "worked correctly." Only checking the IG API revealed the duplicates.
Root cause: the matching input was broken
_verify_landed() fetched the profile page DOM and compared the first 12 characters of the body text with the first 12 characters of the caption. The design assumed "the DOM's inner_text contains the entire caption."
But Instagram renders emoji as <img> tags. When the caption starts with 👾, the DOM contains <img alt="👾">, and that character never appears in inner_text.
キャプション文字列: "👾 個人開発の量産 vol..."
照合しようとした文字列(先頭12文字): "👾 個人開発の量産 vo"
DOM inner_text(先頭12文字): " 個人開発の量産 vol." ← 👾 が img に化けて消える
→ 永久に不一致
This kind of false negative doesn't break on "did the click work (did the post go out)?" — it breaks on "what did you compare?" The matching mechanism runs fine. The material it's matching is corrupted.
The fix, in three parts
1. Caption normalization (strip emoji and full-width characters)
Transform the match key into a form that doesn't depend on how the DOM renders. By passing both sides — the caption string and the body text pulled from the DOM — through the same function, neither side can drop something the other keeps.
def normalize_caption_key(text: str) -> str:
import unicodedata
# 全角・半角空白を除去
text = "".join(c for c in text if not c.isspace())
# BMP外(絵文字 ord > 0xFFFF)と Unicode カテゴリ So/Sk を除去
text = "".join(
c for c in text
if ord(c) <= 0xFFFF
and unicodedata.category(c) not in ("So", "Sk")
)
# 先頭24文字(残りが24文字未満ならキャプション全体)
return text[:24] if len(text) >= 24 else text
The match width grew from 12 characters to 24 as a margin for the emoji that normalization strips out. The original 12-character rule was designed for unnormalized strings; for the normalized remainder to carry the same amount of information, it has to be longer.
"Apply the same normalization to both the caption and the page body" — that's the crux. Normalize only one side and the transformed lengths won't line up, producing a different mismatch.
2. API-first lookup, with the DOM fallback cut off
The DOM's inner_text depends on how IG renders things, and IG can change that at any moment. An API-first lookup removes that uncertainty.
Using the ds_user_id cookie from the logged-in page, the script queries the internal API directly. If the API returns JSON (i.e., it responded normally) but the post isn't there, the result is locked in as a failure without falling back to the DOM. Only when all 8 queries return non-JSON (the API is broken, etc.) does it fall back to a single DOM check.
API 問い合わせ (最大8回)
│
├─ JSON 返答あり + 投稿あり → ok: true
├─ JSON 返答あり + 投稿なし → ok: false(DOM に逃がさない)
└─ 8回とも JSON 以外 → DOM を1回だけ確認 → ok / false
This is designed so that "couldn't verify" is never mistranslated as "not posted." If you escape to the DOM whenever the API is down, you're right back to inner_text matching, and the false-negative path stays open.
3. Fail-closed design
When API verification is impossible, treat the default as "already posted." The reasoning is asymmetry.
- "Misjudged as not posted → re-run → duplicate": the actual damage this time (3 duplicates, cleanup work afterward)
- "Misjudged as posted → skip → one slot's delay": naturally recovered at the next scheduled run
A duplicate can be deleted later, but it was public for a while and someone has to delete it. A delay is absorbed by the next job. For asymmetric damage, fail-closed — defaulting to the lighter outcome — is the right call.
Alongside this, I introduced an already-live verdict so that posts that are already public don't get recorded as failures in the ledger.
# 既存投稿として検出した場合
return {"ok": True, "note": "already-live"}
If it lands in the ledger as ok:false, the next retry check treats it as "not posted." Recording ok:true is what makes the duplicate guard actually work.
All three fixes landed without touching IG at all — static implementation changes plus pure-function tests (pytest 12 passed). The pre-post duplicate check (has_existing_caption()) was also wired in as a shared function for both the carousel and reel lanes, and it likewise stops fail-closed when the API can't be verified.
Implementation details
normalize_caption_key — why it ended up this way
Let's break the normalization function from above down one more level.
def normalize_caption_key(text: str) -> str:
import unicodedata
text = "".join(c for c in text if not c.isspace())
text = "".join(
c for c in text
if ord(c) <= 0xFFFF
and unicodedata.category(c) not in ("So", "Sk")
)
return text[:24] if len(text) >= 24 else text
The condition ord(c) <= 0xFFFF keeps only characters within the BMP (Basic Multilingual Plane). Most emoji sit at U+1F000 and above, meaning ord exceeds 65535. 👾 has an ord of 128126, so it's excluded immediately. The Unicode categories So (Symbol, Other) and Sk (Symbol, Modifier) are also removed because decorative symbols exist inside the BMP too. ★ (U+2605, category So), for instance, fits in the BMP but can't be relied on to appear consistently in inner_text.
The 24-character match width was worked backwards from measurement. The original 12 characters were designed for unnormalized strings. After normalization, the string is shorter by however many emoji were dropped. With a caption opening like 👾 個人開発の量産 vol.XX ——, the first 12 characters after 👾 falls away become 「 個人開発の量産 v」 — a fairly generic string. Extending to 24 reads through 「 個人開発の量産 vol.18 ——」, which is much less likely to collide with another post.
The key point is what the function is applied to. The same normalize_caption_key goes over both the caption string and the page body pulled from the DOM. Normalize only one side and the post-transform character counts diverge, producing a fresh mismatch. What pytest verified was exactly this scenario: "do both sides match after going through the function?"
# テストの骨格
def test_normalize_both_sides():
caption = "👾 個人開発の量産 vol.18 —— "
dom_text = " 個人開発の量産 vol.18 —— " # IG が img に変換した後
assert normalize_caption_key(caption) == normalize_caption_key(dom_text)
It's a pure function, so it can be verified without connecting to IG at all. That's what "12 passed in pytest with no IG connection" actually means.
API-first lookup — separating "couldn't verify" from "not posted"
Turning the flow above into code, the decision branches look like this:
def _verify_via_api(shortcode: str, caption: str) -> dict:
for attempt in range(8):
resp = _fetch_profile_api(user_id=DS_USER_ID, cursor=...)
if resp is None: # JSON以外が返ってきた
continue
# JSONが返った = API正常
for post in resp.get("edges", []):
node_text = post["node"].get("edge_media_to_caption", {})
key = normalize_caption_key(node_text.get("edges", [{}])[0].get("node", {}).get("text", ""))
if key and key == normalize_caption_key(caption):
return {"ok": True, "source": "api"}
# JSONが返ったが該当なし → DOMへ逃がさない
return {"ok": False, "source": "api", "reason": "not_found"}
# 8回ともJSONでなかった → DOMを1回だけ
return _verify_via_dom(caption)
The core is the asymmetry between the resp is None branch and the return {"ok": False} branch. If "the API is up and returned JSON" but "the post isn't found," that is definitive information: it wasn't posted. There's no reason to escape to the DOM. Only when "none of the 8 attempts returned JSON" does it fall back to a DOM check.
Why 8? Requests to the internal API sometimes hit Instagram's rate limiting and return normally after one or two failures. But if all 8 come back non-JSON, either the API is broken or the session has expired, and asking further won't change the outcome.
The reason for using the ds_user_id cookie is that the logged-in internal API returns post lists as JSON even for profiles you don't follow. Pagination uses the after cursor when needed, but landing verification only checks the most recent posts, so one page is enough.
Fail-closed and the already-live ledger design
# API確認が8回とも不能 → DOMへ行ったが結果も不明 → fail-closed
def _verify_landed(shortcode: str, caption: str) -> dict:
result = _verify_via_api(shortcode, caption)
if result.get("source") == "api":
return result
# DOMフォールバックの結果
dom_result = result
if dom_result.get("ok") is None: # 確認不能
return {"ok": True, "note": "unverified-assumed-live"}
return dom_result
The record format ok: True, note: "already-live" exists because the ledger reader uses ok:true for its skip decision. Recorded as ok:false, the entry gets reprocessed as "not posted" at the next retry. The note field is there so a human reading the ledger can see "why was this treated as success?" — the script logic doesn't use it. Without that distinction, three months from now you'll open the ledger and have no answer to "why is this one true?"
A second check based on post-count delta is included as well. It fetches the profile's post count before and after posting; if the difference is +1, it declares success independently of caption matching. This is a safety net for cases where caption normalization fails to match for whatever reason. Both checks use the same internal API, but they look at different "material" (caption vs. count), so if one breaks, the verdict doesn't die completely.
Where I got stuck
The day after the fix, 16 empty posts showed up
On August 30, two days after the August 28 fix (normalization + API-first + fail-closed), 16 carousels with no body text went public. Two patterns, eight rounds each.
The symptom was "duplicates again," but this time the cause wasn't matching — it was the posting side. keyboard.insert_text in ig_autopost.py never made it into the caption field, and the screenshots showed "Share" being pressed at 0/2200.
This incident was nastier than the previous one, as my learning notes put it:
🔴 The pre-post duplicate guard (
has_existing_caption) and the post-post landing check (_verify_landed) share the same internal API and the same 24-character key (normalize_caption_key). With an empty body, neither matches → the duplicate guard says "not out yet," the landing check says "not out" → treated as failure, same ID re-posted.
Extracting the pre-post duplicate check into a shared function on August 28 was the right call. But as a result of sharing it, the failure mode was shared too. A single function is correct from a reuse standpoint, and it's fine as long as the caption goes in properly. But against the path where the caption comes out empty, every guard that shares it is neutralized simultaneously.
The fix came in two stages.
Stage one: confirm the caption went in before posting.
def insert_caption_with_verify(page, caption: str) -> None:
# 手段1: keyboard.insert_text
# 手段2: clipboard経由でpaste
# 手段3: JSでvalue直接セット
for method in [_keyboard_insert, _clipboard_paste, _js_set_value]:
method(page, caption)
actual = page.locator('[data-lexical-editor]').inner_text()
if len(actual) >= len(caption) - 5: # 5文字マージン
return
raise RuntimeError(f"キャプション投入失敗: 期待{len(caption)}文字/実測{len(actual)}文字")
"Did it go in?" is decided by the actual character count in the field, not by the return value of keyboard.insert_text. The API can report "inserted" while the DOM rendering hasn't caught up. In a live run, it printed キャプション投入 1257文字 / 期待1257文字 and then landing verification succeeded in 13 seconds.
Stage two: block the path where the match key becomes empty, at the entrance.
def normalize_caption_key(text: str) -> str:
# ...(前述の正規化)
result = text[:24] if len(text) >= 24 else text
if not result:
raise ValueError("照合キーが空: 投稿前に中止してください")
return result
An empty key isn't "no match" — it's a third state: "the comparison never happened." Record the result of a comparison that never happened as ok:false in the ledger and a retry fires, multiplying the side effect. Stopping at the entrance with an explicit error breaks the chain of "can't compare → re-post."
TikTok threw errors for 15 straight days, but every post had gone through
On September 8, I discovered that post_reel_tiktok.py had been exiting with RuntimeError every time since September 24. For 15 days, the ledger accumulated a daily ok:false. But when I checked the TikTok dashboard, every single post had succeeded.
The cause: the success check depended on the vendor's fixed wording. After clicking Post, the script polled the page body for 120 seconds waiting for one of five fixed strings to appear.
SUCCESS_TEXTS = [
"動画が投稿されました",
"投稿を作成しました",
"コンテンツが公開されました",
# ...他2種
]
TikTok was navigating to the content management page after posting (URL containing /tiktokstudio/content), but the sidebar label on that screen had changed from 「管理する」 to 「管理」. Nothing in SUCCESS_TEXTS matched, so it threw RuntimeError every time.
There are two reasons it went unnoticed for 15 days. First, the ledger's skip logic only looked at ok:true entries. Video IDs recorded as ok:false became candidates for reprocessing as "not yet posted" on the next run. They had been posted, so had a re-run actually happened, the same video would have gone public twice. The only reason no duplicates appeared is that the ok:false IDs didn't happen to reach the front of the queue — new videos were prioritized.
Second, the failure exception carried neither the page URL nor the start of the body. A single line — RuntimeError: タイムアウト: 成功文言が見つかりません — gives you no way to diagnose "TikTok changed its wording." When an error leaves no evidence at the scene, recovery time balloons. The 15 days were caused by that missing record.
The fix was to layer the checks.
def _is_posted_successfully(page) -> bool:
# 判定1: 従来の成功文言(TikTokが変えるかも)
if any(t in page.inner_text("body") for t in SUCCESS_TEXTS):
return True
# 判定2: URLがuploadを離れてcontentに着いた
if "/tiktokstudio/content" in page.url:
return True
# 判定3: 自分が投げたキャプションの先頭12文字が本文にある
if normalize_caption_key(caption)[:12] in page.inner_text("body"):
return True
return False
Check 3 — "verify using the caption I submitted" — is the most robust. Even if the vendor changes the UI, if I'm the one who posted, my own text will be on the screen. Checks 1 and 2, which depend on the vendor's vocabulary, become fallbacks.
My learning notes record it like this:
A check that depends on vendor wording can't be fixed unless you preserve the evidence at the moment it fails
Designing your error records matters as much as designing your retries. Always attaching page.url and the first 200 characters of body to the exception dates from this incident.
X (formerly Twitter) quote posts showed "0" for five days straight
On September 10, I noticed that every quote post since September 5 had been counted as quoted=0. They had actually been posted, but the landing verification script returned unverified for all of them and never incremented the count.
The cause was Playwright's strict mode combined with a DOM structure change.
// 変更前
const tweetText = await article.locator('[data-testid="tweetText"]').innerText()
A quote post's article element contains two tweetText nodes: my own text and the embedded original post. In strict mode, Playwright's innerText() throws when multiple elements match. That error was caught and turned into an empty string, the comparison fell to unverified, and the count stayed at quoted=0.
The fix is .first() to read only the first element.
const tweetText = await article.locator('[data-testid="tweetText"]').first().innerText()
A one-line change, but it stopped five days of false alerts. health.mjs had been pushing "0 today" notifications to the desktop, so for five days the state was "all quote posts are failing, cause unknown." In reality nothing was broken and every post had succeeded.
This incident has exactly the same structure as TikTok's 15 straight days of exit 1. "When the vendor's DOM goes from one element to multiple, a strict comparison falls to failure and produces a false negative" — same structure, different platform, two weeks later. If I'd been checking that pattern across lanes after learning it once, it would have been closed in 0 days, not 5.
This is the one that stung most. I knew this failure mode, and still couldn't prevent its recurrence on another platform. After the fix, I preemptively added .first() to the reply.mjs in every lane using the same kind of comparison — but that was after the fact.
What the three failures have in common
The 16 empty-caption posts on August 30, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days — what they share is this structure: the comparator can't tell you that the comparator itself is broken.
The script keeps returning ok:false. The ledger accumulates failures. The scheduler books re-runs. Nowhere in that chain is there a signal saying "the input to the verdict is broken." The script is working correctly — with the wrong material.
My retry-design reference (retry-and-giveup-design.md) has this line:
Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count.
All three cases are exactly this. Cut retries to two, leave them at three — as long as the comparison material is broken, nothing changes. When the verdict falls the same way three times in a row — consecutive false negatives — the first thing to suspect is "what am I using as comparison material?"
inner_text's rendering dependency, a vendor changing fixed wording, strict mode's element-count sensitivity — all three are the pattern "the comparison logic I wrote is correct, but the outside environment invalidated the comparison." Adjusting retry counts is powerless against this pattern. You either change the material or change how you compare. There's no third option.
In my current environment, when landing verification fails, the next step is not an immediate retry but asking "why did it fail?" through a separate path. On failure, it hits the API directly to check "does the post exist?" If it does, it's recorded as already-live. Only if it doesn't is "genuinely not posted" confirmed. That one extra step — "verify, then decide" — is the last wall against duplicated side effects.
Pitfalls
A full list of the landmines I actually stepped on. Every one follows the pattern "the script exits normally, and only the real world is broken."
Symbols other than emoji also vanish from inner_text.
★(U+2605, Unicode categorySo) is a BMP character, but it can't be relied on to appear consistently ininner_textunder IG's rendering. I thoughtord(c) <= 0xFFFFwas enough; this is why categoriesSo(Symbol, Other) andSk(Symbol, Modifier) are additionally stripped. Base normalization on "is it in the BMP?" alone and the next incident is on its way.Normalizing the match key on only one side. Normalize just the caption string and compare against the raw DOM text, and the character counts diverge by however many emoji were dropped, creating a new mismatch. Always pass
normalize_caption_keyover both sides — the easiest thing to forget in the whole implementation.Not closing the path where the match key becomes empty. This was the root cause of the 16 body-less carousels on August 30.
keyboard.insert_textinig_autopost.pynever reached the caption field, and "Share" was pressed at 0/2200. The pre-post duplicate guard (has_existing_caption) and the post-post landing check (_verify_landed) share the same 24-character key, so the moment the key goes empty, both are neutralized at once. The duplicate guard says "not out yet," the landing check says "not out," and the same content keeps getting retried."Prevention" and "detection" sharing the same comparator. Correct from a code-reuse perspective, but the instant that function breaks, prevention and detection die simultaneously. A hidden single point of failure. At least one of them needs to verify with different material (post-count delta, API count).
Success checks that depend on the vendor's fixed wording. TikTok navigated to a different page after posting and the sidebar label changed from 「管理する」 to 「管理」 — that alone broke all five registered strings.
post_reel_tiktok.pythrewRuntimeErroron every post and stackedok:falsefor 15 days. Only opening the TikTok dashboard directly revealed that everything had posted.Failure exceptions with no page URL and no body prefix. One line —
RuntimeError: タイムアウト: 成功文言が見つかりません— can't diagnose "TikTok changed its wording." Becausepage.urland the first 200 characters ofbodyweren't in the exception, isolating the cause took 15 days.Playwright strict mode throwing on multiple elements. An X (formerly Twitter) quote post's article element has two
[data-testid="tweetText"]nodes — my own text and the embedded original.innerText()threw in strict mode, returned an empty string, the comparison fell tounverified, and every quote post since September 5 was counted asquoted=0. One added.first()fixed it, but it took five days to notice.A design that mistranslates "couldn't verify" as "not posted." Fall back to the DOM when the API is down and you're back to
inner_textmatching. "The API returned JSON but the post isn't found" is definitive (not posted); "none of 8 attempts returned JSON" just means it couldn't be verified. Escape to the DOM without distinguishing the two and the false-negative recurrence path stays open.Throwing the same failure three times with more attempts. note-autolike's rewrite produced zero output for three days because the validator rejected titles over 58 characters while the generation prompt said nothing about a length limit.
askClaudesimply re-sent the identical prompt three times on failure, and got the same 63-character title three times. A constraint has to be written in both the validator and the generation prompt to function.Exiting 0 immediately on slot-acquisition failure.
browser-slot.shprintedSKIP: global limit reachedand exited the instant it couldn't grab one of the global slots (3), so 12 jobs in the 12:00 hour, 9 in the 09:00 hour, and 8 in the 11:00 hour vanished without ever starting. After adding a 600-second wait option and randomizing the retry interval to 15–45 seconds, misfires dropped to nearly zero. A fixed interval makes every job stampede at the same instant (thundering herd), so the random spread is mandatory.A fallback that lands on the same wall. In
gen_note_thumbs.pyI added a three-tier fallback — real Chrome → bundled chromium → env — and all three tiers ended in the same 90-secondrc=-9. Same symptom doesn't mean same cause. Before wiring in a fallback, measure once whether that fallback hits a different wall than the original.IG's delete API returning 200 without deleting. Call the delete API with an expired session (
sessionid) and it returns 200 with an HTML body. Trying to delete the three August 28 duplicates, not one was removed. Don't define success as "the API returned 200" — the exact same lesson as post verification.Best-effort pass-through that only surfaces downstream. In IG reel crop-ratio selection, there was a branch that silently passed through when
query_selectorreturned None. The label varied by day — 「オリジナル」, 「Original」, 「9:16」, 「元の写真」, 「元の比率」 — while the code's candidate list was fixed. 9:16 wasn't selected, "Next" was pressed at 1:1, and a video with the sides cropped off went public on the grid. Select/toggle operations need measured confirmation that the selection took before moving to the next step.Trusting the note CTA paste result. On September 2, note.com changed its behavior to drop quote blocks. The script verified by matching a signature string at the end, so it judged the stripped articles as "not applied" and kept re-pasting — producing 59 duplicate CTAs and 31 missing signatures. It should have verified by reading back the published page, not the editor's "inserted" record.
Reproducing the same-shaped bug on another platform. I'd learned the structure "DOM goes from one element to multiple → strict comparison falls to failure → false negative" on TikTok. Two weeks later I hit the exact same structure on X. The result of having no habit of preemptively fixing same-shaped spots across all lanes at fix time. Checking a pattern learned in one place across the board would have closed it in 0 days, not 5.
Best practices
Here are the patterns actually in use in my environment now, distilled from the pitfalls above.
1. Always pass both sides of a match key through the same normalization function
def normalize_caption_key(text: str) -> str:
import unicodedata
text = "".join(c for c in text if not c.isspace())
text = "".join(
c for c in text
if ord(c) <= 0xFFFF
and unicodedata.category(c) not in ("So", "Sk")
)
return text[:24] if len(text) >= 24 else text
Run the same function over both the caption string and the DOM-extracted text. One side alone leaves the post-transform lengths out of sync and produces a new mismatch.
2. Turn an empty match key into an error at the entrance
result = text[:24] if len(text) >= 24 else text
if not result:
raise ValueError("照合キーが空: 投稿前に中止してください")
return result
An empty key isn't "no match (ok:false)" — it's the third state, "the comparison never happened." Stack it as ok:false in the ledger and the next retry fires, multiplying side effects.
3. Verify side-effecting operations against the other side's actual state
def _verify_via_api(shortcode: str, caption: str) -> dict:
for attempt in range(8):
resp = _fetch_profile_api(user_id=DS_USER_ID, cursor=...)
if resp is None:
continue
for post in resp.get("edges", []):
key = normalize_caption_key(...)
if key and key == normalize_caption_key(caption):
return {"ok": True, "source": "api"}
return {"ok": False, "source": "api", "reason": "not_found"}
return _verify_via_dom(caption)
"The API returned JSON but the post isn't found" is definitive — don't escape to the DOM. Only when none of 8 attempts returns JSON, use the DOM exactly once. "Couldn't verify" and "not posted" are different states.
4. Fail closed — treat "unverifiable" as "already posted"
The damage from a duplicate (cleanup work, time spent public) and from a delay (absorbed by the next job) is asymmetric. When you can't verify, pick the lighter one and record {"ok": True, "note": "unverified-assumed-live"} in the ledger. Recording ok:true is what prevents the next retry.
5. Use different material for "prevention" and "detection"
If the pre-post duplicate guard and the post-post landing check share the same 24-character key, one breaking kills both. Run the post-count delta (difference in profile post count before and after) as an independent verification material. With caption and count moving independently, one breaking doesn't kill the verdict completely.
6. Judge caption insertion by the field's actual character count
def insert_caption_with_verify(page, caption: str) -> None:
for method in [_keyboard_insert, _clipboard_paste, _js_set_value]:
method(page, caption)
actual = page.locator('[data-lexical-editor]').inner_text()
if len(actual) >= len(caption) - 5:
return
raise RuntimeError(f"キャプション投入失敗: 期待{len(caption)}文字/実測{len(actual)}文字")
"It went in" is decided by the field's actual character count, not the API's return value. Try three methods and verify by reading back — in a live run it printed キャプション投入 1257文字 / 期待1257文字 and then passed landing verification in 13 seconds.
7. Judge success by the text you submitted, not vendor wording
def _is_posted_successfully(page, caption) -> bool:
if any(t in page.inner_text("body") for t in SUCCESS_TEXTS):
return True
if "/tiktokstudio/content" in page.url:
return True
if normalize_caption_key(caption)[:12] in page.inner_text("body"):
return True
return False
Check 3 — "verify using the caption I submitted" — is the hardest to break. The vendor can change its UI, but the text I posted will be on the screen. Fixed strings are the fallback.
8. Always attach page.url and the first 200 characters of body to failure exceptions
When a vendor-wording-dependent check breaks, diagnosis takes 15 days if there's no evidence at the scene. Always put page.url and page.inner_text("body")[:200] in the exception's one line.
9. Use .first() explicitly under Playwright strict mode, and add it preemptively across all lanes
// 変更前: strict modeでthrow
const tweetText = await article.locator('[data-testid="tweetText"]').innerText()
// 変更後
const tweetText = await article.locator('[data-testid="tweetText"]').first().innerText()
At fix time, check every lane using the same kind of comparison and apply the same fix. This is the habit that prevents "learned it in one place, recurred on another platform."
10. When the same failure happens three times, suspect what you're passing in, not the count
Exactly as written in retry-and-giveup-design.md — "Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count." Deterministic failures (validation violations, expired logins, vendor wording changes) must be classified before retrying, or adding attempts just burns resources.
11. Set retry counts by "how many times may this side effect occur?"
Attaching three retries to a non-idempotent operation declares that you'll tolerate the side effect up to three times. Before designing retries for social posts, ask "is this operation idempotent?" If not, always insert a step that checks the current state before retrying.
12. Wait up to 600 seconds for a slot instead of skipping immediately
BROWSER_SLOT_WAIT_SEC=600 # 0にすれば従来の即skip
# 再試行間隔はランダム(thundering herd 防止)
sleep $((RANDOM % 30 + 15))
BROWSER_SLOT_WAIT_SEC=0 exactly matches the old immediate-skip behavior, preserving backward compatibility with the 30+ existing launchd jobs.
13. Distinguish "waited / gave up / skipped" in the ledger log
If a record that gave up after waited=600s and a record that did nothing and hit exit 0 look like the same line, you can't tell "inefficient" from "never ran at all." To judge from the ledger whether a run of false negatives means "the comparator is broken" or "it genuinely isn't running," always log the reason.
14. When you add a retry, put fault injection in the same commit
# 本番では無効、注入時だけ有効
WA_API_FAIL_ONCE=2 node index.js
# → "api retry attempt=1/3" → "attempt=2/3" → exit 0 で完走
# 未設定なら retry ログが1行も出ない
Claiming you "added" a retry without fault injection proves nothing, because intermittent failures can't be reproduced and you can't show the path was actually exercised. Don't stop at showing the git diff.
15. Read back the published page before recording "applied"
Don't trust the editor's "inserted" return value. The 90 duplicate note CTAs happened because this verification step was missing. The principle reader-state-not-self-record — judge by the other side's actual state, not your own send log — applies to both post verification and paste verification.
Summary
In roughly two weeks starting August 28, three platforms broke the same way in succession. IG's 3 duplicates, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days — every one has the structure "the comparator works fine, but the material it compares is broken."
The script throws no errors. The ledger keeps stacking ok:false, and the scheduler calmly books re-runs. Nowhere is there a signal saying "the job is broken." You only notice when you look at reality directly from outside — three carousels lined up on the profile, a post list checked on the dashboard, a ledger finally opened after a week of zero-count notifications.
One line from retry-and-giveup-design.md sums up this structure:
Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count.
In my environment now, when landing verification fails, the next step isn't an immediate retry but asking "why did it fail?" through a separate path. Hit the API directly to confirm the post exists; if it does, record it as already-live with ok:true. Only if it doesn't is "genuinely not posted" confirmed.
That one extra step — verify, then decide — is the precondition for running 171 jobs autonomously. Building an environment means driving the number of times I have to touch anything toward zero. Keeping it at zero means continually asking what the verdict is actually based on. The comparator doesn't break. What breaks is the material the comparator is looking at.
One question for you: in your own automation, which success check is currently trusting a return value instead of reading back the other side's actual state?
The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day setup guide are in a paid note (Japanese).
📕 How I actually make money with an autonomous Claude Code environment — system, examples, getting started, support
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)