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:
jtricerolph 2026-07-15 09:44:18 +00:00
parent 7d0b7d2d3b
commit 078cb47b16
3 changed files with 238 additions and 1 deletions

View file

@ -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}")
async def refresh_single_date(
rate_date: str,

View file

@ -61,6 +61,53 @@ async def run_scheduled_direct_scrape():
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():
from jobs.check_rate_parity import run_parity_check
import asyncio
@ -159,6 +206,9 @@ def start_scheduler():
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")
# 30-day NewBook rescrape — optional intraday refresh, interval from config
apply_newbook_rescrape_schedule()
def shutdown_scheduler():
if scheduler.running: