Build BulkPublish
KINDAreplaces $13.99/mosaves $167.88/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - 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 - accountfree
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 - accountfree
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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/scheduler.db | Posts, attempts and accounts. The attempts table is the audit trail. |
MASTODON_BASE_URL | required | https://mastodon.example | The server you post to. Its limits are fetched from here. |
MASTODON_ACCESS_TOKENsecret | required | a token from your application settings | Write scope for statuses and media only. Revoke it to test the pause. |
BLUESKY_HANDLE | required | you.bsky.social | Your handle, used with an app password. |
BLUESKY_APP_PASSWORDsecret | required | xxxx-xxxx-xxxx-xxxx | An app password, never your main password, so it can be revoked alone. |
MEDIA_DIR | required | ./data/media | Copies of imported media, resized to the fetched limits, deleted after retention. |
MEDIA_RETENTION_DAYS | required | 90 | After a row sends. The deletion is logged. |
SENDING_STALE_MINUTES | required | 10 | A row in sending for longer than this becomes needs_reconciliation, never a retry. |
The build, in order
Importing a spreadsheet that tells you what is wrong
Every row is checked against the network's own limits before it is accepted.
Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time.
Files
src/networks/mastodon.pysrc/networks/bluesky.pysrc/limits.pyThe limit is in graphemes and bytes, and an emoji is neither one character nor one byte.
Files
src/validate.pytests/test_graphemes.pyA rejected import with reasons beats an accepted one that fails at midnight.
Files
src/templates/import.html
done when · tick each as it passeswatch 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.
Slots in the account's timezone
Rows without a time land in the next free posting slot, correctly across a clock change.
Slots come from the zone's rules for that date, not from a fixed offset.
Files
src/slots.pytests/test_dst.pyTwo rows in one slot is a double post with extra steps.
Files
src/slots.pyA 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 passeswatch 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.
Sending exactly once
A crash, a timeout or a double tick produces one post, never two.
Before the network is touched. This is the whole protection.
Files
src/sender.pytests/test_exactly_once.pyA one hour memory on the server is a second line of defence, not the first.
Files
src/networks/mastodon.pyFetch recent posts and match the key before ever resending.
Files
src/reconcile.py
done when · tick each as it passeswatch 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.
Media that arrives whole
Every image is accepted by the network the first time.
Not at send time, when nobody is watching.
Files
src/media.pytests/test_media.pySome networks process uploads after accepting them; check how each one signals ready.
Files
src/networks/mastodon.pysrc/networks/bluesky.pyMedia outlives its usefulness by exactly the retention period.
Files
src/retention.py
done when · tick each as it passeswatch 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.
Knowing what actually landedproduct builder
Every row's fate is visible, a dead credential pauses an account, and nothing fails silently at midnight.
The attempts table is the audit trail when a post is missing.
Files
src/templates/history.htmlOne alert beats a week of failures.
Files
src/sender.pytests/test_pause.pyAccepted is not the same as published.
Files
src/verify.py
done when · tick each as it passeswatch out- A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced.
That is the whole plan for BulkPublish. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- X, Instagram, LinkedIn and TikTok.
- Analytics pulled back per post.
- Approvals, teams and client workspaces.
- Browser automation of any kind.
- staying connected · networks expire your auth and someone has to notice
- knowing a post is malformed before the network rejects it, not after
- an honest publish status when a network accepts the upload and fails minutes later
- analytics pulled back per post
- approvals, teams and multiple client workspaces
- the platform review process that gates posting on some networks entirely
- A third open network added as a module, once the sender has run for a month without a duplicate
- Repeat schedules for evergreen rows, which reuse the slot logic and add nothing to the sender
- A preview of each post rendered per network, since the same caption reads differently on each
Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.