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": ""
}
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"
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=
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"
}
}
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)
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)
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"
}
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 |
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
-
Add unit tests for
ArchiveManager._write_metadatathat assert thedevto_urlkey exists after a successful publish. -
Introduce a generic
platform_urlsdict inContentBundleto avoid adding a new field each time we start publishing to another service. -
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”. -
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)