Loosely Decoupled
GuidePython

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.

A hard-hat pigeon stamps old sheets of paper and files each into its own slim folder in a long row, while a man with headphones watches. One folder has a cyan ribbon bookmark.
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:

Command
$ python export_blogger_posts.py --blog-url https://example.blogspot.com --write-html

The 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

Command
$ python3 -m venv .venv && source .venv/bin/activate
$ python -m pip install google-api-python-client google-auth-oauthlib python-dotenv

python-dotenv is optional; the script loads a .env when the module is present and reads the environment either way.

3. The Python script

export_blogger_posts.py
#!/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

Command
$ export BLOGGER_CLIENT_SECRET_FILE=client_secret.json
$ python export_blogger_posts.py --blog-url https://example.blogspot.com --write-html

Output

Exported 69 posts to exported_posts

The 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:

Text
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:

JSON
{
  "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

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.

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

New articles, by email.

A short note when something new is published.