Daniel Junghyun Son

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.

Event list with nearest-station chips and filters

How it's built

Architecture: the KTO TourAPI and Seoul Open Data feed koevents ingest, which writes data/events.json. koevents describe fetches official pages for listings with no description and sends them to a translator, either Claude Haiku 4.5 or an OpenAI-compatible model, cached by content hash. koevents build renders pages in English, Japanese and Chinese into public/, including the sitemap, feeds, events.json and the offline files. refresh.sh with DEPLOY=1 deploys public/ to Cloudflare Pages at koreaeventsnow.com, where the browser filters events, sorts them by distance without sending the location, and keeps an offline page.

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.

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.


  1. Supreme Court of Korea, 2021도1533, decided 12 May 2022. The prosecutor's appeal was dismissed ("상고를 모두 기각한다"). Full text on casenote.kr

  2. Shin & Kim, 크롤링 관련 최근 대법원 판결과 그 시사점

  3. Google Search Central, Spam policies for Google web search

All work