DEV Community

Seok June Park
Seok June Park

Posted on

Scraping Korea's biggest stores: the hidden JSON APIs behind Olive Young, Musinsa & Naver

Korean e-commerce is one of the richest, least-scraped data sources on the web. K-beauty and K-fashion drive billions in global demand, but almost every tutorial you find scrapes Amazon or Shopify — because the Korean platforms sit behind a double wall: the language, and some genuinely annoying anti-bot setups.

I spent a while getting reliable, structured data out of the three that matter most — Olive Young (K-beauty), Musinsa (K-fashion), and Naver Place (local businesses + reviews). Here's what actually worked, the parts that fought back, and the rules I set for myself.

Rule #1: never parse the HTML if the site talks JSON to itself

Every one of these is a JavaScript SPA. If you GET the page and run Cheerio over it, you get a shell. The trick is the same everywhere: open the network tab, reload, and watch what the frontend calls to fill itself in. That call is almost always a clean JSON endpoint — the same data the page renders, minus the parsing pain and minus 90% of the breakage when they reskin the site.

Musinsa was the friendliest. One endpoint powers both category rankings and keyword search:

GET https://api.musinsa.com/api2/dp/v1/plp/goods
    ?gf=A&sortCode=POPULAR&caller=CATEGORY&category=001&page=1&size=60
Enter fullscreen mode Exit fullscreen mode

Swap caller=CATEGORY&category=001 for caller=SEARCH&keyword=<term> and you've got search. The response hands you goodsNo, brandName, normalPrice, finalPrice, saleRate, reviewCount, reviewScore — everything, already structured. No auth. (Their reviewScore is 0–100, so divide by 20 if you want a 0–5 rating.)

Olive Young was two services once I switched to their global storefront (global.oliveyoung.com) — which is actually better for most people, because it returns English product names and USD prices instead of Korean + KRW:

GET  product-ranking-service.oliveyoung.com/v1/pages/ranking/sales/products   # best sellers
POST cbe-external-api.oliveyoung.com/display/v1/search/products/unified-search # search
Enter fullscreen mode Exit fullscreen mode

The domestic site (www.oliveyoung.co.kr) 403'd my datacenter IP immediately; the global one didn't. Worth checking both when a Korean site blocks you — the international storefront is often more permissive.

Naver Place was the stubborn one. The clean data lives in the Apollo cache embedded in the search results HTML:

GET https://search.naver.com/search.naver?where=nexearch&query=강남역 카페
Enter fullscreen mode Exit fullscreen mode

Pull the __APOLLO_STATE__ blob out of the page, and you get PlaceListBusinessesItem nodes with name, category, address, phone, rating, review count — plus a few review snippets linked from each place. The deeper per-place review API (pcmap-api.place.naver.com/graphql) exists, but it CAPTCHA-walls datacenter IPs with an HTTP 405. More on that below.

The parts that fought back

Datacenter IPs get filtered. All three tolerate a light touch from a normal IP but clamp down on cloud ranges fast. The fix is boring: residential proxies (Korea-geo for Naver especially). What matters in code is that a hung proxy tunnel shouldn't take down the whole run — wrap the fetch so a proxy timeout falls back to a direct connection instead of throwing. That one change turned a lot of silent zero-result runs into successful ones.

"Succeeded with 0 results" is a lie you tell yourself. My first Naver cloud run reported success and returned nothing, with no error in the logs — because the code swallowed a proxy timeout and exited cleanly. If a run collects nothing, throw. A paid data tool that silently returns empty is worse than one that fails loudly.

Pagination isn't always real pagination. Naver's search page repeats results after ~15 businesses; true depth requires the GraphQL route (and a residential IP). Know where your easy path ends so you don't ship something that looks paginated but isn't.

Rule #2: public, non-personal data only

This is the line I don't cross, and I'd encourage anyone scraping reviews to hold it too: I never collect reviewer identity. Review objects carry text, rating, date, and keywords — never nicknames, profile URLs, or user IDs. Business phone numbers and addresses are fine (they're public business info); a person's name attached to a review is not. It keeps the data useful for sentiment/market analysis without turning into a privacy problem.

The output that makes it usable

Whatever the source shape, everything normalizes to one flat, snake_case contract:

{
  "source": "oliveyoung",
  "product_id": "GA230518746",
  "name": "SKIN1004 Madagascar Centella Hyalu-Cica Water-Fit Sun Serum",
  "brand": "SKIN1004",
  "price_usd": 28.0,
  "sale_price_usd": 22.4,
  "rating": 4.9,
  "review_count": 9958,
  "rank": 1,
  "url": "https://global.oliveyoung.com/product/detail?prdtNo=GA230518746",
  "scraped_at": "2026-07-07T09:35:33+09:00"
}
Enter fullscreen mode Exit fullscreen mode

Consistent field names and KST timestamps across all three sources mean you can dump them into the same table and diff prices over time without writing per-site glue.

If you'd rather not maintain any of this

I packaged all three as ready-to-run scrapers on the Apify Store — they handle the proxying, the fallbacks, and the JSON normalization, and they're priced per result so you only pay for what you pull:

But honestly, the endpoints above are the whole trick — if you just need a one-off pull, the network tab will get you most of the way. Happy to answer questions on any of the three in the comments.

Top comments (0)