diff --git a/.gitignore b/.gitignore index d9129f8..c1dbfbe 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ config/local.ts # Dev mode request/response logs debug/ + +# Runtime state +state/ + +# Change logs +logs/ diff --git a/config/default.ts b/config/default.ts index a60b336..2506bc4 100644 --- a/config/default.ts +++ b/config/default.ts @@ -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: "", diff --git a/src/differ.ts b/src/differ.ts new file mode 100644 index 0000000..ab91d98 --- /dev/null +++ b/src/differ.ts @@ -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}"` + } +} diff --git a/src/fetcher.ts b/src/fetcher.ts index 1b87161..2764447 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -24,9 +24,12 @@ function parseCookies(headers: Headers): Record { const cookies: Record = {} 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 } diff --git a/src/index.ts b/src/index.ts index 84969f3..4b4e152 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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} Kč${sold}`) + } + + return lines.join("\n") +} + +async function tg(text: string): Promise { + 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, `
${message}
`) - 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) diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..9bbb6e3 --- /dev/null +++ b/src/logger.ts @@ -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 { + 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}`) +} diff --git a/src/parser.ts b/src/parser.ts index 4858235..003cfa2 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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 { + 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 } } diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..e7232b1 --- /dev/null +++ b/src/state.ts @@ -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 { + 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 { + await fs.mkdir(path.dirname(STATE_FILE), { recursive: true }) + await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2)) +}