@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
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@peristyle/emdash-plugin-instagram-to-recipe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Import Instagram post URLs as recipe drafts in the EmDash admin",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./sandbox": {
|
|
16
|
+
"types": "./dist/sandbox-entry.d.ts",
|
|
17
|
+
"import": "./dist/sandbox-entry.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"astro": ">=6.0.0-beta.0",
|
|
31
|
+
"emdash": "^0.14.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"emdash": "^0.14.0",
|
|
35
|
+
"tsup": "^8.0.0",
|
|
36
|
+
"typescript": "^5.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ParsedInstagramRecipe } from "./instagram-recipes.js";
|
|
2
|
+
import { previewLines } from "./recipe-content.js";
|
|
3
|
+
|
|
4
|
+
type Block = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export function importFormBlocks(initialUrl = ""): Block[] {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
type: "header",
|
|
10
|
+
text: "Import from Instagram",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
type: "context",
|
|
14
|
+
text: "Paste a public Instagram post URL. The caption is parsed into recipe ingredients and steps, and the post image becomes the featured image.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: "form",
|
|
18
|
+
block_id: "import-form",
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
type: "text_input",
|
|
22
|
+
action_id: "instagram_url",
|
|
23
|
+
label: "Instagram post URL",
|
|
24
|
+
placeholder: "https://www.instagram.com/p/SHORTCODE/",
|
|
25
|
+
initial_value: initialUrl,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
submit: { label: "Convert to recipe", action_id: "convert" },
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function previewBlocks(
|
|
34
|
+
recipe: ParsedInstagramRecipe,
|
|
35
|
+
options?: { existingContentId?: string },
|
|
36
|
+
): Block[] {
|
|
37
|
+
const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
|
|
38
|
+
const instructionPreview = recipe.instructions
|
|
39
|
+
.slice(0, 4)
|
|
40
|
+
.map((step, i) => `${i + 1}. ${step}`)
|
|
41
|
+
.join("\n");
|
|
42
|
+
|
|
43
|
+
const blocks: Block[] = [
|
|
44
|
+
...importFormBlocks(recipe.source.instagram_url),
|
|
45
|
+
{ type: "divider" },
|
|
46
|
+
{
|
|
47
|
+
type: "banner",
|
|
48
|
+
title: "Recipe parsed",
|
|
49
|
+
description: previewLines(recipe).join(" · "),
|
|
50
|
+
variant: "default",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "fields",
|
|
54
|
+
fields: [
|
|
55
|
+
{ label: "Title", value: recipe.title },
|
|
56
|
+
{ label: "Suggested slug", value: recipe.suggested_slug },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "section",
|
|
61
|
+
text: `Excerpt: ${recipe.excerpt}`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "section",
|
|
65
|
+
text: `Ingredients (${recipe.ingredients.length}): ${ingredientPreview || "None parsed"}${recipe.ingredients.length > 8 ? " …" : ""}`,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: "section",
|
|
69
|
+
text: `Instructions (${recipe.instructions.length}): ${instructionPreview || "None parsed"}${recipe.instructions.length > 4 ? " …" : ""}`,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
if (recipe.featured_image?.url) {
|
|
74
|
+
blocks.push({
|
|
75
|
+
type: "image",
|
|
76
|
+
url: recipe.featured_image.url,
|
|
77
|
+
alt: recipe.featured_image.alt,
|
|
78
|
+
title: "Featured image preview",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (recipe.image_notice) {
|
|
83
|
+
blocks.push({
|
|
84
|
+
type: "banner",
|
|
85
|
+
title: "No featured image",
|
|
86
|
+
description: recipe.image_notice,
|
|
87
|
+
variant: "alert",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (recipe.featured_image?.upload_failed) {
|
|
92
|
+
blocks.push({
|
|
93
|
+
type: "banner",
|
|
94
|
+
title: "Featured image not saved to Media",
|
|
95
|
+
description:
|
|
96
|
+
"The preview loads from Instagram in your browser, but the server could not download it for the media library. You can upload an image manually on the recipe after creating the draft.",
|
|
97
|
+
variant: "alert",
|
|
98
|
+
});
|
|
99
|
+
} else if (recipe.featured_image?.emdash_media) {
|
|
100
|
+
blocks.push({
|
|
101
|
+
type: "banner",
|
|
102
|
+
title: "Featured image ready",
|
|
103
|
+
description:
|
|
104
|
+
"The post image is already in your media library and will be attached when you create the draft.",
|
|
105
|
+
variant: "default",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (options?.existingContentId) {
|
|
110
|
+
blocks.push({
|
|
111
|
+
type: "banner",
|
|
112
|
+
title: "Already imported",
|
|
113
|
+
description: `This post was previously imported (content id: ${options.existingContentId}). Creating again will add another draft.`,
|
|
114
|
+
variant: "alert",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
blocks.push({
|
|
119
|
+
type: "actions",
|
|
120
|
+
block_id: "preview-actions",
|
|
121
|
+
elements: [
|
|
122
|
+
{
|
|
123
|
+
type: "button",
|
|
124
|
+
action_id: "create_draft",
|
|
125
|
+
label: "Create draft post",
|
|
126
|
+
style: "primary",
|
|
127
|
+
value: recipe.source.shortcode,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return blocks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function errorBlocks(message: string, initialUrl = ""): Block[] {
|
|
136
|
+
return [
|
|
137
|
+
...importFormBlocks(initialUrl),
|
|
138
|
+
{
|
|
139
|
+
type: "banner",
|
|
140
|
+
title: "Could not import post",
|
|
141
|
+
description: message,
|
|
142
|
+
variant: "error",
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginDescriptor } from "emdash";
|
|
2
|
+
|
|
3
|
+
export type InstagramToRecipePluginOptions = {
|
|
4
|
+
/** Content collection slug for imported recipes (default: posts). */
|
|
5
|
+
collection?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Instagram username for web_profile_info (e.g. cookingwithashhhh).
|
|
8
|
+
* When omitted, the post page's owner username is used automatically.
|
|
9
|
+
*/
|
|
10
|
+
instagramHandle?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function instagramToRecipePlugin(
|
|
14
|
+
options: InstagramToRecipePluginOptions = {},
|
|
15
|
+
): PluginDescriptor<InstagramToRecipePluginOptions> {
|
|
16
|
+
return {
|
|
17
|
+
id: "peristyle-instagram-to-recipe",
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
format: "standard",
|
|
20
|
+
entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
|
|
21
|
+
options,
|
|
22
|
+
capabilities: [
|
|
23
|
+
"content:read",
|
|
24
|
+
"content:write",
|
|
25
|
+
"media:write",
|
|
26
|
+
"network:request",
|
|
27
|
+
],
|
|
28
|
+
allowedHosts: [
|
|
29
|
+
"www.instagram.com",
|
|
30
|
+
"*.cdninstagram.com",
|
|
31
|
+
"*.fbcdn.net",
|
|
32
|
+
],
|
|
33
|
+
adminPages: [
|
|
34
|
+
{
|
|
35
|
+
path: "/import",
|
|
36
|
+
label: "Import from Instagram",
|
|
37
|
+
icon: "link",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
const BROWSER_UA =
|
|
7
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
8
|
+
|
|
9
|
+
const IG_APP_ID = "936619743392459";
|
|
10
|
+
|
|
11
|
+
export type InstagramCurlProfileResult =
|
|
12
|
+
| { ok: true; user: unknown; httpStatus: number }
|
|
13
|
+
| { ok: false; message: string; httpStatus?: number };
|
|
14
|
+
|
|
15
|
+
function cookieJarPath(handle: string): string {
|
|
16
|
+
return `/tmp/emdash-ig-${handle.toLowerCase()}.cookies.txt`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Same curl + cookie-jar flow as init-brand-resources brand-bootstrap.
|
|
21
|
+
* Used when fetch() to web_profile_info fails (often HTTP 429).
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchInstagramProfileViaCurl(
|
|
24
|
+
handle: string,
|
|
25
|
+
): Promise<InstagramCurlProfileResult> {
|
|
26
|
+
const profileUrl = `https://www.instagram.com/${handle}/`;
|
|
27
|
+
const apiUrl = `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(handle)}`;
|
|
28
|
+
const jar = cookieJarPath(handle);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await execFileAsync(
|
|
32
|
+
"curl",
|
|
33
|
+
[
|
|
34
|
+
"-sL",
|
|
35
|
+
"-c",
|
|
36
|
+
jar,
|
|
37
|
+
"-b",
|
|
38
|
+
jar,
|
|
39
|
+
"-A",
|
|
40
|
+
BROWSER_UA,
|
|
41
|
+
profileUrl,
|
|
42
|
+
"-o",
|
|
43
|
+
"/dev/null",
|
|
44
|
+
"-w",
|
|
45
|
+
"%{http_code}",
|
|
46
|
+
],
|
|
47
|
+
{ timeout: 30_000 },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const { stdout } = await execFileAsync(
|
|
51
|
+
"curl",
|
|
52
|
+
[
|
|
53
|
+
"-sS",
|
|
54
|
+
"-b",
|
|
55
|
+
jar,
|
|
56
|
+
"-A",
|
|
57
|
+
BROWSER_UA,
|
|
58
|
+
"-H",
|
|
59
|
+
`X-IG-App-ID: ${IG_APP_ID}`,
|
|
60
|
+
"-H",
|
|
61
|
+
"X-Requested-With: XMLHttpRequest",
|
|
62
|
+
"-H",
|
|
63
|
+
`Referer: ${profileUrl}`,
|
|
64
|
+
"-w",
|
|
65
|
+
"\n__HTTP__:%{http_code}",
|
|
66
|
+
apiUrl,
|
|
67
|
+
],
|
|
68
|
+
{ timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const marker = stdout.lastIndexOf("\n__HTTP__:");
|
|
72
|
+
const body = marker >= 0 ? stdout.slice(0, marker) : stdout;
|
|
73
|
+
const httpStatus = marker >= 0 ? Number(stdout.slice(marker + 10)) : 0;
|
|
74
|
+
|
|
75
|
+
if (!httpStatus || httpStatus >= 400) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
httpStatus: httpStatus || undefined,
|
|
79
|
+
message: `curl API HTTP ${httpStatus || "?"}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let payload: unknown;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(body);
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, httpStatus, message: "curl response was not JSON" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const user = (payload as { data?: { user?: unknown } })?.data?.user;
|
|
91
|
+
if (!user) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
httpStatus,
|
|
95
|
+
message: `profile not found for @${handle}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ok: true, user, httpStatus };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
102
|
+
return { ok: false, message: `curl fallback failed: ${message}` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function shouldTryInstagramCurlFallback(): boolean {
|
|
107
|
+
const v = process.env.INSTAGRAM_USE_CURL?.trim();
|
|
108
|
+
if (v === "0") return false;
|
|
109
|
+
if (v === "1") return true;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function curlAvailable(): Promise<boolean> {
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync("curl", ["--version"], { timeout: 5000 });
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function decodeInstagramEfgTag(url: string): string | undefined {
|
|
2
|
+
const match = /[?&]efg=([^&]+)/i.exec(url);
|
|
3
|
+
if (!match?.[1]) return undefined;
|
|
4
|
+
try {
|
|
5
|
+
const padded = match[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
6
|
+
return atob(padded);
|
|
7
|
+
} catch {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Reel/video permalink posters — play icon baked in, portrait, not feed thumbnails. */
|
|
13
|
+
export function isInstagramVideoCoverUrl(url: string): boolean {
|
|
14
|
+
const efg = decodeInstagramEfgTag(url);
|
|
15
|
+
if (efg && /video_default_cover_frame|cover_frame/i.test(efg)) return true;
|
|
16
|
+
if (/video_default_cover_frame|cover_frame/i.test(url)) return true;
|
|
17
|
+
if (/t51\.71878-15\//.test(url) && /dst-jpg_e15/i.test(url)) return true;
|
|
18
|
+
if (/c0\.\d+\.\d+x\d+a_/i.test(url)) return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Prefer feed-style display_url (1080) over OG share-card images with play buttons. */
|
|
23
|
+
export function scoreInstagramImageUrl(
|
|
24
|
+
url: string,
|
|
25
|
+
opts?: { width?: number; height?: number },
|
|
26
|
+
): number {
|
|
27
|
+
if (/\.mp4(\?|$)|video\//i.test(url)) return -1;
|
|
28
|
+
if (isInstagramVideoCoverUrl(url)) return -1;
|
|
29
|
+
|
|
30
|
+
let score = 0;
|
|
31
|
+
if (url.includes("p1080x1080")) score += 10_000;
|
|
32
|
+
else if (url.includes("p750x750")) score += 7_500;
|
|
33
|
+
else if (url.includes("p640x640")) score += 4_000;
|
|
34
|
+
if (url.includes("dst-jpg_e35")) score += 800;
|
|
35
|
+
if (url.includes("dst-jpg")) score += 500;
|
|
36
|
+
if (/\/s\d+x\d+\//.test(url)) score -= 2_000;
|
|
37
|
+
if (url.includes("thumbnail")) score -= 1_000;
|
|
38
|
+
|
|
39
|
+
const w = opts?.width;
|
|
40
|
+
const h = opts?.height;
|
|
41
|
+
if (w && h) {
|
|
42
|
+
if (h > w) score -= 5_000;
|
|
43
|
+
score += Math.min(w * h, 2_000_000);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
score += Math.min(url.length, 500);
|
|
47
|
+
return score;
|
|
48
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const IG_APP_ID = "936619743392459";
|
|
2
|
+
|
|
3
|
+
const BROWSER_UA =
|
|
4
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
5
|
+
|
|
6
|
+
type TimelineNode = {
|
|
7
|
+
shortcode?: string;
|
|
8
|
+
display_url?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type WebProfileUser = {
|
|
12
|
+
edge_owner_to_timeline_media?: {
|
|
13
|
+
edges?: Array<{ node?: TimelineNode }>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function normalizeInstagramHandle(raw: string): string {
|
|
18
|
+
let s = raw.trim();
|
|
19
|
+
const urlMatch = /instagram\.com\/([A-Za-z0-9._]+)/i.exec(s);
|
|
20
|
+
if (urlMatch) s = urlMatch[1]!;
|
|
21
|
+
if (s.startsWith("@")) s = s.slice(1);
|
|
22
|
+
s = s.replace(/\/+$/, "").trim();
|
|
23
|
+
if (!/^[A-Za-z0-9._]{1,30}$/.test(s)) {
|
|
24
|
+
throw new Error("Invalid Instagram handle.");
|
|
25
|
+
}
|
|
26
|
+
return s.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Owner username from Instagram's embedded post payload. */
|
|
30
|
+
export function extractOwnerUsername(html: string): string | undefined {
|
|
31
|
+
const ownerMatch = /"owner":\{[^}]*"username":"([A-Za-z0-9._]+)"/.exec(html);
|
|
32
|
+
if (ownerMatch?.[1]) return ownerMatch[1].toLowerCase();
|
|
33
|
+
|
|
34
|
+
const userMatch = /"user":\{[^}]*"username":"([A-Za-z0-9._]+)"/.exec(html);
|
|
35
|
+
if (userMatch?.[1]) return userMatch[1].toLowerCase();
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
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 {
|
|
45
|
+
const edges =
|
|
46
|
+
(user as WebProfileUser)?.edge_owner_to_timeline_media?.edges ?? [];
|
|
47
|
+
|
|
48
|
+
for (const edge of edges) {
|
|
49
|
+
const node = edge.node;
|
|
50
|
+
if (node?.shortcode === shortcode && node.display_url) {
|
|
51
|
+
return node.display_url;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function profileApiHeaders(handle: string): HeadersInit {
|
|
59
|
+
const profileUrl = `https://www.instagram.com/${handle}/`;
|
|
60
|
+
return {
|
|
61
|
+
Accept: "*/*",
|
|
62
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
63
|
+
"User-Agent": BROWSER_UA,
|
|
64
|
+
"X-IG-App-ID": IG_APP_ID,
|
|
65
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
66
|
+
Referer: profileUrl,
|
|
67
|
+
Origin: "https://www.instagram.com",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PROFILE_FETCH_RETRIES = 2;
|
|
72
|
+
const PROFILE_RETRY_DELAY_MS = 2500;
|
|
73
|
+
|
|
74
|
+
function sleep(ms: number): Promise<void> {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ProfileDisplayUrlResult = {
|
|
79
|
+
url?: string;
|
|
80
|
+
httpStatus?: number;
|
|
81
|
+
rateLimited?: boolean;
|
|
82
|
+
shortcodeMissing?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve display_url via web_profile_info — same API and field as
|
|
87
|
+
* init-brand-resources brand-bootstrap (postsFromProfile → displayUrl).
|
|
88
|
+
*/
|
|
89
|
+
async function fetchProfileUserOnce(
|
|
90
|
+
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
91
|
+
handle: string,
|
|
92
|
+
): Promise<
|
|
93
|
+
| { ok: true; user: unknown; httpStatus: number }
|
|
94
|
+
| { ok: false; httpStatus?: number; retryable?: boolean }
|
|
95
|
+
> {
|
|
96
|
+
const username = normalizeInstagramHandle(handle);
|
|
97
|
+
const apiUrl = `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(username)}`;
|
|
98
|
+
|
|
99
|
+
const res = await fetchFn(apiUrl, {
|
|
100
|
+
method: "GET",
|
|
101
|
+
redirect: "follow",
|
|
102
|
+
headers: profileApiHeaders(username),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const httpStatus = res.status;
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
httpStatus,
|
|
110
|
+
retryable: httpStatus === 429 || httpStatus === 503,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let payload: unknown;
|
|
115
|
+
try {
|
|
116
|
+
payload = await res.json();
|
|
117
|
+
} catch {
|
|
118
|
+
return { ok: false, httpStatus };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const user = (payload as { data?: { user?: unknown } })?.data?.user;
|
|
122
|
+
if (!user) {
|
|
123
|
+
return { ok: false, httpStatus };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: true, user, httpStatus };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function fetchProfileDisplayUrl(
|
|
130
|
+
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
131
|
+
handle: string,
|
|
132
|
+
shortcode: string,
|
|
133
|
+
): Promise<ProfileDisplayUrlResult> {
|
|
134
|
+
const username = normalizeInstagramHandle(handle);
|
|
135
|
+
let lastHttpStatus: number | undefined;
|
|
136
|
+
let rateLimited = false;
|
|
137
|
+
|
|
138
|
+
for (let attempt = 0; attempt <= PROFILE_FETCH_RETRIES; attempt++) {
|
|
139
|
+
if (attempt > 0) {
|
|
140
|
+
await sleep(PROFILE_RETRY_DELAY_MS * attempt);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = await fetchProfileUserOnce(fetchFn, username);
|
|
144
|
+
if (result.ok) {
|
|
145
|
+
const url = displayUrlFromProfileUser(result.user, shortcode);
|
|
146
|
+
if (url) {
|
|
147
|
+
return { url, httpStatus: result.httpStatus };
|
|
148
|
+
}
|
|
149
|
+
return { httpStatus: result.httpStatus, shortcodeMissing: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lastHttpStatus = result.httpStatus;
|
|
153
|
+
if (result.httpStatus === 429 || result.httpStatus === 503) {
|
|
154
|
+
rateLimited = true;
|
|
155
|
+
}
|
|
156
|
+
if (!result.retryable) break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
typeof process !== "undefined" &&
|
|
161
|
+
process.versions?.node
|
|
162
|
+
) {
|
|
163
|
+
try {
|
|
164
|
+
const { shouldTryInstagramCurlFallback, curlAvailable, fetchInstagramProfileViaCurl } =
|
|
165
|
+
await import("./instagram-curl-profile.js");
|
|
166
|
+
|
|
167
|
+
if (shouldTryInstagramCurlFallback() && (await curlAvailable())) {
|
|
168
|
+
const curlResult = await fetchInstagramProfileViaCurl(username);
|
|
169
|
+
if (curlResult.ok) {
|
|
170
|
+
const url = displayUrlFromProfileUser(curlResult.user, shortcode);
|
|
171
|
+
if (url) {
|
|
172
|
+
return { url, httpStatus: curlResult.httpStatus };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
httpStatus: curlResult.httpStatus,
|
|
176
|
+
shortcodeMissing: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Fall through.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
httpStatus: lastHttpStatus,
|
|
187
|
+
rateLimited,
|
|
188
|
+
shortcodeMissing: false,
|
|
189
|
+
};
|
|
190
|
+
}
|