Build BulkPublish

KINDAreplaces $13.99/mosaves $167.88/yrback to the verdict

0%0 of 18 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one.

estimated effort weekendthe files for this build are in the project pack

RuntimeOne process on an always-on machine with a scheduler tick every minuteStorageSQLite with the send state machine enforced in the schemaNetworksMastodon and Bluesky only, one module each behind one publish functionLimitsFetched from each network at connect time and rechecked daily, never hard-codedIdempotencyThe network's own key where it offers one, the state machine everywhereMediaCopied beside the database, checked and resized at import, deleted after a stated retention

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the scheduler and the web interface in one process.

    Get it python.org, or your package manager. open ↗

    Verify python3 --version prints 3.12 or higher

  2. have readya few pounds a month, or free if you own the box

    Why The scheduler tick has to fire at the slot, and a laptop that sleeps turns the queue into a list of things that did not happen.

    Get it A small VPS, a home server or a single-board computer with a static power supply.

    Verify A test row scheduled for three in the morning has sent by breakfast

  3. free

    Why One of the two networks, connected the documented way with a token you can revoke.

    Get it Create an application in your account's development settings with write scope for statuses and media, and copy the access token. open ↗

    Verify The instance endpoint returns that server's limits with your token

  4. free

    Why The other network, connected with an app password rather than your main one so it can be revoked alone.

    Get it Create an app password in your account settings and use it with your handle. open ↗

    Verify A session is created and your profile resolves

  5. have readyfree

    Why Exactly-once and reconciliation are only testable by actually sending, and a duplicate on your real account is a duplicate your followers see.

    Get it A second account on each network that nobody follows, connected the same way.

    Verify Both test accounts are connected and a row sends to each

  6. decidefree

    Why Each is asserted by a test, and deciding them after seeing failures means choosing whatever passes.

    Get it Write down the posting slots per account, how long media is kept after sending, and what happens to a slot that falls in the daylight saving gap.

    Verify All three are in the configuration and named in the tests

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason)
- batches(id, filename, imported_at, rows_total, rows_accepted, report_json)
- posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation
- media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after)
- attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out
- slots(account_id, day_of_week, time_local) · posting times in the account's own timezone
- The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/scheduler.dbPosts, attempts and accounts. The attempts table is the audit trail.
MASTODON_BASE_URLrequiredhttps://mastodon.exampleThe server you post to. Its limits are fetched from here.
MASTODON_ACCESS_TOKENsecretrequireda token from your application settingsWrite scope for statuses and media only. Revoke it to test the pause.
BLUESKY_HANDLErequiredyou.bsky.socialYour handle, used with an app password.
BLUESKY_APP_PASSWORDsecretrequiredxxxx-xxxx-xxxx-xxxxAn app password, never your main password, so it can be revoked alone.
MEDIA_DIRrequired./data/mediaCopies of imported media, resized to the fetched limits, deleted after retention.
MEDIA_RETENTION_DAYSrequired90After a row sends. The deletion is logged.
SENDING_STALE_MINUTESrequired10A row in sending for longer than this becomes needs_reconciliation, never a retry.

The build, in order

  1. Importing a spreadsheet that tells you what is wrong

    Every row is checked against the network's own limits before it is accepted.

    1. Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time.

      Files src/networks/mastodon.pysrc/networks/bluesky.pysrc/limits.py

    2. The limit is in graphemes and bytes, and an emoji is neither one character nor one byte.

      Files src/validate.pytests/test_graphemes.py

    3. A rejected import with reasons beats an accepted one that fails at midnight.

      Files src/templates/import.html

    done when · tick each as it passes
    watch out
    • Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug.
  2. Slots in the account's timezone

    Rows without a time land in the next free posting slot, correctly across a clock change.

    1. Slots come from the zone's rules for that date, not from a fixed offset.

      Files src/slots.pytests/test_dst.py

    2. Two rows in one slot is a double post with extra steps.

      Files src/slots.py

    3. A hundred rows into two slots a day is seven weeks, and the person should know.

      Files src/templates/import.html

    done when · tick each as it passes
    watch out
    • Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly.
  3. Sending exactly once

    A crash, a timeout or a double tick produces one post, never two.

    1. Before the network is touched. This is the whole protection.

      Files src/sender.pytests/test_exactly_once.py

    2. A one hour memory on the server is a second line of defence, not the first.

      Files src/networks/mastodon.py

    3. Fetch recent posts and match the key before ever resending.

      Files src/reconcile.py

    done when · tick each as it passes
    watch out
    • Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again.
  4. Media that arrives whole

    Every image is accepted by the network the first time.

    1. Not at send time, when nobody is watching.

      Files src/media.pytests/test_media.py

    2. Some networks process uploads after accepting them; check how each one signals ready.

      Files src/networks/mastodon.pysrc/networks/bluesky.py

    3. Media outlives its usefulness by exactly the retention period.

      Files src/retention.py

    done when · tick each as it passes
    watch out
    • Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.