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_idlẫ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). GraphQLsearchReservations, 🔴pagination.offsetlà SỐ TRANG (0,1,2…) chứ không phải số dòng;rowsPerPage30 (200 → lỗi 500). - Nạp
fact_reservationsbằng gộp theo khoá(channel, slug, reservation_id)nhưota-reservations-extract.py(đơn đã có thì THAY, giữ lạirooms/is_last_minutecũ 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ốc2026-09-26là bản trọn đời 189 đơn từ nguồn group (source_file=groups/reservations …); cộtroomsvàis_last_minuteđể trống (nguồn không có); đơn khôngokghiprice_vnd = 0như 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-crawlnên dùng script group; script property chỉ còn cần khi muốn cộtrooms.
🔴 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
pagevàperpagetrên URL bị bỏ qua — trang luôn gọipage=1&perpage=100.tokencủ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ằngfetchđược.- Chặn
fetch/XHRtrong trang cũng không bắt được gì (calls:1màn:0) — request đi từ realm khác. Đường chạy được là đểchrome-grab.mjsbắ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/19-Harthliving-OTA/performance/metrics/fact_metrics.csv')
d = pd.read_csv('vault/19-Harthliving-OTA/performance/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. |