まえがき
この記事は生成AIだけでどこまで出来るかの実験も兼ねています。
- 使用したツール: Manus で基本形を作成、 Windsurf で詳細を修正・デバッグして完成
- この記事の本文: Windsurf で自動生成
- Manusの再生リンク: https://manus.im/share/DMdtF3cJvVP1J88zNiuBWM?replay=1
- GitHubリポジトリ: https://github.com/tomorrow56/note_web_downloader
- デプロイURL(Vercel): https://note-web-downloader.vercel.app/
はじめに
「作成した自分のnote記事を画像も含めてオフラインへバックアップしたい」という個人的なニーズから、note.com の記事URLを入力するだけで、本文をMarkdown化し、画像も含めてZIPで一括ダウンロードできるWebツール「note Web Downloader」を作りました。
この記事では、ツールの機能と、それを支える技術的な仕組みを解説します。
ツールの主な機能
-
URL入力だけで簡単ダウンロード
noteのURLを貼り付けて実行するだけで、記事の取得が完了します。 -
HTML→Markdown変換(ATX見出し)
取得したHTMLから本文を抽出し、見出しはATXスタイルでMarkdown化します。 -
画像の自動ダウンロード
本文内の画像を検出し、images/image_001.extのような連番で保存します。
data:スキーマのインライン画像は除外します。 -
ZIP形式で一括配布
article.mdとimages/を含むZIPをAPIレスポンスとして返却します。 -
note特有の要素を適切に除外
目次領域(o-noteEyecatch-tableOfContents)など、本文以外のブロックを除外します。 -
運用補助
CORS対応、ヘルスチェック、デバッグ用エンドポイントを提供します。
技術的な仕組みの解説
フロントはシンプルな1ページ(src/static/index.html)。
フォーム送信で /api/download にJSON POSTし、返ってきたBlobをそのまま保存します。
サーバはFlaskのBlueprintでAPIを提供し、Vercelへサーバレスデプロイしています。
コア技術1: HTMLの解析と情報抽出(BeautifulSoup)
- 記事HTMLの取得(
requests.get)→BeautifulSoupで解析 - タイトルは
h1やog:titleなどから堅牢に取得 - 本文は
<article>要素を基点に抽出 - 目次ブロックを削除
import requests
from bs4 import BeautifulSoup
resp = requests.get(url, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, 'html.parser')
# タイトル(複数候補から最初に見つかったもの)
# 例: <meta property="og:title" content="..."> / <h1>...</h1>
content = soup.find('article')
if not content:
raise Exception('記事本文が見つかりません')
# 目次の除去(note固有)
toc = content.find(class_='o-noteEyecatch-tableOfContents')
if toc:
toc.decompose()
コア技術2: 画像のダウンロードとパス置換
-
<img>を走査し、srcの絶対URL化(urljoin) - ストリーミングで安全に保存(チャンク書き込み)
- URL末尾 or
Content-Typeから拡張子を推定 - 連番ファイル名で
images/に保存し、img[src]をローカル相対パスへ書換え -
data:URIは除外、<a><img></a>のようなラップはアンラップ
from urllib.parse import urljoin
image_count = 0
for img in content.find_all('img'):
src = img.get('src')
if not src or src.startswith('data:'):
continue
img_url = urljoin(url, src)
r = requests.get(img_url, stream=True, timeout=30)
r.raise_for_status()
image_count += 1
ext = guess_ext(img_url, r.headers.get('Content-Type')) # URL末尾/Content-Typeで推定
name = f'image_{image_count:03d}{ext}'
with open(os.path.join(images_dir, name), 'wb') as f:
for chunk in r.iter_content(8192):
f.write(chunk)
img['src'] = os.path.join('images', name)
if img.parent.name == 'a':
img.parent.unwrap()
コア技術3: Markdownへの変換と品質向上
-
markdownifyで本文HTML→Markdown(heading_style='ATX') -
data:URIの画像参照は正規表現で削除(安全性・サイズ対策)
from markdownify import markdownify as md
import re
markdown = md(str(content), heading_style='ATX')
# data URI 画像を除去
markdown = re.sub(r'!\[.*?\]\(data:[^\)]+\)', '', markdown)
こだわったポイント(運用・品質)
-
ファイル名サニタイズ
記事タイトルをフォルダ名に使う前に、\\/*?"<>|などの禁止文字を除去。 -
安全な一時領域の管理
tempfile.mkdtemp()で作った作業ディレクトリを finally で確実に削除。 -
堅牢なエラーハンドリング
ネットワーク例外、JSONパース失敗、無効URL、ZIP失敗などを粒度高く返却。 -
デプロイ互換性
Vercel環境ではインメモリSQLite、ローカルではファイルSQLiteに自動切替。
API エンドポイント
-
POST /api/download- 入力:
{ "url": "https://note.com/<user>/n/<id>" } - 成功:
application/zip(Content-Dispositionでファイル名付与) - 失敗:
{ error: "..." }
- 入力:
-
GET /api/health- 依存関係のロード可否を返却
-
GET /api/debug/info,POST /api/debug/test-request,POST /api/debug/simple-download- 環境やリクエストの検証に利用
-
GET /api/usersほか(デモ)- SQLAlchemy を使った簡易CRUD
ローカル実行手順
-
必要環境の準備
- Python 3.8 以上
- pip
-
リポジトリ取得とセットアップ
git clone https://github.com/tomorrow56/note_web_downloader
cd note_web_downloader
python -m venv venv
# macOS/Linux
source venv/bin/activate
# Windows (PowerShell)
.\venv\Scripts\Activate.ps1
pip install -r requirements.txt
- アプリの起動
python api/index.py
- ブラウザでアクセス
- 動作確認(任意)
# ヘルスチェック
curl http://localhost:5001/api/health
# ダウンロードAPI(ZIPを保存)
curl -X POST \
-H "Content-Type: application/json" \
-d '{"url":"https://note.com/username/n/XXXXXXXX"}' \
http://localhost:5001/api/download \
--output note_article.zip
処理フロー(/api/download)
- フロントエンドが
{ url: "https://note.com/..." }を POST - サーバでURLを検証(
note.com判定・型チェック) - 一時ディレクトリを作成
- 記事HTMLを取得し、
<article>を抽出 - 目次を除去し、本文中の
<img>を走査- 絶対URL化(
urljoin) - ストリーミング取得
- 拡張子推定(URL末尾 or
Content-Type) -
images/image_XXX.extで保存し、srcを相対パスへ書換え
- 絶対URL化(
-
markdownifyで本文HTML→Markdown -
article.mdとimages/をZIP化して返却 - finallyで一時ディレクトリをクリーンアップ
対応URL形式
https://note.com/<username>/n/<article_id>
ディレクトリ構成(抜粋)
note_web_downloader/
├── api/
│ └── index.py # Flaskアプリ(Vercelエントリ)
├── src/
│ ├── routes/
│ │ ├── download.py # ダウンロードAPI本体
│ │ ├── health.py # ヘルスチェック
│ │ ├── debug.py # デバッグAPI
│ │ └── user.py # ユーザーAPI(デモ)
│ ├── models/user.py # SQLAlchemyモデル
│ └── static/index.html # UI(1ページ)
├── requirements.txt # 依存関係
└── vercel.json # デプロイ設定
注意事項 / 制限
- note.com のDOM変更により、抽出ロジックの調整が必要になる場合があります。
- 画像点数が多い記事はダウンロードに時間がかかります。
- ご利用の際は note.com の利用規約 を遵守してください。
まとめ
URLを1つ渡すだけで、記事本文のMarkdownと画像一式を整理し、ZIPで安全に取得できます。フロントはシンプル、サーバは小さなFlask API群、デプロイはVercelで手軽にサーバレス運用が可能です。