0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

まえがき

この記事は生成AIだけでどこまで出来るかの実験も兼ねています。

はじめに

「作成した自分の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.mdimages/ を含む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 で解析
  • タイトルは h1og: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/zipContent-Dispositionでファイル名付与)
    • 失敗: { error: "..." }
  • GET /api/health

    • 依存関係のロード可否を返却
  • GET /api/debug/info, POST /api/debug/test-request, POST /api/debug/simple-download

    • 環境やリクエストの検証に利用
  • GET /api/users ほか(デモ)

    • SQLAlchemy を使った簡易CRUD

ローカル実行手順

  1. 必要環境の準備

    • Python 3.8 以上
    • pip
  2. リポジトリ取得とセットアップ

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
  1. アプリの起動
python api/index.py
  1. ブラウザでアクセス
  1. 動作確認(任意)
# ヘルスチェック
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)

  1. フロントエンドが { url: "https://note.com/..." } を POST
  2. サーバでURLを検証(note.com 判定・型チェック)
  3. 一時ディレクトリを作成
  4. 記事HTMLを取得し、<article> を抽出
  5. 目次を除去し、本文中の <img> を走査
    • 絶対URL化(urljoin
    • ストリーミング取得
    • 拡張子推定(URL末尾 or Content-Type
    • images/image_XXX.ext で保存し、src を相対パスへ書換え
  6. markdownify で本文HTML→Markdown
  7. article.mdimages/ をZIP化して返却
  8. 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で手軽にサーバレス運用が可能です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?