プラットフォームは、いつか使えなくなる。そのとき手元に何が残っているかで、次の一手が決まる。
本文も画像も、丸ごとローカルに落とすスクリプトを作ります
📋 この記事でわかること
- 記事本文をAPIから取得する方法
- 本文HTMLをMarkdownに変換する処理
- 本文中の画像を抽出してダウンロードする手順
- 記事ごとにフォルダを作って整理する構成
- 変更があった記事だけを更新する差分バックアップ
- ZIPでの世代管理と、復元の考え方
note上に記事を積み上げていくと、それ自体が資産になります。しかし、その資産はnoteというプラットフォームの上に置かれています。アカウントに何かあったとき、サービスの方針が変わったとき、他のプラットフォームへ移りたくなったとき——手元にデータが無いと身動きが取れません。バックアップは保険であると同時に、選択肢を確保する行為です。この記事では、記事本文と画像を含めてローカルに保存するスクリプトを作ります。前回の記事で扱った一覧取得の続きにあたる内容です。
必要なもの
- Python 3.10以降、
requests - Markdown変換をする場合は
html2text(pip install html2text) - 公開記事のみなら認証不要。有料記事の全文や下書きは認証が必要
バックアップの全体設計
いきなりコードを書く前に、どういう形で保存するかを決めておきます。後から探しやすい構造にしておくことが、実際に役立つかどうかを分けます。
note_backup/
├── index.md # 全記事の一覧(リンク付き)
├── articles.json # メタデータ一覧(差分判定に使う)
├── 20240115_nabcd1234/ # 公開日 + 記事キー
│ ├── article.md # Markdown形式の本文
│ ├── article.html # 元のHTML(保険として残す)
│ ├── meta.json # タイトル・スキ数・価格など
│ └── images/
│ ├── eyecatch.jpg # 見出し画像
│ ├── 001.png # 本文中の画像
│ └── 002.jpg
├── 20240122_nefgh5678/
│ └── ...
└── logs/
└── backup.log
フォルダ名を「公開日+記事キー」にしておくと、時系列で並び、かつ重複しません。タイトルをフォルダ名にすると、記号や長さの問題でファイルシステム上のトラブルが起きやすいため避けます。
| 保存するもの | 理由 |
|---|---|
| Markdown(.md) | 他プラットフォームへの移行が容易 |
| 元のHTML(.html) | 変換で失われた情報を復元できる |
| メタデータ(.json) | スキ数・価格・タグを残す |
| 画像ファイル | note側のURLが消えても再利用できる |
記事本文を取得する
本文の取得には /api/v3/notes/{key} を使います。一覧APIには本文が含まれないため、記事ごとに個別に叩く必要があります。
import time
import requests
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36")
def fetch_note_detail(session: requests.Session, note_key: str) -> dict:
"""記事1本の詳細(本文含む)を取得する"""
res = session.get(
f"https://note.com/api/v3/notes/{note_key}",
timeout=20,
)
if res.status_code != 200:
print(f" 取得失敗 {note_key}: HTTP {res.status_code}")
return {}
return res.json().get("data", {})
def make_session() -> requests.Session:
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept": "application/json"})
return s
if __name__ == "__main__":
session = make_session()
detail = fetch_note_detail(session, "n6a10366298b0")
print("タイトル :", detail.get("name"))
print("公開日 :", detail.get("publishAt", detail.get("publish_at")))
print("価格 :", detail.get("price"))
print("スキ :", detail.get("likeCount", detail.get("like_count")))
body = detail.get("body", "")
print(f"本文 : {len(body)} 文字(HTML)")
print("--- 冒頭200文字 ---")
print(body[:200])
time.sleep(1.2)
有料記事の本文について
他人の有料記事は、購入していなければ無料部分しか取得できません。自分の有料記事であれば、認証を通すことで全文を取得できます。バックアップの目的では自分の記事だけを対象にするため、認証付きクライアントを使うのが確実です。他人の有料記事の全文を何らかの方法で取得しようとする行為は、明確に問題があります。
HTMLをMarkdownに変換する
noteの本文はHTMLで返ります。そのまま保存してもいいのですが、他のプラットフォームで使うことを考えるとMarkdownに変換しておくと便利です。
"""
インストール:
pip install html2text
noteの本文HTMLをMarkdownへ変換する。
画像パスをローカル参照に書き換える機能も持たせる。
"""
import re
import html2text
def make_converter() -> html2text.HTML2Text:
h = html2text.HTML2Text()
h.body_width = 0 # 自動改行しない(日本語では折り返し不要)
h.ignore_links = False # リンクは残す
h.ignore_images = False # 画像も残す
h.ignore_emphasis = False
h.mark_code = True
h.unicode_snob = True # 記号をそのまま出す
return h
def html_to_markdown(html: str) -> str:
"""本文HTMLをMarkdownに変換する"""
if not html:
return ""
converter = make_converter()
md = converter.handle(html)
# 3行以上の空行を2行に詰める
md = re.sub(r"\n{3,}", "\n\n", md)
return md.strip()
def replace_image_paths(md: str, url_map: dict) -> str:
"""
Markdown内の画像URLを、ローカルの相対パスに置き換える。
url_map: {"https://assets.st-note.com/...": "images/001.png"}
"""
for remote, local in url_map.items():
md = md.replace(remote, local)
return md
if __name__ == "__main__":
sample = (
"<h2>見出しです</h2>"
"<p>本文の<strong>強調</strong>と"
"<a href='https://example.com'>リンク</a>です。</p>"
"<ul><li>項目1</li><li>項目2</li></ul>"
)
print(html_to_markdown(sample))
body_width = 0 の指定が重要です。既定では一定の文字数で自動改行されますが、日本語の文章では不自然な位置で折り返されてしまいます。0を指定すると折り返しが無効になります。
本文中の画像を抽出してダウンロードする
本文HTMLから img タグのURLを抜き出し、順にダウンロードします。標準ライブラリの正規表現で十分対応できます。
"""
本文HTMLから画像URLを抽出し、ローカルに保存する。
"""
import os
import re
import time
from urllib.parse import urlparse
import requests
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36")
IMG_PATTERN = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
MAX_BYTES = 30 * 1024 * 1024 # 1ファイル30MBまで
def extract_image_urls(html: str) -> list:
"""本文HTMLから画像URLを重複なく抽出する"""
if not html:
return []
urls = IMG_PATTERN.findall(html)
# 順序を保ったまま重複を除去する
seen = set()
result = []
for u in urls:
if u.startswith("//"):
u = "https:" + u
if u.startswith("http") and u not in seen:
seen.add(u)
result.append(u)
return result
def guess_extension(url: str, content_type: str = "") -> str:
"""URLとContent-Typeから拡張子を推測する"""
path = urlparse(url).path
ext = os.path.splitext(path)[1].lower()
if ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"):
return ext
mapping = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
return mapping.get(content_type.split(";")[0].strip(), ".jpg")
def download_images(urls: list, dest_dir: str,
session: requests.Session = None,
sleep_sec: float = 0.8) -> dict:
"""
画像を保存し、{元URL: 相対パス} の対応表を返す。
すでに存在するファイルはスキップする。
"""
if not urls:
return {}
os.makedirs(dest_dir, exist_ok=True)
session = session or requests.Session()
session.headers.update({"User-Agent": UA})
url_map = {}
for i, url in enumerate(urls, start=1):
try:
res = session.get(url, timeout=30, stream=True)
if res.status_code != 200:
print(f" 画像取得失敗({res.status_code}): {url[:60]}")
continue
ext = guess_extension(url, res.headers.get("Content-Type", ""))
filename = f"{i:03d}{ext}"
path = os.path.join(dest_dir, filename)
if os.path.exists(path):
url_map[url] = f"images/{filename}"
continue
size = 0
with open(path, "wb") as f:
for chunk in res.iter_content(chunk_size=8192):
size += len(chunk)
if size > MAX_BYTES:
raise ValueError("ファイルが大きすぎます")
f.write(chunk)
url_map[url] = f"images/{filename}"
print(f" 保存: {filename}({size // 1024} KB)")
except Exception as e:
print(f" 画像エラー: {e}")
finally:
time.sleep(sleep_sec)
return url_map
if __name__ == "__main__":
html = '<p><img src="https://example.com/sample.png"></p>'
print(extract_image_urls(html))
サイズ上限を設けているのは、想定外に大きなファイルでディスクを圧迫しないための保険です。また、すでに保存済みのファイルはスキップするため、再実行しても無駄なダウンロードが発生しません。
▶ バックアップを取ったら、次はデータを読む番です。
pandasで自分の記事の傾向を分析する方法を解説しています。
バックアップ本体を組み立てる
ここまでの部品を組み合わせて、実際に動くバックアップスクリプトにします。
"""
noteの全記事を本文・画像ごとローカルに保存する。
使い方:
python backup.py your_note_id
"""
import json
import os
import sys
import time
from datetime import datetime
import requests
from html_to_md import html_to_markdown, replace_image_paths
from image_downloader import extract_image_urls, download_images
BACKUP_ROOT = "note_backup"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36")
SLEEP_SEC = 1.5
def make_session() -> requests.Session:
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept": "application/json"})
return s
def list_articles(session: requests.Session, urlname: str,
max_pages: int = 100) -> list:
"""記事一覧を全ページ取得する"""
items = []
for page in range(1, max_pages + 1):
res = session.get(
f"https://note.com/api/v2/creators/{urlname}/contents",
params={"kind": "note", "page": page},
timeout=20,
)
if res.status_code != 200:
break
data = res.json().get("data", {})
contents = data.get("contents", [])
if not contents:
break
items.extend(contents)
print(f"一覧 page {page}: 累計 {len(items)} 件")
if data.get("isLastPage") or data.get("is_last_page"):
break
time.sleep(1.0)
return items
def folder_name(item: dict) -> str:
"""公開日 + 記事キー のフォルダ名を作る"""
published = item.get("publishAt", item.get("publish_at", "")) or ""
date_part = published[:10].replace("-", "") or "00000000"
return f"{date_part}_{item.get('key', 'unknown')}"
def save_article(session: requests.Session, item: dict, root: str) -> bool:
"""記事1本を保存する。保存したらTrueを返す"""
key = item.get("key", "")
if not key:
return False
dest = os.path.join(root, folder_name(item))
os.makedirs(dest, exist_ok=True)
# 本文を取得
res = session.get(f"https://note.com/api/v3/notes/{key}", timeout=20)
if res.status_code != 200:
print(f" スキップ(HTTP {res.status_code}): {item.get('name', '')}")
return False
detail = res.json().get("data", {})
body_html = detail.get("body", "") or ""
title = detail.get("name", "") or "(無題)"
print(f" 保存中: {title[:40]}")
# 画像をダウンロードしてパスを差し替える
urls = extract_image_urls(body_html)
url_map = download_images(urls, os.path.join(dest, "images"), session)
# 見出し画像も保存
eyecatch = detail.get("eyecatch")
if eyecatch:
eye_map = download_images([eyecatch],
os.path.join(dest, "images"), session)
url_map.update(eye_map)
# HTMLをそのまま保存(保険)
with open(os.path.join(dest, "article.html"), "w", encoding="utf-8") as f:
f.write(body_html)
# Markdownに変換して保存
md = html_to_markdown(body_html)
md = replace_image_paths(md, url_map)
header = (
f"# {title}\n\n"
f"- 公開日: {detail.get('publishAt', detail.get('publish_at', ''))}\n"
f"- URL: https://note.com/{detail.get('user', {}).get('urlname', '')}"
f"/n/{key}\n"
f"- スキ: {detail.get('likeCount', detail.get('like_count', 0))}\n"
f"- 価格: {detail.get('price', 0)}\n\n---\n\n"
)
with open(os.path.join(dest, "article.md"), "w", encoding="utf-8") as f:
f.write(header + md)
# メタデータを保存
meta = {
"key": key,
"id": detail.get("id"),
"title": title,
"published_at": detail.get("publishAt",
detail.get("publish_at", "")),
"likes": detail.get("likeCount", detail.get("like_count", 0)),
"price": detail.get("price", 0),
"body_length": len(body_html),
"image_count": len(url_map),
"backed_up_at": datetime.now().isoformat(),
}
with open(os.path.join(dest, "meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return True
def write_index(items: list, root: str):
"""全記事の一覧をMarkdownで出力する"""
lines = ["# note バックアップ一覧", "",
f"作成日時: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"記事数: {len(items)} 本", "", "---", ""]
for item in items:
folder = folder_name(item)
title = item.get("name", "(無題)")
published = (item.get("publishAt",
item.get("publish_at", "")) or "")[:10]
likes = item.get("likeCount", item.get("like_count", 0))
lines.append(f"- [{title}]({folder}/article.md) "
f"({published} / スキ{likes})")
with open(os.path.join(root, "index.md"), "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"index.md を作成しました({len(items)} 件)")
def main(urlname: str):
os.makedirs(BACKUP_ROOT, exist_ok=True)
session = make_session()
print("=== 記事一覧を取得します ===")
items = list_articles(session, urlname)
print(f"対象: {len(items)} 件\n")
print("=== 各記事を保存します ===")
saved = 0
for i, item in enumerate(items, start=1):
print(f"[{i}/{len(items)}]")
if save_article(session, item, BACKUP_ROOT):
saved += 1
time.sleep(SLEEP_SEC)
write_index(items, BACKUP_ROOT)
# 差分判定用にメタ一覧を保存
with open(os.path.join(BACKUP_ROOT, "articles.json"),
"w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
print(f"\n完了: {saved}/{len(items)} 件を保存しました")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使い方: python backup.py your_note_id")
sys.exit(1)
main(sys.argv[1])
記事数が多い場合、初回の実行には時間がかかります。100記事なら1.5秒間隔で約4分、画像のダウンロードを含めるとさらに伸びます。初回は時間に余裕があるときに走らせ、以降は差分更新に切り替えるのが現実的です。
差分だけを更新する
2回目以降は、新しい記事と、更新された記事だけを処理します。前回保存した articles.json と比較する方式です。
"""
前回のバックアップと比較し、新規・更新された記事だけを保存する。
"""
import json
import os
import sys
import time
from backup import (BACKUP_ROOT, make_session, list_articles,
save_article, write_index, folder_name)
ARTICLES_JSON = os.path.join(BACKUP_ROOT, "articles.json")
def load_previous() -> dict:
"""前回の記事一覧を {key: item} で返す"""
if not os.path.exists(ARTICLES_JSON):
return {}
with open(ARTICLES_JSON, encoding="utf-8") as f:
items = json.load(f)
return {i.get("key"): i for i in items if i.get("key")}
def needs_update(item: dict, previous: dict) -> str:
"""更新が必要かを判定し、理由を返す。不要なら空文字"""
key = item.get("key")
old = previous.get(key)
if old is None:
return "新規"
# フォルダが消えていたら再取得
if not os.path.isdir(os.path.join(BACKUP_ROOT, folder_name(item))):
return "フォルダなし"
# タイトルが変わっていたら本文も変わっている可能性が高い
if item.get("name") != old.get("name"):
return "タイトル変更"
return ""
def main(urlname: str):
session = make_session()
previous = load_previous()
print(f"前回の記録: {len(previous)} 件")
items = list_articles(session, urlname)
print(f"現在の記事: {len(items)} 件\n")
targets = []
for item in items:
reason = needs_update(item, previous)
if reason:
targets.append((item, reason))
if not targets:
print("更新が必要な記事はありません")
return
print(f"=== {len(targets)} 件を更新します ===")
for i, (item, reason) in enumerate(targets, start=1):
print(f"[{i}/{len(targets)}] ({reason})")
save_article(session, item, BACKUP_ROOT)
time.sleep(1.5)
write_index(items, BACKUP_ROOT)
with open(ARTICLES_JSON, "w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
print(f"\n完了: {len(targets)} 件を更新しました")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使い方: python backup_diff.py your_note_id")
sys.exit(1)
main(sys.argv[1])
本文が更新されたかどうかを厳密に判定するのは難しいため、ここではタイトル変更とフォルダの有無で判断しています。月に1回は全記事を取り直す運用にしておけば、判定漏れがあっても回収できます。
ZIPで世代管理する
バックアップは1世代だけだと不安が残ります。誤って上書きした場合に備え、定期的にZIPで固めて残しておきます。
"""
バックアップフォルダをZIPで固め、古い世代を自動削除する。
"""
import os
import shutil
from datetime import datetime, timedelta
BACKUP_ROOT = "note_backup"
ARCHIVE_DIR = "note_archives"
KEEP_GENERATIONS = 6 # 残す世代数
def create_archive() -> str:
os.makedirs(ARCHIVE_DIR, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d")
base = os.path.join(ARCHIVE_DIR, f"note_backup_{stamp}")
path = shutil.make_archive(base, "zip", BACKUP_ROOT)
size_mb = os.path.getsize(path) / 1024 / 1024
print(f"作成: {path}({size_mb:.1f} MB)")
return path
def cleanup_old(keep: int = KEEP_GENERATIONS):
"""古いZIPを削除して指定世代数だけ残す"""
if not os.path.isdir(ARCHIVE_DIR):
return
zips = sorted(
(f for f in os.listdir(ARCHIVE_DIR) if f.endswith(".zip")),
reverse=True,
)
for old in zips[keep:]:
path = os.path.join(ARCHIVE_DIR, old)
os.remove(path)
print(f"削除: {old}")
def report():
"""現在のアーカイブ状況を表示する"""
if not os.path.isdir(ARCHIVE_DIR):
print("アーカイブがありません")
return
zips = sorted(f for f in os.listdir(ARCHIVE_DIR) if f.endswith(".zip"))
total = 0
print("=== 保管中のアーカイブ ===")
for z in zips:
size = os.path.getsize(os.path.join(ARCHIVE_DIR, z))
total += size
print(f" {z} {size / 1024 / 1024:.1f} MB")
print(f"合計: {total / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
create_archive()
cleanup_old()
report()
保存先は分散させる
ローカルPCだけに置いていると、PCの故障で全部失われます。ZIPをクラウドストレージの同期フォルダに置く、外付けドライブにコピーする、といった多重化をしておくと安心です。バックアップは「別の場所にもある」状態になって初めて機能します。
▼ RECOMMENDATION ▼
移行先を考えるなら、収益構造も一緒に考える
バックアップを取る目的の1つは、他プラットフォームへの移行です。移行先を検討するなら、収益化の条件も比較材料に入れてください。noteはASPアフィリエイトが原則使えませんが、アメブロは規約上使えます。さらにアメプレスProを使えば、いいね・フォロー・アクセス獲得まで自動化できます。月額2,980円、365日LINEサポート付きです。
※ 当サイトはアフィリエイト広告を含みます。
WordPress移行を見据えた整形
バックアップしたMarkdownを他所で使う場合、フロントマター(記事の属性情報)を付けておくと静的サイトジェネレーターでそのまま扱えます。
"""
バックアップ済みのMarkdownに、YAMLフロントマターを付与する。
Hugo / Astro / Jekyll などで扱いやすい形にする。
"""
import json
import os
BACKUP_ROOT = "note_backup"
OUTPUT_DIR = "for_migration"
def escape_yaml(value: str) -> str:
"""YAMLの値として安全な形にする"""
return str(value).replace('"', '\\"')
def convert(folder: str) -> bool:
meta_path = os.path.join(BACKUP_ROOT, folder, "meta.json")
md_path = os.path.join(BACKUP_ROOT, folder, "article.md")
if not (os.path.exists(meta_path) and os.path.exists(md_path)):
return False
with open(meta_path, encoding="utf-8") as f:
meta = json.load(f)
with open(md_path, encoding="utf-8") as f:
body = f.read()
# 元のMarkdownからヘッダー部分を落とす
if "\n---\n\n" in body:
body = body.split("\n---\n\n", 1)[1]
published = (meta.get("published_at") or "")[:10]
front = "\n".join([
"---",
f'title: "{escape_yaml(meta.get("title", ""))}"',
f"date: {published}",
f"slug: {meta.get('key', '')}",
"draft: false",
f"note_likes: {meta.get('likes', 0)}",
f"note_price: {meta.get('price', 0)}",
"---",
"",
])
os.makedirs(OUTPUT_DIR, exist_ok=True)
out_path = os.path.join(OUTPUT_DIR, f"{meta.get('key', folder)}.md")
with open(out_path, "w", encoding="utf-8") as f:
f.write(front + body)
return True
def main():
if not os.path.isdir(BACKUP_ROOT):
print("バックアップフォルダがありません")
return
folders = [d for d in os.listdir(BACKUP_ROOT)
if os.path.isdir(os.path.join(BACKUP_ROOT, d))
and d != "logs"]
count = sum(1 for d in sorted(folders) if convert(d))
print(f"{count} 件を {OUTPUT_DIR}/ に出力しました")
if __name__ == "__main__":
main()
この形にしておけば、移行先が決まっていなくても対応の幅が広がります。バックアップの価値は、いざというときの選択肢の多さで決まります。
バックアップが機能しているか確認する
取っているつもりで壊れていた、というのがバックアップで最悪のパターンです。定期的に中身を検証する仕組みを入れておきます。
"""
バックアップの健全性をチェックする。
- 記事フォルダの数
- 本文が空のものがないか
- 画像が0件の記事がないか
"""
import json
import os
BACKUP_ROOT = "note_backup"
MIN_BODY_CHARS = 100
def verify():
if not os.path.isdir(BACKUP_ROOT):
print("バックアップフォルダがありません")
return
folders = [d for d in sorted(os.listdir(BACKUP_ROOT))
if os.path.isdir(os.path.join(BACKUP_ROOT, d))
and d != "logs"]
problems = []
total_images = 0
for folder in folders:
base = os.path.join(BACKUP_ROOT, folder)
md_path = os.path.join(base, "article.md")
meta_path = os.path.join(base, "meta.json")
if not os.path.exists(md_path):
problems.append(f"{folder}: article.md がありません")
continue
with open(md_path, encoding="utf-8") as f:
content = f.read()
if len(content) < MIN_BODY_CHARS:
problems.append(f"{folder}: 本文が極端に短い({len(content)}文字)")
if not os.path.exists(meta_path):
problems.append(f"{folder}: meta.json がありません")
img_dir = os.path.join(base, "images")
if os.path.isdir(img_dir):
total_images += len(os.listdir(img_dir))
print("=== 検証結果 ===")
print(f" 記事フォルダ : {len(folders)} 件")
print(f" 画像ファイル : {total_images} 件")
print(f" 問題 : {len(problems)} 件")
for p in problems[:20]:
print(" -", p)
if not problems:
print("\n問題は見つかりませんでした")
if __name__ == "__main__":
verify()
まとめ:手元にあることが選択肢になる
記事本文と画像を含めたバックアップは、一覧取得より一段複雑ですが、部品に分けて考えれば難しくありません。本文取得・Markdown変換・画像ダウンロード・保存という4つの処理を組み合わせるだけです。
この記事の要点
- 本文は
/api/v3/notes/{key}で記事ごとに取得する - フォルダ名は「公開日+記事キー」にすると整理しやすい
- MarkdownとHTMLの両方を残すと復元の幅が広がる
html2textはbody_width = 0を指定する- 画像URLは正規表現で抽出し、ローカルパスに差し替える
- 2回目以降は差分更新。月1回は全件を取り直す
- ZIPで世代管理し、保存先を分散させる
- 定期的に中身を検証する。取れているつもりが一番危ない
バックアップは、必要になるまで価値が実感できない作業です。しかし必要になったときには、それしか手がありません。仕組みさえ作れば以降は自動で回るので、早い段階で用意しておくことをおすすめします。
FAQ|noteのバックアップについてよくある質問
公式エクスポートは1日3回までという制限があり、実行のたびに手作業が必要です。またWXR形式のXMLで出力されるため、そのまま分析や別形式への変換には向きません。APIを使えば自動化でき、Markdownなど扱いやすい形式で保存できます。ただし公式エクスポートには本文と画像が確実に含まれるという強みがあるため、両方を併用するのがもっとも安全です。
自分の有料記事であれば、認証を通すことで全文を取得できます。この記事のコードは認証なしの構成になっているため、有料記事を含める場合は make_session() の部分を認証付きクライアントに差し替えてください。なお、他人の有料記事について購入せずに全文を取得しようとする行為は、規約上も倫理上も問題があります。
埋め込みコンテンツやnote独自の記法は、Markdownに完全には変換できません。だからこそ、この記事のスクリプトは元のHTMLも article.html として保存しています。変換結果が不十分だった箇所は、HTMLを見れば元の情報が確認できます。完璧な変換を目指すより、元データを残しておくほうが実用的です。
記事数と画像数に比例するため、初回は時間がかかります。100記事で各3枚なら300回のリクエストになり、0.8秒間隔で約4分です。すでに保存済みの画像はスキップする実装になっているので、2回目以降は大幅に短縮されます。急ぐ場合でも間隔を極端に詰めるのは避けてください。負荷をかけると制限の対象になります。
投稿頻度によります。週1本のペースなら週1回、毎日書くなら毎日でも構いません。差分更新であれば負荷は小さいため、日次で回しても問題ない規模です。加えて、月1回は全記事の取り直しを行い、ZIPで世代を残しておくと安心です。重要なのは頻度そのものより、自動で回り続ける仕組みにしておくことです。
手元のデータを使って再投稿することは可能です。ただし、APIで自動的に元通りに戻す仕組みは自分で作る必要があります。Markdownをnote用のHTMLに変換して投稿する処理は別記事で解説しています。実際のところ、復元先がnoteとは限りません。WordPressや静的サイトに移すという選択肢も含めて、データが手元にあること自体が価値です。
自分が書いた記事であれば、著作権は自分にあるため、他所で公開することに問題はありません。ただし有料記事を無料で再公開すると、購入者との関係で問題が生じる可能性があります。また、記事内に他者の画像や引用が含まれる場合は、その利用条件が移行先でも満たされているか確認が必要です。
変換部分を使わなければ、html2textは不要です。article.html の保存だけであれば標準ライブラリで完結します。Markdownが必要な場合の代替としては、markdownify や beautifulsoup4 を使った自前の変換もあります。まずはHTMLの保存だけを動かし、変換は後から追加するという進め方でも構いません。
この記事のスクリプトは、記事1本の失敗で全体が止まらないよう例外を個別に処理しています。それでも止まる場合は、ネットワークの問題か、レスポンス構造の変更が考えられます。どの記事で止まったかを確認し、その記事キーを直接指定して単体で実行すると原因を切り分けやすくなります。すでに保存済みの記事はスキップされるので、再実行しても最初からやり直しにはなりません。
バックアップ先をクラウドストレージの同期フォルダに設定するのが、もっとも簡単な方法です。スクリプト側は何も変更せず、保存先パスを同期フォルダ内にするだけで済みます。より確実にしたい場合は、各クラウドサービスのAPIやCLIツールを使ってZIPをアップロードする処理を追加してください。いずれにせよ、ローカル以外にもコピーがある状態を作ることが目的です。