# Yanolja Scraper: Stays, Rates & Reviews (`abotapi/yanolja-com-scraper`) Actor

Scrape Yanolja (NOL), Korea's largest accommodations marketplace, by region or URL. Every row carries name, address, coordinates, phone, star class, room types with per-night rates, review score and review text, amenities, policies and photos from one stay page read.

- **URL**: https://apify.com/abotapi/yanolja-com-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 listing records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Yanolja (NOL) Stay Scraper

Extract accommodations from Yanolja (nol.yanolja.com), Korea's largest
lodging marketplace: motels, hotels, resorts, pensions and poolvillas. Every
record comes from ONE stay page read and carries:

- identity: name, stay group (hotel / motel / pension), star class, badges
- location: full address, city detail, directions text, latitude, longitude
- contact: the stay's own publicly displayed booking phone
- rates: room types with per-night availability rates for the site's default
  next-night window, overnight and day-use sold-out flags, the site's own
  price-range text
- reviews: rating value, review count, reply count, the service's short
  review summary, and the page's own review highlights with author initials,
  score, date, and review text
- policies: check-in / check-out times, cancellation text, policy notices
- media: the main photo, all property photos and room photos
- rooms: one nested entry per room type with id, name, subtitle, rate pair,
  benefits and up to three photos

### Modes

- **search** (default): give one or more Korean region or place names, for
  example `서울`, `제주`, `부산`, `가평`. The actor matches each word against
  the region names of the site's own ranked collection pages (it publishes
  about three thousand of them per stay group), then reads every matching
  collection for its ranked stays, then reads each stay for the full record.
  A word that matches no published collection is skipped with a warning, and
  no slug is ever invented: an unknown slug is refused by the site's edge.
- **url**: paste stay URLs (`https://nol.yanolja.com/stay/domestic/123456`)
  and/or collection URLs
  (`https://nol.yanolja.com/programmatic/domestic-accommodation/<name>`).
  A stay URL is a single full record; a collection URL is browsed like a
  search for its ranked stays.

Filters apply in both modes: lowest-rate price bounds (KRW) and the returned
row ordering (site order, price, rating).

### Notes and limits, measured live

- The default next-night window is what the site serves in one read. Date
  specific rate picking for arbitrary future check-in dates is an app surface
  and is not offered on the web read lane, so the actor does not fake one:
  check-in/check-out dates on each row are the window the site itself served.
- Reviews per stay are the page's own ranked set (a handful of best and
  recent rows plus the summary's two-sided short text), not the full review
  history: the web lanes do not carry a per-stay review-index page (a probe
  of the reviews path returned a plain 404).
- Web keyword search does not exist on this storefront (search lives in the
  mobile app). The search mode is the region and collection browse, which is
  the site's own public read surface for discovering stays.
- Collection pages are single ranked top lists, so a collection contributes
  up to about ten stays per run; the full catalogue universe (hundreds of
  thousands of stay ids) is reachable because every collection gives its own
  ten, and URL mode takes stay ids directly.
- The ranked feed inside a collection ROTATES between page loads (the top
  list disagrees on which stays it names between two reads), so incremental
  legs on search mode legitimately classify mostly NEW; the stable shape for
  incremental monitoring is URL mode with fixed pasted stay URLs, where
  suppression is proven live.
- Prices are Korean won as the site displays them, with comma separators
  stripped into the numeric fields.

### Connection

The default pins a Korean resident-type Exit connection. Stay pages answered
every share of the cheaper pools with an edge refusal page during live
probing, so that is not an optional upgrade, it is the only lane this
storefront serves. Any proxy configuration you paste wins verbatim.

### Output

One row per stay. See the dataset view named `overview` (the default tab in
the Console) for the full field set, and `scrapedAt` / `changeType` /
`changedFields` / `firstSeenAt` / `lastSeenAt` when incremental mode is on.

### Incremental mode and resume

Two separate things, in one section of the input:

- **Resume from a previous run**: continue ONE interrupted crawl by pasting
  its run or dataset id; collected record ids are skipped.
- **Incremental mode**: schedule the same scope and get only what changed
  (NEW / UPDATED / REAPPEARED / EXPIRED). Rate moves are reported as
  UPDATED: on an accommodation marketplace the rate is the signal.

### Sample record (fictional placeholder values, never a real stay)

```
{
  "recordId": "100000001",
  "stayId": "100000001",
  "rowType": "stay",
  "url": "https://nol.yanolja.com/stay/domestic/100000001",
  "name": "Sample example stay at Sample Area (example campaign name)",
  "stayGroup": "Motels",
  "address": "Sample-gu, Sample-si, Sample Road 12 (example-dong)",
  "cityName": null,
  "sellerPhone": "05000000000",
  "ratingValue": 4.2,
  "reviewCount": 123,
  "lowestPriceKrw": 36000,
  "overnightPriceKrw": 36000,
  "dayUsePriceKrw": null,
  "overnightSoldOut": false,
  "dayUseSoldOut": true,
  "priceRange": "Room 20,000 KRW and up, Stay 30,000 KRW and up",
  "facilities": ["Sample amenity one", "Sample amenity two"],
  "rooms": [
    {
      "roomTypeId": 250000,
      "roomTypeName": "Sample room (sample walk-in type)",
      "subTitle": null,
      "overnightPriceKrw": 36000,
      "dayUsePriceKrw": null,
      "benefits": ["Streaming service included"]
    }
  ]
}
```

### Reporting a blocked run

A refused window fails the run loud ("Could not read any results from the
data source..."), it never leaves a record-shaped sentinel row behind, and a
genuinely empty scope ends quietly with a status message. The alternate
fallback tier engages only after the main connection refused everything.

### Export to your apps (MCP connectors, optional)

Nothing in this section changes the dataset. Authorize an MCP connector under
Apify, Settings, API & Integrations, then select it in the input's Export
panel. Notion gets a page per record; other connectors receive a best-effort
write or digest. The full record always stays in the dataset; the connector
side channel receives a condensed summary per record.

# Actor input Schema

## `mode` (type: `string`):

How to start the scrape. 'search' browses the site's own ranked collection pages for the regions you list. 'url' processes the Yanolja URLs you paste: a stay URL (a page ending in /stay/domestic/<number>) is fetched as a single stay, and a collection URL (a page starting with /programmatic/domestic-accommodation/) is browsed like a search, its own path deciding the scope.

## `locations` (type: `array`):

One or more Korean region or place names, for example '서울', '제주', '부산', '가평'. Each name is matched against the region names of the site's ranked collection pages, and every matching collection is scraped independently. Search mode only: in URL mode the pasted URL decides its own scope. A match with no ranked collection for a typed group is skipped with a warning, never invented.

## `categoryGroup` (type: `string`):

Narrow search mode to one availability group the site itself publishes: hotels and resorts, motels, or pensions and poolvillas. Search mode ONLY: in URL mode the pasted URLs decide their own scope. Leave empty for every group the region publishes.

## `urls` (type: `array`):

Paste stay URLs (https://nol.yanolja.com/stay/domestic/123456) or collection URLs (https://nol.yanolja.com/programmatic/domestic-accommodation/<region-name>). A pasted stay URL returns that single stay in full; a pasted collection URL is browsed for its list. The price and ordering filters below still narrow what the run keeps.

## `minPriceKrw` (type: `integer`):

Only keep rows whose lowest available rate is at least this many Korean won. Applied to the rows this run reads. Applies in BOTH search mode and URL mode.

## `maxPriceKrw` (type: `integer`):

Only keep rows whose lowest available rate is at most this many Korean won. Applied to the rows this run reads. Applies in BOTH search mode and URL mode.

## `sortResultsBy` (type: `string`):

Arranges the rows this run collected, after they are read. This is a local tidy up of one run's output, not a catalogue wide ordering. Applies in BOTH search mode and URL mode.

## `fetchDetails` (type: `boolean`):

Read each listing's own stay page for the complete record: room types with per-night rates, review score and review text, address, coordinates, phone, amenities, policies and photos. This IS the main data read, because a bare listing row carries almost none of it, so leave it on unless you only want the cheap name-and-image listing stubs. Turning it on adds the Stay detail enrichment charge per record actually enriched this run.

## `maxItems` (type: `integer`):

The single cap on this run: stop after collecting this many records across all regions, values and URLs. Set 0 for unlimited.

## `maxPages` (type: `integer`):

Safety bound on how many of the site's ranked collection pages to walk per region name. Leave empty (0) for no page limit: the run then stops at Max items, not here. Collection pages are single ranked lists, so in practice one region rarely needs more than a handful of pages.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue a large crawl without returning or charging for records already collected there. Use this after an interrupted or capped run. For recurring monitoring of the same search, use Incremental mode below instead.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or weekly recurring monitoring of the same scope, for example a region price watch. The first run returns everything matching as NEW. Later runs normally return only NEW, UPDATED and REAPPEARED records. Rate moves ARE reported as UPDATED: on an accommodation marketplace the rate is the signal, not noise. Turn on Emit unchanged or Emit expired only when you also want those rows returned (and billed). State is kept separately per region, URL and filter setup; use State key to name or deliberately share a monitoring campaign. To continue one specific interrupted run instead, use Resume from a previous run above.

## `stateKey` (type: `string`):

Optional. Name this monitoring campaign to keep its state stable, or to deliberately share state across differently configured runs. Leave empty to let the actor derive a key automatically from the regions, URLs and filters, so two different scopes never mix state.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return records that have not changed since the last run, marked UNCHANGED. This returns, and bills, extra rows you already have, so leave it off unless you specifically want a full snapshot every run.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return records that were present in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked scope: not when Max items capped it, not when Resume was used, and not when the run was refused. This returns, and bills, extra synthetic rows, so leave it off unless you need expiry tracking.

## `mcpConnectors` (type: `array`):

Optionally send results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize one under Apify, Settings, API & Integrations, then select it here. Notion gets a page per record; other connectors get a best-effort write or digest. Each connector receives a condensed summary per record, not the full record; the complete record always stays in the dataset. Leave empty to skip; this never changes the dataset output. Supported: Notion (https://mcp.notion.com/mcp), Linear (https://mcp.linear.app/sse), Airtable (https://mcp.airtable.com/mcp), Apify (https://mcp.apify.com).

## `notionParentPageUrl` (type: `string`):

URL or id of the Notion page under which record pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on records written to each connector per run. Does not affect the dataset.

## `proxy` (type: `object`):

Leave the default: every stay page read travels through a Korean resident-type Exit connection, because the shared pools were probed live and refused on the stay routes (three out of three attempts returning refusal pages instead of data). Prices quoted on each row are Korean won.

## Actor input object example

```json
{
  "mode": "search",
  "locations": [
    "서울"
  ],
  "categoryGroup": "all",
  "urls": [
    "https://nol.yanolja.com/stay/domestic/123456"
  ],
  "sortResultsBy": "site_order",
  "fetchDetails": true,
  "maxItems": 20,
  "maxPages": 0,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "maxNotifyListings": 50,
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "KR"
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "search",
    "locations": [
        "서울"
    ],
    "categoryGroup": "all",
    "urls": [
        "https://nol.yanolja.com/stay/domestic/123456"
    ],
    "fetchDetails": true,
    "maxItems": 20,
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "proxy": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "KR"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/yanolja-com-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "search",
    "locations": ["서울"],
    "categoryGroup": "all",
    "urls": ["https://nol.yanolja.com/stay/domestic/123456"],
    "fetchDetails": True,
    "maxItems": 20,
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "proxy": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "KR",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/yanolja-com-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "search",
  "locations": [
    "서울"
  ],
  "categoryGroup": "all",
  "urls": [
    "https://nol.yanolja.com/stay/domestic/123456"
  ],
  "fetchDetails": true,
  "maxItems": 20,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "KR"
  }
}' |
apify call abotapi/yanolja-com-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/yanolja-com-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/3FaVlxItepA2Wsin7/builds/Gtme6bq2Lgw2mm3Qt/openapi.json
