Export Every Published Post From a Blogger Blog as JSON With the API in Python
You end up with a folder of JSON files, one per published post and named by its date and title, with the HTML body beside each when you want it separate.

On this page
TL;DR
Blogger’s own export is one XML file. The v3 API gives you every published post as a record, with its labels, dates, URL and HTML body, and the script below writes each one to disk as JSON:
$ python export_blogger_posts.py --blog-url https://example.blogspot.com --write-htmlThe first run opens a browser to authorize a read-only scope and caches the
token; every run after that is silent. It needs a Desktop OAuth client from
Google Cloud, which takes the loopback redirect without any setup. A web-type
client is what stops most first runs: it needs http://localhost:8080/ in its
authorized redirect URIs, or the consent screen ends in
redirect_uri_mismatch.
I started a blog on Blogger in 2001 and wrote there until 2012. Sixty-nine posts, mostly VBScript and SQL for Systems Management Server, and I wanted them as files before doing anything else with them. One record per post, the HTML body separate from the metadata, in a shape a script can read. Blogger’s Settings page offers a single Atom XML file of the whole blog. The API offers the posts.
1. Create an OAuth client
The Blogger API needs a Google Cloud project with the Blogger API enabled and
an OAuth client of type Desktop. A web client works too, only if
http://localhost:8080/ is in its authorized redirect URIs; a Desktop client
allows loopback redirects without listing them. Download the client JSON.
Two settings on the consent screen matter for a personal tool. The app can
stay in testing, and your own Google account has to be listed as a test user,
or sign-in fails with access_denied before the consent screen. An app
in testing gets
short-lived refresh tokens (opens in a new tab),
so a later run opens the browser again; the script treats a failed refresh as
a first run.
Warning
The client JSON and the cached token are credentials. If the script lives in
a repository, add client_secret*.json and .blogger_token.json to its
.gitignore before the first commit, not after.
2. Install the libraries
$ python3 -m venv .venv && source .venv/bin/activate
$ python -m pip install google-api-python-client google-auth-oauthlib python-dotenvpython-dotenv is optional; the script loads a .env when the module is
present and reads the environment either way.
3. The Python script
#!/usr/bin/env python3
"""Export every published post from a Blogger blog as one JSON file per post."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
from typing import Any
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/blogger.readonly"]
def parse_args() -> argparse.Namespace:
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
parser = argparse.ArgumentParser(description="Export Blogger posts to JSON files.")
parser.add_argument(
"--client-secret-file",
default=os.getenv("BLOGGER_CLIENT_SECRET_FILE", "client_secret.json"),
)
parser.add_argument(
"--token-file",
default=os.getenv("BLOGGER_TOKEN_FILE", ".blogger_token.json"),
)
parser.add_argument("--blog-id", default=os.getenv("BLOGGER_BLOG_ID"))
parser.add_argument("--blog-url", default=os.getenv("BLOGGER_BLOG_URL"))
parser.add_argument(
"--output-dir",
default=os.getenv("BLOGGER_OUTPUT_DIR", "exported_posts"),
)
parser.add_argument(
"--auth-port",
type=int,
default=int(os.getenv("BLOGGER_AUTH_PORT", "8080")),
help="Loopback port for the OAuth redirect; fixed so it is authorized once.",
)
parser.add_argument(
"--write-html",
action="store_true",
help="Also write each post's HTML body as a .html file.",
)
return parser.parse_args()
def get_credentials(
client_secret: pathlib.Path, token_path: pathlib.Path, auth_port: int
) -> Credentials:
creds = None
if token_path.is_file():
# Owner-only on macOS and Linux, including a token left by an older,
# looser run. Windows ignores the mode; the file keeps its folder's.
token_path.chmod(0o600)
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except RefreshError:
# An app in testing expires its refresh tokens; consent again.
creds = None
if not creds or not creds.valid:
flow = InstalledAppFlow.from_client_secrets_file(client_secret, SCOPES)
creds = flow.run_local_server(port=auth_port)
# A new token file is created owner-only rather than tightened after.
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as token_file:
token_file.write(creds.to_json())
return creds
def resolve_blog_id(service: Any, blog_id: str | None, blog_url: str | None) -> str:
if blog_id:
return blog_id
if not blog_url:
raise ValueError("Either BLOGGER_BLOG_ID or BLOGGER_BLOG_URL must be provided.")
return service.blogs().getByUrl(url=blog_url).execute()["id"]
def fetch_posts(service: Any, blog_id: str) -> list[dict[str, Any]]:
posts: list[dict[str, Any]] = []
params: dict[str, Any] = {
"blogId": blog_id,
"maxResults": 500,
"fetchBodies": True,
"status": ["LIVE"],
}
page_token = None
while True:
if page_token:
params["pageToken"] = page_token
response = service.posts().list(**params).execute()
posts.extend(response.get("items", []))
page_token = response.get("nextPageToken")
if not page_token:
return posts
def slugify(text: str) -> str:
slug = "".join(ch.lower() if ch.isalnum() else "-" for ch in text)
return "-".join(filter(None, slug.split("-"))) or "untitled"
def write_posts(
posts: list[dict[str, Any]], output_dir: pathlib.Path, write_html: bool
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
for post in posts:
title = post.get("title") or "untitled"
published = (post.get("published") or "")[:10]
parts = [published, slugify(title), post.get("id")]
base_name = "-".join(part for part in parts if part)
keys = (
"id", "status", "title", "url", "published",
"updated", "labels", "author", "content",
)
record = {key: post.get(key) for key in keys}
(output_dir / f"{base_name}.json").write_text(
json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8"
)
if write_html and post.get("content"):
(output_dir / f"{base_name}.html").write_text(
post["content"], encoding="utf-8"
)
def main() -> None:
args = parse_args()
client_secret = pathlib.Path(args.client_secret_file)
if not client_secret.exists():
sys.exit(
f"Client secret file not found at {client_secret}; "
"set BLOGGER_CLIENT_SECRET_FILE."
)
try:
creds = get_credentials(
client_secret, pathlib.Path(args.token_file), args.auth_port
)
service = build("blogger", "v3", credentials=creds)
blog_id = resolve_blog_id(service, args.blog_id, args.blog_url)
posts = fetch_posts(service, blog_id)
write_posts(posts, pathlib.Path(args.output_dir), args.write_html)
print(f"Exported {len(posts)} posts to {args.output_dir}")
except HttpError as err:
sys.exit(f"Blogger API error: {err}")
if __name__ == "__main__":
main()Three decisions in it are worth knowing before you run it.
The redirect port is explicit. run_local_server listens on 8080 unless
told otherwise, and a web-type client rejects a redirect it was not told
about. The flag keeps the port in sight, so the URI you authorize on a web
client and the one the script uses can’t drift apart. A Desktop client does
not need the entry.
The scope is read-only. blogger.readonly is all an export needs. The
token this caches cannot post, edit or delete, so a leaked
.blogger_token.json is a smaller problem than it could be. On macOS and
Linux the script also makes the file readable by you alone. On Windows it
takes the permissions of the folder it’s in, because
Python’s os.chmod (opens in a new tab) can
set only the read-only flag there, so keep it in a folder only you can read.
The blog is found by URL. blogs().getByUrl turns the address you know
into the numeric ID the rest of the API takes. Nothing numeric has to be
copied out of the Blogger dashboard. Pass --blog-id if you have it already.
4. Run it
$ export BLOGGER_CLIENT_SECRET_FILE=client_secret.json
$ python export_blogger_posts.py --blog-url https://example.blogspot.com --write-htmlOutput
Exported 69 posts to exported_postsThe first run opens a browser for consent and writes .blogger_token.json.
Each post becomes YYYY-MM-DD-<title-slug>-<postId>.json, and with
--write-html a .html beside it holding the body alone:
exported_posts/
2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.json
2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.html
...Each record carries what the API returns for the post, with the body last:
{
"id": "4298533488206221767",
"status": null,
"title": "Reading the SCCM Client GUID",
"url": "http://example.blogspot.com/2012/03/reading-sccm-client-guid.html",
"published": "2012-03-27T15:43:00-07:00",
"updated": "2012-04-30T12:00:20-07:00",
"labels": ["PowerShell", "SCCM", "VBScript", "WMI"],
"author": { "displayName": "..." },
"content": "<p>...</p>"
}status came back null on every post in this export. The API sets it only
for admin-level requests, and the script doesn’t make one.
Common failures
redirect_uri_mismatch on the consent screen
The client is web-type and
http://localhost:8080/ isn’t in its authorized redirects; the trailing
slash is part of the match. Add it, or recreate the client as Desktop.
access_denied before the consent screen
The app is in testing and your account isn’t a test user. Add it under the OAuth consent screen.
Fewer posts than expected
The script asks for 500 posts a page and follows nextPageToken until the
API stops sending one. It asks for published posts only, so drafts and
scheduled posts aren’t in the count.
Old files after a second export
The script writes into the output folder and never removes anything. A post
renamed since the last run leaves its old file beside the new one, and an
unpublished post keeps its file. Export into an empty folder, with
--output-dir, when the folder has to match the blog.
Client secret file not found
The path is read from
BLOGGER_CLIENT_SECRET_FILE or --client-secret-file and is relative to the
directory you run from, not the script’s.
Tested with
- Python
- 3.13
- google-api-python-client
- 2.187
- google-auth-oauthlib
- 1.2
- Blog
- 69 posts, 2001 to 2012
- Date
- December 2025