📊

C# - Qiita と Zenn 記事の統計情報をあわせて閲覧

に公開

はじめに

現在、記事を Qiita, Zenn にクロスポストしています。
以前「Qiita API v2 でページビューの一覧取得」についての記事を掲載しましたが、Zenn 統計情報とあわせて閲覧したいと思ったので、対象ソースコードを作成してみました。

NuGet 対象、Qiita API、Qiita アクセストークン、および、HttpClient の扱い方については下記記事をご確認ください。

https://zenn.dev/chai0917/articles/8fbcde07a6b887

Zenn API については下記記事をご確認ください。

https://zenn.dev/manase/scraps/489f556f7ff15b

テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。

  • Windows Forms - .NET Framework 4.8
  • Windows Forms - .NET 8
  • WPF - .NET Framework 4.8
  • WPF - .NET 8

事前処理

NuGet

詳しくは「はじめに」に記載した「Qiita API v2 でページビューの一覧取得」に記載していますが、NuGet で取得するモジュールを記載しておきます。

  • JSON デシリアライズ用(.NET Framework のみ必要)
    • PM> NuGet\Install-Package System.Text.Json
  • IHttpClientFactory を用いて HttpClient 利用
    • PM> NuGet\Install-Package Microsoft.Extensions.DependencyInjection
    • PM> NuGet\Install-Package Microsoft.Extensions.Http

初期設定

変数、定数、IHttpClientFactory に関するソースコードを掲載します。

// .NET Framework 時は null 許容参照型の明示 `?` を削除してください
private static IServiceProvider? MyServiceProvider = null;
private const string QiitaUrl = "https://qiita.com";
private const string QiitaAccessToken = "<事前準備で取得したアクセストークン>";  // TODO
private const string ZennUrl = "https://zenn.dev";
private const string ZennUsername = "<対象ユーザ名>";  // TODO
// ServiceProvider 初期化
private void ServiceProvider_Initialize()
{
  var serviceCollection = new ServiceCollection();
  serviceCollection.AddHttpClient("QiitaClient", client =>
  {
    client.BaseAddress = new Uri(QiitaUrl);
    client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", QiitaAccessToken);
    client.DefaultRequestHeaders.Add("Accept", "application/json");
  });
  serviceCollection.AddHttpClient("ZennClient", client =>
  {
    client.BaseAddress = new Uri(ZennUrl);
    client.DefaultRequestHeaders.Add("Accept", "application/json");
  });
  MyServiceProvider = serviceCollection.BuildServiceProvider();
}

記事の統計情報

Qiita

「はじめに」に記載した Qiita API v2 元記事をベースとして、「タイトル」「ページビュー」「ライク数」「ストック数」取得に修正しました。

#region Qiita
// Qiita API v2 - /api/v2/authenticated_user/items
// .NET Framework 時は null 許容参照型の明示 `?` を削除してください
private async Task<List<ResponseQiita>?> QiitaGetPageList()
{
  // IHttpClientFactory で QiitaClient という名前の HttpClient 取得
  var factory = MyServiceProvider?.GetService<IHttpClientFactory>();
  var httpClient = factory?.CreateClient("QiitaClient");
  if (httpClient == null)
  {
    // エラー発生 - TODO
    return null;
  }

  string baseUrl = "/api/v2/authenticated_user/items";
  var lstPages = new List<ResponseQiita>();
  int totalCount = 0;  // 全件数
  int limit = 20;      // 1回の取得件数
  int page = 1;        // ループ回数(1~)

  // 1回の処理は limit 件のページ取得なので、ループ処理とする
  while (true)
  {
    string targetUrl = baseUrl + $"?page={page}&per_page={limit}";
    using (var request = new HttpRequestMessage(HttpMethod.Get, targetUrl))
    using (var response = await httpClient.SendAsync(request))
    {
      if (response?.IsSuccessStatusCode == true)
      {
        var content = response.Content.ReadAsStringAsync().Result;
        var obj = System.Text.Json.JsonSerializer
                     .Deserialize<ResponseQiita[]>(content);
        if (obj == null)
        {
          // エラー発生 - TODO
          return null;
        }
        if (totalCount == 0) // 初回
        {
          if (response.Headers.Contains("Total-Count") 
           && int.TryParse(response.Headers.GetValues("Total-Count").First(),
                                                      out int count))
          {
            totalCount = count;
          }
        }
        if (obj.Length > 0)
        {
          lstPages.AddRange(obj);
        }
      }
      else
      {
        // エラー発生 - TODO
        return null;
      }
      // 残りがあるか?
      if (totalCount <= (page++ * limit))
      {
        break;
      }
    }
  }
  return lstPages;
}

// Qiita 統計情報 JSON
// .NET Framework の場合、title に対する required 指定は不要
public class ResponseQiita
{
  // 利用する項目のみ定義
  public required string title { get; set; }   // タイトル
  public int? page_views_count { get; set; }   // ページビュー
  public int? likes_count { get; set; }        // Like数
  public int? stocks_count { get; set; }       // Skocks数
}
#endregion

Zenn

/api/articles を用いて、「タイトル」「ライク数」「ストック数(ブックマーク数)」を取得します。

#region Zenn
// Zenn API - /api/articles
// .NET Framework 時は null 許容参照型の明示 `?` を削除してください
private async Task<List<ResponseZennStats>?> ZennGetPageList()
{
  // IHttpClientFactory で QiitaClient という名前の HttpClient 取得
  var factory = MyServiceProvider?.GetService<IHttpClientFactory>();
  var httpClient = factory?.CreateClient("ZennClient");
  if (httpClient == null)
  {
    // エラー発生 - TODO
    return null;
  }

  string baseUrl = "/api/articles";
  var lstPages = new List<ResponseZennStats>();
  int? page = 1;        // ループ回数(1~)

  // next_page = null となるまでのループ処理とする
  while (true)
  {
    string targetUrl = baseUrl + $"?username={ZennUsername}&page={page}";
    using (var request = new HttpRequestMessage(HttpMethod.Get, targetUrl))
    using (var response = await httpClient.SendAsync(request))
    {
      if (response?.IsSuccessStatusCode == true)
      {
        var content = response.Content.ReadAsStringAsync().Result;
        var obj = System.Text.Json.JsonSerializer
                     .Deserialize<ResponseZenn>(content);
        if (obj == null)
        {
          // エラー発生 - TODO
          return null;
        }
        if (obj.articles?.Count > 0)
        {
          lstPages.AddRange(obj.articles);
        }
        if (obj.next_page != null)
        {
          page = obj.next_page;
        }
        else
        {
          // End Of Data
          return lstPages;
        }
      }
      else
      {
        // エラー発生 - TODO
        return null;
      }
    }
  }
}

// Zenn 統計情報 JSON
// .NET Framework の場合、articles に対する null 許容参照型の明示 `?` は不要
// .NET Framework の場合、title に対する required 指定は不要
public class ResponseZenn
{
  // 利用する項目のみ定義
  public List<ResponseZennStats>? articles {  get; set; }
  public int? next_page { get; set; }       // 次ページ情報 (null時終端) 
}
public class ResponseZennStats
{
  // 利用する項目のみ定義
  public required string title { get; set; }   // タイトル
  public int? liked_count { get; set; }        // Like数
  public int? bookmarked_count { get; set; }   // Bookmark数
}
#endregion

統計情報の加工

Full Outer Join 結合

Qiita, Zenn 記事の統計情報は「タイトル」で結合します。
基本的にタイトルが一致していることを想定してますが、片方のみに存在するケースを考慮して、Full Outer Join で結合することにします。

#region FullOuterJoin
// Qiita と Zenn 記事統計情報を Full Outer Join
// .NET Framework 時は null 許容参照型の明示 `?` を削除してください
// TODO - DataGird/DataGridView などに出力
private async Task<IEnumerable<StatsJoin>?> FullOuterJoin()
{
  var qiita = await QiitaGetPageList();
  if (qiita == null || qiita.Count <= 0)
  {
    // エラー発生 - TODO
    return null;
  }
  var zenn = await ZennGetPageList();
  if (zenn == null || zenn.Count <= 0)
  {
    // エラー発生 - TODO
    return null;
  }

  // C# の LINQ には直接的な  構文は存在しませんが、
  // 左外部結合と右外部結合を組み合わせることで実現できます。

  // 左外部結合
  var leftJoin = from q in qiita
                 join z in zenn on q.title equals z.title into temp
                 from z in temp.DefaultIfEmpty()
                 select new StatsJoin
                 {
                   Title = q.title,
                   QiitaViews = q.page_views_count ?? 0,
                   QiitaLikes = q.likes_count ?? 0,
                   QiitaStocks = q.stocks_count ?? 0,
                   ZennLikes = z?.liked_count ?? 0,
                   ZennStocks = z?.bookmarked_count ?? 0
                 };
  // 右外部結合(左に存在しないものを抽出)
  var rightJoin = from z in zenn
                  join q in qiita on z.title equals q.title into temp
                  from q in temp.DefaultIfEmpty()
                  where q == null
                  select new StatsJoin
                  {
                    Title = z.title,
                    QiitaViews = 0,
                    QiitaLikes = 0,
                    QiitaStocks = 0,
                    ZennLikes = z.liked_count ?? 0,
                    ZennStocks = z.bookmarked_count ?? 0
                  };

  // 結合
  var fullOuterJoin = leftJoin.Concat(rightJoin);
  return fullOuterJoin;
}

// 結合した統計情報
// .NET Framework の場合、Title に対する required 指定は不要
public class StatsJoin
{
  public required string Title { get; set; }
  // Qiita 統計
  public int QiitaViews { get; set; }
  public int QiitaLikes { get; set; }
  public int QiitaStocks { get; set; }
  // Zenn 統計
  public int ZennLikes { get; set; }
  public int ZennStocks { get; set; }
}
#endregion

ツール仕立て

ここまでは、「テスト環境」に記載した環境で動作確認しましたが、ツール仕立ては、それぞれの好みもあるので、特定の環境で構築したツールのキャプションとソースのみを掲載します。
ここでは、Windows Forms - .Net 8 で、QiitaLikes, ZennLikes, QiitaViews でソートした結果を DataGirdView で表示するツールとして構築してみました。
※ chai0917 - 2025/10/01 時点の情報です。

サンプルソースを以下に掲載しておきます。

public Form1()
{
  InitializeComponent();

  // DataGridView 初期化
  DataGridView_Initialize();

  // ServiceProvider 初期化
  ServiceProvider_Initialize();

  btnAction.Click += btnAction_Click;
}
#region DataGirdView
// DataGridView 初期化
private void DataGridView_Initialize()
{
  dataGridView1.AutoGenerateColumns = false;
  dataGridView1.SelectionMode = DataGridViewSelectionMode.FullRowSelect;  // 行選択モード
  dataGridView1.MultiSelect = false;             // 複数選択無効
  dataGridView1.ColumnHeadersVisible = true;     // 列ヘッダ表示
  dataGridView1.RowHeadersVisible = false;       // 行ヘッダ非表示
  dataGridView1.AllowUserToDeleteRows = false;   // 削除キーで削除を無効
  dataGridView1.AllowUserToAddRows = false;      // 末尾行での行追加を無効
  dataGridView1.ScrollBars = ScrollBars.Both;
  dataGridView1.ReadOnly = true;

  dataGridView1.Columns.AddRange(new DataGridViewColumn[]
  {
    new DataGridViewTextBoxColumn
    {
      Name = "Title", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
    },
    new DataGridViewTextBoxColumn
    {
      Name = "QiitaViews", AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      DefaultCellStyle = new DataGridViewCellStyle {
          Alignment = DataGridViewContentAlignment.MiddleRight }
    },
    new DataGridViewTextBoxColumn
    {
      Name = "QiitaLikes", AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      DefaultCellStyle = new DataGridViewCellStyle {
          Alignment = DataGridViewContentAlignment.MiddleRight }
    },
    new DataGridViewTextBoxColumn
    {
      Name = "QiitaStocks", AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      DefaultCellStyle = new DataGridViewCellStyle {
          Alignment = DataGridViewContentAlignment.MiddleRight }
    },
    new DataGridViewTextBoxColumn
    {
      Name = "ZennLikes", AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      DefaultCellStyle = new DataGridViewCellStyle {
          Alignment = DataGridViewContentAlignment.MiddleRight }
    },
    new DataGridViewTextBoxColumn
    {
      Name = "ZennStocks", AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      DefaultCellStyle = new DataGridViewCellStyle {
          Alignment = DataGridViewContentAlignment.MiddleRight }
    }
  });
}
#endregion

#region DataGirDivew 表示
// .NET Framework 時は null 許容参照型の明示 `?` を削除してください
private async void btnAction_Click(object? sender, EventArgs e)
{
  dataGridView1.Rows.Clear();

  var stats = await FullOuterJoin();
  if (stats != null)
  {
    var sorted = stats.OrderByDescending(x => x.QiitaLikes)
                      .ThenByDescending(x => x.ZennLikes)
                      .ThenByDescending(x => x.QiitaViews);

    foreach (var page in sorted)
    {
      var row = new DataGridViewRow();
      row.CreateCells(dataGridView1);

      row.Cells[0].Value = page.Title;
      row.Cells[1].Value = page.QiitaViews;
      row.Cells[2].Value = page.QiitaLikes;
      row.Cells[3].Value = page.QiitaStocks;
      row.Cells[4].Value = page.ZennLikes;
      row.Cells[5].Value = page.ZennStocks;

      dataGridView1.Rows.Add(row);
    }
    MessageBox.Show("データ出力しました。");
  }
}
#endregion

出典

本記事は、2025/10/01 Qiita 投稿記事の転載です。

C# - Qiita と Zenn 記事の統計情報をあわせて閲覧

Discussion