@peristyle/emdash-plugin-instagram-to-recipe 0.1.4 → 0.1.6
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 +54 -39
- package/dist/index.d.ts +7 -11
- package/dist/index.js +2 -3
- package/dist/sandbox-entry.d.ts +6 -0
- package/dist/sandbox-entry.js +102 -6
- package/package.json +1 -1
- package/src/admin-blocks.ts +48 -3
- package/src/index.ts +6 -14
- package/src/instagram-curl-profile.ts +2 -2
- package/src/instagram-profile-image.ts +9 -10
- package/src/parse-instagram-post.ts +10 -9
- package/src/sandbox-entry.ts +82 -5
package/README.md
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
# @peristyle/emdash-plugin-instagram-to-recipe
|
|
2
2
|
|
|
3
|
-
Import
|
|
3
|
+
Import public Instagram posts into your [EmDash](https://www.npmjs.com/package/emdash) site as draft recipe posts — one at a time by URL, or in bulk by scanning a profile.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
For a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) the plugin will:
|
|
6
6
|
|
|
7
|
-
1. Fetch the post
|
|
8
|
-
2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps
|
|
9
|
-
3. Download the post **feed image** (
|
|
10
|
-
4. Create a **draft** post in your
|
|
11
|
-
|
|
12
|
-
Parsing logic matches [brand-bootstrap](https://github.com/time-to-eat/init-brand-resources) Instagram recipe extraction.
|
|
7
|
+
1. Fetch the post and read the caption (profile API first, HTML Open Graph metadata as fallback)
|
|
8
|
+
2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps — including captions Instagram has collapsed onto one line
|
|
9
|
+
3. Download the post **feed image** (the embedded `display_url`, not the OG share-card with a play button) and store its dimensions for responsive images
|
|
10
|
+
4. Create a **draft** post in your configured collection
|
|
13
11
|
|
|
14
12
|
## Install
|
|
15
13
|
|
|
16
|
-
From your EmDash site (e.g. `food-blog-base`):
|
|
17
|
-
|
|
18
14
|
```bash
|
|
19
|
-
pnpm add
|
|
15
|
+
pnpm add @peristyle/emdash-plugin-instagram-to-recipe
|
|
16
|
+
# or: npm install / yarn add
|
|
20
17
|
```
|
|
21
18
|
|
|
22
19
|
Register in `astro.config.mjs`:
|
|
@@ -27,12 +24,7 @@ import { instagramToRecipePlugin } from "@peristyle/emdash-plugin-instagram-to-r
|
|
|
27
24
|
export default defineConfig({
|
|
28
25
|
integrations: [
|
|
29
26
|
emdash({
|
|
30
|
-
plugins: [
|
|
31
|
-
recipesPlugin(),
|
|
32
|
-
instagramToRecipePlugin(),
|
|
33
|
-
// optional: custom collection slug
|
|
34
|
-
// instagramToRecipePlugin({ collection: "posts" }),
|
|
35
|
-
],
|
|
27
|
+
plugins: [instagramToRecipePlugin()],
|
|
36
28
|
}),
|
|
37
29
|
],
|
|
38
30
|
});
|
|
@@ -42,51 +34,74 @@ Rebuild or restart the dev server after adding the plugin.
|
|
|
42
34
|
|
|
43
35
|
## Admin usage
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
Open **Plugins → Import from Instagram** in the EmDash admin. The page has three sections:
|
|
38
|
+
|
|
39
|
+
### Single post
|
|
40
|
+
|
|
41
|
+
1. Paste an Instagram post URL and click **Convert to recipe**
|
|
42
|
+
2. Review the parsed preview (title, ingredients, steps, featured image)
|
|
43
|
+
3. Click **Create draft post**
|
|
44
|
+
4. Edit the draft under **Posts** (slug, categories, times, publish)
|
|
45
|
+
|
|
46
|
+
### Bulk import from a profile
|
|
47
|
+
|
|
48
|
+
1. Enter a public Instagram handle and click **Scan profile**
|
|
49
|
+
2. The profile grid is fetched in a single request and every caption that parses as a recipe is listed, sorted by likes
|
|
50
|
+
3. Check the recipes you want (already-imported posts are unchecked and labeled) and click **Create selected drafts**
|
|
51
|
+
|
|
52
|
+
Bulk import is idempotent — posts already imported are skipped. Deleting an imported draft clears the bookkeeping, so the post can be imported again later.
|
|
53
|
+
|
|
54
|
+
### Settings
|
|
55
|
+
|
|
56
|
+
- **Content collection** — where drafts are created (default: `posts`)
|
|
57
|
+
- **Instagram handle** (optional) — pre-fills the bulk scan and lets single-post imports go straight to the profile API; when empty, the post owner's handle is detected automatically
|
|
58
|
+
|
|
59
|
+
> Standard-format EmDash plugins are configured here (settings are stored in the plugin's KV store), not via constructor options.
|
|
50
60
|
|
|
51
61
|
## Capabilities
|
|
52
62
|
|
|
53
|
-
| Capability
|
|
54
|
-
|
|
|
55
|
-
| `network:
|
|
56
|
-
| `read
|
|
57
|
-
| `write
|
|
58
|
-
| `write
|
|
63
|
+
| Capability | Purpose |
|
|
64
|
+
| ----------------- | ----------------------------------- |
|
|
65
|
+
| `network:request` | Fetch Instagram HTML and CDN images |
|
|
66
|
+
| `content:read` | Check for prior imports |
|
|
67
|
+
| `content:write` | Create draft posts |
|
|
68
|
+
| `media:write` | Upload featured images |
|
|
59
69
|
|
|
60
70
|
Allowed hosts: `www.instagram.com`, `*.cdninstagram.com`, `*.fbcdn.net`.
|
|
61
71
|
|
|
62
72
|
## API routes
|
|
63
73
|
|
|
64
|
-
For scripting or custom admin UI:
|
|
74
|
+
For scripting or a custom admin UI:
|
|
65
75
|
|
|
66
|
-
| Route | Method | Body
|
|
67
|
-
| -------------- | ------ |
|
|
68
|
-
| `parse` | POST | `{ "url": "https://..." }`
|
|
69
|
-
| `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }`
|
|
76
|
+
| Route | Method | Body |
|
|
77
|
+
| -------------- | ------ | ------------------------------------- |
|
|
78
|
+
| `parse` | POST | `{ "url": "https://..." }` |
|
|
79
|
+
| `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
|
|
80
|
+
| `bulk-scan` | POST | `{ "handle": "somefoodblogger" }` |
|
|
81
|
+
| `bulk-create` | POST | `{ "shortcodes": ["DXkhLGbEcOY"] }` |
|
|
70
82
|
|
|
71
83
|
Base path: `/_emdash/api/plugins/peristyle-instagram-to-recipe/<route>`
|
|
72
84
|
|
|
73
85
|
## Limitations
|
|
74
86
|
|
|
75
87
|
- **Public posts only** — private or login-walled posts cannot be read
|
|
76
|
-
- **Caption format** — works best with “What you'll need”
|
|
77
|
-
- **
|
|
78
|
-
- **
|
|
79
|
-
- **
|
|
88
|
+
- **Caption format** — works best with a “What you'll need” / “Ingredients:” section plus numbered steps
|
|
89
|
+
- **Bulk scan depth** — one profile request returns roughly the 12 most recent posts; older posts can still be imported individually by URL
|
|
90
|
+
- **Instagram rate limits** — repeated fetches may hit HTTP 429; responses are cached for 24 hours and stale cache is used as a fallback, but you may need to wait and retry
|
|
91
|
+
- **No video frames** — video posts use the feed thumbnail, not in-video text
|
|
92
|
+
|
|
93
|
+
## Building from source
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
The TypeScript source ships in the package under `src/`. From an unpacked copy:
|
|
82
96
|
|
|
83
97
|
```bash
|
|
84
|
-
cd plugins/instagram-to-recipe
|
|
85
98
|
pnpm install
|
|
86
99
|
pnpm build
|
|
87
100
|
pnpm typecheck
|
|
88
101
|
```
|
|
89
102
|
|
|
103
|
+
The development repository is not currently public.
|
|
104
|
+
|
|
90
105
|
## License
|
|
91
106
|
|
|
92
107
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { PluginDescriptor } from 'emdash';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
instagramHandle?: string;
|
|
11
|
-
};
|
|
12
|
-
declare function instagramToRecipePlugin(options?: InstagramToRecipePluginOptions): PluginDescriptor<InstagramToRecipePluginOptions>;
|
|
3
|
+
/**
|
|
4
|
+
* Standard-format EmDash plugins are configured via the admin UI (KV
|
|
5
|
+
* settings), not constructor options — set the target collection and
|
|
6
|
+
* Instagram handle under Plugins → Import from Instagram → Settings.
|
|
7
|
+
*/
|
|
8
|
+
declare function instagramToRecipePlugin(): PluginDescriptor;
|
|
13
9
|
|
|
14
|
-
export {
|
|
10
|
+
export { instagramToRecipePlugin };
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
function instagramToRecipePlugin(
|
|
2
|
+
function instagramToRecipePlugin() {
|
|
3
3
|
return {
|
|
4
4
|
id: "peristyle-instagram-to-recipe",
|
|
5
|
-
version: "0.1.
|
|
5
|
+
version: "0.1.6",
|
|
6
6
|
format: "standard",
|
|
7
7
|
entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
|
|
8
|
-
options,
|
|
9
8
|
capabilities: [
|
|
10
9
|
"content:read",
|
|
11
10
|
"content:write",
|
package/dist/sandbox-entry.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ declare const _default: {
|
|
|
60
60
|
"plugin:install": {
|
|
61
61
|
handler: (_event: unknown, ctx: PluginContext) => Promise<void>;
|
|
62
62
|
};
|
|
63
|
+
"content:afterDelete": {
|
|
64
|
+
handler: (event: {
|
|
65
|
+
id: string;
|
|
66
|
+
collection: string;
|
|
67
|
+
}, ctx: PluginContext) => Promise<void>;
|
|
68
|
+
};
|
|
63
69
|
};
|
|
64
70
|
routes: {
|
|
65
71
|
parse: {
|
package/dist/sandbox-entry.js
CHANGED
|
@@ -96,8 +96,47 @@ function importFormBlocks(initialUrl = "") {
|
|
|
96
96
|
}
|
|
97
97
|
];
|
|
98
98
|
}
|
|
99
|
-
function
|
|
100
|
-
return [
|
|
99
|
+
function settingsFormBlocks(current) {
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
type: "header",
|
|
103
|
+
text: "Settings"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "context",
|
|
107
|
+
text: "Drafts are created in the collection below. The Instagram handle is optional \u2014 it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: "form",
|
|
111
|
+
block_id: "settings-form",
|
|
112
|
+
fields: [
|
|
113
|
+
{
|
|
114
|
+
type: "text_input",
|
|
115
|
+
action_id: "collection",
|
|
116
|
+
label: "Content collection",
|
|
117
|
+
placeholder: "posts",
|
|
118
|
+
initial_value: current.collection
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: "text_input",
|
|
122
|
+
action_id: "instagram_handle",
|
|
123
|
+
label: "Instagram handle (optional)",
|
|
124
|
+
placeholder: "@yourhandle",
|
|
125
|
+
initial_value: current.instagramHandle
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
submit: { label: "Save settings", action_id: "save_settings" }
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
function pageBlocks(settings) {
|
|
133
|
+
return [
|
|
134
|
+
...importFormBlocks(),
|
|
135
|
+
{ type: "divider" },
|
|
136
|
+
...bulkFormBlocks(settings.instagramHandle),
|
|
137
|
+
{ type: "divider" },
|
|
138
|
+
...settingsFormBlocks(settings)
|
|
139
|
+
];
|
|
101
140
|
}
|
|
102
141
|
function previewBlocks(recipe, options) {
|
|
103
142
|
const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
|
|
@@ -1040,6 +1079,7 @@ var instagramBrowserUserAgent = BROWSER_UA2;
|
|
|
1040
1079
|
var DEFAULT_COLLECTION = "posts";
|
|
1041
1080
|
var PARSE_CACHE_PREFIX = "parse:";
|
|
1042
1081
|
var IMPORT_MAP_PREFIX = "imported:";
|
|
1082
|
+
var IMPORT_BY_ID_PREFIX = "imported-by-id:";
|
|
1043
1083
|
var PROFILE_CACHE_PREFIX = "igprofile:";
|
|
1044
1084
|
var BULK_CREATE_DELAY_MS = 600;
|
|
1045
1085
|
function sleep2(ms) {
|
|
@@ -1048,12 +1088,21 @@ function sleep2(ms) {
|
|
|
1048
1088
|
async function collectionSlug(ctx) {
|
|
1049
1089
|
return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
|
|
1050
1090
|
}
|
|
1091
|
+
async function pluginSettings(ctx) {
|
|
1092
|
+
return {
|
|
1093
|
+
collection: await collectionSlug(ctx),
|
|
1094
|
+
instagramHandle: await ctx.kv.get("config:instagram_handle") ?? ""
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1051
1097
|
function parseCacheKey(shortcode) {
|
|
1052
1098
|
return `${PARSE_CACHE_PREFIX}${shortcode}`;
|
|
1053
1099
|
}
|
|
1054
1100
|
function importMapKey(shortcode) {
|
|
1055
1101
|
return `${IMPORT_MAP_PREFIX}${shortcode}`;
|
|
1056
1102
|
}
|
|
1103
|
+
function importByIdKey(contentId) {
|
|
1104
|
+
return `${IMPORT_BY_ID_PREFIX}${contentId}`;
|
|
1105
|
+
}
|
|
1057
1106
|
async function httpFetch(ctx, url, init) {
|
|
1058
1107
|
if (!ctx.http) {
|
|
1059
1108
|
throw new Error("Network access is not available for this plugin.");
|
|
@@ -1188,6 +1237,7 @@ async function createDraftFromRecipe(ctx, shortcode) {
|
|
|
1188
1237
|
const data = recipeToPostWriteData(recipe, featuredImage);
|
|
1189
1238
|
const item = await ctx.content.create(collection, data);
|
|
1190
1239
|
await ctx.kv.set(importMapKey(shortcode), item.id);
|
|
1240
|
+
await ctx.kv.set(importByIdKey(item.id), shortcode);
|
|
1191
1241
|
return {
|
|
1192
1242
|
contentId: item.id,
|
|
1193
1243
|
title: recipe.title,
|
|
@@ -1270,6 +1320,19 @@ var sandbox_entry_default = {
|
|
|
1270
1320
|
await ctx.kv.set("config:collection", DEFAULT_COLLECTION);
|
|
1271
1321
|
}
|
|
1272
1322
|
}
|
|
1323
|
+
},
|
|
1324
|
+
// Deleting an imported recipe post doesn't otherwise touch this plugin's
|
|
1325
|
+
// own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
|
|
1326
|
+
// treating the source Instagram post as already imported forever.
|
|
1327
|
+
"content:afterDelete": {
|
|
1328
|
+
handler: async (event, ctx) => {
|
|
1329
|
+
const collection = await collectionSlug(ctx);
|
|
1330
|
+
if (event.collection !== collection) return;
|
|
1331
|
+
const shortcode = await ctx.kv.get(importByIdKey(event.id));
|
|
1332
|
+
if (!shortcode) return;
|
|
1333
|
+
await ctx.kv.delete(importByIdKey(event.id));
|
|
1334
|
+
await ctx.kv.delete(importMapKey(shortcode));
|
|
1335
|
+
}
|
|
1273
1336
|
}
|
|
1274
1337
|
},
|
|
1275
1338
|
routes: {
|
|
@@ -1319,11 +1382,44 @@ var sandbox_entry_default = {
|
|
|
1319
1382
|
handler: async (routeCtx, ctx) => {
|
|
1320
1383
|
const interaction = routeCtx.input ?? { type: "page_load" };
|
|
1321
1384
|
if (interaction.type === "page_load") {
|
|
1322
|
-
return { blocks: pageBlocks() };
|
|
1385
|
+
return { blocks: pageBlocks(await pluginSettings(ctx)) };
|
|
1323
1386
|
}
|
|
1324
1387
|
const instagramHandle = String(
|
|
1325
1388
|
interaction.values?.instagram_handle ?? ""
|
|
1326
1389
|
).trim();
|
|
1390
|
+
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
|
|
1391
|
+
const collection = String(interaction.values?.collection ?? "").trim().toLowerCase();
|
|
1392
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
|
|
1393
|
+
return {
|
|
1394
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1395
|
+
toast: {
|
|
1396
|
+
message: "Collection must be a slug (letters, numbers, dashes).",
|
|
1397
|
+
type: "error"
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
let handle = "";
|
|
1402
|
+
if (instagramHandle) {
|
|
1403
|
+
try {
|
|
1404
|
+
handle = normalizeInstagramHandle(instagramHandle);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return {
|
|
1407
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1408
|
+
toast: { message: "Invalid Instagram handle.", type: "error" }
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
await ctx.kv.set("config:collection", collection);
|
|
1413
|
+
if (handle) {
|
|
1414
|
+
await ctx.kv.set("config:instagram_handle", handle);
|
|
1415
|
+
} else {
|
|
1416
|
+
await ctx.kv.delete("config:instagram_handle");
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
blocks: pageBlocks({ collection, instagramHandle: handle }),
|
|
1420
|
+
toast: { message: "Settings saved", type: "success" }
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1327
1423
|
if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
|
|
1328
1424
|
try {
|
|
1329
1425
|
const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
|
|
@@ -1342,7 +1438,7 @@ var sandbox_entry_default = {
|
|
|
1342
1438
|
const message = error instanceof Error ? error.message : String(error);
|
|
1343
1439
|
return {
|
|
1344
1440
|
blocks: [
|
|
1345
|
-
...pageBlocks(),
|
|
1441
|
+
...pageBlocks(await pluginSettings(ctx)),
|
|
1346
1442
|
{
|
|
1347
1443
|
type: "banner",
|
|
1348
1444
|
title: "Could not scan profile",
|
|
@@ -1359,7 +1455,7 @@ var sandbox_entry_default = {
|
|
|
1359
1455
|
const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
1360
1456
|
if (shortcodes.length === 0) {
|
|
1361
1457
|
return {
|
|
1362
|
-
blocks: pageBlocks(),
|
|
1458
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1363
1459
|
toast: { message: "Select at least one recipe", type: "error" }
|
|
1364
1460
|
};
|
|
1365
1461
|
}
|
|
@@ -1379,7 +1475,7 @@ var sandbox_entry_default = {
|
|
|
1379
1475
|
} catch (error) {
|
|
1380
1476
|
const message = error instanceof Error ? error.message : String(error);
|
|
1381
1477
|
return {
|
|
1382
|
-
blocks: pageBlocks(),
|
|
1478
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1383
1479
|
toast: { message, type: "error" }
|
|
1384
1480
|
};
|
|
1385
1481
|
}
|
package/package.json
CHANGED
package/src/admin-blocks.ts
CHANGED
|
@@ -30,9 +30,54 @@ export function importFormBlocks(initialUrl = ""): Block[] {
|
|
|
30
30
|
];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
export type PluginSettings = {
|
|
34
|
+
collection: string;
|
|
35
|
+
instagramHandle: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function settingsFormBlocks(current: PluginSettings): Block[] {
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: "header",
|
|
42
|
+
text: "Settings",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "context",
|
|
46
|
+
text: "Drafts are created in the collection below. The Instagram handle is optional — it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: "form",
|
|
50
|
+
block_id: "settings-form",
|
|
51
|
+
fields: [
|
|
52
|
+
{
|
|
53
|
+
type: "text_input",
|
|
54
|
+
action_id: "collection",
|
|
55
|
+
label: "Content collection",
|
|
56
|
+
placeholder: "posts",
|
|
57
|
+
initial_value: current.collection,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "text_input",
|
|
61
|
+
action_id: "instagram_handle",
|
|
62
|
+
label: "Instagram handle (optional)",
|
|
63
|
+
placeholder: "@yourhandle",
|
|
64
|
+
initial_value: current.instagramHandle,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
submit: { label: "Save settings", action_id: "save_settings" },
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Landing view: single-post import, bulk profile import, settings. */
|
|
73
|
+
export function pageBlocks(settings: PluginSettings): Block[] {
|
|
74
|
+
return [
|
|
75
|
+
...importFormBlocks(),
|
|
76
|
+
{ type: "divider" },
|
|
77
|
+
...bulkFormBlocks(settings.instagramHandle),
|
|
78
|
+
{ type: "divider" },
|
|
79
|
+
...settingsFormBlocks(settings),
|
|
80
|
+
];
|
|
36
81
|
}
|
|
37
82
|
|
|
38
83
|
export function previewBlocks(
|
package/src/index.ts
CHANGED
|
@@ -3,25 +3,17 @@ import type { PluginDescriptor } from "emdash";
|
|
|
3
3
|
/** Replaced at build time by tsup `define` from package.json — single source of truth. */
|
|
4
4
|
declare const __PLUGIN_VERSION__: string;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*/
|
|
13
|
-
instagramHandle?: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function instagramToRecipePlugin(
|
|
17
|
-
options: InstagramToRecipePluginOptions = {},
|
|
18
|
-
): PluginDescriptor<InstagramToRecipePluginOptions> {
|
|
6
|
+
/**
|
|
7
|
+
* Standard-format EmDash plugins are configured via the admin UI (KV
|
|
8
|
+
* settings), not constructor options — set the target collection and
|
|
9
|
+
* Instagram handle under Plugins → Import from Instagram → Settings.
|
|
10
|
+
*/
|
|
11
|
+
export function instagramToRecipePlugin(): PluginDescriptor {
|
|
19
12
|
return {
|
|
20
13
|
id: "peristyle-instagram-to-recipe",
|
|
21
14
|
version: __PLUGIN_VERSION__,
|
|
22
15
|
format: "standard",
|
|
23
16
|
entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
|
|
24
|
-
options,
|
|
25
17
|
capabilities: [
|
|
26
18
|
"content:read",
|
|
27
19
|
"content:write",
|
|
@@ -17,8 +17,8 @@ function cookieJarPath(handle: string): string {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
* Used when fetch()
|
|
20
|
+
* curl + cookie-jar fallback: prime cookies on the profile page, then call
|
|
21
|
+
* web_profile_info. Used when fetch() fails (often HTTP 429).
|
|
22
22
|
*/
|
|
23
23
|
export async function fetchInstagramProfileViaCurl(
|
|
24
24
|
handle: string,
|
|
@@ -43,7 +43,7 @@ export function extractOwnerUsername(html: string): string | undefined {
|
|
|
43
43
|
return undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** Post data resolved from the profile grid
|
|
46
|
+
/** Post data resolved from the profile grid. */
|
|
47
47
|
export type ProfilePostNode = {
|
|
48
48
|
shortcode: string;
|
|
49
49
|
caption: string;
|
|
@@ -53,7 +53,7 @@ export type ProfilePostNode = {
|
|
|
53
53
|
isVideo: boolean;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/** Cached web_profile_info payload for one handle. */
|
|
57
57
|
export type CachedInstagramProfile = {
|
|
58
58
|
fetchedAt: string;
|
|
59
59
|
handle: string;
|
|
@@ -106,7 +106,7 @@ export function postNodeFromProfileUser(
|
|
|
106
106
|
return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/** node.display_url from the profile grid — the feed image URL. */
|
|
110
110
|
export function displayUrlFromProfileUser(
|
|
111
111
|
user: unknown,
|
|
112
112
|
shortcode: string,
|
|
@@ -127,7 +127,7 @@ function profileApiHeaders(handle: string): HeadersInit {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
//
|
|
130
|
+
// 3 retries with exponential backoff (2.5s, 5s, 10s).
|
|
131
131
|
const PROFILE_FETCH_RETRIES = 3;
|
|
132
132
|
const PROFILE_RETRY_BASE_DELAY_MS = 2500;
|
|
133
133
|
|
|
@@ -155,8 +155,7 @@ export type ProfileDisplayUrlResult = {
|
|
|
155
155
|
};
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Resolve display_url via web_profile_info
|
|
159
|
-
* init-brand-resources brand-bootstrap (postsFromProfile → displayUrl).
|
|
158
|
+
* Resolve display_url via one web_profile_info request for the handle.
|
|
160
159
|
*/
|
|
161
160
|
async function fetchProfileUserOnce(
|
|
162
161
|
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
@@ -228,9 +227,9 @@ async function cacheSet(
|
|
|
228
227
|
}
|
|
229
228
|
|
|
230
229
|
/**
|
|
231
|
-
* Fetch the post's grid node via web_profile_info,
|
|
232
|
-
*
|
|
233
|
-
*
|
|
230
|
+
* Fetch the post's grid node via web_profile_info: fresh cache first, then
|
|
231
|
+
* live fetch with backoff, then stale cache on failure, then the curl
|
|
232
|
+
* cookie-jar fallback.
|
|
234
233
|
*/
|
|
235
234
|
export async function fetchProfilePostNode(
|
|
236
235
|
fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
@@ -275,7 +274,7 @@ export async function fetchProfilePostNode(
|
|
|
275
274
|
if (!result.retryable) break;
|
|
276
275
|
}
|
|
277
276
|
|
|
278
|
-
// Stale cache beats no image
|
|
277
|
+
// Stale cache beats no image when Instagram is rate-limiting.
|
|
279
278
|
if (cached) {
|
|
280
279
|
const node = postNodeFromProfileUser(cached.user, shortcode);
|
|
281
280
|
if (node) {
|
|
@@ -146,8 +146,9 @@ export function extractEmbeddedImageUrl(html: string): string | undefined {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
|
-
* Resolve the post's feed image (
|
|
150
|
-
* Never uses og:video. Uses og:image only when no embedded
|
|
149
|
+
* Resolve the post's feed image (the embedded display_url, not the OG
|
|
150
|
+
* share-card). Never uses og:video. Uses og:image only when no embedded
|
|
151
|
+
* display_url exists.
|
|
151
152
|
*/
|
|
152
153
|
export function resolvePostImageUrl(
|
|
153
154
|
html: string,
|
|
@@ -300,7 +301,7 @@ export type FetchedInstagramPost = {
|
|
|
300
301
|
};
|
|
301
302
|
|
|
302
303
|
export type FetchInstagramPostOptions = {
|
|
303
|
-
/** Override owner handle for web_profile_info lookup
|
|
304
|
+
/** Override owner handle for the web_profile_info lookup. */
|
|
304
305
|
instagramHandle?: string;
|
|
305
306
|
/** Profile payload cache (24h TTL, stale fallback on 429) — back with ctx.kv. */
|
|
306
307
|
profileCache?: InstagramProfileCache;
|
|
@@ -358,10 +359,10 @@ export async function fetchInstagramPost(
|
|
|
358
359
|
const { shortcode } = parseInstagramPostUrl(postUrl);
|
|
359
360
|
const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
|
|
360
361
|
|
|
361
|
-
// Profile-API-first
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
362
|
+
// Profile-API-first: a single (cached) web_profile_info request supplies
|
|
363
|
+
// caption, display_url, and is_video. Anonymous HTML page loads are what
|
|
364
|
+
// trip Instagram's rate limiter, so they are reserved for the fallback
|
|
365
|
+
// below.
|
|
365
366
|
const configuredHandle = options.instagramHandle
|
|
366
367
|
? normalizeInstagramHandle(options.instagramHandle)
|
|
367
368
|
: undefined;
|
|
@@ -412,8 +413,8 @@ export async function fetchInstagramPost(
|
|
|
412
413
|
const cookieHeader = cookieHeaderFromResponse(res);
|
|
413
414
|
const ownerHandle = extractOwnerUsername(html);
|
|
414
415
|
|
|
415
|
-
//
|
|
416
|
-
//
|
|
416
|
+
// Image source: web_profile_info → node.display_url. Skip the lookup when
|
|
417
|
+
// the configured handle already answered for this owner.
|
|
417
418
|
if (ownerHandle && ownerHandle !== configuredHandle) {
|
|
418
419
|
profileResult = await fetchProfilePostNode(
|
|
419
420
|
fetchFn,
|
package/src/sandbox-entry.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
pageBlocks,
|
|
10
10
|
previewBlocks,
|
|
11
11
|
type BulkCandidate,
|
|
12
|
+
type PluginSettings,
|
|
12
13
|
} from "./admin-blocks.js";
|
|
13
14
|
import {
|
|
14
15
|
buildBulkRecipesFromPosts,
|
|
@@ -34,6 +35,7 @@ import { recipeToPostWriteData } from "./recipe-content.js";
|
|
|
34
35
|
const DEFAULT_COLLECTION = "posts";
|
|
35
36
|
const PARSE_CACHE_PREFIX = "parse:";
|
|
36
37
|
const IMPORT_MAP_PREFIX = "imported:";
|
|
38
|
+
const IMPORT_BY_ID_PREFIX = "imported-by-id:";
|
|
37
39
|
const PROFILE_CACHE_PREFIX = "igprofile:";
|
|
38
40
|
|
|
39
41
|
/** Delay between draft creations so per-post image downloads stay polite. */
|
|
@@ -56,6 +58,14 @@ async function collectionSlug(ctx: PluginContext): Promise<string> {
|
|
|
56
58
|
return (await ctx.kv.get<string>("config:collection")) ?? DEFAULT_COLLECTION;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
async function pluginSettings(ctx: PluginContext): Promise<PluginSettings> {
|
|
62
|
+
return {
|
|
63
|
+
collection: await collectionSlug(ctx),
|
|
64
|
+
instagramHandle:
|
|
65
|
+
(await ctx.kv.get<string>("config:instagram_handle")) ?? "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
function parseCacheKey(shortcode: string): string {
|
|
60
70
|
return `${PARSE_CACHE_PREFIX}${shortcode}`;
|
|
61
71
|
}
|
|
@@ -64,6 +74,10 @@ function importMapKey(shortcode: string): string {
|
|
|
64
74
|
return `${IMPORT_MAP_PREFIX}${shortcode}`;
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
function importByIdKey(contentId: string): string {
|
|
78
|
+
return `${IMPORT_BY_ID_PREFIX}${contentId}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
|
|
68
82
|
if (!ctx.http) {
|
|
69
83
|
throw new Error("Network access is not available for this plugin.");
|
|
@@ -71,7 +85,7 @@ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
|
|
|
71
85
|
return ctx.http.fetch(url, init);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
/**
|
|
88
|
+
/** Profile payload cache backed by plugin KV (24h TTL, stale fallback). */
|
|
75
89
|
function profileCacheForCtx(ctx: PluginContext): InstagramProfileCache {
|
|
76
90
|
return {
|
|
77
91
|
async get(handle) {
|
|
@@ -261,6 +275,7 @@ async function createDraftFromRecipe(
|
|
|
261
275
|
|
|
262
276
|
const item = await ctx.content.create(collection, data);
|
|
263
277
|
await ctx.kv.set(importMapKey(shortcode), item.id);
|
|
278
|
+
await ctx.kv.set(importByIdKey(item.id), shortcode);
|
|
264
279
|
|
|
265
280
|
return {
|
|
266
281
|
contentId: item.id,
|
|
@@ -397,6 +412,24 @@ export default {
|
|
|
397
412
|
}
|
|
398
413
|
},
|
|
399
414
|
},
|
|
415
|
+
// Deleting an imported recipe post doesn't otherwise touch this plugin's
|
|
416
|
+
// own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
|
|
417
|
+
// treating the source Instagram post as already imported forever.
|
|
418
|
+
"content:afterDelete": {
|
|
419
|
+
handler: async (
|
|
420
|
+
event: { id: string; collection: string },
|
|
421
|
+
ctx: PluginContext,
|
|
422
|
+
) => {
|
|
423
|
+
const collection = await collectionSlug(ctx);
|
|
424
|
+
if (event.collection !== collection) return;
|
|
425
|
+
|
|
426
|
+
const shortcode = await ctx.kv.get<string>(importByIdKey(event.id));
|
|
427
|
+
if (!shortcode) return;
|
|
428
|
+
|
|
429
|
+
await ctx.kv.delete(importByIdKey(event.id));
|
|
430
|
+
await ctx.kv.delete(importMapKey(shortcode));
|
|
431
|
+
},
|
|
432
|
+
},
|
|
400
433
|
},
|
|
401
434
|
|
|
402
435
|
routes: {
|
|
@@ -460,13 +493,57 @@ export default {
|
|
|
460
493
|
const interaction = routeCtx.input ?? { type: "page_load" };
|
|
461
494
|
|
|
462
495
|
if (interaction.type === "page_load") {
|
|
463
|
-
return { blocks: pageBlocks() };
|
|
496
|
+
return { blocks: pageBlocks(await pluginSettings(ctx)) };
|
|
464
497
|
}
|
|
465
498
|
|
|
466
499
|
const instagramHandle = String(
|
|
467
500
|
interaction.values?.instagram_handle ?? "",
|
|
468
501
|
).trim();
|
|
469
502
|
|
|
503
|
+
if (
|
|
504
|
+
interaction.type === "form_submit" &&
|
|
505
|
+
interaction.action_id === "save_settings"
|
|
506
|
+
) {
|
|
507
|
+
const collection = String(interaction.values?.collection ?? "")
|
|
508
|
+
.trim()
|
|
509
|
+
.toLowerCase();
|
|
510
|
+
|
|
511
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
|
|
512
|
+
return {
|
|
513
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
514
|
+
toast: {
|
|
515
|
+
message:
|
|
516
|
+
"Collection must be a slug (letters, numbers, dashes).",
|
|
517
|
+
type: "error",
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let handle = "";
|
|
523
|
+
if (instagramHandle) {
|
|
524
|
+
try {
|
|
525
|
+
handle = normalizeInstagramHandle(instagramHandle);
|
|
526
|
+
} catch {
|
|
527
|
+
return {
|
|
528
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
529
|
+
toast: { message: "Invalid Instagram handle.", type: "error" },
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await ctx.kv.set("config:collection", collection);
|
|
535
|
+
if (handle) {
|
|
536
|
+
await ctx.kv.set("config:instagram_handle", handle);
|
|
537
|
+
} else {
|
|
538
|
+
await ctx.kv.delete("config:instagram_handle");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
blocks: pageBlocks({ collection, instagramHandle: handle }),
|
|
543
|
+
toast: { message: "Settings saved", type: "success" },
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
470
547
|
if (
|
|
471
548
|
interaction.type === "form_submit" &&
|
|
472
549
|
interaction.action_id === "bulk_scan"
|
|
@@ -492,7 +569,7 @@ export default {
|
|
|
492
569
|
error instanceof Error ? error.message : String(error);
|
|
493
570
|
return {
|
|
494
571
|
blocks: [
|
|
495
|
-
...pageBlocks(),
|
|
572
|
+
...pageBlocks(await pluginSettings(ctx)),
|
|
496
573
|
{
|
|
497
574
|
type: "banner",
|
|
498
575
|
title: "Could not scan profile",
|
|
@@ -516,7 +593,7 @@ export default {
|
|
|
516
593
|
|
|
517
594
|
if (shortcodes.length === 0) {
|
|
518
595
|
return {
|
|
519
|
-
blocks: pageBlocks(),
|
|
596
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
520
597
|
toast: { message: "Select at least one recipe", type: "error" },
|
|
521
598
|
};
|
|
522
599
|
}
|
|
@@ -545,7 +622,7 @@ export default {
|
|
|
545
622
|
const message =
|
|
546
623
|
error instanceof Error ? error.message : String(error);
|
|
547
624
|
return {
|
|
548
|
-
blocks: pageBlocks(),
|
|
625
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
549
626
|
toast: { message, type: "error" },
|
|
550
627
|
};
|
|
551
628
|
}
|