WikibaseのAPIによるデータインポート

せっかくWikibaseの環境を構築しても中にデータが入っていなければ何の意味もありません。そこで、PHPのスクリプトを作成して、CSV形式の月待塔オープンデータをAPI経由でインポートすることにします。

ボット用パスワードの作成
APIを使用してWikibaseにアクセスする場合、通常のログインに使用するアカウントとパスワードではなく、アカウントに紐付いたボット名とボット用パスワードを使用します。
そこでまず、特別ページの「利用者と権限」にある「ボット用パスワード」を開きます。

ボット名を入力して[作成]ボタンをクリックします。

編集関係の権限を付与して[作成]ボタンをクリックすると、作成されたボット用パスワードが表示されます。


インポートプログラムの作成

メインのプログラムは以下のとおりです。

<?php
require('login.php');
require('util.php');

mb_internal_encoding("UTF-8");
setlocale(LC_CTYPE, 'C');
$endPoint = "https://wiki.midoriit.com/api.php";

$login_Token = getLoginToken();
$login = loginRequest( $login_Token );
echo "Login: ".$login."\n";

if( $login == "Success" ) {

  if (($csv = fopen("https://raw.githubusercontent.com/midoriit/tsukimachito/master/tsukimachito.csv", "r")) !== FALSE) {
    $row = fgetcsv($csv);     //ヘッダ行読み捨て
    $token = getEditToken();  // 編集用トークン取得
    while ($row = fgetcsv($csv)) {
      if( $id = wbSearchEntities($row[0]) ) {
        echo $row[0]." skip( ".$id." )\n";
      } else {
        $id = wbEditEntity( $row, $token );
        if( $id < 0 ) {
          exit(1);
        }
        echo $row[0]." create( ".$id." )\n";
      }
    }
    fclose($csv);
  } else {
    echo "Failed to open 'tsukimachito.csv'\n";
  }
}
unlink( "cookie.txt" );
?>

requireで読み込む login.php と util.php については後で解説します。
setlocale(LC_CTYPE, 'C'); は、Windows環境において fgetcsv() が日本語を正しく扱えるようにするためのものです。詳しくはこちらを参照して下さい。

login.php で定義された以下の2つの関数を呼び出してWikibaseにログインします。APIの種類によっては、操作をする前にトークンを取得をする必要があります。

$login_Token = getLoginToken();
$login = loginRequest( $login_Token );

ログインに成功したら、GitHub上にある月待塔オープンデータのCSVファイルを開き、util.php で定義した getEditToken() 関数で編集用トークンを取得しておきます。
CSVファイルを1行ずつ読み込みながら、wbSearchEntities() で既に項目が存在するかどうかを確認し、存在しなければ wbEditEntity() で項目を登録します。CSVファイルの1行がWikibaseにおける1つの項目となります。また、CSVファイルの1カラム目のIDは、Wikibaseの項目のラベルになります。
最後の unlink() では、トークンの取得時に作成したクッキーを削除しています。

login.php は、こちらにあるMITライセンスのサンプルコードを修正して利用します。

<?php
/*
    login.php

    MediaWiki API Demos
    Demo of `Login` module: Sending post request to login
    MIT license
*/

// Step 1: GET Request to fetch login token
function getLoginToken() {
  global $endPoint;

  $params1 = [
    "action" => "query",
    "meta" => "tokens",
    "type" => "login",
    "format" => "json"
  ];

  $url = $endPoint . "?" . http_build_query( $params1 );

  $ch = curl_init( $url );
  curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
  curl_setopt( $ch, CURLOPT_COOKIEJAR, "cookie.txt" );
  curl_setopt( $ch, CURLOPT_COOKIEFILE, "cookie.txt" );

  $output = curl_exec( $ch );
  curl_close( $ch );

  $result = json_decode( $output, true );
  return $result["query"]["tokens"]["logintoken"];
}

// Step 2: POST Request to log in. Use of main account for login is not
// supported. Obtain credentials via Special:BotPasswords
// (https://www.mediawiki.org/wiki/Special:BotPasswords) for lgname & lgpassword
function loginRequest( $logintoken ) {
  global $endPoint;

  $params2 = [
    "action" => "login",
    "lgname" => "ボット名",
    "lgpassword" => "ボット用パスワード",
    "lgtoken" => $logintoken,
    "format" => "json"
  ];

  $ch = curl_init();

  curl_setopt( $ch, CURLOPT_URL, $endPoint );
  curl_setopt( $ch, CURLOPT_POST, true );
  curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $params2 ) );
  curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
  curl_setopt( $ch, CURLOPT_COOKIEJAR, "cookie.txt" );
  curl_setopt( $ch, CURLOPT_COOKIEFILE, "cookie.txt" );

  $output = curl_exec( $ch );
  curl_close( $ch );

  $result = json_decode( $output, true );
  return $result["login"]["result"];
}
?>

lgname と lgpassword はボット名とボット用パスワードを設定します。loginRequest() は処理結果を表す文字列を返すように変更しました。
トークン取得APIではHTTPのGETメソッドを使用し、ログインAPIではPOSTメソッドを使用しています。

util.php では、編集用トークンを取得する getEditToken()、データを検索する wbSearchEntities()、データを登録する wbEditEntity() とその補助関数を定義しています。

<?php
// getEditToken
function getEditToken() {
  global $endPoint;

  $params = [
    "action" => "query",
    "meta" => "tokens",
    "type" => "csrf",
    "format" => "json"
  ];

  $url = $endPoint . "?" . http_build_query( $params );

  $ch = curl_init( $url );
  curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
  curl_setopt( $ch, CURLOPT_COOKIEJAR, "cookie.txt" );
  curl_setopt( $ch, CURLOPT_COOKIEFILE, "cookie.txt" );

  $output = curl_exec( $ch );
  curl_close( $ch );

  $result = json_decode( $output, true );
  return $result["query"]["tokens"]["csrftoken"];
}

// wbSearchEntities
function wbSearchEntities($id) {
  global $endPoint;

  $params = [
    "action" => "wbsearchentities",
    "language" => "ja",
    "search" => $id,
    "format" => "json"
  ];

  $ch = curl_init();
  curl_setopt( $ch, CURLOPT_URL, $endPoint );
  curl_setopt( $ch, CURLOPT_POST, true );
  curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $params ) );
  curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
  curl_setopt( $ch, CURLOPT_COOKIEJAR, "cookie.txt" );
  curl_setopt( $ch, CURLOPT_COOKIEFILE, "cookie.txt" );

  $output = curl_exec( $ch );
  curl_close( $ch );
  $result = json_decode( $output, true );
  if( count($result["search"]) > 0 ) {
    return $result["search"][0]["id"];
  }
}

// wbEditEntity
function wbEditEntity( $row, $token ) {

  global $endPoint;

  $claims = buildClaims( $row );
  $data = '{"labels":{"ja":{"language":"ja","value":"'.$row[0].'"}},"claims":'.$claims.'}';

  $params = [
    "action" => "wbeditentity",
    "new" => "item",
    "data" => $data,
    "token" => $token,
    "format" => "json"
  ];

  $ch = curl_init();
  curl_setopt( $ch, CURLOPT_URL, $endPoint );
  curl_setopt( $ch, CURLOPT_POST, true );
  curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $params ) );
  curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
  curl_setopt( $ch, CURLOPT_COOKIEJAR, "cookie.txt" );
  curl_setopt( $ch, CURLOPT_COOKIEFILE, "cookie.txt" );
  $output = curl_exec( $ch );
  curl_close( $ch );

  $result = json_decode( $output, true );
  if(array_key_exists("entity", $result)) {
    return $result["entity"]["id"];
  } else {
    var_dump($result);
    return -1;
  }
}

// buildClaims
function buildClaims( $row ) {

  // $row[0] -> ツイートID (P4)
  $claims = buildClaimSub( "P4", substr($row[0], 1) );

  // $row[1] -> ツイート日 (P5)
  $date = new DateTime($row[1]);
  $iso8601 = substr($date->format(DateTime::ATOM), 0, 19);
  $claims = $claims.','.buildClaimSub2( "P5", "+".$iso8601."Z", 11 );

  // $row[2] -> Twitterアカウント (P6)
  $claims = $claims.','.buildClaimSub( "P6", $row[2] );

  // $row[3],$row[4] -> 緯度経度 (P7)
  $claims = $claims.','.buildClaimSub3( "P7", $row[3], $row[4] );

  // $row[5] -> ツイートURL (P8)
  $claims = $claims.','.buildClaimSub( "P8", $row[5] );

  // $row[6]~$row[9] -> 画像URL (P9)
  for( $i=0 ; $i<4 ; $i++ ) {
    if( $row[6+$i] ) {
      $claims = $claims.','.buildClaimSub( "P9", $row[6+$i] );
    } else {
      break;
    }
  }

  // $row[10] -> 分類 (P1)
  $types = explode(" ", $row[10]);
  foreach ($types as $type) {
    switch( $type ) {
      case '三日月塔': $q = 3; break;
      case '(二十)三夜塔': $q = 23; break;
      case '七夜待塔': $q = 7; break;
      case '十三夜塔': $q = 13; break;
      case '十四夜塔': $q = 14; break;
      case '十五夜塔': $q = 15; break;
      case '十六夜塔': $q = 16; break;
      case '十七夜塔': $q = 17; break;
      case '十八夜塔': $q = 18; break;
      case '十九夜塔': $q = 19; break;
      case '二十夜塔': $q = 20; break;
      case '二十一夜塔': $q = 21; break;
      case '二十二夜塔': $q = 22; break;
      case '二十三夜塔': $q = 23; break;
      case '二十四夜塔': $q = 24; break;
      case '二十五夜塔': $q = 25; break;
      case '二十六夜塔': $q = 26; break;
      case '二十七夜塔': $q = 27; break;
      case '二十八夜塔': $q = 28; break;
      default : $q = 0;
    }
    if( $q != 0 ) {
      $claims = $claims.','.buildClaimSub4( "P1", $q );
    }
  }

  // $row[11] -> 造立年(和暦)(P17)
  if( $row[11] ) {
    $claims = $claims.','.buildClaimSub( "P17", $row[11] );
  }

  // $row[12] -> 造立年(西暦)(P18)
  if( $row[12] ) {
    $claims = $claims.','.buildClaimSub( "P18", $row[12] );
  }

  // $row[13] -> 所在地 (P15)
  if( $row[13] ) {
    $claims = $claims.','.buildClaimSub( "P15", $row[13] );
  }

  // $row[14] -> 場所 (P16)
  if( $row[14] ) {
    $claims = $claims.','.buildClaimSub( "P16", $row[14] );
  }

  return "[".$claims."]";
}

// buildClaimSub(プロパティ, 値)
function buildClaimSub( $prop, $value ) {
  return '{"mainsnak":{"snaktype":"value","property":"'.$prop.'","datavalue":{"value":"'.$value.'","type":"string"}},"type":"statement","rank":"normal"}';
}

// buildClaimSub2(プロパティ, 日付, 精度)
function buildClaimSub2( $prop, $date, $precision ) {
  return '{"mainsnak":{"snaktype":"value","property":"'.$prop.'","datavalue":{"value":{"time":"'.$date.'","timezone": 0,"before": 0,"after": 0,"precision":'.$precision.',"calendarmodel": "http:\/\/www.wikidata.org\/entity\/Q1985727"},"type":"time"}},"type":"statement","rank":"normal"}';
}

// buildClaimSub3(プロパティ, 緯度, 経度)
function buildClaimSub3( $prop, $lat, $lon ) {
  return '{"mainsnak":{"snaktype":"value","property":"'.$prop.'","datavalue":{"value":{"latitude":'.$lat.',"longitude":'.$lon.',"precision":0.000001},"type":"globecoordinate"}},"type":"statement","rank":"normal"}';
}

// buildClaimSub4(プロパティ, 項目)
function buildClaimSub4( $prop, $item ) {
  return '{"mainsnak":{"snaktype":"value","property":"'.$prop.'","datavalue":{"value":{"entity-type":"item","numeric-id":'.$item.'},"type":"wikibase-entityid"}},"type":"statement","rank":"normal"}';
}
?>

getEditToken() の内容は getLoginToken() とほぼ同様ですが、取得するトークンはCSRFトークンになります。
wbSearchEntities() も同様にAPIを呼び出し、結果を取得して返します。

データを登録する wbEditeEntity() 関数は、Wikibaseに登録する内容を指定するJSON形式のデータを作成し、APIを呼び出します。
登録内容はラベルと文から成ります。ラベルは
{"labels":{"ja":{"language":"ja","value":"ラベル"}}
と指定します。
文を登録するためのJSONはやや複雑なため、buildClaims() 関数とその補助関数を用いて作成します。
補助関数は、プロパティに対する値の型によって、文字列用の buildClaimSub()、日付用の buildClaimSub2()、緯度経度用の buildClaimSub3()、項目用の buildClaimSub4() を用意しました。型ごとのJSONフォーマットの仕様についてはこちらが参考になります。

インポートを実行すると、以下のような項目が作成されます。

項目には「説明」を設定していないため、特別ページの説明がないエンティティを表示するとインポートした項目を一覧表示することができます。

Wikibaseの環境構築 123