Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 3 additions & 25 deletions .github/ISSUE_TEMPLATE/add-recipe.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,20 @@
name: Ajouter une recette
description: Coller la légende d'une publication pour en tirer une recette au format RecipeMD.
description: Coller le lien d'une publication Instagram pour en tirer une recette au format RecipeMD.
title: "Ajouter une recette : "
labels: ["add-recipe"]
body:
- type: markdown
attributes:
value: |
Coller la légende telle quelle. Le texte promotionnel, les hashtags et les mentions sont retirés automatiquement ; inutile de nettoyer quoi que ce soit.
Coller le lien de la publication. La légende et le compte sont lus directement sur Instagram ; le texte promotionnel, les hashtags et les mentions sont retirés automatiquement.

Une pull request est ouverte avec la recette. Rien n'est ajouté à la collection avant qu'elle soit relue et fusionnée.

- type: textarea
id: caption
attributes:
label: Caption
description: "La légende complète de la publication."
placeholder: |
Ingrédients :
• 4 cuisses de poulet
• 3 carottes
...
validations:
required: true

- type: input
id: url
attributes:
label: Source URL
description: "Le lien vers la publication. Les paramètres de suivi sont retirés."
placeholder: https://www.instagram.com/reel/XXXXXXXXXXX/
validations:
required: false

- type: input
id: handle
attributes:
label: Instagram handle
description: "Le compte à l'origine de la recette. Les liens de partage Instagram ne le contiennent pas, d'où ce champ. Il est ajouté au nom du dossier, donc à l'URL de la recette : « louloukitchen_ » donne « poulet-roti-louloukitchen »."
placeholder: louloukitchen_
validations:
required: false
required: true
31 changes: 12 additions & 19 deletions .github/workflows/add-recipe.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
name: Add a recipe from a caption

# Issue forms are the only trigger GitHub offers with a real textarea, and a
# caption is many lines long. workflow_dispatch is kept for the command line,
# where multi-line values pass fine through the API even though the web form
# renders a single-line box.
#
# `labeled` only, never `opened`: the issue form applies the label as it
# creates the issue, so listening for both ran everything twice. Labelling by
# hand afterwards is also a valid way in, and this covers it.
#
# workflow_dispatch takes the same single field as the issue form, for
# running this from the command line or the Actions tab.
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
caption:
description: The caption to turn into a recipe
required: true
url:
description: Link to the original post
required: false
handle:
description: Instagram handle of the account it came from
required: false
required: true

permissions:
contents: write
Expand Down Expand Up @@ -65,23 +57,24 @@ jobs:

- name: Install dependencies
run: npm ci
env:
# Only Chromium is used; the next step fetches just that one, with
# the system libraries it needs to actually launch headless.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does nothing, and it's left for you. playwright 1.63.0 has no install script (its package.json has no scripts, and the lockfile doesn't flag one), so npm ci never downloads a browser to skip. The next step's npx playwright install --with-deps chromium does all the work. The comment above says otherwise, so removing both would be clearer. It's harmless as it stands, and repo rules mean I don't edit workflow files without your approval.


Generated by Claude Code


- name: Install Chromium
run: npx playwright install --with-deps chromium

# The issue body is untrusted input, so it reaches the script through a
# file and an environment variable, never through the shell.
- name: Assemble the request
env:
EVENT_NAME: ${{ github.event_name }}
ISSUE_BODY: ${{ github.event.issue.body }}
INPUT_CAPTION: ${{ inputs.caption }}
INPUT_URL: ${{ inputs.url }}
INPUT_HANDLE: ${{ inputs.handle }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
{
printf '### Caption\n\n%s\n\n' "$INPUT_CAPTION"
printf '### Source URL\n\n%s\n\n' "${INPUT_URL:-_No response_}"
printf '### Instagram handle\n\n%s\n' "${INPUT_HANDLE:-_No response_}"
} > request.md
printf '### Source URL\n\n%s\n' "$INPUT_URL" > request.md
else
printf '%s' "$ISSUE_BODY" > request.md
fi
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,23 @@ They are served at [cookbook.eschults.org](https://cookbook.eschults.org) by [Es

Everything below is specific to this fork.

## Adding a recipe from a caption
## Adding a recipe from a post

Most recipes here start life as a post someone wrote for Instagram. Rather than reformat one by hand, open an [**Ajouter une recette**](../../issues/new?template=add-recipe.yml) issue, paste the caption, and a pull request appears with the recipe in RecipeMD.
Most recipes here start life as a post someone wrote for Instagram, and Instagram does not let a phone copy a caption. So open an [**Ajouter une recette**](../../issues/new?template=add-recipe.yml) issue, paste the post's link, and a pull request appears with the recipe in RecipeMD.

| Field | |
| --- | --- |
| **Caption** | The post text, pasted as-is. Promotional lines, hashtags and mentions are dropped for you. |
| **Source URL** | Optional. Tracking parameters are stripped before it becomes the recipe's source line. |
| **Instagram handle** | Optional, and appended to the directory name: `louloukitchen_` turns `poulet-roti` into `poulet-roti-louloukitchen`. Instagram share links do not carry the handle, which is why it needs its own field. |
A headless browser opens the post and reads its caption and the account that posted it straight off the page; Instagram does not require a login to see either. Tracking parameters are stripped from the link before it becomes the recipe's source line, the handle is appended to the directory name (`louloukitchen_` turns `poulet-roti` into `poulet-roti-louloukitchen`), and Gemini drops promotional lines, hashtags and mentions from the caption while turning it into a recipe. If the browser finds no caption on the page, the run fails rather than asking the model to guess one.

For a post the browser cannot read, write the recipe locally instead (see below) with the caption pasted into `request.md`: a caption given directly skips the fetch.

The model never writes RecipeMD. It fills in a schema, and [`scripts/add-recipe/render.js`](scripts/add-recipe/render.js) turns that into markdown, so a malformed document is not a failure mode it can reach. The result is parsed back with Cookbook's own parser and re-rendered; if those two disagree the run fails rather than committing a recipe that quietly lost an ingredient. The reference Python parser then checks it once more before the pull request opens.

Nothing reaches the collection unreviewed: the workflow only ever opens a pull request.

Running it locally needs a `GEMINI_API_KEY` in `.env` (copy `.env.example`) and a checkout of [Cookbook](https://github.com/Eschults/cookbook) beside this repository, or `COOKBOOK_DIR` pointing at one. `request.md` holds a pasted caption in the same shape the issue form produces, and `--dry-run` prints the recipe instead of writing it:
Running it locally needs a `GEMINI_API_KEY` in `.env` (copy `.env.example`) and a checkout of [Cookbook](https://github.com/Eschults/cookbook) beside this repository, or `COOKBOOK_DIR` pointing at one. Reading a caption from a post also needs the browser Playwright drives, which `npx playwright install chromium` downloads once. `request.md` holds a request in the same shape the issue form produces (a `### Caption` section skips the fetch), and `--dry-run` prints the recipe instead of writing it:

```bash
npm install
npx playwright install chromium

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 884a8d5. The README said npm install downloads the browser, but playwright has no install script, so a local run with only a URL would fail at chromium.launch() with "Executable doesn't exist".


Generated by Claude Code

npm test
npm run add-recipe -- --body-file request.md --dry-run
```
Expand Down
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"dependencies": {
"@google/genai": "^2.21.0",
"playwright": "^1.63.0",
"zod": "^4.5.4"
}
}
64 changes: 64 additions & 0 deletions scripts/add-recipe/instagram.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Post URL in, caption out.
*
* The caption never appears in the page Instagram sends over the wire: it is
* filled in by client-side JavaScript once the page has loaded, so a plain
* HTTP fetch sees an empty shell. A headless browser runs that JavaScript
* and reads the result, the same way Instagram tells search engines and link
* previews what the post says: through its `og:description` meta tag, which
* needs no login and carries the caption whole.
*/
import { chromium } from 'playwright'

const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'

/**
* Instagram writes this as `12K likes, 34 comments - handle on 24 September
* 2026: "caption text".` The caption is quoted, so it is read as everything
* between the first and last quote rather than split on ": ", which the
* caption itself is free to contain.
*/
export function parseOgDescription(description) {
const text = String(description ?? '')
const first = text.indexOf('"')
const last = text.lastIndexOf('"')
if (first === -1 || last <= first) return { caption: '', handle: '' }

const before = text.slice(0, first)
const handle = before.match(/(\S+)\s+on\s+[^:]+:\s*$/)?.[1] ?? ''

return { caption: text.slice(first + 1, last).trim(), handle }
}

async function readOgDescription(url, launch) {
const browser = await launch()

try {
const context = await browser.newContext({ userAgent: USER_AGENT })
const page = await context.newPage()
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
return await page.$eval('meta[property="og:description"]', element => element.content).catch(() => null)
} finally {
await browser.close()
}
}

export async function fetchCaption({ url, launch = () => chromium.launch() }) {
if (!url) throw new Error('The issue gives no post URL, so there is no caption to read')

const description = await readOgDescription(url, launch)
const answer = parseOgDescription(description)

if (!answer.caption) throw new Error(`Instagram showed no caption for ${url}`)

return answer
}

/** A caption given by hand wins, so the command line still works for a post the browser cannot read. */
export async function withCaption(request, { fetch = fetchCaption } = {}) {
if (request.caption) return request

const fetched = await fetch({ url: request.url })
return { ...request, caption: fetched.caption, handle: request.handle || fetched.handle }
}
3 changes: 2 additions & 1 deletion scripts/add-recipe/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { RecipeMDError } from './cookbook.js'
import { SchemaError, extractRecipe } from './extract.js'
import { withCaption } from './instagram.js'
import { parseIssueBody } from './issue.js'
import { parseToData } from './parse.js'
import { renderRecipeMD } from './render.js'
Expand Down Expand Up @@ -72,7 +73,7 @@ function readIssueBody(argv) {

async function run(argv = process.argv.slice(2)) {
const dryRun = argv.includes('--dry-run')
const { caption, url, handle } = parseIssueBody(readIssueBody(argv))
const { caption, url, handle } = await withCaption(parseIssueBody(readIssueBody(argv)))
const existingTags = collectExistingTags()

let markdown = null
Expand Down
93 changes: 93 additions & 0 deletions test/instagram.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fetchCaption, parseOgDescription, withCaption } from '../scripts/add-recipe/instagram.js'

const url = 'https://www.instagram.com/reel/DdrLS5ON1cI/'

/** Stands in for a Playwright `chromium.launch()` result, down to the calls fetchCaption makes. */
function fakeBrowser(description) {
const calls = { goto: null, closed: false }
const browser = {
async newContext(options) {
calls.contextOptions = options
return {
async newPage() {
return {
async goto(target, options) {
calls.goto = { target, options }
},
async $eval(selector, fn) {
if (description === null) throw new Error('no such element')
return fn({ content: description })
}
}
}
}
},
async close() {
calls.closed = true
}
}
return { launch: async () => browser, calls }
}

test('fetchCaption reads the caption and handle from og:description', async () => {
const { launch, calls } = fakeBrowser(
'60K likes, 387 comments - louloukitchen_ on September 24, 2026: "Ingrédients :\n• 3 carottes".'
)

assert.deepEqual(await fetchCaption({ url, launch }), {
caption: 'Ingrédients :\n• 3 carottes',
handle: 'louloukitchen_'
})
assert.equal(calls.goto.target, url)
assert.equal(calls.closed, true)
})

test('fetchCaption closes the browser even when the page has no caption', async () => {
const { launch, calls } = fakeBrowser(null)

await assert.rejects(fetchCaption({ url, launch }), /showed no caption/)
assert.equal(calls.closed, true)
})

test('fetchCaption fails when the description carries no quoted caption', async () => {
const { launch } = fakeBrowser('louloukitchen_ shared a photo.')

await assert.rejects(fetchCaption({ url, launch }), /showed no caption/)
})

test('fetchCaption needs a URL', async () => {
await assert.rejects(fetchCaption({ url: '' }), /no post URL/)
})

test('parseOgDescription handles a caption with no engagement counts', () => {
assert.deepEqual(
parseOgDescription('louloukitchen_ on September 24, 2026: "Une légende simple".'),
{ caption: 'Une légende simple', handle: 'louloukitchen_' }
)
})

test('parseOgDescription keeps quotes that appear inside the caption', () => {
assert.deepEqual(
parseOgDescription('1 like, 0 comments - chef on 1 January 2026: "Une recette "spéciale" du jour".'),
{ caption: 'Une recette "spéciale" du jour', handle: 'chef' }
)
})

test('parseOgDescription returns empty fields for text it cannot parse', () => {
assert.deepEqual(parseOgDescription('Instagram'), { caption: '', handle: '' })
assert.deepEqual(parseOgDescription(null), { caption: '', handle: '' })
})

test('withCaption fetches only when the request carries no caption', async () => {
const fetch = async () => ({ caption: 'fetched', handle: 'fetched_handle' })

assert.deepEqual(await withCaption({ caption: '', url, handle: '' }, { fetch }), {
caption: 'fetched', url, handle: 'fetched_handle'
})
assert.deepEqual(await withCaption({ caption: 'pasted', url, handle: '' }, { fetch: () => assert.fail('fetched') }), {
caption: 'pasted', url, handle: ''
})
assert.equal((await withCaption({ caption: '', url, handle: 'given' }, { fetch })).handle, 'given')
})
9 changes: 9 additions & 0 deletions test/issue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,12 @@ test('cleanSourceUrl keeps a URL it cannot parse', () => {
assert.equal(cleanSourceUrl('not a url'), 'not a url')
assert.equal(cleanSourceUrl(''), '')
})

test('a form with only the post URL reads as a request with no caption', () => {
const urlOnly = ['### Source URL', '', 'https://www.instagram.com/p/DTest/?igsh=abc'].join('\n')
assert.deepEqual(parseIssueBody(urlOnly), {
caption: '',
url: 'https://www.instagram.com/p/DTest/',
handle: ''
})
})