DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Weekly Multi‑Platform Posts with a Python Generator and Docker

Automating Weekly Multi‑Platform Posts with a Python Generator and Docker

TL;DR: I built a tiny Python CLI that pulls data from metadata.json, renders Jinja2 templates, and spits out ready‑to‑publish Markdown and JSON files for Medium, Substack, and Bluesky. Packaging it in a Docker image lets the CI pipeline run the generator on every sprint without manual copy‑pasting.


The Problem

Every sprint I had to take the same weekly summary (changelog, release notes, bug fixes) and publish it on three different platforms:

  • Medium – requires a full‑length Markdown file with front‑matter.
  • Substack – a shorter Markdown version, also with front‑matter.
  • Bluesky – a JSON payload limited to 300 characters per post.

Doing this by hand meant:

  • Inconsistent wording – copy‑paste errors showed up in production.
  • Layout regressions – the mobile overflow‑x bug in tables.css was fixed in the codebase, but the documentation never mentioned it because I missed the step.
  • Wasted time – ~30 minutes per sprint just to re‑format the same content three times.

The CI pipeline already built Docker images for the app, but there was no automated step to generate the marketing copy.


What I Tried First

I started with a bash script that concatenated static snippets:

#!/usr/bin/env bash
cat header.md content/2026/10/19/VS/changelog.md > out/medium.md
Enter fullscreen mode Exit fullscreen mode

It broke immediately:

./gen.sh: line 3: content/2026/10/19/VS/changelog.md: No such file or directory
Enter fullscreen mode Exit fullscreen mode

The script assumed a fixed folder layout and failed when a new week was added (2026/10/20). I also tried a Node.js script with fs.promises.readFile, but the JSON payload for Bluesky kept getting malformed (missing commas) and I couldn’t easily reuse the same data for the Markdown files.

Both approaches suffered from hard‑coded paths and no templating – any change required editing the script itself.


The Implementation

1. Directory layout

content-automation/
├─ templates/
│  ├─ medium.md.j2
│  ├─ substack.md.j2
│  └─ bluesky.json.j2
├─ src/
│  └─ generate_content.py
├─ Dockerfile.prod
└─ content/
   └─ 2026/10/19/VS/
       ├─ changelog.md
       ├─ metadata.json
       ├─ medium_en.md   (generated)
       ├─ substack_en.md (generated)
       └─ bluesky_en.json (generated)
Enter fullscreen mode Exit fullscreen mode

2. metadata.json – source of truth

{
  "project": "VS",
  "date": "2026-10-19",
  "pull_requests": 6,
  "releases": 0,
  "closed_issues": 0,
  "medium_generated": true,
  "substack_generated": true,
  "bluesky_generated": false
}
Enter fullscreen mode Exit fullscreen mode

The generator updates the boolean flags after each successful render (see src/generate_content.py).

3. Jinja2 templates

templates/medium.md.j2

---
title: "{{ project }} – Weekly Update ({{ date }})"
tags: ["{{ project }}", "productivity"]
published: true
---

{% include "changelog.md" %}

## Highlights
- Fixed the overflow‑x bug in `tables.css` that broke mobile layout.
- Added `src/ai/inference_engine.py` (ONNX inference REST API).
- Updated Dockerfile for production builds.
Enter fullscreen mode Exit fullscreen mode

templates/bluesky.json.j2

[
  {
    "type": "progress",
    "text": "Finally fixed the overflow‑x bug in tables.css that was pushing the page sideways on mobile. The diff shows the new `max-width: 100%` rule."
  },
  {
    "type": "feature",
    "text": "Deployed `src/ai/inference_engine.py` – a lightweight FastAPI service for ONNX model inference."
  }
]
Enter fullscreen mode Exit fullscreen mode

4. Core generator (src/generate_content.py)


python
#!/usr/bin/env python3
import json
import pathlib
import sys
from jinja2 import Environment, FileSystemLoader, select_autoescape

BASE = pathlib.Path(__file__).resolve().parents[1]
CONTENT_ROOT = BASE / "content" / "2026" / "10" / "19" / "VS"

def load_metadata() -> dict:
    meta_path = CONTENT_ROOT / "metadata.json"
    with meta_path.open() as f:
        return json.load(f)

def render_template(tpl_name: str, dest: pathlib.Path, ctx: dict):
    env = Environment(
        loader=FileSystemLoader(str(BASE / "templates")),
        autoescape=select_autoescape(['html', 'xml']),
        trim_blocks=True,
        lstrip_blocks=True,
    )
    template = env.get_template(tpl_name)
    rendered = template.render(**ctx)
    dest.write_text(rendered)
    print(f"✔️  Generated {dest.relative_to(BASE)}")

def main():
    meta = load_metadata()
    ctx = {
        "project": meta["project"],
        "date": meta["date"],
        "changelog_path": str(CONTENT_ROOT / "changelog.md"),
    }

    # Render Medium
    render_template(
        "medium.md.j2",
        CONTENT_ROOT / "medium_en.md",
        ctx,
    )

    # Render Substack (reuse same template, just shorter)
    render_template(
        "substack.md.j2",
        CONTENT_ROOT / "substack_en.md",
        ctx,
    )

    # Render Bluesky JSON
    render_template(
        "bluesky.json.j2",
        CONTENT_ROOT / "bluesky_en.json",
        ctx,
    )

    # Update flags
    meta.update({
        "medium_generated": True,
        "substack_generated": True,
        "bluesky_generated": True,
    })
    with (CONTENT_ROOT / "metadata.json").open("w") as f:
        json.dump(meta, f, indent=2)
    print("✅  metadata.json flags updated")

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/content-automation` · 2026-10-20*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)