TypeScriptを使った理由
特にJavaScriptでも問題ないのですが、TypeScriptに慣れてきてJavaScriptで書くのがつらくなってきたため使いました。
今回のような最終的な同じCSVに書き出す場合などは、項目漏れなどがVSCode上ですぐ分かったので便利でした。
また非同期処理、同期処理が混在するため、その間違いにすぐ気付けるのも大きなメリットでした。
デメリットしては数秒のビルド時間がかかるぐらいでしょうか。
環境構築
Node(私の環境ではv14.14.0を使用)やyarnなどはインストール済みとします。
Puppeteerインストール
puppeteerとCSV用のツールを入れました。
yarn add puppeteer csv-write
TypeScript周りは省略しますが、package.jsonはこんな感じ。
ビルドしたファイルは特に使わないので、ts-nodeを使ってビルド&実行してました。
{
"name": "scrapuppeteer",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start:sample": "ts-node src/sample.ts",
"lint": "eslint --fix ./ --ext ts"
},
"devDependencies": {
"@types/node": "^14.14.14",
"@types/puppeteer": "^5.4.2",
"@typescript-eslint/eslint-plugin": "^4.11.0",
"@typescript-eslint/parser": "^4.11.0",
"eslint": "^7.16.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-prettier": "^3.3.0",
"prettier": "^2.2.1",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
},
"dependencies": {
"csv-writer": "^1.6.0",
"puppeteer": "^5.5.0"
}
}
Puppeteerを使ったスクレイピング処理の流れ
WEBサイトによって構造はバラバラなので、基本的にオーダーメイドになりますがよくある処理をまとめておきます。
いろんなサイトを同じCSVにまとめたので、こんな感じのinterfaceを作ってつかってました。
不足してたり、誤字に気付けるので便利です。
```
interface CsvItem {
companyName: string
name: string
postalCode: string
address: string
tel: string
fax: string
email: string
hp: string
}
```
よく使う処理
サイト構造に限らずよく使うメソッドなどは別に管理して使いまわします。
utils/index.ts
// ゆらぎをもたせたいわゆるsleep
export const randomSleep = (ms: number): Promise<void> => {
const sleepMs = ms + ms * Math.random()
console.log(`sleep: ${sleepMs}`)
return new Promise((resolve) => setTimeout(resolve, sleepMs))
}
// HTMLをローカルに落としてやるときに使う
export const getContentsFromFile = async (path: string): Promise<string> => {
return await readFile(path, { encoding: 'utf-8' })
}
// 指定したElementHandleのテキストとかを抽出する、CSSセレクタで絞り込んだり、propertyNameでhrefとかinnerHTMLとかいろいろ取れる
export const getTextFromElement = async (
element: ElementHandle,
selecter = '',
propertyName = 'textContent'
): Promise<string> => {
if (selecter) {
const selected = await element?.$(selecter)
if (selected) {
element = selected
} else {
return ''
}
}
const text = await (await element?.getProperty(propertyName))?.jsonValue()
if (typeof text === 'string') {
return text
}
return ''
}
// User Agentの文字列。普段のブラウザと合わせた
const uaString =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36'
// おまじない
export const launchOptions: ChromeArgOptions = {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
],
}
// ページの起動とUAのセットと、デフォルトタイムアウトが30秒だと長いので10秒にしておく
export const initPage = async (browser: Browser): Promise<Page> => {
const page = await browser.newPage()
await page.setUserAgent(uaString)
page.setDefaultTimeout(10000)
return page
}
ブラウザとページの起動
ブラウザと最初のページを用意
const browser = await launch(launchOptions)
const page = await initPage(browser)
await page.goto('http://example.com/')
# 次の動作に必要な要素を待っておく
await page.waitForSelector('#searchButton')
ページから必要な情報を抜き出す関数を実行
これは内部で一覧から直接情報を抜き出す場合(tableの trごと、ulのliなど)のparseListPage()と
さらに詳細リンクがある場合そちらから取得する場合parseDetailPage()がよくあるので関数に分けることが多かった。
最後に一覧と詳細の情報を統合して、1件ずつCSVに書き込む。
await writeCsvPageContent(browser, page)
const writeCsvPageContent = async (
browser: Browser,
page: Page
): Promise<void> => {
const items = await page.$$('#list table tr')
let isFirst = true
for (const item of items) {
if (isFirst) {
isFirst = false
continue
}
const listResult = await parseListPage(item)
if (!listResult) {
console.error('listResultが空です')
continue
}
// 詳細ページあり
const detailLink = await item.$('.detail-link a')
let detailResult = null
if (detailLink) {
detailResult = await parseDetailPage(browser, page, detailLink)
//console.log('detailResultあり')
}
const result = {
companyName: listResult.companyName,
name: listResult.name,
postalCode: listResult.postalCode,
address: listResult.address,
tel: listResult.tel,
fax: detailResult?.fax ?? '',
email: detailResult?.email ?? '',
hp: '',
}
await csvWriter.writeRecords([result])
}
}
詳細ページへ移動するリンクの場合
一覧ページを移動するといろいろややこしくなるので素直に新しいPageつくってgotoするのが楽だった。
const parseDetailPage = async (
browser: Browser,
page: Page,
detailLink: ElementHandle
): Promise<Partial<SyaroshiItem> | null> => {
const href = await getTextFromElement(detailLink, undefined, 'href')
const newPage = await browser.newPage()
await newPage.goto(href)
// パースする処理
await newPage.close()
取得したらPageをclose()するのを忘れないこと(メモリ食う)。
次のページのリンク要素を取得する関数
pageから次のページへのリンク要素を抜き出す関数をつくる
次のページがとれないときはnullを返すようにしておく。
let nextPageLink = await getNextPageLink(page)
どれが次のページへのリンクかサイトによってバラバラだけど、
これはリンクが「次へ」の例。
const getNextPageLink = async (page: Page): Promise<ElementHandle | null> => {
const pagenation = await page.$$('.pagination > a')
for (const a of pagenation) {
const label = await getTextFromElement(a)
if (label === '次へ') {
return a
}
}
return null
}
あと現在のページだけclassがついてたり、aが無いなどのパターンがあり、その場合はfor ofで回して、その次の要素を返すようにすることが多かった。
const getNextPageLink = async (page: Page): Promise<ElementHandle | null> => {
const pagenation = await page.$$('.pagination li')
let current = false
let nextPage = null
for (const p of pagenation) {
if (current) {
nextPage = await p.$('a')
break
}
const className = await getTextFromElement(p, 'li', 'className')
if (className == 'current') {
current = true
}
}
if (nextPage) {
return nextPage ?? null
} else {
return null
}
}
次のページがなくなるまでwhileループ
// ページネーション
let nextPageLink = await getNextPageLink(page)
while (nextPageLink) {
await nextPageLink.click()
await page.waitForSelector('.pageNav li')
console.log(page.url())
await writeCsvPageContent(browser, page)
await randomSleep(3000)
nextPageLink = await getNextPageLink(page)
if (!nextPageLink) {
break
}
}
以上の雑にmain関数にいれて実行してました
sample.ts
// import系は省略
const main = async (): Promise<void> => {
const browser = await launch(launchOptions)
const page = await initPage(browser)
await page.goto('http://example.com')
await page.waitForSelector('.pagination ul li')
}
console.log(page.url())
await writeCsvPageContent(browser, page)
// ページネーション
let nextPageLink = await getNextPageLink(page)
while (nextPageLink) {
await nextPageLink.click()
await page.waitForSelector('.pagination ul li')
console.log(page.url())
await writeCsvPageContent(browser, page)
await randomSleep(5000)
nextPageLink = await getNextPageLink(page)
if (!nextPageLink) {
break
}
}
await browser.close()
}
main()
package.jsonに書いたscriptで実行
yarn start:sample