Add configurable 30-day NewBook rate rescrape on 2/4/6/12h intervals
Intraday rescrape jobs are distributed evenly between the main nightly run (05:20) and cover only the next 30 days — lightweight complement to the full 720-day nightly sweep. Interval is configurable from the Newbook tab in Settings and takes effect immediately without a container restart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d0b7d2d3b
commit
078cb47b16
3 changed files with 238 additions and 1 deletions
|
|
@ -781,6 +781,90 @@ async def get_occupancy_history(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# RESCRAPE INTERVAL CONFIG
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def _rescrape_times_from_config(interval_hours: int, time_str: str) -> list:
|
||||||
|
"""Compute extra rescrape fire times (HH:MM strings) from interval and base time."""
|
||||||
|
try:
|
||||||
|
parts = time_str.split(':')
|
||||||
|
base_hour, base_minute = int(parts[0]), int(parts[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
base_hour, base_minute = 5, 20
|
||||||
|
if interval_hours not in (2, 4, 6, 12):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
f"{(base_hour + offset) % 24:02d}:{base_minute:02d}"
|
||||||
|
for offset in range(interval_hours, 24, interval_hours)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/rescrape-schedule")
|
||||||
|
async def get_rescrape_schedule(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Return current 30-day rescrape interval config and computed fire times."""
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT config_key, config_value FROM system_config
|
||||||
|
WHERE config_key IN ('newbook_rescrape_interval_hours', 'sync_newbook_current_rates_time')
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
cfg = {row.config_key: row.config_value for row in result.fetchall()}
|
||||||
|
try:
|
||||||
|
interval_hours = int(cfg.get('newbook_rescrape_interval_hours') or '0')
|
||||||
|
except ValueError:
|
||||||
|
interval_hours = 0
|
||||||
|
time_str = cfg.get('sync_newbook_current_rates_time') or '05:20'
|
||||||
|
return {
|
||||||
|
"interval_hours": interval_hours,
|
||||||
|
"base_time": time_str,
|
||||||
|
"rescrape_times": _rescrape_times_from_config(interval_hours, time_str),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RescrapeIntervalBody(BaseModel):
|
||||||
|
interval_hours: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/rescrape-interval")
|
||||||
|
async def set_rescrape_interval(
|
||||||
|
body: RescrapeIntervalBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Save 30-day rescrape interval and live-reload the scheduler jobs."""
|
||||||
|
if body.interval_hours not in (0, 2, 4, 6, 12):
|
||||||
|
raise HTTPException(status_code=400, detail="interval_hours must be 0, 2, 4, 6, or 12")
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO system_config (config_key, config_value)
|
||||||
|
VALUES ('newbook_rescrape_interval_hours', :val)
|
||||||
|
ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value
|
||||||
|
"""),
|
||||||
|
{"val": str(body.interval_hours)}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
from scheduler import apply_newbook_rescrape_schedule
|
||||||
|
apply_newbook_rescrape_schedule()
|
||||||
|
|
||||||
|
time_result = await db.execute(
|
||||||
|
text("SELECT config_value FROM system_config WHERE config_key = 'sync_newbook_current_rates_time'")
|
||||||
|
)
|
||||||
|
time_row = time_result.fetchone()
|
||||||
|
time_str = (time_row.config_value if time_row and time_row.config_value else None) or '05:20'
|
||||||
|
return {
|
||||||
|
"status": "saved",
|
||||||
|
"interval_hours": body.interval_hours,
|
||||||
|
"base_time": time_str,
|
||||||
|
"rescrape_times": _rescrape_times_from_config(body.interval_hours, time_str),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh-date/{rate_date}")
|
@router.post("/refresh-date/{rate_date}")
|
||||||
async def refresh_single_date(
|
async def refresh_single_date(
|
||||||
rate_date: str,
|
rate_date: str,
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,53 @@ async def run_scheduled_direct_scrape():
|
||||||
await loop.run_in_executor(None, run_scrape_all_direct)
|
await loop.run_in_executor(None, run_scrape_all_direct)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_short_newbook_rescrape():
|
||||||
|
"""Fetch next-30-day NewBook rates — lightweight complement to the full nightly run."""
|
||||||
|
from jobs.fetch_current_rates import run_fetch_current_rates
|
||||||
|
await run_fetch_current_rates(horizon_days=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_rescrape_times(base_hour: int, base_minute: int, interval_hours: int) -> list:
|
||||||
|
"""Return (hour, minute) tuples evenly spaced around the clock, excluding the base (main) run."""
|
||||||
|
return [
|
||||||
|
((base_hour + offset) % 24, base_minute)
|
||||||
|
for offset in range(interval_hours, 24, interval_hours)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_newbook_rescrape_schedule():
|
||||||
|
"""Read config and (re)register 30-day NewBook rescrape jobs without a scheduler restart."""
|
||||||
|
for job in list(scheduler.get_jobs()):
|
||||||
|
if job.id.startswith('nb_rescrape_'):
|
||||||
|
scheduler.remove_job(job.id)
|
||||||
|
|
||||||
|
interval_str = get_config_value('newbook_rescrape_interval_hours', '0')
|
||||||
|
try:
|
||||||
|
interval_hours = int(interval_str or '0')
|
||||||
|
except ValueError:
|
||||||
|
interval_hours = 0
|
||||||
|
|
||||||
|
if interval_hours not in (2, 4, 6, 12):
|
||||||
|
logger.info("NewBook 30-day rescrape disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
rates_hour, rates_minute = get_sync_time('newbook_current_rates', 5, 20)
|
||||||
|
times = _compute_rescrape_times(rates_hour, rates_minute, interval_hours)
|
||||||
|
|
||||||
|
for i, (h, m) in enumerate(times):
|
||||||
|
scheduler.add_job(
|
||||||
|
run_short_newbook_rescrape,
|
||||||
|
CronTrigger(hour=h, minute=m),
|
||||||
|
id=f'nb_rescrape_{i}',
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"NewBook rescrape: {len(times)} extra run(s) at {interval_hours}h intervals "
|
||||||
|
f"(base {rates_hour:02d}:{rates_minute:02d}): "
|
||||||
|
f"{[f'{h:02d}:{m:02d}' for h, m in times]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_scheduled_parity_check():
|
async def run_scheduled_parity_check():
|
||||||
from jobs.check_rate_parity import run_parity_check
|
from jobs.check_rate_parity import run_parity_check
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -159,6 +206,9 @@ def start_scheduler():
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00, parity check at 06:45, watchdog every 30m")
|
logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00, parity check at 06:45, watchdog every 30m")
|
||||||
|
|
||||||
|
# 30-day NewBook rescrape — optional intraday refresh, interval from config
|
||||||
|
apply_newbook_rescrape_schedule()
|
||||||
|
|
||||||
|
|
||||||
def shutdown_scheduler():
|
def shutdown_scheduler():
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck, AlertTriangle } from 'lucide-react'
|
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck, AlertTriangle, Timer } from 'lucide-react'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|
@ -664,6 +664,109 @@ function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: N
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoomCategoriesCard />
|
<RoomCategoriesCard />
|
||||||
|
<RescrapeCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 30-day Rescrape Schedule ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RescrapeSchedule {
|
||||||
|
interval_hours: number
|
||||||
|
base_time: string
|
||||||
|
rescrape_times: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function RescrapeCard() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [interval, setInterval] = useState<number>(0)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<RescrapeSchedule>({
|
||||||
|
queryKey: ['rescrape-schedule'],
|
||||||
|
queryFn: () => api.get('/bookability/config/rescrape-schedule').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !loaded) {
|
||||||
|
setInterval(data.interval_hours)
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
}, [data, loaded])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post('/bookability/config/rescrape-interval', { interval_hours: interval }).then(r => r.data as RescrapeSchedule),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['rescrape-schedule'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewTimes = (data && loaded)
|
||||||
|
? (() => {
|
||||||
|
const baseTime = data.base_time
|
||||||
|
if (interval === 0) return []
|
||||||
|
const [bh, bm] = baseTime.split(':').map(Number)
|
||||||
|
const times: string[] = []
|
||||||
|
for (let offset = interval; offset < 24; offset += interval) {
|
||||||
|
const h = (bh + offset) % 24
|
||||||
|
times.push(`${String(h).padStart(2, '0')}:${String(bm).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
return times
|
||||||
|
})()
|
||||||
|
: data?.rescrape_times ?? []
|
||||||
|
|
||||||
|
if (isLoading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Timer size={16} strokeWidth={1.75} />
|
||||||
|
30-day Rate Rescrape
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${interval > 0 ? 'badge-success' : 'badge-neutral'}`}>
|
||||||
|
{interval > 0 ? `Every ${interval}h` : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||||||
|
Re-fetches the next 30 days of NewBook rates throughout the day, between the main
|
||||||
|
nightly run at <strong>{data?.base_time ?? '05:20'}</strong>. Useful for keeping
|
||||||
|
Bookability fresh on days when tariff availability changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
style={{ width: 200 }}
|
||||||
|
value={interval}
|
||||||
|
onChange={e => setInterval(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={0}>Disabled</option>
|
||||||
|
<option value={2}>Every 2 hours</option>
|
||||||
|
<option value={4}>Every 4 hours</option>
|
||||||
|
<option value={6}>Every 6 hours</option>
|
||||||
|
<option value={12}>Every 12 hours</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={save.isPending}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
|
<Save size={13} strokeWidth={1.75} />
|
||||||
|
{save.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{save.isSuccess && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--success)' }}>✓ Saved</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewTimes.length > 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-mid)' }}>
|
||||||
|
<span style={{ fontWeight: 600, marginRight: 6 }}>Rescrape runs at:</span>
|
||||||
|
{previewTimes.join(' · ')}
|
||||||
|
<span style={{ marginLeft: 6 }}>(in addition to the {data?.base_time} main run)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue