Resolve recipe pages to their hero image for cover scraping

Mealie's POST /api/recipes/{slug}/image downloads the URL it is given and
rejects anything that is not an image, so handing it a recipe page answered
400 {"message": "Url is not an image"}. This was read as a source-specific
failure on ica.se, but that page's own og:image uploads fine (verified live,
200 + 74608 bytes of image/webp), and httpbin's JPEG works too — the endpoint
simply never accepted pages.

Callers naturally pass the recipe page they just imported from, so resolve a
page to its advertised og:image and retry rather than making every caller know
the distinction. A page with no discoverable image now raises instead of
leaving the recipe silently coverless.

Co-authored-by: fredamn76 <fredrik.fallman@gmail.com>
Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
2026-07-31 09:50:01 +02:00
committed by fredamn76
parent 141b84bae1
commit c8775ac713
2 changed files with 147 additions and 5 deletions
+58 -5
View File
@@ -1,6 +1,7 @@
"""Small, confirmation-friendly MCP facade over the Mealie API."""
from __future__ import annotations
import html
import json
import os
import re
@@ -546,17 +547,53 @@ def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
}
#: Hero image declared by a recipe page, in the order worth trusting.
_HERO_META = (
re.compile(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)', re.I),
re.compile(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', re.I),
re.compile(r'<meta[^>]+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)', re.I),
)
def _hero_image_url(page_url: str) -> str | None:
"""Find the image a recipe page advertises as its own.
Mealie's cover endpoint downloads the URL it is given and rejects anything
that is not an image, so a page has to be reduced to a real image URL first.
Fetched with a browser user-agent because recipe sites refuse default ones.
"""
try:
response = httpx.get(
page_url,
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"},
timeout=30,
follow_redirects=True,
)
response.raise_for_status()
except httpx.HTTPError:
return None
for pattern in _HERO_META:
match = pattern.search(response.text)
if match:
return html.unescape(match.group(1))
return None
@mcp.tool()
def set_cover_image(
slug_or_id: str,
source_url: str | None = None,
image_path: str | None = None,
) -> dict[str, Any]:
"""Set a recipe cover image by scraping a URL or uploading a local file.
"""Set a recipe cover image from a URL or a local file.
``source_url`` may be a recipe page or a direct image URL. Mealie's endpoint
only accepts the latter — handing it a page answers
``400 {"message": "Url is not an image"}`` — so a page is resolved to its
``og:image`` first and that is what gets sent.
Local uploads must send the multipart ``extension`` field or the upload fails
validation silently on this instance. When a scrape does not produce a visible
cover (observed on Allrecipes), download the hero image and upload it instead.
validation silently on this instance.
"""
if not source_url and not image_path:
raise ValueError("Provide either source_url or image_path")
@@ -575,11 +612,27 @@ def set_cover_image(
_raise_for_status(response)
method = f"upload ({extension})"
else:
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": source_url})
used_url = source_url
method = "scrape"
try:
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url})
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 400 or "not an image" not in exc.response.text:
raise
used_url = _hero_image_url(source_url)
if not used_url:
raise ValueError(
f"{source_url} is not an image and no og:image was found on the page; "
"pass a direct image URL or use image_path"
) from exc
_request("POST", f"/api/recipes/{slug_or_id}/image", json={"url": used_url})
method = "scrape (resolved og:image)"
recipe = get_recipe(slug_or_id)
return {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
result = {"method": method, "image": recipe.get("image"), "slug": recipe.get("slug")}
if not image_path and used_url != source_url:
result["resolved_image_url"] = used_url
return result
# --- Verify -----------------------------------------------------------------