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

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.
@@ -0,0 +1,905 @@
1
+ // src/sandbox-entry.ts
2
+ import { z } from "astro/zod";
3
+
4
+ // src/recipe-content.ts
5
+ function listBlocks(items, prefix, listItem) {
6
+ return items.map((text, index) => ({
7
+ _type: "block",
8
+ _key: `${prefix}${index}`,
9
+ style: "normal",
10
+ listItem,
11
+ level: 1,
12
+ children: [
13
+ {
14
+ _type: "span",
15
+ _key: `${prefix}${index}s`,
16
+ text
17
+ }
18
+ ]
19
+ }));
20
+ }
21
+ function recipeToPortableTextFields(recipe) {
22
+ const ingredients = listBlocks(recipe.ingredients, "ing", "bullet");
23
+ const recipe_instructions = [
24
+ {
25
+ _type: "block",
26
+ _key: "ri-heading",
27
+ style: "h3",
28
+ children: [
29
+ {
30
+ _type: "span",
31
+ _key: "ri-heading-s",
32
+ text: "Instructions"
33
+ }
34
+ ]
35
+ },
36
+ ...listBlocks(recipe.instructions, "ri", "number")
37
+ ];
38
+ return { ingredients, recipe_instructions };
39
+ }
40
+ function recipeToPostWriteData(recipe, featuredMedia) {
41
+ const { ingredients, recipe_instructions } = recipeToPortableTextFields(recipe);
42
+ const data = {
43
+ title: recipe.title,
44
+ excerpt: recipe.excerpt,
45
+ ingredients,
46
+ recipe_instructions,
47
+ difficulty: "easy"
48
+ };
49
+ if (featuredMedia) {
50
+ data.featured_image = {
51
+ ...featuredMedia,
52
+ alt: recipe.featured_image?.alt ?? featuredMedia.alt,
53
+ filename: recipe.featured_image?.filename ?? featuredMedia.filename
54
+ };
55
+ }
56
+ return data;
57
+ }
58
+ function previewLines(recipe) {
59
+ const lines = [
60
+ `Title: ${recipe.title}`,
61
+ `Suggested slug: ${recipe.suggested_slug}`,
62
+ `Ingredients: ${recipe.ingredients.length}`,
63
+ `Steps: ${recipe.instructions.length}`,
64
+ `Source: ${recipe.source.instagram_url}`
65
+ ];
66
+ if (recipe.featured_image) {
67
+ lines.push("Featured image: yes");
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ // src/admin-blocks.ts
73
+ function importFormBlocks(initialUrl = "") {
74
+ return [
75
+ {
76
+ type: "header",
77
+ text: "Import from Instagram"
78
+ },
79
+ {
80
+ type: "context",
81
+ text: "Paste a public Instagram post URL. The caption is parsed into recipe ingredients and steps, and the post image becomes the featured image."
82
+ },
83
+ {
84
+ type: "form",
85
+ block_id: "import-form",
86
+ fields: [
87
+ {
88
+ type: "text_input",
89
+ action_id: "instagram_url",
90
+ label: "Instagram post URL",
91
+ placeholder: "https://www.instagram.com/p/SHORTCODE/",
92
+ initial_value: initialUrl
93
+ }
94
+ ],
95
+ submit: { label: "Convert to recipe", action_id: "convert" }
96
+ }
97
+ ];
98
+ }
99
+ function previewBlocks(recipe, options) {
100
+ const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
101
+ const instructionPreview = recipe.instructions.slice(0, 4).map((step, i) => `${i + 1}. ${step}`).join("\n");
102
+ const blocks = [
103
+ ...importFormBlocks(recipe.source.instagram_url),
104
+ { type: "divider" },
105
+ {
106
+ type: "banner",
107
+ title: "Recipe parsed",
108
+ description: previewLines(recipe).join(" \xB7 "),
109
+ variant: "default"
110
+ },
111
+ {
112
+ type: "fields",
113
+ fields: [
114
+ { label: "Title", value: recipe.title },
115
+ { label: "Suggested slug", value: recipe.suggested_slug }
116
+ ]
117
+ },
118
+ {
119
+ type: "section",
120
+ text: `Excerpt: ${recipe.excerpt}`
121
+ },
122
+ {
123
+ type: "section",
124
+ text: `Ingredients (${recipe.ingredients.length}): ${ingredientPreview || "None parsed"}${recipe.ingredients.length > 8 ? " \u2026" : ""}`
125
+ },
126
+ {
127
+ type: "section",
128
+ text: `Instructions (${recipe.instructions.length}): ${instructionPreview || "None parsed"}${recipe.instructions.length > 4 ? " \u2026" : ""}`
129
+ }
130
+ ];
131
+ if (recipe.featured_image?.url) {
132
+ blocks.push({
133
+ type: "image",
134
+ url: recipe.featured_image.url,
135
+ alt: recipe.featured_image.alt,
136
+ title: "Featured image preview"
137
+ });
138
+ }
139
+ if (recipe.image_notice) {
140
+ blocks.push({
141
+ type: "banner",
142
+ title: "No featured image",
143
+ description: recipe.image_notice,
144
+ variant: "alert"
145
+ });
146
+ }
147
+ if (recipe.featured_image?.upload_failed) {
148
+ blocks.push({
149
+ type: "banner",
150
+ title: "Featured image not saved to Media",
151
+ description: "The preview loads from Instagram in your browser, but the server could not download it for the media library. You can upload an image manually on the recipe after creating the draft.",
152
+ variant: "alert"
153
+ });
154
+ } else if (recipe.featured_image?.emdash_media) {
155
+ blocks.push({
156
+ type: "banner",
157
+ title: "Featured image ready",
158
+ description: "The post image is already in your media library and will be attached when you create the draft.",
159
+ variant: "default"
160
+ });
161
+ }
162
+ if (options?.existingContentId) {
163
+ blocks.push({
164
+ type: "banner",
165
+ title: "Already imported",
166
+ description: `This post was previously imported (content id: ${options.existingContentId}). Creating again will add another draft.`,
167
+ variant: "alert"
168
+ });
169
+ }
170
+ blocks.push({
171
+ type: "actions",
172
+ block_id: "preview-actions",
173
+ elements: [
174
+ {
175
+ type: "button",
176
+ action_id: "create_draft",
177
+ label: "Create draft post",
178
+ style: "primary",
179
+ value: recipe.source.shortcode
180
+ }
181
+ ]
182
+ });
183
+ return blocks;
184
+ }
185
+ function errorBlocks(message, initialUrl = "") {
186
+ return [
187
+ ...importFormBlocks(initialUrl),
188
+ {
189
+ type: "banner",
190
+ title: "Could not import post",
191
+ description: message,
192
+ variant: "error"
193
+ }
194
+ ];
195
+ }
196
+
197
+ // src/instagram-recipes.ts
198
+ function titleCase(s) {
199
+ return s.split(/\s+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
200
+ }
201
+ function slugifyRecipeTitle(title) {
202
+ const s = title.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
203
+ return s.slice(0, 80) || "instagram-recipe";
204
+ }
205
+ function stripHashtagsAndMentions(text) {
206
+ return text.replace(/@[\w.]+/g, "").replace(/#\w+/g, "").replace(/\s+/g, " ").trim();
207
+ }
208
+ function isLikelyNonRecipeCaption(caption) {
209
+ const lower = caption.toLowerCase();
210
+ return /grocery\s+(shopping|haul)/.test(lower) || /#wieiad\b/.test(lower) || /\bwieiad\b/.test(lower) || /#cookingfail\b/.test(lower) || /#tastetest\b/.test(lower) || /#foodfail\b/.test(lower) || /espresso at home/.test(lower) || /#espresso\b/.test(lower) || /#coffeetiktok\b/.test(lower);
211
+ }
212
+ function isLikelyRecipeCaption(caption) {
213
+ if (!caption.trim() || isLikelyNonRecipeCaption(caption)) return false;
214
+ if (/what you['']ll need|ingredients\s*:/i.test(caption)) return true;
215
+ const hasSteps = /(?:^|\n)\s*\d+\.\s+\S/.test(caption);
216
+ const hasFoodMeasures = /(\d+\/\d+|\d+)\s*(cup|cups|tbsp|tsp|lb|lbs|oz|clove|cloves|pkg|package)/i.test(
217
+ caption
218
+ ) || /\b(tablespoon|teaspoon|pound|ounce|minute|minutes|simmer|bake|roast|air fryer|broil)\b/i.test(
219
+ caption
220
+ );
221
+ return hasSteps && hasFoodMeasures;
222
+ }
223
+ function extractTitleFromCaption(caption) {
224
+ const ingredientsSplit = caption.split(
225
+ /what you['']ll need:?|ingredients\s*:/i
226
+ )[0];
227
+ const intro = stripHashtagsAndMentions(ingredientsSplit ?? caption);
228
+ const firstSentence = intro.split(/[.!?]/)[0]?.trim() ?? intro;
229
+ const withoutThe = firstSentence.replace(/^the\s+/i, "");
230
+ const beforeVerb = withoutThe.split(/\s+(?:is|are|have|has|was|will|makes)\s+/i)[0]?.trim() ?? withoutThe;
231
+ if (beforeVerb.length >= 8 && beforeVerb.length <= 72) {
232
+ return titleCase(beforeVerb);
233
+ }
234
+ if (firstSentence.length <= 72) {
235
+ return titleCase(firstSentence);
236
+ }
237
+ return titleCase(firstSentence.slice(0, 68).trim());
238
+ }
239
+ function parseIngredientsFromCaption(caption) {
240
+ const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
241
+ if (!match || match.index == null) return [];
242
+ const afterHeader = caption.slice(match.index + match[0].length);
243
+ const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
244
+ const ingredientsBlock = stepsMatch?.index ? afterHeader.slice(0, stepsMatch.index) : afterHeader;
245
+ const lines = ingredientsBlock.split(/\n/);
246
+ const ingredients = [];
247
+ for (const raw of lines) {
248
+ const line = raw.trim();
249
+ if (!line) continue;
250
+ if (/^\[[^\]]+\]$/.test(line)) continue;
251
+ if (/^recipe below/i.test(line)) continue;
252
+ ingredients.push(line);
253
+ }
254
+ return ingredients;
255
+ }
256
+ function parseInstructionsFromCaption(caption) {
257
+ const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
258
+ return matches.map((m) => m[2]?.trim()).filter((s) => Boolean(s && s.length > 3));
259
+ }
260
+ function parseRecipeCaption(caption) {
261
+ if (!isLikelyRecipeCaption(caption)) return null;
262
+ const ingredients = parseIngredientsFromCaption(caption);
263
+ const instructions = parseInstructionsFromCaption(caption);
264
+ const title = extractTitleFromCaption(caption);
265
+ if (ingredients.length === 0 && instructions.length === 0) return null;
266
+ const intro = caption.split(/what you['']ll need:?|ingredients\s*:/i)[0]?.trim();
267
+ const excerptSource = stripHashtagsAndMentions(intro ?? caption).split(/[.!?]/)[0]?.trim();
268
+ const excerpt = excerptSource && excerptSource.length > 20 ? excerptSource.slice(0, 220) : `${title} \u2014 from Instagram.`;
269
+ return { title, excerpt, ingredients, instructions };
270
+ }
271
+ function imageFilenameForSlug(slug) {
272
+ return `${slug}.jpg`;
273
+ }
274
+ function buildParsedRecipeFromPost(post) {
275
+ const parsed = parseRecipeCaption(post.caption);
276
+ if (!parsed) return null;
277
+ const slug = slugifyRecipeTitle(parsed.title);
278
+ const alt = post.accessibilityCaption?.trim() || `${parsed.title} \u2014 photo from Instagram`;
279
+ const recipe = {
280
+ source: {
281
+ instagram_url: post.postUrl,
282
+ shortcode: post.shortcode,
283
+ likes: post.likes,
284
+ is_video: post.isVideo
285
+ },
286
+ suggested_slug: slug,
287
+ title: parsed.title,
288
+ excerpt: parsed.excerpt,
289
+ caption_raw: post.caption,
290
+ ingredients: parsed.ingredients,
291
+ instructions: parsed.instructions
292
+ };
293
+ if (post.displayUrl) {
294
+ recipe.featured_image = {
295
+ url: post.displayUrl,
296
+ alt,
297
+ filename: imageFilenameForSlug(slug)
298
+ };
299
+ }
300
+ return recipe;
301
+ }
302
+ function buildParsedRecipeFromPostLenient(post) {
303
+ const strict = buildParsedRecipeFromPost(post);
304
+ if (strict) return strict;
305
+ const ingredients = parseIngredientsFromCaption(post.caption);
306
+ const instructions = parseInstructionsFromCaption(post.caption);
307
+ if (ingredients.length === 0 && instructions.length === 0) return null;
308
+ const title = extractTitleFromCaption(post.caption);
309
+ const slug = slugifyRecipeTitle(title);
310
+ const alt = post.accessibilityCaption?.trim() || `${title} \u2014 photo from Instagram`;
311
+ const recipe = {
312
+ source: {
313
+ instagram_url: post.postUrl,
314
+ shortcode: post.shortcode,
315
+ likes: post.likes,
316
+ is_video: post.isVideo
317
+ },
318
+ suggested_slug: slug,
319
+ title,
320
+ excerpt: `${title} \u2014 imported from Instagram.`,
321
+ caption_raw: post.caption,
322
+ ingredients,
323
+ instructions
324
+ };
325
+ if (post.displayUrl) {
326
+ recipe.featured_image = {
327
+ url: post.displayUrl,
328
+ alt,
329
+ filename: imageFilenameForSlug(slug)
330
+ };
331
+ }
332
+ return recipe;
333
+ }
334
+
335
+ // src/instagram-profile-image.ts
336
+ var IG_APP_ID = "936619743392459";
337
+ 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";
338
+ function normalizeInstagramHandle(raw) {
339
+ let s = raw.trim();
340
+ const urlMatch = /instagram\.com\/([A-Za-z0-9._]+)/i.exec(s);
341
+ if (urlMatch) s = urlMatch[1];
342
+ if (s.startsWith("@")) s = s.slice(1);
343
+ s = s.replace(/\/+$/, "").trim();
344
+ if (!/^[A-Za-z0-9._]{1,30}$/.test(s)) {
345
+ throw new Error("Invalid Instagram handle.");
346
+ }
347
+ return s.toLowerCase();
348
+ }
349
+ function extractOwnerUsername(html) {
350
+ const ownerMatch = /"owner":\{[^}]*"username":"([A-Za-z0-9._]+)"/.exec(html);
351
+ if (ownerMatch?.[1]) return ownerMatch[1].toLowerCase();
352
+ const userMatch = /"user":\{[^}]*"username":"([A-Za-z0-9._]+)"/.exec(html);
353
+ if (userMatch?.[1]) return userMatch[1].toLowerCase();
354
+ return void 0;
355
+ }
356
+ function displayUrlFromProfileUser(user, shortcode) {
357
+ const edges = user?.edge_owner_to_timeline_media?.edges ?? [];
358
+ for (const edge of edges) {
359
+ const node = edge.node;
360
+ if (node?.shortcode === shortcode && node.display_url) {
361
+ return node.display_url;
362
+ }
363
+ }
364
+ return void 0;
365
+ }
366
+ function profileApiHeaders(handle) {
367
+ const profileUrl = `https://www.instagram.com/${handle}/`;
368
+ return {
369
+ Accept: "*/*",
370
+ "Accept-Language": "en-US,en;q=0.9",
371
+ "User-Agent": BROWSER_UA,
372
+ "X-IG-App-ID": IG_APP_ID,
373
+ "X-Requested-With": "XMLHttpRequest",
374
+ Referer: profileUrl,
375
+ Origin: "https://www.instagram.com"
376
+ };
377
+ }
378
+ var PROFILE_FETCH_RETRIES = 2;
379
+ var PROFILE_RETRY_DELAY_MS = 2500;
380
+ function sleep(ms) {
381
+ return new Promise((resolve) => setTimeout(resolve, ms));
382
+ }
383
+ async function fetchProfileUserOnce(fetchFn, handle) {
384
+ const username = normalizeInstagramHandle(handle);
385
+ const apiUrl = `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(username)}`;
386
+ const res = await fetchFn(apiUrl, {
387
+ method: "GET",
388
+ redirect: "follow",
389
+ headers: profileApiHeaders(username)
390
+ });
391
+ const httpStatus = res.status;
392
+ if (!res.ok) {
393
+ return {
394
+ ok: false,
395
+ httpStatus,
396
+ retryable: httpStatus === 429 || httpStatus === 503
397
+ };
398
+ }
399
+ let payload;
400
+ try {
401
+ payload = await res.json();
402
+ } catch {
403
+ return { ok: false, httpStatus };
404
+ }
405
+ const user = payload?.data?.user;
406
+ if (!user) {
407
+ return { ok: false, httpStatus };
408
+ }
409
+ return { ok: true, user, httpStatus };
410
+ }
411
+ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
412
+ const username = normalizeInstagramHandle(handle);
413
+ let lastHttpStatus;
414
+ let rateLimited = false;
415
+ for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
416
+ if (attempt > 0) {
417
+ await sleep(PROFILE_RETRY_DELAY_MS * attempt);
418
+ }
419
+ const result = await fetchProfileUserOnce(fetchFn, username);
420
+ if (result.ok) {
421
+ const url = displayUrlFromProfileUser(result.user, shortcode);
422
+ if (url) {
423
+ return { url, httpStatus: result.httpStatus };
424
+ }
425
+ return { httpStatus: result.httpStatus, shortcodeMissing: true };
426
+ }
427
+ lastHttpStatus = result.httpStatus;
428
+ if (result.httpStatus === 429 || result.httpStatus === 503) {
429
+ rateLimited = true;
430
+ }
431
+ if (!result.retryable) break;
432
+ }
433
+ if (typeof process !== "undefined" && process.versions?.node) {
434
+ try {
435
+ const { shouldTryInstagramCurlFallback, curlAvailable, fetchInstagramProfileViaCurl } = await import("./instagram-curl-profile-H4ND6RCQ.js");
436
+ if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
437
+ const curlResult = await fetchInstagramProfileViaCurl(username);
438
+ if (curlResult.ok) {
439
+ const url = displayUrlFromProfileUser(curlResult.user, shortcode);
440
+ if (url) {
441
+ return { url, httpStatus: curlResult.httpStatus };
442
+ }
443
+ return {
444
+ httpStatus: curlResult.httpStatus,
445
+ shortcodeMissing: true
446
+ };
447
+ }
448
+ }
449
+ } catch {
450
+ }
451
+ }
452
+ return {
453
+ httpStatus: lastHttpStatus,
454
+ rateLimited,
455
+ shortcodeMissing: false
456
+ };
457
+ }
458
+
459
+ // src/parse-instagram-post.ts
460
+ var INSTAGRAM_POST_RE = /(?:https?:\/\/)?(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/i;
461
+ var BROWSER_UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
462
+ var HTML_ENTITIES = {
463
+ "&quot;": '"',
464
+ "&#39;": "'",
465
+ "&#x27;": "'",
466
+ "&#x2019;": "'",
467
+ "&#x2018;": "'",
468
+ "&amp;": "&",
469
+ "&lt;": "<",
470
+ "&gt;": ">",
471
+ "&#10;": "\n",
472
+ "&#13;": "\r"
473
+ };
474
+ function parseInstagramPostUrl(raw) {
475
+ const trimmed = raw.trim();
476
+ const match = INSTAGRAM_POST_RE.exec(trimmed);
477
+ if (!match?.[1]) {
478
+ throw new Error(
479
+ "Paste a valid Instagram post URL (e.g. https://www.instagram.com/p/SHORTCODE/)."
480
+ );
481
+ }
482
+ const shortcode = match[1];
483
+ return {
484
+ shortcode,
485
+ postUrl: `https://www.instagram.com/p/${shortcode}/`
486
+ };
487
+ }
488
+ function decodeHtmlEntities(text) {
489
+ let out = text;
490
+ for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
491
+ out = out.split(entity).join(char);
492
+ }
493
+ out = out.replace(
494
+ /&#(\d+);/g,
495
+ (_, code) => String.fromCharCode(Number(code))
496
+ );
497
+ out = out.replace(
498
+ /&#x([0-9a-f]+);/gi,
499
+ (_, hex) => String.fromCharCode(parseInt(hex, 16))
500
+ );
501
+ return out;
502
+ }
503
+ function extractMetaContent(html, property) {
504
+ const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
505
+ const patterns = [
506
+ new RegExp(`property="${escaped}"\\s+content="([^"]*)"`, "i"),
507
+ new RegExp(`content="([^"]*)"\\s+property="${escaped}"`, "i")
508
+ ];
509
+ for (const re of patterns) {
510
+ const match = re.exec(html);
511
+ if (match?.[1]) return match[1];
512
+ }
513
+ return void 0;
514
+ }
515
+ async function primeInstagramSession(fetchFn) {
516
+ await fetchFn("https://www.instagram.com/", {
517
+ method: "GET",
518
+ redirect: "follow",
519
+ headers: instagramFetchHeaders()
520
+ });
521
+ }
522
+ function instagramFetchHeaders(referer) {
523
+ return {
524
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
525
+ "Accept-Language": "en-US,en;q=0.9",
526
+ "Cache-Control": "no-cache",
527
+ "Sec-Fetch-Dest": "document",
528
+ "Sec-Fetch-Mode": "navigate",
529
+ "Sec-Fetch-Site": referer ? "same-origin" : "none",
530
+ "Upgrade-Insecure-Requests": "1",
531
+ "User-Agent": BROWSER_UA2,
532
+ ...referer ? { Referer: referer } : {}
533
+ };
534
+ }
535
+ function instagramImageFetchHeaders(referer) {
536
+ return {
537
+ Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
538
+ "Accept-Language": "en-US,en;q=0.9",
539
+ "Cache-Control": "no-cache",
540
+ Referer: referer,
541
+ "Sec-Fetch-Dest": "image",
542
+ "Sec-Fetch-Mode": "no-cors",
543
+ "Sec-Fetch-Site": "cross-site",
544
+ "User-Agent": BROWSER_UA2
545
+ };
546
+ }
547
+ function cookieHeaderFromResponse(res) {
548
+ const headers = res.headers;
549
+ const getSetCookie = headers.getSetCookie?.bind(headers);
550
+ const cookies = getSetCookie?.();
551
+ if (cookies?.length) {
552
+ return cookies.map((c) => c.split(";")[0]).join("; ");
553
+ }
554
+ const single = headers.get("set-cookie");
555
+ return single ? single.split(";")[0] : void 0;
556
+ }
557
+ async function downloadPostImage(fetchFn, imageUrl, postUrl, cookieHeader) {
558
+ const headers = {
559
+ ...instagramImageFetchHeaders(postUrl)
560
+ };
561
+ if (cookieHeader) {
562
+ headers.Cookie = cookieHeader;
563
+ }
564
+ const res = await fetchFn(imageUrl, { method: "GET", headers });
565
+ if (!res.ok) {
566
+ return void 0;
567
+ }
568
+ const contentType = res.headers.get("content-type") ?? "image/jpeg";
569
+ if (!contentType.startsWith("image/")) {
570
+ return void 0;
571
+ }
572
+ return { bytes: await res.arrayBuffer(), contentType };
573
+ }
574
+ function extractCaptionFromOgDescription(description) {
575
+ const decoded = decodeHtmlEntities(description).trim();
576
+ const quoted = decoded.match(/:\s*["“]([\s\S]+?)["”]\.\s*$/);
577
+ if (quoted?.[1]) return quoted[1].trim();
578
+ const inline = decoded.match(/:\s*["“]([\s\S]+?)["”]/);
579
+ if (inline?.[1]) return inline[1].trim();
580
+ return decoded;
581
+ }
582
+ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
583
+ const { shortcode } = parseInstagramPostUrl(postUrl);
584
+ const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
585
+ await primeInstagramSession(fetchFn);
586
+ const res = await fetchFn(canonicalUrl, {
587
+ method: "GET",
588
+ redirect: "follow",
589
+ headers: instagramFetchHeaders("https://www.instagram.com/")
590
+ });
591
+ if (!res.ok) {
592
+ throw new Error(
593
+ `Instagram returned HTTP ${res.status}. Try again in a few minutes if rate-limited.`
594
+ );
595
+ }
596
+ const html = await res.text();
597
+ const ogDescription = extractMetaContent(html, "og:description");
598
+ if (!ogDescription) {
599
+ throw new Error(
600
+ "Could not read caption from the Instagram page. The post may be private or unavailable."
601
+ );
602
+ }
603
+ const caption = extractCaptionFromOgDescription(ogDescription);
604
+ const ogVideo = extractMetaContent(html, "og:video");
605
+ const isVideo = Boolean(ogVideo) || /"is_video":true/i.test(html) || /"__typename":"GraphVideo"/i.test(html) || /"media_type":2[,}]/i.test(html);
606
+ 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(
612
+ fetchFn,
613
+ ownerHandle,
614
+ shortcode
615
+ );
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
+ }
625
+ let imageBytes;
626
+ let imageContentType;
627
+ if (displayUrl) {
628
+ const downloaded = await downloadPostImage(
629
+ fetchFn,
630
+ displayUrl,
631
+ canonicalUrl,
632
+ cookieHeader
633
+ );
634
+ imageBytes = downloaded?.bytes;
635
+ imageContentType = downloaded?.contentType;
636
+ }
637
+ return {
638
+ shortcode,
639
+ postUrl: canonicalUrl,
640
+ caption,
641
+ displayUrl,
642
+ isVideo,
643
+ imageNotice,
644
+ imageBytes,
645
+ imageContentType
646
+ };
647
+ }
648
+ var instagramBrowserUserAgent = BROWSER_UA2;
649
+
650
+ // src/sandbox-entry.ts
651
+ var DEFAULT_COLLECTION = "posts";
652
+ var PARSE_CACHE_PREFIX = "parse:";
653
+ var IMPORT_MAP_PREFIX = "imported:";
654
+ async function collectionSlug(ctx) {
655
+ return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
656
+ }
657
+ function parseCacheKey(shortcode) {
658
+ return `${PARSE_CACHE_PREFIX}${shortcode}`;
659
+ }
660
+ function importMapKey(shortcode) {
661
+ return `${IMPORT_MAP_PREFIX}${shortcode}`;
662
+ }
663
+ async function httpFetch(ctx, url, init) {
664
+ if (!ctx.http) {
665
+ throw new Error("Network access is not available for this plugin.");
666
+ }
667
+ return ctx.http.fetch(url, init);
668
+ }
669
+ async function convertInstagramUrl(ctx, instagramUrl) {
670
+ const fetchFn = (input, init) => httpFetch(ctx, input, init);
671
+ const instagramHandle = await ctx.kv.get("config:instagram_handle");
672
+ const post = await fetchInstagramPost(instagramUrl, fetchFn, {
673
+ instagramHandle: instagramHandle ?? void 0
674
+ });
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({
683
+ shortcode: post.shortcode,
684
+ caption: post.caption,
685
+ displayUrl: post.displayUrl,
686
+ likes: 0,
687
+ isVideo: post.isVideo,
688
+ postUrl: post.postUrl
689
+ });
690
+ if (!recipe) {
691
+ throw new Error(
692
+ "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."
693
+ );
694
+ }
695
+ if (post.imageNotice) {
696
+ recipe.image_notice = post.imageNotice;
697
+ }
698
+ if (recipe.featured_image) {
699
+ const uploaded = await uploadFeaturedImageForRecipe(
700
+ ctx,
701
+ recipe,
702
+ post.imageBytes,
703
+ post.imageContentType
704
+ );
705
+ if (uploaded) {
706
+ recipe.featured_image.emdash_media = uploaded;
707
+ } else if (recipe.featured_image.url) {
708
+ recipe.featured_image.upload_failed = true;
709
+ }
710
+ }
711
+ await ctx.kv.set(parseCacheKey(post.shortcode), recipe);
712
+ const existingContentId = await ctx.kv.get(importMapKey(post.shortcode)) ?? void 0;
713
+ return { recipe, existingContentId };
714
+ }
715
+ async function uploadFeaturedImageBytes(ctx, recipe, bytes, contentType) {
716
+ if (!ctx.media?.upload || !recipe.featured_image) {
717
+ return void 0;
718
+ }
719
+ const filename = recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
720
+ const uploaded = await ctx.media.upload(filename, contentType, bytes);
721
+ return {
722
+ provider: "local",
723
+ id: uploaded.mediaId,
724
+ src: uploaded.url,
725
+ mimeType: contentType,
726
+ meta: { storageKey: uploaded.storageKey }
727
+ };
728
+ }
729
+ async function uploadFeaturedImageForRecipe(ctx, recipe, imageBytes, imageContentType) {
730
+ if (!recipe.featured_image || !ctx.media?.upload) {
731
+ return void 0;
732
+ }
733
+ if (imageBytes && imageContentType) {
734
+ return uploadFeaturedImageBytes(
735
+ ctx,
736
+ recipe,
737
+ imageBytes,
738
+ imageContentType
739
+ );
740
+ }
741
+ if (!recipe.featured_image.url) {
742
+ return void 0;
743
+ }
744
+ const res = await httpFetch(ctx, recipe.featured_image.url, {
745
+ headers: {
746
+ "User-Agent": instagramBrowserUserAgent,
747
+ Referer: recipe.source.instagram_url,
748
+ Accept: "image/*,*/*;q=0.8"
749
+ }
750
+ });
751
+ if (!res.ok) {
752
+ ctx.log.warn(`Featured image download failed: HTTP ${res.status}`);
753
+ return void 0;
754
+ }
755
+ const contentType = res.headers.get("content-type") ?? "image/jpeg";
756
+ if (!contentType.startsWith("image/")) {
757
+ ctx.log.warn(
758
+ `Featured image download returned non-image content-type: ${contentType}`
759
+ );
760
+ return void 0;
761
+ }
762
+ return uploadFeaturedImageBytes(
763
+ ctx,
764
+ recipe,
765
+ await res.arrayBuffer(),
766
+ contentType
767
+ );
768
+ }
769
+ async function createDraftFromRecipe(ctx, shortcode) {
770
+ if (!ctx.content?.create) {
771
+ throw new Error("This plugin does not have permission to create content.");
772
+ }
773
+ const recipe = await ctx.kv.get(parseCacheKey(shortcode));
774
+ if (!recipe) {
775
+ throw new Error(
776
+ "Parsed recipe expired. Convert the Instagram URL again before creating a draft."
777
+ );
778
+ }
779
+ const collection = await collectionSlug(ctx);
780
+ let featuredImage = recipe.featured_image?.emdash_media;
781
+ if (!featuredImage && recipe.featured_image?.url) {
782
+ featuredImage = await uploadFeaturedImageForRecipe(ctx, recipe);
783
+ }
784
+ const data = recipeToPostWriteData(recipe, featuredImage);
785
+ const item = await ctx.content.create(collection, data);
786
+ await ctx.kv.set(importMapKey(shortcode), item.id);
787
+ return {
788
+ contentId: item.id,
789
+ title: recipe.title,
790
+ collection
791
+ };
792
+ }
793
+ var sandbox_entry_default = {
794
+ hooks: {
795
+ "plugin:install": {
796
+ handler: async (_event, ctx) => {
797
+ ctx.log.info("Instagram to Recipe plugin installed");
798
+ const existing = await ctx.kv.get("config:collection");
799
+ if (!existing) {
800
+ await ctx.kv.set("config:collection", DEFAULT_COLLECTION);
801
+ }
802
+ }
803
+ }
804
+ },
805
+ routes: {
806
+ parse: {
807
+ input: z.object({
808
+ url: z.string().min(1)
809
+ }),
810
+ handler: async (routeCtx, ctx) => {
811
+ const { recipe, existingContentId } = await convertInstagramUrl(
812
+ ctx,
813
+ routeCtx.input.url
814
+ );
815
+ return { ok: true, recipe, existingContentId };
816
+ }
817
+ },
818
+ "create-draft": {
819
+ input: z.object({
820
+ shortcode: z.string().min(1)
821
+ }),
822
+ handler: async (routeCtx, ctx) => {
823
+ const result = await createDraftFromRecipe(ctx, routeCtx.input.shortcode);
824
+ return { ok: true, ...result };
825
+ }
826
+ },
827
+ admin: {
828
+ handler: async (routeCtx, ctx) => {
829
+ const interaction = routeCtx.input ?? { type: "page_load" };
830
+ if (interaction.type === "page_load") {
831
+ return { blocks: importFormBlocks() };
832
+ }
833
+ const instagramUrl = String(
834
+ interaction.values?.instagram_url ?? ""
835
+ ).trim();
836
+ if (interaction.type === "form_submit" && interaction.action_id === "convert") {
837
+ try {
838
+ if (!instagramUrl) {
839
+ return {
840
+ blocks: errorBlocks("Paste an Instagram post URL."),
841
+ toast: { message: "URL required", type: "error" }
842
+ };
843
+ }
844
+ parseInstagramPostUrl(instagramUrl);
845
+ const { recipe, existingContentId } = await convertInstagramUrl(
846
+ ctx,
847
+ instagramUrl
848
+ );
849
+ return {
850
+ blocks: previewBlocks(recipe, { existingContentId }),
851
+ toast: { message: "Recipe parsed", type: "success" }
852
+ };
853
+ } catch (error) {
854
+ const message = error instanceof Error ? error.message : String(error);
855
+ return {
856
+ blocks: errorBlocks(message, instagramUrl),
857
+ toast: { message, type: "error" }
858
+ };
859
+ }
860
+ }
861
+ if ((interaction.type === "block_action" || interaction.type === "button_click") && interaction.action_id === "create_draft") {
862
+ const shortcode = String(interaction.value ?? "").trim();
863
+ if (!shortcode) {
864
+ return {
865
+ blocks: errorBlocks(
866
+ "Missing post reference. Convert the URL again.",
867
+ instagramUrl
868
+ ),
869
+ toast: { message: "Try converting again", type: "error" }
870
+ };
871
+ }
872
+ try {
873
+ const { contentId, title, collection } = await createDraftFromRecipe(ctx, shortcode);
874
+ const recipe = await ctx.kv.get(parseCacheKey(shortcode));
875
+ return {
876
+ blocks: recipe ? [
877
+ ...previewBlocks(recipe, { existingContentId: contentId }),
878
+ {
879
+ type: "banner",
880
+ title: "Draft created",
881
+ description: `\u201C${title}\u201D was saved to ${collection}. Open Posts in the admin to review and publish.`,
882
+ variant: "default"
883
+ }
884
+ ] : importFormBlocks(),
885
+ toast: {
886
+ message: `Draft created: ${title}`,
887
+ type: "success"
888
+ }
889
+ };
890
+ } catch (error) {
891
+ const message = error instanceof Error ? error.message : String(error);
892
+ return {
893
+ blocks: errorBlocks(message, instagramUrl),
894
+ toast: { message, type: "error" }
895
+ };
896
+ }
897
+ }
898
+ return { blocks: importFormBlocks(instagramUrl) };
899
+ }
900
+ }
901
+ }
902
+ };
903
+ export {
904
+ sandbox_entry_default as default
905
+ };