Dev.to API发布实战:tag规则、限流与幂等设计
用脚本把文章自动同步到 Dev.to,听起来是个十分钟的活:拿 API key,POST 一个 /articles 就完事。实际跑起来会踩三个坑:tag 里的连字符直接 422、发布频率高了 429 要等 300 秒、重跑脚本时文章发重了。
这篇文章把这三个坑和对应的解法写清楚,代码可以直接拿去改。
API 认证:一个 header 搞定
Dev.to 的认证走 api-key header,不需要 OAuth 流程(个人发布场景):
import os
import requests
API_KEY = os.environ["DEVTO_API_KEY"]
BASE = "https://dev.to/api"
HEADERS = {
"api-key": API_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
}
API key 在 Dev.to 的 Settings → Extensions → DEV Community API Keys 里生成。注意两点:
- key 只显示一次,生成后立刻存进环境变量或密钥管理,别留在代码里。
- 请求体用 JSON 传,
Content-Type必须显式声明,否则部分网关会当成表单处理。
发布文章的 payload 大致长这样:
payload = {
"article": {
"title": "文章标题",
"published": True,
"body_markdown": "# 正文\n...",
"tags": ["python", "quant"],
"canonical_url": "https://example.com/original-post",
"description": "摘要",
}
}
canonical_url 是 SEO 上的关键字段。同一篇文章在自有站点和 Dev.to 各有一份,如果不标 canonical,搜索引擎会认为是重复内容,权重可能被分走。把 canonical_url 指向原文地址,Dev.to 页面会带上 canonical 标签,告诉搜索引擎"正版在这边"。做多平台同步的,这个字段别省。
坑一:tag 只允许纯字母数字
这是最容易卡住的一个。Dev.to 的 tags 文档里写着"alphanumeric",但实际校验比想象中严格:连字符、下划线、空格全部拒绝。
比如你按常见习惯写:
tags = ["python", "quant-trading", "api-publish"]
请求会返回 422,错误信息大意是 tag 包含 non-alphanumeric 字符。第一反应往往是去查中文、特殊符号,绕一圈才意识到问题出在连字符上。
解法很简单,在提交前做一次清洗:
import re
def sanitize_tags(tags: list[str]) -> list[str]:
"""Dev.to tag 只接受纯字母数字,连字符/下划线/空格一律剔除。"""
cleaned = []
for tag in tags:
# 去掉所有非字母数字字符
t = re.sub(r"[^a-zA-Z0-9]", "", tag).lower()
if t and t not in cleaned:
cleaned.append(t)
return cleaned[:4] # Dev.to 最多 4 个 tag
quant-trading 会被清洗成 quanttrading。这个结果不一定符合你的预期,所以更好的做法是在文章元数据里就写合规的 tag,清洗函数只作为兜底。另外 Dev.to 单篇文章最多 4 个 tag,超出的部分会被静默截断,最好自己先截。
顺带一提,tag 清洗函数建议单独写单测,因为这类"看起来无害但会 422"的规则,靠人肉检查很容易漏。
坑二:429 限流与 300 秒等待
批量发布时,连续请求会触发限流,返回 429。Dev.to 的限流窗口比较长,实测触发后需要等待约 300 秒才能恢复。
这里有两个设计要点:
第一,区分可重试与不可重试状态码。 429 和 5xx 可以重试,422 重试一万次也没用,直接抛错让人工修数据。
第二,退避时间要够长。 常见的指数退避从 1 秒起步,对 Dev.to 不够用,第一次重试就该等 300 秒左右。
import time
import logging
logger = logging.getLogger(__name__)
RETRYABLE = {429, 500, 502, 503, 504}
RATE_LIMIT_WAIT = 300 # 秒
def request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs):
for attempt in range(1, max_retries + 1):
resp = requests.request(method, url, headers=HEADERS, timeout=30, **kwargs)
if resp.status_code < 400:
return resp
if resp.status_code not in RETRYABLE:
# 422 之类的业务错误,重试无意义
raise RuntimeError(f"{resp.status_code}: {resp.text}")
wait = RATE_LIMIT_WAIT if resp.status_code == 429 else 2 ** attempt
logger.warning(
"attempt %d/%d got %d, sleeping %ds",
attempt, max_retries, resp.status_code, wait,
)
if attempt == max_retries:
raise RuntimeError(f"max retries exceeded: {resp.status_code}")
time.sleep(wait)
如果一次要发十几篇,更稳妥的做法是在每次成功请求后主动 sleep 几秒,把请求摊开,而不是等撞上 429 再退避。限流是"配额"不是"故障",主动控速比被动重试便宜得多。
坑三:幂等——状态文件 + PUT 更新
脚本跑第二遍时,最怕两件事:文章发重了;或者想更新已发文章,结果又新建了一篇。
解法是维护一个状态文件,记录每篇文章的本地标识、Dev.to 返回的 id 和 url。发布前先查状态:
- 没记录 → POST 新建
- 有记录 → PUT 更新已有文章
import json
from pathlib import Path
STATE_FILE = Path("devto_state.json")
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
return {}
def save_state(state: dict) -> None:
STATE_FILE.write_text(
json.dumps(state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
状态文件的 key 用文章 slug 或文件路径都行,关键是稳定——同一篇文章每次跑出来的 key 必须一致。用标题做 key 是个坑,标题一改就认不出来了。
发布主逻辑:
def publish_article(slug: str, payload: dict) -> dict:
state = load_state()
record = state.get(slug)
if record and record.get("id"):
# 已发布过,走 PUT 更新
url = f"{BASE}/articles/{record['id']}"
resp = request_with_retry("PUT", url, json=payload)
else:
# 首次发布,走 POST
resp = request_with_retry("POST", f"{BASE}/articles", json=payload)
data = resp.json()
state[slug] = {
"id": data["id"],
"url": data.get("url"),
"title": data.get("title"),
}
save_state(state)
return data
有个细节:PUT 更新时 payload 结构和 POST 一样,都是 {"article": {...}},但更新接口对某些字段的处理和新建立略有差异,比如 published 状态。更新已发布文章时保留 published: True 即可,不要传 False,否则文章会被撤回草稿。
完整发布脚本
把上面几块拼起来,一个可用的发布脚本:
import json
import logging
import os
import re
import time
from pathlib import Path
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
BASE = "https://dev.to/api"
HEADERS = {
"api-key": os.environ["DEVTO_API_KEY"],
"Content-Type": "application/json",
"Accept": "application/json",
}
STATE_FILE = Path("devto_state.json")
RETRYABLE = {429, 500, 502, 503, 504}
RATE_LIMIT_WAIT = 300
def sanitize_tags(tags):
cleaned = []
for tag in tags:
t = re.sub(r"[^a-zA-Z0-9]", "", tag).lower()
if t and t not in cleaned:
cleaned.append(t)
return cleaned[:4]
def load_state():
return json.loads(STATE_FILE.read_text(encoding="utf-8")) if STATE_FILE.exists() else {}
def save_state(state):
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
def request_with_retry(method, url, max_retries=3, **kwargs):
for attempt in range(1, max_retries + 1):
resp = requests.request(method, url, headers=HEADERS, timeout=30, **kwargs)
if resp.status_code < 400:
return resp
if resp.status_code not in RETRYABLE:
raise RuntimeError(f"{resp.status_code}: {resp.text}")
wait = RATE_LIMIT_WAIT if resp.status_code == 429 else 2 ** attempt
logger.warning("attempt %d/%d -> %d, sleep %ds", attempt, max_retries, resp.status_code, wait)
if attempt == max_retries:
raise RuntimeError(f"max retries exceeded: {resp.status_code}")
time.sleep(wait)
def build_payload(title, body_md, tags, canonical_url, description=""):
return {
"article": {
"title": title,
"published": True,
"body_markdown": body_md,
"tags": sanitize_tags(tags),
"canonical_url": canonical_url,
"description": description,
}
}
def publish(slug, payload):
state = load_state()
record = state.get(slug)
if record and record.get("id"):
logger.info("update existing article id=%s", record["id"])
resp = request_with_retry("PUT", f"{BASE}/articles/{record['id']}", json=payload)
else:
logger.info("create new article")
resp = request_with_retry("POST", f"{BASE}/articles", json=payload)
data = resp.json()
state[slug] = {"id": data["id"], "url": data.get("url"), "title": data.get("title")}
save_state(state)
logger.info("done: %s", data.get("url"))
return data
if __name__ == "__main__":
payload = build_payload(
title="Dev.to API发布实战:tag规则、限流与幂等设计",
body_md=Path("post.md").read_text(encoding="utf-8"),
tags=["python", "api-publish", "automation"],
canonical_url="https://example.com/posts/devto-api-publish",
description="Dev.to API 发布踩坑记录",
)
publish("devto-api-publish", payload)
注意 tags 里写了 api-publish,会被清洗成 apipublish——这正是坑一说的,最好在源头就写合规 tag。
小结
三个坑的共性:API 的错误信息往往只描述现象,不描述原因。422 说 "non-alphanumeric",但没人告诉你罪魁是连字符;429 只给状态码,等待时长得自己试。所以自动发布脚本里,防御性设计比功能本身更重要:
- tag 入口做清洗,别指望调用方守规矩
- 重试策略区分状态码,429 单独给长等待
- 状态文件做幂等,POST/PUT 按记录分流
canonical_url必填,多平台同步的 SEO 底线
状态文件建议纳入版本管理或至少定期备份,它是幂等逻辑的唯一依据,丢了就只能靠人工核对哪些文章发过了。
更多内容请关注本站。