OTA PortalHarth Living
Dữ liệu đọc tới 24/09/2026 (số liệu Booking) · PriceLabs 27/09/2026 · nguồn sửa lần cuối 29/09/2026 09:28
Tổng quan › Dữ liệu & báo cáo › Tài liệu kỹ thuật

Lớp dữ liệu HIỆU SUẤT theo thời gian — schema + luật ghi

Tổng quanBáo cáo & auditKho dữ liệuTài liệu kỹ thuậtVề dữ liệu

Lớp dữ liệu HIỆU SUẤT theo thời gian — schema + luật ghi

Nguồn: metrics/README.md
Mục lục

metrics/ — chuỗi thời gian để phân tích xu hướng

Lớp này khác data/ ở chỗ cơ bản nhất: hình dạng dữ liệu.

data/<slug>/<kênh>.json metrics/
Trả lời câu hỏi căn này ĐANG khai gì căn này BÁN ĐƯỢC bao nhiêu, qua từng mốc
Hình dạng tài liệu trạng thái bảng sự kiện dạng dài
Khi đọc lại ghi đè chỉ thêm, không bao giờ sửa dòng cũ
Mất lịch sử? có, và không sao không bao giờ — mất lịch sử là mất luôn khả năng phân tích

🔴 Luật số một: KHÔNG sửa và KHÔNG xoá dòng đã có trong fact_metrics.csv. Một phép đo sai vẫn là sự thật về điều extranet trả về hôm đó. Muốn sửa thì thêm mốc mới, hoặc thêm cột đánh dấu — đừng viết lại lịch sử.

Sáu thành phần

metrics/
├── dim_property.csv           CHIỀU  — 1 dòng mỗi (căn × kênh). Thay đổi chậm.   7 dòng
├── fact_metrics.csv           SỰ KIỆN dạng DÀI — 1 dòng = 1 phép đo.            935 dòng
├── fact_reservations.csv      SỰ KIỆN — 1 dòng = 1 ĐƠN đặt phòng.               164 dòng
├── fact_funnel.csv            SỰ KIỆN dạng RỘNG — 1 dòng = mốc × căn × KỲ.       35 dòng
├── fact_reviews.csv           SỰ KIỆN — 1 dòng = 1 REVIEW.                       14 dòng
├── booking_ranking.csv        SỰ KIỆN — 1 dòng = 1 căn × 1 lượt tìm kiếm CÔNG KHAI (thêm 26/09/2026)
├── crawl_log.csv              NHẬT KÝ — 1 dòng mỗi lần cào.                       4 dòng
└── _raw/…/                    payload gốc nén gzip + _manifest.json

Bảng nào do lượt cào nào lấp — và kiểm đủ bằng gì

Bảng Lượt cào Kiểm đã đủ chưa
dim_property viết tay khi thêm/bớt listing số căn khớp mục A của listings.md
fact_metrics ota-metrics-crawl.py + --history ota-metrics.py check 4/4 sạch · không căn nào trống cả một nhóm
fact_reservations ota-read-booking-reservations.mjs → ota-reservations-extract.py mọi cửa sổ ✅ khớp tổng API, không còn dòng 🔴
fact_funnel ota-read-booking-funnel.mjs → ota-funnel-extract.py mỗi căn đủ 4 kỳ (30·60·90·365) = 28 dòng/mốc
fact_reviews payload của lượt ① → ota-reviews-extract.py số dòng khớp review_count trong fact_metrics; lệch thì căn đó >10 review, phải chạy ota-read-booking-reviews.mjs
booking_ranking node scripts/ota-booking-search-rank.mjs … --save (site khách, không qua extranet) mỗi lượt đủ 7 dòng; unique_ids ≈ 900–1.000 (trần Booking)
crawl_log script tự ghi có đúng một dòng cho mỗi lượt, kể cả lượt hỏng

🔴 Cào "xong" mà một trong sáu ô kiểm trên chưa đạt thì chưa xong. Chỗ hay hụt nhất là fact_reservations: cửa sổ hỏng thường là cửa sổ đông đơn nhất, bỏ qua là mất cả chục đơn mà không bảng nào báo.

_raw/ là bảo hiểm: bộ trích có bug thì dựng lại số từ payload gốc, không phải đi cào lại (và không cào lại được — extranet không cho xem số của quá khứ).

Tên file trong _raw/ và cách soát độ phủ

<slug>__<trang>__<file mạng>.gz, <trang> là một trong reviews · sales · showrank. Nhìn tên là biết payload của căn nào, trang nào — không phải giải nén ra mới biết.

⚠️ File tên dạng NN-admin.booking.com_hotel_hoteladmin_extranet__… là của lược đồ cũ (trước 23/09/2026). Lược đồ đó cắt tên thư mục ở 46 ký tự nên mất cả hotel_id lẫn tên trang, và chạy bù trong cùng ngày thì ghi đè lên nhau. Đừng dùng lại.

_manifest.json có ba khối đọc được ngay, không cần mở file .gz nào:

Khoá Nghĩa
coverage mỗi slug đã có payload của những nhóm nào (sales · rank · reviews)
missing nhóm nào còn thiếu — đây là chỗ đọc để biết lần cào có hụt hay không
files[].alias payload trùng byte chỉ ghi một lần; các chỗ dùng lại nằm ở đây, nên coverage vẫn đúng

Cào bù trong cùng ngày thì manifest được gộp, không ghi đè — mất mục lục là mất luôn khả năng biết payload nào đã có.

🔴 Payload trả về rỗng vẫn phải giữ. salesInsightsTotal với data: [], hoặc với propertyValue: -1, là bằng chứng đã hỏi và extranet trả về không có gì — đó là thứ duy nhất phân biệt 0 thật với cào hụt.

-1 của Booking nghĩa là "không có dữ liệu", không phải "bằng 0"

salesInsightsTotal trả roomNights · totalTransactionValue · averageDailyRate cùng bằng -1 (kể cả benchmarkValue) khi căn không có đêm ở nào trong cửa sổ 12 tháng; kèm theo salesInsightsBreakdown.rooms: []. Bộ trích bỏ qua giá trị âm — ghi -1 vào bảng là bịa số, mà ghi 0 cũng là bịa vì extranet không nói thế.

⇒ Căn trống cả khối 12m không mặc nhiên là cào hụt. Mở _raw/<slug>__sales__*.gz: thấy -1 hoặc data: [] là đã hỏi rồi; không có file mới là chưa hỏi được. Đã xác minh trên alacarte404, 23/09/2026 (đối chứng alacarte305: 174 đêm / 336.132.266 ₫).

Lược đồ fact_metrics.csv

Cột Nghĩa
snapshot_date ngày cào, không phải ngày của số liệu
channel · slug · external_id khoá nối sang dim_property.csv
metric tên chỉ số, snake_case
value · unit giá trị + đơn vị (VND · count · ratio · score_10 · position · nights)
peer_value số đối chuẩn Booking trả kèm, để so với nhóm tương đương
period_start · period_end khoảng mà con số này đo
window nhãn cửa sổ: 12m · 90d · lifetime
basis cách tính: stay_date · report_window · cumulative
source_page · source_field truy ngược tới đúng trang và đúng trường JSON

Khoá tự nhiên: (snapshot_date, channel, slug, metric, period_start, period_end). Nạp lại cùng khoá thì thay giá trị, không đẻ dòng trùng — ota-metrics.py append lo việc đó.

🔴 Bẫy chết người: mỗi chỉ số có CỬA SỔ ĐO RIÊNG

Nhóm chỉ số Cửa sổ Tính theo
revenue · room_nights · adr 12 tháng ngày Ở, chỉ gồm khách đã ở xong
search_impressions · page_views · ctr · conversion · bookings · rank_in_city · property_page_score 90 ngày cửa sổ báo cáo của Ranking Dashboard
review_count · review_score trọn đời luỹ kế

⇒ Chia revenue (12 tháng) cho bookings (90 ngày) là ra một con số vô nghĩa. Analyst phải WHERE window = '12m' trước khi so sánh hay cộng. Cột window tồn tại chính vì lý do đó.

Lịch sử TỪ KHI MỞ LISTING — window = 1m và alltime

Cửa sổ 12 tháng ở trên là mặc định của trang, không phải giới hạn của dữ liệu. Trang doanh thu đọc khoảng ngày từ query string, nên nới bằng URL chứ không phải bấm date-picker (ô đó không nhận chữ gõ vào):

statistics/sales_insights.html?hotel_id=<id>&fromDate=2015-01-01&untilDate=<hôm nay>
   &granularity=MONTHLY&reservationType=STAYED&showTotals=true

Đặt fromDate sớm hơn mọi listing là được — Booking không báo lỗi, chỉ cắt về mốc sớm nhất nó phục vụ. Payload salesInsightsGranular trả một điểm mỗi tháng, salesInsightsTotal trả tổng của đúng khoảng đã xin.

⚠️ Mốc sàn là của TÀI KHOẢN, không phải của từng listing. Xin fromDate=2015-01-01 thì cả bảy căn đều trả chuỗi bắt đầu 2023-06 (40 tháng tính tới 09/2026) — giống hệt nhau, nên đó không thể là ngày mở của từng căn. Chưa xác minh được 2023-06 là giới hạn lưu trữ của Booking hay ngày mở tài khoản đối tác. Các tháng trước lần ở đầu tiên của mỗi căn được đệm bằng 0, không phải khuyết.

⇒ Với danh mục hiện tại chuyện này vô hại: lần ở sớm nhất của mọi căn đều sau 2023-06, nên chuỗi đã phủ trọn lịch sử. Nhưng đừng đọc tháng đầu chuỗi thành "ngày mở listing" — muốn biết ngày mở thì tra chỗ khác.

window Một dòng là period_start · period_end
1m một tháng doanh thu / đêm phòng / ADR mốc đầu và cuối của tháng đó
alltime tổng từ khi mở listing tới ngày cào fromDate đã xin · ngày cào
12m ảnh chụp 12 tháng gần nhất, giữ cho liên tục với các mốc cũ 01 của tháng năm ngoái · ngày cào
python3 scripts/ota-metrics-crawl.py --history            # cả 7 căn, từ khi mở
python3 scripts/ota-metrics-crawl.py --history --from 2019-01-01 --only alacarte305

🔴 1m là chuỗi thật, 12m/alltime là số đã cộng sẵn — đừng trộn. Cộng 1m lại sẽ ra đúng alltime của cùng khoảng; cộng 1m với 12m là tính hai lần. Chuỗi 1m không đổi về quá khứ, nên nó cũng là thứ cho phép đọc xu hướng ngay từ mốc cào đầu tiên, không phải chờ đủ ba tuần.

fact_reviews.csv — từng review một

fact_metrics.csv chỉ giữ hai con số về review (review_count, review_score). Payload gốc có nhiều hơn thế, nên có bảng riêng: ngày ở, sáu điểm thành phần, nhận xét tốt/xấu, quốc tịch, ngôn ngữ, đã trả lời hay chưa.

python3 scripts/ota-reviews-extract.py          # dựng lại từ payload ĐÃ CÓ trong _raw/, không cần mạng
node scripts/ota-read-booking-reviews.mjs --out <dir>   # tải mới, rows=500 nên không sót trang sau

🔴 Điểm thành phần 0 nghĩa là KHÔNG ĐÁNH GIÁ, không phải điểm không. Thang chạy 2,5–10 theo nửa điểm; khách không tiếp xúc hạng mục nào thì ô đó về 0 (vd nhận phòng tự động → không chấm staff). Bảng giữ nguyên 0 vì đó là thứ payload trả về — mọi phép trung bình phải WHERE <cột> > 0, nếu không một ô trống kéo tụt cả danh mục. Đo 23/09/2026: gộp cả ô 0 thì staff ra 9,11 và đội sổ; loại ra thì 9,81 và đứng thứ hai từ trên.

⚠️ Trang reviews.html chỉ nạp 10 review đầu. Căn nào nhiều hơn là bộ cào theo trang mất phần đuôi (alacarte305: pageCount 2, lấy 10/12). Dùng script .mjs ở trên — nó xin thẳng rows=500 qua review/fetch_reviews.json, và phải có &ses=<token> lấy từ tab extranet đang mở, thiếu là Booking đá sang trang đăng nhập dù Chrome vẫn đăng nhập.

Không lưu tên khách và số đặt phòng: lớp này để phân tích, không để liên hệ.

Reservation — fact_reservations.csv

Doanh thu và đêm phòng ở trên là số đã cộng. Muốn biết có bao nhiêu ĐƠN, đặt vào lúc nào, ở mấy đêm, huỷ hay không thì phải xuống tầng đơn lẻ, và tầng đó nằm ở trang Reservations.

node scripts/ota-read-booking-reservations.mjs          # cào, tự chia cửa sổ theo quý
python3 scripts/ota-reservations-extract.py             # nạp vào fact_reservations.csv

✅ Trang GROUP không có hai trần đó — lấy TRỌN lịch sử (đo 26/09/2026)

admin.booking.com/hotel/hoteladmin/groups/reservations/index.html (menu nhóm → Reservations) trả mọi property một lần, không ép 1 năm, không giới hạn 3 tháng: dải nhận phòng 2010–2030 ra 189 đơn = dải 2023–2027; 2010–2022 ra 0; theo ngày đặt / ngày trả cũng 189 ⇒ đó là toàn bộ (đơn sớm nhất đặt 25/12/2024, nhận phòng sớm nhất 09/01/2025). Kiểm chéo: 165/165 đơn của lượt cào theo property có mặt, trang group thêm 24 đơn (23 đơn nhận phòng 2025 mà trang property cắt mất). Nút Download của trang (tạo file sau ~3 phút "Retrieving file") ra Reservations_<from>_<to>.xls khớp 189/189 mã.

  • Cào: node scripts/ota-read-booking-group-reservations.mjs [--from 2023-01-01 --to 2027-12-31] → JSON có tên khách ở _private/reservations/<ngày>/ (đã .gitignore, không commit). GraphQL searchReservations, 🔴 pagination.offset là SỐ TRANG (0,1,2…) chứ không phải số dòng; rowsPerPage 30 (200 → lỗi 500).
  • Nạp fact_reservations bằng gộp theo khoá (channel, slug, reservation_id) như ota-reservations-extract.py (đơn đã có thì THAY, giữ lại rooms/is_last_minute cũ khi nguồn group để trống) — ⚠️ 26/09 lần đầu lỡ nối thẳng thành 354 dòng trùng, đã gộp lại 27/09 còn 189 dòng = 189 đơn. Mốc 2026-09-26 là bản trọn đời 189 đơn từ nguồn group (source_file = groups/reservations …); cột rooms và is_last_minute để trống (nguồn không có); đơn không ok ghi price_vnd = 0 như quy ước cũ (giá gốc đơn huỷ còn trong bản _private). Bản CSV không tên khách: _raw/reservations-group/2026-09-26/booking-reservations-all.csv.
  • ⇒ Lượt reservation của /ota-metrics-crawl nên dùng script group; script property chỉ còn cần khi muốn cột rooms.

🔴 Hai trần của Booking — đo 23/09/2026, không phải giới hạn của công cụ

Trần Biểu hiện
Lùi tối đa 1 năm date_from sớm hơn hôm nay − 1 năm bị ép về đúng mốc đó, không báo lỗi. Xin 2023-06-01 · 2024-01-01 · 2025-01-01 đều thành 2025-09-23. Khoảng nằm hẳn trong quá khứ (2025-01-01→2025-06-30) bị bóp về một ngày. Ép như nhau cho arrival · book · departure.
Mỗi lần hỏi tối đa ~3 tháng Dải 3 tháng trả success: 1. Dải 6 tháng và 12 tháng thì trang hỏng hẳn, không gọi nổi endpoint. Script tự chia cửa sổ quý.

⇒ Đơn nhận phòng trước hôm nay − 1 năm là không lấy được. Doanh thu và đêm phòng của giai đoạn đó thì vẫn có (window = 1m, lùi tới 2023-06) — nên số đêm có lịch sử dài hơn số đơn, và hai đường trên cùng một biểu đồ sẽ bắt đầu ở hai thời điểm khác nhau. Nói rõ điều đó trên biểu đồ, đừng để người đọc tưởng trước đó không có đơn nào.

Ba thứ tưởng làm được mà không

  • page và perpage trên URL bị bỏ qua — trang luôn gọi page=1&perpage=100.
  • token của endpoint là nonce dùng một lần; phát lại đúng URL đó trả success: 0, nên không tự phân trang bằng fetch được.
  • Chặn fetch/XHR trong trang cũng không bắt được gì (calls:1 mà n:0) — request đi từ realm khác. Đường chạy được là để chrome-grab.mjs bắt ở tầng mạng.

Phép kiểm đủ

Mỗi cửa sổ tự đối chiếu: cộng price.amount của các đơn nhận được, so với totalPrice mà API trả cho toàn bộ tập lọc. Khớp thì cửa sổ đó chắc chắn không sót trang — mạnh hơn nhiều so với tin vào cờ hasNextPage, vì cờ đó từng trả '' cho cả trang đầy. Cờ complete trong file kết quả là AND của mọi cửa sổ.

fact_funnel.csv — phễu theo KỲ

Cùng dữ liệu phễu với fact_metrics nhưng ở dạng rộng (một dòng đọc thẳng được) và có thêm chiều kỳ: Ranking Dashboard cho chọn 30 · 60 · 90 · 365 ngày, mỗi kỳ một dòng.

node scripts/ota-read-booking-funnel.mjs     # đổi bộ chọn kỳ, bắt response, lưu payload thô
python3 scripts/ota-funnel-extract.py        # nạp vào bảng
Cột Nghĩa
period_days · period_label kỳ Booking đo; (dò ngược) là dòng suy ra trước khi biết có bộ chọn kỳ
window_start · window_end · lag_days chỉ có ở dòng dò ngược — dashboard có độ trễ ~7–11 ngày
window_method declared-by-booking · derived-exact · derived-approx · unknown
window_ambiguity_days biên xê dịch được bao nhiêu ngày mà vẫn khớp; 0 là chắc
<chỉ số> + <chỉ số>_peer số của mình và của comp set, cùng thang
rank_total tổng số chỗ nghỉ ở thành phố — mẫu số của hạng, KHÔNG phải mức đối chuẩn

🔴 Các kỳ CHỒNG lên nhau. Kỳ 90 ngày đã bao trùm kỳ 30 ngày — trừ kỳ này cho kỳ kia rồi đọc thành "hai tháng trước" là sai, vì không biết phần chênh rơi vào ngày nào.

🔴 bookings đếm đơn ĐƯỢC ĐẶT trong kỳ, GỒM CẢ đơn sau này huỷ. Nên conversion ở đây là chuyển đổi sang lượt đặt, không phải sang đêm ở. Với tỷ lệ huỷ 53% của danh mục, hai thứ đó cách nhau rất xa — muốn con số thật thì nối sang fact_reservations qua (slug, booked_at) và lọc status = 'ok'.

⚠️ 365 ngày là trần. Xa hơn Booking không phục vụ. Muốn chuỗi thời gian thật của phễu thì phải tự tích luỹ qua từng mốc cào hằng tuần — khác hẳn doanh thu, vốn có sẵn chuỗi tháng lùi tới 2023-06.

booking_ranking.csv — thứ hạng trên trang tìm kiếm CÔNG KHAI booking.com (thêm 26/09/2026)

Bản đôi của ../metrics-agoda/agoda_ranking.csv (tên do user đặt theo cặp). Nguồn: scripts/ota-booking-search-rank.mjs — mở browser context sạch (không đăng nhập, không lịch sử), bắt GraphQL FullSearch rồi phát lại theo offset; căn nào không lọt vào danh sách thì hỏi riêng (destType: HOTEL) để biết còn phòng không. Chỉ thêm, không sửa. Đây là thứ hạng cho đúng một tìm kiếm; hạng thành phố chính thức 90 ngày (competitiveSetLeaderboard, vd 305 #1.039/3.241) nằm ở Ranking Dashboard extranet — số khác bản chất, đừng trộn.

Cột Nghĩa
search_id · pass_no mã tìm kiếm (A–D, trùng bộ lọc với agoda_ranking ngày 26/09) · lượt đo
dest_id · checkin · checkout · nights · adults · rooms bộ lọc; Đà Nẵng = -3712125
sort · personalization default_top_picks (sắp xếp mặc định Our top picks) · clean_context
total_reported · rows_scanned · unique_ids nbResultsTotal · số dòng đã duyệt · số id khác nhau = trần xem được (~900–1.000)
rank · page_approx · pct_from_top vị trí đầu tiên gặp · trang (25 thẻ/trang) · % tính từ đầu
status ranked · sold_out (Booking báo hết phòng/không vừa bộ lọc) · beyond_cap (còn phòng nhưng không nằm trong ~1.000 dòng xem được) · not_found
price_per_stay_vnd giá trọn kỳ khách thấy trong ô hỏi riêng (chỉ có khi còn phòng)

🔴 Ba bẫy đã đo: (1) tài khoản đăng nhập bị cá nhân hoá — 25AT11 #1 / 41LHC #5 khi đăng nhập, #122–136 / #104 ở context sạch; (2) &offset= trên URL bị bỏ qua, phải đi qua FullSearch; (3) sau ~1.000 kết quả Booking chỉ trả dòng lặp, nên căn chìm sâu hơn mốc đó không đo được thứ hạng, chỉ biết là beyond_cap.

Dùng

python3 scripts/ota-metrics.py log              # nhật ký các lần cào
python3 scripts/ota-metrics.py table            # bảng rộng của mốc mới nhất
python3 scripts/ota-metrics.py trend revenue    # xu hướng một chỉ số, có % thay đổi giữa các mốc
python3 scripts/ota-metrics.py check            # soát: slug lạ · dòng trùng · thiếu cửa sổ · thiếu giá trị
python3 scripts/ota-metrics.py append rows.json # nạp thêm, idempotent

Analyst mở thẳng bằng pandas hoặc DuckDB, không cần tool của repo:

import pandas as pd
f = pd.read_csv('vault/18-ota-listings/metrics/fact_metrics.csv')
d = pd.read_csv('vault/18-ota-listings/metrics/dim_property.csv')
df = f.merge(d, on=['slug','channel'])

# xu hướng doanh thu theo căn
(df[df.metric=='revenue']
   .pivot_table(index='snapshot_date', columns='name', values='value'))

# phễu 90 ngày — LỌC CỬA SỔ TRƯỚC
(df[(df.window=='90d') & df.metric.isin(['search_impressions','page_views','bookings'])]
   .pivot_table(index='name', columns='metric', values='value'))
-- DuckDB đọc thẳng CSV, không cần nạp
SELECT slug, metric, value FROM 'fact_metrics.csv'
WHERE window = '12m' AND snapshot_date = (SELECT max(snapshot_date) FROM 'fact_metrics.csv');

Vì sao CSV chứ không phải database

7 listing × ~12 chỉ số × cào hằng tuần ≈ 4.400 dòng/năm. Ở cỡ này CSV thắng tuyệt đối: đọc được bằng mắt, diff được trong git (thấy ngay số nào đổi giữa hai lần cào), mở bằng pandas / DuckDB / Excel, không cần server, không cần migration. Dựng warehouse cho vài nghìn dòng là tự tạo việc bảo trì mà không đổi lại được gì.

Ngưỡng nên đổi: khi vượt ~1 triệu dòng, hoặc khi cần nhiều người ghi đồng thời. Lúc đó chuyển sang Parquet + DuckDB là bước kế tiếp tự nhiên — lược đồ dạng dài này giữ nguyên, không phải viết lại.

Nhịp cào đề xuất

Hằng tuần, cùng thứ trong tuần — khoảng cách đều thì xu hướng mới đọc được.

Một lượt đầy đủ gồm bốn lần cào theo thứ tự, xem /ota-metrics-crawl: chỉ số nền → lịch sử doanh thu (--history) → reservation → phễu nhiều kỳ. Lượt reservation phải chạy trước lượt phễu, vì bộ trích phễu dò ngược cửa sổ bằng fact_reservations.

Sau mỗi lần cào nhớ ghi một dòng vào crawl_log.csv: thiếu nhật ký thì sau này không biết khoảng trống trong chuỗi là không cào hay cào mà không có số.

Lịch sử

Mốc Kênh Căn Dòng Ghi chú
2026-09-23 booking 7 77 Lần cào đầu. 4/7 căn chưa có review nào; A La Carte 404 chưa có doanh thu.