<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ZHIWEI</title>
    <description>The latest articles on DEV Community by ZHIWEI (@zhiwei_224e5e15600342dc0b).</description>
    <link>https://dev.to/zhiwei_224e5e15600342dc0b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4181129%2F67c44224-3270-4202-b374-fc7b047cf507.png</url>
      <title>DEV Community: ZHIWEI</title>
      <link>https://dev.to/zhiwei_224e5e15600342dc0b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhiwei_224e5e15600342dc0b"/>
    <language>en</language>
    <item>
      <title>What actually happens between “type a topic” and a lip-synced rap video</title>
      <dc:creator>ZHIWEI</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:58:36 +0000</pubDate>
      <link>https://dev.to/zhiwei_224e5e15600342dc0b/what-actually-happens-between-type-a-topic-and-a-lip-synced-rap-video-22am</link>
      <guid>https://dev.to/zhiwei_224e5e15600342dc0b/what-actually-happens-between-type-a-topic-and-a-lip-synced-rap-video-22am</guid>
      <description>&lt;p&gt;When we tell people we built a generator that turns a sentence — or a photo — into a 12-second rap performance, the follow-up question is always the same: "okay, but what is actually happening in there?"&lt;/p&gt;

&lt;p&gt;This post is the honest walkthrough of the pipeline behind AI Rap Video (&lt;a href="https://useairapvideo.com" rel="noopener noreferrer"&gt;https://useairapvideo.com&lt;/a&gt;), step by step, with the failure modes we hit while building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Lyrics from a topic
&lt;/h2&gt;

&lt;p&gt;Everything starts with text. The user gives a topic ("my coworker who microwaves fish"), optionally their own lyrics, and picks a stage template. A lyric model turns the topic into structured bars: rhyme pairs, a hook, and a length target that fits the video duration. The template matters here — a diss track needs punchlines, a birthday rap needs the recipient's name in the first two bars, so the prompt carries template constraints, not just the topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — A rap voice performs them
&lt;/h2&gt;

&lt;p&gt;The lyrics go to a rap-styled vocal model. This is not text-to-speech with a filter: delivery — where the flow sits relative to the beat, which syllables get stressed — is part of the generation. The same bars performed straight sound like a recital; performed with push-pull against the beat, they sound like a verse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — An original beat
&lt;/h2&gt;

&lt;p&gt;The beat is composed per video and matched to the vocal take's tempo. We made one deliberate product decision here: &lt;strong&gt;no copyrighted audio, ever.&lt;/strong&gt; No famous instrumentals, no "sounds like" beats. The audio is original by default, users can upload beats they own the rights to, and there is a DMCA takedown flow. It is more work than bundling known beats, but it is the only version of this product we were willing to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Lip sync from a photo
&lt;/h2&gt;

&lt;p&gt;If the user supplied a photo, a lip-sync model animates the face to the vocal: it extracts mouth-region landmarks, generates a mouth shape sequence aligned to phonemes, and composites it back. Front-facing, well-lit photos sync dramatically better than angled ones — the model needs to see the whole mouth. Two photos? The duo template maps each face to its own vocal part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Render
&lt;/h2&gt;

&lt;p&gt;The vocal, beat, and animated video are composited and rendered as a 12-second MP4 (480p or 720p). Each render consumes credits from a one-time pack — there is no subscription, which users mention unprompted more than we expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch it end to end
&lt;/h2&gt;

&lt;p&gt;There are four full sample videos on the homepage at &lt;a href="https://useairapvideo.com" rel="noopener noreferrer"&gt;https://useairapvideo.com&lt;/a&gt; — diss track, birthday, Santa, and a two-person duo — if you want to see all five steps compressed into a dozen seconds each. The &lt;a href="https://useairapvideo.com/ai-rap-generator" rel="noopener noreferrer"&gt;rap generator&lt;/a&gt; itself runs free 6-second previews, one per account, if you would rather poke at it directly.&lt;/p&gt;

&lt;p&gt;Questions about any of the steps are welcome in the comments — especially on the lip-sync stage, which is where most of our iteration time went.&lt;/p&gt;

&lt;p&gt;— AI Rap Video team&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>Grok TTS speech tags, tested: what &lt;loud&gt;, &lt;singing&gt; and [laugh] actually do</title>
      <dc:creator>ZHIWEI</dc:creator>
      <pubDate>Sun, 01 Nov 2026 03:02:49 +0000</pubDate>
      <link>https://dev.to/zhiwei_224e5e15600342dc0b/grok-tts-speech-tags-tested-what-and-laugh-actually-do-1jda</link>
      <guid>https://dev.to/zhiwei_224e5e15600342dc0b/grok-tts-speech-tags-tested-what-and-laugh-actually-do-1jda</guid>
      <description>&lt;p&gt;xAI's Grok text to speech API (&lt;code&gt;POST https://api.x.ai/v1/tts&lt;/code&gt;) has 28 built-in voices, 20 languages and a set of speech tags you put straight into the text. The docs list the tags; they don't say how much each one changes. I measured a couple.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.x.ai/v1/tts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stand back. &amp;lt;loud&amp;gt;This is not a drill.&amp;lt;/loud&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;voice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;output_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mp3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bit_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mp3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;language&lt;/code&gt; also takes &lt;code&gt;auto&lt;/code&gt;. Price is $15 per million characters (xAI pricing page, checked 2026-10-30).&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of tags
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inline&lt;/strong&gt; tags insert a sound where they sit: &lt;code&gt;[pause]&lt;/code&gt;, &lt;code&gt;[long-pause]&lt;/code&gt;, &lt;code&gt;[hum-tune]&lt;/code&gt;, &lt;code&gt;[laugh]&lt;/code&gt;, &lt;code&gt;[chuckle]&lt;/code&gt;, &lt;code&gt;[giggle]&lt;/code&gt;, &lt;code&gt;[cry]&lt;/code&gt;, &lt;code&gt;[tsk]&lt;/code&gt;, &lt;code&gt;[tongue-click]&lt;/code&gt;, &lt;code&gt;[lip-smack]&lt;/code&gt;, &lt;code&gt;[breath]&lt;/code&gt;, &lt;code&gt;[inhale]&lt;/code&gt;, &lt;code&gt;[exhale]&lt;/code&gt;, &lt;code&gt;[sigh]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrapping&lt;/strong&gt; tags change how the enclosed phrase is read: &lt;code&gt;&amp;lt;soft&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;whisper&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;loud&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;build-intensity&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;decrease-intensity&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;higher-pitch&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;lower-pitch&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;slow&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;fast&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;sing-song&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;singing&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;emphasis&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What two of them measured
&lt;/h2&gt;

&lt;p&gt;Same sentence, voice Eve, English, one clip with and one without the tag. One clip each, so this is a quick check rather than a benchmark.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Length&lt;/th&gt;
&lt;th&gt;Loudness (RMS)&lt;/th&gt;
&lt;th&gt;Median pitch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none → &lt;code&gt;&amp;lt;loud&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.5 s → 5.5 s&lt;/td&gt;
&lt;td&gt;-20.7 → -20.9 dBFS&lt;/td&gt;
&lt;td&gt;195 → 253 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none → &lt;code&gt;&amp;lt;singing&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;6.5 s → 8.4 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;189 → 250 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So &lt;code&gt;&amp;lt;loud&amp;gt;&lt;/code&gt; is closer to "raised, urgent delivery" than a volume control: if you need a louder file, normalise it afterwards. &lt;code&gt;&amp;lt;singing&amp;gt;&lt;/code&gt; stretches the words into a melody, which also makes the clip longer (and, on a per-character price, not more expensive).&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying tags without writing code
&lt;/h2&gt;

&lt;p&gt;If you want to hear the tags on all 28 voices before wiring up the API, I built a free front-end: &lt;a href="https://groktexttospeech.com/" rel="noopener noreferrer"&gt;Grok Text to Speech&lt;/a&gt;. The &lt;a href="https://groktexttospeech.com/grok-tts-speech-tags/" rel="noopener noreferrer"&gt;speech tag reference&lt;/a&gt; has a before/after sample for each group, and there are preset pages for &lt;a href="https://groktexttospeech.com/whisper-text-to-speech/" rel="noopener noreferrer"&gt;whisper&lt;/a&gt;, &lt;a href="https://groktexttospeech.com/shouting-text-to-speech/" rel="noopener noreferrer"&gt;shouting&lt;/a&gt; and &lt;a href="https://groktexttospeech.com/singing-text-to-speech/" rel="noopener noreferrer"&gt;singing&lt;/a&gt;. It's an independent project, not affiliated with xAI.&lt;/p&gt;

</description>
      <category>tts</category>
      <category>javascript</category>
      <category>ai</category>
      <category>api</category>
    </item>
    <item>
      <title>Restoring Japanese mojibake: brute-forcing encoding pairs with TextDecoder</title>
      <dc:creator>ZHIWEI</dc:creator>
      <pubDate>Tue, 20 Oct 2026 05:48:57 +0000</pubDate>
      <link>https://dev.to/zhiwei_224e5e15600342dc0b/restoring-japanese-mojibake-brute-forcing-encoding-pairs-with-textdecoder-5hih</link>
      <guid>https://dev.to/zhiwei_224e5e15600342dc0b/restoring-japanese-mojibake-brute-forcing-encoding-pairs-with-textdecoder-5hih</guid>
      <description>&lt;p&gt;Paste a Japanese email into the wrong decoder and &lt;code&gt;こんにちは&lt;/code&gt; comes back as&lt;br&gt;
&lt;code&gt;縺薙ｓ縺ｫ縺｡縺ｯ&lt;/code&gt;. Save the same word to a file and open it in a Western&lt;br&gt;
editor and you get &lt;code&gt;æ—¥æœ¬èªž&lt;/code&gt;. Annoying? Yes. Hopeless? No — in the&lt;br&gt;
overwhelming majority of cases &lt;strong&gt;the bytes are still there, untouched.&lt;/strong&gt;&lt;br&gt;
Mojibake is a decoding mistake, not corruption, and you can undo it with&lt;br&gt;
nothing but the &lt;code&gt;TextDecoder&lt;/code&gt; that is already built into your browser.&lt;/p&gt;

&lt;p&gt;This post walks through the whole restoration pipeline: the encoding pairs&lt;br&gt;
that actually matter, how to get the bytes back, what to do when&lt;br&gt;
&lt;code&gt;TextEncoder&lt;/code&gt; can't help you, how to rank the candidate readings, and the&lt;br&gt;
two nasty edge cases — the lone &lt;code&gt;縺・&lt;/code&gt; at the end of old emails, and text&lt;br&gt;
that was garbled twice.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bytes in, wrong interpretation out
&lt;/h2&gt;

&lt;p&gt;Every case of classic mojibake follows the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Text is &lt;strong&gt;saved&lt;/strong&gt; as encoding A (bytes on disk / bytes in the email).&lt;/li&gt;
&lt;li&gt;Something &lt;strong&gt;reads&lt;/strong&gt; those bytes as encoding B.&lt;/li&gt;
&lt;li&gt;The reader prints whatever characters those bytes mean &lt;em&gt;in B&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing destroyed the bytes. The UTF-8 bytes of &lt;code&gt;こんにちは&lt;/code&gt; are&lt;br&gt;
&lt;code&gt;E3 81 93 E3 82 93 E3 81 AB E3 81 A1 E3 81 AF&lt;/code&gt; — fifteen bytes. Read as&lt;br&gt;
Shift_JIS (1.5 bytes per kana character on average), they turn into eight&lt;br&gt;
different, perfectly valid characters: &lt;code&gt;縺薙ｓ縺ｫ縺｡縺ｯ&lt;/code&gt;. The information&lt;br&gt;
survived; only the interpretation is wrong.&lt;/p&gt;

&lt;p&gt;So restoration is a search problem: &lt;strong&gt;recover the bytes, re-read them with&lt;br&gt;
every plausible charset, and rank the readings by how much they look like&lt;br&gt;
real Japanese.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The nine pairs that matter
&lt;/h2&gt;

&lt;p&gt;Japan has more legacy encodings than anyone would like: UTF-8, Shift_JIS,&lt;br&gt;
EUC-JP, UTF-16 — plus Windows-1252/Latin-1, the fallback every Western&lt;br&gt;
tool reaches for. Going through who-saved-what and who-misread-it, nine&lt;br&gt;
pairings cover practically everything you will meet in the wild:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;saved as&lt;/th&gt;
&lt;th&gt;misread as&lt;/th&gt;
&lt;th&gt;typical scene&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8&lt;/td&gt;
&lt;td&gt;Shift_JIS&lt;/td&gt;
&lt;td&gt;Excel or other Windows software opening a UTF-8 CSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8&lt;/td&gt;
&lt;td&gt;Windows-1252&lt;/td&gt;
&lt;td&gt;non-Japanese-aware services and APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8&lt;/td&gt;
&lt;td&gt;EUC-JP&lt;/td&gt;
&lt;td&gt;old Unix environments, EUC-JP-era web pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shift_JIS&lt;/td&gt;
&lt;td&gt;EUC-JP&lt;/td&gt;
&lt;td&gt;Shift_JIS files rendered on EUC-JP systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EUC-JP&lt;/td&gt;
&lt;td&gt;Shift_JIS&lt;/td&gt;
&lt;td&gt;old EUC-JP mail/pages viewed on Windows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shift_JIS&lt;/td&gt;
&lt;td&gt;Windows-1252&lt;/td&gt;
&lt;td&gt;Western tools opening Shift_JIS files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EUC-JP&lt;/td&gt;
&lt;td&gt;Windows-1252&lt;/td&gt;
&lt;td&gt;same, for EUC-JP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-16LE&lt;/td&gt;
&lt;td&gt;Windows-1252&lt;/td&gt;
&lt;td&gt;UTF-16 files read as 1-byte text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-16BE&lt;/td&gt;
&lt;td&gt;Windows-1252&lt;/td&gt;
&lt;td&gt;same, byte-swapped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only ever memorize one row, make it the first one — a UTF-8 CSV&lt;br&gt;
double-clicked open in Excel is probably the single most common way&lt;br&gt;
Japanese text gets mangled on earth.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: getting the bytes back
&lt;/h2&gt;

&lt;p&gt;When the text was misread through a &lt;strong&gt;single-byte&lt;/strong&gt; charset such as&lt;br&gt;
Windows-1252, the original bytes are trivially recoverable — every&lt;br&gt;
character you see &lt;em&gt;is&lt;/em&gt; one original byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;garbled&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now throw every decoder at it. &lt;code&gt;TextDecoder&lt;/code&gt; accepts all the labels we&lt;br&gt;
need: &lt;code&gt;utf-8&lt;/code&gt;, &lt;code&gt;shift_jis&lt;/code&gt;, &lt;code&gt;euc-jp&lt;/code&gt;, &lt;code&gt;utf-16le&lt;/code&gt;, &lt;code&gt;utf-16be&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;windows-1252&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shift_jis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;euc-jp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-16le&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-16be&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;windows-1252&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// invalid sequences for this encoding&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: the TextEncoder problem
&lt;/h2&gt;

&lt;p&gt;The recipe above works when the &lt;em&gt;reading&lt;/em&gt; was wrong. But in the Japanese&lt;br&gt;
→ Japanese cases (saved as Shift_JIS, misread as EUC-JP — or the&lt;br&gt;
reverse), restoration needs the opposite move: take the characters you&lt;br&gt;
see and &lt;strong&gt;re-encode&lt;/strong&gt; them back through the wrong charset to recover the&lt;br&gt;
bytes.&lt;/p&gt;

&lt;p&gt;And here the platform says no: &lt;code&gt;TextEncoder&lt;/code&gt; only ever produces UTF-8.&lt;br&gt;
There is no &lt;code&gt;new TextEncoder("shift_jis")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The workaround is to build the reverse mapping yourself. Decode &lt;strong&gt;every&lt;br&gt;
possible byte sequence&lt;/strong&gt; with &lt;code&gt;TextDecoder("shift_jis")&lt;/code&gt; once, and you&lt;br&gt;
have a complete byte → character table for Shift_JIS; invert it and you&lt;br&gt;
can map characters back to bytes. It sounds heavy — a 2-byte code point&lt;br&gt;
has 65,536 combinations — but the table is built lazily and the whole&lt;br&gt;
thing runs in milliseconds. This is also how you recover bytes from&lt;br&gt;
Shift_JIS-style mojibake where several characters were merged, not just&lt;br&gt;
re-mapped one-to-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: which candidate is right?
&lt;/h2&gt;

&lt;p&gt;Brute force returns many "readable" results. The wrong ones tend to be&lt;br&gt;
assembled from rare characters. &lt;code&gt;縺&lt;/code&gt;, &lt;code&gt;繧&lt;/code&gt;, &lt;code&gt;繝&lt;/code&gt; — the first characters of&lt;br&gt;
classic UTF-8-as-Shift_JIS garbage — are all &lt;strong&gt;JIS level-2 kanji&lt;/strong&gt;,&lt;br&gt;
characters that appear almost never in daily Japanese. The correct&lt;br&gt;
reading, meanwhile, is made of level-1 characters and everyday kana.&lt;/p&gt;

&lt;p&gt;That gives you a cheap, dependency-free ranking function: score each&lt;br&gt;
candidate by how frequently its characters occur in normal Japanese&lt;br&gt;
text, adding weight for common kanji and kana, subtracting for JIS&lt;br&gt;
level-2 rarities. A frequency table built from a modest corpus is&lt;br&gt;
enough; the right reading almost always floats to the top with a visible&lt;br&gt;
margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lone 縺・ at the end
&lt;/h2&gt;

&lt;p&gt;Old Japanese mail software truncated messages at fixed &lt;strong&gt;byte&lt;/strong&gt; lengths —&lt;br&gt;
sometimes right in the middle of a multi-byte character. The victim is&lt;br&gt;
always the last character: its leading byte survived, the continuation&lt;br&gt;
bytes were cut. The result ends with something like &lt;code&gt;縺・&lt;/code&gt; and every&lt;br&gt;
decoder refuses it or prints a replacement mark.&lt;/p&gt;

&lt;p&gt;These bytes can be resurrected. A cut multi-byte character leaves a&lt;br&gt;
known fragment; enumerate the byte sequences that would continue it&lt;br&gt;
(there are only a handful of legal continuations), re-decode, and pick&lt;br&gt;
the completion that best fits the surrounding text. It is a guess in&lt;br&gt;
the strict sense — but an extremely well-educated one, and in practice&lt;br&gt;
it reads naturally in the overwhelming number of cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  When text was garbled twice
&lt;/h2&gt;

&lt;p&gt;Copy a mojibake sentence, save it in another tool, misread it again —&lt;br&gt;
now you have &lt;em&gt;double&lt;/em&gt; garbling, where the second wrong decoding was&lt;br&gt;
applied to the output of the first. The fix is what you would expect:&lt;br&gt;
run the whole pair search once, then run it &lt;strong&gt;again on the first&lt;br&gt;
round's output&lt;/strong&gt;. What you would not expect is the cost: the second&lt;br&gt;
pass multiplies the work ninefold, so a sane implementation runs it&lt;br&gt;
only on short texts — pasted sentences, never whole files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The nine pairs, the byte-recovery tricks, naturalness ranking, the&lt;br&gt;
trailing-character inference and the second pass all run in one page at&lt;br&gt;
&lt;a href="https://henkanya.com/" rel="noopener noreferrer"&gt;henkanya.com&lt;/a&gt; — free, no sign-up, and entirely&lt;br&gt;
in your browser: the text you paste never leaves your machine. It also&lt;br&gt;
handles the encodings that don't fit the brute-force model&lt;br&gt;
(&lt;code&gt;=?ISO-2022-JP?B?…?=&lt;/code&gt; mail headers, old JIS mail with lost ESC&lt;br&gt;
sequences, Unicode escapes, HTML numeric references) and converts CSV&lt;br&gt;
or text files to UTF-8 with BOM so that Excel opens them without&lt;br&gt;
decorating your data with mojibake of its own.&lt;/p&gt;

&lt;p&gt;If you want to see the pipeline fail — or better, save your afternoon —&lt;br&gt;
paste your favorite garbage string and check whether the top candidate&lt;br&gt;
is the sentence you remember.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>unicode</category>
      <category>i18n</category>
    </item>
    <item>
      <title>How I Keep a Fan Guide in Sync With a Viral Browser Game Using a GitHub Actions Cron</title>
      <dc:creator>ZHIWEI</dc:creator>
      <pubDate>Tue, 13 Oct 2026 05:54:04 +0000</pubDate>
      <link>https://dev.to/zhiwei_224e5e15600342dc0b/how-i-keep-a-fan-guide-in-sync-with-a-viral-browser-game-using-a-github-actions-cron-4ggd</link>
      <guid>https://dev.to/zhiwei_224e5e15600342dc0b/how-i-keep-a-fan-guide-in-sync-with-a-viral-browser-game-using-a-github-actions-cron-4ggd</guid>
      <description>&lt;p&gt;A Nigerian browser game called &lt;strong&gt;Lagos Life&lt;/strong&gt; launched at the start of October 2026 and went viral fast. I run an independent fan guide for it, and within a few days I hit the problem every guide writer for a live game hits: &lt;strong&gt;the game changes faster than I can edit pages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Players elect a governor who sets a law that changes the economy. There's a new in-game headline every day, a daily gem hunt with a fresh clue, and the developers tweak job and business tables without notice. A hand-maintained page is stale within days.&lt;/p&gt;

&lt;p&gt;So I stopped editing by hand. This post walks through the small pipeline that now does it for me: a GitHub Actions cron that reads what the official site already serves to every visitor, writes a few JSON files, and rebuilds a static site only when something actually changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The guide is a &lt;strong&gt;prerendered static site&lt;/strong&gt; (TanStack Start) on &lt;strong&gt;Cloudflare Pages&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Pages read their data from JSON files committed in the repo (&lt;code&gt;live-log.json&lt;/code&gt;, &lt;code&gt;jobs.json&lt;/code&gt;, …) at build time.&lt;/li&gt;
&lt;li&gt;One workflow runs &lt;strong&gt;every 3 hours&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fetch scripts ──▶ anything changed? ──no──▶ stop
                        │ yes
                        ▼
                 build ▶ deploy ▶ ping IndexNow ▶ commit the JSON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;   &lt;span class="c1"&gt;# minute 17, not 0: avoid the top-of-hour rush&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;live-log&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# never kill a run halfway through a deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Source 1: the public JSON the site already serves
&lt;/h2&gt;

&lt;p&gt;The official site calls a few JSON endpoints for logged-out visitors: the current governor and law, the daily headline, and the gem hunt. Reading those is the easy part — a plain &lt;code&gt;fetch&lt;/code&gt; with a timeout, a couple of retries, and a User-Agent that says who I am and where the data ends up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-guide live log (+https://example.com/updates/)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions mattered more than the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Store only what the page shows.&lt;/strong&gt; The governor endpoint also returns election candidates and player-written announcements. I never write those to disk. The log keeps the law id, when it was set, the daily date/weather/headline/tip, and the hunt's clue — nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append, don't overwrite.&lt;/strong&gt; A new law creates a new entry with a &lt;code&gt;firstSeen&lt;/code&gt; timestamp, so the page becomes a dated history instead of a snapshot. If only the term-end time moves, the last entry is updated in place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The optional endpoint (the gem hunt) is wrapped in its own &lt;code&gt;catch&lt;/code&gt;, so if it breaks, the law and headline log still runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source 2: when there is no API — read the bundle
&lt;/h2&gt;

&lt;p&gt;Job tables and pay rules aren't exposed as JSON; they live in the game's client JavaScript. The game is a Next.js app, so the homepage HTML references chunks under &lt;code&gt;/_next/static/chunks/&lt;/code&gt;, and chunks reference more chunks.&lt;/p&gt;

&lt;p&gt;The script does a small breadth-first crawl: start from the chunk names in the homepage, fetch six at a time, queue any new chunk names found inside, and stop as soon as every value it needs has been found (hard cap: 400 chunks).&lt;/p&gt;

&lt;p&gt;Each value has its own &lt;strong&gt;finder&lt;/strong&gt;, a function that returns &lt;code&gt;null&lt;/code&gt; until it sees its pattern in some chunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FINDERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;careers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"CAREERS",0,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;starBonus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"STAR_BONUS",0,&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...one finder per rule constant&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minified variable names change on every build, so the patterns anchor on things that survive minification — &lt;strong&gt;string literals and object shapes&lt;/strong&gt; like &lt;code&gt;{id:"…",name:"…",levels:[{title:"…",pay:…}]}&lt;/code&gt; — never on short identifiers like &lt;code&gt;e&lt;/code&gt; or &lt;code&gt;a&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard rails: fail loudly, keep the last good data
&lt;/h2&gt;

&lt;p&gt;A regex scraper against minified code &lt;em&gt;will&lt;/em&gt; break one day. The goal is that when it breaks, the site stays correct and I get told.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validate before writing.&lt;/strong&gt; If fewer than 30 careers parse, or any career doesn't have five paid levels, or a workplace has no name, the script writes nothing and exits 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate failures.&lt;/strong&gt; Each fetch script is its own workflow step with &lt;code&gt;continue-on-error: true&lt;/code&gt;, so a broken job-table parser never blocks the law log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two dates, not one.&lt;/strong&gt; &lt;code&gt;checkedAt&lt;/code&gt; moves on every good read; &lt;code&gt;changedAt&lt;/code&gt; moves only when the table actually differs. The page shows both, so readers can tell "verified today" from "changed today".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be polite.&lt;/strong&gt; The bundle crawl runs at most once per Lagos day (WAT, UTC+1). If today's check already happened, it makes zero requests.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WAT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;WAT_MS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;checkedAt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already checked today&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Commit only after a successful deploy
&lt;/h2&gt;

&lt;p&gt;This one took me a while to get right. If the workflow committed the new JSON &lt;em&gt;before&lt;/em&gt; deploying and the deploy then failed, the next run would see "no change" and the live site would stay stale forever.&lt;/p&gt;

&lt;p&gt;So the order is: &lt;strong&gt;fetch → build → deploy → commit&lt;/strong&gt;. The data file only lands in git once the site that shows it is live. A failed run leaves git untouched, and the next run three hours later simply tries again.&lt;/p&gt;

&lt;p&gt;The build step also greps the generated &lt;code&gt;sitemap.xml&lt;/code&gt; for the expected URLs before deploying — a cheap check that catches a build that "succeeded" but produced the wrong site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell search engines only about pages that changed
&lt;/h2&gt;

&lt;p&gt;After a deploy, the workflow pings &lt;strong&gt;IndexNow&lt;/strong&gt; — but only for the pages whose content changed. A new check date alone rebuilds the jobs page without a ping; a new law pings only the updates page. Pinging unchanged URLs every three hours would just be noise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LIVE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/updates/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOBS_TABLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; changed &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;paths&lt;/span&gt;:+&lt;span class="nv"&gt;$paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it produces
&lt;/h2&gt;

&lt;p&gt;Since 8 October the log has been filling itself: each law with the date it was first seen, every daily headline, every gem-hunt clue (clues only — no answers). That's the updates page of the guide:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://lagoslifegames.com/updates/" rel="noopener noreferrer"&gt;Lagos Life Updates: This Week's Law, Governor &amp;amp; Gem Hunt&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The game itself is at &lt;a href="https://lagoslife.app/" rel="noopener noreferrer"&gt;lagoslife.app&lt;/a&gt;; my site is an independent fan guide, not affiliated with the developers, and everything it reads is what the official site already serves to any visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For a fast-changing live game, a &lt;strong&gt;3-hourly cron + static rebuild&lt;/strong&gt; is plenty — no server, no database.&lt;/li&gt;
&lt;li&gt;Anchor bundle-scraping patterns on &lt;strong&gt;string literals and shapes&lt;/strong&gt;, and validate hard before writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit after deploy&lt;/strong&gt;, so a failed run is automatically retried.&lt;/li&gt;
&lt;li&gt;Store the &lt;strong&gt;minimum&lt;/strong&gt; — especially anything player-written or personal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built something similar for another live game, I'd love to hear how you handle the parser breaking.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
