Help us shape the future of Go! Take the 2026 Go Developer Survey

feed2sky

command module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 1 Imported by: 0

README

feed2sky

Publishes RSS items to a PDS as app.bsky.feed.post records.

The feed carries the record key. bckt computes a deterministic TID per post and emits it as <atproto:rkey> in the XML namespace https://github.com/vrypan/bckt/ns/atproto; feed2sky reads that key and writes the record at it. It never computes record keys of its own.

The feed may also say which repo a post belongs to, as <atproto:did>. Together the two identify a record exactly, which is what lets feed2sky recognise a post that already exists somewhere other than its own account — see Posts that already exist.

Install

go install github.com/vrypan/feed2sky@latest     # newest release
go install github.com/vrypan/feed2sky@v0.1.0     # a specific release
go install github.com/vrypan/feed2sky@main       # current main branch

Check what you are running:

$ feed2sky version
feed2sky v0.1.0
go1.26.5 darwin/arm64

feed2sky --version prints the same thing, and daemon mode logs the version on startup so it shows up in the journal.

Installed releases report their tag. A local go build reports the commit it came from, with +dirty if the working tree had uncommitted changes:

feed2sky v0.0.0-20260802191308-7501b6f7ca8e+dirty

Release builds can stamp an explicit version instead:

go build -ldflags "-X github.com/vrypan/feed2sky/cmd/feed2sky.version=v0.1.0" .
Prebuilt binaries

The Build binaries workflow produces archives for linux/amd64, linux/arm64 and darwin/arm64. It is manual — nothing is built on push. Run it from the Actions tab, picking the branch or tag to build in the "Use workflow from" dropdown.

The binaries are static (CGO_ENABLED=0), so the Linux ones run on a bare Raspberry Pi OS, Debian or Alpine image with no libc to match. Each archive carries the binary, README, LICENSE, the example config and the systemd unit, and the run publishes a SHA256SUMS alongside them.

Leaving the release input unchecked uploads the archives as workflow artifacts. Ticking it also creates the GitHub release, which requires the commit being built to carry a tag — either select the tag in the dropdown, or run from a branch whose HEAD is that tagged commit. On an untagged commit the workflow stops with an explanatory error rather than publishing a release named after a branch.

To cross-compile the same thing yourself, e.g. for a 32-bit Pi Zero, which the workflow does not cover:

CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "-s -w" .

Configure

Copy feed2sky.example.toml to feed2sky.toml:

feed_url     = "https://blog.vrypan.net/rss.xml"
pds          = "https://pds.vrypan.net"
identifier   = "blog.vrypan.net"        # handle or DID
app_password = "xxxx-xxxx-xxxx-xxxx"
langs        = ["en"]

An app password is enough — ordinary repo writes need no privileged session. feed2sky.toml is gitignored; keep it that way.

Run

feed2sky --dry-run     # print what would be published, write nothing
feed2sky               # publish once and exit
feed2sky --update      # also overwrite records that already exist
feed2sky --limit 5     # only the newest 5 feed items
feed2sky daemon        # keep polling the feed

-config <path> points at a config other than ./feed2sky.toml.

Run --dry-run first. The first real run publishes everything currently in the feed that has a valid rkey — feed depth is the backfill boundary.

How it behaves

  • Stateless. The PDS is the state. "Already published?" is answered by an unauthenticated com.atproto.repo.getRecord. No local database, cursor file or seen-list, so re-running is always safe.
  • Never overwrites. An existing record is skipped and logged exists. Editing a blog post does not rewrite the live post unless you pass --update.
  • Writes with putRecord at the feed-supplied rkey, so an interrupted run can be retried without double-posting.
  • Oldest first, so a backfill lands in the order the posts were written.
  • Exits non-zero if any item failed, so cron wrappers notice.

Items whose <atproto:rkey> is missing, or is not exactly 13 characters of the base32-sortable alphabet 234567abcdefghijklmnopqrstuvwxyz, are skipped with a log line.

Posts that already exist

A blog post does not have to originate on the blog. at2bckt imports a Bluesky post into a bckt blog, and that post already exists as a record — under its original author's DID, at its original record key. Publishing it again would write someone else's post into your repo under their record key.

So the record key is not read alone. bckt emits the owning repo as <atproto:did>, per item, defaulting to a channel-level <atproto:did> for the blog's own account:

<channel>
  <atproto:did>did:plc:blog</atproto:did>
  <item>
    <atproto:rkey>3mrdggl4va2ea</atproto:rkey>          <!-- ours -->
  </item>
  <item>
    <atproto:rkey>3ms6khrp53s2b</atproto:rkey>
    <atproto:did>did:plc:someone-else</atproto:did>     <!-- imported -->
  </item>
</channel>

An item whose DID is not the account in feed2sky.toml is skipped and logged:

skip    published as did:plc:someone-else https://blog.example/imported/

It is skipped rather than published elsewhere because feed2sky holds credentials for exactly one account and cannot write to another repo in any case.

The DID in feed2sky.toml is the fallback, not the override:

  • Item has no <atproto:did>, and neither does the channel — the post is assumed to be yours and published as before. A feed that carries no DIDs at all behaves exactly as it did before the field existed, and costs no extra lookup.
  • identifier is a handle — it is resolved once, with an unauthenticated com.atproto.identity.resolveHandle, and cached. A dry run still needs no session.
  • The lookup fails — the DIDs are ignored for that cycle and every item is treated as yours, which is the pre-existing behaviour. The per-item existence check still stands between a retry and a double post.

Record shapes

Chosen by whether the item has a <title>:

  • Titled article — an app.bsky.embed.external link card carrying {uri, title, description}. text is the description, not the title: the card already displays the title, and clients clamp the card's description to about two lines while text always renders in full. Items with no description fall back to the title.
  • Titleless note (micropost) — text is the description flattened to plain text with the permalink appended as a link facet. If the item has an image it becomes an app.bsky.embed.images embed, with aspectRatio taken from the decoded image.

Shared: createdAt is the item's pubDate normalized to UTC RFC 3339; text is capped at 300 graphemes and truncated on a word boundary; langs comes from config.

Images

An item's image goes to the card as external.thumb for a titled article, or becomes an app.bsky.embed.images embed for a titleless note. The source is read from ordinary feed markup, in order:

  1. <media:thumbnail url="…"> (Media RSS)
  2. <media:content url="…" medium="image">
  3. <enclosure url="…" type="image/…"> (RSS 2.0)

So a typical feed works as-is — <atproto:rkey> remains the only bckt-specific requirement, <atproto:did> being optional. An enclosure that declares a non-image type (a podcast episode) is ignored without being fetched.

feed2sky then fetches the image and inspects the bytes, not the feed's claims: it sniffs the real format, and if the image is already within the lexicon's 1,000,000-byte cap it is uploaded untouched (keeping PNG transparency). Oversized images are downscaled to fit a 1200 px box and re-encoded as JPEG, dropping quality and dimensions in steps until they fit. The blob ref comes back from uploadBlob, so no CID is ever computed locally.

If an image cannot be fetched or shrunk, it is logged as a warning and the post is published without it — a bad image never costs you the post.

Alt text comes from <media:description>, falling back to <media:title>. Feeds supplying neither get an empty alt, which the lexicon permits but screen readers cannot use.

Daemon mode

feed2sky daemon --interval 5m

Polls the feed and publishes anything new. poll_interval in the config sets the default; --interval overrides it. The minimum is 30s.

It stays cheap and polite:

  • Conditional GET. The ETag/Last-Modified from the previous response are sent back as If-None-Match/If-Modified-Since, so an unchanged feed costs a 304 and the cycle ends immediately. Nothing is logged for an unchanged feed.
  • One listRecords, not one getRecord per item. Existing rkeys are fetched in a single paged call and diffed in memory. If listRecords fails it falls back to per-item checks rather than risk republishing.
  • Sessions are refreshed, not recreated. createSession is rate limited (bsky.social documents roughly 30 per five minutes and a few hundred per day), which a polling daemon would otherwise exhaust. The daemon logs in once and then uses com.atproto.server.refreshSession, replaying any write that hit an expired token. A full login happens only if the refresh token itself expires.

Transient failures — feed 503, PDS timeout — are logged and retried with exponential backoff from 30s to 15m, resetting on success. Only a fatal error (bad credentials) stops the loop. SIGINT/SIGTERM finish the item in flight and exit 0, so a deploy never lands between uploadBlob and putRecord.

Nothing is persisted: cache validators and the session live in memory, and a restart costs one conditional GET and one login. The PDS remains the only record of what has been published.

To run it as a service, see deploy/systemd.

Not in v1

Deletes, and interactions (replies/likes back to the blog — that is listnr's job). See plans/CLAUDE.md for the reasoning.

License

MIT — see LICENSE.

Develop

go build ./...
go vet ./...
go test ./...
gofmt -l .

The root main.go is a five-line shim so that go install github.com/vrypan/feed2sky@latest keeps working; everything else lives in cmd/feed2sky/.

Documentation

Overview

Command feed2sky publishes RSS items carrying <atproto:rkey> to a PDS.

The implementation lives in cmd/feed2sky; this package exists only so that `go install github.com/vrypan/feed2sky@latest` keeps working.

Directories

Path Synopsis
cmd

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL