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:
@@ -38,3 +38,9 @@ config/local.ts
|
|||||||
|
|
||||||
# Dev mode request/response logs
|
# Dev mode request/response logs
|
||||||
debug/
|
debug/
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
state/
|
||||||
|
|
||||||
|
# Change logs
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface TelegramConfig {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
days: DayUrls
|
days: DayUrls
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
|
overrideTime: string | null
|
||||||
telegram: TelegramConfig
|
telegram: TelegramConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const config: Config = {
|
|||||||
friday: "",
|
friday: "",
|
||||||
},
|
},
|
||||||
devMode: false,
|
devMode: false,
|
||||||
|
overrideTime: null,
|
||||||
telegram: {
|
telegram: {
|
||||||
botToken: "",
|
botToken: "",
|
||||||
chatId: "",
|
chatId: "",
|
||||||
|
|||||||
@@ -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
@@ -24,9 +24,12 @@ function parseCookies(headers: Headers): Record<string, string> {
|
|||||||
const cookies: Record<string, string> = {}
|
const cookies: Record<string, string> = {}
|
||||||
const setCookieHeader = headers.getSetCookie?.() ?? []
|
const setCookieHeader = headers.getSetCookie?.() ?? []
|
||||||
for (const raw of setCookieHeader) {
|
for (const raw of setCookieHeader) {
|
||||||
const [pair] = raw.split(";")
|
const pair = raw.split(";")[0] ?? ""
|
||||||
const [name, ...rest] = pair.split("=")
|
const eqIdx = pair.indexOf("=")
|
||||||
cookies[name.trim()] = rest.join("=").trim()
|
if (eqIdx === -1) continue
|
||||||
|
const name = pair.slice(0, eqIdx).trim()
|
||||||
|
const value = pair.slice(eqIdx + 1).trim()
|
||||||
|
cookies[name] = value
|
||||||
}
|
}
|
||||||
return cookies
|
return cookies
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-21
@@ -1,23 +1,76 @@
|
|||||||
import config from "../config"
|
import config from "../config"
|
||||||
import { fetchPage } from "./fetcher"
|
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"
|
import { sendMessage } from "./telegram"
|
||||||
|
|
||||||
type Weekday = "monday" | "tuesday" | "wednesday" | "thursday" | "friday"
|
type Weekday = "monday" | "tuesday" | "wednesday" | "thursday" | "friday"
|
||||||
|
|
||||||
const WEEKDAYS: Weekday[] = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
const WEEKDAYS: Weekday[] = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||||
|
|
||||||
function todayWeekday(): Weekday | null {
|
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
|
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} Kč${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() {
|
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) {
|
if (!weekday) {
|
||||||
console.log("Weekend — nothing to do.")
|
console.log("Weekend, exiting.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,28 +80,66 @@ async function main() {
|
|||||||
process.exit(1)
|
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)
|
const { body } = await fetchPage(url, config.devMode)
|
||||||
console.log(`Response status: ${status}`)
|
const availability = checkAvailability(body)
|
||||||
|
|
||||||
const result = parseMenu(body)
|
if (!availability.available) {
|
||||||
|
console.log(`Menu not available: ${availability.reason}`)
|
||||||
if (!result.valid) {
|
if (isAfter10 && !state.sentAt10) {
|
||||||
console.log("Restaurant appears closed today.")
|
await tg("Dnes menu není k dispozici.")
|
||||||
if (config.devMode) console.log("Raw preview:", result.raw)
|
state.sentAt10 = true
|
||||||
|
await saveState(state)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = JSON.stringify(result, null, 2)
|
const current = parseMenu(body)
|
||||||
console.log("Menu:", message)
|
|
||||||
|
|
||||||
if (config.telegram.botToken && config.telegram.chatId) {
|
// First run of the day
|
||||||
await sendMessage(config.telegram.botToken, config.telegram.chatId, `<pre>${message}</pre>`)
|
if (!state.reference) {
|
||||||
console.log("Sent to Telegram.")
|
state.reference = current
|
||||||
} else {
|
state.lastKnown = current
|
||||||
console.log("Telegram not configured, skipping send.")
|
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)
|
main().catch(console.error)
|
||||||
|
|||||||
@@ -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
@@ -1,19 +1,72 @@
|
|||||||
import * as cheerio from "cheerio"
|
import * as cheerio from "cheerio"
|
||||||
|
|
||||||
export interface MenuResult {
|
export interface MenuItem {
|
||||||
valid: boolean
|
id: string
|
||||||
address?: string
|
code: string
|
||||||
items?: string[]
|
name: string
|
||||||
raw?: 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)
|
const $ = cheerio.load(html)
|
||||||
|
|
||||||
// TODO: implement actual selectors once we see the page structure
|
const kategorieRaw = $(".js-configsource[data-kategorie]").attr("data-kategorie")
|
||||||
// For now returns raw body text so we can inspect it in dev mode
|
if (kategorieRaw) {
|
||||||
return {
|
const kategorie = parseDataAttr(kategorieRaw)
|
||||||
valid: false,
|
if (kategorie.db_zablokovana === "1") {
|
||||||
raw: $("body").text().trim().slice(0, 500),
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user