自動データ収集ツールの実装 - Node.jsの学習4

スクレイピングとは

ブラウザで画面が表示されるまで

(要求されたデータを受信 - レスポンス)
サーバー
 ⇅
ブラウザ
(欲しい情報を要求 - リクエスト)

ブラウザがHTMLを解析して画面を作成.
データ: HTML, CSS, JavaScript, 画像など

HTMLとは

Webページの構造をタグを使って定義する書き方
タグごとに意味が異なる

  • h1~6 -> ページの見出しに使う
  • p -> 文章等に使う
  • a -> リンクを貼る時に使う
  • img -> 画像を挿入するときに使う

準備

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "export": "next export",
    "start": "next start",
    "build-start": "npm-run-all -s build start",
    "lint": "next lint"
  }
}
  • npm-run-all 後に記載するコマンドを全て実行する
  • -s シーケンシャル(直列)で実行する

Playwrightを使用する

postinstall → npm install後に行うinstall

{
  "scripts": {
    "postinstall": "npx playwright install"
  }
}

npm run build-startでNext.jsを起動してから下記コードを実行する

import { chromium } from "@playwright/test"

;async () => {
    // slowMo -> delayを指定する
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")
  const htmlStr = await page.content()

  console.log(htmlStr)

  await browser.close()
}

Locatorで取得したい要素を特定する

import { chromium } from "@playwright/test"
;(async () => {
  // slowMo -> delayを指定する
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  // CSSセレクター
  const pageTitleLocator = await page.locator(".navbar-brand")
  const pageTitle = await pageTitleLocator.innerText()
  // console.log(pageTitle)

  // 文字列で要素を取得
  const textLocator = await page.locator("text=名刺管理アプリ")
  const pageText = await textLocator.innerText()
  // console.log(pageText)

  // XPathで要素を取得
  const xpathLocator = await page.locator(
    'xpath=//*[@id="__next"]/div/div[2]/div/ul/li[1]'
  )
  const xpathText = await xpathLocator.innerText()
  console.log(xpathText)

  await browser.close()
})()

Locatorの詳しい使い方

import { chromium } from "@playwright/test"

// @see セレクターのチェーンの利用方法(>>)
// https://playwright.dev/docs/selectors#chaining-selectors
;(async () => {
  const browser = await chromium.launch({ headless: true, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  // CSS セレクターで要素を取得
  const pageTitleLocator1 = page.locator(
    ".cards.list-group-item:nth-child(3) > a"
  )
  const pageTitle1 = await pageTitleLocator1.innerText()

  // 0からカウントするインデックスで取得
  // `>>` -> 複合的に条件を指定することが出来る
  const pageTitleLocator2 = page.locator(".cards.list-group-item > a >> nth=2")
  const pageTitle2 = await pageTitleLocator2.innerText()

  // 親要素が欲しい場合 `>>`
  const pageTitleLocator3a = page.locator(".cards.list-group-item >> ..")
  const pageTitle3a = await pageTitleLocator3a.innerText()
  // or
  const pageTitleLocator3b = page.locator(".cards.list-group-item")
  const parentLocator = pageTitleLocator3b.locator("..")
  debugger
  const pageTitle3b = await parentLocator.innerText()
  console.log(pageTitle3b)

  await browser.close()
})()

デバッグでコードを確認する

VSCode実行とデバッグawaitの必要性などが確認できる。

UIイベントをスクリプトで記述する

import { chromium } from "@playwright/test"

// @see セレクターのチェーンの利用方法(>>)
// https://playwright.dev/docs/selectors#chaining-selectors
;(async () => {
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  // inputに「美」を入力
  const inputLocator = page.locator('//*[@id="__next"]/div/div[1]/label/input')
  // await inputLocator.type("美穂") // typeメソッド <- @playwright/test@1.40.0で非推奨
  await inputLocator.fill("美")

  await page.waitForTimeout(1000)

  // 指定ページに移動する
  const page3Locator = page.locator(".page-link.page-number >> nth=-1")
  await page3Locator.click()

  await page.waitForTimeout(1000)

  // ページ内の人物の名前の数
  const cardLocator = page.locator(".cards.list-group-item")
  const cardCount = await cardLocator.count()
  console.log(cardCount)

  await page.waitForTimeout(2000)
  await browser.close()
})()

textContentとinnerTextの違い

  • textContent タグの中の文字列を取得する
  • innerText タグの中で表示されているものを取得する (display: none; や visibility: hidden; が付与されている場合、取得できない

ファイルへの書き込み処理

import { chromium } from "@playwright/test"
import * as fs from "fs"

;(async () => {
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  const cardLocator = page.locator(".cards.list-group-item >> nth=1")

  const cardText = await cardLocator.textContent()
  await browser.close()

  fs.writeFileSync("./text-data.csv", cardText)
})()

CSV形式でデータ保存

import { chromium } from "@playwright/test"
import * as fs from "fs"
import { Parser } from "json2csv"
;(async () => {
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  const cardLocators = page.locator(".cards.list-group-item")
  const cardCount = await cardLocators.count()

  const fetchedCards = []
  for (let i = 0; i < cardCount; i++) {
    const cardLocator = cardLocators.locator(`nth=${i}`)
    const cardText = await cardLocator.textContent()
    fetchedCards.push({
      name: cardText,
    })
  }

  await browser.close()

  console.log(fetchedCards)

  // JSON形式のデータをCSV形式に変換する
  const parser = new Parser()
  const csv = parser.parse(fetchedCards)
  fs.writeFileSync("./text-data.csv", csv)
})()

CSVに追加情報を書き込む

import { chromium } from "@playwright/test"
import * as fs from "fs"
import { Parser } from "json2csv"
;(async () => {
  const browser = await chromium.launch({ headless: false, slowMo: 500 })
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  const cardLocators = page.locator(".cards.list-group-item")
  const cardCount = await cardLocators.count()

  const fetchedCards = []
  for (let i = 0; i < cardCount; i++) {
    const cardLocator = cardLocators.locator(`nth=${i} >> a`)
    const cardText = await cardLocator.textContent()

    await cardLocator.click()
    const companyLocator = page.locator(".card-title.company")
    const companyText = await companyLocator.textContent()

    fetchedCards.push({
      company: companyText,
      name: cardText,
    })

    const backLocator = page.locator("text=戻る")
    await backLocator.click()
  }

  await browser.close()

  console.log(fetchedCards)

  // JSON形式のデータをCSV形式に変換する
  const parser = new Parser()
  const csv = parser.parse(fetchedCards)
  fs.writeFileSync("./text-data.csv", csv)
})()

Google Spread Sheetの操作

Google Cloud初期設定

最初にGoogle Cloud → APIとサービスでプロジェクトを作成

プロジェクト内でGoogle Sheets API, Google Drive APIを有効化

IAMと管理→サービスアカウントでキーを作成

スプレッドシートを作成したら共有で作成したキーのemailを入力

【重要】Sheetのアクセスに必要な設定

1. プロジェクトの作成

2. APIとサービスの有効化

3. サービスアカウントの作成

セルの値の取得

シートの操作に必要なパッケージのインストール

npm install googleapis@105 @google-cloud/local-auth@2.1.0 --save

JSONファイルは直接、importでは読み込めない

Node.jsでJSONがimportで読み込めないときの対処法

import { GoogleSpreadsheet } from "google-spreadsheet"

import env from "dotenv"
env.config()

// Google Cloudのサービス アカウント キー(JSON)を取得する
// import { createRequire } from "module"
// const require = createRequire(import.meta.url)
// const secrets = require("../../../google_secrets.json")
// or
import { readFile } from "fs/promises"
const secrets = JSON.parse(await readFile("./google_secrets.json"))

;(async () => {
  // スプレッドシートのURLにIDが含まれている
  // https://docs.google.com/spreadsheets/d/1a8hWW0I5X4AOz3pjwZHCa3F41FDbcE7hojOlIAkpkvo/edit#gid=0
  const doc = new GoogleSpreadsheet(process.env.GOOGLE_SHEET_ID)

  await doc.useServiceAccountAuth({
    client_email: secrets.client_email,
    private_key: secrets.private_key,
  })

  await doc.loadInfo()

  const sheet = doc.sheetsByIndex[0]
  await sheet.loadCells("A1:C4")

  // getCell(行, 列)
  const a1 = sheet.getCell(0, 0)
  const b1 = sheet.getCell(0, 1)
  const b2 = sheet.getCellByA1("B2")
  const b4 = sheet.getCell(3, 1)

  console.log("a1", a1.value)
  console.log("b1", b1.value)
  console.log("b2", b2.value)
  console.log("b4", b4.value)
})()

.envを読み込んでいるコードのデバッグ方法

{
  // ********* デバッグの設定ファイル *********
  // IntelliSense を使用して利用可能な属性を学べます。
  // 既存の属性の説明をホバーして表示します。
  // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run Current File",
      "skipFiles": ["<node_internals>/**"],
            // `${file}` -> 現在開いているファイルをデバッグする
      "program": "${file}"
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Launch 04_automation Program File",
      "skipFiles": ["<node_internals>/**"],
      "program": "${file}",
            // .envファイルの場所を指定している
      "envFile": "${workspaceFolder}/04_automation/.env"
    },
    ...
}

環境変数を.envファイルに抽出

URLやパスは、設定値として.envファイルに抽出する

{
  "dependencies": {
    "dotenv": "^16.0.3",
  }
}
import env from 'dotenv';
env.config()
TARGET_URL=http://localhost:3000
OUTPUT_FILE=./text-data.csv
EMAIL_FROM=
EMAIL_TO=
APP_PASS=

エラーが発生時の処理(例外処理)を記述

import { chromium } from "@playwright/test"
;(async () => {
  const browser = await chromium.launch({})
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  await page.waitForTimeout(2000)

  try {
    const inputLocator = page.locator('//*[@id="__next"]/div[1]/label/input')
        // 例外が発生しそうな時のタイムアウトを設定できる
    await inputLocator.fill("美", { timeout: 1000 })
  } catch (e) {
    console.log("インプットの入力処理で例外が発生しました。", e)
  }

  const pager3Locator = page.locator(".page-link.page-number >> nth=-1")
  await pager3Locator.click()

  const cardLocator = page.locator(".cards.list-group-item")

  const cardCount = await cardLocator.count()
  console.log(cardCount)

  await browser.close()
})()

セルの書き込み

const sheet = doc.sheetsByIndex[0]
await sheet.loadCells("A1:C5")

const a1 = sheet.getCell(0, 0)
const a5 = sheet.getCell(4, 0)
const b1 = sheet.getCell(0, 1)
const b2 = sheet.getCellByA1("B2")

// 書式の出力
console.log("a1", a5.textFormat)

a1.value = 11
b1.value = 15
// 書式の変更
a1.textFormat = { fontSize: 18 }
a5.value = "=sum(A1:A4)"

await sheet.saveUpdatedCells()

行の追加

// シートの作成、ヘッダーの追加
    await doc.addSheet({
      title: "persons",
      headerValues: ["name", "age", "gender"],
})

const personSheet = doc.sheetsByTitle["persons"]

// 行を追加
const row = await personSheet.addRow({
  name: "Tom",
  age: 18,
  gender: "M",
})
row.save()

// 複数行を追加
const rows = await personSheet.addRows([
  {
    name: "Tom",
    age: 18,
    gender: "M",
  },
  {
    name: "Hanako",
    age: 20,
    gender: "F",
  },
  {
    name: "John",
    age: 25,
    gender: "M",
  },
])
for (const row of rows) {
  await row.save()
}
// or
rows.forEach(async (row) => {
  await row.save()
})

行の更新

const personSheet = doc.sheetsByTitle["persons"]
const rows = await personSheet.getRows()

// console.log(rows[2].age)

// 行の更新
// rows[0].age = 30
// rows[0].save()

// 行の削除
rows[0].delete()

スクレイピングをSheetにまとめる

import { chromium } from "@playwright/test";

async function getEmployeesByScraping() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto("http://localhost:3000");

  const cardLocators = page.locator(".cards.list-group-item");
  const cardCount = await cardLocators.count();

  const fetchedCards = [];
  for(let i = 0; i < cardCount; i++) {
    const cardLocator = cardLocators.locator(`nth=${i} >> a`);
    const cardText = await cardLocator.textContent();
    
    await cardLocator.click();
    const companyLocator = page.locator('.card-title.company');
    const companyText = await companyLocator.textContent();

    fetchedCards.push({
      company: companyText,
      name: cardText
    });

    const backLocator = page.locator('text=戻る');
    await backLocator.click();

  }

  await browser.close();

  return fetchedCards;
};

export { getEmployeesByScraping };
import { GoogleSpreadsheet } from "google-spreadsheet"
import env from "dotenv"
env.config()
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const secrets = require("../../../google_secrets.json")
import { getEmployeesByScraping } from "./scraping.mjs"
;(async () => {
  const doc = new GoogleSpreadsheet(process.env.GOOGLE_SHEET_ID)

  await doc.useServiceAccountAuth({
    client_email: secrets.client_email,
    private_key: secrets.private_key,
  })

  await doc.loadInfo()

  const employees = await getEmployeesByScraping()

  const sheet = doc.sheetsByTitle["scraping"]

  const rows = await sheet.addRows(employees)

  rows.forEach((row) => {
    row.save()
  })
})()

cron(スケジューラー)の使い方

https://github.com/node-cron/node-cron

スケジューリング(定期実行)の手法

import cron from "node-cron";

cron.schedule('* * * * * *', () => console.log('毎秒に実行'));
cron.schedule('*/3 * * * * *', () => console.log('3秒ごとに実行'));
cron.schedule('* * * * *', () => console.log('毎分に実行'));
cron.schedule('0 0 9,18 * * *', () => console.log('毎日9時,18時に実行'));
cron.schedule('30 30 12 * * *', () => console.log('毎日12時30分30秒に実行'));

定期的な処理のことをバッチと呼ぶ。

import { chromium } from "@playwright/test";

async function getEmployeesByScraping() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto("http://localhost:3000");

  const cardLocators = page.locator(".cards.list-group-item");
  const cardCount = await cardLocators.count();

  const fetchedCards = [];
  for(let i = 0; i < cardCount; i++) {
    const cardLocator = cardLocators.locator(`nth=${i} >> a`);
    const cardText = await cardLocator.textContent();
    
    await cardLocator.click();
    const companyLocator = page.locator('.card-title.company');
    const companyText = await companyLocator.textContent();

    fetchedCards.push({
      company: companyText,
      name: cardText
    });

    const backLocator = page.locator('text=戻る');
    await backLocator.click();

  }

  await browser.close();

  return fetchedCards;
};

export { getEmployeesByScraping };
import { GoogleSpreadsheet } from "google-spreadsheet"
import env from "dotenv"
env.config()
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const secrets = require("../../../google_secrets.json")
import { getEmployeesByScraping } from "./scraping.mjs"
export const addEmployeesToGS = async () => {
  const doc = new GoogleSpreadsheet(process.env.GOOGLE_SHEET_ID)

  await doc.useServiceAccountAuth({
    client_email: secrets.client_email,
    private_key: secrets.private_key,
  })

  await doc.loadInfo()

  const employees = await getEmployeesByScraping()

  const sheet = doc.sheetsByTitle["scraping"]

  const rows = await sheet.addRows(employees)

  rows.forEach((row) => {
    row.save()
  })
}
import { addEmployeesToGS } from "./google-sheet.mjs"
import cron from "node-cron"

// 15:52毎に実行
cron.schedule("52 15 * * *", () => {
  addEmployeesToGS()
})

定期実行の結果をEmailで送信してみよう

2段階認証でアプリパスワードを設定する

import nodemailer from "nodemailer"
import dotenv from "dotenv"
dotenv.config()
;(async () => {
  const message = {
    from: process.env.EMAIL_FROM,
    to: process.env.EMAIL_TO,
    subject: "メールの件名です",
    text: `これはスクリプトによって送信されました。\n改行後`,
  }

  const smtpConfig = {
    host: "smtp.gmail.com",
    port: 465,
    secure: true, // SSL
    auth: {
      user: process.env.EMAIL_FROM,
      //googleアカウントのアプリパスワードを設定
      // see https://support.google.com/accounts/answer/185833?hl=ja
      pass: process.env.APP_PASS,
    },
  }

  const transporter = nodemailer.createTransport(smtpConfig)

  transporter.sendMail(message, function (err, response) {
    console.log(err || response)
  })
})()
TARGET_URL=http://localhost:3000
OUTPUT_FILE=./text-data.csv
GOOGLE_SHEET_ID=
EMAIL_FROM=
EMAIL_TO=
APP_PASS=

スクレイピング→シート書込み→メール送信

// 自作モジュールよりパッケージは先に読み込んだ方が綺麗に書ける
import cron from "node-cron"

import { addEmployeesToGS } from "./google-sheet.mjs"
import { sendEmail } from "./email.mjs"

// cron.schedule("52 15 * * *", () => {
//   main()
// })

main()

async function main() {
  const dt = new Date()
  const dtStr = dt.toDateString()
  const sheetUrl = `https://docs.google.com/spreadsheets/d/${process.env.GOOGLE_SHEET_ID}`
  try {
    await addEmployeesToGS()
    sendEmail("処理が成功しました。", `処理時刻:${dtStr}\n${sheetUrl}`)
  } catch (error) {
    sendEmail("エラーが発生しました。", `エラー発生時刻:${dtStr}\n${error}`)
  }
}
import nodemailer from "nodemailer"
import dotenv from "dotenv"
dotenv.config()

const sendEmail = async (subject, text) => {
  const message = {
    from: process.env.EMAIL_FROM,
    to: process.env.EMAIL_TO,
    subject: subject,
    text: text,
  }

  const smtpConfig = {
    host: "smtp.gmail.com",
    port: 465,
    secure: true, // SSL
    auth: {
      user: process.env.EMAIL_FROM,
      // googleアカウントのアプリパスワードを設定
      // see https://support.google.com/accounts/answer/185833?hl=ja
      pass: process.env.APP_PASS,
    },
  }

  const transporter = nodemailer.createTransport(smtpConfig)

  transporter.sendMail(message, function (err, response) {
    console.log(err || response)
  })
}

export { sendEmail }
import { GoogleSpreadsheet } from "google-spreadsheet"
import env from "dotenv"
env.config()
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const secrets = require("../../../google_secrets.json")
import { getEmployeesByScraping } from "./scraping.mjs"
export const addEmployeesToGS = async () => {
  const doc = new GoogleSpreadsheet(process.env.GOOGLE_SHEET_ID)

  await doc.useServiceAccountAuth({
    client_email: secrets.client_email,
    private_key: secrets.private_key,
  })

  await doc.loadInfo()

  const employees = await getEmployeesByScraping()

  const sheet = doc.sheetsByTitle["scraping"]

  const rows = await sheet.addRows(employees)

  rows.forEach((row) => {
    row.save()
  })
}
import { chromium } from "@playwright/test"

async function getEmployeesByScraping() {
  const browser = await chromium.launch()
  const page = await browser.newPage()
  await page.goto("http://localhost:3000")

  const cardLocators = page.locator(".cards.list-group-item")
  const cardCount = await cardLocators.count()

  const fetchedCards = []
  for (let i = 0; i < cardCount; i++) {
    const cardLocator = cardLocators.locator(`nth=${i}`)
    const cardText = await cardLocator.textContent()

    await cardLocator.click()
    const companyLocator = page.locator(".card-title.company")
    const companyText = await companyLocator.textContent()

    fetchedCards.push({
      company: companyText,
      name: cardText,
    })

    const backLocator = page.locator("text=戻る")
    await backLocator.click()
  }

  await browser.close()

  return fetchedCards
}

export { getEmployeesByScraping }