Dev.to API发布实战:tag规则、限流与幂等设计

2 阅读1分钟

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 里生成。注意两点:

  1. key 只显示一次,生成后立刻存进环境变量或密钥管理,别留在代码里。
  2. 请求体用 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 底线

状态文件建议纳入版本管理或至少定期备份,它是幂等逻辑的唯一依据,丢了就只能靠人工核对哪些文章发过了。

更多内容请关注本站。