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?

Qiita API v2 を活用した記事取得・分析ツールの実装詳解(Python + pandas + matplotlib)

0
Posted at

1. はじめに

本稿では、Qiita API v2 を用いて記事データを取得し、統計解析および可視化を行う CLI ツール群「Qiita Data Toolkit」の実装について、解説します。

GitHub リポジトリ: Qiita-Data-Toolkit

2. プロジェクトのディレクトリ構成と依存関係

本ツールセットは、保守性と拡張性を考慮し、機能ごとに独立した 3 つのスクリプトで構成されています。

構成ファイル

.
├── users.py          # 特定ユーザーの投稿データをクロールし JSON 出力
├── search.py         # 検索クエリに基づき条件に合致する記事をクロールし JSON 出力
├── analyze.py        # 取得済み JSON データの統計解析・可視化(グラフ生成)
├── requirements.txt  # 依存ライブラリ一覧
└── README.md         # セットアップ手順

依存ライブラリ (requirements.txt)

本ツールが依存する外部ライブラリは以下の 3 つに絞り込んでいます。

  • requests: HTTP 通信用(API 叩き)
  • pandas: データ処理・解析用
  • matplotlib: グラフ描画用

3. 実装:構成管理と認証(Configuration Management)

API トークンなどの機密情報を安全に扱うため、標準ライブラリ os を用いた認証情報の解決ロジックを実装しています。

3.1. トークンの解決優先順位

システム環境変数、.env ファイル、実行時入力の順にトークンを探索します。これにより、CI/CD 環境(環境変数推奨)とローカル開発環境(.env 推奨)の両立を可能にしています。

import os

def load_token():
    """
    認証トークンの読み込み。
    優先順位: 環境変数 > .envファイル > 実行時入力
    """
    # 1. 環境変数のチェック
    # コンテナ環境や GitHub Actions などでの実行を想定
    token = os.environ.get("QIITA_TOKEN")
    if token:
        return token

    # 2. .env ファイルのチェック
    # ファイルを 1 行ずつ読み込み、キー・バリュー形式でパースする
    dotenv_path = ".env"
    if os.path.exists(dotenv_path):
        try:
            with open(dotenv_path, "r", encoding="utf-8") as f:
                for line in f:
                    # 前後空白の除去とコメント行の無視
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    # キーが QIITA_TOKEN= で始まる行を抽出
                    if line.startswith("QIITA_TOKEN="):
                        # キーと値を分離(最初の '=' でのみ分割)
                        value = line.split("=", 1)[1]
                        return value.strip().strip('"').strip("'")
        except IOError as e:
            print(f"Warning: .env ファイルの読み込みに失敗しました: {e}")

    # 3. 実行時の対話型入力
    # 設定が一切ない場合、ユーザーに入力を促す
    print("Qiitaアクセストークンが設定されていません。")
    token_input = input("アクセストークンを入力してください (Enterでスキップ): ").strip()
    
    if token_input:
        # 入力されたトークンを .env に保存して永続化する(利便性のため)
        save_confirm = input("この情報を .env ファイルに保存しますか? (y/n): ").strip().lower()
        if save_confirm == 'y':
            with open(dotenv_path, "w", encoding="utf-8") as f:
                f.write(f"QIITA_TOKEN={token_input}\n")
            print(f"DEBUG: {dotenv_path} に保存しました。")
            
    return token_input

4. 実装:Qiita API v2 との通信(Data Ingestion)

データを取得する際は、API の仕様に基づいたページネーションと、サーバー負荷を考慮したレートリミット制御が必須です。

4.1. 堅牢なクロール・ループの実装

Qiita API は一度のリクエストで最大 100 件のデータしか返しません。全データを網羅するためには、レスポンスが空になるまでループを回す必要があります。

import requests
import time

def fetch_qiita_items(url, headers, base_params):
    """
    Qiita API から記事データをページネーションを考慮して取得
    """
    all_data = []
    page = 1
    
    print(f"Fetching from: {url}")
    
    while True:
        # パラメータの更新
        params = base_params.copy()
        params.update({
            "page": page,
            "per_page": 100  # 最大件数を指定してリクエスト回数を最小化
        })
        
        try:
            # HTTP GET リクエストの実行
            response = requests.get(url, headers=headers, params=params)
            
            # ステータスコードのチェック (200 OK 以外は例外を発生させる)
            response.raise_for_status()
            
            items = response.json()
            
            # データが空 (page を進めて記事がなくなった) ならループ終了
            if not items:
                print("End of data reached.")
                break
                
            all_data.extend(items)
            print(f" - Page {page}: {len(items)} items fetched (Total: {len(all_data)})")
            
            # レスポンスが 100 件に満たない場合、次ページが存在しないことが確定する
            if len(items) < 100:
                break
                
            # ページインクリメント
            page += 1
            
            # サーバー負荷軽減および 429 Too Many Requests 回避のためのスリープ
            # Qiita API v2 のレート制限範囲内 (認証あり時 1000req/h) に収める
            time.sleep(0.2)
            
        except requests.exceptions.RequestException as e:
            print(f"Error during API request: {e}")
            break
            
    return all_data

5. 実装:データのクレンジングと平坦化(Data Processing)

API から返ってくる JSON は、高度にネストされた構造(特に tagsuser オブジェクト)を持っています。これを統計解析可能な DataFrame に変換するプロセスを解説します。

5.1. pandas による JSON flattening

JSON のリストから必要なフィールドを抽出し、統計処理が行いやすい型へ変換します。

import pandas as pd

def normalize_to_dataframe(json_list):
    """
    ネストされた JSON データをフラットな DataFrame に変換
    """
    rows = []
    for item in json_list:
        # 特定の属性のみを抽出
        row = {
            "id": item.get("id"),
            "title": item.get("title", "No Title"),
            "url": item.get("url"),
            "likes": int(item.get("likes_count", 0)),
            "stocks": int(item.get("stocks_count", 0)),
            # 日時文字列を datetime オブジェクトにパース
            "created_at": pd.to_datetime(item.get("created_at")),
            # タグはリスト形式で保持
            "tags": [tag.get("name") for tag in item.get("tags", [])]
        }
        rows.append(row)
        
    df = pd.DataFrame(rows)
    return df

5.2. 多対多関係の正規化(explode を活用)

1 記事に複数のタグがあるため、タグごとの集計(例:どのタグが一番人気か)を行うには、各タグを独立したレコードとして展開する必要があります。

def create_tags_ranking(df):
    """
    タグごとの統計用 DataFrame を作成
    """
    # 1記事1行の状態から、タグごとにレコードを複製(平坦化)
    # 例: ['Python', 'Django'] -> Pythonの行, Djangoの行 に分かれる
    exploded_df = df.explode("tags")
    
    # タグ名でグルーピングし、いいね合計や記事数を算出
    ranking = exploded_df.groupby("tags").agg({
        "id": "count",      # 記事数
        "likes": "sum",     # 合計LGTM数
    }).rename(columns={"id": "count"})
    
    return ranking.sort_values(by="count", ascending=False)

6. 実装:マルチプラットフォーム対応の可視化(Visualization)

matplotlib はデフォルトで日本語を表示できませんが、OS 判定を行うことでフォント設定を動的に切り替えています。

6.1. クロスプラットフォーム・フォント設定

Windows、macOS、Linux で異なる日本語フォントパスを自動解決します。

import sys
import matplotlib.pyplot as plt

def initialize_plotting():
    """
    OS 環境に応じて適切な日本語フォントをセットアップ
    """
    platform = sys.platform
    
    if platform == "win32":
        # Windows
        plt.rcParams["font.family"] = "MS Gothic"
    elif platform == "darwin":
        # macOS
        plt.rcParams["font.family"] = "Hiragino Sans"
    else:
        # Linux / Other (IPAフォントなどがインストールされている前提)
        plt.rcParams["font.family"] = "sans-serif"
    
    # 図表のデフォルトサイズ設定
    plt.rcParams["figure.figsize"] = (12, 6)
    # マイナス記号の文字化け防止
    plt.rcParams["axes.unicode_minus"] = False

6.2. 記事分布とタグランキングの描画

実際にグラフを生成し、ファイル(PNG)として保存するロジックです。

def plot_results(df, tag_ranking_df, out_dir):
    """
    解析結果のグラフ化
    """
    # 1. いいね数の分布 (ヒストグラム)
    plt.figure()
    df["likes"].plot(kind="hist", bins=30, alpha=0.7, color="skyblue", edgecolor="black")
    plt.title("LGTM数分布")
    plt.xlabel("いいね数")
    plt.ylabel("記事数")
    plt.grid(axis="y", alpha=0.3)
    plt.savefig(f"{out_dir}/likes_hist.png")
    plt.close()

    # 2. タグランキング (バーチャート)
    plt.figure()
    top_20_tags = tag_ranking_df.head(20)
    top_20_tags["count"].sort_values().plot(kind="barh", color="forestgreen")
    plt.title("出現数の多いタグ (TOP 20)")
    plt.xlabel("記事数")
    plt.ylabel("タグ名")
    plt.tight_layout()
    plt.savefig(f"{out_dir}/top_tags.png")
    plt.close()

7. 実装の課題と最適化

本ツールを大規模(数万件単位)な解析に適用する際の検討事項です。

  • メモリ効率: 全データを pd.DataFrame に一度に保持するため、数万件を超えるとメモリ不足(OOM)の懸念があります。数ギガバイト規模になる場合は、SQLite などのローカル DB への蓄積を検討すべきです。
  • 取得速度: 現在の実装はシングルスレッドです。I/O 待ちが支配的であるため、asyncio や多スレッド化による高速化が可能ですが、Qiita API のレート制限に即座に抵触するため、あえて制限を設けています。

8. 結論

本ツールの実装を通して、以下のデータ分析プロセスの基礎をカバーしました。

  • requests によるページネーション付き API クローラーの構築
  • .env を活用したポータブルな環境変数解決
  • pandas.explode() を用いた非構造化(リスト内蔵)データの正規化
  • sys.platform 判定による OS 依存性の正規化

これらのパターンは、Qiita API に限らず、多くの REST API 連携ツールを構築する際に利用可能な設計です。

詳細な全ソースコードについては、ぜひ GitHub リポジトリを参照してください。(Star等もぜひ)
GitHub リポジトリ: Qiita-Data-Toolkit

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?