# Delphi で Qiita の記事をバックアップしてみる --- tags: Delphi プログラミング Pascal embarcadero objectpascal created_at: 2020-04-05 updated_at: 2024-06-03 --- # はじめに なんとなく、自分で書いた 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/(?[0-9a-f-]+\.(png|gif|jpg))'; EXP2 = 'https://qiita\.com/%s/(items|private)/(?[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)), ' '); 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 ``` です。私の場合だと次のようになります。 ``` GetQiitaItems ht_deko ``` 実行すると、 ![image.png](./images/d6368e04-a88b-f3ad-ed85-1e0785dfd16a.png) 次のようなフォーマットで取得情報が標準出力されます。 ``` Title: 記事のタイトル URL: 記事の URL Tags: 記事のタグ - (画像ファイルの URL) ... ``` マークダウンファイルは `<記事ID>.md` というファイル名で `.\source` フォルダに保存され、画像は `.\source\images` に保存されます。 ![image.png](./images/e976d592-9019-e543-4103-0ae7b856d93a.png) **Typora** 等のマークダウンエディタで開くことができます。 ![image.png](./images/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` を付けて表示させるとマークダウンで表示できますが、それにはヘッダが追加されています。例えば[この記事](./f89774c5670952d52404.md.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)](./9bf3b2e761cc4f169c75.md) 限定投稿 (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)