DEV Community

Roberto Luna
Roberto Luna

Posted on

Persisting Dev.to URLs in `metadata.json` – fixing “No publicado” in daily email reports

Persisting Dev.to URLs in metadata.json – fixing “No publicado” in daily email reports

TL;DR: I added a devto_url field to the ContentBundle dataclass and made the publishing step write that URL into metadata.json. The daily email now shows the correct Dev.to link instead of “No publicado”.


The Problem

Our content‑automation pipeline publishes the same article to Medium, Substack, Bluesky and Dev.to every day. The final step is a summary email that reads the generated metadata.json to build a table of links:

{
  "devto_en": "https://dev.to/robertoluna/weekly‑automation‑2026‑08‑23",
  "devto_url": ""
}
Enter fullscreen mode Exit fullscreen mode

Even when the Dev.to API responded with a successful url, the email always displayed “No publicado” for the Dev.to column. The symptom was a missing URL in the JSON payload that the email renderer consumed.

The root cause was simple: the publishing code saved the URL in a temporary variable, logged it, but never persisted it back to the metadata file. Since the ContentBundle dataclass didn’t even have a field for the URL, the JSON serializer silently omitted it, leaving the email with an empty string.


What I Tried First

My first instinct was to patch the email‑generation script:

# email_renderer.py (hypothetical)
if not devto_url:
    devto_url = "No publicado"
Enter fullscreen mode Exit fullscreen mode

That “quick‑fix” worked superficially, but it masked the real issue – the source of truth (metadata.json) was still incomplete. I also tried adding the URL directly in the email template using Jinja2’s default filter, but that introduced duplicate logic and made the data flow harder to reason about.

Both approaches failed the “single source of truth” principle we follow at VibeCoding, so I went back to the publishing step.


The Implementation

1. Extend the data model

src/archive_manager.py defines the bundle that travels through the pipeline. I added a dedicated field for the Dev.to URL:

@@
 class ContentBundle:
     substack_es: str = ""
     substack_en: str = ""
     devto_en: str = ""
+    devto_url: str = ""
     bluesky_es_posts: List[Dict] = field(default_factory=
Enter fullscreen mode Exit fullscreen mode

Why a separate field?

devto_en already stores the article body; mixing the URL into that string would break downstream parsers. A dedicated attribute keeps the schema clean and makes JSON serialization explicit.

2. Capture the URL from the API response

In src/main.py the publishing routine calls publish_to_devto. The response looks like:

{
  "en": {
    "url": "https://dev.to/robertoluna/weekly‑automation‑2026‑08‑23",
    "status": "published"
  }
}
Enter fullscreen mode Exit fullscreen mode

I extended the extraction helper to pull the URL and assign it to the new field:

@@
 def _extract_headline(posts: list, fallback: str) -> str:
     # existing logic …
-    devto_url = devto_result["en"].get("url", "")
-    logger.info("[%s] Dev.to published
+    devto_url = devto_result["en"].get("url", "")
+    bundle.devto_url = devto_url   # persist for later stages
+    logger.info("[%s] Dev.to published %s", bundle.title, devto_url)
Enter fullscreen mode Exit fullscreen mode

Now the bundle object carries the URL forward.

3. Persist the URL in metadata.json

The ArchiveManager writes the bundle back to disk after each platform finishes. I added the field to the serialization map:

# src/archive_manager.py (excerpt)
def _write_metadata(self, bundle: ContentBundle, path: Path):
    data = {
        "medium_generated": bundle.medium_generated,
        "substack_generated": bundle.substack_generated,
        "devto_en": bundle.devto_en,
        "devto_url": bundle.devto_url,   # NEW
        # … other flags …
    }
    with open(path / "metadata.json", "w") as f:
        json.dump(data, f, indent=2)
Enter fullscreen mode Exit fullscreen mode

Because ContentBundle now has a default empty string for devto_url, older bundles that never published to Dev.to still produce a valid JSON file.

4. Verify the end‑to‑end flow

Running the full pipeline for a test date (2026‑08‑23) produced:

{
  "medium_generated": true,
  "substack_generated": true,
  "devto_en": "## Weekly Automation\n…",
  "devto_url": "https://dev.to/robertoluna/weekly-automation-2026-08-23"
}
Enter fullscreen mode Exit fullscreen mode

The daily email template reads metadata["devto_url"] directly, so the table now shows a clickable link:

| Platform | Link |
|----------|------|
| Dev.to   | https://dev.to/robertoluna/weekly-automation-2026-08-23 |
Enter fullscreen mode Exit fullscreen mode

No more “No publicado”.


Key Takeaway

Never rely on transient variables for data that downstream consumers need. Extend your domain model (dataclasses, schemas) to include every piece of information that must survive beyond the current function call, and make the persistence layer aware of those additions. This keeps the pipeline deterministic and eliminates hidden “magic” defaults in UI layers.


What's Next

  1. Add unit tests for ArchiveManager._write_metadata that assert the devto_url key exists after a successful publish.
  2. Introduce a generic platform_urls dict in ContentBundle to avoid adding a new field each time we start publishing to another service.
  3. Graceful fallback – if the Dev.to API ever returns a missing URL, store a sentinel value (null) and have the email renderer display “Failed to publish”.
  4. Telemetry – emit a Prometheus metric (devto_publish_success_total) each time a URL is persisted, so we can monitor health over time.

Roberto Luna Osorio – Full Stack Developer & Project Lead

Playa del Carmen, México


vibecoding #buildinpublic #python #json #devto #automation #ci


Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-08-24

#playadev #buildinpublic

Top comments (0)