@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.
- package/dist/index.js +1 -1
- package/dist/sandbox-entry.d.ts +48 -1
- package/dist/sandbox-entry.js +580 -51
- package/package.json +6 -2
- package/src/admin-blocks.ts +157 -0
- package/src/image-dimensions.ts +113 -0
- package/src/index.ts +4 -1
- package/src/instagram-profile-image.ts +274 -27
- package/src/instagram-recipes.ts +36 -0
- package/src/parse-instagram-post.ts +96 -22
- package/src/recipe-content.ts +2 -0
- package/src/sandbox-entry.ts +292 -18
package/dist/sandbox-entry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
361
|
-
|
|
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
|
|
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 =
|
|
379
|
-
var
|
|
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
|
|
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(
|
|
661
|
+
await sleep(retryDelayMs(attempt - 1));
|
|
418
662
|
}
|
|
419
663
|
const result = await fetchProfileUserOnce(fetchFn, username);
|
|
420
664
|
if (result.ok) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 {
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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 =
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 ?? ""
|