diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index aea8e86..10d22c3 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- [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 body = `${marker}\n## 🌍 Web preview\n\n- [Open travel dashboard](${root}/index.html)\n- [Open live flight prices](${root}/flights.html)\n- [Open flight status tracker](${root}/flight-status.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-flight-status.yml b/.github/workflows/update-flight-status.yml new file mode 100644 index 0000000..b494a01 --- /dev/null +++ b/.github/workflows/update-flight-status.yml @@ -0,0 +1,71 @@ +name: Update flight status + +on: + workflow_dispatch: + schedule: + # Daily baseline/live check at 07:37 Vietnam time. + - cron: '37 0 * * *' + # Hourly around the two travel dates. The script skips dates outside 2026 targets. + - cron: '37 * 18-20,25-27 10 *' + +permissions: + contents: write + pages: write + +concurrency: + group: flight-status-${{ 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 tracked flight status + 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 }} + FLIGHT_STATUS_LIVE_WINDOW_DAYS: '7' + FORCE_FLIGHT_STATUS: ${{ github.event_name == 'workflow_dispatch' && 'true' || 'false' }} + run: node scripts/fetch-flight-status.mjs + + - name: Commit status snapshot + id: commit + shell: bash + run: | + if git diff --quiet -- data/flight-status.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No flight-status 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/flight-status.json + git commit -m "chore: refresh tracked flight status [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Request GitHub Pages rebuild + if: 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/flight-status.json b/data/flight-status.json new file mode 100644 index 0000000..a8936d9 --- /dev/null +++ b/data/flight-status.json @@ -0,0 +1,106 @@ +{ + "generated_at": "2026-08-29T08:42:00.000Z", + "provider": "TravelLog schedule baseline", + "live_mode": false, + "message": "Live operational status is usually published closer to departure. The tracker will start automatic live checks 7 days before each flight and can also be run manually.", + "flights": [ + { + "id": "VJ3900-2026-10-19", + "flight_number": "VJ3900", + "display_flight_number": "VJ 3900", + "date": "2026-10-19", + "airline": "Vietjet", + "airline_iata_code": "VJ", + "airline_logo": "https://www.gstatic.com/flights/airline_logos/70px/VJ.png", + "aircraft": "Airbus A321", + "route": { + "origin": "SGN", + "origin_name": "TP. Hồ Chí Minh", + "destination": "PVG", + "destination_name": "Thượng Hải Pudong" + }, + "status": { + "code": "scheduled", + "label": "Theo lịch", + "raw": "Scheduled", + "live": false + }, + "departure": { + "scheduled": "2026-10-19T20:00:00+07:00", + "estimated": null, + "actual": null, + "time_zone": "Asia/Ho_Chi_Minh", + "terminal": null, + "gate": null, + "delay_minutes": 0 + }, + "arrival": { + "scheduled": "2026-10-20T01:00:00+08:00", + "estimated": null, + "actual": null, + "time_zone": "Asia/Shanghai", + "terminal": null, + "gate": null, + "delay_minutes": 0 + }, + "schedule_source": "Google Flights snapshot in TravelLog", + "checked_at": null, + "latest_update": null, + "next_live_check_at": "2026-10-12T00:00:00.000Z", + "provider_sources": [], + "external_links": { + "google": "https://www.google.com/search?q=VJ3900+flight+status", + "airline": "https://www.vietjetair.com/" + } + }, + { + "id": "CZ3106-2026-10-26", + "flight_number": "CZ3106", + "display_flight_number": "CZ 3106", + "date": "2026-10-26", + "airline": "China Southern Airlines", + "airline_iata_code": "CZ", + "airline_logo": "https://www.gstatic.com/flights/airline_logos/70px/CZ.png", + "aircraft": "Airbus A350", + "route": { + "origin": "PKX", + "origin_name": "Bắc Kinh Daxing", + "destination": "CAN", + "destination_name": "Quảng Châu Baiyun" + }, + "status": { + "code": "scheduled", + "label": "Theo lịch", + "raw": "Scheduled", + "live": false + }, + "departure": { + "scheduled": "2026-10-26T13:30:00+08:00", + "estimated": null, + "actual": null, + "time_zone": "Asia/Shanghai", + "terminal": null, + "gate": null, + "delay_minutes": 0 + }, + "arrival": { + "scheduled": "2026-10-26T16:45:00+08:00", + "estimated": null, + "actual": null, + "time_zone": "Asia/Shanghai", + "terminal": "2", + "gate": null, + "delay_minutes": 0 + }, + "schedule_source": "Booked itinerary", + "checked_at": null, + "latest_update": null, + "next_live_check_at": "2026-10-19T00:00:00.000Z", + "provider_sources": [], + "external_links": { + "google": "https://www.google.com/search?q=CZ3106+flight+status", + "airline": "https://www.csair.com/cn/status/" + } + } + ] +} diff --git a/flight-status.css b/flight-status.css new file mode 100644 index 0000000..5a4cab0 --- /dev/null +++ b/flight-status.css @@ -0,0 +1,59 @@ +.status-content{width:min(1120px,calc(100% - 44px))} +.status-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:14px} +.status-heading h1{margin:4px 0 5px;font-size:27px;line-height:1.15;letter-spacing:-.035em} +.status-heading p{margin:0;color:var(--muted);font-size:12.5px;max-width:680px} +.status-kicker{font-size:10.5px;color:var(--primary);font-weight:800;text-transform:uppercase;letter-spacing:.07em} +.status-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.status-overview{display:grid;grid-template-columns:1.1fr .9fr .9fr;overflow:hidden;margin-bottom:12px} +.status-overview-item{padding:16px 18px;min-height:86px;border-right:1px solid var(--line)} +.status-overview-item:last-child{border-right:0} +.status-overview-item span{display:block;font-size:10.5px;color:var(--muted);font-weight:650} +.status-overview-item strong{display:block;font-size:16px;margin-top:5px;letter-spacing:-.015em} +.status-overview-item small{display:block;color:var(--muted);font-size:10.5px;margin-top:2px} +.status-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.status-card{overflow:hidden} +.status-card-head{padding:16px 17px 13px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-bottom:1px solid var(--line)} +.status-airline{display:flex;align-items:center;gap:10px;min-width:0} +.status-airline-logo{width:36px;height:36px;border:1px solid var(--line);border-radius:10px;background:#fff;display:grid;place-items:center;overflow:hidden;flex:0 0 auto} +.status-airline-logo img{max-width:29px;max-height:29px} +.status-airline strong{display:block;font-size:14px} +.status-airline span{display:block;font-size:10.5px;color:var(--muted);margin-top:1px} +.status-badge{display:inline-flex;align-items:center;gap:6px;min-height:25px;padding:0 9px;border-radius:999px;font-size:10.5px;font-weight:800;white-space:nowrap;background:var(--primary-soft);color:var(--primary)} +.status-badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor} +.status-badge.on_time,.status-badge.landed{background:var(--success-soft);color:var(--success)} +.status-badge.delayed{background:var(--warning-soft);color:var(--warning)} +.status-badge.cancelled{background:color-mix(in srgb,var(--danger) 12%,var(--surface));color:var(--danger)} +.status-badge.in_air,.status-badge.boarding,.status-badge.departed{background:var(--primary-soft);color:var(--primary)} +.status-route{padding:18px 17px 13px;display:grid;grid-template-columns:1fr minmax(105px,.8fr) 1fr;align-items:center;gap:10px} +.status-airport strong{display:block;font-size:24px;letter-spacing:-.035em;line-height:1} +.status-airport span{display:block;color:var(--muted);font-size:10.5px;margin-top:5px} +.status-airport.right{text-align:right} +.status-path{text-align:center;color:var(--muted)} +.status-path-line{height:1px;background:var(--line-2);position:relative;margin:8px 5px} +.status-path-line:after{content:"✈";position:absolute;left:50%;top:50%;transform:translate(-50%,-53%);background:var(--surface);padding:0 6px;color:var(--primary);font-size:14px} +.status-path small{font-size:9.5px;color:var(--muted)} +.status-times{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-2)} +.status-time{padding:13px 17px} +.status-time+ .status-time{border-left:1px solid var(--line)} +.status-time span{display:block;color:var(--muted);font-size:10px;font-weight:650} +.status-time strong{display:block;font-size:17px;margin-top:3px} +.status-time small{display:block;color:var(--muted);font-size:10.5px;margin-top:2px} +.status-live-grid{display:grid;grid-template-columns:repeat(4,1fr);padding:12px 17px;gap:8px} +.status-detail{min-width:0} +.status-detail span{display:block;font-size:9.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-weight:700} +.status-detail strong{display:block;font-size:11.5px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.status-note{margin:0 17px 13px;padding:10px 11px;border-radius:9px;background:var(--surface-2);border:1px solid var(--line);color:var(--muted);font-size:10.5px} +.status-note.live{background:var(--success-soft);border-color:transparent;color:var(--success)} +.status-card-foot{padding:0 17px 16px;display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.status-card-foot .status-source{margin-left:auto;color:var(--muted);font-size:9.5px;text-align:right} +.status-help{margin-top:12px;padding:16px 18px;display:grid;grid-template-columns:1.1fr .9fr;gap:22px} +.status-help h2{font-size:14px;margin:0 0 5px} +.status-help p{margin:0;color:var(--muted);font-size:11.5px} +.status-help-list{display:grid;gap:7px} +.status-help-item{display:flex;align-items:flex-start;gap:8px;font-size:10.5px;color:var(--muted)} +.status-help-item b{color:var(--text)} +.status-help-dot{width:7px;height:7px;border-radius:50%;background:var(--primary);margin-top:4px;flex:0 0 auto} +.status-loading{padding:34px;text-align:center;color:var(--muted)} +.status-error{padding:18px;color:var(--danger);text-align:center} +@media(max-width:850px){.status-grid{grid-template-columns:1fr}.status-help{grid-template-columns:1fr}.status-live-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:700px){.status-content{width:min(100% - 24px,1120px);padding-top:16px}.status-heading{display:block}.status-actions{margin-top:12px}.status-overview{grid-template-columns:1fr}.status-overview-item{border-right:0;border-bottom:1px solid var(--line);min-height:auto}.status-overview-item:last-child{border-bottom:0}.status-route{grid-template-columns:1fr 90px 1fr}.status-airport strong{font-size:21px}.status-card-foot .status-source{width:100%;margin-left:0;text-align:left}.status-heading h1{font-size:24px}} diff --git a/flight-status.html b/flight-status.html new file mode 100644 index 0000000..98fad3f --- /dev/null +++ b/flight-status.html @@ -0,0 +1,76 @@ + + + + + + + + + + + Trạng thái chuyến bay · TravelLog + + +
+ +
+
+
Trạng thái chuyến bayHành trình Trung Quốc 2026
+ +
+
TravelLog
+
+
+
Flight status

Trạng thái chuyến bay đã đặt

Theo dõi lịch bay và trạng thái vận hành cho hai chuyến quan trọng trong hành trình.

+ +
+ +
+
CHUYẾN ĐANG THEO DÕI2 chuyếnVJ 3900 · CZ 3106
+
TRẠNG THÁI DỮ LIỆUĐang tải…0 chuyến có dữ liệu live
+
CẬP NHẬT GẦN NHẤTGiờ Việt Nam (UTC+7)
+
+ +
Đang tải trạng thái chuyến bay…
+ +
+

Khi nào trạng thái live xuất hiện?

Trạng thái vận hành như đúng giờ, trễ, gate, boarding hoặc đã cất cánh thường chỉ đáng tin cậy khi gần ngày bay. TravelLog giữ nguyên lịch đã đặt làm mốc và không lấy dữ liệu của ngày khác để thay thế.

+
+
Trước 7 ngày: bắt đầu kiểm tra tự động mỗi ngày.
+
Quanh ngày bay: kiểm tra tự động mỗi giờ.
+
Kiểm tra ngay: chạy thủ công GitHub Action bất cứ lúc nào.
+
+
+
+
+
+ + + + + diff --git a/flight-status.js b/flight-status.js new file mode 100644 index 0000000..ba56316 --- /dev/null +++ b/flight-status.js @@ -0,0 +1,144 @@ +(()=>{ + const $=selector=>document.querySelector(selector); + const esc=value=>String(value??'').replace(/[&<>'"]/g,char=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); + + const syncThemeUI=()=>{ + const dark=document.documentElement.dataset.theme==='dark'; + document.querySelectorAll('[data-theme-toggle]').forEach(button=>{ + const use=button.querySelector('use'); + if(use) use.setAttribute('href',dark?'#i-sun':'#i-moon'); + const label=dark?'Chuyển sang giao diện sáng':'Chuyển sang giao diện tối'; + button.setAttribute('aria-label',label); + button.setAttribute('title',label); + }); + }; + const 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(); + + function formatIso(value,timeZone,options={}){ + if(!value) return '—'; + const date=new Date(value); + if(Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat('vi-VN',{ + timeZone, + day:'2-digit',month:'2-digit', + hour:'2-digit',minute:'2-digit',hour12:false, + ...options + }).format(date).replace(',', ' ·'); + } + + function formatDate(value){ + if(!value) return '—'; + const date=new Date(`${value}T12:00:00Z`); + return new Intl.DateTimeFormat('vi-VN',{day:'2-digit',month:'2-digit',year:'numeric',timeZone:'UTC'}).format(date); + } + + function formatChecked(value){ + if(!value) return 'Chưa kiểm tra live'; + const date=new Date(value); + if(Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat('vi-VN',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit',hour12:false,timeZone:'Asia/Ho_Chi_Minh'}).format(date); + } + + function countdown(iso){ + const target=new Date(iso); + const diff=target-Date.now(); + if(diff<=0) return 'Đã đến ngày bay'; + const days=Math.ceil(diff/86400000); + return days===1?'Còn 1 ngày':`Còn ${days} ngày`; + } + + function chosenTime(part){ + const value=part.actual||part.estimated||part.scheduled; + const label=part.actual?'Thực tế':part.estimated?'Dự kiến':'Theo lịch'; + return {value,label}; + } + + function delayText(minutes){ + const n=Number(minutes||0); + if(n>0) return `Trễ ${n} phút`; + if(n<0) return `Sớm ${Math.abs(n)} phút`; + return 'Chưa ghi nhận trễ'; + } + + function liveNote(flight){ + if(flight.status?.live){ + const update=flight.latest_update?` · ${esc(flight.latest_update)}`:''; + return `
Đã có dữ liệu vận hành live${update}. Vẫn nên kiểm tra lại với hãng trước khi ra sân bay.
`; + } + const next=flight.next_live_check_at?formatIso(flight.next_live_check_at,'Asia/Ho_Chi_Minh',{year:'numeric'}):'gần ngày bay'; + return `
Chưa có trạng thái vận hành live cho đúng ngày bay. Hệ thống đang dùng lịch đã xác nhận làm mốc; tự động bắt đầu kiểm tra live từ ${esc(next)}.
`; + } + + function sourceLinks(flight){ + const links=[]; + if(flight.external_links?.google) links.push(`Google status`); + if(flight.external_links?.airline) links.push(`Website hãng`); + for(const source of flight.provider_sources||[]){ + if(source.link) links.push(`${esc(source.name||'Nguồn live')}`); + } + return links.join(''); + } + + function card(flight){ + const dep=chosenTime(flight.departure||{}),arr=chosenTime(flight.arrival||{}); + const depDisplay=formatIso(dep.value,flight.departure?.time_zone); + const arrDisplay=formatIso(arr.value,flight.arrival?.time_zone); + const depScheduled=formatIso(flight.departure?.scheduled,flight.departure?.time_zone); + const arrScheduled=formatIso(flight.arrival?.scheduled,flight.arrival?.time_zone); + const statusCode=flight.status?.code||'scheduled'; + return `
+
+
+ +
${esc(flight.display_flight_number||flight.flight_number)}${esc(flight.airline)} · ${esc(formatDate(flight.date))}
+
+ ${esc(flight.status?.label||'Theo lịch')} +
+
+
${esc(flight.route?.origin)}${esc(flight.route?.origin_name)}
+
${esc(countdown(flight.departure?.scheduled))}
${esc(flight.aircraft||'Máy bay chưa xác định')}
+
${esc(flight.route?.destination)}${esc(flight.route?.destination_name)}
+
+
+
KHỞI HÀNH · ${esc(dep.label)}${esc(depDisplay)}${dep.label==='Theo lịch'?'Giờ địa phương':`Theo lịch ${esc(depScheduled)}`}
+
ĐẾN · ${esc(arr.label)}${esc(arrDisplay)}${arr.label==='Theo lịch'?'Giờ địa phương':`Theo lịch ${esc(arrScheduled)}`}
+
+
+
Terminal đi${esc(flight.departure?.terminal||'—')}
+
Gate đi${esc(flight.departure?.gate||'—')}
+
Terminal đến${esc(flight.arrival?.terminal||'—')}
+
Độ trễ${esc(delayText(Math.max(Number(flight.departure?.delay_minutes||0),Number(flight.arrival?.delay_minutes||0))))}
+
+ ${liveNote(flight)} +
${sourceLinks(flight)}Lịch gốc: ${esc(flight.schedule_source||'TravelLog')}
Live check: ${esc(formatChecked(flight.checked_at))}
+
`; + } + + async function load(){ + try{ + const response=await fetch(`./data/flight-status.json?t=${Date.now()}`,{cache:'no-store'}); + if(!response.ok) throw new Error(`HTTP ${response.status}`); + const data=await response.json(); + const flights=Array.isArray(data.flights)?data.flights:[]; + $('#statusGrid').innerHTML=flights.length?flights.map(card).join(''):'
Chưa cấu hình chuyến bay cần theo dõi.
'; + $('#trackedCount').textContent=String(flights.length); + $('#liveCount').textContent=String(flights.filter(f=>f.status?.live).length); + $('#updatedAt').textContent=formatChecked(data.generated_at); + $('#dataMode').textContent=data.live_mode?'Có dữ liệu live':'Đang dùng lịch gốc'; + $('#statusMessage').textContent=data.message||'Trạng thái sẽ được cập nhật gần ngày bay.'; + }catch(error){ + $('#statusGrid').innerHTML='
Không thể tải dữ liệu trạng thái chuyến bay. Hãy thử tải lại trang.
'; + $('#dataMode').textContent='Dữ liệu không khả dụng'; + console.error(error); + } + } + + load(); +})(); diff --git a/scripts/fetch-flight-status.mjs b/scripts/fetch-flight-status.mjs new file mode 100644 index 0000000..a9d96f1 --- /dev/null +++ b/scripts/fetch-flight-status.mjs @@ -0,0 +1,233 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const OUTPUT = path.resolve('data/flight-status.json'); +const LIVE_WINDOW_DAYS = Number(process.env.FLIGHT_STATUS_LIVE_WINDOW_DAYS || 7); +const FORCE = /^(1|true|yes|on)$/i.test(process.env.FORCE_FLIGHT_STATUS || ''); + +const KEYS = [ + process.env.SERPAPI_API_KEY, + process.env.SERPAPI_API_KEY_2, + process.env.SERPAPI_API_KEY_3, + process.env.SERPAPI_BOOKING_API_KEY +].filter(Boolean); + +const configs = [ + { + id: 'VJ3900-2026-10-19', + flightNumber: 'VJ3900', + date: '2026-10-19', + origin: 'SGN', + destination: 'PVG' + }, + { + id: 'CZ3106-2026-10-26', + flightNumber: 'CZ3106', + date: '2026-10-26', + origin: 'PKX', + destination: 'CAN' + } +]; + +const normalizeFlight = value => String(value || '').replace(/[^A-Z0-9]/gi, '').toUpperCase(); + +function dayDiff(targetDate) { + const today = new Date(); + const target = new Date(`${targetDate}T00:00:00Z`); + const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); + return Math.ceil((target.getTime() - todayUtc) / 86400000); +} + +function normalizeStatus(raw, departureDelay = 0, arrivalDelay = 0) { + const text = String(raw || '').toLowerCase(); + if (text.includes('cancel')) return ['cancelled', 'Đã hủy']; + if (text.includes('land') || text.includes('arriv')) return ['landed', 'Đã hạ cánh']; + if (text.includes('airborne') || text.includes('in air') || text.includes('in_air')) return ['in_air', 'Đang bay']; + if (text.includes('depart')) return ['departed', 'Đã khởi hành']; + if (text.includes('board')) return ['boarding', 'Đang lên máy bay']; + if (text.includes('delay') || text.includes('late') || Number(departureDelay) > 0 || Number(arrivalDelay) > 0) return ['delayed', 'Bị trễ']; + if (text.includes('on time') || text.includes('ontime')) return ['on_time', 'Đúng giờ']; + if (text.includes('schedule')) return ['scheduled', 'Theo lịch']; + return ['scheduled', 'Theo lịch']; +} + +function isQuotaError(message) { + return /429|quota|rate.?limit|monthly.?search|search(es)?.?limit|credit/i.test(String(message || '')); +} + +async function serpSearch(config) { + if (!KEYS.length) throw new Error('No SerpApi credential is configured.'); + const q = `${config.flightNumber} ${config.date} flight status`; + let lastError; + + for (let i = 0; i < KEYS.length; i++) { + const url = new URL('https://serpapi.com/search.json'); + url.searchParams.set('engine', 'google'); + url.searchParams.set('q', q); + url.searchParams.set('hl', 'en'); + url.searchParams.set('gl', 'us'); + url.searchParams.set('api_key', KEYS[i]); + + try { + const response = await fetch(url, { headers: { 'user-agent': 'TravelLog-FlightStatus/1.0' } }); + const json = await response.json(); + if (!response.ok || json.error) { + const message = json.error || `SerpApi HTTP ${response.status}`; + lastError = new Error(message); + if (isQuotaError(message)) continue; + throw lastError; + } + return json; + } catch (error) { + lastError = error; + if (isQuotaError(error?.message)) continue; + throw error; + } + } + throw lastError || new Error('All SerpApi credentials failed.'); +} + +function selectFlightResult(json, config) { + const result = json?.flight_result; + if (!result) return null; + const exactDate = (result.dates || []).find(item => item?.date === config.date); + if (!exactDate) return null; + + const metadata = exactDate.metadata || {}; + const designator = normalizeFlight(result.flight_designator || `${metadata.airline_iata_code || ''}${metadata.flight_number || ''}`); + if (designator && designator !== normalizeFlight(config.flightNumber)) return null; + if (metadata.origin && metadata.origin !== config.origin) return null; + if (metadata.destination && metadata.destination !== config.destination) return null; + + return { + rawStatus: metadata.status || exactDate.status || 'SCHEDULED_STATUS', + departureDelay: Number(metadata.departure_delay || 0), + arrivalDelay: Number(metadata.arrival_delay || 0), + departure: exactDate.departure || null, + arrival: exactDate.arrival || null, + latestUpdate: exactDate.latest_update || result.latest_update || null, + sources: result.sources || [] + }; +} + +function dateLooksExact(text, target) { + if (!text) return false; + const targetDate = new Date(`${target}T12:00:00Z`); + const month = targetDate.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }).toLowerCase(); + const day = String(targetDate.getUTCDate()); + const value = String(text).toLowerCase(); + return value.includes(month) && new RegExp(`(^|\\D)0?${day}(\\D|$)`).test(value); +} + +function selectAnswerBox(json, config) { + const box = json?.answer_box; + if (!box || box.type !== 'flight_status') return null; + const candidates = Array.isArray(box.flights) ? box.flights : [box]; + for (const item of candidates) { + const num = normalizeFlight(item.flight_number || item.flight_name || box.flight_number || box.title); + if (num && !num.includes(normalizeFlight(config.flightNumber))) continue; + const dep = item.departure || {}; + const arr = item.arrival || {}; + if (dep.airport_name && dep.airport_name !== config.origin) continue; + if (arr.airport_name && arr.airport_name !== config.destination) continue; + if (!dateLooksExact(dep.date || dep.time, config.date)) continue; + return { + rawStatus: item.flight_status || box.flight_status || 'Scheduled', + departureDelay: 0, + arrivalDelay: 0, + departure: dep, + arrival: arr, + latestUpdate: item.latest_update || box.latest_update || null, + sources: item.sources || box.sources || [] + }; + } + return null; +} + +function firstValue(obj, names) { + for (const name of names) if (obj?.[name] != null && obj[name] !== '') return obj[name]; + return null; +} + +function mergeAirport(base, live, delayMinutes) { + if (!live) return { ...base, delay_minutes: Number(delayMinutes || base.delay_minutes || 0) }; + return { + ...base, + estimated: firstValue(live, ['estimated_time', 'estimated', 'estimated_at']) || base.estimated, + actual: firstValue(live, ['actual_time', 'actual', 'actual_at']) || base.actual, + terminal: firstValue(live, ['terminal']) || base.terminal, + gate: firstValue(live, ['gate']) || base.gate, + delay_minutes: Number(delayMinutes || 0) + }; +} + +async function readCurrent() { + try { + return JSON.parse(await fs.readFile(OUTPUT, 'utf8')); + } catch { + throw new Error('data/flight-status.json is missing or invalid.'); + } +} + +const data = await readCurrent(); +let changed = false; +let attempted = 0; +const nowIso = new Date().toISOString(); + +for (const config of configs) { + const flight = data.flights?.find(item => item.id === config.id); + if (!flight) continue; + + const days = dayDiff(config.date); + const withinWindow = days <= LIVE_WINDOW_DAYS && days >= -1; + if (!FORCE && !withinWindow) continue; + + attempted++; + console.log(`Checking ${config.flightNumber} on ${config.date}...`); + const json = await serpSearch(config); + const live = selectFlightResult(json, config) || selectAnswerBox(json, config); + + flight.checked_at = nowIso; + changed = true; + + if (!live) { + flight.status = { + ...flight.status, + code: 'scheduled', + label: 'Theo lịch', + raw: 'No exact-date operational status yet', + live: false + }; + flight.latest_update = 'Chưa có trạng thái vận hành đúng ngày bay từ Google.'; + flight.provider_sources = []; + console.log(`No exact-date live status available for ${config.flightNumber}; baseline preserved.`); + continue; + } + + const [code, label] = normalizeStatus(live.rawStatus, live.departureDelay, live.arrivalDelay); + flight.status = { code, label, raw: String(live.rawStatus), live: true }; + flight.departure = mergeAirport(flight.departure, live.departure, live.departureDelay); + flight.arrival = mergeAirport(flight.arrival, live.arrival, live.arrivalDelay); + flight.latest_update = live.latestUpdate; + flight.provider_sources = (live.sources || []).map(source => ({ + name: source.name || 'Source', + link: source.link || source.url || null + })).filter(source => source.link); + console.log(`${config.flightNumber}: ${label} (${live.rawStatus})`); +} + +if (!attempted) { + console.log(`No flight is inside the ${LIVE_WINDOW_DAYS}-day live window. Nothing to fetch.`); + process.exit(0); +} + +if (changed) { + data.generated_at = nowIso; + data.provider = 'SerpApi · Google Search flight status'; + data.live_mode = data.flights.some(flight => flight.status?.live); + data.message = data.live_mode + ? 'Operational status was checked against Google flight status data. Recheck with the airline before leaving for the airport.' + : 'The provider does not yet expose an exact-date operational status. The booked/scheduled times remain the baseline.'; + await fs.writeFile(OUTPUT, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + console.log(`Updated ${OUTPUT}`); +}