[GAS]リクナビのサイトからテレアポリストを作成するスクレイピングの制作過程

スクリーンショット 2020-10-09 19.15.28

要望
 テレアポのためのリスト作成作業をスクレイピングで効率化したい
現状
 現在手作業でおこなっている
要求
 ページ内検索で医薬品で検索した一覧の中から電話番号、会社メール、社員数、会社名を抜き出しスプシに反映できること

要求分析
 リクナビの検索一覧urlの仕様を確認する
  https://job.rikunabi.com/2021/s/10_0________/
 業種の検索idの仕様を確認する
 業種の検索条件が1つの場合は/s/番号_番号________/
 業種の検索条件が複数の場合は一律/s/
-> 複数条件はurlに反映されていないので難易度が上がるかもしれない

 検索一覧の詳細ページurlの要素を確認する
  ul > div > div > a要素の中のhref
 要素の取得がし易いxpathを確認する
  //*[@id="cassette-r525430034"]/div[1]/div/a

 詳細ページurlの仕様を確認する
  https://job.rikunabi.com/2021/company/r525430034/
  url一覧の要素のidと詳細urlのidが対応している
->検索一覧urlのidだけを一括取得して、url + idとしてアクセスすればスムーズかもしれない

 ページ内の電話番号、会社メール、社員数、会社名の要素を確認する
  電話番号
   div #company -data04 > divの中の文字列
   要素の取得がし易いxpathを確認する
    //*[@id="company-data04"]/div
  会社メール
   電話番号と同様の要素
   要素内に存在しているページと存在していないページがある
    存在していない https://job.rikunabi.com/2021/company/r525430034/
    存在している https://job.rikunabi.com/2021/company/r183110030/
  社員数
   #company -data03000006203752 の中の文字列
   要素の取得がし易いxpathを確認する
    /html/body/div[1]/div[2]/div[2]/div[4]/table/tbody/tr[4]/td
  会社名
   body > div.ts-h-l-root > div.ts-h-l-body > div.ts-h-company-upperArea > div.ts-h-company-upperArea-companyNameArea.ts-s-cf > div.ts-h-company-upperArea-companyNameArea-titleArea > h1 > aの中の文字列
   要素の取得がし易いxpathを確認する
    /html/body/div[1]/div[2]/div[1]/div[1]/div[1]/h1/a

分析結果
 インターフェースは業種のid一覧をシート上に起こし、選択して実行するようにするのが一番容易
 複数検索条件はurlにidが反映されていないので難易度が上がるかもしれない
  実現する場合ヘッドレスブラウザの使用が必要になる
 詳細ページurlは、検索一覧の詳細ページurlの要素のidと同一なので、idを一括取得して、url + idとして繰り返しアクセスすればスムーズかもしれない

要求の仕様化

 業種の検索条件を一覧で表示する
 検索条件を選択する
 実行する
 業種で検索した一覧のページにアクセスする
 詳細ページurl一覧を取得する
 詳細ページにアクセスする
 ページ内の電話番号、会社メール、社員数、会社名の要素を取得する
 取得した要素の中の文字列をスプレッドシートに出力する

インプット
 https://dividable.net/programming/python/python-scraping
 https://dividable.net/programming/gas-scraping
 https://developers.google.com/apps-script/reference/spreadsheet/sheet#getrangerow,-column
 https://qiita.com/sakaimo/items/ba5594208c254fa528dc
 https://www.d-wood.com/blog/2019/08/02_11423.html
 https://www.d-wood.com/blog/2019/08/02_11423.html
 https://tanuhack.com/gas-log/
 https://www.google.com/search?q=xpath+javascript&oq=xpath+javascript&aqs=chrome..69i57j0l7.4890j0j7&sourceid=chrome&ie=UTF-8
 https://shanabrian.com/web/javascript/xpath-evaluate.php
 https://rinoguchi.hatenablog.com/entry/2019/09/19/134903
 https://www.kotanin0.work/entry/2019/01/06/200000
 https://qiita.com/takaito0423/items/259097b55b026800c875
 https://teratail.com/questions/121482
 https://jsprimer.net/basic/object/
 https://rabbitfoot.xyz/gas-scraiping-with-parser/
 https://qiita.com/diescake/items/70d9b0cbd4e3d5cc6fce#foreach--map
 https://tonari-it.com/gas-regular-expression/
 http://kito0039.hatenablog.com/entry/2016/09/11/004257
 https://teratail.com/questions/98549
 https://qiita.com/munieru_jp/items/101ee00c6906847df750
 https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
 https://gray-code.com/javascript/remove-empty-element-for-array/
 http://nevernoteit1419.blogspot.com/2012/07/blog-post_24.html
 https://qiita.com/ssmxgo/items/c50d07ad6f53a5865034
 https://gist.github.com/xl1/6d5f120c42be56b215f1
 https://blog.katsubemakito.net/gas/enable-v8
 https://jsprimer.net/use-case/nodecli/refactor-and-unittest/
 https://www.monotalk.xyz/blog/google-app-script-の-urlfetchapp-の-例外ハンドリングについて/
 https://postd.cc/a-response-to-why-most-unit-testing-is-waste/

筆記開示

columns// 業種で検索した一覧のページにアクセスする
industry = "インタフェースの仕様を決めて指定する"
url = "https://job.rikunabi.com/2021/s/" + industry
response = "htmlを取得する関数"
html = "htmlをパースする関数"

// 詳細ページurl一覧を取得する
urls = []
// ループ処理でidを抽出する
    // 詳細ページのurlを作成する
    url = "https://job.rikunabi.com/2021/company/" + id + "/"
    // urlsにurlを詰める

// urlsの数だけ詳細ページにアクセスし、ページ内の電話番号、会社メール、社員数、会社名の要素を取得する
sales_list = []
    columns = {} 
    response = "htmlを取得する関数"
    html = "htmlをパースする関数"
    columns['phone_number'] = "電話番号を抽出する"
    columns['company_email'] = "会社メールを抽出する"
    columns['employees'] = "社員数を抽出する"
    columns['company_name'] = "会社名を抽出する"
    // sales_listにcolumnsを詰める
    
// 取得した要素の中の文字列をスプレッドシートに出力する
    // スプレッドシートを取得する
    let ss = SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName(sheetName);
    // シートのオブジェクトを操作して取得した要素の文字列をループで設定する
    sales_listをループ処理する
        sheet.getRange(1, 1).setValue(elements['phone_number']);
        sheet.getRange(1, 2).setValue(elements['company_email']);
        sheet.getRange(1, 3).setValue(elements['employees']);
        sheet.getRange(1, 4).setValue(elements['company_name']);

設計

function fetch() {

  // 業種の検索条件を一覧で表示する
  
}

function execute() {
  extract();
  transform();
  load();
}

function extract() {

  // 業種で検索した一覧のページにアクセスする
  // htmlを取得する
  
}

function transform() {

  // 詳細ページurl一覧を取得する
  // urlsの数だけ詳細ページにアクセスし、ページ内の電話番号、会社メール、社員数、会社名の要素を取得する
  
}

function load() {

  // urlsの数だけ詳細ページにアクセスし、ページ内の電話番号、会社メール、社員数、会社名の要素を取得する
  
}

実装

function fetch() {
   const URL = "https://job.rikunabi.com/2021/search/company/condition/";
   let html = UrlFetchApp.fetch(URL).getContentText('UTF-8');
   let industryElements = Parser.data(html).from('<span class="ts-h-_searchCondition-checkboxText">').to('</span>').iterate();
   let industryColmns = industryElements.filter(industryElement => industryElement.indexOf("href") != -1).map(industriesHref => industriesHref.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,''));
   let industryIds = industryElements.filter(industryElement => industryElement.indexOf("href") != -1).map(industriesHref => industriesHref.split("/2021/s/")[1].split("?")[0]).filter(Boolean);
 
   let ss = SpreadsheetApp.getActiveSpreadsheet();
   let sheet = ss.getActiveSheet();

   let i = 0;
   while (i < industryIds.length){
       let rowNumber = i + 1;
       sheet.getRange(rowNumber, 11).setValue(industryColmns[i]);
       sheet.getRange(rowNumber, 12).setValue(industryIds[i]);
       i++;
   }
}

function execute() {
   //アクティブなセルのidを取得する
   let ss = SpreadsheetApp.getActiveSpreadsheet();
   let sheet = ss.getActiveSheet();
   let cell = sheet.getActiveCell();
 
   let industry = sheet.getRange(cell.getRow(), 12).getValue();

   let html = extract(industry);
   let sales_list = transform(html);
   load(sales_list);
}

function extract(industry) {
   const URL = "https://job.rikunabi.com/2021/s/" + industry;
 
   return UrlFetchApp.fetch(URL).getContentText('UTF-8');
}


function transform(html) {
   // 詳細ページurl一覧を作成する
   let urls = makeDetailUrls(html);
 
   // urlsの数だけ詳細ページにアクセスし、ページ内の電話番号、会社メール、社員数、会社名の要素を取得する
   return makeSalesList(urls);
}

function load(sales_list) {
   // スプレッドシートを取得する
   let ss = SpreadsheetApp.getActiveSpreadsheet();
   let sheet = ss.getSheetByName('シート1');
 
   // シートのオブジェクトを操作して取得した要素の文字列をループで設定する
   let i = 0;
   while (i < sales_list.length){
       let rowNumber = i + 1;
       sheet.getRange(rowNumber, 1).setValue(sales_list[i]['url']);
       sheet.getRange(rowNumber, 2).setValue(sales_list[i]['phone_number']);
       sheet.getRange(rowNumber, 3).setValue(sales_list[i]['company_email']);
       sheet.getRange(rowNumber, 4).setValue(sales_list[i]['employees']);
       sheet.getRange(rowNumber, 5).setValue(sales_list[i]['company_name']);
       i++;
   }
}

function makeSalesList(urls) {
   let sales_list = [];

   const label = {
       url: "URL",
       phone_number: "電話番号",
       company_email: "会社メール",
       employees: "社員数",
       company_name: "会社名"
   };
   sales_list.push(label);

   urls.forEach(url => {
       try {
           let html = UrlFetchApp.fetch(url).getContentText('UTF-8');
           let columns = {};
           columns['url'] = url;
           columns['phone_number'] = makePhoneNumber(html);
           columns['company_email'] = makeCompanyEmail(html);
           columns['employees'] = makeEmployees(html);
           columns['company_name'] = Parser.data(html).from('<h1 class="ts-h-company-mainTitle">').to('</h1>').build().replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');
           sales_list.push(columns);
       } catch (e) {
           Logger.log("message:" + e.message + "\nfileName:" + e.fileName + "\nlineNumber:" + e.lineNumber + "\nstack:" + e.stack);
       }
   });

   return sales_list;
}

function makeDetailUrls(html) {
   let urls = [];
   // 詳細ページのurlの要素を抽出する
   let urlElements = Parser.data(html).from('<div class="ts-h-search-cassetteTitle">').to('</div>').iterate();
   // 詳細ページのurlを作成する
   urlElements.forEach(urlElement => {
       let id = urlElement.split('/2021/company/')[1].split('" target="_blank"')[0];
       urls.push("https://job.rikunabi.com/2021/company/" + id);
   });

   return urls;
}

function makePhoneNumber(html) {
   let companyText = Parser.data(html).from('<div id="company-data04" class="ts-h-company-section">').to('</div>').build().replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');

   let phoneNumber;
   if (companyText.indexOf("TEL") != -1) {
       phoneNumber = companyText.split("TEL")[1].match(/(0\d{1,4})[-\(\s](\d{1,4})[-\)\s](\d{4})/)[0];
   } else {
       phoneNumber = "TELなし";      
   }

   return phoneNumber;
}


function makeCompanyEmail(html) {
   let companyText = Parser.data(html).from('<div id="company-data04" class="ts-h-company-section">').to('</div>').build().replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');
   let companyEmail;
   if (companyText.indexOf("@") != -1) {
       companyEmail = companyText.match(/[\w\-\.]+@[\w\-\.]+\.[a-zA-Z]+/)[0];
   } else {
       companyEmail = "emailなし";      
   }

   return companyEmail;
}

function makeEmployees(html) {
   let tableElement = Parser.data(html).from('<table class="ts-h-mod-dataTable02">').to('</table>').build();
   let trElements = Parser.data(tableElement).from('<tr>').to('</tr>').iterate();
   let employees;
   trElements.forEach(trElement => {
       if (trElement.indexOf("従業員数") != -1) {
           employees = trElement.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'').split("従業員数")[1];
       }
   });

   return employees;
}

テスト

var exports = GASUnit.exports
var assert = GASUnit.assert

function makeTest() {
 exports({
   '#makeCompanyEmailTest()': {
     'emailのみのテキストを作成する': function () {
       assert(makeCompanyEmail('<div>test@gmail.com</div>') === "test@gmail.com")
     },
     'emailがなかったらemailなし': function () {
       assert(makeCompanyEmail('<div></div>') === "emailなし")
     }
   },
   '#makeEmployeesTest()': {
     '従業員数のみのテキストを作成する': function () {
       assert(makeEmployees("<tr>従業員数10名</tr>") === "10名")
     }
   },
   '#makePhoneNumberTest()': {
     'TELがあったら電話番号のみのテキストを作成する': function () {
       assert(makePhoneNumber("<div>TEL080-6512-1354</div>") === "080-6512-1354")
     },
     'TELがなかったらTELなし': function () {
       assert(makePhoneNumber("<div>080-6512-1354</div>") === "TELなし")
     }
   },
   '#makeDetailUrlsTest()': {
     'htmlのurl一覧要素からurl一覧を作成する': function () {
       const html = UrlFetchApp.fetch('https://job.rikunabi.com/2021/s/10_0________/').getContentText('UTF-8');
       const urls = ["https://job.rikunabi.com/2021/company/r436500030/"];
       assert(makeDetailUrls(html)[0] === urls[0])
     }
   },
   '#makeSalesListTest()': {
     'url一覧から出力するリストを作成する': function () {
       const urls = ["https://job.rikunabi.com/2021/company/r436500030/"];
       assert(makeSalesList(urls)[1]['url'].indexOf('https') != -1)
       assert(makeSalesList(urls)[1]['phone_number'].match(/(0\d{1,4})[-\(\s](\d{1,4})[-\)\s](\d{4})/)[0])
       assert(makeSalesList(urls)[1]['company_email'].match(/[\w\-\.]+@[\w\-\.]+\.[a-zA-Z]+/)[0])
       assert(makeSalesList(urls)[1]['employees'].match(/[0-90-9]/)[0])
       assert(makeSalesList(urls)[1]['company_name'] === "日新製薬株式会社・日新薬品株式会社")
     }
   }
 })
}

実際に作成したものを使用する場合はリンクをクリック、コピーしてご利用ください

https://docs.google.com/spreadsheets/d/1-27ormasZrIrjG0ezJb_Yo2AAGjQpYEBWKLaCayjufU/edit#gid=0

いいなと思ったら応援しよう!