Puppeteerを使ってDMMの同人ランキングから人気のあるジャンルを列挙してみる

E2Eテストの練習としてPuppeteerで実用的なコードを書いてみた。

やったこと

FANZA同人のランキング1〜100位の作品を取得し、全てのジャンルを計測して 人気ジャンル を出してみた。

www.dmm.co.jp

ソースコード

const puppeteer = require('puppeteer');
let browser;
beforeEach(async () => browser = await puppeteer.launch());
afterEach(async () => await browser.close());

const timeout = 20 * 60 * 1000; // 20 minutes

displayRanking = async (page) => {
  for(let i = 1; i < 5; i++) {
    await page.evaluate(_ => window.scrollBy(0, document.body.scrollHeight));
    await page.waitFor(`.rank-rankListItem:nth-child(${20 * (i + 1)})`);
  }
};

findTags = async (page, urls) => {
  const tagsArray = [];

  for(let i = 0; i < urls.length; i++) {
    console.log(`${i}: parsing... ${urls[i]}`);

    await page.goto(urls[i]);
    const tags = await page.$$eval('.genreTagList__item', divs => divs.map(div => div.innerText));
    tagsArray.push(tags);
  }

  return tagsArray.flat();
};

test('doujin', async () => {
  const page = await browser.newPage();
  await page.goto('https://www.dmm.co.jp/dc/doujin/-/ranking-all/=/sort=popular/term=monthly/');

  const title = await page.$eval('.c_hdg_withSortTitle', el => el.innerText);
  expect(title).toMatch(/総合ランキング 月間/);

  await displayRanking(page);
  const itemsCount = await page.$$eval('.rank-rankListItem', items => items.length);
  expect(itemsCount).toBe(100);

  const urls = await page.$$eval('.rank-name a', links => links.map(link => link.href));
  const tags = await findTags(page, urls);

  const ranks = {};
  tags.forEach((tag) => {
    if (ranks[tag] === undefined) ranks[tag] = 0;
    ranks[tag] += 1;
  });
  ranks['成人向け'] = 0;
  ranks['男性向け'] = 0;
  const topRanks = Object.entries(ranks).sort((a, b) => b[1] - a[1]).slice(0, 20);

  console.log(topRanks);
}, timeout);

無限スクロール

最初は1〜20位までしか表示されていないので、スクロールしつつ waitFor で作品の表示を待つようにする。

displayRanking = async (page) => {
  for(let i = 1; i < 5; i++) {
    await page.evaluate(_ => window.scrollBy(0, document.body.scrollHeight));
    await page.waitFor(`.rank-rankListItem:nth-child(${20 * (i + 1)})`);
  }
};

各ページのタグ取得

タブを生成して、並列に処理しようとしたら TimeoutError: Navigation Timeout Exceeded: 30000ms exceeded が起きて、解決方法が分からなかった。

とりあえず、1ページずつ順番に処理する方法で対応。(時間かかるけど)

findTags = async (page, urls) => {
  const tagsArray = [];

  for(let i = 0; i < urls.length; i++) {
    console.log(`${i}: parsing... ${urls[i]}`);

    await page.goto(urls[i]);
    const tags = await page.$$eval('.genreTagList__item', divs => divs.map(div => div.innerText));
    tagsArray.push(tags);
  }

  return tagsArray.flat();
};

ランキング計測

Rubyのgroup_byみたいなのが見当たらなかったので、それっぽくカウントした。

成人向けと男性向けは全ての作品に入っていたので除外してる。

const ranks = {};
tags.forEach((tag) => {
  if (ranks[tag] === undefined) ranks[tag] = 0;
  ranks[tag] += 1;
});
ranks['成人向け'] = 0;
ranks['男性向け'] = 0;
const topRanks = Object.entries(ranks).sort((a, b) => b[1] - a[1]).slice(0, 20);

結果

こんなジャンルが人気あるみたいですよ。

  console.log test/dmm.test.js:52
    [
      [ '中出し', 77 ],
      [ '巨乳', 63 ],
      [ 'フェラ', 56 ],
      [ '新作', 47 ],
      [ 'おっぱい', 44 ],
      [ '制服', 36 ],
      [ 'パイズリ', 35 ],
      [ '寝取り・寝取られ・NTR', 27 ],
      [ '準新作', 22 ],
      [ '処女', 20 ],
      [ 'アナル', 20 ],
      [ '人妻・主婦', 19 ],
      [ '野外・露出', 18 ],
      [ 'ラブラブ・あまあま', 16 ],
      [ '辱め', 14 ],
      [ 'ぶっかけ', 14 ],
      [ '近親相姦', 13 ],
      [ '和姦', 11 ],
      [ '3P・4P', 11 ],
      [ 'ハーレム', 11 ]
    ]

 PASS  test/dmm.test.js (687.794s)
  ✓ doujin (686607ms)

AWSのmfaが必要なコマンドを簡単に実行する

先日、Terraformで AssumeRole + MFA を簡単にする方法を書きました。

sinsoku.hatenablog.com

ブログを書いたあとにもっと汎用的にできる案を思いついたので、更に改良した。

以下のスクリプト~/bin/mfa のようにパスが通った場所に置いてください。

#!/bin/bash

set -e

# It generates json referring to the processing of `AssumeRoleCredentialFetcher` and `_create_cache_key`.
#
# memo:
#   * https://github.com/boto/botocore/blob/1.12.162/botocore/credentials.py#L611
#   * https://github.com/boto/botocore/blob/1.12.162/botocore/credentials.py#L690-L692
ROLE_ARGS=$(cat - << EOS
{
  "RoleArn": "$(aws configure get role_arn)",
  "SerialNumber": "$(aws configure get mfa_serial)"
}
EOS
)
CREATE_CACHE_KEY=$(cat - << EOS
import sys, os, json;
from hashlib import sha1;
args = json.load(sys.stdin);
hash = sha1(json.dumps(args, sort_keys=True)).hexdigest();
print hash.replace(':', '_').replace(os.path.sep, '_').replace('/', '_');
EOS
)
CACHE_KEY=$(echo -n "$ROLE_ARGS" | python -c "$CREATE_CACHE_KEY")
CACHE_PATH="$HOME/.aws/cli/cache/$CACHE_KEY.json"

if [ -e "$CACHE_PATH" ]; then
  EXPIRATION=$(cat "$CACHE_PATH" | jq --raw-output .Credentials.Expiration)
  EXPIRATION_UNIX=$(date -u -jf %FT%TZ  $EXPIRATION +%s)
  NOW_UNIX=$(date +%s)

  if [ $EXPIRATION_UNIX -lt $NOW_UNIX ]; then
    aws sts get-caller-identity > /dev/null
  fi
else
  aws sts get-caller-identity > /dev/null
fi

export AWS_ACCESS_KEY_ID=$(cat $CACHE_PATH | jq -r .Credentials.AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(cat $CACHE_PATH | jq -r .Credentials.SecretAccessKey)
export AWS_SESSION_TOKEN=$(cat $CACHE_PATH | jq -r .Credentials.SessionToken)

$*

こんな感じで使います。

$ mfa terraform plan

ソースコード

github.com

良さそうな変更あれば、プルリクください。

表参道.rb #47 〜API〜で共有した知見 #omotesandorb

LTする方が少なかったので、自分がやってる知見的な事を紹介するためのブログ。

API ドキュメント

OpenAPIで書いています。

初めて聞いた人向けの説明

Swagger Editorで書くと、 Swagger UIみたいな見た目のドキュメントが作れる。

ファイルの置き場所

#{Rails.root}/doc/api/openapi.yml に1ファイルで置いてあります。

更新するとき

#{Rails.root}/doc/api/docker-compose.yml を作っておきます。

version: "3.7"
services:
  editor:
    image: swaggerapi/swagger-editor
    ports:
      - "8080:8080"
  ui:
    image: swaggerapi/swagger-ui
    ports:
      - "8081:8080"
    volumes:
      - ./openapi.yaml:/usr/share/nginx/html/openapi.yaml
    environment:
      API_URL: openapi.yaml

つまり Swagger Editor を起動し、 http://localhost:8080 上で編集してエクスポートする感じです。

$ cd doc/api
$ docker-compose up -d
$ open http://localhost:8080

ドキュメントの共有

CIでS3にアップロードして、S3 bucketのポリシーでIP制限をかけてます。

以下のようなスクリプトをCIから実行するようにしてある。

#!/bin/sh

set -eu

sudo apt-get update
sudo apt-get install python-pip
sudo pip install awscli

S3_BUCKET="proj-doc"
S3_BUCKET_PATH="${S3_BUCKET}/proj-api"
S3_BASE_URL="https://s3-ap-northeast-1.amazonaws.com/${S3_BUCKET_PATH}"

if [ "$CIRCLE_BRANCH" = "master" ]; then
  aws s3 sync --delete doc s3://${S3_BUCKET_PATH}/doc
else
  aws s3 sync --delete doc s3://${S3_BUCKET_PATH}/tree/${CIRCLE_BRANCH}/doc
  echo ${S3_BASE_URL}/tree/${CIRCLE_BRANCH}/doc/rdoc/index.html
  echo ${S3_BASE_URL}/tree/${CIRCLE_BRANCH}/doc/api/html/index.html
fi

ドキュメントをどうやってメンテするか?

committee-railscommittee を使って、全てのAPIをテストで検証しています。

RSpec.configure do |config|
  # committee-rails
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('doc', 'api', 'openapi.yaml').to_s,
    prefix: '/api'
  }
  config.after(type: :request) do |ex|
    if ex.metadata[:api_validation].nil? && ex.location.include?('api') && !request.nil?
      extend Committee::Rails::Test::Methods
      assert_schema_conform
    end
  end
end

APIのキャッシュ

APIサーバの前段にCloudFrontを置いておき、ユーザーが更新しないリソースは全てキャッシュするように設定してます。

  1. RailsのCache-Controlにprivateが入っているので min_ttl = 0 だと miss hit
  2. headers = ["*"] だと miss hit
  3. cookies の forward = "all" だと miss hit

つまり以下の設定ならキャッシュされる。

default_cache_behavior {
  allowed_methods  = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
  cached_methods   = ["GET", "HEAD"]
  target_origin_id = "${local.api_alb_origin_id}"

  forwarded_values {
    headers      = ["Host", "Authorization"]
    query_string = true

    cookies {
      forward = "none"
    }
  }

  min_ttl                = 3600
  default_ttl            = 3600
  max_ttl                = 86400
  viewer_protocol_policy = "redirect-to-https"
}

URL設計について

CloudFront はパスベースでTTLを変更できるので、キャッシュ時間でURLを変えると楽。

/favorites
/settings/profile
/settings/security

例えば、 /settings/* だけTTLを0にしたりできる。

TerraformでAssumeRole + MFAを簡単に実行する

追記: 以下の記事の方法を使った方が楽かも。

sinsoku.hatenablog.com


Terraformを普通に使うとAssumeRole + MFAが面倒なので、ラッパースクリプトを書いた。

これを ~/bin/mfa_terraform とかに置けば良い。

#!/bin/bash

set -e

# It generates json referring to the processing of `AssumeRoleCredentialFetcher` and `_create_cache_key`.
#
# memo:
#   * https://github.com/boto/botocore/blob/1.12.162/botocore/credentials.py#L611
#   * https://github.com/boto/botocore/blob/1.12.162/botocore/credentials.py#L690-L692
ROLE_ARGS=$(cat - << EOS
{"RoleArn": "$(aws configure get role_arn)", "SerialNumber": "$(aws configure get mfa_serial)"}
EOS
)
CACHE_KEY=$(echo -n "$ROLE_ARGS" | openssl sha1)
CACHE_PATH="$HOME/.aws/cli/cache/$CACHE_KEY.json"

if [ -e "$CACHE_PATH" ]; then
  EXPIRATION=$(cat "$CACHE_PATH" | jq --raw-output .Credentials.Expiration)
  EXPIRATION_UNIX=$(date -u -jf %FT%TZ  $EXPIRATION +%s)
  NOW_UNIX=$(date +%s)

  if [ $EXPIRATION_UNIX -lt $NOW_UNIX ]; then
    aws sts get-caller-identity > /dev/null
  fi
else
  aws sts get-caller-identity > /dev/null
fi

export AWS_ACCESS_KEY_ID=$(cat $CACHE_PATH | jq -r .Credentials.AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(cat $CACHE_PATH | jq -r .Credentials.SecretAccessKey)
export AWS_SESSION_TOKEN=$(cat $CACHE_PATH | jq -r .Credentials.SessionToken)

terraform $*

aws-cliと同じく、AWS_PROFILEの環境変数で簡単に切り替えできます。

$ AWS_PROFILE=foo_stg mfa_terraform plan

OSSはコード読んで頑張れば、頑張れるので便利。

RubyVM::AbstractSyntaxTree.ofをevalやirbで動かすために調べたこと

調査結果を書いただけで、あまり良い解決方法は見つけられていない・・・。

調べたきっかけ

RubyKaigi 2019でRubyVM::AbstractSyntaxTree.ofメソッドを 悪用 活用している方々の発表を聞いて、自分でも試してみようとirbでメソッドを使ったら、無慈悲にもエラーメッセージが表示された。

irb(main):001:0> pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })
Traceback (most recent call last):
        5: from /Users/sinsoku/.rbenv/versions/2.6.2/bin/irb:23:in `<main>'
        4: from /Users/sinsoku/.rbenv/versions/2.6.2/bin/irb:23:in `load'
        3: from /Users/sinsoku/.rbenv/versions/2.6.2/lib/ruby/gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
        2: from (irb):1
        1: from (irb):1:in `of'
Errno::ENOENT (No such file or directory @ rb_sysopen - (irb))

「自分の使い方が悪い・・・?」「いや、空気を読んで動いてくれないRubyが悪い」

という気持ちになったので、ソースコードを読んで直せないか調べてみた。

RubyVM::AbstractSyntaxTree.of のコードの概要

どうやらast.cの中のrb_ast_s_ofの関数が呼ばれているっぽい。

VALUE path, node, lines;
int node_id;
const rb_iseq_t *iseq = NULL;

if (rb_obj_is_proc(body)) {
    iseq = vm_proc_iseq(body);

    if (!rb_obj_is_iseq((VALUE)iseq)) {
        iseq = NULL;
    }
}
else {
    iseq = rb_method_iseq(body);
}

if (!iseq) return Qnil;

path = rb_iseq_path(iseq);
node_id = iseq->body->location.node_id;

コード片(ブロックで渡されたコード)を受け取って、iseqを作って、node_idを取り出している。

if (!NIL_P(lines = script_lines(path))) {
    node = rb_ast_parse_array(lines);
}
else if (RSTRING_LEN(path) == 2 && memcmp(RSTRING_PTR(path), "-e", 2) == 0) {
    node = rb_ast_parse_str(rb_e_script);
}
else {
    node = rb_ast_parse_file(path);
}

return node_find(node, node_id);

これらの分岐は「ソースコード全体をASTにしたnodeを取得する」処理です。 つまり、 ソースコード全体のASTからコード片と同じnode_idのASTを探して返す ような実装になっています。

コード片だけではファイル内の行列数が分からないため、ソースコード全体のASTを一度作る必要があるのかな。

エラーの原因

エラーメッセージの「Errno::ENOENT (No such file or directory @ rb_sysopen - (irb))」で分かるように、 rb_ast_parse_file が例外を出しています。 これを何かしらの方法で避ける必要がありそうです。

SCRIPT_LINES__ を使う処理

ソースコード全体のASTを取得する処理は以下の3つの分岐になっています。

  1. SCRIPT_LINES__ で取得した配列を使う処理
  2. ruby -e に対応するための処理
  3. ソースコードのファイルを読み込む処理

script_lines関数のコードを読むと分かるように、 SCRIPT_LINES__ の定数からソースコードの配列を取得しています。 つまり、evalやirbで渡した文字列をSCRIPT_LINES__に配列として突っ込めば、良い感じに動きそうです。

eval を上書きしてみる

module AbstractSyntaxTreeOfPatch
  REPL_FNAME = ["(eval)", "(irb)"].freeze

  def eval(expr, bind = nil, fname = "(eval)", lineno = 1)
    return super unless REPL_FNAME.include?(fname)

    defined = Object.const_defined?(:SCRIPT_LINES__)
    Object.const_set(:SCRIPT_LINES__, {}) unless defined
    Object::SCRIPT_LINES__[fname] = [expr]

    super.tap do
      if defined
        Object::SCRIPT_LINES__.delete(fname)
      else
        Object.send(:remove_const, :SCRIPT_LINES__)
      end
    end
  end
end
Object.prepend(AbstractSyntaxTreeOfPatch)

# コード内
pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })
# eval
pp eval('RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })')
binding.irb
# irbで `pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })` を試す
puts

こんな感じでevalを上書きすることで、それっぽいASTが返ってくるようになった。

いろんな問題

byebugやpryもevalを使っているだろうし、evalを上書きすれば他も簡単に対応できる....と思っていた。 世の中はそんなに甘くなかった。

良い感じに直せたら、ruby/rubyにプルリクを作れるかも・・・?とか思ったけど、色々な問題が起きるので厳しい感じ。

まとめ

あまり良い成果は出ていないけど、とりあえず最近調べていた事をブログにまとめておいた。
irbやevalでASTを作れると、色々と捗りそうなんだけど難しい。

別解: irbでRubyVM::AbstractSyntaxTree.ofを動かす方法

@hanachin_さんがTracePointを使って動かす方法を書かれているので、興味ある方はそちらを読んでみると良いかと思います。

qiita.com

Rails Developer Meetup 2019で"自己修復的なインフラ"という登壇した #railsdm2019

3/22(金)、3/23(土)に開催されていたRails Developer Meetup 2019に参加し、登壇させて頂きした。

色々と記憶があるうちにブログに書いておく。

スライド

www.slideshare.net

イベントの感想

参加者は250人以上で、登壇者も3トラックx2日ととても多かった。 これだけの規模のイベント運営はとても大変だと思うし、運営スタッフならびにカルパスさん本当にありがとうございました。

登壇者の方がアップロードしていた資料をいくつか読みましたが、自分が知らない知見が色々と書かれていて勉強になります。 まだ全てに目を通せてないので、楽しみにしながら少しずつ読みたいと思う。

他にも感想を書きたいところなのですが、実は2日目の午後からの参加だったので、あまり感想を挙げらない...。すまない。

登壇のふりかえり

いくつかあるので、将来の自分のためにふりかえりをしておく。

😀良かった: Decksetでスライドを作成した

今回のスライドでは初めてDecksetを使いました。

慣れているMarkdown形式で書けるし、シンタックスハイライトにも対応していて、便利でした。 ただ、画像の位置調整には癖があるので、そこは慣れが必要そう。

次のスライドもDecksetを使ってみようと思っている。

😣失敗した: テーマを詰め込みすぎた

スライドの中で、話したい事として以下4つを挙げていました。

  1. AWS IAMの基礎とロール設計
  2. インフラ開発環境の構築
  3. Railsアプリケーションのデプロイ
  4. 自己修復的なインフラという話

2月にテーマを入稿したときには「いっぱい知見あるので、色々と伝えるぞ!」と思って詰め込みました。

そして、スライドを作り始めてから「これ、1つの項目で30分くらい必要だぞ...」みたいになった。

  • 深掘りしすぎると30分に全く収まらない
  • テーマとして挙げたのに触れないのも微妙

と、スライド構成を決めるのに苦戦していました。 もうちょいテーマを絞って、1点突破みたいな内容にするべきだった。

😀良かった: 「参考になった」という感想を頂けた

前述のようにスライド構成に不安があり、しかも部屋が満員という状況での登壇だった。

あまり話すの得意でも無いので説明が分かりづらい点もあったとは思いますが、登壇を聞いた方に何か知見を伝えられたようで良かった。

😣失敗した: AWSの構成図は時間かかる

AWSの構成図を作るのは地味に時間がかかるので、普段のスライド作りよりも少し時間を多く見積もるべきだと思った。

Cacooで図を作って、pngでエクスポートして、Decksetの画面で文章とあわせて確認して....を繰り返していた気がします。

話せなかった内容について

スライドの最後に書いたように、E2Eテストを試したり、自動revertプルリクは近いうちに挑戦してみようと思っている。

ただ、それ以外にもいくつかスライドに含められなかった内容があったりする。

  • Terraformの詳細な話
  • DevSecOps的な話
  • ECS(Fargate) + Railsの詳細

この辺りは別の機会を見つけて、そのうちアウトプットしていきたい。

神戸市の働き方に関する勉強会に参加した #forkwell

Forkwellの中の人からイベントに誘われ、リモートワークや地方の働き方には少し興味あったので参加してみた。

forkwell.connpass.com

トークで印象だった話

話聞いてたときにメモったりしてなかったので、自分の記憶に残っている話題だけ書いておく。

ACALL株式会社

www.acall.jp

  • ACALLのサービス紹介
    • 入退室の自動化は便利そう
    • 導入企業が多くて、順調に成長してる
  • 使っている技術はRailsやVue.js、AWS
  • CTOが唎酒師(ききさけし)
  • ブログ書いてる時に気づいたけど、 freee さんの受付のやつじゃん!
    • 使ったことあった

株式会社職人さんドットコム

www.shokunin-san.com

  • 職人の需要に対して、供給が足りてない
    • ITで作業効率を上げる方針なのすごく良い
  • 職人向けの商品を扱うお店の検索ができる
    • 狙いが尖っていて、ビジネス的には良さそう
  • マネタイズ対象は企業やショップで、職人は無料で使える
    • 広告などが収益源
  • 使っている技術はPHPGCPを使っていたような気がするけど記憶に自信がない)
  • 本社は神戸に置きつつ、東京に開発拠点を作ろうとしている
    • エンジニアを募集中

神戸市

kobeliveandwork.org

  • 神戸の良いところの紹介
  • 山から見える景色の良い写真を紹介しながら「出社前に登ったりできます」
    • いや、出社前は流石に疲れるのでは...と思ったけど、体力ある人なら気持ち良い出社かもしれない
  • 市でスタートアップの支援を色々としてる

住みやすさ、食事の美味しさ、交通の利便性、...など神戸のQOLは高そうという印象を受けた。

交流会

神戸市の美味しい食べ物を頂きつつ、個人的に興味のあった神戸の金銭事情についていくつか聞いた。

  • 神戸市のエンジニアの年収帯
  • 家賃の相場感
  • スタートアップの支援について
  • 社内や神戸のエンジニアの人数に関する話
  • ....など

答えにくい直球なお金の話に、しっかり回答して頂けて個人的には満足した。

全体の感想

普段はなかなか聞けない話をお聞きできて、面白い会でした。 神戸がスタートアップ支援してるの知らなかったし、聞けてよかった。

ただ、企業のトーク時間が短くて、プロダクトの紹介で終わっていたのは少し残念。 東京では出来ない働き方や会社の制度、技術的な取り組みをもう少し聞きたかったなーと。

東京以外の勉強会の少なさによる不安

勉強会で好きな技術に関して話すのは楽しいので、その機会が減りそうなのは少し気になってる。

あと、自分の勉強する機会が減りそうという不安もある。
東京だと勉強会で野生のコミッターにぶつかって、新機能やバグ修正を本人から聞けるイベントに遭遇したりする。 こういうイベントで「アウトプットしよう」とモチベーションが高まったりする。

東京以外だとQOL高いのは魅力的なんだけど、東京を離れるデメリットも結構多くて、まだ東京を離れる選択はできない。