@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/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @peristyle/emdash-plugin-instagram-to-recipe
|
|
2
|
+
|
|
3
|
+
Import a public Instagram post into your food blog as a draft recipe from the EmDash admin.
|
|
4
|
+
|
|
5
|
+
Paste a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) and the plugin will:
|
|
6
|
+
|
|
7
|
+
1. Fetch the post page and read the caption from Open Graph metadata
|
|
8
|
+
2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps
|
|
9
|
+
3. Download the post **feed image** (`display_url` from embedded JSON — same source as brand-bootstrap — not the OG share-card with a play button)
|
|
10
|
+
4. Create a **draft** post in your `posts` collection
|
|
11
|
+
|
|
12
|
+
Parsing logic matches [brand-bootstrap](https://github.com/time-to-eat/init-brand-resources) Instagram recipe extraction.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
From your EmDash site (e.g. `food-blog-base`):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add ../plugins/instagram-to-recipe
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Register in `astro.config.mjs`:
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { instagramToRecipePlugin } from "@peristyle/emdash-plugin-instagram-to-recipe";
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
integrations: [
|
|
29
|
+
emdash({
|
|
30
|
+
plugins: [
|
|
31
|
+
recipesPlugin(),
|
|
32
|
+
instagramToRecipePlugin(),
|
|
33
|
+
// optional: custom collection slug
|
|
34
|
+
// instagramToRecipePlugin({ collection: "posts" }),
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rebuild or restart the dev server after adding the plugin.
|
|
42
|
+
|
|
43
|
+
## Admin usage
|
|
44
|
+
|
|
45
|
+
1. Open **Plugins → Instagram to Recipe → Import from Instagram**
|
|
46
|
+
2. Paste an Instagram post URL
|
|
47
|
+
3. Click **Convert to recipe** and review the preview
|
|
48
|
+
4. Click **Create draft post**
|
|
49
|
+
5. Edit the draft under **Posts** (slug, categories, times, publish)
|
|
50
|
+
|
|
51
|
+
## Capabilities
|
|
52
|
+
|
|
53
|
+
| Capability | Purpose |
|
|
54
|
+
| ---------------- | -------------------------------------------- |
|
|
55
|
+
| `network:fetch` | Fetch Instagram HTML and CDN images |
|
|
56
|
+
| `read:content` | Check for prior imports |
|
|
57
|
+
| `write:content` | Create draft posts |
|
|
58
|
+
| `write:media` | Upload featured images |
|
|
59
|
+
|
|
60
|
+
Allowed hosts: `www.instagram.com`, `*.cdninstagram.com`, `*.fbcdn.net`.
|
|
61
|
+
|
|
62
|
+
## API routes
|
|
63
|
+
|
|
64
|
+
For scripting or custom admin UI:
|
|
65
|
+
|
|
66
|
+
| Route | Method | Body |
|
|
67
|
+
| -------------- | ------ | ----------------------------- |
|
|
68
|
+
| `parse` | POST | `{ "url": "https://..." }` |
|
|
69
|
+
| `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
|
|
70
|
+
|
|
71
|
+
Base path: `/_emdash/api/plugins/peristyle-instagram-to-recipe/<route>`
|
|
72
|
+
|
|
73
|
+
## Limitations
|
|
74
|
+
|
|
75
|
+
- **Public posts only** — private or login-walled posts cannot be read
|
|
76
|
+
- **Caption format** — works best with “What you'll need” + numbered steps (same as brand-bootstrap)
|
|
77
|
+
- **Instagram rate limits** — repeated imports may hit HTTP 429; wait and retry
|
|
78
|
+
- **No video frames** — uses the OG thumbnail/image, not in-video text
|
|
79
|
+
- **Cloudflare sandbox** — uses `ctx.http.fetch` and `ctx.media.upload` (no Node `curl`)
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cd plugins/instagram-to-recipe
|
|
85
|
+
pnpm install
|
|
86
|
+
pnpm build
|
|
87
|
+
pnpm typecheck
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PluginDescriptor } from 'emdash';
|
|
2
|
+
|
|
3
|
+
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
|
+
declare function instagramToRecipePlugin(options?: InstagramToRecipePluginOptions): PluginDescriptor<InstagramToRecipePluginOptions>;
|
|
13
|
+
|
|
14
|
+
export { type InstagramToRecipePluginOptions, instagramToRecipePlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function instagramToRecipePlugin(options = {}) {
|
|
3
|
+
return {
|
|
4
|
+
id: "peristyle-instagram-to-recipe",
|
|
5
|
+
version: "0.1.0",
|
|
6
|
+
format: "standard",
|
|
7
|
+
entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
|
|
8
|
+
options,
|
|
9
|
+
capabilities: [
|
|
10
|
+
"content:read",
|
|
11
|
+
"content:write",
|
|
12
|
+
"media:write",
|
|
13
|
+
"network:request"
|
|
14
|
+
],
|
|
15
|
+
allowedHosts: [
|
|
16
|
+
"www.instagram.com",
|
|
17
|
+
"*.cdninstagram.com",
|
|
18
|
+
"*.fbcdn.net"
|
|
19
|
+
],
|
|
20
|
+
adminPages: [
|
|
21
|
+
{
|
|
22
|
+
path: "/import",
|
|
23
|
+
label: "Import from Instagram",
|
|
24
|
+
icon: "link"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
instagramToRecipePlugin
|
|
31
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/instagram-curl-profile.ts
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
var execFileAsync = promisify(execFile);
|
|
5
|
+
var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
6
|
+
var IG_APP_ID = "936619743392459";
|
|
7
|
+
function cookieJarPath(handle) {
|
|
8
|
+
return `/tmp/emdash-ig-${handle.toLowerCase()}.cookies.txt`;
|
|
9
|
+
}
|
|
10
|
+
async function fetchInstagramProfileViaCurl(handle) {
|
|
11
|
+
const profileUrl = `https://www.instagram.com/${handle}/`;
|
|
12
|
+
const apiUrl = `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(handle)}`;
|
|
13
|
+
const jar = cookieJarPath(handle);
|
|
14
|
+
try {
|
|
15
|
+
await execFileAsync(
|
|
16
|
+
"curl",
|
|
17
|
+
[
|
|
18
|
+
"-sL",
|
|
19
|
+
"-c",
|
|
20
|
+
jar,
|
|
21
|
+
"-b",
|
|
22
|
+
jar,
|
|
23
|
+
"-A",
|
|
24
|
+
BROWSER_UA,
|
|
25
|
+
profileUrl,
|
|
26
|
+
"-o",
|
|
27
|
+
"/dev/null",
|
|
28
|
+
"-w",
|
|
29
|
+
"%{http_code}"
|
|
30
|
+
],
|
|
31
|
+
{ timeout: 3e4 }
|
|
32
|
+
);
|
|
33
|
+
const { stdout } = await execFileAsync(
|
|
34
|
+
"curl",
|
|
35
|
+
[
|
|
36
|
+
"-sS",
|
|
37
|
+
"-b",
|
|
38
|
+
jar,
|
|
39
|
+
"-A",
|
|
40
|
+
BROWSER_UA,
|
|
41
|
+
"-H",
|
|
42
|
+
`X-IG-App-ID: ${IG_APP_ID}`,
|
|
43
|
+
"-H",
|
|
44
|
+
"X-Requested-With: XMLHttpRequest",
|
|
45
|
+
"-H",
|
|
46
|
+
`Referer: ${profileUrl}`,
|
|
47
|
+
"-w",
|
|
48
|
+
"\n__HTTP__:%{http_code}",
|
|
49
|
+
apiUrl
|
|
50
|
+
],
|
|
51
|
+
{ timeout: 3e4, maxBuffer: 8 * 1024 * 1024 }
|
|
52
|
+
);
|
|
53
|
+
const marker = stdout.lastIndexOf("\n__HTTP__:");
|
|
54
|
+
const body = marker >= 0 ? stdout.slice(0, marker) : stdout;
|
|
55
|
+
const httpStatus = marker >= 0 ? Number(stdout.slice(marker + 10)) : 0;
|
|
56
|
+
if (!httpStatus || httpStatus >= 400) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
httpStatus: httpStatus || void 0,
|
|
60
|
+
message: `curl API HTTP ${httpStatus || "?"}`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let payload;
|
|
64
|
+
try {
|
|
65
|
+
payload = JSON.parse(body);
|
|
66
|
+
} catch {
|
|
67
|
+
return { ok: false, httpStatus, message: "curl response was not JSON" };
|
|
68
|
+
}
|
|
69
|
+
const user = payload?.data?.user;
|
|
70
|
+
if (!user) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
httpStatus,
|
|
74
|
+
message: `profile not found for @${handle}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, user, httpStatus };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
80
|
+
return { ok: false, message: `curl fallback failed: ${message}` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function shouldTryInstagramCurlFallback() {
|
|
84
|
+
const v = process.env.INSTAGRAM_USE_CURL?.trim();
|
|
85
|
+
if (v === "0") return false;
|
|
86
|
+
if (v === "1") return true;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
async function curlAvailable() {
|
|
90
|
+
try {
|
|
91
|
+
await execFileAsync("curl", ["--version"], { timeout: 5e3 });
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
curlAvailable,
|
|
99
|
+
fetchInstagramProfileViaCurl,
|
|
100
|
+
shouldTryInstagramCurlFallback
|
|
101
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { PluginContext } from 'emdash';
|
|
2
|
+
import { z } from 'astro/zod';
|
|
3
|
+
|
|
4
|
+
type ParsedInstagramRecipe = {
|
|
5
|
+
source: {
|
|
6
|
+
instagram_url: string;
|
|
7
|
+
shortcode: string;
|
|
8
|
+
likes: number;
|
|
9
|
+
is_video: boolean;
|
|
10
|
+
};
|
|
11
|
+
suggested_slug: string;
|
|
12
|
+
title: string;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
caption_raw: string;
|
|
15
|
+
ingredients: string[];
|
|
16
|
+
instructions: string[];
|
|
17
|
+
featured_image?: {
|
|
18
|
+
url: string;
|
|
19
|
+
alt: string;
|
|
20
|
+
filename: string;
|
|
21
|
+
/** Populated when the image is uploaded to EmDash media during convert. */
|
|
22
|
+
emdash_media?: {
|
|
23
|
+
provider: string;
|
|
24
|
+
id: string;
|
|
25
|
+
src: string;
|
|
26
|
+
mimeType?: string;
|
|
27
|
+
meta?: {
|
|
28
|
+
storageKey: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/** True when convert could not upload the image to media library. */
|
|
32
|
+
upload_failed?: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** Set when convert succeeds but no featured image could be resolved. */
|
|
35
|
+
image_notice?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type AdminInteraction = {
|
|
39
|
+
type: string;
|
|
40
|
+
page?: string;
|
|
41
|
+
action_id?: string;
|
|
42
|
+
block_id?: string;
|
|
43
|
+
values?: Record<string, string | boolean | number>;
|
|
44
|
+
value?: unknown;
|
|
45
|
+
};
|
|
46
|
+
declare const _default: {
|
|
47
|
+
hooks: {
|
|
48
|
+
"plugin:install": {
|
|
49
|
+
handler: (_event: unknown, ctx: PluginContext) => Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
routes: {
|
|
53
|
+
parse: {
|
|
54
|
+
input: z.ZodObject<{
|
|
55
|
+
url: z.ZodString;
|
|
56
|
+
}, z.core.$strip>;
|
|
57
|
+
handler: (routeCtx: {
|
|
58
|
+
input: {
|
|
59
|
+
url: string;
|
|
60
|
+
};
|
|
61
|
+
}, ctx: PluginContext) => Promise<{
|
|
62
|
+
ok: boolean;
|
|
63
|
+
recipe: ParsedInstagramRecipe;
|
|
64
|
+
existingContentId: string | undefined;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
"create-draft": {
|
|
68
|
+
input: z.ZodObject<{
|
|
69
|
+
shortcode: z.ZodString;
|
|
70
|
+
}, z.core.$strip>;
|
|
71
|
+
handler: (routeCtx: {
|
|
72
|
+
input: {
|
|
73
|
+
shortcode: string;
|
|
74
|
+
};
|
|
75
|
+
}, ctx: PluginContext) => Promise<{
|
|
76
|
+
contentId: string;
|
|
77
|
+
title: string;
|
|
78
|
+
collection: string;
|
|
79
|
+
ok: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
};
|
|
82
|
+
admin: {
|
|
83
|
+
handler: (routeCtx: {
|
|
84
|
+
input?: AdminInteraction;
|
|
85
|
+
}, ctx: PluginContext) => Promise<{
|
|
86
|
+
blocks: {
|
|
87
|
+
[x: string]: unknown;
|
|
88
|
+
}[];
|
|
89
|
+
toast?: undefined;
|
|
90
|
+
} | {
|
|
91
|
+
blocks: {
|
|
92
|
+
[x: string]: unknown;
|
|
93
|
+
}[];
|
|
94
|
+
toast: {
|
|
95
|
+
message: string;
|
|
96
|
+
type: string;
|
|
97
|
+
};
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export { _default as default };
|