Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
YAR (Одеса, вул. Шишкіна, 48/1): AI-підбір страв і напоїв + фото-лінки.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/yar_menu_flow.py
1#!/usr/bin/env python32"""YAR cafe menu automation over ChoiceQR.34Features:5- Discover all sections from one URL (menu/bar/wine/etc)6- Dump normalized menu JSON (name, price, category, section, photo, description)7- Search by free text8- Suggest pizza + drink combos with generic filters9- Build "cards" payload (photo link + caption/description) for chat delivery1011Examples:12python3 scripts/yar_menu_flow.py dump --out yar_dump.json13python3 scripts/yar_menu_flow.py search --input yar_dump.json --query "салат"14python3 scripts/yar_menu_flow.py suggest --input yar_dump.json --budget 600 --no-alcohol15python3 scripts/yar_menu_flow.py cards --input yar_dump.json --query "піца" --require-photo16"""1718from __future__ import annotations1920import argparse21import json22import re23import sys24from dataclasses import dataclass25from datetime import datetime, timezone26from pathlib import Path27from typing import Dict, Iterable, List, Optional, Sequence, Tuple28from urllib.parse import urlparse2930import requests3132DEFAULT_URL = "https://yar.choiceqr.com/section:menu"33SECTION_RE = re.compile(r"section:([A-Za-z0-9_-]+)")34NEXT_DATA_RE = re.compile(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S)3536NON_ALCOHOL_CATEGORY_HINTS = (37"безалко",38"безалког",39"прохолод",40"гарячі напої",41"чай",42"фреш",43"кава",44)4546ALCOHOL_CATEGORY_HINTS = (47"віскі",48"ром",49"джин",50"горіл",51"коньяк",52"бренді",53"текіла",54"меск",55"аперитив",56"діджестив",57"пиво",58"коктейл",59)6061DRINK_CATEGORY_HINTS = tuple(dict.fromkeys(NON_ALCOHOL_CATEGORY_HINTS + ALCOHOL_CATEGORY_HINTS + (62"напої",63"чай",64"кава",65"фреш",66"лимонад",67"сік",68)))6970DRINK_INTENT_HINTS = (71"пити",72"попити",73"напій",74"напої",75"кава",76"чай",77"drink",78"beverage",79)8081STOPWORDS = {82"і", "й", "та", "або", "а", "але", "що", "щоб", "це", "ця", "цей", "ці",83"мені", "мене", "для", "дуже", "просто", "потрібно", "хочу", "хочеться", "щось",84"не", "так", "там", "тут", "по", "на", "в", "у", "з", "із", "до", "без",85"я", "мы", "мне", "меня", "хочу", "что", "это", "для", "или", "и", "но",86"please", "with", "without", "and", "the", "want", "something",87}8889TOKEN_RE = re.compile(r"[\w'’\-]{3,}", re.UNICODE)9091TERM_EXPANSIONS = {92"ситне": ["м'яс", "гриль", "стейк", "ребра", "шашлич", "піц"],93"сытное": ["м'яс", "гриль", "стейк", "ребра", "шашлич", "піц"],94"hearty": ["м'яс", "гриль", "стейк", "ребра", "шашлич", "піц"],95"легке": ["салат", "риба", "морепродукт"],96"легкое": ["салат", "риба", "морепродукт"],97"light": ["салат", "риба", "морепродукт"],98"десерт": ["солод", "чізкейк", "торт", "медовик"],99"солодке": ["солод", "чізкейк", "торт", "медовик"],100"sweet": ["солод", "чізкейк", "торт", "медовик"],101"кава": ["еспресо", "латте", "капучино", "раф"],102"coffee": ["еспресо", "латте", "капучино", "раф"],103}104105106@dataclass107class MenuItem:108name: str109price_uah: Optional[float]110description: str111category_name: str112section_name: str113section_slug: str114hurl: str115available: bool116media_url: Optional[str]117118def to_dict(self) -> Dict[str, object]:119return {120"name": self.name,121"price_uah": self.price_uah,122"description": self.description,123"category_name": self.category_name,124"section_name": self.section_name,125"section_slug": self.section_slug,126"hurl": self.hurl,127"available": self.available,128"media_url": self.media_url,129}130131132def _site_base(url: str) -> str:133p = urlparse(url)134if not p.scheme or not p.netloc:135raise ValueError(f"Invalid URL: {url}")136return f"{p.scheme}://{p.netloc}"137138139def _get_html(url: str, timeout: int = 30) -> str:140r = requests.get(url, timeout=timeout)141r.raise_for_status()142return r.text143144145def discover_sections(url: str) -> List[str]:146html = _get_html(url)147found = sorted(set(SECTION_RE.findall(html)))148if not found:149m = SECTION_RE.search(url)150if m:151return [m.group(1)]152return found153154155def _extract_next_data(html: str) -> Dict[str, object]:156m = NEXT_DATA_RE.search(html)157if not m:158raise RuntimeError("Could not find __NEXT_DATA__ payload in HTML")159return json.loads(m.group(1))160161162def _pick_media_url(media: object) -> Optional[str]:163if not isinstance(media, list) or not media:164return None165first = media[0]166if not isinstance(first, dict):167return None168for k in ("big", "medium", "url", "thumbnail"):169v = first.get(k)170if isinstance(v, str) and v:171return v172return None173174175def fetch_section(site_base: str, section_slug: str) -> Dict[str, object]:176url = f"{site_base}/section:{section_slug}"177html = _get_html(url)178payload = _extract_next_data(html)179180app = ((payload.get("props") or {}).get("app") or {})181place = app.get("place") or {}182menu = app.get("menu") or []183categories = app.get("categories") or []184sections = app.get("sections") or []185186category_map = {str(c.get("_id")): str(c.get("name") or "") for c in categories if isinstance(c, dict)}187section_map = {188str(s.get("_id")): {"name": str(s.get("name") or ""), "hurl": str(s.get("hurl") or "")}189for s in sections190if isinstance(s, dict)191}192193normalized: List[MenuItem] = []194for raw in menu:195if not isinstance(raw, dict):196continue197price_raw = raw.get("price")198price_uah = round(float(price_raw) / 100.0, 2) if isinstance(price_raw, (int, float)) else None199section_meta = section_map.get(str(raw.get("section")), {})200201normalized.append(202MenuItem(203name=str(raw.get("name") or "").strip(),204price_uah=price_uah,205description=str(raw.get("description") or "").strip(),206category_name=category_map.get(str(raw.get("category")), ""),207section_name=str(section_meta.get("name") or ""),208section_slug=str(section_meta.get("hurl") or section_slug),209hurl=str(raw.get("hurl") or ""),210available=bool(raw.get("available", True)),211media_url=_pick_media_url(raw.get("media")),212)213)214215return {216"section_slug": section_slug,217"url": url,218"place_name": str(place.get("name") or ""),219"place_type": str(place.get("type") or ""),220"item_count": len(normalized),221"items": [i.to_dict() for i in normalized],222}223224225def dump_menu(url: str, explicit_sections: Optional[Sequence[str]] = None) -> Dict[str, object]:226site = _site_base(url)227sections = list(explicit_sections or discover_sections(url))228if not sections:229raise RuntimeError("No sections discovered. Provide --sections explicitly.")230231data_sections = []232for slug in sections:233try:234data_sections.append(fetch_section(site, slug))235except Exception as e: # noqa: BLE001236data_sections.append(237{238"section_slug": slug,239"url": f"{site}/section:{slug}",240"error": str(e),241"item_count": 0,242"items": [],243}244)245246place_name = ""247for s in data_sections:248if isinstance(s, dict) and s.get("place_name"):249place_name = str(s.get("place_name"))250break251252return {253"source_url": url,254"site_base": site,255"place_name": place_name,256"fetched_at": datetime.now(timezone.utc).isoformat(),257"sections": data_sections,258}259260261def _iter_items(dataset: Dict[str, object]) -> Iterable[Dict[str, object]]:262for sec in dataset.get("sections", []):263if not isinstance(sec, dict):264continue265for item in sec.get("items", []):266if isinstance(item, dict):267yield item268269270def _matches(text: str, terms: Sequence[str]) -> bool:271t = text.lower()272return all(term.lower() in t for term in terms)273274275def search_items(dataset: Dict[str, object], query: str, limit: int = 30) -> List[Dict[str, object]]:276terms = [x for x in re.split(r"\s+", query.strip()) if x]277rows = []278for item in _iter_items(dataset):279hay = " ".join(280[281str(item.get("name", "")),282str(item.get("description", "")),283str(item.get("category_name", "")),284str(item.get("section_name", "")),285]286)287if not terms or _matches(hay, terms):288rows.append(item)289rows.sort(key=lambda x: (x.get("section_name", ""), x.get("category_name", ""), x.get("name", "")))290return rows[:limit]291292293def _is_non_alcohol_drink(item: Dict[str, object]) -> bool:294cat = str(item.get("category_name", "")).lower()295name = str(item.get("name", "")).lower()296if any(k in cat for k in NON_ALCOHOL_CATEGORY_HINTS):297return True298if "n/a" in name:299return True300if "pepsi" in name or "компот" in name or "моршин" in name or "borjomi" in name:301return True302return False303304305def _is_alcohol(item: Dict[str, object]) -> bool:306cat = str(item.get("category_name", "")).lower()307return any(k in cat for k in ALCOHOL_CATEGORY_HINTS)308309310def _tokenize_preference_text(text: str) -> List[str]:311tokens = [t.lower() for t in TOKEN_RE.findall(text or "")]312out: List[str] = []313for t in tokens:314if t in STOPWORDS:315continue316if t.isdigit():317continue318out.append(t)319# Keep order + uniqueness.320seen = set()321uniq: List[str] = []322for t in out:323if t in seen:324continue325seen.add(t)326uniq.append(t)327return uniq328329330def _expand_positive_terms(terms: Sequence[str]) -> List[str]:331out: List[str] = []332for t in terms:333if t not in out:334out.append(t)335for key, extra in TERM_EXPANSIONS.items():336if key in t or t in key:337for x in extra:338if x not in out:339out.append(x)340return out341342343def _extract_avoid_terms(text: str) -> List[str]:344low = (text or "").lower()345patterns = [346r"(?:без|without|avoid|exclude|no)\s+([\w'’\-]{3,})",347r"(?:не\s+хочу|не\s+люблю)\s+([\w'’\-]{3,})",348]349found: List[str] = []350for p in patterns:351for m in re.findall(p, low, flags=re.UNICODE):352if m and m not in found:353found.append(m)354return found355356357def _is_drink(item: Dict[str, object]) -> bool:358cat = str(item.get("category_name", "")).lower()359return any(k in cat for k in DRINK_CATEGORY_HINTS)360361362def _is_food(item: Dict[str, object]) -> bool:363return not _is_drink(item)364365366def _wants_drink(preference_text: str) -> bool:367low = (preference_text or "").lower()368return any(h in low for h in DRINK_INTENT_HINTS)369370371def _item_text(item: Dict[str, object]) -> Tuple[str, str, str]:372name = str(item.get("name", "")).lower()373desc = str(item.get("description", "")).lower()374cat = str(item.get("category_name", "")).lower()375return name, desc, cat376377378def _score_item(item: Dict[str, object], terms: Sequence[str]) -> Tuple[int, List[str]]:379name, desc, cat = _item_text(item)380score = 0381matched: List[str] = []382for t in terms:383tt = t.lower()384if tt in name:385score += 6386matched.append(tt)387elif tt in cat:388score += 4389matched.append(tt)390elif tt in desc:391score += 2392matched.append(tt)393if item.get("media_url"):394score += 1395if item.get("description"):396score += 1397return score, matched398399400def suggest_combo(401dataset: Dict[str, object],402preference_text: str,403likes: Sequence[str],404avoid_terms: Sequence[str],405no_alcohol: bool,406budget: Optional[float],407with_drink: str,408limit: int = 3,409) -> List[Dict[str, object]]:410items = [x for x in _iter_items(dataset) if bool(x.get("available", True))]411412auto_terms = _tokenize_preference_text(preference_text)413explicit_terms = [x.strip().lower() for x in likes if x.strip()]414positive_terms = _expand_positive_terms(list(dict.fromkeys(explicit_terms + auto_terms)))415416auto_avoid = _extract_avoid_terms(preference_text)417all_avoid = list(dict.fromkeys([x.strip().lower() for x in avoid_terms if x.strip()] + auto_avoid))418419def avoid_variants(term: str) -> List[str]:420t = term.strip().lower()421variants = [t]422stripped = re.sub(r"[уюоеаияіь]+$", "", t)423if len(stripped) >= 3:424variants.append(stripped)425if len(t) >= 4:426variants.append(t[:4])427return list(dict.fromkeys([v for v in variants if len(v) >= 3]))428429avoid_all_variants = []430for a in all_avoid:431avoid_all_variants.extend(avoid_variants(a))432avoid_all_variants = list(dict.fromkeys(avoid_all_variants))433434def bad(item: Dict[str, object]) -> bool:435text = " ".join(_item_text(item))436return any(a in text for a in avoid_all_variants)437438foods = [x for x in items if _is_food(x) and not bad(x)]439drinks = [x for x in items if _is_drink(x) and not bad(x)]440441if no_alcohol:442drinks = [x for x in drinks if not _is_alcohol(x)]443444scored_foods: List[Tuple[int, Dict[str, object], List[str]]] = []445for f in foods:446s, m = _score_item(f, positive_terms)447scored_foods.append((s, f, m))448449scored_drinks: List[Tuple[int, Dict[str, object], List[str]]] = []450for d in drinks:451s, m = _score_item(d, positive_terms)452scored_drinks.append((s, d, m))453454# If no clear terms, fallback to balanced picks (with photos first, then cheaper first).455def food_sort_key(x: Tuple[int, Dict[str, object], List[str]]) -> Tuple[int, int, float]:456score, item, _ = x457has_photo = 1 if item.get("media_url") else 0458price = float(item.get("price_uah") or 0)459return (score, has_photo, -price)460461def drink_sort_key(x: Tuple[int, Dict[str, object], List[str]]) -> Tuple[int, int, float]:462score, item, _ = x463has_photo = 1 if item.get("media_url") else 0464price = float(item.get("price_uah") or 0)465return (score, has_photo, -price)466467scored_foods.sort(key=food_sort_key, reverse=True)468scored_drinks.sort(key=drink_sort_key, reverse=True)469470need_drink = with_drink == "yes" or (with_drink == "auto" and _wants_drink(preference_text))471472suggestions: List[Tuple[float, Dict[str, object]]] = []473474if need_drink:475for fs, food, fmatch in scored_foods[:18]:476f_price = float(food.get("price_uah") or 0)477for ds, drink, dmatch in scored_drinks[:24]:478d_price = float(drink.get("price_uah") or 0)479total = f_price + d_price480if budget is not None and total > budget:481continue482rank = (fs * 10 + ds) - abs((budget or total) - total)483suggestions.append(484(485rank,486{487"main": food,488"drink": drink,489"total_uah": round(total, 2),490"matched_terms": list(dict.fromkeys(fmatch + dmatch)),491},492)493)494else:495for fs, food, fmatch in scored_foods[:40]:496total = float(food.get("price_uah") or 0)497if budget is not None and total > budget:498continue499rank = fs - abs((budget or total) - total)500suggestions.append(501(502rank,503{504"