タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Google API Client for Ruby でGoogleDriveにアップロード

S3などのオブジェクトストレージと比べて面倒

  • フォルダを作る必要がある
  • 同名のフォルダを複数作成できてしまう
gem 'google-api-client'
require 'googleauth'
require 'google/apis/drive_v3'

FOLDER_ID = "xxxxxxxxxxxxxxxxxxxxxx" # https://drive.google.com/drive/folders/xxxxxxxxxxxxxxxxxxxxxx
UPLOAD_FILE_PATH = "/path/to/file.txt"

service_account_key_json = File.read("path/to/service-account.key.json")
drive_folder_id = FOLDER_ID

authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
  json_key_io: StringIO.new(service_account_key_json),
  scope: 'https://www.googleapis.com/auth/drive'
)
authorizer.fetch_access_token!

drive = Google::Apis::DriveV3::DriveService.new
drive.authorization = authorizer

drive.create_file(
  {
    name: "file.txt",
    parents: [drive_folder_id],
    modifiedTime: File::Stat.new(UPLOAD_FILE_PATH).mtime
  },
  upload_source: UPLOAD_FILE_PATH 
)

任意のフォルダを作成してアップロード

ちゃんとやるなら排他制御なども考える必要がありそうです。

require 'googleauth'
require 'google/apis/drive_v3'

FOLDER_ID = "xxxxxxxxxxxxxxxxxxxxxx" # https://drive.google.com/drive/folders/xxxxxxxxxxxxxxxxxxxxxx
UPLOAD_FILE_PATH = "/path/to/file.txt"

service_account_key_json = File.read("path/to/service-account.key.json")
drive_folder_id = FOLDER_ID

authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
  json_key_io: StringIO.new(service_account_key_json),
  scope: 'https://www.googleapis.com/auth/drive'
)
authorizer.fetch_access_token!

drive = Google::Apis::DriveV3::DriveService.new
drive.authorization = authorizer

# 必要なフォルダがある確認してなければ作成する
folders = "path/to/folder".split("/")
upload_folder_id = folders.each_with_object([drive_folder_id]) { |folder_name, folder_ids|
  folder = drive.list_files(
    q: "mimeType='application/vnd.google-apps.folder' and '#{folder_ids.last}' in parents and name = '#{folder_name}'",
    page_size: 1
  ).files.first
  if folder
    # noop
  else
    folder = drive.create_file({
      name: folder_name,
      mime_type: 'application/vnd.google-apps.folder',
      parents: [folder_ids.last],
    }, fields: 'id')
  end
  folder_ids.push(folder.id)
}.last

drive.create_file(
  {
    name: "file.txt",
    parents: [upload_folder_id],
    modifiedTime: File::Stat.new(UPLOAD_FILE_PATH).mtime
  },
  upload_source: UPLOAD_FILE_PATH 
)

JavaScript (TypeScript) でビデオキャプチャーからの映像を表示したり、スナップショットを取ったりしたい!

前回はJavaScript(TypeScript)で「デスクトップや他のウインドウ」を表示しました。

blog.takeyuweb.co.jp

今回は、USBビデオキャプチャー Mirabox HDMI ビデオキャプチャー HSV321 を買ったので、

f:id:uzuki05:20200612224819p:plain

  • Switch のゲームプレイ画面をブラウザ上に表示
  • 任意のタイミングでスナップショットを撮影して表示

してみました。

サンプル

See the Pen getUserMedia by Yuichi Takeuchi (@takeyuweb) on CodePen.

getUserMedia

USBビデオキャプチャーからの映像は、ブラウザからはWebカメラと同じように扱えます。 従って getUserMedia を使うことで簡単に取得することができます。

developer.mozilla.org

captureStream = await navigator.mediaDevices.getUserMedia({video: { deviceId: getSelectedVideo(), width: 1920, height: 1080 }, audio: {deviceId: getSelectedAudio()}});
const video = document.createElement("video");
video.autoplay = true;
video.srcObject = captureStream;
videoContainer.appendChild(video);

バイスの選択

// 先に許可を貰わないとenumerateDevicesが返すデバイス名(label)を取得できない
await navigator.mediaDevices.getUserMedia({video: true, audio: true});
const devices = await navigator.mediaDevices.enumerateDevices();
devices.forEach((device) => {
  console.log(device.kind + ": " + device.label + " id = " + device.deviceId);
  const option = document.createElement("option");
  option.text = device.label;
  option.value = device.deviceId;

  if (device.kind === "audioinput") {
    audioDeviceSelect.appendChild(option);
  } else if (device.kind === "videoinput") {
    videoDeviceSelect.appendChild(option);
  }
});

const getSelectedVideo = () => {
  return videoDeviceSelect.value;
}

const getSelectedAudio = () => {
  return audioDeviceSelect.value;
}

今回、私はMiraBoxで決め打ちだったので雑に書くとこんな感じになりました。

const deviceLabel = 'MiraBox'; 
let miraVideoId:string;
let miraAudioId:string;

await navigator.mediaDevices.getUserMedia({video: true, audio: true});
const devices = await navigator.mediaDevices.enumerateDevices();
devices.forEach((device) => {
  console.log(device.kind + ": " + device.label + " id = " + device.deviceId);
  if (device.kind === "audioinput") {
    if (device.label.match(deviceLabel)) {
      miraAudioId = device.deviceId;
    }
  } else if (device.kind === "videoinput") {
    if (device.label.match(deviceLabel)) {
      miraVideoId = device.deviceId;
    }
  }
});

JavaScript (TypeScript) で画面全体や他のウインドウのスクリーンショットを取りたい!

Webアプリでデスクトップや他(ブラウザ以外の)ウインドウのスクリーンショットを撮りたい場面がありました。

こういうことです。

f:id:uzuki05:20200611192328g:plain
ブラウザで画面をキャプチャ&ショット

サンプル

See the Pen wvMGvWp by Yuichi Takeuchi (@takeyuweb) on CodePen.

動作確認に使った環境は Windows 10 Pro 2004 + Chromium Edge です。

f:id:uzuki05:20200611193122p:plain
Chromium Edge

TypeScript

2020/6/11現在、TypeScriptの定義が追いついておらずコンパイルできません。

ワークアラウンド

declare global しちゃう

export {};

declare global {
  interface MediaDevices {
    getDisplayMedia(constraints?: MediaStreamConstraints): Promise<MediaStream>;
  }
}

あわせて、型定義を追加

$ npm install --save @types/dom-mediacapture-record
$ npm install --save @types/w3c-image-capture

rails new したときにライブラリのインストールをしない

docker-compose などで動かしたいとき、ホストへライブラリのインストールをなるべくしたくない。(依存パッケージの関係でそもそも pg が入らない、とか)

bundle installrails webpacker:install を避ける

rails new myapp --skip-bundle --database=postgresql --skip-webpack-install 

S3からGoogle Driveに同期する(Lambda Ruby + CDK)

やりたかったこと

  • S3にアップロードされたアイテムをGoogleDriveの共有フォルダに同期する
  • S3のオブジェクト作成イベントでLambdaを実行して処理
  • 対象のバケットやLambda関数などはCDKで作成する

この記事に書いたこと

  • Lambda Ruby で gem を使う方法
  • AWS SDK for Ruby で SecretsManager の秘密情報を取得する方法
  • AWS SDK for Ruby で S3 からダウンロードする方法
  • Google API Client for Ruby で GoogleDrive にアップロードする方法
  • S3 + EventNotification + Lambda でアップロードされたオブジェクトの情報を取得する方法
  • CDK で S3 + EventNotification + Lambda を構成する方法

GoogleDriveにアクセスするための情報を用意する

  • サービスアカウントキー(JSON
  • 共有フォルダのID

こちらの記事が参考になります。

qiita.com

SecretsManager

秘密情報はAmazon SecretsManagerに入れておき、Lambda関数内で取り出して使うことにします。

サービスアカウントキー(JSON
$ aws secretsmanager create-secret --name "GoogleDriveServiceAccountKey" --secret-string file://serviceaccount-1234567890234-123456789012.json
共有フォルダのID
$ aws secretsmanager create-secret --name "GoogleDriveDirectoryID" --secret-string 1AMGFIquotsMPeGz1glPoLS39sB6GBy5j
Lambda関数

コード

次のフォルダ構成で作成します。

  • functions
    • sync_to_google_drive
      • index.rb
      • Gemfile
Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'aws-sdk'
gem 'google-api-client'
index.rb
require "json"
require "aws-sdk-secretsmanager"
require "aws-sdk-s3"
require 'googleauth'
require 'google/apis/drive_v3'

def handler(event:, context:)
  puts "event: #{event.inspect}"
  puts "context: #{context.inspect}"

  secretsmanager = Aws::SecretsManager::Client.new
  service_account_key_json = secretsmanager.get_secret_value(secret_id: "GoogleDriveServiceAccountKey").secret_string
  google_drive_directory_id = secretsmanager.get_secret_value(secret_id: "GoogleDriveDirectoryID").secret_string

  bucket_name = event['Records'][0]['s3']['bucket']['name']
  key = event['Records'][0]['s3']['object']['key']
  local_file = File.join('/tmp', File.basename(key))
  s3 = Aws::S3::Client.new
  s3_obj = s3.get_object(
    response_target: local_file,
    bucket:bucket_name,
    key: key
  )
  puts "s3_obj: #{s3_obj.inspect}"

  authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
    json_key_io: StringIO.new(service_account_key_json),
    scope: 'https://www.googleapis.com/auth/drive'
  )
  authorizer.fetch_access_token!

  drive = Google::Apis::DriveV3::DriveService.new
  drive.authorization = authorizer

  file_object = {
    name: key,
    parents: [google_drive_directory_id],
    modifiedTime: s3_obj.last_modified
  }
  drive.create_file(
    file_object,
    upload_source: local_file
  )

  { statusCode: 200, body: JSON.dump({ok: true}) }
rescue => e
  message = "#{e.class.name} (#{e.message})"
  puts message

  { statusCode: 501, body: JSON.dump({ok: false, error: message}) }
end

bundle install

gemを vendor/bundle 以下にインストールして、本体と一緒にアップロードできるようにしておきます。

$ cd functions/sync_to_google_drive
$ bundle install --path vendor/bundle

CDK

抜粋

import * as iam from "@aws-cdk/aws-iam";
import * as lambda from "@aws-cdk/aws-lambda";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3n from "@aws-cdk/aws-s3-notifications";

const myBucket = new s3.Bucket(this, "myBucket", {
  bucketName: 'my-bucket',
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  versioned: true,
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});
const syncToGoogleDriveFunctionRole = new iam.Role(
  this,
  "syncToGoogleDriveFunctionRole",
  {
    assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
    managedPolicies: [
      iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute"),
    ],
  }
);
syncToGoogleDriveFunctionRole.attachInlinePolicy(
  new iam.Policy(this, "syncToGoogleDriveFunctionRolePolicy", {
    statements: [
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["secretsmanager:GetSecretValue"],
        resources: ["*"],
      }),
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["s3:GetObject"],
        resources: [
          myBucket.arnForObjects("*")
        ],
      }),
    ],
  })
);
const syncToGoogleDriveFunction = new lambda.Function(this, "syncToGoogleDriveFunction", {
  runtime: lambda.Runtime.RUBY_2_5,
  handler: "index.handler",
  code: new lambda.AssetCode("./functions/sync_to_google_drive"),
  role: syncToGoogleDriveFunctionRole,
  environment: {},
  timeout: cdk.Duration.seconds(900),
});
myBucket.addEventNotification(s3.EventType.OBJECT_CREATED, new s3n.LambdaDestination(syncToGoogleDriveFunction));

Ruby で SSLサーバ証明書の内容を確認する

cert_content = <<EOF
-----BEGIN CERTIFICATE-----
dummy
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
dummy
-----END CERTIFICATE-----
EOF

pkey_content = <<EOF
-----BEGIN EC PARAMETERS-----
dummy
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
dummy
-----END EC PRIVATE KEY-----
EOF

cert = OpenSSL::X509::Certificate.new(cert_content)
pkey = OpenSSL::PKey::EC.new(pkey_content)

# 秘密鍵が正しいか?
cert.check_private_key(pkey) # => true

# コモンネームが期待したものか?
cert.subject.to_a.find { |name, _| name == 'CN' }.fetch(1) #=> "*.takeyuweb.co.jp"

docs.ruby-lang.org

docs.ruby-lang.org

Ruby による Amazon Personalize の使い方

AWS機械学習レコメンデーションサービスである Amazon Personalize を案件で利用したのでSDKの使い方メモです。

Amazon Personalize

aws.amazon.com

Amazon Personalize は、機械学習の知識がなくても、簡単にレコメンデーションをアプリに組み込むことができるサービスです。

これまで、AWS機械学習レコメンデーションを行うには、ElasticMapReduce + Mahoutで自前で計算するなど、インフラ構築と保守の手間があったり、モデル設計や評価についての知識が必要だったりとなかなかに大変だったのですが、一般的なレコメンデーションに限っては Amazon Personalize を使うことで、サーバーレスで簡単に実現できるようになりました。

この記事では Amazon Personalize の使い方については解説しません。公式ドキュメントその他を参照してください。

docs.aws.amazon.com

Ruby SDK

github.com

リソースの作成

データセットグループ

DATASET_GROUP_NAME = "my-first-datasetgroup"

personalize = Aws::Personalize::Client.new

dataset_group_arn = personalize.create_dataset_group(name: DATASET_GROUP_NAME).dataset_group_arn

スキーマ

ユーザー、アイテム、インタラクションのどのデータセットタイプなのかににより、適切な形式のスキーマを作成する。

docs.aws.amazon.com

SCHEMA_NAME = "Users"
DATASET_TYPE = "users" # or "items" or "interactions "

personalize = Aws::Personalize::Client.new

avro_schema = case DATASET_TYPE
when "users"
  {
    "type": "record",
    "name": "Users",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "USER_ID",
        "type": "string"
      },
      {
        "name": "AGE",
        "type": "int"
      },
      {
        "name": "GENDER",
        "type": "string"
      }
    ],
    "version": "1.0"
  }
when "items"
  {
    "type": "record",
    "name": "Items",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "ITEM_ID",
        "type": "string"
      },
      {
        "name": "LABEL",
        "type": "string",
        "categorical": true
      },
      {
        "name": "CAST_ID",
        "type": "string",
        "categorical": true
      },
      {
        "name": "DURATION",
        "type": "long"
      },
      {
        "name": "PUBLISH_AT",
        "type": "long"
      }
    ],
    "version": "1.0"
  }
when "interactions"
  {
    "type": "record",
    "name": "Interactions",
    "namespace": "com.amazonaws.personalize.schema",
    "fields": [
      {
        "name": "USER_ID",
        "type": "string"
      },
      {
        "name": "ITEM_ID",
        "type": "string"
      },
      {
        "name": "EVENT_TYPE",
        "type": "string"
      },
      {
        "name": "EVENT_VALUE",
        "type": "float"
      },
      {
        "name": "TIMESTAMP",
        "type": "long"
      }
    ],
    "version": "1.0"
  }
end

schema_arn = personalize.create_schema(
  name: SCHEMA_NAME,
  schema: avro_schema.to_json
).schema_arn

データセット

DATASET_NAME = "users"
DATASET_TYPE = "users" # or "items" or "interactions "
SCHEMA_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:schema/{{SCHEMA_NAME}}"
DATASET_GROUP_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}"

personalize = Aws::Personalize::Client.new

personalize.create_dataset(
  name: DATASET_NAME,
  schema_arn: SCHEMA_ARN,
  dataset_group_arn: DATASET_GROUP_ARN ,
  dataset_type: DATASET_TYPE 
)

学習用データを置くS3バケット

Amazon Personalize からアクセスできるようにバケットポリシーを設定する必要があります。

BUCKET_NAME = "myapp-personalize"

s3 = Aws::S3::Client.new

s3.create_bucket(bucket: S3_BUCKET_NAME)
s3.put_bucket_policy(
  bucket: S3_BUCKET_NAME,
  policy: {
    "Version": "2008-10-17",
    "Id": "PolicyForPersonalizePrivateContent",
    "Statement": [
      {
        "Sid": "PersonalizeS3BucketAccessPolicy",
        "Effect": "Allow",
          "Principal": {
            "Service": "personalize.amazonaws.com"
        },
        "Action": [
          "s3:GetObject",
          "s3:ListBucket"
        ],
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}",
          "arn:aws:s3:::#{S3_BUCKET_NAME}/*"
        ]
      }
    ]
  }.to_json
)

学習用データのインポートに使う実行ロール

AmazonPersonalizeFullAccess のサービスロールを使うのに加え、学習用データを置くS3バケットへのアクセスを許可します。

BUCKET_NAME = "myapp-personalize"
ROLE_NAME = "personalizeExecutionRole"
ROLE_PATH = "/myapp/"

iam = Aws::IAM::Client.new

role = iam.create_role(
  assume_role_policy_document: {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "Service": "personalize.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
      }
    ]
  }.to_json,
  path: ROLE_PATH,
  role_name: ROLE_NAME,
).role
iam.attach_role_policy(
  policy_arn: "arn:aws:iam::aws:policy/service-role/AmazonPersonalizeFullAccess",
  role_name: role.role_name,
)
iam.put_role_policy(
  policy_document: {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": [
          "s3:ListBucket"
        ],
        "Effect": "Allow",
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}"
        ]
      },
      {
        "Action": [
          "s3:GetObject",
          "s3:PutObject"
        ],
        "Effect": "Allow",
        "Resource": [
          "arn:aws:s3:::#{S3_BUCKET_NAME}/*"
        ]
      }
    ]
  }.to_json,
  policy_name: "AllowAccessToS3Bucket",
  role_name: role.role_name,
)

イベントトラッカー

イベントトラッキングを行うには、イベントトラッカーを作る必要があります。 イベントトラッカーを作ると、イベントトラッカーが受け取ったデータを投入するデータセットなども一緒に作られます。

docs.aws.amazon.com

DATASET_GROUP_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}"

personalize = Aws::Personalize::Client.new

event_tracker_arn = personalize.create_event_tracker(
  name: EVENT_TRACKER_NAME,
  dataset_group_arn: DATASET_GROUP_ARN
).event_tracker_arn

学習用データのインポート

ユーザー、アイテム、インタラクションそれぞれ学習させたいデータセットタイプにあわせてCSVを作成します。 アップロードしたCSVを指定したデータセットインポートジョブを作成します。

データセットインポートジョブのパラメータとして、インポート先のデータセット、データセットタイプに対応するスキーマで作成したCSVのS3オブジェクトキー、実行IAMロールを指定します。

# CSV
BUCKET_NAME = "myapp-personalize"
OBJECT_KEY = "/users.csv"
FILE_PATH = "/tmp/users.csv"
# Amazon Personalize リソース
DATASET_ARN = "arn:aws:personalize:{{REGION}}:{{ACCOUNT_ID}}:dataset/{{DATASET_GROUP_NAME}}/{{DATASET_NAME}}" # 作成したデータセット
ROLE_ARN = "arn:aws:iam::{{ACCOUNT_ID}}:role/myapp/{{ROLE_NAME}}" # 作成した実行ロール

s3 = Aws::S3::Client.new

File.open(FILE_PATH, 'r') do |file|
  s3.put_object(
    bucket: BUCKET_NAME,
    key: OBJECT_KEY,
    body: file,
    content_type: 'text/csv'
  )
end

personalize = Aws::Personalize::Client.new

personalize.create_dataset_import_job(
  job_name: "users-import-job-#{Time.current.to_i}",  # 重複はできない
  dataset_arn: DATASET_ARN,
  data_source: {
    data_location: File.join('s3://', BUCKET_NAME , OBJECT_KEY)
  },
  role_arn: ROLE_ARN,
)

リアルタイムイベントトラッキング

EVENT_TRACKER_TRACKING_ID = "0000000-0000-0000-0000-000000000000"
USER_ID = current_user.id
SESSION_ID = session.id
EVENT_TYPE = "rating"  # 今回は★1-5のレビュー評価をトラッキングする想定。自由に決められる。
ITEM_ID = review.item_id
EVENT_VALUE = review.rating
SENT_AT = review.created_at

personalize_events = Aws::PersonalizeEvents::Client.new

payload = {
  tracking_id: EVENT_TRACKER_TRACKING_ID,
  user_id: USER_ID.to_s,
  session_id: SESSION_ID ,
  event_list: [
    {
      event_type: EVENT_TYPE,
      properties: {
        itemId: ITEM_ID.to_s,
        eventValue: EVENT_VALUE,
      }.to_json,
      sent_at: SENT_AT,
     }
  ]
}
personalize_events.put_events(payload)

レコメンデーション結果の取得

アイテムベース (SIMS)も、ユーザーベースも、レコメンデーション結果を取得するAPIは同じです。 パラメータが違います。

アイテムベース (SIMSレシピのソリューションを使ったキャンペーン)

item_id を渡します。

CAMPAIGN_ARN = "arn:aws:personalize:{{REGION}}:{{AWS_ACCOUNT_ID}}:campaign/{{CAMPAIGN_NAME}}"
ITEM_ID = 1234
NUM_RESULTS = 20

personalize_runtime = Aws::PersonalizeRuntime::Client.new

# item_list[0].item_id #=> String
# item_list[0].score #=> 常に 0
item_list = personalize_runtime.get_recommendations(
  campaign_arn: CAMPAIGN_ARN ,
  item_id: ITEM_ID.to_s,
  num_results: NUM_RESULTS,
).item_list

ユーザーベース (HRNN レシピのソリューションを使ったキャンペーン)

user_id を渡します。

結果セットにスコアが含まれるので、おすすめ順に並べるのに使えますね。

CAMPAIGN_ARN = "arn:aws:personalize:{{REGION}}:{{AWS_ACCOUNT_ID}}:campaign/{{CAMPAIGN_NAME}}"
USER_ID = 1234
NUM_RESULTS = 20

personalize_runtime = Aws::PersonalizeRuntime::Client.new

# item_list[0].item_id #=> String
# item_list[0].score #=> Float
item_list = personalize_runtime.get_recommendations(
  campaign_arn: CAMPAIGN_ARN ,
  user_id: USER_ID.to_s,
  num_results: NUM_RESULTS,
).item_list