MENU

【Pythyon】Google Photos API を実行するとUnknownApiNameOrVersionのエラー

google-api-python-clientに関しての内容です。

github.com

一年前に実装したGoogle Photosから写真を取得するプログラムをそのまま放置してしまっており、久しぶりに別環境で動かそうと思ったらエラーが出てしまい動かなくなってしまっておりました。

kittagon.hateblo.jp

正しいやり方ではないのかもしれませんが取り急ぎ解決出来たのでメモとして残しておきます。
(Qiita等でも「間違った情報の記事をアップすると全体の質が下がる。初心者は記事を投稿するな」という意見がありますがこちらは個人用の備忘録ですのであしからず。。)

エラーメッセージ

Traceback (most recent call last):
  File "*******/Develop/google-photos/google_photos.py", line 177, in <module>
    main()
  File "******/google-photos/google_photos.py", line 156, in main
    service = discovery.build(
  File "*******/.pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/_helpers.py", line 134, in positional_wrapper
    return wrapped(*args, **kwargs)
  File "*******/.pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/discovery.py", line 273, in build
    content = _retrieve_discovery_doc(
  File "*******/.pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/discovery.py", line 387, in _retrieve_discovery_doc
    raise UnknownApiNameOrVersion("name: %s  version: %s" % (serviceName, version))
googleapiclient.errors.UnknownApiNameOrVersion: name: photoslibrary  version: v1

上記のようなエラーが出てしまい、APIが存在しないと言われてしまっております。
取り敢えず、エラーが出ているところを見てみます。

当該ソースコード

ライブラリのパスはお使いの環境によって変わると思いますが、おおよそ以下のような箇所でエラーが出ているようです。

# .pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/_helpers.py
    def positional_decorator(wrapped):
        @functools.wraps(wrapped)
        def positional_wrapper(*args, **kwargs):
            if len(args) > max_positional_args:
                plural_s = ""
                if max_positional_args != 1:
                    plural_s = "s"
                message = (
                    "{function}() takes at most {args_max} positional "
                    "argument{plural} ({args_given} given)".format(
                        function=wrapped.__name__,
                        args_max=max_positional_args,
                        args_given=len(args),
                        plural=plural_s,
                    )
                )
                if positional_parameters_enforcement == POSITIONAL_EXCEPTION:
                    raise TypeError(message)
                elif positional_parameters_enforcement == POSITIONAL_WARNING:
                    logger.warning(message)
            return wrapped(*args, **kwargs)

        return positional_wrapper

    if isinstance(max_positional_args, six.integer_types):
        return positional_decorator
    else:
        args, _, _, defaults = inspect.getargspec(max_positional_args)
        return positional(len(args) - len(defaults))(max_positional_args)
# .pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/discovery.py", line 273, in build
    params = {"api": serviceName, "apiVersion": version}

    if http is None:
        discovery_http = build_http()
    else:
        discovery_http = http

    service = None

    for discovery_url in _discovery_service_uri_options(discoveryServiceUrl, version):
        requested_url = uritemplate.expand(discovery_url, params)

        try:
            content = _retrieve_discovery_doc(
                requested_url,
                discovery_http,
                cache_discovery,
                serviceName,
                version,
                cache,
                developerKey,
                num_retries=num_retries,
                static_discovery=static_discovery,
            )
            service = build_from_document(
                content,
                base=discovery_url,
                http=http,
                developerKey=developerKey,
                model=model,
                requestBuilder=requestBuilder,
                credentials=credentials,
                client_options=client_options,
                adc_cert_path=adc_cert_path,
                adc_key_path=adc_key_path,
            )
            break  # exit if a service was created
        except HttpError as e:
            if e.resp.status == http_client.NOT_FOUND:
                continue
            else:
                raise e

    # If discovery_http was created by this function, we are done with it
    # and can safely close it
    if http is None:
        discovery_http.close()

    if service is None:
        raise UnknownApiNameOrVersion("name: %s  version: %s" % (serviceName, version))
    else:
        return service
# .pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/discovery.py", line 387, in _retrieve_discovery_doc
    if cache_discovery:
        if cache is None:
            cache = discovery_cache.autodetect()
        if cache:
            content = cache.get(url)
            if content:
                return content

    # When `static_discovery=True`, use static discovery artifacts included
    # with the library
    if static_discovery:
        content = discovery_cache.get_static_doc(serviceName, version)
        if content:
            return content
        else:
            raise UnknownApiNameOrVersion("name: %s  version: %s" % (serviceName, version))

どうやらGoogle APIに接続するためのServiceを生成しようと、discovery.pyの中のbuildメソッドを実行し、その中で更に、discovery_cache/__init__ .py_retrieve_discovery_docを呼び出しているが、contentnullのためraise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName, version))しているようです。
ではなぜcontentnullなのかというと、本来discovery_cache.get_static_doc(serviceName, version)で取得できるはずなのですが、取得出来ていないようです。
そのメソッドを追ってみると以下のような実装になっておりました。

# .pyenv/versions/google-photos/lib/python3.9/site-packages/googleapiclient/discovery_cache/__init__ .py
def get_static_doc(serviceName, version):
    """Retrieves the discovery document from the directory defined in
    DISCOVERY_DOC_DIR corresponding to the serviceName and version provided.

    Args:
        serviceName: string, name of the service.
        version: string, the version of the service.

    Returns:
        A string containing the contents of the JSON discovery document,
        otherwise None if the JSON discovery document was not found.
    """

    content = None
    doc_name = "{}.{}.json".format(serviceName, version)

    try:
        with open(os.path.join(DISCOVERY_DOC_DIR, doc_name), 'r') as f:
            content = f.read()
    except FileNotFoundError:
        # File does not exist. Nothing to do here.
        pass

    return content

DISCOVERY_DOC_DIR配下のdoc_name = "{}.{}.json".format(serviceName, version)ファイル(今回はphotoslibrary.v1.json)を開きに行っているようですが、ファイルが見つからないようです。
当該ディレクトリを見てみると、以下のように様々なAPI向けのJSONファイルが配置してあるのですが、確かにphotoslibrary.v1.jsonが無いようです。

document配下 document配下

google-api-python-clientリポジトリを見てみるとこのdocumentディレクトリにstatic filesが配置されそこからserviceオブジェクトを生成する方式になったのは昨年2020年の11月のようなので、そこから上記のようなエラーが出るようになってしまっているものと思われます。

github.com

他の方法で取得するやり方も不明なので、取り敢えずphotoslibrary.v1.jsonを無理くりで作っちゃうことにしました。
他のサービスのjsongmail.v1.json)などを見てみると、oauth用のためのscopeやエンドポイントなどそのサービスのAPIに関する情報がすべてまとめてあるJSONファイルだったので、その情報を集めていたとこと、GoogleCloudPlatformのリポジトリにほぼ求めていたまんまのjsonがありました。

github.com

中身をざーっと見た感じでも、フォーマットも合っており必要な情報も揃ってそうだったので、乱暴にもそれをそのままリネームして当該ディレクトリに配置してみたところうまく動くようになりました。
↓こんな感じのjsonです。

// *******/.pyenv/versions/3.9.0/envs/google-photos/lib/python3.9/site-packages/googleapiclient/discovery_cache/documents/photoslibrary.v1.json
{
    "auth": {
      "oauth2": {
        "scopes": {
          "https://www.googleapis.com/auth/drive.photos.readonly": {
            "description": "View the photos, videos and albums in your Google Photos"
          },
          "https://www.googleapis.com/auth/photoslibrary": {
            "description": "View and manage your Google Photos library"
          },
          "https://www.googleapis.com/auth/photoslibrary.appendonly": {
            "description": "Add to your Google Photos library"
          },
          "https://www.googleapis.com/auth/photoslibrary.readonly": {
            "description": "View your Google Photos library"
          },
          "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": {
            "description": "Manage photos added by this app"
          },
          "https://www.googleapis.com/auth/photoslibrary.sharing": {
            "description": "Manage and add to shared albums on your behalf"
          }
        }
      }
    },
    "basePath": "",
    "baseUrl": "https://photoslibrary.googleapis.com/",
    "batchPath": "batch",
    "canonicalName": "Photos Library",
    "description": "Manage photos, videos, and albums in Google Photos\n",
    "discoveryVersion": "v1",
    "documentationLink": "https://developers.google.com/photos/",

  ~中略~~

    "servicePath": "",
    "title": "Photos Library API",
    "version": "v1",
    "version_module": true
}

その後も色々と調査してみたのですが、結局正式にはどのようにgoogle photos用のサービスオブジェクトを生成すればよいかわからなかったのですが、go言語のAPIクライアントの方にもgoogle photosに関する定義ファイルは存在していないようなので、何か意図があってのことなのかもしれません。
取り敢えず今回はこれで動くようになったのでしばらくはこのまま運用しようと思います。

【Golang】Google API 複数scopeのクレデンシャルを取得

先日1年ほど前に書いたGoのソースを眺めていたら、Google APIのクレデンシャル取得のところをどうしてこういう実装にしたのか忘れてしまい、戸惑ったので備忘として残しておきます。

Gmail API Go Quickstart に掲載されている実装の

        // If modifying these scopes, delete your previously saved token.json.
        config, err := google.ConfigFromJSON(b, gmail.GmailReadonlyScope)

の箇所が、ここではGmailのReadOnlyのScopeでconfigを取得していますが、これが複数サービス(たとえはGoogle Calender、Google Tasks)への権限も含めて取得したいとなった場合、このConfigFromJSONは使えず、またscopeを複数配列で渡せるようなメソッドもなかった(見つけられなかっただけかもしれません。。) ので、自分で実装を変更してみた、という内容です。

方法

↑の実装ではConfigFromJSONで取得するはずだった、Credentials構造体のscopeの箇所だけ自分で代入した、というだけです。

type Credentials struct {
    Installed struct {
        ClientId                string   `json:"client_id"`
        ProjectId               string   `json:"project_id"`
        AuthUrl                 string   `json:"auth_url"`
        TokenUrl                string   `json:"token_url"`
        AuthProviderX509CertUrl string   `json:"auth_provider_x509_cert_url"`
        ClientSecret            string   `json:"client_secret"`
        RdirectUris             []string `json:"redirect_uris"`
    } `json:"installed"`
}

func getConfig() *oauth2.Config {
    b, err := ioutil.ReadFile("credentials.json")
    if err != nil {
        log.Fatalf("Unable to read client secret file: %v", err)
    }

    var cred Credentials
    json.Unmarshal(b, &cred)
    // tasks.TasksReadonlyScope → tasks.TasksScope
    scopes := []string{gmail.GmailReadonlyScope, tasks.TasksScope, calendar.CalendarReadonlyScope}
    return &oauth2.Config{
        ClientID:     cred.Installed.ClientId,
        ClientSecret: cred.Installed.ClientSecret,
        Endpoint:     google.Endpoint,
        Scopes:       scopes,
        RedirectURL:  cred.Installed.RdirectUris[0],
    }
}

それ以外のToken取得などの処理はチュートリアルのままでOKです。
全体としては以下のような実装になります。

type Credentials struct {
    Installed struct {
        ClientId                string   `json:"client_id"`
        ProjectId               string   `json:"project_id"`
        AuthUrl                 string   `json:"auth_url"`
        TokenUrl                string   `json:"token_url"`
        AuthProviderX509CertUrl string   `json:"auth_provider_x509_cert_url"`
        ClientSecret            string   `json:"client_secret"`
        RdirectUris             []string `json:"redirect_uris"`
    } `json:"installed"`
}

// Retrieve a token, saves the token, then returns the generated client.
func getClient(config *oauth2.Config) *http.Client {
    // The file token.json stores the user's access and refresh tokens, and is
    // created automatically when the authorization flow completes for the first
    // time.
    tokFile := "token.json"
    tok, err := tokenFromFile(tokFile)
    if err != nil {
        tok = getTokenFromWeb(config)
        saveToken(tokFile, tok)
    }
    return config.Client(context.Background(), tok)
}

// Request a token from the web, then returns the retrieved token.
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
    authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    fmt.Printf("Go to the following link in your browser then type the "+
        "authorization code: \n%v\n", authURL)

    var authCode string
    if _, err := fmt.Scan(&authCode); err != nil {
        log.Fatalf("Unable to read authorization code: %v", err)
    }

    tok, err := config.Exchange(context.TODO(), authCode)
    if err != nil {
        log.Fatalf("Unable to retrieve token from web: %v", err)
    }
    return tok
}

// Retrieves a token from a local file.
func tokenFromFile(file string) (*oauth2.Token, error) {
    f, err := os.Open(file)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    tok := &oauth2.Token{}
    err = json.NewDecoder(f).Decode(tok)
    return tok, err
}

// Saves a token to a file path.
func saveToken(path string, token *oauth2.Token) {
    fmt.Printf("Saving credential file to: %s\n", path)
    f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
    if err != nil {
        log.Fatalf("Unable to cache oauth token: %v", err)
    }
    defer f.Close()
    json.NewEncoder(f).Encode(token)
}

func getConfig() *oauth2.Config {
    b, err := ioutil.ReadFile("credentials.json")
    if err != nil {
        log.Fatalf("Unable to read client secret file: %v", err)
    }

    var cred Credentials
    json.Unmarshal(b, &cred)
    // tasks.TasksReadonlyScope → tasks.TasksScope
    scopes := []string{gmail.GmailReadonlyScope, tasks.TasksScope, calendar.CalendarReadonlyScope}
    return &oauth2.Config{
        ClientID:     cred.Installed.ClientId,
        ClientSecret: cred.Installed.ClientSecret,
        Endpoint:     google.Endpoint,
        Scopes:       scopes,
        RedirectURL:  cred.Installed.RdirectUris[0],
    }
}

func main() {
    config := getConfig()
    client := getClient(config)
    srv, err := gmail.New(client)
        //
        // gmail取得処理
        //
    srv, err := calendar.New(client)
        //
        // calendar取得処理
        //
    srv, err :=tasks.New(client)
        //
        //task取得処理
        //
}

こんな感じで一つのトークン、Configで複数サービスへの接続ができるようになります。
もちろん一つのクレデンシャルに権限を与えすぎるのもリスクはありますが自分で管理するアプリケーションの中で接続サービスごとにトークンを分けるのも大変なので、自分はこうしてしまっています。

【Golang】Gmail API メール本文のデコード

以前できなくて悩んでいた所が最近再度見てみたらサクッと解決できたのでメモ。

結論

base64パッケージのStdEncodingではなくURLEncodingを使えばOK。
以前はStdEncodingを使って、うまくできない、途中で途切れてしまう〜と悩んでいました。
以下の感じで取れます。

message, err := srv.Users.Messages.Get(user, m.Id).Format("full").Do()
message, err := srv.Users.Messages.Get(user, m.Id).Format("full").Do()
if err != nil {
    log.Fatalf("Unable to retrieve messages: %v", err)
}
body := message.Payload.Body
data := body.Data
decodedMessage, _ := b64.URLEncoding.DecodeString(data)

GmailAPIの仕様

については今回は割愛しますが、自分は主にusers.messages.listメソッドにクエリーを投げてmessageIdを取得し、取得したmessageIdusers.messages.listへのリクエストに投げてメール本文を取得、という感じでつかってます。

developers.google.com

developers.google.com

取得したメールのオブジェクトの構造は以下URLの通りなので、この通りbody.dataまでを辿って上記の通りデコードすれば本文が取得出来ます。

developers.google.com

スティード400を復活 〜プラグ交換編〜

これまでにキャブの洗浄とサスペンション交換、オイル、エレメント交換を実施しました。
コロナ禍でなかなか地元に帰ることができず時間が立ってしまいましたが、先日久しぶりに作業できたのでまとめておきます。

オイル交換してからすでに一年が経過してしまっているので結局乗り出す前には再度交換しないといけませんが。。。
作業する順序を少し考えないと。。

kittagon.hateblo.jp

kittagon.hateblo.jp

前回エンジンがまたかからなくなってしまっておりプラグがダメになっている可能性があったので、今回交換してみました。

プラグの場所

ティードにはVツインエンジンの前後それぞれ左右に一つづつ、計4つのプラグがあります。

左前
左前

左後ろ
左後ろ

右前
右前

右後ろ
右後ろ

見ての通り、左前と右後ろの位置のプラグはなかなかエグい位置にプラグが刺さっておりかなり作業しづらいので、少し工夫が必要です。

右前と左後ろの位置

この位置は簡単です。
こんな感じでプラグカバーを外せばプラグがむき出しになるので、プラグレンチで外して交換するだけです。

右前プラグカバー外したところ
右前プラグカバー外したところ

適合するプラグは Webike とかで検索してみるとわかります。

今回はこれを購入しました。

また、プラグの対辺寸法は18mmなのでプラグレンチはこちらを購入しました。
純正の車載工具があるとプラグ交換がとてもやりやすいらしいのですが、自分は持っていなかったのでこうした社外品で頑張りました。

スティードのプラグは深い位置にあるのでこういった長めのものがあると回しやすいです。
でも左前と右後ろはそれでもやりづらいですが。。

このプラグをプラグレンチで外すと簡単に取れます。

右前プラグ外すところ
右前プラグ外すところ

新しいものと比較すると汚れが一目瞭然。。。

新旧プラグ比較
新旧プラグ比較

中に埃が入らないよう素早く新しいものを取り付けます。

右前新プラグ
右前新プラグ

プラグカバーをもとに戻したら完了です。

右前プラグカバー取り付け
右前プラグカバー取り付け

左後ろの位置も比較的やりやすい場所にあるので同様に外して新しいものを取り付ければ簡単に交換できます。

左前と右後ろの位置

右後ろのプラグカバーは、繋がっているケーブルがプラスチックの留め具で固定されているので、それを外してから外す感じになります。

右後ろプラスチック留め具
右後ろプラスチック留め具

マイナスドライバーなどで簡単に外れます。

右後ろプラスチック留め具外したところ
右後ろプラスチック留め具外したところ

左前と右後ろの位置かなり作業がしずらいところにあるので、プラグレンチだけでは回せません。
このようにプラグレンチを挿しても穴にすっぽり収まってしまい長さが足りず回すことができません。

左前プラグレンチ指したところ
左前プラグレンチ挿したところ

そこで今回は六角レンチをジョイントして回すことにしました。
使っているプラグレンチの片側が16mmのため、16mmの六角レンチを購入しました。

これをプラグレンチに挿せば狭い場所でも回すことができます。

六角レンチ挿したところ
六角レンチ挿したところ

左前六角レンチ挿したところ
左前六角レンチ挿したところ

右後ろ六角レンチ挿したところ
右後ろ六角レンチ挿したところ

これでレンチで回すことはできるようになるのですが、穴が深いので緩めたあとの回収と取り付けも気をつけないと大変です。

緩めたあとは↓のようにラジオペンチを突っ込んで挟めれば取れます。
(素人なのでわからないのですがこういうのを回収できるような磁石がついた工具とかあるもんなんですかね・・・?)

右後ろラジペンでプラグ回収
右後ろラジペンでプラグ回収

ただ、ここでラジペンでしっかり挟めていない状態でプラグが外れてしまうと下のように空洞?になっているところにプラグが落ちてしまい取るのが大変になってしまいます。

右後ろプラグ落ちてしまったところ
右後ろプラグ落ちてしまったところ

なので、プラグレンチで緩めきったあとは下に紙を挟み込んでおいて、落ちないようにしておいてからプラグレンチを引き抜き、プラグ回収をしたほうがやりやすいです。

左前プラグレンチの下に紙
左前プラグレンチの下に紙

右後ろプラグの下に紙
右後ろプラグの下に紙
(写真の場所がそれぞれ違うところを写したものになってますが)

こうするとうまくプラグが回収できます。

プラグを取り付ける際も、穴が深いので、プラグを取り付けてからレンチで締めるということができないので、プラグをレンチにはめた状態で取り付けるとうまくできます。

左前プラグ取り付け
左前プラグ取り付け

こうして4箇所プラグ交換をすれば完了です。

終わりに

今回始めてプラグ交換を実施してみました。
かなり難しい位置にあるプラグなので苦労しましたが、ちょうどよい道具が見つけられたのでなんとか交換できました。
肝心なエンジンがかかるかどうかの確認はできていないのでまたの機会に確認しようと思います。
また、このときスプロケ、チェーン交換もやってみた(結局できなかった。。)のでそちらも後日まとめたいと思います。

Golang + Gin + Heroku でLineBotを作ってみた

はじめに

コロナ禍大変になってきてますね。
最近かなり暖かくもなってきたので、誰にも迷惑をかけず感染リスクも侵さない娯楽としてバイクにめちゃくちゃ乗りたくなってきたのですが、
残念ながらまだかつての愛車も修理が終わっておらず、新たに購入する資金力も無いため、家族共々自宅に籠もっております。
ふと思い立ってGoを勉強してみたいと思い、勉強し始めました。特に理由はありません。
基本的な文法を学ぶ必要がありますが、モチベーション維持のためにはやはり何か作りながらのほうがいい。
しかし特に作りたいものも無いしどうしたものか。。

現在、妻へ連絡する色々を自動化するためにLineBotを使っているのですが、
バックエンドは昔PHPで実装したものをそのまま継続的に使用しており(学生時代PHPしか書けなかったので。。。)
最近個人的にPHPを使うことも少なくなってきたので、Goで書き直そうとやってみました。
取り合えずSDKとGoのWebフレームワークであるGinを使ってオウム返答するところまでを動かします。

Goの環境をDockerで構築

Flask-AskでAlexaのカスタムスキルを作成 - 雑食へいちゃんの思い出達

この記事でやったことと同じです。
まずはこんなalpineベースのgolangイメージをベースにDockerfileを作成します。

FROM golang:1.14.2-alpine3.11

WORKDIR /go
RUN apk update && \
    apk add vim git

以下コマンドでコンテナの中に入ります。

$ docker build . -t go-env
$ docker run -it -v $PWD:/go -p 8080:8080 go-env /bin/ash

コンテナに入ったらGinをインストールします。

$ go get github.com/gin-gonic/gin

まずは、サンプルのmain.goを書いて動作を確認します。

package main

import (
  "net/http"
  "github.com/gin-gonic/gin"
)
func main() {
  router := gin.Default()
  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })
  router.Run(":8080")
}
$ go run main.go

ブラウザからlocalhost:8080/helloにアクセスして「Hello World!!」と表示されれば成功です。

次はこれをHerokuにデプロイしていきます。
Dockerのコンテナに作った環境をherokuにデプロイしていくので、
Dockerfileを以下の通り書き換えます。

FROM golang:1.14.2-alpine3.11

WORKDIR /go
ADD . /go
RUN apk update && \
    apk add git vim
CMD go run main.go

また、main.goで立ち上げるポートも8080ではなく、環境変数を使用するように変更します。

package main

import (
  "os"
  "net/http"
  "github.com/gin-gonic/gin"
)
func main() {
  bot, err := linebot.New(
    os.Getenv("LINEBOT_CHANNEL_SECRET"),
    os.Getenv("LINEBOT_CHANNEL_TOKEN"),
  )
  if err != nil {
    log.Fatal(err)
  }
  router := gin.Default()

  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })
  router.Run(":" + os.Getenv("PORT")) // ここを環境変数を参照するように変更
}

ローカル環境のDockerコンテナ内でGinサーバを立ち上げる際は、以下コマンドで環境変数を追加する必要があります。

$ export PORT=8080

続いてHerokuにデプロイしていきます。
デプロイの方法は先程同様↓の記事を参照ください。

Flask-AskでAlexaのカスタムスキルを作成 - 雑食へいちゃんの思い出達

HerokuのURLにアクセスし、localhostと同様の表示が出れば成功です。

オウム返しするLineBotを作成する

Line Messaging APIにはGolang用のSDKが提供されているので、それを使用します。

github.com

$ go get github.com/line/line-bot-sdk-go/linebot

main.goを以下のように修正します。
基本すべて公式に掲載されているサンプルですが、Ginに合わせて一部修正しています。

line-bot-sdk-go/server.go at master · line/line-bot-sdk-go · GitHub

package main

import (
  "os"
  "fmt"
  "log"
  "net/http"
  "github.com/gin-gonic/gin"
  "github.com/line/line-bot-sdk-go/linebot"
)
func main() {
  bot, err := linebot.New(
    os.Getenv("LINEBOT_CHANNEL_SECRET"),
    os.Getenv("LINEBOT_CHANNEL_TOKEN"),
  )
  if err != nil {
    log.Fatal(err)
  }
  router := gin.Default()

  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })

  router.POST("/callback", func(c *gin.Context) {
    events, err := bot.ParseRequest(c.Request)
    if err != nil {
      if err == linebot.ErrInvalidSignature {
        c.Writer.WriteHeader(400)
      } else {
        c.Writer.WriteHeader(500)
      }
      return
    }
    for _, event := range events {
      if event.Type == linebot.EventTypeMessage {
        switch message := event.Message.(type) {
          case *linebot.TextMessage:
            if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(message.Text)).Do(); err != nil {
              log.Print(err)
            }
          case *linebot.StickerMessage:
            replyMessage := fmt.Sprintf(
              "sticker id is %s, stickerResourceType is %s", message.StickerID, message.StickerResourceType)
            if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(replyMessage)).Do(); err != nil {
              log.Print(err)
            }
        }
      }
    }
  })
  router.Run(":" + os.Getenv("PORT"))
}

これをHerokuにデプロイし、LINEBOT_CHANNEL_SECRETLINEBOT_CHANNEL_TOKEN環境変数として設定し、Line Developersサイトでcallback URLにHerokuのサイトURLを設定すれば完了です。

終わり

とりあえず動くところまではできたので、文法についても勉強しながら、現行で動いている機能を移植していきたいと思います。

【Python】Google Photos API でアルバムの画像取得

はじめに

我が家ではこどもたちの写真を撮影月ごとにGoogle Photosの共有アルバムにまとめて両親に共有しています。
これらの写真をAPIで取得して、フォトフレームなど使えるといいなーと思っていましたが、以前はGoogle Photos のAPIは整備されておらず、Picasa APIのみでした。
Picasa自体も既にサービス終了しており、APIもあまりメンテナンスされている雰囲気でもなくいつか終わりそうな気がしたので、こっちを使うことは避けてました。)
気が付くと2018年にGoogle Photos APIがリリースされていたので、これを使っての写真取得を試してみたいと思います。
最終的には、年月を指定すると、その月の共有アルバムに含められている写真を取得する、というプログラムにしたいと思います。

公式リファレンスはこちらです

developers.google.com

Credentialを取得

まず初めに、APIを使用するのに必要なCredentialを取得します。

Google Photos APを使うにはOAuthでのユーザー認証が必要になります。
OAuthの認証にはPythonのクライアントライブラリが提供されているのでそちらを使います。

github.com

上記の公式にも下記の記述がある通りGoogle Photos APIでは、Service Account方式は使えないようです。

Note that the Library API does not support service accounts; to use this API, users must be signed in to a valid Google Account.

なので、OAuth 2.0 for Web Server Applicationsか、OAuth 2.0 for Installed Applicationsのどちらかをつかうのですが、今回はWeb Serverを立てるわけではないので、Installed Applicationsタイプで実装しました。

github.com

client_secretの取得

まずはOAuthの認証に必要なclient secretを取得します。 Google API Consoleにアクセス。

console.developers.google.com

f:id:ti_taka:20200403225259p:plain 左上の「APIとサービス」より「認証情報」を選択

f:id:ti_taka:20210126004742p:plain 「+認証情報を作成」をクリック

f:id:ti_taka:20200403225337p:plain

f:id:ti_taka:20210126004911p:plain

f:id:ti_taka:20210126005021p:plain

これでダウンロードよりclient_secret.jsonをダウンロードします。

credentialの取得

ダウンロードしたclient_secret.jsonを読み取り、credentialを取得します。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'

flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)        
credentials = flow.run_console()

上記プログラムを実行すると、

$ python google_photos.py
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={$cliens_id}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fphotoslibrary&state={$state}&prompt=consent&access_type=offline
Enter the authorization code:

のように、URLが出力されるので、上記URLをブラウザに入力すると以下の画面になるので、Googleアカウントでログインしてください。
ログイン後は、以下のように認可コードが払い出されるので、コピーしてターミナルに貼り付けます。
f:id:ti_taka:20210126005208p:plain

上記でCredentialは取得出来るのですが、その中にはtokenやRefresh tokenも含まれており、その後はRefresh tokenを使用してCredentialを取得することが出来るようになるため、毎回ログインする必要がなくなります。
しかし、現在のgoogle_auth_oauthlibまだcredentialの保存をサポートしていないので、credentialをそのままjsonのファイルで保存して、今後はRefresh Tokenを使用してcredentialを取得するようにします。

github.com

google-auth-oauthlib does not currently have support for credentials storage. It may be added in the future. See oauth2client deprecation for more details.

credentialの中にはdatetimeオブジェクトが入っているので、↓のコードを頂戴してcredentialをシリアライズして保存しておきます。

qiita.com

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))

こうすることで、一度credentialを取得したあとは、ログインをし直す必要なく、Refresh Tokenでcredentialの取得が出来るようになります。

if os.path.exists(CREDENTIAL_FILE):
  with open(CREDENTIAL_FILE) as f_credential_r:
    credentials_json = json.loads(f_credential_r.read())
    credentials = google.oauth2.credentials.Credentials(
      credentials_json['token'],
      refresh_token=credentials_json['_refresh_token'],
      token_uri=credentials_json['_token_uri'],
      client_id=credentials_json['_client_id'],
      client_secret=credentials_json['_client_secret']
    )

credentialを取得後は以下のようにServiceを取得します。

service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
)

今後このserviceで各種APIを叩いていきます。
これまでのコードの全体像は以下のようになります。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os
from datetime import datetime,date
from googleapiclient.discovery import build

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
CREDENTIAL_FILE = 'credential.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def main():
    credentials = getCredentials()
    service = build(
        API_SERVICE_NAME,
        API_VERSION,
        credentials=credentials
    )

if __name__ == "__main__":
    main()

各アルバム内の写真を取得

共有アルバムIDの取得

アルバムIDは以下のように取得できます。

sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()

我が家では前述の通り、子供たちの写真を共有アルバムにて両親に共有しています。
その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、
該当するアルバム名を取得します。

nextPageToken = ''
albums = {}
while True:
  sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
  for sharedAlbum in sharedAlbums['sharedAlbums']:
    if 'title' in sharedAlbum:
      m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
      if m:
        month = m.group()
        if not (month in albums):
          albums[m.group()] = []
        albums[m.group()].append(sharedAlbum['id'])
  if 'nextPageToken' in sharedAlbums:
    nextPageToken = sharedAlbums['nextPageToken']
  else:
    break

その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、

この運用を始めてからは基本的に一月に1共有アルバムになったのですが、
この運用ルールで実施できていなかった頃に、一月の間に複数アルバムを作ってしまっているので、
以下のように複数取得出来るようにしています。

if not (month in albums):
  albums[m.group()] = []
albums[m.group()].append(sharedAlbum['id'])

また最終的には、年月を指定すると、その月の写真をとってくる、というプログラムにしたいので、毎回共有アルバムのIDを取得するのは面倒なので、
予め子供の写真を登録している共有アルバムのリストを取得してファイルに保存しておき、
写真を取得するときは、指定した年月の共有アルバムIDリストのファイルから読み込む、という動きにしました。

MONTH_ALBUMS_FILE = 'month_albums.json'

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          monthAlbums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

共有アルバムのIDを取得してMONTH_ALBUMS_FILEというファイルに保存しておくupdateAlbumListという関数と、
年月をyyyymm01形式で渡すと共有アルバムのIDを返却するgetAlbumIdsという関数とで分けました。

共有アルバム内の写真を取得

いよいよ写真の取得ですが、写真の取得は

developers.google.com

mediaItems.searchというIFで取得ができます。 リクエストbodyのJSONの中にアルバムIDを含めてリクエストすると、そのアルバム内の写真を取得できます。

以下ように取得できます。

body = {
  'albumId' : albumId,
  'pageSize' : 50,
}
mediaItems = service.mediaItems().search(body=body).execute()

アルバムIDの配列を渡されたら、そのアルバムID内の写真を取得するような関数にしました。

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

最終的な全体の実装は以下の通りです。

from googleapiclient.discovery import build
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os,re
from datetime import datetime,date

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
CREDENTIAL_FILE = 'credential.json'
MONTH_ALBUMS_FILE = 'month_albums.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          albums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

def main():
  credentials = getCredentials()
  service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
  )
  updateAlbumList(service)  # ここは必要に応じて実施するように後々処理を分けるつもり
  months = ['20190101','20190201']
  albumIds = getAlbumIds(months)
  getPhotos(service, albumIds)
    
if __name__ == "__main__":
  main()

まとめ

Pythonを使って、Google Photos APIを使ってみました。
共有アルバムから写真は取得出来るようになったので、今度はAlexaと連携して、声で指示した写真を取得出来るようにしたいと思います。

2021年3月15日追記

一年経ったぐらいに久しぶりにこのコードを動かしてみたらエラーが出るようになってしまったので修正しました。

kittagon.hateblo.jp

スティード400を復活 〜サスペンション&オイルフィルター交換編〜

昨年の夏に実家に眠っているスティード400 のキャブ洗浄を行いました。
kittagon.hateblo.jp

今年のGWにはオイルエレメントの交換と、サスペンションの交換をしたのでまとめます。
手探りでやったのであまり写真に残せませんでしたが。。

サスペンションの交換

僕のスティード400はリジットバーをつけているので、今回はそれを交換したいと思います。
リジットバーを付けていると車体が下がってカッコいいんですが、なにせショック吸収が皆無なのでお尻が結構辛いです。
高校生の頃はそれで良かったのですが、今の歳になると絶対にキツい、、、
なので純正とまでは行きませんが、最低限のショック吸収があるサスペンションに交換したいと思います。

今回はヤフオクで購入した、デイトナのサスペンションを装着します。
f:id:ti_taka:20190804005917j:plain

前回と同様に、シートとサイドカバーを外しておきます。
f:id:ti_taka:20190804010011j:plain
f:id:ti_taka:20190804010023j:plain
f:id:ti_taka:20190804010033j:plain

とりあえずリジットバーが固定されている、シート下側のボルトを緩めてしまいます。(↓の赤丸箇所) f:id:ti_taka:20190804010110j:plain

ここのボルトを引き抜くためには、後輪が動ける状態でないと外せないので、この辺りをジャッキアップします。
f:id:ti_taka:20190804010137j:plain

他のブログ等見てみると、ボルトにテンションを掛けておいて、スルッと外れる高さまでうまいこと丁度にジャッキアップすると書いてありますが、僕は微調整も面倒だったので、がっつりジャッキアップした後、後輪を手で持ち上げながらボルトを引き抜きました。
どうせ後から付けるサスペンションは高さが違うのでここで微調整してもつけるときはまた高さ調整しなくてはなりませんからね。

左右それぞれこれぐらいの高さまで上げてしまいました。 f:id:ti_taka:20190804010211j:plain

リジットバーが付いているので、リアのタイヤも浮いた状態になります。写真を撮るのを忘れましたが)

ボルトを外しておいて、スイングアームの↓の赤丸の辺りを持ち上げると、ボルトにかかるテンションが緩むので、持ち上げた状態でボルトを引き抜くと簡単に取れます。
f:id:ti_taka:20190804010226j:plain
f:id:ti_taka:20190804010238j:plain
f:id:ti_taka:20190804010637j:plain

こうしてシート側のボルトが無事外せたので、今度はスイングアーム側に固定されているボルトを緩めて外せばOKです。
f:id:ti_taka:20190804010821j:plain
f:id:ti_taka:20190804010832j:plain
f:id:ti_taka:20190804010844j:plain

若干ステーが邪魔ですが、シートの下からボルトを回して外せます。
f:id:ti_taka:20190804010948j:plain
f:id:ti_taka:20190804011002j:plain
f:id:ti_taka:20190804011016j:plain

次はいよいよサスペンションを取り付けていきます。
外すときはタイヤの横から手を突っ込んで外せたので意識する必要はなかったのですが、取り付けるときはシート下のステーを外す必要があります。
最初からステーを外してしまうほうが、リジットバーを外す際も楽です。僕は外すのが面倒臭かかったので横着してステーを外さないままリジットバーを外したのですが、結局交換のサスを取り付ける際に外す必要があったので、最初から外しておけばよかったと思いました。
↓これです。 f:id:ti_taka:20190804011111j:plain

これが少し面倒臭くて、シート下からは取れず、左右のサイドカバー下にあるボルトを外さないと取れません。
↓左側 f:id:ti_taka:20190804011154j:plain
f:id:ti_taka:20190804011203j:plain

↓右側。ステーの表にイグニッションコイルがついているので一旦外す必要があります。
f:id:ti_taka:20190804011230j:plain

イグニッションコイルを外した下に見えるボルトを外します。
f:id:ti_taka:20190804011302j:plain

こんな感じでシート下でサスペンションを固定しているところが見えるようになるので、あとは先程リジットバーを取り外したのと逆の手順でサスペンションを取り付ければOKです。
f:id:ti_taka:20190804011325j:plain

取り付けるときは、スイングアーム側を固定するのは簡単ですが、シート下を固定するためにはジャッキの高さを微調整してボルトの穴がちょうど良い位置にしないといけません。
これがかなり面倒臭いです。。

無事取り付けることができました。
リジットバーをつけていたときと比べるとだいぶ車高が高くなりました。
これなら乗りやすそうですね。
f:id:ti_taka:20190804011353j:plain

オイル、オイルフィルターの交換

去年せっかくキャブを洗浄したので、オイルも交換しようと思いますが、オイルフィルターも前回いつ交換したんだっけ?という感じだったので、今回の機に交換しようと思います。

オイルフィルターについてはあまり知識が無いのですが、とりあえず型をみてスティードに適合しそうなものの中から以下を選定しました。

また、オイルフィルターを外すためにはオイルフィルターレンチが必要になります。
バイク屋さん曰く、オイルフィルターレンチのサイズは、スズキ車とホンダ・カワサキヤマハで違うそうです。
ホンダ車用のこちらを購入し使用しました。

まずは、オイル交換です。こちらは簡単ですね。
エンジンの下にあるボルトを外してオイルを出します。
f:id:ti_taka:20190804011421j:plain

市販の廃油吸収剤を買ってきて下に敷いておきます。
f:id:ti_taka:20190804011452j:plain

敷いた状態で↑のボルトを外すとオイルが出てきます。
f:id:ti_taka:20190804011531j:plain

次にオイルフィルターを交換します。
ティードのオイルフィルターはここ↓にあります。

f:id:ti_taka:20190804011603j:plain

これを先程のオイルフィルターレンチで回して外します。
このオイルフィルターを外す際も、外したあとオイルが出てくるので、廃油吸収剤を下に置いておいたほうが良いです。

写真を取り忘れてしまいましたが、オイルフィルターを交換したら、オイルを適量入れて作業終了です。

終わりに

サスペンションの交換は、高校の時リジットバーに交換した際も、詳しい先輩にやっていただいたのですが、とても大変そうだった印象だったので、とりあえず自力でやりきれてよかったです。
ちゃんと安定させてジャッキアップできれば特に難しい部分はありませんでした。
オイルも交換したし改めてエンジンをかけてみようとしたらかからなくなってました。
色々調べた結果プラグが悪くなっている可能性があるので、次回時間があるときはプラグの交換をしたいと思います。