@peristyle/emdash-plugin-instagram-to-recipe 0.1.3 → 0.1.4
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/dist/index.js +1 -1
- package/dist/sandbox-entry.js +32 -7
- package/package.json +1 -1
- package/src/instagram-recipes.ts +61 -7
package/dist/index.js
CHANGED
package/dist/sandbox-entry.js
CHANGED
|
@@ -349,26 +349,51 @@ function extractTitleFromCaption(caption) {
|
|
|
349
349
|
}
|
|
350
350
|
return titleCase(firstSentence.slice(0, 68).trim());
|
|
351
351
|
}
|
|
352
|
+
var _QTY_UNIT_SPLIT = new RegExp(
|
|
353
|
+
String.raw`(?<=\s)(?=(?:\d[\d/.\s]*|[¼½¾⅓⅔⅛]\s*)(?:lbs?|pounds?|cups?|tsp|tbsp|teaspoons?|tablespoons?|oz|ounces?|cloves?|ears?|heads?|cans?|bunch(?:es)?|sticks?|pinch|dash|g|kg|ml|l)\b)` + String.raw`|(?<=\s)(?=(?:zest|juice)\s+of\b)` + String.raw`|(?<=\s)(?<!\b(?:of|about|around|approx)\s)(?=[\d¼½¾⅓⅔⅛][\d/.¼½¾⅓⅔⅛]*\s+[a-zA-Z])`,
|
|
354
|
+
"giu"
|
|
355
|
+
);
|
|
356
|
+
function looksCollapsed(line) {
|
|
357
|
+
const starts = line.split(_QTY_UNIT_SPLIT);
|
|
358
|
+
return line.split(/\s+/).length > 8 && starts.length >= 3;
|
|
359
|
+
}
|
|
360
|
+
function resplitCollapsedIngredientLine(line) {
|
|
361
|
+
if (!looksCollapsed(line)) return [line];
|
|
362
|
+
return line.split(_QTY_UNIT_SPLIT).map((part) => part.trim()).filter(Boolean);
|
|
363
|
+
}
|
|
352
364
|
function parseIngredientsFromCaption(caption) {
|
|
353
365
|
const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
|
|
354
366
|
if (!match || match.index == null) return [];
|
|
355
367
|
const afterHeader = caption.slice(match.index + match[0].length);
|
|
356
368
|
const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
|
|
357
|
-
|
|
369
|
+
let ingredientsBlock = stepsMatch?.index ? afterHeader.slice(0, stepsMatch.index) : afterHeader;
|
|
370
|
+
const instructionsHeader = ingredientsBlock.match(
|
|
371
|
+
/\b(?:instructions|directions|steps|method)\s*:/i
|
|
372
|
+
);
|
|
373
|
+
if (instructionsHeader?.index) {
|
|
374
|
+
ingredientsBlock = ingredientsBlock.slice(0, instructionsHeader.index);
|
|
375
|
+
}
|
|
358
376
|
const lines = ingredientsBlock.split(/\n/);
|
|
359
377
|
const ingredients = [];
|
|
360
378
|
for (const raw of lines) {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
379
|
+
for (const piece of resplitCollapsedIngredientLine(raw)) {
|
|
380
|
+
const line = piece.trim();
|
|
381
|
+
if (!line) continue;
|
|
382
|
+
if (/^\[[^\]]+\]$/.test(line)) continue;
|
|
383
|
+
if (/^recipe below/i.test(line)) continue;
|
|
384
|
+
ingredients.push(line);
|
|
385
|
+
}
|
|
366
386
|
}
|
|
367
387
|
return ingredients;
|
|
368
388
|
}
|
|
369
389
|
function parseInstructionsFromCaption(caption) {
|
|
370
390
|
const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
|
|
371
|
-
|
|
391
|
+
const steps = matches.map((m) => m[2]?.trim()).filter((s) => Boolean(s && s.length > 3));
|
|
392
|
+
if (steps.length > 0) return steps;
|
|
393
|
+
const header = caption.match(/\b(?:instructions|directions|steps|method)\s*:/i);
|
|
394
|
+
if (!header || header.index == null) return [];
|
|
395
|
+
const block = caption.slice(header.index + header[0].length);
|
|
396
|
+
return block.split(/(?=\b\d{1,2}\.\s)/).map((s) => s.replace(/^\d{1,2}\.\s*/, "").trim()).filter((s) => s.length > 3);
|
|
372
397
|
}
|
|
373
398
|
function parseRecipeCaption(caption) {
|
|
374
399
|
if (!isLikelyRecipeCaption(caption)) return null;
|
package/package.json
CHANGED
package/src/instagram-recipes.ts
CHANGED
|
@@ -117,25 +117,69 @@ export function extractTitleFromCaption(caption: string): string {
|
|
|
117
117
|
return titleCase(firstSentence.slice(0, 68).trim());
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// A quantity token starting a new ingredient: "1 lb", "1/3 cup", "½ tsp", "3
|
|
121
|
+
// cloves"... Used to re-split caption lines whose newlines were lost upstream
|
|
122
|
+
// (Instagram's og:description meta tag flattens single line breaks, so a whole
|
|
123
|
+
// ingredient section can arrive as one long line).
|
|
124
|
+
const _QTY_UNIT_SPLIT = new RegExp(
|
|
125
|
+
String.raw`(?<=\s)(?=(?:\d[\d/.\s]*|[¼½¾⅓⅔⅛]\s*)(?:lbs?|pounds?|cups?|tsp|tbsp|teaspoons?|tablespoons?|oz|ounces?|cloves?|ears?|heads?|cans?|bunch(?:es)?|sticks?|pinch|dash|g|kg|ml|l)\b)` +
|
|
126
|
+
String.raw`|(?<=\s)(?=(?:zest|juice)\s+of\b)` +
|
|
127
|
+
String.raw`|(?<=\s)(?<!\b(?:of|about|around|approx)\s)(?=[\d¼½¾⅓⅔⅛][\d/.¼½¾⅓⅔⅛]*\s+[a-zA-Z])`,
|
|
128
|
+
"giu",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** Count of quantity-like tokens — how many ingredients a line appears to hold. */
|
|
132
|
+
function looksCollapsed(line: string): boolean {
|
|
133
|
+
const starts = line.split(_QTY_UNIT_SPLIT);
|
|
134
|
+
return line.split(/\s+/).length > 8 && starts.length >= 3;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Split a line that holds several ingredients back into one line each.
|
|
139
|
+
*
|
|
140
|
+
* Lossy-input recovery only: applied when a line clearly contains multiple
|
|
141
|
+
* quantity tokens (see ``looksCollapsed``), never to normal one-ingredient
|
|
142
|
+
* lines. A trailing "Section name:" prefix is kept as its own line so the
|
|
143
|
+
* downstream header filters can drop it.
|
|
144
|
+
*/
|
|
145
|
+
export function resplitCollapsedIngredientLine(line: string): string[] {
|
|
146
|
+
if (!looksCollapsed(line)) return [line];
|
|
147
|
+
return line
|
|
148
|
+
.split(_QTY_UNIT_SPLIT)
|
|
149
|
+
.map((part) => part.trim())
|
|
150
|
+
.filter(Boolean);
|
|
151
|
+
}
|
|
152
|
+
|
|
120
153
|
export function parseIngredientsFromCaption(caption: string): string[] {
|
|
121
154
|
const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
|
|
122
155
|
if (!match || match.index == null) return [];
|
|
123
156
|
|
|
124
157
|
const afterHeader = caption.slice(match.index + match[0].length);
|
|
125
158
|
const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
|
|
126
|
-
|
|
159
|
+
let ingredientsBlock = stepsMatch?.index
|
|
127
160
|
? afterHeader.slice(0, stepsMatch.index)
|
|
128
161
|
: afterHeader;
|
|
162
|
+
// With a collapsed caption the numbered steps carry no leading newline, so
|
|
163
|
+
// the cut above misses. Cut again at an explicit instructions header so the
|
|
164
|
+
// steps can't leak into the ingredient list.
|
|
165
|
+
const instructionsHeader = ingredientsBlock.match(
|
|
166
|
+
/\b(?:instructions|directions|steps|method)\s*:/i,
|
|
167
|
+
);
|
|
168
|
+
if (instructionsHeader?.index) {
|
|
169
|
+
ingredientsBlock = ingredientsBlock.slice(0, instructionsHeader.index);
|
|
170
|
+
}
|
|
129
171
|
|
|
130
172
|
const lines = ingredientsBlock.split(/\n/);
|
|
131
173
|
const ingredients: string[] = [];
|
|
132
174
|
|
|
133
175
|
for (const raw of lines) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
176
|
+
for (const piece of resplitCollapsedIngredientLine(raw)) {
|
|
177
|
+
const line = piece.trim();
|
|
178
|
+
if (!line) continue;
|
|
179
|
+
if (/^\[[^\]]+\]$/.test(line)) continue;
|
|
180
|
+
if (/^recipe below/i.test(line)) continue;
|
|
181
|
+
ingredients.push(line);
|
|
182
|
+
}
|
|
139
183
|
}
|
|
140
184
|
|
|
141
185
|
return ingredients;
|
|
@@ -143,9 +187,19 @@ export function parseIngredientsFromCaption(caption: string): string[] {
|
|
|
143
187
|
|
|
144
188
|
export function parseInstructionsFromCaption(caption: string): string[] {
|
|
145
189
|
const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
|
|
146
|
-
|
|
190
|
+
const steps = matches
|
|
147
191
|
.map((m) => m[2]?.trim())
|
|
148
192
|
.filter((s): s is string => Boolean(s && s.length > 3));
|
|
193
|
+
if (steps.length > 0) return steps;
|
|
194
|
+
// Collapsed caption (og:description strips newlines): fall back to slicing
|
|
195
|
+
// the text after an instructions header on the "1." "2." markers themselves.
|
|
196
|
+
const header = caption.match(/\b(?:instructions|directions|steps|method)\s*:/i);
|
|
197
|
+
if (!header || header.index == null) return [];
|
|
198
|
+
const block = caption.slice(header.index + header[0].length);
|
|
199
|
+
return block
|
|
200
|
+
.split(/(?=\b\d{1,2}\.\s)/)
|
|
201
|
+
.map((s) => s.replace(/^\d{1,2}\.\s*/, "").trim())
|
|
202
|
+
.filter((s) => s.length > 3);
|
|
149
203
|
}
|
|
150
204
|
|
|
151
205
|
export function parseRecipeCaption(caption: string): {
|