diff --git a/.github/ISSUE_TEMPLATE/add-recipe.yml b/.github/ISSUE_TEMPLATE/add-recipe.yml index 37f340e..e01ad7b 100644 --- a/.github/ISSUE_TEMPLATE/add-recipe.yml +++ b/.github/ISSUE_TEMPLATE/add-recipe.yml @@ -1,28 +1,15 @@ 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: @@ -30,13 +17,4 @@ body: 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 diff --git a/.github/workflows/add-recipe.yml b/.github/workflows/add-recipe.yml index 3e642bb..c810676 100644 --- a/.github/workflows/add-recipe.yml +++ b/.github/workflows/add-recipe.yml @@ -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 @@ -65,6 +57,13 @@ 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" + + - 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. @@ -72,16 +71,10 @@ jobs: 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 diff --git a/README.md b/README.md index 281aa89..670433e 100644 --- a/README.md +++ b/README.md @@ -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 npm test npm run add-recipe -- --body-file request.md --dry-run ``` diff --git a/package-lock.json b/package-lock.json index b3cfe3d..6ff627d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "recipes-tooling", "dependencies": { "@google/genai": "^2.21.0", + "playwright": "^1.63.0", "zod": "^4.5.4" } }, @@ -386,6 +387,33 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/protobufjs": { "version": "7.6.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", diff --git a/package.json b/package.json index 16b124c..d0cf565 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@google/genai": "^2.21.0", + "playwright": "^1.63.0", "zod": "^4.5.4" } } diff --git a/scripts/add-recipe/instagram.js b/scripts/add-recipe/instagram.js new file mode 100644 index 0000000..6c5a040 --- /dev/null +++ b/scripts/add-recipe/instagram.js @@ -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 } +} diff --git a/scripts/add-recipe/main.js b/scripts/add-recipe/main.js index bdb2c32..d919ffd 100644 --- a/scripts/add-recipe/main.js +++ b/scripts/add-recipe/main.js @@ -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' @@ -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 diff --git a/test/instagram.test.js b/test/instagram.test.js new file mode 100644 index 0000000..1e41b6a --- /dev/null +++ b/test/instagram.test.js @@ -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') +}) diff --git a/test/issue.test.js b/test/issue.test.js index 8aa363a..9605a5a 100644 --- a/test/issue.test.js +++ b/test/issue.test.js @@ -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: '' + }) +})