@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peristyle/emdash-plugin-instagram-to-recipe",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Import Instagram post URLs as recipe drafts in the EmDash admin",
6
6
  "publishConfig": {
@@ -21,11 +21,6 @@
21
21
  "dist",
22
22
  "src"
23
23
  ],
24
- "scripts": {
25
- "build": "tsup",
26
- "dev": "tsup --watch",
27
- "typecheck": "tsc --noEmit"
28
- },
29
24
  "peerDependencies": {
30
25
  "astro": ">=6.0.0-beta.0",
31
26
  "emdash": "^0.14.0"
@@ -34,5 +29,13 @@
34
29
  "emdash": "^0.14.0",
35
30
  "tsup": "^8.0.0",
36
31
  "typescript": "^5.0.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "dev": "tsup --watch",
36
+ "typecheck": "tsc --noEmit",
37
+ "preversion": "pnpm typecheck",
38
+ "version": "pnpm build",
39
+ "postversion": "git push --follow-tags"
37
40
  }
38
- }
41
+ }
@@ -30,6 +30,11 @@ export function importFormBlocks(initialUrl = ""): Block[] {
30
30
  ];
31
31
  }
32
32
 
33
+ /** Landing view: single-post import on top, bulk profile import below. */
34
+ export function pageBlocks(): Block[] {
35
+ return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
36
+ }
37
+
33
38
  export function previewBlocks(
34
39
  recipe: ParsedInstagramRecipe,
35
40
  options?: { existingContentId?: string },
@@ -132,6 +137,158 @@ export function previewBlocks(
132
137
  return blocks;
133
138
  }
134
139
 
140
+ export type BulkCandidate = {
141
+ recipe: ParsedInstagramRecipe;
142
+ alreadyImported: boolean;
143
+ };
144
+
145
+ export function bulkFormBlocks(initialHandle = ""): Block[] {
146
+ return [
147
+ {
148
+ type: "header",
149
+ text: "Bulk import from a profile",
150
+ },
151
+ {
152
+ type: "context",
153
+ 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 — pick the ones to create.",
154
+ },
155
+ {
156
+ type: "form",
157
+ block_id: "bulk-form",
158
+ fields: [
159
+ {
160
+ type: "text_input",
161
+ action_id: "instagram_handle",
162
+ label: "Instagram handle",
163
+ placeholder: "@cookingwithashhhh",
164
+ initial_value: initialHandle,
165
+ },
166
+ ],
167
+ submit: { label: "Scan profile", action_id: "bulk_scan" },
168
+ },
169
+ ];
170
+ }
171
+
172
+ export function bulkPreviewBlocks(
173
+ handle: string,
174
+ candidates: BulkCandidate[],
175
+ meta: { scanned: number; rateLimited?: boolean; fromCache?: boolean } = {
176
+ scanned: 0,
177
+ },
178
+ ): Block[] {
179
+ const blocks: Block[] = [...bulkFormBlocks(handle), { type: "divider" }];
180
+
181
+ if (meta.rateLimited) {
182
+ blocks.push({
183
+ type: "banner",
184
+ title: "Instagram rate-limited the profile API",
185
+ description:
186
+ "Showing whatever could be recovered (possibly from cache). Try again in a few minutes for a full scan.",
187
+ variant: "alert",
188
+ });
189
+ }
190
+
191
+ if (candidates.length === 0) {
192
+ blocks.push({
193
+ type: "banner",
194
+ title: "No recipes found",
195
+ 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.`,
196
+ variant: "default",
197
+ });
198
+ return blocks;
199
+ }
200
+
201
+ blocks.push({
202
+ type: "banner",
203
+ title: `${candidates.length} recipe${candidates.length === 1 ? "" : "s"} found`,
204
+ description: `Scanned ${meta.scanned} recent post(s) for @${handle}${meta.fromCache ? " (from cache)" : ""}. Already-imported posts are unchecked by default.`,
205
+ variant: "default",
206
+ });
207
+
208
+ const options = candidates.map((c) => ({
209
+ label: `${c.recipe.title} — ${c.recipe.source.likes} likes${
210
+ c.alreadyImported ? " (already imported)" : ""
211
+ }`,
212
+ value: c.recipe.source.shortcode,
213
+ }));
214
+
215
+ const initialSelected = candidates
216
+ .filter((c) => !c.alreadyImported)
217
+ .map((c) => c.recipe.source.shortcode);
218
+
219
+ blocks.push({
220
+ type: "form",
221
+ block_id: "bulk-select-form",
222
+ fields: [
223
+ {
224
+ type: "checkbox",
225
+ action_id: "bulk_select",
226
+ label: "Recipes to import",
227
+ options,
228
+ initial_value: initialSelected,
229
+ },
230
+ ],
231
+ submit: { label: "Create selected drafts", action_id: "bulk_create" },
232
+ });
233
+
234
+ for (const c of candidates) {
235
+ if (c.recipe.featured_image?.url) {
236
+ blocks.push({
237
+ type: "image",
238
+ url: c.recipe.featured_image.url,
239
+ alt: c.recipe.featured_image.alt,
240
+ title: c.recipe.title,
241
+ });
242
+ }
243
+ }
244
+
245
+ return blocks;
246
+ }
247
+
248
+ export function bulkResultBlocks(
249
+ handle: string,
250
+ result: {
251
+ created: Array<{ title: string }>;
252
+ skipped: Array<{ title: string }>;
253
+ failed: Array<{ shortcode: string; message: string }>;
254
+ },
255
+ ): Block[] {
256
+ const blocks: Block[] = [...bulkFormBlocks(handle), { type: "divider" }];
257
+
258
+ blocks.push({
259
+ type: "banner",
260
+ title: `Created ${result.created.length} draft${result.created.length === 1 ? "" : "s"}`,
261
+ description: result.created.length
262
+ ? `Saved: ${result.created.map((c) => c.title).join(", ")}. Open Posts in the admin to review and publish.`
263
+ : "No new drafts were created.",
264
+ variant: "default",
265
+ });
266
+
267
+ if (result.skipped.length > 0) {
268
+ blocks.push({
269
+ type: "banner",
270
+ title: `Skipped ${result.skipped.length} already imported`,
271
+ description: `Already in your library: ${result.skipped
272
+ .map((s) => s.title)
273
+ .join(", ")}.`,
274
+ variant: "default",
275
+ });
276
+ }
277
+
278
+ if (result.failed.length > 0) {
279
+ blocks.push({
280
+ type: "banner",
281
+ title: `${result.failed.length} failed`,
282
+ description: result.failed
283
+ .map((f) => `${f.shortcode}: ${f.message}`)
284
+ .join(" · "),
285
+ variant: "alert",
286
+ });
287
+ }
288
+
289
+ return blocks;
290
+ }
291
+
135
292
  export function errorBlocks(message: string, initialUrl = ""): Block[] {
136
293
  return [
137
294
  ...importFormBlocks(initialUrl),
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { PluginDescriptor } from "emdash";
2
2
 
3
+ /** Replaced at build time by tsup `define` from package.json — single source of truth. */
4
+ declare const __PLUGIN_VERSION__: string;
5
+
3
6
  export type InstagramToRecipePluginOptions = {
4
7
  /** Content collection slug for imported recipes (default: posts). */
5
8
  collection?: string;
@@ -15,7 +18,7 @@ export function instagramToRecipePlugin(
15
18
  ): PluginDescriptor<InstagramToRecipePluginOptions> {
16
19
  return {
17
20
  id: "peristyle-instagram-to-recipe",
18
- version: "0.1.0",
21
+ version: __PLUGIN_VERSION__,
19
22
  format: "standard",
20
23
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
21
24
  options,
@@ -6,6 +6,12 @@ const BROWSER_UA =
6
6
  type TimelineNode = {
7
7
  shortcode?: string;
8
8
  display_url?: string;
9
+ is_video?: boolean;
10
+ accessibility_caption?: string;
11
+ edge_liked_by?: { count?: number };
12
+ edge_media_to_caption?: {
13
+ edges?: Array<{ node?: { text?: string } }>;
14
+ };
9
15
  };
10
16
 
11
17
  type WebProfileUser = {
@@ -37,22 +43,75 @@ export function extractOwnerUsername(html: string): string | undefined {
37
43
  return undefined;
38
44
  }
39
45
 
40
- /** Same field brand-bootstrap uses: node.display_url from the profile grid. */
41
- export function displayUrlFromProfileUser(
42
- user: unknown,
43
- shortcode: string,
44
- ): string | undefined {
46
+ /** Post data resolved from the profile grid — same fields brand-bootstrap maps. */
47
+ export type ProfilePostNode = {
48
+ shortcode: string;
49
+ caption: string;
50
+ displayUrl?: string;
51
+ accessibilityCaption?: string;
52
+ likes: number;
53
+ isVideo: boolean;
54
+ };
55
+
56
+ /** Same shape brand-bootstrap persists under `.cache/instagram/<handle>.json`. */
57
+ export type CachedInstagramProfile = {
58
+ fetchedAt: string;
59
+ handle: string;
60
+ httpStatus: number;
61
+ user: unknown;
62
+ };
63
+
64
+ /** Storage adapter so the sandbox can back the cache with ctx.kv. */
65
+ export type InstagramProfileCache = {
66
+ get(handle: string): Promise<CachedInstagramProfile | undefined>;
67
+ set(handle: string, entry: CachedInstagramProfile): Promise<void>;
68
+ };
69
+
70
+ const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
71
+
72
+ export function isProfileCacheFresh(entry: CachedInstagramProfile): boolean {
73
+ const age = Date.now() - Date.parse(entry.fetchedAt);
74
+ return Number.isFinite(age) && age >= 0 && age <= PROFILE_CACHE_TTL_MS;
75
+ }
76
+
77
+ /** Map every post in the profile grid payload to a ProfilePostNode. */
78
+ export function postsFromProfileUser(user: unknown): ProfilePostNode[] {
45
79
  const edges =
46
80
  (user as WebProfileUser)?.edge_owner_to_timeline_media?.edges ?? [];
81
+ const posts: ProfilePostNode[] = [];
47
82
 
48
83
  for (const edge of edges) {
49
84
  const node = edge.node;
50
- if (node?.shortcode === shortcode && node.display_url) {
51
- return node.display_url;
52
- }
85
+ if (!node?.shortcode) continue;
86
+
87
+ const capEdges = node.edge_media_to_caption?.edges ?? [];
88
+ posts.push({
89
+ shortcode: node.shortcode,
90
+ caption: capEdges[0]?.node?.text?.trim() ?? "",
91
+ displayUrl: node.display_url,
92
+ accessibilityCaption: node.accessibility_caption,
93
+ likes: node.edge_liked_by?.count ?? 0,
94
+ isVideo: Boolean(node.is_video),
95
+ });
53
96
  }
54
97
 
55
- return undefined;
98
+ return posts;
99
+ }
100
+
101
+ /** Find a post in the profile grid payload by shortcode. */
102
+ export function postNodeFromProfileUser(
103
+ user: unknown,
104
+ shortcode: string,
105
+ ): ProfilePostNode | undefined {
106
+ return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
107
+ }
108
+
109
+ /** Same field brand-bootstrap uses: node.display_url from the profile grid. */
110
+ export function displayUrlFromProfileUser(
111
+ user: unknown,
112
+ shortcode: string,
113
+ ): string | undefined {
114
+ return postNodeFromProfileUser(user, shortcode)?.displayUrl;
56
115
  }
57
116
 
58
117
  function profileApiHeaders(handle: string): HeadersInit {
@@ -68,13 +127,26 @@ function profileApiHeaders(handle: string): HeadersInit {
68
127
  };
69
128
  }
70
129
 
71
- const PROFILE_FETCH_RETRIES = 2;
72
- const PROFILE_RETRY_DELAY_MS = 2500;
130
+ // Match brand-bootstrap: 3 retries with exponential backoff (2.5s, 5s, 10s).
131
+ const PROFILE_FETCH_RETRIES = 3;
132
+ const PROFILE_RETRY_BASE_DELAY_MS = 2500;
133
+
134
+ function retryDelayMs(attempt: number): number {
135
+ return PROFILE_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
136
+ }
73
137
 
74
138
  function sleep(ms: number): Promise<void> {
75
139
  return new Promise((resolve) => setTimeout(resolve, ms));
76
140
  }
77
141
 
142
+ export type ProfilePostNodeResult = {
143
+ node?: ProfilePostNode;
144
+ httpStatus?: number;
145
+ rateLimited?: boolean;
146
+ shortcodeMissing?: boolean;
147
+ fromCache?: boolean;
148
+ };
149
+
78
150
  export type ProfileDisplayUrlResult = {
79
151
  url?: string;
80
152
  httpStatus?: number;
@@ -126,25 +198,72 @@ async function fetchProfileUserOnce(
126
198
  return { ok: true, user, httpStatus };
127
199
  }
128
200
 
129
- export async function fetchProfileDisplayUrl(
201
+ async function cacheGet(
202
+ cache: InstagramProfileCache | undefined,
203
+ handle: string,
204
+ ): Promise<CachedInstagramProfile | undefined> {
205
+ try {
206
+ return await cache?.get(handle);
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+
212
+ async function cacheSet(
213
+ cache: InstagramProfileCache | undefined,
214
+ handle: string,
215
+ user: unknown,
216
+ httpStatus: number,
217
+ ): Promise<void> {
218
+ try {
219
+ await cache?.set(handle, {
220
+ fetchedAt: new Date().toISOString(),
221
+ handle,
222
+ httpStatus,
223
+ user,
224
+ });
225
+ } catch {
226
+ // Cache write failures must not break the import flow.
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Fetch the post's grid node via web_profile_info, mirroring brand-bootstrap:
232
+ * fresh cache first, then live fetch with backoff, then stale cache on
233
+ * failure, then the curl cookie-jar fallback.
234
+ */
235
+ export async function fetchProfilePostNode(
130
236
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
131
237
  handle: string,
132
238
  shortcode: string,
133
- ): Promise<ProfileDisplayUrlResult> {
239
+ cache?: InstagramProfileCache,
240
+ ): Promise<ProfilePostNodeResult> {
134
241
  const username = normalizeInstagramHandle(handle);
242
+
243
+ const cached = await cacheGet(cache, username);
244
+ if (cached && isProfileCacheFresh(cached)) {
245
+ const node = postNodeFromProfileUser(cached.user, shortcode);
246
+ // A fresh cache that lacks the shortcode may simply predate the post,
247
+ // so only short-circuit on a hit.
248
+ if (node) {
249
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
250
+ }
251
+ }
252
+
135
253
  let lastHttpStatus: number | undefined;
136
254
  let rateLimited = false;
137
255
 
138
256
  for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
139
257
  if (attempt > 0) {
140
- await sleep(PROFILE_RETRY_DELAY_MS * attempt);
258
+ await sleep(retryDelayMs(attempt - 1));
141
259
  }
142
260
 
143
261
  const result = await fetchProfileUserOnce(fetchFn, username);
144
262
  if (result.ok) {
145
- const url = displayUrlFromProfileUser(result.user, shortcode);
146
- if (url) {
147
- return { url, httpStatus: result.httpStatus };
263
+ await cacheSet(cache, username, result.user, result.httpStatus);
264
+ const node = postNodeFromProfileUser(result.user, shortcode);
265
+ if (node) {
266
+ return { node, httpStatus: result.httpStatus };
148
267
  }
149
268
  return { httpStatus: result.httpStatus, shortcodeMissing: true };
150
269
  }
@@ -156,20 +275,29 @@ export async function fetchProfileDisplayUrl(
156
275
  if (!result.retryable) break;
157
276
  }
158
277
 
159
- if (
160
- typeof process !== "undefined" &&
161
- process.versions?.node
162
- ) {
278
+ // Stale cache beats no image — same trade-off brand-bootstrap makes on 429.
279
+ if (cached) {
280
+ const node = postNodeFromProfileUser(cached.user, shortcode);
281
+ if (node) {
282
+ return { node, httpStatus: cached.httpStatus, fromCache: true };
283
+ }
284
+ }
285
+
286
+ if (typeof process !== "undefined" && process.versions?.node) {
163
287
  try {
164
- const { shouldTryInstagramCurlFallback, curlAvailable, fetchInstagramProfileViaCurl } =
165
- await import("./instagram-curl-profile.js");
288
+ const {
289
+ shouldTryInstagramCurlFallback,
290
+ curlAvailable,
291
+ fetchInstagramProfileViaCurl,
292
+ } = await import("./instagram-curl-profile.js");
166
293
 
167
294
  if (shouldTryInstagramCurlFallback() && (await curlAvailable())) {
168
295
  const curlResult = await fetchInstagramProfileViaCurl(username);
169
296
  if (curlResult.ok) {
170
- const url = displayUrlFromProfileUser(curlResult.user, shortcode);
171
- if (url) {
172
- return { url, httpStatus: curlResult.httpStatus };
297
+ await cacheSet(cache, username, curlResult.user, curlResult.httpStatus);
298
+ const node = postNodeFromProfileUser(curlResult.user, shortcode);
299
+ if (node) {
300
+ return { node, httpStatus: curlResult.httpStatus };
173
301
  }
174
302
  return {
175
303
  httpStatus: curlResult.httpStatus,
@@ -178,7 +306,7 @@ export async function fetchProfileDisplayUrl(
178
306
  }
179
307
  }
180
308
  } catch {
181
- // Fall through.
309
+ // Sandbox without child_process — fall through.
182
310
  }
183
311
  }
184
312
 
@@ -188,3 +316,122 @@ export async function fetchProfileDisplayUrl(
188
316
  shortcodeMissing: false,
189
317
  };
190
318
  }
319
+
320
+ export type ProfileGridResult = {
321
+ handle: string;
322
+ posts: ProfilePostNode[];
323
+ httpStatus?: number;
324
+ rateLimited?: boolean;
325
+ fromCache?: boolean;
326
+ };
327
+
328
+ /**
329
+ * Fetch the whole profile grid (~12 most recent posts) in a single
330
+ * web_profile_info request — the bulk-import counterpart to
331
+ * fetchProfilePostNode. Same cache → live-with-backoff → stale-cache → curl
332
+ * fallback chain, so a bulk scan costs at most one live API call per handle
333
+ * (and zero on a 24h cache hit).
334
+ */
335
+ export async function fetchProfileGrid(
336
+ fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
337
+ handle: string,
338
+ cache?: InstagramProfileCache,
339
+ ): Promise<ProfileGridResult> {
340
+ const username = normalizeInstagramHandle(handle);
341
+
342
+ const cached = await cacheGet(cache, username);
343
+ if (cached && isProfileCacheFresh(cached)) {
344
+ return {
345
+ handle: username,
346
+ posts: postsFromProfileUser(cached.user),
347
+ httpStatus: cached.httpStatus,
348
+ fromCache: true,
349
+ };
350
+ }
351
+
352
+ let lastHttpStatus: number | undefined;
353
+ let rateLimited = false;
354
+
355
+ for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
356
+ if (attempt > 0) {
357
+ await sleep(retryDelayMs(attempt - 1));
358
+ }
359
+
360
+ const result = await fetchProfileUserOnce(fetchFn, username);
361
+ if (result.ok) {
362
+ await cacheSet(cache, username, result.user, result.httpStatus);
363
+ return {
364
+ handle: username,
365
+ posts: postsFromProfileUser(result.user),
366
+ httpStatus: result.httpStatus,
367
+ };
368
+ }
369
+
370
+ lastHttpStatus = result.httpStatus;
371
+ if (result.httpStatus === 429 || result.httpStatus === 503) {
372
+ rateLimited = true;
373
+ }
374
+ if (!result.retryable) break;
375
+ }
376
+
377
+ // Stale cache beats no posts — same trade-off the single-post flow makes.
378
+ if (cached) {
379
+ return {
380
+ handle: username,
381
+ posts: postsFromProfileUser(cached.user),
382
+ httpStatus: cached.httpStatus,
383
+ fromCache: true,
384
+ };
385
+ }
386
+
387
+ if (typeof process !== "undefined" && process.versions?.node) {
388
+ try {
389
+ const {
390
+ shouldTryInstagramCurlFallback,
391
+ curlAvailable,
392
+ fetchInstagramProfileViaCurl,
393
+ } = await import("./instagram-curl-profile.js");
394
+
395
+ if (shouldTryInstagramCurlFallback() && (await curlAvailable())) {
396
+ const curlResult = await fetchInstagramProfileViaCurl(username);
397
+ if (curlResult.ok) {
398
+ await cacheSet(
399
+ cache,
400
+ username,
401
+ curlResult.user,
402
+ curlResult.httpStatus,
403
+ );
404
+ return {
405
+ handle: username,
406
+ posts: postsFromProfileUser(curlResult.user),
407
+ httpStatus: curlResult.httpStatus,
408
+ };
409
+ }
410
+ }
411
+ } catch {
412
+ // Sandbox without child_process — fall through.
413
+ }
414
+ }
415
+
416
+ return {
417
+ handle: username,
418
+ posts: [],
419
+ httpStatus: lastHttpStatus,
420
+ rateLimited,
421
+ };
422
+ }
423
+
424
+ export async function fetchProfileDisplayUrl(
425
+ fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
426
+ handle: string,
427
+ shortcode: string,
428
+ cache?: InstagramProfileCache,
429
+ ): Promise<ProfileDisplayUrlResult> {
430
+ const result = await fetchProfilePostNode(fetchFn, handle, shortcode, cache);
431
+ return {
432
+ url: result.node?.displayUrl,
433
+ httpStatus: result.httpStatus,
434
+ rateLimited: result.rateLimited,
435
+ shortcodeMissing: result.shortcodeMissing,
436
+ };
437
+ }
@@ -213,6 +213,40 @@ export function buildParsedRecipeFromPost(
213
213
  return recipe;
214
214
  }
215
215
 
216
+ /**
217
+ * Build recipes for every post whose caption is an actual recipe (strict
218
+ * parse only — bulk import favours precision over recall so non-recipe posts
219
+ * like grocery hauls are dropped). Slugs and image filenames are de-duplicated
220
+ * across the batch.
221
+ */
222
+ export function buildBulkRecipesFromPosts(
223
+ posts: InstagramPostForRecipes[],
224
+ ): ParsedInstagramRecipe[] {
225
+ const recipes: ParsedInstagramRecipe[] = [];
226
+ const usedSlugs = new Set<string>();
227
+
228
+ for (const post of posts) {
229
+ const recipe = buildParsedRecipeFromPost(post);
230
+ if (!recipe) continue;
231
+
232
+ let slug = recipe.suggested_slug;
233
+ let n = 2;
234
+ while (usedSlugs.has(slug)) {
235
+ slug = `${recipe.suggested_slug}-${n}`;
236
+ n++;
237
+ }
238
+ usedSlugs.add(slug);
239
+ recipe.suggested_slug = slug;
240
+ if (recipe.featured_image) {
241
+ recipe.featured_image.filename = imageFilenameForSlug(slug);
242
+ }
243
+
244
+ recipes.push(recipe);
245
+ }
246
+
247
+ return recipes;
248
+ }
249
+
216
250
  /** Looser parse when caption has steps/ingredients but fails strict recipe heuristics. */
217
251
  export function buildParsedRecipeFromPostLenient(
218
252
  post: InstagramPostForRecipes,