20200518のNode.jsに関する記事は5件です。

Node.jsでエラー: No valid exports main found for '/SOME_PATH/node_modules/uuid'

原因

Node.jsのバージョンが不具合のあるものなため
バージョンを14.1.0に上げたら解決しました。

環境

sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.4
BuildVersion:   19E287
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Qiitaの自分の投稿にLGTMが付いたら通知してもらう

自分が投稿した記事に、どなたかがLGTM(いいね)してくれると、やっぱりうれしくて、書いたかいがあったなあと、元気をもらえます。

そこで、30分ごとに、LGTM(いいね)数とフォロワー数をウォッチして、増えていたら、LINEに通知と、自宅にあるGoogleHomeスマートスピーカにしゃべってもらおうと思います。
コロナの影響でずっと在宅勤務なので、ちょっとしたアクセントにもなります。

ちなみに、GoogleHomeスマートスピーカは、これから立ち上げるcron実行PCと同じネットワークにある必要があります。

通知の準備

LINE通知には、LINE Notifyを使いました。
そのためには、パーソナルアクセストークンが必要です。

以下にアクセスして、アクセストークンの発行(開発者向け)をします。
 https://notify-bot.line.me/my/

グループに発行してもよいですし、1:1でLINE Notifyから通知を受け取る でもよいです。
そうすると、43文字程度のパーソナルアクセストークンが発行されます。あとで、使います。

image.png

Google Homeスマートスピーカからしゃべってもらうためには、スマートスピーカのIPアドレスが必要です。
スマートスピーカと連携済みのAndroidのGoogle Homeアプリから、デバイス設定を選ぶと、一番下の情報 というところに、IPアドレスがありますのでメモっておきます。

image.png

現在のLGTM(いいね)数とフォロワー数の取得

Qiita APIを見てみたのですが、記事ごとのLGTM(いいね)数は取得できますが、合計数をとれるようなAPIは見当たりませんでした。
そこで、QiitaのWebページからスクレーピングします。

スクレーピングには、npmのcheerioを使いました。
また、Webページの取得には、node-fetchを使いました。

npm install cheerio
npm install node-fetch

class=” UserCounterList__UserCounterItem-sc-******” のような感じのHTMLエレメントがあるので、その近辺を探しています。

image.png

ソースはこんな感じ。

index.js
const cheerio = require('cheerio');
const fetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');

async function get_qiita_state(userid){
    return do_get('https://qiita.com/' + userid, {})
    .then((text) =>{
      const $ = cheerio.load(text);
      var state = {};
      var root = $("[class^='UserCounterList__UserCounterItem-sc-']");
      root.each((i, elem) =>{
        var label = $(elem).children("[class^='UserCounterList__UserCounterItemLabel-sc-']").text();
        var num = parseInt($(elem).children("[class^='UserCounterList__UserCounterItemCount-sc-']").text());
        state[label] = num;
      });
      // Posts, Contributions, Followers
      console.log(state);

      return state;
    });
}

function do_get(url, qs) {
  var params = new URLSearchParams(qs);
  var url2 = new URL(url);
  url2.search = params;

  return fetch(url2.toString(), {
      method: 'GET',
    })
    .then((response) => {
      if (!response.ok)
        throw 'status is not 200';
      return response.text();
    });
}

関数get_qiita_state()の呼び出しにより、

 { Posts : 投稿数, Contributions : LGTM数, Followers : フォロワー数 }

が返ってきます。

LINE通知する

関数line_notifyに、通知したいメッセージと、さきほどのパーソナルアクセストークンを指定します。

index.js
const fetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');
const Headers = fetch.Headers;

function line_notify(message, token){
  var params = {
      message: message
  };
  return do_post_urlencoded_token('https://notify-api.line.me/api/notify', params, token);
}

function do_post_urlencoded_token(url, params, token) {
  const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization' : 'Bearer ' + token });
  var body = new URLSearchParams(params);

  return fetch(new URL(url).toString(), {
      method: 'POST',
      body: body,
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw 'status is not 200';
      return response.json();
    })
}

GoogleHomeスマートスピーカにしゃべらせる

関数homeSpeechに、メッセージと、スマートスピーカのIPアドレスを指定すると、数秒後にスマートスピーカがしゃべります。

index.js
const Client = require('castv2-client').Client;
const MediaReceiver = require('castv2-client').DefaultMediaReceiver;
const googletts = require('google-tts-api');

function homeSpeech(text, host) {
  return googletts(text, 'ja-JP', 1)
    .then(function(url) {
      return playUrl(url, host);
    });
}

function playUrl(url, host) {
  return new Promise((resolve, reject) => {
    var client = new Client();
    client.connect(host, () => {
      client.launch(MediaReceiver, (err, player) => {
        if( err ){
          console.log('Error: %s', err.message);
          client.close();
          return reject(err);
        }

        var media = {
          contentId: url,
          contentType: 'audio/mp3',
          streamType: 'BUFFERED'
        };
        player.load(media, { autoplay: true }, (err, status) =>{
          client.close();
          resolve('Device notified');
        });
      });
    });

    client.on('error', (err) =>{
      console.log('Error: %s', err.message);
      client.close();
      reject(err);
    });
  })
}

本体

以上の関数を呼び出すメイン部です。
いくつか、ちょっと特殊処理を入れています。

LGTM数とフォロワー数は、以前のチェック時からの増加をみて、LINEとスマートスピーカから通知をしています。以前のチェック時の数は、ファイルに保存しています。
ファイル名として、QIITA_STATE_FILE を指定し、読み出しと更新用の関数をstate_read、state_updateを用意しました。

また、夜中に通知されても困るので、現在時刻を見て、NOTIFY_RANGE_HOURS の配列に示した時間の時だけ通知するようにしました。

index.js
const fs = require('fs');

const QIITA_USERID = process.env.QIITA_USERID || '【チェックしたいQiitaユーザ名】';
const GOOGLE_DEVICE_ADDRESS = process.env.DEVICE_ADDRESS || '【GoogleHomeスマートスピーカwのIPアドレス】';
const QIITA_STATE_FILE = process.env.QIITA_STATE_FILE || './data/qiita_state.json';
const LINE_PERSONAL_ACCESS_TOKEN = process.env.LINE_PERSONAL_ACCESS_TOKEN || '【LINE Notifyのパーソナルアクセストークン】';
const NOTIFY_RANGE_HOURS = process.env.NOTIFY_RANGE || [ 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 0, 1 ]

get_qiita_state(QIITA_USERID)
.then(async (state) =>{
  var prev_state = state_read();

  var date = new Date();
  var hour = date.getHours();

  if( NOTIFY_RANGE_HOURS.includes(hour) ){
    state_update(state);

    var message = "";
    if( state.Contributions > prev_state.Contributions  )
      message += 'いいねが' + (state.Contributions - prev_state.Contributions ) + '件増えたよ。';
    if( state.Followers > prev_state.Followers  ){
      if( message != "" )
        message += "\n";
      message += 'フォロワーが' + (state.Followers - prev_state.Followers ) + '人増えたよ。';
    }

    if( message != "" ){
      await homeSpeech(message, GOOGLE_DEVICE_ADDRESS);
      await line_notify("\n" + message, LINE_PERSONAL_ACCESS_TOKEN);
    }else{
      console.log('no change');
    }
  }
})
.catch(error =>{
  console.log(error);
});

function state_read(){
  try{
    return JSON.parse(fs.readFileSync(QIITA_STATE_FILE, 'utf8'));
  }catch(error){
    return {};
  }
}

function state_update(state){
  fs.writeFileSync(QIITA_STATE_FILE, JSON.stringify(state), 'utf8');
}

ということで、以下を環境に合わせて変更してください。

・QIITA_USERID:チェックしたいQiitaユーザ名
・GOOGLE_DEVICE_ADDRESS:GoogleHomeスマートスピーカwのIPアドレス
・QIITA_STATE_FILE:現在のLGTM数の保存先ファイル名
・LINE_PERSONAL_ACCESS_TOKEN:LINE Notifyのパーソナルアクセストークン
・NOTIFY_RANGE_HOURS:通知されてもよい時間(時)の配列

cron化

シェルスクリプトを作成します。
以下例です。

vi index.sh
chmod +x index.sh

#!/bin/sh

cd /home/XXXXX/projects/node/cron_lgtm
/home/XXXXX/.nvm/versions/node/v8.12.0/bin/node index.js

あとはcronに登録するだけです。以下は30分ごとにチェックする場合です。

crontab -e

0,30 * * * * /home/XXXXX/projects/node/cron_lgtm/index.sh

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

node-fetchをwebpackすると実行できない・・・

やりたかったこと

シンプルにとあるWebサイトをスクレイピングして、情報を取得したかった。
過去にもスクレイパーを作成したことはあるのだが、その時リクエストを送信するのにrequestモジュールを使用していたが、どうやらパッケージが2020年2月に廃止されたらしい…
そこで見つけたのが「node-fetch」。
試しにこれを使って指定したページのHTML要素を取得したい。

使ってみる

TypeScriptをインストール

yarn add typescript

詳しい環境構築はこの記事の本筋からズレるため省略させていただきます。

node-fetchをインストール

yarn add node-fetch

ここでは詳しい使い方などは省略させていただきます。
詳しくはこちらを

サンプルコードを作成

index.ts
import fetch from 'node-fetch';

const url: string = "https://qiita.com/";

fetch(url)
  .then(res => res.text())
  .then(body => console.log(body));

これでHTMLが取得できるはず。

補足

JavaScriptで動的に生成されている要素を取得したい場合、ヘッドレスブラウザが必要になるようです。
この記事の本筋と異なるため、こちらも省略させていただきます。
(今回スクレイピングするサイトには必要なかったので詳しく調べてないです…ごめんなさい)

実行してみる

ts-nodeを使用して実行してみる。

package.json
"scripts": {
  "start": "ts-node ./index.ts",
  "build": "webpack"
}

実行

$ npm run start

<!DOCTYPE html><html><head><meta charset="utf-8" />
<title>Qiita</title><meta content="Qiitaは、プログラマのための技術情報共有サービスです。 プログラミングに関するTips、ノウハウ、メモを簡単に記録 &amp;amp; 公開することができます。" name="description" />
<meta content="width=device-width,initial-scale=1,shrink-to-fit=no" name="viewport" />
<meta content="#55c500" name="theme-color" />
<meta content="XWpkTG32-_C4joZoJ_UsmDUi-zaH-hcrjF6ZC_FoFbk" name="google-site-verification" />
<link href="/manifest.json" rel="manifest" />
<link href="/opensearch.xml" rel="search" title="Qiita" type="application/opensearchdescription+xml" />
<meta name="csrf-param" content="authenticity_token" />

....省略

うん。取れた。

webpack

たったこれだけなのでJavaScriptで書いても良かったのですが、今回作るプロダクトではTypeScriptを使いたかった。
というわけでwebpackでトランスパイルします。

まずはwebpackのツールをインストール

yarn add -D webpack webpack-cli ts-loader

webpack.config.jsを作成します。

webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './index.ts',
  output: {
      path: path.join(__dirname, "dist"),
      filename: "index.js"
  },
  module: {
    rules: [{
      test: /\.ts$/,
      use: [
        {loader: 'ts-loader'}
      ]
    }]
  },
  resolve: {
      modules: [
      "node_modules",
      ],
      extensions: [ '.ts', '.js', 'json' ]
  }
};

webpackして実行

$ node ./dist/index.js

webpack:///./node_modules/node-fetch/browser.js?:11
        throw new Error('unable to locate global object');
        ^

Error: unable to locate global object
    at getGlobal (webpack:///./node_modules/node-fetch/browser.js?:11:8)
    at eval (webpack:///./node_modules/node-fetch/browser.js?:14:14)
    at Object../node_modules/node-fetch/browser.js (/Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:97:1)
    at __webpack_require__ (/Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:20:30)
    at eval (webpack:///./src/index.ts?:26:36)
    at Object../src/index.ts (/Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:109:1)
    at __webpack_require__ (/Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:20:30)
    at /Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:84:18
    at Object.<anonymous> (/Users/taisei/Documents/project/MagicReview/scraping/dist/index.js:87:10)
    at Module._compile (internal/modules/cjs/loader.js:1158:30)

あれ?トランスパイルしたらエラーでた…

解決方法

調査した結果、以下の方法で解決しました。

webpack.config.js
// 省略

target: "node",   //この行を追加
module: {
    rules: [{
      test: /\.ts$/,
      use: [
        {loader: 'ts-loader'}
      ]
    }]
  },

....

どうやら、webpackでバンドルするときにtargetをnodeに指定しないとbrowserオブジェクトが取得できないようです。
省略しますが、webpack.config.jsを上記のように修正後、webpack→実行すると意図した結果が得られました。

targetをnodeに指定することで、Node.js環境で実行できるようにコンパイルしてくれるようです。
参考→webpackドキュメント

追記

ちなみに、axiosでやってみても同じことが起こりました。
フロントでaxiosを使用していたときには、targetを指定しなくても意図した挙動をしてくれていたのですが、サーバーサイド(Node.js環境)で使用するには必要なようです。
いい勉強になりました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Cypress:セッションを保持した状態でテストする

はじめに

新天地で、社内システムの自動テスト化について取り組ませていただくことになりました。
テストは、独自ランナーが使いやすい、Cypressです。
英語がまったく読めないので、苦戦・・・
Chromeの翻訳機能に助けられながら、やったことをメモしておきます。
OSはMacです。

自動テストの概念

自動テストについて調べた結果、概念はこんな感じです。

  • できるだけ簡単なコードで作成する
  • 機能別・テスト名・コード単位で検索しやすい
  • 簡単に更新できる
  • ひとつのものをみんなで共有出来る
  • 誰かに依存しない

セッションを保持したまま、複数のテストを実施する

まずは、ログインIDとパスワードを入れ、セッションを保持したまま別のテストを行う。
という入り口的な部分。
試行錯誤をしましたが、以下の2点を活用しました。

1.Cookieをファイル経由で保存する

参照サイト:Cypressで送る快適E2Eライフ

上記サイト様を参考にして、Cookie情報を別のファイルに出力し、それを読み込むという手法。
ログイン処理をカスタムコマンドにし、ログイン〜Cookieの出力まで行います。

カスタムコマンドについてはこちら ▶ 公式ドキュメント

cypress/support/commands.js
Cypress.Commands.add('login, () => {

  cy.url().then(url => {
    if(url === 'http://...'){

        console.log('relogin')

        cy.clearCookie('session_id')

        const login_id = Cypress.env('login_id') 
        const password = Cypress.env('password')

        cy.get('[name=login_id]').type(login_id).should('have.value', login_id)
        cy.get('[name=password]'). type(password,{ log:false }).should(el$ => {
          if(el$.val() !== password){
            throw new Error('Different value of typed password')
          }
        })
        cy.get('.submit').click()

        //セッションを保持
        cy.getCookie('session_id').should('exist').then((cookie) => {
        cy.writeFile("cache/cookie/session_id.json", {
          value: cookie.value
        })

      })
    }
  })

});


cypress/support/commands.js
内容としては、もし、アクセスしたURLがログインフォームと同じだったらば、clearCookieして再ログインしてね。
セッションがあればスルーされる部分です。

また、ログインIDとパスワードは、cypress.jsonファイルに、環境変数としてセット。
真ん中の色々書いてある部分は、ログイン情報はセキュリティ上表示させないようにしてね、という処理。
ですので、ランナーのコマンドログにパスワードは表示されません。

 

cypress/integration/login_spec.js

describe('Login Action',() => {
    /**
     * セッション設定
     */
    before('Session Setting', () => {
        cy.readFile("cache/cookie/session_id.json").then(cs => {
            cy.setCookie("session_id", cs.value)
        });

        Cypress.Cookies.preserveOnce('session_id')
        cy.visit('URL')
    });

    /**
     * ログインチェック
     */
    beforeEach ('Login Check',() => {
          cy.login()

    })

    /**
     * テスト処理
     */
    it ('Test Case1', () => {
        // something assertion
        })
})

cypress/integration/login_spec.js
内容としては、セッションがあればテストを開始、
セッションが無ければ、再ログイン(カスタムコマンド)をしてからテスト開始。
beforeに、ファイルに出力されたCookie情報をreadさせて、beforeEachで再ログインの有無をチェック。
因みにbeforeEachはすべてのテスト(it)に当たるので、毎回再ログインチェックしている。
別に要らないんですけどね。。。:joy:
余計な処理も、Cypress速いからまあ良いでしょう。ということ。
 

2.Cypress.Cookies.preserveOnce('session_id') を使う

公式ドキュメント

cypress/integration/login_spec.js
ファイルに出力されたCookie情報を、保持しておくことが出来るコマンドです。
跨いで保持することは出来ないようです。

Cypress.Cookies.preserveOnce('session_id')
おわりに

他にも、セッションについては永続的に保持しておけるlocalStorageの利用もひとつの手段かと思いましたが、なかなか実装が出来ず。
Cypressは便利な分、不安定な要素や、実装できない要素等まだまだあるようです。
npmモジュールを入れると、ランナーが起動しなくなるし、正しく実行できるテストも、5回に1回はなんとかエラーが出ます。(ホットリロードすると正常)

まだまだ勉強することはたくさんありますが、セッションの保持が一個クリア出来たので、記録します。
シンプルな方法ですが、初心者なのでこれからこれから。
他に良い方法や、おすすめの方法があれば教えていただきたいです。:bow_tone1:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

aws-serverless-expressのサンプル動かないんだけど

動かないやつ

https://github.com/awslabs/aws-serverless-express/tree/master/examples/basic-starter
npm run setup

出てたエラー

An error occurred (ValidationError) when calling the CreateChangeSet operation: Stack:arn:aws:cloudformation:ap-northeast-1:363880502757:stack/AwsServerlessExpressStack/aaaaaaaaaaaaaaaaaaa is in ROLLBACK_COMPLETE state and can not be updated.
npm ERR! code ELIFECYCLE
npm ERR! errno 255
npm ERR! aws-serverless-express-example@2.1.1 deploy: `aws cloudformation deploy --template-file packaged-sam.yaml --stack-name $npm_package_config_cloudFormationStackName --capabilities CAPABILITY_IAM --region $npm_package_config_region`
npm ERR! Exit status 255
npm ERR!
npm ERR! Failed at the aws-serverless-express-example@2.1.1 deploy script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/aaaaa/.npm/_logs/2020-05-17T15_40_32_799Z-debug.log
npm ERR! code ELIFECYCLE
npm ERR! errno 255
npm ERR! aws-serverless-express-example@2.1.1 package-deploy: `npm run package && npm run deploy`
npm ERR! Exit status 255
npm ERR!
npm ERR! Failed at the aws-serverless-express-example@2.1.1 package-deploy script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/aaaaaaaa/.npm/_logs/2020-05-17T15_40_32_816Z-debug.log
npm ERR! code ELIFECYCLE
npm ERR! errno 255
npm ERR! aws-serverless-express-example@2.1.1 setup: `npm install && (aws s3api get-bucket-location --bucket $npm_package_config_s3BucketName --region $npm_package_config_region || npm run create-bucket) && npm run package-deploy`
npm ERR! Exit status 255
npm ERR!
npm ERR! Failed at the aws-serverless-express-example@2.1.1 setup script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/aaaaaaaa/.npm/_logs/2020-05-17T15_40_32_832Z-debug.log

色々すっ飛ばして結論

  • runtime のnodeバージョンが古い(サンプルの8系がサポート終わってる(直せや

どうするか

  • サンプルプロジェクト内の cloudformation.ymal の 60行目付近の Runtimeを書き換える
    • Runtime: nodejs8.10 -> Runtime: nodejs12.x
    • xがミソ。バージョン指定すると動かない

は?動かないんだけど

  • RollBack出来ないとかなんとかなので一回Stackを消してから npm run setupしてみよう。

おわり

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む