Publishing with the Blogger API from Python: Tokens, Rate Limits, and Images
I publish this blog, and a Traditional Chinese sister blog, entirely through the Blogger API v3 from Python: no web editor, just HTML files in a folder and a script. Getting the first batch of posts out took a day of running into Blogger's undocumented corners. These are the lessons, with the code that came out of them.
A small client with no dependencies
Google's client libraries work, but the API is plain REST, and a dozen lines of urllib cover everything a blog needs. The one piece that deserves care is the token:
import datetime as dt, json, os, urllib.parse, urllib.request
TOKENS = os.path.expanduser("~/.config/blogger/tokens.json") # chmod 600
CLIENT = os.path.expanduser("~/.config/blogger/client.json") # {client_id, client_secret}
API = "https://www.googleapis.com/blogger/v3"
def token() -> str:
"""Return an access token, refreshing it when it has under 5 minutes left."""
t = json.load(open(TOKENS))
left = dt.datetime.fromisoformat(t["expires_at"]) - dt.datetime.now()
if left.total_seconds() < 300:
c = json.load(open(CLIENT))
body = urllib.parse.urlencode(dict(
client_id=c["client_id"], client_secret=c["client_secret"],
refresh_token=t["refresh_token"], grant_type="refresh_token")).encode()
n = json.load(urllib.request.urlopen("https://oauth2.googleapis.com/token", body, timeout=30))
t["access_token"] = n["access_token"]
t["refresh_token"] = n.get("refresh_token", t["refresh_token"]) # keep it if Google rotates it
t["expires_at"] = (dt.datetime.now() + dt.timedelta(seconds=n["expires_in"])).isoformat()
tmp = TOKENS + ".tmp" # write-then-rename: a crash never leaves half a file
with open(tmp, "w") as f:
json.dump(t, f, indent=2)
os.chmod(tmp, 0o600)
os.replace(tmp, TOKENS)
return t["access_token"]
def api(method: str, url: str, body: dict | None = None) -> dict:
req = urllib.request.Request(
url, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + token(), "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
- Refresh early. Access tokens live one hour. Refreshing with five minutes still left means a long publish run never fails halfway through.
- Write the token file atomically. Write to a temporary file, then
os.replace(). Several scripts (and a separate automation agent) share this file, and a half-written token file locks all of them out. - Keep it private.
chmod 600, outside any git repository, and never pasted into a post or a chat.
Day-to-day use:
from blogger import API, api
# 1. find your blog id
for b in api("GET", f"{API}/users/self/blogs").get("items", []):
print(b["id"], b["name"], b["url"])
BLOG = "1234567890"
# 2. publish (without isDraft=false you get a draft)
post = api("POST", f"{API}/blogs/{BLOG}/posts?isDraft=false",
{"title": "Hello", "content": "<p>First post</p>", "labels": ["Notes"]})
print(post["id"], post["url"])
# 3. update only what changed; the URL stays the same
api("PATCH", f"{API}/blogs/{BLOG}/posts/{post['id']}", {"title": "Hello, world"})
# 4. read the raw HTML back (e.g. for a backup before editing)
raw = api("GET", f"{API}/blogs/{BLOG}/posts/{post['id']}?view=ADMIN")["content"]
PATCH for updates. It changes only the fields you send, and the post keeps its URL. The URL slug is fixed at first publish, so even a new title won't change it. ?isDraft=false is easy to forget: without it, a new post lands as a draft.
Lesson 1: in "Testing" mode, your token dies after 7 days
If your OAuth consent screen is set to External and its publishing status is still Testing, Google issues refresh tokens that expire after 7 days. That's documented, but easy to miss, because everything works perfectly until it doesn't. The failure is an invalid_grant from the token endpoint, and refreshing more often doesn't extend the deadline.
HTTP 400 {"error": "invalid_grant", "error_description": "Token has been expired or revoked."}
It happened to me while writing this post, several days before I expected it. The error says "expired or revoked", and it doesn't tell you which. Either way, the only way out is to authorize again in a browser. The fix is in the Google Cloud Console: OAuth consent screen → Publish app. For a personal project, you can keep using an unverified app (you'll click through a warning screen when authorizing), and the tokens stop expiring weekly. Then authorize once more to get a long-lived token.
While you're there, request only the scopes you use. My first authorization also included Google Drive access, which I ended up not using (see below), so the new authorization asks for Blogger only.
Lesson 2: a new blog gets rate-limited fast
Publishing my first batch, the sixth post in quick succession came back with 429 rateLimitExceeded. After waiting about half an hour, the remaining posts went through. Brand-new blogs seem to get a much tighter limit than the API's published quotas suggest, presumably as an anti-spam measure.
What works:
- Make publishing idempotent. I keep a small JSON map of source file → post id. The publish script skips anything already in the map, so after a 429 I just rerun it later.
- Pace it. A few posts a day is also better for search engines than dumping a whole archive at once.
Lesson 3: the API can't upload images
Blogger API v3 has no media upload endpoint. Images you insert in the web editor get hosted on Google's servers, but the API can't do that for you. Options:
- Reuse already-hosted images. If an image is already in one of your Blogger posts (a
blogger.googleusercontent.comURL), you can reference the same URL from any other post. I did this for the posts this blog shares with the Chinese one. - Host them elsewhere. Google Drive "works", but its free storage is shared with your Gmail and Photos, and Drive links aren't meant as image hosting. I tried it and backed out.
- Embed them as data URIs. The image goes inside the post's HTML as base64. No hosting at all, and it renders fine for readers. The cost is page weight, so the images must be small.
For my own photos I use data URIs, and the preparation step matters more than the embedding:
import base64, io
from PIL import Image, ImageOps
def photo_uri(path: str, width: int = 820, quality: int = 70) -> str:
"""Phone photo -> upright, EXIF-free, resized JPEG as a data URI."""
im = ImageOps.exif_transpose(Image.open(path)) # apply the rotation flag before dropping EXIF
im = im.convert("RGB") # re-encoding writes no EXIF (GPS, camera, time)
im.thumbnail((width, width))
buf = io.BytesIO()
im.save(buf, "JPEG", quality=quality, optimize=True)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
# <img src="{photo_uri('IMG_1234.jpg')}" alt="..." style="max-width:100%;height:auto">
- Strip EXIF. Phone photos carry GPS coordinates, the camera model, and timestamps. Re-encoding through Pillow writes none of it. I check the output for zero EXIF tags before publishing.
- Rotate first. Phones often store the pixels sideways and fix it with an EXIF orientation flag. Dropping EXIF without
exif_transpose()leaves you with sideways photos. - Resize hard. 820 px wide at JPEG quality 70 is about 100 KB per photo and looks fine in a blog column. My heaviest post, with four photos and a short GIF, is about 1 MB and publishes without trouble.
- Edit through the API only. Since the HTML source in my folder is the single source of truth, I never open these posts in Blogger's web editor. Opening and re-saving them there could quietly alter the HTML.
Lesson 4: back up before you PATCH
A PATCH replaces the whole content field, and Blogger keeps no revision history you can reach through the API. Before any edit, I save the current version with GET …?view=ADMIN to a dated backup folder. It's cheap insurance, and it has already let me compare a corrected post against the original line by line.
Summary
- Plain REST plus a careful token helper (refresh early, write atomically,
chmod 600) is all you need. - Publish your OAuth app, or the refresh token dies after 7 days with
invalid_grant. - New blogs hit 429 quickly: make publishing idempotent and pace it.
- No image upload in the API: reuse hosted URLs or embed small, EXIF-stripped data URIs.
POST ?isDraft=falseto publish,PATCHto update, and a backup with?view=ADMINbefore every change.
Comments
Post a Comment