Add time-based orchestration, state management, diffing and logging

- config: add overrideTime for testing (HH:MM string)
- state.ts: daily state persistence (reference, lastKnown, sentAt10)
- differ.ts: detect text changes and sold-out changes between menu snapshots
- logger.ts: append timestamped changes to logs/YYYY-MM-DD.log
- index.ts: full cron logic — before 10 log only, at 10 send TG, after 10
  send on text change; sold-out always to TG without log; exit after 14:00
- .gitignore: add state/ and logs/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
František Musil
2026-05-12 09:25:11 +02:00
parent ffa638429e
commit a37b3e848d
8 changed files with 309 additions and 35 deletions
+6
View File
@@ -38,3 +38,9 @@ config/local.ts
# Dev mode request/response logs
debug/
# Runtime state
state/
# Change logs
logs/
+2
View File
@@ -14,6 +14,7 @@ export interface TelegramConfig {
export interface Config {
days: DayUrls
devMode: boolean
overrideTime: string | null
telegram: TelegramConfig
}
@@ -26,6 +27,7 @@ const config: Config = {
friday: "",
},
devMode: false,
overrideTime: null,
telegram: {
botToken: "",
chatId: "",
+73
View File
@@ -0,0 +1,73 @@
import type { ParsedMenu, MenuItem } from "./parser"
export interface TextChange {
type: "added" | "removed" | "changed"
code: string
name: string
field?: "name" | "description" | "price"
from?: string | number
to?: string | number
}
export interface SoldOutChange {
type: "sold-out" | "back-in-stock"
code: string
name: string
}
export interface MenuDiff {
textChanges: TextChange[]
soldOutChanges: SoldOutChange[]
}
export function diffMenus(prev: ParsedMenu, curr: ParsedMenu): MenuDiff {
const textChanges: TextChange[] = []
const soldOutChanges: SoldOutChange[] = []
const allItems = (menu: ParsedMenu): MenuItem[] =>
[...(menu.soup ? [menu.soup] : []), ...menu.items]
const prevMap = new Map(allItems(prev).map(i => [i.code, i]))
const currMap = new Map(allItems(curr).map(i => [i.code, i]))
for (const [code, curr] of currMap) {
const prev = prevMap.get(code)
if (!prev) {
textChanges.push({ type: "added", code, name: curr.name })
continue
}
if (prev.name !== curr.name) {
textChanges.push({ type: "changed", code, name: curr.name, field: "name", from: prev.name, to: curr.name })
}
if (prev.description !== curr.description) {
textChanges.push({ type: "changed", code, name: curr.name, field: "description", from: prev.description, to: curr.description })
}
if (prev.price !== curr.price) {
textChanges.push({ type: "changed", code, name: curr.name, field: "price", from: prev.price, to: curr.price })
}
if (!prev.inactive && curr.inactive) {
soldOutChanges.push({ type: "sold-out", code, name: curr.name })
} else if (prev.inactive && !curr.inactive) {
soldOutChanges.push({ type: "back-in-stock", code, name: curr.name })
}
}
for (const [code, prev] of prevMap) {
if (!currMap.has(code)) {
textChanges.push({ type: "removed", code, name: prev.name })
}
}
return { textChanges, soldOutChanges }
}
export function formatTextChange(change: TextChange): string {
switch (change.type) {
case "added":
return `PŘIDÁNO [${change.code}] ${change.name}`
case "removed":
return `ODEBRÁNO [${change.code}] ${change.name}`
case "changed":
return `ZMĚNA [${change.code}] ${change.field}: "${change.from}" → "${change.to}"`
}
}
+6 -3
View File
@@ -24,9 +24,12 @@ function parseCookies(headers: Headers): Record<string, string> {
const cookies: Record<string, string> = {}
const setCookieHeader = headers.getSetCookie?.() ?? []
for (const raw of setCookieHeader) {
const [pair] = raw.split(";")
const [name, ...rest] = pair.split("=")
cookies[name.trim()] = rest.join("=").trim()
const pair = raw.split(";")[0] ?? ""
const eqIdx = pair.indexOf("=")
if (eqIdx === -1) continue
const name = pair.slice(0, eqIdx).trim()
const value = pair.slice(eqIdx + 1).trim()
cookies[name] = value
}
return cookies
}
+112 -21
View File
@@ -1,23 +1,76 @@
import config from "../config"
import { fetchPage } from "./fetcher"
import { parseMenu } from "./parser"
import { checkAvailability, parseMenu } from "./parser"
import type { ParsedMenu } from "./parser"
import { loadState, saveState } from "./state"
import { diffMenus, formatTextChange } from "./differ"
import { logChange } from "./logger"
import { sendMessage } from "./telegram"
type Weekday = "monday" | "tuesday" | "wednesday" | "thursday" | "friday"
const WEEKDAYS: Weekday[] = ["monday", "tuesday", "wednesday", "thursday", "friday"]
function todayWeekday(): Weekday | null {
const day = new Date().getDay() // 0=Sun, 1=Mon, ..., 5=Fri, 6=Sat
const day = new Date().getDay()
if (day === 0 || day === 6) return null
return WEEKDAYS[day - 1]
return WEEKDAYS[day - 1] ?? null
}
function getCurrentTime(): { totalMinutes: number; dateStr: string } {
const now = new Date()
const dateStr = now.toISOString().slice(0, 10)
if (config.overrideTime) {
const parts = config.overrideTime.split(":").map(Number)
return { totalMinutes: (parts[0] ?? 0) * 60 + (parts[1] ?? 0), dateStr }
}
return {
totalMinutes: now.getHours() * 60 + now.getMinutes(),
dateStr,
}
}
function formatMenu(menu: ParsedMenu): string {
const lines: string[] = ["Denní menu:"]
if (menu.soup) {
lines.push(`Polévka: ${menu.soup.name} (${menu.soup.price} Kč)`)
}
const sorted = [...menu.items].sort((a, b) => {
const na = parseInt(a.name.match(/^(\d+)\./)?.[1] ?? "99", 10)
const nb = parseInt(b.name.match(/^(\d+)\./)?.[1] ?? "99", 10)
return na - nb
})
for (const item of sorted) {
const sold = item.inactive ? " [VYPRODÁNO]" : ""
lines.push(`${item.name}${item.price}${sold}`)
}
return lines.join("\n")
}
async function tg(text: string): Promise<void> {
if (!config.telegram.botToken || !config.telegram.chatId) {
console.log(`TG (not configured): ${text}`)
return
}
await sendMessage(config.telegram.botToken, config.telegram.chatId, text)
}
async function main() {
const weekday = todayWeekday()
const { totalMinutes, dateStr } = getCurrentTime()
if (totalMinutes >= 14 * 60) {
console.log("After 14:00, exiting.")
return
}
const weekday = todayWeekday()
if (!weekday) {
console.log("Weekend — nothing to do.")
console.log("Weekend, exiting.")
return
}
@@ -27,28 +80,66 @@ async function main() {
process.exit(1)
}
console.log(`Fetching menu for ${weekday}: ${url}`)
const isAfter10 = totalMinutes >= 10 * 60
const state = await loadState(dateStr)
const { status, body } = await fetchPage(url, config.devMode)
console.log(`Response status: ${status}`)
const { body } = await fetchPage(url, config.devMode)
const availability = checkAvailability(body)
const result = parseMenu(body)
if (!result.valid) {
console.log("Restaurant appears closed today.")
if (config.devMode) console.log("Raw preview:", result.raw)
if (!availability.available) {
console.log(`Menu not available: ${availability.reason}`)
if (isAfter10 && !state.sentAt10) {
await tg("Dnes menu není k dispozici.")
state.sentAt10 = true
await saveState(state)
}
return
}
const message = JSON.stringify(result, null, 2)
console.log("Menu:", message)
const current = parseMenu(body)
if (config.telegram.botToken && config.telegram.chatId) {
await sendMessage(config.telegram.botToken, config.telegram.chatId, `<pre>${message}</pre>`)
console.log("Sent to Telegram.")
} else {
console.log("Telegram not configured, skipping send.")
// First run of the day
if (!state.reference) {
state.reference = current
state.lastKnown = current
if (isAfter10) {
await tg(formatMenu(current))
state.sentAt10 = true
}
await saveState(state)
console.log("First run, reference saved.")
return
}
const diff = diffMenus(state.lastKnown!, current)
const hasTextChanges = diff.textChanges.length > 0
const hasSoldOut = diff.soldOutChanges.length > 0
// Log text changes (always)
for (const change of diff.textChanges) {
await logChange(dateStr, formatTextChange(change))
}
if (isAfter10) {
// First send at or after 10:00
if (!state.sentAt10) {
await tg(formatMenu(current))
state.sentAt10 = true
} else if (hasTextChanges) {
await tg(formatMenu(current))
}
// Sold-out notifications (no log)
for (const change of diff.soldOutChanges) {
const msg = change.type === "sold-out"
? `Vyprodáno: ${change.name}`
: `Zpět v nabídce: ${change.name}`
await tg(msg)
}
}
state.lastKnown = current
await saveState(state)
}
main().catch(console.error)
+12
View File
@@ -0,0 +1,12 @@
import fs from "fs/promises"
import path from "path"
const LOGS_DIR = "logs"
export async function logChange(date: string, message: string): Promise<void> {
await fs.mkdir(LOGS_DIR, { recursive: true })
const file = path.join(LOGS_DIR, `${date}.log`)
const time = new Date().toTimeString().slice(0, 8)
await fs.appendFile(file, `[${time}] ${message}\n`)
console.log(`LOG: ${message}`)
}
+64 -11
View File
@@ -1,19 +1,72 @@
import * as cheerio from "cheerio"
export interface MenuResult {
valid: boolean
address?: string
items?: string[]
raw?: string
export interface MenuItem {
id: string
code: string
name: string
description: string
price: number
inactive: boolean
}
export function parseMenu(html: string): MenuResult {
export interface ParsedMenu {
soup: MenuItem | null
items: MenuItem[]
}
export type AvailabilityStatus =
| { available: false; reason: "blocked" }
| { available: false; reason: "not-today" }
| { available: true }
function parseDataAttr(raw: string): Record<string, string | null> {
const json = raw.replace(/'/g, '"')
return JSON.parse(json)
}
export function checkAvailability(html: string): AvailabilityStatus {
const $ = cheerio.load(html)
// TODO: implement actual selectors once we see the page structure
// For now returns raw body text so we can inspect it in dev mode
return {
valid: false,
raw: $("body").text().trim().slice(0, 500),
const kategorieRaw = $(".js-configsource[data-kategorie]").attr("data-kategorie")
if (kategorieRaw) {
const kategorie = parseDataAttr(kategorieRaw)
if (kategorie.db_zablokovana === "1") {
return { available: false, reason: "blocked" }
}
}
if ($("p.neaktivniKategorieInfo").length > 0) {
return { available: false, reason: "not-today" }
}
return { available: true }
}
export function parseMenu(html: string): ParsedMenu {
const $ = cheerio.load(html)
let soup: MenuItem | null = null
const items: MenuItem[] = []
$(".js-config-product[data-produkt]").each((_, el) => {
const raw = $(el).attr("data-produkt")
if (!raw) return
const data = parseDataAttr(raw)
const item: MenuItem = {
id: data.db_id ?? "",
code: data.db_kod ?? "",
name: data.db_nazev ?? "",
description: data.db_popisek ?? "",
price: parseInt(data.db_cena ?? "0", 10),
inactive: data.db_neaktivita !== null && (data.db_neaktivita ?? "").trim() !== "",
}
if ((data.db_kod ?? "").startsWith("POL")) {
soup = item
} else {
items.push(item)
}
})
return { soup, items }
}
+34
View File
@@ -0,0 +1,34 @@
import fs from "fs/promises"
import path from "path"
import type { ParsedMenu } from "./parser"
const STATE_FILE = "state/state.json"
export interface AppState {
date: string
reference: ParsedMenu | null
lastKnown: ParsedMenu | null
sentAt10: boolean
}
const emptyState = (date: string): AppState => ({
date,
reference: null,
lastKnown: null,
sentAt10: false,
})
export async function loadState(today: string): Promise<AppState> {
try {
const raw = await fs.readFile(STATE_FILE, "utf-8")
const state: AppState = JSON.parse(raw)
return state.date === today ? state : emptyState(today)
} catch {
return emptyState(today)
}
}
export async function saveState(state: AppState): Promise<void> {
await fs.mkdir(path.dirname(STATE_FILE), { recursive: true })
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2))
}