西町北21丁目/a>

西町北21丁目

まともな事を書きます

openBD APIとgoogleスプレッドシートで蔵書を管理しよう

蔵書が多すぎる。

多いのはまあ仕方ないとしよう。収集はオタクの習性なので。けれども問題なのは蔵書が増えてくると「あの本買ってたっけ」と記憶が曖昧になってくること。

ブックオフで「あれ、これ既所持だったっけ」→「まあいいやどうせ110円だしとりあえず買っとこ」で2冊以上の在庫を抱えてしまうことが多々あります。先日蔵書を掘り返していたら『ちょっといっぱい!』の5巻を3冊も買っていた事が発覚しました。アホですね。

ということで蔵書リストを作ろうと思いました。が、データを手入力するのは面倒ですし、そんな面倒なことやってたら絶対に更新をサボって腐らせるはずです。自分の事は自分が一番詳しいんだ。

なのでISBNを入力すると自動で諸々の情報を拾ってきてくれるとよい。なおかつgoogleスプレッドシートで実現すること。なんでスプシに拘るかというと、理由はスマホからでもアクセスが容易だから。「既所持だったっけ」が発生するのは99%外出先ですから、スプレッドシートのアプリを開けばすぐにアクセスできるスプシが好ましいのです。

ということでISBNを入力したら書籍情報を書き込んでくれるスクリプトを書きました。こんな感じです。 最初はここスクリプトをパクらせてもらっていましたが、あれも表示したいこれも表示したいとあれこれ追加したり、蔵書数が多すぎて動作時間がGASの実行時間上限である6分を超えてしまったので動作を高速化したりしているうちにだいぶ原型から離れたコードが書き上がったので書き残しておこうと思います。

「とりあえず動けばいいや」の精神で書いたのでコードを書いて飯を食っている人からしたらかなりガバガバなスクリプトに仕上がっているはずですし、同じようなことがしたくてここを覗いた人は参考程度に留めておいてください。授業サボりすぎてjavasscriptやる科目の単位落としたような人間が書いているので。

で、どういうスクリプト書いたの

こういうのです。

function getdata() {
  //おまじない
  const sheet=SpreadsheetApp.getActiveSheet();
  const lastrow=sheet.getLastRow();
  var targetCell = "A1"
  var range= SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(targetCell);

//ISBNとタイトルの列を配列に格納
  var datarow = sheet.getRange(2,1,lastrow-1,2).getValues();
  for(var i =1; i<lastrow;i++){

     //配列からISBNを取り出す
    var isbncode = String(datarow[i-1][0]);
    
    //空行に当たったら終了
    if(isbncode == ""){
      Logger.log("end");
      break;
    }
    //エラー吐いた行は飛ばす
    if(datarow[i-1][1]!=""&&datarow[i-1][1]!="ISBN CODE Error(length?)"&&datarow[i-1][1]!="ISBN CODE Error(typo?)"&&datarow[i-1][1]!="not ISBN"){
      continue;
    }
    //ISBNの先頭が9か4じゃないのもおかしいので飛ばす
     if(isbncode.slice(0,1)!=9&&isbncode.slice(0,1)!=4){
      range.offset(i,1).setValue("not ISBN");
      continue;
     }
    //桁数がおかしいのも飛ばす
    var isbnLen = isbncode.length;
    if(isbnLen != 13 && isbnLen != 10){
      Logger.log("non isbn");
      range.offset(i,1).setValue("ISBN CODE Error(length?)");
      continue;
    }
    //isbnのチェックデジットを判定する
    if(isbnLen == 13){
      var judge=isbn13check(isbncode);
      if(judge=="false"){
      range.offset(i,1).setValue("ISBN CODE Error(typo?)"); 
      continue;
      }
    }   
    
    // OpenBD APIに問い合わせ
    var response = UrlFetchApp.fetch("https://api.openbd.jp/v1/get?isbn=" + isbncode);
    console.log(i);

    // APIの結果をparseする
    var resultJson = JSON.parse(response);
    

    //APIからnullが帰ってきたら飛ばす
    if(resultJson[0] != null){

    //著者名カナがあったら取り出す
    var authkana=null;
    var cont=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Contributor"]);
    if(cont=="[]"){
    authkana=undefined;
    }
    else{
    authkana=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Contributor"][0,0]["PersonName"]["collationkey"]);
    }
    if(authkana==undefined){
      authkana=null;
    }
    else{
    authkana=authkana.replace(/"/g,'');
    }

    //レーベルの情報があったら取り出す
    var a=0;
    var labelcheck = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]);
    if(labelcheck==undefined){a=1}
    else{
    var labelcheck2= JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]);
    if(labelcheck2==undefined){a=2};
    }
    var label=null;
    if(a==0){
    label = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]["TitleElement"][0,0]["TitleText"]["content"] );
    label=label.replace(/"/g,'');
    }
    //Cコードがあったら取り出す
    var c=0;
    var codecheck = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Subject"]);
    if(codecheck==undefined){c=1};
    var ccode=null;
    if(c==0){
    var ccode=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Subject"][0,0]["SubjectCode"]);
    ccode="C"+ccode;
    ccode=ccode.replace(/"/g,'');
    }

    //追加日時がなかったら書き込む
    if(range.offset(i,7).getValue()==""){
    var date = new Date();
    date = Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd")
    range.offset(i,7).setValue(date);
    }
    
      //title書き込み
      range.offset(i,1).setValue(resultJson[0].summary.title);
      
      //author書き込み
      range.offset(i,2).setValue(resultJson[0].summary.author);

      //authkana書き込み
      range.offset(i,3).setValue(authkana);
      
      //publisher書き込み
      range.offset(i,4).setValue(resultJson[0].summary.publisher);
      
      //label書き込み
      range.offset(i,5).setValue(label);

      //ccode書き込み
      range.offset(i,6).setValue(ccode);
      
    }
      
    else{
      //nullが帰ってきてたらnot foundを書き込む
      range.offset(i,1).setValue("not found");
      range.offset(i,2).setValue("OpenBD API Error");

    }
       
  } 
  Logger.log("end"); 
}

やっている事としてはISBNとタイトルの行を配列に格納してから、OpenBD APIから書籍情報をもらってきて、そこから欲しい情報を引っ張り出してセルへ書き込むという内容です。ISBNを登録したら即座に走るgetdata関数と毎晩走ってデータの更新をチェックしてくれるdaily関数を用意しましたが、基本的な部分は全く変わらないのでまずはgetdata関数から。

パクった元はISBNコードの参照をgetValueで行っていたのですが、GASはgetValueやsetValueといったGASのAPIを叩きすぎるとやる気を失くして速度が低下してしまうので、300冊あたりから1冊/秒程度までスローダウンしてしまい、6分に到達してタイムオーバーになってしまっていたので配列に格納することでgetValueを1回にまとめました。

また、データが既に書き込まれている本や、版元ドットコムに書籍情報が登録されておらず、APIでnullが帰ってきた本(意外とある)はdaily関数に面倒を見てもらうことにして処理をすっ飛ばします。

処理を飛ばす本を選別したら、まずはISBNが10桁もしくは13桁か、ISBNの先頭が9もしくは4か(洋書とかだとそうでないのもあるけど自分はそういう本は持ってないのでよし)、ISBNが13桁の場合はチェックデジットが正しいかを判定します。

講談社のコミックなんかは裏表紙にバーコードがなく、ISBNコードを手打ちせざるを得ません。クソむかつく。やはり手入力だとtypoすることがちょこちょこあるので、13桁のISBNはisbn13check関数を呼び出してチェックデジットの判定をしてもらいます。

ちなみにisbn13check関数の中身はこんな感じ。

function isbn13check(isbn13){
var sum=0;
isbn13=String(isbn13);
isbn13=isbn13.split('');
for(j=0;j<isbn13.length;j++){
  isbn13[j]=Number(isbn13[j]);
}
var n;
for(j=0;j<isbn13.length-1;j++){
  n=isbn13[j];
  if((j+1)%2==0){
    sum=sum+(n*3);
  }
  else{
    sum=sum+n;
  }
}
var checkdigittrue=0;
if(sum%10!=0){
  checkdigittrue=10-(sum%10);
}
var checkdigit=isbn13[12];
var judge;
if(checkdigittrue==checkdigit){
  judge="true";
}
else{
  judge="false";
}
return judge;
}

13桁のISBNコードは最後の13桁目がチェックデジットになっており、チェックデジットはモジュラス10ウエイト3という方式で算出されています。実際の内容としては「左から順に奇数番は1、偶数番は3をかけた数の和を10で割り、あまりが0ならばチェックデジットは0、0でなければ10からあまりを引いた値がチェックデジット」という仕組みです。1桁づつ配列に格納して計算するために文字列形式のISBNを一桁づつバラしてから数値に変換するというアホみたいな方法を取っています。もっと冴えたやり方無いんですかね。雑魚なのでわかりませんが動くのでヨシ!

話をgetdata関数に戻して、チェックデジットも正しければいよいよOpenBD APIに書籍情報をリクエストします。

リクエストしたデータはjsonで送られるのでjsonをいじくり回して自分が欲しいデータを拾います。例えば「かきバターを神田で」(ISBN978-4-16-791390-8)の場合はこんなのが返ってきます。

そこで問題となるのがjsonの深い階層にあるデータを取り出すのが面倒ということです。パクった元はjsonの最末尾にあるsummaryというプロパティに入っているデータを参照していましたが、私はレーベルだとか著者名のカナ表記だとかCコードが欲しかったので、summaryに入っている情報だけでは物足りません。

その他のいろんな情報はjsonの比較的深い階層に埋まっているのですが、GASではどうやらjsonをparseすると2階層より深い所はオブジェクトという形で丸められてしまうようで、プロパティで直接参照してもundefinedが返ってきます。なんてこった。

最悪
なのでJSON.stringifyで中身を直接文字列として引っ張り出します。JSON.stringifyはちゃんと3階層より深い所も掘らせてくれます。えらい。
ここ
例えばここに埋まっているレーベルの情報は

JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]["TitleElement"][0,0]["TitleText"]["content"] )

で文字列として引っ張り出せます。ただし、ダブルクォーテーションもついてくるのでそれを取り除く必要がありますけどね。

この要領で他に欲しかったCコードや著者名のカナ表記も取り出せますし、jsonに入っている情報なら何でも取り出し放題です。好きな情報を追加してぼくのかんがえたさいきょうのぞうしょかんりを作りましょう。 ただ、summary以外の場所から引っ張ってくるような細かい情報は本によって無いことがあるので、その時用の処理を加える必要があります。例えば「北欧女子オーサが見つけた日本の不思議」(ISBN978-4-04-067423-0)はレーベルの情報を持っていません。例外処理を加えていないコードを走らせると「resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]がundefinedなんだけど💢」と怒られます。なのでundefinedが返ってきたらレーベルはnullにするとかの処理が必要になります。レーベルの情報が無い本はonix/DescriptiveDetailCollection/かonix/DescriptiveDetailCollection/TitleDetail/で中身が途切れるのでこれらのプロパティがundefinedか否かでif分岐を作りました。

そうして欲しいデータを取り出したのならば、あとはsetValueでセルに情報を書き込めばOKです。なお、浅い階層にある情報はparseしたjsonから直接参照して書き込んでいます。

daily関数はどこが違うの

さて、ここでようやく出てきたdaily関数の話です。daily関数はAPIからnullが返ってきたデータがもしかしたら突然登録されるかもしれない、データに何らかの更新があるかもしれないという望みを込めて健気に毎晩1回走ってくれる関数です。データ更新の確認ですからアホみたいな量の本のデータを全部さらわなければなりません。もちろん時間はクソほどかかりますし、getdata関数より更に高速化しなければなりません。

しかし、daily関数は寝ている間に走る関数ですから、getdataみたくデータを即時に反映する必要がありません。そこでdaily関数では取得した情報を配列に格納し、最後にsetValuesで一気に書き込むという方式を取りました。これで6回×行の数あるsetValueを1回にまとめられるのでこの方式に改修するまでは900冊弱で6分ギリギリだったのが1600冊でもだいたい4.5分で終わらせてくれるようになりました。配列マジ最強。

1609冊ありました
javascriptで二次元配列を宣言するのは死ぬほど面倒だということは竹取物語にも記載されており、全人類の共通認識だと思いますが、大量のデータを配列に収めるには2次元配列にしなければなりません。仕方ないので最初に1次元の配列を宣言し、forループの先頭でまた配列を宣言することで2次元配列を作る正攻法で行きました。C#みたいにもっとスタイリッシュなやり方にさせてくれ。

あとはほぼ変わりません。ほぼ同じような内容の処理を2種作るんだから中の処理を関数にしても良かったかなと思いましたが、改修が面倒なので長々とした関数が2つ書かれた状態になっています。あ、チェックデジットの判定は後から付け足したのでちゃんと関数になってます。

ということでほぼ同じ内容のdaily関数はこんな感じです。

function daily() {
  
  const sheet=SpreadsheetApp.getActiveSheet();
  const lastrow=sheet.getLastRow();
  var targetCell = "A1"
  var range= SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(targetCell);
  var datarow = sheet.getRange(2,1,lastrow-1,2).getValues();
  var arraydata=[];
  for(var i =1; i<lastrow;i++){
    arraydata[i-1] = ['', '', '','','','',''];
    var isbncode = String(datarow[i-1][0]);
    
    if(isbncode == ""){
      Logger.log("end");
      break;
    }
     if(isbncode.slice(0,1)!=9&&isbncode.slice(0,1)!=4){
      arraydata[i-1][0]="not ISBN";
      continue;
     }

    var isbnLen = isbncode.length;
    if(isbnLen != 13 && isbnLen != 10){
      arraydata[i-1][1]="ISBN CODE Error(length?)";
      continue;
    }

    if(isbnLen == 13){
      var judge=isbn13check(isbncode);
      if(judge=="false"){
      range.offset(i,0).setValue("ISBN CODE Error(typo?)"); 
      continue;
      }
    }   

    var response = UrlFetchApp.fetch("https://api.openbd.jp/v1/get?isbn=" + isbncode);
    var resultJson = JSON.parse(response);
    
    if(resultJson[0] != null){
    var authkana=null;
    var cont=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Contributor"]);
    if(cont=="[]"){
    authkana=undefined;
    }
    else{
    authkana=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Contributor"][0,0]["PersonName"]["collationkey"]);
    }
    if(authkana==undefined){
      authkana=null;
    }
    else{
    authkana=authkana.replace(/"/g,'');
    }

    var a=0;
    var labelcheck = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]);
    if(labelcheck==undefined){a=1}
    else{
    var labelcheck2= JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]);
    if(labelcheck2==undefined){a=2};
    }

    var label=null;
    if(a==0){
    label = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Collection"]["TitleDetail"]["TitleElement"][0,0]["TitleText"]["content"] );
    label=label.replace(/"/g,'');
    }

    var c=0;
    var codecheck = JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Subject"]);
    if(codecheck==undefined){c=1};
    var ccode=null;
    if(c==0){
    var ccode=JSON.stringify( resultJson[0]["onix"]["DescriptiveDetail"]["Subject"][0,0]["SubjectCode"]);
    ccode="C"+ccode;
    ccode=ccode.replace(/"/g,'');
    }

    var datedata=range.offset(i,7).getValue();
    if(datedata==""){
    var date = new Date();
    date = Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd")
    arraydata[i-1][6]=date;
    }
    else{
      arraydata[i-1][6]=datedata;
    }
    
      arraydata[i-1][0]=resultJson[0].summary.title;
      arraydata[i-1][1]=resultJson[0].summary.author;
      arraydata[i-1][2]=authkana;
      arraydata[i-1][3]=resultJson[0].summary.publisher;
      arraydata[i-1][4]=label;
      arraydata[i-1][5]=ccode;
      
    }
      
    else{
      arraydata[i-1][0]="not found";
    }
       
  } 

//配列の中身を書き込み
sheet.getRange(2,2,lastrow-1,7).setValues(arraydata);
Logger.log("end"); 
  
}

おしまい

とりあえず現状はこんな感じになっていますが、10桁ISBNのチェックデジット判定(10桁時代の本がそんなに無いので放置している)や、さらなる動作の高速化などまだまだ課題があります。まあとりあえず一つのゴールということで、このへんで書き残しておきます。

スプシが完成した後、登録のためにバーコードリーダーを購入しました。1000冊以上手打ちとか流石に正気の沙汰じゃないので。バーコードリーダーめちゃくちゃ快適です。

ようやっと1600冊までこぎつけましたが、まだ未登録の本が150冊ぐらい残ってるんですよね…気が向いたら登録しようと思います。