diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 0524986..aea8e86 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -22,7 +22,7 @@ jobs: const pr = context.payload.pull_request; const sha = pr.head.sha; const root = `https://rawcdn.githack.com/${owner}/${repo}/${sha}`; - const body = `${marker}\n## 🌍 Web preview\n\n- [Open travel dashboard](${root}/index.html)\n- [Open live flight prices](${root}/flights.html)\n\nPreview is pinned to commit \`${sha.slice(0, 7)}\` and updates automatically when the PR changes.`; + const body = `${marker}\n## 🌍 Web preview\n\n- [Open travel dashboard](${root}/index.html)\n- [Open live flight prices](${root}/flights.html)\n- [Open live hotel prices](${root}/hotels.html)\n\nPreview is pinned to commit \`${sha.slice(0, 7)}\` and updates automatically when the PR changes.`; const comments = await github.paginate(github.rest.issues.listComments, { owner, diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml new file mode 100644 index 0000000..6bcb480 --- /dev/null +++ b/.github/workflows/update-hotel-prices.yml @@ -0,0 +1,205 @@ +name: Update live hotel prices + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'scripts/fetch-hotels.mjs' + - '.github/workflows/update-hotel-prices.yml' + schedule: + # 07:27 in Vietnam (UTC+7), after the flight tracker. + - cron: '27 0 * * *' + +permissions: + contents: write + pages: write + issues: write + +concurrency: + group: live-hotel-prices-${{ github.ref }} + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check SerpApi configuration + id: config + env: + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} + 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 }} + shell: bash + run: | + if [ -z "$SERPAPI_API_KEY" ] && [ -z "$SERPAPI_API_KEY_2" ] && [ -z "$SERPAPI_API_KEY_3" ] && [ -z "$SERPAPI_BOOKING_API_KEY" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::No SerpApi credential is configured." + exit 0 + fi + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Fetch Google Hotels prices via SerpApi + id: search + if: steps.config.outputs.enabled == 'true' + env: + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} + 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 }} + shell: bash + run: | + set -euo pipefail + + keys=("$SERPAPI_API_KEY" "$SERPAPI_API_KEY_2" "$SERPAPI_API_KEY_3" "$SERPAPI_BOOKING_API_KEY") + labels=("search_1" "search_2" "search_3" "booking_fallback") + search_ok=0 + last_status=1 + + for index in "${!keys[@]}"; do + key="${keys[$index]}" + [ -z "$key" ] && continue + + echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..." + set +e + output=$(SERPAPI_API_KEY="$key" node scripts/fetch-hotels.mjs 2>&1) + status=$? + set -e + printf '%s\n' "$output" + last_status=$status + + if [ "$status" -eq 0 ]; then + search_ok=1 + echo "search_credential=${labels[$index]}" >> "$GITHUB_OUTPUT" + break + 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 + echo "::warning::Credential ${labels[$index]} failed. Trying the next configured credential." + fi + done + + if [ "$search_ok" -ne 1 ]; then + echo "::error::No SerpApi credential could complete the Google Hotels search." + exit "$last_status" + fi + + 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; } + + - name: Evaluate hotel price alert + if: steps.config.outputs.enabled == 'true' && github.ref_name == 'main' && steps.search.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + ALERT_AMOUNT: ${{ vars.HOTEL_ALERT_AMOUNT }} + ALERT_CURRENCY: ${{ vars.HOTEL_ALERT_CURRENCY }} + shell: bash + run: | + if [ -z "$ALERT_AMOUNT" ]; then + echo "No HOTEL_ALERT_AMOUNT repository variable configured; skipping GitHub Issue alert." + 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')") + 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} + + if [ -z "$CURRENT_AMOUNT" ]; then + echo "No current hotel price found; skipping alert." + exit 0 + fi + if [ "$CURRENT_CURRENCY" != "$ALERT_CURRENCY" ]; then + echo "::warning::Hotel alert currency is $ALERT_CURRENCY but tracker returned $CURRENT_CURRENCY. Comparison skipped." + exit 0 + fi + + TITLE="🏨 Hotel price alert · Shanghai 19–20 Oct 2026" + 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 + ) + if [ -n "$ISSUE" ]; then + gh issue edit "$ISSUE" --body "$BODY" + echo "Updated hotel price alert issue #$ISSUE." + else + gh issue create --title "$TITLE" --body "$BODY" + echo "Created hotel price alert issue." + fi + else + echo "Current hotel price ${CURRENT_AMOUNT} ${CURRENT_CURRENCY} is above target ${ALERT_AMOUNT} ${ALERT_CURRENCY}." + if [ -n "$ISSUE" ]; then + gh issue close "$ISSUE" --comment "Latest hotel price moved back above target: ${CURRENT_AMOUNT} ${CURRENT_CURRENCY} (target ${ALERT_AMOUNT} ${ALERT_CURRENCY})." + fi + fi + + - name: Update SerpApi credit usage snapshot + if: always() && steps.config.outputs.enabled == 'true' + env: + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} + 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 }} + run: node scripts/update-api-usage.mjs + + - name: Commit refreshed snapshots + if: always() && steps.config.outputs.enabled == 'true' + id: commit + shell: bash + run: | + if git diff --quiet -- data/hotels.json data/hotel-history.json data/api-usage.json README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No hotel/API usage changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + 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 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' + env: + GH_TOKEN: ${{ github.token }} + run: | + curl --fail-with-body -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/pages/builds" diff --git a/data/hotel-history.json b/data/hotel-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/hotel-history.json @@ -0,0 +1 @@ +[] diff --git a/data/hotels.json b/data/hotels.json new file mode 100644 index 0000000..d261a11 --- /dev/null +++ b/data/hotels.json @@ -0,0 +1,25 @@ +{ + "status": "waiting_for_refresh", + "provider": "SerpApi", + "source": "Google Hotels", + "generated_at": null, + "live_mode": false, + "disclaimer": "Run the hotel price workflow to fetch the first live Google Hotels snapshot.", + "search": { + "trip_key": "shanghai-jinling-2026-10-19__2026-10-20", + "query": "Jinling East Road Shanghai hotels", + "location_label": "Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai", + "check_in_date": "2026-10-19", + "check_out_date": "2026-10-20", + "nights": 1, + "adults_per_room": 2, + "children": 0, + "currency": "VND", + "group_rooms_estimate": 3, + "searches_per_refresh": 1, + "google_hotels_url": null + }, + "properties": [], + "cheapest_property_id": null, + "cheapest_shortlisted_property_id": null +} diff --git a/hotel-booking.css b/hotel-booking.css new file mode 100644 index 0000000..7f14ee2 --- /dev/null +++ b/hotel-booking.css @@ -0,0 +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 diff --git a/hotel-booking.js b/hotel-booking.js new file mode 100644 index 0000000..7a8a915 --- /dev/null +++ b/hotel-booking.js @@ -0,0 +1 @@ +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 diff --git a/hotels.html b/hotels.html new file mode 100644 index 0000000..01d0021 --- /dev/null +++ b/hotels.html @@ -0,0 +1,44 @@ + + + + + + + + + + + Giá khách sạn · TravelLog + + +
+ +
+
Giá phòngThượng Hải · 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.
+
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.
+
+
+
+ + + + + diff --git a/hotels/index.html b/hotels/index.html new file mode 100644 index 0000000..f412aa5 --- /dev/null +++ b/hotels/index.html @@ -0,0 +1,13 @@ + + + + + + + Giá khách sạn · TravelLog + + + +

Đang chuyển đến trang theo dõi giá khách sạn… Mở trang giá phòng

+ + diff --git a/scripts/fetch-hotels.mjs b/scripts/fetch-hotels.mjs new file mode 100644 index 0000000..780d9d2 --- /dev/null +++ b/scripts/fetch-hotels.mjs @@ -0,0 +1,258 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const API_KEY = process.env.SERPAPI_API_KEY; +if (!API_KEY) { + console.error('Missing SERPAPI_API_KEY. Add it as a GitHub Actions repository secret.'); + process.exit(2); +} + +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 SEARCH = { + q: 'Jinling East Road 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'], + ['seventh heaven'], + ['magnificent international'], + ['autoongo', 'bund'], + ['atour', 'dashijie'] + ] +}; + +function normalize(value = '') { + return String(value) + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/g, ' ') + .trim(); +} + +function isShortlisted(name) { + const normalized = normalize(name); + return SEARCH.shortlist.some(parts => parts.every(part => normalized.includes(normalize(part)))); +} + +function finiteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +async function readJson(file, fallback) { + try { return JSON.parse(await fs.readFile(file, 'utf8')); } + catch { return fallback; } +} + +async function serpapi(params) { + const url = new URL(API); + Object.entries(params).forEach(([key, value]) => { + 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 (body?.search_metadata?.status && body.search_metadata.status !== 'Success') { + throw new Error(`SerpApi search did not complete successfully: ${body.search_metadata.status}`); + } + return body; +} + +function compactPrice(price) { + if (!price) return null; + const amount = finiteNumber(price.extracted_lowest); + if (amount === null) return null; + return { + amount, + formatted: price.lowest || `${amount} ${SEARCH.currency}`, + before_taxes_fees_amount: finiteNumber(price.extracted_before_taxes_fees), + before_taxes_fees_formatted: price.before_taxes_fees || null + }; +} + +function compactSources(prices) { + return (Array.isArray(prices) ? prices : []) + .map(item => { + const rate = compactPrice(item?.rate_per_night); + return rate ? { + source: item?.source || 'Unknown source', + logo: item?.logo || null, + rate_per_night: rate + } : null; + }) + .filter(Boolean) + .sort((a, b) => a.rate_per_night.amount - b.rate_per_night.amount) + .slice(0, 8); +} + +function propertyId(raw) { + return raw?.property_token || `name:${normalize(raw?.name || 'hotel')}`; +} + +function compactProperty(raw) { + const rate = compactPrice(raw?.rate_per_night); + if (!rate) return null; + const totalRate = compactPrice(raw?.total_rate); + const image = Array.isArray(raw?.images) ? raw.images.find(item => item?.thumbnail || item?.original_image) : null; + const id = propertyId(raw); + + return { + id, + property_token: raw?.property_token || null, + name: raw?.name || 'Hotel', + description: raw?.description || null, + shortlisted: isShortlisted(raw?.name), + website_url: raw?.link || null, + image_url: image?.thumbnail || image?.original_image || 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), + 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 + }, + total_rate: totalRate ? { ...totalRate, currency: SEARCH.currency } : null, + estimated_three_rooms_amount: rate.amount * SEARCH.groupRoomsEstimate, + price_sources: compactSources(raw?.prices) + }; +} + +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 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' +}); + +const rawProperties = [ + ...(Array.isArray(body?.properties) ? body.properties : []), + ...(Array.isArray(body?.non_matching_properties) ? body.non_matching_properties : []) +]; + +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); + +const previousProperties = Array.isArray(previous?.properties) ? previous.properties : []; +for (const property of properties) { + 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) { + property.previous_rate_per_night_amount = previousAmount; + property.price_delta = property.rate_per_night.amount - previousAmount; + } else { + property.previous_rate_per_night_amount = null; + 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] + .filter(item => item.shortlisted) + .sort((a, b) => a.rate_per_night.amount - b.rate_per_night.amount)[0] || null; + +const result = { + status: properties.length ? 'ok' : '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.', + search: { + trip_key: TRIP_KEY, + query: SEARCH.q, + location_label: SEARCH.locationLabel, + check_in_date: SEARCH.checkIn, + check_out_date: SEARCH.checkOut, + nights: 1, + adults_per_room: SEARCH.adults, + 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 + }, + properties, + cheapest_property_id: cheapest?.id || null, + cheapest_shortlisted_property_id: cheapestShortlisted?.id || null +}; + +const newHistory = properties.map(property => ({ + checked_at: generatedAt, + 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, + provider: 'SerpApi', + source: 'Google Hotels' +})); + +const history = [...(Array.isArray(oldHistory) ? oldHistory : []), ...newHistory].slice(-2400); + +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.`); +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`); +} diff --git a/scripts/update-api-usage.mjs b/scripts/update-api-usage.mjs index b4d0ac5..0700202 100644 --- a/scripts/update-api-usage.mjs +++ b/scripts/update-api-usage.mjs @@ -8,9 +8,9 @@ const START = ''; const END = ''; const credentials = [ - { id: 'search_1', label: 'Flight Search #1', role: 'Theo dõi giá vé', key: process.env.SERPAPI_API_KEY }, - { id: 'search_2', label: 'Flight Search #2', role: 'Theo dõi giá vé dự phòng', key: process.env.SERPAPI_API_KEY_2 }, - { id: 'search_3', label: 'Flight Search #3', role: 'Theo dõi giá vé dự phòng', key: process.env.SERPAPI_API_KEY_3 }, + { id: 'search_1', label: 'Flight + Hotel Search #1', role: 'Theo dõi giá vé + phòng', key: process.env.SERPAPI_API_KEY }, + { id: 'search_2', label: 'Flight + Hotel Search #2', role: 'Search dự phòng', key: process.env.SERPAPI_API_KEY_2 }, + { id: 'search_3', label: 'Flight + Hotel Search #3', role: 'Search dự phòng', key: process.env.SERPAPI_API_KEY_3 }, { id: 'booking', label: 'Booking Options', role: 'Booking/baggage + search fallback', key: process.env.SERPAPI_BOOKING_API_KEY } ].filter(item => item.key); diff --git a/sw.js b/sw.js index e416d42..efc2fff 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ -const CACHE='travel-log-ui-pr-v13'; -const ASSETS=['./','./index.html','./flights.html','./flights/','./app.css','./base.css','./dashboard.css','./motion.css','./loading.css','./theme-icon.css','./flight-booking.css','./flight-booking.js','./flight-details.css','./flight-details.js','./assets/china-hero.svg','./manifest.webmanifest','./icon.svg']; +const CACHE='travel-log-ui-pr-v14'; +const ASSETS=['./','./index.html','./flights.html','./flights/','./hotels.html','./hotels/','./app.css','./base.css','./dashboard.css','./motion.css','./loading.css','./theme-icon.css','./flight-booking.css','./flight-booking.js','./flight-details.css','./flight-details.js','./hotel-booking.css','./hotel-booking.js','./data/hotels.json','./data/hotel-history.json','./assets/china-hero.svg','./manifest.webmanifest','./icon.svg']; self.addEventListener('install',event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(ASSETS)));self.skipWaiting()}); self.addEventListener('activate',event=>{event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key!==CACHE).map(key=>caches.delete(key)))));self.clients.claim()}); self.addEventListener('fetch',event=>{if(event.request.method!=='GET')return;event.respondWith(fetch(event.request).then(response=>{const copy=response.clone();caches.open(CACHE).then(cache=>cache.put(event.request,copy));return response}).catch(()=>caches.match(event.request).then(cached=>cached||caches.match('./index.html'))))});