API自動化 Python

noteの予約投稿をPythonで自作する|スケジューラ設計

スケジューラ

読まれる時間に、自分が起きているとは限らない。だが、スクリプトは起きている。

キューを作り、時刻を見て公開する。仕組みは驚くほど単純です

📋 この記事でわかること

  • 投稿キューをJSONで管理する設計
  • 公開時刻を判定して実行するスケジューラの実装
  • Windowsタスクスケジューラとcronの具体的な設定
  • 二重実行を防ぐロック処理
  • 失敗を検知して通知する仕組み
  • 記事を日付に自動で振り分ける分散配信

noteには予約投稿の機能がありますが、noteプレミアム(月額500円)の加入者だけが使えます。無料会員が投稿時刻をコントロールしたい場合、その時刻に自分で公開ボタンを押すしかありません。しかし公開処理をAPIで実行できるなら、実行するタイミングを制御するだけで予約投稿は自作できます。この記事では、投稿キューを管理し、時刻を見て公開するスケジューラを実装します。公開APIの記事で作った NotePublisher を使うので、先にそちらを読んでおいてください。

自作する意味

  1. プレミアムに加入しなくても投稿時刻を制御できる
  2. 複数記事の配信日を自動で分散できる
  3. 公開と同時にSNS告知など他の処理も走らせられる
  4. 失敗をログに残し、通知できる

全体の設計

仕組みはシンプルです。「いつ・どの記事を公開するか」をファイルに書いておき、定期的に実行されるスクリプトがそれを見て、時刻が来たものを公開する——これだけです。

  • 1
    下書きを作っておく——記事の実体はnote側に下書きとして置いておきます。
  • 2
    キューに登録する——記事キーと公開予定時刻をJSONファイルに書きます。
  • 3
    定期実行する——タスクスケジューラやcronで、15分おきなどにスクリプトを走らせます。
  • 4
    時刻を判定して公開——予定時刻を過ぎた記事があれば公開し、キューから外します。
ファイル構成
note_scheduler/
├── .env                     # 認証情報(gitignore対象)
├── queue.json               # 投稿キュー
├── history.json             # 実行履歴
├── scheduler.lock           # 二重実行防止用(自動生成)
├── note_auth_client.py      # 認証クライアント
├── note_publisher.py        # 公開処理
├── queue_manager.py         # キューの操作
├── scheduler.py             # 本体(定期実行される)
├── cli.py                   # キューの登録・確認用
└── logs/
    └── scheduler.log

キューを管理するモジュール

まず、投稿予定を保持する部分を作ります。JSONファイルの読み書きと、状態の管理を担当します。

queue_manager.py
"""
投稿キューの管理。
queue.json に「いつ・どの記事を公開するか」を保持する。
"""
import json
import os
from datetime import datetime, timedelta

QUEUE_FILE = "queue.json"
HISTORY_FILE = "history.json"


def load_queue(path: str = QUEUE_FILE) -> list:
    if not os.path.exists(path):
        return []
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def save_queue(items: list, path: str = QUEUE_FILE):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)


def add(note_key: str, publish_at: str, title: str = "",
        tags: list = None, path: str = QUEUE_FILE) -> dict:
    """
    キューに追加する。
    publish_at は "2026-08-05 07:30" の形式。
    """
    try:
        scheduled = datetime.strptime(publish_at, "%Y-%m-%d %H:%M")
    except ValueError:
        raise ValueError("日時は '2026-08-05 07:30' の形式で指定してください")

    items = load_queue(path)

    # 同じ記事が既に登録されていないか確認する
    for item in items:
        if item["note_key"] == note_key and item["status"] == "pending":
            raise ValueError(f"この記事は既に登録されています: {note_key}")

    entry = {
        "note_key": note_key,
        "title": title,
        "publish_at": scheduled.isoformat(),
        "tags": tags or [],
        "status": "pending",
        "created_at": datetime.now().isoformat(),
        "attempts": 0,
    }

    items.append(entry)
    items.sort(key=lambda x: x["publish_at"])
    save_queue(items, path)

    return entry


def due_items(now: datetime = None, path: str = QUEUE_FILE) -> list:
    """公開時刻を過ぎた pending の項目を返す"""
    now = now or datetime.now()
    items = load_queue(path)

    due = []
    for item in items:
        if item["status"] != "pending":
            continue
        scheduled = datetime.fromisoformat(item["publish_at"])
        if scheduled <= now:
            due.append(item)

    return due


def mark(note_key: str, status: str, message: str = "",
         path: str = QUEUE_FILE):
    """項目の状態を更新する"""
    items = load_queue(path)

    for item in items:
        if item["note_key"] == note_key and item["status"] == "pending":
            item["status"] = status
            item["message"] = message
            item["processed_at"] = datetime.now().isoformat()
            item["attempts"] = item.get("attempts", 0) + 1
            break

    save_queue(items, path)


def cleanup(days: int = 30, path: str = QUEUE_FILE):
    """完了から一定期間経った項目を履歴へ移す"""
    items = load_queue(path)
    threshold = datetime.now() - timedelta(days=days)

    keep = []
    archived = []

    for item in items:
        processed = item.get("processed_at")
        if item["status"] == "done" and processed:
            if datetime.fromisoformat(processed) < threshold:
                archived.append(item)
                continue
        keep.append(item)

    if archived:
        history = []
        if os.path.exists(HISTORY_FILE):
            with open(HISTORY_FILE, encoding="utf-8") as f:
                history = json.load(f)
        history.extend(archived)

        with open(HISTORY_FILE, "w", encoding="utf-8") as f:
            json.dump(history, f, ensure_ascii=False, indent=2)

        save_queue(keep, path)
        print(f"{len(archived)} 件を履歴へ移しました")


def summary(path: str = QUEUE_FILE):
    """キューの状態を表示する"""
    items = load_queue(path)
    if not items:
        print("キューは空です")
        return

    counts = {}
    for item in items:
        counts[item["status"]] = counts.get(item["status"], 0) + 1

    print("=== キューの状態 ===")
    for status, count in counts.items():
        print(f"  {status}: {count} 件")

    pending = [i for i in items if i["status"] == "pending"]
    if pending:
        print("\n=== 公開待ち ===")
        for item in pending[:15]:
            when = datetime.fromisoformat(item["publish_at"])
            title = item.get("title") or item["note_key"]
            print(f"  {when.strftime('%m/%d %H:%M')}  {title[:40]}")


if __name__ == "__main__":
    summary()

スケジューラ本体

定期的に実行され、時刻が来た記事を公開する部分です。ここに二重実行の防止とエラー処理を組み込みます。

scheduler.py
"""
定期実行され、公開時刻を過ぎた記事を公開する。

    python scheduler.py           # 確認のみ
    python scheduler.py --run     # 実際に公開する
"""
import logging
import os
import sys
import time
from datetime import datetime

from note_auth_client import NoteAuthClient, CookieExpiredError
from note_publisher import NotePublisher
import queue_manager as qm

LOCK_FILE = "scheduler.lock"
LOCK_TIMEOUT_SEC = 1800     # ロックが残り続けた場合の解除時間
SLEEP_BETWEEN = 5.0
MAX_ATTEMPTS = 3

os.makedirs("logs", exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("logs/scheduler.log", encoding="utf-8"),
        logging.StreamHandler(),
    ],
)
logger = logging.getLogger(__name__)


# ---- 二重実行の防止 ----

def acquire_lock() -> bool:
    """ロックを取得する。既にロックがあればFalse"""
    if os.path.exists(LOCK_FILE):
        age = time.time() - os.path.getmtime(LOCK_FILE)
        if age < LOCK_TIMEOUT_SEC:
            logger.warning("別のプロセスが実行中です(%.0f秒前から)", age)
            return False
        logger.warning("古いロックを解除します(%.0f秒経過)", age)
        release_lock()

    with open(LOCK_FILE, "w", encoding="utf-8") as f:
        f.write(f"{os.getpid()}\n{datetime.now().isoformat()}")
    return True


def release_lock():
    if os.path.exists(LOCK_FILE):
        os.remove(LOCK_FILE)


# ---- 公開処理 ----

def publish_one(pub: NotePublisher, item: dict) -> bool:
    """1件を公開する"""
    note_key = item["note_key"]
    title = item.get("title") or note_key

    logger.info("公開処理: %s", title)

    try:
        current = pub.fetch(note_key)
    except Exception as e:
        logger.error("記事を取得できません(%s): %s", note_key, e)
        qm.mark(note_key, "error", str(e))
        return False

    # すでに公開済みならスキップ
    if current.get("status") == "published":
        logger.info("すでに公開されています: %s", title)
        qm.mark(note_key, "done", "already published")
        return True

    changes = {}
    if item.get("tags"):
        changes["hashtags"] = item["tags"]

    result = pub.publish(note_key, dry_run=False, **changes)

    if result.get("ok"):
        logger.info("公開しました: %s", title)
        qm.mark(note_key, "done", "published")
        return True

    attempts = item.get("attempts", 0) + 1
    if attempts >= MAX_ATTEMPTS:
        logger.error("試行上限に達しました: %s", title)
        qm.mark(note_key, "failed", "max attempts")
    else:
        logger.warning("失敗(%s回目)。次回に再試行します: %s",
                       attempts, title)
        # pending のまま attempts だけ増やす
        items = qm.load_queue()
        for i in items:
            if i["note_key"] == note_key and i["status"] == "pending":
                i["attempts"] = attempts
        qm.save_queue(items)

    return False


def main(dry_run: bool = True):
    due = qm.due_items()

    if not due:
        logger.info("公開対象はありません")
        return 0

    logger.info("公開対象: %s 件", len(due))
    for item in due:
        when = datetime.fromisoformat(item["publish_at"])
        print(f"  {when.strftime('%m/%d %H:%M')}  "
              f"{item.get('title') or item['note_key']}")

    if dry_run:
        print("\n[確認モード] --run を付けると実際に公開します")
        return 0

    if not acquire_lock():
        return 1

    try:
        client = NoteAuthClient()
        client.verify()
        pub = NotePublisher(client)

        success = 0
        for item in due:
            if publish_one(pub, item):
                success += 1
            time.sleep(SLEEP_BETWEEN)

        logger.info("完了: %s/%s 件を公開しました", success, len(due))
        qm.cleanup()
        return 0

    except CookieExpiredError as e:
        logger.error("認証エラー: %s", e)
        return 2
    except Exception as e:
        logger.exception("想定外のエラー: %s", e)
        return 3
    finally:
        release_lock()


if __name__ == "__main__":
    sys.exit(main(dry_run="--run" not in sys.argv))

ロック処理が必要な理由

タスクスケジューラやcronは、前回の実行が終わっていなくても次を起動します。処理に時間がかかると、2つのプロセスが同じ記事を公開しようとする可能性があります。ロックファイルで排他制御を入れておけば、この事故を防げます。finally で必ず解放するのと、古いロックを一定時間で解除する処理の両方が必要です。

▶ 公開APIの詳細な仕様は、別記事で解説しています。
差分更新できない仕様への対処が要点です。

公開APIの記事を見る

キューを操作するCLI

予約を登録したり、確認したりするためのコマンドラインツールです。

cli.py
"""
キューを操作するコマンドラインツール。

    python cli.py list
    python cli.py add nXXXXXXXX "2026-08-05 07:30" --title "記事タイトル"
    python cli.py remove nXXXXXXXX
    python cli.py drafts
"""
import sys
from datetime import datetime

import queue_manager as qm


def cmd_list():
    qm.summary()


def cmd_add(args: list):
    if len(args) < 2:
        print('使い方: python cli.py add <note_key> "YYYY-MM-DD HH:MM"')
        return

    note_key = args[0]
    publish_at = args[1]

    title = ""
    if "--title" in args:
        idx = args.index("--title")
        if idx + 1 < len(args):
            title = args[idx + 1]

    tags = []
    if "--tags" in args:
        idx = args.index("--tags")
        if idx + 1 < len(args):
            tags = [t.strip() for t in args[idx + 1].split(",")]

    try:
        entry = qm.add(note_key, publish_at, title=title, tags=tags)
    except ValueError as e:
        print("エラー:", e)
        return

    when = datetime.fromisoformat(entry["publish_at"])
    print(f"登録しました: {when.strftime('%Y-%m-%d %H:%M')} "
          f"{title or note_key}")


def cmd_remove(args: list):
    if not args:
        print("使い方: python cli.py remove <note_key>")
        return

    note_key = args[0]
    items = qm.load_queue()
    before = len(items)
    items = [i for i in items if i["note_key"] != note_key]

    if len(items) == before:
        print("該当する項目がありません")
        return

    qm.save_queue(items)
    print(f"削除しました: {note_key}")


def cmd_drafts():
    """下書き一覧を表示して、キューに登録しやすくする"""
    from note_auth_client import NoteAuthClient
    from note_draft import NoteDraft

    client = NoteAuthClient()
    client.verify()

    drafts = NoteDraft(client).list_drafts()

    if not drafts:
        print("下書きはありません")
        return

    print("=== 下書き一覧 ===")
    for d in drafts:
        key = d.get("key", "")
        title = (d.get("name") or "(無題)")[:44]
        print(f"  {key}  {title}")

    print("\n登録するには:")
    print('  python cli.py add <key> "2026-08-05 07:30" --title "..."')


COMMANDS = {
    "list": lambda args: cmd_list(),
    "add": cmd_add,
    "remove": cmd_remove,
    "drafts": lambda args: cmd_drafts(),
}


if __name__ == "__main__":
    if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
        print("使えるコマンド:", ", ".join(COMMANDS))
        sys.exit(1)

    COMMANDS[sys.argv[1]](sys.argv[2:])

定期実行の設定

スクリプトを自動で走らせる設定です。15分おき程度の実行を想定しています。

run_scheduler.bat(Windows)
@echo off
rem タスクスケジューラから15分おきに実行する
rem 「タスクの作成」→「トリガー」→「繰り返し間隔: 15分」

cd /d C:\note_scheduler
call venv\Scripts\activate.bat

python scheduler.py --run

deactivate

タスクスケジューラでの設定手順を整理します。

項目 設定値
トリガー 毎日、15分ごとに繰り返し(期間は1日)
操作 プログラムの開始 → run_scheduler.bat
開始(作業フォルダ) C:\note_scheduler
設定 「タスクを停止するまでの時間」を1時間に
条件 「電源に接続されている場合のみ」のチェックを外す
crontab(macOS / Linux)
# crontab -e で編集する
# 15分ごとに実行する

*/15 * * * * cd /home/user/note_scheduler && venv/bin/python scheduler.py --run

# 毎朝8時にキューの状態を確認する
0 8 * * * cd /home/user/note_scheduler && venv/bin/python cli.py list

実行間隔は15分で十分

1分おきに実行しても、公開時刻の精度が上がるだけで実質的な差はありません。むしろ認証確認のリクエストが増えるだけです。15分おきなら、予約時刻から最大15分のずれで公開されます。読者にとってこの差は認識されないので、負荷の少ない間隔を選んでください。

記事を日付に自動で振り分ける

書き溜めた記事をまとめてキューに入れるとき、公開日を1本ずつ指定するのは面倒です。ルールに従って自動で割り当てる処理を作ります。

distribute.py
"""
下書きを、指定したルールで日付に振り分けてキューに登録する。

    python distribute.py --interval 2 --time 07:30 --start 2026-08-01
"""
import sys
from datetime import datetime, timedelta

from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
import queue_manager as qm

# 投稿しない曜日(0=月曜 ... 6=日曜)
SKIP_WEEKDAYS = set()


def parse_args(argv: list) -> dict:
    opts = {
        "interval": 2,
        "time": "07:30",
        "start": None,
        "limit": 20,
    }

    for key in opts:
        flag = f"--{key}"
        if flag in argv:
            idx = argv.index(flag)
            if idx + 1 < len(argv):
                value = argv[idx + 1]
                opts[key] = int(value) if key in ("interval", "limit") \
                    else value

    return opts


def next_slot(current: datetime, interval_days: int) -> datetime:
    """次の投稿日を求める(スキップ曜日を避ける)"""
    candidate = current + timedelta(days=interval_days)

    while candidate.weekday() in SKIP_WEEKDAYS:
        candidate += timedelta(days=1)

    return candidate


def main(argv: list):
    opts = parse_args(argv)

    hour, minute = (int(x) for x in opts["time"].split(":"))

    if opts["start"]:
        start = datetime.strptime(opts["start"], "%Y-%m-%d")
    else:
        start = datetime.now() + timedelta(days=1)

    start = start.replace(hour=hour, minute=minute,
                          second=0, microsecond=0)

    try:
        client = NoteAuthClient()
        client.verify()
    except CookieExpiredError as e:
        print("認証エラー:", e)
        return

    drafts = NoteDraft(client).list_drafts()

    # 本文がある程度あるものだけを対象にする
    targets = [
        d for d in drafts
        if len(d.get("body") or "") > 500 and (d.get("name") or "").strip()
    ]

    if not targets:
        print("登録できる下書きがありません")
        return

    targets = targets[:opts["limit"]]

    print(f"=== {len(targets)} 件を振り分けます ===")
    print(f"  開始    : {start.strftime('%Y-%m-%d %H:%M')}")
    print(f"  間隔    : {opts['interval']} 日おき")

    current = start
    registered = 0

    for d in targets:
        key = d.get("key")
        title = (d.get("name") or "")[:44]

        if not key:
            continue

        try:
            qm.add(
                key,
                current.strftime("%Y-%m-%d %H:%M"),
                title=title,
            )
            print(f"  {current.strftime('%m/%d %H:%M')}  {title}")
            registered += 1
            current = next_slot(current, opts["interval"])
        except ValueError as e:
            print(f"  スキップ: {title}({e})")

    print(f"\n{registered} 件をキューに登録しました")
    print("確認: python cli.py list")


if __name__ == "__main__":
    main(sys.argv[1:])

この処理があると、週末にまとめて書いて、2日おきに自動配信するといった運用ができます。投稿が途切れる最大の原因は「書く日と出す日が同じであること」なので、この2つを分離できる効果は小さくありません。この考え方は予約投稿の記事でも解説しています。

▼ RECOMMENDATION ▼

投稿の自動化より、集客の自動化のほうが効く

予約投稿を自作すれば、投稿タイミングは制御できます。ただし、公開した記事を読む人を増やす作業は依然として手作業です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を24時間自動で回します。スクリプトの保守もサーバーの準備も不要です。noteと違ってASPアフィリエイトが使えるため、集めたアクセスを収益に変える設計もできます。月額2,980円、365日LINEサポート付きです。

✦ 保守不要で24時間稼働 ✦ 自動いいね・フォロー ✦ ASPアフィリOK ✦ 365日LINEサポート
アメプレスPro 公式ページを確認する →

※ 当サイトはアフィリエイト広告を含みます。

失敗を検知して通知する

自動実行の最大のリスクは、失敗に気づかないことです。認証切れで数週間止まっていた、というのはよくある話です。

notify.py
"""
スケジューラの状態を確認し、問題があれば通知する。
1日1回実行する想定。
"""
import os
from datetime import datetime, timedelta

import requests

import queue_manager as qm

# Discord や Slack の Webhook URL(環境変数から読む)
WEBHOOK_URL = os.getenv("NOTIFY_WEBHOOK", "")
LOG_FILE = "logs/scheduler.log"
STALE_HOURS = 6


def send(message: str):
    print(message)
    if not WEBHOOK_URL:
        return
    try:
        requests.post(WEBHOOK_URL, json={"content": message}, timeout=10)
    except requests.RequestException as e:
        print("通知の送信に失敗:", e)


def check() -> list:
    """異常を検出して、メッセージの配列を返す"""
    problems = []
    items = qm.load_queue()
    now = datetime.now()

    # 1. 公開時刻を大きく過ぎているのに残っている
    overdue = []
    for item in items:
        if item["status"] != "pending":
            continue
        scheduled = datetime.fromisoformat(item["publish_at"])
        if scheduled < now - timedelta(hours=1):
            overdue.append(item)

    if overdue:
        titles = ", ".join(
            (i.get("title") or i["note_key"])[:20] for i in overdue[:5]
        )
        problems.append(
            f"公開時刻を過ぎた記事が {len(overdue)} 件あります: {titles}"
        )

    # 2. 失敗した項目
    failed = [i for i in items if i["status"] in ("failed", "error")]
    if failed:
        problems.append(f"失敗した項目が {len(failed)} 件あります")

    # 3. ログが更新されていない(スケジューラが動いていない)
    if os.path.exists(LOG_FILE):
        age_hours = (
            datetime.now().timestamp() - os.path.getmtime(LOG_FILE)
        ) / 3600
        if age_hours > STALE_HOURS:
            problems.append(
                f"スケジューラが {age_hours:.0f} 時間動いていません"
            )
    else:
        problems.append("ログファイルがありません")

    return problems


def main():
    problems = check()

    if not problems:
        print("問題ありません")
        return 0

    message = "【note スケジューラ】\n" + "\n".join(
        f"- {p}" for p in problems
    )
    send(message)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

3つ目のチェック——ログが更新されていないことの検知——が特に重要です。スクリプトがエラーで止まっているだけでなく、タスクスケジューラの設定が外れている、PCがスリープしていた、といった原因も検出できます。

運用上の注意点

注意点 理由 対策
PCの電源が入っていないと動かない ローカル実行の限界 常時稼働のサーバーを使う
スリープ中は実行されない タスクが起動しない スリープ解除の設定を有効にする
認証が切れると全部止まる Cookieの有効期限 失効を通知する仕組みを入れる
予約後に記事を編集すると混乱する キューは記事の内容を持たない 公開前の編集は避ける
時事ネタは予約に向かない 公開時に状況が変わる 手動で公開する
同じ日に複数公開してしまう キューの確認不足 定期的に一覧を確認する

ローカルPCでの運用には限界がある

タスクスケジューラでの運用は手軽ですが、PCが起動していなければ実行されません。確実に動かしたい場合は、常時稼働のサーバーやクラウド環境を使うことになります。ただし、そこまでするかどうかは投稿頻度次第です。週に数本であれば、多少のずれは実害がありません。仕組みを高度にするより、ずれても困らない運用にするほうが現実的な場合もあります。

まとめ:キューと時刻判定だけで作れる

予約投稿の自作は、複雑な仕組みを必要としません。公開処理がAPIで実行できる以上、あとは「いつ実行するか」を管理するだけです。

この記事の要点

  1. 記事の実体は下書きとしてnote側に置き、キューには参照だけを持つ
  2. スケジューラは15分おき程度の実行で十分
  3. ロックファイルで二重実行を防ぐ(finallyで必ず解放する)
  4. 失敗はリトライし、上限に達したら状態を変える
  5. 下書きを日付に自動で振り分けると、まとめ書きが活きる
  6. ログの更新が止まっていることを検知する仕組みを入れる
  7. 時事ネタは予約に向かない。手動で公開する
  8. ローカル実行はPCの稼働状況に依存する

まずは1本だけキューに入れて、翌朝きちんと公開されるかを確認してください。動作が確認できたら、書き溜めた記事をまとめて登録する運用に移行できます。

FAQ|noteの予約投稿自作についてよくある質問

Q. noteプレミアムの予約投稿と何が違いますか?

公式機能はnote側のサーバーで実行されるため、自分のPCが起動している必要がありません。確実性ではこちらが上です。一方、自作の場合は月額費用がかからず、公開と同時に他の処理(SNS告知やログ記録など)を走らせられる自由度があります。投稿頻度が高く確実性を求めるなら公式機能、細かい制御をしたいなら自作、という使い分けになります。

Q. PCを起動していない時間帯に投稿できますか?

ローカル実行では不可能です。対策は3つあります。1つ目はPCのスリープ解除設定を使う方法、2つ目は常時稼働のサーバーやクラウド環境で動かす方法、3つ目は投稿時刻をPCが起動している時間帯に限定する方法です。3つ目が最も手軽で、実際には多くのケースでこれで足ります。

Q. 予約時刻の精度はどのくらいですか?

スケジューラの実行間隔に依存します。15分おきに実行する設定なら、予約時刻から最大15分遅れて公開されます。読者からすればこの差は認識されないため、実用上の問題はありません。精度を上げたければ実行間隔を短くできますが、その分だけリクエストが増えるため、5分より短くする必要性はほとんどないでしょう。

Q. 同じ記事が2回公開されることはありますか?

この記事の実装では防いでいます。ロックファイルによる排他制御に加え、公開前に記事の状態を確認し、すでにpublishedであればスキップする処理を入れています。さらにキューへの登録時にも重複チェックがあります。この3段構えで、二重公開はほぼ起こりません。ただし手動で公開した記事がキューに残っている場合は、スキップ扱いになります。

Q. 認証が切れたときはどうなりますか?

公開処理は失敗し、キューにpendingのまま残ります。次回の実行でも失敗し、試行回数が上限に達するとfailed状態になります。この記事の notify.py を1日1回実行しておけば、公開時刻を過ぎた記事が溜まっていることを検知して通知できます。Cookieを更新すれば、pendingのまま残っている記事は次回の実行で公開されます。

Q. 公開と同時にSNSへ告知できますか?

できます。publish_one() が成功したあとに、SNSのAPIを呼ぶ処理を追加するだけです。これは自作する大きなメリットの1つで、公式の予約投稿では実現できません。ただし各SNSのAPIには別途認証が必要になるため、実装の手間は増えます。まずはnoteの公開だけを安定させてから、拡張を検討してください。

Q. 予約した記事を後から編集したいのですが

note側で下書きを編集すれば反映されます。キューは記事キーと時刻だけを持っており、内容は保持していないためです。ただし、編集中に公開時刻が来ると中途半端な状態で公開される可能性があります。編集する場合は、一度キューから外すか、公開時刻を先に延ばしてから作業してください。

Q. 何本くらいキューに入れておけますか?

技術的な上限はありませんが、実運用では2週間から1ヶ月分程度が適切です。それ以上先まで予約すると、公開される頃には情報が古くなっていたり、自分の考えが変わっていたりします。この記事の distribute.py で上限を20件に設定しているのはこの理由です。予約は「近い将来」に留めるのが安全です。

Q. 時刻はどう決めるのがいいですか?

一般的には朝7〜9時、昼12〜13時、夜20〜22時が読まれやすい時間帯とされています。ただし、これは想定読者の生活リズム次第です。会社員向けなら通勤時間、主婦層向けなら午前中、学生向けなら夜遅めが機能します。検索から読まれることを狙った記事であれば、公開時刻はほとんど影響しません。詳しくは投稿時間の記事で解説しています。

Q. cronの設定が反映されません

cronは環境変数が最小限しか設定されないため、Pythonのパスが通っていないケースが多くあります。仮想環境のPythonをフルパスで指定してください。また作業ディレクトリも異なるため、cd で明示的に移動する必要があります。ログをファイルに出力する設定にしておけば、実行されているかどうかを確認できます。この記事の設定例はこれらに対応しています。

アメプレスラボ編集部

AMEPRESS LAB EDITORIAL TEAM

アメブロ・note・WordPressを実際に運営し、それぞれの収益構造を数字で検証しているチーム。プラットフォームごとの得意・不得意を踏まえた、宣伝色に偏らない実践情報の発信を続けています。