@peristyle/emdash-plugin-instagram-to-recipe 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,19 @@
1
1
  # @peristyle/emdash-plugin-instagram-to-recipe
2
2
 
3
- Import a public Instagram post into your food blog as a draft recipe from the EmDash admin.
3
+ Import public Instagram posts into your [EmDash](https://www.npmjs.com/package/emdash) site as draft recipe posts one at a time by URL, or in bulk by scanning a profile.
4
4
 
5
- Paste a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) and the plugin will:
5
+ For a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) the plugin will:
6
6
 
7
- 1. Fetch the post page and read the caption from Open Graph metadata
8
- 2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps
9
- 3. Download the post **feed image** (`display_url` from embedded JSON — same source as brand-bootstrap — not the OG share-card with a play button)
10
- 4. Create a **draft** post in your `posts` collection
11
-
12
- Parsing logic matches [brand-bootstrap](https://github.com/time-to-eat/init-brand-resources) Instagram recipe extraction.
7
+ 1. Fetch the post and read the caption (profile API first, HTML Open Graph metadata as fallback)
8
+ 2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps — including captions Instagram has collapsed onto one line
9
+ 3. Download the post **feed image** (the embedded `display_url`, not the OG share-card with a play button) and store its dimensions for responsive images
10
+ 4. Create a **draft** post in your configured collection
13
11
 
14
12
  ## Install
15
13
 
16
- From your EmDash site (e.g. `food-blog-base`):
17
-
18
14
  ```bash
19
- pnpm add ../plugins/instagram-to-recipe
15
+ pnpm add @peristyle/emdash-plugin-instagram-to-recipe
16
+ # or: npm install / yarn add
20
17
  ```
21
18
 
22
19
  Register in `astro.config.mjs`:
@@ -27,12 +24,7 @@ import { instagramToRecipePlugin } from "@peristyle/emdash-plugin-instagram-to-r
27
24
  export default defineConfig({
28
25
  integrations: [
29
26
  emdash({
30
- plugins: [
31
- recipesPlugin(),
32
- instagramToRecipePlugin(),
33
- // optional: custom collection slug
34
- // instagramToRecipePlugin({ collection: "posts" }),
35
- ],
27
+ plugins: [instagramToRecipePlugin()],
36
28
  }),
37
29
  ],
38
30
  });
@@ -42,51 +34,74 @@ Rebuild or restart the dev server after adding the plugin.
42
34
 
43
35
  ## Admin usage
44
36
 
45
- 1. Open **Plugins → Instagram to Recipe Import from Instagram**
46
- 2. Paste an Instagram post URL
47
- 3. Click **Convert to recipe** and review the preview
48
- 4. Click **Create draft post**
49
- 5. Edit the draft under **Posts** (slug, categories, times, publish)
37
+ Open **Plugins → Import from Instagram** in the EmDash admin. The page has three sections:
38
+
39
+ ### Single post
40
+
41
+ 1. Paste an Instagram post URL and click **Convert to recipe**
42
+ 2. Review the parsed preview (title, ingredients, steps, featured image)
43
+ 3. Click **Create draft post**
44
+ 4. Edit the draft under **Posts** (slug, categories, times, publish)
45
+
46
+ ### Bulk import from a profile
47
+
48
+ 1. Enter a public Instagram handle and click **Scan profile**
49
+ 2. The profile grid is fetched in a single request and every caption that parses as a recipe is listed, sorted by likes
50
+ 3. Check the recipes you want (already-imported posts are unchecked and labeled) and click **Create selected drafts**
51
+
52
+ Bulk import is idempotent — posts already imported are skipped. Deleting an imported draft clears the bookkeeping, so the post can be imported again later.
53
+
54
+ ### Settings
55
+
56
+ - **Content collection** — where drafts are created (default: `posts`)
57
+ - **Instagram handle** (optional) — pre-fills the bulk scan and lets single-post imports go straight to the profile API; when empty, the post owner's handle is detected automatically
58
+
59
+ > Standard-format EmDash plugins are configured here (settings are stored in the plugin's KV store), not via constructor options.
50
60
 
51
61
  ## Capabilities
52
62
 
53
- | Capability | Purpose |
54
- | ---------------- | -------------------------------------------- |
55
- | `network:fetch` | Fetch Instagram HTML and CDN images |
56
- | `read:content` | Check for prior imports |
57
- | `write:content` | Create draft posts |
58
- | `write:media` | Upload featured images |
63
+ | Capability | Purpose |
64
+ | ----------------- | ----------------------------------- |
65
+ | `network:request` | Fetch Instagram HTML and CDN images |
66
+ | `content:read` | Check for prior imports |
67
+ | `content:write` | Create draft posts |
68
+ | `media:write` | Upload featured images |
59
69
 
60
70
  Allowed hosts: `www.instagram.com`, `*.cdninstagram.com`, `*.fbcdn.net`.
61
71
 
62
72
  ## API routes
63
73
 
64
- For scripting or custom admin UI:
74
+ For scripting or a custom admin UI:
65
75
 
66
- | Route | Method | Body |
67
- | -------------- | ------ | ----------------------------- |
68
- | `parse` | POST | `{ "url": "https://..." }` |
69
- | `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
76
+ | Route | Method | Body |
77
+ | -------------- | ------ | ------------------------------------- |
78
+ | `parse` | POST | `{ "url": "https://..." }` |
79
+ | `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
80
+ | `bulk-scan` | POST | `{ "handle": "somefoodblogger" }` |
81
+ | `bulk-create` | POST | `{ "shortcodes": ["DXkhLGbEcOY"] }` |
70
82
 
71
83
  Base path: `/_emdash/api/plugins/peristyle-instagram-to-recipe/<route>`
72
84
 
73
85
  ## Limitations
74
86
 
75
87
  - **Public posts only** — private or login-walled posts cannot be read
76
- - **Caption format** — works best with “What you'll need” + numbered steps (same as brand-bootstrap)
77
- - **Instagram rate limits** — repeated imports may hit HTTP 429; wait and retry
78
- - **No video frames** — uses the OG thumbnail/image, not in-video text
79
- - **Cloudflare sandbox** — uses `ctx.http.fetch` and `ctx.media.upload` (no Node `curl`)
88
+ - **Caption format** — works best with a “What you'll need” / “Ingredients:” section plus numbered steps
89
+ - **Bulk scan depth** — one profile request returns roughly the 12 most recent posts; older posts can still be imported individually by URL
90
+ - **Instagram rate limits** — repeated fetches may hit HTTP 429; responses are cached for 24 hours and stale cache is used as a fallback, but you may need to wait and retry
91
+ - **No video frames** — video posts use the feed thumbnail, not in-video text
92
+
93
+ ## Building from source
80
94
 
81
- ## Development
95
+ The TypeScript source ships in the package under `src/`. From an unpacked copy:
82
96
 
83
97
  ```bash
84
- cd plugins/instagram-to-recipe
85
98
  pnpm install
86
99
  pnpm build
87
100
  pnpm typecheck
88
101
  ```
89
102
 
103
+ The development repository is not currently public.
104
+
90
105
  ## License
91
106
 
92
107
  MIT
package/dist/index.d.ts CHANGED
@@ -1,14 +1,10 @@
1
1
  import { PluginDescriptor } from 'emdash';
2
2
 
3
- type InstagramToRecipePluginOptions = {
4
- /** Content collection slug for imported recipes (default: posts). */
5
- collection?: string;
6
- /**
7
- * Instagram username for web_profile_info (e.g. cookingwithashhhh).
8
- * When omitted, the post page's owner username is used automatically.
9
- */
10
- instagramHandle?: string;
11
- };
12
- declare function instagramToRecipePlugin(options?: InstagramToRecipePluginOptions): PluginDescriptor<InstagramToRecipePluginOptions>;
3
+ /**
4
+ * Standard-format EmDash plugins are configured via the admin UI (KV
5
+ * settings), not constructor options — set the target collection and
6
+ * Instagram handle under Plugins → Import from Instagram → Settings.
7
+ */
8
+ declare function instagramToRecipePlugin(): PluginDescriptor;
13
9
 
14
- export { type InstagramToRecipePluginOptions, instagramToRecipePlugin };
10
+ export { instagramToRecipePlugin };
package/dist/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  // src/index.ts
2
- function instagramToRecipePlugin(options = {}) {
2
+ function instagramToRecipePlugin() {
3
3
  return {
4
4
  id: "peristyle-instagram-to-recipe",
5
- version: "0.1.4",
5
+ version: "0.1.6",
6
6
  format: "standard",
7
7
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
8
- options,
9
8
  capabilities: [
10
9
  "content:read",
11
10
  "content:write",
@@ -60,6 +60,12 @@ declare const _default: {
60
60
  "plugin:install": {
61
61
  handler: (_event: unknown, ctx: PluginContext) => Promise<void>;
62
62
  };
63
+ "content:afterDelete": {
64
+ handler: (event: {
65
+ id: string;
66
+ collection: string;
67
+ }, ctx: PluginContext) => Promise<void>;
68
+ };
63
69
  };
64
70
  routes: {
65
71
  parse: {
@@ -96,8 +96,47 @@ function importFormBlocks(initialUrl = "") {
96
96
  }
97
97
  ];
98
98
  }
99
- function pageBlocks() {
100
- return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
99
+ function settingsFormBlocks(current) {
100
+ return [
101
+ {
102
+ type: "header",
103
+ text: "Settings"
104
+ },
105
+ {
106
+ type: "context",
107
+ text: "Drafts are created in the collection below. The Instagram handle is optional \u2014 it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically."
108
+ },
109
+ {
110
+ type: "form",
111
+ block_id: "settings-form",
112
+ fields: [
113
+ {
114
+ type: "text_input",
115
+ action_id: "collection",
116
+ label: "Content collection",
117
+ placeholder: "posts",
118
+ initial_value: current.collection
119
+ },
120
+ {
121
+ type: "text_input",
122
+ action_id: "instagram_handle",
123
+ label: "Instagram handle (optional)",
124
+ placeholder: "@yourhandle",
125
+ initial_value: current.instagramHandle
126
+ }
127
+ ],
128
+ submit: { label: "Save settings", action_id: "save_settings" }
129
+ }
130
+ ];
131
+ }
132
+ function pageBlocks(settings) {
133
+ return [
134
+ ...importFormBlocks(),
135
+ { type: "divider" },
136
+ ...bulkFormBlocks(settings.instagramHandle),
137
+ { type: "divider" },
138
+ ...settingsFormBlocks(settings)
139
+ ];
101
140
  }
102
141
  function previewBlocks(recipe, options) {
103
142
  const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
@@ -1040,6 +1079,7 @@ var instagramBrowserUserAgent = BROWSER_UA2;
1040
1079
  var DEFAULT_COLLECTION = "posts";
1041
1080
  var PARSE_CACHE_PREFIX = "parse:";
1042
1081
  var IMPORT_MAP_PREFIX = "imported:";
1082
+ var IMPORT_BY_ID_PREFIX = "imported-by-id:";
1043
1083
  var PROFILE_CACHE_PREFIX = "igprofile:";
1044
1084
  var BULK_CREATE_DELAY_MS = 600;
1045
1085
  function sleep2(ms) {
@@ -1048,12 +1088,21 @@ function sleep2(ms) {
1048
1088
  async function collectionSlug(ctx) {
1049
1089
  return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
1050
1090
  }
1091
+ async function pluginSettings(ctx) {
1092
+ return {
1093
+ collection: await collectionSlug(ctx),
1094
+ instagramHandle: await ctx.kv.get("config:instagram_handle") ?? ""
1095
+ };
1096
+ }
1051
1097
  function parseCacheKey(shortcode) {
1052
1098
  return `${PARSE_CACHE_PREFIX}${shortcode}`;
1053
1099
  }
1054
1100
  function importMapKey(shortcode) {
1055
1101
  return `${IMPORT_MAP_PREFIX}${shortcode}`;
1056
1102
  }
1103
+ function importByIdKey(contentId) {
1104
+ return `${IMPORT_BY_ID_PREFIX}${contentId}`;
1105
+ }
1057
1106
  async function httpFetch(ctx, url, init) {
1058
1107
  if (!ctx.http) {
1059
1108
  throw new Error("Network access is not available for this plugin.");
@@ -1188,6 +1237,7 @@ async function createDraftFromRecipe(ctx, shortcode) {
1188
1237
  const data = recipeToPostWriteData(recipe, featuredImage);
1189
1238
  const item = await ctx.content.create(collection, data);
1190
1239
  await ctx.kv.set(importMapKey(shortcode), item.id);
1240
+ await ctx.kv.set(importByIdKey(item.id), shortcode);
1191
1241
  return {
1192
1242
  contentId: item.id,
1193
1243
  title: recipe.title,
@@ -1270,6 +1320,19 @@ var sandbox_entry_default = {
1270
1320
  await ctx.kv.set("config:collection", DEFAULT_COLLECTION);
1271
1321
  }
1272
1322
  }
1323
+ },
1324
+ // Deleting an imported recipe post doesn't otherwise touch this plugin's
1325
+ // own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
1326
+ // treating the source Instagram post as already imported forever.
1327
+ "content:afterDelete": {
1328
+ handler: async (event, ctx) => {
1329
+ const collection = await collectionSlug(ctx);
1330
+ if (event.collection !== collection) return;
1331
+ const shortcode = await ctx.kv.get(importByIdKey(event.id));
1332
+ if (!shortcode) return;
1333
+ await ctx.kv.delete(importByIdKey(event.id));
1334
+ await ctx.kv.delete(importMapKey(shortcode));
1335
+ }
1273
1336
  }
1274
1337
  },
1275
1338
  routes: {
@@ -1319,11 +1382,44 @@ var sandbox_entry_default = {
1319
1382
  handler: async (routeCtx, ctx) => {
1320
1383
  const interaction = routeCtx.input ?? { type: "page_load" };
1321
1384
  if (interaction.type === "page_load") {
1322
- return { blocks: pageBlocks() };
1385
+ return { blocks: pageBlocks(await pluginSettings(ctx)) };
1323
1386
  }
1324
1387
  const instagramHandle = String(
1325
1388
  interaction.values?.instagram_handle ?? ""
1326
1389
  ).trim();
1390
+ if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
1391
+ const collection = String(interaction.values?.collection ?? "").trim().toLowerCase();
1392
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
1393
+ return {
1394
+ blocks: pageBlocks(await pluginSettings(ctx)),
1395
+ toast: {
1396
+ message: "Collection must be a slug (letters, numbers, dashes).",
1397
+ type: "error"
1398
+ }
1399
+ };
1400
+ }
1401
+ let handle = "";
1402
+ if (instagramHandle) {
1403
+ try {
1404
+ handle = normalizeInstagramHandle(instagramHandle);
1405
+ } catch {
1406
+ return {
1407
+ blocks: pageBlocks(await pluginSettings(ctx)),
1408
+ toast: { message: "Invalid Instagram handle.", type: "error" }
1409
+ };
1410
+ }
1411
+ }
1412
+ await ctx.kv.set("config:collection", collection);
1413
+ if (handle) {
1414
+ await ctx.kv.set("config:instagram_handle", handle);
1415
+ } else {
1416
+ await ctx.kv.delete("config:instagram_handle");
1417
+ }
1418
+ return {
1419
+ blocks: pageBlocks({ collection, instagramHandle: handle }),
1420
+ toast: { message: "Settings saved", type: "success" }
1421
+ };
1422
+ }
1327
1423
  if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
1328
1424
  try {
1329
1425
  const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
@@ -1342,7 +1438,7 @@ var sandbox_entry_default = {
1342
1438
  const message = error instanceof Error ? error.message : String(error);
1343
1439
  return {
1344
1440
  blocks: [
1345
- ...pageBlocks(),
1441
+ ...pageBlocks(await pluginSettings(ctx)),
1346
1442
  {
1347
1443
  type: "banner",
1348
1444
  title: "Could not scan profile",
@@ -1359,7 +1455,7 @@ var sandbox_entry_default = {
1359
1455
  const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
1360
1456
  if (shortcodes.length === 0) {
1361
1457
  return {
1362
- blocks: pageBlocks(),
1458
+ blocks: pageBlocks(await pluginSettings(ctx)),
1363
1459
  toast: { message: "Select at least one recipe", type: "error" }
1364
1460
  };
1365
1461
  }
@@ -1379,7 +1475,7 @@ var sandbox_entry_default = {
1379
1475
  } catch (error) {
1380
1476
  const message = error instanceof Error ? error.message : String(error);
1381
1477
  return {
1382
- blocks: pageBlocks(),
1478
+ blocks: pageBlocks(await pluginSettings(ctx)),
1383
1479
  toast: { message, type: "error" }
1384
1480
  };
1385
1481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peristyle/emdash-plugin-instagram-to-recipe",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Import Instagram post URLs as recipe drafts in the EmDash admin",
6
6
  "publishConfig": {
@@ -30,9 +30,54 @@ export function importFormBlocks(initialUrl = ""): Block[] {
30
30
  ];
31
31
  }
32
32
 
33
- /** Landing view: single-post import on top, bulk profile import below. */
34
- export function pageBlocks(): Block[] {
35
- return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
33
+ export type PluginSettings = {
34
+ collection: string;
35
+ instagramHandle: string;
36
+ };
37
+
38
+ export function settingsFormBlocks(current: PluginSettings): Block[] {
39
+ return [
40
+ {
41
+ type: "header",
42
+ text: "Settings",
43
+ },
44
+ {
45
+ type: "context",
46
+ text: "Drafts are created in the collection below. The Instagram handle is optional — it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically.",
47
+ },
48
+ {
49
+ type: "form",
50
+ block_id: "settings-form",
51
+ fields: [
52
+ {
53
+ type: "text_input",
54
+ action_id: "collection",
55
+ label: "Content collection",
56
+ placeholder: "posts",
57
+ initial_value: current.collection,
58
+ },
59
+ {
60
+ type: "text_input",
61
+ action_id: "instagram_handle",
62
+ label: "Instagram handle (optional)",
63
+ placeholder: "@yourhandle",
64
+ initial_value: current.instagramHandle,
65
+ },
66
+ ],
67
+ submit: { label: "Save settings", action_id: "save_settings" },
68
+ },
69
+ ];
70
+ }
71
+
72
+ /** Landing view: single-post import, bulk profile import, settings. */
73
+ export function pageBlocks(settings: PluginSettings): Block[] {
74
+ return [
75
+ ...importFormBlocks(),
76
+ { type: "divider" },
77
+ ...bulkFormBlocks(settings.instagramHandle),
78
+ { type: "divider" },
79
+ ...settingsFormBlocks(settings),
80
+ ];
36
81
  }
37
82
 
38
83
  export function previewBlocks(
package/src/index.ts CHANGED
@@ -3,25 +3,17 @@ import type { PluginDescriptor } from "emdash";
3
3
  /** Replaced at build time by tsup `define` from package.json — single source of truth. */
4
4
  declare const __PLUGIN_VERSION__: string;
5
5
 
6
- export type InstagramToRecipePluginOptions = {
7
- /** Content collection slug for imported recipes (default: posts). */
8
- collection?: string;
9
- /**
10
- * Instagram username for web_profile_info (e.g. cookingwithashhhh).
11
- * When omitted, the post page's owner username is used automatically.
12
- */
13
- instagramHandle?: string;
14
- };
15
-
16
- export function instagramToRecipePlugin(
17
- options: InstagramToRecipePluginOptions = {},
18
- ): PluginDescriptor<InstagramToRecipePluginOptions> {
6
+ /**
7
+ * Standard-format EmDash plugins are configured via the admin UI (KV
8
+ * settings), not constructor options — set the target collection and
9
+ * Instagram handle under Plugins → Import from Instagram → Settings.
10
+ */
11
+ export function instagramToRecipePlugin(): PluginDescriptor {
19
12
  return {
20
13
  id: "peristyle-instagram-to-recipe",
21
14
  version: __PLUGIN_VERSION__,
22
15
  format: "standard",
23
16
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
24
- options,
25
17
  capabilities: [
26
18
  "content:read",
27
19
  "content:write",
@@ -17,8 +17,8 @@ function cookieJarPath(handle: string): string {
17
17
  }
18
18
 
19
19
  /**
20
- * Same curl + cookie-jar flow as init-brand-resources brand-bootstrap.
21
- * Used when fetch() to web_profile_info fails (often HTTP 429).
20
+ * curl + cookie-jar fallback: prime cookies on the profile page, then call
21
+ * web_profile_info. Used when fetch() fails (often HTTP 429).
22
22
  */
23
23
  export async function fetchInstagramProfileViaCurl(
24
24
  handle: string,
@@ -43,7 +43,7 @@ export function extractOwnerUsername(html: string): string | undefined {
43
43
  return undefined;
44
44
  }
45
45
 
46
- /** Post data resolved from the profile grid — same fields brand-bootstrap maps. */
46
+ /** Post data resolved from the profile grid. */
47
47
  export type ProfilePostNode = {
48
48
  shortcode: string;
49
49
  caption: string;
@@ -53,7 +53,7 @@ export type ProfilePostNode = {
53
53
  isVideo: boolean;
54
54
  };
55
55
 
56
- /** Same shape brand-bootstrap persists under `.cache/instagram/<handle>.json`. */
56
+ /** Cached web_profile_info payload for one handle. */
57
57
  export type CachedInstagramProfile = {
58
58
  fetchedAt: string;
59
59
  handle: string;
@@ -106,7 +106,7 @@ export function postNodeFromProfileUser(
106
106
  return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
107
107
  }
108
108
 
109
- /** Same field brand-bootstrap uses: node.display_url from the profile grid. */
109
+ /** node.display_url from the profile grid — the feed image URL. */
110
110
  export function displayUrlFromProfileUser(
111
111
  user: unknown,
112
112
  shortcode: string,
@@ -127,7 +127,7 @@ function profileApiHeaders(handle: string): HeadersInit {
127
127
  };
128
128
  }
129
129
 
130
- // Match brand-bootstrap: 3 retries with exponential backoff (2.5s, 5s, 10s).
130
+ // 3 retries with exponential backoff (2.5s, 5s, 10s).
131
131
  const PROFILE_FETCH_RETRIES = 3;
132
132
  const PROFILE_RETRY_BASE_DELAY_MS = 2500;
133
133
 
@@ -155,8 +155,7 @@ export type ProfileDisplayUrlResult = {
155
155
  };
156
156
 
157
157
  /**
158
- * Resolve display_url via web_profile_info same API and field as
159
- * init-brand-resources brand-bootstrap (postsFromProfile → displayUrl).
158
+ * Resolve display_url via one web_profile_info request for the handle.
160
159
  */
161
160
  async function fetchProfileUserOnce(
162
161
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
@@ -228,9 +227,9 @@ async function cacheSet(
228
227
  }
229
228
 
230
229
  /**
231
- * Fetch the post's grid node via web_profile_info, mirroring brand-bootstrap:
232
- * fresh cache first, then live fetch with backoff, then stale cache on
233
- * failure, then the curl cookie-jar fallback.
230
+ * Fetch the post's grid node via web_profile_info: fresh cache first, then
231
+ * live fetch with backoff, then stale cache on failure, then the curl
232
+ * cookie-jar fallback.
234
233
  */
235
234
  export async function fetchProfilePostNode(
236
235
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
@@ -275,7 +274,7 @@ export async function fetchProfilePostNode(
275
274
  if (!result.retryable) break;
276
275
  }
277
276
 
278
- // Stale cache beats no image same trade-off brand-bootstrap makes on 429.
277
+ // Stale cache beats no image when Instagram is rate-limiting.
279
278
  if (cached) {
280
279
  const node = postNodeFromProfileUser(cached.user, shortcode);
281
280
  if (node) {
@@ -146,8 +146,9 @@ export function extractEmbeddedImageUrl(html: string): string | undefined {
146
146
  }
147
147
 
148
148
  /**
149
- * Resolve the post's feed image (same class of URL as brand-bootstrap display_url).
150
- * Never uses og:video. Uses og:image only when no embedded display_url exists.
149
+ * Resolve the post's feed image (the embedded display_url, not the OG
150
+ * share-card). Never uses og:video. Uses og:image only when no embedded
151
+ * display_url exists.
151
152
  */
152
153
  export function resolvePostImageUrl(
153
154
  html: string,
@@ -300,7 +301,7 @@ export type FetchedInstagramPost = {
300
301
  };
301
302
 
302
303
  export type FetchInstagramPostOptions = {
303
- /** Override owner handle for web_profile_info lookup (same as brand-bootstrap handle). */
304
+ /** Override owner handle for the web_profile_info lookup. */
304
305
  instagramHandle?: string;
305
306
  /** Profile payload cache (24h TTL, stale fallback on 429) — back with ctx.kv. */
306
307
  profileCache?: InstagramProfileCache;
@@ -358,10 +359,10 @@ export async function fetchInstagramPost(
358
359
  const { shortcode } = parseInstagramPostUrl(postUrl);
359
360
  const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
360
361
 
361
- // Profile-API-first, like init-brand-resources brand-bootstrap: a single
362
- // (cached) web_profile_info request supplies caption, display_url, and
363
- // is_video. Anonymous HTML page loads are what trip Instagram's rate
364
- // limiter, so they are reserved for the fallback below.
362
+ // Profile-API-first: a single (cached) web_profile_info request supplies
363
+ // caption, display_url, and is_video. Anonymous HTML page loads are what
364
+ // trip Instagram's rate limiter, so they are reserved for the fallback
365
+ // below.
365
366
  const configuredHandle = options.instagramHandle
366
367
  ? normalizeInstagramHandle(options.instagramHandle)
367
368
  : undefined;
@@ -412,8 +413,8 @@ export async function fetchInstagramPost(
412
413
  const cookieHeader = cookieHeaderFromResponse(res);
413
414
  const ownerHandle = extractOwnerUsername(html);
414
415
 
415
- // Same image source as init-brand-resources: web_profile_info → node.display_url.
416
- // Skip the lookup when the configured handle already answered for this owner.
416
+ // Image source: web_profile_info → node.display_url. Skip the lookup when
417
+ // the configured handle already answered for this owner.
417
418
  if (ownerHandle && ownerHandle !== configuredHandle) {
418
419
  profileResult = await fetchProfilePostNode(
419
420
  fetchFn,
@@ -9,6 +9,7 @@ import {
9
9
  pageBlocks,
10
10
  previewBlocks,
11
11
  type BulkCandidate,
12
+ type PluginSettings,
12
13
  } from "./admin-blocks.js";
13
14
  import {
14
15
  buildBulkRecipesFromPosts,
@@ -34,6 +35,7 @@ import { recipeToPostWriteData } from "./recipe-content.js";
34
35
  const DEFAULT_COLLECTION = "posts";
35
36
  const PARSE_CACHE_PREFIX = "parse:";
36
37
  const IMPORT_MAP_PREFIX = "imported:";
38
+ const IMPORT_BY_ID_PREFIX = "imported-by-id:";
37
39
  const PROFILE_CACHE_PREFIX = "igprofile:";
38
40
 
39
41
  /** Delay between draft creations so per-post image downloads stay polite. */
@@ -56,6 +58,14 @@ async function collectionSlug(ctx: PluginContext): Promise<string> {
56
58
  return (await ctx.kv.get<string>("config:collection")) ?? DEFAULT_COLLECTION;
57
59
  }
58
60
 
61
+ async function pluginSettings(ctx: PluginContext): Promise<PluginSettings> {
62
+ return {
63
+ collection: await collectionSlug(ctx),
64
+ instagramHandle:
65
+ (await ctx.kv.get<string>("config:instagram_handle")) ?? "",
66
+ };
67
+ }
68
+
59
69
  function parseCacheKey(shortcode: string): string {
60
70
  return `${PARSE_CACHE_PREFIX}${shortcode}`;
61
71
  }
@@ -64,6 +74,10 @@ function importMapKey(shortcode: string): string {
64
74
  return `${IMPORT_MAP_PREFIX}${shortcode}`;
65
75
  }
66
76
 
77
+ function importByIdKey(contentId: string): string {
78
+ return `${IMPORT_BY_ID_PREFIX}${contentId}`;
79
+ }
80
+
67
81
  async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
68
82
  if (!ctx.http) {
69
83
  throw new Error("Network access is not available for this plugin.");
@@ -71,7 +85,7 @@ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
71
85
  return ctx.http.fetch(url, init);
72
86
  }
73
87
 
74
- /** Same role as brand-bootstrap's `.cache/instagram/` files, backed by KV. */
88
+ /** Profile payload cache backed by plugin KV (24h TTL, stale fallback). */
75
89
  function profileCacheForCtx(ctx: PluginContext): InstagramProfileCache {
76
90
  return {
77
91
  async get(handle) {
@@ -261,6 +275,7 @@ async function createDraftFromRecipe(
261
275
 
262
276
  const item = await ctx.content.create(collection, data);
263
277
  await ctx.kv.set(importMapKey(shortcode), item.id);
278
+ await ctx.kv.set(importByIdKey(item.id), shortcode);
264
279
 
265
280
  return {
266
281
  contentId: item.id,
@@ -397,6 +412,24 @@ export default {
397
412
  }
398
413
  },
399
414
  },
415
+ // Deleting an imported recipe post doesn't otherwise touch this plugin's
416
+ // own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
417
+ // treating the source Instagram post as already imported forever.
418
+ "content:afterDelete": {
419
+ handler: async (
420
+ event: { id: string; collection: string },
421
+ ctx: PluginContext,
422
+ ) => {
423
+ const collection = await collectionSlug(ctx);
424
+ if (event.collection !== collection) return;
425
+
426
+ const shortcode = await ctx.kv.get<string>(importByIdKey(event.id));
427
+ if (!shortcode) return;
428
+
429
+ await ctx.kv.delete(importByIdKey(event.id));
430
+ await ctx.kv.delete(importMapKey(shortcode));
431
+ },
432
+ },
400
433
  },
401
434
 
402
435
  routes: {
@@ -460,13 +493,57 @@ export default {
460
493
  const interaction = routeCtx.input ?? { type: "page_load" };
461
494
 
462
495
  if (interaction.type === "page_load") {
463
- return { blocks: pageBlocks() };
496
+ return { blocks: pageBlocks(await pluginSettings(ctx)) };
464
497
  }
465
498
 
466
499
  const instagramHandle = String(
467
500
  interaction.values?.instagram_handle ?? "",
468
501
  ).trim();
469
502
 
503
+ if (
504
+ interaction.type === "form_submit" &&
505
+ interaction.action_id === "save_settings"
506
+ ) {
507
+ const collection = String(interaction.values?.collection ?? "")
508
+ .trim()
509
+ .toLowerCase();
510
+
511
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
512
+ return {
513
+ blocks: pageBlocks(await pluginSettings(ctx)),
514
+ toast: {
515
+ message:
516
+ "Collection must be a slug (letters, numbers, dashes).",
517
+ type: "error",
518
+ },
519
+ };
520
+ }
521
+
522
+ let handle = "";
523
+ if (instagramHandle) {
524
+ try {
525
+ handle = normalizeInstagramHandle(instagramHandle);
526
+ } catch {
527
+ return {
528
+ blocks: pageBlocks(await pluginSettings(ctx)),
529
+ toast: { message: "Invalid Instagram handle.", type: "error" },
530
+ };
531
+ }
532
+ }
533
+
534
+ await ctx.kv.set("config:collection", collection);
535
+ if (handle) {
536
+ await ctx.kv.set("config:instagram_handle", handle);
537
+ } else {
538
+ await ctx.kv.delete("config:instagram_handle");
539
+ }
540
+
541
+ return {
542
+ blocks: pageBlocks({ collection, instagramHandle: handle }),
543
+ toast: { message: "Settings saved", type: "success" },
544
+ };
545
+ }
546
+
470
547
  if (
471
548
  interaction.type === "form_submit" &&
472
549
  interaction.action_id === "bulk_scan"
@@ -492,7 +569,7 @@ export default {
492
569
  error instanceof Error ? error.message : String(error);
493
570
  return {
494
571
  blocks: [
495
- ...pageBlocks(),
572
+ ...pageBlocks(await pluginSettings(ctx)),
496
573
  {
497
574
  type: "banner",
498
575
  title: "Could not scan profile",
@@ -516,7 +593,7 @@ export default {
516
593
 
517
594
  if (shortcodes.length === 0) {
518
595
  return {
519
- blocks: pageBlocks(),
596
+ blocks: pageBlocks(await pluginSettings(ctx)),
520
597
  toast: { message: "Select at least one recipe", type: "error" },
521
598
  };
522
599
  }
@@ -545,7 +622,7 @@ export default {
545
622
  const message =
546
623
  error instanceof Error ? error.message : String(error);
547
624
  return {
548
- blocks: pageBlocks(),
625
+ blocks: pageBlocks(await pluginSettings(ctx)),
549
626
  toast: { message, type: "error" },
550
627
  };
551
628
  }