Systems · Content automation

Human-approved social publishing pipeline

A Python service for a football content workflow. It finds stories, drafts captions, checks and cuts out real photos, and renders branded posts, carousels and reels. A person picks, shapes and approves every post in Telegram before it goes to Instagram.

At a glance

Role

Designed and built it with Claude Code. I operate it and approve every post.

Tools

  • Python
  • Telegram Bot API
  • Instagram Graph API
  • Pillow
  • FFmpeg
  • Gemini 2.5 Flash
  • BiRefNet on fal

Outcomes

  • Nothing is drafted until a story is picked, and nothing is published without an explicit approval.
  • Real photos pass five checks before they are offered; subjects are cut out, not generated.
  • 342 offline tests pass with every network call stubbed.
Seven steps from story to published post. Three of them belong to a person.

The problem

Football news moves fast, and an account needs posts within minutes, in a consistent voice and look. Fully automatic posting risks wrong facts and unusable photos; fully manual posting is too slow.

The service does the searching, drafting, checking and rendering. The person keeps the judgement: which story, which words, which photo, and whether it goes out at all.

From story to published post

  1. 01

    Collect stories

    Every two hours the service refreshes its story pool. Outside quiet hours (23:00 to 08:00) it sends a menu of up to ten stories, at most three per source.

  2. 02

    Pick one

    The operator picks a story, filters the menu, asks for a topic or pastes a link.

    Human approval
  3. 03

    Draft and search

    Three caption options in the house voice, and candidate photos from the article, image search and open libraries.

  4. 04

    Shape it

    Choose caption A, B or C, a headline word and photo 1, 2 or 3. Edit in plain language, undo, or send an exact edit.

    Human approval
  5. 05

    Build the post

    The cut-out, headline, grade and wordmark are rendered into a single post, carousel or reel preview.

  6. 06

    Approve

    Post as a single image or carousel, or cancel.

    Human approval
  7. 07

    Publish

    The post goes to Instagram and the live link comes back to the chat.

Human approval

Five checks before a photo is offered

  1. 01

    Source filter

    Watermarked stock-agency hosts are dropped before anything is downloaded.

  2. 02

    Resolution

    The short side must be at least 500 px. No thumbnails.

  3. 03

    Vision check

    A vision model confirms the right subject and face, and rejects watermarks and pre-made graphics.

  4. 04

    Cut-out

    A segmentation matte isolates the subject, who must fill at least half the frame height.

  5. 05

    Crop

    The frame is auto-cropped to 4:5 for the post canvas.

Output formats

FormatSizeBuilt from
Single post1080 × 1350Fixed headline at the top, cut-out subject with rim light and shadow, grain, wordmark and accent colour
Carousel1080 × 1350 slidesPhotos cropped to 4:5 with a light grade and the wordmark
Reel1080 × 1920A pasted clip with a hook card, highlighted punch word and source credit, composed with FFmpeg

Limits that keep the operator in control

Open drafts
New menus pause while two drafts are waiting for a decision.
Unpicked stories
Return to the pool and can be offered again after 90 minutes.
Failures
A failed attempt returns the story to the menu instead of marking it used.
Keys
API keys live only on the server, never in the repository.

The menu gate

app/sched.py (excerpt, comments trimmed)
NEWS_INTERVAL_S = 2 * 60 * 60
QUIET_START, QUIET_END = 23, 8
MAX_OPEN_DRAFTS = 2
MENU_SIZE = 10
RE_PITCH_COOLDOWN_MIN = 90
SOURCE_CAP = 3
…
    if not _awake():
        log.info("cycle: quiet hours — pool refreshed, no menu")
        return {"ok": True, "skipped": "quiet hours"}
    _expire_stale_drafts()
    open_rows = _open_draft_rows()
    if len(open_rows) >= MAX_OPEN_DRAFTS:
        …
    stories = list_story_candidates()
    if not stories:
        return {"ok": True, "skipped": "nothing new to pitch"}
    out = send_story_menu(stories)

The scheduler never drafts on its own. It only offers a menu, and holds it back in quiet hours or while decisions are pending.

Architecture

Operator

  • Telegram chat: menus, options, previews, approve and cancel

Python service

  • Scheduler and story pool
  • Drafts: captions, headline words, edits
  • Images: search, checks, cut-outs, crops
  • Templates: posts, carousels, reel cards
  • Reels: clip fetch and FFmpeg compose

Models

  • Caption model: GLM, with Gemini 2.5 Flash as fallback
  • Gemini 2.5 Flash for vision checks
  • BiRefNet on fal for segmentation

Publishing

  • Instagram Graph API: image, carousel and reel
  • Cloudflare R2 for image hosting

One plain Python service using httpx, Pillow and boto3. Models are called over plain HTTP, with no agent framework.

Testing

Tests
342 passing on 15 September 2026, run offline with a fake Telegram client and every network call stubbed.
Screens
The live chat and the published posts identify the account, so this page shows the flow, checks and formats instead of screenshots.