IT関連雑記帳

IT関連の話をつらつらと

(Puppeteer) Kindle for PCの書籍情報をCSV化するスクリプト

github.com

ものすごく久しぶりの更新です。
Kindleで購入した書籍が786冊になりました。安くなっている時にまとめ買いしたマンガが多く、たぶん半分以上読んでいない気がします。

という話はどうでもいいんですが、Kindle for PCの書籍データがxmlファイルとしてPC内部に保存されているという情報を聞きまして、そこからデータを抜き出すスクリプトを作成しました。

同じような感じでCSVファイルを作成するスクリプトをベースに、1時間ぐらいで作ったのでもしかしたら環境によっては上手く動かないかもしれませんが、良かったら使ってみてください。

例によってGitHubにアップロードしてあります。

xmlファイルが格納されている場所はこの辺りにあると思います。スクリプトの上の方で定義していますので、環境に合わせて修正してから実行してください。

C:\Users\username\AppData\Local\Amazon\Kindle\Cache\KindleSyncMetadataCache.xml

とりあえず本日は以上です。

(Puppeteer)内部ビューアで表示されるファイルをダウンロードする方法

サイトからファイルをダウンロードしたかったのですが、リンクをクリックすると内部ビューアでファイルが表示されてしまうんですよね。PDFとか。

Puppeteerには「右クリック ⇒ 名前を付けて保存」みたいな機能はないので、どうしたものかとGoogle先生に聞きまくったら、以下のサイトを教えてくれました。

officeforest.org

やりたいことそのままだったので、本当に助かりました。ありがとうございます。

仕組みなどは上記のサイトに詳しく書いてあるので、そちらをご参照願います。

万が一ですが、参照先のサイトが無くなってしまうような事があると困るので、例によって私のコードも自分メモとして貼っておきます。コードをほぼ完全にコピーしているのはご容赦ください。

変更したのはファイル名をURLから抜き出し、decodeURI()をしているぐらいですかね。

const fs = require('fs');
const requestP = require('request-promise');

(省略)
const targetFile = await (await (await Page_new.$('a[href$=".pdf"]')).getProperty('href')).jsonValue();
await Page_new.click('a[href$=".pdf"]');

await Page_new.setRequestInterception(true);
Page_new.on('request', async (request) => {
	if (request.url().startsWith(targetFile)){

		options = {
			method: request._method,
			uri: request._url,
			body: request._postData,
			headers: request._headers,
			encoding: "binary"
		}

		let cookies = await Page_main[0].cookies();
		options.headers.Cookie = cookies.map(ck => ck.name + '=' + ck.value).join(';');

		requestP(options).then(function (body){
			let targetFilenameIdx = targetFile.lastIndexOf('/');
			let targetFilename = decodeURI(targetFile.substr(targetFilenameIdx + 1));

			fs.writeFileSync(targetFilename, body, "binary", (err) => {console.log(err)})
		}).catch(err => {
			console.log(err);
		})

		request.abort();

	} else {

		request.continue()

	}
});

await Page_new.reload();

ページをリロードした後にファイルが保存されますが、ブラウザでは以下のように「このサイトにアクセスできません」というメッセージが表示されました。最初、プログラムがちゃんと動いていないのではないかと焦ったのですが、本来ブラウザが受け取るべきデータを横取りしているのでこうなるのは仕方が無いのですよね。

f:id:ta9mi3:20210202000024p:plain

Puppeteerでカスタムデータ属性の値を設定する方法

前回は値を取得する方法を書きましたが、値を設定する場合のコードも自分用にメモしておきます。

CSVファイルから読み込んできたデータを変数に格納しておき、その変数を利用して値を設定するつもりでした。ところがスコープが外れてしまうようで、変数名が定義されていないというエラーが発生してしまいました。

Google先生に泣きついて同じような質問を探してもらったところ、まさに知りたいことを見つけたくれたので解決。

HTML(抜粋)
<tr>
    <td>hogehoge</td>
    <td>
        <input id="xxxx" type="text" value="99999999" maxlength="10">
    </td>
    <td>
        <a href="https://example.com" data-test-id="12345678" class="foo">リンク</a>
    </td>
</tr>
Puppeteer
await tdList[2].$eval('a', (e, no) => e.setAttribute('data-test-id', no), newData);

例によってevalの意味をちゃんと理解できていませんが、今回もちゃんと動いたのでヨシ!(絶対よくない

【参考にしたサイト】

github.com

Puppeteerでカスタムデータ属性の値を取得する方法

Puppeteerでdata-*のようなカスタムデータ属性の値を取得するには、以下のようにevaluateを使用します。

このやり方、知っている人には当たり前の話すぎるのか分かりませんが、Google先生に聞いてもほとんど見つかりませんでした。構文をすぐに忘れてしまいそうなので、自分用にメモしておきます。(メモしたことすら忘れるかもしれませんが……)

HTML(抜粋)
<tr>
    <td>hogehoge</td>
    <td>
        <input id="xxxx" type="text" value="99999999" maxlength="10">
    </td>
    <td>
        <a href="https://example.com" data-test-id="12345678" class="foo">リンク</a>
    </td>
</tr>
Puppeteer
let trList = await page.$('tr');
let tdList = await trList.$$('td');
let testId = await (await tdList[2].$('a')).evaluate(node => node.getAttribute('data-test-id'));

evaluateをちゃんと理解できていないし、違う書き方もありそうなんですが、とりあえず動いたのでヨシ(よくない

【参考にしたサイト】

stackoverflow.com

 

直接会ったことが無い人にテスト仕様書を書いてもらった話

これは、ソフトウェアテストの小ネタ Advent Calendar 2020の12日目の記事になります。
qiita.com

■今回のお話

ZoomやSlackで何回かやりとりはしたことがあるものの、直接会ったことはないテスターに4日間でテスト仕様書を書いてもらうという無茶なことをやりました。その時の経験を勝手ながら共有させてください。

■体制

私以外のテストチームのメンバーは次の通りです。
Aさん …… テスター。IT業界は初めてとのこと。
Bさん …… テスター。知見はそこそこありそう。PJへの参画は私たちとほぼ同じ。
Cさん …… 後輩。ものすごく有能。

元々はAさんと別の方(Dさん)がテスターとして活動していたようです。Dさんの離職に伴いBさんが入り、同時にテストチームの体制を立て直そうということで、テストを専門としているCさんと私がプロジェクトに参画することになりました。

AさんとBさんは私たちとは別の会社に所属。ダブルワークをしているとのことで、日中は別の仕事を行い、こちらの仕事は夜に自宅で行うという状況です。

■テスト仕様書を作成することになった経緯

プロジェクトに参画の2週間後のことです。発売中の製品の品質があまり良くなかったので、テストを再実施しようということになりました。

それまで使用していたテスト仕様書に代わり、まずは私の方でテスト仕様書のフォーマット作成を行ってお客様に提供。そのフォーマットを使用してお客様がテスト仕様書を作成する予定だったが、サンプルが欲しいということで、製品の一部の機能について私たちの方でテスト仕様書を作成することになった。

というのが経緯です。

■こんなこともあろうかと(1日目)

私とCさんは現行の製品の不具合改修確認とお客様対応で手一杯になっていたため、手が空いているのはAさんとBさんしかいません。

先ほど書いたように、AさんとBさんはダブルワークでリモートワークという働き方をしています。夜間に仕事をするのですが、私たちの勤務時間は10時から19時。Zoomで説明しようにも30分ぐらい時間を合わせるのが限界という状況のため、意思の疎通を図るのも一苦労です。

というわけで、一から作成するとなると絶望的なオーダーだったのですが、実は(テスト仕様書の作成依頼も来そうだな)と感じていたので、観点と項目の一部は私の方で作ってあったんですよね。

「こんなこともあろうかと」と、私が作成したテスト手順書をAさんとBさんに引継ぎすることにし、Zoomで今回の作業について説明。2日目に一度チェックし、完成の目途をお客様に伝えることにしました。

f:id:ta9mi3:20201211215458p:plain:w200

■テスト仕様書作成中(2日目)

2日目の朝に出来上がりをチェック。観点の追加だけを想定していましたが、試験の前提条件や試験手順などもある程度埋まっていました。「おお、すごい」と思いましたが、前提条件に試験手順が書かれてしまっているなどの問題が見つかりました。テスト手順書の書き方についてはルールを作っておいたのですが、どうやら伝わっていなかったようです(涙

さらに、お客様から「テスト手順書にバリデーションチェックの観点も入れてね」と言われてしまい、項目の追加が発生しました。対象外で調整していたはずなのに……。

f:id:ta9mi3:20201211215732p:plain:w200

昼間は私の方でテスト項目書のレビューと新規項目の追加を行い、テスト手順書ルールを送付。それからバリデーションチェックの観点が必要となったので、新規に項目を作成して欲しい旨をAさんとBさんに連絡。

■直ってないじゃん(3日目)

「前提条件に試験手順が入ったままじゃーん!」

f:id:ta9mi3:20201211214618p:plain:w200

とはいえ連携ミスの原因はこちらにもあるはずなので、試験項目のサンプルをさらに増やし、「これと同じように作ってね」と説明。バリデーションチェックの項目も統一感が無かったので、改めてサンプルを作成しておきました。5日目にお客様に提出するということを考えると、今夜が正念場。

がんばってくだされ(祈り

■おっ、いいね(4日目)

サンプルが良かったのか、ところどころおかしいところはありましたが、前日に比べると格段に良くなりました。お客様に提出する前に私とCさんで最終チェックは行いますが、今夜がテスト仕様書の作成の最終日となります。

項目漏れが無いかどうかの見直しと、必要があれば項目の追加、時間があれば体裁を整えることをメインに作業してもらうことにしました。

■そして提出へ(5日目)

ここでトラブル発生。とある事情によりSlackが使用できなくなり、テスターと連絡を取ることができなくなりました。ありゃ? 昨夜の成果物は……?

ラッキーなことに、たまたまAさんと電話番号の交換をしてありまして、SMSで連絡してファイルを送ってもらうことができました。

その後、私とCさんでもらったファイルの手直しを行い、予定通りお客様に提出することができました。あまりレビューが行えなかったため、テスト仕様書の質は若干低い気もしますが、テスター2名がこの短期間でよく完成まで持って行けたものだと感心しています。

そうそう、AさんとBさんとはZoomで会話をしましたが、基本的に顔出しをしない文化だったようで、プロジェクトが終了した今でもお互いの顔を知らないままとなっています(笑)

■まとめ

今回は短期間、かつリモートでテスト仕様書を作ってもらう必要があるということで、以下の事を意識して仕事を進めました。

  • 考えてもらうことを減らす

 観点やサンプルは事前に用意し、作成者はできるだけ考え込まずに済むようにしました。

  • 仲間として接する、信頼する

 指示を出す者・受ける者、上位職・下位職、会社が違うなど、いろいろな環境や立場がありますが、目的を共有するものとして仲間意識を持つ必要があると思います。
 個人的な話になりますが、ハイキュー!!に出てくる「ネットのこっちっ側にいる全員! もれなく味方なんだよ!!」という言葉が大好きです。

  • 楽しく仕事をする

 Zoomで会話をするときに、楽しく会話することを心掛けました。事務的に進めることもできますが、気持ちよく働いてもらうに越したことはないので。

  • 連絡手段を複数確保する

 Slackに頼り切らず、メール、電話など、複数の連絡手段を持っておくこと。今回はたまたま電話番号を貰っておいたので、テスターにすぐに連絡が取れました。ただし、電話番号は個人情報の取り扱いが絡んでくるため難しいところです。

あとは、Aさん、Bさんの仕事が早かったからというのもあると思います。
むちゃ振りにも関わらず、本当にありがとうございました。

また、つたない文章にも関わらずここまでお読みいただき、ありがとうございます。

とりあえず今回は以上です。

Google Playのランキングをリスト化するスクリプト(Puppeteer)

github.com

とある事情により、Google Playに登録されているアプリのランキングをリスト化する必要が発生しました。

アプリのランキングページは、こんな構成になっています。

  • 無料
  • 人気(有料)アプリ
  • 売上トップのアプリ
  • 無料
  • 人気(有料)ゲーム
  • 売上トップのゲーム

それぞれ200個のアプリが表示されるので、つまり200 x 6で1200アプリをリスト化しなくてはなりません。さすがに手作業でそれを実施していくのは嫌だし、何より間違いの元なのでPuppeteerを使ってスクレイピングを行い、リスト(CSV)を作成するスクリプトを作成しました。

取得するのは以下の4つです。

  • アプリ名
  • 提供元
  • カテゴリー
  • Google Playのアプリのページ(URL)

本当はアプリのアイコンも取得するようにしていたのですが、画像周りは権利やら著作権やらでややこしいことになりそうな気がするので、公開版では外しています。

スクリプトGitHubで公開していますので、必要な方はどうぞ……って、普通は必要にならないか(笑)

出力されたCSVファイルの文字コードUTF-8なので、そのままExcelで開くと文字化けするのでお気をつけて。

あと、自分用に作ったのでエラー処理はほとんど無いです。

スクリーンショットの画像サイズがおかしかった件

Androidの技術書を読み終わったので、5月の末から週末を利用してコツコツとアプリの開発に取り掛かっています。

作りたいのはスクリーンショットを撮影するアプリです。

ボタンを押したらスクリーンショットを撮影して保存するところまで作ったのですが、自分のXperiaで動作を確認したらスクリーンショットのサイズがおかしい……。

プログラムはこんな感じ。長くなりすぎるので、ところどころ省略しています。

DisplayMetrics metrics = new DisplayMetrics();
mWindowManager.getDefaultDisplay().getRealMetrics(metrics);
mScreenDensity = (int) metrics.density;
mDisplayWidth = metrics.widthPixels;
mDisplayHeight = metrics.heightPixels;

mImageReader = ImageReader.newInstance(mDisplayWidth, mDisplayHeight, PixelFormat.RGBA_8888, 2);

mVirtualDisplay = mMediaProjection.createVirtualDisplay("Screenshot",
        mDisplayWidth, mDisplayHeight, mScreenDensity,
        DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
        mImageReader.getSurface(), null, null);


画面のサイズを取得して、ImageReader、createVirtualDisplay()を使用。
この時点で、

mScreenDensity = 3
mDisplayWidth = 1080
mDisplayHeight = 1920

が入っていました。その後、取得したImageを元にcreateBitmap()を実施します。

Image.Plane[] planes = image.getPlanes();
ByteBuffer buffer = planes[0].getBuffer();

int pixelStride = planes[0].getPixelStride();
int rowStride = planes[0].getRowStride();
int rowPadding = rowStride - pixelStride * mDisplayWidth;

// バッファからBitmapを生成する
Bitmap bitmap = Bitmap.createBitmap(mDisplayWidth + rowPadding / pixelStride, mDisplayHeight, Bitmap.Config.ARGB_8888);
bitmap.copyPixelsFromBuffer(buffer);


この時点での値はこんな感じ。

pixelStride = 4
rowStride = 4352
rowPadding = 32

というものが入っていました。この値をセットして画像ファイルに落とし込むと、1088 x 1920の画像サイズとなり、以下のように右側に8バイトのゴミが入っているように見えました。


f:id:ta9mi3:20200719201254j:plain:w320


いまいち理解に苦しむのは、画面中央よりやや右側の画像が表示されているっぽいところ。考えても仕方ないとは思いますが、なんなんでしょうか。

createBitmapの第1引数を以下のように変更し、1080を指定してみました。

// バッファからBitmapを生成する
Bitmap bitmap = Bitmap.createBitmap(mDisplayWidth, mDisplayHeight, Bitmap.Config.ARGB_8888);


そうしたらこんな画像になりました。


f:id:ta9mi3:20200719201859j:plain:w320


スクランブルかよ(笑)


APIの呼び出し方が悪いのではないかと思い、いろいろ試しましたがどうしても直らず。仕方が無いのでImageReaderで取得した画像のうち、paddingと思われるところを除外したバイト列を作成し、それをファイルに落とし込むという対応をしました。


f:id:ta9mi3:20200719202358j:plain:w320


いちおうゴミは無くなり、1080 x 1920のJPEG画像が作成されるようになりましたが、この対応でいいんでしょうか? 誰か原因を知っていたら教えて欲しいです。

ちなみに余計なpaddingを消す処理はこんな感じ。1080バイトを取得し、残りの8バイトを捨て、また1080バイトを取り出してという処理。ARGBなので各サイズは4倍で計算しています。

byte[] tempJPEGByte = new byte[mDisplayWidth * 4];
byte[] tempJPEGByte2 = new byte[buffer.limit()];

// イメージから余計なPaddingを消す
int destPos = 0;
int deleteSize = rowPadding / pixelStride;
long imageSize = buffer.limit();

for (int counter = 0; counter < imageSize;){
    buffer.position(counter);
    buffer.get(tempJPEGByte, 0, mDisplayWidth * 4);
    System.arraycopy(tempJPEGByte, 0, tempJPEGByte2, destPos, mDisplayWidth * 4);
    counter += (mDisplayWidth + deleteSize) * 4;
    destPos += mDisplayWidth * 4;
}

// バッファからBitmapを生成する
Bitmap bitmap = Bitmap.createBitmap(mDisplayWidth, mDisplayHeight, Bitmap.Config.ARGB_8888);
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(tempJPEGByte2));


もっときれいな書き方があるような気がしなくも無いですが、よわよわエンジニアなのでご容赦を。