@peristyle/emdash-plugin-instagram-to-recipe 0.1.0
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/README.md +92 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +31 -0
- package/dist/instagram-curl-profile-H4ND6RCQ.js +101 -0
- package/dist/sandbox-entry.d.ts +103 -0
- package/dist/sandbox-entry.js +905 -0
- package/package.json +38 -0
- package/src/admin-blocks.ts +145 -0
- package/src/index.ts +41 -0
- package/src/instagram-curl-profile.ts +120 -0
- package/src/instagram-image-url.ts +48 -0
- package/src/instagram-profile-image.ts +190 -0
- package/src/instagram-recipes.ts +257 -0
- package/src/media-preview.ts +28 -0
- package/src/parse-instagram-post.ts +386 -0
- package/src/recipe-content.ts +116 -0
- package/src/sandbox-entry.ts +371 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
export type InstagramPostForRecipes = {
|
|
2
|
+
shortcode: string;
|
|
3
|
+
caption: string;
|
|
4
|
+
displayUrl?: string;
|
|
5
|
+
accessibilityCaption?: string;
|
|
6
|
+
likes: number;
|
|
7
|
+
isVideo: boolean;
|
|
8
|
+
postUrl: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ParsedInstagramRecipe = {
|
|
12
|
+
source: {
|
|
13
|
+
instagram_url: string;
|
|
14
|
+
shortcode: string;
|
|
15
|
+
likes: number;
|
|
16
|
+
is_video: boolean;
|
|
17
|
+
};
|
|
18
|
+
suggested_slug: string;
|
|
19
|
+
title: string;
|
|
20
|
+
excerpt: string;
|
|
21
|
+
caption_raw: string;
|
|
22
|
+
ingredients: string[];
|
|
23
|
+
instructions: string[];
|
|
24
|
+
featured_image?: {
|
|
25
|
+
url: string;
|
|
26
|
+
alt: string;
|
|
27
|
+
filename: string;
|
|
28
|
+
/** Populated when the image is uploaded to EmDash media during convert. */
|
|
29
|
+
emdash_media?: {
|
|
30
|
+
provider: string;
|
|
31
|
+
id: string;
|
|
32
|
+
src: string;
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
meta?: { storageKey: string };
|
|
35
|
+
};
|
|
36
|
+
/** True when convert could not upload the image to media library. */
|
|
37
|
+
upload_failed?: boolean;
|
|
38
|
+
};
|
|
39
|
+
/** Set when convert succeeds but no featured image could be resolved. */
|
|
40
|
+
image_notice?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function titleCase(s: string): string {
|
|
44
|
+
return s
|
|
45
|
+
.split(/\s+/)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
48
|
+
.join(" ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function slugifyRecipeTitle(title: string): string {
|
|
52
|
+
const s = title
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.normalize("NFKD")
|
|
55
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
56
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
57
|
+
.replace(/^-+|-+$/g, "");
|
|
58
|
+
return s.slice(0, 80) || "instagram-recipe";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripHashtagsAndMentions(text: string): string {
|
|
62
|
+
return text
|
|
63
|
+
.replace(/@[\w.]+/g, "")
|
|
64
|
+
.replace(/#\w+/g, "")
|
|
65
|
+
.replace(/\s+/g, " ")
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isLikelyNonRecipeCaption(caption: string): boolean {
|
|
70
|
+
const lower = caption.toLowerCase();
|
|
71
|
+
return (
|
|
72
|
+
/grocery\s+(shopping|haul)/.test(lower) ||
|
|
73
|
+
/#wieiad\b/.test(lower) ||
|
|
74
|
+
/\bwieiad\b/.test(lower) ||
|
|
75
|
+
/#cookingfail\b/.test(lower) ||
|
|
76
|
+
/#tastetest\b/.test(lower) ||
|
|
77
|
+
/#foodfail\b/.test(lower) ||
|
|
78
|
+
/espresso at home/.test(lower) ||
|
|
79
|
+
/#espresso\b/.test(lower) ||
|
|
80
|
+
/#coffeetiktok\b/.test(lower)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isLikelyRecipeCaption(caption: string): boolean {
|
|
85
|
+
if (!caption.trim() || isLikelyNonRecipeCaption(caption)) return false;
|
|
86
|
+
if (/what you['']ll need|ingredients\s*:/i.test(caption)) return true;
|
|
87
|
+
const hasSteps = /(?:^|\n)\s*\d+\.\s+\S/.test(caption);
|
|
88
|
+
const hasFoodMeasures =
|
|
89
|
+
/(\d+\/\d+|\d+)\s*(cup|cups|tbsp|tsp|lb|lbs|oz|clove|cloves|pkg|package)/i.test(
|
|
90
|
+
caption,
|
|
91
|
+
) ||
|
|
92
|
+
/\b(tablespoon|teaspoon|pound|ounce|minute|minutes|simmer|bake|roast|air fryer|broil)\b/i.test(
|
|
93
|
+
caption,
|
|
94
|
+
);
|
|
95
|
+
return hasSteps && hasFoodMeasures;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function extractTitleFromCaption(caption: string): string {
|
|
99
|
+
const ingredientsSplit = caption.split(
|
|
100
|
+
/what you['']ll need:?|ingredients\s*:/i,
|
|
101
|
+
)[0];
|
|
102
|
+
const intro = stripHashtagsAndMentions(ingredientsSplit ?? caption);
|
|
103
|
+
const firstSentence = intro.split(/[.!?]/)[0]?.trim() ?? intro;
|
|
104
|
+
const withoutThe = firstSentence.replace(/^the\s+/i, "");
|
|
105
|
+
const beforeVerb =
|
|
106
|
+
withoutThe.split(/\s+(?:is|are|have|has|was|will|makes)\s+/i)[0]?.trim() ??
|
|
107
|
+
withoutThe;
|
|
108
|
+
|
|
109
|
+
if (beforeVerb.length >= 8 && beforeVerb.length <= 72) {
|
|
110
|
+
return titleCase(beforeVerb);
|
|
111
|
+
}
|
|
112
|
+
if (firstSentence.length <= 72) {
|
|
113
|
+
return titleCase(firstSentence);
|
|
114
|
+
}
|
|
115
|
+
return titleCase(firstSentence.slice(0, 68).trim());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseIngredientsFromCaption(caption: string): string[] {
|
|
119
|
+
const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
|
|
120
|
+
if (!match || match.index == null) return [];
|
|
121
|
+
|
|
122
|
+
const afterHeader = caption.slice(match.index + match[0].length);
|
|
123
|
+
const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
|
|
124
|
+
const ingredientsBlock = stepsMatch?.index
|
|
125
|
+
? afterHeader.slice(0, stepsMatch.index)
|
|
126
|
+
: afterHeader;
|
|
127
|
+
|
|
128
|
+
const lines = ingredientsBlock.split(/\n/);
|
|
129
|
+
const ingredients: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const raw of lines) {
|
|
132
|
+
const line = raw.trim();
|
|
133
|
+
if (!line) continue;
|
|
134
|
+
if (/^\[[^\]]+\]$/.test(line)) continue;
|
|
135
|
+
if (/^recipe below/i.test(line)) continue;
|
|
136
|
+
ingredients.push(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return ingredients;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseInstructionsFromCaption(caption: string): string[] {
|
|
143
|
+
const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
|
|
144
|
+
return matches
|
|
145
|
+
.map((m) => m[2]?.trim())
|
|
146
|
+
.filter((s): s is string => Boolean(s && s.length > 3));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseRecipeCaption(caption: string): {
|
|
150
|
+
title: string;
|
|
151
|
+
excerpt: string;
|
|
152
|
+
ingredients: string[];
|
|
153
|
+
instructions: string[];
|
|
154
|
+
} | null {
|
|
155
|
+
if (!isLikelyRecipeCaption(caption)) return null;
|
|
156
|
+
|
|
157
|
+
const ingredients = parseIngredientsFromCaption(caption);
|
|
158
|
+
const instructions = parseInstructionsFromCaption(caption);
|
|
159
|
+
const title = extractTitleFromCaption(caption);
|
|
160
|
+
|
|
161
|
+
if (ingredients.length === 0 && instructions.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
const intro = caption.split(/what you['']ll need:?|ingredients\s*:/i)[0]?.trim();
|
|
164
|
+
const excerptSource = stripHashtagsAndMentions(intro ?? caption)
|
|
165
|
+
.split(/[.!?]/)[0]
|
|
166
|
+
?.trim();
|
|
167
|
+
const excerpt =
|
|
168
|
+
excerptSource && excerptSource.length > 20
|
|
169
|
+
? excerptSource.slice(0, 220)
|
|
170
|
+
: `${title} — from Instagram.`;
|
|
171
|
+
|
|
172
|
+
return { title, excerpt, ingredients, instructions };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function imageFilenameForSlug(slug: string): string {
|
|
176
|
+
return `${slug}.jpg`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildParsedRecipeFromPost(
|
|
180
|
+
post: InstagramPostForRecipes,
|
|
181
|
+
): ParsedInstagramRecipe | null {
|
|
182
|
+
const parsed = parseRecipeCaption(post.caption);
|
|
183
|
+
if (!parsed) return null;
|
|
184
|
+
|
|
185
|
+
const slug = slugifyRecipeTitle(parsed.title);
|
|
186
|
+
const alt =
|
|
187
|
+
post.accessibilityCaption?.trim() ||
|
|
188
|
+
`${parsed.title} — photo from Instagram`;
|
|
189
|
+
|
|
190
|
+
const recipe: ParsedInstagramRecipe = {
|
|
191
|
+
source: {
|
|
192
|
+
instagram_url: post.postUrl,
|
|
193
|
+
shortcode: post.shortcode,
|
|
194
|
+
likes: post.likes,
|
|
195
|
+
is_video: post.isVideo,
|
|
196
|
+
},
|
|
197
|
+
suggested_slug: slug,
|
|
198
|
+
title: parsed.title,
|
|
199
|
+
excerpt: parsed.excerpt,
|
|
200
|
+
caption_raw: post.caption,
|
|
201
|
+
ingredients: parsed.ingredients,
|
|
202
|
+
instructions: parsed.instructions,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (post.displayUrl) {
|
|
206
|
+
recipe.featured_image = {
|
|
207
|
+
url: post.displayUrl,
|
|
208
|
+
alt,
|
|
209
|
+
filename: imageFilenameForSlug(slug),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return recipe;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Looser parse when caption has steps/ingredients but fails strict recipe heuristics. */
|
|
217
|
+
export function buildParsedRecipeFromPostLenient(
|
|
218
|
+
post: InstagramPostForRecipes,
|
|
219
|
+
): ParsedInstagramRecipe | null {
|
|
220
|
+
const strict = buildParsedRecipeFromPost(post);
|
|
221
|
+
if (strict) return strict;
|
|
222
|
+
|
|
223
|
+
const ingredients = parseIngredientsFromCaption(post.caption);
|
|
224
|
+
const instructions = parseInstructionsFromCaption(post.caption);
|
|
225
|
+
if (ingredients.length === 0 && instructions.length === 0) return null;
|
|
226
|
+
|
|
227
|
+
const title = extractTitleFromCaption(post.caption);
|
|
228
|
+
const slug = slugifyRecipeTitle(title);
|
|
229
|
+
const alt =
|
|
230
|
+
post.accessibilityCaption?.trim() ||
|
|
231
|
+
`${title} — photo from Instagram`;
|
|
232
|
+
|
|
233
|
+
const recipe: ParsedInstagramRecipe = {
|
|
234
|
+
source: {
|
|
235
|
+
instagram_url: post.postUrl,
|
|
236
|
+
shortcode: post.shortcode,
|
|
237
|
+
likes: post.likes,
|
|
238
|
+
is_video: post.isVideo,
|
|
239
|
+
},
|
|
240
|
+
suggested_slug: slug,
|
|
241
|
+
title,
|
|
242
|
+
excerpt: `${title} — imported from Instagram.`,
|
|
243
|
+
caption_raw: post.caption,
|
|
244
|
+
ingredients,
|
|
245
|
+
instructions,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (post.displayUrl) {
|
|
249
|
+
recipe.featured_image = {
|
|
250
|
+
url: post.displayUrl,
|
|
251
|
+
alt,
|
|
252
|
+
filename: imageFilenameForSlug(slug),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return recipe;
|
|
257
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Fixed JPEG header Instagram strips from `media_preview` payloads. */
|
|
2
|
+
const JPEG_PREVIEW_TEMPLATE =
|
|
3
|
+
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEABsaGikdKUEmJkFCLy8vQkc/Pj4/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0cBHSkpNCY0PygoP0c/NT9HR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR//AABEIABQAKgMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AA==";
|
|
4
|
+
|
|
5
|
+
/** Decode Instagram's compact `media_preview` field into JPEG bytes. */
|
|
6
|
+
export function decodeMediaPreview(previewBase64: string): Uint8Array | undefined {
|
|
7
|
+
try {
|
|
8
|
+
const binary = atob(previewBase64);
|
|
9
|
+
if (binary.length < 4) return undefined;
|
|
10
|
+
|
|
11
|
+
const width = binary.charCodeAt(1);
|
|
12
|
+
const height = binary.charCodeAt(2);
|
|
13
|
+
const body = binary.slice(3);
|
|
14
|
+
const headerBinary = atob(JPEG_PREVIEW_TEMPLATE);
|
|
15
|
+
const header = headerBinary.split("");
|
|
16
|
+
header[160] = String.fromCharCode(height);
|
|
17
|
+
header[162] = String.fromCharCode(width);
|
|
18
|
+
|
|
19
|
+
const jpeg = header.join("") + body;
|
|
20
|
+
const bytes = new Uint8Array(jpeg.length);
|
|
21
|
+
for (let i = 0; i < jpeg.length; i++) {
|
|
22
|
+
bytes[i] = jpeg.charCodeAt(i);
|
|
23
|
+
}
|
|
24
|
+
return bytes;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
const INSTAGRAM_POST_RE =
|
|
2
|
+
/(?:https?:\/\/)?(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([A-Za-z0-9_-]+)/i;
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
extractOwnerUsername,
|
|
6
|
+
fetchProfileDisplayUrl,
|
|
7
|
+
} from "./instagram-profile-image.js";
|
|
8
|
+
import { scoreInstagramImageUrl } from "./instagram-image-url.js";
|
|
9
|
+
|
|
10
|
+
const BROWSER_UA =
|
|
11
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
12
|
+
|
|
13
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
14
|
+
""": '"',
|
|
15
|
+
"'": "'",
|
|
16
|
+
"'": "'",
|
|
17
|
+
"’": "'",
|
|
18
|
+
"‘": "'",
|
|
19
|
+
"&": "&",
|
|
20
|
+
"<": "<",
|
|
21
|
+
">": ">",
|
|
22
|
+
" ": "\n",
|
|
23
|
+
" ": "\r",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function parseInstagramPostUrl(raw: string): {
|
|
27
|
+
shortcode: string;
|
|
28
|
+
postUrl: string;
|
|
29
|
+
} {
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
const match = INSTAGRAM_POST_RE.exec(trimmed);
|
|
32
|
+
if (!match?.[1]) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Paste a valid Instagram post URL (e.g. https://www.instagram.com/p/SHORTCODE/).",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const shortcode = match[1];
|
|
39
|
+
return {
|
|
40
|
+
shortcode,
|
|
41
|
+
postUrl: `https://www.instagram.com/p/${shortcode}/`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function decodeHtmlEntities(text: string): string {
|
|
46
|
+
let out = text;
|
|
47
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
48
|
+
out = out.split(entity).join(char);
|
|
49
|
+
}
|
|
50
|
+
out = out.replace(/&#(\d+);/g, (_, code) =>
|
|
51
|
+
String.fromCharCode(Number(code)),
|
|
52
|
+
);
|
|
53
|
+
out = out.replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
|
|
54
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
55
|
+
);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const EMBEDDED_IMAGE_PATTERNS = [
|
|
60
|
+
/"display_url":"((?:https?:)?\\?\/\\?\/[^"\\]+)"/g,
|
|
61
|
+
/"display_url":"(https?:[^"\\]+)"/g,
|
|
62
|
+
/"thumbnail_src":"(https?:[^"\\]+)"/g,
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
function decodeEmbeddedUrl(raw: string): string | undefined {
|
|
66
|
+
const normalized = raw.replaceAll("\\/", "/");
|
|
67
|
+
const url = decodeHtmlEntities(normalized).trim();
|
|
68
|
+
if (!url.startsWith("http")) return undefined;
|
|
69
|
+
if (/\.mp4(\?|$)|video\//i.test(url)) return undefined;
|
|
70
|
+
if (!url.includes("cdninstagram") && !url.includes("fbcdn.net")) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return url;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collect display_url / thumbnail_src values from Instagram's embedded post JSON. */
|
|
77
|
+
export function extractEmbeddedImageUrls(html: string): string[] {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const urls: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const pattern of EMBEDDED_IMAGE_PATTERNS) {
|
|
82
|
+
for (const match of html.matchAll(pattern)) {
|
|
83
|
+
const decoded = decodeEmbeddedUrl(match[1] ?? "");
|
|
84
|
+
if (!decoded || seen.has(decoded)) continue;
|
|
85
|
+
seen.add(decoded);
|
|
86
|
+
urls.push(decoded);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return urls;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { isInstagramVideoCoverUrl, scoreInstagramImageUrl } from "./instagram-image-url.js";
|
|
94
|
+
|
|
95
|
+
type ImageCandidate = { url: string; width?: number; height?: number };
|
|
96
|
+
|
|
97
|
+
export function extractImageVersionCandidates(html: string): ImageCandidate[] {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const out: ImageCandidate[] = [];
|
|
100
|
+
const itemRe =
|
|
101
|
+
/\{"height":(\d+),"url":"((?:https?:)?\\?\/\\?\/[^"\\]+)","width":(\d+)\}/g;
|
|
102
|
+
|
|
103
|
+
for (const match of html.matchAll(itemRe)) {
|
|
104
|
+
const normalized = (match[2] ?? "").replaceAll("\\/", "/");
|
|
105
|
+
const url = decodeHtmlEntities(normalized).trim();
|
|
106
|
+
if (!url.startsWith("http") || seen.has(url)) continue;
|
|
107
|
+
seen.add(url);
|
|
108
|
+
out.push({
|
|
109
|
+
url,
|
|
110
|
+
width: Number(match[3]),
|
|
111
|
+
height: Number(match[1]),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function pickBestInstagramImageUrl(
|
|
119
|
+
candidates: string[] | ImageCandidate[],
|
|
120
|
+
): string | undefined {
|
|
121
|
+
const normalized: ImageCandidate[] = candidates.map((c) =>
|
|
122
|
+
typeof c === "string" ? { url: c } : c,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const ranked = normalized
|
|
126
|
+
.map((entry) => ({
|
|
127
|
+
url: entry.url,
|
|
128
|
+
score: scoreInstagramImageUrl(entry.url, {
|
|
129
|
+
width: entry.width,
|
|
130
|
+
height: entry.height,
|
|
131
|
+
}),
|
|
132
|
+
}))
|
|
133
|
+
.filter((entry) => entry.score >= 0)
|
|
134
|
+
.sort((a, b) => b.score - a.score);
|
|
135
|
+
|
|
136
|
+
return ranked[0]?.url;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @deprecated Use extractEmbeddedImageUrls + pickBestInstagramImageUrl */
|
|
140
|
+
export function extractEmbeddedImageUrl(html: string): string | undefined {
|
|
141
|
+
return pickBestInstagramImageUrl(extractEmbeddedImageUrls(html));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the post's feed image (same class of URL as brand-bootstrap display_url).
|
|
146
|
+
* Never uses og:video. Uses og:image only when no embedded display_url exists.
|
|
147
|
+
*/
|
|
148
|
+
export function resolvePostImageUrl(
|
|
149
|
+
html: string,
|
|
150
|
+
fallbacks?: { ogImage?: string; isVideo?: boolean },
|
|
151
|
+
): string | undefined {
|
|
152
|
+
const embedded = extractEmbeddedImageUrls(html);
|
|
153
|
+
const bestEmbedded = pickBestInstagramImageUrl(embedded);
|
|
154
|
+
if (bestEmbedded) return bestEmbedded;
|
|
155
|
+
|
|
156
|
+
if (!fallbacks?.isVideo) {
|
|
157
|
+
const versioned = extractImageVersionCandidates(html);
|
|
158
|
+
const bestVersioned = pickBestInstagramImageUrl(versioned);
|
|
159
|
+
if (bestVersioned) return bestVersioned;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ogImage = fallbacks?.ogImage
|
|
163
|
+
? decodeHtmlEntities(fallbacks.ogImage)
|
|
164
|
+
: undefined;
|
|
165
|
+
if (
|
|
166
|
+
ogImage &&
|
|
167
|
+
!fallbacks?.isVideo &&
|
|
168
|
+
scoreInstagramImageUrl(ogImage) >= 0
|
|
169
|
+
) {
|
|
170
|
+
return ogImage;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function extractMetaContent(html: string, property: string): string | undefined {
|
|
177
|
+
const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
178
|
+
const patterns = [
|
|
179
|
+
new RegExp(`property="${escaped}"\\s+content="([^"]*)"`, "i"),
|
|
180
|
+
new RegExp(`content="([^"]*)"\\s+property="${escaped}"`, "i"),
|
|
181
|
+
];
|
|
182
|
+
for (const re of patterns) {
|
|
183
|
+
const match = re.exec(html);
|
|
184
|
+
if (match?.[1]) return match[1];
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function primeInstagramSession(
|
|
190
|
+
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
await fetchFn("https://www.instagram.com/", {
|
|
193
|
+
method: "GET",
|
|
194
|
+
redirect: "follow",
|
|
195
|
+
headers: instagramFetchHeaders(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function instagramFetchHeaders(referer?: string): HeadersInit {
|
|
200
|
+
return {
|
|
201
|
+
Accept:
|
|
202
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
203
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
204
|
+
"Cache-Control": "no-cache",
|
|
205
|
+
"Sec-Fetch-Dest": "document",
|
|
206
|
+
"Sec-Fetch-Mode": "navigate",
|
|
207
|
+
"Sec-Fetch-Site": referer ? "same-origin" : "none",
|
|
208
|
+
"Upgrade-Insecure-Requests": "1",
|
|
209
|
+
"User-Agent": BROWSER_UA,
|
|
210
|
+
...(referer ? { Referer: referer } : {}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function instagramImageFetchHeaders(referer: string): HeadersInit {
|
|
215
|
+
return {
|
|
216
|
+
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
217
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
218
|
+
"Cache-Control": "no-cache",
|
|
219
|
+
Referer: referer,
|
|
220
|
+
"Sec-Fetch-Dest": "image",
|
|
221
|
+
"Sec-Fetch-Mode": "no-cors",
|
|
222
|
+
"Sec-Fetch-Site": "cross-site",
|
|
223
|
+
"User-Agent": BROWSER_UA,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function cookieHeaderFromResponse(res: Response): string | undefined {
|
|
228
|
+
const headers = res.headers;
|
|
229
|
+
const getSetCookie = headers.getSetCookie?.bind(headers);
|
|
230
|
+
const cookies = getSetCookie?.();
|
|
231
|
+
if (cookies?.length) {
|
|
232
|
+
return cookies.map((c) => c.split(";")[0]).join("; ");
|
|
233
|
+
}
|
|
234
|
+
const single = headers.get("set-cookie");
|
|
235
|
+
return single ? single.split(";")[0] : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function downloadPostImage(
|
|
239
|
+
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
240
|
+
imageUrl: string,
|
|
241
|
+
postUrl: string,
|
|
242
|
+
cookieHeader?: string,
|
|
243
|
+
): Promise<{ bytes: ArrayBuffer; contentType: string } | undefined> {
|
|
244
|
+
const headers: Record<string, string> = {
|
|
245
|
+
...instagramImageFetchHeaders(postUrl),
|
|
246
|
+
} as Record<string, string>;
|
|
247
|
+
if (cookieHeader) {
|
|
248
|
+
headers.Cookie = cookieHeader;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const res = await fetchFn(imageUrl, { method: "GET", headers });
|
|
252
|
+
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const contentType = res.headers.get("content-type") ?? "image/jpeg";
|
|
258
|
+
if (!contentType.startsWith("image/")) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { bytes: await res.arrayBuffer(), contentType };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Pull caption text from Instagram's og:description meta tag. */
|
|
266
|
+
export function extractCaptionFromOgDescription(description: string): string {
|
|
267
|
+
const decoded = decodeHtmlEntities(description).trim();
|
|
268
|
+
|
|
269
|
+
const quoted = decoded.match(/:\s*["“]([\s\S]+?)["”]\.\s*$/);
|
|
270
|
+
if (quoted?.[1]) return quoted[1].trim();
|
|
271
|
+
|
|
272
|
+
const inline = decoded.match(/:\s*["“]([\s\S]+?)["”]/);
|
|
273
|
+
if (inline?.[1]) return inline[1].trim();
|
|
274
|
+
|
|
275
|
+
return decoded;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type FetchedInstagramPost = {
|
|
279
|
+
shortcode: string;
|
|
280
|
+
postUrl: string;
|
|
281
|
+
caption: string;
|
|
282
|
+
displayUrl?: string;
|
|
283
|
+
isVideo: boolean;
|
|
284
|
+
/** Shown in admin when no usable thumbnail could be resolved. */
|
|
285
|
+
imageNotice?: string;
|
|
286
|
+
/** Image bytes downloaded in the same session as the post page (for media upload). */
|
|
287
|
+
imageBytes?: ArrayBuffer;
|
|
288
|
+
imageContentType?: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export type FetchInstagramPostOptions = {
|
|
292
|
+
/** Override owner handle for web_profile_info lookup (same as brand-bootstrap handle). */
|
|
293
|
+
instagramHandle?: string;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export async function fetchInstagramPost(
|
|
297
|
+
postUrl: string,
|
|
298
|
+
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
299
|
+
options: FetchInstagramPostOptions = {},
|
|
300
|
+
): Promise<FetchedInstagramPost> {
|
|
301
|
+
const { shortcode } = parseInstagramPostUrl(postUrl);
|
|
302
|
+
const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
|
|
303
|
+
|
|
304
|
+
await primeInstagramSession(fetchFn);
|
|
305
|
+
|
|
306
|
+
const res = await fetchFn(canonicalUrl, {
|
|
307
|
+
method: "GET",
|
|
308
|
+
redirect: "follow",
|
|
309
|
+
headers: instagramFetchHeaders("https://www.instagram.com/"),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!res.ok) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Instagram returned HTTP ${res.status}. Try again in a few minutes if rate-limited.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const html = await res.text();
|
|
319
|
+
const ogDescription = extractMetaContent(html, "og:description");
|
|
320
|
+
if (!ogDescription) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
"Could not read caption from the Instagram page. The post may be private or unavailable.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const caption = extractCaptionFromOgDescription(ogDescription);
|
|
327
|
+
const ogVideo = extractMetaContent(html, "og:video");
|
|
328
|
+
const isVideo =
|
|
329
|
+
Boolean(ogVideo) ||
|
|
330
|
+
/"is_video":true/i.test(html) ||
|
|
331
|
+
/"__typename":"GraphVideo"/i.test(html) ||
|
|
332
|
+
/"media_type":2[,}]/i.test(html);
|
|
333
|
+
|
|
334
|
+
const cookieHeader = cookieHeaderFromResponse(res);
|
|
335
|
+
const ownerHandle =
|
|
336
|
+
options.instagramHandle ?? extractOwnerUsername(html);
|
|
337
|
+
|
|
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(
|
|
344
|
+
fetchFn,
|
|
345
|
+
ownerHandle,
|
|
346
|
+
shortcode,
|
|
347
|
+
);
|
|
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
|
+
}
|
|
359
|
+
|
|
360
|
+
let imageBytes: ArrayBuffer | undefined;
|
|
361
|
+
let imageContentType: string | undefined;
|
|
362
|
+
if (displayUrl) {
|
|
363
|
+
const downloaded = await downloadPostImage(
|
|
364
|
+
fetchFn,
|
|
365
|
+
displayUrl,
|
|
366
|
+
canonicalUrl,
|
|
367
|
+
cookieHeader,
|
|
368
|
+
);
|
|
369
|
+
imageBytes = downloaded?.bytes;
|
|
370
|
+
imageContentType = downloaded?.contentType;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
shortcode,
|
|
375
|
+
postUrl: canonicalUrl,
|
|
376
|
+
caption,
|
|
377
|
+
displayUrl,
|
|
378
|
+
isVideo,
|
|
379
|
+
imageNotice,
|
|
380
|
+
imageBytes,
|
|
381
|
+
imageContentType,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export const instagramBrowserUserAgent = BROWSER_UA;
|
|
386
|
+
export { instagramFetchHeaders, primeInstagramSession };
|