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
+89
View File
@@ -259,3 +259,92 @@ class TestCoverImage:
def test_requires_a_source(self):
with pytest.raises(ValueError):
server.set_cover_image("x")
def test_a_recipe_page_is_resolved_to_its_hero_image(self, mock_mealie, monkeypatch):
# Verified live: Mealie answers 400 "Url is not an image" for a recipe page,
# and 200 for that same page's og:image.
page = "https://www.ica.se/recept/kalsoppa-722069/"
hero = "https://assets.icanet.se/imagevaultfiles/kalsoppa.jpg"
posted: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
if request.method == "POST" and request.url.path.endswith("/image"):
url = _json.loads(request.content)["url"]
posted.append(url)
if url == page:
return httpx.Response(
400, json={"detail": {"message": "Url is not an image", "error": True}}
)
return httpx.Response(200, json=None)
return httpx.Response(200, json={"slug": "soppa", "image": "abcd"})
mock_mealie(handler)
monkeypatch.setattr(
server, "_hero_image_url", lambda url: hero if url == page else None
)
result = server.set_cover_image("soppa", source_url=page)
# The page is tried first, then retried with the image it advertises.
assert posted == [page, hero]
assert result["method"] == "scrape (resolved og:image)"
assert result["resolved_image_url"] == hero
def test_a_direct_image_url_is_sent_unchanged(self, mock_mealie, monkeypatch):
recorded = mock_mealie(lambda r: httpx.Response(200, json={"slug": "x", "image": "img"}))
monkeypatch.setattr(server, "_hero_image_url", lambda url: pytest.fail("not needed"))
result = server.set_cover_image("x", source_url="https://example.com/hero.jpg")
assert result["method"] == "scrape"
assert "resolved_image_url" not in result
assert any(r.method == "POST" for r in recorded)
def test_a_page_with_no_hero_image_fails_loudly(self, mock_mealie, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and request.url.path.endswith("/image"):
return httpx.Response(400, json={"detail": {"message": "Url is not an image"}})
return httpx.Response(200, json={"slug": "x"})
mock_mealie(handler)
monkeypatch.setattr(server, "_hero_image_url", lambda url: None)
with pytest.raises(ValueError, match="no og:image"):
server.set_cover_image("x", source_url="https://example.com/page")
class TestHeroImageUrl:
def test_reads_og_image_in_either_attribute_order(self, monkeypatch):
pages = {
"a": '<meta property="og:image" content="https://x.test/a.jpg">',
"b": '<meta content="https://x.test/b.jpg" property="og:image">',
"c": '<meta name="twitter:image" content="https://x.test/c.jpg">',
"d": "<html><body>no image here</body></html>",
}
def fake_get(url, **kwargs):
return httpx.Response(200, text=pages[url], request=httpx.Request("GET", "http://t"))
monkeypatch.setattr(server.httpx, "get", fake_get)
assert server._hero_image_url("a") == "https://x.test/a.jpg"
assert server._hero_image_url("b") == "https://x.test/b.jpg"
assert server._hero_image_url("c") == "https://x.test/c.jpg"
assert server._hero_image_url("d") is None
def test_unescapes_entities_in_the_url(self, monkeypatch):
# Query strings in og:image arrive HTML-escaped and must be decoded.
page = '<meta property="og:image" content="https://x.test/a.jpg?w=1&amp;h=2">'
monkeypatch.setattr(
server.httpx, "get",
lambda url, **kw: httpx.Response(200, text=page, request=httpx.Request("GET", "http://t")),
)
assert server._hero_image_url("p") == "https://x.test/a.jpg?w=1&h=2"
def test_an_unreachable_page_is_not_an_error(self, monkeypatch):
def boom(url, **kwargs):
raise httpx.ConnectError("no route")
monkeypatch.setattr(server.httpx, "get", boom)
assert server._hero_image_url("https://example.com/page") is None