Qiita
C#
スクレイピング
WPF
QiitaAPI

Qiitaに投稿した記事のストック数が知りたかったから簡単にWPFで作ってみた

More than 1 year has passed since last update.

(2017/06/09更新しました)


はじめに

Qiitaには「いいね」と「ストック」があり、「いいね」された数は記事から見ることができるが、「ストックされた数」は見ることができない

「いいね」されなくてもどれくらいの人に「ストック」されたか分かればモチベ向上や記事の需要とかがわかりそうだと思ったから見れるようなやつをWPFで作ってみた


作ったもの

DL先:DropBox/QiitaStocksViewer

こんな感じでユーザーIDを入れて読込開始を押すと自分が投稿した記事の情報が取得できる

Items、Contributions、いいね数、コメント数はスクレイピングで取得し、他はQiitaAPIで取得している

URLはクリックすると既定のブラウザで開けるようにした

2017-06-09_13h24_31.jpg

ストック者数のとこを押すとストックした人のIDも分かるようにしてみた

2017-06-09_13h29_31.jpg

あとCSVに保存できる誰得機能も付けてみた('ω')

APIについてはQiita API v2ドキュメントを見ればなんとなく分かると思う

Qiita API v2では認証なしのリクエストはIPアドレスごとに60回/hと制限されている

今回作ったコードでは、まずユーザーの投稿記事取得のために1回、投稿した各記事の情報取得のために投稿記事の数だけリクエストしている

そのため、たくさん記事を投稿している人や1時間以内に何度もリクエストを送ると制限されてしまう

なのでアクセストークンを入れて認証ありでリクエストできるようにしてみた

(認証状態だと1000回/hまでOK)

アクセストークンは

Qiitaの設定>アプリケーション>個人用アクセストークン>新しくトークンを発行する

で作れる

(OAuthはよくわからんからやめた)

2017-05-24_19h12_02.png

アクセストークンの説明は自分がわかるように書いて、スコープはread_qiitaにして発行するを押せばアクセストークンが発行されるのでそれをコピペすればOK

(セキュリティとかは気を付けること)

2017-05-24_19h12_13.png


作り方

自己流にガリガリと作ってみたから変なとこあるかもしれない(;´・ω・)

※ReactiveProperty、Json.NET、HtmlAgilityPackを使用している

以下参考

かずきのBlog@hatena/ReactiveProperty オーバービュー

マイクロソフト系技術情報 Wiki/JSONのparseを色々試してみた。

kitayama lab/C#とHtmlAgilityPackでスクレイピングしてみた


Model


public class Model
{
public ReactiveProperty<string> _UserID { get;private set; } = new ReactiveProperty<string>();
public ReactiveProperty<string> _AccessToken { get;private set; } = new ReactiveProperty<string>();
public ReactiveProperty<string> _ItemCount { get;private set; } = new ReactiveProperty<string>("0");
public ReactiveProperty<string> _Contributions { get; private set; } = new ReactiveProperty<string>("0");
public ReactiveCollection<PostInformation> _PostList { get; } = new ReactiveCollection<PostInformation>();
public ReactiveCommand C_GetPostList { get; } = new ReactiveCommand();
public ReactiveCommand C_OutputToCSV { get; } = new ReactiveCommand();
public Model()
{
C_GetPostList = _UserID
.Select(x => !string.IsNullOrWhiteSpace(x))
.ToReactiveCommand();
C_GetPostList.Subscribe(LoadFromQiita);
C_OutputToCSV.Subscribe(OutputToCSV);
}
private async void LoadFromQiita()
{
if (!string.IsNullOrWhiteSpace(_UserID.Value))
{
var client = new HttpClient();
Uri uri = new Uri("https://qiita.com/api/v2/users/" + _UserID.Value + "/items");
string result;
try
{
var html = await client.GetStringAsync("http://qiita.com/" + _UserID.Value);
_ItemCount.Value = ScrapeMyPage(html,XPaths.myPage_ItemCount.getXPath());
_Contributions.Value = ScrapeMyPage(html, XPaths.myPage_Contributions.getXPath());
if (string.IsNullOrWhiteSpace(_AccessToken.Value))
{
result = await client.GetStringAsync(uri);
}
else
{
var request = new HttpRequestMessage();
request.Method = HttpMethod.Get;
request.RequestUri = uri;
request.Headers.Host = "qiita.com";
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _AccessToken.Value);
var respons = await client.SendAsync(request);
result = await respons.Content.ReadAsStringAsync();
}
var data = (JArray)JsonConvert.DeserializeObject(result);
_PostList.Clear();
foreach (JObject item in data)
{
_PostList.Add(new PostInformation(client, "http://qiita.com/" + _UserID.Value + "/items/" + item[JsonKeys.e_id.getKeyName()].ToString())
{
_Title = item[JsonKeys.e_title.getKeyName()].ToString(),
_PostTime = ISO8601ToDateTime(item[JsonKeys.e_created_at.getKeyName()].ToString()),
_UpDatedTime = ISO8601ToDateTime(item[JsonKeys.e_updated_at.getKeyName()].ToString()),
_LimitedShared = bool.Parse(item[JsonKeys.e_private.getKeyName()].ToString()),
_URL = new Uri(item[JsonKeys.e_url.getKeyName()].ToString()),
_StockInfo = new PostInformation.StockInformation(item[JsonKeys.e_id.getKeyName()].ToString(),client),
});
}
}
catch (Exception e) when (e is HttpRequestException || e is InvalidCastException)
{
MessageBox.Show("エラーが発生しました" + Environment.NewLine
+ "ユーザーID,AccessTokenが間違っているか、リクエスト制限を超えた可能性があります" + Environment.NewLine
+ "(リクエスト回数はIPアドレス毎に \n 認証無:60回/h \n 認証有:1000回/h \n までです)", e.GetType().ToString());
}
}
}
private string ScrapeMyPage(string html, string XPath)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
return doc.DocumentNode.SelectSingleNode(XPath).InnerText;
}
private DateTime ISO8601ToDateTime(string dateTime)
{
return DateTime.Parse(dateTime, null, System.Globalization.DateTimeStyles.RoundtripKind);
}
private void OutputToCSV()
{
if (_PostList.Count != 0)
{
var dialog = new SaveFileDialog();
dialog.Title = "保存";
dialog.Filter = "csvファイル(*.csv)|*.csv";
if ((bool)dialog.ShowDialog())
{
string str = "タイトル,いいね数,ストック者数,コメント数,ストック者ID,URL,投稿日,最終更新日,限定共有" + Environment.NewLine;
foreach (var postInfo in _PostList)
{
str += postInfo._Title + ",";
str += postInfo._LikeCount.Value + ",";
str += postInfo._StockInfo._StockCount.Value + ",";
str += postInfo._CommentCount.Value + ",\"";
foreach (var item in postInfo._StockInfo._StockedPerson)
{
str += item + ",";
}
str += "\"," + postInfo._URL + ",";
str += postInfo._PostTime + ",";
str += postInfo._UpDatedTime + ",";
str += postInfo._LimitedShared + "," + Environment.NewLine;
}
try
{
var stream = dialog.OpenFile();
StreamWriter streamWriter = new StreamWriter(stream, Encoding.UTF8);
streamWriter.Write(str);
streamWriter.Close();
stream.Close();
}
catch (Exception e)
{
MessageBox.Show("エラーが発生しました" + Environment.NewLine
+ "ファイルの保存に失敗しました", e.GetType().ToString());
}
}
}
}
}

public class PostInformation
{
public string _Title { get; set; } = "";
public ReactiveProperty<string> _LikeCount { get; set; } =new ReactiveProperty<string>();
public ReactiveProperty<string> _CommentCount { get; set; } = new ReactiveProperty<string>();
public DateTime _PostTime { get; set; } = new DateTime();
public DateTime _UpDatedTime { get; set; } = new DateTime();
public bool _LimitedShared { get; set; } = false;
public Uri _URL { get; set; } = new Uri("http://qiita.com/");
public StockInformation _StockInfo { get; set; } = new StockInformation();
public ReactiveProperty<bool> _isPopupOpen { get; private set; } = new ReactiveProperty<bool>() { Value = false };
public ReactiveCommand C_PopupChange { get; } = new ReactiveCommand();
public PostInformation(HttpClient client, string url)
{
ScrapePost(client, url);
C_PopupChange.Subscribe(_ => _isPopupOpen.Value = !_isPopupOpen.Value);
}
public PostInformation()
{
}
private async void ScrapePost(HttpClient client, string url)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(await client.GetStringAsync(url));
_LikeCount.Value = doc.DocumentNode.SelectSingleNode(XPaths.Post_LikeCount.getXPath()).InnerText;
_CommentCount.Value = doc.DocumentNode.SelectSingleNode(XPaths.Post_CommentCOunt.getXPath()).InnerText;
}

public class StockInformation
{
public string _PostID { get; } = "";
public ReactiveProperty<int> _StockCount { get; private set; } = new ReactiveProperty<int>(0);
public ReactiveCollection<string> _StockedPerson { get; private set; } = new ReactiveCollection<string>();
public StockInformation(string PostID,HttpClient client)
{
_PostID = PostID;
getStockInfo(PostID,client);
}
public StockInformation()
{
}
private async void getStockInfo(string postID,HttpClient client)
{
Uri uri = new Uri("https://qiita.com/api/v2/items/" + postID + "/stockers");
var result = await client.GetStringAsync(uri);
JArray data = (JArray)JsonConvert.DeserializeObject(result);
_StockCount.Value = data.Count();
foreach (JObject item in data)
{
_StockedPerson.Add(item[JsonKeys.e_id.getKeyName()].ToString());
}
}
}
}

public enum JsonKeys
{
e_renderd_body,
e_body,
e_coediting,
e_created_at,
e_group,
e_id,
e_private,
e_tags,
e_title,
e_updated_at,
e_url,
e_user,
}
public static class JsonKeysEx
{
public static string getKeyName(this JsonKeys key)
{
switch (key)
{
case JsonKeys.e_renderd_body: return "renderd_body";
case JsonKeys.e_body: return "body";
case JsonKeys.e_coediting: return "coediting";
case JsonKeys.e_created_at: return "created_at";
case JsonKeys.e_group: return "group";
case JsonKeys.e_id: return "id";
case JsonKeys.e_private: return "private";
case JsonKeys.e_tags: return "tags";
case JsonKeys.e_title: return "title";
case JsonKeys.e_updated_at: return "updated_at";
case JsonKeys.e_url: return "url";
case JsonKeys.e_user: return "user";
default: return "";
}
}
}

public enum XPaths
{
myPage_ItemCount,
myPage_Contributions,
Post_LikeCount,
Post_CommentCOunt,
}
public static class XPathEx
{
public static string getXPath(this XPaths path)
{
switch (path)
{
case XPaths.myPage_ItemCount: return @"//*[@id=""main""]/div/div/div[2]/div[1]/div[2]/a[1]/span[1]";
case XPaths.myPage_Contributions: return @"//*[@id=""main""]/ div/div/div[2]/div[1]/div[2]/a[2]/span[1]";
case XPaths.Post_LikeCount: return @"//*[@id=""main""]/article/div[1]/div[2]/div/div[2]/div/ul/li[1]/div[1]/span[2]";
case XPaths.Post_CommentCOunt: return @"//*[@id=""main""]/article/div[1]/div[2]/div/div[2]/div/ul/li[2]/div[1]/text()";
default: return "";
}
}
}

長くなりそうだからざっくり説明(需要があれば詳しく解説する)

記事の情報を持つクラス(PostInformation)を定義して、その配列をModelに持たせる

Modelは他にもユーザーIDとかアクセストークンとかコマンドを持っている

記事の情報を持つクラス(PostInformation)はインナークラスにストック情報をもつクラス(StockInformation)を持ち、コンストラクタで記事IDを渡すと勝手にストック情報を取ってくれるようにしてある

情報を取得するコマンドはユーザーIDが入力されている時だけ有効にしていて、

実際の処理は

1.ユーザーページのhtmlを取得してHtmlDocumentにLoadさせ、XPathを指定してItemsとContributionsを取得

2.QiitaのAPIを叩く

2.帰ってきたJsonをJson.NET使ってConvert

3.記事情報の配列を一旦クリアして記事の数だけ追加、同時に初期化で情報をセット

4.記事情報の追加時、ユーザー名と記事IDからURLを作り、1と同様にスクレイプしていいね数とコメント数を取得

5.ストック情報取得

という感じ

アクセストークンが入力されていたらAuthorizationリクエストヘッダに詰め込んでリクエストしてるよ

指定するXPathとかは、使いやすくするためenumにし、拡張メソッドので値を取得している

(参考:C#のenumに関連する小技。)

XPathはChromeでF12押してコピペした


ViewModel

いつもの

もはやコメント不要( ˘ω˘)

    public class ViewModel

{
public Model _Model { get; set; } = new Model();
}


View

重要そうなDataGridのとこだけざっくり解説


<Window.Resources>
<Style TargetType="DataGridColumnHeader">
<Setter Property="HorizontalContentAlignment" Value="Center"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="TextBlock.TextAlignment" Value="Center"/>
</Style>
</Window.Resources>

<DataGrid x:Name="dataGrid" Grid.Row="1" AutoGenerateColumns="False" CanUserDeleteRows="False" CanUserAddRows="False" AlternatingRowBackground="#FFA5F9A1" SelectionUnit="CellOrRowHeader" RowHeaderWidth="40" ItemsSource="{Binding _Model._PostList}" RowHeight="31" MinRowHeight="31">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding _Title}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_title, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding _LikeCount.Value}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_likeCount, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Header="{Binding ColumnHeaderName_stockCount, Mode=OneWay, Source={StaticResource resources}}" SortMemberPath="_StockInfo._StockCount.Value">
<DataGridTemplateColumn.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="IsTabStop" Value="False"/>
</Style>
</DataGridTemplateColumn.CellStyle>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid>
<Button Content="{Binding _StockInfo._StockCount.Value}" FontSize="14" Command="{Binding C_PopupChange}" Background="White" BorderThickness="0" BorderBrush="White" HorizontalContentAlignment="Center" VerticalContentAlignment="Center"/>
<Popup IsOpen="{Binding _isPopupOpen.Value}" StaysOpen="False">
<DataGrid ItemsSource="{Binding _StockInfo._StockedPerson}" AutoGenerateColumns="False" GridLinesVisibility="None" HeadersVisibility="None" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" CanUserResizeRows="False" >
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Mode=OneWay}"/>
</DataGrid.Columns>
</DataGrid>
</Popup>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding _CommentCount.Value}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_commentCount, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Header="{Binding ColumnHeaderName_url, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True" CanUserSort="True" SortMemberPath="_URL.AbsoluteUri">
<DataGridTemplateColumn.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="IsTabStop" Value="False"/>
</Style>
</DataGridTemplateColumn.CellStyle>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Label HorizontalContentAlignment="Center" VerticalContentAlignment="Center">
<Hyperlink>
<i:Interaction.Behaviors>
<local:HyperlinkBehavior uri="{Binding _URL}"/>
</i:Interaction.Behaviors>
<TextBlock Text="{Binding _URL}"/>
</Hyperlink>
</Label>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding _PostTime}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_post, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding _UpDatedTime}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_update, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridCheckBoxColumn Binding="{Binding _LimitedShared}" ClipboardContentBinding="{x:Null}" Header="{Binding ColumnHeaderName_limitedShare, Mode=OneWay, Source={StaticResource resources}}" IsReadOnly="True">
<DataGridCheckBoxColumn.ElementStyle>
<Style TargetType="CheckBox">
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="IsHitTestVisible" Value="False"/>
<Setter Property="IsTabStop" Value="False"/>
</Style>
</DataGridCheckBoxColumn.ElementStyle>
</DataGridCheckBoxColumn>
</DataGrid.Columns>
</DataGrid>

まず、各列のヘッダーを中央にしたかったからStyleでDataGridColumnHeaderをターゲットに設定している

また、セル内のテキストも中央に表示させたいので同様Styleで設定

DataGridは記事の各情報を各列に定義している

3列目のストック数の列はテンプレートにしてボタンを定義し、押したときにポップアップでストックした人のIDが表示されるようにしている

5列目のURLでは、CellTemplate内でLabelを設定し、LabelにHyperlinkを入れ、ビヘイビアを使って動作させている

ビヘイビアはこんな感じ

class HyperlinkBehavior : Behavior<Hyperlink>

{
public Uri uri
{
get
{
return (Uri)GetValue(UriProperty);
}
set
{
SetValue(UriProperty, value);
}
}
public static readonly DependencyProperty UriProperty= DependencyProperty.Register("uri", typeof(Uri), typeof(HyperlinkBehavior), new UIPropertyMetadata(null));
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Click += OpenLinkTo; ; ;
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Click -= OpenLinkTo;
}

private void OpenLinkTo(object sender, RoutedEventArgs e)
{
Process.Start(uri.AbsoluteUri);
}
}

ビヘイビアについてはココが参考になると思う


さいごに

APIとかJsonとか初めて触ってみたけどなんとかなった(/・ω・)/

スクレイピングも初めてやってみたけどできて満足(゚∀゚)

ホントは記事の閲覧数も取得してみたかったけど無理っぽい(´・ω・`)

やる気が出れば機能拡張とかするかも