koreaeventsnow.com
What's on in Korea, in English, Japanese and Chinese, built from official data.
What it is
A guide for visitors to Korea who don't read Korean. The site currently lists 157 events, and carries 57 practical guides in each of English, Japanese and Chinese: getting from the airport into Seoul, buying KTX tickets, claiming a tax refund, reading subway exit numbers, the K-ETA entry requirement, emergency numbers, vegetarian and halal dining.
Live at koreaeventsnow.com.

How it's built
From official data to a visitor's phone.
A single Go command-line tool runs the whole pipeline. Its only libraries are goldmark, which renders the guides from Markdown, and goldmark's front-matter extension.
ingestpulls events from two official public APIs, the Korea Tourism Organization's TourAPI and Seoul Open Data. It removes Seoul listings that fail a visitor-facing rule, and keeps ended events for three years so their pages survive; lists tag them as ended.describefinds listings that arrived without a description, fetches the official event page, and translates the text.buildrenders a static site: event pages, the guides, a sitemap and a JSON feed.
Translation runs through a pluggable backend: Anthropic's Claude Haiku by default, or any OpenAI-compatible local model. Results are cached per listing, so a listing isn't sent to a model again unless its source text changes. The refresh script refetches events nightly.
The site is served from Cloudflare Pages as an installable web app with an offline fallback. Pages carry structured data for search engines. "Events near me" sorts the list by distance using the browser's location, and the code that reads the location makes no network request.
About 12,200 lines of Go across 59 files, 28 of them tests, in 59 commits since the repository's first commit on 31 August 2026.
Two decisions behind it
Official data only
The listings come from official public APIs rather than from scraping other event sites. In Korea the legal line on scraping is easy to misread. In the 야놀자 v. 여기어때 criminal case, the Supreme Court (2021도1533, 12 May 2022) upheld acquittals on every charge. On database rights, the court held that what had been copied did not amount to a substantial part of the database.12 A separate civil case over the same scraping still found unfair competition (Seoul Central District Court, 2018가합508729, 19 August 2021).2 Winning the criminal case did not make scraping a competitor safe.
Structured data over generated pages
Google defines scaled content abuse as "when many pages are generated for the primary purpose of manipulating search rankings and not helping users," and lists automated translation of scraped content among its examples.3 That rules out generating thousands of thin pages from someone else's listings. The site publishes structured facts a visitor needs (what, where, when) from official sources, and spends translation on making those facts readable to people who otherwise couldn't use them.
The hard problem: Korean left inside English sentences
Every translated description has to pass a language check before it is accepted. The original check counted letters: the output counted as English if at least 35% of its Hangul, kana, Chinese and Latin letters were Latin, and anything under 20 letters passed automatically.
With Gemma running as the local translation model, 3 of 122 English descriptions came back with fragments like "Buk서울 Museum of Art". The test for the fix reproduces the failure with this sentence:
Since 2017, the Buk서울 Museum of Art has experimented with idle spaces around the museum.
A sentence like that is almost entirely Latin letters, so it sails past a 35% threshold. The ratio check could tell that a text was mostly English. It couldn't tell that it was entirely English, and in place names a reader notices the difference at once.
The fix adds a second rule for English output: any Hangul syllable is a rejection.
var hangulLeak = regexp.MustCompile(`[가-힣]`)
if t.Target == "en" && hangulLeak.MatchString(desc) {
return "", fmt.Errorf("hangul left in english output: %q", snippet(desc))
}
A failed attempt gets exactly one retry, with a stricter instruction appended to the prompt:
desc, err := t.textAttempt(prompt, korean)
if err != nil {
desc, err = t.textAttempt(prompt+" Never leave Korean (Hangul) characters in the output; romanize or translate every name.", korean)
}
The retry covers every kind of failure: a failed request, an empty answer, output in the wrong language, or leaked Hangul. The cache is written only after an attempt succeeds, so a bad translation is never stored. If the second attempt also fails, that listing reports an error.
A test pins the behaviour down. A fake model server returns the leaky sentence on the first call and a clean one on the second, and the test requires exactly two calls and no Hangul in the result.
What it still misses. The regex covers composed syllables (가 to 힣). A stray standalone letter such as ㄱ or ㅏ would pass. And a model that leaks twice in a row fails that listing outright instead of falling back to a partial result.
-
Supreme Court of Korea, 2021도1533, decided 12 May 2022. The prosecutor's appeal was dismissed ("상고를 모두 기각한다"). Full text on casenote.kr. ↩
-
Shin & Kim, 크롤링 관련 최근 대법원 판결과 그 시사점. ↩↩
-
Google Search Central, Spam policies for Google web search. ↩