Repository navigation
Feat: Read the Instagram caption from the post URL #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2164850
73ba290
93923d7
319c738
884a8d5
789576f
381c225
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 884a8d5. The README said Generated by Claude Code |
||
| npm test | ||
| npm run add-recipe -- --body-file request.md --dry-run | ||
| ``` | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| }, | ||
| "dependencies": { | ||
| "@google/genai": "^2.21.0", | ||
| "playwright": "^1.63.0", | ||
| "zod": "^4.5.4" | ||
| } | ||
| } | ||
| 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 } | ||
| } |
| 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') | ||
| }) |
There was a problem hiding this comment.
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.
playwright1.63.0 has no install script (itspackage.jsonhas noscripts, and the lockfile doesn't flag one), sonpm cinever downloads a browser to skip. The next step'snpx playwright install --with-deps chromiumdoes 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