No description
  • Python 66.3%
  • Nix 26.4%
  • Dockerfile 7.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
marti 95cbd9a75e
All checks were successful
ci / check (push) Successful in 1m2s
Rename to social-extract and generalise the heuristic
Nothing about fetching a caption and transcribing audio is specific to
recipes, so the name was narrower than the service. Renamed throughout:
package, CLI, NixOS module and options, systemd unit, cache dir, docs.

The heuristic was the part that actually was recipe-shaped.
looks_like_recipe() and a regex of tbsp and cucharadas became
caption_is_substantial() and a detail regex that counts any quantity -
grams in a recipe, reps in a workout, prices in a review.

Added the signal that matters more than any of them: a caption pointing
somewhere else ("link in bio", "full details below") is thin however long
and detailed it is, because the content is deliberately not in it. Verified
against both reels: the "Full recipe on website" one now transcribes, the
caption-complete one still does not.

MIN_CAPTION_CHARS makes the length gate tunable instead of hardcoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:42:10 +02:00
.forgejo/workflows ci: drop the pinned nixos/nix image 2026-09-19 12:33:21 +02:00
docs Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
nix Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
src/social_extract Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
tests Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
.editorconfig Initial commit: recipe-extract 2026-09-19 12:08:44 +02:00
.env.example Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
.gitattributes Initial commit: recipe-extract 2026-09-19 12:08:44 +02:00
.gitignore Initial commit: recipe-extract 2026-09-19 12:08:44 +02:00
AGENTS.md Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
CLAUDE.md Initial commit: recipe-extract 2026-09-19 12:08:44 +02:00
compose.yaml Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
Dockerfile Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
flake.lock Initial commit: recipe-extract 2026-09-19 12:08:44 +02:00
flake.nix Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
pyproject.toml Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00
README.md Rename to social-extract and generalise the heuristic 2026-09-19 12:42:10 +02:00

social-extract

Turn a social video or web URL into readable text, so something else can make sense of it.

Give it a link. yt-dlp resolves the post and returns the caption; when the caption is too thin to carry the content — the usual "full details in bio" — the audio is transcribed with whisper.cpp and both halves come back in one content string, ready to hand to a model.

It knows nothing about what it is extracting. A recipe reel, a workout, a product review and a conference talk all come back the same shape, and the caller decides what the text means.

It exists because Instagram stopped server-rendering captions into /embed/captioned/: that page is now a client-side React shell that returns the same bytes whether the post is real or invented, so scraping it cannot work. yt-dlp talks to the endpoints the app itself uses.

Because yt-dlp does the resolving, TikTok, YouTube, Facebook and Vimeo work the same way, with no per-platform code here.

Quickstart

nix run git+https://git.cumsek.com/marti/social-extract
curl -s localhost:8300/extract \
  -H 'content-type: application/json' \
  -d '{"url": "https://www.instagram.com/reel/SHORTCODE/"}' | jq

API

POST /extract

Field Type Default Meaning
url string required An http(s) URL. Anything else is rejected with a 422.
transcribe auto | always | never auto auto transcribes only when the caption looks too thin.
language string auto-detect ISO code forced on whisper.

The response carries caption, transcript, thumbnail, title, uploader, duration, a transcribed flag, the transcribe_reason behind that decision, and content — caption and transcript under labelled headings.

GET /healthz answers as soon as the process is up. GET /readyz returns 503 until whisper, ffmpeg and the model are all resolvable, and names what is missing.

When does it transcribe?

Under auto, a caption counts as substantial when it is at least MIN_CAPTION_CHARS long and either reads as a list (five or more lines) or quantifies things twice over — grams in a recipe, reps in a workout, prices in a review, all the same signal.

A caption that points somewhere else is thin no matter how long it is. "Link in bio", "full recipe on my website", "swipe up" and friends short-circuit the whole check, because whatever they describe is in the audio or off-site.

The bias is deliberate and one-sided: calling a caption thin costs one extra transcription, calling it substantial can lose the content entirely, and the caption is returned either way.

Configuration

Every variable, its default and what it does is in .env.example. The whole environment is parsed and validated once at startup, so a bad value stops the process there rather than surfacing mid-request.

WHISPER_MODEL is the only one that must be set, and the Nix package sets it for you.

Running it

Three supported paths, none of which is a prerequisite for another.

Nix

nix run .                       # run it
nix build .                     # build, which also runs the tests
nix develop                     # toolchain, with WHISPER_MODEL already set

Docker

docker compose up --build

Local Python — needs Python 3.11+, ffmpeg and whisper.cpp on PATH, and a ggml model on disk.

uv sync --extra dev
WHISPER_MODEL=/path/to/ggml-small.bin uv run social-extract
uv run pytest

On NixOS

The flake ships a module and an overlay.

{
  inputs.social-extract.url = "git+https://git.cumsek.com/marti/social-extract";

  # in your host config
  imports = [inputs.social-extract.nixosModules.default];

  services.social-extract = {
    enable = true;
    package = inputs.social-extract.packages.x86_64-linux.default;
    host = "0.0.0.0";
    openFirewall = true;
    whisperThreads = 4;
  };
}

Setting package explicitly uses this flake's own pinned nixpkgs, which is what keeps yt-dlp current. Drop it and add overlays.default to nixpkgs.overlays instead if you would rather build against your own.

Every option is in nix/module.nix; the notable ones are host, port, openFirewall, whisperThreads, maxDuration, maxTranscribeJobs and environment for anything not given an option.

Docs

Caveats

The service has no authentication. Bind it to loopback or a trusted network; host = "0.0.0.0" puts it in reach of everything that can route to the machine.

Instagram is actively hostile to extraction. Public posts work without credentials today; that is not a promise, and a yt-dlp bump is the usual fix when it stops.