GitLab CI で GitLab Container Registry に push した Docker イメージを使う

GitLab には Docker コンテナレジストリである GitLab Container Registry が統合されています。 GitLab CI は Docker イメージを使って CI を行うので、そこで GitLab Container Registry に push した Docker イメージを使う方法を調べました。

以下の 2 つのリポジトリを使うと仮定します。

  • Docker イメージ自体のリポジトリ: gitlab.com/{user}/dockerfile
  • Docker イメージを CI で使うリポジトリ: gitlab.com/{user}/dockerfile-user

Docker イメージ自体も CI するのが都合が良いと思うので、別のリポジトリとしています。 もちろん同じリポジトリであっても構いません。

なおこの記事中ではホスティングサービスである gitlab.com を想定しています。

Docker イメージのリポジトリで deploy token を作成する

2018-09-27 追記

同じユーザーのプライベートリポジトリ間、もしくは同じグループのプライベートリポジトリ間では、deploy token なしでも .gitlab-ci.ymlimage: registry.gitlab.com/{user}/dockerfile:latest とするだけで Container Registry に push した Docker イメージを利用することができました。 GitLab のリポジトリ間の読み取り権限をそのまま利用しているのではないかと思われます。

追記ここまで

Docker イメージ自体のリポジトリ、すなわち Container Registry を有効にしているリポジトリにて、Settings > Repository > Deploy Tokens から deploy token を作成します。 このとき read_registry にチェックを入れて、Container Registry に対する読み取りアクセスを有効にします1

作成された認証情報である username と password を後ほど使います。

Docker イメージを使うリポジトリで変数 DOCKER_AUTH_CONFIG を設定する

GitLab CI でプライベートコンテナレジストリにログインするには、認証情報を変数 DOCKER_AUTH_CONFIG に設定する必要があります2。 設定する値は、作成した deploy token の username と password を :(コロン)で連結し、Base64 エンコードしたものです。

base64 コマンドを使うと、次のようなコマンドで Base64 エンコードすることができます。echo で改行を出力させないために -n オプションを付けます。

$ echo -n "{username}:{password}" | base64

そして Settings > CI / CD > Variables から DOCKER_AUTH_CONFIG を設定します。

後は .gitlab-ci.ymlimage に Container Registry に push した Docker イメージを指定すれば完了です。

image: registry.gitlab.com/{user}/dockerfile:latest

まとめ

GitLab CI で GitLab Container Registry に push した Docker イメージを使う方法を説明しました。 Container Registry のリポジトリread_registry 権限のある deploy token を作成し、CI を行うリポジトリで deploy token を Base64 エンコードしたものを変数 DOCKER_AUTH_CONFIG に設定することで利用できます。

2018-09-27 追記

同じユーザーまたは同じグループのリポジトリでは、deploy token は必要なく、registry.gitlab.com から始まる Docker イメージをそのまま利用できるようです。

SVG の path 要素のみを用いたくり抜き図形について

react-shareLINE に対応させるプルリクエストを作成する際に SVG アイコンを用意する必要があり、いろいろと慣れない作業をしました。 SVG をエディタで開いて手作業で編集する機会はなかなかないと思うので、半分作業メモのような形で記録を残しておきます。

react-share のアイコンについて

react-shareFacebookTwitter などのソーシャルシェアボタンの React コンポーネントライブラリです。 LINE には対応していなかったので、対応させるプルリクエストを送りました。

react-share ではアイコンに SVG を使っています。 色付きの正方形 rect または円 circle の上に、白色の塗りつぶしパス path を描写することでアイコンを形成しています。

LINE のアイコンを用意するときに悩んだのは、path 要素が 1 つしか使われていないことです1。 1 つの path 要素の d 属性 と fill 属性のみを使って、文字の部分をくり抜いたような図形(以下、くり抜き図形と呼ぶ)を作るのに、どうすればいいのか当初はさっぱりわかりませんでした。

デモを見ると、Reddit のアイコンは明らかにくり抜き図形なので、何らかの方法でできるはずだと思っていろいろと調査しました。

path 要素を使って塗りつぶし図形を作るには

まず SVG のパス(path 要素)を用いて塗りつぶし図形をつくるのに簡単に解説をしておきます。

path 要素の図形は d 属性によって定義されて、d 要素には一連のコマンドによって定義されるパスを複数設定することができます。簡単のため、ここでは直線のみのパスを考えることにします。一連のコマンドとは、以下のようなコマンドを並べたものです。

  • M x y: パスの始点を座標 (x, y) に設定する
  • H y: 現在の位置から Y 座標 y への垂直な直線を描画する
  • V x: 現在の位置から X 座標 x への水平な直線を描画する
  • Z: 現在の位置からパスの始点へ戻る直線を描画する

そして fill 属性に色を指定することでパスで囲まれた部分が塗りつぶされます。例えば正方形を描画する path は次のようになります。わかりやすさのためにパス自体にも stroke 属性を利用して色を付けています。

<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

コマンドの詳細は Paths - SVG: Scalable Vector Graphics | MDN などを見てください。

path 要素の fill-rule 属性について

単純な図形の塗りつぶしなら問題はありませんが、くり抜き図形を塗りつぶそうとするとうまくいかない場合があります。そこで用いられるのが fill-rule 属性です。 fill-rule 属性は複数のパスで囲まれる部分がパスの内側かどうかを判断して塗りつぶしを制御するための属性で、値としては主に nonzero (省略した場合のデフォルト値) と evenodd があります。

fill-rule="nonzero" (省略時のデフォルト値)

ある視点を図形の外から内へ向けて進めていくと仮定します。カウント 0 から始めて、右向きのパスを横切るときにカウントに 1 を加え、左向きのパスを横切るときにカウントから 1 を引きます。そしてカウントが 0 でない部分を塗りつぶします。

これを利用してくり抜き図形を作るときは、外側のパスと内側のパスを逆向きにする必要があります。

<!-- 左: 外側パスが反時計回り、内側パスが半時計回り。くり抜けない。 -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 H 70 V 70 H 30 Z" fill-rule="nonzero" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

<!-- 右: 外側パスが反時計回り、内側パスが時計周り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 V 70 H 70 V 30 Z" fill-rule="nonzero" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

fill-rule="evenodd"

同じくある視点を図形の外から内へ向けて進めていくと仮定します。カウント 0 から始めて、パスの向きは考慮せずパスを横切るときにカウントに 1 を加えます。そしてカウントが奇数の部分を塗りつぶします。

これを利用してくり抜き図形を作るときは、外側のパスと内側のパスの向きが合っているかどうかは無関係になります。

<!-- 左: 外側パスが反時計回り、内側パスが反時計回り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 H 70 V 70 H 30 Z" fill-rule="evenodd" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

<!-- 右: 外側パスが反時計回り、内側パスが時計周り -->
<svg viewBox="0 0 100 100" width="100" height="100">
  <path d="M 10 10 H 90 V 90 H 10 Z M 30 30 V 70 H 70 V 30 Z" fill-rule="evenodd" fill="rgb(147,200,254)" stroke-width="5" stroke="rgb(38,145,252)" />
</svg>

fill-rule="evenodd"path 要素を fill-rule="nonzero" に変換する

こちらが作業メモになります。

ベクタ画像の編集に Gravit Designer を使っているのですが、これでエクスポートした SVG には fill-rule="evenodd" 属性が付いていました。

まず fill-rule 属性を削除して、図形の様子を確認しました。 案の定どの文字もくり抜かれていなかったので、パスの向きを逆にする必要がありました。

何か良いツールはないかと探したら、svg-path-reverse を発見し、これを使ってパスの向きを逆にすることができました。 使い方はこのような感じです。

const { reverse } = require('svg-path-reverse');

const path = 'ここに d 要素の中身';
console.log(reverse(path, 0));

reverse 関数の第 2 引数にサブパスのインデックス(0 始まり)を指定することで、指定したサブパスだけを逆向きにできます。 今回は外側のパスが最初のサブパスであり、外側のパスだけ逆向きにすればよかったので、第 2 引数に 0 を指定しています。

パスの向き変更とは関係が薄いですが、svg-path-reverse が出力したパスは冗長になりがちだったので、svgo という SVG を最適化するツールも併用しておきました。

まとめ

内部がくり抜かれた塗りつぶし図形を SVGpath 要素のみを用いて描画する方法を調査し、簡単にまとめました。 fill-rule="nonzero"(もしくは省略時)の場合に外側のパスの向きと内側のパスの向きを逆にする方法と、fill-rule="evenodd" を使う方法の 2 通りを紹介しました。

参考

LINE で共有するリンク・ディープリンクについて調べた

テキストや URL を LINE で共有するためのリンク・ディープリンクについて調べました。
LINEで送るボタンLINE URLスキームを使う の 2 通りの方法があります。

LINEで送るボタン

LINEで送るボタンカスタムアイコンのリンクを使うことで、URL を LINE で送るリンクを作成できます。 URL を送ることを目的としていて、リンク URL 中の url パラメータに共有する URL を設定します。

以下 URL 中のパラメータとして、共有する URL を {URL}、テキストを {TEXT} として表します。

  • リンク URL: https://social-plugins.line.me/lineit/share?url={URL}
  • パラメータ url: 共有する URL
  • パラメータ text: 共有するテキスト(ドキュメントに記載なし)

例: https://social-plugins.line.me/lineit/share?url=http://example.com

ドキュメントには記載されていませんが、ブラウザで開いた場合のみ text パラメータに設定したテキストがテキストボックスに入力されます。 ディープリンク(アプリで開く)の場合は text パラメータは無視されて、url パラメータに設定された URL がテキストボックスに入力されます。

例: https://social-plugins.line.me/lineit/share?url=http://example.com&text=foobar

LINE URLスキームを使う

LINE URLスキームを使うテキストメッセージを送る機能を使うことで、テキストを LINE で送るリンクを作成できます。

  • リンク URL: https://line.me/R/msg/text/?{TEXT}
  • クエリ文字列: 共有するテキストを URL エンコードしたもの

例: https://line.me/R/msg/text/?foobar

テキストメッセージを送ることを目的としています。 ただしブラウザで開いた場合のみ、HTTP Header の Referer に設定された URL も共有されるようになっているようです。

react-share を使って LINE で共有するボタンを作る

React のソーシャルメディアボタンライブラリである react-share が LINE に対応していなかったので、LINE で共有するボタンを作成してみました。 アイコンの扱いについてガイドラインを守っているか微妙なので、プルリクエストは送っていません。

line-share-icon.js

LINE APP ICON GUIDELINE から Circle type のアイコンを AI 形式ダウンロードして SVG 形式でエクスポート後、svgr を使って React コンポーネントに変換しています。 react-share では動的に SVG アイコンが丸型かどうかを変えられますが、今回は丸型で固定しています。

import React from 'react';
import PropTypes from 'prop-types';

const LineShareIcon = props => (
  <svg
    style={{ isolation: 'isolate' }}
    viewBox="0 0 160 160"
    width={props.size}
    height={props.size}
  >
    <defs>
      <clipPath id="a">
        <path d="M0 0h160v160H0z" />
      </clipPath>
    </defs>
    <g clipPath="url(#a)">
      <path
        d="M160 80c0 44.183-35.817 80-80 80S0 124.183 0 80 35.817 0 80 0s80 35.817 80 80z"
        fill="#00B900"
      />
      <path
        d="M133.213 75.196c0-23.811-23.87-43.183-53.213-43.183-29.339 0-53.212 19.372-53.212 43.183 0 21.347 18.93 39.224 44.502 42.604 1.732.373 4.091 1.143 4.688 2.624.538 1.345.351 3.453.172 4.812 0 0-.624 3.755-.76 4.555-.232 1.345-1.068 5.262 4.61 2.869 5.68-2.393 30.645-18.045 41.809-30.895h-.002c7.712-8.457 11.406-17.04 11.406-26.569z"
        fill="#FFF"
      />
      <path
        d="M69.188 63.69h-3.733c-.572 0-1.036.464-1.036 1.035v23.186c0 .571.464 1.034 1.036 1.034h3.733c.572 0 1.036-.463 1.036-1.034V64.725c0-.571-.464-1.035-1.036-1.035zM94.88 63.69h-3.732c-.574 0-1.038.464-1.038 1.035v13.774L79.485 64.15a1.024 1.024 0 0 0-.087-.112c-.02-.023-.042-.043-.062-.064l-.02-.017c-.018-.016-.036-.032-.055-.046l-.027-.021-.054-.037-.031-.019a1.561 1.561 0 0 0-.088-.047l-.06-.025-.033-.012-.062-.02-.036-.009-.06-.014a.682.682 0 0 0-.042-.005c-.019-.004-.038-.005-.055-.007l-.055-.004-.036-.001H74.89c-.572 0-1.037.464-1.037 1.035v23.186c0 .571.465 1.034 1.037 1.034h3.732c.574 0 1.038-.463 1.038-1.034V74.139l10.638 14.37a1.073 1.073 0 0 0 .338.301c.01.007.02.011.029.016l.049.025.051.02c.011.004.02.01.031.012.025.01.048.016.07.024.006 0 .011.003.015.003.084.023.173.035.267.035h3.732c.572 0 1.036-.463 1.036-1.034V64.725c0-.571-.464-1.035-1.036-1.035zM60.191 83.14H50.05V64.725c0-.572-.464-1.036-1.036-1.036H45.28c-.572 0-1.036.464-1.036 1.036v23.186c0 .277.111.53.29.714a.11.11 0 0 0 .014.018l.015.014c.187.178.437.288.716.288H60.191c.572 0 1.035-.464 1.035-1.036v-3.733c0-.572-.463-1.036-1.035-1.036zM115.492 69.496c.572 0 1.035-.464 1.035-1.037v-3.732c0-.572-.463-1.036-1.035-1.036H100.58c-.28 0-.532.11-.72.292l-.011.01a1.024 1.024 0 0 0-.304.732V87.911c0 .278.111.529.289.716l.015.016a1.025 1.025 0 0 0 .731.302H115.492c.572 0 1.035-.464 1.035-1.036v-3.733c0-.571-.463-1.036-1.035-1.036h-10.141v-3.92h10.141c.572 0 1.035-.464 1.035-1.036v-3.732c0-.573-.463-1.039-1.035-1.039h-10.141v-3.917h10.141z"
        fill="#00B900"
      />
    </g>
  </svg>
);

LineShareIcon.propTypes = {
  size: PropTypes.number
};

LineShareIcon.defaultProps = {
  size: 160
};

export default LineShareIcon;

line-share-button.js

LINEで送るボタンの共有方法を使っています。

import PropTypes from 'prop-types';
import createShareButton from 'react-share/es/utils/createShareButton';
import objectToGetParams from 'react-share/es/utils/objectToGetParams';

function lineLink(url, { title }) {
  return (
    'https://social-plugins.line.me/lineit/share' +
    objectToGetParams({
      url,
      text: title
    })
  );
}

const LineShareButton = createShareButton(
  'line',
  lineLink,
  props => ({
    title: props.title,
    url: props.url
  }),
  {
    title: PropTypes.string,
    url: PropTypes.string
  },
  {
    windowWidth: 500,
    windowHeight: 500
  }
);

export default LineShareButton;

使い方

<LineShareButton url={'http://example.com'} title={'foobar'}>
  <LineShareIcon size={32} />
</LineShareButton>

まとめ

LINE で共有するためのリンク・ディープリンクについて調べました。
URL を共有するための LINEで送るボタン、テキストを送信するための LINE URLスキーム の 2 通りの方法について紹介しました。
react-share を使って LINE で共有するための React コンポーネントを作成しました。

Twitter API を使って特定ツイートをリツイートしたユーザーを取得する方法を調べた

特定のツイートをリツイートしたユーザー一覧を取得する必要があったので、その方法を調べました。
結論から言うと、事前準備をしなかった場合では、リツイートしたユーザーを完全に取得するのは難しいことがわかりました。

Twitter API を使ってリツイートを取得する方法

詳しくは [Twitter API] リツイートを100件より多く取得する方法 | プログラミング生放送 にまとまっています。

Retweets API を使う

Retweets API には特定ツイートをリツイートしたユーザーを取得するための 2 つのエンドポイントがあります。

GET statuses/retweets/:id は特定ツイートに対する最新のリツイートをユーザー情報付きで最大 100 件まで取得できます。
GET statuses/retweeters/ids も同じようなエンドポイントですが、リツイート自体ではなくリツイートしたユーザーの ID のみを最大 100 件まで取得できます。

これらの API は最大でも 100 件しか取得できない制限があります。 そのためリツイートしたユーザーを 100 人以上取得したい場合は、定期的に実行して結果を保存しておくなどする必要があります。

例として Node 環境で twitter モジュールを使ったときのソースコードを載せておきます。 GET statuses/retweets/:id からユーザー情報を取得して、CSV 形式で標準出力に出力します。

const Twitter = require('twitter');

const client = new Twitter({
  consumer_key: process.env.CONSUMER_KEY,
  consumer_secret: process.env.CONSUMER_SECRET,
  access_token_key: process.env.ACCESS_TOKEN,
  access_token_secret: process.env.ACCESS_TOKEN_SECRET
});

const params = {
  id: 'TARGET_TWEET_ID',
  count: 100
};

client.get('statuses/retweets', params, (err, tweets) => {
  if (err) {
    console.error(err);
    return;
  }

  console.log('id_str,created_at,user.id_str,user.name,user.screen_name');
  for (const status of tweets) {
    const columns = [
      status.id_str,
      status.created_at,
      status.user.id_str,
      status.user.name,
      status.user.screen_name
    ];
    console.log(columns.join(','));
  }
});

Standard Search API を使う

Standard Search API は 7 日以内のツイートを検索できるエンドポイントであり、これを利用して対象ツイートに含まれる文字列で検索を行うことで、リツイートを取得することができます。 Retweets API における 100 件の制限を超えてリツイートを取得することができますが、Standard Search API にも制限があり、検索対象となる期間は 7 日以内であり、更に 7 日以内であっても全てのツイートが検索対象となるわけではありません1

以下に twitter モジュールを使ったときのソースコード例を載せておきます。 同様にリツイートとそのユーザー情報を CSV 形式で標準出力に出力します。

const querystring = require('querystring');

const Twitter = require('twitter');

const client = new Twitter({
  consumer_key: process.env.CONSUMER_KEY,
  consumer_secret: process.env.CONSUMER_SECRET,
  access_token_key: process.env.ACCESS_TOKEN,
  access_token_secret: process.env.ACCESS_TOKEN_SECRET
});

(async () => {
  console.log('id_str,created_at,user.id_str,user.name,user.screen_name,text');

  const params = {
    q: 'TARGET_TWEET_TEXT',
    since_id: 'TARGET_TWEET_ID',
    count: 100
  };

  while (true) {
    const response = await client.get('search/tweets', params).catch(err => {
      console.error(err);
      process.exit(1);
    });

    for (const status of response.statuses) {
      const escapedText = status.text.replace(/\n+/g, '\\n');
      const columns = [
        status.id_str,
        status.created_at,
        status.user.id_str,
        status.user.name,
        status.user.screen_name,
        escapedText
      ];
      console.log(columns.join(','));
    }

    if (response.search_metadata.next_results) {
      const next_results = querystring.parse(
        response.search_metadata.next_results.replace(/^\?/, '')
      );
      params.max_id = next_results.max_id;
    } else {
      break;
    }
  }
})();

リクエストパラメータの q には対象ツイートに含まれるテキストなどを、since_id には対象ツイートの ID を設定します。 100 件以上取得するにはパラメータ max_id を設定して結果のページングを行う必要があり、設定すべき max_id の値は、直前のレスポンスの search_metadata.next_results プロパティにクエリ文字列として含まれているので、それをパースして設定しています。

q パラメータによっては思ったような結果が得られない場合もあり、何度か試行錯誤をしたり、結果のツイートのテキストを元にフィルタリングしたりする必要がありました。

番外編:メール通知を使う

注意:アイデアでしかなく、実現可能かどうかはわかりません。

自分のリツイートリツイートされたことをメールで通知されるように設定しておき、後から受信したメールを検索すればリツイートしたユーザーを取得できるのではないでしょうか?
試していないのと、メール通知にそれほど詳しくないので、実現可能かどうかはわかりません。

まとめ

Twitter API を使って特定ツイートをリツイートしたユーザーを取得する方法を調べました。 Retweets API では直近 100 件までのリツイートを、Standard Search API では直近 7 日間のおおよそのリツイートを取得できることがわかりました。

7 日間以上に渡ってリツイートを取得したい場合はこれらの API を定期的に実行するか、Premium Search API を利用する必要があると思われます。

.npm-init.js を使って package.json の初期値を設定する

これまでは npm init 実行時に作成される package.json の初期値の設定に .npmrc を使っていましたが、.npm-init.js を使ってより多くのプロパティを設定できるようにしました。

.npmrc を使って package.json の初期値を設定する

npm は設定に .npmrc ファイルを使います1。 ユーザー単位の設定ファイルは ~/.npmrc になります。

npm v6 時点では npm init 時に作成される package.json のプロパティに関係するコンフィグは以下の通りです。

package.json のプロパティ .npmrc のコンフィグ
author.name init-author-name
author.email init-author-email
author.url init-author-url
license init-license
version init-version

.npmrc を使って設定する例は以下のようになります。

init-author-name=John Due
init-author-email=john@example.com
init-license=MIT
init-version=0.1.0

~/.npmrc の問題点

npm でパッケージを公開するために npm adduser (npm login) すると、認証トークンが ~/.npmrc に書き込まれます。 ~/.npmrc を dotfiles リポジトリで管理していたりすると、この認証トークンをリポジトリにコミットする訳にはいけません。

git で特定の行だけ無視するという方法2もありますし、.npmrc を使わずに NPM_CONFIG_INIT_AUTHOR_NAME などの環境変数に設定する3ことでも初期値を設定できます。 ただ、どうせならより柔軟にプロパティを設定できる .npm-init.js を使うことにしました。

.npm-init.js を使って package.json の初期値を設定する

npm init 実行時にはコンフィグ init-module (デフォルト値は ~/.npm-init.js)に設定されたモジュールが呼び出されます。 ファイルが存在しない場合は、init-package-json パッケージの default-input.js が呼び出されます。

このモジュールの仕組みをざっくりと言うと、module.exports でエクスポートした Object がそのまま package.json の初期値に設定されるようになっています。

私は基本的にソースコードディレクトリを ~/src 以下に Go のお作法に習って配置する4ようにしているので、カレントディレクトリがその作法に則っていれば、リポジトリの URL なども初期値に設定するようにしました。

設定した package.json のプロパティ

fixpack を使って package.json をフォーマットすることが多いので、プロパティの順番をそれっぽくしています。 プロパティの詳細は https://docs.npmjs.com/files/package.json にあります。

author

author.name, author.email, author.url は、まとめて author プロパティにショートハンド文字列として設定できます。
例: "John Due <john@example.com> (https://example.com)"

repository

ホスティング先が GitHub, GitLab, Bitbucket の場合は、repository.typerepository.url をまとめて repository プロパティにショートハンド文字列として設定できます。
例: "github:user/repo"

private

意図しない npm publish を防止するために、初期値では true に設定しました。

作成した ~/.npm-init.jsソースコードは以下のようになりました。

// ~/.npm-init.js
function repositoryMeta(provider, user, repo) {
  if (provider === 'github.com' || provider === 'gitlab.com') {
    const homepage = `https://${provider}/${user}/${repo}`;
    return {
      bugs: {
        url: `${homepage}/issues`
      },
      homepage,
      repository: `${provider.replace(/\.com$/, '')}:${user}/${repo}`
    };
  }
  return null;
}

const cwdTree = process.cwd().split('/');
const repo = cwdTree.pop();
const user = cwdTree.pop();
const provider = cwdTree.pop();
const meta = repositoryMeta(provider, user, repo);

// Sort properties as `fixpack` does by default
const json = {
  name: repo,
  description: '',
  version: '0.1.0',
  author: 'Yuichi Tanikawa <kojole.jp@gmail.com> (https://kojole.jp)'
};

if (meta) {
  json.bugs = meta.bugs;
  json.homepage = meta.homepage;
}

json.keywords = [];
json.license = 'MIT';
json.main = '';
json.private = true;

if (meta) {
  json.repository = meta.repository;
}

json.scripts = {
  test: 'exit 1'
};

module.exports = json;

まとめ

npm init 実行時に作成される package.json の初期値の設定に、 ~/.npmrc ではなく ~/.npm-init.js を使うようにしました。 これにより、.npmrcを使うとき以上に柔軟にプロパティの初期値を設定できるようになりました。

西友 5%OFF 開催日カレンダーをスクレイピングして Google カレンダーとして公開しました

元ネタ:スーパーの5%OFF開催日をiCal形式で配信してGoogleカレンダーに表示してみた

はじめに

私は毎日に西友に行くほどの西友ヘビーユーザーなので、元ネタに触発されて、西友 5%OFF 開催日の Google カレンダーを作って公開しました。

自分の Google カレンダーに追加するには、上記カレンダーの URL を開いて右下のボタンを押すか、Calendar ID を [友だちのカレンダーを追加] に入力してエンターを押すとできます。

元ネタでは AWS Lambda 上でスクレイピングを実行した結果を iCal 形式で配信し、それを Google カレンダーに追加するという形をとっています。

AWS Lambda は無料枠があるため個人で使う程度であれば無料ですが、カレンダーを一般公開しようとすると課金される可能性があります。 そこで、完全無料で定期的に動作させることを目指し、結果としては Google カレンダー、Google Apps Script、Travis CI cron job を使うことで完全無料を実現しました。

スクレイピングのプログラムは GitHub で公開しています。
https://github.com/kojole/seiyu-5off

実装について

スクレイピング

HTTP クライアントは node-fetchスクレイピングには jsdom を使いました。 スクレイピングのライブラリに特に詳しくはなかったので、ブラウザ環境で使うことに慣れている fetchDOM と同様に扱えることを理由に選びました。

スクレイピングした開催日のデータは、リポジトリ中に JSON ファイルとして保存しています。 Travis CI の cron Job を使って定期的にスクレイピングを行い、自動的に JSON ファイルを更新するようにしました。 Travis CI からリポジトリにコミットする方法については、Travis CIのcron jobsを使ってGitHubに定期的にcommitする方法 が非常に参考になりました。

Google カレンダーのイベント作成

Google API クライアントである google-api-nodejs-client を使おうとすると OAuth 周りが少し面倒なので、Google Apps Script を使ってカレンダーにイベントを作成するようにしました。

リポジトリから開催日データの JSON を取得してカレンダーにイベントを作成するだけの割と単純な機能だったので、今回はモジュールバンドラを使用せず生の JavaScript (ES5) で書きました。

モジュールを使わないので単体テストを実行するのに少し工夫が必要です。例えばテスト対象のファイルを index.js とすると、テストファイルの先頭でテスト対象のファイルを読み込んで、vm.runInContext で対象ファイルを実行する必要があります。なお、テストには jest を使っています。

// index.test.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');

const code = fs.readFileSync(path.join(__dirname, 'index.js'));
const globalContext = vm.createContext(global);
vm.runInContext(code, globalContext, { filename: 'index.js' });

describe('', () => {
  // ...
});

まとめ

西友 5%OFF 開催日をスクレイピングで取得し、Google カレンダーとして公開しました(リンク)。 Travis CI cron job と Google Apps Script を使って定期的に開催日データが更新される仕組みを作りました。

全然関係ないですが、西友のセルフレジの重さ判定で時々エラーになって店員さんに対応してもらうとき、少し居心地が悪く感じてしまいます。