Market View: Scrape 7d/30d buttons, Booking.com day-search links on date headers
- Scrape queue generalised to date-range jobs; range and per-date scrapes share one sequential queue (timeout scales with range length) - Matrix refetches every 30s while a scrape runs so columns fill in live - Eye icon per date header opens that night's location search on Booking.com Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6a3aee4db0
commit
77284b3ecc
1 changed files with 95 additions and 29 deletions
|
|
@ -1,14 +1,35 @@
|
||||||
import React, { useState, useMemo, useCallback, useEffect } from 'react'
|
import React, { useState, useMemo, useCallback, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
|
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
|
||||||
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
// Booking.com search results for the scrape location on a given night
|
||||||
|
const buildSearchUrl = (location: string, checkin: string) => {
|
||||||
|
const co = new Date(checkin + 'T00:00:00')
|
||||||
|
co.setDate(co.getDate() + 1)
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ss: location,
|
||||||
|
checkin,
|
||||||
|
checkout: fmtDate(co),
|
||||||
|
group_adults: '2',
|
||||||
|
no_rooms: '1',
|
||||||
|
group_children: '0',
|
||||||
|
})
|
||||||
|
return `https://www.booking.com/searchresults.html?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
interface ScrapeJob {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScraperStatus {
|
interface ScraperStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
paused: boolean
|
paused: boolean
|
||||||
|
|
@ -915,7 +936,6 @@ const RateMatrixTab: React.FC = () => {
|
||||||
})
|
})
|
||||||
const [includeMarket, setIncludeMarket] = useState(false)
|
const [includeMarket, setIncludeMarket] = useState(false)
|
||||||
const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null)
|
const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null)
|
||||||
const [scrapingDate, setScrapingDate] = useState<string | null>(null)
|
|
||||||
const [rangeMode, setRangeMode] = useState(false)
|
const [rangeMode, setRangeMode] = useState(false)
|
||||||
const [customFrom, setCustomFrom] = useState(fmtDate(new Date()))
|
const [customFrom, setCustomFrom] = useState(fmtDate(new Date()))
|
||||||
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
|
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
|
||||||
|
|
@ -923,49 +943,61 @@ const RateMatrixTab: React.FC = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// The scrape endpoint returns immediately (runs in background), and the
|
// The scrape endpoint returns immediately (runs in background), and the
|
||||||
// scraper handles one date at a time, so queued dates are dispatched
|
// scraper handles one job at a time, so queued jobs (single dates or
|
||||||
// sequentially: watch scraper status until a new batch finishes, refetch
|
// ranges) are dispatched sequentially: watch scraper status until a new
|
||||||
// the matrix, then start the next date in the queue.
|
// batch finishes, refetch the matrix, then start the next job.
|
||||||
const [scrapeQueue, setScrapeQueue] = useState<string[]>([])
|
const [scrapeQueue, setScrapeQueue] = useState<ScrapeJob[]>([])
|
||||||
const [scrapeWatch, setScrapeWatch] = useState<{ prevBatch: string | null, startedAt: number } | null>(null)
|
const [scrapeWatch, setScrapeWatch] = useState<{ prevBatch: string | null, startedAt: number, timeoutMs: number } | null>(null)
|
||||||
|
|
||||||
|
const enqueueScrape = (from: string, to: string) => {
|
||||||
|
setScrapeQueue(q => q.some(j => j.from === from && j.to === to) ? q : [...q, { from, to }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeJob = scrapeWatch ? scrapeQueue[0] : undefined
|
||||||
|
const scrapingDate = activeJob && activeJob.from === activeJob.to ? activeJob.from : null
|
||||||
|
|
||||||
const dateScrapeM = useMutation({
|
const dateScrapeM = useMutation({
|
||||||
mutationFn: async (d: string) => {
|
mutationFn: async (job: ScrapeJob) => {
|
||||||
const st = (await api.get('/competitors/status')).data as ScraperStatus
|
const st = (await api.get('/competitors/status')).data as ScraperStatus
|
||||||
setScrapingDate(d)
|
const days = Math.round((new Date(job.to).getTime() - new Date(job.from).getTime()) / 86400000) + 1
|
||||||
setScrapeWatch({ prevBatch: st?.last_scrape?.batch_id ?? null, startedAt: Date.now() })
|
setScrapeWatch({
|
||||||
return (await api.post('/competitors/scrape', { from_date: d, to_date: d })).data
|
prevBatch: st?.last_scrape?.batch_id ?? null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
// generous per-date allowance: ranges take ~1 min per date
|
||||||
|
timeoutMs: 10 * 60 * 1000 + days * 90 * 1000,
|
||||||
|
})
|
||||||
|
return (await api.post('/competitors/scrape', { from_date: job.from, to_date: job.to })).data
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setScrapingDate(null)
|
|
||||||
setScrapeWatch(null)
|
setScrapeWatch(null)
|
||||||
setScrapeQueue(q => q.slice(1))
|
setScrapeQueue(q => q.slice(1))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dispatch the next queued date when idle
|
// Dispatch the next queued job when idle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrapeQueue.length && !scrapeWatch && !dateScrapeM.isPending) {
|
if (scrapeQueue.length && !scrapeWatch && !dateScrapeM.isPending) {
|
||||||
dateScrapeM.mutate(scrapeQueue[0])
|
dateScrapeM.mutate(scrapeQueue[0])
|
||||||
}
|
}
|
||||||
}, [scrapeQueue, scrapeWatch, dateScrapeM.isPending]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [scrapeQueue, scrapeWatch, dateScrapeM.isPending]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Also serves the date-header Booking.com links via location_name
|
||||||
const { data: watchStatus } = useQuery<ScraperStatus>({
|
const { data: watchStatus } = useQuery<ScraperStatus>({
|
||||||
queryKey: ['scraper-status'],
|
queryKey: ['scraper-status'],
|
||||||
queryFn: async () => (await api.get('/competitors/status')).data,
|
queryFn: async () => (await api.get('/competitors/status')).data,
|
||||||
enabled: scrapeWatch !== null,
|
|
||||||
refetchInterval: scrapeWatch !== null ? 5000 : false,
|
refetchInterval: scrapeWatch !== null ? 5000 : false,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
const locationName = watchStatus?.location_name
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrapeWatch) return
|
if (!scrapeWatch) return
|
||||||
const ls = watchStatus?.last_scrape
|
const ls = watchStatus?.last_scrape
|
||||||
const finished = ls && ls.batch_id !== scrapeWatch.prevBatch && ls.status !== 'running'
|
const finished = ls && ls.batch_id !== scrapeWatch.prevBatch && ls.status !== 'running'
|
||||||
const timedOut = Date.now() - scrapeWatch.startedAt > 10 * 60 * 1000
|
const timedOut = Date.now() - scrapeWatch.startedAt > scrapeWatch.timeoutMs
|
||||||
if (finished || timedOut) {
|
if (finished || timedOut) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] })
|
queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['scraper-status'] })
|
queryClient.invalidateQueries({ queryKey: ['scraper-status'] })
|
||||||
setScrapingDate(null)
|
|
||||||
setScrapeWatch(null)
|
setScrapeWatch(null)
|
||||||
setScrapeQueue(q => q.slice(1))
|
setScrapeQueue(q => q.slice(1))
|
||||||
}
|
}
|
||||||
|
|
@ -995,6 +1027,8 @@ const RateMatrixTab: React.FC = () => {
|
||||||
})
|
})
|
||||||
return (await api.get(`/competitors/matrix?${params}`)).data
|
return (await api.get(`/competitors/matrix?${params}`)).data
|
||||||
},
|
},
|
||||||
|
// While a scrape runs, refresh so columns fill in as dates complete
|
||||||
|
refetchInterval: scrapeWatch !== null ? 30000 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dates = data?.dates || []
|
const dates = data?.dates || []
|
||||||
|
|
@ -1115,6 +1149,24 @@ const RateMatrixTab: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{[{ label: 'Scrape 7d', days: 7 }, { label: 'Scrape 30d', days: 30 }].map(p => {
|
||||||
|
const from = fmtDate(new Date())
|
||||||
|
const to = fmtDate(new Date(Date.now() + (p.days - 1) * 86400000))
|
||||||
|
const queued = scrapeQueue.some(j => j.from === from && j.to === to)
|
||||||
|
const active = activeJob && activeJob.from === from && activeJob.to === to
|
||||||
|
return (
|
||||||
|
<button key={p.label}
|
||||||
|
style={buttonStyle('outline', 'small')}
|
||||||
|
disabled={queued}
|
||||||
|
onClick={() => enqueueScrape(from, to)}
|
||||||
|
title={`Scrape today + next ${p.days - 1} days (~${p.days} min)`}
|
||||||
|
>
|
||||||
|
{active ? 'Scraping…' : queued ? 'Queued' : p.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<label style={styles.checkboxLabel}>
|
<label style={styles.checkboxLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -1149,6 +1201,7 @@ const RateMatrixTab: React.FC = () => {
|
||||||
{dates.map((d, colIdx) => {
|
{dates.map((d, colIdx) => {
|
||||||
const scrapeAge = formatScrapeAge(scrapedAtByDate[d])
|
const scrapeAge = formatScrapeAge(scrapedAtByDate[d])
|
||||||
const isColHovered = hoveredCell?.col === colIdx
|
const isColHovered = hoveredCell?.col === colIdx
|
||||||
|
const queuePos = scrapeQueue.findIndex(j => j.from === d && j.to === d)
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={d}
|
key={d}
|
||||||
|
|
@ -1166,21 +1219,34 @@ const RateMatrixTab: React.FC = () => {
|
||||||
{scrapeAge ? (
|
{scrapeAge ? (
|
||||||
<span style={styles.scrapeAge}>{scrapeAge}</span>
|
<span style={styles.scrapeAge}>{scrapeAge}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div style={{ display: 'flex', gap: 2, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setScrapeQueue(q => q.includes(d) ? q : [...q, d])}
|
onClick={() => enqueueScrape(d, d)}
|
||||||
disabled={scrapeQueue.includes(d)}
|
disabled={queuePos >= 0}
|
||||||
style={mergeStyles(
|
style={mergeStyles(
|
||||||
styles.scrapeBtn,
|
styles.scrapeBtn,
|
||||||
scrapeQueue.includes(d) ? styles.scrapeBtnActive : {}
|
queuePos >= 0 ? styles.scrapeBtnActive : {}
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
scrapingDate === d ? `Scraping ${d}…`
|
scrapingDate === d ? `Scraping ${d}…`
|
||||||
: scrapeQueue.includes(d) ? `Queued (#${scrapeQueue.indexOf(d) + 1})`
|
: queuePos >= 0 ? `Queued (#${queuePos + 1})`
|
||||||
: `Scrape ${d}`
|
: `Scrape ${d}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{scrapingDate === d ? '...' : scrapeQueue.includes(d) ? `#${scrapeQueue.indexOf(d) + 1}` : '↻'}
|
{scrapingDate === d ? '...' : queuePos >= 0 ? `#${queuePos + 1}` : '↻'}
|
||||||
</button>
|
</button>
|
||||||
|
{locationName && (
|
||||||
|
<a
|
||||||
|
href={buildSearchUrl(locationName, d)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`View ${d} search on Booking.com`}
|
||||||
|
style={{ color: 'var(--text-mid)', display: 'inline-flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Eye size={11} strokeWidth={1.75} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue