É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.
- La clé API, dans l'en-tête
api-key. Elle se génère dans les paramètres du compte Dev.to. - L'URL :
https://dev.to/api/articlespour créer,https://dev.to/api/articles/<id>pour modifier. - Un en-tête
User-Agent. Sans lui, vous prenez un403et 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.
Top comments (0)