Blogger API を使い記事を一括更新(注意事項あり)

2025年1月19日日曜日

BeautifulSoup Blogger Blogger API Bloggerカスタマイズ Python

t f B! P L

ブログの記事が多くなってくると、複数の記事に対してある文字を置き換えるなど一括編集したくなる場面があります。前の記事でブログ村のバナー貼りを各記事に人力コピペしていたところをテンプレートに取り込みました。こうするとこれまで人力で貼ったバナーとテンプレートのバナーが2つ表示されてしまうため、人力コピペバナーを削除したくなりました。しかしながら、Bloggerの記事編集画面にはそういった機能はなさそうです。

APIを使うことで記事を更新できるため、Pythonスクリプトで記事を取得し、投稿の内容を変更してから更新する方法を調べやってみました。

準備

Google Cloudの認証情報取得

Blogger APIを使って記事を更新するには、Google CloudでBlogger APIを有効にし、 Blogger API ガイド から、またはGoogle Cloud ConsoleからOAuth 2.0 クライアント IDを作成する必要があります。

OAuth 2.0 クライアント IDが作成できたらクライアント シークレットをjsonファイルにダウンロードしておきます。

lifetechia.com さんにこのあたりの手順が詳しく載っています。

Pythonの準備

$ pip install google-api-python-client oauth2client httplib2 bs4

スクリプト

モジュールのインポートとグローバル変数

import os
import re
import json
import httplib2
from bs4 import BeautifulSoup
from googleapiclient.discovery import build
from oauth2client import file, client, tools

# グローバル変数
client_id = "XXXXXXXXXXXXXXX.apps.googleusercontent.com"
client_secret = "XXXXXXXXXXXXXX"
blog_id = "626605194105590####"

OAuth2.0認証

oauth2clientの認証フローです。

def blogger_auth(blog_id, client_id, client_secret):
    # OAuth 2.0 flow
    flow = client.OAuth2WebServerFlow(
        client_id,
        client_secret,
        "https://www.googleapis.com/auth/blogger",
        redirect_uri="urn:ietf:wg:oauth:2.0:oob",
    )
    storage = file.Storage(__file__ + "dat")
    credentials = storage.get()

    if credentials is None or credentials.invalid:
        credentials = tools.run_flow(flow, storage)

    http = credentials.authorize(httplib2.Http())
    service = build("blogger", "v3", http=http)

    return service

プログラムの初回実行時にブラウザが立ち上がり、認証画面が出ます。認証情報はファイルに保存され、トークンの期限切れまでは認証画面なしで実行できます。

認証後、Blogger APIのサービスを返します。

投稿の取得とバックアップ

投稿の取得は先程の認証で取得したサービスに対し、APIのメソッド(posts, list)を使います。APIの詳細は Blogger APIリファレンス PyDocリファレンス を参照ください。

def blogger_get_posts(
    service, blog_id, end_date, start_date, max_results, order_by, sort_option
):

    try:
        post_list = (
            service.posts()
            .list(
                blogId=blog_id,
                endDate=end_date,
                startDate=start_date,
                maxResults=max_results,
                orderBy=order_by,
                sortOption=sort_option,
                fetchBodies=True,
                fetchImages=True,
            )
            .execute()
        )
        print("リストを取得しました:", len(post_list["items"]))
        return post_list
    except Exception as e:
        print("Errror occured:", str(e))
        return False

これで取得した投稿リストの各投稿に対して変更を行っていくわけですが、その前に念の為バックアップをしておきましょう。listメソッドで返ってきたオブジェクトをjson.dumpsして保存すればよいのですが、各記事をローカルでブラウザから見られるように1記事ごとHTMLの1ファイルに書き出す機能も付けました。

def blogger_backup_local(posts, split=False):
    if split:
        for i, p in enumerate(posts["items"]):
            fname = re.sub(
                r'[&()\\/:*?"<>|\s]+',
                "_",
                f'{i:03}_{p["id"]}_{p["title"]}.html',
            )
            with open("archive/" + fname, mode="w") as f:
                f.write(p["content"])
        return True
    else:
        print(json.dumps(post, ensure_ascii=False))
        return True

これでいったんバックアップを保存します。

if __name__ == "__main__":
    service = blogger_auth(blog_id, client_id, client_secret)
    posts = blogger_get_posts(
        service,
        blog_id,
        "2025-12-31T00:00:00.000Z",
        "2020-01-01T00:00:00.000Z",
        500,
        "PUBLISHED",
        "ASCENDING",
    )
    blogger_backup_local(posts, split=True)
    blogger_backup_local(posts, split=False)

投稿の内容を変更

投稿の内容を変更する部分です。今回は、 <div class="blogmura"> ... </div> の部分を削除するため、BeautifulSoupを使いました。lxmlパーサは余計なhtml, head, bodyタグを付けるので、divを削除後、これらを外しています。簡単な編集であれば replace() re.gsub() で文字列置換してもいいでしょう。

def modify_content(content):
    soup = BeautifulSoup(content, "lxml")
    tags = soup.select("div.blogmura")
    for tag in tags:
        tag.extract()
    if soup.html is not None:
        soup.html.unwrap()
    if soup.head is not None:
        soup.head.unwrap()
    if soup.body is not None:
        soup.body.unwrap()
    print(len(tags), "個のdiv.blogmuraタグを削除しました。")
    return len(tags), soup.prettify()

変更が無ければ記事のアップデートはしないようにするため、削除したタグの個数を戻り値に含めています。

投稿を更新

まずは特定の投稿を更新する関数です。記事の更新は、updateとpatchがあるようです。今回はpatchを使い、部分的な更新(記事の中身、contentのみ)を行います。

def blogger_patch(service, blog_id, post_id, body):
    if body is None:
        print("No body")
        return None

    try:
        new_post = (
            service.posts()
            .patch(blogId=blog_id, postId=post_id, body=body, publish=None)
            .execute()
        )
        print("投稿を更新しました。id:", new_post["id"], " ,url:", new_post["url"])
        return True
    except Exception as e:
        print("Error occured:...", str(e))
        return False

メインの中で取得した投稿リストに対し、各記事をループで回し、変更があれば更新するようにしました。

if __name__ == "__main__":
    service = blogger_auth(blog_id, client_id, client_secret)
    posts = blogger_get_posts(
        service,
        blog_id,
        "2026-01-01T00:00:00.000Z",
        "2025-01-12T00:00:00.000Z",
        500,
        "PUBLISHED",
        "ASCENDING",
    )

    # update
    for p in posts["items"]:
        print("投稿id: ", p["id"], "を変更します。")
        replaced, p["content"] = modify_content(p["content"])

        if replaced > 0:
            blogger_patch(
                service,
                blog_id,
                p["id"],
                p,
            )
        else:
            print("更新はありませんでした。")

メタタグ 検索向け説明 が消えてしまう

blogger_get_posts の期間を1〜2記事に限定し試し、大丈夫そうなので全記事を更新していました。5分程度で300記事ちょっとが更新され大成功!!

と思いきや、大きな課題が出ました。

Bloggerの設定で「メタタグ 検索向け説明を有効にする」をONにすると、各記事に150字以内の説明文が記入でき、meta description タグが挿入される仕様になっています。

しかし更新後、となんと各記事の検索向け説明が消えてしまいました😭

綺麗サッパリきえました、ええ。「検索向け説明」は、バックアップでも取れていないし、APIからの更新方法についても全く記載がありません。ネット検索すると同じような質問がありましたが、解決策は無いようです。

やってしまった。。。これからは「検索向け説明」OFFでやっていきます。

もう1点、失敗したことがあります。上の例ではpatchで更新しましたが、はじめupdateで更新し、必要な部分(content)のみ送信したところ、記事のタイトルが全部消えてしまいました😭 これはバックアップから復元できましたがupdateを使う際は基本的にlistで取得した記事のすべての情報を入れて送信してあげる必要があります。

まとめ

Blogger APIを使い、記事を更新することができました。大量の記事を自動で更新できるのは非常に役に立ちますが、検索向け説明が消える問題はいかんともしがたく、解決策ご存知の方はコメント欄で教えてください🙇

関連記事

QooQ