From a32f3273920a03a8d1027a1e270de832c447499f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 19:39:12 +0700 Subject: [PATCH 1/5] feat: preserve hotel gallery and detail metadata --- scripts/fetch-hotels-with-gallery.mjs | 134 ++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 scripts/fetch-hotels-with-gallery.mjs diff --git a/scripts/fetch-hotels-with-gallery.mjs b/scripts/fetch-hotels-with-gallery.mjs new file mode 100644 index 0000000..212af9f --- /dev/null +++ b/scripts/fetch-hotels-with-gallery.mjs @@ -0,0 +1,134 @@ +import fs from 'node:fs/promises'; + +const realFetch = globalThis.fetch; +const captured = new Map(); + +function normalize(value = '') { + return String(value) + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/g, ' ') + .trim(); +} + +function propertyKey(raw) { + return raw?.property_token || `name:${normalize(raw?.name || '')}`; +} + +function compactImages(images) { + const seen = new Set(); + return (Array.isArray(images) ? images : []) + .map(item => ({ + thumbnail: item?.thumbnail || null, + original_image: item?.original_image || null + })) + .filter(item => item.thumbnail || item.original_image) + .filter(item => { + const key = item.original_image || item.thumbnail; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 12); +} + +function compactNearby(places) { + return (Array.isArray(places) ? places : []) + .slice(0, 8) + .map(place => ({ + name: place?.name || null, + transportations: (Array.isArray(place?.transportations) ? place.transportations : []) + .slice(0, 4) + .map(item => ({ type: item?.type || null, duration: item?.duration || null })) + .filter(item => item.type || item.duration) + })) + .filter(place => place.name); +} + +function capture(raw) { + if (!raw?.name && !raw?.property_token) return; + const key = propertyKey(raw); + const previous = captured.get(key) || {}; + const images = compactImages(raw?.images); + const nearby = compactNearby(raw?.nearby_places); + captured.set(key, { + name: raw?.name || previous.name || null, + property_token: raw?.property_token || previous.property_token || null, + images: images.length ? images : (previous.images || []), + phone: raw?.phone || previous.phone || null, + nearby_places: nearby.length ? nearby : (previous.nearby_places || []), + typical_price_range: raw?.typical_price_range || previous.typical_price_range || null, + essential_info: Array.isArray(raw?.essential_info) + ? raw.essential_info.slice(0, 12) + : (previous.essential_info || []), + sponsored: typeof raw?.sponsored === 'boolean' ? raw.sponsored : (previous.sponsored ?? null), + eco_certified: typeof raw?.eco_certified === 'boolean' ? raw.eco_certified : (previous.eco_certified ?? null) + }); +} + +globalThis.fetch = async (...args) => { + const response = await realFetch(...args); + try { + const url = String(args[0] || ''); + if (url.includes('serpapi.com') && url.includes('engine=google_hotels')) { + const body = await response.clone().json(); + for (const raw of [ + ...(Array.isArray(body?.properties) ? body.properties : []), + ...(Array.isArray(body?.non_matching_properties) ? body.non_matching_properties : []) + ]) capture(raw); + } + } catch (error) { + console.warn(`Could not capture hotel detail metadata: ${error.message}`); + } + return response; +}; + +try { + await import(`./fetch-hotels.mjs?gallery=${Date.now()}`); +} finally { + globalThis.fetch = realFetch; +} + +const file = new URL('../data/hotels.json', import.meta.url); +const data = JSON.parse(await fs.readFile(file, 'utf8')); +let galleryHotels = 0; +let galleryImages = 0; + +for (const property of Array.isArray(data?.properties) ? data.properties : []) { + const capturedItem = captured.get(property.property_token || `name:${normalize(property.name)}`); + if (!capturedItem) continue; + + const images = capturedItem.images || []; + if (images.length) { + property.images = images; + property.image_url = property.image_url || images[0]?.thumbnail || images[0]?.original_image || null; + property.photo_count_from_discovery = images.length; + galleryHotels += 1; + galleryImages += images.length; + } else { + property.images = Array.isArray(property.images) ? property.images : []; + property.photo_count_from_discovery = property.images.length; + } + + property.phone = capturedItem.phone || property.phone || null; + property.nearby_places = capturedItem.nearby_places?.length + ? capturedItem.nearby_places + : (Array.isArray(property.nearby_places) ? property.nearby_places : []); + property.typical_price_range = capturedItem.typical_price_range || property.typical_price_range || null; + property.essential_info = capturedItem.essential_info?.length + ? capturedItem.essential_info + : (Array.isArray(property.essential_info) ? property.essential_info : []); + property.sponsored = capturedItem.sponsored ?? property.sponsored ?? null; + property.eco_certified = capturedItem.eco_certified ?? property.eco_certified ?? null; + property.detail_available = Boolean(property.property_token); +} + +data.search = data.search || {}; +data.search.gallery_hotel_count = galleryHotels; +data.search.gallery_image_count = galleryImages; +data.search.gallery_source = 'Google Hotels discovery response'; +data.snapshot_schema = Math.max(Number(data.snapshot_schema || 0), 3); + +await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n'); +console.log(`Preserved gallery/detail metadata: ${galleryHotels} hotels · ${galleryImages} images from discovery response.`); From 3915f2518e748c616350acdbe17f90c546c1b9aa Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 19:39:56 +0700 Subject: [PATCH 2/5] feat: capture hotel gallery metadata during refresh --- .github/workflows/update-hotel-prices.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml index bfbe936..a0f868c 100644 --- a/.github/workflows/update-hotel-prices.yml +++ b/.github/workflows/update-hotel-prices.yml @@ -17,6 +17,7 @@ on: - main paths: - 'scripts/fetch-hotels.mjs' + - 'scripts/fetch-hotels-with-gallery.mjs' - '.github/workflows/update-hotel-prices.yml' schedule: # 07:27 in Vietnam (UTC+7), after the flight tracker. @@ -106,7 +107,7 @@ jobs: echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..." set +e - output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels.mjs 2>&1) + output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels-with-gallery.mjs 2>&1) status=$? set -e printf '%s\n' "$output" @@ -144,11 +145,13 @@ jobs: 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); + const galleries = Number(data?.search?.gallery_hotel_count || 0); + const images = Number(data?.search?.gallery_image_count || 0); if (!raw || !unique || !pages) { - console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages }); + console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages, galleries, images }); process.exit(4); } - console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced.`); + console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced, ${galleries} galleries / ${images} images.`); NODE - name: Evaluate hotel price alert From 68e9b54513f31194fc18e2077ce6b516ffa73a61 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 19:40:55 +0700 Subject: [PATCH 3/5] feat: add hotel detail gallery modal --- hotel-detail.js | 234 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 hotel-detail.js diff --git a/hotel-detail.js b/hotel-detail.js new file mode 100644 index 0000000..dfb4bb4 --- /dev/null +++ b/hotel-detail.js @@ -0,0 +1,234 @@ +(() => { + const detailState = { properties: [], active: null, imageIndex: 0 }; + + function escapeHtml(value = '') { + return String(value).replace(/[&<>"']/g, ch => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[ch])); + } + + function finiteNumber(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + function formatVnd(value) { + const number = finiteNumber(value); + if (number === null) return 'Chưa có giá'; + return new Intl.NumberFormat('vi-VN', { + style: 'currency', currency: 'VND', maximumFractionDigits: 0 + }).format(number).replace('₫', 'đ'); + } + + function formatNumber(value) { + const number = finiteNumber(value); + return number === null ? '—' : new Intl.NumberFormat('vi-VN').format(number); + } + + function formatDistance(value) { + const number = finiteNumber(value); + return number === null ? 'Chưa rõ' : `${number.toLocaleString('vi-VN', { maximumFractionDigits: 2 })} km`; + } + + function imageList(property) { + const seen = new Set(); + const list = []; + for (const item of Array.isArray(property?.images) ? property.images : []) { + const src = item?.original_image || item?.thumbnail; + if (!src || seen.has(src)) continue; + seen.add(src); + list.push({ src, thumb: item?.thumbnail || src }); + } + if (property?.image_url && !seen.has(property.image_url)) { + list.push({ src: property.image_url, thumb: property.image_url }); + } + return list.slice(0, 12); + } + + function getPropertyByName(name) { + return detailState.properties.find(item => item.name === name) || null; + } + + function ensureModal() { + if (document.getElementById('hotelDetailBackdrop')) return; + const backdrop = document.createElement('div'); + backdrop.id = 'hotelDetailBackdrop'; + backdrop.className = 'hotel-detail-backdrop'; + backdrop.hidden = true; + backdrop.innerHTML = ` + `; + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', event => { + if (event.target === backdrop || event.target.closest('.hotel-detail-close')) closeDetail(); + const thumb = event.target.closest('[data-detail-image]'); + if (thumb) { + detailState.imageIndex = Number(thumb.dataset.detailImage || 0); + renderGalleryOnly(); + } + if (event.target.closest('[data-gallery-prev]')) { + const images = imageList(detailState.active); + if (images.length) detailState.imageIndex = (detailState.imageIndex - 1 + images.length) % images.length; + renderGalleryOnly(); + } + if (event.target.closest('[data-gallery-next]')) { + const images = imageList(detailState.active); + if (images.length) detailState.imageIndex = (detailState.imageIndex + 1) % images.length; + renderGalleryOnly(); + } + }); + } + + function renderGallery(property) { + const images = imageList(property); + if (!images.length) { + return `
🏨Google Hotels chưa trả ảnh cho khách sạn này trong snapshot hiện tại.Thông tin chi tiết vẫn có thể xem bên dưới.
`; + } + detailState.imageIndex = Math.min(detailState.imageIndex, images.length - 1); + const current = images[detailState.imageIndex]; + const controls = images.length > 1 + ? `` + : ''; + const thumbs = images.map((image, index) => ` + `).join(''); + return `
${thumbs}
`; + } + + function renderGalleryOnly() { + if (!detailState.active) return; + const target = document.getElementById('hotelDetailGallery'); + if (target) target.innerHTML = renderGallery(detailState.active); + } + + function infoRow(label, value) { + if (!value) return ''; + return `
${escapeHtml(label)}${value}
`; + } + + function renderDetail(property) { + const amount = finiteNumber(property?.rate_per_night?.amount); + const rating = finiteNumber(property?.overall_rating); + const reviews = finiteNumber(property?.reviews); + const stars = finiteNumber(property?.stars); + const amenities = (property?.amenities || []).map(item => `${escapeHtml(item)}`).join(''); + const essentials = (property?.essential_info || []).map(item => { + const text = typeof item === 'string' ? item : (item?.title || item?.name || item?.description || ''); + return text ? `${escapeHtml(text)}` : ''; + }).join(''); + const nearby = (property?.nearby_places || []).map(place => { + const transport = (place.transportations || []).map(item => [item.type, item.duration].filter(Boolean).join(' · ')).filter(Boolean).join(' / '); + return `
  • ${escapeHtml(place.name)}${transport ? `${escapeHtml(transport)}` : ''}
  • `; + }).join(''); + const sources = (property?.price_sources || []).map(source => { + const label = `${source.source || 'Nguồn đặt phòng'} · ${formatVnd(source?.rate_per_night?.amount)}`; + return source.link + ? `${escapeHtml(label)}` + : `${escapeHtml(label)}`; + }).join(''); + const phone = property?.phone + ? `${escapeHtml(property.phone)}` + : ''; + const website = property?.website_url + ? `Mở trang khách sạn` + : ''; + + document.getElementById('hotelDetailTitle').textContent = property.name; + document.getElementById('hotelDetailBody').innerHTML = ` + +
    +
    Giá hiện tại${formatVnd(amount)}${amount !== null ? '/ phòng / đêm · 2 người lớn' : 'Google Hotels chưa trả giá live'}
    +
    Rating${rating !== null ? `★ ${rating}` : '—'}${reviews !== null ? `${formatNumber(reviews)} review` : 'Chưa có review'}
    +
    Khoảng cách${formatDistance(property.distance_from_anchor_km)}từ 531 Jinling East Road
    +
    Hạng${stars !== null ? `${stars} sao` : '—'}${escapeHtml(property.area || 'Chưa rõ khu vực')}
    +
    + ${property.description ? `

    ${escapeHtml(property.description)}

    ` : ''} +
    + ${infoRow('Địa chỉ', escapeHtml(property.address || ''))} + ${infoRow('Điện thoại', phone)} + ${infoRow('Check-in', escapeHtml(property.check_in_time || ''))} + ${infoRow('Check-out', escapeHtml(property.check_out_time || ''))} + ${infoRow('Khoảng giá thường gặp', escapeHtml(typeof property.typical_price_range === 'string' ? property.typical_price_range : ''))} +
    + ${amenities || essentials ? `

    Tiện nghi & thông tin

    ${amenities}${essentials}
    ` : ''} + ${nearby ? `

    Địa điểm gần đó

      ${nearby}
    ` : ''} + ${sources ? `

    Nguồn giá đang có

    ${sources}
    ` : ''} +
    Gallery và chi tiết lấy từ snapshot Google Hotels hiện tại; mở modal không phát sinh thêm SerpApi request.${website}
    `; + } + + function openDetail(property) { + ensureModal(); + detailState.active = property; + detailState.imageIndex = 0; + renderDetail(property); + const backdrop = document.getElementById('hotelDetailBackdrop'); + backdrop.hidden = false; + document.body.classList.add('hotel-detail-open'); + backdrop.querySelector('.hotel-detail-close')?.focus(); + } + + function closeDetail() { + const backdrop = document.getElementById('hotelDetailBackdrop'); + if (backdrop) backdrop.hidden = true; + document.body.classList.remove('hotel-detail-open'); + detailState.active = null; + } + + function enhanceCards() { + document.querySelectorAll('#hotelResults .hotel-card').forEach(card => { + if (card.dataset.detailReady === 'true') return; + const name = card.querySelector('h3')?.textContent?.trim(); + const property = getPropertyByName(name); + const actions = card.querySelector('.hotel-card-actions'); + if (!property || !actions) return; + const count = imageList(property).length; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn sm secondary hotel-detail-btn'; + button.dataset.hotelDetail = property.id; + button.textContent = count ? `Chi tiết · ${count} ảnh` : 'Chi tiết'; + actions.insertBefore(button, actions.firstChild); + card.dataset.detailReady = 'true'; + }); + } + + async function loadDetails() { + try { + const response = await fetch(`./data/hotels.json?v=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return; + const data = await response.json(); + detailState.properties = Array.isArray(data?.properties) ? data.properties : []; + enhanceCards(); + } catch (error) { + console.warn('Hotel detail data unavailable:', error); + } + } + + document.addEventListener('click', event => { + const button = event.target.closest('[data-hotel-detail]'); + if (!button) return; + const property = detailState.properties.find(item => item.id === button.dataset.hotelDetail); + if (property) openDetail(property); + }); + + document.addEventListener('keydown', event => { + const backdrop = document.getElementById('hotelDetailBackdrop'); + if (!backdrop || backdrop.hidden) return; + if (event.key === 'Escape') closeDetail(); + if (event.key === 'ArrowLeft') backdrop.querySelector('[data-gallery-prev]')?.click(); + if (event.key === 'ArrowRight') backdrop.querySelector('[data-gallery-next]')?.click(); + }); + + const results = document.getElementById('hotelResults'); + if (results) new MutationObserver(enhanceCards).observe(results, { childList: true, subtree: true }); + ensureModal(); + loadDetails(); +})(); From 50efd70046db64724a1f02df8f4c30db7e8b48a4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 19:41:18 +0700 Subject: [PATCH 4/5] style: add hotel detail gallery modal --- hotel-detail.css | 1 + 1 file changed, 1 insertion(+) create mode 100644 hotel-detail.css diff --git a/hotel-detail.css b/hotel-detail.css new file mode 100644 index 0000000..40abc51 --- /dev/null +++ b/hotel-detail.css @@ -0,0 +1 @@ +body.hotel-detail-open{overflow:hidden}.hotel-detail-backdrop{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px;background:rgba(15,23,42,.58);backdrop-filter:blur(8px)}.hotel-detail-backdrop[hidden]{display:none}.hotel-detail-modal{width:min(1040px,100%);max-height:min(900px,calc(100vh - 56px));overflow:auto;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:0 30px 90px rgba(15,23,42,.28)}.hotel-detail-header{position:sticky;top:0;z-index:4;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--surface) 94%,transparent);backdrop-filter:blur(16px)}.hotel-detail-header h2{margin:4px 0 0;font-size:21px;letter-spacing:-.025em}.hotel-detail-close{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--text);font-size:25px;line-height:1;cursor:pointer}.hotel-detail-body{padding:20px}.hotel-detail-gallery{margin-bottom:18px}.hotel-detail-gallery-main{position:relative;display:grid;place-items:center;height:min(470px,52vw);min-height:300px;overflow:hidden;border-radius:15px;background:var(--surface-2)}.hotel-detail-gallery-main>img{width:100%;height:100%;object-fit:cover}.gallery-arrow{position:absolute;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:42px;height:42px;border:0;border-radius:999px;background:rgba(15,23,42,.7);color:#fff;font-size:31px;cursor:pointer;backdrop-filter:blur(8px)}.gallery-arrow.prev{left:14px}.gallery-arrow.next{right:14px}.gallery-count{position:absolute;right:14px;bottom:13px;padding:6px 9px;border-radius:999px;background:rgba(15,23,42,.72);color:#fff;font-size:10px;font-weight:800}.hotel-detail-thumbs{display:grid;grid-auto-flow:column;grid-auto-columns:96px;gap:8px;margin-top:9px;overflow-x:auto;padding-bottom:4px}.hotel-detail-thumb{height:66px;padding:0;overflow:hidden;border:2px solid transparent;border-radius:9px;background:var(--surface-2);cursor:pointer}.hotel-detail-thumb.active{border-color:var(--primary)}.hotel-detail-thumb img{width:100%;height:100%;object-fit:cover}.hotel-detail-no-photo{display:flex;min-height:280px;flex-direction:column;align-items:center;justify-content:center;gap:8px;border-radius:15px;background:var(--surface-2);color:var(--muted);text-align:center;font-size:12px}.hotel-detail-no-photo:first-letter{font-size:42px}.hotel-detail-no-photo strong{color:var(--text);font-size:14px}.hotel-detail-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:17px}.hotel-detail-summary>div{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}.hotel-detail-summary span,.hotel-detail-summary small{display:block;color:var(--muted);font-size:10px}.hotel-detail-summary strong{display:block;margin:5px 0 3px;font-size:16px}.hotel-detail-description{margin:0 0 17px;color:var(--muted);font-size:12px;line-height:1.65}.hotel-detail-info-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.hotel-detail-info{padding:12px 13px;border:1px solid var(--line);border-radius:11px}.hotel-detail-info span{display:block;margin-bottom:4px;color:var(--muted);font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}.hotel-detail-info strong{font-size:12px;line-height:1.5}.hotel-detail-info a{color:var(--primary);text-decoration:none}.hotel-detail-section{margin-top:19px}.hotel-detail-section h3{margin:0 0 10px;font-size:13px}.detail-chips,.detail-sources{display:flex;flex-wrap:wrap;gap:7px}.detail-chip,.detail-source{display:inline-flex;padding:7px 9px;border:1px solid var(--line);border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:10px;text-decoration:none}.detail-source[href]{color:var(--primary)}.hotel-nearby-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0;padding:0;list-style:none}.hotel-nearby-list li{padding:11px 12px;border:1px solid var(--line);border-radius:10px}.hotel-nearby-list strong,.hotel-nearby-list span{display:block;font-size:10.5px}.hotel-nearby-list span{margin-top:4px;color:var(--muted)}.hotel-detail-footer{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:21px;padding-top:15px;border-top:1px solid var(--line)}.hotel-detail-footer>span{max-width:650px;color:var(--muted);font-size:9.5px;line-height:1.5}.hotel-detail-btn{white-space:nowrap}@media(max-width:760px){.hotel-detail-backdrop{padding:0;place-items:end stretch}.hotel-detail-modal{width:100%;max-height:94vh;border-radius:18px 18px 0 0}.hotel-detail-header{padding:15px}.hotel-detail-body{padding:14px}.hotel-detail-gallery-main{height:54vw;min-height:230px}.hotel-detail-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.hotel-detail-info-grid,.hotel-nearby-list{grid-template-columns:1fr}.hotel-detail-footer{align-items:stretch;flex-direction:column}.hotel-detail-footer .btn{justify-content:center}} \ No newline at end of file From 3c51bda652685edcf57a3002b8109c63fb19fa8e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Aug 2026 19:42:10 +0700 Subject: [PATCH 5/5] feat: wire hotel gallery detail modal --- hotels.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hotels.html b/hotels.html index 4f5f815..170203c 100644 --- a/hotels.html +++ b/hotels.html @@ -8,6 +8,7 @@ + Giá khách sạn · TravelLog @@ -65,5 +66,6 @@ +