みなさんこんにちは、高校を卒業してクラウドソーシングでお小遣いを稼いでいるmurabito です。
とあるサイトから指定した検索条件で検索し、その検索結果から詳細ページに飛んで全ての詳細情報をスクレイピングする方法のメモです。
(※Rx初心者がイキって書いているので間違えているかもです。マサカリはいくらでも投げてください。)
Rxとは?
Rxというのは、Reactive Extensions の略です。
関数指向のライブラリで、非同期処理をみやすくし、コールバック地獄を解消してくれるすごいやつです。
具体的には、
requestで情報取得
↓
mapで結果を元に修飾
↓
subscribeで結果をconsole.logで表示する
などの動作を以下でできます。
import * as request from 'request-promise'
import * as Rx from 'rx'
Rx.Observable.fromPromise(request('https://hogehoge'))
.map(d=>'data:'+d)
.subscribe(console.log)
cheerio-httpcliとは?
cheerio-httpcliとは、Node.JSの標準ライブラリrequestを使ってHTML・XMLを取得し、cheerioというJQuery風のクラスに変換してくれます。
またcheerio-httpcliのREADMEが日本語で書かれているので良いです。
Rx+cheerio-httpcliを使って商品詳細を全て取得
今回は、例としてbic cameraからスクレイピングを行なってみます。
まずclient.fetchをObservableに変換
client.fetchはPromiseなため、Observableに変換する必要があります。
Promise→ObservableにはfromPromise関数を使います。
またいちいちビックカメラのURLを書くのは面倒なので関数化します。
function fetchObservable(url: string, params:{[key:string]:any}) {
return Rx.Observable.fromPromise(client.fetch(url,params))
.map((result) => {
console.log(url)
return result.$
})
}
function fetchBCItemList(searchObject: :{[key:string]:any}) {
const url = `https://www.biccamera.com/bc/category/?#bcs_resultTxt`
return fetchObservable(url,searchObject)
}
商品結果ページを全取得
検索結果ページを全て取得するために、検索件数を取得します。
ビックカメラであれば、#bcs_resultTxtの中のemの部分が相当します。
検索件数をparseIntし、rowPerPageで割ったものを切り上げる。
これで最終ページ数を割り出し、最初のページ(p=1)よりも大きければrange関数で2から計算結果までのクエリのpを変更させて行き、全ページを取得します。
const scrapingItemListObservable = (queries: {[key:string]:any}) =>
Rx.Observable.if(
() => (!!queries && !!queries.q),
Rx.Observable.of({
...queries,
rowPerPage: 100,
type: 1,
p: 1
})
)
.concatMap(searchObject =>
fetchBCItemList(searchObject)
.map($ => ({ $, searchObject: searchObject }))
)
.catch(e =>
(e['statusCode'] !== 403) ? Rx.Observable.empty() : Rx.Observable.throw(e))
.concatMap(({ $, searchObject }) => {
const page = Math.min(
Math.ceil(
(parseInt($('#bcs_resultTxt')
.find('em')
.text()
) || 3 - 2) / searchObject.rowPerPage
),
MAX_PAGE - 1
)
return Rx.Observable.if(
() => page > 2,
Rx.Observable.range(
2,
page
)
.concatMap(p =>
fetchBCItemList({ ...searchObject, p })
)
)
.startWith(Rx.Observable.of($))
})
.map($=>{
//スクレイピング処理
})
商品結果から商品詳細ページをスクレイピング
bic cameraには、商品コードを使ってリンクができているので、商品コードから商品詳細ページをスクレイピングを行えるようにします。
const scrapingDetailObservable = (id: string) =>
fetchBCDetail(id)
.map($ => {
// スクレイピング処理
})
アクセス制限をウェイト時間をかけて全力で拒否する
ビックカメラでは高頻度でスクレイピングを行うとブラックリストに載ってアクセスが精選されます。
そこで、十分に待ち時間をかけて実行します。
一つ一つ実行させる
あえて検索結果ページのスクレイピングをconcatMapではなくmap関数を使い、Observableを返すことで実行されるタイミングを変更することができます。
scrapingItemListObservableを以下のように変更します。
const scrapingItemListObservable = (queries: {[key:string]:any}) =>
Rx.Observable.if(
() => (!!queries && !!queries.q),
Rx.Observable.of({
...queries,
rowPerPage: 100,
type: 1,
p: 1
})
)
.concatMap(searchObject =>
fetchBCItemList(searchObject)
.map($ => ({ $, searchObject: searchObject }))
)
.catch(e =>
(e['statusCode'] !== 403) ? Rx.Observable.empty() : Rx.Observable.throw(e))
.concatMap(({ $, searchObject }) => {
const page = Math.min(
Math.ceil(
(parseInt($('#bcs_resultTxt')
.find('em')
.text()
) || 3 - 2) / searchObject.rowPerPage
),
MAX_PAGE - 1
)
return Rx.Observable.if(
() => page > 2,
Rx.Observable.range(
2,
page
)
.map(p =>
fetchBCItemList({ ...searchObject, p })
)
)
.startWith(Rx.Observable.of($))
})
.map(obs =>
obs
.flatMap($ => $('.bcs_boxItem .prod_box')
.toArray()
.map(el => el.attribs['data-item-id'])
)
)
1つ1つにランダムウェイトをかける
ランダムウェイトをかけることによってアクセス制限を回避することができます。
delay関数を使い、ウェイト時間を指定して処理を遅らせることができます。
これをfetchObservableにつけることでスクレイピングする際にウェイトをかけることができます。
またウェイト時間をランダムにしてより人間らしくすることができます。
const MAX_WAIT_SEC = 10 * 1000
const MIN_WAIT_SEC = 5 * 1000
function fetchObservable(url: string, parms:{[key:string]:any}) {
return Rx.Observable.fromPromise(client.fetch(url))
.map((result) => {
console.log(url)
return result.$
})
.delay(Math.random() * (MAX_WAIT_SEC - MIN_WAIT_SEC) + MIN_WAIT_SEC)
}
終わりに
Rxとcheerio-httpcliを使った記事があまり出ていなかったので、少し書かせていただきました。
みなさんの助けになっていれば幸いです。
Qiitaで描き始めてまだ2作目なので書き方に問題があるかもしれませんので、ご自由にどうぞ。