@peristyle/emdash-plugin-instagram-to-recipe 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/sandbox-entry.d.ts +46 -1
- package/dist/sandbox-entry.js +512 -51
- package/package.json +10 -7
- package/src/admin-blocks.ts +157 -0
- package/src/index.ts +4 -1
- package/src/instagram-profile-image.ts +274 -27
- package/src/instagram-recipes.ts +34 -0
- package/src/parse-instagram-post.ts +96 -22
- package/src/sandbox-entry.ts +285 -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;
|
|
@@ -353,15 +487,31 @@ function extractOwnerUsername(html) {
|
|
|
353
487
|
if (userMatch?.[1]) return userMatch[1].toLowerCase();
|
|
354
488
|
return void 0;
|
|
355
489
|
}
|
|
356
|
-
|
|
490
|
+
var PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
491
|
+
function isProfileCacheFresh(entry) {
|
|
492
|
+
const age = Date.now() - Date.parse(entry.fetchedAt);
|
|
493
|
+
return Number.isFinite(age) && age >= 0 && age <= PROFILE_CACHE_TTL_MS;
|
|
494
|
+
}
|
|
495
|
+
function postsFromProfileUser(user) {
|
|
357
496
|
const edges = user?.edge_owner_to_timeline_media?.edges ?? [];
|
|
497
|
+
const posts = [];
|
|
358
498
|
for (const edge of edges) {
|
|
359
499
|
const node = edge.node;
|
|
360
|
-
if (node?.shortcode
|
|
361
|
-
|
|
362
|
-
|
|
500
|
+
if (!node?.shortcode) continue;
|
|
501
|
+
const capEdges = node.edge_media_to_caption?.edges ?? [];
|
|
502
|
+
posts.push({
|
|
503
|
+
shortcode: node.shortcode,
|
|
504
|
+
caption: capEdges[0]?.node?.text?.trim() ?? "",
|
|
505
|
+
displayUrl: node.display_url,
|
|
506
|
+
accessibilityCaption: node.accessibility_caption,
|
|
507
|
+
likes: node.edge_liked_by?.count ?? 0,
|
|
508
|
+
isVideo: Boolean(node.is_video)
|
|
509
|
+
});
|
|
363
510
|
}
|
|
364
|
-
return
|
|
511
|
+
return posts;
|
|
512
|
+
}
|
|
513
|
+
function postNodeFromProfileUser(user, shortcode) {
|
|
514
|
+
return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
|
|
365
515
|
}
|
|
366
516
|
function profileApiHeaders(handle) {
|
|
367
517
|
const profileUrl = `https://www.instagram.com/${handle}/`;
|
|
@@ -375,8 +525,11 @@ function profileApiHeaders(handle) {
|
|
|
375
525
|
Origin: "https://www.instagram.com"
|
|
376
526
|
};
|
|
377
527
|
}
|
|
378
|
-
var PROFILE_FETCH_RETRIES =
|
|
379
|
-
var
|
|
528
|
+
var PROFILE_FETCH_RETRIES = 3;
|
|
529
|
+
var PROFILE_RETRY_BASE_DELAY_MS = 2500;
|
|
530
|
+
function retryDelayMs(attempt) {
|
|
531
|
+
return PROFILE_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
532
|
+
}
|
|
380
533
|
function sleep(ms) {
|
|
381
534
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
382
535
|
}
|
|
@@ -408,19 +561,45 @@ async function fetchProfileUserOnce(fetchFn, handle) {
|
|
|
408
561
|
}
|
|
409
562
|
return { ok: true, user, httpStatus };
|
|
410
563
|
}
|
|
411
|
-
async function
|
|
564
|
+
async function cacheGet(cache, handle) {
|
|
565
|
+
try {
|
|
566
|
+
return await cache?.get(handle);
|
|
567
|
+
} catch {
|
|
568
|
+
return void 0;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function cacheSet(cache, handle, user, httpStatus) {
|
|
572
|
+
try {
|
|
573
|
+
await cache?.set(handle, {
|
|
574
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
575
|
+
handle,
|
|
576
|
+
httpStatus,
|
|
577
|
+
user
|
|
578
|
+
});
|
|
579
|
+
} catch {
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function fetchProfilePostNode(fetchFn, handle, shortcode, cache) {
|
|
412
583
|
const username = normalizeInstagramHandle(handle);
|
|
584
|
+
const cached = await cacheGet(cache, username);
|
|
585
|
+
if (cached && isProfileCacheFresh(cached)) {
|
|
586
|
+
const node = postNodeFromProfileUser(cached.user, shortcode);
|
|
587
|
+
if (node) {
|
|
588
|
+
return { node, httpStatus: cached.httpStatus, fromCache: true };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
413
591
|
let lastHttpStatus;
|
|
414
592
|
let rateLimited = false;
|
|
415
593
|
for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
|
|
416
594
|
if (attempt > 0) {
|
|
417
|
-
await sleep(
|
|
595
|
+
await sleep(retryDelayMs(attempt - 1));
|
|
418
596
|
}
|
|
419
597
|
const result = await fetchProfileUserOnce(fetchFn, username);
|
|
420
598
|
if (result.ok) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
599
|
+
await cacheSet(cache, username, result.user, result.httpStatus);
|
|
600
|
+
const node = postNodeFromProfileUser(result.user, shortcode);
|
|
601
|
+
if (node) {
|
|
602
|
+
return { node, httpStatus: result.httpStatus };
|
|
424
603
|
}
|
|
425
604
|
return { httpStatus: result.httpStatus, shortcodeMissing: true };
|
|
426
605
|
}
|
|
@@ -430,15 +609,26 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
|
|
|
430
609
|
}
|
|
431
610
|
if (!result.retryable) break;
|
|
432
611
|
}
|
|
612
|
+
if (cached) {
|
|
613
|
+
const node = postNodeFromProfileUser(cached.user, shortcode);
|
|
614
|
+
if (node) {
|
|
615
|
+
return { node, httpStatus: cached.httpStatus, fromCache: true };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
433
618
|
if (typeof process !== "undefined" && process.versions?.node) {
|
|
434
619
|
try {
|
|
435
|
-
const {
|
|
620
|
+
const {
|
|
621
|
+
shouldTryInstagramCurlFallback,
|
|
622
|
+
curlAvailable,
|
|
623
|
+
fetchInstagramProfileViaCurl
|
|
624
|
+
} = await import("./instagram-curl-profile-H4ND6RCQ.js");
|
|
436
625
|
if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
|
|
437
626
|
const curlResult = await fetchInstagramProfileViaCurl(username);
|
|
438
627
|
if (curlResult.ok) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
628
|
+
await cacheSet(cache, username, curlResult.user, curlResult.httpStatus);
|
|
629
|
+
const node = postNodeFromProfileUser(curlResult.user, shortcode);
|
|
630
|
+
if (node) {
|
|
631
|
+
return { node, httpStatus: curlResult.httpStatus };
|
|
442
632
|
}
|
|
443
633
|
return {
|
|
444
634
|
httpStatus: curlResult.httpStatus,
|
|
@@ -455,6 +645,79 @@ async function fetchProfileDisplayUrl(fetchFn, handle, shortcode) {
|
|
|
455
645
|
shortcodeMissing: false
|
|
456
646
|
};
|
|
457
647
|
}
|
|
648
|
+
async function fetchProfileGrid(fetchFn, handle, cache) {
|
|
649
|
+
const username = normalizeInstagramHandle(handle);
|
|
650
|
+
const cached = await cacheGet(cache, username);
|
|
651
|
+
if (cached && isProfileCacheFresh(cached)) {
|
|
652
|
+
return {
|
|
653
|
+
handle: username,
|
|
654
|
+
posts: postsFromProfileUser(cached.user),
|
|
655
|
+
httpStatus: cached.httpStatus,
|
|
656
|
+
fromCache: true
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
let lastHttpStatus;
|
|
660
|
+
let rateLimited = false;
|
|
661
|
+
for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
|
|
662
|
+
if (attempt > 0) {
|
|
663
|
+
await sleep(retryDelayMs(attempt - 1));
|
|
664
|
+
}
|
|
665
|
+
const result = await fetchProfileUserOnce(fetchFn, username);
|
|
666
|
+
if (result.ok) {
|
|
667
|
+
await cacheSet(cache, username, result.user, result.httpStatus);
|
|
668
|
+
return {
|
|
669
|
+
handle: username,
|
|
670
|
+
posts: postsFromProfileUser(result.user),
|
|
671
|
+
httpStatus: result.httpStatus
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
lastHttpStatus = result.httpStatus;
|
|
675
|
+
if (result.httpStatus === 429 || result.httpStatus === 503) {
|
|
676
|
+
rateLimited = true;
|
|
677
|
+
}
|
|
678
|
+
if (!result.retryable) break;
|
|
679
|
+
}
|
|
680
|
+
if (cached) {
|
|
681
|
+
return {
|
|
682
|
+
handle: username,
|
|
683
|
+
posts: postsFromProfileUser(cached.user),
|
|
684
|
+
httpStatus: cached.httpStatus,
|
|
685
|
+
fromCache: true
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
689
|
+
try {
|
|
690
|
+
const {
|
|
691
|
+
shouldTryInstagramCurlFallback,
|
|
692
|
+
curlAvailable,
|
|
693
|
+
fetchInstagramProfileViaCurl
|
|
694
|
+
} = await import("./instagram-curl-profile-H4ND6RCQ.js");
|
|
695
|
+
if (shouldTryInstagramCurlFallback() && await curlAvailable()) {
|
|
696
|
+
const curlResult = await fetchInstagramProfileViaCurl(username);
|
|
697
|
+
if (curlResult.ok) {
|
|
698
|
+
await cacheSet(
|
|
699
|
+
cache,
|
|
700
|
+
username,
|
|
701
|
+
curlResult.user,
|
|
702
|
+
curlResult.httpStatus
|
|
703
|
+
);
|
|
704
|
+
return {
|
|
705
|
+
handle: username,
|
|
706
|
+
posts: postsFromProfileUser(curlResult.user),
|
|
707
|
+
httpStatus: curlResult.httpStatus
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
handle: username,
|
|
716
|
+
posts: [],
|
|
717
|
+
httpStatus: lastHttpStatus,
|
|
718
|
+
rateLimited
|
|
719
|
+
};
|
|
720
|
+
}
|
|
458
721
|
|
|
459
722
|
// src/parse-instagram-post.ts
|
|
460
723
|
var INSTAGRAM_POST_RE = /(?:https?:\/\/)?(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/i;
|
|
@@ -512,13 +775,6 @@ function extractMetaContent(html, property) {
|
|
|
512
775
|
}
|
|
513
776
|
return void 0;
|
|
514
777
|
}
|
|
515
|
-
async function primeInstagramSession(fetchFn) {
|
|
516
|
-
await fetchFn("https://www.instagram.com/", {
|
|
517
|
-
method: "GET",
|
|
518
|
-
redirect: "follow",
|
|
519
|
-
headers: instagramFetchHeaders()
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
778
|
function instagramFetchHeaders(referer) {
|
|
523
779
|
return {
|
|
524
780
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
@@ -579,10 +835,56 @@ function extractCaptionFromOgDescription(description) {
|
|
|
579
835
|
if (inline?.[1]) return inline[1].trim();
|
|
580
836
|
return decoded;
|
|
581
837
|
}
|
|
838
|
+
function imageNoticeFromProfileResult(result) {
|
|
839
|
+
if (result.node?.displayUrl) return void 0;
|
|
840
|
+
if (result.rateLimited) {
|
|
841
|
+
return "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image \u2014 upload one manually, or try Convert again in a few minutes.";
|
|
842
|
+
}
|
|
843
|
+
if (result.shortcodeMissing) {
|
|
844
|
+
return "This post was not found in the latest profile grid (Instagram only returns ~12 posts). Import without a featured image and upload one manually.";
|
|
845
|
+
}
|
|
846
|
+
return void 0;
|
|
847
|
+
}
|
|
848
|
+
async function postFromProfileNode(fetchFn, node, canonicalUrl) {
|
|
849
|
+
let imageBytes;
|
|
850
|
+
let imageContentType;
|
|
851
|
+
if (node.displayUrl) {
|
|
852
|
+
const downloaded = await downloadPostImage(
|
|
853
|
+
fetchFn,
|
|
854
|
+
node.displayUrl,
|
|
855
|
+
canonicalUrl
|
|
856
|
+
);
|
|
857
|
+
imageBytes = downloaded?.bytes;
|
|
858
|
+
imageContentType = downloaded?.contentType;
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
shortcode: node.shortcode,
|
|
862
|
+
postUrl: canonicalUrl,
|
|
863
|
+
caption: node.caption,
|
|
864
|
+
displayUrl: node.displayUrl,
|
|
865
|
+
accessibilityCaption: node.accessibilityCaption,
|
|
866
|
+
likes: node.likes,
|
|
867
|
+
isVideo: node.isVideo,
|
|
868
|
+
imageBytes,
|
|
869
|
+
imageContentType
|
|
870
|
+
};
|
|
871
|
+
}
|
|
582
872
|
async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
|
|
583
873
|
const { shortcode } = parseInstagramPostUrl(postUrl);
|
|
584
874
|
const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
|
|
585
|
-
|
|
875
|
+
const configuredHandle = options.instagramHandle ? normalizeInstagramHandle(options.instagramHandle) : void 0;
|
|
876
|
+
let profileResult;
|
|
877
|
+
if (configuredHandle) {
|
|
878
|
+
profileResult = await fetchProfilePostNode(
|
|
879
|
+
fetchFn,
|
|
880
|
+
configuredHandle,
|
|
881
|
+
shortcode,
|
|
882
|
+
options.profileCache
|
|
883
|
+
);
|
|
884
|
+
if (profileResult.node?.caption.trim()) {
|
|
885
|
+
return postFromProfileNode(fetchFn, profileResult.node, canonicalUrl);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
586
888
|
const res = await fetchFn(canonicalUrl, {
|
|
587
889
|
method: "GET",
|
|
588
890
|
redirect: "follow",
|
|
@@ -604,24 +906,18 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
|
|
|
604
906
|
const ogVideo = extractMetaContent(html, "og:video");
|
|
605
907
|
const isVideo = Boolean(ogVideo) || /"is_video":true/i.test(html) || /"__typename":"GraphVideo"/i.test(html) || /"media_type":2[,}]/i.test(html);
|
|
606
908
|
const cookieHeader = cookieHeaderFromResponse(res);
|
|
607
|
-
const ownerHandle =
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (ownerHandle) {
|
|
611
|
-
const profile = await fetchProfileDisplayUrl(
|
|
909
|
+
const ownerHandle = extractOwnerUsername(html);
|
|
910
|
+
if (ownerHandle && ownerHandle !== configuredHandle) {
|
|
911
|
+
profileResult = await fetchProfilePostNode(
|
|
612
912
|
fetchFn,
|
|
613
913
|
ownerHandle,
|
|
614
|
-
shortcode
|
|
914
|
+
shortcode,
|
|
915
|
+
options.profileCache
|
|
615
916
|
);
|
|
616
|
-
displayUrl = profile.url;
|
|
617
|
-
if (!displayUrl) {
|
|
618
|
-
if (profile.rateLimited) {
|
|
619
|
-
imageNotice = "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image \u2014 upload one manually, or try Convert again in a few minutes.";
|
|
620
|
-
} else if (profile.shortcodeMissing) {
|
|
621
|
-
imageNotice = "This post was not found in the latest profile grid (Instagram only returns ~12 posts). Import without a featured image and upload one manually.";
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
917
|
}
|
|
918
|
+
const node = profileResult?.node;
|
|
919
|
+
const displayUrl = node?.displayUrl;
|
|
920
|
+
const imageNotice = profileResult ? imageNoticeFromProfileResult(profileResult) : void 0;
|
|
625
921
|
let imageBytes;
|
|
626
922
|
let imageContentType;
|
|
627
923
|
if (displayUrl) {
|
|
@@ -637,8 +933,10 @@ async function fetchInstagramPost(postUrl, fetchFn, options = {}) {
|
|
|
637
933
|
return {
|
|
638
934
|
shortcode,
|
|
639
935
|
postUrl: canonicalUrl,
|
|
640
|
-
caption,
|
|
936
|
+
caption: caption || node?.caption || "",
|
|
641
937
|
displayUrl,
|
|
938
|
+
accessibilityCaption: node?.accessibilityCaption,
|
|
939
|
+
likes: node?.likes ?? 0,
|
|
642
940
|
isVideo,
|
|
643
941
|
imageNotice,
|
|
644
942
|
imageBytes,
|
|
@@ -651,6 +949,11 @@ var instagramBrowserUserAgent = BROWSER_UA2;
|
|
|
651
949
|
var DEFAULT_COLLECTION = "posts";
|
|
652
950
|
var PARSE_CACHE_PREFIX = "parse:";
|
|
653
951
|
var IMPORT_MAP_PREFIX = "imported:";
|
|
952
|
+
var PROFILE_CACHE_PREFIX = "igprofile:";
|
|
953
|
+
var BULK_CREATE_DELAY_MS = 600;
|
|
954
|
+
function sleep2(ms) {
|
|
955
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
956
|
+
}
|
|
654
957
|
async function collectionSlug(ctx) {
|
|
655
958
|
return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
|
|
656
959
|
}
|
|
@@ -666,27 +969,35 @@ async function httpFetch(ctx, url, init) {
|
|
|
666
969
|
}
|
|
667
970
|
return ctx.http.fetch(url, init);
|
|
668
971
|
}
|
|
972
|
+
function profileCacheForCtx(ctx) {
|
|
973
|
+
return {
|
|
974
|
+
async get(handle) {
|
|
975
|
+
return await ctx.kv.get(
|
|
976
|
+
`${PROFILE_CACHE_PREFIX}${handle}`
|
|
977
|
+
) ?? void 0;
|
|
978
|
+
},
|
|
979
|
+
async set(handle, entry) {
|
|
980
|
+
await ctx.kv.set(`${PROFILE_CACHE_PREFIX}${handle}`, entry);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
669
984
|
async function convertInstagramUrl(ctx, instagramUrl) {
|
|
670
985
|
const fetchFn = (input, init) => httpFetch(ctx, input, init);
|
|
671
986
|
const instagramHandle = await ctx.kv.get("config:instagram_handle");
|
|
672
987
|
const post = await fetchInstagramPost(instagramUrl, fetchFn, {
|
|
673
|
-
instagramHandle: instagramHandle ?? void 0
|
|
988
|
+
instagramHandle: instagramHandle ?? void 0,
|
|
989
|
+
profileCache: profileCacheForCtx(ctx)
|
|
674
990
|
});
|
|
675
|
-
const
|
|
991
|
+
const postForRecipe = {
|
|
676
992
|
shortcode: post.shortcode,
|
|
677
993
|
caption: post.caption,
|
|
678
994
|
displayUrl: post.displayUrl,
|
|
679
|
-
|
|
995
|
+
accessibilityCaption: post.accessibilityCaption,
|
|
996
|
+
likes: post.likes,
|
|
680
997
|
isVideo: post.isVideo,
|
|
681
998
|
postUrl: post.postUrl
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
caption: post.caption,
|
|
685
|
-
displayUrl: post.displayUrl,
|
|
686
|
-
likes: 0,
|
|
687
|
-
isVideo: post.isVideo,
|
|
688
|
-
postUrl: post.postUrl
|
|
689
|
-
});
|
|
999
|
+
};
|
|
1000
|
+
const recipe = buildParsedRecipeFromPost(postForRecipe) ?? buildParsedRecipeFromPostLenient(postForRecipe);
|
|
690
1001
|
if (!recipe) {
|
|
691
1002
|
throw new Error(
|
|
692
1003
|
"Caption does not look like a recipe (needs ingredients and numbered steps). Try a post with a \u201CWhat you'll need\u201D section and numbered instructions."
|
|
@@ -790,6 +1101,72 @@ async function createDraftFromRecipe(ctx, shortcode) {
|
|
|
790
1101
|
collection
|
|
791
1102
|
};
|
|
792
1103
|
}
|
|
1104
|
+
function nodeToPostForRecipes(node) {
|
|
1105
|
+
return {
|
|
1106
|
+
shortcode: node.shortcode,
|
|
1107
|
+
caption: node.caption,
|
|
1108
|
+
displayUrl: node.displayUrl,
|
|
1109
|
+
accessibilityCaption: node.accessibilityCaption,
|
|
1110
|
+
likes: node.likes,
|
|
1111
|
+
isVideo: node.isVideo,
|
|
1112
|
+
postUrl: `https://www.instagram.com/p/${node.shortcode}/`
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
async function scanProfileRecipes(ctx, handleInput) {
|
|
1116
|
+
const fallbackHandle = await ctx.kv.get("config:instagram_handle") ?? "";
|
|
1117
|
+
const handle = normalizeInstagramHandle(handleInput || fallbackHandle);
|
|
1118
|
+
const fetchFn = (input, init) => httpFetch(ctx, input, init);
|
|
1119
|
+
const grid = await fetchProfileGrid(fetchFn, handle, profileCacheForCtx(ctx));
|
|
1120
|
+
const posts = [...grid.posts].sort((a, b) => b.likes - a.likes).map(nodeToPostForRecipes);
|
|
1121
|
+
const recipes = buildBulkRecipesFromPosts(posts);
|
|
1122
|
+
const candidates = [];
|
|
1123
|
+
for (const recipe of recipes) {
|
|
1124
|
+
await ctx.kv.set(parseCacheKey(recipe.source.shortcode), recipe);
|
|
1125
|
+
const existing = await ctx.kv.get(
|
|
1126
|
+
importMapKey(recipe.source.shortcode)
|
|
1127
|
+
);
|
|
1128
|
+
candidates.push({ recipe, alreadyImported: Boolean(existing) });
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
handle: grid.handle,
|
|
1132
|
+
candidates,
|
|
1133
|
+
scanned: grid.posts.length,
|
|
1134
|
+
rateLimited: grid.rateLimited,
|
|
1135
|
+
fromCache: grid.fromCache
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
async function createDraftsForShortcodes(ctx, shortcodes, options = {}) {
|
|
1139
|
+
const created = [];
|
|
1140
|
+
const skipped = [];
|
|
1141
|
+
const failed = [];
|
|
1142
|
+
for (const shortcode of shortcodes) {
|
|
1143
|
+
if (!options.force) {
|
|
1144
|
+
const existing = await ctx.kv.get(importMapKey(shortcode));
|
|
1145
|
+
if (existing) {
|
|
1146
|
+
const recipe = await ctx.kv.get(parseCacheKey(shortcode));
|
|
1147
|
+
skipped.push({
|
|
1148
|
+
shortcode,
|
|
1149
|
+
contentId: existing,
|
|
1150
|
+
title: recipe?.title ?? shortcode
|
|
1151
|
+
});
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (created.length + failed.length > 0) {
|
|
1156
|
+
await sleep2(BULK_CREATE_DELAY_MS);
|
|
1157
|
+
}
|
|
1158
|
+
try {
|
|
1159
|
+
const { contentId, title } = await createDraftFromRecipe(ctx, shortcode);
|
|
1160
|
+
created.push({ shortcode, contentId, title });
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
failed.push({
|
|
1163
|
+
shortcode,
|
|
1164
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return { created, skipped, failed };
|
|
1169
|
+
}
|
|
793
1170
|
var sandbox_entry_default = {
|
|
794
1171
|
hooks: {
|
|
795
1172
|
"plugin:install": {
|
|
@@ -824,11 +1201,95 @@ var sandbox_entry_default = {
|
|
|
824
1201
|
return { ok: true, ...result };
|
|
825
1202
|
}
|
|
826
1203
|
},
|
|
1204
|
+
"bulk-scan": {
|
|
1205
|
+
input: z.object({
|
|
1206
|
+
handle: z.string().min(1)
|
|
1207
|
+
}),
|
|
1208
|
+
handler: async (routeCtx, ctx) => {
|
|
1209
|
+
const result = await scanProfileRecipes(ctx, routeCtx.input.handle);
|
|
1210
|
+
return { ok: true, ...result };
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
"bulk-create": {
|
|
1214
|
+
input: z.object({
|
|
1215
|
+
shortcodes: z.array(z.string().min(1)).min(1)
|
|
1216
|
+
}),
|
|
1217
|
+
handler: async (routeCtx, ctx) => {
|
|
1218
|
+
const result = await createDraftsForShortcodes(
|
|
1219
|
+
ctx,
|
|
1220
|
+
routeCtx.input.shortcodes
|
|
1221
|
+
);
|
|
1222
|
+
return { ok: true, ...result };
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
827
1225
|
admin: {
|
|
828
1226
|
handler: async (routeCtx, ctx) => {
|
|
829
1227
|
const interaction = routeCtx.input ?? { type: "page_load" };
|
|
830
1228
|
if (interaction.type === "page_load") {
|
|
831
|
-
return { blocks:
|
|
1229
|
+
return { blocks: pageBlocks() };
|
|
1230
|
+
}
|
|
1231
|
+
const instagramHandle = String(
|
|
1232
|
+
interaction.values?.instagram_handle ?? ""
|
|
1233
|
+
).trim();
|
|
1234
|
+
if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
|
|
1235
|
+
try {
|
|
1236
|
+
const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
|
|
1237
|
+
return {
|
|
1238
|
+
blocks: bulkPreviewBlocks(handle, candidates, {
|
|
1239
|
+
scanned,
|
|
1240
|
+
rateLimited,
|
|
1241
|
+
fromCache
|
|
1242
|
+
}),
|
|
1243
|
+
toast: {
|
|
1244
|
+
message: candidates.length ? `Found ${candidates.length} recipe(s)` : "No recipes found",
|
|
1245
|
+
type: candidates.length ? "success" : "error"
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1250
|
+
return {
|
|
1251
|
+
blocks: [
|
|
1252
|
+
...pageBlocks(),
|
|
1253
|
+
{
|
|
1254
|
+
type: "banner",
|
|
1255
|
+
title: "Could not scan profile",
|
|
1256
|
+
description: message,
|
|
1257
|
+
variant: "error"
|
|
1258
|
+
}
|
|
1259
|
+
],
|
|
1260
|
+
toast: { message, type: "error" }
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (interaction.type === "form_submit" && interaction.action_id === "bulk_create") {
|
|
1265
|
+
const raw = interaction.values?.bulk_select;
|
|
1266
|
+
const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
1267
|
+
if (shortcodes.length === 0) {
|
|
1268
|
+
return {
|
|
1269
|
+
blocks: pageBlocks(),
|
|
1270
|
+
toast: { message: "Select at least one recipe", type: "error" }
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
const result = await createDraftsForShortcodes(ctx, shortcodes);
|
|
1275
|
+
const notes = [
|
|
1276
|
+
result.skipped.length ? `${result.skipped.length} skipped` : "",
|
|
1277
|
+
result.failed.length ? `${result.failed.length} failed` : ""
|
|
1278
|
+
].filter(Boolean);
|
|
1279
|
+
return {
|
|
1280
|
+
blocks: bulkResultBlocks(instagramHandle, result),
|
|
1281
|
+
toast: {
|
|
1282
|
+
message: `Created ${result.created.length} draft(s)${notes.length ? `, ${notes.join(", ")}` : ""}`,
|
|
1283
|
+
type: result.failed.length && !result.created.length ? "error" : "success"
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1288
|
+
return {
|
|
1289
|
+
blocks: pageBlocks(),
|
|
1290
|
+
toast: { message, type: "error" }
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
832
1293
|
}
|
|
833
1294
|
const instagramUrl = String(
|
|
834
1295
|
interaction.values?.instagram_url ?? ""
|