Tagimporting5

What every import does

The long version of The archive → Importing: per-source walkthroughs, what each import keeps and skips, and how to check -- or undo -- the result. Everything here was verified against real archives, not just read from API docs.

Two ways in, same mapping underneath:

./import.sh                              # wizard: pick a source, preview, confirm
ruby scripts/migrate_<source>.rb <arg>   # scriptable: writes immediately, for cron

The wizard always reads the whole source in dry-run first and reports what would be written -- posts, media files, the first slugs, and every skipped item with its reason -- then asks you to type the post count to confirm. The scripts skip the preview; sample with LIMIT=20 instead, which stops after twenty written posts. Re-running either way is safe: posts are matched on source.platform/account/original_id and overwritten in place, never duplicated.

Read more

The sources

beehiiv

ruby scripts/migrate_beehiiv.rb <posts.csv>

In beehiiv: Settings → Export Data → Export Posts → Export All Posts, the posts CSV. One file, no media: each row carries the entire EMAIL as HTML, and most of the import is undoing that -- slicing out the real content, dropping template variables, tracking pixels and the unsubscribe footer, turning the layout tables back into prose. The subtitle becomes the first paragraph, YouTube thumbnails become video blocks, and images download from beehiiv's CDN with the email's quality=80 rewritten to full quality. Paid posts import in full -- the export is yours -- but a premium issue arrives as a draft tagged beehiiv-premium rather than published, so nothing your subscribers paid for goes public without you deciding it should.

Two things worth knowing, both the CSV's: the only date in it is created_at -- beehiiv does not export the publish date, so a long-scheduled archive can sit slightly early on the timeline -- and tags ride in a semicolon-separated column that not every export populates.

Blogger

Read more

Checking the result

The preview already told you the counts; after the real run, spot-check the built pages (./blog.sh preview), and remember two date rules: imported posts keep their original dates, so they land in the archive rather than on the homepage, and dates a reader sees render in site.timezone -- set it before importing if the machine's clock isn't in your zone (see Getting started).

Then run ./blog.sh check (operations.md → Checking the archive), because a body's own links came across exactly as they were written. Where they point at your old posts that is right: with KEEP_PERMALINKS the import wrote a redirect at each of those addresses, so they answer here too and check accepts them. Where they point at the old platform's own furniture it is not -- a WordPress archive still carries /wp-admin/..., /wp-content/..., /category/... and /feed/ inside its post bodies, and after the move those paths address the new site, which has nothing there. check names every one with its post ("links to X, which nothing on this site answers at"); on a blog that linked to itself a lot there will be dozens, and each is a decision -- rewrite it, drop it, or point it at the Wayback capture.

Read more

Undoing an import

Select on the source triple, never on "everything except what I wrote":

ruby -rjson -rfileutils -e '
n = 0
Dir.glob("content.nosync/posts/*/*.json").each do |f|
  p = JSON.parse(File.read(f, encoding: "utf-8"))
  s = p["source"] || {}
  next unless s["platform"] == "PLATFORM" && s["account"] == "ACCOUNT"
  d = File.join("media.nosync", File.basename(File.dirname(f)), p["slug"])
  FileUtils.rm_rf(d) if Dir.exist?(d)
  File.delete(f); n += 1
end
puts "removed #{n}"'

Fill in PLATFORM/ACCOUNT from any imported post's JSON, then rebuild and deploy with --prune --force (the guard will flag the shrink -- that's it working). The same selector is why re-importing later is safe.

Troubleshooting

SymptomWhat it means
N media file(s) could not be downloadedThe URLs are dead at the source -- old CDNs disappear (every distilleryimage*.instagram.com link from 2012 resolves to nothing). The posts were written without those images; nothing to fix on your side.
Tumblr API returned 401 UnauthorizedWrong TUMBLR_API_KEY or blog name.
Many skipped (not a post) from a WXRNormal -- WordPress's own menu items and revisions travel in the same export. Attachments and pages have their own lines, and so does every custom post type, named after itself (wp:portfolio) -- if you recognize one of those as a section of your blog, its posts stayed behind.
skipped (error) with stderr linesThose items were malformed at the source; the rest imported. Re-run after a fix overwrites in place.
The source stopped answering after N item(s)The platform died mid-run. Everything written so far is saved; re-run once the source recovers -- posts are matched on their source id, so nothing duplicates.
cannot move '<slug>' into <year>: a different post already owns ...A re-imported item's date moved into a year where another post has the same slug. That one item is skipped, nothing was touched; rename one of the slugs and re-run.
The same id-less post appears twice after a re-importThe item carries neither guid nor link, so it can't be matched. Delete the extra copy; better, give the item a guid at the source.
The deploy stops with a % increase warningThe guard doing its job after a bulk change -- re-run with --force once the numbers look right.
An imported post shows a shifted datesite.timezone wasn't set and the machine runs UTC -- set it and rebuild; stored dates don't change, only the rendered day.