はじめに
なんとなく、自分で書いた Qiita の記事のバックアップを取っておきたくなったのです。
バックアップ
『先生!記事をマークダウン形式のファイルで保存し、記事中の画像も保存したいです!』
ソースコード
記事一覧を取得するコンソールアプリケーションを書いてみました。Delphi 10.3 Rio 以降でコンパイルできます。
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
実行すると、
次のようなフォーマットで取得情報が標準出力されます。
Title: 記事のタイトル
URL: 記事の URL
Tags: 記事のタグ
- (画像ファイルの URL)
...
マークダウンファイルは <記事ID>.md
というファイル名で .\source
フォルダに保存され、画像は .\source\images
に保存されます。
Typora 等のマークダウンエディタで開くことができます。
See also:
アルゴリズム
Qiita API v2 の GET /api/v2/users/:user_id/items
を使って記事の一覧を取得しています。
一度に取得できる記事の数は per_page
パラメータで指定する事が可能で、最大 100 件取得できます。100 件を超える投稿は page
パラメータをインクリメントしながら取得します。
例えば 120 件のデータを取得するには page=1
と page=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:
- 正規表現の活用 (主に Delphi 2009 以降) (ht-deko.com)
- System.RegularExpressions.TRegEx.Replace (DocWiki)
- TRegExReplace (Delphi) (DocWiki)
同一ユーザの投稿
Markdown ファイル内にあるリンクのうち、同一ユーザの Qiita への投稿は相対パス (./
) に置換します。バックアップされている記事へのリンクなので、拡張子 .md
を末尾に追加しています。
https://qiita.com/ht_deko/items/f89774c5670952d52404
↓
./f89774c5670952d52404.md
TRegEx で置換する際、マッチ結果を元に置換するには Replace() メソッドの引数として "置換ロジックを記述したイベントハンドラ" を指定しなくてはなりません。
今回、コンソールアプリケーションですから、フォームクラスにメソッド (イベントハンドラ) を作って指定する事はできません。そこで置換用のメソッドを提供するためだけのクラス TReplaceMethodClass を作成してそのメソッドを Replace() に指定しています。
See also:
- 手続き型 (関数ポインタ) (DocWiki)
- メソッドポインタ (DocWiki)
- メソッド参照 (無名メソッド) (DocWiki)
- 関数ポインタとメソッドポインタの相互代入のおはなし。 (Swanman's Horizon)
アクセストークンを使った記事の取得
アクセストークンを使えば、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: