Add Doris Kitchen household recipe app
This commit is contained in:
144
home/doris-kitchen/app/services/recipe_search.py
Normal file
144
home/doris-kitchen/app/services/recipe_search.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
from urllib import parse, request
|
||||
|
||||
from app.config import settings
|
||||
from app.services.recipe_importer import normalize_recipe_from_url
|
||||
from app.services.repository import repo
|
||||
from app.services.scoring import extract_ingredients, extract_tags, score_recipe
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
SEARCH_URL = "https://www.bing.com/search?format=rss&q="
|
||||
ENGLISH_HINT = " site:allrecipes.com OR site:budgetbytes.com OR site:seriouseats.com OR site:recipetineats.com OR site:spendwithpennies.com OR site:delish.com OR site:food.com"
|
||||
ALLOWED_DOMAINS = (
|
||||
"allrecipes.com",
|
||||
"budgetbytes.com",
|
||||
"seriouseats.com",
|
||||
"recipetineats.com",
|
||||
"spendwithpennies.com",
|
||||
"delish.com",
|
||||
"food.com",
|
||||
)
|
||||
FALLBACK_SEED_URLS = [
|
||||
"https://www.budgetbytes.com/curry-beef-with-peas/",
|
||||
"https://www.recipetineats.com/asian-beef-bowls/",
|
||||
"https://www.spendwithpennies.com/ground-beef-stroganoff/",
|
||||
"https://www.budgetbytes.com/chicken-and-rice-skillet/",
|
||||
]
|
||||
|
||||
|
||||
class RecipeSearchService:
|
||||
def __init__(self) -> None:
|
||||
self.store = JsonStore()
|
||||
|
||||
def _search_page(self, query: str) -> list[dict[str, str]]:
|
||||
req = request.Request(
|
||||
SEARCH_URL + parse.quote(query + ENGLISH_HINT),
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Doris Kitchen)",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
with request.urlopen(req, timeout=30) as resp:
|
||||
rss_text = resp.read().decode("utf-8", "replace")
|
||||
root = ET.fromstring(rss_text)
|
||||
results: list[dict[str, str]] = []
|
||||
for item in root.findall("./channel/item"):
|
||||
href = str(item.findtext("link") or "").strip()
|
||||
title = str(item.findtext("title") or "").strip()
|
||||
snippet = str(item.findtext("description") or "").strip()
|
||||
host = parse.urlparse(href).netloc.lower()
|
||||
if not href.startswith("http"):
|
||||
continue
|
||||
if not any(host.endswith(domain) or host == domain for domain in ALLOWED_DOMAINS):
|
||||
continue
|
||||
results.append({"url": href, "title": title, "snippet": snippet})
|
||||
deduped: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in results:
|
||||
if item["url"] in seen:
|
||||
continue
|
||||
seen.add(item["url"])
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
def search(self, query: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
limit = limit or settings.search_result_limit
|
||||
profile = repo.get_profile()
|
||||
results: list[dict[str, Any]] = []
|
||||
for hit in self._search_page(query):
|
||||
if len(results) >= limit:
|
||||
break
|
||||
try:
|
||||
payload = normalize_recipe_from_url(hit["url"], timeout=12)
|
||||
except Exception:
|
||||
continue
|
||||
score, reasons = score_recipe(query, payload, profile)
|
||||
suggestion = {
|
||||
"id": self.store.new_id("suggestion"),
|
||||
"title": payload.get("name") or hit["title"],
|
||||
"url": hit["url"],
|
||||
"source_query": query,
|
||||
"source_title": hit["title"],
|
||||
"source_snippet": hit["snippet"],
|
||||
"payload": payload,
|
||||
"ingredients": extract_ingredients(payload),
|
||||
"tags": extract_tags(payload),
|
||||
"score": score,
|
||||
"score_reasons": reasons,
|
||||
"status": "suggested",
|
||||
"created_at": self.store.now(),
|
||||
"updated_at": self.store.now(),
|
||||
"kitchenowl_status": "not-imported",
|
||||
"ready_for_auto_import": bool(profile.get("auto_import_enabled") and score >= int(profile.get("auto_import_threshold", settings.auto_import_threshold))),
|
||||
}
|
||||
repo.upsert_suggestion(suggestion)
|
||||
results.append(suggestion)
|
||||
return results
|
||||
|
||||
def _fallback_seed(self) -> list[dict[str, Any]]:
|
||||
profile = repo.get_profile()
|
||||
results: list[dict[str, Any]] = []
|
||||
for url in FALLBACK_SEED_URLS:
|
||||
try:
|
||||
payload = normalize_recipe_from_url(url, timeout=12)
|
||||
except Exception:
|
||||
continue
|
||||
score, reasons = score_recipe("seeded household dinner", payload, profile)
|
||||
suggestion = {
|
||||
"id": self.store.new_id("suggestion"),
|
||||
"title": payload.get("name") or url,
|
||||
"url": url,
|
||||
"source_query": "seeded household dinner",
|
||||
"source_title": payload.get("name") or url,
|
||||
"source_snippet": "Curated fallback suggestion for initial household testing.",
|
||||
"payload": payload,
|
||||
"ingredients": extract_ingredients(payload),
|
||||
"tags": extract_tags(payload),
|
||||
"score": score,
|
||||
"score_reasons": reasons,
|
||||
"status": "suggested",
|
||||
"created_at": self.store.now(),
|
||||
"updated_at": self.store.now(),
|
||||
"kitchenowl_status": "not-imported",
|
||||
"ready_for_auto_import": False,
|
||||
}
|
||||
repo.upsert_suggestion(suggestion)
|
||||
results.append(suggestion)
|
||||
return results
|
||||
|
||||
def seed_queries(self) -> list[dict[str, Any]]:
|
||||
profile = repo.get_profile()
|
||||
queries = profile.get("seed_queries") or []
|
||||
seeded: list[dict[str, Any]] = []
|
||||
for query in queries:
|
||||
seeded.extend(self.search(query, limit=2))
|
||||
if not seeded:
|
||||
seeded = self._fallback_seed()
|
||||
return seeded
|
||||
|
||||
|
||||
recipe_search = RecipeSearchService()
|
||||
Reference in New Issue
Block a user