📚

node.jsライブラリpuppeteerで操作ログ検索結果画面からログインアカウントをスクレイピングする

15 min read

はじめに

某プロジェクト管理システムへのログイン履歴をスクレイピングするバッチを作成しました。以下にわたしの忘備録もかねてみなさまにご紹介いたします。まずは初版で公開いたします。今後定期的に記事を膨らませるために追記していきます。

対象システム

某プロジェクト管理システムです。主に課題管理・障害管理・問題管理に利用しています。

操作履歴検索結果とはこんな感じ

以下のような操作履歴検索結果ログからログイン操作したアカウントの履歴を1年間分スクレイピングします

やりたいこと 〜要件〜

操作履歴ログはあらかじめログインのみを取得するようにします。操作ログは新しいものから古いものへのページをめくりながら参照します。ページをめくるためには次ページボタン▶️を押す必要があります。

1年間分を参照しようとすると、開始日に1年前の年月日を入力し、終了日は空白にします。操作ログで取得したいのはログインですので検索対象としてログインをチェックボックスで選択します。

そうして、検索するには実行ボタンを押します。検索結果全てを参照するためには、おおむね500ページ以上を次ページボタン▶️を押しながらページをめくる必要があります

このページをめくりながらログインしたアカウントの情報を取り出しますが、500ページはとても大変です。しかも単純作業です。そこで、これをnode.jsのヘッドレスChrome操作ライブラリ puppeteer でバッチプログラムを作成して自動化します。このバッチを夜間や早朝に走らせれば、ログイン操作したアカウントの履歴を取得することができます

ログイン操作したアカウントの履歴一覧はマークダウン形式に変換して関係者だけが閲覧できるWiKiページの公開パスに配置します。この公開処理もバッチの後処理として行わせます

スクレイピング対象

  • アカウント:この中のアカウントIDを取り出します
  • 操作者:この中の氏名を取り出します
  • 操作日

実行環境

  • 形式:node.js Batch
  • ライブラリ:puppeteer
  • 結果発行先:Windows Server2016 Apache http server
  • 結果発行形式:マークダウン (WiKi)
  • 実行コマンド
node loginLogScraping.js

出力結果


ログインアカウント一覧

ログ取得期間:2019年12月11日 ~ 2020年12月11日 ■アクティブアカウント数:423
SEQ NO. アカウント 氏名 直近操作日
1 A111111 馬鹿 間抜 2020-12-11
2 B222222 阿保 田吾作 2020-12-11
3 C333333 阿波図 蓮子 2020-12-11
4 D444444 附子 毛芽子 2020-12-11
5 E555555 破壊 魔王 2020-12-11
6 F666666 絶体 絶命 2020-12-11
7 G777777 心筋 梗塞 2020-12-11
8 H888888 感染 拡大 2020-12-11

  • マークダウン出力イメージ
loginaccount.md
|SEQ NO.|アカウント|氏名|直近操作日|
|-------|-------|---|--------|
|1|A111111|馬鹿 間抜|2020-12-11|
|2|B222222|阿保 田吾作|2020-12-11|
|3|C333333|阿波図 蓮子|2020-12-11|
|4|D444444|附子 毛芽子|2020-12-11|
|5|E555555|破壊 魔王|2020-12-11|
|6|F666666|絶体 絶命|2020-12-11|
|7|G777777|心筋 梗塞|2020-12-11|
|8|H888888|感染 拡大|2020-12-11|

開発環境

  • エディタ:Visual Studio Code
  • 仮想環境:WSL2 (Windows Subsystem for Linux)
  • ターミナル:Ubunts z-sh Terminal または PowerShell

設計・実装トピックス

プロキシサーバログイン

authenticate() メソッドでプロキシサーバのIDとパスワードを設定するのがキモです

browserProxyLogin()
const browserProxyLogin = async function() {
  console.log( '..... browserProxyLogin() .....' )
  const browser = await puppeteer.launch( { 
    headless: true ,
    args: [ '--proxy-server=' + PROXY.server ]
  } )
  const page = await browser.newPage()
  await page.authenticate( { username: PROXY.userid , password: PROXY.password } );

  return [ browser, page ]
}

ログイン画面ログイン

ログインボタンを押す時に Promise.all() で以下の操作を同時に実行します。どちらかが実行されるか拒否されるまで処理が続きます

  • 押す
  • ページが変わるのを待つ
targetLogin()
const targetLogin = async function( page ) {
  console.log( '..... targetLogin() .....' )
  await page.setDefaultNavigationTimeout( 0 );
  await page.goto( TARGET.url.home )
  await page.type( 'input[ id="accountpart" ]', LOGIN.userid   )
  await page.type( 'input[ id="passwdpart"  ]', LOGIN.password )
  await Promise.all( [
    page.click( 'input[ id="btn" ]' ), 
    page.waitForNavigation()
  ] )
}

操作ログ検索期間開始日入力

入力フィールドに値を入力する場合にはtype() メソッドを使います。name属性の入力フィールドを操作する場合には第1引数に 'input[ name="styear" ]' のように指定します

setLogSetting()
const setLogSetting = async function( page ) {
  console.log( '..... setLogSettingExecute() .....' )
  await page.type( 'input[ name="styear"  ]', TARGET.date.start.year  )
  await page.type( 'input[ name="stmonth" ]', TARGET.date.start.month )
  await page.type( 'input[ name="stday"   ]', TARGET.date.start.day   )

  await page.click( 'input[ name="logattrs[]" ]' )
}

ログ履歴検索開始ボタンまたは次ページボタンを押す

ログ履歴検索開始ボタンを押すには click() メソッドを使います。次ページボタンを押す操作はボタンを押した時にページ遷移するので goto() メソッドを使います。どちらも操作してからしばらく待たないといけませんので、waitForNavigation() メソッドで待ちます

waitForNavigation() メソッドは 操作メソッド click()goto() ともに 配列に入れて Promise.all() メソッドの引数に指定します

scrapeLogPageData()
const scrapeLogPageData = async function( page, pageNO ) {
  console.log( '..... gotoNextLogPage() .....' )
  if( pageNO == 0 ){
    await Promise.all( [
      page.click( 'a[ id="execbtn" ]' ),
      page.waitForNavigation()
    ] )
  } else {
    await Promise.all( [
      page.goto( TARGET.url.log.search + pageNO ),
      page.waitForNavigation()
    ] )
  }
  console.log( '........................................\n' + 
               'Page【' + pageNO + '】' + TARGET.url.log.search + pageNO +
               '\n........................................' )
}

検索結果の一覧表の各行からアカウントデータを取り出す

あらかじめ検索結果テーブルの1ページあたりの行数を調べておきます。そうして、行数分 for 文で繰り返します

各行の項目を取り出すには getContent() メソッドに itemSelector を指定します。 itemSelector は以下のように指定します

itemSelector
#content > table > tbody > tr:nth-child(%rowNO%) > td:nth-child(%colNO%)

ここで、 %rowNO% は行番号の、 %colNO% は列番号のパターン識別文字列です。行番号には for 文のインデックスを置き換えて指定します。列番号は取り出したい情報が入っている列の番号です。どちらも0から始まる整数です

とり出したアカウントデータを accountObj オブジェクトに識別子 アカウント氏名直近操作日 をつけて格納します。

getAccountList
const getAccountList = async function( page, accountList ) {
  console.log( '..... getAccountList() .....' )

  let account = ''; let operator = ''; let operationdate = ''

  for( let i = TARGET.log.topRowNo; i <= TARGET.log.bottomRowNo; i++ ){
    account = await getContent( page, 
      TARGET.log.account.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    operator = await getContent( page, 
      TARGET.log.operator.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    operationdate = await getContent( page, 
      TARGET.log.operationdate.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    let accountObj = {
      アカウント: takeAcountID( account ) ,
      氏名      : takeOperatorName( operator )  ,
      直近操作日: takeOperationDate( operationdate )
    }
    if( !isObjectExistsInArray( accountList, accountObj ) ){
      accountList.push( accountObj )
    }
  }
}

以下、別の日に解説を追加いたします。

実装コード

loginLogScraping.js
const puppeteer = require( 'puppeteer' )
const fs = require( 'fs' )
const path = require( 'path' )

const PASSWORD = 'hsecretpassword'
const PROXY = {
  server: 'proxy.hoge.co.jp:8080',
  userid: 'foo123456', password: PASSWORD
}
const LOGIN = { userid: 'foo123456' , password: PASSWORD }
const TARGET = {
  url: {
    home: 'https://manage-sys.jp/',
    log: {
      setting: 'https://manage-sys.jp/log/setting.php',
      search : 'https://manage-sys.jp/log/search.php?page='
    }
  }
  ,
  date: {
    start: { year: '2020', month: '12', day: '11' },
    end:   { year: '2020', month: '12', day: '10' },
  }
  ,
  log: {
    rowNoMask: '%rowNO%',
    topRowNo: 3, bottomRowNo: 102
    ,
    operationdate: {
      itemSelector: 
        '#content > table > tbody > tr:nth-child(%rowNO%) > td:nth-child(2)',
    }
    ,
    operator: {
      itemSelector:
        '#content > table > tbody > tr:nth-child(%rowNO%) > td:nth-child(4)',
      startStringNO: 0, afterString: ' ( ',
      except: 'SISTEST'
    }
    ,
    account: {
      itemSelector: '#content > table > tbody > tr:nth-child(%rowNO%) > td:nth-child(12)',
      beforString: '】', afterString: ' - '
    }
  }
}
const INFO = {
  output: {
    markdown: {
      path: '\\\\release-foo-bar\\Apache24\\htdocs\\manage-sys\\list\\login',
      filename: 'loginaccount.md'
    }
  }
}

const scrapeLoginLog = async function() {
  const [ browser, page ] = await prepareProcess()
  let pageNO = 0; let accountList = []
  try {
    console.log( '<------ Log Page Start ------>' )
    while( true ) {
      await scrapeLogPageData( page, pageNO++ )
      await getAccountList( page, accountList )
    }
  } catch( error ) {
    await browser.close()
    console.log( error.message + '\n------> Log Page End <------' )
  } finally {
    await browser.close()
  }
  outputAccountList( accountList )
}

const prepareProcess = async function() {
  const [ browser, page ] = await browserProxyLogin()
  await targetLogin( page )
  await gotoLogSettingPage( page )
  await setLogSetting( page )
  return [ browser, page ]
}

const browserProxyLogin = async function() {
  console.log( '..... browserProxyLogin() .....' )
  const browser = await puppeteer.launch( { 
    headless: true ,
    args: [ '--proxy-server=' + PROXY.server ]
  } )
  const page = await browser.newPage()
  await page.authenticate( { username: PROXY.userid , password: PROXY.password } );

  return [ browser, page ]
}

const targetLogin = async function( page ) {
  console.log( '..... targetLogin() .....' )
  await page.setDefaultNavigationTimeout( 0 );
  await page.goto( TARGET.url.home )
  await page.type( 'input[ id="accountpart" ]', LOGIN.userid   )
  await page.type( 'input[ id="passwdpart"  ]', LOGIN.password )
  await Promise.all( [
    page.click( 'input[ id="btn" ]' ), 
    page.waitForNavigation()
  ] )
}

const gotoLogSettingPage = async function( page ) {
  console.log( '..... gotoLogSettingPage() .....' )
  await Promise.all( [
    page.goto( TARGET.url.log.setting ),
    page.waitForNavigation()
  ] )
}

const setLogSetting = async function( page ) {
  console.log( '..... setLogSettingExecute() .....' )
  await page.type( 'input[ name="styear"  ]', TARGET.date.start.year  )
  await page.type( 'input[ name="stmonth" ]', TARGET.date.start.month )
  await page.type( 'input[ name="stday"   ]', TARGET.date.start.day   )

  await page.click( 'input[ name="logattrs[]" ]' )
}

const scrapeLogPageData = async function( page, pageNO ) {
  console.log( '..... gotoNextLogPage() .....' )
  if( pageNO == 0 ){
    await Promise.all( [
      page.click( 'a[ id="execbtn" ]' ),
      page.waitForNavigation()
    ] )
  } else {
    await Promise.all( [
      page.goto( TARGET.url.log.search + pageNO ),
      page.waitForNavigation()
    ] )
  }
  console.log( '........................................\n' + 
               'Page【' + pageNO + '】' + TARGET.url.log.search + pageNO +
               '\n........................................' )
}

const getAccountList = async function( page, accountList ) {
  console.log( '..... getAccountList() .....' )

  let account = ''; let operator = ''; let operationdate = ''

  for( let i = TARGET.log.topRowNo; i <= TARGET.log.bottomRowNo; i++ ){
    account = await getContent( page, 
      TARGET.log.account.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    operator = await getContent( page, 
      TARGET.log.operator.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    operationdate = await getContent( page, 
      TARGET.log.operationdate.itemSelector.replace( TARGET.log.rowNoMask, i )
    )
    let accountObj = {
      アカウント: takeAcountID( account ) ,
      氏名      : takeOperatorName( operator )  ,
      直近操作日: takeOperationDate( operationdate )
    }
    if( !isObjectExistsInArray( accountList, accountObj ) ){
      accountList.push( accountObj )
    }
  }
}

const getContent = async function( page, itemSelector ) {
  return(
    await page.$eval(
      itemSelector, item => {
        return item.textContent
      }
    )
  )
}

const takeAcountID = function( acountItem ) {
  return(
    acountItem.substring(
      acountItem.indexOf( TARGET.log.account.beforString )
                        + TARGET.log.account.beforString.length
      ,
      acountItem.indexOf( TARGET.log.account.afterString )
    )  
  )
}

const takeOperatorName = function( operatorItem ) {
  return(
    operatorItem.substring(
      TARGET.log.operator.startStringNO
      ,
      operatorItem.indexOf( TARGET.log.operator.afterString )
    )  
  )
}

const takeOperationDate = function( OperationDate ) {
  return( OperationDate )
}

const objectArrayToCSV = function( objectArray ) {
  let csvList = ''
  for( let i = 0; i < objectArray.length; i++ ) {
    csvList +=
      Object.keys( objectArray[ i ] ) + ',' +
      Object.values( objectArray[ i ] ) + '\n'
  }
  return csvList
}

const objectArrayToMarkdown = function( objectArray ) {
  let markdownList =
      '## ログインアカウント一覧' + '\n'
    + '### ログ取得期間:' + getPeriod() + ' '
    + '■アクティブアカウント数:' + objectArray.length + '\n\n'
    + '|SEQ NO.|'
    + Object.keys( objectArray[ 0 ] )[ 0 ] + '|' 
    + Object.keys( objectArray[ 0 ] )[ 1 ] + '|'
    + Object.keys( objectArray[ 0 ] )[ 2 ] + '|'
    + '\n' + '|---|-------|-------|------|' + '\n'

  for( let i = 0; i < objectArray.length; i++ ) {
    markdownList +=
      '|' + ( i + 1 ) +'|'
      + Object.values( objectArray[ i ] )[ 0 ] + '|'
      + Object.values( objectArray[ i ] )[ 1 ] + '|'
      + Object.values( objectArray[ i ] )[ 2 ] + '|'
      + '\n'
  }
  return markdownList
}

const isObjectExistsInArray = function ( objectArray, valueObj ) {
  if( valueObj.氏名 === TARGET.log.operator.except ) return true;

  for ( let i = 0, len = objectArray.length; i < len; i++ ) {
    if ( valueObj.アカウント === objectArray[ i ].アカウント ) {
      return true;
    }
  }
  return false;
}

const outputAccountList = function( accountList ) {
  console.log(
    'acounts : ' + accountList.length + '\n' +
    objectArrayToMarkdown( accountList )
  )
  fs.writeFileSync(
    path.join( INFO.output.markdown.path,INFO.output.markdown.filename ),
    objectArrayToMarkdown( accountList )
  )
}

const getPeriod = function() {
  const date = new Date(); 
  return(
      TARGET.date.start.year  + '年'
    + TARGET.date.start.month + '月'
    + TARGET.date.start.day   + '日'
    + ' ~ '
    + ( date.getFullYear()  ) + '年'
    + ( date.getMonth() + 1 ) + '月'
    + ( date.getDate()      ) + '日'
  )
}

scrapeLoginLog();

Discussion

ログインするとコメントできます