Going from 100,000 yen a month as a college student to 1.2 million yen in monthly revenue didn't happen because the automation stack worked. It happened because I kept fixing it every time it quietly broke. This post is about one of those breakages — including the parts where I got stuck.
The first thing people trip over when they run a high-volume article business on social platforms isn't "producing articles at volume." It's "the brand falling apart after you produce at volume."
I currently run five note accounts in parallel. The themes are deliberately different: note1 (@bokuwalily) is a field log of AI side income, note2 (@bokumolily) is book and product reviews, build (@watashiwalily) covers monthly AI subscriptions sold to SMBs, affiliate (@watashimolily) is the reality of affiliate marketing, and funnel (@lilydesu) documents monetization funnels. Splitting the genres is about increasing the number of contexts the algorithm can latch onto.
But once you run five accounts through the same pipeline, one problem is guaranteed to show up: you can no longer tell which account a post came from just by looking at the thumbnail.
The LLM handles writing differently per account just fine. Thumbnails, however, were generated through a separate path, so the character's hair color changed every time. Some days an article shipped from the build (blue) lane came out with a red-haired character. Each account has a designated hair color for its icon, and the thumbnails weren't following it.
This isn't purely cosmetic. Once the premise that followers recognize an account by its thumbnail collapses, the "this is that person's post, so I'll read it" decision stops happening. The brand never compounds. Everything looks like a different stranger.
Flip that around and simply pinning the thumbnail character's hair color to each account's icon color burns "this account's vibe" into the reader's memory. It's an unglamorous but effective move when you're running multiple accounts.
At the time, I was running the auto-posting scripts daily, at a pace of 10+ articles published per day across all five lanes. By the time I noticed the hair colors were inconsistent, 44 already-published articles were live with the wrong hair color. Fixing that by hand would have burned an entire day. This post is about solving it with automation instead.
The problem split into three layers.
[Root cause]
daily-codex-note.js mis-maps lane -> source
build -> 'note' (comes out red-haired)
affiliate -> 'note2' (comes out black-haired)
v Fix: pass source = lane.key through unchanged
[Going forward (new articles)]
hairForSource(source) in gen-codex-thumbnail.mjs looks up the
HAIR_BY_SOURCE table and returns the correct hair color
v Runs in parallel with the above
[Retrofit (44 past articles)]
retrofit-hair-batch.mjs -> note2 (black) 27 articles
retrofit-hair-codexlane.mjs -> build (blue) 6 / affiliate (blonde) 6 / funnel (yellow-green) 5
|- API: GET /api/v2/creators/{urlname}/contents to list published articles
|- ensureCodexThumbnail() to regenerate the thumbnail
|- swap-eyecatch.js to swap it in via Playwright
|- state/retrofit-hair-*.json for idempotency (resumable mid-run)
The heart of the fix is just under 20 lines added to src/gen-codex-thumbnail.mjs
.
// 髪色は「記事を出すアカウントのアイコンの髪色」に合わせる(2026-07-20 ユーザー指示・以後固定)。
// note1=赤 / note2=黒 / build=青 / affiliate(AIサブスク)=金 / funnel(lilydesu)=黄緑。
export const HAIR_BY_SOURCE = Object.freeze({
note: { en: 'red-haired', jp: '赤髪' },
note2: { en: 'black-haired', jp: '黒髪' },
build: { en: 'blue-haired', jp: '青髪' },
affiliate: { en: 'blonde', jp: '金髪' },
funnel: { en: 'yellow-green-haired', jp: '黄緑髪' },
});
export function hairForSource(source) {
return HAIR_BY_SOURCE[source] || HAIR_BY_SOURCE.note;
}
Object.freeze()
is there to prevent some other part of the scripts from overwriting the table by accident. The fallback is note
(red hair). When an unknown source shows up, falling back to a default is easier to debug than breaking silently.
hairForSource()
gets injected into the part that assembles the thumbnail generation prompt.
function brandStyleForSource(source) {
return `${BRAND_STYLE_BASE}, fixed young ${hairForSource(source).en} anime boy as the hero`;
}
"Fixed character = an anime boy with X-colored hair" is injected in English into the prompt handed to Codex, and the image generator draws the character to spec. I've been burned before by an LLM ignoring CLI arguments and fabricating a value (the incident where my real name got baked into a handle), so hair color follows the same policy: resolve the value from the environment and embed it directly into the prompt.
This function is injected into both paths — free thumbnails (gen-codex-thumbnail.mjs
) and paid covers (gen-paid-cover.mjs
). Since both share the definition, changing it means editing one file.
Building the function is pointless if the caller passes the wrong key. And that's exactly what daily-codex-note.js
was doing.
The diff in commit 7b78ca5
is the evidence.
- const source = lane.key === 'build' ? 'note' : lane.key === 'affiliate' ? 'note2' : 'funnel';
+ // (旧: build→note/affiliate→note2 に再マップしていたが、それだと赤/黒髪になり要望と食い違うため)
+ const source = lane.key;
The pre-fix code converted build to 'note'
(red hair) and affiliate to 'note2'
(black hair). My guess is that during the initial implementation there was a misconception that "source names have to be remapped to thumbnail-system names," and someone hand-wrote a conversion table. The moment HAIR_BY_SOURCE
existed, that conversion became unnecessary — or rather, actively harmful.
The fix is a single line: pass lane.key
straight through as source
. Call hairForSource('build')
and you get blue hair. Obvious in hindsight, but the indirect conversion in the middle was destroying correctness.
The going-forward fix was one line in one commit. The real problem starts here: 44 already-published articles were still live with wrong-hair-color thumbnails.
I never intended to fix 44 by hand. Thumbnail swapping was already automated with Playwright, so I just needed three steps: fetch the list of published articles, regenerate, and swap. What I avoided was cramming all five lanes into a single script. Because of a Playwright profile conflict issue (covered below), I split it into retrofit-hair-batch.mjs
for note2 and retrofit-hair-codexlane.mjs
for build/affiliate/funnel.
note has an unofficial but practical API. GET /api/v2/creators/{urlname}/contents
with kind=note&status=published
returns that account's published articles in page order. Each element of data.contents
in the response carries key
(the article's unique ID), name
(the slug), and eyecatch
(the current thumbnail URL).
The script hits this API starting from page=1
and pages through until is_last_page: true
comes back. For note2, that turned up 27 articles.
async function fetchPublishedNotes(urlname) {
const notes = [];
let page = 1;
while (true) {
const res = await fetch(
`https://note.com/api/v2/creators/${urlname}/contents?kind=note&status=published&page=${page}`
);
const json = await res.json();
notes.push(...json.data.contents);
if (json.data.is_last_page) break;
page++;
}
return notes;
}
This API works without authentication. Since I'm only enumerating my own published articles, being able to fetch without cookies was a big help.
Once I have the article list, I call ensureCodexThumbnail()
for each article to regenerate the thumbnail. That function lives in gen-codex-thumbnail.mjs
and is the same one used in the normal publishing flow.
Roughly, it works like this: enqueue a JSON request into ~/content/note-thumbnail-pipeline/queued/
→ the watcher launchd job fires → generation is requested from Codex → the result lands in done/
→ validate_thumbnail_delivery.py
validates it → it gets copied to a .png
with the same name as the article path.
The only change for the retrofit was thinning out the call site so it works by passing just the article slug (name
) and source
(note2
, build
, etc.) as arguments.
await ensureCodexThumbnail({
title: article.name,
source: lane.key, // ← hairForSource() がここを参照する
outputPath: tmpPngPath,
maxWait: 120_000,
});
Passing the correct lane.key
as source
makes hairForSource('build')
return blue hair, so the generated thumbnail has the right hair color. Same root as the going-forward fix.
Once the thumbnail is generated locally, the next step is pasting it onto the published article. src/swap-eyecatch.js
handles that.
Four steps: open the article's edit screen → click the delete button on the existing icon → hand the new PNG to the file input → press the "Update" button. Each step saves a screenshot, so if it stalls partway through I can look at what happened.
The thing I had to watch out for on the Playwright side was articles where the delete button doesn't appear. Articles that never had an eyecatch set skip the delete step and jump straight to upload. Without that, it hunts for a nonexistent button and times out.
const deleteBtn = await page.$('button[aria-label="アイキャッチ画像を削除"]');
if (deleteBtn) {
await deleteBtn.click();
await page.waitForTimeout(500);
}
// 削除の有無に関わらずアップロードへ
The scariest thing about a retrofit is "it died partway through and now I don't know how far it got." If I'm processing 27 articles in order and the network drops or Playwright crashes on the 15th, restarting from scratch is scary (possible double swaps) and leaving it alone is scary too.
The countermeasure was writing progress into state/retrofit-hair-note2.json
.
{
"processed": [
{ "key": "nXXXXXX", "status": "done", "ts": "2026-07-20T04:12:00Z" },
{ "key": "nYYYYYY", "status": "done", "ts": "2026-07-20T04:14:30Z" }
]
}
The script reads this JSON at startup and skips any key
present in processed
. The state is: "no matter where it stops, re-running node retrofit-hair-batch.mjs
picks up where it left off."
retrofit-hair-codexlane.mjs
for build/affiliate/funnel uses state/retrofit-hair-codexlane.json
the same way.
Here's how it finished.
| Lane | Account | Hair color | Articles targeted | Completed |
|---|---|---|---|---|
| note2 | @bokumolily | Black | 27 | 27/27 |
| build | @watashiwalily | Blue | 6 | 6/6 |
| affiliate | @watashimolily | Blonde | 6 | 6/6 |
| funnel | @lilydesu | Yellow-green | 5 | 5/5 |
44 total. note1 (red hair) was already correct, so it wasn't in scope — it was the one lane the mis-mapping never touched. The conversion table in daily-codex-note.js
was only wrong for build and affiliate, so source='note'
(note1) and source='note2'
passed through untouched.
The broad strokes are as above, but none of it worked on the first try. Here are three places I got stuck, each with symptom, cause, and fix.
--limit
did nothing at all
I didn't want a test run to process all 27 articles right away, so I tried to run it small with --limit 5
. The command looked like this.
node retrofit-hair-batch.mjs --urlname bokumolily --limit 5
Result: all 27 got processed. --limit 5
was completely ignored.
Digging into the cause turned up a classic argument-parser mistake.
// バグのあったコード
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--limit') {
opts.limit = parseInt(argv[i + 1]);
}
}
// 使う側
if (count >= opts.limit) break;
When --limit
is placed at the end of the argument list, argv[i + 1]
is undefined
. parseInt(undefined)
returns NaN
. And count >= NaN
is always false. No matter how high the counter climbs, it never hits the ceiling.
The loop's exit condition was never satisfied, so it processed every article.
The fix was two steps. First, throw an error when the parseInt
result is NaN
, so an argument mistake is immediately visible. Second, I fixed the argument order so --limit
must come before --urlname
.
opts.limit = parseInt(argv[i + 1]);
if (isNaN(opts.limit)) throw new Error(`--limit の次に数値が必要です: ${argv[i + 1]}`);
The thing to watch out for is re-running from that state. The state JSON now has 27 articles marked "done," so re-running with --limit 5
just skips 5 and does zero new work. I had to delete the state JSON and start over. Idempotency is a lifesaver, but it gets in the way when you want to redo everything. I should have made the state JSON deletable with a --reset
flag.
retrofit-hair-codexlane.mjs
(for build/affiliate/funnel) opens each lane's Playwright profile to swap thumbnails. The profiles are profiles/codex-build
, profiles/codex-affiliate
, and profiles/note3
.
The problem is that those same profiles are used by the daily posting job. daily-codex-note.js
fires at specific times in the morning and launches Playwright. Chromium can't have multiple processes using the same profile directory simultaneously. If the retrofit script runs while the job is running, Chromium dies before it can even launch.
The symptom is easy to read — you get this error.
Error: Failed to launch chromium because another instance is already running.
At first I was going to settle for "just run it manually after the posting job finishes." But if I'm running it late at night, checking "is the job running right now?" every single time is a hassle.
As a countermeasure, I added code that inspects processes via ps aux
.
async function waitForProfileFree(profileDir, maxWaitMs = 30 * 60 * 1000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const { stdout } = await execa('ps', ['aux']);
if (!stdout.includes(profileDir)) return; // 使用中プロセスなし
console.log(`プロファイル使用中: ${profileDir} → 60秒待機`);
await sleep(60_000);
}
throw new Error(`${maxWaitMs / 60000}分待ってもプロファイルが空きませんでした`);
}
If the profile path isn't in the ps aux
output, it's considered free; if it is, wait 60 seconds and check again. Designed to wait up to 30 minutes. This prevents the "oops, I ran the retrofit while the posting job was going" mistake.
When I actually tried it, running it around 8 AM caught daily-codex-note.js
mid-run, waited 12 minutes, and then resumed — I confirmed that behavior. Since progress is preserved in state/retrofit-hair-codexlane.json
, resuming after the wait is fully idempotent.
While regenerating thumbnails for the retrofit, some articles had their thumbnail text render as □ (tofu boxes). Specifically, funnel-lane articles whose titles contain emoji.
For example, with a title like "🐾 AIが書いたアフィリ記事で実際に売れた話", the thumbnail text compositing script overlay_note_text.py
tries to draw it in Hiragino Kaku Gothic W9 and the 🐾 turns into tofu. Hiragino doesn't bundle SF Symbols or an emoji font, so it can't display U+1F43E
(paw prints).
The symptom is quiet. The script exits normally and the PNG is produced. It's only when you look at the PNG that you see a □ at the start of the text. It's the kind of thing you notice after it's already published as a thumbnail.
The fix was adding emoji stripping to the sanitize_text()
function.
import re
EMOJI_PATTERN = re.compile(
"["
"\U0001F300-\U0001F9FF" # 顔・物・動物・食べ物・場所
"\U00002600-\U000027BF" # その他記号(☀🌙等)
"\U000023F0" # 目覚まし時計(U+27BFの除外漏れ対策)
"]+",
flags=re.UNICODE
)
def sanitize_text(text: str) -> str:
return EMOJI_PATTERN.sub('', text).strip()
By running values through sanitize_text()
before passing them to --title
and --kicker
, the text is drawn with the emoji removed.
This is not a root-cause fix. The proper fix would be "integrate an emoji-capable font (Noto Color Emoji, etc.) into the compositing," but changing the compositing script's font stack affects the quality of every thumbnail, so for now I'm operating with removal.
As a way to check that the fix works, I generated PNGs before and after the fix from the same emoji-containing title and took SHA-256 hashes, checking whether the stripped-version hashes matched. Visuals obviously differ with and without the emoji; the check was whether the post-removal result is deterministically identical. The stripped-version hashes matched before and after the fix, which confirmed no regression.
Looking back at the implementation, the densest part of this work wasn't the retrofit. It was sealing the root cause with a one-line fix, and then proving that one line worked across 44 real articles. The hairForSource()
implementation is 20 lines; the source = lane.key
fix is one line. But the total work including the retrofit is two scripts of 300+ lines.
The cost of paying back 44 articles of debt accumulated from a small bug is many times the cost of the moment the bug was written. That's not specific to this story — it's something to keep in mind whenever you run multiple automation pipelines in parallel.
Beyond the three covered above (the --limit
NaN problem, the Playwright profile conflict, and the emoji tofu), here's a summary of the traps that became visible when I reviewed the implementation as a whole. It includes not just "moments I got stuck during implementation" but also "design mistakes I noticed afterwards."
① If you don't respect the order — going-forward fix → verify → retrofit — you end up fixing twice
If you run the retrofit script before making the one-line source = lane.key
fix (commit 7b78ca5
), then the next day's daily-codex-note.js
will generate new thumbnails with the old mapping (build → 'note'
) and overwrite the 44 past articles you just corrected. In other words, "I fixed it but it's back to how it was." This time I posted one new article on the build lane after the going-forward fix and visually confirmed blue hair before running the batch — but because I never explicitly defined that as a "procedure," the verification step depended on someone remembering to do it. The order "verify the bug fix is correctly applied to new data before fixing past data" should have been documented as a rule.
② If hairForSource()'s fallback is red hair, you can't distinguish it from "the bug surviving"
I chose note
(red hair) as the fallback on the reasoning that "falling back to a default is easier to debug than breaking silently when an unknown source arrives." But since the original bug was "build articles were coming out red-haired," when a new build-lane article showed a red-haired thumbnail after the going-forward fix, I couldn't tell by eye whether that was "fallback fired = unknown source arrived" or "the fix hasn't taken effect yet." In hindsight, adding a switch that throws on fallback when process.env.STRICT_HAIR_SOURCE
is set — dev and test environments only — would have made verifying the fix dramatically easier. The ideal is a two-tier setup: keep the default fallback in production, run strict mode only during tests.
③ There was no automated way to verify "is it actually fixed?" after the retrofit
swap-eyecatch.js
only confirms "Playwright was able to click the update button." Being able to press the button and the uploaded PNG having the correct hair color are two different claims. What was really needed was fetching the uploaded URL and inspecting pixel values, or at minimum an assertion that the thumbnail URL changed. This time I visually checked all 44 by hand, but at a scale of 100 or 300 that isn't realistic. "The script exited 0" and "the expected artifact actually exists" must be treated as separate propositions, and retrofit-style scripts need a post-verification step built in.
④ Forget Object.freeze() and an accidental overwrite from another script passes silently
export const HAIR_BY_SOURCE = Object.freeze({
note: { en: 'red-haired', jp: '赤髪' },
build: { en: 'blue-haired', jp: '青髪' },
// ...
});
If you export it without Object.freeze()
, an assignment like HAIR_BY_SOURCE.build = { en: 'red-haired', jp: '赤髪' }
in some other importing file goes through at runtime with no error whatsoever. It never actually happened in production, but in a codebase where five-plus lanes' worth of scripts reference one table, freeze functions as a static breakwater. Especially when delegating implementation to Codex — Codex might generate code that adds to or rewrites table entries "for convenience," so there's real value in physically preventing it with freeze.
⑤ Using only is_last_page as the exit condition for a paginated API is risky
The loop in fetchPublishedNotes()
increments the page until json.data.is_last_page
is true
. If the note API returns an error response due to a network failure or a transient 5xx, referencing is_last_page
with no json.data
gives undefined
. undefined
isn't equal to true
, so the loop never stops. The note API was stable this time so there was no actual harm, but adding a safety valve like if (!json?.data?.contents?.length) break
lets it terminate normally when an empty page comes back too.
⑥ I didn't design for whether sanitize_text() was also needed on the retrofit path
Because I put sanitize_text()
at the drawing entry point in overlay_note_text.py
as the emoji-tofu countermeasure, emoji get stripped automatically both going forward and via retrofit calls. But there was no documentation saying "this function lives at the entrance of the drawing pipeline, so callers don't need to sanitize," which cost me time investigating "do I need to sanitize on this side too?" while writing the retrofit script. I needed the habit of leaving a one-line note in the code — or in the README — about the design intent of "what responsibility lives where."
⑦ Without a --reset flag on the state JSON, you can't "redo everything"
state/retrofit-hair-note2.json
records the keys of processed articles and skips them on re-run — an idempotent design. That's correct in itself, but after the --limit NaN
bug processed all records, when I wanted "to redo just the first 5," there was no option other than deleting the state JSON by hand. Deleting the state JSON also erases the record of how far it got. Having a --reset
flag to clear just the state JSON before entering the flow, plus an --only-keys key1,key2
option to reprocess specific articles, would have made later adjustments vastly easier.
⑧ Hardcoding the five lanes' profile paths means editing every script when you add a lane
Before consolidating the lane-to-profile mapping in comment-lanes.json
, there was a period where each script held its own profile paths. When I added the funnel lane, more than six scripts needed profile edits, and missing one of them became one of the causes of the profile conflicts. Centralizing configuration in one place is a structural solution to "missed edits," and it's cheaper to do from the start than to retrofit.
⑨ You can't predict when the daily job will fire during a retrofit
If daily-codex-note.js
fires in its morning slots (build 7:15, affiliate 8:15, funnel 10:40) while retrofit-hair-codexlane.mjs
is running, the profiles conflict and it crashes immediately. I added waitForProfileFree()
to check the profile path via ps aux
every 60 seconds, but that's ultimately a "detect and wait" approach to a race condition. More reliable would be constraining the launchd schedule so the retrofit only runs outside the daily job's slots, or using a global lock file to manage the "somebody is using Playwright" state.
⑩ I almost declared it "done" before confirming the fix worked going forward
The source = lane.key
fix is a one-line diff. Looking only at the code review, the feeling that "this should work" is strong. But "should work" and "actually worked" are different propositions, so this time I actually published one new article after the fix and confirmed the blue-haired thumbnail before moving to the retrofit. Fixes to an automation pipeline should be judged complete by "the correct output was actually produced," not by "the code changed." When there's no staging environment, put "publish one in production and check" into the checklist.
⑪ I only noticed the going-forward bug after 44 articles had piled up
I noticed the thumbnail hair-color bug after 44 articles had been published. At an auto-posting pace of 10 per day, that's roughly a 4–5 day blind spot. Because there was no operational flow for visually checking thumbnails daily, it went undetected until the impression "hmm, the colors are all over the place" had accumulated in my memory. Automation pipelines need an operational rule for daily or weekly sampling checks of output quality. In this case, "a one-minute check every morning that the latest article in each lane has the correct hair color" would have kept 44 down to 4 or 5.
Design principles extracted from this work that generalize to multi-account automation pipelines. Principles without the "why" are useless the next time you're unsure, so I'm recording each one paired with its reason.
① Don't convert source identifiers midway
The moment you write even one indirect table converting lane.key
into source
, that table owns a "single source of truth." This bug happened because the conversion table build → 'note'
, affiliate → 'note2'
survived even after HAIR_BY_SOURCE
was added. Make "pass the identifier through unchanged" the principle, as in source = lane.key
, and conversion tables can't rot. If mapping is needed, confine it to a dedicated function like hairForSource()
and have callers only "pass the identifier through."
② Protect constant tables with Object.freeze()
Apply Object.freeze()
to constant objects imported from multiple files. Accidental assignment from the importing side is then detected immediately as a TypeError. Especially when delegating implementation to Codex — Codex may write code that extends the table "for convenience," so making it physically unmodifiable prevents unintended extension.
③ Make the fallback's "working wrongness" strict in development only
When the fallback behaves identically to production (here, "comes out red-haired"), you can't distinguish a surviving going-forward bug from the fallback firing. Design it so an environment variable like process.env.STRICT_HAIR_SOURCE = '1'
switches to a mode that throws immediately when an unknown source arrives. Running tests in strict mode makes it much easier to confirm the fix works.
④ Respect the order: going-forward fix → verify with new data → fix past data
Bug fixes and past-data fixes must follow the order "confirm the bug fix is correctly reflected in new data, then fix past data." Work in the reverse order and old code keeps running after the retrofit completes, reproducing bad data — articles you supposedly fixed revert the next day. Codify the intermediate step of "verify with one item in production before moving to bulk processing."
⑤ Design "idempotent + state JSON + --reset flag" into batch processing from the start
Include a progress file like state/retrofit-hair-*.json
in the design from the beginning, so re-running only processes what's left — an idempotent design. Alongside it, provide a --reset
flag to zero out the state JSON before re-running, and an --only-keys key1,key2
option to process specific items only. The later you bolt on an idempotent design, the higher the cost of reconciling the state JSON format with existing logic.
⑥ Put a NaN check immediately after parsing in CLI argument parsers
opts.limit = parseInt(argv[i + 1]);
if (isNaN(opts.limit)) throw new Error(`--limit の次に数値が必要です: ${argv[i + 1]}`);
parseInt(undefined)
returns NaN
, and count >= NaN
is always false
. The comparison never holds no matter how large the number, so the limit effectively disappears. Treat argument parsing at the same level as "validating user input," and put type checks and immediate errors right after the parse.
⑦ Detect Playwright profile contention in advance and avoid it by polling
Chromium can't open the same profile directory from multiple processes simultaneously. Before a batch run, search ps aux
for the profile's path string, and if found, poll every 60 seconds waiting for it to free up. Set a maximum wait (30 minutes here) and throw on timeout to prevent waiting forever. This pattern generalizes to any situation where multiple scripts contend for the same resource.
⑧ Put emoji / unsupported-character stripping at the entrance of the drawing pipeline
Putting sanitize_text()
on the caller side means every path — going forward, retrofit, and manual runs — has to call the same function. Put it at the internal entry point of the drawing logic (here, the top of render()
in overlay_note_text.py
) and it applies automatically regardless of which path invokes it. A concrete instance of the principle "place defensive processing as close to the entrance as possible."
⑨ Centralize configuration to prevent "missed edits" across scripts
Consolidate the lane / profile / account URL mapping into a single file like config/comment-lanes.json
. Design each script to read that file and look up its own profile path dynamically. When adding a new lane, updating one file propagates the configuration to every script. Hardcoding creates both "the place to change" and "the place you'll forget to change" at the same time.
⑩ "The script exited normally" and "the expected artifact is correct" are separate assertions
swap-eyecatch.js
exiting 0 and "the uploaded thumbnail has the correct hair color" are different propositions. Bulk-processing scripts must always include a post-verification step that reconciles the processed count against the actual artifacts. This time it was a visual check, but building in "fetch the post-swap image URL and inspect pixel attributes" before scaling would guarantee quality even at 100 or 300 items in the future.
⑪ Settle brand design before the code
The brand definition "what hair color is each account's icon?" comes first; the HAIR_BY_SOURCE
table is its implementation. When the definition changes after the fact, you need a mass retrofit like this one. Before running five accounts in parallel, decide "each account's color identity" and then reflect it in code — that's the correct order. Be conscious from the start of the repayment cost of changing the brand after producing articles at volume.
⑫ Assume from the start that you're responsible for applying fixes to both going-forward and past data
When an "identifier mis-mapping" bug like this is lurking in an auto-posting pipeline, the fix requires two things: the one going-forward line, and a retrofit of past data. Carrying the perspective "it's not done when you fix one line of code — the data that piled up also needs fixing" from the start makes fix planning easier. Especially in a pipeline that auto-generates dozens of items daily, stay in a position where you can immediately estimate how many items were produced during the bug's lifetime.
⑬ Share definitions between paid covers and free thumbnails
hairForSource()
is imported by both gen-codex-thumbnail.mjs
(free thumbnails) and gen-paid-cover.mjs
(paid covers). Collecting the color definitions in one place means changing 1 of the 5 colors is a one-line edit to the HAIR_BY_SOURCE
table that propagates to both paths. Duplicated definitions are a breeding ground for "fixed one side only, now they're out of sync." When running multiple generation scripts in parallel, decide where shared configuration values live up front.
In one sentence: I wrote 300+ lines of scripts in order to fix a one-line bug.
The mis-mapping in daily-codex-note.js
(build → 'note'
, affiliate → 'note2'
) was a judgment error at implementation time. Conversion logic written back when the HAIR_BY_SOURCE
table didn't exist survived after the table was added. That's the "the spec changed but the code didn't" pattern, which shows up especially often in automation pipelines.
The depth of the problem comes from automation pipelines continuing to run every day even on a wrong spec. If I were writing articles by hand, I'd have a chance to notice "this thumbnail's color looks off." But when 10 thumbnails a day are generated automatically, checking each one costs too much and the misses keep piling up. By the time you notice, you have 4–5 days' worth — 44 articles — of debt.
The fix itself was 20 lines of hairForSource()
and one line of source = lane.key
. But what it actually took to solve the problem was the fetch logic for the published-article list, an idempotent state JSON, Playwright profile conflict detection, emoji stripping, and a NaN check in the argument parser — the "surrounding code required to repay the bug." The two retrofit scripts total over 300 lines.
That cost asymmetry is the biggest lesson here. The higher your automation's throughput, the higher the repayment cost of a design mistake. Behind a figure like 1.2 million yen in monthly revenue are dozens of these "get it wrong, then fix it" round trips. Judging "it's running, so it's fine" for weeks on end ends with a chunk of debt surfacing all at once.
This isn't unique to automation, but automation amplifies the effect in particular. Protecting constants with Object.freeze()
, checking for NaN
, guaranteeing idempotency with a state JSON, detecting profile conflicts in advance — each one is unglamorous. But they stack up into the state where "thumbnails come out with the correct hair color for all five accounts, every day."
What sustains 1.2 million yen a month isn't "the system that worked the first time," it's "the system you can fix quickly when it breaks." The one-line fix in commit 7b78ca5
was quick to write, but without an idempotent foundation capable of safely executing a 44-article retrofit, I'd have stepped on another bug mid-fix and turned it into even bigger debt. Automation quality is determined not by "how fast it runs" but by "how controllably you can fix it when it breaks" — something this experience drove home again.
The full picture of the system, the breakdown of the 1.2M yen/month, and the 30-day procedure are collected in a paid note post.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*