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

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;
@@ -332,6 +466,72 @@ function buildParsedRecipeFromPostLenient(post) {
332
466
  return recipe;
333
467
  }
334
468
 
469
+ // src/image-dimensions.ts
470
+ function decodeImageDimensions(buffer) {
471
+ const b = new Uint8Array(buffer);
472
+ const view = new DataView(buffer);
473
+ if (b.length >= 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) {
474
+ return valid(view.getUint32(16), view.getUint32(20));
475
+ }
476
+ if (b.length >= 10 && b[0] === 71 && b[1] === 73 && b[2] === 70) {
477
+ return valid(view.getUint16(6, true), view.getUint16(8, true));
478
+ }
479
+ if (b.length >= 30 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) {
480
+ const fourcc = String.fromCharCode(b[12], b[13], b[14], b[15]);
481
+ if (fourcc === "VP8X") {
482
+ const w = 1 + (b[24] | b[25] << 8 | b[26] << 16);
483
+ const h = 1 + (b[27] | b[28] << 8 | b[29] << 16);
484
+ return valid(w, h);
485
+ }
486
+ if (fourcc === "VP8 ") {
487
+ if (b[23] === 157 && b[24] === 1 && b[25] === 42) {
488
+ return valid(
489
+ view.getUint16(26, true) & 16383,
490
+ view.getUint16(28, true) & 16383
491
+ );
492
+ }
493
+ return null;
494
+ }
495
+ if (fourcc === "VP8L") {
496
+ if (b[20] !== 47) return null;
497
+ const bits = view.getUint32(21, true);
498
+ return valid((bits & 16383) + 1, (bits >> 14 & 16383) + 1);
499
+ }
500
+ return null;
501
+ }
502
+ if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
503
+ let off = 2;
504
+ while (off + 9 < b.length) {
505
+ if (b[off] !== 255) {
506
+ off++;
507
+ continue;
508
+ }
509
+ const marker = b[off + 1];
510
+ if (marker === 255) {
511
+ off++;
512
+ continue;
513
+ }
514
+ if (marker === 1 || marker >= 208 && marker <= 217) {
515
+ off += 2;
516
+ continue;
517
+ }
518
+ const len = view.getUint16(off + 2);
519
+ if (len < 2) return null;
520
+ const isSOF = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
521
+ if (isSOF) {
522
+ if (off + 9 > b.length) return null;
523
+ return valid(view.getUint16(off + 7), view.getUint16(off + 5));
524
+ }
525
+ off += 2 + len;
526
+ }
527
+ return null;
528
+ }
529
+ return null;
530
+ }
531
+ function valid(width, height) {
532
+ return width > 0 && height > 0 ? { width, height } : null;
533
+ }
534
+
335
535
  // src/instagram-profile-image.ts
336
536
  var IG_APP_ID = "936619743392459";
337
537
  var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -353,15 +553,31 @@ function extractOwnerUsername(html) {
353
553
  if (userMatch?.[1]) return userMatch[1].toLowerCase();
354
554
  return void 0;
355
555
  }
356
- function displayUrlFromProfileUser(user, shortcode) {
556
+ var PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
557
+ function isProfileCacheFresh(entry) {
558
+ const age = Date.now() - Date.parse(entry.fetchedAt);
559
+ return Number.isFinite(age) && age >= 0 && age <= PROFILE_CACHE_TTL_MS;
560
+ }
561
+ function postsFromProfileUser(user) {
357
562
  const edges = user?.edge_owner_to_timeline_media?.edges ?? [];
563
+ const posts = [];
358
564
  for (const edge of edges) {
359
565
  const node = edge.node;
360
- if (node?.shortcode === shortcode && node.display_url) {
361
- return node.display_url;
362
- }
566
+ if (!node?.shortcode) continue;
567
+ const capEdges = node.edge_media_to_caption?.edges ?? [];
568
+ posts.push({
569
+ shortcode: node.shortcode,
570
+ caption: capEdges[0]?.node?.text?.trim() ?? "",
571
+ displayUrl: node.display_url,
572
+ accessibilityCaption: node.accessibility_caption,
573
+ likes: node.edge_liked_by?.count ?? 0,
574
+ isVideo: Boolean(node.is_video)
575
+ });
363
576
  }
364
- return void 0;
577
+ return posts;
578
+ }
579
+ function postNodeFromProfileUser(user, shortcode) {
580
+ return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
365
581
  }
366
582
  function profileApiHeaders(handle) {
367
583
  const profileUrl = `https://www.instagram.com/${handle}/`;
@@ -375,8 +591,11 @@ function profileApiHeaders(handle) {
375
591
  Origin: "https://www.instagram.com"
376
592
  };
377
593
  }
378
- var PROFILE_FETCH_RETRIES = 2;
379
- var PROFILE_RETRY_DELAY_MS = 2500;
594
+ var PROFILE_FETCH_RETRIES = 3;
595
+ var PROFILE_RETRY_BASE_DELAY_MS = 2500;
596
+ function retryDelayMs(attempt) {
597
+ return PROFILE_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
598
+ }
380
599
  function sleep(ms) {
381
600
  return new Promise((resolve) => setTimeout(resolve, ms));
382
601
  }
@@ -408,19 +627,45 @@ async function fetchProfileUserOnce(fetchFn, handle) {
408
627
  }
409
628
  return { ok: true, user, httpStatus };
410
629
  }
411
- async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
630
+ async function cacheGet(cache, handle) {
631
+ try {
632
+ return await cache?.get(handle);
633
+ } catch {
634
+ return void 0;
635
+ }
636
+ }
637
+ async function cacheSet(cache, handle, user, httpStatus) {
638
+ try {
639
+ await cache?.set(handle, {
640
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
641
+ handle,
642
+ httpStatus,
643
+ user
644
+ });
645
+ } catch {
646
+ }
647
+ }
648
+ async function fetchProfilePostNode(fetchFn, handle, shortcode, cache) {
412
649
  const username = normalizeInstagramHandle(handle);
650
+ const cached = await cacheGet(cache, username);
651
+ if (cached && isProfileCacheFresh(cached)) {
652
+ const node = postNodeFromProfileUser(cached.user, shortcode);
653
+ if (node) {
654
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
655
+ }
656
+ }
413
657
  let lastHttpStatus;
414
658
  let rateLimited = false;
415
659
  for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
416
660
  if (attempt > 0) {
417
- await sleep(PROFILE_RETRY_DELAY_MS * attempt);
661
+ await sleep(retryDelayMs(attempt - 1));
418
662
  }
419
663
  const result = await fetchProfileUserOnce(fetchFn, username);
420
664
  if (result.ok) {
421
- const url = displayUrlFromProfileUser(result.user, shortcode);
422
- if (url) {
423
- return { url, httpStatus: result.httpStatus };
665
+ await cacheSet(cache, username, result.user, result.httpStatus);
666
+ const node = postNodeFromProfileUser(result.user, shortcode);
667
+ if (node) {
668
+ return { node, httpStatus: result.httpStatus };
424
669
  }
425
670
  return { httpStatus: result.httpStatus, shortcodeMissing: true };
426
671
  }
@@ -430,15 +675,26 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
430
675
  }
431
676
  if (!result.retryable) break;
432
677
  }
678
+ if (cached) {
679
+ const node = postNodeFromProfileUser(cached.user, shortcode);
680
+ if (node) {
681
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
682
+ }
683
+ }
433
684
  if (typeof process !== "undefined" && process.versions?.node) {
434
685
  try {
435
- const { shouldTryInstagramCurlFallback, curlAvailable, fetchInstagramProfileViaCurl } = await import("./instagram-curl-profile-H4ND6RCQ.js");
686
+ const {
687
+ shouldTryInstagramCurlFallback,
688
+ curlAvailable,
689
+ fetchInstagramProfileViaCurl
690
+ } = await import("./instagram-curl-profile-H4ND6RCQ.js");
436
691
  if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
437
692
  const curlResult = await fetchInstagramProfileViaCurl(username);
438
693
  if (curlResult.ok) {
439
- const url = displayUrlFromProfileUser(curlResult.user, shortcode);
440
- if (url) {
441
- return { url, httpStatus: curlResult.httpStatus };
694
+ await cacheSet(cache, username, curlResult.user, curlResult.httpStatus);
695
+ const node = postNodeFromProfileUser(curlResult.user, shortcode);
696
+ if (node) {
697
+ return { node, httpStatus: curlResult.httpStatus };
442
698
  }
443
699
  return {
444
700
  httpStatus: curlResult.httpStatus,
@@ -455,6 +711,79 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
455
711
  shortcodeMissing: false
456
712
  };
457
713
  }
714
+ async function fetchProfileGrid(fetchFn, handle, cache) {
715
+ const username = normalizeInstagramHandle(handle);
716
+ const cached = await cacheGet(cache, username);
717
+ if (cached && isProfileCacheFresh(cached)) {
718
+ return {
719
+ handle: username,
720
+ posts: postsFromProfileUser(cached.user),
721
+ httpStatus: cached.httpStatus,
722
+ fromCache: true
723
+ };
724
+ }
725
+ let lastHttpStatus;
726
+ let rateLimited = false;
727
+ for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
728
+ if (attempt > 0) {
729
+ await sleep(retryDelayMs(attempt - 1));
730
+ }
731
+ const result = await fetchProfileUserOnce(fetchFn, username);
732
+ if (result.ok) {
733
+ await cacheSet(cache, username, result.user, result.httpStatus);
734
+ return {
735
+ handle: username,
736
+ posts: postsFromProfileUser(result.user),
737
+ httpStatus: result.httpStatus
738
+ };
739
+ }
740
+ lastHttpStatus = result.httpStatus;
741
+ if (result.httpStatus === 429 || result.httpStatus === 503) {
742
+ rateLimited = true;
743
+ }
744
+ if (!result.retryable) break;
745
+ }
746
+ if (cached) {
747
+ return {
748
+ handle: username,
749
+ posts: postsFromProfileUser(cached.user),
750
+ httpStatus: cached.httpStatus,
751
+ fromCache: true
752
+ };
753
+ }
754
+ if (typeof process !== "undefined" && process.versions?.node) {
755
+ try {
756
+ const {
757
+ shouldTryInstagramCurlFallback,
758
+ curlAvailable,
759
+ fetchInstagramProfileViaCurl
760
+ } = await import("./instagram-curl-profile-H4ND6RCQ.js");
761
+ if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
762
+ const curlResult = await fetchInstagramProfileViaCurl(username);
763
+ if (curlResult.ok) {
764
+ await cacheSet(
765
+ cache,
766
+ username,
767
+ curlResult.user,
768
+ curlResult.httpStatus
769
+ );
770
+ return {
771
+ handle: username,
772
+ posts: postsFromProfileUser(curlResult.user),
773
+ httpStatus: curlResult.httpStatus
774
+ };
775
+ }
776
+ }
777
+ } catch {
778
+ }
779
+ }
780
+ return {
781
+ handle: username,
782
+ posts: [],
783
+ httpStatus: lastHttpStatus,
784
+ rateLimited
785
+ };
786
+ }
458
787
 
459
788
  // src/parse-instagram-post.ts
460
789
  var INSTAGRAM_POST_RE = /(?:https?:\/\/)?(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/i;
@@ -512,13 +841,6 @@ function extractMetaContent(html, property) {
512
841
  }
513
842
  return void 0;
514
843
  }
515
- async function primeInstagramSession(fetchFn) {
516
- await fetchFn("https://www.instagram.com/", {
517
- method: "GET",
518
- redirect: "follow",
519
- headers: instagramFetchHeaders()
520
- });
521
- }
522
844
  function instagramFetchHeaders(referer) {
523
845
  return {
524
846
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
@@ -579,10 +901,56 @@ function extractCaptionFromOgDescription(description) {
579
901
  if (inline?.[1]) return inline[1].trim();
580
902
  return decoded;
581
903
  }
904
+ function imageNoticeFromProfileResult(result) {
905
+ if (result.node?.displayUrl) return void 0;
906
+ if (result.rateLimited) {
907
+ 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.";
908
+ }
909
+ if (result.shortcodeMissing) {
910
+ 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.";
911
+ }
912
+ return void 0;
913
+ }
914
+ async function postFromProfileNode(fetchFn, node, canonicalUrl) {
915
+ let imageBytes;
916
+ let imageContentType;
917
+ if (node.displayUrl) {
918
+ const downloaded = await downloadPostImage(
919
+ fetchFn,
920
+ node.displayUrl,
921
+ canonicalUrl
922
+ );
923
+ imageBytes = downloaded?.bytes;
924
+ imageContentType = downloaded?.contentType;
925
+ }
926
+ return {
927
+ shortcode: node.shortcode,
928
+ postUrl: canonicalUrl,
929
+ caption: node.caption,
930
+ displayUrl: node.displayUrl,
931
+ accessibilityCaption: node.accessibilityCaption,
932
+ likes: node.likes,
933
+ isVideo: node.isVideo,
934
+ imageBytes,
935
+ imageContentType
936
+ };
937
+ }
582
938
  async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
583
939
  const { shortcode } = parseInstagramPostUrl(postUrl);
584
940
  const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
585
- await primeInstagramSession(fetchFn);
941
+ const configuredHandle = options.instagramHandle ? normalizeInstagramHandle(options.instagramHandle) : void 0;
942
+ let profileResult;
943
+ if (configuredHandle) {
944
+ profileResult = await fetchProfilePostNode(
945
+ fetchFn,
946
+ configuredHandle,
947
+ shortcode,
948
+ options.profileCache
949
+ );
950
+ if (profileResult.node?.caption.trim()) {
951
+ return postFromProfileNode(fetchFn, profileResult.node, canonicalUrl);
952
+ }
953
+ }
586
954
  const res = await fetchFn(canonicalUrl, {
587
955
  method: "GET",
588
956
  redirect: "follow",
@@ -604,24 +972,18 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
604
972
  const ogVideo = extractMetaContent(html, "og:video");
605
973
  const isVideo = Boolean(ogVideo) || /"is_video":true/i.test(html) || /"__typename":"GraphVideo"/i.test(html) || /"media_type":2[,}]/i.test(html);
606
974
  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(
975
+ const ownerHandle = extractOwnerUsername(html);
976
+ if (ownerHandle && ownerHandle !== configuredHandle) {
977
+ profileResult = await fetchProfilePostNode(
612
978
  fetchFn,
613
979
  ownerHandle,
614
- shortcode
980
+ shortcode,
981
+ options.profileCache
615
982
  );
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
983
  }
984
+ const node = profileResult?.node;
985
+ const displayUrl = node?.displayUrl;
986
+ const imageNotice = profileResult ? imageNoticeFromProfileResult(profileResult) : void 0;
625
987
  let imageBytes;
626
988
  let imageContentType;
627
989
  if (displayUrl) {
@@ -637,8 +999,10 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
637
999
  return {
638
1000
  shortcode,
639
1001
  postUrl: canonicalUrl,
640
- caption,
1002
+ caption: caption || node?.caption || "",
641
1003
  displayUrl,
1004
+ accessibilityCaption: node?.accessibilityCaption,
1005
+ likes: node?.likes ?? 0,
642
1006
  isVideo,
643
1007
  imageNotice,
644
1008
  imageBytes,
@@ -651,6 +1015,11 @@ var instagramBrowserUserAgent = BROWSER_UA2;
651
1015
  var DEFAULT_COLLECTION = "posts";
652
1016
  var PARSE_CACHE_PREFIX = "parse:";
653
1017
  var IMPORT_MAP_PREFIX = "imported:";
1018
+ var PROFILE_CACHE_PREFIX = "igprofile:";
1019
+ var BULK_CREATE_DELAY_MS = 600;
1020
+ function sleep2(ms) {
1021
+ return new Promise((resolve) => setTimeout(resolve, ms));
1022
+ }
654
1023
  async function collectionSlug(ctx) {
655
1024
  return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
656
1025
  }
@@ -666,27 +1035,35 @@ async function httpFetch(ctx, url, init) {
666
1035
  }
667
1036
  return ctx.http.fetch(url, init);
668
1037
  }
1038
+ function profileCacheForCtx(ctx) {
1039
+ return {
1040
+ async get(handle) {
1041
+ return await ctx.kv.get(
1042
+ `${PROFILE_CACHE_PREFIX}${handle}`
1043
+ ) ?? void 0;
1044
+ },
1045
+ async set(handle, entry) {
1046
+ await ctx.kv.set(`${PROFILE_CACHE_PREFIX}${handle}`, entry);
1047
+ }
1048
+ };
1049
+ }
669
1050
  async function convertInstagramUrl(ctx, instagramUrl) {
670
1051
  const fetchFn = (input, init) => httpFetch(ctx, input, init);
671
1052
  const instagramHandle = await ctx.kv.get("config:instagram_handle");
672
1053
  const post = await fetchInstagramPost(instagramUrl, fetchFn, {
673
- instagramHandle: instagramHandle ?? void 0
1054
+ instagramHandle: instagramHandle ?? void 0,
1055
+ profileCache: profileCacheForCtx(ctx)
674
1056
  });
675
- const recipe = buildParsedRecipeFromPost({
676
- shortcode: post.shortcode,
677
- caption: post.caption,
678
- displayUrl: post.displayUrl,
679
- likes: 0,
680
- isVideo: post.isVideo,
681
- postUrl: post.postUrl
682
- }) ?? buildParsedRecipeFromPostLenient({
1057
+ const postForRecipe = {
683
1058
  shortcode: post.shortcode,
684
1059
  caption: post.caption,
685
1060
  displayUrl: post.displayUrl,
686
- likes: 0,
1061
+ accessibilityCaption: post.accessibilityCaption,
1062
+ likes: post.likes,
687
1063
  isVideo: post.isVideo,
688
1064
  postUrl: post.postUrl
689
- });
1065
+ };
1066
+ const recipe = buildParsedRecipeFromPost(postForRecipe) ?? buildParsedRecipeFromPostLenient(postForRecipe);
690
1067
  if (!recipe) {
691
1068
  throw new Error(
692
1069
  "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."
@@ -717,12 +1094,14 @@ async function uploadFeaturedImageBytes(ctx, recipe, bytes, contentType) {
717
1094
  return void 0;
718
1095
  }
719
1096
  const filename = recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
1097
+ const dims = decodeImageDimensions(bytes);
720
1098
  const uploaded = await ctx.media.upload(filename, contentType, bytes);
721
1099
  return {
722
1100
  provider: "local",
723
1101
  id: uploaded.mediaId,
724
1102
  src: uploaded.url,
725
1103
  mimeType: contentType,
1104
+ ...dims ?? {},
726
1105
  meta: { storageKey: uploaded.storageKey }
727
1106
  };
728
1107
  }
@@ -790,6 +1169,72 @@ async function createDraftFromRecipe(ctx, shortcode) {
790
1169
  collection
791
1170
  };
792
1171
  }
1172
+ function nodeToPostForRecipes(node) {
1173
+ return {
1174
+ shortcode: node.shortcode,
1175
+ caption: node.caption,
1176
+ displayUrl: node.displayUrl,
1177
+ accessibilityCaption: node.accessibilityCaption,
1178
+ likes: node.likes,
1179
+ isVideo: node.isVideo,
1180
+ postUrl: `https://www.instagram.com/p/${node.shortcode}/`
1181
+ };
1182
+ }
1183
+ async function scanProfileRecipes(ctx, handleInput) {
1184
+ const fallbackHandle = await ctx.kv.get("config:instagram_handle") ?? "";
1185
+ const handle = normalizeInstagramHandle(handleInput || fallbackHandle);
1186
+ const fetchFn = (input, init) => httpFetch(ctx, input, init);
1187
+ const grid = await fetchProfileGrid(fetchFn, handle, profileCacheForCtx(ctx));
1188
+ const posts = [...grid.posts].sort((a, b) => b.likes - a.likes).map(nodeToPostForRecipes);
1189
+ const recipes = buildBulkRecipesFromPosts(posts);
1190
+ const candidates = [];
1191
+ for (const recipe of recipes) {
1192
+ await ctx.kv.set(parseCacheKey(recipe.source.shortcode), recipe);
1193
+ const existing = await ctx.kv.get(
1194
+ importMapKey(recipe.source.shortcode)
1195
+ );
1196
+ candidates.push({ recipe, alreadyImported: Boolean(existing) });
1197
+ }
1198
+ return {
1199
+ handle: grid.handle,
1200
+ candidates,
1201
+ scanned: grid.posts.length,
1202
+ rateLimited: grid.rateLimited,
1203
+ fromCache: grid.fromCache
1204
+ };
1205
+ }
1206
+ async function createDraftsForShortcodes(ctx, shortcodes, options = {}) {
1207
+ const created = [];
1208
+ const skipped = [];
1209
+ const failed = [];
1210
+ for (const shortcode of shortcodes) {
1211
+ if (!options.force) {
1212
+ const existing = await ctx.kv.get(importMapKey(shortcode));
1213
+ if (existing) {
1214
+ const recipe = await ctx.kv.get(parseCacheKey(shortcode));
1215
+ skipped.push({
1216
+ shortcode,
1217
+ contentId: existing,
1218
+ title: recipe?.title ?? shortcode
1219
+ });
1220
+ continue;
1221
+ }
1222
+ }
1223
+ if (created.length + failed.length > 0) {
1224
+ await sleep2(BULK_CREATE_DELAY_MS);
1225
+ }
1226
+ try {
1227
+ const { contentId, title } = await createDraftFromRecipe(ctx, shortcode);
1228
+ created.push({ shortcode, contentId, title });
1229
+ } catch (error) {
1230
+ failed.push({
1231
+ shortcode,
1232
+ message: error instanceof Error ? error.message : String(error)
1233
+ });
1234
+ }
1235
+ }
1236
+ return { created, skipped, failed };
1237
+ }
793
1238
  var sandbox_entry_default = {
794
1239
  hooks: {
795
1240
  "plugin:install": {
@@ -824,11 +1269,95 @@ var sandbox_entry_default = {
824
1269
  return { ok: true, ...result };
825
1270
  }
826
1271
  },
1272
+ "bulk-scan": {
1273
+ input: z.object({
1274
+ handle: z.string().min(1)
1275
+ }),
1276
+ handler: async (routeCtx, ctx) => {
1277
+ const result = await scanProfileRecipes(ctx, routeCtx.input.handle);
1278
+ return { ok: true, ...result };
1279
+ }
1280
+ },
1281
+ "bulk-create": {
1282
+ input: z.object({
1283
+ shortcodes: z.array(z.string().min(1)).min(1)
1284
+ }),
1285
+ handler: async (routeCtx, ctx) => {
1286
+ const result = await createDraftsForShortcodes(
1287
+ ctx,
1288
+ routeCtx.input.shortcodes
1289
+ );
1290
+ return { ok: true, ...result };
1291
+ }
1292
+ },
827
1293
  admin: {
828
1294
  handler: async (routeCtx, ctx) => {
829
1295
  const interaction = routeCtx.input ?? { type: "page_load" };
830
1296
  if (interaction.type === "page_load") {
831
- return { blocks: importFormBlocks() };
1297
+ return { blocks: pageBlocks() };
1298
+ }
1299
+ const instagramHandle = String(
1300
+ interaction.values?.instagram_handle ?? ""
1301
+ ).trim();
1302
+ if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
1303
+ try {
1304
+ const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
1305
+ return {
1306
+ blocks: bulkPreviewBlocks(handle, candidates, {
1307
+ scanned,
1308
+ rateLimited,
1309
+ fromCache
1310
+ }),
1311
+ toast: {
1312
+ message: candidates.length ? `Found ${candidates.length} recipe(s)` : "No recipes found",
1313
+ type: candidates.length ? "success" : "error"
1314
+ }
1315
+ };
1316
+ } catch (error) {
1317
+ const message = error instanceof Error ? error.message : String(error);
1318
+ return {
1319
+ blocks: [
1320
+ ...pageBlocks(),
1321
+ {
1322
+ type: "banner",
1323
+ title: "Could not scan profile",
1324
+ description: message,
1325
+ variant: "error"
1326
+ }
1327
+ ],
1328
+ toast: { message, type: "error" }
1329
+ };
1330
+ }
1331
+ }
1332
+ if (interaction.type === "form_submit" && interaction.action_id === "bulk_create") {
1333
+ const raw = interaction.values?.bulk_select;
1334
+ const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
1335
+ if (shortcodes.length === 0) {
1336
+ return {
1337
+ blocks: pageBlocks(),
1338
+ toast: { message: "Select at least one recipe", type: "error" }
1339
+ };
1340
+ }
1341
+ try {
1342
+ const result = await createDraftsForShortcodes(ctx, shortcodes);
1343
+ const notes = [
1344
+ result.skipped.length ? `${result.skipped.length} skipped` : "",
1345
+ result.failed.length ? `${result.failed.length} failed` : ""
1346
+ ].filter(Boolean);
1347
+ return {
1348
+ blocks: bulkResultBlocks(instagramHandle, result),
1349
+ toast: {
1350
+ message: `Created ${result.created.length} draft(s)${notes.length ? `, ${notes.join(", ")}` : ""}`,
1351
+ type: result.failed.length && !result.created.length ? "error" : "success"
1352
+ }
1353
+ };
1354
+ } catch (error) {
1355
+ const message = error instanceof Error ? error.message : String(error);
1356
+ return {
1357
+ blocks: pageBlocks(),
1358
+ toast: { message, type: "error" }
1359
+ };
1360
+ }
832
1361
  }
833
1362
  const instagramUrl = String(
834
1363
  interaction.values?.instagram_url ?? ""