Compare commits
10 Commits
ffa638429e
...
afd1ec1709
| Author | SHA1 | Date | |
|---|---|---|---|
| afd1ec1709 | |||
| 14e4d86719 | |||
| 8b258fd96d | |||
| 6b445af19e | |||
| d2058ff9ef | |||
| 80c4f6111c | |||
| dad182fe12 | |||
| 3811e47593 | |||
| ad6094898c | |||
| a37b3e848d |
+8
-1
@@ -11,7 +11,6 @@ coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
@@ -38,3 +37,11 @@ config/local.ts
|
||||
|
||||
# Dev mode request/response logs
|
||||
debug/
|
||||
|
||||
# Runtime state
|
||||
state/*
|
||||
!state/.gitkeep
|
||||
|
||||
# Change logs
|
||||
logs/*
|
||||
!logs/.gitkeep
|
||||
|
||||
@@ -1,15 +1,82 @@
|
||||
# ogaracheck
|
||||
# ogaraCheck
|
||||
|
||||
To install dependencies:
|
||||
Automatické sledování denního menu restaurace Ogarova pizza (Otrokovice) s notifikacemi přes Telegram.
|
||||
|
||||
## Co dělá
|
||||
|
||||
- Každých 15 minut od 8:00 do 14:00 (pracovní dny) načte dnešní menu
|
||||
- V 10:00 pošle aktuální menu na Telegram (nebo oznámení "dnes menu není")
|
||||
- Před 10:00 loguje změny v menu do souboru
|
||||
- Po 10:00 posílá na Telegram textové změny i informace o vyprodaných položkách
|
||||
|
||||
## Instalace
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
## Konfigurace
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
Vytvoř soubor `config/local.ts` (je gitignored):
|
||||
|
||||
```ts
|
||||
import type { Config } from "./default"
|
||||
|
||||
const localConfig: Partial<Config> = {
|
||||
days: {
|
||||
monday: "https://otrokovice-ogarova-pizza.cz/produkty/118_pondeli",
|
||||
tuesday: "https://otrokovice-ogarova-pizza.cz/produkty/119_utery",
|
||||
wednesday: "https://otrokovice-ogarova-pizza.cz/produkty/120_streda",
|
||||
thursday: "https://otrokovice-ogarova-pizza.cz/produkty/121_-ctvrtek",
|
||||
friday: "https://otrokovice-ogarova-pizza.cz/produkty/122_patek",
|
||||
},
|
||||
telegram: {
|
||||
botToken: "váš-bot-token",
|
||||
chatId: "váš-chat-id",
|
||||
},
|
||||
// devMode: true, // ukládá HTTP debug logy do debug/
|
||||
// silent: true, // potlačí výstup na stdout
|
||||
// overrideTime: "10:00", // simuluje čas pro testování
|
||||
}
|
||||
|
||||
export default localConfig
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.13. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
## Manuální spuštění
|
||||
|
||||
```bash
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
## Cron
|
||||
|
||||
Přidej do `crontab -e` — každých 15 minut od 8:00 do 13:45 v pracovní dny:
|
||||
|
||||
```cron
|
||||
*/15 8-13 * * 1-5 cd /home/lister/workspace/ogaraCheck && /home/lister/.bun/bin/bun run src/index.ts 2>&1 | systemd-cat -t ogaraCheck
|
||||
```
|
||||
|
||||
Sledování logů:
|
||||
|
||||
```bash
|
||||
journalctl -t ogaraCheck -f
|
||||
journalctl -t ogaraCheck --since today
|
||||
```
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
config/
|
||||
default.ts # výchozí konfigurace (commitováno)
|
||||
local.ts # lokální přepisy — token, URL (gitignored)
|
||||
src/
|
||||
index.ts # vstupní bod, časová logika
|
||||
fetcher.ts # HTTP klient s cookie jar a dev logováním
|
||||
parser.ts # cheerio parser + detekce dostupnosti menu
|
||||
differ.ts # porovnání stavu menu mezi spuštěními
|
||||
logger.ts # zápis změn do logs/YYYY-MM-DD.log
|
||||
telegram.ts # odesílání zpráv přes Telegram Bot API
|
||||
logs/ # denní logy textových změn (gitignored)
|
||||
state/ # stav mezi spuštěními — reference, lastKnown (gitignored)
|
||||
debug/ # HTTP debug logy při devMode: true (gitignored)
|
||||
```
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface TelegramConfig {
|
||||
export interface Config {
|
||||
days: DayUrls
|
||||
devMode: boolean
|
||||
silent: boolean
|
||||
overrideTime: string | null
|
||||
telegram: TelegramConfig
|
||||
}
|
||||
|
||||
@@ -26,6 +28,8 @@ const config: Config = {
|
||||
friday: "",
|
||||
},
|
||||
devMode: false,
|
||||
silent: false,
|
||||
overrideTime: null,
|
||||
telegram: {
|
||||
botToken: "",
|
||||
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 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
|
||||
}
|
||||
|
||||
+118
-21
@@ -1,23 +1,82 @@
|
||||
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) => {
|
||||
if (a.inactive !== b.inactive) return a.inactive ? 1 : -1
|
||||
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")
|
||||
}
|
||||
|
||||
function log(msg: string): void {
|
||||
if (!config.silent) console.log(msg)
|
||||
}
|
||||
|
||||
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)
|
||||
log(`TG sent: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const weekday = todayWeekday()
|
||||
const { totalMinutes, dateStr } = getCurrentTime()
|
||||
|
||||
if (totalMinutes >= 14 * 60) {
|
||||
log("After 14:00, exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
const weekday = todayWeekday()
|
||||
if (!weekday) {
|
||||
console.log("Weekend — nothing to do.")
|
||||
log("Weekend, exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,28 +86,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) {
|
||||
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)
|
||||
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), config.silent)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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, silent = false): 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`)
|
||||
if (!silent) console.log(`LOG: ${message}`)
|
||||
}
|
||||
+65
-11
@@ -1,19 +1,73 @@
|
||||
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 soldOut = $(el).find(".stitekDoporucujeme").text().trim() === "Vyprodáno"
|
||||
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: soldOut,
|
||||
}
|
||||
|
||||
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