feat(rss): improve external card thumbnail uploads with stronger compression and shorter cooldowns

This commit is contained in:
2026-04-13 09:47:28 +02:00
parent 3f50e7d786
commit 07bfd5e2d5

View File

@@ -31,21 +31,23 @@ DEFAULT_COOLDOWN_STATE_PATH = "rss2bsky_cooldowns.json"
DEDUPE_BSKY_LIMIT = 30 DEDUPE_BSKY_LIMIT = 30
BSKY_TEXT_MAX_LENGTH = 275 BSKY_TEXT_MAX_LENGTH = 275
EXTERNAL_THUMB_MAX_BYTES = 950 * 1024 # External thumbnail tuning
EXTERNAL_THUMB_MAX_DIMENSION = 1200 EXTERNAL_THUMB_MAX_BYTES = 750 * 1024
EXTERNAL_THUMB_MIN_JPEG_QUALITY = 40 EXTERNAL_THUMB_TARGET_BYTES = 500 * 1024
EXTERNAL_THUMB_MAX_DIMENSION = 1000
EXTERNAL_THUMB_MIN_JPEG_QUALITY = 35
BSKY_BLOB_UPLOAD_MAX_RETRIES = 5 BSKY_BLOB_UPLOAD_MAX_RETRIES = 3
BSKY_BLOB_UPLOAD_BASE_DELAY = 10 BSKY_BLOB_UPLOAD_BASE_DELAY = 8
BSKY_BLOB_UPLOAD_MAX_DELAY = 300 BSKY_BLOB_UPLOAD_MAX_DELAY = 120
BSKY_BLOB_TRANSIENT_ERROR_RETRIES = 3 BSKY_BLOB_TRANSIENT_ERROR_RETRIES = 2
BSKY_BLOB_TRANSIENT_ERROR_DELAY = 15 BSKY_BLOB_TRANSIENT_ERROR_DELAY = 10
HTTP_TIMEOUT = 20 HTTP_TIMEOUT = 20
POST_RETRY_DELAY_SECONDS = 2 POST_RETRY_DELAY_SECONDS = 2
DEFAULT_POST_COOLDOWN_SECONDS = 3600 DEFAULT_POST_COOLDOWN_SECONDS = 3600
DEFAULT_THUMB_COOLDOWN_SECONDS = 3600 DEFAULT_THUMB_COOLDOWN_SECONDS = 1800
# --- Logging --- # --- Logging ---
@@ -679,7 +681,7 @@ def upload_blob_with_retry(client, binary_data, media_label="media", optional=Fa
return None return None
def compress_external_thumb_to_limit(image_bytes, max_bytes=EXTERNAL_THUMB_MAX_BYTES): def compress_external_thumb_to_limit(image_bytes, target_bytes=EXTERNAL_THUMB_TARGET_BYTES, hard_max_bytes=EXTERNAL_THUMB_MAX_BYTES):
if not PIL_AVAILABLE: if not PIL_AVAILABLE:
return None return None
@@ -694,15 +696,30 @@ def compress_external_thumb_to_limit(image_bytes, max_bytes=EXTERNAL_THUMB_MAX_B
scale = EXTERNAL_THUMB_MAX_DIMENSION / max_dim scale = EXTERNAL_THUMB_MAX_DIMENSION / max_dim
new_size = (max(1, int(width * scale)), max(1, int(height * scale))) new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
img = img.resize(new_size, Image.LANCZOS) img = img.resize(new_size, Image.LANCZOS)
logging.info(f"🖼️ Resized external thumb to {new_size[0]}x{new_size[1]}")
for quality in [85, 75, 65, 55, 45, EXTERNAL_THUMB_MIN_JPEG_QUALITY]: for quality in [78, 70, 62, 54, 46, 40, EXTERNAL_THUMB_MIN_JPEG_QUALITY]:
out = io.BytesIO() out = io.BytesIO()
img.save(out, format="JPEG", quality=quality, optimize=True, progressive=True) img.save(out, format="JPEG", quality=quality, optimize=True, progressive=True)
data = out.getvalue() data = out.getvalue()
if len(data) <= max_bytes:
logging.info(
f"🖼️ External thumb candidate size at JPEG quality {quality}: "
f"{len(data) / 1024:.2f} KB"
)
if len(data) <= target_bytes:
return data return data
for target_dim in [1000, 900, 800, 700, 600]: if len(data) <= hard_max_bytes:
best_so_far = data
# Additional downscale passes
best_candidate = locals().get("best_so_far")
if best_candidate and len(best_candidate) <= hard_max_bytes:
return best_candidate
for target_dim in [900, 800, 700, 600, 500]:
resized = img.copy() resized = img.copy()
width, height = resized.size width, height = resized.size
max_dim = max(width, height) max_dim = max(width, height)
@@ -712,13 +729,25 @@ def compress_external_thumb_to_limit(image_bytes, max_bytes=EXTERNAL_THUMB_MAX_B
new_size = (max(1, int(width * scale)), max(1, int(height * scale))) new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
resized = resized.resize(new_size, Image.LANCZOS) resized = resized.resize(new_size, Image.LANCZOS)
for quality in [60, 50, 45, EXTERNAL_THUMB_MIN_JPEG_QUALITY]: for quality in [54, 46, 40, EXTERNAL_THUMB_MIN_JPEG_QUALITY]:
out = io.BytesIO() out = io.BytesIO()
resized.save(out, format="JPEG", quality=quality, optimize=True, progressive=True) resized.save(out, format="JPEG", quality=quality, optimize=True, progressive=True)
data = out.getvalue() data = out.getvalue()
if len(data) <= max_bytes:
logging.info(
f"🖼️ External thumb resized to <= {target_dim}px at quality {quality}: "
f"{len(data) / 1024:.2f} KB"
)
if len(data) <= target_bytes:
return data return data
if len(data) <= hard_max_bytes:
best_candidate = data
if best_candidate and len(best_candidate) <= hard_max_bytes:
return best_candidate
except Exception as e: except Exception as e:
logging.warning(f"⚠️ Could not compress external thumbnail: {repr(e)}") logging.warning(f"⚠️ Could not compress external thumbnail: {repr(e)}")
@@ -742,16 +771,16 @@ def get_external_thumb_blob_from_url(image_url, client, http_client, cooldown_pa
logging.warning(f"⚠️ Could not fetch external thumb {image_url}: empty body") logging.warning(f"⚠️ Could not fetch external thumb {image_url}: empty body")
return None return None
upload_bytes = content logging.info(f"🖼️ Downloaded external thumb {image_url} ({len(content) / 1024:.2f} KB)")
if len(upload_bytes) > EXTERNAL_THUMB_MAX_BYTES:
compressed = compress_external_thumb_to_limit(upload_bytes, EXTERNAL_THUMB_MAX_BYTES) upload_bytes = compress_external_thumb_to_limit(content)
if compressed: if not upload_bytes:
upload_bytes = compressed logging.warning("⚠️ Could not prepare compressed external thumbnail. Omitting thumbnail.")
else:
logging.warning("⚠️ Could not compress external thumb to fit limit. Omitting thumbnail.")
return None return None
return upload_blob_with_retry( logging.info(f"🖼️ Final external thumb upload size: {len(upload_bytes) / 1024:.2f} KB")
blob = upload_blob_with_retry(
client, client,
upload_bytes, upload_bytes,
media_label=f"external-thumb:{image_url}", media_label=f"external-thumb:{image_url}",
@@ -759,6 +788,12 @@ def get_external_thumb_blob_from_url(image_url, client, http_client, cooldown_pa
cooldown_on_rate_limit=True, cooldown_on_rate_limit=True,
cooldown_path=cooldown_path cooldown_path=cooldown_path
) )
if blob:
logging.info("✅ External thumbnail uploaded successfully")
return blob
logging.warning("⚠️ External thumbnail upload failed. Will omit thumbnail.")
return None
except Exception as e: except Exception as e:
logging.warning(f"⚠️ Could not fetch/upload external thumb {image_url}: {repr(e)}") logging.warning(f"⚠️ Could not fetch/upload external thumb {image_url}: {repr(e)}")
@@ -802,6 +837,8 @@ def build_external_link_embed(url, fallback_title, client, http_client, cooldown
logging.info("✅ External link card thumbnail prepared successfully") logging.info("✅ External link card thumbnail prepared successfully")
else: else:
logging.info(" External link card will be posted without thumbnail") logging.info(" External link card will be posted without thumbnail")
else:
logging.info(" No og:image found for external link card")
if link_metadata.get("title") or link_metadata.get("description") or thumb_blob: if link_metadata.get("title") or link_metadata.get("description") or thumb_blob:
return models.AppBskyEmbedExternal.Main( return models.AppBskyEmbedExternal.Main(
@@ -878,6 +915,7 @@ def is_probable_length_error(exc):
"string too long", "string too long",
"maxLength", "maxLength",
"length", "length",
"grapheme too big",
] ]
return any(signal.lower() in text.lower() for signal in signals) return any(signal.lower() in text.lower() for signal in signals)