LoginSignup
6
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-04-05

はじめに

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

バックアップ

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

ソースコード

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

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\..*amazonaws\.com/0/.+/(?<filename>.+\.(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
次のようなフォーマットで取得情報が標準出力されます。

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

マークダウンファイルは <記事ID>.md というファイル名で .\source フォルダに保存され、画像は .\source\images に保存されます。
image.png
Typora 等のマークダウンエディタで開くことができます。
image.png

See also:

アルゴリズム

Qiita API v2 の GET /api/v2/users/:user_id/itemsを使って記事の一覧を取得しています。

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

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

HTTP (GET)

HTTP 通信には TNetHTTPRequest (変数: Request) と TNetHTTPClient を使っています。

レスポンスは TMemoryStream (変数: Response) で受け取っています。これを TStringList (変数: Content) で読み込んで文字列にしています。

JSON

JSON データは TStringReader (変数: StringReader) / TJsonTextReader (変数: TextReader) / TJSONIterator (変数: Iterator) で処理しています。

See also:

Qiita 互換のヘッダ

Qiita 記事の URL の最後に .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 を使っています。

See also:

同一ユーザの投稿

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

https://qiita.com/ht_deko/items/f89774c5670952d52404
↓
./f89774c5670952d52404.md

TRegEx で置換する際、マッチ結果を元に置換するには Replace() メソッドの引数として "置換ロジックを記述したイベントハンドラ" を指定しなくてはなりません。

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

See also:

アクセストークンを使った記事の取得

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

        // 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);

この部分を変更します。

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);

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

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

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

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

See also:

おわりに

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

私はやりました。

See also:

6
3
1

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
6
3