@peristyle/emdash-plugin-instagram-to-recipe 0.1.5 → 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.js +84 -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 +58 -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.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");
|
|
@@ -1049,6 +1088,12 @@ function sleep2(ms) {
|
|
|
1049
1088
|
async function collectionSlug(ctx) {
|
|
1050
1089
|
return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
|
|
1051
1090
|
}
|
|
1091
|
+
async function pluginSettings(ctx) {
|
|
1092
|
+
return {
|
|
1093
|
+
collection: await collectionSlug(ctx),
|
|
1094
|
+
instagramHandle: await ctx.kv.get("config:instagram_handle") ?? ""
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1052
1097
|
function parseCacheKey(shortcode) {
|
|
1053
1098
|
return `${PARSE_CACHE_PREFIX}${shortcode}`;
|
|
1054
1099
|
}
|
|
@@ -1337,11 +1382,44 @@ var sandbox_entry_default = {
|
|
|
1337
1382
|
handler: async (routeCtx, ctx) => {
|
|
1338
1383
|
const interaction = routeCtx.input ?? { type: "page_load" };
|
|
1339
1384
|
if (interaction.type === "page_load") {
|
|
1340
|
-
return { blocks: pageBlocks() };
|
|
1385
|
+
return { blocks: pageBlocks(await pluginSettings(ctx)) };
|
|
1341
1386
|
}
|
|
1342
1387
|
const instagramHandle = String(
|
|
1343
1388
|
interaction.values?.instagram_handle ?? ""
|
|
1344
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
|
+
}
|
|
1345
1423
|
if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
|
|
1346
1424
|
try {
|
|
1347
1425
|
const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
|
|
@@ -1360,7 +1438,7 @@ var sandbox_entry_default = {
|
|
|
1360
1438
|
const message = error instanceof Error ? error.message : String(error);
|
|
1361
1439
|
return {
|
|
1362
1440
|
blocks: [
|
|
1363
|
-
...pageBlocks(),
|
|
1441
|
+
...pageBlocks(await pluginSettings(ctx)),
|
|
1364
1442
|
{
|
|
1365
1443
|
type: "banner",
|
|
1366
1444
|
title: "Could not scan profile",
|
|
@@ -1377,7 +1455,7 @@ var sandbox_entry_default = {
|
|
|
1377
1455
|
const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
1378
1456
|
if (shortcodes.length === 0) {
|
|
1379
1457
|
return {
|
|
1380
|
-
blocks: pageBlocks(),
|
|
1458
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1381
1459
|
toast: { message: "Select at least one recipe", type: "error" }
|
|
1382
1460
|
};
|
|
1383
1461
|
}
|
|
@@ -1397,7 +1475,7 @@ var sandbox_entry_default = {
|
|
|
1397
1475
|
} catch (error) {
|
|
1398
1476
|
const message = error instanceof Error ? error.message : String(error);
|
|
1399
1477
|
return {
|
|
1400
|
-
blocks: pageBlocks(),
|
|
1478
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
1401
1479
|
toast: { message, type: "error" }
|
|
1402
1480
|
};
|
|
1403
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,
|
|
@@ -57,6 +58,14 @@ async function collectionSlug(ctx: PluginContext): Promise<string> {
|
|
|
57
58
|
return (await ctx.kv.get<string>("config:collection")) ?? DEFAULT_COLLECTION;
|
|
58
59
|
}
|
|
59
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
|
+
|
|
60
69
|
function parseCacheKey(shortcode: string): string {
|
|
61
70
|
return `${PARSE_CACHE_PREFIX}${shortcode}`;
|
|
62
71
|
}
|
|
@@ -76,7 +85,7 @@ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
|
|
|
76
85
|
return ctx.http.fetch(url, init);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
|
-
/**
|
|
88
|
+
/** Profile payload cache backed by plugin KV (24h TTL, stale fallback). */
|
|
80
89
|
function profileCacheForCtx(ctx: PluginContext): InstagramProfileCache {
|
|
81
90
|
return {
|
|
82
91
|
async get(handle) {
|
|
@@ -484,13 +493,57 @@ export default {
|
|
|
484
493
|
const interaction = routeCtx.input ?? { type: "page_load" };
|
|
485
494
|
|
|
486
495
|
if (interaction.type === "page_load") {
|
|
487
|
-
return { blocks: pageBlocks() };
|
|
496
|
+
return { blocks: pageBlocks(await pluginSettings(ctx)) };
|
|
488
497
|
}
|
|
489
498
|
|
|
490
499
|
const instagramHandle = String(
|
|
491
500
|
interaction.values?.instagram_handle ?? "",
|
|
492
501
|
).trim();
|
|
493
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
|
+
|
|
494
547
|
if (
|
|
495
548
|
interaction.type === "form_submit" &&
|
|
496
549
|
interaction.action_id === "bulk_scan"
|
|
@@ -516,7 +569,7 @@ export default {
|
|
|
516
569
|
error instanceof Error ? error.message : String(error);
|
|
517
570
|
return {
|
|
518
571
|
blocks: [
|
|
519
|
-
...pageBlocks(),
|
|
572
|
+
...pageBlocks(await pluginSettings(ctx)),
|
|
520
573
|
{
|
|
521
574
|
type: "banner",
|
|
522
575
|
title: "Could not scan profile",
|
|
@@ -540,7 +593,7 @@ export default {
|
|
|
540
593
|
|
|
541
594
|
if (shortcodes.length === 0) {
|
|
542
595
|
return {
|
|
543
|
-
blocks: pageBlocks(),
|
|
596
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
544
597
|
toast: { message: "Select at least one recipe", type: "error" },
|
|
545
598
|
};
|
|
546
599
|
}
|
|
@@ -569,7 +622,7 @@ export default {
|
|
|
569
622
|
const message =
|
|
570
623
|
error instanceof Error ? error.message : String(error);
|
|
571
624
|
return {
|
|
572
|
-
blocks: pageBlocks(),
|
|
625
|
+
blocks: pageBlocks(await pluginSettings(ctx)),
|
|
573
626
|
toast: { message, type: "error" },
|
|
574
627
|
};
|
|
575
628
|
}
|