DEV Community

loach
loach

Posted on Originally published at zenn.dev

I Built a Python Script to Cross-Post X Lists to Bluesky — x2b

Introduction

Using both X and Bluesky means that useful information often ends up split across two timelines.

In my case, I keep a curated X list for official accounts and news in a specific area, while I increasingly use Bluesky as my main social feed.

So I built x2b, a Python script that automatically cross-posts new posts from an X list to Bluesky.

https://github.com/mao2009/x2b

At first, I thought the job would be simple: fetch posts from X and send them to Bluesky.

Once I started running it regularly, however, a lot of edge cases appeared:

  • avoiding duplicate posts
  • staying within Bluesky's text limit
  • not breaking emoji or combined Unicode characters
  • handling oversized OGP images
  • retrying only temporary failures
  • testing safely without publishing anything

This article is about how a small automation script gradually turned into something that can be operated more reliably.

What x2b Does

The basic pipeline looks like this:

X List
  ↓
Fetch posts
  ↓
Check whether each post was already processed
  ↓
Build Bluesky text
  ↓
Fetch OGP metadata and thumbnail
  ↓
Publish to Bluesky
  ↓
Persist processed post IDs
Enter fullscreen mode Exit fullscreen mode

After setup, the normal command is simply:

.venv/bin/python x2b.py
Enter fullscreen mode Exit fullscreen mode

Configuration is kept in .env:

BSKY_HANDLE=your-handle.bsky.social
BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
X_LIST_ID=1234567890
Enter fullscreen mode Exit fullscreen mode

Preventing Duplicate Posts

A scheduled cross-poster needs to know which X posts it has already handled.

x2b stores processed X post IDs in seen.json.

The important part is not only what gets stored, but when it gets stored.

def mark_seen(seen, post_id):
    seen.add(post_id)
    save_seen(seen)
Enter fullscreen mode Exit fullscreen mode

A post is normally marked as seen only after a successful Bluesky post.

If it were marked before publishing, a temporary Bluesky outage could cause the post to be skipped forever on the next run.

On the other hand, known permanent failures can safely be marked as seen because replaying the exact same payload would produce the same result.

So x2b does not treat every failure the same way.

300 Characters Is Not Just len()

Bluesky limits post text by grapheme count.

That matters because Python's len() does not always match what a user perceives as a single character. Emoji and combining sequences can consist of multiple code points while still appearing as one character.

x2b uses the grapheme package when available:

def count_graphemes(text):
    if grapheme:
        return grapheme.length(text)
    return len(text)
Enter fullscreen mode Exit fullscreen mode

Truncation also happens on grapheme boundaries:

def truncate_text_to_graphemes(text, max_graphemes):
    if max_graphemes <= 0:
        return ""

    if count_graphemes(text) <= max_graphemes:
        return text

    keep = max_graphemes - 1

    if grapheme:
        return grapheme.slice(text, 0, keep) + ""

    return text[:keep] + ""
Enter fullscreen mode Exit fullscreen mode

This reduces the risk of cutting an emoji or combined character in the middle.

x2b also has to account for the prefix added before the original X text, such as the author name and handle.

So the available body length is calculated from the final Bluesky limit:

available_length = (
    BSKY_MAX_TEXT_GRAPHEMES
    - prefix_length
    - TEXT_LENGTH_MARGIN
)
Enter fullscreen mode Exit fullscreen mode

This is safer than blindly truncating the X text to a fixed number of characters.

OGP Failure Should Not Kill the Post

When an X post contains a URL, x2b tries to build a Bluesky external embed using OGP metadata.

But OGP data is external and unreliable by nature.

A remote page may have:

  • an unreachable image
  • a timeout
  • an unsupported or broken image
  • a thumbnail larger than Bluesky allows

The important design decision is that a thumbnail failure should not prevent the text itself from being posted.

If the thumbnail is too large, x2b tries to resize it with Pillow. If that still fails, the post continues without the thumbnail.

Download thumbnail
  ↓
Within limit → use it
  ↓
Too large → try resizing
  ↓
Still unusable → continue without thumbnail
Enter fullscreen mode Exit fullscreen mode

This turned out to be essential for unattended operation.

Classifying API Errors

An HTTP 500 response and an invalid payload are both failures, but they should not be handled the same way.

x2b classifies errors into three groups:

class PermanentError(Exception):
    pass

class TransientError(Exception):
    pass

class UnknownError(Exception):
    pass
Enter fullscreen mode Exit fullscreen mode

PermanentError

Used for known failures that will not be fixed by resending the same payload, for example:

  • text length violations
  • payload-too-large errors
  • known validation failures

These are not retried.

TransientError

Used for failures that may succeed later:

  • HTTP 429
  • HTTP 5xx
  • timeouts
  • network failures

Only these are retried, using exponential backoff:

delay = RETRY_BASE_DELAY * (2 ** attempt)
time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

UnknownError

This category is intentionally conservative.

If an error cannot be classified, x2b does not silently assume it is permanent.

Unknown failures are not retried immediately and are not marked as seen, so they become visible again on the next run and can be investigated.

For automation, continuing to run is important, but silently losing work is worse.

Dry-Run Through the Real Pipeline

Testing an auto-posting script is uncomfortable if every test publishes something to a real social account.

So x2b supports:

.venv/bin/python x2b.py --dry-run
Enter fullscreen mode Exit fullscreen mode

In dry-run mode, it does not:

  • publish to Bluesky
  • upload blobs
  • modify seen.json

But it still executes the same pipeline for:

  • fetching X posts
  • filtering
  • building Bluesky text
  • grapheme validation
  • OGP fetching
  • thumbnail validation and resizing
  • error classification
  • retry paths

The key idea is that dry-run disables only the side effects.

It is not just an early return such as:

if dry_run:
    return
Enter fullscreen mode Exit fullscreen mode

Instead, it runs as much of the production path as possible and reports what would have been posted.

This makes it much safer to check what a cron job will do before enabling real publishing.

Testing Without External Services

x2b also includes pytest-based tests:

pytest
Enter fullscreen mode Exit fullscreen mode

The test suite does not require a real Bluesky account, X API access, or external network access.

The most important cases are the ones that could cause operational problems:

  • no side effects in dry-run mode
  • retries only for transient errors
  • no retry loops for permanent errors
  • unknown failures are not silently marked as seen
  • oversized images are handled safely
  • generated text remains within the grapheme limit

External APIs are exactly where a small script benefits from clearly separated boundaries.

What I Learned

Fetching a post from X and sending it to Bluesky is not particularly difficult by itself.

The difficult part is everything around it:

What if fetching fails?
What if the same post appears again?
What if the text is too long?
What if it contains complex emoji?
What if the OGP image is huge?
What if Bluesky is temporarily unavailable?
What if the error is unknown?
How do I test it without publishing?
Enter fullscreen mode Exit fullscreen mode

Handling these cases one by one is what turned x2b from a one-off script into something closer to an operational tool.

Conclusion

x2b started as a small personal utility for using X and Bluesky together.

As it evolved, it ended up touching several concerns that are common to API-based automation in general: idempotency, Unicode handling, retries, external media, error classification, and side-effect isolation.

If you are building a small Python automation tool of your own, I hope some of these design choices are useful.

The source code is available on GitHub:

https://github.com/mao2009/x2b

Top comments (0)