Fixes for 429
- fix SyntaxError caused by global STATE_PATH/COOLDOWN_STATE_PATH usage in main() - pass state and cooldown paths explicitly to helpers - add emoji-based logging for login, posting, cooldown, and summary events - keep shared cooldown behavior across parallel Jenkins workers
This commit is contained in:
241
rss2bsky.py
241
rss2bsky.py
@@ -25,8 +25,8 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
STATE_PATH = "rss2bsky_state.json"
|
DEFAULT_STATE_PATH = "rss2bsky_state.json"
|
||||||
COOLDOWN_STATE_PATH = "rss2bsky_cooldowns.json"
|
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
|
||||||
@@ -56,7 +56,7 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not PIL_AVAILABLE:
|
if not PIL_AVAILABLE:
|
||||||
logging.warning("Pillow is not installed. External card thumbnail compression is disabled.")
|
logging.warning("🟡 Pillow is not installed. External card thumbnail compression is disabled.")
|
||||||
|
|
||||||
|
|
||||||
# --- Cooldown persistence ---
|
# --- Cooldown persistence ---
|
||||||
@@ -69,7 +69,7 @@ def default_cooldown_state():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_cooldown_state(path=COOLDOWN_STATE_PATH):
|
def load_cooldown_state(path):
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return default_cooldown_state()
|
return default_cooldown_state()
|
||||||
|
|
||||||
@@ -86,11 +86,11 @@ def load_cooldown_state(path=COOLDOWN_STATE_PATH):
|
|||||||
state.setdefault("updated_at", None)
|
state.setdefault("updated_at", None)
|
||||||
return state
|
return state
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not load cooldown state {path}: {e}")
|
logging.warning(f"⚠️ Could not load cooldown state {path}: {e}")
|
||||||
return default_cooldown_state()
|
return default_cooldown_state()
|
||||||
|
|
||||||
|
|
||||||
def save_cooldown_state(state, path=COOLDOWN_STATE_PATH):
|
def save_cooldown_state(state, path):
|
||||||
try:
|
try:
|
||||||
state["updated_at"] = arrow.utcnow().isoformat()
|
state["updated_at"] = arrow.utcnow().isoformat()
|
||||||
temp_path = f"{path}.tmp"
|
temp_path = f"{path}.tmp"
|
||||||
@@ -100,48 +100,48 @@ def save_cooldown_state(state, path=COOLDOWN_STATE_PATH):
|
|||||||
|
|
||||||
os.replace(temp_path, path)
|
os.replace(temp_path, path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not save cooldown state {path}: {e}")
|
logging.warning(f"⚠️ Could not save cooldown state {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_global_post_cooldown_until():
|
def get_global_post_cooldown_until(cooldown_path):
|
||||||
state = load_cooldown_state()
|
state = load_cooldown_state(cooldown_path)
|
||||||
return int(state.get("post_creation_cooldown_until", 0) or 0)
|
return int(state.get("post_creation_cooldown_until", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
def get_global_thumb_cooldown_until():
|
def get_global_thumb_cooldown_until(cooldown_path):
|
||||||
state = load_cooldown_state()
|
state = load_cooldown_state(cooldown_path)
|
||||||
return int(state.get("thumb_upload_cooldown_until", 0) or 0)
|
return int(state.get("thumb_upload_cooldown_until", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
def is_global_post_cooldown_active():
|
def is_global_post_cooldown_active(cooldown_path):
|
||||||
return int(time.time()) < get_global_post_cooldown_until()
|
return int(time.time()) < get_global_post_cooldown_until(cooldown_path)
|
||||||
|
|
||||||
|
|
||||||
def is_global_thumb_cooldown_active():
|
def is_global_thumb_cooldown_active(cooldown_path):
|
||||||
return int(time.time()) < get_global_thumb_cooldown_until()
|
return int(time.time()) < get_global_thumb_cooldown_until(cooldown_path)
|
||||||
|
|
||||||
|
|
||||||
def set_global_post_cooldown_until(reset_ts):
|
def set_global_post_cooldown_until(reset_ts, cooldown_path):
|
||||||
state = load_cooldown_state()
|
state = load_cooldown_state(cooldown_path)
|
||||||
current = int(state.get("post_creation_cooldown_until", 0) or 0)
|
current = int(state.get("post_creation_cooldown_until", 0) or 0)
|
||||||
|
|
||||||
if reset_ts > current:
|
if reset_ts > current:
|
||||||
state["post_creation_cooldown_until"] = int(reset_ts)
|
state["post_creation_cooldown_until"] = int(reset_ts)
|
||||||
save_cooldown_state(state)
|
save_cooldown_state(state, cooldown_path)
|
||||||
|
|
||||||
final_ts = int(state.get("post_creation_cooldown_until", 0) or 0)
|
final_ts = int(load_cooldown_state(cooldown_path).get("post_creation_cooldown_until", 0) or 0)
|
||||||
return final_ts
|
return final_ts
|
||||||
|
|
||||||
|
|
||||||
def set_global_thumb_cooldown_until(reset_ts):
|
def set_global_thumb_cooldown_until(reset_ts, cooldown_path):
|
||||||
state = load_cooldown_state()
|
state = load_cooldown_state(cooldown_path)
|
||||||
current = int(state.get("thumb_upload_cooldown_until", 0) or 0)
|
current = int(state.get("thumb_upload_cooldown_until", 0) or 0)
|
||||||
|
|
||||||
if reset_ts > current:
|
if reset_ts > current:
|
||||||
state["thumb_upload_cooldown_until"] = int(reset_ts)
|
state["thumb_upload_cooldown_until"] = int(reset_ts)
|
||||||
save_cooldown_state(state)
|
save_cooldown_state(state, cooldown_path)
|
||||||
|
|
||||||
final_ts = int(state.get("thumb_upload_cooldown_until", 0) or 0)
|
final_ts = int(load_cooldown_state(cooldown_path).get("thumb_upload_cooldown_until", 0) or 0)
|
||||||
return final_ts
|
return final_ts
|
||||||
|
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ def desescapar_unicode(text):
|
|||||||
try:
|
try:
|
||||||
return html.unescape(text)
|
return html.unescape(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Error unescaping unicode/html entities: {e}")
|
logging.warning(f"⚠️ Error unescaping unicode/html entities: {e}")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -206,21 +206,10 @@ def process_title(title):
|
|||||||
title_text = clean_whitespace(title_text)
|
title_text = clean_whitespace(title_text)
|
||||||
return title_text
|
return title_text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Error processing title: {e}")
|
logging.warning(f"⚠️ Error processing title: {e}")
|
||||||
return title or ""
|
return title or ""
|
||||||
|
|
||||||
|
|
||||||
def truncate_text_safely(text, max_length=BSKY_TEXT_MAX_LENGTH):
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
|
|
||||||
truncated = text[:max_length - 3]
|
|
||||||
last_space = truncated.rfind(" ")
|
|
||||||
if last_space > 0:
|
|
||||||
return truncated[:last_space] + "..."
|
|
||||||
return truncated + "..."
|
|
||||||
|
|
||||||
|
|
||||||
def build_post_text_variants(title_text, link):
|
def build_post_text_variants(title_text, link):
|
||||||
title_text = clean_whitespace(title_text)
|
title_text = clean_whitespace(title_text)
|
||||||
link = canonicalize_url(link) or link or ""
|
link = canonicalize_url(link) or link or ""
|
||||||
@@ -288,9 +277,9 @@ def default_state():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_state(state_path=STATE_PATH):
|
def load_state(state_path):
|
||||||
if not os.path.exists(state_path):
|
if not os.path.exists(state_path):
|
||||||
logging.info(f"No state file found at {state_path}. Starting with empty state.")
|
logging.info(f"🧠 No state file found at {state_path}. Starting with empty state.")
|
||||||
return default_state()
|
return default_state()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -298,7 +287,7 @@ def load_state(state_path=STATE_PATH):
|
|||||||
state = json.load(f)
|
state = json.load(f)
|
||||||
|
|
||||||
if not isinstance(state, dict):
|
if not isinstance(state, dict):
|
||||||
logging.warning("State file invalid. Reinitializing.")
|
logging.warning("⚠️ State file invalid. Reinitializing.")
|
||||||
return default_state()
|
return default_state()
|
||||||
|
|
||||||
state.setdefault("version", 1)
|
state.setdefault("version", 1)
|
||||||
@@ -308,11 +297,11 @@ def load_state(state_path=STATE_PATH):
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not load state file {state_path}: {e}. Reinitializing.")
|
logging.warning(f"⚠️ Could not load state file {state_path}: {e}. Reinitializing.")
|
||||||
return default_state()
|
return default_state()
|
||||||
|
|
||||||
|
|
||||||
def save_state(state, state_path=STATE_PATH):
|
def save_state(state, state_path):
|
||||||
try:
|
try:
|
||||||
state["updated_at"] = arrow.utcnow().isoformat()
|
state["updated_at"] = arrow.utcnow().isoformat()
|
||||||
temp_path = f"{state_path}.tmp"
|
temp_path = f"{state_path}.tmp"
|
||||||
@@ -321,10 +310,10 @@ def save_state(state, state_path=STATE_PATH):
|
|||||||
json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True)
|
json.dump(state, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
|
||||||
os.replace(temp_path, state_path)
|
os.replace(temp_path, state_path)
|
||||||
logging.info(f"State saved to {state_path}")
|
logging.info(f"💾 State saved to {state_path}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to save state file {state_path}: {e}")
|
logging.error(f"❌ Failed to save state file {state_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def prune_state(state, max_entries=5000):
|
def prune_state(state, max_entries=5000):
|
||||||
@@ -459,7 +448,7 @@ def get_recent_bsky_posts(client, handle, limit=DEDUPE_BSKY_LIMIT):
|
|||||||
logging.debug(f"Skipping one Bluesky feed item during dedupe fetch: {e}")
|
logging.debug(f"Skipping one Bluesky feed item during dedupe fetch: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not fetch recent Bluesky posts for duplicate detection: {e}")
|
logging.warning(f"⚠️ Could not fetch recent Bluesky posts for duplicate detection: {e}")
|
||||||
|
|
||||||
return recent_posts
|
return recent_posts
|
||||||
|
|
||||||
@@ -597,27 +586,27 @@ def is_timeout_error(error_obj):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def activate_post_creation_cooldown_from_error(error_obj):
|
def activate_post_creation_cooldown_from_error(error_obj, cooldown_path):
|
||||||
reset_ts = get_rate_limit_reset_timestamp(error_obj)
|
reset_ts = get_rate_limit_reset_timestamp(error_obj)
|
||||||
if not reset_ts:
|
if not reset_ts:
|
||||||
reset_ts = int(time.time()) + DEFAULT_POST_COOLDOWN_SECONDS
|
reset_ts = int(time.time()) + DEFAULT_POST_COOLDOWN_SECONDS
|
||||||
|
|
||||||
final_ts = set_global_post_cooldown_until(reset_ts)
|
final_ts = set_global_post_cooldown_until(reset_ts, cooldown_path)
|
||||||
logging.error(
|
logging.error(
|
||||||
f"=== BSKY POST STOPPED: RATE LIMITED === Posting disabled until "
|
f"🛑 === BSKY POST STOPPED: RATE LIMITED === Posting disabled until "
|
||||||
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(final_ts))}"
|
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(final_ts))}"
|
||||||
)
|
)
|
||||||
return final_ts
|
return final_ts
|
||||||
|
|
||||||
|
|
||||||
def activate_thumb_upload_cooldown_from_error(error_obj):
|
def activate_thumb_upload_cooldown_from_error(error_obj, cooldown_path):
|
||||||
reset_ts = get_rate_limit_reset_timestamp(error_obj)
|
reset_ts = get_rate_limit_reset_timestamp(error_obj)
|
||||||
if not reset_ts:
|
if not reset_ts:
|
||||||
reset_ts = int(time.time()) + DEFAULT_THUMB_COOLDOWN_SECONDS
|
reset_ts = int(time.time()) + DEFAULT_THUMB_COOLDOWN_SECONDS
|
||||||
|
|
||||||
final_ts = set_global_thumb_cooldown_until(reset_ts)
|
final_ts = set_global_thumb_cooldown_until(reset_ts, cooldown_path)
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Thumbnail uploads disabled until "
|
f"🖼️ Thumbnail uploads disabled until "
|
||||||
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(final_ts))}."
|
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(final_ts))}."
|
||||||
)
|
)
|
||||||
return final_ts
|
return final_ts
|
||||||
@@ -633,7 +622,7 @@ def get_rate_limit_wait_seconds(error_obj, default_delay):
|
|||||||
return default_delay
|
return default_delay
|
||||||
|
|
||||||
|
|
||||||
def upload_blob_with_retry(client, binary_data, media_label="media", optional=False, cooldown_on_rate_limit=False):
|
def upload_blob_with_retry(client, binary_data, media_label="media", optional=False, cooldown_on_rate_limit=False, cooldown_path=DEFAULT_COOLDOWN_STATE_PATH):
|
||||||
last_exception = None
|
last_exception = None
|
||||||
transient_attempts = 0
|
transient_attempts = 0
|
||||||
|
|
||||||
@@ -647,11 +636,11 @@ def upload_blob_with_retry(client, binary_data, media_label="media", optional=Fa
|
|||||||
|
|
||||||
if is_rate_limited_error(e):
|
if is_rate_limited_error(e):
|
||||||
if cooldown_on_rate_limit:
|
if cooldown_on_rate_limit:
|
||||||
activate_thumb_upload_cooldown_from_error(e)
|
activate_thumb_upload_cooldown_from_error(e, cooldown_path)
|
||||||
|
|
||||||
if optional and cooldown_on_rate_limit:
|
if optional and cooldown_on_rate_limit:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Optional blob upload rate-limited for {media_label}. "
|
f"🟡 Optional blob upload rate-limited for {media_label}. "
|
||||||
f"Skipping remaining retries and omitting optional media."
|
f"Skipping remaining retries and omitting optional media."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -664,29 +653,29 @@ def upload_blob_with_retry(client, binary_data, media_label="media", optional=Fa
|
|||||||
|
|
||||||
if attempt < BSKY_BLOB_UPLOAD_MAX_RETRIES:
|
if attempt < BSKY_BLOB_UPLOAD_MAX_RETRIES:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Blob upload rate-limited for {media_label}. "
|
f"⏳ Blob upload rate-limited for {media_label}. "
|
||||||
f"Retry {attempt}/{BSKY_BLOB_UPLOAD_MAX_RETRIES} after {wait_seconds}s."
|
f"Retry {attempt}/{BSKY_BLOB_UPLOAD_MAX_RETRIES} after {wait_seconds}s."
|
||||||
)
|
)
|
||||||
time.sleep(wait_seconds)
|
time.sleep(wait_seconds)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logging.warning(f"Exhausted blob upload retries for {media_label}: {repr(e)}")
|
logging.warning(f"⚠️ Exhausted blob upload retries for {media_label}: {repr(e)}")
|
||||||
break
|
break
|
||||||
|
|
||||||
if is_transient_blob_error(e) and transient_attempts < BSKY_BLOB_TRANSIENT_ERROR_RETRIES:
|
if is_transient_blob_error(e) and transient_attempts < BSKY_BLOB_TRANSIENT_ERROR_RETRIES:
|
||||||
transient_attempts += 1
|
transient_attempts += 1
|
||||||
wait_seconds = BSKY_BLOB_TRANSIENT_ERROR_DELAY * transient_attempts
|
wait_seconds = BSKY_BLOB_TRANSIENT_ERROR_DELAY * transient_attempts
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Transient blob upload failure for {media_label}: {repr(e)}. "
|
f"⏳ Transient blob upload failure for {media_label}: {repr(e)}. "
|
||||||
f"Retry {transient_attempts}/{BSKY_BLOB_TRANSIENT_ERROR_RETRIES} after {wait_seconds}s."
|
f"Retry {transient_attempts}/{BSKY_BLOB_TRANSIENT_ERROR_RETRIES} after {wait_seconds}s."
|
||||||
)
|
)
|
||||||
time.sleep(wait_seconds)
|
time.sleep(wait_seconds)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logging.warning(f"Could not upload {media_label}: {repr(e)}")
|
logging.warning(f"⚠️ Could not upload {media_label}: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logging.warning(f"Could not upload {media_label}: {repr(last_exception)}")
|
logging.warning(f"⚠️ Could not upload {media_label}: {repr(last_exception)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -731,26 +720,26 @@ def compress_external_thumb_to_limit(image_bytes, max_bytes=EXTERNAL_THUMB_MAX_B
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
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)}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_external_thumb_blob_from_url(image_url, client, http_client):
|
def get_external_thumb_blob_from_url(image_url, client, http_client, cooldown_path):
|
||||||
if is_global_thumb_cooldown_active():
|
if is_global_thumb_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_thumb_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_thumb_cooldown_until(cooldown_path)))
|
||||||
logging.info(f"Skipping external thumbnail upload due to active cooldown until {reset_str}")
|
logging.info(f"🖼️ Skipping external thumbnail upload due to active cooldown until {reset_str}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = http_client.get(image_url, timeout=HTTP_TIMEOUT, follow_redirects=True)
|
r = http_client.get(image_url, timeout=HTTP_TIMEOUT, follow_redirects=True)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
logging.warning(f"Could not fetch external thumb {image_url}: HTTP {r.status_code}")
|
logging.warning(f"⚠️ Could not fetch external thumb {image_url}: HTTP {r.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
content = r.content
|
content = r.content
|
||||||
if not content:
|
if not content:
|
||||||
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
|
upload_bytes = content
|
||||||
@@ -759,7 +748,7 @@ def get_external_thumb_blob_from_url(image_url, client, http_client):
|
|||||||
if compressed:
|
if compressed:
|
||||||
upload_bytes = compressed
|
upload_bytes = compressed
|
||||||
else:
|
else:
|
||||||
logging.warning("Could not compress external thumb to fit limit. Omitting thumbnail.")
|
logging.warning("⚠️ Could not compress external thumb to fit limit. Omitting thumbnail.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return upload_blob_with_retry(
|
return upload_blob_with_retry(
|
||||||
@@ -767,11 +756,12 @@ def get_external_thumb_blob_from_url(image_url, client, http_client):
|
|||||||
upload_bytes,
|
upload_bytes,
|
||||||
media_label=f"external-thumb:{image_url}",
|
media_label=f"external-thumb:{image_url}",
|
||||||
optional=True,
|
optional=True,
|
||||||
cooldown_on_rate_limit=True
|
cooldown_on_rate_limit=True,
|
||||||
|
cooldown_path=cooldown_path
|
||||||
)
|
)
|
||||||
|
|
||||||
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)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -798,20 +788,20 @@ def fetch_link_metadata(url, http_client):
|
|||||||
"image": image["content"] if image and image.has_attr("content") else None,
|
"image": image["content"] if image and image.has_attr("content") else None,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not fetch link metadata for {url}: {e}")
|
logging.warning(f"⚠️ Could not fetch link metadata for {url}: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def build_external_link_embed(url, fallback_title, client, http_client):
|
def build_external_link_embed(url, fallback_title, client, http_client, cooldown_path):
|
||||||
link_metadata = fetch_link_metadata(url, http_client)
|
link_metadata = fetch_link_metadata(url, http_client)
|
||||||
|
|
||||||
thumb_blob = None
|
thumb_blob = None
|
||||||
if link_metadata.get("image"):
|
if link_metadata.get("image"):
|
||||||
thumb_blob = get_external_thumb_blob_from_url(link_metadata["image"], client, http_client)
|
thumb_blob = get_external_thumb_blob_from_url(link_metadata["image"], client, http_client, cooldown_path)
|
||||||
if thumb_blob:
|
if thumb_blob:
|
||||||
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")
|
||||||
|
|
||||||
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(
|
||||||
@@ -854,7 +844,7 @@ def build_candidates_from_feed(feed):
|
|||||||
published_at = parse_entry_time(item)
|
published_at = parse_entry_time(item)
|
||||||
|
|
||||||
if not title_text and not link:
|
if not title_text and not link:
|
||||||
logging.info("Skipping feed item with no usable title and no link.")
|
logging.info("⏭️ Skipping feed item with no usable title and no link.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
normalized_title = normalize_text(title_text)
|
normalized_title = normalize_text(title_text)
|
||||||
@@ -872,7 +862,7 @@ def build_candidates_from_feed(feed):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to prepare feed entry candidate: {e}")
|
logging.warning(f"⚠️ Failed to prepare feed entry candidate: {e}")
|
||||||
|
|
||||||
candidates.sort(key=lambda c: c["published_arrow"] or arrow.get(0))
|
candidates.sort(key=lambda c: c["published_arrow"] or arrow.get(0))
|
||||||
return candidates
|
return candidates
|
||||||
@@ -892,30 +882,30 @@ def is_probable_length_error(exc):
|
|||||||
return any(signal.lower() in text.lower() for signal in signals)
|
return any(signal.lower() in text.lower() for signal in signals)
|
||||||
|
|
||||||
|
|
||||||
def try_send_post_with_variants(client, text_variants, embed, post_lang):
|
def try_send_post_with_variants(client, text_variants, embed, post_lang, cooldown_path):
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
raise RuntimeError(f"Posting skipped because global post cooldown is active until {reset_str}")
|
raise RuntimeError(f"Posting skipped because global post cooldown is active until {reset_str}")
|
||||||
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
||||||
for idx, variant in enumerate(text_variants, start=1):
|
for idx, variant in enumerate(text_variants, start=1):
|
||||||
try:
|
try:
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
raise RuntimeError(f"Posting skipped because global post cooldown is active until {reset_str}")
|
raise RuntimeError(f"Posting skipped because global post cooldown is active until {reset_str}")
|
||||||
|
|
||||||
logging.info(f"Trying post text variant {idx}/{len(text_variants)} (length={len(variant)})")
|
logging.info(f"📝 Trying post text variant {idx}/{len(text_variants)} (length={len(variant)})")
|
||||||
rich_text = make_rich(variant)
|
rich_text = make_rich(variant)
|
||||||
result = client.send_post(text=rich_text, embed=embed, langs=[post_lang])
|
result = client.send_post(text=rich_text, embed=embed, langs=[post_lang])
|
||||||
return result, variant
|
return result, variant
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exception = e
|
last_exception = e
|
||||||
logging.warning(f"Post variant {idx} failed: {repr(e)}")
|
logging.warning(f"⚠️ Post variant {idx} failed: {repr(e)}")
|
||||||
|
|
||||||
if is_rate_limited_error(e):
|
if is_rate_limited_error(e):
|
||||||
activate_post_creation_cooldown_from_error(e)
|
activate_post_creation_cooldown_from_error(e, cooldown_path)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if is_timeout_error(e):
|
if is_timeout_error(e):
|
||||||
@@ -939,14 +929,10 @@ def main():
|
|||||||
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
||||||
parser.add_argument("--service", default="https://bsky.social", help="Bluesky server URL")
|
parser.add_argument("--service", default="https://bsky.social", help="Bluesky server URL")
|
||||||
parser.add_argument("--lang", default="ca", help="Language code for the post")
|
parser.add_argument("--lang", default="ca", help="Language code for the post")
|
||||||
parser.add_argument("--state-path", default=STATE_PATH, help="Path to local JSON state file")
|
parser.add_argument("--state-path", default=DEFAULT_STATE_PATH, help="Path to local JSON state file")
|
||||||
parser.add_argument("--cooldown-path", default=COOLDOWN_STATE_PATH, help="Path to shared cooldown JSON state file")
|
parser.add_argument("--cooldown-path", default=DEFAULT_COOLDOWN_STATE_PATH, help="Path to shared cooldown JSON state file")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
global STATE_PATH, COOLDOWN_STATE_PATH
|
|
||||||
STATE_PATH = args.state_path
|
|
||||||
COOLDOWN_STATE_PATH = args.cooldown_path
|
|
||||||
|
|
||||||
feed_url = args.rss_feed
|
feed_url = args.rss_feed
|
||||||
bsky_handle = args.bsky_handle
|
bsky_handle = args.bsky_handle
|
||||||
bsky_username = args.bsky_username
|
bsky_username = args.bsky_username
|
||||||
@@ -954,10 +940,11 @@ def main():
|
|||||||
service_url = args.service
|
service_url = args.service
|
||||||
post_lang = args.lang
|
post_lang = args.lang
|
||||||
state_path = args.state_path
|
state_path = args.state_path
|
||||||
|
cooldown_path = args.cooldown_path
|
||||||
|
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.warning(f"=== BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
logging.warning(f"🟡 === BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
||||||
return
|
return
|
||||||
|
|
||||||
client = Client(base_url=service_url)
|
client = Client(base_url=service_url)
|
||||||
@@ -965,25 +952,25 @@ def main():
|
|||||||
backoff = 60
|
backoff = 60
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.warning(f"=== BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
logging.warning(f"🟡 === BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Attempting login to server: {service_url} with user: {bsky_username}")
|
logging.info(f"🔐 Attempting login to server: {service_url} with user: {bsky_username}")
|
||||||
client.login(bsky_username, bsky_password)
|
client.login(bsky_username, bsky_password)
|
||||||
logging.info(f"Login successful for user: {bsky_username}")
|
logging.info(f"✅ Login successful for user: {bsky_username}")
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Login exception")
|
logging.exception("❌ Login exception")
|
||||||
time.sleep(backoff)
|
time.sleep(backoff)
|
||||||
backoff = min(backoff + 60, 600)
|
backoff = min(backoff + 60, 600)
|
||||||
|
|
||||||
state = load_state(state_path)
|
state = load_state(state_path)
|
||||||
recent_bsky_posts = get_recent_bsky_posts(client, bsky_handle, limit=DEDUPE_BSKY_LIMIT)
|
recent_bsky_posts = get_recent_bsky_posts(client, bsky_handle, limit=DEDUPE_BSKY_LIMIT)
|
||||||
|
|
||||||
logging.info(f"Loaded {len(recent_bsky_posts)} recent Bluesky posts for duplicate detection.")
|
logging.info(f"🧠 Loaded {len(recent_bsky_posts)} recent Bluesky posts for duplicate detection.")
|
||||||
logging.info(f"Local state currently tracks {len(state.get('posted_entries', {}))} posted items.")
|
logging.info(f"🧠 Local state currently tracks {len(state.get('posted_entries', {}))} posted items.")
|
||||||
|
|
||||||
response = httpx.get(feed_url, timeout=HTTP_TIMEOUT, follow_redirects=True)
|
response = httpx.get(feed_url, timeout=HTTP_TIMEOUT, follow_redirects=True)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -994,58 +981,58 @@ def main():
|
|||||||
raise ValueError("Could not detect feed encoding.")
|
raise ValueError("Could not detect feed encoding.")
|
||||||
feed_content = result.text
|
feed_content = result.text
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logging.warning("Could not detect feed encoding with charset_normalizer. Trying latin-1.")
|
logging.warning("⚠️ Could not detect feed encoding with charset_normalizer. Trying latin-1.")
|
||||||
try:
|
try:
|
||||||
feed_content = response.content.decode("latin-1")
|
feed_content = response.content.decode("latin-1")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
logging.warning("Could not decode with latin-1. Trying utf-8 with ignored errors.")
|
logging.warning("⚠️ Could not decode with latin-1. Trying utf-8 with ignored errors.")
|
||||||
feed_content = response.content.decode("utf-8", errors="ignore")
|
feed_content = response.content.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
feed = fastfeedparser.parse(feed_content)
|
feed = fastfeedparser.parse(feed_content)
|
||||||
candidates = build_candidates_from_feed(feed)
|
candidates = build_candidates_from_feed(feed)
|
||||||
|
|
||||||
logging.info(f"Prepared {len(candidates)} feed entry candidates for duplicate comparison.")
|
logging.info(f"📰 Prepared {len(candidates)} feed entry candidates for duplicate comparison.")
|
||||||
|
|
||||||
entries_to_post = []
|
entries_to_post = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
is_dup_state, reason_state = candidate_matches_state(candidate, state)
|
is_dup_state, reason_state = candidate_matches_state(candidate, state)
|
||||||
if is_dup_state:
|
if is_dup_state:
|
||||||
logging.info(f"Skipping candidate due to local state duplicate match on: {reason_state}")
|
logging.info(f"⏭️ Skipping candidate due to local state duplicate match on: {reason_state}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_dup_bsky, reason_bsky = candidate_matches_existing_bsky(candidate, recent_bsky_posts)
|
is_dup_bsky, reason_bsky = candidate_matches_existing_bsky(candidate, recent_bsky_posts)
|
||||||
if is_dup_bsky:
|
if is_dup_bsky:
|
||||||
logging.info(f"Skipping candidate due to recent Bluesky duplicate match on: {reason_bsky}")
|
logging.info(f"⏭️ Skipping candidate due to recent Bluesky duplicate match on: {reason_bsky}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entries_to_post.append(candidate)
|
entries_to_post.append(candidate)
|
||||||
|
|
||||||
logging.info(f"{len(entries_to_post)} entries remain after duplicate filtering.")
|
logging.info(f"📬 {len(entries_to_post)} entries remain after duplicate filtering.")
|
||||||
|
|
||||||
if not entries_to_post:
|
if not entries_to_post:
|
||||||
logging.info("ℹ️ Execution finished: no new entries to publish.")
|
logging.info("ℹ️ Execution finished: no new entries to publish.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.warning(f"=== BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
logging.warning(f"🟡 === BSKY POST SKIPPED: GLOBAL COOLDOWN === Active until {reset_str}")
|
||||||
return
|
return
|
||||||
|
|
||||||
noves_entrades = 0
|
noves_entrades = 0
|
||||||
|
|
||||||
with httpx.Client() as http_client:
|
with httpx.Client() as http_client:
|
||||||
for candidate in entries_to_post:
|
for candidate in entries_to_post:
|
||||||
if is_global_post_cooldown_active():
|
if is_global_post_cooldown_active(cooldown_path):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.error(f"=== BSKY POST STOPPED: GLOBAL COOLDOWN === Skipping remaining entries until {reset_str}")
|
logging.error(f"🛑 === BSKY POST STOPPED: GLOBAL COOLDOWN === Skipping remaining entries until {reset_str}")
|
||||||
break
|
break
|
||||||
|
|
||||||
title_text = candidate["title_text"]
|
title_text = candidate["title_text"]
|
||||||
canonical_link = candidate["canonical_link"]
|
canonical_link = candidate["canonical_link"]
|
||||||
text_variants = candidate["post_text_variants"]
|
text_variants = candidate["post_text_variants"]
|
||||||
|
|
||||||
logging.info(f"Preparing to post RSS entry: {canonical_link or title_text}")
|
logging.info(f"📰 Preparing to post RSS entry: {canonical_link or title_text}")
|
||||||
logging.info(f"=== BSKY POST START === {canonical_link or title_text}")
|
logging.info(f"🚀 === BSKY POST START === {canonical_link or title_text}")
|
||||||
|
|
||||||
embed = None
|
embed = None
|
||||||
if canonical_link:
|
if canonical_link:
|
||||||
@@ -1053,7 +1040,8 @@ def main():
|
|||||||
canonical_link,
|
canonical_link,
|
||||||
fallback_title=title_text or "Enllaç",
|
fallback_title=title_text or "Enllaç",
|
||||||
client=client,
|
client=client,
|
||||||
http_client=http_client
|
http_client=http_client,
|
||||||
|
cooldown_path=cooldown_path
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1061,7 +1049,8 @@ def main():
|
|||||||
client=client,
|
client=client,
|
||||||
text_variants=text_variants,
|
text_variants=text_variants,
|
||||||
embed=embed,
|
embed=embed,
|
||||||
post_lang=post_lang
|
post_lang=post_lang,
|
||||||
|
cooldown_path=cooldown_path
|
||||||
)
|
)
|
||||||
|
|
||||||
bsky_uri = getattr(post_result, "uri", None)
|
bsky_uri = getattr(post_result, "uri", None)
|
||||||
@@ -1080,28 +1069,28 @@ def main():
|
|||||||
recent_bsky_posts = recent_bsky_posts[:DEDUPE_BSKY_LIMIT]
|
recent_bsky_posts = recent_bsky_posts[:DEDUPE_BSKY_LIMIT]
|
||||||
|
|
||||||
noves_entrades += 1
|
noves_entrades += 1
|
||||||
logging.info(f"=== BSKY POST SUCCESS === {canonical_link or title_text}")
|
logging.info(f"✅ === BSKY POST SUCCESS === {canonical_link or title_text}")
|
||||||
logging.info(f"Posted RSS entry to Bluesky: {canonical_link or title_text}")
|
logging.info(f"🎉 Posted RSS entry to Bluesky: {canonical_link or title_text}")
|
||||||
time.sleep(POST_RETRY_DELAY_SECONDS)
|
time.sleep(POST_RETRY_DELAY_SECONDS)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if is_rate_limited_error(e):
|
if is_rate_limited_error(e):
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.error(f"=== BSKY POST FAILED === {canonical_link or title_text}")
|
logging.error(f"❌ === BSKY POST FAILED === {canonical_link or title_text}")
|
||||||
logging.error(f"=== BSKY POST STOPPED: RATE LIMITED === Ending publish loop until {reset_str}")
|
logging.error(f"🛑 === BSKY POST STOPPED: RATE LIMITED === Ending publish loop until {reset_str}")
|
||||||
break
|
break
|
||||||
|
|
||||||
if "global post cooldown is active" in str(e).lower():
|
if "global post cooldown is active" in str(e).lower():
|
||||||
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until()))
|
reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(get_global_post_cooldown_until(cooldown_path)))
|
||||||
logging.warning(f"=== BSKY POST SKIPPED: GLOBAL COOLDOWN === {canonical_link or title_text}")
|
logging.warning(f"🟡 === BSKY POST SKIPPED: GLOBAL COOLDOWN === {canonical_link or title_text}")
|
||||||
logging.warning(f"=== BSKY POST STOPPED: GLOBAL COOLDOWN === Ending publish loop until {reset_str}")
|
logging.warning(f"🛑 === BSKY POST STOPPED: GLOBAL COOLDOWN === Ending publish loop until {reset_str}")
|
||||||
break
|
break
|
||||||
|
|
||||||
if is_timeout_error(e):
|
if is_timeout_error(e):
|
||||||
logging.error(f"=== BSKY POST FAILED === {canonical_link or title_text} :: timeout")
|
logging.error(f"⏰ === BSKY POST FAILED === {canonical_link or title_text} :: timeout")
|
||||||
break
|
break
|
||||||
|
|
||||||
logging.exception(f"=== BSKY POST FAILED === {canonical_link or title_text}")
|
logging.exception(f"❌ === BSKY POST FAILED === {canonical_link or title_text}")
|
||||||
|
|
||||||
if noves_entrades > 0:
|
if noves_entrades > 0:
|
||||||
logging.info(f"🎉 Execution finished: published {noves_entrades} new entries to Bluesky.")
|
logging.info(f"🎉 Execution finished: published {noves_entrades} new entries to Bluesky.")
|
||||||
|
|||||||
Reference in New Issue
Block a user