@peristyle/emdash-plugin-instagram-to-recipe 0.1.0 → 0.1.2

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.
@@ -96,6 +96,9 @@ function importFormBlocks(initialUrl = "") {
96
96
  }
97
97
  ];
98
98
  }
99
+ function pageBlocks() {
100
+ return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
101
+ }
99
102
  function previewBlocks(recipe, options) {
100
103
  const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
101
104
  const instructionPreview = recipe.instructions.slice(0, 4).map((step, i) => `${i + 1}. ${step}`).join("\n");
@@ -182,6 +185,116 @@ function previewBlocks(recipe, options) {
182
185
  });
183
186
  return blocks;
184
187
  }
188
+ function bulkFormBlocks(initialHandle = "") {
189
+ return [
190
+ {
191
+ type: "header",
192
+ text: "Bulk import from a profile"
193
+ },
194
+ {
195
+ type: "context",
196
+ text: "Enter a public Instagram handle. Its latest posts are scanned in a single request, and only captions that parse as recipes are listed for import \u2014 pick the ones to create."
197
+ },
198
+ {
199
+ type: "form",
200
+ block_id: "bulk-form",
201
+ fields: [
202
+ {
203
+ type: "text_input",
204
+ action_id: "instagram_handle",
205
+ label: "Instagram handle",
206
+ placeholder: "@cookingwithashhhh",
207
+ initial_value: initialHandle
208
+ }
209
+ ],
210
+ submit: { label: "Scan profile", action_id: "bulk_scan" }
211
+ }
212
+ ];
213
+ }
214
+ function bulkPreviewBlocks(handle, candidates, meta = {
215
+ scanned: 0
216
+ }) {
217
+ const blocks = [...bulkFormBlocks(handle), { type: "divider" }];
218
+ if (meta.rateLimited) {
219
+ blocks.push({
220
+ type: "banner",
221
+ title: "Instagram rate-limited the profile API",
222
+ description: "Showing whatever could be recovered (possibly from cache). Try again in a few minutes for a full scan.",
223
+ variant: "alert"
224
+ });
225
+ }
226
+ if (candidates.length === 0) {
227
+ blocks.push({
228
+ type: "banner",
229
+ title: "No recipes found",
230
+ description: `Scanned ${meta.scanned} recent post(s) for @${handle} but none had a parseable recipe caption (ingredients + numbered steps). Use the single-post import above for an individual post.`,
231
+ variant: "default"
232
+ });
233
+ return blocks;
234
+ }
235
+ blocks.push({
236
+ type: "banner",
237
+ title: `${candidates.length} recipe${candidates.length === 1 ? "" : "s"} found`,
238
+ description: `Scanned ${meta.scanned} recent post(s) for @${handle}${meta.fromCache ? " (from cache)" : ""}. Already-imported posts are unchecked by default.`,
239
+ variant: "default"
240
+ });
241
+ const options = candidates.map((c) => ({
242
+ label: `${c.recipe.title} \u2014 ${c.recipe.source.likes} likes${c.alreadyImported ? " (already imported)" : ""}`,
243
+ value: c.recipe.source.shortcode
244
+ }));
245
+ const initialSelected = candidates.filter((c) => !c.alreadyImported).map((c) => c.recipe.source.shortcode);
246
+ blocks.push({
247
+ type: "form",
248
+ block_id: "bulk-select-form",
249
+ fields: [
250
+ {
251
+ type: "checkbox",
252
+ action_id: "bulk_select",
253
+ label: "Recipes to import",
254
+ options,
255
+ initial_value: initialSelected
256
+ }
257
+ ],
258
+ submit: { label: "Create selected drafts", action_id: "bulk_create" }
259
+ });
260
+ for (const c of candidates) {
261
+ if (c.recipe.featured_image?.url) {
262
+ blocks.push({
263
+ type: "image",
264
+ url: c.recipe.featured_image.url,
265
+ alt: c.recipe.featured_image.alt,
266
+ title: c.recipe.title
267
+ });
268
+ }
269
+ }
270
+ return blocks;
271
+ }
272
+ function bulkResultBlocks(handle, result) {
273
+ const blocks = [...bulkFormBlocks(handle), { type: "divider" }];
274
+ blocks.push({
275
+ type: "banner",
276
+ title: `Created ${result.created.length} draft${result.created.length === 1 ? "" : "s"}`,
277
+ description: result.created.length ? `Saved: ${result.created.map((c) => c.title).join(", ")}. Open Posts in the admin to review and publish.` : "No new drafts were created.",
278
+ variant: "default"
279
+ });
280
+ if (result.skipped.length > 0) {
281
+ blocks.push({
282
+ type: "banner",
283
+ title: `Skipped ${result.skipped.length} already imported`,
284
+ description: `Already in your library: ${result.skipped.map((s) => s.title).join(", ")}.`,
285
+ variant: "default"
286
+ });
287
+ }
288
+ if (result.failed.length > 0) {
289
+ blocks.push({
290
+ type: "banner",
291
+ title: `${result.failed.length} failed`,
292
+ description: result.failed.map((f) => `${f.shortcode}: ${f.message}`).join(" \xB7 "),
293
+ variant: "alert"
294
+ });
295
+ }
296
+ return blocks;
297
+ }
185
298
  function errorBlocks(message, initialUrl = "") {
186
299
  return [
187
300
  ...importFormBlocks(initialUrl),
@@ -299,6 +412,27 @@ function buildParsedRecipeFromPost(post) {
299
412
  }
300
413
  return recipe;
301
414
  }
415
+ function buildBulkRecipesFromPosts(posts) {
416
+ const recipes = [];
417
+ const usedSlugs = /* @__PURE__ */ new Set();
418
+ for (const post of posts) {
419
+ const recipe = buildParsedRecipeFromPost(post);
420
+ if (!recipe) continue;
421
+ let slug = recipe.suggested_slug;
422
+ let n = 2;
423
+ while (usedSlugs.has(slug)) {
424
+ slug = `${recipe.suggested_slug}-${n}`;
425
+ n++;
426
+ }
427
+ usedSlugs.add(slug);
428
+ recipe.suggested_slug = slug;
429
+ if (recipe.featured_image) {
430
+ recipe.featured_image.filename = imageFilenameForSlug(slug);
431
+ }
432
+ recipes.push(recipe);
433
+ }
434
+ return recipes;
435
+ }
302
436
  function buildParsedRecipeFromPostLenient(post) {
303
437
  const strict = buildParsedRecipeFromPost(post);
304
438
  if (strict) return strict;
@@ -353,15 +487,31 @@ function extractOwnerUsername(html) {
353
487
  if (userMatch?.[1]) return userMatch[1].toLowerCase();
354
488
  return void 0;
355
489
  }
356
- function displayUrlFromProfileUser(user, shortcode) {
490
+ var PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
491
+ function isProfileCacheFresh(entry) {
492
+ const age = Date.now() - Date.parse(entry.fetchedAt);
493
+ return Number.isFinite(age) && age >= 0 && age <= PROFILE_CACHE_TTL_MS;
494
+ }
495
+ function postsFromProfileUser(user) {
357
496
  const edges = user?.edge_owner_to_timeline_media?.edges ?? [];
497
+ const posts = [];
358
498
  for (const edge of edges) {
359
499
  const node = edge.node;
360
- if (node?.shortcode === shortcode && node.display_url) {
361
- return node.display_url;
362
- }
500
+ if (!node?.shortcode) continue;
501
+ const capEdges = node.edge_media_to_caption?.edges ?? [];
502
+ posts.push({
503
+ shortcode: node.shortcode,
504
+ caption: capEdges[0]?.node?.text?.trim() ?? "",
505
+ displayUrl: node.display_url,
506
+ accessibilityCaption: node.accessibility_caption,
507
+ likes: node.edge_liked_by?.count ?? 0,
508
+ isVideo: Boolean(node.is_video)
509
+ });
363
510
  }
364
- return void 0;
511
+ return posts;
512
+ }
513
+ function postNodeFromProfileUser(user, shortcode) {
514
+ return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
365
515
  }
366
516
  function profileApiHeaders(handle) {
367
517
  const profileUrl = `https://www.instagram.com/${handle}/`;
@@ -375,8 +525,11 @@ function profileApiHeaders(handle) {
375
525
  Origin: "https://www.instagram.com"
376
526
  };
377
527
  }
378
- var PROFILE_FETCH_RETRIES = 2;
379
- var PROFILE_RETRY_DELAY_MS = 2500;
528
+ var PROFILE_FETCH_RETRIES = 3;
529
+ var PROFILE_RETRY_BASE_DELAY_MS = 2500;
530
+ function retryDelayMs(attempt) {
531
+ return PROFILE_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
532
+ }
380
533
  function sleep(ms) {
381
534
  return new Promise((resolve) => setTimeout(resolve, ms));
382
535
  }
@@ -408,19 +561,45 @@ async function fetchProfileUserOnce(fetchFn, handle) {
408
561
  }
409
562
  return { ok: true, user, httpStatus };
410
563
  }
411
- async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
564
+ async function cacheGet(cache, handle) {
565
+ try {
566
+ return await cache?.get(handle);
567
+ } catch {
568
+ return void 0;
569
+ }
570
+ }
571
+ async function cacheSet(cache, handle, user, httpStatus) {
572
+ try {
573
+ await cache?.set(handle, {
574
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
575
+ handle,
576
+ httpStatus,
577
+ user
578
+ });
579
+ } catch {
580
+ }
581
+ }
582
+ async function fetchProfilePostNode(fetchFn, handle, shortcode, cache) {
412
583
  const username = normalizeInstagramHandle(handle);
584
+ const cached = await cacheGet(cache, username);
585
+ if (cached && isProfileCacheFresh(cached)) {
586
+ const node = postNodeFromProfileUser(cached.user, shortcode);
587
+ if (node) {
588
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
589
+ }
590
+ }
413
591
  let lastHttpStatus;
414
592
  let rateLimited = false;
415
593
  for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
416
594
  if (attempt > 0) {
417
- await sleep(PROFILE_RETRY_DELAY_MS * attempt);
595
+ await sleep(retryDelayMs(attempt - 1));
418
596
  }
419
597
  const result = await fetchProfileUserOnce(fetchFn, username);
420
598
  if (result.ok) {
421
- const url = displayUrlFromProfileUser(result.user, shortcode);
422
- if (url) {
423
- return { url, httpStatus: result.httpStatus };
599
+ await cacheSet(cache, username, result.user, result.httpStatus);
600
+ const node = postNodeFromProfileUser(result.user, shortcode);
601
+ if (node) {
602
+ return { node, httpStatus: result.httpStatus };
424
603
  }
425
604
  return { httpStatus: result.httpStatus, shortcodeMissing: true };
426
605
  }
@@ -430,15 +609,26 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
430
609
  }
431
610
  if (!result.retryable) break;
432
611
  }
612
+ if (cached) {
613
+ const node = postNodeFromProfileUser(cached.user, shortcode);
614
+ if (node) {
615
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
616
+ }
617
+ }
433
618
  if (typeof process !== "undefined" && process.versions?.node) {
434
619
  try {
435
- const { shouldTryInstagramCurlFallback, curlAvailable, fetchInstagramProfileViaCurl } = await import("./instagram-curl-profile-H4ND6RCQ.js");
620
+ const {
621
+ shouldTryInstagramCurlFallback,
622
+ curlAvailable,
623
+ fetchInstagramProfileViaCurl
624
+ } = await import("./instagram-curl-profile-H4ND6RCQ.js");
436
625
  if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
437
626
  const curlResult = await fetchInstagramProfileViaCurl(username);
438
627
  if (curlResult.ok) {
439
- const url = displayUrlFromProfileUser(curlResult.user, shortcode);
440
- if (url) {
441
- return { url, httpStatus: curlResult.httpStatus };
628
+ await cacheSet(cache, username, curlResult.user, curlResult.httpStatus);
629
+ const node = postNodeFromProfileUser(curlResult.user, shortcode);
630
+ if (node) {
631
+ return { node, httpStatus: curlResult.httpStatus };
442
632
  }
443
633
  return {
444
634
  httpStatus: curlResult.httpStatus,
@@ -455,6 +645,79 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
455
645
  shortcodeMissing: false
456
646
  };
457
647
  }
648
+ async function fetchProfileGrid(fetchFn, handle, cache) {
649
+ const username = normalizeInstagramHandle(handle);
650
+ const cached = await cacheGet(cache, username);
651
+ if (cached && isProfileCacheFresh(cached)) {
652
+ return {
653
+ handle: username,
654
+ posts: postsFromProfileUser(cached.user),
655
+ httpStatus: cached.httpStatus,
656
+ fromCache: true
657
+ };
658
+ }
659
+ let lastHttpStatus;
660
+ let rateLimited = false;
661
+ for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
662
+ if (attempt > 0) {
663
+ await sleep(retryDelayMs(attempt - 1));
664
+ }
665
+ const result = await fetchProfileUserOnce(fetchFn, username);
666
+ if (result.ok) {
667
+ await cacheSet(cache, username, result.user, result.httpStatus);
668
+ return {
669
+ handle: username,
670
+ posts: postsFromProfileUser(result.user),
671
+ httpStatus: result.httpStatus
672
+ };
673
+ }
674
+ lastHttpStatus = result.httpStatus;
675
+ if (result.httpStatus === 429 || result.httpStatus === 503) {
676
+ rateLimited = true;
677
+ }
678
+ if (!result.retryable) break;
679
+ }
680
+ if (cached) {
681
+ return {
682
+ handle: username,
683
+ posts: postsFromProfileUser(cached.user),
684
+ httpStatus: cached.httpStatus,
685
+ fromCache: true
686
+ };
687
+ }
688
+ if (typeof process !== "undefined" && process.versions?.node) {
689
+ try {
690
+ const {
691
+ shouldTryInstagramCurlFallback,
692
+ curlAvailable,
693
+ fetchInstagramProfileViaCurl
694
+ } = await import("./instagram-curl-profile-H4ND6RCQ.js");
695
+ if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
696
+ const curlResult = await fetchInstagramProfileViaCurl(username);
697
+ if (curlResult.ok) {
698
+ await cacheSet(
699
+ cache,
700
+ username,
701
+ curlResult.user,
702
+ curlResult.httpStatus
703
+ );
704
+ return {
705
+ handle: username,
706
+ posts: postsFromProfileUser(curlResult.user),
707
+ httpStatus: curlResult.httpStatus
708
+ };
709
+ }
710
+ }
711
+ } catch {
712
+ }
713
+ }
714
+ return {
715
+ handle: username,
716
+ posts: [],
717
+ httpStatus: lastHttpStatus,
718
+ rateLimited
719
+ };
720
+ }
458
721
 
459
722
  // src/parse-instagram-post.ts
460
723
  var INSTAGRAM_POST_RE = /(?:https?:\/\/)?(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/i;
@@ -512,13 +775,6 @@ function extractMetaContent(html, property) {
512
775
  }
513
776
  return void 0;
514
777
  }
515
- async function primeInstagramSession(fetchFn) {
516
- await fetchFn("https://www.instagram.com/", {
517
- method: "GET",
518
- redirect: "follow",
519
- headers: instagramFetchHeaders()
520
- });
521
- }
522
778
  function instagramFetchHeaders(referer) {
523
779
  return {
524
780
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
@@ -579,10 +835,56 @@ function extractCaptionFromOgDescription(description) {
579
835
  if (inline?.[1]) return inline[1].trim();
580
836
  return decoded;
581
837
  }
838
+ function imageNoticeFromProfileResult(result) {
839
+ if (result.node?.displayUrl) return void 0;
840
+ if (result.rateLimited) {
841
+ return "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image \u2014 upload one manually, or try Convert again in a few minutes.";
842
+ }
843
+ if (result.shortcodeMissing) {
844
+ return "This post was not found in the latest profile grid (Instagram only returns ~12 posts). Import without a featured image and upload one manually.";
845
+ }
846
+ return void 0;
847
+ }
848
+ async function postFromProfileNode(fetchFn, node, canonicalUrl) {
849
+ let imageBytes;
850
+ let imageContentType;
851
+ if (node.displayUrl) {
852
+ const downloaded = await downloadPostImage(
853
+ fetchFn,
854
+ node.displayUrl,
855
+ canonicalUrl
856
+ );
857
+ imageBytes = downloaded?.bytes;
858
+ imageContentType = downloaded?.contentType;
859
+ }
860
+ return {
861
+ shortcode: node.shortcode,
862
+ postUrl: canonicalUrl,
863
+ caption: node.caption,
864
+ displayUrl: node.displayUrl,
865
+ accessibilityCaption: node.accessibilityCaption,
866
+ likes: node.likes,
867
+ isVideo: node.isVideo,
868
+ imageBytes,
869
+ imageContentType
870
+ };
871
+ }
582
872
  async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
583
873
  const { shortcode } = parseInstagramPostUrl(postUrl);
584
874
  const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
585
- await primeInstagramSession(fetchFn);
875
+ const configuredHandle = options.instagramHandle ? normalizeInstagramHandle(options.instagramHandle) : void 0;
876
+ let profileResult;
877
+ if (configuredHandle) {
878
+ profileResult = await fetchProfilePostNode(
879
+ fetchFn,
880
+ configuredHandle,
881
+ shortcode,
882
+ options.profileCache
883
+ );
884
+ if (profileResult.node?.caption.trim()) {
885
+ return postFromProfileNode(fetchFn, profileResult.node, canonicalUrl);
886
+ }
887
+ }
586
888
  const res = await fetchFn(canonicalUrl, {
587
889
  method: "GET",
588
890
  redirect: "follow",
@@ -604,24 +906,18 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
604
906
  const ogVideo = extractMetaContent(html, "og:video");
605
907
  const isVideo = Boolean(ogVideo) || /"is_video":true/i.test(html) || /"__typename":"GraphVideo"/i.test(html) || /"media_type":2[,}]/i.test(html);
606
908
  const cookieHeader = cookieHeaderFromResponse(res);
607
- const ownerHandle = options.instagramHandle ?? extractOwnerUsername(html);
608
- let displayUrl;
609
- let imageNotice;
610
- if (ownerHandle) {
611
- const profile = await fetchProfileDisplayUrl(
909
+ const ownerHandle = extractOwnerUsername(html);
910
+ if (ownerHandle && ownerHandle !== configuredHandle) {
911
+ profileResult = await fetchProfilePostNode(
612
912
  fetchFn,
613
913
  ownerHandle,
614
- shortcode
914
+ shortcode,
915
+ options.profileCache
615
916
  );
616
- displayUrl = profile.url;
617
- if (!displayUrl) {
618
- if (profile.rateLimited) {
619
- imageNotice = "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image \u2014 upload one manually, or try Convert again in a few minutes.";
620
- } else if (profile.shortcodeMissing) {
621
- imageNotice = "This post was not found in the latest profile grid (Instagram only returns ~12 posts). Import without a featured image and upload one manually.";
622
- }
623
- }
624
917
  }
918
+ const node = profileResult?.node;
919
+ const displayUrl = node?.displayUrl;
920
+ const imageNotice = profileResult ? imageNoticeFromProfileResult(profileResult) : void 0;
625
921
  let imageBytes;
626
922
  let imageContentType;
627
923
  if (displayUrl) {
@@ -637,8 +933,10 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
637
933
  return {
638
934
  shortcode,
639
935
  postUrl: canonicalUrl,
640
- caption,
936
+ caption: caption || node?.caption || "",
641
937
  displayUrl,
938
+ accessibilityCaption: node?.accessibilityCaption,
939
+ likes: node?.likes ?? 0,
642
940
  isVideo,
643
941
  imageNotice,
644
942
  imageBytes,
@@ -651,6 +949,11 @@ var instagramBrowserUserAgent = BROWSER_UA2;
651
949
  var DEFAULT_COLLECTION = "posts";
652
950
  var PARSE_CACHE_PREFIX = "parse:";
653
951
  var IMPORT_MAP_PREFIX = "imported:";
952
+ var PROFILE_CACHE_PREFIX = "igprofile:";
953
+ var BULK_CREATE_DELAY_MS = 600;
954
+ function sleep2(ms) {
955
+ return new Promise((resolve) => setTimeout(resolve, ms));
956
+ }
654
957
  async function collectionSlug(ctx) {
655
958
  return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
656
959
  }
@@ -666,27 +969,35 @@ async function httpFetch(ctx, url, init) {
666
969
  }
667
970
  return ctx.http.fetch(url, init);
668
971
  }
972
+ function profileCacheForCtx(ctx) {
973
+ return {
974
+ async get(handle) {
975
+ return await ctx.kv.get(
976
+ `${PROFILE_CACHE_PREFIX}${handle}`
977
+ ) ?? void 0;
978
+ },
979
+ async set(handle, entry) {
980
+ await ctx.kv.set(`${PROFILE_CACHE_PREFIX}${handle}`, entry);
981
+ }
982
+ };
983
+ }
669
984
  async function convertInstagramUrl(ctx, instagramUrl) {
670
985
  const fetchFn = (input, init) => httpFetch(ctx, input, init);
671
986
  const instagramHandle = await ctx.kv.get("config:instagram_handle");
672
987
  const post = await fetchInstagramPost(instagramUrl, fetchFn, {
673
- instagramHandle: instagramHandle ?? void 0
988
+ instagramHandle: instagramHandle ?? void 0,
989
+ profileCache: profileCacheForCtx(ctx)
674
990
  });
675
- const recipe = buildParsedRecipeFromPost({
991
+ const postForRecipe = {
676
992
  shortcode: post.shortcode,
677
993
  caption: post.caption,
678
994
  displayUrl: post.displayUrl,
679
- likes: 0,
995
+ accessibilityCaption: post.accessibilityCaption,
996
+ likes: post.likes,
680
997
  isVideo: post.isVideo,
681
998
  postUrl: post.postUrl
682
- }) ?? buildParsedRecipeFromPostLenient({
683
- shortcode: post.shortcode,
684
- caption: post.caption,
685
- displayUrl: post.displayUrl,
686
- likes: 0,
687
- isVideo: post.isVideo,
688
- postUrl: post.postUrl
689
- });
999
+ };
1000
+ const recipe = buildParsedRecipeFromPost(postForRecipe) ?? buildParsedRecipeFromPostLenient(postForRecipe);
690
1001
  if (!recipe) {
691
1002
  throw new Error(
692
1003
  "Caption does not look like a recipe (needs ingredients and numbered steps). Try a post with a \u201CWhat you'll need\u201D section and numbered instructions."
@@ -790,6 +1101,72 @@ async function createDraftFromRecipe(ctx, shortcode) {
790
1101
  collection
791
1102
  };
792
1103
  }
1104
+ function nodeToPostForRecipes(node) {
1105
+ return {
1106
+ shortcode: node.shortcode,
1107
+ caption: node.caption,
1108
+ displayUrl: node.displayUrl,
1109
+ accessibilityCaption: node.accessibilityCaption,
1110
+ likes: node.likes,
1111
+ isVideo: node.isVideo,
1112
+ postUrl: `https://www.instagram.com/p/${node.shortcode}/`
1113
+ };
1114
+ }
1115
+ async function scanProfileRecipes(ctx, handleInput) {
1116
+ const fallbackHandle = await ctx.kv.get("config:instagram_handle") ?? "";
1117
+ const handle = normalizeInstagramHandle(handleInput || fallbackHandle);
1118
+ const fetchFn = (input, init) => httpFetch(ctx, input, init);
1119
+ const grid = await fetchProfileGrid(fetchFn, handle, profileCacheForCtx(ctx));
1120
+ const posts = [...grid.posts].sort((a, b) => b.likes - a.likes).map(nodeToPostForRecipes);
1121
+ const recipes = buildBulkRecipesFromPosts(posts);
1122
+ const candidates = [];
1123
+ for (const recipe of recipes) {
1124
+ await ctx.kv.set(parseCacheKey(recipe.source.shortcode), recipe);
1125
+ const existing = await ctx.kv.get(
1126
+ importMapKey(recipe.source.shortcode)
1127
+ );
1128
+ candidates.push({ recipe, alreadyImported: Boolean(existing) });
1129
+ }
1130
+ return {
1131
+ handle: grid.handle,
1132
+ candidates,
1133
+ scanned: grid.posts.length,
1134
+ rateLimited: grid.rateLimited,
1135
+ fromCache: grid.fromCache
1136
+ };
1137
+ }
1138
+ async function createDraftsForShortcodes(ctx, shortcodes, options = {}) {
1139
+ const created = [];
1140
+ const skipped = [];
1141
+ const failed = [];
1142
+ for (const shortcode of shortcodes) {
1143
+ if (!options.force) {
1144
+ const existing = await ctx.kv.get(importMapKey(shortcode));
1145
+ if (existing) {
1146
+ const recipe = await ctx.kv.get(parseCacheKey(shortcode));
1147
+ skipped.push({
1148
+ shortcode,
1149
+ contentId: existing,
1150
+ title: recipe?.title ?? shortcode
1151
+ });
1152
+ continue;
1153
+ }
1154
+ }
1155
+ if (created.length + failed.length > 0) {
1156
+ await sleep2(BULK_CREATE_DELAY_MS);
1157
+ }
1158
+ try {
1159
+ const { contentId, title } = await createDraftFromRecipe(ctx, shortcode);
1160
+ created.push({ shortcode, contentId, title });
1161
+ } catch (error) {
1162
+ failed.push({
1163
+ shortcode,
1164
+ message: error instanceof Error ? error.message : String(error)
1165
+ });
1166
+ }
1167
+ }
1168
+ return { created, skipped, failed };
1169
+ }
793
1170
  var sandbox_entry_default = {
794
1171
  hooks: {
795
1172
  "plugin:install": {
@@ -824,11 +1201,95 @@ var sandbox_entry_default = {
824
1201
  return { ok: true, ...result };
825
1202
  }
826
1203
  },
1204
+ "bulk-scan": {
1205
+ input: z.object({
1206
+ handle: z.string().min(1)
1207
+ }),
1208
+ handler: async (routeCtx, ctx) => {
1209
+ const result = await scanProfileRecipes(ctx, routeCtx.input.handle);
1210
+ return { ok: true, ...result };
1211
+ }
1212
+ },
1213
+ "bulk-create": {
1214
+ input: z.object({
1215
+ shortcodes: z.array(z.string().min(1)).min(1)
1216
+ }),
1217
+ handler: async (routeCtx, ctx) => {
1218
+ const result = await createDraftsForShortcodes(
1219
+ ctx,
1220
+ routeCtx.input.shortcodes
1221
+ );
1222
+ return { ok: true, ...result };
1223
+ }
1224
+ },
827
1225
  admin: {
828
1226
  handler: async (routeCtx, ctx) => {
829
1227
  const interaction = routeCtx.input ?? { type: "page_load" };
830
1228
  if (interaction.type === "page_load") {
831
- return { blocks: importFormBlocks() };
1229
+ return { blocks: pageBlocks() };
1230
+ }
1231
+ const instagramHandle = String(
1232
+ interaction.values?.instagram_handle ?? ""
1233
+ ).trim();
1234
+ if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
1235
+ try {
1236
+ const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
1237
+ return {
1238
+ blocks: bulkPreviewBlocks(handle, candidates, {
1239
+ scanned,
1240
+ rateLimited,
1241
+ fromCache
1242
+ }),
1243
+ toast: {
1244
+ message: candidates.length ? `Found ${candidates.length} recipe(s)` : "No recipes found",
1245
+ type: candidates.length ? "success" : "error"
1246
+ }
1247
+ };
1248
+ } catch (error) {
1249
+ const message = error instanceof Error ? error.message : String(error);
1250
+ return {
1251
+ blocks: [
1252
+ ...pageBlocks(),
1253
+ {
1254
+ type: "banner",
1255
+ title: "Could not scan profile",
1256
+ description: message,
1257
+ variant: "error"
1258
+ }
1259
+ ],
1260
+ toast: { message, type: "error" }
1261
+ };
1262
+ }
1263
+ }
1264
+ if (interaction.type === "form_submit" && interaction.action_id === "bulk_create") {
1265
+ const raw = interaction.values?.bulk_select;
1266
+ const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
1267
+ if (shortcodes.length === 0) {
1268
+ return {
1269
+ blocks: pageBlocks(),
1270
+ toast: { message: "Select at least one recipe", type: "error" }
1271
+ };
1272
+ }
1273
+ try {
1274
+ const result = await createDraftsForShortcodes(ctx, shortcodes);
1275
+ const notes = [
1276
+ result.skipped.length ? `${result.skipped.length} skipped` : "",
1277
+ result.failed.length ? `${result.failed.length} failed` : ""
1278
+ ].filter(Boolean);
1279
+ return {
1280
+ blocks: bulkResultBlocks(instagramHandle, result),
1281
+ toast: {
1282
+ message: `Created ${result.created.length} draft(s)${notes.length ? `, ${notes.join(", ")}` : ""}`,
1283
+ type: result.failed.length && !result.created.length ? "error" : "success"
1284
+ }
1285
+ };
1286
+ } catch (error) {
1287
+ const message = error instanceof Error ? error.message : String(error);
1288
+ return {
1289
+ blocks: pageBlocks(),
1290
+ toast: { message, type: "error" }
1291
+ };
1292
+ }
832
1293
  }
833
1294
  const instagramUrl = String(
834
1295
  interaction.values?.instagram_url ?? ""