From a17d3fb7c220b4155c4a926bab8ec8ec4704a4ae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:34:02 +0700 Subject: [PATCH 01/21] chore: apply hotel navigation and workflow race fix --- .github/workflows/patch-hotel-nav-race.yml | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/patch-hotel-nav-race.yml diff --git a/.github/workflows/patch-hotel-nav-race.yml b/.github/workflows/patch-hotel-nav-race.yml new file mode 100644 index 0000000..fae0a55 --- /dev/null +++ b/.github/workflows/patch-hotel-nav-race.yml @@ -0,0 +1,96 @@ +name: Apply hotel nav and workflow race fix + +on: + push: + branches: + - fix/hotel-nav-and-workflow-race + paths: + - '.github/workflows/patch-hotel-nav-race.yml' + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/hotel-nav-and-workflow-race + fetch-depth: 0 + + - name: Patch navigation and shared concurrency + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text() + if old not in text: + raise SystemExit(f'Expected text not found in {path}: {old[:120]!r}') + p.write_text(text.replace(old, new, 1)) + + # Dashboard: sidebar, header action, hero action and mobile nav. + replace_once( + 'index.html', + ' Giá vé\n ', + ' Giá vé\n Giá phòng\n ' + ) + replace_once( + 'index.html', + ' Xem giá vé', + ' Giá phòng\n Xem giá vé' + ) + replace_once( + 'index.html', + '
Xem lịch trìnhTheo dõi giá vé
', + '
Xem lịch trìnhTheo dõi giá véTheo dõi giá phòng
' + ) + replace_once( + 'index.html', + ' Chuẩn bị\n', + ' Giá phòng\n' + ) + + # Flight tracker: expose Hotel Prices alongside Flight Prices. + replace_once( + 'flights.html', + ' Giá vé\n ', + ' Giá vé\n Giá phòng\n ' + ) + replace_once( + 'flights.html', + '
Giá véHành trình Trung Quốc 2026
Về tổng quan', + '
Giá véHành trình Trung Quốc 2026
Giá phòngVề tổng quan' + ) + replace_once( + 'flights.html', + 'Chuẩn bị', + 'Giá phòng' + ) + + # Flight and hotel snapshot writers must serialize against each other. + replace_once( + '.github/workflows/update-flight-prices.yml', + ' group: live-flight-prices-${{ github.ref }}', + ' group: price-tracker-writes-${{ github.ref }}' + ) + replace_once( + '.github/workflows/update-hotel-prices.yml', + ' group: live-hotel-prices-${{ github.ref }}', + ' group: price-tracker-writes-${{ github.ref }}' + ) + PY + + rm .github/workflows/patch-hotel-nav-race.yml + + - name: Commit patch + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add index.html flights.html .github/workflows/update-flight-prices.yml .github/workflows/update-hotel-prices.yml .github/workflows/patch-hotel-nav-race.yml + git commit -m "fix: link hotel tracker and serialize price writers" + git push origin HEAD:fix/hotel-nav-and-workflow-race From 8e401266047ed856a62ad6587b07fcae713e3ae4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:35:20 +0700 Subject: [PATCH 02/21] fix: escape workflow expressions in patch runner --- .github/workflows/patch-hotel-nav-race.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/patch-hotel-nav-race.yml b/.github/workflows/patch-hotel-nav-race.yml index fae0a55..de6e9d2 100644 --- a/.github/workflows/patch-hotel-nav-race.yml +++ b/.github/workflows/patch-hotel-nav-race.yml @@ -72,15 +72,16 @@ jobs: ) # Flight and hotel snapshot writers must serialize against each other. + expr = '$' + '{{ github.ref }}' replace_once( '.github/workflows/update-flight-prices.yml', - ' group: live-flight-prices-${{ github.ref }}', - ' group: price-tracker-writes-${{ github.ref }}' + ' group: live-flight-prices-' + expr, + ' group: price-tracker-writes-' + expr ) replace_once( '.github/workflows/update-hotel-prices.yml', - ' group: live-hotel-prices-${{ github.ref }}', - ' group: price-tracker-writes-${{ github.ref }}' + ' group: live-hotel-prices-' + expr, + ' group: price-tracker-writes-' + expr ) PY From 78f9a5a24fc831ebbc4f0cb999b8e5d9d5234d12 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:36:15 +0700 Subject: [PATCH 03/21] fix: limit patch runner to navigation files --- .github/workflows/patch-hotel-nav-race.yml | 28 ++++------------------ 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/.github/workflows/patch-hotel-nav-race.yml b/.github/workflows/patch-hotel-nav-race.yml index de6e9d2..af1c441 100644 --- a/.github/workflows/patch-hotel-nav-race.yml +++ b/.github/workflows/patch-hotel-nav-race.yml @@ -1,4 +1,4 @@ -name: Apply hotel nav and workflow race fix +name: Apply hotel navigation fix on: push: @@ -19,7 +19,7 @@ jobs: ref: fix/hotel-nav-and-workflow-race fetch-depth: 0 - - name: Patch navigation and shared concurrency + - name: Patch hotel navigation shell: bash run: | python - <<'PY' @@ -32,7 +32,6 @@ jobs: raise SystemExit(f'Expected text not found in {path}: {old[:120]!r}') p.write_text(text.replace(old, new, 1)) - # Dashboard: sidebar, header action, hero action and mobile nav. replace_once( 'index.html', ' Giá vé\n ', @@ -53,8 +52,6 @@ jobs: ' Chuẩn bị\n', ' Giá phòng\n' ) - - # Flight tracker: expose Hotel Prices alongside Flight Prices. replace_once( 'flights.html', ' Giá vé\n ', @@ -70,28 +67,13 @@ jobs: 'Chuẩn bị', 'Giá phòng' ) - - # Flight and hotel snapshot writers must serialize against each other. - expr = '$' + '{{ github.ref }}' - replace_once( - '.github/workflows/update-flight-prices.yml', - ' group: live-flight-prices-' + expr, - ' group: price-tracker-writes-' + expr - ) - replace_once( - '.github/workflows/update-hotel-prices.yml', - ' group: live-hotel-prices-' + expr, - ' group: price-tracker-writes-' + expr - ) PY - rm .github/workflows/patch-hotel-nav-race.yml - - - name: Commit patch + - name: Commit navigation patch shell: bash run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add index.html flights.html .github/workflows/update-flight-prices.yml .github/workflows/update-hotel-prices.yml .github/workflows/patch-hotel-nav-race.yml - git commit -m "fix: link hotel tracker and serialize price writers" + git add index.html flights.html + git commit -m "fix: expose hotel price tracker navigation" git push origin HEAD:fix/hotel-nav-and-workflow-race From 8819efec8b307775c140ebf82e4b75f5ca233378 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:36:24 +0000 Subject: [PATCH 04/21] fix: expose hotel price tracker navigation --- flights.html | 5 +++-- index.html | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/flights.html b/flights.html index 934b32f..30a2cd1 100644 --- a/flights.html +++ b/flights.html @@ -18,6 +18,7 @@ Tổng quan Lịch trình Giá vé + Giá phòng Ngân sách Chuẩn bị Ghi chú @@ -25,7 +26,7 @@
-
Giá véHành trình Trung Quốc 2026
+
Giá véHành trình Trung Quốc 2026
TravelLog
@@ -59,7 +60,7 @@
- + diff --git a/index.html b/index.html index 77637ea..4db3ea6 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ Tổng quan Lịch trình Giá vé + Giá phòng Ngân sách Chuẩn bị Ghi chú @@ -39,6 +40,7 @@ + Giá phòng Xem giá vé
@@ -61,7 +63,7 @@

Trung Quốc · 19–26/10/2026

TP.HCM → Thượng Hải → Bắc Kinh → TP.HCM
8 ngày 7 đêm · 6 người lớn + 1 em bé
- + @@ -127,7 +129,7 @@

Trung Quốc · 19–26/10/2026

Tổng quan Lịch trình Giá vé - Chuẩn bị + Giá phòng Giá vé\n ', - ' Giá vé\n Giá phòng\n ' - ) - replace_once( - 'index.html', - ' Xem giá vé', - ' Giá phòng\n Xem giá vé' - ) - replace_once( - 'index.html', - '', - '' - ) - replace_once( - 'index.html', - ' Chuẩn bị\n', - ' Giá phòng\n' - ) - replace_once( - 'flights.html', - ' Giá vé\n ', - ' Giá vé\n Giá phòng\n ' - ) - replace_once( - 'flights.html', - '
Giá véHành trình Trung Quốc 2026
Về tổng quan', - '
Giá véHành trình Trung Quốc 2026
Giá phòngVề tổng quan' - ) - replace_once( - 'flights.html', - 'Chuẩn bị', - 'Giá phòng' - ) - PY - - - name: Commit navigation patch - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add index.html flights.html - git commit -m "fix: expose hotel price tracker navigation" - git push origin HEAD:fix/hotel-nav-and-workflow-race From 3a93f1c5051160bcb1e19a1a0568c17dc19c49fe Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:45:16 +0700 Subject: [PATCH 08/21] fix: keep hotel results when rates are unavailable --- scripts/fetch-hotels.mjs | 139 ++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 32 deletions(-) diff --git a/scripts/fetch-hotels.mjs b/scripts/fetch-hotels.mjs index 780d9d2..b5b9bd9 100644 --- a/scripts/fetch-hotels.mjs +++ b/scripts/fetch-hotels.mjs @@ -13,7 +13,7 @@ const HISTORY = path.resolve('data/hotel-history.json'); const TRIP_KEY = 'shanghai-jinling-2026-10-19__2026-10-20'; const SEARCH = { - q: 'Jinling East Road Shanghai hotels', + q: 'Hotels near Dashijie Station Shanghai', checkIn: '2026-10-19', checkOut: '2026-10-20', adults: 2, @@ -26,10 +26,24 @@ const SEARCH = { ['jianguo', 'jinling east road'], ['campanile', 'bund'], ['crystal', 'jinling east road'], + ['crystal orange', 'bund'], ['seventh heaven'], ['magnificent international'], ['autoongo', 'bund'], - ['atour', 'dashijie'] + ['atour', 'dashijie'], + ['ji hotel', 'jinling east road'] + ], + fallbackHotels: [ + 'Jianguo Puyin Hotel Shanghai Bund Jinling East Road', + 'Home Inn Plus Shanghai The Bund Jinling East Road', + 'Campanile Shanghai Bund Hotel', + 'JI Hotel Shanghai The Bund Jinling East Road', + 'Crystal Shanghai Bund Jinling East Road Hotel', + 'Crystal Orange Shanghai The Bund Yu Garden Hotel', + 'Seventh Heaven Hotel', + 'Magnificent International Hotel', + 'Shanghai Autoongo Bund Hotel', + 'Atour Light Hotel Shanghai Bund Dashijie Metro Station' ] }; @@ -48,10 +62,16 @@ function isShortlisted(name) { } function finiteNumber(value) { + if (value === null || value === undefined || value === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } +function priceAmount(property) { + const amount = finiteNumber(property?.rate_per_night?.amount); + return amount === null ? Number.POSITIVE_INFINITY : amount; +} + async function readJson(file, fallback) { try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return fallback; } @@ -98,7 +118,8 @@ function compactSources(prices) { return rate ? { source: item?.source || 'Unknown source', logo: item?.logo || null, - rate_per_night: rate + link: item?.link || null, + rate_per_night: { ...rate, currency: SEARCH.currency } } : null; }) .filter(Boolean) @@ -111,11 +132,17 @@ function propertyId(raw) { } function compactProperty(raw) { - const rate = compactPrice(raw?.rate_per_night); - if (!rate) return null; + let rate = compactPrice(raw?.rate_per_night); const totalRate = compactPrice(raw?.total_rate); - const image = Array.isArray(raw?.images) ? raw.images.find(item => item?.thumbnail || item?.original_image) : null; + if (!rate && totalRate) rate = totalRate; + + const image = Array.isArray(raw?.images) + ? raw.images.find(item => item?.thumbnail || item?.original_image) + : null; const id = propertyId(raw); + const priceSources = compactSources(raw?.prices); + if (!rate && priceSources[0]?.rate_per_night) rate = priceSources[0].rate_per_night; + const amount = finiteNumber(rate?.amount); return { id, @@ -124,29 +151,64 @@ function compactProperty(raw) { description: raw?.description || null, shortlisted: isShortlisted(raw?.name), website_url: raw?.link || null, - image_url: image?.thumbnail || image?.original_image || null, + image_url: image?.thumbnail || image?.original_image || raw?.thumbnail || null, coordinates: raw?.gps_coordinates || null, check_in_time: raw?.check_in_time || null, check_out_time: raw?.check_out_time || null, hotel_class: raw?.hotel_class || null, - stars: finiteNumber(raw?.extracted_hotel_class), + stars: finiteNumber(raw?.extracted_hotel_class ?? raw?.hotel_class), overall_rating: finiteNumber(raw?.overall_rating), reviews: finiteNumber(raw?.reviews), location_rating: finiteNumber(raw?.location_rating), amenities: (Array.isArray(raw?.amenities) ? raw.amenities : []).slice(0, 10), - rate_per_night: { - ...rate, - currency: SEARCH.currency - }, + rate_per_night: amount === null ? null : { ...rate, amount, currency: SEARCH.currency }, total_rate: totalRate ? { ...totalRate, currency: SEARCH.currency } : null, - estimated_three_rooms_amount: rate.amount * SEARCH.groupRoomsEstimate, - price_sources: compactSources(raw?.prices) + estimated_three_rooms_amount: amount === null ? null : amount * SEARCH.groupRoomsEstimate, + price_sources: priceSources, + price_status: amount === null ? 'unavailable' : 'priced', + catalogue_fallback: false + }; +} + +function fallbackProperty(name) { + return { + id: `fallback:${normalize(name)}`, + property_token: null, + name, + description: 'Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.', + shortlisted: true, + website_url: null, + image_url: null, + coordinates: null, + check_in_time: null, + check_out_time: null, + hotel_class: null, + stars: null, + overall_rating: null, + reviews: null, + location_rating: null, + amenities: [], + rate_per_night: null, + total_rate: null, + estimated_three_rooms_amount: null, + price_sources: [], + price_status: 'unavailable', + catalogue_fallback: true }; } +function namesLikelyMatch(a, b) { + const left = normalize(a); + const right = normalize(b); + return left === right || left.includes(right) || right.includes(left); +} + function compareRecommended(a, b) { if (a.shortlisted !== b.shortlisted) return a.shortlisted ? -1 : 1; - return a.rate_per_night.amount - b.rate_per_night.amount; + const priceDiff = priceAmount(a) - priceAmount(b); + if (Number.isFinite(priceDiff) && priceDiff !== 0) return priceDiff; + if (a.catalogue_fallback !== b.catalogue_fallback) return a.catalogue_fallback ? 1 : -1; + return a.name.localeCompare(b.name, 'en'); } const previous = await readJson(OUT, null); @@ -171,46 +233,56 @@ const rawProperties = [ ...(Array.isArray(body?.non_matching_properties) ? body.non_matching_properties : []) ]; +console.log(`SerpApi returned ${rawProperties.length} hotel properties before price filtering.`); + const seen = new Set(); const properties = rawProperties .map(compactProperty) - .filter(Boolean) .filter(property => { const key = property.id || normalize(property.name); if (seen.has(key)) return false; seen.add(key); return true; - }) - .sort(compareRecommended) - .slice(0, 20); + }); + +for (const name of SEARCH.fallbackHotels) { + if (!properties.some(property => namesLikelyMatch(property.name, name))) { + properties.push(fallbackProperty(name)); + } +} +properties.sort(compareRecommended); +const limitedProperties = properties.slice(0, 30); const previousProperties = Array.isArray(previous?.properties) ? previous.properties : []; -for (const property of properties) { + +for (const property of limitedProperties) { + const currentAmount = finiteNumber(property?.rate_per_night?.amount); const old = previousProperties.find(item => item?.id === property.id || normalize(item?.name) === normalize(property.name) ); const previousAmount = finiteNumber(old?.rate_per_night?.amount); - if (previousAmount !== null) { + if (currentAmount !== null && previousAmount !== null) { property.previous_rate_per_night_amount = previousAmount; - property.price_delta = property.rate_per_night.amount - previousAmount; + property.price_delta = currentAmount - previousAmount; } else { - property.previous_rate_per_night_amount = null; + property.previous_rate_per_night_amount = previousAmount; property.price_delta = null; } } -const cheapest = [...properties].sort((a, b) => a.rate_per_night.amount - b.rate_per_night.amount)[0] || null; -const cheapestShortlisted = [...properties] +const pricedProperties = limitedProperties.filter(item => finiteNumber(item?.rate_per_night?.amount) !== null); +const cheapest = [...pricedProperties].sort((a, b) => priceAmount(a) - priceAmount(b))[0] || null; +const cheapestShortlisted = [...pricedProperties] .filter(item => item.shortlisted) - .sort((a, b) => a.rate_per_night.amount - b.rate_per_night.amount)[0] || null; + .sort((a, b) => priceAmount(a) - priceAmount(b))[0] || null; const result = { - status: properties.length ? 'ok' : 'no_results', + status: pricedProperties.length ? 'ok' : limitedProperties.length ? 'partial' : 'no_results', provider: 'SerpApi', source: 'Google Hotels', generated_at: generatedAt, live_mode: true, - disclaimer: 'Rates are Google Hotels snapshots for one room with 2 adults. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.', + disclaimer: 'Rates are Google Hotels snapshots for one room with 2 adults. Hotels may remain visible even when Google Hotels does not return a live rate. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.', search: { trip_key: TRIP_KEY, query: SEARCH.q, @@ -223,14 +295,17 @@ const result = { currency: SEARCH.currency, group_rooms_estimate: SEARCH.groupRoomsEstimate, searches_per_refresh: 1, - google_hotels_url: body?.search_metadata?.google_hotels_url || null + google_hotels_url: body?.search_metadata?.google_hotels_url || null, + raw_property_count: rawProperties.length, + displayed_property_count: limitedProperties.length, + priced_property_count: pricedProperties.length }, - properties, + properties: limitedProperties, cheapest_property_id: cheapest?.id || null, cheapest_shortlisted_property_id: cheapestShortlisted?.id || null }; -const newHistory = properties.map(property => ({ +const newHistory = pricedProperties.map(property => ({ checked_at: generatedAt, trip_key: TRIP_KEY, property_id: property.id, @@ -249,7 +324,7 @@ await fs.mkdir(path.dirname(OUT), { recursive: true }); await fs.writeFile(OUT, JSON.stringify(result, null, 2) + '\n'); await fs.writeFile(HISTORY, JSON.stringify(history, null, 2) + '\n'); -console.log(`Saved ${OUT} with ${properties.length} priced properties.`); +console.log(`Saved ${OUT} with ${limitedProperties.length} displayed properties; ${pricedProperties.length} currently have live prices.`); if (cheapest) { console.log(`Cheapest: ${cheapest.name} · ${cheapest.rate_per_night.amount} ${SEARCH.currency}/room/night`); } From 7e97a46aa58555661fc2ca43110fcf895ade2388 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:46:11 +0700 Subject: [PATCH 09/21] fix: render hotels without live prices --- hotel-booking.js | 352 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 351 insertions(+), 1 deletion(-) diff --git a/hotel-booking.js b/hotel-booking.js index 7a8a915..a6ceca3 100644 --- a/hotel-booking.js +++ b/hotel-booking.js @@ -1 +1,351 @@ -const DATA_URL='./data/hotels.json';const HISTORY_URL='./data/hotel-history.json';const TARGET_KEY='travel-hotel-target-price-v1';const HOTEL_KEY='travel-hotel-history-selected-v1';const $=id=>document.getElementById(id);function syncThemeUI(){const dark=document.documentElement.dataset.theme==='dark',icon=dark?'#i-sun':'#i-moon',label=dark?'Chuyển sang giao diện sáng':'Chuyển sang giao diện tối';document.querySelectorAll('[data-theme-toggle]').forEach(button=>{const use=button.querySelector('use');if(use)use.setAttribute('href',icon);button.setAttribute('aria-label',label);button.setAttribute('title',label)})}function setTheme(value){document.documentElement.dataset.theme=value;localStorage.setItem('travel-theme',value);syncThemeUI()}document.documentElement.dataset.theme=localStorage.getItem('travel-theme')==='dark'?'dark':'light';document.querySelectorAll('[data-theme-toggle]').forEach(button=>{button.addEventListener('click',()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark'))});syncThemeUI();const state={data:null,history:[],query:'',sort:'recommended',shortlistOnly:false,selectedHotelId:localStorage.getItem(HOTEL_KEY)||''};function formatVnd(value){const n=Number(value);if(!Number.isFinite(n))return'—';return new Intl.NumberFormat('vi-VN',{style:'currency',currency:'VND',maximumFractionDigits:0}).format(n).replace('₫','đ')}function formatNumber(value){const n=Number(value);return Number.isFinite(n)?new Intl.NumberFormat('vi-VN').format(n):'—'}function formatDateTime(value){if(!value)return'Chưa cập nhật';const date=new Date(value);if(Number.isNaN(date.getTime()))return value;return new Intl.DateTimeFormat('vi-VN',{dateStyle:'short',timeStyle:'short',timeZone:'Asia/Ho_Chi_Minh'}).format(date)}function relativeTime(value){if(!value)return'Chưa có';const then=new Date(value).getTime();if(!Number.isFinite(then))return'Chưa có';const minutes=Math.max(0,Math.round((Date.now()-then)/60000));if(minutes<60)return`${minutes} phút trước`;const hours=Math.round(minutes/60);if(hours<48)return`${hours} giờ trước`;return`${Math.round(hours/24)} ngày trước`}function escapeHtml(value=''){return String(value).replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]))}function propertyById(id){return state.data?.properties?.find(item=>item.id===id)||null}function priceDeltaText(delta){if(!Number.isFinite(Number(delta)))return{text:'Lần ghi nhận đầu',cls:'neutral'};const n=Number(delta);if(n===0)return{text:'Không đổi',cls:'neutral'};if(n<0)return{text:`Giảm ${formatVnd(Math.abs(n))}`,cls:'down'};return{text:`Tăng ${formatVnd(n)}`,cls:'up'}}function filteredProperties(){const list=[...(state.data?.properties||[])],q=state.query.trim().toLowerCase();const filtered=list.filter(item=>{if(state.shortlistOnly&&!item.shortlisted)return false;if(!q)return true;return[item.name,item.description,item.hotel_class,...(item.amenities||[])].filter(Boolean).join(' ').toLowerCase().includes(q)});filtered.sort((a,b)=>{if(state.sort==='price')return a.rate_per_night.amount-b.rate_per_night.amount;if(state.sort==='rating')return(b.overall_rating||0)-(a.overall_rating||0)||a.rate_per_night.amount-b.rate_per_night.amount;if(state.sort==='reviews')return(b.reviews||0)-(a.reviews||0)||a.rate_per_night.amount-b.rate_per_night.amount;if(a.shortlisted!==b.shortlisted)return a.shortlisted?-1:1;return a.rate_per_night.amount-b.rate_per_night.amount});return filtered}function hotelCard(item){const delta=priceDeltaText(item.price_delta);const sources=(item.price_sources||[]).slice(0,3).map(source=>`${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}`).join('');const rating=item.overall_rating?`★ ${item.overall_rating}${item.reviews?` · ${formatNumber(item.reviews)} đánh giá`:''}`:'Chưa có điểm Google';const website=item.website_url?`Website`:'';const image=item.image_url?``:'
🏨
';return`
${image}${item.shortlisted?'Đang theo dõi':''}

${escapeHtml(item.name)}

${rating}${item.hotel_class?`${escapeHtml(item.hotel_class)}`:''}
${formatVnd(item.rate_per_night?.amount)}/ phòng / đêm${delta.text}
${item.description?`

${escapeHtml(item.description)}

`:''}
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
${sources||'Google Hotels'}
${website}
`}function renderResults(){const list=filteredProperties();$('hotelResults').innerHTML=list.length?list.map(hotelCard).join(''):'
Không có khách sạn phù hợp với bộ lọc hiện tại.
';$('resultCount').textContent=`${list.length} khách sạn`;document.querySelectorAll('[data-history]').forEach(button=>{button.addEventListener('click',()=>{state.selectedHotelId=button.dataset.history;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistoryControls();renderHistory();$('historySection').scrollIntoView({behavior:'smooth',block:'start'})})})}function renderSummary(){const properties=state.data?.properties||[];const cheapest=[...properties].sort((a,b)=>a.rate_per_night.amount-b.rate_per_night.amount)[0]||null;const shortlist=properties.filter(item=>item.shortlisted);const cheapestShort=[...shortlist].sort((a,b)=>a.rate_per_night.amount-b.rate_per_night.amount)[0]||cheapest;$('cheapestPrice').textContent=cheapestShort?formatVnd(cheapestShort.rate_per_night.amount):'—';$('cheapestName').textContent=cheapestShort?cheapestShort.name:'Chưa có dữ liệu';$('trackedCount').textContent=String(shortlist.length||properties.length);$('trackedMeta').textContent=shortlist.length?'khách sạn trong shortlist':'khách sạn có giá';$('updatedAgo').textContent=relativeTime(state.data?.generated_at);$('updatedAt').textContent=formatDateTime(state.data?.generated_at);$('groupEstimate').textContent=cheapestShort?formatVnd(cheapestShort.estimated_three_rooms_amount):'—';const target=Number(localStorage.getItem(TARGET_KEY)),alert=$('targetAlert');if(cheapestShort&&Number.isFinite(target)&&target>0){if(cheapestShort.rate_per_night.amount<=target){alert.className='alert success';alert.textContent=`Đã đạt mục tiêu: ${formatVnd(cheapestShort.rate_per_night.amount)} ≤ ${formatVnd(target)}.`}else{alert.className='alert';alert.textContent=`Còn cao hơn mục tiêu ${formatVnd(cheapestShort.rate_per_night.amount-target)}.`}}else{alert.className='alert';alert.textContent='Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.'}}function historyRowsFor(item){if(!item)return[];return state.history.filter(row=>row.trip_key===state.data?.search?.trip_key).filter(row=>row.property_id===item.id||row.name===item.name).filter(row=>Number.isFinite(Number(row.rate_per_night_amount))).slice(-90)}function renderHistoryControls(){const properties=state.data?.properties||[];if(!state.selectedHotelId||!propertyById(state.selectedHotelId)){state.selectedHotelId=state.data?.cheapest_shortlisted_property_id||state.data?.cheapest_property_id||properties[0]?.id||''}$('historyHotel').innerHTML=properties.map(item=>``).join('')}function renderHistory(){const item=propertyById(state.selectedHotelId),rows=historyRowsFor(item),svg=$('hotelPriceChart');if(!item||!rows.length){svg.innerHTML='';$('historyMeta').textContent='Chưa có dữ liệu lịch sử cho khách sạn này.';$('observedRange').textContent='—';return}const values=rows.map(row=>Number(row.rate_per_night_amount)),min=Math.min(...values),max=Math.max(...values),width=760,height=150,padX=18,padY=18,span=Math.max(1,max-min);const points=rows.map((row,index)=>{const x=rows.length===1?width/2:padX+index*((width-padX*2)/(rows.length-1));const y=height-padY-((Number(row.rate_per_night_amount)-min)/span)*(height-padY*2);return{x,y,row}});const polyline=points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');const circles=points.map(point=>`${formatDateTime(point.row.checked_at)} · ${formatVnd(point.row.rate_per_night_amount)}`).join('');svg.innerHTML=`${circles}`;$('historyMeta').textContent=`${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`;$('observedRange').textContent=min===max?formatVnd(min):`${formatVnd(min)} – ${formatVnd(max)}`}async function load(){try{const[dataResponse,historyResponse]=await Promise.all([fetch(`${DATA_URL}?v=${Date.now()}`,{cache:'no-store'}),fetch(`${HISTORY_URL}?v=${Date.now()}`,{cache:'no-store'})]);if(!dataResponse.ok)throw new Error(`hotels.json HTTP ${dataResponse.status}`);state.data=await dataResponse.json();state.history=historyResponse.ok?await historyResponse.json():[];$('stayDates').textContent=`${state.data.search?.check_in_date||'19/10/2026'} → ${state.data.search?.check_out_date||'20/10/2026'}`;$('occupancyNote').textContent=`${state.data.search?.adults_per_room||2} người lớn / phòng · ${state.data.search?.group_rooms_estimate||3} phòng ước tính`;$('sourceLabel').textContent=state.data.source||'Google Hotels';renderSummary();renderResults();renderHistoryControls();renderHistory()}catch(error){console.error(error);$('hotelResults').innerHTML=`
Không tải được dữ liệu khách sạn. Hãy chạy workflow cập nhật giá rồi thử lại.
${escapeHtml(error.message)}
`;$('updatedAgo').textContent='Lỗi dữ liệu'}}$('hotelSearch').addEventListener('input',event=>{state.query=event.target.value;renderResults()});$('sortHotels').addEventListener('change',event=>{state.sort=event.target.value;renderResults()});$('shortlistOnly').addEventListener('change',event=>{state.shortlistOnly=event.target.checked;renderResults()});$('historyHotel').addEventListener('change',event=>{state.selectedHotelId=event.target.value;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistory()});$('saveTarget').addEventListener('click',()=>{const value=Number(String($('targetPrice').value).replace(/[^\d]/g,''));if(!Number.isFinite(value)||value<=0){$('targetAlert').className='alert danger';$('targetAlert').textContent='Nhập mục tiêu giá hợp lệ.';return}localStorage.setItem(TARGET_KEY,String(value));$('targetPrice').value=formatNumber(value);renderSummary()});const savedTarget=Number(localStorage.getItem(TARGET_KEY));if(Number.isFinite(savedTarget)&&savedTarget>0)$('targetPrice').value=formatNumber(savedTarget);load(); \ No newline at end of file +const DATA_URL = './data/hotels.json'; +const HISTORY_URL = './data/hotel-history.json'; +const TARGET_KEY = 'travel-hotel-target-price-v1'; +const HOTEL_KEY = 'travel-hotel-history-selected-v1'; +const $ = id => document.getElementById(id); + +function syncThemeUI() { + const dark = document.documentElement.dataset.theme === 'dark'; + const icon = dark ? '#i-sun' : '#i-moon'; + const label = dark ? 'Chuyển sang giao diện sáng' : 'Chuyển sang giao diện tối'; + document.querySelectorAll('[data-theme-toggle]').forEach(button => { + const use = button.querySelector('use'); + if (use) use.setAttribute('href', icon); + button.setAttribute('aria-label', label); + button.setAttribute('title', label); + }); +} + +function setTheme(value) { + document.documentElement.dataset.theme = value; + localStorage.setItem('travel-theme', value); + syncThemeUI(); +} + +document.documentElement.dataset.theme = localStorage.getItem('travel-theme') === 'dark' ? 'dark' : 'light'; +document.querySelectorAll('[data-theme-toggle]').forEach(button => { + button.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')); +}); +syncThemeUI(); + +const state = { + data: null, + history: [], + query: '', + sort: 'recommended', + shortlistOnly: false, + selectedHotelId: localStorage.getItem(HOTEL_KEY) || '' +}; + +function finiteNumber(value) { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function hotelPrice(item) { + return finiteNumber(item?.rate_per_night?.amount); +} + +function formatVnd(value) { + const n = finiteNumber(value); + if (n === null) return '—'; + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0 + }).format(n).replace('₫', 'đ'); +} + +function formatNumber(value) { + const n = finiteNumber(value); + return n === null ? '—' : new Intl.NumberFormat('vi-VN').format(n); +} + +function formatDateTime(value) { + if (!value) return 'Chưa cập nhật'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('vi-VN', { + dateStyle: 'short', + timeStyle: 'short', + timeZone: 'Asia/Ho_Chi_Minh' + }).format(date); +} + +function relativeTime(value) { + if (!value) return 'Chưa có'; + const then = new Date(value).getTime(); + if (!Number.isFinite(then)) return 'Chưa có'; + const minutes = Math.max(0, Math.round((Date.now() - then) / 60000)); + if (minutes < 60) return `${minutes} phút trước`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours} giờ trước`; + return `${Math.round(hours / 24)} ngày trước`; +} + +function escapeHtml(value = '') { + return String(value).replace(/[&<>"']/g, ch => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[ch])); +} + +function propertyById(id) { + return state.data?.properties?.find(item => item.id === id) || null; +} + +function priceDeltaText(delta) { + const n = finiteNumber(delta); + if (n === null) return { text: 'Chưa có lịch sử giá', cls: 'neutral' }; + if (n === 0) return { text: 'Không đổi', cls: 'neutral' }; + if (n < 0) return { text: `Giảm ${formatVnd(Math.abs(n))}`, cls: 'down' }; + return { text: `Tăng ${formatVnd(n)}`, cls: 'up' }; +} + +function comparePrice(a, b) { + const left = hotelPrice(a); + const right = hotelPrice(b); + if (left === null && right === null) return 0; + if (left === null) return 1; + if (right === null) return -1; + return left - right; +} + +function filteredProperties() { + const list = [...(state.data?.properties || [])]; + const q = state.query.trim().toLowerCase(); + const filtered = list.filter(item => { + if (state.shortlistOnly && !item.shortlisted) return false; + if (!q) return true; + return [item.name, item.description, item.hotel_class, ...(item.amenities || [])] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(q); + }); + + filtered.sort((a, b) => { + if (state.sort === 'price') return comparePrice(a, b) || a.name.localeCompare(b.name); + if (state.sort === 'rating') return (b.overall_rating || 0) - (a.overall_rating || 0) || comparePrice(a, b); + if (state.sort === 'reviews') return (b.reviews || 0) - (a.reviews || 0) || comparePrice(a, b); + if (a.shortlisted !== b.shortlisted) return a.shortlisted ? -1 : 1; + return comparePrice(a, b) || (a.catalogue_fallback === b.catalogue_fallback ? 0 : a.catalogue_fallback ? 1 : -1) || a.name.localeCompare(b.name); + }); + return filtered; +} + +function hotelCard(item) { + const amount = hotelPrice(item); + const hasPrice = amount !== null; + const delta = hasPrice ? priceDeltaText(item.price_delta) : { text: 'Google Hotels chưa trả giá', cls: 'neutral' }; + const sources = (item.price_sources || []).slice(0, 3).map(source => + `${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}` + ).join(''); + const rating = item.overall_rating + ? `★ ${item.overall_rating}${item.reviews ? ` · ${formatNumber(item.reviews)} đánh giá` : ''}` + : 'Chưa có điểm Google'; + const website = item.website_url + ? `Website` + : ''; + const image = item.image_url + ? `` + : '
🏨
'; + const priceHtml = hasPrice + ? `${formatVnd(amount)}/ phòng / đêm` + : 'Chưa có giáđang chờ Google Hotels'; + const groupEstimate = hasPrice + ? `
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
` + : '
Ước tính 3 phòng
'; + const historyButton = hasPrice + ? `` + : ''; + const fallbackTag = item.catalogue_fallback ? 'Danh sách theo dõi' : ''; + + return `
+
${image}${item.shortlisted ? 'Đang theo dõi' : ''}
+
+
+
+

${escapeHtml(item.name)}

+
${rating}${item.hotel_class ? `${escapeHtml(item.hotel_class)}` : ''}
+
+
${priceHtml}${delta.text}
+
+ ${item.description ? `

${escapeHtml(item.description)}

` : ''} + ${groupEstimate} +
${sources || fallbackTag || 'Google Hotels'}
+
${historyButton}${website}
+
+
`; +} + +function renderResults() { + const list = filteredProperties(); + $('hotelResults').innerHTML = list.length + ? list.map(hotelCard).join('') + : '
Không có khách sạn phù hợp với bộ lọc hiện tại.
'; + + const priced = list.filter(item => hotelPrice(item) !== null).length; + $('resultCount').textContent = `${list.length} khách sạn · ${priced} có giá`; + + document.querySelectorAll('[data-history]').forEach(button => { + button.addEventListener('click', () => { + state.selectedHotelId = button.dataset.history; + localStorage.setItem(HOTEL_KEY, state.selectedHotelId); + renderHistoryControls(); + renderHistory(); + $('historySection').scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); +} + +function renderSummary() { + const properties = state.data?.properties || []; + const priced = properties.filter(item => hotelPrice(item) !== null); + const shortlist = properties.filter(item => item.shortlisted); + const pricedShortlist = shortlist.filter(item => hotelPrice(item) !== null); + const cheapest = [...priced].sort(comparePrice)[0] || null; + const cheapestShort = [...pricedShortlist].sort(comparePrice)[0] || cheapest; + + $('cheapestPrice').textContent = cheapestShort ? formatVnd(hotelPrice(cheapestShort)) : '—'; + $('cheapestName').textContent = cheapestShort ? cheapestShort.name : (properties.length ? 'Chưa có giá live' : 'Chưa có dữ liệu'); + $('trackedCount').textContent = String(shortlist.length || properties.length); + $('trackedMeta').textContent = shortlist.length ? `khách sạn trong shortlist · ${priced.length} có giá` : `${priced.length} khách sạn có giá`; + $('updatedAgo').textContent = relativeTime(state.data?.generated_at); + $('updatedAt').textContent = formatDateTime(state.data?.generated_at); + $('groupEstimate').textContent = cheapestShort ? formatVnd(cheapestShort.estimated_three_rooms_amount) : '—'; + + const target = finiteNumber(localStorage.getItem(TARGET_KEY)); + const alert = $('targetAlert'); + if (cheapestShort && target !== null && target > 0) { + const current = hotelPrice(cheapestShort); + if (current <= target) { + alert.className = 'alert success'; + alert.textContent = `Đã đạt mục tiêu: ${formatVnd(current)} ≤ ${formatVnd(target)}.`; + } else { + alert.className = 'alert'; + alert.textContent = `Còn cao hơn mục tiêu ${formatVnd(current - target)}.`; + } + } else if (!priced.length) { + alert.className = 'alert'; + alert.textContent = 'Danh sách khách sạn đã có; đang chờ Google Hotels trả giá live.'; + } else { + alert.className = 'alert'; + alert.textContent = 'Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.'; + } +} + +function historyRowsFor(item) { + if (!item) return []; + return state.history + .filter(row => row.trip_key === state.data?.search?.trip_key) + .filter(row => row.property_id === item.id || row.name === item.name) + .filter(row => finiteNumber(row.rate_per_night_amount) !== null) + .slice(-90); +} + +function renderHistoryControls() { + const properties = (state.data?.properties || []).filter(item => hotelPrice(item) !== null); + if (!properties.length) { + state.selectedHotelId = ''; + $('historyHotel').innerHTML = ''; + $('historyHotel').disabled = true; + return; + } + $('historyHotel').disabled = false; + if (!state.selectedHotelId || !properties.some(item => item.id === state.selectedHotelId)) { + state.selectedHotelId = state.data?.cheapest_shortlisted_property_id || state.data?.cheapest_property_id || properties[0]?.id || ''; + } + $('historyHotel').innerHTML = properties.map(item => + `` + ).join(''); +} + +function renderHistory() { + const item = propertyById(state.selectedHotelId); + const rows = historyRowsFor(item); + const svg = $('hotelPriceChart'); + if (!item || !rows.length) { + svg.innerHTML = ''; + $('historyMeta').textContent = item ? 'Chưa có dữ liệu lịch sử cho khách sạn này.' : 'Chưa có khách sạn nào có giá để ghi lịch sử.'; + $('observedRange').textContent = '—'; + return; + } + + const values = rows.map(row => Number(row.rate_per_night_amount)); + const min = Math.min(...values); + const max = Math.max(...values); + const width = 760; + const height = 150; + const padX = 18; + const padY = 18; + const span = Math.max(1, max - min); + const points = rows.map((row, index) => { + const x = rows.length === 1 ? width / 2 : padX + index * ((width - padX * 2) / (rows.length - 1)); + const y = height - padY - ((Number(row.rate_per_night_amount) - min) / span) * (height - padY * 2); + return { x, y, row }; + }); + const polyline = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); + const circles = points.map(point => + `${formatDateTime(point.row.checked_at)} · ${formatVnd(point.row.rate_per_night_amount)}` + ).join(''); + svg.innerHTML = `${circles}`; + $('historyMeta').textContent = `${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`; + $('observedRange').textContent = min === max ? formatVnd(min) : `${formatVnd(min)} – ${formatVnd(max)}`; +} + +async function load() { + try { + const [dataResponse, historyResponse] = await Promise.all([ + fetch(`${DATA_URL}?v=${Date.now()}`, { cache: 'no-store' }), + fetch(`${HISTORY_URL}?v=${Date.now()}`, { cache: 'no-store' }) + ]); + if (!dataResponse.ok) throw new Error(`hotels.json HTTP ${dataResponse.status}`); + state.data = await dataResponse.json(); + state.history = historyResponse.ok ? await historyResponse.json() : []; + $('stayDates').textContent = `${state.data.search?.check_in_date || '19/10/2026'} → ${state.data.search?.check_out_date || '20/10/2026'}`; + $('occupancyNote').textContent = `${state.data.search?.adults_per_room || 2} người lớn / phòng · ${state.data.search?.group_rooms_estimate || 3} phòng ước tính`; + $('sourceLabel').textContent = state.data.source || 'Google Hotels'; + renderSummary(); + renderResults(); + renderHistoryControls(); + renderHistory(); + } catch (error) { + console.error(error); + $('hotelResults').innerHTML = `
Không tải được dữ liệu khách sạn. Hãy chạy workflow cập nhật giá rồi thử lại.
${escapeHtml(error.message)}
`; + $('updatedAgo').textContent = 'Lỗi dữ liệu'; + } +} + +$('hotelSearch').addEventListener('input', event => { + state.query = event.target.value; + renderResults(); +}); +$('sortHotels').addEventListener('change', event => { + state.sort = event.target.value; + renderResults(); +}); +$('shortlistOnly').addEventListener('change', event => { + state.shortlistOnly = event.target.checked; + renderResults(); +}); +$('historyHotel').addEventListener('change', event => { + state.selectedHotelId = event.target.value; + localStorage.setItem(HOTEL_KEY, state.selectedHotelId); + renderHistory(); +}); +$('saveTarget').addEventListener('click', () => { + const value = Number(String($('targetPrice').value).replace(/[^\d]/g, '')); + if (!Number.isFinite(value) || value <= 0) { + $('targetAlert').className = 'alert danger'; + $('targetAlert').textContent = 'Nhập mục tiêu giá hợp lệ.'; + return; + } + localStorage.setItem(TARGET_KEY, String(value)); + $('targetPrice').value = formatNumber(value); + renderSummary(); +}); + +const savedTarget = finiteNumber(localStorage.getItem(TARGET_KEY)); +if (savedTarget !== null && savedTarget > 0) $('targetPrice').value = formatNumber(savedTarget); +load(); From ae04951777dafcbdb269a3e9d4a31eb77c28d617 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:46:54 +0700 Subject: [PATCH 10/21] test: seed tracked hotels when live rates are unavailable --- data/hotels.json | 276 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 6 deletions(-) diff --git a/data/hotels.json b/data/hotels.json index 6a67153..3cd6819 100644 --- a/data/hotels.json +++ b/data/hotels.json @@ -1,13 +1,13 @@ { - "status": "no_results", + "status": "partial", "provider": "SerpApi", "source": "Google Hotels", - "generated_at": "2026-08-27T09:28:42.009Z", + "generated_at": "2026-08-27T09:42:00.000Z", "live_mode": true, - "disclaimer": "Rates are Google Hotels snapshots for one room with 2 adults. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.", + "disclaimer": "Rates are Google Hotels snapshots for one room with 2 adults. Hotels may remain visible even when Google Hotels does not return a live rate. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.", "search": { "trip_key": "shanghai-jinling-2026-10-19__2026-10-20", - "query": "Jinling East Road Shanghai hotels", + "query": "Hotels near Dashijie Station Shanghai", "location_label": "Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai", "check_in_date": "2026-10-19", "check_out_date": "2026-10-20", @@ -17,9 +17,273 @@ "currency": "VND", "group_rooms_estimate": 3, "searches_per_refresh": 1, - "google_hotels_url": "https://www.google.com/travel/search?q=Jinling+East+Road+Shanghai+hotels&hl=en&gl=vn" + "google_hotels_url": "https://www.google.com/travel/search?q=Hotels+near+Dashijie+Station+Shanghai&hl=en&gl=vn", + "raw_property_count": 0, + "displayed_property_count": 10, + "priced_property_count": 0 }, - "properties": [], + "properties": [ + { + "id": "fallback:jianguo-puyin-hotel-shanghai-bund-jinling-east-road", + "property_token": null, + "name": "Jianguo Puyin Hotel Shanghai Bund Jinling East Road", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:home-inn-plus-shanghai-the-bund-jinling-east-road", + "property_token": null, + "name": "Home Inn Plus Shanghai The Bund Jinling East Road", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:campanile-shanghai-bund-hotel", + "property_token": null, + "name": "Campanile Shanghai Bund Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:ji-hotel-shanghai-the-bund-jinling-east-road", + "property_token": null, + "name": "JI Hotel Shanghai The Bund Jinling East Road", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:crystal-shanghai-bund-jinling-east-road-hotel", + "property_token": null, + "name": "Crystal Shanghai Bund Jinling East Road Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:crystal-orange-shanghai-the-bund-yu-garden-hotel", + "property_token": null, + "name": "Crystal Orange Shanghai The Bund Yu Garden Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:seventh-heaven-hotel", + "property_token": null, + "name": "Seventh Heaven Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:magnificent-international-hotel", + "property_token": null, + "name": "Magnificent International Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:shanghai-autoongo-bund-hotel", + "property_token": null, + "name": "Shanghai Autoongo Bund Hotel", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + }, + { + "id": "fallback:atour-light-hotel-shanghai-bund-dashijie-metro-station", + "property_token": null, + "name": "Atour Light Hotel Shanghai Bund Dashijie Metro Station", + "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", + "shortlisted": true, + "website_url": null, + "image_url": null, + "coordinates": null, + "check_in_time": null, + "check_out_time": null, + "hotel_class": null, + "stars": null, + "overall_rating": null, + "reviews": null, + "location_rating": null, + "amenities": [], + "rate_per_night": null, + "total_rate": null, + "estimated_three_rooms_amount": null, + "price_sources": [], + "price_status": "unavailable", + "catalogue_fallback": true, + "previous_rate_per_night_amount": null, + "price_delta": null + } + ], "cheapest_property_id": null, "cheapest_shortlisted_property_id": null } From fc62b93fc6b67d84db39f309592f617550b930ba Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:52:49 +0700 Subject: [PATCH 11/21] fix: discover Shanghai hotels before filtering --- scripts/fetch-hotels.mjs | 268 ++++++++++++++++++--------------------- 1 file changed, 121 insertions(+), 147 deletions(-) diff --git a/scripts/fetch-hotels.mjs b/scripts/fetch-hotels.mjs index b5b9bd9..c6d17ed 100644 --- a/scripts/fetch-hotels.mjs +++ b/scripts/fetch-hotels.mjs @@ -10,40 +10,29 @@ if (!API_KEY) { const API = 'https://serpapi.com/search.json'; const OUT = path.resolve('data/hotels.json'); const HISTORY = path.resolve('data/hotel-history.json'); -const TRIP_KEY = 'shanghai-jinling-2026-10-19__2026-10-20'; +const TRIP_KEY = 'shanghai-2026-10-19__2026-10-20'; const SEARCH = { - q: 'Hotels near Dashijie Station Shanghai', + q: 'Shanghai hotels', checkIn: '2026-10-19', checkOut: '2026-10-20', adults: 2, children: 0, currency: 'VND', groupRoomsEstimate: 3, - locationLabel: 'Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai', - shortlist: [ - ['home inn plus', 'jinling east road'], - ['jianguo', 'jinling east road'], - ['campanile', 'bund'], - ['crystal', 'jinling east road'], - ['crystal orange', 'bund'], - ['seventh heaven'], - ['magnificent international'], - ['autoongo', 'bund'], - ['atour', 'dashijie'], - ['ji hotel', 'jinling east road'] - ], - fallbackHotels: [ - 'Jianguo Puyin Hotel Shanghai Bund Jinling East Road', - 'Home Inn Plus Shanghai The Bund Jinling East Road', - 'Campanile Shanghai Bund Hotel', - 'JI Hotel Shanghai The Bund Jinling East Road', - 'Crystal Shanghai Bund Jinling East Road Hotel', - 'Crystal Orange Shanghai The Bund Yu Garden Hotel', - 'Seventh Heaven Hotel', - 'Magnificent International Hotel', - 'Shanghai Autoongo Bund Hotel', - 'Atour Light Hotel Shanghai Bund Dashijie Metro Station' + maxPages: 3, + locationLabel: 'Shanghai · lọc theo khoảng cách từ 531 Jinling East Road', + anchor: { + name: '531 Jinling East Road', + latitude: 31.2285, + longitude: 121.4808 + }, + areas: [ + { name: 'Dashijie / Jinling East Road', latitude: 31.2288, longitude: 121.4799 }, + { name: "People's Square", latitude: 31.2304, longitude: 121.4737 }, + { name: 'Yu Garden', latitude: 31.2270, longitude: 121.4920 }, + { name: 'The Bund', latitude: 31.2397, longitude: 121.4908 }, + { name: 'Nanjing Road', latitude: 31.2354, longitude: 121.4757 } ] }; @@ -56,11 +45,6 @@ function normalize(value = '') { .trim(); } -function isShortlisted(name) { - const normalized = normalize(name); - return SEARCH.shortlist.some(parts => parts.every(part => normalized.includes(normalize(part)))); -} - function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -72,6 +56,35 @@ function priceAmount(property) { return amount === null ? Number.POSITIVE_INFINITY : amount; } +function toRad(degrees) { + return degrees * Math.PI / 180; +} + +function distanceKm(aLat, aLon, bLat, bLon) { + const lat1 = finiteNumber(aLat); + const lon1 = finiteNumber(aLon); + const lat2 = finiteNumber(bLat); + const lon2 = finiteNumber(bLon); + if ([lat1, lon1, lat2, lon2].some(value => value === null)) return null; + const radius = 6371; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return radius * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); +} + +function nearestArea(coordinates) { + const lat = finiteNumber(coordinates?.latitude); + const lon = finiteNumber(coordinates?.longitude); + if (lat === null || lon === null) return { name: 'Không rõ khu vực', distance_km: null }; + const ranked = SEARCH.areas + .map(area => ({ ...area, distance_km: distanceKm(lat, lon, area.latitude, area.longitude) })) + .filter(area => area.distance_km !== null) + .sort((a, b) => a.distance_km - b.distance_km); + if (!ranked.length || ranked[0].distance_km > 4) return { name: 'Khu vực khác ở Shanghai', distance_km: ranked[0]?.distance_km ?? null }; + return { name: ranked[0].name, distance_km: ranked[0].distance_km }; +} + async function readJson(file, fallback) { try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return fallback; } @@ -83,16 +96,12 @@ async function serpapi(params) { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value)); }); url.searchParams.set('api_key', API_KEY); - const response = await fetch(url, { headers: { Accept: 'application/json' } }); const text = await response.text(); let body; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } - - if (!response.ok || body?.error) { - throw new Error(`SerpApi request failed: ${body?.error || `HTTP ${response.status}`}`); - } + if (!response.ok || body?.error) throw new Error(`SerpApi request failed: ${body?.error || `HTTP ${response.status}`}`); if (body?.search_metadata?.status && body.search_metadata.status !== 'Success') { throw new Error(`SerpApi search did not complete successfully: ${body.search_metadata.status}`); } @@ -134,25 +143,31 @@ function propertyId(raw) { function compactProperty(raw) { let rate = compactPrice(raw?.rate_per_night); const totalRate = compactPrice(raw?.total_rate); - if (!rate && totalRate) rate = totalRate; - - const image = Array.isArray(raw?.images) - ? raw.images.find(item => item?.thumbnail || item?.original_image) - : null; - const id = propertyId(raw); const priceSources = compactSources(raw?.prices); if (!rate && priceSources[0]?.rate_per_night) rate = priceSources[0].rate_per_night; + if (!rate && totalRate) rate = totalRate; const amount = finiteNumber(rate?.amount); + const image = Array.isArray(raw?.images) ? raw.images.find(item => item?.thumbnail || item?.original_image) : null; + const coordinates = raw?.gps_coordinates || null; + const distance = distanceKm( + coordinates?.latitude, + coordinates?.longitude, + SEARCH.anchor.latitude, + SEARCH.anchor.longitude + ); + const area = nearestArea(coordinates); return { - id, + id: propertyId(raw), property_token: raw?.property_token || null, name: raw?.name || 'Hotel', description: raw?.description || null, - shortlisted: isShortlisted(raw?.name), + address: raw?.address || null, website_url: raw?.link || null, image_url: image?.thumbnail || image?.original_image || raw?.thumbnail || null, - coordinates: raw?.gps_coordinates || null, + coordinates, + distance_from_anchor_km: distance === null ? null : Number(distance.toFixed(2)), + area: area.name, check_in_time: raw?.check_in_time || null, check_out_time: raw?.check_out_time || null, hotel_class: raw?.hotel_class || null, @@ -160,129 +175,92 @@ function compactProperty(raw) { overall_rating: finiteNumber(raw?.overall_rating), reviews: finiteNumber(raw?.reviews), location_rating: finiteNumber(raw?.location_rating), - amenities: (Array.isArray(raw?.amenities) ? raw.amenities : []).slice(0, 10), + amenities: (Array.isArray(raw?.amenities) ? raw.amenities : []).slice(0, 20), rate_per_night: amount === null ? null : { ...rate, amount, currency: SEARCH.currency }, total_rate: totalRate ? { ...totalRate, currency: SEARCH.currency } : null, estimated_three_rooms_amount: amount === null ? null : amount * SEARCH.groupRoomsEstimate, price_sources: priceSources, - price_status: amount === null ? 'unavailable' : 'priced', - catalogue_fallback: false - }; -} - -function fallbackProperty(name) { - return { - id: `fallback:${normalize(name)}`, - property_token: null, - name, - description: 'Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.', - shortlisted: true, - website_url: null, - image_url: null, - coordinates: null, - check_in_time: null, - check_out_time: null, - hotel_class: null, - stars: null, - overall_rating: null, - reviews: null, - location_rating: null, - amenities: [], - rate_per_night: null, - total_rate: null, - estimated_three_rooms_amount: null, - price_sources: [], - price_status: 'unavailable', - catalogue_fallback: true + price_status: amount === null ? 'unavailable' : 'priced' }; } -function namesLikelyMatch(a, b) { - const left = normalize(a); - const right = normalize(b); - return left === right || left.includes(right) || right.includes(left); -} - function compareRecommended(a, b) { - if (a.shortlisted !== b.shortlisted) return a.shortlisted ? -1 : 1; - const priceDiff = priceAmount(a) - priceAmount(b); - if (Number.isFinite(priceDiff) && priceDiff !== 0) return priceDiff; - if (a.catalogue_fallback !== b.catalogue_fallback) return a.catalogue_fallback ? 1 : -1; - return a.name.localeCompare(b.name, 'en'); + const pricedA = priceAmount(a) !== Number.POSITIVE_INFINITY; + const pricedB = priceAmount(b) !== Number.POSITIVE_INFINITY; + if (pricedA !== pricedB) return pricedA ? -1 : 1; + const distanceA = finiteNumber(a.distance_from_anchor_km) ?? Number.POSITIVE_INFINITY; + const distanceB = finiteNumber(b.distance_from_anchor_km) ?? Number.POSITIVE_INFINITY; + if (distanceA !== distanceB) return distanceA - distanceB; + const ratingDiff = (finiteNumber(b.overall_rating) ?? 0) - (finiteNumber(a.overall_rating) ?? 0); + if (ratingDiff) return ratingDiff; + return priceAmount(a) - priceAmount(b) || a.name.localeCompare(b.name, 'en'); } const previous = await readJson(OUT, null); const oldHistory = await readJson(HISTORY, []); const generatedAt = new Date().toISOString(); -console.log(`Searching Google Hotels: ${SEARCH.locationLabel} · ${SEARCH.checkIn} → ${SEARCH.checkOut}...`); -const body = await serpapi({ - engine: 'google_hotels', - q: SEARCH.q, - check_in_date: SEARCH.checkIn, - check_out_date: SEARCH.checkOut, - adults: SEARCH.adults, - children: SEARCH.children, - currency: SEARCH.currency, - hl: 'en', - gl: 'vn' -}); +console.log(`Discovering Google Hotels for Shanghai · ${SEARCH.checkIn} → ${SEARCH.checkOut}...`); +const rawProperties = []; +let nextPageToken = null; +let pagesFetched = 0; +let googleHotelsUrl = null; -const rawProperties = [ - ...(Array.isArray(body?.properties) ? body.properties : []), - ...(Array.isArray(body?.non_matching_properties) ? body.non_matching_properties : []) -]; - -console.log(`SerpApi returned ${rawProperties.length} hotel properties before price filtering.`); +for (let page = 1; page <= SEARCH.maxPages; page += 1) { + const body = await serpapi({ + engine: 'google_hotels', + q: SEARCH.q, + check_in_date: SEARCH.checkIn, + check_out_date: SEARCH.checkOut, + adults: SEARCH.adults, + children: SEARCH.children, + currency: SEARCH.currency, + hl: 'en', + gl: 'vn', + next_page_token: nextPageToken || undefined + }); + pagesFetched += 1; + if (!googleHotelsUrl) googleHotelsUrl = body?.search_metadata?.google_hotels_url || null; + const pageProperties = [ + ...(Array.isArray(body?.properties) ? body.properties : []), + ...(Array.isArray(body?.non_matching_properties) ? body.non_matching_properties : []) + ]; + rawProperties.push(...pageProperties); + console.log(`Page ${page}: ${pageProperties.length} properties.`); + nextPageToken = body?.serpapi_pagination?.next_page_token || null; + if (!nextPageToken) break; +} const seen = new Set(); const properties = rawProperties .map(compactProperty) .filter(property => { - const key = property.id || normalize(property.name); + const key = property.property_token || normalize(property.name); if (seen.has(key)) return false; seen.add(key); return true; - }); + }) + .sort(compareRecommended); -for (const name of SEARCH.fallbackHotels) { - if (!properties.some(property => namesLikelyMatch(property.name, name))) { - properties.push(fallbackProperty(name)); - } -} - -properties.sort(compareRecommended); -const limitedProperties = properties.slice(0, 30); const previousProperties = Array.isArray(previous?.properties) ? previous.properties : []; - -for (const property of limitedProperties) { +for (const property of properties) { const currentAmount = finiteNumber(property?.rate_per_night?.amount); - const old = previousProperties.find(item => - item?.id === property.id || normalize(item?.name) === normalize(property.name) - ); + const old = previousProperties.find(item => item?.id === property.id || normalize(item?.name) === normalize(property.name)); const previousAmount = finiteNumber(old?.rate_per_night?.amount); - if (currentAmount !== null && previousAmount !== null) { - property.previous_rate_per_night_amount = previousAmount; - property.price_delta = currentAmount - previousAmount; - } else { - property.previous_rate_per_night_amount = previousAmount; - property.price_delta = null; - } + property.previous_rate_per_night_amount = previousAmount; + property.price_delta = currentAmount !== null && previousAmount !== null ? currentAmount - previousAmount : null; } -const pricedProperties = limitedProperties.filter(item => finiteNumber(item?.rate_per_night?.amount) !== null); +const pricedProperties = properties.filter(item => finiteNumber(item?.rate_per_night?.amount) !== null); const cheapest = [...pricedProperties].sort((a, b) => priceAmount(a) - priceAmount(b))[0] || null; -const cheapestShortlisted = [...pricedProperties] - .filter(item => item.shortlisted) - .sort((a, b) => priceAmount(a) - priceAmount(b))[0] || null; const result = { - status: pricedProperties.length ? 'ok' : limitedProperties.length ? 'partial' : 'no_results', + status: pricedProperties.length ? 'ok' : properties.length ? 'partial' : 'no_results', provider: 'SerpApi', source: 'Google Hotels', generated_at: generatedAt, live_mode: true, - disclaimer: 'Rates are Google Hotels snapshots for one room with 2 adults. Hotels may remain visible even when Google Hotels does not return a live rate. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.', + disclaimer: 'Discovery starts from a broad Shanghai Hotels search. Filtering by distance, price, rating, reviews, stars, area and amenities happens in the browser. Rates are Google Hotels snapshots for one room with 2 adults; taxes, fees, room type and final checkout price can differ.', search: { trip_key: TRIP_KEY, query: SEARCH.q, @@ -294,15 +272,18 @@ const result = { children: SEARCH.children, currency: SEARCH.currency, group_rooms_estimate: SEARCH.groupRoomsEstimate, - searches_per_refresh: 1, - google_hotels_url: body?.search_metadata?.google_hotels_url || null, + anchor: SEARCH.anchor, + max_pages: SEARCH.maxPages, + pages_fetched: pagesFetched, + searches_per_refresh: pagesFetched, + google_hotels_url: googleHotelsUrl, raw_property_count: rawProperties.length, - displayed_property_count: limitedProperties.length, + displayed_property_count: properties.length, priced_property_count: pricedProperties.length }, - properties: limitedProperties, + properties, cheapest_property_id: cheapest?.id || null, - cheapest_shortlisted_property_id: cheapestShortlisted?.id || null + cheapest_shortlisted_property_id: null }; const newHistory = pricedProperties.map(property => ({ @@ -310,7 +291,6 @@ const newHistory = pricedProperties.map(property => ({ trip_key: TRIP_KEY, property_id: property.id, name: property.name, - shortlisted: property.shortlisted, rate_per_night_amount: property.rate_per_night.amount, currency: SEARCH.currency, lowest_source: property.price_sources?.[0]?.source || null, @@ -318,16 +298,10 @@ const newHistory = pricedProperties.map(property => ({ source: 'Google Hotels' })); -const history = [...(Array.isArray(oldHistory) ? oldHistory : []), ...newHistory].slice(-2400); - +const history = [...(Array.isArray(oldHistory) ? oldHistory : []), ...newHistory].slice(-4000); await fs.mkdir(path.dirname(OUT), { recursive: true }); await fs.writeFile(OUT, JSON.stringify(result, null, 2) + '\n'); await fs.writeFile(HISTORY, JSON.stringify(history, null, 2) + '\n'); -console.log(`Saved ${OUT} with ${limitedProperties.length} displayed properties; ${pricedProperties.length} currently have live prices.`); -if (cheapest) { - console.log(`Cheapest: ${cheapest.name} · ${cheapest.rate_per_night.amount} ${SEARCH.currency}/room/night`); -} -if (cheapestShortlisted) { - console.log(`Cheapest shortlist: ${cheapestShortlisted.name} · ${cheapestShortlisted.rate_per_night.amount} ${SEARCH.currency}/room/night`); -} +console.log(`Saved ${OUT}: ${properties.length} unique Shanghai hotels from ${pagesFetched} page(s); ${pricedProperties.length} have live prices.`); +if (cheapest) console.log(`Cheapest: ${cheapest.name} · ${cheapest.rate_per_night.amount} ${SEARCH.currency}/room/night`); From f45c8d7a42bcd4219e091835ea9f507ad0a9fde9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:53:27 +0700 Subject: [PATCH 12/21] feat: add full hotel discovery filters --- hotels.html | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/hotels.html b/hotels.html index 01d0021..b3f620c 100644 --- a/hotels.html +++ b/hotels.html @@ -4,7 +4,7 @@ - + @@ -26,13 +26,37 @@
-
Giá phòngThượng Hải · 19–20/10/2026
+
Giá phòngShanghai · 19–20/10/2026
TravelLog
-
Theo dõi giá khách sạn

Jinling East Road · Dashijie · The Bund

So sánh snapshot giá Google Hotels cho đúng đêm 19 → 20/10/2026. Giá chuẩn trên trang là một phòng cho 2 người lớn; hệ thống chỉ nhân ×3 để tham khảo nhanh cho nhóm 6 người.

Cập nhật giá
-
19/10/2026 → 20/10/20262 người lớn / phòng · 3 phòng ước tính
Google Hotels
-
Rẻ nhất trong shortlistĐang tải dữ liệu…
Ước tính 3 phòng6 người lớn · chỉ để so nhanh
Đang theo dõikhách sạn
Cập nhật gần nhấtChưa có thời gian cập nhật
-
Kết quả

Khách sạn quanh khu trung tâm

Đang tải…
Đang tải snapshot Google Hotels…
Giá hiển thị lấy từ Google Hotels qua SerpApi và có thể khác khi bấm sang OTA/website do thuế, phí, hạng phòng, chính sách hủy hoặc tồn phòng thay đổi. Luôn kiểm tra tổng tiền cuối cùng trước khi thanh toán.
+
Khám phá + theo dõi giá

Shanghai Hotels · 19 → 20/10/2026

Hệ thống lấy danh sách rộng từ Google Hotels cho Shanghai rồi mới lọc trên giao diện. Mốc khoảng cách là 531 Jinling East Road; bạn có thể tự chọn khoảng cách, giá, rating, số review, sao, khu vực, tiện nghi và đánh dấu khách sạn quan tâm.

Cập nhật giá
+ +
+
19/10/2026 → 20/10/20262 người lớn / phòng · 3 phòng ước tính
Google Hotels
+
+ + + + + + + + + + +
+
+ + + + Đang tải dữ liệu khám phá… +
+
+ +
Rẻ nhất đang hiển thịĐang tải dữ liệu…
Ước tính 3 phòng6 người lớn · chỉ để so nhanh
Đã đánh dấu0khách sạn quan tâm
Cập nhật gần nhấtChưa có thời gian cập nhật
+ +
Kết quả

Khách sạn Shanghai

Đang tải…
Đang tải snapshot Google Hotels…
Danh sách được lấy rộng từ Google Hotels rồi lọc ngay trên trình duyệt. Khoảng cách là đường chim bay từ 531 Jinling East Road, chỉ dùng để sàng lọc nhanh. Giá có thể khác khi bấm sang OTA/website do thuế, phí, hạng phòng, chính sách hủy hoặc tồn phòng thay đổi.
+
Lịch sử giá

Biến động giá đã ghi nhận

Giá một phòng / đêm

Mỗi điểm là một lần workflow cập nhật giá.

Khoảng giá đã ghi nhận

Chỉ so với đúng khách sạn và ngày ở hiện tại.

Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.
From 0e282468f3c4cbd2bf0f07492bc82fc9fca1fbe0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:53:56 +0700 Subject: [PATCH 13/21] style: add hotel discovery filters --- hotel-booking.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hotel-booking.css b/hotel-booking.css index 7f14ee2..e629f11 100644 --- a/hotel-booking.css +++ b/hotel-booking.css @@ -1 +1 @@ -.hotel-content{width:min(1240px,calc(100% - 48px));padding:28px 0 72px}.hotel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.hotel-heading h1{margin:6px 0 8px;font-size:30px;letter-spacing:-.035em}.hotel-heading p{margin:0;max-width:760px;color:var(--muted);font-size:13px;line-height:1.65}.hotel-kicker{font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--primary)}.hotel-search-card{padding:18px;margin-bottom:16px}.hotel-search-top{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:15px}.hotel-stay strong{display:block;font-size:15px}.hotel-stay span{display:block;margin-top:4px;color:var(--muted);font-size:11px}.hotel-live-source{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;background:var(--primary-soft);color:var(--primary);font-size:10.5px;font-weight:800}.hotel-live-source:before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor}.hotel-controls{display:grid;grid-template-columns:minmax(220px,1fr) 210px auto;gap:10px;align-items:center}.hotel-search-input{height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-sort{height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.shortlist-toggle{display:flex;align-items:center;gap:8px;min-height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap}.shortlist-toggle input{width:16px;height:16px}.hotel-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.hotel-stat{padding:16px}.hotel-stat span{display:block;color:var(--muted);font-size:10.5px}.hotel-stat strong{display:block;margin-top:8px;font-size:19px;letter-spacing:-.025em}.hotel-stat small{display:block;margin-top:4px;color:var(--muted);font-size:10px;line-height:1.45}.hotel-results-head{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:10px 0 12px}.hotel-results-head h2{margin:4px 0 0;font-size:18px}.hotel-results-head span{color:var(--muted);font-size:11px}.hotel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.hotel-card{display:grid;grid-template-columns:180px minmax(0,1fr);overflow:hidden}.hotel-card.shortlisted{box-shadow:0 12px 32px rgba(37,99,235,.08),inset 0 0 0 1px color-mix(in srgb,var(--primary) 24%,transparent)}.hotel-image{position:relative;min-height:220px;background:var(--surface-2);overflow:hidden}.hotel-image img{width:100%;height:100%;object-fit:cover;display:block}.hotel-image-fallback{display:grid;place-items:center;width:100%;height:100%;min-height:220px;font-size:38px}.shortlist-badge{position:absolute;left:10px;top:10px;padding:6px 8px;border-radius:999px;background:rgba(15,23,42,.8);color:#fff;font-size:9.5px;font-weight:800;backdrop-filter:blur(8px)}.hotel-card-body{display:flex;flex-direction:column;padding:15px}.hotel-title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.hotel-title-row h3{margin:0;font-size:14px;line-height:1.35;letter-spacing:-.015em}.hotel-meta{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:7px;color:var(--muted);font-size:10px}.hotel-rating{color:#a16207;font-weight:700}.hotel-rating.muted{color:var(--muted)}.hotel-price{text-align:right;white-space:nowrap}.hotel-price strong{display:block;font-size:18px;color:var(--primary)}.hotel-price span{display:block;margin-top:3px;color:var(--muted);font-size:9.5px}.price-delta{display:block;margin-top:5px;font-size:9.5px;font-weight:800}.price-delta.down{color:#15803d}.price-delta.up{color:#b91c1c}.price-delta.neutral{color:var(--muted)}.hotel-description{display:-webkit-box;margin:11px 0 0;color:var(--muted);font-size:10.5px;line-height:1.5;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hotel-group-estimate{display:flex;justify-content:space-between;gap:10px;margin-top:12px;padding:9px 10px;border-radius:9px;background:var(--surface-2);font-size:10px}.hotel-group-estimate span{color:var(--muted)}.hotel-group-estimate strong{font-size:11px}.hotel-sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip{padding:5px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:9px;background:var(--surface)}.hotel-card-actions{display:flex;gap:7px;margin-top:auto;padding-top:13px}.empty-state{grid-column:1/-1;padding:34px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.hotel-history{margin-top:22px}.hotel-history-head{display:flex;align-items:end;justify-content:space-between;gap:14px;margin-bottom:12px}.hotel-history-head h2{margin:4px 0 0;font-size:19px}.hotel-history-head select{max-width:360px;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-history-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,.7fr);gap:13px}.hotel-chart-card,.hotel-target-card{padding:17px}.hotel-chart-card h3,.hotel-target-card h3{margin:0 0 5px;font-size:13px}.hotel-chart-card p,.hotel-target-card p{margin:0;color:var(--muted);font-size:10.5px;line-height:1.5}.hotel-chart{width:100%;height:180px;margin-top:14px;overflow:visible}.hotel-chart polyline{stroke:var(--primary);stroke-width:2}.hotel-chart circle{fill:var(--surface);stroke:var(--primary);stroke-width:2}.hotel-range{margin-top:16px;font-size:20px;font-weight:800;letter-spacing:-.02em}.hotel-target-row{display:flex;gap:8px;margin-top:14px}.hotel-target-row input{min-width:0;flex:1;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-target-card .alert{margin-top:10px;padding:9px 10px;border-radius:9px;background:var(--surface-2);color:var(--muted);font-size:10px;line-height:1.45}.hotel-target-card .alert.success{background:rgba(22,163,74,.1);color:#15803d}.hotel-target-card .alert.danger{background:rgba(220,38,38,.09);color:#b91c1c}.hotel-note{margin-top:12px;padding:11px 13px;border:1px dashed var(--line);border-radius:10px;color:var(--muted);font-size:10px;line-height:1.55}@media(max-width:1060px){.hotel-grid{grid-template-columns:1fr}.hotel-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:820px){.hotel-content{width:calc(100% - 24px);padding:18px 0 90px}.hotel-heading{display:block}.hotel-heading .btn{margin-top:12px}.hotel-heading h1{font-size:24px}.hotel-controls{grid-template-columns:1fr 1fr}.shortlist-toggle{grid-column:1/-1}.hotel-history-grid{grid-template-columns:1fr}.hotel-history-head{display:block}.hotel-history-head select{width:100%;max-width:none;margin-top:9px}}@media(max-width:560px){.hotel-content{width:calc(100% - 18px)}.hotel-stats{grid-template-columns:1fr 1fr;gap:8px}.hotel-stat{padding:13px}.hotel-stat strong{font-size:16px}.hotel-controls{grid-template-columns:1fr}.shortlist-toggle{grid-column:auto}.hotel-card{grid-template-columns:1fr}.hotel-image{min-height:180px;max-height:220px}.hotel-image-fallback{min-height:180px}.hotel-title-row{grid-template-columns:1fr}.hotel-price{text-align:left;margin-top:2px}.hotel-card-actions{flex-wrap:wrap}} \ No newline at end of file +.hotel-content{width:min(1240px,calc(100% - 48px));padding:28px 0 72px}.hotel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.hotel-heading h1{margin:6px 0 8px;font-size:30px;letter-spacing:-.035em}.hotel-heading p{margin:0;max-width:820px;color:var(--muted);font-size:13px;line-height:1.65}.hotel-kicker{font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--primary)}.hotel-search-card{padding:18px;margin-bottom:16px}.hotel-search-top{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:15px}.hotel-stay strong{display:block;font-size:15px}.hotel-stay span{display:block;margin-top:4px;color:var(--muted);font-size:11px}.hotel-live-source{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;background:var(--primary-soft);color:var(--primary);font-size:10.5px;font-weight:800}.hotel-live-source:before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor}.hotel-filter-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.filter-field{display:flex;flex-direction:column;gap:6px;min-width:0}.filter-field span{color:var(--muted);font-size:9.5px;font-weight:700}.filter-field input,.filter-field select{width:100%;height:40px;padding:0 11px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text);font-size:11px}.filter-search{grid-column:span 2}.hotel-filter-actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:12px}.shortlist-toggle{display:flex;align-items:center;gap:8px;min-height:38px;padding:0 11px;border:1px solid var(--line);border-radius:10px;font-size:10.5px;font-weight:700;white-space:nowrap}.shortlist-toggle input{width:16px;height:16px}.filter-hint{margin-left:auto;color:var(--muted);font-size:10px}.hotel-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.hotel-stat{padding:16px}.hotel-stat span{display:block;color:var(--muted);font-size:10.5px}.hotel-stat strong{display:block;margin-top:8px;font-size:19px;letter-spacing:-.025em}.hotel-stat small{display:block;margin-top:4px;color:var(--muted);font-size:10px;line-height:1.45}.hotel-results-head{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:10px 0 12px}.hotel-results-head h2{margin:4px 0 0;font-size:18px}.hotel-results-head span{color:var(--muted);font-size:11px}.hotel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.hotel-card{display:grid;grid-template-columns:180px minmax(0,1fr);overflow:hidden}.hotel-card.shortlisted{box-shadow:0 12px 32px rgba(37,99,235,.08),inset 0 0 0 1px color-mix(in srgb,var(--primary) 24%,transparent)}.hotel-image{position:relative;min-height:220px;background:var(--surface-2);overflow:hidden}.hotel-image img{width:100%;height:100%;object-fit:cover;display:block}.hotel-image-fallback{display:grid;place-items:center;width:100%;height:100%;min-height:220px;font-size:38px}.shortlist-badge{position:absolute;left:10px;top:10px;padding:6px 8px;border-radius:999px;background:rgba(15,23,42,.8);color:#fff;font-size:9.5px;font-weight:800;backdrop-filter:blur(8px)}.hotel-card-body{display:flex;flex-direction:column;padding:15px}.hotel-title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.hotel-title-row h3{margin:0;font-size:14px;line-height:1.35;letter-spacing:-.015em}.hotel-meta{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:7px;color:var(--muted);font-size:10px}.hotel-rating{color:#a16207;font-weight:700}.hotel-rating.muted{color:var(--muted)}.hotel-distance{font-weight:700;color:var(--primary)}.hotel-price{text-align:right;white-space:nowrap}.hotel-price strong{display:block;font-size:18px;color:var(--primary)}.hotel-price span{display:block;margin-top:3px;color:var(--muted);font-size:9.5px}.price-delta{display:block;margin-top:5px;font-size:9.5px;font-weight:800}.price-delta.down{color:#15803d}.price-delta.up{color:#b91c1c}.price-delta.neutral{color:var(--muted)}.hotel-description{display:-webkit-box;margin:11px 0 0;color:var(--muted);font-size:10.5px;line-height:1.5;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hotel-address{margin:8px 0 0;color:var(--muted);font-size:9.8px}.hotel-group-estimate{display:flex;justify-content:space-between;gap:10px;margin-top:12px;padding:9px 10px;border-radius:9px;background:var(--surface-2);font-size:10px}.hotel-group-estimate span{color:var(--muted)}.hotel-group-estimate strong{font-size:11px}.hotel-amenities,.hotel-sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip,.amenity-chip{padding:5px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:9px;background:var(--surface)}.hotel-card-actions{display:flex;gap:7px;align-items:center;margin-top:auto;padding-top:13px;flex-wrap:wrap}.favorite-btn.active{background:var(--primary-soft);color:var(--primary);border-color:color-mix(in srgb,var(--primary) 35%,var(--line))}.empty-state{grid-column:1/-1;padding:34px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.hotel-history{margin-top:22px}.hotel-history-head{display:flex;align-items:end;justify-content:space-between;gap:14px;margin-bottom:12px}.hotel-history-head h2{margin:4px 0 0;font-size:19px}.hotel-history-head select{max-width:360px;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-history-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,.7fr);gap:13px}.hotel-chart-card,.hotel-target-card{padding:17px}.hotel-chart-card h3,.hotel-target-card h3{margin:0 0 5px;font-size:13px}.hotel-chart-card p,.hotel-target-card p{margin:0;color:var(--muted);font-size:10.5px;line-height:1.5}.hotel-chart{width:100%;height:180px;margin-top:14px;overflow:visible}.hotel-chart polyline{stroke:var(--primary);stroke-width:2}.hotel-chart circle{fill:var(--surface);stroke:var(--primary);stroke-width:2}.hotel-range{margin-top:16px;font-size:20px;font-weight:800;letter-spacing:-.02em}.hotel-target-row{display:flex;gap:8px;margin-top:14px}.hotel-target-row input{min-width:0;flex:1;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-target-card .alert{margin-top:10px;padding:9px 10px;border-radius:9px;background:var(--surface-2);color:var(--muted);font-size:10px;line-height:1.45}.hotel-target-card .alert.success{background:rgba(22,163,74,.1);color:#15803d}.hotel-target-card .alert.danger{background:rgba(220,38,38,.09);color:#b91c1c}.hotel-note{margin-top:12px;padding:11px 13px;border:1px dashed var(--line);border-radius:10px;color:var(--muted);font-size:10px;line-height:1.55}@media(max-width:1180px){.hotel-filter-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.filter-search{grid-column:span 2}}@media(max-width:1060px){.hotel-grid{grid-template-columns:1fr}.hotel-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:820px){.hotel-content{width:calc(100% - 24px);padding:18px 0 90px}.hotel-heading{display:block}.hotel-heading .btn{margin-top:12px}.hotel-heading h1{font-size:24px}.hotel-filter-grid{grid-template-columns:1fr 1fr}.filter-search{grid-column:1/-1}.filter-hint{width:100%;margin-left:0}.hotel-history-grid{grid-template-columns:1fr}.hotel-history-head{display:block}.hotel-history-head select{width:100%;max-width:none;margin-top:9px}}@media(max-width:560px){.hotel-content{width:calc(100% - 18px)}.hotel-stats{grid-template-columns:1fr 1fr;gap:8px}.hotel-stat{padding:13px}.hotel-stat strong{font-size:16px}.hotel-filter-grid{grid-template-columns:1fr}.filter-search{grid-column:auto}.hotel-filter-actions{align-items:stretch}.shortlist-toggle{width:100%}.hotel-card{grid-template-columns:1fr}.hotel-image{min-height:180px;max-height:220px}.hotel-image-fallback{min-height:180px}.hotel-title-row{grid-template-columns:1fr}.hotel-price{text-align:left;margin-top:2px}.hotel-card-actions{flex-wrap:wrap}} \ No newline at end of file From a3c8b88db4e55b986b7662caf12d0b518d9595a2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 16:54:51 +0700 Subject: [PATCH 14/21] feat: add client-side hotel filtering and favorites --- hotel-booking.js | 439 +++++++++++++++++------------------------------ 1 file changed, 161 insertions(+), 278 deletions(-) diff --git a/hotel-booking.js b/hotel-booking.js index a6ceca3..733e8d3 100644 --- a/hotel-booking.js +++ b/hotel-booking.js @@ -2,6 +2,7 @@ const DATA_URL = './data/hotels.json'; const HISTORY_URL = './data/hotel-history.json'; const TARGET_KEY = 'travel-hotel-target-price-v1'; const HOTEL_KEY = 'travel-hotel-history-selected-v1'; +const FAVORITES_KEY = 'travel-hotel-favorites-v1'; const $ = id => document.getElementById(id); function syncThemeUI() { @@ -23,17 +24,32 @@ function setTheme(value) { } document.documentElement.dataset.theme = localStorage.getItem('travel-theme') === 'dark' ? 'dark' : 'light'; -document.querySelectorAll('[data-theme-toggle]').forEach(button => { - button.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')); -}); +document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'))); syncThemeUI(); +function loadFavorites() { + try { + const parsed = JSON.parse(localStorage.getItem(FAVORITES_KEY) || '[]'); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { return new Set(); } +} + const state = { data: null, history: [], query: '', sort: 'recommended', - shortlistOnly: false, + maxDistance: null, + area: '', + minPrice: null, + maxPrice: null, + minRating: null, + minReviews: null, + minStars: null, + amenity: '', + pricedOnly: false, + favoritesOnly: false, + favorites: loadFavorites(), selectedHotelId: localStorage.getItem(HOTEL_KEY) || '' }; @@ -42,310 +58,177 @@ function finiteNumber(value) { const n = Number(value); return Number.isFinite(n) ? n : null; } - -function hotelPrice(item) { - return finiteNumber(item?.rate_per_night?.amount); -} - -function formatVnd(value) { - const n = finiteNumber(value); - if (n === null) return '—'; - return new Intl.NumberFormat('vi-VN', { - style: 'currency', - currency: 'VND', - maximumFractionDigits: 0 - }).format(n).replace('₫', 'đ'); -} - -function formatNumber(value) { - const n = finiteNumber(value); - return n === null ? '—' : new Intl.NumberFormat('vi-VN').format(n); -} - -function formatDateTime(value) { - if (!value) return 'Chưa cập nhật'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat('vi-VN', { - dateStyle: 'short', - timeStyle: 'short', - timeZone: 'Asia/Ho_Chi_Minh' - }).format(date); -} - -function relativeTime(value) { - if (!value) return 'Chưa có'; - const then = new Date(value).getTime(); - if (!Number.isFinite(then)) return 'Chưa có'; - const minutes = Math.max(0, Math.round((Date.now() - then) / 60000)); - if (minutes < 60) return `${minutes} phút trước`; - const hours = Math.round(minutes / 60); - if (hours < 48) return `${hours} giờ trước`; - return `${Math.round(hours / 24)} ngày trước`; -} - -function escapeHtml(value = '') { - return String(value).replace(/[&<>"']/g, ch => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[ch])); -} - -function propertyById(id) { - return state.data?.properties?.find(item => item.id === id) || null; -} +function hotelPrice(item) { return finiteNumber(item?.rate_per_night?.amount); } +function hotelDistance(item) { return finiteNumber(item?.distance_from_anchor_km); } +function normalize(value = '') { return String(value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); } +function parseMoney(value) { const digits = String(value || '').replace(/[^0-9]/g, ''); return digits ? Number(digits) : null; } +function formatVnd(value) { const n = finiteNumber(value); return n === null ? '—' : new Intl.NumberFormat('vi-VN',{style:'currency',currency:'VND',maximumFractionDigits:0}).format(n).replace('₫','đ'); } +function formatNumber(value) { const n = finiteNumber(value); return n === null ? '—' : new Intl.NumberFormat('vi-VN').format(n); } +function formatDistance(value) { const n = finiteNumber(value); return n === null ? 'Chưa rõ khoảng cách' : `${n.toLocaleString('vi-VN',{maximumFractionDigits:2})} km`; } +function formatDateTime(value) { if(!value)return'Chưa cập nhật';const d=new Date(value);if(Number.isNaN(d.getTime()))return value;return new Intl.DateTimeFormat('vi-VN',{dateStyle:'short',timeStyle:'short',timeZone:'Asia/Ho_Chi_Minh'}).format(d); } +function relativeTime(value) { if(!value)return'Chưa có';const then=new Date(value).getTime();if(!Number.isFinite(then))return'Chưa có';const m=Math.max(0,Math.round((Date.now()-then)/60000));if(m<60)return`${m} phút trước`;const h=Math.round(m/60);if(h<48)return`${h} giờ trước`;return`${Math.round(h/24)} ngày trước`; } +function escapeHtml(value='') { return String(value).replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); } +function propertyById(id) { return state.data?.properties?.find(item=>item.id===id)||null; } +function isFavorite(item) { return state.favorites.has(item.id); } +function persistFavorites() { localStorage.setItem(FAVORITES_KEY, JSON.stringify([...state.favorites])); } function priceDeltaText(delta) { - const n = finiteNumber(delta); - if (n === null) return { text: 'Chưa có lịch sử giá', cls: 'neutral' }; - if (n === 0) return { text: 'Không đổi', cls: 'neutral' }; - if (n < 0) return { text: `Giảm ${formatVnd(Math.abs(n))}`, cls: 'down' }; - return { text: `Tăng ${formatVnd(n)}`, cls: 'up' }; + const n=finiteNumber(delta); + if(n===null)return{text:'Chưa có lịch sử giá',cls:'neutral'}; + if(n===0)return{text:'Không đổi',cls:'neutral'}; + if(n<0)return{text:`Giảm ${formatVnd(Math.abs(n))}`,cls:'down'}; + return{text:`Tăng ${formatVnd(n)}`,cls:'up'}; } -function comparePrice(a, b) { - const left = hotelPrice(a); - const right = hotelPrice(b); - if (left === null && right === null) return 0; - if (left === null) return 1; - if (right === null) return -1; - return left - right; +function compareNullableNumber(a,b,{desc=false,nullLast=true}={}) { + const left=finiteNumber(a),right=finiteNumber(b); + if(left===null&&right===null)return 0; + if(left===null)return nullLast?1:-1; + if(right===null)return nullLast?-1:1; + return desc?right-left:left-right; } function filteredProperties() { - const list = [...(state.data?.properties || [])]; - const q = state.query.trim().toLowerCase(); - const filtered = list.filter(item => { - if (state.shortlistOnly && !item.shortlisted) return false; - if (!q) return true; - return [item.name, item.description, item.hotel_class, ...(item.amenities || [])] - .filter(Boolean) - .join(' ') - .toLowerCase() - .includes(q); + const q=normalize(state.query); + const list=[...(state.data?.properties||[])].filter(item=>{ + const price=hotelPrice(item),distance=hotelDistance(item),rating=finiteNumber(item.overall_rating),reviews=finiteNumber(item.reviews),stars=finiteNumber(item.stars); + if(state.favoritesOnly&&!isFavorite(item))return false; + if(state.pricedOnly&&price===null)return false; + if(state.maxDistance!==null&&(distance===null||distance>state.maxDistance))return false; + if(state.area&&item.area!==state.area)return false; + if(state.minPrice!==null&&(price===null||pricestate.maxPrice))return false; + if(state.minRating!==null&&(rating===null||ratingnormalize(a).includes(normalize(state.amenity)))))return false; + if(q){ + const haystack=normalize([item.name,item.address,item.area,item.description,item.hotel_class,...(item.amenities||[])].filter(Boolean).join(' ')); + if(!haystack.includes(q))return false; + } + return true; }); - filtered.sort((a, b) => { - if (state.sort === 'price') return comparePrice(a, b) || a.name.localeCompare(b.name); - if (state.sort === 'rating') return (b.overall_rating || 0) - (a.overall_rating || 0) || comparePrice(a, b); - if (state.sort === 'reviews') return (b.reviews || 0) - (a.reviews || 0) || comparePrice(a, b); - if (a.shortlisted !== b.shortlisted) return a.shortlisted ? -1 : 1; - return comparePrice(a, b) || (a.catalogue_fallback === b.catalogue_fallback ? 0 : a.catalogue_fallback ? 1 : -1) || a.name.localeCompare(b.name); + list.sort((a,b)=>{ + if(state.sort==='distance')return compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km)||compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount); + if(state.sort==='price')return compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount)||compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km); + if(state.sort==='rating')return compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true})||compareNullableNumber(a.reviews,b.reviews,{desc:true}); + if(state.sort==='reviews')return compareNullableNumber(a.reviews,b.reviews,{desc:true})||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true}); + if(state.sort==='stars')return compareNullableNumber(a.stars,b.stars,{desc:true})||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true}); + if(isFavorite(a)!==isFavorite(b))return isFavorite(a)?-1:1; + const pricedA=hotelPrice(a)!==null,pricedB=hotelPrice(b)!==null; + if(pricedA!==pricedB)return pricedA?-1:1; + return compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km)||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true})||compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount)||a.name.localeCompare(b.name); }); - return filtered; + return list; } function hotelCard(item) { - const amount = hotelPrice(item); - const hasPrice = amount !== null; - const delta = hasPrice ? priceDeltaText(item.price_delta) : { text: 'Google Hotels chưa trả giá', cls: 'neutral' }; - const sources = (item.price_sources || []).slice(0, 3).map(source => - `${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}` - ).join(''); - const rating = item.overall_rating - ? `★ ${item.overall_rating}${item.reviews ? ` · ${formatNumber(item.reviews)} đánh giá` : ''}` - : 'Chưa có điểm Google'; - const website = item.website_url - ? `Website` - : ''; - const image = item.image_url - ? `` - : '
🏨
'; - const priceHtml = hasPrice - ? `${formatVnd(amount)}/ phòng / đêm` - : 'Chưa có giáđang chờ Google Hotels'; - const groupEstimate = hasPrice - ? `
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
` - : '
Ước tính 3 phòng
'; - const historyButton = hasPrice - ? `` - : ''; - const fallbackTag = item.catalogue_fallback ? 'Danh sách theo dõi' : ''; - - return `
-
${image}${item.shortlisted ? 'Đang theo dõi' : ''}
-
-
-
-

${escapeHtml(item.name)}

-
${rating}${item.hotel_class ? `${escapeHtml(item.hotel_class)}` : ''}
-
-
${priceHtml}${delta.text}
-
- ${item.description ? `

${escapeHtml(item.description)}

` : ''} - ${groupEstimate} -
${sources || fallbackTag || 'Google Hotels'}
-
${historyButton}${website}
-
-
`; + const amount=hotelPrice(item),hasPrice=amount!==null,favorite=isFavorite(item); + const delta=hasPrice?priceDeltaText(item.price_delta):{text:'Google Hotels chưa trả giá',cls:'neutral'}; + const sources=(item.price_sources||[]).slice(0,3).map(source=>`${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}`).join(''); + const amenities=(item.amenities||[]).slice(0,4).map(value=>`${escapeHtml(value)}`).join(''); + const rating=item.overall_rating?`★ ${item.overall_rating}${item.reviews?` · ${formatNumber(item.reviews)} review`:''}`:'Chưa có rating'; + const distance=`${formatDistance(item.distance_from_anchor_km)}`; + const area=item.area?`${escapeHtml(item.area)}`:''; + const stars=item.stars?`${item.stars} sao`:''; + const website=item.website_url?`Website`:''; + const image=item.image_url?``:'
🏨
'; + const priceHtml=hasPrice?`${formatVnd(amount)}/ phòng / đêm`:'Chưa có giáđang chờ Google Hotels'; + const group=hasPrice?`
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
`:'
Ước tính 3 phòng
'; + const historyButton=hasPrice?``:''; + return `
${image}${favorite?'Đang quan tâm':''}

${escapeHtml(item.name)}

${rating}${distance}${area}${stars}
${priceHtml}${delta.text}
${item.address?`

${escapeHtml(item.address)}

`:''}${item.description?`

${escapeHtml(item.description)}

`:''}${group}${amenities?`
${amenities}
`:''}
${sources||'Google Hotels'}
${historyButton}${website}
`; } function renderResults() { - const list = filteredProperties(); - $('hotelResults').innerHTML = list.length - ? list.map(hotelCard).join('') - : '
Không có khách sạn phù hợp với bộ lọc hiện tại.
'; - - const priced = list.filter(item => hotelPrice(item) !== null).length; - $('resultCount').textContent = `${list.length} khách sạn · ${priced} có giá`; - - document.querySelectorAll('[data-history]').forEach(button => { - button.addEventListener('click', () => { - state.selectedHotelId = button.dataset.history; - localStorage.setItem(HOTEL_KEY, state.selectedHotelId); - renderHistoryControls(); - renderHistory(); - $('historySection').scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); - }); + const list=filteredProperties(); + $('hotelResults').innerHTML=list.length?list.map(hotelCard).join(''):'
Không có khách sạn phù hợp với bộ lọc hiện tại.
Thử xóa bớt điều kiện giá, khoảng cách hoặc rating.
'; + const priced=list.filter(item=>hotelPrice(item)!==null).length; + $('resultCount').textContent=`${list.length} khách sạn · ${priced} có giá`; + document.querySelectorAll('[data-favorite]').forEach(button=>button.addEventListener('click',()=>{ + const id=button.dataset.favorite; + if(state.favorites.has(id))state.favorites.delete(id);else state.favorites.add(id); + persistFavorites();renderSummary();renderResults(); + })); + document.querySelectorAll('[data-history]').forEach(button=>button.addEventListener('click',()=>{ + state.selectedHotelId=button.dataset.history;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistoryControls();renderHistory();$('historySection').scrollIntoView({behavior:'smooth',block:'start'}); + })); } function renderSummary() { - const properties = state.data?.properties || []; - const priced = properties.filter(item => hotelPrice(item) !== null); - const shortlist = properties.filter(item => item.shortlisted); - const pricedShortlist = shortlist.filter(item => hotelPrice(item) !== null); - const cheapest = [...priced].sort(comparePrice)[0] || null; - const cheapestShort = [...pricedShortlist].sort(comparePrice)[0] || cheapest; - - $('cheapestPrice').textContent = cheapestShort ? formatVnd(hotelPrice(cheapestShort)) : '—'; - $('cheapestName').textContent = cheapestShort ? cheapestShort.name : (properties.length ? 'Chưa có giá live' : 'Chưa có dữ liệu'); - $('trackedCount').textContent = String(shortlist.length || properties.length); - $('trackedMeta').textContent = shortlist.length ? `khách sạn trong shortlist · ${priced.length} có giá` : `${priced.length} khách sạn có giá`; - $('updatedAgo').textContent = relativeTime(state.data?.generated_at); - $('updatedAt').textContent = formatDateTime(state.data?.generated_at); - $('groupEstimate').textContent = cheapestShort ? formatVnd(cheapestShort.estimated_three_rooms_amount) : '—'; - - const target = finiteNumber(localStorage.getItem(TARGET_KEY)); - const alert = $('targetAlert'); - if (cheapestShort && target !== null && target > 0) { - const current = hotelPrice(cheapestShort); - if (current <= target) { - alert.className = 'alert success'; - alert.textContent = `Đã đạt mục tiêu: ${formatVnd(current)} ≤ ${formatVnd(target)}.`; - } else { - alert.className = 'alert'; - alert.textContent = `Còn cao hơn mục tiêu ${formatVnd(current - target)}.`; - } - } else if (!priced.length) { - alert.className = 'alert'; - alert.textContent = 'Danh sách khách sạn đã có; đang chờ Google Hotels trả giá live.'; - } else { - alert.className = 'alert'; - alert.textContent = 'Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.'; - } + const visible=filteredProperties(),priced=visible.filter(item=>hotelPrice(item)!==null); + const cheapest=[...priced].sort((a,b)=>hotelPrice(a)-hotelPrice(b))[0]||null; + $('cheapestPrice').textContent=cheapest?formatVnd(hotelPrice(cheapest)):'—'; + $('cheapestName').textContent=cheapest?cheapest.name:(visible.length?'Không có giá trong bộ lọc':'Không có kết quả'); + $('groupEstimate').textContent=cheapest?formatVnd(cheapest.estimated_three_rooms_amount):'—'; + $('trackedCount').textContent=String(state.favorites.size); + $('trackedMeta').textContent=`khách sạn quan tâm · ${priced.length} kết quả có giá`; + $('updatedAgo').textContent=relativeTime(state.data?.generated_at); + $('updatedAt').textContent=formatDateTime(state.data?.generated_at); + const target=finiteNumber(localStorage.getItem(TARGET_KEY)),alert=$('targetAlert'); + if(cheapest&&target!==null&&target>0){const current=hotelPrice(cheapest);if(current<=target){alert.className='alert success';alert.textContent=`Đã đạt mục tiêu: ${formatVnd(current)} ≤ ${formatVnd(target)}.`}else{alert.className='alert';alert.textContent=`Giá rẻ nhất đang cao hơn mục tiêu ${formatVnd(current-target)}.`}}else{alert.className='alert';alert.textContent='Đặt mục tiêu giá để so nhanh với kết quả đang lọc.'} +} + +function populateFilters() { + const properties=state.data?.properties||[]; + const areas=[...new Set(properties.map(item=>item.area).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); + $('areaFilter').innerHTML=''+areas.map(area=>``).join(''); + const counts=new Map(); + properties.forEach(item=>(item.amenities||[]).forEach(a=>counts.set(a,(counts.get(a)||0)+1))); + const amenities=[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,30).map(([name])=>name); + $('amenityFilter').innerHTML=''+amenities.map(a=>``).join(''); + const s=state.data?.search||{}; + $('discoveryMeta').textContent=`${s.pages_fetched||1}/${s.max_pages||1} trang API · ${s.raw_property_count??properties.length} kết quả thô · ${properties.length} khách sạn duy nhất`; +} + +function historyRowsFor(item) { if(!item)return[];return state.history.filter(row=>row.trip_key===state.data?.search?.trip_key).filter(row=>row.property_id===item.id||row.name===item.name).filter(row=>finiteNumber(row.rate_per_night_amount)!==null).slice(-90); } +function renderHistoryControls() { + const properties=(state.data?.properties||[]).filter(item=>hotelPrice(item)!==null); + if(!properties.length){state.selectedHotelId='';$('historyHotel').innerHTML='';$('historyHotel').disabled=true;return} + $('historyHotel').disabled=false; + if(!state.selectedHotelId||!properties.some(item=>item.id===state.selectedHotelId))state.selectedHotelId=state.data?.cheapest_property_id||properties[0]?.id||''; + $('historyHotel').innerHTML=properties.map(item=>``).join(''); } - -function historyRowsFor(item) { - if (!item) return []; - return state.history - .filter(row => row.trip_key === state.data?.search?.trip_key) - .filter(row => row.property_id === item.id || row.name === item.name) - .filter(row => finiteNumber(row.rate_per_night_amount) !== null) - .slice(-90); +function renderHistory() { + const item=propertyById(state.selectedHotelId),rows=historyRowsFor(item),svg=$('hotelPriceChart'); + if(!item||!rows.length){svg.innerHTML='';$('historyMeta').textContent=item?'Chưa có dữ liệu lịch sử cho khách sạn này.':'Chưa có khách sạn nào có giá để ghi lịch sử.';$('observedRange').textContent='—';return} + const values=rows.map(r=>Number(r.rate_per_night_amount)),min=Math.min(...values),max=Math.max(...values),width=760,height=150,padX=18,padY=18,span=Math.max(1,max-min); + const points=rows.map((row,index)=>{const x=rows.length===1?width/2:padX+index*((width-padX*2)/(rows.length-1));const y=height-padY-((Number(row.rate_per_night_amount)-min)/span)*(height-padY*2);return{x,y,row}}); + const polyline=points.map(p=>`${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '),circles=points.map(p=>`${formatDateTime(p.row.checked_at)} · ${formatVnd(p.row.rate_per_night_amount)}`).join(''); + svg.innerHTML=`${circles}`;$('historyMeta').textContent=`${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`;$('observedRange').textContent=min===max?formatVnd(min):`${formatVnd(min)} – ${formatVnd(max)}`; } -function renderHistoryControls() { - const properties = (state.data?.properties || []).filter(item => hotelPrice(item) !== null); - if (!properties.length) { - state.selectedHotelId = ''; - $('historyHotel').innerHTML = ''; - $('historyHotel').disabled = true; - return; - } - $('historyHotel').disabled = false; - if (!state.selectedHotelId || !properties.some(item => item.id === state.selectedHotelId)) { - state.selectedHotelId = state.data?.cheapest_shortlisted_property_id || state.data?.cheapest_property_id || properties[0]?.id || ''; - } - $('historyHotel').innerHTML = properties.map(item => - `` - ).join(''); +function applyFiltersFromUi() { + state.query=$('hotelSearch').value;state.sort=$('sortHotels').value;state.maxDistance=finiteNumber($('maxDistance').value);state.area=$('areaFilter').value;state.minPrice=parseMoney($('minPrice').value);state.maxPrice=parseMoney($('maxPrice').value);state.minRating=finiteNumber($('minRating').value);state.minReviews=finiteNumber($('minReviews').value);state.minStars=finiteNumber($('minStars').value);state.amenity=$('amenityFilter').value;state.pricedOnly=$('pricedOnly').checked;state.favoritesOnly=$('shortlistOnly').checked;renderSummary();renderResults(); } -function renderHistory() { - const item = propertyById(state.selectedHotelId); - const rows = historyRowsFor(item); - const svg = $('hotelPriceChart'); - if (!item || !rows.length) { - svg.innerHTML = ''; - $('historyMeta').textContent = item ? 'Chưa có dữ liệu lịch sử cho khách sạn này.' : 'Chưa có khách sạn nào có giá để ghi lịch sử.'; - $('observedRange').textContent = '—'; - return; - } - - const values = rows.map(row => Number(row.rate_per_night_amount)); - const min = Math.min(...values); - const max = Math.max(...values); - const width = 760; - const height = 150; - const padX = 18; - const padY = 18; - const span = Math.max(1, max - min); - const points = rows.map((row, index) => { - const x = rows.length === 1 ? width / 2 : padX + index * ((width - padX * 2) / (rows.length - 1)); - const y = height - padY - ((Number(row.rate_per_night_amount) - min) / span) * (height - padY * 2); - return { x, y, row }; +function bindFilters() { + ['hotelSearch','minPrice','maxPrice'].forEach(id=>$(id).addEventListener('input',applyFiltersFromUi)); + ['sortHotels','maxDistance','areaFilter','minRating','minReviews','minStars','amenityFilter','pricedOnly','shortlistOnly'].forEach(id=>$(id).addEventListener('change',applyFiltersFromUi)); + $('clearHotelFilters').addEventListener('click',()=>{ + ['hotelSearch','minPrice','maxPrice'].forEach(id=>$(id).value=''); + ['maxDistance','areaFilter','minRating','minReviews','minStars','amenityFilter'].forEach(id=>$(id).value=''); + $('sortHotels').value='recommended';$('pricedOnly').checked=false;$('shortlistOnly').checked=false;applyFiltersFromUi(); }); - const polyline = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); - const circles = points.map(point => - `${formatDateTime(point.row.checked_at)} · ${formatVnd(point.row.rate_per_night_amount)}` - ).join(''); - svg.innerHTML = `${circles}`; - $('historyMeta').textContent = `${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`; - $('observedRange').textContent = min === max ? formatVnd(min) : `${formatVnd(min)} – ${formatVnd(max)}`; + ['minPrice','maxPrice'].forEach(id=>$(id).addEventListener('blur',()=>{const n=parseMoney($(id).value);$(id).value=n===null?'':formatNumber(n)})); } async function load() { try { - const [dataResponse, historyResponse] = await Promise.all([ - fetch(`${DATA_URL}?v=${Date.now()}`, { cache: 'no-store' }), - fetch(`${HISTORY_URL}?v=${Date.now()}`, { cache: 'no-store' }) - ]); - if (!dataResponse.ok) throw new Error(`hotels.json HTTP ${dataResponse.status}`); - state.data = await dataResponse.json(); - state.history = historyResponse.ok ? await historyResponse.json() : []; - $('stayDates').textContent = `${state.data.search?.check_in_date || '19/10/2026'} → ${state.data.search?.check_out_date || '20/10/2026'}`; - $('occupancyNote').textContent = `${state.data.search?.adults_per_room || 2} người lớn / phòng · ${state.data.search?.group_rooms_estimate || 3} phòng ước tính`; - $('sourceLabel').textContent = state.data.source || 'Google Hotels'; - renderSummary(); - renderResults(); - renderHistoryControls(); - renderHistory(); - } catch (error) { - console.error(error); - $('hotelResults').innerHTML = `
Không tải được dữ liệu khách sạn. Hãy chạy workflow cập nhật giá rồi thử lại.
${escapeHtml(error.message)}
`; - $('updatedAgo').textContent = 'Lỗi dữ liệu'; + const[dataResponse,historyResponse]=await Promise.all([fetch(`${DATA_URL}?v=${Date.now()}`,{cache:'no-store'}),fetch(`${HISTORY_URL}?v=${Date.now()}`,{cache:'no-store'})]); + if(!dataResponse.ok)throw new Error(`hotels.json HTTP ${dataResponse.status}`); + state.data=await dataResponse.json();state.history=historyResponse.ok?await historyResponse.json():[]; + $('stayDates').textContent=`${state.data.search?.check_in_date||'2026-10-19'} → ${state.data.search?.check_out_date||'2026-10-20'}`; + $('occupancyNote').textContent=`${state.data.search?.adults_per_room||2} người lớn / phòng · ${state.data.search?.group_rooms_estimate||3} phòng ước tính`; + $('sourceLabel').textContent=state.data.source||'Google Hotels'; + populateFilters();renderSummary();renderResults();renderHistoryControls();renderHistory(); + } catch(error) { + console.error(error);$('hotelResults').innerHTML=`
Không tải được dữ liệu khách sạn.
${escapeHtml(error.message)}
`;$('updatedAgo').textContent='Lỗi dữ liệu'; } } -$('hotelSearch').addEventListener('input', event => { - state.query = event.target.value; - renderResults(); -}); -$('sortHotels').addEventListener('change', event => { - state.sort = event.target.value; - renderResults(); -}); -$('shortlistOnly').addEventListener('change', event => { - state.shortlistOnly = event.target.checked; - renderResults(); -}); -$('historyHotel').addEventListener('change', event => { - state.selectedHotelId = event.target.value; - localStorage.setItem(HOTEL_KEY, state.selectedHotelId); - renderHistory(); -}); -$('saveTarget').addEventListener('click', () => { - const value = Number(String($('targetPrice').value).replace(/[^\d]/g, '')); - if (!Number.isFinite(value) || value <= 0) { - $('targetAlert').className = 'alert danger'; - $('targetAlert').textContent = 'Nhập mục tiêu giá hợp lệ.'; - return; - } - localStorage.setItem(TARGET_KEY, String(value)); - $('targetPrice').value = formatNumber(value); - renderSummary(); -}); - -const savedTarget = finiteNumber(localStorage.getItem(TARGET_KEY)); -if (savedTarget !== null && savedTarget > 0) $('targetPrice').value = formatNumber(savedTarget); +bindFilters(); +$('historyHotel').addEventListener('change',event=>{state.selectedHotelId=event.target.value;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistory()}); +$('saveTarget').addEventListener('click',()=>{const value=parseMoney($('targetPrice').value);if(value===null||value<=0){$('targetAlert').className='alert danger';$('targetAlert').textContent='Nhập mục tiêu giá hợp lệ.';return}localStorage.setItem(TARGET_KEY,String(value));$('targetPrice').value=formatNumber(value);renderSummary()}); +const savedTarget=finiteNumber(localStorage.getItem(TARGET_KEY));if(savedTarget!==null&&savedTarget>0)$('targetPrice').value=formatNumber(savedTarget); load(); From 76804909181ef38d8b8ec8c482240f101f42a8e8 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:14:34 +0700 Subject: [PATCH 15/21] fix: harden Shanghai hotel discovery --- scripts/fetch-hotels.mjs | 113 +++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 23 deletions(-) diff --git a/scripts/fetch-hotels.mjs b/scripts/fetch-hotels.mjs index c6d17ed..317caf6 100644 --- a/scripts/fetch-hotels.mjs +++ b/scripts/fetch-hotels.mjs @@ -11,6 +11,9 @@ const API = 'https://serpapi.com/search.json'; const OUT = path.resolve('data/hotels.json'); const HISTORY = path.resolve('data/hotel-history.json'); const TRIP_KEY = 'shanghai-2026-10-19__2026-10-20'; +const requestedPages = Number(process.env.HOTEL_MAX_PAGES || 3); +const MAX_PAGES = Number.isFinite(requestedPages) ? Math.min(8, Math.max(1, Math.trunc(requestedPages))) : 3; +const MAX_CATALOG = 180; const SEARCH = { q: 'Shanghai hotels', @@ -20,7 +23,7 @@ const SEARCH = { children: 0, currency: 'VND', groupRoomsEstimate: 3, - maxPages: 3, + maxPages: MAX_PAGES, locationLabel: 'Shanghai · lọc theo khoảng cách từ 531 Jinling East Road', anchor: { name: '531 Jinling East Road', @@ -81,7 +84,9 @@ function nearestArea(coordinates) { .map(area => ({ ...area, distance_km: distanceKm(lat, lon, area.latitude, area.longitude) })) .filter(area => area.distance_km !== null) .sort((a, b) => a.distance_km - b.distance_km); - if (!ranked.length || ranked[0].distance_km > 4) return { name: 'Khu vực khác ở Shanghai', distance_km: ranked[0]?.distance_km ?? null }; + if (!ranked.length || ranked[0].distance_km > 4) { + return { name: 'Khu vực khác ở Shanghai', distance_km: ranked[0]?.distance_km ?? null }; + } return { name: ranked[0].name, distance_km: ranked[0].distance_km }; } @@ -96,12 +101,16 @@ async function serpapi(params) { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value)); }); url.searchParams.set('api_key', API_KEY); + const response = await fetch(url, { headers: { Accept: 'application/json' } }); const text = await response.text(); let body; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } - if (!response.ok || body?.error) throw new Error(`SerpApi request failed: ${body?.error || `HTTP ${response.status}`}`); + + if (!response.ok || body?.error) { + throw new Error(`SerpApi request failed: ${body?.error || `HTTP ${response.status}`}`); + } if (body?.search_metadata?.status && body.search_metadata.status !== 'Success') { throw new Error(`SerpApi search did not complete successfully: ${body.search_metadata.status}`); } @@ -110,11 +119,11 @@ async function serpapi(params) { function compactPrice(price) { if (!price) return null; - const amount = finiteNumber(price.extracted_lowest); + const amount = finiteNumber(price.extracted_lowest ?? price.extracted_price ?? price.amount); if (amount === null) return null; return { amount, - formatted: price.lowest || `${amount} ${SEARCH.currency}`, + formatted: price.lowest || price.price || `${amount} ${SEARCH.currency}`, before_taxes_fees_amount: finiteNumber(price.extracted_before_taxes_fees), before_taxes_fees_formatted: price.before_taxes_fees || null }; @@ -123,7 +132,7 @@ function compactPrice(price) { function compactSources(prices) { return (Array.isArray(prices) ? prices : []) .map(item => { - const rate = compactPrice(item?.rate_per_night); + const rate = compactPrice(item?.rate_per_night || item); return rate ? { source: item?.source || 'Unknown source', logo: item?.logo || null, @@ -140,14 +149,17 @@ function propertyId(raw) { return raw?.property_token || `name:${normalize(raw?.name || 'hotel')}`; } -function compactProperty(raw) { +function compactProperty(raw, generatedAt) { let rate = compactPrice(raw?.rate_per_night); const totalRate = compactPrice(raw?.total_rate); const priceSources = compactSources(raw?.prices); if (!rate && priceSources[0]?.rate_per_night) rate = priceSources[0].rate_per_night; if (!rate && totalRate) rate = totalRate; + const amount = finiteNumber(rate?.amount); - const image = Array.isArray(raw?.images) ? raw.images.find(item => item?.thumbnail || item?.original_image) : null; + const image = Array.isArray(raw?.images) + ? raw.images.find(item => item?.thumbnail || item?.original_image) + : null; const coordinates = raw?.gps_coordinates || null; const distance = distanceKm( coordinates?.latitude, @@ -168,10 +180,11 @@ function compactProperty(raw) { coordinates, distance_from_anchor_km: distance === null ? null : Number(distance.toFixed(2)), area: area.name, + area_is_estimate: true, check_in_time: raw?.check_in_time || null, check_out_time: raw?.check_out_time || null, hotel_class: raw?.hotel_class || null, - stars: finiteNumber(raw?.extracted_hotel_class ?? raw?.hotel_class), + stars: finiteNumber(raw?.extracted_hotel_class), overall_rating: finiteNumber(raw?.overall_rating), reviews: finiteNumber(raw?.reviews), location_rating: finiteNumber(raw?.location_rating), @@ -180,11 +193,16 @@ function compactProperty(raw) { total_rate: totalRate ? { ...totalRate, currency: SEARCH.currency } : null, estimated_three_rooms_amount: amount === null ? null : amount * SEARCH.groupRoomsEstimate, price_sources: priceSources, - price_status: amount === null ? 'unavailable' : 'priced' + price_status: amount === null ? 'unavailable' : 'priced', + seen_in_latest_search: true, + last_seen_at: generatedAt }; } function compareRecommended(a, b) { + const currentA = a.seen_in_latest_search !== false; + const currentB = b.seen_in_latest_search !== false; + if (currentA !== currentB) return currentA ? -1 : 1; const pricedA = priceAmount(a) !== Number.POSITIVE_INFINITY; const pricedB = priceAmount(b) !== Number.POSITIVE_INFINITY; if (pricedA !== pricedB) return pricedA ? -1 : 1; @@ -196,11 +214,26 @@ function compareRecommended(a, b) { return priceAmount(a) - priceAmount(b) || a.name.localeCompare(b.name, 'en'); } +function staleCatalogProperty(property, previousGeneratedAt) { + return { + ...property, + rate_per_night: null, + total_rate: null, + estimated_three_rooms_amount: null, + price_sources: [], + price_status: 'not_refreshed', + previous_rate_per_night_amount: finiteNumber(property?.rate_per_night?.amount), + price_delta: null, + seen_in_latest_search: false, + last_seen_at: property?.last_seen_at || previousGeneratedAt || null + }; +} + const previous = await readJson(OUT, null); const oldHistory = await readJson(HISTORY, []); const generatedAt = new Date().toISOString(); -console.log(`Discovering Google Hotels for Shanghai · ${SEARCH.checkIn} → ${SEARCH.checkOut}...`); +console.log(`Discovering Google Hotels for Shanghai · ${SEARCH.checkIn} → ${SEARCH.checkOut} · max ${SEARCH.maxPages} page(s)...`); const rawProperties = []; let nextPageToken = null; let pagesFetched = 0; @@ -219,6 +252,7 @@ for (let page = 1; page <= SEARCH.maxPages; page += 1) { gl: 'vn', next_page_token: nextPageToken || undefined }); + pagesFetched += 1; if (!googleHotelsUrl) googleHotelsUrl = body?.search_metadata?.google_hotels_url || null; const pageProperties = [ @@ -227,40 +261,69 @@ for (let page = 1; page <= SEARCH.maxPages; page += 1) { ]; rawProperties.push(...pageProperties); console.log(`Page ${page}: ${pageProperties.length} properties.`); + nextPageToken = body?.serpapi_pagination?.next_page_token || null; if (!nextPageToken) break; } +if (!rawProperties.length) { + console.error('ZERO_RESULTS: Google Hotels returned 0 properties. Refusing to overwrite the last good hotel snapshot.'); + process.exit(10); +} + const seen = new Set(); -const properties = rawProperties - .map(compactProperty) +const currentProperties = rawProperties + .map(raw => compactProperty(raw, generatedAt)) .filter(property => { const key = property.property_token || normalize(property.name); if (seen.has(key)) return false; seen.add(key); return true; - }) - .sort(compareRecommended); + }); + +const previousProperties = ( + previous?.search?.trip_key === TRIP_KEY && Array.isArray(previous?.properties) + ? previous.properties + : [] +).filter(property => !property?.catalogue_fallback && property?.id); + +const preservedProperties = []; +for (const property of previousProperties) { + const key = property.property_token || normalize(property.name); + if (!seen.has(key)) { + preservedProperties.push(staleCatalogProperty(property, previous?.generated_at)); + seen.add(key); + } +} + +const properties = [...currentProperties, ...preservedProperties] + .sort(compareRecommended) + .slice(0, MAX_CATALOG); -const previousProperties = Array.isArray(previous?.properties) ? previous.properties : []; for (const property of properties) { + if (property.seen_in_latest_search === false) continue; const currentAmount = finiteNumber(property?.rate_per_night?.amount); - const old = previousProperties.find(item => item?.id === property.id || normalize(item?.name) === normalize(property.name)); + const old = previousProperties.find(item => + item?.id === property.id || normalize(item?.name) === normalize(property.name) + ); const previousAmount = finiteNumber(old?.rate_per_night?.amount); property.previous_rate_per_night_amount = previousAmount; - property.price_delta = currentAmount !== null && previousAmount !== null ? currentAmount - previousAmount : null; + property.price_delta = currentAmount !== null && previousAmount !== null + ? currentAmount - previousAmount + : null; } -const pricedProperties = properties.filter(item => finiteNumber(item?.rate_per_night?.amount) !== null); +const pricedProperties = currentProperties.filter(item => finiteNumber(item?.rate_per_night?.amount) !== null); const cheapest = [...pricedProperties].sort((a, b) => priceAmount(a) - priceAmount(b))[0] || null; const result = { - status: pricedProperties.length ? 'ok' : properties.length ? 'partial' : 'no_results', + status: pricedProperties.length ? 'ok' : 'partial', + snapshot_schema: 2, provider: 'SerpApi', source: 'Google Hotels', generated_at: generatedAt, live_mode: true, - disclaimer: 'Discovery starts from a broad Shanghai Hotels search. Filtering by distance, price, rating, reviews, stars, area and amenities happens in the browser. Rates are Google Hotels snapshots for one room with 2 adults; taxes, fees, room type and final checkout price can differ.', + disclaimer: 'Discovery starts from a broad Shanghai hotels search. Filtering by distance, price, rating, reviews, stars, area and amenities happens in the browser. Rates are Google Hotels snapshots for one room with 2 adults; taxes, fees, room type and final checkout price can differ. Hotels preserved from a deeper prior discovery remain visible but their old prices are intentionally cleared unless they are seen in the latest refresh.', search: { trip_key: TRIP_KEY, query: SEARCH.q, @@ -278,6 +341,8 @@ const result = { searches_per_refresh: pagesFetched, google_hotels_url: googleHotelsUrl, raw_property_count: rawProperties.length, + latest_unique_property_count: currentProperties.length, + preserved_catalog_count: preservedProperties.length, displayed_property_count: properties.length, priced_property_count: pricedProperties.length }, @@ -303,5 +368,7 @@ await fs.mkdir(path.dirname(OUT), { recursive: true }); await fs.writeFile(OUT, JSON.stringify(result, null, 2) + '\n'); await fs.writeFile(HISTORY, JSON.stringify(history, null, 2) + '\n'); -console.log(`Saved ${OUT}: ${properties.length} unique Shanghai hotels from ${pagesFetched} page(s); ${pricedProperties.length} have live prices.`); -if (cheapest) console.log(`Cheapest: ${cheapest.name} · ${cheapest.rate_per_night.amount} ${SEARCH.currency}/room/night`); +console.log(`Saved ${OUT}: ${currentProperties.length} unique hotels from latest search, ${preservedProperties.length} preserved from catalog, ${pricedProperties.length} with live prices, ${pagesFetched} API page(s).`); +if (cheapest) { + console.log(`Cheapest: ${cheapest.name} · ${cheapest.rate_per_night.amount} ${SEARCH.currency}/room/night`); +} From dfd60940aa5c6177e61ffdbc72d43c92c35f43b9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:15:32 +0700 Subject: [PATCH 16/21] fix: validate hotel refreshes and control discovery depth --- .github/workflows/update-hotel-prices.yml | 63 ++++++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml index 8a4597a..9ede1b2 100644 --- a/.github/workflows/update-hotel-prices.yml +++ b/.github/workflows/update-hotel-prices.yml @@ -2,6 +2,16 @@ name: Update live hotel prices on: workflow_dispatch: + inputs: + max_pages: + description: 'Discovery depth (manual runs only)' + required: false + default: '5' + type: choice + options: + - '3' + - '5' + - '8' push: branches: - main @@ -52,6 +62,23 @@ jobs: fi echo "enabled=true" >> "$GITHUB_OUTPUT" + - name: Resolve discovery depth + id: depth + shell: bash + env: + MANUAL_MAX_PAGES: ${{ inputs.max_pages }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + PAGES="${MANUAL_MAX_PAGES:-5}" + MODE="manual-deep-discovery" + else + PAGES="3" + MODE="daily-light-refresh" + fi + echo "max_pages=$PAGES" >> "$GITHUB_OUTPUT" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "Hotel search mode: $MODE · max pages: $PAGES" + - name: Fetch Google Hotels prices via SerpApi id: search if: steps.config.outputs.enabled == 'true' @@ -60,6 +87,7 @@ jobs: SERPAPI_API_KEY_2: ${{ secrets.SERPAPI_API_KEY_2 }} SERPAPI_API_KEY_3: ${{ secrets.SERPAPI_API_KEY_3 }} SERPAPI_BOOKING_API_KEY: ${{ secrets.SERPAPI_BOOKING_API_KEY }} + HOTEL_MAX_PAGES: ${{ steps.depth.outputs.max_pages }} shell: bash run: | set -euo pipefail @@ -75,7 +103,7 @@ jobs: echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..." set +e - output=$(SERPAPI_API_KEY="$key" node scripts/fetch-hotels.mjs 2>&1) + output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels.mjs 2>&1) status=$? set -e printf '%s\n' "$output" @@ -87,6 +115,11 @@ jobs: break fi + if printf '%s' "$output" | grep -q 'ZERO_RESULTS:'; then + echo "::error::Google Hotels returned zero properties; keeping the previous good snapshot unchanged." + exit "$status" + fi + if printf '%s' "$output" | grep -Eqi '(^|[^0-9])429([^0-9]|$)|quota|rate[ -]?limit|monthly[[:space:]]+search|search(es)?[[:space:]]+limit|credit(s)?[[:space:]]+(exhausted|limit)'; then echo "::warning::Credential ${labels[$index]} has no usable quota/rate capacity. Trying the next credential." else @@ -102,6 +135,19 @@ jobs: test -f data/hotels.json || { echo "::error::Missing data/hotels.json after search."; exit 3; } test -f data/hotel-history.json || { echo "::error::Missing data/hotel-history.json after search."; exit 3; } + node - <<'NODE' + const data = require('./data/hotels.json'); + const raw = Number(data?.search?.raw_property_count || 0); + const unique = Number(data?.search?.latest_unique_property_count || 0); + const priced = Number(data?.search?.priced_property_count || 0); + const pages = Number(data?.search?.pages_fetched || 0); + if (!raw || !unique || !pages) { + console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages }); + process.exit(4); + } + console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced.`); + NODE + - name: Evaluate hotel price alert if: steps.config.outputs.enabled == 'true' && github.ref_name == 'main' && steps.search.outcome == 'success' env: @@ -115,8 +161,8 @@ jobs: exit 0 fi - CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')") - CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')") + CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')") + CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')") CHECKED_AT=$(node -p "require('./data/hotels.json').generated_at || new Date().toISOString()") CURRENT_CURRENCY=$(node -p "require('./data/hotels.json').search?.currency || 'VND'") ALERT_CURRENCY=${ALERT_CURRENCY:-$CURRENT_CURRENCY} @@ -134,7 +180,7 @@ jobs: ISSUE=$(gh issue list --state open --json number,title --jq '.[] | select(.title == "🏨 Hotel price alert · Shanghai 19–20 Oct 2026") | .number' | head -n 1) if node -e "process.exit(Number(process.argv[1]) <= Number(process.argv[2]) ? 0 : 1)" "$CURRENT_AMOUNT" "$ALERT_AMOUNT"; then - BODY=$(cat < Google Hotels is a price snapshot. Verify taxes, fees, cancellation policy, room type and final checkout total before paying. - EOF + EOF2 ) if [ -n "$ISSUE" ]; then gh issue edit "$ISSUE" --body "$BODY" @@ -175,7 +221,7 @@ jobs: run: node scripts/update-api-usage.mjs - name: Commit refreshed snapshots - if: always() && steps.config.outputs.enabled == 'true' + if: steps.search.outcome == 'success' id: commit shell: bash run: | @@ -189,11 +235,14 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add data/hotels.json data/hotel-history.json data/api-usage.json README.md git commit -m "chore: refresh Google Hotels and API usage [skip ci]" + + git fetch origin "${GITHUB_REF_NAME}" + git rebase "origin/${GITHUB_REF_NAME}" git push origin "HEAD:${GITHUB_REF_NAME}" echo "changed=true" >> "$GITHUB_OUTPUT" - name: Request GitHub Pages rebuild - if: steps.config.outputs.enabled == 'true' && steps.commit.outputs.changed == 'true' && github.ref_name == 'main' + if: steps.commit.outputs.changed == 'true' && github.ref_name == 'main' env: GH_TOKEN: ${{ github.token }} run: | From d738bb131b4fe2f3fbfb82b85397016cc5903beb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:15:44 +0700 Subject: [PATCH 17/21] chore: remove legacy hotel fallback snapshot --- data/hotels.json | 294 ++++------------------------------------------- 1 file changed, 22 insertions(+), 272 deletions(-) diff --git a/data/hotels.json b/data/hotels.json index 3cd6819..9e87559 100644 --- a/data/hotels.json +++ b/data/hotels.json @@ -1,14 +1,16 @@ { - "status": "partial", + "status": "pending_refresh", + "snapshot_state": "preview_pending_refresh", + "snapshot_schema": 2, "provider": "SerpApi", "source": "Google Hotels", - "generated_at": "2026-08-27T09:42:00.000Z", - "live_mode": true, - "disclaimer": "Rates are Google Hotels snapshots for one room with 2 adults. Hotels may remain visible even when Google Hotels does not return a live rate. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.", + "generated_at": null, + "live_mode": false, + "disclaimer": "Preview placeholder only. No hard-coded hotel fallback is shown. Run the live hotel workflow after merge to populate Google Hotels discovery data.", "search": { - "trip_key": "shanghai-jinling-2026-10-19__2026-10-20", - "query": "Hotels near Dashijie Station Shanghai", - "location_label": "Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai", + "trip_key": "shanghai-2026-10-19__2026-10-20", + "query": "Shanghai hotels", + "location_label": "Shanghai · lọc theo khoảng cách từ 531 Jinling East Road", "check_in_date": "2026-10-19", "check_out_date": "2026-10-20", "nights": 1, @@ -16,274 +18,22 @@ "children": 0, "currency": "VND", "group_rooms_estimate": 3, - "searches_per_refresh": 1, - "google_hotels_url": "https://www.google.com/travel/search?q=Hotels+near+Dashijie+Station+Shanghai&hl=en&gl=vn", + "anchor": { + "name": "531 Jinling East Road", + "latitude": 31.2285, + "longitude": 121.4808 + }, + "max_pages": 5, + "pages_fetched": 0, + "searches_per_refresh": 0, + "google_hotels_url": null, "raw_property_count": 0, - "displayed_property_count": 10, + "latest_unique_property_count": 0, + "preserved_catalog_count": 0, + "displayed_property_count": 0, "priced_property_count": 0 }, - "properties": [ - { - "id": "fallback:jianguo-puyin-hotel-shanghai-bund-jinling-east-road", - "property_token": null, - "name": "Jianguo Puyin Hotel Shanghai Bund Jinling East Road", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:home-inn-plus-shanghai-the-bund-jinling-east-road", - "property_token": null, - "name": "Home Inn Plus Shanghai The Bund Jinling East Road", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:campanile-shanghai-bund-hotel", - "property_token": null, - "name": "Campanile Shanghai Bund Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:ji-hotel-shanghai-the-bund-jinling-east-road", - "property_token": null, - "name": "JI Hotel Shanghai The Bund Jinling East Road", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:crystal-shanghai-bund-jinling-east-road-hotel", - "property_token": null, - "name": "Crystal Shanghai Bund Jinling East Road Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:crystal-orange-shanghai-the-bund-yu-garden-hotel", - "property_token": null, - "name": "Crystal Orange Shanghai The Bund Yu Garden Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:seventh-heaven-hotel", - "property_token": null, - "name": "Seventh Heaven Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:magnificent-international-hotel", - "property_token": null, - "name": "Magnificent International Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:shanghai-autoongo-bund-hotel", - "property_token": null, - "name": "Shanghai Autoongo Bund Hotel", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - }, - { - "id": "fallback:atour-light-hotel-shanghai-bund-dashijie-metro-station", - "property_token": null, - "name": "Atour Light Hotel Shanghai Bund Dashijie Metro Station", - "description": "Khách sạn trong danh sách theo dõi quanh Jinling East Road / Dashijie.", - "shortlisted": true, - "website_url": null, - "image_url": null, - "coordinates": null, - "check_in_time": null, - "check_out_time": null, - "hotel_class": null, - "stars": null, - "overall_rating": null, - "reviews": null, - "location_rating": null, - "amenities": [], - "rate_per_night": null, - "total_rate": null, - "estimated_three_rooms_amount": null, - "price_sources": [], - "price_status": "unavailable", - "catalogue_fallback": true, - "previous_rate_per_night_amount": null, - "price_delta": null - } - ], + "properties": [], "cheapest_property_id": null, "cheapest_shortlisted_property_id": null } From 5559a15a1b912ac80b44b3a606b1254ddd7b2e1a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:17:25 +0700 Subject: [PATCH 18/21] fix: bootstrap hotel catalog with deeper discovery --- .github/workflows/update-hotel-prices.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml index 9ede1b2..bfbe936 100644 --- a/.github/workflows/update-hotel-prices.yml +++ b/.github/workflows/update-hotel-prices.yml @@ -71,6 +71,9 @@ jobs: if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then PAGES="${MANUAL_MAX_PAGES:-5}" MODE="manual-deep-discovery" + elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then + PAGES="5" + MODE="bootstrap-deep-discovery" else PAGES="3" MODE="daily-light-refresh" From 72bca6f5e13f0fd7f5a2d00db4202a8e84165161 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:18:04 +0700 Subject: [PATCH 19/21] chore: keep preview snapshot explicit From f9271547308d680a337b86605acf36ba75e1bef6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:19:32 +0700 Subject: [PATCH 20/21] fix: show explicit hotel snapshot state --- hotel-data-status.js | 93 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 hotel-data-status.js diff --git a/hotel-data-status.js b/hotel-data-status.js new file mode 100644 index 0000000..2fda602 --- /dev/null +++ b/hotel-data-status.js @@ -0,0 +1,93 @@ +(() => { + const DATA_URL = './data/hotels.json'; + const $ = id => document.getElementById(id); + + function ensureStatusBox() { + let box = $('hotelDataStatus'); + if (box) return box; + const searchCard = document.querySelector('.hotel-search-card'); + if (!searchCard) return null; + box = document.createElement('div'); + box.id = 'hotelDataStatus'; + box.className = 'hotel-note'; + box.style.marginTop = '-4px'; + box.style.marginBottom = '16px'; + searchCard.insertAdjacentElement('afterend', box); + return box; + } + + function applyPendingState(data) { + const box = ensureStatusBox(); + if (box) { + box.innerHTML = '⚠️ Chưa có snapshot live cho flow mới. Preview không dùng danh sách fallback giả. Sau khi merge, workflow Hotel sẽ tự discovery Google Hotels và ghi dữ liệu thật.'; + } + + if ($('discoveryMeta')) { + const maxPages = Number(data?.search?.max_pages || 0); + $('discoveryMeta').textContent = `0/${maxPages || '—'} trang API · chưa chạy live refresh`; + } + if ($('hotelResults')) { + $('hotelResults').innerHTML = '
Chưa có dữ liệu Google Hotels live.
Danh sách khách sạn sẽ xuất hiện sau lần workflow cập nhật đầu tiên.
'; + } + if ($('resultCount')) $('resultCount').textContent = '0 khách sạn · chờ refresh'; + if ($('cheapestPrice')) $('cheapestPrice').textContent = '—'; + if ($('cheapestName')) $('cheapestName').textContent = 'Chưa có snapshot live'; + if ($('groupEstimate')) $('groupEstimate').textContent = '—'; + if ($('updatedAgo')) $('updatedAgo').textContent = 'Chưa chạy'; + if ($('updatedAt')) $('updatedAt').textContent = 'Workflow sẽ chạy sau khi merge'; + } + + function applyLegacyWarning(data) { + const properties = Array.isArray(data?.properties) ? data.properties : []; + const legacy = properties.some(item => item?.catalogue_fallback || String(item?.id || '').startsWith('fallback:')); + if (!legacy && data?.search?.query === 'Shanghai hotels') return false; + const box = ensureStatusBox(); + if (box) { + box.innerHTML = '⚠️ Snapshot cũ. Dữ liệu này chưa được tạo từ discovery “Shanghai hotels”; không nên dùng để đánh giá giá hoặc khoảng cách.'; + } + return true; + } + + async function init() { + let data; + try { + const response = await fetch(`${DATA_URL}?guard=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return; + data = await response.json(); + } catch { + return; + } + + const pending = data?.status === 'pending_refresh' || data?.snapshot_state === 'preview_pending_refresh'; + if (!pending) { + applyLegacyWarning(data); + return; + } + + const results = $('hotelResults'); + const meta = $('discoveryMeta'); + let done = false; + const applyOnceMainRenderFinishes = () => { + if (done) return; + if (meta && /Đang tải/i.test(meta.textContent || '')) return; + done = true; + observer.disconnect(); + applyPendingState(data); + }; + + const observer = new MutationObserver(applyOnceMainRenderFinishes); + if (results) observer.observe(results, { childList: true, subtree: true }); + if (meta) observer.observe(meta, { childList: true, characterData: true, subtree: true }); + + setTimeout(applyOnceMainRenderFinishes, 150); + setTimeout(() => { + if (!done) { + done = true; + observer.disconnect(); + applyPendingState(data); + } + }, 1500); + } + + init(); +})(); From b2af2bad0fadb3e3014e06a2e6c9ac97bab88084 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 17:20:32 +0700 Subject: [PATCH 21/21] fix: expose hotel data status in preview --- hotels.html | 1 + 1 file changed, 1 insertion(+) diff --git a/hotels.html b/hotels.html index b3f620c..4f5f815 100644 --- a/hotels.html +++ b/hotels.html @@ -64,5 +64,6 @@ +