Fix srcset-polluted URLs from butlerd, sanitize cover and screenshot URLs
All checks were successful
Build plugin / build (push) Successful in 1m13s

butlerd sometimes passes itch.io coverUrl/stillCoverUrl with trailing
srcset descriptors (e.g. ' 1x, h' or ' 1'). These polluted URLs cause
HTTPImageFetcher to fail because the URL is invalid and the extension
extraction breaks.

Changes:
- Add _sanitize_url() to strip srcset artifacts from URLs
- Add _is_gif_url() helper that works with both clean and polluted URLs
- Fix SCREENSHOT_REGEX_PATTERN to use [\w+/=]* instead of [^"]* to
  avoid crossing srcset boundaries in HTML
- Replace all .ends_with('.gif') checks with _is_gif_url()
- Sanitize all URLs from game metadata and HTML scraping
- Add IMAGE_EXTS constant for extension validation

Also bump to v0.1.7.
This commit is contained in:
Jose Falanga 2026-08-19 18:08:35 -03:00
parent 730b600ec0
commit 1bde072983
2 changed files with 34 additions and 12 deletions

View file

@ -13,7 +13,8 @@ extends BoxArtProvider
const BOXART_DIR := "user://boxart/itch" const BOXART_DIR := "user://boxart/itch"
const META_CACHE_FILE := "itch_art_meta.json" const META_CACHE_FILE := "itch_art_meta.json"
const HTTP_TIMEOUT_MS := 15000 const HTTP_TIMEOUT_MS := 15000
const SCREENSHOT_REGEX_PATTERN := "https://img\\.itch\\.zone/aW1hZ2Uv[^\"]*/original/[^\"]*" const SCREENSHOT_REGEX_PATTERN := "https://img\\.itch\\.zone/aW1hZ2Uv[\\w+/=]*/original/[\\w+/=]*\\.[a-z]+"
const IMAGE_EXTS := ["png", "jpg", "jpeg", "gif", "webp", "bmp"]
const CONNECT_POLL_DELAY_MS := 50 const CONNECT_POLL_DELAY_MS := 50
const READ_POLL_DELAY_MS := 10 const READ_POLL_DELAY_MS := 10
const LOG_URL_MAX_LEN := 80 const LOG_URL_MAX_LEN := 80
@ -45,6 +46,27 @@ func _ready() -> void:
add_child(http_image) add_child(http_image)
## Strips HTML srcset artifacts from a URL. Butlerd sometimes passes through
## itch.io cover URLs with trailing srcset descriptors like " 1x, h" or " 2x".
## These cause HTTPImageFetcher to fail because the URL is no longer valid.
func _sanitize_url(url: String) -> String:
if url.is_empty():
return url
# Srcset descriptors are separated by spaces — take only the URL portion.
var clean: String = url.split(" ")[0]
# Validate that the cleaned URL ends with a known image extension.
var ext: String = clean.get_extension().to_lower()
if ext not in IMAGE_EXTS:
logger.warn("Sanitized URL has unexpected extension '%s': %s" % [ext, clean.left(LOG_URL_MAX_LEN)])
return clean
## Returns whether a URL points to a GIF image. Handles both clean URLs and
## URLs contaminated with srcset artifacts (e.g. "image.gif 1x, h").
func _is_gif_url(url: String) -> bool:
return _sanitize_url(url).to_lower().ends_with(".gif")
func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
if not kind in layout_map: if not kind in layout_map:
logger.error("Unsupported boxart layout: {0}".format([kind])) logger.error("Unsupported boxart layout: {0}".format([kind]))
@ -81,15 +103,15 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
else: else:
logger.warn("No URL in game dict for: %s" % title) logger.warn("No URL in game dict for: %s" % title)
var screenshots: Array = meta.get("screenshots", []) var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
# Filter out GIF screenshots — Godot can't decode them. # Filter out GIF screenshots — Godot can't decode them.
var non_gif_screenshots: Array = screenshots.filter( var non_gif_screenshots: Array = screenshots.filter(
func(url): return not url.to_lower().ends_with(".gif") func(url): return not _is_gif_url(url)
) )
if screenshots.size() != non_gif_screenshots.size(): if screenshots.size() != non_gif_screenshots.size():
logger.info("Filtered %d GIF screenshots for: %s" % [screenshots.size() - non_gif_screenshots.size(), title]) logger.info("Filtered %d GIF screenshots for: %s" % [screenshots.size() - non_gif_screenshots.size(), title])
var cover_url := _best_portrait_url(game, non_gif_screenshots) var cover_url := _sanitize_url(_best_portrait_url(game, non_gif_screenshots))
if cover_url.is_empty(): if cover_url.is_empty():
logger.warn("No cover URL for: %s (id=%d)" % [title, game_id]) logger.warn("No cover URL for: %s (id=%d)" % [title, game_id])
return null return null
@ -150,14 +172,14 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str
LAYOUT.GRID_LANDSCAPE: LAYOUT.GRID_LANDSCAPE:
# Prefer a non-GIF screenshot # Prefer a non-GIF screenshot
for url in screenshots: for url in screenshots:
if not url.to_lower().ends_with(".gif"): if not _is_gif_url(url):
return url return url
return cover_url return cover_url
LAYOUT.BANNER: LAYOUT.BANNER:
# Single screenshot fallback (two-screenshot case handled in get_boxart). # Single screenshot fallback (two-screenshot case handled in get_boxart).
# Prefer a non-GIF screenshot. # Prefer a non-GIF screenshot.
for url in screenshots: for url in screenshots:
if not url.to_lower().ends_with(".gif"): if not _is_gif_url(url):
return url return url
return cover_url return cover_url
return "" return ""
@ -169,15 +191,15 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str
func _best_portrait_url(game: Dictionary, screenshots: Array) -> String: func _best_portrait_url(game: Dictionary, screenshots: Array) -> String:
# Prefer stillCoverUrl if it's not a GIF # Prefer stillCoverUrl if it's not a GIF
var still: String = game.get("stillCoverUrl", "") var still: String = game.get("stillCoverUrl", "")
if not still.is_empty() and not still.to_lower().ends_with(".gif"): if not still.is_empty() and not _is_gif_url(still):
return still return still
# Try coverUrl if it's not a GIF # Try coverUrl if it's not a GIF
var cover: String = game.get("coverUrl", "") var cover: String = game.get("coverUrl", "")
if not cover.is_empty() and not cover.to_lower().ends_with(".gif"): if not cover.is_empty() and not _is_gif_url(cover):
return cover return cover
# Try the first non-GIF screenshot # Try the first non-GIF screenshot
for url in screenshots: for url in screenshots:
if not url.to_lower().ends_with(".gif"): if not _is_gif_url(url):
return url return url
# Last resort: use whatever we have (GIF) # Last resort: use whatever we have (GIF)
return still if not still.is_empty() else cover return still if not still.is_empty() else cover
@ -274,8 +296,8 @@ func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
shot_re.compile(SCREENSHOT_REGEX_PATTERN) shot_re.compile(SCREENSHOT_REGEX_PATTERN)
var screenshots: Array = [] var screenshots: Array = []
for m in shot_re.search_all(html): for m in shot_re.search_all(html):
var url: String = m.get_string(0) var url: String = _sanitize_url(m.get_string(0))
if url.to_lower().ends_with(".gif"): if _is_gif_url(url):
logger.debug("Skipping GIF screenshot: " + url) logger.debug("Skipping GIF screenshot: " + url)
continue continue
screenshots.append(url) screenshots.append(url)

View file

@ -1,7 +1,7 @@
{ {
"plugin.id": "itch-artprovider", "plugin.id": "itch-artprovider",
"plugin.name": "itch.io Art Provider", "plugin.name": "itch.io Art Provider",
"plugin.version": "0.1.6", "plugin.version": "0.1.7",
"plugin.min-api-version": "1.1.0", "plugin.min-api-version": "1.1.0",
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",