@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.
@@ -3,7 +3,11 @@ const INSTAGRAM_POST_RE =
3
3
 
4
4
  import {
5
5
  extractOwnerUsername,
6
- fetchProfileDisplayUrl,
6
+ fetchProfilePostNode,
7
+ normalizeInstagramHandle,
8
+ type InstagramProfileCache,
9
+ type ProfilePostNode,
10
+ type ProfilePostNodeResult,
7
11
  } from "./instagram-profile-image.js";
8
12
  import { scoreInstagramImageUrl } from "./instagram-image-url.js";
9
13
 
@@ -186,6 +190,11 @@ function extractMetaContent(html: string, property: string): string | undefined
186
190
  return undefined;
187
191
  }
188
192
 
193
+ /**
194
+ * Warm-up request kept for the diagnostic scripts. Not used in the convert
195
+ * flow: anonymous instagram.com page loads trip the rate limiter, and fetch()
196
+ * discards the cookies anyway.
197
+ */
189
198
  async function primeInstagramSession(
190
199
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
191
200
  ): Promise<void> {
@@ -280,10 +289,12 @@ export type FetchedInstagramPost = {
280
289
  postUrl: string;
281
290
  caption: string;
282
291
  displayUrl?: string;
292
+ accessibilityCaption?: string;
293
+ likes: number;
283
294
  isVideo: boolean;
284
295
  /** Shown in admin when no usable thumbnail could be resolved. */
285
296
  imageNotice?: string;
286
- /** Image bytes downloaded in the same session as the post page (for media upload). */
297
+ /** Image bytes downloaded during convert (for media upload). */
287
298
  imageBytes?: ArrayBuffer;
288
299
  imageContentType?: string;
289
300
  };
@@ -291,8 +302,54 @@ export type FetchedInstagramPost = {
291
302
  export type FetchInstagramPostOptions = {
292
303
  /** Override owner handle for web_profile_info lookup (same as brand-bootstrap handle). */
293
304
  instagramHandle?: string;
305
+ /** Profile payload cache (24h TTL, stale fallback on 429) — back with ctx.kv. */
306
+ profileCache?: InstagramProfileCache;
294
307
  };
295
308
 
309
+ function imageNoticeFromProfileResult(
310
+ result: ProfilePostNodeResult,
311
+ ): string | undefined {
312
+ if (result.node?.displayUrl) return undefined;
313
+ if (result.rateLimited) {
314
+ return "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image — upload one manually, or try Convert again in a few minutes.";
315
+ }
316
+ if (result.shortcodeMissing) {
317
+ 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.";
318
+ }
319
+ return undefined;
320
+ }
321
+
322
+ async function postFromProfileNode(
323
+ fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
324
+ node: ProfilePostNode,
325
+ canonicalUrl: string,
326
+ ): Promise<FetchedInstagramPost> {
327
+ let imageBytes: ArrayBuffer | undefined;
328
+ let imageContentType: string | undefined;
329
+
330
+ if (node.displayUrl) {
331
+ const downloaded = await downloadPostImage(
332
+ fetchFn,
333
+ node.displayUrl,
334
+ canonicalUrl,
335
+ );
336
+ imageBytes = downloaded?.bytes;
337
+ imageContentType = downloaded?.contentType;
338
+ }
339
+
340
+ return {
341
+ shortcode: node.shortcode,
342
+ postUrl: canonicalUrl,
343
+ caption: node.caption,
344
+ displayUrl: node.displayUrl,
345
+ accessibilityCaption: node.accessibilityCaption,
346
+ likes: node.likes,
347
+ isVideo: node.isVideo,
348
+ imageBytes,
349
+ imageContentType,
350
+ };
351
+ }
352
+
296
353
  export async function fetchInstagramPost(
297
354
  postUrl: string,
298
355
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
@@ -301,8 +358,29 @@ export async function fetchInstagramPost(
301
358
  const { shortcode } = parseInstagramPostUrl(postUrl);
302
359
  const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
303
360
 
304
- await primeInstagramSession(fetchFn);
361
+ // Profile-API-first, like init-brand-resources brand-bootstrap: a single
362
+ // (cached) web_profile_info request supplies caption, display_url, and
363
+ // is_video. Anonymous HTML page loads are what trip Instagram's rate
364
+ // limiter, so they are reserved for the fallback below.
365
+ const configuredHandle = options.instagramHandle
366
+ ? normalizeInstagramHandle(options.instagramHandle)
367
+ : undefined;
368
+ let profileResult: ProfilePostNodeResult | undefined;
305
369
 
370
+ if (configuredHandle) {
371
+ profileResult = await fetchProfilePostNode(
372
+ fetchFn,
373
+ configuredHandle,
374
+ shortcode,
375
+ options.profileCache,
376
+ );
377
+ if (profileResult.node?.caption.trim()) {
378
+ return postFromProfileNode(fetchFn, profileResult.node, canonicalUrl);
379
+ }
380
+ }
381
+
382
+ // Fallback: scrape the post page (no handle configured, the post is older
383
+ // than the latest profile grid, or the grid caption was empty).
306
384
  const res = await fetchFn(canonicalUrl, {
307
385
  method: "GET",
308
386
  redirect: "follow",
@@ -332,31 +410,25 @@ export async function fetchInstagramPost(
332
410
  /"media_type":2[,}]/i.test(html);
333
411
 
334
412
  const cookieHeader = cookieHeaderFromResponse(res);
335
- const ownerHandle =
336
- options.instagramHandle ?? extractOwnerUsername(html);
413
+ const ownerHandle = extractOwnerUsername(html);
337
414
 
338
- let displayUrl: string | undefined;
339
- let imageNotice: string | undefined;
340
-
341
- // Same image source as init-brand-resources: web_profile_info → node.display_url
342
- if (ownerHandle) {
343
- const profile = await fetchProfileDisplayUrl(
415
+ // Same image source as init-brand-resources: web_profile_info node.display_url.
416
+ // Skip the lookup when the configured handle already answered for this owner.
417
+ if (ownerHandle && ownerHandle !== configuredHandle) {
418
+ profileResult = await fetchProfilePostNode(
344
419
  fetchFn,
345
420
  ownerHandle,
346
421
  shortcode,
422
+ options.profileCache,
347
423
  );
348
- displayUrl = profile.url;
349
- if (!displayUrl) {
350
- if (profile.rateLimited) {
351
- imageNotice =
352
- "Instagram rate-limited the profile API (HTTP 429). The recipe was parsed without a featured image — upload one manually, or try Convert again in a few minutes.";
353
- } else if (profile.shortcodeMissing) {
354
- imageNotice =
355
- "This post was not found in the latest profile grid (Instagram only returns ~12 posts). Import without a featured image and upload one manually.";
356
- }
357
- }
358
424
  }
359
425
 
426
+ const node = profileResult?.node;
427
+ const displayUrl = node?.displayUrl;
428
+ const imageNotice = profileResult
429
+ ? imageNoticeFromProfileResult(profileResult)
430
+ : undefined;
431
+
360
432
  let imageBytes: ArrayBuffer | undefined;
361
433
  let imageContentType: string | undefined;
362
434
  if (displayUrl) {
@@ -373,8 +445,10 @@ export async function fetchInstagramPost(
373
445
  return {
374
446
  shortcode,
375
447
  postUrl: canonicalUrl,
376
- caption,
448
+ caption: caption || node?.caption || "",
377
449
  displayUrl,
450
+ accessibilityCaption: node?.accessibilityCaption,
451
+ likes: node?.likes ?? 0,
378
452
  isVideo,
379
453
  imageNotice,
380
454
  imageBytes,
@@ -2,31 +2,52 @@ import type { PluginContext } from "emdash";
2
2
  import { z } from "astro/zod";
3
3
 
4
4
  import {
5
+ bulkPreviewBlocks,
6
+ bulkResultBlocks,
5
7
  errorBlocks,
6
8
  importFormBlocks,
9
+ pageBlocks,
7
10
  previewBlocks,
11
+ type BulkCandidate,
8
12
  } from "./admin-blocks.js";
9
13
  import {
14
+ buildBulkRecipesFromPosts,
10
15
  buildParsedRecipeFromPost,
11
16
  buildParsedRecipeFromPostLenient,
17
+ type InstagramPostForRecipes,
12
18
  } from "./instagram-recipes.js";
13
19
  import {
14
20
  fetchInstagramPost,
15
21
  instagramBrowserUserAgent,
16
22
  parseInstagramPostUrl,
17
23
  } from "./parse-instagram-post.js";
24
+ import {
25
+ fetchProfileGrid,
26
+ normalizeInstagramHandle,
27
+ type CachedInstagramProfile,
28
+ type InstagramProfileCache,
29
+ type ProfilePostNode,
30
+ } from "./instagram-profile-image.js";
18
31
  import { recipeToPostWriteData } from "./recipe-content.js";
19
32
 
20
33
  const DEFAULT_COLLECTION = "posts";
21
34
  const PARSE_CACHE_PREFIX = "parse:";
22
35
  const IMPORT_MAP_PREFIX = "imported:";
36
+ const PROFILE_CACHE_PREFIX = "igprofile:";
37
+
38
+ /** Delay between draft creations so per-post image downloads stay polite. */
39
+ const BULK_CREATE_DELAY_MS = 600;
40
+
41
+ function sleep(ms: number): Promise<void> {
42
+ return new Promise((resolve) => setTimeout(resolve, ms));
43
+ }
23
44
 
24
45
  type AdminInteraction = {
25
46
  type: string;
26
47
  page?: string;
27
48
  action_id?: string;
28
49
  block_id?: string;
29
- values?: Record<string, string | boolean | number>;
50
+ values?: Record<string, string | boolean | number | string[]>;
30
51
  value?: unknown;
31
52
  };
32
53
 
@@ -49,6 +70,22 @@ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
49
70
  return ctx.http.fetch(url, init);
50
71
  }
51
72
 
73
+ /** Same role as brand-bootstrap's `.cache/instagram/` files, backed by KV. */
74
+ function profileCacheForCtx(ctx: PluginContext): InstagramProfileCache {
75
+ return {
76
+ async get(handle) {
77
+ return (
78
+ (await ctx.kv.get<CachedInstagramProfile>(
79
+ `${PROFILE_CACHE_PREFIX}${handle}`,
80
+ )) ?? undefined
81
+ );
82
+ },
83
+ async set(handle, entry) {
84
+ await ctx.kv.set(`${PROFILE_CACHE_PREFIX}${handle}`, entry);
85
+ },
86
+ };
87
+ }
88
+
52
89
  async function convertInstagramUrl(
53
90
  ctx: PluginContext,
54
91
  instagramUrl: string,
@@ -62,24 +99,20 @@ async function convertInstagramUrl(
62
99
  const instagramHandle = await ctx.kv.get<string>("config:instagram_handle");
63
100
  const post = await fetchInstagramPost(instagramUrl, fetchFn, {
64
101
  instagramHandle: instagramHandle ?? undefined,
102
+ profileCache: profileCacheForCtx(ctx),
65
103
  });
104
+ const postForRecipe = {
105
+ shortcode: post.shortcode,
106
+ caption: post.caption,
107
+ displayUrl: post.displayUrl,
108
+ accessibilityCaption: post.accessibilityCaption,
109
+ likes: post.likes,
110
+ isVideo: post.isVideo,
111
+ postUrl: post.postUrl,
112
+ };
66
113
  const recipe =
67
- buildParsedRecipeFromPost({
68
- shortcode: post.shortcode,
69
- caption: post.caption,
70
- displayUrl: post.displayUrl,
71
- likes: 0,
72
- isVideo: post.isVideo,
73
- postUrl: post.postUrl,
74
- }) ??
75
- buildParsedRecipeFromPostLenient({
76
- shortcode: post.shortcode,
77
- caption: post.caption,
78
- displayUrl: post.displayUrl,
79
- likes: 0,
80
- isVideo: post.isVideo,
81
- postUrl: post.postUrl,
82
- });
114
+ buildParsedRecipeFromPost(postForRecipe) ??
115
+ buildParsedRecipeFromPostLenient(postForRecipe);
83
116
 
84
117
  if (!recipe) {
85
118
  throw new Error(
@@ -229,6 +262,123 @@ async function createDraftFromRecipe(
229
262
  };
230
263
  }
231
264
 
265
+ function nodeToPostForRecipes(node: ProfilePostNode): InstagramPostForRecipes {
266
+ return {
267
+ shortcode: node.shortcode,
268
+ caption: node.caption,
269
+ displayUrl: node.displayUrl,
270
+ accessibilityCaption: node.accessibilityCaption,
271
+ likes: node.likes,
272
+ isVideo: node.isVideo,
273
+ postUrl: `https://www.instagram.com/p/${node.shortcode}/`,
274
+ };
275
+ }
276
+
277
+ type ScanResult = {
278
+ handle: string;
279
+ candidates: BulkCandidate[];
280
+ scanned: number;
281
+ rateLimited?: boolean;
282
+ fromCache?: boolean;
283
+ };
284
+
285
+ /**
286
+ * One web_profile_info request → all recipe posts in the grid. Each parsed
287
+ * recipe is cached under the same `parse:<shortcode>` key the single-post flow
288
+ * uses, so `create-draft` / bulk-create can reuse it (and lazily download the
289
+ * featured image) without re-hitting the rate-limited profile API.
290
+ */
291
+ async function scanProfileRecipes(
292
+ ctx: PluginContext,
293
+ handleInput: string,
294
+ ): Promise<ScanResult> {
295
+ const fallbackHandle =
296
+ (await ctx.kv.get<string>("config:instagram_handle")) ?? "";
297
+ const handle = normalizeInstagramHandle(handleInput || fallbackHandle);
298
+
299
+ const fetchFn = (input: string, init?: RequestInit) =>
300
+ httpFetch(ctx, input, init);
301
+ const grid = await fetchProfileGrid(fetchFn, handle, profileCacheForCtx(ctx));
302
+
303
+ const posts = [...grid.posts]
304
+ .sort((a, b) => b.likes - a.likes)
305
+ .map(nodeToPostForRecipes);
306
+ const recipes = buildBulkRecipesFromPosts(posts);
307
+
308
+ const candidates: BulkCandidate[] = [];
309
+ for (const recipe of recipes) {
310
+ await ctx.kv.set(parseCacheKey(recipe.source.shortcode), recipe);
311
+ const existing = await ctx.kv.get<string>(
312
+ importMapKey(recipe.source.shortcode),
313
+ );
314
+ candidates.push({ recipe, alreadyImported: Boolean(existing) });
315
+ }
316
+
317
+ return {
318
+ handle: grid.handle,
319
+ candidates,
320
+ scanned: grid.posts.length,
321
+ rateLimited: grid.rateLimited,
322
+ fromCache: grid.fromCache,
323
+ };
324
+ }
325
+
326
+ type BulkDraft = { shortcode: string; contentId: string; title: string };
327
+
328
+ /**
329
+ * Create a draft for each shortcode, skipping any post already in the
330
+ * `imported:` map so bulk import is idempotent — re-scanning and creating
331
+ * again is a no-op for posts you already have. Pass `force` to re-import.
332
+ */
333
+ async function createDraftsForShortcodes(
334
+ ctx: PluginContext,
335
+ shortcodes: string[],
336
+ options: { force?: boolean } = {},
337
+ ): Promise<{
338
+ created: BulkDraft[];
339
+ skipped: BulkDraft[];
340
+ failed: Array<{ shortcode: string; message: string }>;
341
+ }> {
342
+ const created: BulkDraft[] = [];
343
+ const skipped: BulkDraft[] = [];
344
+ const failed: Array<{ shortcode: string; message: string }> = [];
345
+
346
+ for (const shortcode of shortcodes) {
347
+ if (!options.force) {
348
+ const existing = await ctx.kv.get<string>(importMapKey(shortcode));
349
+ if (existing) {
350
+ const recipe = await ctx.kv.get<
351
+ NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>
352
+ >(parseCacheKey(shortcode));
353
+ skipped.push({
354
+ shortcode,
355
+ contentId: existing,
356
+ title: recipe?.title ?? shortcode,
357
+ });
358
+ continue;
359
+ }
360
+ }
361
+
362
+ // Throttle the per-draft image downloads to *.cdninstagram.com — only a
363
+ // gap between actual creates, not after cheap skips.
364
+ if (created.length + failed.length > 0) {
365
+ await sleep(BULK_CREATE_DELAY_MS);
366
+ }
367
+
368
+ try {
369
+ const { contentId, title } = await createDraftFromRecipe(ctx, shortcode);
370
+ created.push({ shortcode, contentId, title });
371
+ } catch (error) {
372
+ failed.push({
373
+ shortcode,
374
+ message: error instanceof Error ? error.message : String(error),
375
+ });
376
+ }
377
+ }
378
+
379
+ return { created, skipped, failed };
380
+ }
381
+
232
382
  export default {
233
383
  hooks: {
234
384
  "plugin:install": {
@@ -269,12 +419,129 @@ export default {
269
419
  },
270
420
  },
271
421
 
422
+ "bulk-scan": {
423
+ input: z.object({
424
+ handle: z.string().min(1),
425
+ }),
426
+ handler: async (
427
+ routeCtx: { input: { handle: string } },
428
+ ctx: PluginContext,
429
+ ) => {
430
+ const result = await scanProfileRecipes(ctx, routeCtx.input.handle);
431
+ return { ok: true, ...result };
432
+ },
433
+ },
434
+
435
+ "bulk-create": {
436
+ input: z.object({
437
+ shortcodes: z.array(z.string().min(1)).min(1),
438
+ }),
439
+ handler: async (
440
+ routeCtx: { input: { shortcodes: string[] } },
441
+ ctx: PluginContext,
442
+ ) => {
443
+ const result = await createDraftsForShortcodes(
444
+ ctx,
445
+ routeCtx.input.shortcodes,
446
+ );
447
+ return { ok: true, ...result };
448
+ },
449
+ },
450
+
272
451
  admin: {
273
452
  handler: async (routeCtx: { input?: AdminInteraction }, ctx: PluginContext) => {
274
453
  const interaction = routeCtx.input ?? { type: "page_load" };
275
454
 
276
455
  if (interaction.type === "page_load") {
277
- return { blocks: importFormBlocks() };
456
+ return { blocks: pageBlocks() };
457
+ }
458
+
459
+ const instagramHandle = String(
460
+ interaction.values?.instagram_handle ?? "",
461
+ ).trim();
462
+
463
+ if (
464
+ interaction.type === "form_submit" &&
465
+ interaction.action_id === "bulk_scan"
466
+ ) {
467
+ try {
468
+ const { handle, candidates, scanned, rateLimited, fromCache } =
469
+ await scanProfileRecipes(ctx, instagramHandle);
470
+ return {
471
+ blocks: bulkPreviewBlocks(handle, candidates, {
472
+ scanned,
473
+ rateLimited,
474
+ fromCache,
475
+ }),
476
+ toast: {
477
+ message: candidates.length
478
+ ? `Found ${candidates.length} recipe(s)`
479
+ : "No recipes found",
480
+ type: candidates.length ? "success" : "error",
481
+ },
482
+ };
483
+ } catch (error) {
484
+ const message =
485
+ error instanceof Error ? error.message : String(error);
486
+ return {
487
+ blocks: [
488
+ ...pageBlocks(),
489
+ {
490
+ type: "banner",
491
+ title: "Could not scan profile",
492
+ description: message,
493
+ variant: "error",
494
+ },
495
+ ],
496
+ toast: { message, type: "error" },
497
+ };
498
+ }
499
+ }
500
+
501
+ if (
502
+ interaction.type === "form_submit" &&
503
+ interaction.action_id === "bulk_create"
504
+ ) {
505
+ const raw = interaction.values?.bulk_select;
506
+ const shortcodes = Array.isArray(raw)
507
+ ? raw.map((s) => String(s).trim()).filter(Boolean)
508
+ : [];
509
+
510
+ if (shortcodes.length === 0) {
511
+ return {
512
+ blocks: pageBlocks(),
513
+ toast: { message: "Select at least one recipe", type: "error" },
514
+ };
515
+ }
516
+
517
+ try {
518
+ const result = await createDraftsForShortcodes(ctx, shortcodes);
519
+ const notes = [
520
+ result.skipped.length
521
+ ? `${result.skipped.length} skipped`
522
+ : "",
523
+ result.failed.length ? `${result.failed.length} failed` : "",
524
+ ].filter(Boolean);
525
+ return {
526
+ blocks: bulkResultBlocks(instagramHandle, result),
527
+ toast: {
528
+ message: `Created ${result.created.length} draft(s)${
529
+ notes.length ? `, ${notes.join(", ")}` : ""
530
+ }`,
531
+ type:
532
+ result.failed.length && !result.created.length
533
+ ? "error"
534
+ : "success",
535
+ },
536
+ };
537
+ } catch (error) {
538
+ const message =
539
+ error instanceof Error ? error.message : String(error);
540
+ return {
541
+ blocks: pageBlocks(),
542
+ toast: { message, type: "error" },
543
+ };
544
+ }
278
545
  }
279
546
 
280
547
  const instagramUrl = String(