This commit is contained in:
Guillem Hernandez Sola
2026-05-08 14:48:50 +02:00
parent f9aaa8b3ed
commit 022af846a4

View File

@@ -2,12 +2,10 @@
""" """
bsky_post.py — Post text + optional image or video to Bluesky/federated PDS. bsky_post.py — Post text + optional image or video to Bluesky/federated PDS.
Usage examples: Examples:
python3 bsky_post.py "DIVENDRES!!!!" --video friday.mp4 --username you --password app-pass --service https://eurosky.social python3 bsky_post.py "DIVENDRES!!!!" --video media/divendres.mp4 --username you --password app-pass --service https://eurosky.social
python3 bsky_post.py "Dijous!!!!" --image thursday.jpg --username you --password app-pass python3 bsky_post.py "Dijous!!!!" --image media/dijous.jpg --username you --password app-pass --service https://eurosky.social
python3 bsky_post.py "Bon dia!" --username you --password app-pass python3 bsky_post.py "Bon dia!" --username you --password app-pass --service https://eurosky.social
python3 bsky_post.py "Bon dia!" --image photo.jpg --lang ca --username you --password app-pass
python3 bsky_post.py "Long video!" --video clip.mp4 --username you --password app-pass --allow-pds-video-fallback
""" """
import argparse import argparse
@@ -22,7 +20,7 @@ from dataclasses import dataclass
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from atproto import Client, client_utils, models from atproto import Client, models
# ============================================================ # ============================================================
@@ -47,47 +45,6 @@ def setup_logging() -> None:
) )
# ============================================================
# Text builder
# ============================================================
def make_rich(content: str):
text_builder = client_utils.TextBuilder()
content = content.strip()
lines = content.splitlines()
for line_idx, line in enumerate(lines):
if not line.strip():
if line_idx < len(lines) - 1:
text_builder.text("\n")
continue
words = line.split(" ")
for i, word in enumerate(words):
if not word:
if i < len(words) - 1:
text_builder.text(" ")
continue
if word.startswith("http://") or word.startswith("https://"):
text_builder.link(word, word)
elif word.startswith("#") and len(word) > 1:
tag_name = word[1:].rstrip(".,;:!?)'\"")
if tag_name:
text_builder.tag(word, tag_name)
else:
text_builder.text(word)
else:
text_builder.text(word)
if i < len(words) - 1:
text_builder.text(" ")
if line_idx < len(lines) - 1:
text_builder.text("\n")
return text_builder
# ============================================================ # ============================================================
# Error helpers # Error helpers
# ============================================================ # ============================================================
@@ -147,27 +104,7 @@ def is_transient_error(error_obj) -> bool:
def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float:
try: try:
now_ts = int(time.time()) now_ts = int(time.time())
headers = getattr(error_obj, "headers", None) or {} headers = getattr(error_obj, "headers", None) or {}
retry_after = headers.get("retry-after") or headers.get("Retry-After")
if retry_after:
return min(max(float(retry_after), 1.0), max_delay)
x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After")
if x_after:
return min(max(float(x_after), 1.0), max_delay)
reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset")
if reset_value:
wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay)
return min(wait_seconds, max_delay)
except Exception:
pass
try:
response = getattr(error_obj, "response", None)
headers = getattr(response, "headers", None) or {}
now_ts = int(time.time())
retry_after = headers.get("retry-after") or headers.get("Retry-After") retry_after = headers.get("retry-after") or headers.get("Retry-After")
if retry_after: if retry_after:
@@ -232,36 +169,25 @@ def login_with_backoff(
if is_rate_limited_error(e): if is_rate_limited_error(e):
if attempt < max_attempts: if attempt < max_attempts:
wait = get_rate_limit_wait_seconds(e, default_delay=base_delay, max_delay=max_delay) wait = get_rate_limit_wait_seconds(e, default_delay=base_delay, max_delay=max_delay)
wait = wait + random.uniform(0, jitter) wait += random.uniform(0, jitter)
logging.warning( logging.warning(f"⏳ Rate-limited. Retrying in {wait:.1f}s...")
f"⏳ Rate-limited on login (attempt {attempt}/{max_attempts}). "
f"Retrying in {wait:.1f}s..."
)
time.sleep(wait) time.sleep(wait)
continue continue
logging.error("❌ Exhausted login retries due to rate limiting.") logging.error("❌ Exhausted login retries due to rate limiting.")
return False return False
if is_network_error(e) or is_transient_error(e): if is_network_error(e) or is_transient_error(e):
if attempt < max_attempts: if attempt < max_attempts:
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter)
logging.warning( logging.warning(f"⏳ Transient error. Retrying in {wait:.1f}s...")
f"⏳ Transient login error (attempt {attempt}/{max_attempts}). "
f"Retrying in {wait:.1f}s..."
)
time.sleep(wait) time.sleep(wait)
continue continue
logging.error("❌ Exhausted login retries after transient/network errors.") logging.error("❌ Exhausted login retries after transient/network errors.")
return False return False
if attempt < max_attempts: if attempt < max_attempts:
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter)
logging.warning( logging.warning(f"⏳ Unknown login error. Retrying in {wait:.1f}s...")
f"⏳ Unknown login error (attempt {attempt}/{max_attempts}). "
f"Retrying in {wait:.1f}s..."
)
time.sleep(wait) time.sleep(wait)
continue continue
@@ -289,10 +215,9 @@ def detect_mime_type(path: str) -> str:
return fallbacks.get(ext, "application/octet-stream") return fallbacks.get(ext, "application/octet-stream")
def wait_with_heartbeat(total_seconds: float, label: str = "indexing") -> None: def wait_with_heartbeat(total_seconds: float, label: str = "processing") -> None:
if total_seconds <= 0: if total_seconds <= 0:
return return
logging.info(f"⏳ Waiting {total_seconds:.0f}s for {label}...") logging.info(f"⏳ Waiting {total_seconds:.0f}s for {label}...")
remaining = total_seconds remaining = total_seconds
while remaining > 0: while remaining > 0:
@@ -311,6 +236,16 @@ def pds_did_from_service_url(service_url: str) -> str:
return f"did:web:{host}" return f"did:web:{host}"
def model_to_dict(obj):
if obj is None:
return None
if hasattr(obj, "model_dump"):
return obj.model_dump(by_alias=True, exclude_none=True)
if hasattr(obj, "dict"):
return obj.dict(by_alias=True, exclude_none=True)
return obj
# ============================================================ # ============================================================
# Media upload — Image # Media upload — Image
# ============================================================ # ============================================================
@@ -320,6 +255,10 @@ def upload_image(
alt_text: str = "", alt_text: str = "",
) -> models.AppBskyEmbedImages.Image | None: ) -> models.AppBskyEmbedImages.Image | None:
try: try:
if not os.path.exists(image_path):
logging.error(f"❌ Image file not found: {image_path}")
return None
mime = detect_mime_type(image_path) mime = detect_mime_type(image_path)
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
data = f.read() data = f.read()
@@ -336,7 +275,7 @@ def upload_image(
# ============================================================ # ============================================================
# Media upload — Video (PDS direct fallback only) # Media upload — Video via PDS direct fallback
# ============================================================ # ============================================================
def upload_video_via_pds( def upload_video_via_pds(
client: Client, client: Client,
@@ -346,7 +285,7 @@ def upload_video_via_pds(
) -> models.AppBskyEmbedVideo.Main | None: ) -> models.AppBskyEmbedVideo.Main | None:
""" """
Direct upload to home PDS via upload_blob. Direct upload to home PDS via upload_blob.
Kept only as optional fallback; playback can be unreliable in clients. Fallback only. Can be less reliable for playback in clients.
""" """
try: try:
if not os.path.exists(video_path): if not os.path.exists(video_path):
@@ -373,7 +312,7 @@ def upload_video_via_pds(
# ============================================================ # ============================================================
# Media upload — Video (video.bsky.app primary) # Media upload — Video via video.bsky.app (primary)
# ============================================================ # ============================================================
def _extract_service_auth_token(upload_auth) -> str | None: def _extract_service_auth_token(upload_auth) -> str | None:
token = getattr(upload_auth, "token", None) token = getattr(upload_auth, "token", None)
@@ -393,9 +332,9 @@ def upload_video_via_bsky_service(
""" """
Upload via centralized video.bsky.app service. Upload via centralized video.bsky.app service.
IMPORTANT FIX: Critical compatibility fixes:
getServiceAuth(aud=...) must use the user's PDS DID (e.g. did:web:eurosky.social), - aud must be user's PDS DID (e.g. did:web:eurosky.social)
not did:web:video.bsky.app. - lxm must be com.atproto.repo.uploadBlob
""" """
try: try:
if not os.path.exists(video_path): if not os.path.exists(video_path):
@@ -411,19 +350,19 @@ def upload_video_via_bsky_service(
VIDEO_HOST = "https://video.bsky.app" VIDEO_HOST = "https://video.bsky.app"
pds_did = pds_did_from_service_url(service_url) pds_did = pds_did_from_service_url(service_url)
# Robust for different atproto versions # Some atproto versions prefer typed params, others accept dict
try: try:
params = models.ComAtprotoServerGetServiceAuth.Params( params = models.ComAtprotoServerGetServiceAuth.Params(
aud=pds_did, # <-- critical fix aud=pds_did,
lxm="app.bsky.video.uploadVideo", lxm="com.atproto.repo.uploadBlob", # <-- critical fix
exp=int(time.time()) + 60 * 30, exp=int(time.time()) + 60 * 30,
) )
upload_auth = client.com.atproto.server.get_service_auth(params) upload_auth = client.com.atproto.server.get_service_auth(params)
except Exception: except Exception:
upload_auth = client.com.atproto.server.get_service_auth( upload_auth = client.com.atproto.server.get_service_auth(
{ {
"aud": pds_did, # <-- critical fix "aud": pds_did,
"lxm": "app.bsky.video.uploadVideo", "lxm": "com.atproto.repo.uploadBlob", # <-- critical fix
"exp": int(time.time()) + 60 * 30, "exp": int(time.time()) + 60 * 30,
} }
) )
@@ -505,10 +444,7 @@ def upload_video_smart(
settle_delay_seconds: float = 30.0, settle_delay_seconds: float = 30.0,
allow_pds_video_fallback: bool = False, allow_pds_video_fallback: bool = False,
) -> models.AppBskyEmbedVideo.Main | None: ) -> models.AppBskyEmbedVideo.Main | None:
logging.info( logging.info(f"🌍 PDS ({service_url}). Trying video.bsky.app first.")
f"🌍 PDS ({service_url}). Trying video.bsky.app first for playback reliability."
)
embed = upload_video_via_bsky_service( embed = upload_video_via_bsky_service(
client=client, client=client,
video_path=video_path, video_path=video_path,
@@ -519,10 +455,7 @@ def upload_video_smart(
return embed return embed
if allow_pds_video_fallback: if allow_pds_video_fallback:
logging.warning( logging.warning("⚠️ video.bsky.app failed; trying direct PDS fallback.")
"⚠️ video.bsky.app failed; trying direct PDS fallback "
"(may be unplayable in some clients)."
)
return upload_video_via_pds( return upload_video_via_pds(
client=client, client=client,
video_path=video_path, video_path=video_path,
@@ -530,15 +463,12 @@ def upload_video_smart(
settle_delay_seconds=settle_delay_seconds, settle_delay_seconds=settle_delay_seconds,
) )
logging.error( logging.error("❌ video.bsky.app failed. Not using direct fallback unless enabled.")
"❌ video.bsky.app failed. Not posting unreliable direct-PDS video. "
"Use --allow-pds-video-fallback to override."
)
return None return None
# ============================================================ # ============================================================
# Post # Post creation (explicit record to guarantee text string)
# ============================================================ # ============================================================
def post_to_bsky( def post_to_bsky(
client: Client, client: Client,
@@ -553,7 +483,6 @@ def post_to_bsky(
) -> bool: ) -> bool:
post_text = text.strip() post_text = text.strip()
# Allow empty text only if media exists
if not post_text and not image_path and not video_path: if not post_text and not image_path and not video_path:
logging.error("❌ Empty post text with no media is not allowed.") logging.error("❌ Empty post text with no media is not allowed.")
return False return False
@@ -563,7 +492,7 @@ def post_to_bsky(
if video_path: if video_path:
logging.info(f"🎬 Preparing video upload: {video_path}") logging.info(f"🎬 Preparing video upload: {video_path}")
video_embed = upload_video_smart( embed_obj = upload_video_smart(
client=client, client=client,
video_path=video_path, video_path=video_path,
service_url=service_url, service_url=service_url,
@@ -571,10 +500,9 @@ def post_to_bsky(
settle_delay_seconds=video_settle_delay, settle_delay_seconds=video_settle_delay,
allow_pds_video_fallback=allow_pds_video_fallback, allow_pds_video_fallback=allow_pds_video_fallback,
) )
if not video_embed: if not embed_obj:
logging.error("❌ Aborting post: video upload/processing failed.") logging.error("❌ Aborting post: video upload/processing failed.")
return False return False
embed_obj = video_embed
elif image_path: elif image_path:
image = upload_image(client, image_path, alt_text=alt_text) image = upload_image(client, image_path, alt_text=alt_text)
@@ -583,10 +511,9 @@ def post_to_bsky(
return False return False
embed_obj = models.AppBskyEmbedImages.Main(images=[image]) embed_obj = models.AppBskyEmbedImages.Main(images=[image])
# Build record explicitly (most reliable)
record = { record = {
"$type": "app.bsky.feed.post", "$type": "app.bsky.feed.post",
"text": post_text, # <-- guaranteed string "text": post_text, # guaranteed plain string
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
} }
@@ -594,23 +521,27 @@ def post_to_bsky(
record["langs"] = langs record["langs"] = langs
if embed_obj is not None: if embed_obj is not None:
# atproto models -> plain dict record["embed"] = model_to_dict(embed_obj)
if hasattr(embed_obj, "model_dump"):
record["embed"] = embed_obj.model_dump(by_alias=True, exclude_none=True)
elif hasattr(embed_obj, "dict"):
record["embed"] = embed_obj.dict(by_alias=True, exclude_none=True)
else:
record["embed"] = embed_obj
logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}") logging.info(f"🧾 Final record text={record.get('text')!r}, has_embed={'embed' in record}")
resp = client.com.atproto.repo.create_record( # typed first, dict fallback for compatibility
models.ComAtprotoRepoCreateRecord.Data( try:
repo=client.me.did, resp = client.com.atproto.repo.create_record(
collection="app.bsky.feed.post", models.ComAtprotoRepoCreateRecord.Data(
record=record, repo=client.me.did,
collection="app.bsky.feed.post",
record=record,
)
)
except Exception:
resp = client.com.atproto.repo.create_record(
{
"repo": client.me.did,
"collection": "app.bsky.feed.post",
"record": record,
}
) )
)
uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None) uri = getattr(resp, "uri", None) or (resp.get("uri") if isinstance(resp, dict) else None)
logging.info(f"✅ Post published! URI: {uri}") logging.info(f"✅ Post published! URI: {uri}")
@@ -620,33 +551,32 @@ def post_to_bsky(
logging.error(f"❌ Failed to send post: {repr(e)}") logging.error(f"❌ Failed to send post: {repr(e)}")
return False return False
# ============================================================ # ============================================================
# CLI # CLI
# ============================================================ # ============================================================
def main(): def main():
setup_logging() setup_logging()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Post text + optional image/video to Bluesky/federated PDS.")
description="Post text + optional image or video to Bluesky/federated PDS."
)
parser.add_argument("text", help="Post text content") parser.add_argument("text", help="Post text content")
parser.add_argument("--username", required=True, help="Bluesky handle or email") parser.add_argument("--username", required=True, help="Bluesky handle or email")
parser.add_argument("--password", required=True, help="Bluesky app password") parser.add_argument("--password", required=True, help="Bluesky app password")
parser.add_argument("--service", default="https://bsky.social", help="Bluesky PDS URL") parser.add_argument("--service", default="https://bsky.social", help="PDS URL")
parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)") parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)")
parser.add_argument("--image", default=None, help="Path to image file to attach") parser.add_argument("--image", default=None, help="Path to image file")
parser.add_argument("--video", default=None, help="Path to video file to attach") parser.add_argument("--video", default=None, help="Path to video file")
parser.add_argument("--alt", default="", help="Alt text for media") parser.add_argument("--alt", default="", help="Alt text for media")
parser.add_argument( parser.add_argument(
"--video-settle-delay", "--video-settle-delay",
type=float, type=float,
default=30.0, default=30.0,
help="Seconds to wait after direct-PDS fallback upload before posting (default: 30).", help="Seconds to wait after direct-PDS fallback upload before posting.",
) )
parser.add_argument( parser.add_argument(
"--allow-pds-video-fallback", "--allow-pds-video-fallback",
action="store_true", action="store_true",
help="Allow direct-PDS fallback if video.bsky.app fails (less reliable playback).", help="Allow direct PDS video fallback if video.bsky.app fails.",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -656,6 +586,7 @@ def main():
sys.exit(1) sys.exit(1)
client = Client(base_url=args.service) client = Client(base_url=args.service)
success = login_with_backoff( success = login_with_backoff(
client=client, client=client,
username=args.username, username=args.username,