신입생 때 데이터 수집을 한다고 크롤링을 처음 접하였다. 당시에는 파이썬으로 진행하였는데 갑자기 크롤링을 해야할 일이 생기기도 하고 자바스크립트를 메인으로 씀에 따라 puppeteer로 크롤링을 진행해보았다.
엑셀 파일에서 논문의 타이틀을 읽고 그 타이틀을 사이트에서 검색하여 약어를 추출한다. 그런데 키워드로 검색을 하였을 때 논문이 두 개 이상 있거나 없다면 크롤링을 하지 않는다.
NodeJS를 이용하여 Headless Chrome을 조작할 수 있는 라이브러리
Headless Browser: GUI가 없는 브라우저, GUI가 없어 cli로 웹을 조작하고 네트워크 통신을 구현할 수 있다.
npm install puppeteer
라이브러리를 이용하여 엑셀에서 데이터를 읽어온다.
npm install read-excel-file
let excelData = [];
await readXlsxFile('./sheet.xlsx').then((rows) => {
rows.forEach((row, i) => {
if (i === 0) { // 0행은 타이틀 행이라 제외
return;
}
const inputData = {
number: row[0],
title: row[1],
jif: row[2],
};
excelData.push(inputData);
});
});
크롤링을 병렬로 진행하면 성능이 더 좋을 것이다. 이를 위해 Chunk 단위로 데이터 리스트를 나누어준다.
const makeChunks = (arr, SIZE) => {
const chunks = [];
let s = 0;
while (s < arr.length) {
results.push(arr.slice(s, s + SIZE));
s += SIZE;
}
return chunks;
};
결과로 2차원 배열이 리턴된다.
async function crawlItem(browser, item) {
const page = await browser.newPage(); // 새로운 페이지
// 접속하는 url에 대한 header 설정
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
});
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'
);
await page.goto('여기에 url을 입력', {
waitUntil: 'networkidle2',
});
await page.type('#term', item);
await page.click('#search');
await page.waitForNavigation({ waitUntil: 'networkidle2' });
// 검색한 결과가 하나일 경우
const isMany = await page.evaluate(() => {
const element = document.querySelector(
'#maincontent > div > div:nth-child(3) > div > h3'
);
return element;
});
// 결과가 하나라면 진행
if (isMany === null) {
// $$eval === Array.from(document.querySelectorAll())
const arr = await page.$$eval(
'#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl dt',
(ele) => {
return ele.map((el) => {
return el.textContent;
});
}
);
let result = arr.findIndex((ele) => ele.includes('NLM'));
if (result >= 0) {
result = 2 * result + 1;
nlmIdx = result + 1;
const data = await page.$eval(
`#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
(ele) => ele.textContent
);
await page.close();
return data;
}
} else {
await page.close();
}
}
퍼퍼티어는 코드로 DOM 요소를 조작할 수 있다. 이 때 DOM 요소를 지정하는 방법으로는 CSS Selector을 이용한다.
// <input id='term' /> , <button id='search' />
await page.type('#term', item); // id: term인 DOM 요소에 item을 입력
await page.click('#search'); // id: search인 DOM 요소를 클릭
await page.waitForNavigation({ waitUntil: 'networkidle2' });
크롤링을 하려면 일단 그 데이터가 DOM 트리에 붙어야 한다. 이를 위해 퍼퍼티어에서는 그때까지 기다리도록 하는 여러 wait method가 있다. waitForNavigation은 페이지가 이동되었을 때 모든 요소가 렌더링이 될 때까지 대기하도록 지정한다.
evaluate와 $eval은 동작은 비슷하지만 큰 차이는 evaluate는 지정한 selector가 없을 때 null을 반환하지만 $eval은 error을 반환한다. $eval을 사용한다면 try-catch로 에러 핸들링을 해주자.
const data = await page.$eval(
`#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
(ele) => ele.textContent
);
이처럼 퍼퍼티어로 DOM 요소를 selector로 지정하여 가져올 수 있다.
동작이 끝나면 page를 닫아준다.
// data: 한 chunk
async function crawl(data) {
const browser = await puppeteer.launch({
headless: true, // false는 개발을 진행할 때
});
const promises = data.map(async (item) => {
const result = await crawlItem(browser, item.title);
return {
...item,
NLM: result,
};
});
const results = await Promise.all(promises);
await browser.close();
return results;
}
결국 promises는 Promise 객체 배열이 되고, Promise.all로 병렬 처리를 해준다.
작업이 완료된다면 브라우저를 닫아준다.
const puppeteer = require('puppeteer');
const readXlsxFile = require('read-excel-file/node');
const fs = require('fs');
// 청크 단위로 분할
const makeChunks = (arr, SIZE) => {
const chunks = [];
let s = 0;
while (s < arr.length) {
results.push(arr.slice(s, s + SIZE));
s += SIZE;
}
return chunks;
};
async function crawl(data) {
const browser = await puppeteer.launch({
headless: true,
});
const promises = data.map(async (item) => {
const result = await crawlItem(browser, item.title);
return {
...item,
NLM: result,
};
});
const results = await Promise.all(promises);
await browser.close();
return results;
}
async function crawlIteminCurrent(browser, item) {}
async function crawlItem(browser, item) {
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
});
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'
);
await page.goto('url을 여기에 입력', {
waitUntil: 'networkidle2',
});
await page.type('#term', item);
await page.click('#search');
await page.waitForNavigation({ waitUntil: 'networkidle2' });
// 검색한 결과가 하나일 경우
const isMany = await page.evaluate(() => {
const element = document.querySelector(
'#maincontent > div > div:nth-child(3) > div > h3'
);
return element;
});
if (isMany === null) {
const arr = await page.$$eval(
'#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl dt',
(ele) => {
return ele.map((el) => {
return el.textContent;
});
}
);
let result = arr.findIndex((ele) => ele.includes('NLM Title'));
if (result >= 0) {
result = 2 * result + 1;
nlmIdx = result + 1;
const data = await page.$eval(
`#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
(ele) => ele.textContent
);
await page.close();
return data;
}
} else {
await page.close();
}
}
async function main() {
// 엑셀 데이터 읽기
let excelData = [];
await readXlsxFile('./sheet.xlsx').then((rows) => {
rows.forEach((row, i) => {
if (i === 0) {
return;
}
const inputData = {
number: row[0],
title: row[1],
jif: row[2],
};
excelData.push(inputData);
});
});
const CHUNKSIZE = 6;
const chunkList = makeChunks(excelData, CHUNKSIZE);
let i = 0;
let resultArr = [];
for (chunk of chunkList) {
const product = await crawl(chunk);
resultArr.push(...product);
}
const realData = resultArr.filter((data) => data.NLM !== undefined);
const jsonDataToString = JSON.stringify(realData);
fs.writeFileSync('./dataToJSon.json', jsonDataToString);
}
main();
사이트가 이상한 것인지는 모르겠는데 검색을 진행하고 그 창에서 다시 검색을 진행하려고 하니 데이터가 안나왔다. 그래서 계속 껐다가 키는 방식을 사용해야만 했는데 이때문에 속도 저하가 많이 있다.. ㅠㅠ
또한 headless를 true로 했을 때 데이터가 밀리거나 잘못 나오는 경우가 있는데 차근차근히 해결해나가도록 하자.
글 잘 봤습니다.