@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
|
@@ -3,7 +3,11 @@ const INSTAGRAM_POST_RE =
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
extractOwnerUsername,
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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,
|
package/src/sandbox-entry.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|