Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Publier sur Dev.to depuis un serveur, avec 130 lignes de Python et zéro dépendance

Écrire dans un éditeur web, coller son markdown, choisir ses tags, cliquer sur publier : quand on écrit une fois par mois, c'est très bien. Quand on écrit deux fois par semaine, ça devient une corvée — et les corvées fin

Écrire dans un éditeur web, coller son markdown, choisir ses tags, cliquer sur publier :
quand on écrit une fois par mois, c'est très bien. Quand on écrit deux fois par semaine, ça
devient une corvée — et les corvées finissent par ne plus être faites.

Mon besoin était simple : j'écris un fichier .md dans un dossier, un job planifié le publie
le lendemain matin à 8 h 40. Pas d'interface, pas de SDK, pas de dépendance à installer sur un
serveur qui doit rester debout pendant des années.

Voici le pipeline complet, avec les erreurs que j'ai réellement rencontrées.

Ce dont l'API a besoin

Trois choses, et la troisième est celle qu'on oublie.

  1. La clé API, dans l'en-tête api-key. Elle se génère dans les paramètres du compte Dev.to.
  2. L'URL : https://dev.to/api/articles pour créer, https://dev.to/api/articles/<id> pour modifier.
  3. Un en-tête User-Agent. Sans lui, vous prenez un 403 et vous cherchez pendant vingt minutes pourquoi votre clé « ne marche pas ».

La clé doit vivre dans un fichier d'environnement, jamais dans le script :

DEVTO_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
def load_env(path):
    """Lit un .env minimaliste : KEY=VALUE, commentaires en #."""
    env = {}
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            env[k.strip()] = v.strip().strip('"').strip("'")
    return env

Étape 1 — lire le front-matter sans PyYAML

Un article, c'est un fichier markdown avec un en-tête entre deux ---. Pas besoin d'un
analyseur YAML complet : trois champs suffisent.

lines = open(fname).read().split("\n")
fm = {}
i = 1
while i < len(lines) and lines[i].strip() != "---":
    if ":" in lines[i]:
        k, v = lines[i].split(":", 1)
        fm[k.strip()] = v.strip()
    i += 1
body = "\n".join(lines[i + 1:]).strip()

Le fichier ressemble à ça :

---
title: Un titre qui donne envie de cliquer
description: "La phrase que Dev.to affichera dans les résultats."
tags: python,automation,tutorial
cover_image: https://exemple.fr/couverture.jpg
canonical_url: https://mon-site.fr/mon-article
---

Le corps, en markdown normal.

Étape 2 — le piège du titre vide

C'est l'erreur que j'ai réellement commise, et elle est publique : un fichier sans champ
title a été publié sous le titre « Sans titre ». Un fichier de brouillons de posts pour les
réseaux sociaux s'était retrouvé dans le dossier de la file, et le script l'a pris pour un
article.

Depuis, la validation se fait avant tout appel réseau :

if "title" not in fm or not fm["title"].strip().strip('"'):
    print(f"SAUTÉ (pas de titre): {fname}")
    return           # on ne publie pas, on passe au suivant

Deux lignes. Elles valent leur pesant de réputation.

Étape 3 — les tags valides, sinon 422

Dev.to n'accepte que des tags de sa liste. Un tag inconnu renvoie
422 — tag not found, et l'article n'est pas créé. Le plus simple est de filtrer contre une
liste blanche :

VALID_TAGS = {
    "python", "javascript", "productivity", "automation", "programming",
    "tutorial", "beginners", "webdev", "linux", "tools", "ai", "career",
    "opensource", "devops", "performance", "testing", "security", "data",
    "startup", "discuss", "showdev", "watercooler", "architecture",
    "codequality", "comparison",
}

tags = [t for t in tags if t in VALID_TAGS][:4]
if not tags:
    tags = ["discuss"]

Quatre tags maximum, c'est la limite de l'API. Les filtrer en silence vaut mieux qu'un échec
de publication pour une virgule de trop.

Étape 4 — créer en brouillon, puis publier

Publier directement, c'est un seul appel : POST avec "published": true. Ça marche… quand
l'API va bien. Aux heures de pointe, j'ai vu passer des 503 sur cet appel, et un 503 sur un
POST non idempotent laisse un doute désagréable : l'article a-t-il été créé, ou pas ?

Le pattern qui tient : créer le brouillon, récupérer son id, puis publier.

def api(method, url, payload=None, key=""):
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers={
        "Content-Type": "application/json",
        "api-key": key,
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) MonBot/1.0",
    })
    with urllib.request.urlopen(req, timeout=40) as resp:
        return json.loads(resp.read())
# 1) brouillon
draft = api("POST", API, {"article": {
    "title": title, "body_markdown": body, "published": False, "tags": tags,
}}, key)
art_id = draft["id"]

# 2) publication, avec deux reprises sur 5xx
for attempt in range(3):
    try:
        api("PUT", f"{API}/{art_id}", {"article": {"published": True}}, key)
        break
    except urllib.error.HTTPError as e:
        if e.code in (502, 503, 504) and attempt < 2:
            time.sleep(8 * (attempt + 1))
            continue
        raise

Un PUT est idempotent : le rejouer ne crée rien en double. C'est toute la raison de cette
danse en deux temps.

Étape 5 — un fichier d'état, sinon vous publiez deux fois

Le job tourne une fois par jour, mais il peut tourner deux fois : un redémarrage, un
déclenchement manuel, une reprise après panne. Un petit JSON suffit à rendre le script
idempotent.

STATE = os.path.expanduser("~/.hermes/scripts/devto-publish-state.json")
state = json.load(open(STATE)) if os.path.exists(STATE) else {}

today = time.strftime("%Y-%m-%d")
if state.get("last") == today:
    print(f"déjà publié aujourd'hui ({today})")
    return

done = set(state.get("done", []))
todo = [f for f in files if f not in done]
fname = todo[0]                      # un article par jour, dans l'ordre alphabétique
# … publication …
done.add(fname)
state.update({"done": sorted(done), "last": today})
tmp = STATE + ".tmp"
json.dump(state, open(tmp, "w"))
os.replace(tmp, STATE)               # écriture atomique

os.replace plutôt qu'une écriture directe : si la machine s'éteint au mauvais moment, on
garde l'ancien état au lieu d'un fichier tronqué.

Le cron, et la seule ligne qui compte

40 8 * * * flock -n /tmp/devto.lock /usr/bin/python3 \
  /home/moi/.hermes/scripts/publish-devto.py >> /home/moi/logs/devto.log 2>&1

Une fois par jour, à heure fixe, avec un verrou. Et lisez ce log de temps en temps : c'est
tout l'objet de mon article précédent sur les crons qui meurent en silence.

Les quatre erreurs que j'ai vues en vrai

Code Message Cause réelle Réaction
403 — En-tête User-Agent absent L'ajouter, toujours
401 unauthorized Clé absente, expirée ou révoquée Régénérer la clé
422 tag not found Tag hors liste blanche Filtrer les tags
422 Title has already been used in the last five minutes Doublon de titre Attendre, ou varier le titre
503 — API surchargée Réessayer avec un backoff

Le 422 sur le titre m'a coûté une soirée : mes deux tentatives de reprise ré-envoyaient le
même titre, donc échouaient de la même façon. Une reprise ne doit se déclencher que sur une
erreur transitoire — jamais sur une erreur de contrat.

Ce que ça donne à l'usage

J'écris un fichier markdown, je le nomme 11-mon-sujet.md, je le pose dans le dossier. Le
lendemain à 8 h 40 il est en ligne, avec ses tags et sa couverture. Le fichier est marqué
comme publié, et il ne repartira pas.

Cent trente lignes, zéro dépendance installée, et un pipeline que je peux encore lire dans
deux ans. C'est exactement ce que je demande à un outil interne.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.