Delphi で Qiita の記事をバックアップしてみる

# はじめに
なんとなく、自分で書いた Qiita の記事のバックアップを取っておきたくなったのです。

# バックアップ

『先生！記事をマークダウン形式のファイルで保存し、記事中の画像も保存したいです！』

## ソースコード

記事一覧を取得するコンソールアプリケーションを書いてみました。**Delphi 10.3 Rio** 以降でコンパイルできます。

```pascal:GetQiitaItems.dpr
program GetQiitaItems;

{$APPTYPE CONSOLE}
{$WARN GARBAGE OFF}

uses
  System.SysUtils, System.Classes, System.Rtti, System.Net.HttpClientComponent,
  System.JSON.Builders, System.JSON.Readers, System.IOUtils, System.RegularExpressions;

const
  API  = 'https://qiita.com/api/v2/%s/items?page=%d&per_page=%d';
  EXP1 = 'https://qiita-image-store\.s3(\.ap-northeast-1|)\.amazonaws\.com/0/21785/(?<filename>[0-9a-f-]+\.(png|gif|jpg))';
  EXP2 = 'https://qiita\.com/%s/(items|private)/(?<filename>[0-9a-f]+)';
  PER_PAGE = 100;

type
  { TReplaceMethodClass }
  TReplaceMethodClass = class
    function NewPath(const AMatch: TMatch): String;
    function RelativePath(const AMatch: TMatch): String;
  end;

  function TReplaceMethodClass.NewPath(const AMatch: TMatch): String;
  begin
    result := './images/' + AMatch.Groups.Item['filename'].Value;
  end; { NewPath }

  function TReplaceMethodClass.RelativePath(const AMatch: TMatch): String;
  begin
    result := './' + AMatch.Groups.Item['filename'].Value + '.md';
  end; { RelativePath }

begin
  if ParamCount < 1 then
    begin
      Writeln('Usage:');
      Writeln(TPath.GetFileNameWithoutExtension(ParamStr(0)), ' <UserID>');
      Writeln;
      Writeln('UserID not specified.');
      Exit;
    end;

  // 格納先の作成
  var Dir := TPath.GetDirectoryName(ParamStr(0));
  var SrcDir := TPath.Combine(Dir, 'source');
  var ImgDir := TPath.Combine(SrcDir, 'images');
  TDirectory.CreateDirectory(ImgDir);

  // パラメータ
  var User_ID := ParamStr(1); // パラメータで与えられた文字列をユーザIDとして扱う
  var Page := 1;              // カレントページ

  var Request := TNetHTTPRequest.Create(nil);
  var Response := TMemoryStream.Create;
  var Content := TStringList.Create;
  var Body := TStringList.Create;
  var ReplaceMethod := TReplaceMethodClass.Create;
  try
    Request.Client := TNetHTTPClient.Create(Request);
    while True do
      begin
        // GET /api/v2/users/:user_id/items
        // https://qiita.com/api/v2/docs#get-apiv2usersuser_iditems
        Response.Clear;
        Request.Get(Format(API, ['users/' + User_ID, Page, PER_PAGE]), Response);
        if Response.Size < 100 then // 100 文字以下のレスポンスは
          Break;                    // データが存在しないかエラー
        Content.LoadFromStream(Response, TEncoding.UTF8);

        // JSON データの読み込み
        var StringReader := TStringReader.Create(Content.Text);
        var TextReader := TJsonTextReader.Create(StringReader);
        var Iterator := TJSONIterator.Create(TextReader);
        try
          Iterator.Recurse;
          while Iterator.Next do
            begin
              // データの取得
              Iterator.Recurse;
              (* BODY *)
              Iterator.Next('body');
              Body.Text := Iterator.AsString;
              (* ID *)
              Iterator.Next('id');
              var Id := Iterator.AsString;
              (* TAGS *)
              Iterator.Next('tags');
              var Tags := '';
              Iterator.Recurse;
              while Iterator.Next do
                begin
                  Iterator.Recurse;
                  Iterator.Next('name');
                  Tags := Tags + Iterator.AsString + ' ';
                  Iterator.Return;
                end;
              Iterator.Return;
              (* TITLE *)
              Iterator.Next('title');
              var Title := Iterator.AsString;
              (* URL *)
              Iterator.Next('url');
              var Url := Iterator.AsString;

              // TITLE と URL と Tags を標準出力
              Writeln('Title: ', Title    );
              Writeln('URL: '  , Url      );
              Writeln('Tags: ' , Tags.Trim);

              // 画像ファイルの取得
              for var Match in TRegEx.Matches(Body.Text, EXP1) do
                begin
                  var ImageURL := Match.Groups.Item[0].Value;
                  // 画像 URL を標準出力
                  Writeln(' - ', ImageURL);
                  // 画像ファイルの取得と保存
                  Response.Clear;
                  Request.Get(ImageURL, Response);
                  Response.SaveToFile(TPath.Combine(ImgDir, TPath.GetFileName(ImageURL)));
                end;

//              // Qiita 互換のヘッダ (任意)
//              Body.WriteBOM := False; // BOM なし
//              Body.LineBreak := #$0A; // LF 改行
//              var Header := '';
//              Header := Header + '---'                 + Body.LineBreak;
//              Header := Header + 'title: ' + Title     + Body.LineBreak;
//              Header := Header + 'tags: '  + Tags.Trim + Body.LineBreak;
//              Header := Header + 'author: '+ User_ID   + Body.LineBreak;
//              Header := Header + 'slide: ' + 'false'   + Body.LineBreak;
//              Header := Header + '---'                 + Body.LineBreak;
//              Body.Text := Header + Body.Text;

              // 画像ファイルのパス変更 (任意)
              Body.Text := TRegEx.Replace(Body.Text, EXP1, ReplaceMethod.NewPath);

              // 自身の投稿を相対パスへ (任意)
              Body.Text := TRegEx.Replace(Body.Text, Format(EXP2, [User_ID]), ReplaceMethod.RelativePath);

              // Markdown ファイルを出力
              Body.SaveToFile(TPath.Combine(SrcDir, Id + '.md'), TEncoding.UTF8);

              Writeln;
              Iterator.Return;
            end;
        finally
          Iterator.Free;
          TextReader.Free;
          StringReader.Free;
        end;
        Inc(Page); // 次のページへ
      end;
  finally
    ReplaceMethod.Free;
    Body.Free;
    Content.Free;
    Response.Free;
    Request.Free;
  end;
end. { Main }
```

ソースコードを `GetQiitaItems.dpr` という名前で保存し、Delphi で開いてコンパイルするだけのお手軽仕様です。素の Delphi でコンパイルできます。外部パッケージや他のファイルは一切必要ありません。

## 使い方
使い方はコマンドラインから

```
GetQiitaItems <UserID>
```

です。私の場合だと次のようになります。

```
 GetQiitaItems ht_deko
```

実行すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/d6368e04-a88b-f3ad-ed85-1e0785dfd16a.png)

次のようなフォーマットで取得情報が標準出力されます。

```
Title: 記事のタイトル
URL: 記事の URL
Tags: 記事のタグ
 - (画像ファイルの URL)
   ...
```

マークダウンファイルは `<記事ID>.md` というファイル名で `.\source` フォルダに保存され、画像は `.\source\images` に保存されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/e976d592-9019-e543-4103-0ae7b856d93a.png)

**Typora** 等のマークダウンエディタで開くことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/ab93bd69-7bba-1b33-9b53-935e2298b99c.png)


**See also:**

 - [コマンドライン サポート メンバ (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3_%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88_%E3%83%A1%E3%83%B3%E3%83%90)
 - [Typora (typora.io)](https://typora.io/)

## アルゴリズム
Qiita API v2 の `GET /api/v2/users/:user_id/items`を使って記事の一覧を取得しています。

 - [GET /api/v2/users/:user_id/items (Qiita)](https://qiita.com/api/v2/docs#get-apiv2usersuser_iditems)

一度に取得できる記事の数は `per_page` パラメータで指定する事が可能で、最大 100 件取得できます。100 件を超える投稿は `page` パラメータをインクリメントしながら取得します。

例えば 120 件のデータを取得するには `page=1` と `page=2` の 2 回に分けて取得します。`page=3`を指定すると空の JSON データが送られてきます。

### HTTP (GET)
HTTP 通信には [TNetHTTPRequest](http://docwiki.embarcadero.com/Libraries/ja/System.Net.HttpClientComponent.TNetHTTPRequest) (変数: **Request**) と [TNetHTTPClient](http://docwiki.embarcadero.com/Libraries/ja/System.Net.HttpClientComponent.TNetHTTPClient) を使っています。

レスポンスは [TMemoryStream](http://docwiki.embarcadero.com/Libraries/ja/System.Classes.TMemoryStream) (変数: **Response**) で受け取っています。これを [TStringList](http://docwiki.embarcadero.com/Libraries/ja/System.Classes.TStringList) (変数: **Content**) で読み込んで文字列にしています。

### JSON
JSON データは [TStringReader](http://docwiki.embarcadero.com/Libraries/ja/System.Classes.TStringReader) (変数: **StringReader**) / [TJsonTextReader](http://docwiki.embarcadero.com/Libraries/ja/System.JSON.Readers.TJsonTextReader) (変数: **TextReader**) / [TJSONIterator](http://docwiki.embarcadero.com/Libraries/ja/System.JSON.Builders.TJSONIterator)  (変数: **Iterator**) で処理しています。

**See also:**

 - [JSON (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/JSON)
 - [JSON リーダー/ライタ フレームワーク (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/JSON_%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC/%E3%83%A9%E3%82%A4%E3%82%BF_%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AF%E3%83%BC%E3%82%AF)

### Qiita 互換のヘッダ
Qiita 記事の URL の最後に `.md` を付けて表示させるとマークダウンで表示できますが、それにはヘッダが追加されています。例えば[この記事](https://qiita.com/ht_deko/items/f89774c5670952d52404.md)のヘッダは次のようになっています。

```
---
title: Delphi で Qiita の記事をバックアップしてみる
tags: Delphi programming objectpascal Pascal
author: ht_deko
slide: false
---
```
ヘッダを付ける機能はコメントアウトで潰してあります。他の環境に持って行く場合には不要だと思われるからです。

### 画像ファイルのパス
Markdown ファイル内にある画像のパスが `amazonaws.com` になっているので、これを `./images` への相対パスに置換します。

```
![image.png](https：//qiita-image-store.s3.amazonaws.com/0/21785/d6368e04-a88b-f3ad-ed85-1e0785dfd16a.png)
↓
![image.png](./images/d6368e04-a88b-f3ad-ed85-1e0785dfd16a.png)
```

せっかく画像も保存していますのでね。

置換には [TRegEx](http://docwiki.embarcadero.com/Libraries/ja/System.RegularExpressions.TRegEx) を使っています。

**See also:**

 - [正規表現の活用 (主に Delphi 2009 以降) (ht-deko.com)](https://ht-deko.com/tech064.html)
 - [System.RegularExpressions.TRegEx.Replace (DocWiki)](http://docwiki.embarcadero.com/Libraries/ja/System.RegularExpressions.TRegEx.Replace)
 - [TRegExReplace (Delphi) (DocWiki)](http://docwiki.embarcadero.com/CodeExamples/en/TRegExReplace_(Delphi))

### 同一ユーザの投稿
Markdown ファイル内にあるリンクのうち、同一ユーザの Qiita への投稿は相対パス (`./`) に置換します。バックアップされている記事へのリンクなので、拡張子 `.md` を末尾に追加しています。

```
https：//qiita.com/ht_deko/items/f89774c5670952d52404
↓
./f89774c5670952d52404.md
```

[TRegEx](http://docwiki.embarcadero.com/Libraries/ja/System.RegularExpressions.TRegEx) で置換する際、マッチ結果を元に置換するには Replace() メソッドの引数として "置換ロジックを記述したイベントハンドラ" を指定しなくてはなりません。

今回、コンソールアプリケーションですから、フォームクラスにメソッド (イベントハンドラ) を作って指定する事はできません。そこで置換用のメソッドを提供するためだけのクラス **TReplaceMethodClass** を作成してそのメソッドを Replace() に指定しています。

**See also:**

 - [手続き型 (関数ポインタ) (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/%E6%89%8B%E7%B6%9A%E3%81%8D%E5%9E%8B) 
 - [メソッドポインタ (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/%E6%89%8B%E7%B6%9A%E3%81%8D%E5%9E%8B#.E3.83.A1.E3.82.BD.E3.83.83.E3.83.89_.E3.83.9D.E3.82.A4.E3.83.B3.E3.82.BF)
 - [メソッド参照 (無名メソッド) (DocWiki)](http://docwiki.embarcadero.com/RADStudio/ja/Delphi_%E3%81%A7%E3%81%AE%E7%84%A1%E5%90%8D%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89#.E6.A7.8B.E6.96.87)
 - [関数ポインタとメソッドポインタの相互代入のおはなし。 (Swanman's Horizon)](lyna.hateblo.jp/entry/20121205/1354636256)


## アクセストークンを使った記事の取得
アクセストークンを使えば、Qiita API v2 の `GET /api/v2/authenticated_user/items`を使って限定投稿を含めた記事の一覧を取得できます。

 - [GET /api/v2/authenticated_user/items (Qiita)](https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems)


```pascal
        // GET /api/v2/users/:user_id/items
        // https://qiita.com/api/v2/docs#get-apiv2usersuser_iditems
        Response.Clear;
        Request.Get(Format(API, ['users/' + User_ID, Page, PER_PAGE]), Response);
```

この部分を変更します。


```pascal
uses
  ..., System.Net.URLClient;

...
        // GET /api/v2/authenticated_user/items
        // https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems
        Response.Clear;
        var Headers: TNetHeaders;
        Headers := [TNameValuePair.Create('Content-Type' , 'application/json'),
                    TNameValuePair.Create('Authorization', 'Bearer [ここにアクセストークン]')];
        Request.Get(Format(API, ['authenticated_user', Page, PER_PAGE]), Response, Headers);
```

「見慣れない記述がある」という方は、次の記事をどうぞ。

 - [Delphi でコントロール配列 \[小ネタ\] (Qiita)](https://qiita.com/ht_deko/items/9bf3b2e761cc4f169c75)

限定投稿 (Private) であるかの判定は次のようなコードで行えます。

```pascal
              (* PRIVATE *)
              var &Private: Boolean := False;
              if Iterator.Next('private') then
                &Private := Iterator.AsBoolean;
```

※ アクセストークンは https://qiita.com/settings/tokens/new から発行できます。

**See also:**

 - [System.Net.URLClient.TNetHeaders (DocWiki)](http://docwiki.embarcadero.com/Libraries/ja/System.Net.URLClient.TNetHeaders)
 - [System.Net.URLClient.TNameValuePair (DocWiki)](http://docwiki.embarcadero.com/Libraries/ja/System.Net.URLClient.TNameValuePair)

# おわりに
後は **md-page** 用のヘッダを組み込むなり、お好きに改変してください。

[私はやりました。](https://ht-deko.com/qiita_mirror/qiita.md)

**See also:**

 - [md-page (oscarmorrison.com)](https://oscarmorrison.com/md-page/)
 - [md-page (GitHub)](https://github.com/oscarmorrison/md-page)
 - [Delphi Community Edition (Embarcadero)](https://www.embarcadero.com/jp/products/delphi/starter)