# 요즘인기여행 데이터 아카이브 — 스키마 (v1)

한국인이 지금 실제로 가는 해외 여행지(네이버 패키지 판매 기준)와, 그 여행지의 항공·호텔·투어·eSIM 가격을
**매일 같은 조건으로** 기록한 시계열입니다. 이 파일 하나와 `schema.json`만 읽으면 전부 해석할 수 있게 썼습니다.

- 관측 시점: 매일 약 19:30~20:30 KST (서버 정기 수집 직후). 날짜(`observed_date`)는 모두 **KST 기준**, 시각은 ISO 8601.
- 시작일: 2026-10-14. 그 전 날짜는 `site_*`·`link_clicks`(방문·클릭)만 소급되어 있습니다.
- 데이터는 지우지 않습니다. 정정이 필요하면 같은 날짜 파일을 다시 만들고 아래 "변경 이력"에 적습니다.

## 폴더

| 경로 | 내용 | 형식 |
|---|---|---|
| `facts/YYYY-MM/YYYY-MM-DD.jsonl.gz` | **한 줄 = 한 관측치.** 분석의 기본 단위 | gzip JSON Lines |
| `daily/YYYY-MM/YYYY-MM-DD.json.gz` | 그날 관측치마다 전 관측 대비·30일 중앙값·최저·최고 | gzip JSON |
| `latest.json` | 가장 최근 `daily` (압축 없음). "지금 싼가?"에 바로 답할 때 | JSON |
| `entities.jsonl` | 모든 대상의 ID·이름·도시·나라·출처 ID·처음/마지막 관측일 | JSON Lines |
| `raw/YYYY-MM/YYYY-MM-DD.<이름>.json.gz` | 그날 화면에 나간 원본 그대로 (data, flights, hotels, tours, esim, stats). 정규화를 고치면 여기서 다시 만든다 | gzip JSON |
| `schema.json` | facts·daily·entities 의 JSON Schema | JSON |

## fact (facts/*.jsonl.gz 한 줄)

```json
{"observed_date":"2026-10-14","observed_at":"2026-10-14T19:52:10+09:00","source":"myrealtrip",
 "metric":"hotel_price","entity_type":"hotel","entity_id":"mrt-stay:3578504","entity_name":"APA 호텔 & 리조트 오사카 난바 역앞 타워",
 "city":"오사카","country":"JP","series_id":"hotel_price|mrt-stay:3578504|weekday|1n|2a",
 "condition":{"checkin":"2026-10-21","checkout":"2026-10-22","nights":1,"adults":2,"rooms":1,"day_type":"weekday","price_scope":"room_total"},
 "value":116826,"unit":"KRW","attrs":{"original_price":null,"rank_in_city":1}}
```

| 필드 | 뜻 |
|---|---|
| `observed_date` | 관측한 날(KST). 방문·클릭은 **그 하루의 집계 날짜**(수집일 전날) |
| `observed_at` | 관측 시각(ISO 8601). 하루 집계 값이면 `null` |
| `source` | `naver_package`(네이버 패키지여행 공개 상품 정보), `myrealtrip`(마이리얼트립 공식 API·MCP), `cheapest_esim`(최저가e심 데이터셋, 네이버 스마트스토어 판매 가격), `own_service`(우리 서비스 접속 기록 집계) |
| `metric` | 무엇을 쟀는가 (아래 표) |
| `entity_type` / `entity_id` | 대상. ID 는 `출처접두:원래ID` 로 이름이 바뀌어도 그대로 |
| `entity_name` | 관측 당시 이름 (바뀔 수 있음 — 조인은 ID 로) |
| `city` | 한국어 도시명(오사카·하노이 …). 모르면 `null` |
| `country` | ISO 3166-1 alpha-2 (JP, VN …). 여러 나라 상품이면 `null` 이고 `entities.jsonl` 의 `info.countries` 에 목록 |
| `series_id` | **같은 조건으로 비교 가능한 값의 묶음.** 날짜 간 비교는 반드시 같은 `series_id` 끼리 |
| `condition` | 값의 조건(일정·인원·기준). 비교·해석에 필요 |
| `value` / `unit` | 숫자 값과 단위. `KRW`(원), `count`(개수·사람 수), `rating_5`(5점 만점 평점) |
| `attrs` | 값은 아니지만 해석에 도움이 되는 부가 정보(순위, 항공사, 판매처 등). 없으면 생략 |

없는 값은 줄 자체를 만들지 않습니다(0 으로 채우지 않음).

## metric

| metric | entity_type | unit | 뜻 · 조건 | series_id |
|---|---|---|---|---|
| `package_weekly_buyers` | package | count | 네이버 패키지 상품 페이지에 공개된 "최근 1주간 N명이 구매했어요"의 N | `…\|naver-pkg:ID` |
| `package_price` | package | KRW | 성인 1인, 쿠폰 적용 최저가(네이버 표시가). `attrs.departure_date` 는 그 가격의 출발일(있을 때) | `…\|naver-pkg:ID` |
| `package_trend_price` | package | KRW | 네이버 가격 추이 기준가(쿠폰 전, 그날 최저 출발가). `package_price`와 **기준이 달라 서로 비교하지 말 것** | `…\|naver-pkg:ID` |
| `package_price_20d_high` | package | KRW | 최근 20일 최고가(추이 기준가 기준). `attrs.high_days_ago` | `…\|naver-pkg:ID` |
| `package_rating` / `package_review_count` | package | rating_5 / count | 상품군 평점·리뷰 수 | `…\|naver-pkg:ID` |
| `destination_weekly_buyers` | destination | count | 그 도시가 포함된 인기 패키지들의 1주 구매 수 합. **우리 앱의 여행지 순위 기준**. `attrs.rank`, `attrs.typical_nights`(구매 가중 최빈 박수) | `…\|city:SLUG` |
| `destination_price_drop_count` | destination | count | 그 도시의 "가격 내린 패키지" 수. `attrs.max_drop_pct` | `…\|city:SLUG` |
| `country_weekly_buyers` | country | count | 나라별 "최근 1주간 N명 구매" 합(네이버 nation 그대로, **모수 100% 커버** — 도시가 없는 유럽 다국가 상품도 들어간다). `attrs.rank`·`share_pct`·`products`. 2026-10-20 부터 | `…\|country:<iso>` |
| `population_weekly_buyers` | population | count | 모수 전체 구매 합. `attrs`: 상품 수, 링크 있는 상품 수, 도시 매핑된 상품·구매 수, `city_coverage_pct`(도시 지수의 커버리지 — 이 값이 낮은 날은 도시 순위를 조심해서 읽을 것). 2026-10-20 부터 | `…\|population:naver-package-overseas` |
| `hotel_price` (2026-10-20 부터) | hotel | KRW | 인기 20곳(**아고다 리뷰 수 + 마이리얼트립 리뷰 수 합산** 순, 같은 호텔은 좌표·이름으로 짝) × 1박 객실1 성인2. `source` 별로 시리즈가 따로이고 **`condition.price_scope` 가 다르다**: agoda = `excl_tax_fees`(세금·수수료 제외, Lite API dailyRate), myrealtrip = `room_total`, tripcom = `incl_tax_fees`(목록 화면, `attrs.method: screen`, `attrs.name_match` = 이름 유사도). **scope 가 다른 값을 같은 줄에 놓고 "더 싸다"고 하지 말 것.** entity_id 는 `agoda:<hotelId>`(아고다에 있는 호텔) 또는 `mrt-stay:<id>` | `…\|agoda:<id>\|weekday\|1n\|2a\|src:<source>` |
| `hotel_rating` / `hotel_review_count` (2026-10-20 부터) | hotel | rating_10 · rating_5 · count | 소스별 평점·리뷰 수. **아고다·트립닷컴은 10점, 마이리얼트립은 5점**(unit 으로 구분, 환산하지 않는다). `attrs.rank_score` = 합산 리뷰 수 | `…\|src:<source>` |
| `flight_roundtrip_price` | flight_route | KRW | 인천 왕복, 성인 1, 일반석, **직항만**(2026-10-20 부터) 최저가. 출발은 다가오는 금요일들, 귀국 = 출발 + 그 도시 패키지 박수. `source` 는 myrealtrip(공식 MCP, 모든 날짜) / tripcom·agoda(노선 최저가 날짜 1개만, 검색 화면에서 읽음). `attrs.method` = `official_api` \| `screen`, `condition.airport_scope` = airport \| city(트립닷컴은 도시코드라 도쿄=나리타+하네다). 소스가 다르면 조건이 달라 `series_id` 도 다르다 — 소스 간 비교는 같은 날짜 값끼리 보되 `method` 를 반드시 같이 읽을 것. 2026-10-19 이전 시리즈(`…\|1a`, 직항 우선·경유 fallback)와는 다른 시리즈 | `…\|route:ICN-XXX\|출발일\|N박\|1a\|direct\|src:<source>` |
| `hotel_price` **(2026-10-19 까지의 옛 시리즈)** | hotel | KRW | 마이리얼트립 단일 소스, 객실 1·성인 2·1박 총액(`price_scope: room_total`). `condition.day_type` weekday(다음 수요일 체크인)·weekend(다음 토요일 체크인). 2026-10-20 부터는 위의 `…\|src:<source>` 시리즈로만 쌓이고 이 시리즈는 더 늘지 않는다 | `…\|mrt-stay:ID\|weekday\|1n\|2a` (src 없음) |
| `hotel_rating` / `hotel_review_count` **(2026-10-19 까지의 옛 시리즈)** | hotel | rating_5 / count | 마이리얼트립 평점·리뷰 수. 도시별 리뷰 많은 순 20곳(평점 4.2↑·리뷰 300↑) | `…\|mrt-stay:ID` (src 없음) |
| `tour_price_from` / `tour_rating` / `tour_review_count` | tour | KRW / rating_5 / count | 도시별 판매량순 상위 3 투어·입장권의 시작가·평점·리뷰 수 | `…\|mrt-tna:ID` |
| `esim_min_price` | esim_country | KRW | 그 나라·그 일수 eSIM(묶음 제외) 최저 판매가. `attrs.seller`, `attrs.seller_is_affiliate` | `…\|esim:ISO\|N d` (`…\|esim:JP\|5d`) |
| `esim_min_affiliate_price` | esim_country | KRW | 같은 조건에서 제휴(수수료) 판매처 중 최저가 | 같음 |
| `esim_seller_count` | esim_country | count | 같은 조건의 판매처 수. `attrs.listing_rows` | 같음 |
| `site_visitors` / `site_pageviews` | channel | count | 우리 서비스 하루 방문자(고유 IP+브라우저)·조회 수. `channel:toss`·`web`·`test` | `…\|channel:X` |
| `link_clicks` | click_type / click_merchant | count | 우리 화면에서 외부 링크 클릭 수. `condition.channel`. click_type: pkg·flight·hotel·tripcom·esim·tour·legal | `…\|click-type:X\|채널` |

## daily (daily/*.json.gz, latest.json)

`series` 의 각 행 = 그날 fact 하나 + 같은 `series_id` 의 최근 31일 이력 요약.

| 필드 | 뜻 |
|---|---|
| `value` | 그날 값 |
| `prev_date` / `prev_value` / `change_pct` | 직전 관측과 비교(%) |
| `obs_30d` | 최근 31일(그날 포함) 관측 횟수 |
| `median_30d` / `min_30d` / `max_30d` | 그 기간 중앙값·최저·최고 — **"평소"는 중앙값** |
| `vs_median_30d_pct` | 중앙값 대비 %(음수 = 평소보다 쌈) |
| `is_30d_low` | 30일 최저인가. 관측 7회 미만이면 `null`(판단 보류) |

`package_trend_price` 이력에는 네이버 가격 추이(상품별 최근 90일 일별 가격)를 함께 씁니다 — 같은 상품·같은 기준이라 첫날부터 30일 비교가 됩니다. `package_price`(쿠폰 적용가)는 우리 관측분만 이력으로 씁니다.

시각: `observed_at` 은 ISO 8601 이며 출처가 준 그대로라 `Z`(UTC) 또는 `+09:00` 둘 다 나옵니다. `naver_package` 의 `observed_at` 은 아카이브 시각이고 실제 수집은 같은 날 19:30 무렵입니다.

## entities.jsonl

```json
{"entity_id":"mrt-stay:1606522","entity_type":"hotel","name":"FTE 바딘 호텔","city":"하노이","country":"VN",
 "source_ids":{"myrealtrip":"1606522"},"info":{"star":5},"first_seen":"2026-10-14","last_seen":"2026-10-14"}
```
- `city:SLUG` 는 영문 소문자(osaka, hanoi …), `info.iata` 공항 코드.
- 같은 호텔을 다른 출처 ID 로 묶은 표는 저장소 `data/hotel-xref.json`(좌표 ≤150m + 이름 + 주소로 사람이 확인).

## 해석할 때 주의
- **비교는 같은 `series_id` 끼리만.** 호텔 `weekday` 시리즈는 체크인 날짜가 매일 "다음 수요일"로 바뀌므로 리드타임이 1~7일 사이에서 흔들립니다. 같은 날짜 숙박의 가격 변화가 아니라 "그 호텔 평일 1박 시세"의 추이로 읽으세요. 항공은 출발일이 `series_id` 에 들어 있어 같은 비행 날짜끼리 비교됩니다.
- 가격은 관측 시점 검색가입니다. 실제 결제가는 회원가·쿠폰·환율·세금에 따라 다를 수 있습니다. "최저가 보장"으로 쓰지 마세요.
- 수집 조건(요일·박수·인원·필터·순위 기준)을 바꾸면 시계열이 끊깁니다. 바꾼 날짜는 아래 "변경 이력"에 있습니다.

## 모수 정의 (2026-10-20 사용자 확정 — 이 절이 원문, 코드 `collector/travel-common.mjs` 는 이걸 옮긴 것)

이 아카이브의 "한국인이 좋아하는 여행지"는 **우리가 정하지 않는다. 네이버 패키지여행 구매 신호가 정한다.** 우리는 집계·매핑·기록만 한다.

1. **모수(population)** = 네이버 패키지여행 해외 상품 중, 매일 19:30 KST 에 인기순(`order=pop`) 15페이지 안에 있고 상세에 **"최근 1주간 N명이 구매했어요"**(집계 기간 7일, N>0) 문구가 있는 상품군(productFamilyId 단위 중복 제거). 출발일 D+3~D+90, 성인 1 기준, 티켓·골프 제외. **제휴 링크 유무·판매 가능 여부와 무관하게** 전부 들어간다(`attrs.linked` 로 구분) — 수요는 우리가 팔 수 있느냐와 무관해야 한다. 2026-10-19 이전 날짜는 링크 있는 상품만 있었다(변경 이력 참고).
2. **단위 계층** = 상품군 → 거점 도시 → 나라. 나라는 네이버 nation 그대로(100% 커버). 도시는 제목 키워드 매핑표(`CITIES.kw`)로 붙이며 **제목에 먼저 나온 도시 = 거점(항공 도착지)**. 유럽 다국가 상품은 도시를 붙이지 않고 나라로만 센다. 매핑 안 되는 상품은 버리지 않고 `city: null` 로 나라 지수에만 합산하며, 커버리지(`population_weekly_buyers.attrs.city_coverage_pct`)를 매일 기록한다(2026-10-19: 70%).
3. **지수** = 도시·나라별 `Σ N`. 의미: 네이버가 그날 보여준 "최근 7일 구매 인원"의 합 → **7일 창이 날마다 겹치는 이동 창의 일별 관측**이라 "주간 합의 추이"로 읽는다(일별 신규 구매가 아니다). 박수(`typical_nights`)는 구매 가중 최빈값, 동률이면 짧은 쪽. 공개 범위: **상품별 N까지 전부**(2026-10-20 사용자 결정. 네이버 약관상 재배포 검토가 끝나지 않았음을 안다 — 문제가 되면 상품별 값만 비공개로 돌리고 도시·나라 지수는 유지).
4. **"네이버 한정 인기 여행지"(호텔·항공·투어의 모수)** = 도시 지수 **상위 20** + **최근 14일 안에 한 번이라도 상위 20이었던 도시 유지**(선정은 네이버가, 유지만 우리가 — 경계 도시가 하루 만에 바뀌어 시계열이 끊기는 걸 막는다). `derive:false` 도시(대마도 — 부산 출발 배편, 인천 기준 항공·호텔과 안 맞음)는 지수엔 있되 파생에서 뺀다. 준비물(트립닷컴 링크 등)이 없는 도시는 "없음"으로 남기지 빼지 않는다.
5. **가격·품질 불변 규칙** = 쿠폰 적용가(`package_price`)와 네이버 추이가(`package_trend_price`)는 섞지 않는다 · 핫딜 = 20일 최고 대비 10%↓ · 하루 1회 같은 시각 · 상품 50개 미만이면 그날 데이터를 교체하지 않는다 · 수집 조건을 바꾸면 `series_id` 를 새로 만들고 아래 변경 이력에 적는다.

## 변경 이력
- 2026-10-14 v1 시작. 같은 날 raw 를 처음 보관했고 방문·클릭은 2026-10-03 부터 소급. 호텔 도시당 20곳(리뷰순), 항공 귀국 = 출발 + 패키지 박수, 투어 상위 3 기준.
- 2026-10-20 **모수 정의 확정**(위 절). (a) data.json 에 `demand[]`(링크 유무 무관 전체 상품군, `linked`) 추가 — 그 전 날짜의 `destination_weekly_buyers` 는 링크 있는 상품만 합한 값이라 2026-10-20 부터와 수준이 다를 수 있다(같은 series_id 지만 모수 범위가 넓어짐; 비교 시 `population_weekly_buyers.attrs.linked_products` 참고). (b) 도시 매핑표 확장(규슈·큐슈·북해도·교토 등 변형 표기, 대마도·연태·장가계·베이징·서안·마카오·호치민·푸켓·보라카이·마나도·코타키나발루·울란바토르 추가) → 도시 지수가 그날부터 올라갈 수 있다. (c) `country_weekly_buyers`·`population_weekly_buyers` 신설, entity_type `country`·`population` 추가. (d) `destination_weekly_buyers` 를 상위 10 도시만이 아니라 매핑된 모든 도시에 대해 기록. (e) 호텔·항공·투어 파생 도시 = 상위 20 + 14일 유지(그 전엔 그날 상위 10).
- 2026-10-20 호텔 **아고다 중심 3소스**. 인기 20곳 기준을 "마이리얼트립 리뷰 300↑ 중 리뷰순"에서 "아고다(공식 Lite API, 정렬 7개 합산 풀, 평점 8↑·3성↑, 풀 50 미만이면 7.5↑로 완화) + 마이리얼트립 리뷰 수 **합산** 순"으로 바꿨다 → entity 가 `mrt-stay:` 에서 `agoda:` 로 바뀌어 시리즈가 끊긴다(옛 시리즈는 그대로 남음). 가격은 소스별 시리즈(`|src:`)에 `price_scope` 를 달리해 기록 — 아고다 dailyRate 가 세금·수수료 제외라는 것을 같은 날짜 랜딩 페이지로 확인(322,359 vs 세금 포함 355,234). 트립닷컴은 API 가 없어 도시 목록 화면(추천순·베스트리뷰순 × 평일·주말)에서 읽은 값(`method: screen`). unit 에 `rating_10` 추가. 아고다 도시 ID 는 파트너 Hotel Data File(2026-10-20)에서 — `data/agoda-cities-2026-10-20.json`.
- 2026-10-20 항공 **직항 고정 + 3소스**. 마이리얼트립 검색을 `directFlightOnly`(결과 50개)로 바꿔 경유 fallback 을 없앴고, `series_id` 끝에 `|direct|src:myrealtrip` 을 붙여 이전 "직항 우선" 시리즈와 끊었다. 같은 날짜(노선 최저가 날짜)의 트립닷컴·아고다 직항 왕복가를 `source: tripcom|agoda`, `attrs.method: screen` 으로 추가(항공 API 가 없어 검색 화면을 읽은 값 — 공식 출처가 아님을 method 로 구분). `source` enum 에 tripcom·agoda 추가. flights raw 에 `routes[].fares[]`(소스별 목록, 직항 없으면 `p: null, note: no_direct`) 가 생겼다.
