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 # Harthliving OTA — toàn bộ nội dung, Phần 34/35 · tiếp: https://harthliving-ota.pages.dev/ai/full/part-35.txt Trong phần này: Lớp dữ liệu listing — nguồn sự thật duy nhất · Lớp dữ liệu listing — nguồn sự thật duy nhất · Lớp hiệu suất AGODA — kho riêng, cùng cơ chế với Booking · Lớp hiệu suất AGODA — kho riêng, cùng cơ chế với Booking · 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 · Luật truy cập OTA và PriceLabs — đọc tự do, ghi phải xin phép · Luật truy cập OTA và PriceLabs — đọc tự do, ghi phải xin phép --- # Lớp dữ liệu listing — nguồn sự thật duy nhất URL: https://harthliving-ota.pages.dev/library/tech/data-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 listing — nguồn sự thật duy nhất ## Lớp dữ liệu listing — nguồn sự thật duy nhất Nguồn: `data/README.md` ### `data/` — nguồn sự thật duy nhất cho mọi listing Mỗi file `data//.json` chứa **toàn bộ sự thật đã xác minh của một căn trên một kênh**, ở dạng máy đọc được. **Khi JSON và Markdown lệch nhau, JSON đúng.** File `.md` trong `properties/` là **lớp trình bày cho người đọc**, sinh ra từ cùng dữ liệu này. #### Vì sao có lớp này Trước 08/09/2026 mọi fact nằm trong văn xuôi Markdown. Ba hệ quả đo được: - **Không tra được bằng máy.** Câu *"Swimming pool → Outdoor · All year · Paid · 06:00–19:00"* là chữ trong bảng; muốn biết bể bơi có thu phí không thì phải đọc hiểu. - **Cùng một con số viết hai kiểu.** Giá buffet xuất hiện **3 lần dạng `285.000₫`** và **3 lần dạng `285,000 VND`** trong cùng một bộ hồ sơ. Grep một dạng là hụt một nửa. - **Không so được giữa các căn.** Muốn biết 502 thiếu gì so với 305 thì phải mở hai file và đọc tay. #### Dùng `python3 scripts/listing-data.py list # có những file nào python3 scripts/listing-data.py check # kiểm khoá bắt buộc + trường còn trống python3 scripts/listing-data.py show alacarte305 # in gọn một listing python3 scripts/listing-data.py compare booking # bảng đối chiếu mọi căn cạnh nhau python3 scripts/listing-data.py gaps alacarte305 # căn khác THIẾU gì so với căn mẫu python3 scripts/listing-data.py get alacarte305 facilities.swimming_pool.paid ` `gaps` là lệnh đáng dùng nhất khi đã có ≥2 căn: lấy một căn làm chuẩn, liệt kê **tiện ích căn kia chưa bật** và **mọi chỉ số thấp hơn căn chuẩn**. #### Bộ khoá | Khối | Chứa gì | |---|---| | `read` | ngày đọc, đọc bằng gì, trỏ tới `_raw/` chứa dump thô | | `identity` | tên, địa chỉ, toạ độ, trạng thái, URL công khai | | `scores` | page score, review tổng + **từng hạng mục**, điểm công ty, điểm của đối thủ cùng toà | | `photos` | tổng, số hiện public, số bị gắn cờ kém, smart ordering | | `room` | loại phòng, diện tích, số phòng ngủ/tắm, **cấu hình giường từng phòng**, sức chứa | | `fees_taxes` | VAT, phí dọn, giá bữa sáng **cả ba nguồn**, giá đỗ xe **khai vs thật** | | `policies` | giờ nhận/trả, thanh toán, trẻ em, nội quy, **từng chính sách huỷ kèm số liệu 90 ngày** | | `facilities` | từng tiện ích: `on` · `paid` · `hours` · `features` · `price_vnd` | | `amenities_room` | danh sách tiện nghi trong phòng | | `accessibility` | khai báo tiếp cận xe lăn | | `messaging` | mẫu tin, lịch gửi, **cờ cảnh báo** như `contains_door_code`, `asks_for_star_rating` | | `sustainability` | đã bắt đầu chưa, chứng nhận | | `profile` | hồ sơ chủ nhà, **độ dài từng ô chữ** | | `public_view` | thứ khách thật sự thấy: huy hiệu, chip, số nhóm tiện ích, **câu phủ định đang chạy** | | `issues` | mã `L-nn` liên quan, tra chéo sang [`../issues.md`](https://harthliving-ota.pages.dev/issues/) | Quy ước: **`null` = chưa đọc được**, khác với `false` = đã đọc và đúng là không. Mọi số tiền là **số nguyên VND**, không kèm ký tự — định dạng để lớp hiển thị lo. #### Thêm một căn 1. Cào bằng `node scripts/chrome-grab.mjs --out vault/18-ota-listings/properties//_raw/-/ …` 2. Tạo `data//.json` theo đúng bộ khoá trên — thiếu khoá nào thì `check` báo, **không cần sửa script**. 3. `python3 scripts/listing-data.py check` rồi `gaps alacarte305` để thấy ngay căn mới hụt gì. #### Trạng thái hiện tại | Căn | Kênh | Ngày đọc | |---|---|---| | `alacarte305` | booking | 2026-09-08 | Sáu căn còn lại (`alacarte404` · `alacarte502` · `15nuocman5` · `181tohienthanh` · `41lehycat` · `25anthuong11`) **chưa có file dữ liệu** — mới chỉ có hồ sơ Markdown. --- # Lớp hiệu suất AGODA — kho riêng, cùng cơ chế với Booking URL: https://harthliving-ota.pages.dev/library/tech/metrics-agoda-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 hiệu suất AGODA — kho riêng, cùng cơ chế với Booking ## Lớp hiệu suất AGODA — kho riêng, cùng cơ chế với Booking Nguồn: `metrics-agoda/README.md` ### `metrics-agoda/` — tách khỏi Booking, gộp sau Cùng cơ chế với [`../metrics/`](https://harthliving-ota.pages.dev/library/data/): bảng chiều + bảng sự kiện, **chỉ thêm không sửa**, payload gốc giữ trong `_raw/`. 🔴 **Tách kho là có chủ ý.** Hai kênh đo những thứ **không quy về nhau được**: cửa sổ khác nhau, định nghĩa "booking" khác nhau, hoa hồng khác nhau. Trộn sớm là mất khả năng truy một con số về đúng nguồn của nó. Gộp là việc của lớp trình bày, làm sau, và làm tường minh. `metrics-agoda/ ├── dim_property.csv 7 căn × kênh agoda (external_id = Agoda hotel id) ├── fact_agoda_summary.csv 84 · mốc × căn × chỉ số × KỲ ├── fact_agoda_daily.csv 178 · mốc × căn × NGÀY × trục (book|stay) ├── fact_agoda_transactions.csv 28 · 1 GIAO DỊCH tiền ├── fact_agoda_reservations.csv 14 · 1 ĐƠN đặt phòng ├── fact_agoda_reviews.csv 8 · 1 REVIEW ├── agoda_ranking.csv 56 · 1 căn × 1 lượt tìm kiếm công khai (thứ hạng Best match) ├── _raw/-agoda/ payload gốc, mỗi căn một file JSON └── README.md file này ` Cào rồi nạp: `node scripts/ota-agoda-metrics-crawl.mjs # → _raw/-agoda/ python3 scripts/ota-agoda-metrics-extract.py # → 5 bảng fact_agoda_* python3 scripts/build-agoda-page.py # → tab Agoda của báo cáo python3 scripts/build-agoda-data-page.py # → tab Data_Agoda ` #### Khoá tự nhiên từng bảng | Bảng | Một dòng là gì | Khoá | |---|---|---| | `dim_property` | 1 chỗ nghỉ × kênh agoda | `(slug, channel)` | | `fact_agoda_summary` | 1 mốc × căn × chỉ số × kỳ | `(snapshot_date, channel, slug, metric, period_type)` | | `fact_agoda_daily` | 1 mốc × căn × ngày × trục | `(snapshot_date, channel, slug, date, basis)` | | `fact_agoda_transactions` | 1 giao dịch tiền | `(channel, slug, transaction_id)` | | `fact_agoda_reservations` | 1 đơn | `(channel, slug, booking_id)` | | `fact_agoda_reviews` | 1 review | `(channel, slug, review_id)` | | `agoda_ranking` | 1 căn × 1 tìm kiếm × 1 lượt đo | `(snapshot_date, search_id, pass_no, slug)` | **Nối đơn ↔ tiền:** `fact_agoda_reservations.booking_id` = `fact_agoda_transactions.reference_number` (kiểm 2/2 trên 181 THT). Danh sách đơn **không kèm tiền**, nên muốn doanh thu từng đơn thì bắt buộc phải nối. `booking_value = 0` nghĩa là **đơn đã huỷ** — 9/28 giao dịch đang như vậy, giống hệt cách Booking xoá giá đơn huỷ. #### Bản đồ endpoint — dò ngày 24/09/2026 Cây menu đầy đủ không cần bấm: `GET /mldc/en-us/api/layout//PrivateLayout/Main`. | Việc | Trang | API | |---|---|---| | Tổng quan | `/app/reporting/dashboard/` | `reporting/PerformanceDashboard/GetPerformanceSummaries/` | | Hạng so đối thủ | cùng trên | `reporting/MarketInsights/RankingAgainstCompetitors/` | | Phân tích | `/app/reporting/analyticscenter/` | `reporting/BookingInsights/GetTimeFrameMetrics/` · `…/GetTopCompetitors/` | | Đơn | `/app/postbook/booking/` | `postbook/Booking/list/` — **POST**, ngày dạng `/Date(ms)/` | | Review | `/app/setting/review/` | `setting/Review/searchreviews/` | | Tiền từng đơn | `/app/finance/transactions/` | `finance/LegacyTransactions//SearchTransactions` | #### Agoda dễ hơn Booking ở hai chỗ **① Ngày là tham số thật.** `RankingAgainstCompetitors?startDate=YYYY-MM-DD&dayRange=N` nhận **ngày tuỳ ý** — thử `startDate=2025-09-01` và `dayRange=365` đều được phục vụ, không bị ép như `date_from` của Booking. Cũng không có chuyện token nonce hay trần 3 tháng mỗi lần hỏi. **② Phân trang tử tế.** `Booking/list` trả `pagedBookingList{totalCount, pageSize, hasNextPage, totalPages}` — không phải đoán như `hasNextPage` rỗng của Booking. #### Và khó hơn ở hai chỗ **① `GetTimeFrameMetrics` chỉ có 4 kỳ cứng, lùi tối đa 30 ngày.** | `timeFrameType` | 1 = theo ngày ĐẶT · 2 = theo ngày Ở | |---|---| | `period` | 1 = 14 ngày qua · 2 = **30 ngày qua** · 3 = 14 ngày tới · 4 = 30 ngày tới | Bù lại, nó trả `daily[]` — **chuỗi theo NGÀY**, thứ Ranking Dashboard của Booking không hề có. **② `periodType` chưa giải mã hết.** `GetPerformanceSummaries` trả `periodSummary[]` với `periodType` 0 · 1 · 2 · 3, và **API không khai nghĩa**. - ✅ **`periodType 1` = *this month*** — xác minh bằng cách đối chiếu với con số hiện trên dashboard của 181 Tô Hiến Thành (Revenue 8.235.938 ₫). - ⬜ `0` · `2` · `3` **chưa chốt**. Nhìn độ lớn thì `3` là kỳ dài nhất, `2` ở giữa — nhưng đó là suy đoán. **Script ghi nguyên số `periodType`, không tự đặt tên.** Muốn chốt thì đối chiếu từng giá trị với nhãn trên dashboard như đã làm với `periodType 1`. `performanceType`: `0` = đêm phòng · `1` = doanh thu · `3` = lượt đặt (suy từ đơn vị và độ lớn, **chưa đối chiếu nhãn**). `2` và `4..7` chưa dùng. #### Số đầu tiên — mốc 24/09/2026 | Căn | Doanh thu `p3` | Lượt đặt `p3` | |---|---|---| | **181 Tô Hiến Thành** | **111.993.840 ₫** | **10** | | 15 Nước Mặn 5 | 36.520.736 ₫ | 3 | | A La Carte 502 | 6.240.000 ₫ | 1 | | 305 · 404 · 41 LHC · 25 AT11 | 0 | 0 | 🔴 **Đây là doanh thu mà báo cáo Booking hoàn toàn không thấy.** 181 Tô Hiến Thành — căn mà bên Booking chỉ có 45,3 tr doanh thu 12 tháng và bị 70% huỷ — **bán được 112 triệu bên Agoda**. Bất kỳ kết luận nào về "căn này yếu" mà chỉ nhìn Booking đều sai. ⚠️ Chưa biết `p3` là cửa sổ nào, nên **chưa được so thẳng với con số 12 tháng của Booking**. Đó là việc phải làm trước khi gộp hai kênh. #### Điều kiện chạy Giống hệt Booking — xem [`/ota-metrics-crawl`] §0: đúng profile Chrome (`Huy`), tab con phải mở bằng `window.open` từ tab đã đăng nhập, daemon CDP còn sống và chưa đầy bộ đệm. Cần **một tab `portal.agoda.com` đang mở**. #### Ngày bắt đầu active — dò 25/09/2026 **Agoda không có trường "ngày lên sóng".** Đã dò cả 27 mục menu YCS: hợp đồng, compliance, property details, payouts, remittances, calendar, property switcher — không trang nào khai. Nên ngày phải suy từ dấu vết, bằng `node scripts/ota-agoda-active-from.mjs`. | Căn | `active_from` | Căn cứ | Hợp đồng sớm nhất | |---|---|---|---| | `181tohienthanh` | **2026-01-23** | 📧 khách hỏi phòng (sớm hơn đơn đầu 19/02 gần một tháng) | 2026-01-19 | | `alacarte305` | **2025-03-06** | đơn đặt sớm nhất (khách hỏi cùng buổi sáng, 10:08) | 2024-12-25 | | `15nuocman5` | **2026-06-19** | đơn đặt sớm nhất (ở 26/06/2026) | 2026-02-02 | | `alacarte502` | **2026-06-22** | đơn đặt sớm nhất (ở 16/07/2026) | 2026-02-02 | | `25anthuong11` | **2026-09-18** | 📧 **Agoda tự khai** — thư *"Your property has been published!"* 19:41 | 2026-09-18 | | `alacarte404` | — | chưa bán được đêm nào · **không thư Agoda nào từng nhắc tên căn này** | 2026-02-02 | | `41lehycat` | — | chưa bán được đêm nào · thư sớm nhất nhắc tên là 08/07/2026, nhưng chỉ là thư marketing | 2026-02-02 | `active_from` là **cận trên quan sát được**: listing chắc chắn đã active *trước hoặc đúng* ngày đó, không có gì bảo đảm nó active *đúng* ngày đó. Cùng quy ước với kho Booking. **Cột `contract_from`** là lần chấp thuận điều khoản sớm nhất của chính căn đó — trần trên của ngày tạo hồ sơ. Để riêng, **không** thế vào `active_from`: ký giấy xong vẫn có thể chưa bật bán. Khoảng cách giữa hai cột nói lên điều đó: 305 ký 25/12/2024 mà mãi 06/03/2025 mới có đơn; bốn căn cùng ký 02/02/2026 nhưng 15 NM5 tới 19/06 mới bán được, còn 404 và 41 LHC tới nay vẫn chưa. 🔴 **Ba căn để trống là "chưa quan sát được", KHÔNG phải "chưa active".** Ba bằng chứng: 1. `PropertyRecStatus = 1` — **giống hệt** bốn căn đang bán được. Không có căn nào ở trạng thái nháp hay bị tạm ngưng. 2. Lịch đang mở và đang có giá: 404 48 ngày · 41 LHC 90 · 25 AT11 88. 3. `totalPages: 0` trên `SearchTransactions` xác nhận **không có đơn thật**, không phải lỗi phân trang (phân biệt với zero-rows-may-mean-logged-out). Nên chỗ trống không phải lỗi đọc, cũng không phải listing hỏng — nó là **hệ quả của cách suy ngày**. Mọi dấu vết Agoda giữ lại đều là dấu vết *khách để lại* (đơn, review, giao dịch, payout). Căn chưa có khách thì không để lại gì để neo. **Thứ suy được là một KHOẢNG, không phải một ngày:** | Căn | Sớm nhất có thể | Muộn nhất có thể | Rộng | |---|---|---|---| | `alacarte404` | 02/02/2026 (ký hợp đồng) | hôm nay | ~8 tháng | | `41lehycat` | 02/02/2026 (ký hợp đồng) | hôm nay | ~8 tháng | | `25anthuong11` | **18/09/2026** (ký hợp đồng) | hôm nay | **~1 tuần** | 25 An Thượng 11 hẹp tới mức coi như đã có đáp số: property tạo bằng Fast-track import và chỉ chấp thuận đúng bản điều khoản hiện hành, nên 18/09/2026 vừa là ngày ký vừa là ngày tạo hồ sơ. Hai căn còn lại thì khoảng quá rộng để gọi là một ngày. **Cách duy nhất làm hẹp thêm:** đợi đơn đầu tiên, hoặc hỏi Agoda support (họ giữ lịch sử trạng thái property, portal thì không). ##### Bên trong hợp đồng có gì — mở đọc 25/09/2026 Nút *View contract* mở **modal trong trang** (không phải PDF, không mở tab mới). Nội dung là **văn bản chung của Agoda, không có tên căn và không có ngày ký riêng**. Thứ duy nhất có ngày là dòng đầu — ngày hiệu lực của *phiên bản điều khoản*, giống hệt nhau ở mọi căn: | id | Phiên bản | Ngày hiệu lực ghi trong văn bản | |---|---|---| | `88` | General T&C **6.0** | 08/12/2023 | | `89` | General T&C **7.0** | 21/02/2025 | | `130` | General T&C **8.0** | 08/06/2026 | 🔴 **Đừng lấy ngày trong văn bản làm ngày của căn.** Ngày per-property nằm ở `lastModify` của API, tức **lúc căn đó chấp thuận**, không phải lúc văn bản ra đời. Ví dụ 41 Lê Hy Cát chấp thuận bản 7.0 ngày 02/02/2026 — 11 tháng sau khi bản đó hiệu lực. Hợp đồng riêng của từng căn (**APPA** — *Accommodation Property Participation Agreement*) là thứ đáng đọc nhất thì **nút View và Download đều `disabled`**: chỉ 305 (`1371548`) và 181 THT (`1705775`) có APPA, và cả hai đều không mở được. Bốn căn còn lại không có APPA nào. **Nhưng danh sách phiên bản đã chấp thuận lại khoanh được vùng ngày tạo hồ sơ.** Một căn chỉ phải chấp thuận những bản còn hiệu lực trong đời nó, nên bản CŨ NHẤT nó có nói lên nó ra đời lúc nào: | Căn | Bản cũ nhất đã chấp thuận | ⇒ hồ sơ tạo trong khoảng | |---|---|---| | `alacarte305` | **6.0** (nhận 25/12/2024) | trước 21/02/2025 — căn duy nhất có mặt từ thời 6.0 | | `181tohienthanh` | 7.0 (nhận 19/01/2026) | 21/02/2025 → 19/01/2026 | | `alacarte404` · `alacarte502` · `15nuocman5` · `41lehycat` | 7.0 (nhận 02/02/2026) | 21/02/2025 → 02/02/2026 | | `25anthuong11` | **8.0** (nhận 18/09/2026) | **sau 08/06/2026** → 18/09/2026 | `25anthuong11` **không có bản 7.0** — nếu hồ sơ đã tồn tại thời 7.0 thì bắt buộc phải có. Đó là bằng chứng độc lập cho thấy căn này sinh ra sau 08/06/2026, khớp với hotel id 96450982 cao vọt so với sáu căn kia. ⚠️ Bản `130` được cả sáu căn cũ nhận **đúng ngày 08/06/2026** (305 lúc 13:12, năm căn kia 13:56) — trùng ngày hiệu lực, nên nhiều khả năng là **đóng dấu hàng loạt tự động**, không phải host bấm. Vì vậy ngày của bản 130 chỉ chứng minh "căn đã tồn tại ngày 08/06/2026", không nói gì thêm. Ngược lại `88` và `89` được nhận muộn hơn ngày hiệu lực cả năm → đó là hành vi thật. ##### Nguồn thứ năm: hộp thư Gmail — mạnh hơn cả portal `node scripts/gmail-read.mjs --search ""` đọc Gmail qua tab Chrome đã đăng nhập, **không cần OAuth, không cần 2FA**. Hộp thư giữ thứ portal đã xoá. Hai mốc mà portal không cho, Gmail cho: - 📧 **`25anthuong11` = 18/09/2026 19:41** — thư `no-reply@notifications.agoda-messaging.com` tiêu đề *"Your property has been published! Let's get booking."*, mở ra ghi đích danh *"Dear 4BR Villa in An Thuong 500m to MyKhe Beach"*. Đây là **Agoda tự khai ngày lên sóng**, mạnh hơn mọi suy luận — nên `active_from_basis` ghi `declared`, không phải `observed`. Chỉ căn này có thư đó (tìm `from:agoda "has been published"` trả đúng 1 kết quả); sáu căn kia lên sóng trước khi Agoda có loại thông báo này. - 📧 **`181tohienthanh` = 23/01/2026 02:01** — thư *"Inquiry by YOUNGJAE YOON"*. Khách hỏi được thì listing đã hiển thị, mà mốc này **sớm hơn đơn đầu tiên gần một tháng** (19/02). Đã kiểm không có thư khách nào sớm hơn. Đối chiếu chéo: 305 có thư hỏi phòng lúc **06/03/2025 10:08** và yêu cầu đặt lúc **10:20** cùng sáng — khớp `createdDate` 06/03/2025 của giao dịch, tức hai nguồn độc lập cùng ra một ngày. Thư *"Welcome to Agoda"* **25/12/2024** cũng khớp đúng `contract_from` của 305, xác nhận cách đọc cột hợp đồng là đúng. ⚠️ **Thư marketing nhắc tên căn KHÔNG phải bằng chứng lên sóng** — Agoda gửi cho cả property chưa bán được gì. `41lehycat` được nhắc tên từ 08/07/2026 trong thư mời Mega Sale nhưng vẫn chưa có dấu hiệu nào cho thấy nó từng bán. `alacarte404` thì **không thư nào nhắc tên**. ##### 🔴 Lịch KHÔNG phải kho lịch sử — đã thử và đã bác bỏ `Calendar/GetAvailability` và `GetRate` **nhận ngày quá khứ tuỳ ý** và trả về đủ 365 ô, nên rất dễ tưởng là lịch sử tồn kho. Không phải. **Phép thử bác bỏ:** A La Carte 305 có đơn tạo 06/03/2025, khách ở 18/03/2025 — vậy mà quét trọn năm 2025 trả **365 ngày đều `remainingRoomNightsAvailable = 0`, `basePrice = null`, `netRoomNightsBooked = 0`**. Quá khứ bị dọn sạch, không phải "chưa từng mở". **Dấu hiệu nhận ra cái bẫy mà không cần phép thử:** sáu căn khác nhau cùng trả `basePrice` bắt đầu đúng một ngày — 26/08/2026. Sáu listing mở bán ở sáu thời điểm khác nhau không thể cùng có giá từ một ngày; đó là ngày cấu hình giá hiện hành, không phải ngày mở bán. Script vẫn quét lịch nhưng in dưới nhãn *chẩn đoán* và **không cho nó tính `active_from`**. ##### `period_type` — đã chốt thêm một giá trị `p3` **khớp tuyệt đối** số đơn trong `fact_agoda_reservations` cho cả bốn căn có đơn (181 THT 10 · 15 NM5 3 · 502 1 · 305 0). Tức `p3` đếm đúng tập mà `Booking/list` trả về. Đối chiếu theo ngày nhận phòng thì ba cửa sổ *12 tháng · từ đầu năm · trọn đời* cho cùng kết quả nên **chưa tách được ba khả năng đó** — nhưng biết chắc `p3` không phải "trọn lịch sử giao dịch", vì 305 có 6 giao dịch từ 2025 mà `p3 = 0`. `p0` (181 THT = 2) và `p2` (181 THT = 5, 15 NM5 = 2) **vẫn chưa khớp cửa sổ nào** đã thử — không khớp 7/30/90 ngày, 6/12 tháng, tháng này, từ đầu năm, theo cả ngày đặt lẫn ngày ở. #### Ba bẫy đã trả giá — đọc trước khi sửa script **① `SearchTransactions` mặc định phân trang MỘT DÒNG mỗi trang.** Thiếu `PageSize` thì API trả 1 dòng với `totalPages: 17` và **không báo lỗi gì**. Dấu hiệu bắt được nó: nới rộng khoảng ngày mà số dòng lại **giảm**. Luôn truyền `PageSize` và đối chiếu với `totalPages`. **② Tên trường của Agoda khác Booking.** `checkinDate` (i thường, không phải `checkInDate`), `roomNights` cho sẵn (không phải tự tính từ hai ngày), không có trường tiền trong đơn. Đoán theo thói quen Booking thì bảng vẫn ra đủ dòng — nhưng **mọi cột đều rỗng trừ `booking_id`**. **③ Review gọi trần trả rỗng.** Phải để trang tự gọi rồi bắt ở tầng mạng qua `chrome-grab`. Trang 181 THT ghi "5 reviews" mà API trả 4 — chênh lệch này chưa giải thích được. #### Chưa làm - `period_type` **0 · 2 · 3** chưa giải mã. Cả ba chỉ số (`bookings` · `revenue` · `room_nights`) đều có đủ 4 kỳ, tức 84 dòng thì **63 dòng chưa biết cửa sổ**. Cách chốt: đối chiếu từng giá trị với nhãn trên dashboard, đúng như đã làm với `1`. - Chuỗi theo ngày mới có **một mốc**. Lùi tối đa 30 ngày nên lịch sử dài chỉ tích luỹ được bằng cách cào hằng tuần — bỏ một tuần là đứt đoạn vĩnh viễn, không cào bù được. - **Không có phễu hiển thị / CTR.** Đã dò cả 27 trang trong `PrivateLayout/Main`: không trang nào trả lượt hiện ra, lượt bấm hay CTR. Nên **không so được khả năng thu hút khách** giữa hai kênh — chỉ so được kết quả bán. - `GetTopCompetitors` trả comp set rỗng cho mọi căn (`isCompetitorsTracked: false`) — chưa rõ phải bật ở đâu. - Chưa dò **Calendar** (occupancy · RevPAR) và **Campaigns / Growth**. #### `agoda_ranking` — thứ hạng trên trang tìm kiếm CÔNG KHAI (thêm 26/09/2026) Tên bảng do user đặt (không theo tiền tố `fact_`). Nguồn: `node scripts/ota-agoda-search-rank.mjs --anon` (phát lại GraphQL `citySearch` của agoda.com) + trạng thái mở bán từ `ari/Calendar/GetAvailability` + sức chứa từ `ChildRate/getroombypropertyId`. **Chỉ thêm, không sửa.** Chi tiết lượt đầu: [`../audits/2026-09-26-agoda-search-rank.md`](https://harthliving-ota.pages.dev/library/reports/2026-09-26-agoda-search-rank/). | Cột | Nghĩa | |---|---| | `search_id` · `pass_no` | mã tìm kiếm trong lượt đo (A–D) · lượt 1/2 — Agoda xếp lại mỗi trang nên phải đo ≥2 lượt | | `city_id` · `checkin` · `checkout` · `nights` · `adults` · `rooms` | bộ lọc khách nhập | | `sort` · `personalization` | `best_match` · `anon` = đã xoá searchHistory (vẫn đăng nhập) | | `total_filtered` · `rows_scanned` · `unique_ids` | tổng Agoda báo · số dòng đã duyệt · số id khác nhau (`rows_scanned > unique_ids` = trùng do xếp lại) | | `rank` · `page_approx` · `pct_from_top` | vị trí đầu tiên gặp · trang (~49 dòng/trang) · % tính từ đầu — **có thể >100 %** vì dòng trùng | | `status` | `ranked` · `not_found` (mở bán, đủ sức chứa mà không thấy) · `not_open` (lịch không mở đủ các đêm) · `over_capacity` (khách > sức chứa) · `grouped_under_84463577` (404 không có dòng riêng, hiện chung nhóm với 502) | | `agoda_bookings_confirmed` | số đơn xác nhận trong `postbook/Booking/list` lúc đo (305: danh sách chỉ trả 1 dù có giao dịch từ 03/2025) | | `note` | ghi chú dòng | ⚠️ Dòng `alacarte502` có `status = ranked` là **thứ hạng của cả nhóm 502 + 404** (*An Hai Bac Houses*), không riêng căn 502. --- # Lớp dữ liệu HIỆU SUẤT theo thời gian — schema + luật ghi URL: 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. | --- # Luật truy cập OTA và PriceLabs — đọc tự do, ghi phải xin phép URL: https://harthliving-ota.pages.dev/library/tech/ota-readonly-rule/ [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/) # Luật truy cập OTA và PriceLabs — đọc tự do, ghi phải xin phép ## Luật truy cập OTA và PriceLabs — đọc tự do, ghi phải xin phép Nguồn: `10-rules/ota-readonly-rule.md` ### Luật truy cập — Booking.com · Airbnb · Agoda · PriceLabs **User chốt 03/09/2026**, sửa lại cùng ngày sau khi user chỉ ra bản đầu quá chặt. Áp cho MỌI agent trong workspace (Claude Code, Codex, Cline, Antigravity) và MỌI công cụ điều khiển trình duyệt. #### 0. Nguyên tắc gốc Agent truy cập bốn hệ thống này **để đọc và hiểu thông tin**. **Không tự ý chỉnh sửa, không áp dụng, không kích hoạt bất cứ thay đổi nào.** Mọi việc chỉnh sửa **chỉ làm khi user ra lệnh trực tiếp cho đúng việc đó**. ##### Ranh giới đúng là ĐỌC ↔ GHI, không phải BẤM ↔ KHÔNG BẤM Bản luật đầu tiên cấm thẳng mọi cú `click`. Sai ở chỗ: nó rộng hơn chính mục đích của mình. Bấm sang trang 2 của một bảng không đổi giá, không đổi lịch, không gửi gì cho ai — nó chỉ đổi cái agent đang nhìn. Cấm nó không bảo vệ được gì, chỉ làm mất dữ liệu. **Phép thử một câu, dùng trước mọi thao tác:** *Sau thao tác này, có thứ gì trên tài khoản khác đi mà **khách, nền tảng, hoặc user** nhìn thấy được không?* - **Không** → đọc hoặc điều khiển hiển thị. Xem tầng A và B. - **Có** → ghi. Cấm, trừ khi user vừa ra lệnh đúng việc đó. Xem tầng C. - **Không chắc** → coi như **CÓ**. Dừng và hỏi. Gọn lại thành một câu để nhớ: **bấm để xem thì tự làm, save hay apply thì phải xin.** #### 1. Bốn hệ thống bị ràng buộc | # | Hệ thống | Miền | |---|---|---| | 1 | **Booking.com** — Extranet / Group homepage | `admin.booking.com`, `account.booking.com`, `partner.booking.com` | | 2 | **Airbnb** — Host / Hosting dashboard | `airbnb.com/hosting/*`, `airbnb.com/multicalendar`, `airbnb.com/users/*` | | 3 | **Agoda** — Partner Portal / YCS | `portal.agoda.com`, `ycs.agoda.com`, `partnerhub.agoda.com` | | 4 | **PriceLabs** — Dynamic Pricing | `app.pricelabs.co`, `pricelabs.co` | Bốn hệ thống này điều khiển **tiền thật và phòng thật**. PriceLabs còn đẩy giá xuống cả ba kênh cùng lúc, nên một thao tác sai ở đó lan ra toàn danh mục. #### 2. Thang ưu tiên — thử hết bậc trên rồi mới xuống bậc dưới Bài học 03/09: 26 dòng bảng giá PriceLabs **không bị luật chặn**, mà bị agent thiếu ý tưởng. Có đường lấy dữ liệu không cần bấm mà agent không nghĩ ra. | Bậc | Cách lấy | Ghi chú | |---|---|---| | **1** | **Đọc response API đã tải** — `list_network_requests` rồi `get_network_request` | Rẻ nhất, đầy đủ nhất. Endpoint dạng `/api/...` thường trả trọn bộ dữ liệu, không phân trang như giao diện | | **2** | **Tải lại trang rồi đọc lại network** | Dùng khi DevTools bắt đầu ghi sau lúc trang đã tải nên không bắt được request gốc. Reload là điều hướng tới URL xem — hợp lệ | | **3** | **`evaluate_script` chỉ-đọc trên DOM** | Khi dữ liệu đã render sẵn | | **4** | **Điều khiển hiển thị** (tầng B) | Chỉ khi ba bậc trên không ra | | **5** | **Dừng, báo user** | Khi cả bốn bậc đều không được | #### 3. Tầng A — được làm tự do - Liệt kê tab, chọn tab, mở tab mới tới một URL **xem**, tải lại trang, quay lại / tiến tới. - Chụp màn hình, chụp snapshot a11y. - `evaluate_script` **chỉ để đọc**: `textContent`, `innerText`, `getAttribute`, đọc bảng, đếm phần tử, dò biến toàn cục. - Đọc console log; liệt kê và đọc nội dung network request đã xảy ra. - Cuộn trang, đổi kích thước cửa sổ, phóng to thu nhỏ. #### 4. Tầng B — BẤM ĐỂ XEM, được phép **User chốt 03/09/2026:** *"bạn có thể tự bấm chỗ cần xem, miễn là không save hay áp dụng thay đổi gì."* Agent **được tự bấm** vào bất cứ thứ gì mà mục đích là **mở ra để nhìn**, không cần xin phép từng lần. ##### Được bấm — mọi thao tác nhằm xem - **Phân trang** · **số dòng mỗi trang** · **sắp xếp cột** · **bộ lọc hiển thị** · **bật/tắt cột**. - **Chuyển tab**, **bung/thu** hàng và khối chi tiết. - **Chuyển tháng / đổi dải ngày** trên lịch. - **Mở một listing, một property, một đơn đặt phòng để xem chi tiết.** - **Mở panel, popover, drawer, modal chỉ để hiển thị thông tin.** - **Mở trang cài đặt để ĐỌC giá trị đang đặt** — phí, chính sách, cấu hình listing. - **Đóng modal, đóng banner, quay lại trang trước.** ##### Ba rào chắn vẫn bắt buộc 1. **Đọc nhãn phần tử trước khi bấm.** Lấy `innerText` / `aria-label` của đúng phần tử sắp bấm. **Không bao giờ bấm theo toạ độ**, không bấm theo thứ tự phần tử. Nhãn khớp danh sách cấm ở tầng C → dừng. 2. **Vào rồi thì chỉ đọc.** Mở một trang cài đặt để xem giá trị là hợp lệ; chạm vào ô nhập, kéo thanh trượt, gạt công tắc trong đó thì không — kể cả khi chưa bấm Save. Nhiều giao diện tự lưu khi rời ô. 3. **Thử bậc 1–3 của thang ưu tiên trước.** Đọc API vẫn rẻ và đầy đủ hơn bấm qua từng trang. Bấm là đường sau cùng, không phải đường đầu tiên. ##### Điều phải khai với user Vài thao tác xem **có ghi thiết lập hiển thị** — ví dụ đổi số dòng/trang ghi cookie `manage_listings_page_size` của PriceLabs. Đó là thiết lập giao diện của chính user, không phải dữ liệu kinh doanh, nên vẫn thuộc tầng B. Nhưng **phải nói ra**, không lờ đi như thể không ghi gì. ##### Cạm bẫy: trang xem chứa nút ghi Vào xem thì được, **chạm vào thứ gì bên trong thì không**. `Review Prices` của PriceLabs mở ra lịch giá — đọc thoải mái, nhưng bên trong có nút lưu và duyệt giá. #### 5. Tầng C — cấm tuyệt đối Cấm không cần hỏi, kể cả khi trông có vẻ vô hại: - **Điền form** (`fill`, `fill_form`), **tải file lên** (`upload_file`), **gõ phím vào ô nhập** (`press_key` vào input). - **Kéo thả** (`drag`) — kéo trên lịch là đổi ngày hoặc đổi giá. - **Bấm nút xác nhận trong hộp thoại** (`handle_dialog` với `accept`). - **`evaluate_script` có tác dụng phụ:** gán DOM, `el.click()`, `form.submit()`, `fetch`/`XHR` phương thức POST · PUT · PATCH · DELETE, ghi `localStorage`, gọi hàm nội bộ của trang. - **Điều hướng tới URL mang hành động** — chứa `action=`, `save`, `apply`, `confirm`, `delete`, `bulk`, `sync`, `publish`, `send`, `approve`. - **Trả lời tin nhắn khách** ở bất kỳ hộp thư nào của bốn kênh. - **Đăng nhập, đăng xuất, đổi tài khoản, đổi mật khẩu, đổi cài đặt thông báo.** Gặp trang đăng nhập thì **dừng và báo user**, không tự điền. ##### Nhãn nút cấm chạm — dừng ngay khi thấy `Save` · `Apply` · `Update` · `Publish` · `Send` · `Confirm` · `Submit` · `Approve` · `Delete` · `Remove` · `Bulk edit` · `Sync now` · `Sync Price` · `Update My Prices Now` · `Add/Re-import Listings` · `Open` / `Close` phòng · `List a property` · `Accept` · `Decline` · `Cancel booking` · `Mark as no-show` #### 6. Vùng nguy hiểm theo từng nền tảng | Nền tảng | Chỗ dễ bấm nhầm nhất | |---|---| | **Booking Extranet** | Nút **Bulk edit** nằm ngay góc phải khối lịch, sát vùng bảng. Hàng `Room status` bấm được để mở/đóng phòng. Ô giá trong lịch là input, chạm vào là sửa được | | **Airbnb** | Banner **"We're simplifying service fees"** dẫn vào luồng có thể **xác nhận đổi phí** — chỉ đọc chữ, không bấm. Multicalendar sửa giá ngay tại ô. Toggle `Listed`/`Unlisted` gỡ listing khỏi sàn | | **Agoda Partner Portal** | Nút **List a property**. Tab `Reservations` có hành động với đơn thật | | **PriceLabs** | **Nguy hiểm nhất** vì đẩy giá xuống cả ba kênh. `Sync Price` ở mỗi hàng, banner `Update My Prices Now`, `Add/Re-import Listings`. `Review Prices` mở trang xem nhưng bên trong có nút lưu | #### 7. Khi thấy việc cần sửa Không sửa. Làm đủ ba bước: 1. **Ghi lại** vào `vault/18-ota-listings/issues.md` — mã `L-nn`, mức ưu tiên, sửa ở đâu. 2. **Báo user** kèm: thấy gì, ở đâu (kênh + ID listing + ngày), hậu quả nếu để nguyên, và **các bấm cụ thể** cần làm. 3. **Đợi lệnh.** User nói làm cái gì thì làm đúng cái đó, không làm kèm thứ khác. #### 8. Phạm vi của một lệnh cho phép Khi user ra lệnh sửa, quyền đó là **một lần, cho đúng việc đó, trên đúng listing đó**. - Không suy ra quyền cho listing khác, ngày khác, kênh khác. - Không suy ra quyền cho lần sau. - *"Mở giá 17/09–01/10 cho căn 502 trên Booking"* **không** cho phép mở giá căn 305, cũng **không** cho phép mở tháng 10. - Việc khó lùi (đóng listing, xoá property, gửi tin cho khách, apply giá hàng loạt) phải **xác nhận lại một lần nữa ngay trước khi bấm**, kể cả khi user đã ra lệnh. - **Báo cáo lại sau khi làm:** đã bấm gì, kết quả ra sao, có gì ngoài dự kiến không. #### 9. Bảo mật và phạm vi trình duyệt - Chrome DevTools MCP bám **profile Chrome mặc định** và thấy **mọi cửa sổ** của profile đó — gồm cả ngân hàng, email, extranet đang mở. User đóng tab nhạy cảm trước khi cho agent chạy tự do. - Agent chỉ thao tác trên tab thuộc bốn miền ở mục 1. - **Không ghi thông tin xác thực vào vault.** URL extranet chứa `ses=...`; response header của API chứa cookie phiên (`remember_user_token`, `cf_clearance`…). Lưu `hotel_id`, `listing_id` — **bỏ hết token, cookie, session**. - **Không ghép session token vào URL tự chế.** Token hết hạn thì trang bật ra màn đăng nhập; agent không được đăng nhập, nên chỉ tạo ra một tab chết. Nhờ user tự mở trang cần xem. #### 10. Nhật ký sửa luật | Ngày | Sửa gì | Vì sao | |---|---|---| | 03/09/2026 | Bản đầu: cấm thẳng mọi `click` | User yêu cầu chỉ-đọc trên bốn hệ thống | | 03/09/2026 | Tách ba tầng A/B/C; thêm thang ưu tiên 5 bậc; thêm vùng nguy hiểm từng nền tảng | User chỉ ra bản đầu tự mâu thuẫn: tiêu đề nói "cấm thứ làm đổi trạng thái" nhưng lại cấm cả cú bấm không đổi gì. Hệ quả thật: mất 26/36 dòng bảng giá PriceLabs, trong khi có đường API lấy được mà agent không nghĩ ra | | 03/09/2026 | Tầng B mở rộng từ danh sách trắng hẹp thành **bấm để xem nói chung** | User: *"bạn có thể tự bấm chỗ cần xem, miễn là không save hay áp dụng thay đổi gì."* Ranh giới chốt ở **save/apply**, không ở cú bấm | #### 11. Liên quan - `vault/18-ota-listings/README.md` — kho hồ sơ listing, luồng làm việc - `vault/10-rules/ota-messaging-rules.md` — luật nội dung tin nhắn gửi khách (khác việc, cùng ba kênh) --- Phần 34/35 · tiếp: https://harthliving-ota.pages.dev/ai/full/part-35.txt