feat(rss): improve external card thumbnail uploads with stronger compression and shorter cooldowns
This commit is contained in:
84
rss2bsky.py
84
rss2bsky.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user