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

feed2sky

command module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 2, 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.

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" .

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.

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. 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