PythonでQiita記事を自動投稿するスクリプトを作った


# PythonでQiita記事を自動投稿するスクリプトを作った

Markdownファイルを作成するだけでQiitaに記事を自動投稿できるPythonスクリプトを作成しました。frontmatterでタイトルやタグを設定でき、コマンド一発で投稿が完了します。

## 特徴

- **簡単操作**: `python3 qiita_uploader.py article.md` で投稿完了
- **frontmatter対応**: YAMLでタイトル、タグ、公開設定を管理
- **複数の認証方法**: 環境変数、設定ファイル、コマンドラインオプション
- **自動タイトル抽出**: frontmatterがなくても最初のh1やファイル名から自動取得
- **ドライランモード**: `--dry-run`で安全なテスト実行
- **詳細ログ**: `--verbose`でデバッグ情報表示
- **強化されたエラーハンドリング**: 詳細なエラーメッセージと適切な例外処理
- **設定バリデーション**: 不正な設定の早期検出
- **型安全性**: 完全な型ヒントでバグを予防

## インストール

必要なパッケージをインストール：

```bash
pip install requests PyYAML
```

## 使用方法

### 1. Qiitaアクセストークンの取得

1. [Qiita設定ページ](https://qiita.com/settings/applications) にアクセス
2. 「個人用アクセストークン」タブを選択
3. 「新しくトークンを発行する」をクリック
4. スコープで「read_qiita」と「write_qiita」を選択
5. トークンを生成・コピー

### 2. トークンの設定

**環境変数で設定（推奨）:**
```bash
export QIITA_ACCESS_TOKEN="your_token_here"
```

**設定ファイル（config.yml）で設定:**
```yaml
access_token: "your_token_here"
defaults:
  private: false
  tweet: false
```

### 3. 記事の投稿

**基本的な使用:**
```bash
python3 qiita_uploader.py article.md
```

**オプション付きの使用例:**
```bash
# ドライランでテスト（実際には投稿しない）
python3 qiita_uploader.py article.md --dry-run

# 詳細ログ付きで実行
python3 qiita_uploader.py article.md --verbose

# 非公開記事として投稿
python3 qiita_uploader.py article.md --private

# 自動ツイート付きで投稿
python3 qiita_uploader.py article.md --tweet

# 複数オプション組み合わせ
python3 qiita_uploader.py article.md --private --tweet --verbose

# トークンを直接指定
python3 qiita_uploader.py article.md --token "your_token"
```

## Markdownファイルの書き方

### frontmatter付きの例

```markdown
---
title: "記事のタイトル"
tags: ["Python", "Qiita", "API"]
private: false
tweet: false
---

# 記事の内容

ここに記事の本文を書きます。
```

### frontmatterなしの例

```markdown
# 記事のタイトル

frontmatterがなくても、最初のh1タグがタイトルとして使用されます。
```

## ソースコード

以下が完全なソースコードです：

```python
#!/usr/bin/env python3
"""
Qiita記事アップローダー
Markdownファイルを読み込んでQiitaに投稿するスクリプト
"""

import os
import sys
import json
import yaml
import logging
import argparse
import requests
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field
from abc import ABC, abstractmethod


@dataclass
class Article:
    """記事データを表すデータクラス"""
    title: str
    body: str
    tags: List[Dict[str, Any]] = field(default_factory=list)
    private: bool = False
    tweet: bool = False
    
    def to_dict(self) -> Dict[str, Any]:
        """API投稿用の辞書に変換"""
        return {
            "title": self.title,
            "body": self.body,
            "tags": self.tags,
            "private": self.private,
            "tweet": self.tweet
        }


@dataclass
class Config:
    """設定データを表すデータクラス"""
    access_token: Optional[str] = None
    base_url: str = "https://qiita.com/api/v2"
    private: bool = False
    tweet: bool = False
    
    def validate(self) -> None:
        """設定の妥当性を検証"""
        if not self.access_token:
            raise ValueError("アクセストークンが設定されていません")
        if not self.access_token.strip():
            raise ValueError("アクセストークンが空です")
        if len(self.access_token) < 10:
            raise ValueError("アクセストークンが短すぎます")


class QiitaAPIException(Exception):
    """Qiita API関連の例外クラス"""
    
    def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[Dict] = None):
        super().__init__(message)
        self.status_code = status_code
        self.response_data = response_data


class FrontmatterParser:
    """Frontmatterを解析するクラス"""
    
    @staticmethod
    def parse(content: str) -> Tuple[Dict[str, Any], str]:
        """
        Markdownファイルのfrontmatterを解析する
        
        Args:
            content: Markdownファイルの内容
            
        Returns:
            (frontmatter_data, markdown_body)
        """
        lines = content.split('\n')
        
        if len(lines) < 3 or lines[0] != '---':
            return {}, content
        
        # frontmatter部分を見つける
        end_index = None
        for i, line in enumerate(lines[1:], 1):
            if line == '---':
                end_index = i
                break
        
        if end_index is None:
            return {}, content
        
        # frontmatterを解析
        frontmatter_text = '\n'.join(lines[1:end_index])
        try:
            frontmatter_data = yaml.safe_load(frontmatter_text) or {}
        except yaml.YAMLError as e:
            logging.warning(f"Frontmatterの解析に失敗しました: {e}")
            frontmatter_data = {}
        
        # 本文を取得
        body = '\n'.join(lines[end_index + 1:])
        
        return frontmatter_data, body


class TitleExtractor:
    """タイトルを抽出するクラス"""
    
    @staticmethod
    def extract(frontmatter: Dict[str, Any], body: str, file_path: str) -> str:
        """
        タイトルを抽出する
        
        Args:
            frontmatter: Frontmatterデータ
            body: 記事本文
            file_path: ファイルパス
            
        Returns:
            抽出されたタイトル
        """
        # 1. Frontmatterから取得
        title = frontmatter.get('title')
        if title and title.strip():
            return title.strip()
        
        # 2. 最初のh1タグから取得
        for line in body.split('\n'):
            if line.startswith('# '):
                title = line[2:].strip()
                if title:
                    return title
        
        # 3. ファイル名から取得
        return Path(file_path).stem


class TagProcessor:
    """タグを処理するクラス"""
    
    @staticmethod
    def process(tags: Any) -> List[Dict[str, Any]]:
        """
        タグを処理して適切な形式に変換する
        
        Args:
            tags: Frontmatterのタグデータ
            
        Returns:
            Qiita API用のタグリスト
        """
        if not tags:
            return []
        
        if not isinstance(tags, list):
            logging.warning(f"タグが配列ではありません: {type(tags)}")
            return []
        
        result = []
        for tag in tags:
            if isinstance(tag, str):
                result.append({"name": tag.strip()})
            elif isinstance(tag, dict) and "name" in tag:
                result.append(tag)
            else:
                logging.warning(f"不正なタグ形式: {tag}")
        
        return result


class ArticleBuilder:
    """記事オブジェクトを構築するクラス"""
    
    def __init__(self, title_extractor: TitleExtractor, tag_processor: TagProcessor):
        self.title_extractor = title_extractor
        self.tag_processor = tag_processor
    
    def build_from_file(self, file_path: str, config: Config) -> Article:
        """
        ファイルから記事オブジェクトを構築する
        
        Args:
            file_path: Markdownファイルのパス
            config: 設定オブジェクト
            
        Returns:
            記事オブジェクト
        """
        if not Path(file_path).exists():
            raise FileNotFoundError(f"ファイルが見つかりません: {file_path}")
        
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()
        except UnicodeDecodeError as e:
            raise ValueError(f"ファイルの読み込みに失敗しました: {e}")
        
        frontmatter, body = FrontmatterParser.parse(content)
        
        title = self.title_extractor.extract(frontmatter, body, file_path)
        tags = self.tag_processor.process(frontmatter.get('tags', []))
        
        return Article(
            title=title,
            body=body,
            tags=tags,
            private=frontmatter.get('private', config.private),
            tweet=frontmatter.get('tweet', config.tweet)
        )


class QiitaAPIClient:
    """Qiita APIクライアント"""
    
    def __init__(self, config: Config):
        self.config = config
        self.headers = {
            "Authorization": f"Bearer {config.access_token}",
            "Content-Type": "application/json"
        }
    
    def post_article(self, article: Article, dry_run: bool = False) -> Dict[str, Any]:
        """
        記事を投稿する
        
        Args:
            article: 投稿する記事
            dry_run: テスト実行フラグ
            
        Returns:
            API応答
        """
        if dry_run:
            logging.info("ドライラン: 実際の投稿は行いません")
            return {
                "id": "dry_run_id",
                "title": article.title,
                "url": "https://qiita.com/dry_run",
                "created_at": "2024-01-01T00:00:00+09:00"
            }
        
        url = f"{self.config.base_url}/items"
        data = article.to_dict()
        
        logging.info(f"記事を投稿します: {article.title}")
        logging.debug(f"投稿データ: {json.dumps(data, ensure_ascii=False, indent=2)}")
        
        try:
            response = requests.post(url, headers=self.headers, json=data)
            
            if response.status_code == 201:
                return response.json()
            else:
                self._handle_api_error(response)
                
        except requests.exceptions.RequestException as e:
            raise QiitaAPIException(f"APIリクエストに失敗しました: {e}")
    
    def _handle_api_error(self, response: requests.Response) -> None:
        """API エラーを処理する"""
        try:
            error_data = response.json()
        except json.JSONDecodeError:
            error_data = {"message": response.text}
        
        error_message = self._get_error_message(response.status_code, error_data)
        raise QiitaAPIException(error_message, response.status_code, error_data)
    
    def _get_error_message(self, status_code: int, error_data: Dict) -> str:
        """エラーメッセージを生成する"""
        base_message = f"API エラー (HTTP {status_code})"
        
        if status_code == 401:
            return f"{base_message}: アクセストークンが無効です"
        elif status_code == 422:
            details = error_data.get('message', '詳細不明')
            return f"{base_message}: 投稿データに問題があります - {details}"
        elif status_code == 429:
            return f"{base_message}: API レート制限に達しました"
        else:
            message = error_data.get('message', '不明なエラー')
            return f"{base_message}: {message}"


class ConfigManager:
    """設定管理クラス"""
    
    CONFIG_PATHS = [
        Path.home() / '.qiita_uploader.yml',
        Path('./config.yml'),
        Path('./qiita_config.yml')
    ]
    
    @classmethod
    def load_config(cls, config_path: Optional[str] = None) -> Config:
        """
        設定を読み込む
        
        Args:
            config_path: 設定ファイルのパス
            
        Returns:
            設定オブジェクト
        """
        config_data = {}
        
        # 設定ファイルを読み込み
        if config_path:
            paths = [Path(config_path)]
        else:
            paths = cls.CONFIG_PATHS
        
        for path in paths:
            if path.exists():
                try:
                    with open(path, 'r', encoding='utf-8') as f:
                        config_data = yaml.safe_load(f) or {}
                    logging.info(f"設定ファイルを読み込みました: {path}")
                    break
                except yaml.YAMLError as e:
                    logging.warning(f"設定ファイルの読み込みに失敗しました: {e}")
        
        return Config(
            access_token=config_data.get('access_token'),
            base_url=config_data.get('base_url', 'https://qiita.com/api/v2'),
            private=config_data.get('defaults', {}).get('private', False),
            tweet=config_data.get('defaults', {}).get('tweet', False)
        )


class QiitaUploader:
    """Qiita アップローダーのメインクラス"""
    
    def __init__(self, config: Config):
        self.config = config
        self.api_client = QiitaAPIClient(config)
        self.article_builder = ArticleBuilder(TitleExtractor(), TagProcessor())
    
    def upload_from_file(self, file_path: str, dry_run: bool = False) -> Dict[str, Any]:
        """
        ファイルから記事を投稿する
        
        Args:
            file_path: Markdownファイルのパス
            dry_run: テスト実行フラグ
            
        Returns:
            投稿結果
        """
        article = self.article_builder.build_from_file(file_path, self.config)
        return self.api_client.post_article(article, dry_run)


def setup_logging(verbose: bool = False) -> None:
    """ログ設定をセットアップする"""
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        handlers=[
            logging.StreamHandler()
        ]
    )


def resolve_access_token(args: argparse.Namespace, config: Config) -> str:
    """アクセストークンを解決する"""
    token = (
        args.token or
        os.environ.get('QIITA_ACCESS_TOKEN') or
        config.access_token
    )
    
    if not token:
        raise ValueError(
            "アクセストークンが設定されていません。\n"
            "以下のいずれかの方法で設定してください:\n"
            "1. --token オプション\n"
            "2. QIITA_ACCESS_TOKEN 環境変数\n"
            "3. 設定ファイル (config.yml)"
        )
    
    return token


def create_argument_parser() -> argparse.ArgumentParser:
    """引数パーサーを作成する"""
    parser = argparse.ArgumentParser(
        description='Qiita記事アップローダー',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
例:
  %(prog)s article.md                    # 基本的な投稿
  %(prog)s article.md --private          # 非公開記事として投稿
  %(prog)s article.md --tweet            # 自動ツイート付きで投稿
  %(prog)s article.md --dry-run          # テスト実行（実際には投稿しない）
  %(prog)s article.md --token TOKEN      # トークンを直接指定
        """
    )
    
    parser.add_argument('file', help='アップロードするMarkdownファイル')
    parser.add_argument('--token', help='Qiitaアクセストークン')
    parser.add_argument('--private', action='store_true', help='非公開記事として投稿')
    parser.add_argument('--tweet', action='store_true', help='自動ツイートを有効にする')
    parser.add_argument('--config', help='設定ファイルのパス')
    parser.add_argument('--dry-run', action='store_true', help='テスト実行（実際には投稿しない）')
    parser.add_argument('--verbose', '-v', action='store_true', help='詳細なログを出力')
    
    return parser


def main() -> None:
    """メイン関数"""
    parser = create_argument_parser()
    args = parser.parse_args()
    
    setup_logging(args.verbose)
    
    try:
        # 設定を読み込み
        config = ConfigManager.load_config(args.config)
        
        # アクセストークンを解決
        config.access_token = resolve_access_token(args, config)
        
        # コマンドライン引数で設定を上書き
        if args.private:
            config.private = True
        if args.tweet:
            config.tweet = True
        
        # 設定を検証
        config.validate()
        
        # アップローダーを作成して実行
        uploader = QiitaUploader(config)
        result = uploader.upload_from_file(args.file, args.dry_run)
        
        # 結果を表示
        if args.dry_run:
            print("✓ ドライラン完了")
        else:
            print("✓ 記事の投稿が完了しました！")
        
        print(f"タイトル: {result['title']}")
        print(f"URL: {result['url']}")
        print(f"ID: {result['id']}")
        
    except (ValueError, FileNotFoundError) as e:
        logging.error(f"設定エラー: {e}")
        sys.exit(1)
    except QiitaAPIException as e:
        logging.error(f"API エラー: {e}")
        if e.response_data:
            logging.debug(f"エラー詳細: {e.response_data}")
        sys.exit(1)
    except Exception as e:
        logging.error(f"予期しないエラー: {e}")
        if args.verbose:
            logging.exception("詳細:")
        sys.exit(1)


if __name__ == "__main__":
    main()
```

## 設定ファイル例（config.yml）

```yaml
# Qiita Uploader 設定ファイル

# Qiitaアクセストークン
# https://qiita.com/settings/applications で取得できます
access_token: "your_access_token_here"

# デフォルト設定
defaults:
  private: false  # デフォルトで非公開記事にするか
  tweet: false    # デフォルトで自動ツイートするか
```

## 依存パッケージ（requirements.txt）

```
requests>=2.25.0
PyYAML>=5.4.0
```

## 設計の特徴

### アーキテクチャ
- **単一責任の原則**: 各クラスが明確な責任を持つ
- **依存性の注入**: テストしやすい構造
- **データクラス**: `Article`と`Config`で型安全性向上

### エラーハンドリング
- **カスタム例外**: `QiitaAPIException`で詳細なエラー情報
- **適切なログレベル**: INFO/DEBUG/ERROR の使い分け
- **ユーザーフレンドリーなエラーメッセージ**

### コード品質
- **型ヒント**: 完全な型安全性
- **docstring**: 全関数にドキュメント
- **責任分離**: FrontmatterParser, TitleExtractor, TagProcessor

## まとめ

このスクリプトを使うことで、Markdownで記事を書いてコマンド一発でQiitaに投稿できるようになります。特に：

- **記事の下書きをローカルで管理**したい方
- **GitでMarkdownファイルをバージョン管理**したい方
- **CI/CDでの自動投稿**を検討している方
- **ドライランで安全にテスト**したい方

などにおすすめです。

ぜひ使ってみてください！何か改善点があればコメントで教えてください。

---

**※この記事とスクリプトは、Claude Code のテストで作成されました。**