@peristyle/emdash-plugin-video-to-recipe
v1.0.0
Published
Import TikTok and Instagram posts as recipe drafts in the EmDash admin — no login for either — and build the whole article from the video with Gemini
Maintainers
Readme
@peristyle/emdash-plugin-video-to-recipe
Import public TikTok and Instagram posts into your EmDash site as draft recipe posts — one at a time by URL, or in bulk by scanning a TikTok profile. No account, API key or session cookie is needed for either platform. With a Gemini API key, it can also watch the video and write the whole article from it, which is the only way to get a recipe out of a post that never wrote one down.
Renamed from
@peristyle/emdash-plugin-instagram-to-recipein 0.5.0, when TikTok support landed. See Upgrading from 0.4.x.
For a post such as a recipe from @recipeincaption, the plugin will:
- Fetch the post and read the caption
- Parse ingredients and numbered steps — including captions the platform delivered as a single unbroken line
- Download the cover image and store its dimensions for responsive images
- Download the source video, so a transcription step can recover spoken instructions later
- Create a draft post in your configured collection
Or, with Gemini configured, skip the caption entirely and build the article from the video — body, ingredients, steps, times and servings, taken from what the video actually shows.
Which platform should you import from?
TikTok, when you have the choice. It is the default platform, and the reason is plumbing rather than taste: TikTok serves captions, cover images, dates and the video file itself to an anonymous request, while Instagram guards the equivalent behind an API that refuses most servers.
| | TikTok | Instagram |
| ---------------------------- | ------------------------------------------- | ------------------------------------------------ |
| Single post by URL | Embed pages, no auth | Post page, no auth |
| Profile scan (bulk) | Embed page, no auth | Not supported — see below |
| Posts per profile scan | ~10–12 | — |
| Caption cap | 4,000 chars | 2,200 chars |
| Caption shape | One flat line, structure rebuilt from markers | Real line breaks, usually one ingredient per line |
| Post date | Exact (createTime) | Exact (taken_at) |
| Popularity signal | Plays | — |
| Source video | Direct MP4, no auth | Direct MP4 from the post page |
| Typical failure | Creator wrote no caption at all | Page omits video_versions |
Single-post import works on both, with no credential at all. Instagram's post page carries the caption, cover image, date and source MP4, so a pasted URL imports fully in one request.
Bulk profile scanning is TikTok-only. Listing an Instagram profile's posts
is possible only through web_profile_info, which requires a logged-in session
and is refused at Meta's edge proxy for many server IPs regardless of what
you send it. Shipping a scan that reliably returns nothing — plus a session
cookie setting to feed it — was worse than not offering it, so the Instagram
adapter no longer implements a profile scan and the admin does not offer it.
Instagram's food-creator culture does put recipes in captions more
consistently, so importing individual Instagram URLs alongside a TikTok profile
scan is a reasonable way to work.
Install
pnpm add @peristyle/emdash-plugin-video-to-recipe
# or: npm install / yarn addRegister in astro.config.mjs:
import { videoToRecipePlugin } from "@peristyle/emdash-plugin-video-to-recipe";
export default defineConfig({
integrations: [
emdash({
plugins: [videoToRecipePlugin()],
}),
],
});Rebuild or restart the dev server after adding the plugin.
Prepare the target collection
EmDash stores each collection field as a real database column, so the
collection you import into must declare every field the plugin writes — an
undeclared field fails the whole import with no column named ....
In the admin, open Content Types → and make sure these fields exist (slugs must match exactly):
| slug | type | notes |
| --------------------- | ------------ | ------------------------------------ |
| title | string | usually already present |
| excerpt | text | |
| ingredients | portableText | |
| recipe_instructions | portableText | |
| difficulty | string | |
| featured_image | image | |
| source_url | url | provenance — permalink |
| source_caption | text | provenance — raw caption |
| import_notes | text | provenance — parse gaps |
| source_video | file | source video, for transcription |
Building articles from video writes a few more, and every import also fills the platform's own link field. These are optional: a field the collection doesn't declare is skipped with a warning in the log rather than failing the import, so add only the ones you want.
| slug | type | notes |
| -------------------- | ------------ | ------------------------------------------------------------------------- |
| content | portableText | the article body |
| prep_time | integer | minutes |
| cook_time | integer | minutes |
| rest_time | integer | minutes — resting, chilling |
| servings | integer | |
| nutrition_calories | integer | only when the video states it |
| tiktok_url | url | set to the post URL for TikTok imports |
| instagram_url | url | set to the post URL for Instagram imports (on a source_* collection) |
Then set Content collection in the plugin's settings to that collection's
slug. The default is posts; if your site's recipe collection is called
something else, imports will fail until you change it.
Already have
instagram_url/instagram_caption/instagram_video? Keep them. The plugin detects which naming your collection uses and writes to it — see Upgrading from 0.4.x. You never need to migrate the schema.
Admin usage
Open Plugins → Import video recipes in the EmDash admin. The page has three sections:
Single post
- Paste a TikTok or Instagram post URL and click Convert to recipe — the platform is detected from the URL, and TikTok short links (
vm.tiktok.com,vt.tiktok.com) are followed automatically - Review the parsed preview (title, ingredients, steps, cover image)
- Click Create draft post
- Edit the draft under Posts (slug, categories, times, publish)
Saving the same post twice rewrites the draft it already has rather than adding a second copy of it, and only the fields the plugin owns — your categories, slug and hand-set times survive.
Bulk import from a profile
- Enter a public TikTok handle, optionally set Posted on or after / Posted on or before, and click Scan profile
- The profile is fetched in a single request and every caption that parses as a recipe is listed with its post date, sorted by plays
- Check the recipes you want (already-imported posts are unchecked and labeled) and click Create selected drafts — the page switches to a progress view at once, and Check progress shows how many drafts are done, which failed and why
- Posts that didn't parse as recipes are listed under Other posts — check any of them and click Create stub drafts to import the cover image and raw caption with empty recipe fields, flagged in
import_notesfor manual or AI completion
Bulk import is idempotent — posts already imported are skipped. Deleting an imported draft clears the bookkeeping, so the post can be imported again later. If a draft was deleted in a way the plugin could not see, the next import of that post creates a fresh draft rather than failing.
A scan that returns nothing says why — rate limiting and a handle with no public posts are different problems with different answers, and the banner names which one it was rather than reporting "no recipes found".
Date range
The date fields are inclusive and filter the posts the scan already fetched. They cannot make the scan reach further back: one profile request returns roughly the 12 most recent posts, so a range starting before that window would otherwise silently miss older posts. The UI is built so that never goes unsaid:
- After a scan, the form itself names the window it reached (“This scan reaches back to 2026-01-15; a range starting earlier will miss posts”)
- Every result reports posts scanned, posts matched, and the oldest date reached
- A range starting before the oldest scanned post raises a warning naming the cut-off, so “0 results” is never mistaken for “nothing exists”
- A range that matches nothing gets its own banner plus a Clear dates button that restores the full scan — served from the 24-hour cache, so it costs no extra request
- Posts returned without a date are kept rather than dropped, and their count is reported
Results are ordered by engagement; when a date range is active they switch to newest first, matching how the list is being read.
While an import is running
The admin host renders no pending state for a plugin interaction: the page keeps its blocks, buttons live, until the handler returns. The honest reading of a page that has not changed is "my click did nothing" — so it gets clicked again. The plugin closes that gap two ways, by how long the work takes.
Quick work runs inline. Converting a URL and scanning a profile take a few seconds, so they still run within the press. An operation already in flight claims itself in the plugin's KV store, so a second press is answered with "still working on that — no need to press again", naming how long the first run has been going, rather than starting a second fetch. The marker is released as soon as the run finishes, including when it fails, and expires by itself if the run never returns.
Slow work runs as a background job. Building an article from a video and creating a batch of drafts take a minute or more, so the press records a job and returns at once with a progress view: what the job is doing (“Downloading the video from TikTok”, “Gemini is watching the video and writing the article”, “Creating draft 3 of 7: Lemon Orzo”), a meter, when it last reported, and a Check progress button. Blocks cannot refresh themselves, so the button is how the view is redrawn; when the job is done the same press shows the result screen the synchronous flow used to return. Bulk jobs list every selected post with its outcome, and a post that fails does not stop the rest.
A job is driven by whichever of three things reaches it first:
- EmDash's
after()keeps the handler's work going past the response —waitUntilon Workers, fire-and-forget on Node. - A one-shot cron task, scheduled when the job starts and again every minute while it is unfinished, and cancelled as soon as the job ends.
- The Check progress press itself. A job nothing is running — queued because the host could not defer it, or abandoned because its runner was cut off — is run by the press that asks about it, and resumes from the last finished post rather than starting over.
Which of them you actually get depends on the host, so it is worth being concrete about EmDash 0.14:
- Node, Bun, a VPS, local dev.
after()is fire-and-forget and finishes the job on its own; EmDash's Node cron scheduler runs on real timers and backs it up. Jobs finish unattended and Check progress is only how you watch them. - Cloudflare Workers.
after()iswaitUntil, which usually carries a job to the end but can be cut off mid-video. Plugin cron tasks are not a fallback there: EmDash 0.14 picks a request-driven cron scheduler for Workers and never wires its tick up, so a scheduled task does not fire — and a Cloudflare Cron Trigger inwrangler.jsoncwill not reach it either, because EmDash exports noscheduledhandler. So on Workers the backstop is the Check progress press, which is enough: it takes the job over and resumes from the last finished post, and a finished article is never paid for twice. The cron driver stays in the plugin because it works today on Node and will work on Workers the moment EmDash calls its owntickCron().
Every runner writes a heartbeat every ten seconds, so a job that has gone quiet for 45 seconds is known to be dead rather than merely slow, and is taken over instead of waited on. A job that dies three times is marked failed with that reason. Finished jobs are kept for a day so the result screen can be revisited, then pruned.
A second press of the same button while its job runs shows that job's progress
— nothing is downloaded, billed or written twice. A running job also holds the
same KV marker the equivalent API route takes, so an agent calling
generate-article while a reviewer's job is building that video is refused
with busy: true rather than billed for a second copy. The marker is held
with the job's own 45-second staleness, not the route's minutes, so the press
that takes a dead runner's job over is never locked out by it.
The host's missing pending state is EmDash's to fix, and a patch that adds a busy indicator to plugin pages is proposed upstream. Once it lands, the quick operations get a spinner too; the job flow stays, because a spinner cannot say which of seven drafts is being written.
Build the article from the video (Gemini)
The caption parser can only recover what the creator typed. On short-form video that is often a dish name and a hashtag wall — the recipe is spoken, or shown as on-screen text, or just demonstrated. Google's agentic video understanding closes that gap: the model searches, scans and re-samples the video rather than reading it at a fixed frame rate, so the quantity the creator says once at 0:04 is findable.
Add a key from Google AI Studio under Settings → Build articles from video, and every video post gets a Build the full article from the video button. One press starts the job and the page shows its progress; when the model is done, Check progress shows the draft with:
- a title and excerpt
- an article body — why the dish works, technique notes, substitutions,
serving and storage — as portable text in
content - ingredients with quantities, and numbered steps including the ones only said out loud
- times, servings and difficulty, where the video actually supports them
import_notesnaming the model, whether agentic processing was used, and the model's own caveats about anything the video left unclear
What it will not do
The prompt forbids invention, and the plumbing enforces the rest:
- No made-up numbers. A quantity, temperature or time the video never gives
is left out and flagged in
notesinstead of guessed.nutrition_caloriesis written only when the video or caption states it. - The video outranks the caption. The caption travels as context, labelled as such; where the two disagree the video wins.
- A draft is never duplicated. Running the builder over an already-imported post updates that draft in place, writing only the fields it owns — your categories, slug and anything else on the row are untouched. It does overwrite the title, excerpt, body, ingredients and steps, so a rewrite discards hand-edits to those.
- The article is cached. Pressing the button twice costs one API call; pass
force(or use Rewrite the article from the video) to actually re-run it. - It degrades rather than fails. If the API declines the agentic flag, the request is retried once with standard processing and the draft records which it got. Only a very short clip (under 4MB) is sent inline; anything larger goes through the Files API, which streams the file instead of base64-encoding it — the plugin runs in a sandboxed Worker isolate with a ~128MB memory ceiling, and the inline path holds three copies of the video at once.
Everything it writes is still a draft. Read it before publishing — that is
what the import_notes line is for.
Cost and time
Standard Gemini API token pricing, no feature fee; agentic processing is the cheaper path on anything but a very short clip. Expect up to a minute per video: the plugin downloads the video from the platform CDN, then waits on the model. The batch route is sequential on purpose — parallel runs get you rate limited by the CDN and the API at once.
Settings
- Content collection — where drafts are created (default:
posts) - TikTok handle (optional) — pre-fills the bulk scan
- Gemini API key — enables building articles from video. Write-only: it is never rendered back into the form. Tick Forget the saved API key and save to remove it; leaving the field blank keeps the saved one.
- Model —
gemini-3.8-flash(default),gemini-3.7-flash,gemini-3.6-flash, orgemini-3.5-flash-lite - Agentic video processing — on by default, and worth leaving on
Standard-format EmDash plugins are configured here (settings are stored in the plugin's KV store), not via constructor options.
Using the article builder outside EmDash
Everything under src/gemini/ is written against plain fetch and a config
object rather than against PluginContext — the sandbox supplies
ctx.http.fetch and KV-stored settings, but the builder itself knows nothing
about either. That makes it usable anywhere, and since 1.0.0 it is exported as
a supported subpath:
import {
generateArticleFromVideo,
isGeminiConfigured,
DEFAULT_GEMINI_MODEL,
} from "@peristyle/emdash-plugin-video-to-recipe/gemini";
const article = await generateArticleFromVideo({
config: { apiKey: process.env.GEMINI_API_KEY, model: DEFAULT_GEMINI_MODEL, agentic: true },
fetchFn: fetch,
video: { bytes, mimeType: "video/mp4", filename: "post.mp4" },
context: { platformLabel: "TikTok", postUrl, handle, caption },
progress: (step, phase) => console.log(`[${phase}] ${step}`),
});
// → { is_recipe, title, excerpt, body[], ingredients[], instructions[],
// prep_time_minutes, cook_time_minutes, servings, difficulty, notes, … }You bring the bytes and the key; the module does the upload, the schema and the
parse. Videos over 4MB go through the Files API automatically. Nothing from
emdash or astro is pulled in, so a plain Node or Worker project can depend
on this without either peer.
This is how Peristyle's own onboarding shows a creator their recipe before their site exists — the recipe on that page is written by this function, not by a reimplementation of it.
How captions are parsed
Neither platform returns a caption with its line structure intact. Instagram's HTML fallback flattens single line breaks; TikTok's embed payload strips them entirely, so a 2,700-character recipe arrives as one unbroken string.
src/caption.ts rebuilds the structure from the markers the writer left behind,
and only from markers that are unambiguous in running prose:
-4 large eggs— a space, a bullet, then content. Nevermedium-high, never5 - 7 minutes2. Heat the pan— a step number followed by a capital. Never1.5 cups, never the50.inside350.For the Tzatziki:— a capitalisedFor …header, or one of a closed vocabulary (Instructions:,Marinade:,Assembly:…)
That vocabulary is deliberately closed rather than "any capitalised word before
a colon": captions are full of phrases like -Flour Mixture: 3/4 cup flour,
where a colon introduces one ingredient's contents, not a new section.
Ingredients are read in three shapes, in order:
An explicit
Ingredients:/What you'll need:headerThe contiguous run of bulleted lines — how most TikTok captions are written
Paragraphs of one ingredient per line, with neither bullets nor a header — how most Instagram captions are written:
greek meatballs: 1 lb chicken 1/2 cup feta cheese greek salad romaine lettuce cherry tomatoesA paragraph qualifies once enough of its lines open with a quantity; the paragraphs after it are then read as continuations, so a produce list with no quantities at all still comes through. The caption's first line is its title and is never an ingredient, and everything from the first numbered step onwards is the method.
Re-splitting a line on its quantities is only done to a caption that
arrived flattened. A line the creator wrote out — dressing - 1/2 cup olive
oil, juice of 1 lemon, 1 tbsp honey — is one ingredient, and shredding it into
six fragments with dangling commas is a worse answer than leaving it alone.
Run the fixtures with pnpm test.
Capabilities
| Capability | Purpose |
| ----------------- | ----------------------------------------- |
| network:request | Fetch post pages and CDN media |
| content:read | Check for prior imports |
| content:write | Create draft posts |
| media:write | Upload cover images and source videos |
Allowed hosts: www.tiktok.com, vm.tiktok.com, vt.tiktok.com,
*.tiktokcdn.com, *.tiktokcdn-us.com, *.tiktokv.com,
www.instagram.com, *.cdninstagram.com, *.fbcdn.net,
generativelanguage.googleapis.com.
The Gemini host is in the manifest whether or not you configure a key — the allowlist is static, and nothing reaches it until a key is saved.
API routes
For scripting or a custom admin UI:
| Route | Method | Body |
| -------------- | ------ | -------------------------------------------------------------------------- |
| parse | POST | { "url": "https://www.tiktok.com/@recipeincaption/video/7663935915911367954" } |
| create-draft | POST | { "key": "tt:7663935915911367954" } |
| bulk-scan | POST | { "source": "tiktok", "handle": "recipeincaption", "from": "2026-01-01", "to": "2026-06-30" } (source/from/to optional) |
| bulk-create | POST | { "keys": ["tt:7663935915911367954"] } |
| create-stub | POST | { "key": "tt:7663935915911367954" } or { "url": "https://..." } |
| generate-article | POST | { "key": "tt:7663935915911367954" } or { "url": "https://..." }, plus optional apply / force |
| generate-articles | POST | { "keys": ["tt:7663935915911367954"], "force": false } |
Posts are addressed by a key — tt:<videoId> for TikTok, ig:<shortcode>
for Instagram — which is unique across platforms. The pre-0.5.0 shortcode /
shortcodes parameters are still accepted; an unprefixed value is read as a
TikTok video id when it is all digits, and as an Instagram shortcode otherwise.
create-draft is idempotent: a post that is already imported is updated in
place and the response carries updated: true, so retrying a call whose
response you never saw cannot duplicate a recipe.
Every route that costs a network round-trip or a model call refuses to run
twice at once for the same target. A concurrent second call rejects with an
error carrying busy: true and a message naming how long the first has been
running, rather than starting a duplicate. The admin page's background jobs
take the same markers, so this holds across the two surfaces: a
generate-article call for a video the admin is already building is refused,
and vice versa. generate-article resolves a url to its post key before
taking the marker, so calling it by URL and by key for the same post collides
as it should.
bulk-scan returns both candidates (parsed recipes) and unparsed (posts
the recipe parser rejected, with their full captions), plus the coverage the
caller needs to judge completeness: scanned (posts fetched), matched (posts
left after the date range), window (oldest/newest dates reached),
rangeBeyondScan (the range asks for posts older than the scan reached), and
undated.
create-stub imports a post without parsing: the draft gets the cover
image, raw caption, and a title guess, with empty recipe fields and an
import_notes marker — intended for prose captions, or recipes that are only
spoken or on screen, that a human or AI agent will structure after import. It
is idempotent: an already-imported post returns the existing draft.
generate-article is the one call an agent needs: give it a post URL and it
converts the post, sends the video to Gemini, and writes the finished article
to the collection — creating the draft when the post is new, updating it in
place when it was already imported. It returns the article, the merged
recipe, the contentId, fromCache, applied and created. Pass
apply: false to get the article back without touching the collection (useful
for a review step), or force: true to re-run the model instead of reusing the
cached article. A key works in place of a url once the post has been
scanned or converted.
generate-articles does the same across a batch of scanned keys, sequentially,
returning created, updated and failed. It is route-only — a run of ten
posts takes minutes, longer than an admin page load should hold open.
Both need a Gemini API key in settings; without one they fail with a message saying where to add it.
Base path: /_emdash/api/plugins/peristyle-video-to-recipe/<route>
Upgrading to 1.0.0
The slow admin buttons no longer hold the page open.
Building an article and creating a batch of drafts are background jobs. The press returns at once with a progress view — the step, a meter, when the runner last reported — and a Check progress button that redraws it and, if nothing else has picked the job up, runs it. The result screen when the job finishes is the one the synchronous flow used to return, so nothing you read before is gone; a bulk run now also lists every selected post with its outcome, and one post failing no longer loses the rest. See While an import is running for what drives a job on your host — it differs between Node and Cloudflare Workers, and on Workers the Check progress press is the backstop.
Nothing to configure and no data migration: jobs live in the plugin's own KV store and finished ones are pruned after a day.
The API routes are unchanged, and still synchronous — an agent that wants
to wait for an article should keep calling generate-article. They now share
their in-flight markers with the admin jobs, so the two surfaces cannot both
pay Gemini for one video.
generate-article called with a url now converts the post before taking
its marker rather than after, so the marker is always the post's. A duplicate
call by URL is answered a page fetch later than it used to be, and a call by
URL racing a call by key for the same post is now correctly refused.
The article builder is importable on its own, at
@peristyle/emdash-plugin-video-to-recipe/gemini — see Using the article
builder outside EmDash. Nothing
inside the plugin changed to allow it; the module never depended on
PluginContext in the first place, and the subpath simply stops that being an
accident. Additive, so no existing import moves.
Upgrading to 0.11.0
gemini-3.8-flash is available, and is the new default model. Google
added it to the models that support agentic video understanding; it sits at the
head of the picker under Settings → Build articles from video. Sites that
never chose a model explicitly move to it on upgrade — a saved choice is left
alone, and gemini-3.7-flash, gemini-3.6-flash and gemini-3.5-flash-lite
are all still selectable.
Drafts fill the platform's own link field. A TikTok import now writes
tiktok_url, and an Instagram import on a source_* collection writes
instagram_url, each set to the same URL as source_url. Both are optional:
a collection without the field imports as before, with a note in the log.
A deleted draft no longer blocks re-import. If the draft a post was imported as is gone — deleted while the plugin was disabled, or from a collection the plugin no longer targets — the next import creates a fresh draft and repairs the bookkeeping instead of failing on the missing row.
TikTok errors say what happened. When the profile embed or a post page cannot be fetched at all — a network failure, or the plugin sandbox refusing a CDN host — the scan banner and the single-post error now carry that reason rather than suggesting the handle was misspelled.
Upgrading to 0.10.0
Two things were removed because they did not work, and one behaviour changed. (0.9.0 was never published; 0.10.0 is the first release carrying these changes.)
The Instagram session cookie setting is gone. Nothing needs it any more. Single-post import reads the post page, which carries the caption, cover image, date and source video with no credential — so the cookie's only remaining job was the profile listing, which Meta refuses at its edge proxy for many server IPs no matter what cookie it is sent. A cookie you had saved is deleted from the plugin's KV store on the first admin page load after upgrading: it is a real credential, and leaving one behind because the code that read it was deleted is not housekeeping. Nothing else about Instagram imports changes, and they got faster (one request instead of three).
Instagram profile scanning is gone. bulk-scan with "source":
"instagram" now fails with an explanation instead of returning an empty list.
Import Instagram posts one URL at a time — that path is unaffected — or scan
the creator on TikTok. The bulk form no longer shows a platform picker while
TikTok is the only scannable platform; it comes back automatically if another
adapter implements a profile scan.
Saving a post twice updates it instead of duplicating it. create-draft
(and the Create draft post button) rewrites the draft an already-imported
post has, and the route response carries updated: true. Your categories,
slug and hand-set fields on that row are left alone.
Upgrading from 0.4.x
The package, the export, and the plugin id all changed:
-import { instagramToRecipePlugin } from "@peristyle/emdash-plugin-instagram-to-recipe";
+import { videoToRecipePlugin } from "@peristyle/emdash-plugin-video-to-recipe";
emdash({
- plugins: [instagramToRecipePlugin()],
+ plugins: [videoToRecipePlugin()],
})instagramToRecipePlugin is still exported as a deprecated alias, so an
un-edited config keeps working — but update the package name and the
noExternal entry if your site lists one.
Two things follow from the id changing from peristyle-instagram-to-recipe to
peristyle-video-to-recipe:
Your collection's field names are left alone. Collections built against
0.4.x declare instagram_url / instagram_caption / instagram_video. The
plugin reads an existing draft to see which naming your collection uses, and
writes to it — and if that guess is ever wrong, the first failed insert is
retried the other way and the answer remembered. New sites get source_*; you
are never forced to migrate.
Import bookkeeping is rebuilt automatically. Plugin KV is namespaced by plugin id, so the record of what you'd already imported does not carry across the rename. Before the first scan, the plugin reads the provenance URL off every existing draft in the collection (up to 2,000) and restores the map, so bulk import stays idempotent instead of offering your whole library again. Drafts beyond that cap may be re-offered; the plugin logs a warning when it hits the limit.
Development
pnpm build # bundle to dist/
pnpm typecheck
pnpm test # caption parser, article builder, Instagram page, routes, TikTok adapter — all offline
pnpm test:routes:live # the route suite against real TikTok (needs network)
pnpm test:routes # route tests against a fake plugin context (hits the network)
pnpm diagnose:tiktok [handle] [videoUrl] # live check of both TikTok endpoints
pnpm diagnose:instagram <postUrl> # live check of the Instagram pathBoth platforms' endpoints are undocumented-but-public and can change without
notice. The diagnostic scripts are the fastest way to see which half broke:
diagnose:instagram runs the real single-post fetch against a live post and
prints the fields that decide what the draft gets — caption, owner, isVideo,
videoUrl, takenAt and the cover image.
The Gemini client
src/gemini/ is a hand-rolled REST client rather than @google/genai: the
plugin sandbox only gets ctx.http.fetch against a static host allowlist, and
the SDK reaches for its own transport. It covers exactly two calls —
POST /v1beta/interactions for the article, and the Files API resumable upload
for a video too large to inline. The upload's start request goes to
/upload/v1beta/files, not /v1beta/files: only the /upload/ frontend
speaks the resumable protocol and answers with an x-goog-upload-url header.
The metadata path returns 200 and no header, which reads as a success with
nowhere to send the bytes.
scripts/test-article.mjs stubs both, plus the platform CDN download, so the
whole article path is tested offline: the request shape (inline vs. file URI,
the agentic flag, the response schema), the fields written to the draft, the
article cache, the in-place update, the agentic-flag retry, and the optional
field drop.
Adding a platform
Implement SourceAdapter (src/sources/types.ts), register it in
src/sources/registry.ts, and add its hosts to allowedHosts. Nothing above
that layer — the caption parser, the recipe builder, the draft writer, the
admin UI — is platform-specific.
