Harthliving OTA · bản chữ cho AI · dựng từ vault (nguồn sửa lần cuối 29/09/2026 09:28) · mục lục: https://harthliving-ota.pages.dev/ai/index.txt URL (HTML): https://harthliving-ota.pages.dev/library/tech/metrics-readme/ [Tổng quan](https://harthliving-ota.pages.dev/) › [Dữ liệu & báo cáo](https://harthliving-ota.pages.dev/library/) › [Tài liệu kỹ thuật](https://harthliving-ota.pages.dev/library/tech/) # Lớp dữ liệu HIỆU SUẤT theo thời gian — schema + luật ghi ## Lớp dữ liệu HIỆU SUẤT theo thời gian — schema + luật ghi Nguồn: `metrics/README.md` ### `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//.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ủ `____.gz`, `` 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/__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=&fromDate=2015-01-01&untilDate= &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 # 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 > 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=`** 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__.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//` (**đã .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 | | `` + `_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. |