- Python 66.3%
- Nix 26.4%
- Dockerfile 7.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci / check (push) Successful in 1m2s
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>
|
||
| .forgejo/workflows | ||
| docs | ||
| nix | ||
| src/social_extract | ||
| tests | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
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
- Architecture — how a request flows, and why.
- Development — running, testing, formatting.
- Deployment — sizing, security, model choice.
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.