@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peristyle/emdash-plugin-instagram-to-recipe",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
package/src/admin-blocks.ts
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
258
|
+
await sleep(retryDelayMs(attempt - 1));
|
|
141
259
|
}
|
|
142
260
|
|
|
143
261
|
const result = await fetchProfileUserOnce(fetchFn, username);
|
|
144
262
|
if (result.ok) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 {
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/instagram-recipes.ts
CHANGED
|
@@ -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,
|