개발/Node js

[Node js]웹 크롤러 만들기1-웹 페이지 기어 다니기-BFS 넓이 우선 탐색(Crawl+Scrape)

재근이 2021. 10. 11. 14:07
반응형

최종 결과물 시각화 이미지

📢이 글에서 구현할 내용

  1. 우리는 먼저 특정 URL을 Seed(시작 위치)로 입력받는다.
  2. 해당 URL의 HTML을 긁어온다.
  3. 긁어온 페이지에 있는 URL들을 수집한다.(URL은 중복 제거한다.)
  4. 깊이 우선 탐색 또는 넓이 우선 탐색 방법으로 "2."와 "3."을 반복한다.
  5. 시작 위치와 연관되지 않는다고 판단되면 더는 진행하지 않는다.
  6. 기어 다니는(크롤링) 행위를 다하고 나서 정리한 URL 테이블을 하나씩 방문해서 HTML 정보를 받아온다.
  7. 형태소 분석기를 사용해서 HTML에서 단어들을 추출하여 정리한다.

위 순서는 "웹 크롤러 만들기 0"에서 정리한 내용이다. 파란색으로 칠한 부분을 이번 글에서 구현해보자.


🧨프로젝트 초기 생성

$ mkdir crawler
$ cd crawler
$ npm init

모듈 사용하는 방식을 import로 할 경우 package.json을 수정해준다.
package.json에 다음과 같이 "type": "module", 을 추가해주자. 혹은 이후 코드에 나오는 import 대신 require을 사용해도 된다.

📑기본 모듈로 페이지 긁어오기

기본 모듈로 탑재된 https(또는 http)를 사용해서 아래와 같이 우리가 원하는 웹 페이지를 가져올 수 있다.

const https = require('https');

const url = "https://www.naver.com";

https.get(url, stream => {
    let rawdata = '';
    stream.setEncoding('utf8');
    stream.on('data', buffer => rawdata += buffer);
    stream.on('end', function () {
        console.log(rawdata);
    });
});

네이버 긁어온 내용 일부

 

📑request 모듈로 페이지 긁어오기

좀 더 다양한 기능들을 사용하기 위해서, 그리고 약간 더 심플하게 코드를 작성하기 위해서 나는 http/https 모듈을 사용하지 않고 request 모듈을 이용하겠다. 사용하기 위해서 먼저 설치해주자.

$ npm install --save request
async function getResponse(url) {
    const options = {
        url: url,
        method: 'GET',
        timeout: 10000,
    };
    try {
        return new Promise((resolve, reject) => {
            request.get(options, function (err, resp) {
                if (err) {
                    reject(err);
                } else {
                    resolve(resp);
                }
            });
        });
    } catch (e) {
        return null;
    }
}

async function test() {
    const resp = await getResponse('https://www.naver.com');
    console.log(resp.body);
}

test();

await 가능하게 getResponse 함수를 만들어 준다. 이렇게 특정 페이지의 HTML을 가져오는 코드를 후딱 작성하자.

 

📑HTML안에 있는 URL들 가져오기

request 모듈로 가져온 HTML안에 있는 URL들을 파싱 해보자.

파싱에 사용될 모듈dom-parser 이다. 설치해주자.

$ npm install --save dom-parser

dom-parser 모듈을 이용해서 HTML에 있는 a 태그href들을 모두 가져오자.

const DomParser = require('dom-parser');
const parser = new DomParser();

// getResponse 코드들
// ...

async function getUrlLinks(url) {
    try {
        const response = await getResponse(url);
        if (response.request.responseContent.statusCode != 200) return null;
        const dom = parser.parseFromString(response.body);
        const aList = dom.getElementsByTagName('a');
        return aList.map(el => {
            return el.getAttribute('href')
        });
    } catch (e) {
        return null;
    }
}

async function test() {
    const resp = await getUrlLinks('https://www.naver.com');
    console.log(resp);
}

test();

HTML에서 파싱한 URL들

 

🚿URL 필터링

URL들을 긁어서 출력해보면 중복된 것들이나 필요 없는 href들이 있어 예외처리가 필요하다. 또한 hrefhttp/https로 시작하는 절대 경로가 적혀있을 수 있지만, 아닌 경우도 있다.

그리고 우리는 모든 URL들을 기어 다니지 않고, Seed URL관련되어있는지는 Origin Host URL을 확인해서 관련된 곳기어 다닐 거다.

우선 getUrlLinks 함수에 예외처리를 해서 의미 있는 URL들만 가져오도록 수정하자.

async function getUrlLinks(url) {
    try {
        const response = await getResponse(url);
        if (response.request.responseContent.statusCode != 200) return null;
        const dom = parser.parseFromString(response.body);
        const aList = dom.getElementsByTagName('a');
        let urlList = aList.map(el => {
            const url = el.getAttribute('href')
            if(url == null || url.indexOf('#') == 0 || url == 'javascript:;') {
                return null;
            } else if (url?.indexOf('http') == 0){
                return url;
            }
            const protocol = response.request.req.protocol;
            const hostUrl = response.request.originalHost;
            if (url.indexOf('/') == 0) {
                return protocol + "//" + hostUrl + url;
            } else {
                return protocol + "//" + hostUrl + "/" + url;
            }
        });
        return urlList.filter(url=>url!=undefined);
    } catch (e) {
        return null;
    }
}

Seed URL과 관련 있는지 확인하기 위한 Seed URLOrigin Host 주소를 가져오는 getSeedOriginHost함수를 만들자.

async function getSeedOriginHost(seedUrl) {
    const response = await getResponse(seedUrl);
    console.log(response.request.originalHost);
    return response?.request.originalHost;
}

getUrlLinks 함수에서 긁어온 URL들을 필터링살짝 더 해보자. 중복된 URL이나 이전에 확인했던 URL들은 건너뛰도록 하고 Origin Host 주소가 다르면 버리도록 하는 getFilteredUrls라는 함수를 작성하자. resultUrls는 최종적으로 의미 있는 URL를 담는 Array이고, skipUrls은 이전 확인한 URL들을 넘어가기 위해 만든 Set 자료구조이다.

let resultUrls = [];
let skipUrls = new Set();
async function getFilteredUrls(urls) {
    let newUrls = [];
    for (let i = 0; i < urls?.length; i++) {
        try {
            const newUrl = removeLastSlash(urls[i]);
            if (skipUrls.has(newUrl)) {
                console.log("skip url");
                continue;
            }
            skipUrls.add(newUrl);
            console.log("newUrl:", newUrl);
            console.log("resultUrls.includes(newUrl):", resultUrls.includes(newUrl));
            if (resultUrls.includes(newUrl) == false) {
                const response = await getResponse(urls[i]);
                console.log("seedOriginHost:", response.request.originalHost);
                if (seedOriginHost == response.request.originalHost) {
                    console.log("url push(urls[i]) : ", urls[i]);
                    resultUrls.push(urls[i]);
                }
            }
        } catch (e) {
            return null;
        }
    }
}

function removeLastSlash(url) {
    if (url[url.length-1] == '/') { return url.slice(0, -1);}
    else { return url; }
}

필터링을 위해 만든 함수들을 조합해서 출력해보자. seedUrl을 "https://www.naver.com" 그냥 네이버 메인을 하게 되면 Origin Host와 같은 URL거의 나오지 않기에 아래 코드에서는 "https://news.naver.com/" 네이버 뉴스 페이지를 긁도록 했다.

const seedUrl = 'https://news.naver.com/';
let seedOriginHost;

async function test() {
    try {
        seedOriginHost = await getSeedOriginHost(seedUrl);
        const resp = await getUrlLinks(seedUrl);
        await getFilteredUrls(resp);
        console.log("urls:", resultUrls);
    } catch (e) {
        console.log(e);
    }
}

test();

필터링된 URL들

 

🚀넓이 우선 탐색(BFS)을 이용한 Crawl!

크롤링(Crawling)의 의미인 기어 다니는, 진짜 기어 다니는 코드구현해보자. Seed URL을 시작으로 페이지에 있는 URL들을 방문해서 해당 사이트의 URL들을 모아 볼 것이다. 조건 없이 모든 URL을 수집하게 되면 이건 끝이 없게 된다. 앞에서 이용한 Origin Host 주소 필터링을 통해 우린 Origin Host가 같은 URL들만 샅샅이 것이다.

BFS(넓이 우선 탐색) 방식으로 URL들을 모두 돌아다니도록 구현했다.

// 앞에 구현한 코드들
// ...

async function bfs() {
    let cur = 0;
    resultUrlsArray.push(seedUrl);
    while (cur < resultUrlsArray.length) {
        try {
            const tempUrls = await getUrlLinks(resultUrlsArray[cur++]);
            await getFilteredUrls(tempUrls);
        } catch (e) {}
    }
}

async function crawlWebPage() {
	try {
		seedOriginHost = await getSeedOriginHost(seedUrl);
		await bfs();
	} catch (e) { }
}

crawlWebPage();

개인 페이지 기어다닌 결과

📄최종 코드

앞에서 구현한 내용들을 모두 합친 최종 코드이다. 필요하다면 더보기를 눌러서 확인하자.

더보기
const request = require('request');
const DomParser = require('dom-parser');
// import request from 'request';
// import DomParser from 'dom-parser';
const parser = new DomParser();
const seedUrl = 'https://news.naver.com';

let seedOriginHost;
let resultUrlsArray = [];
let skipUrlsArray = new Set();

crawlWebPage();

async function crawlWebPage() {
    try {
        seedOriginHost = await getSeedOriginHost(seedUrl);
        await bfs();
        console.log('after bfs');
        console.log(resultUrlsArray);
    } catch (e) {
        console.log(e);
    }
}

async function getSeedOriginHost(seedUrl) {
    const response = await getResponse(seedUrl);
    console.log(response.request.originalHost);
    return response?.request.originalHost;
}

async function getResponse(url) {
    const options = {
        url: url,
        method: 'GET',
        timeout: 10000,
    };
    try {
        return new Promise((resolve, reject) => {
            request.get(options, function (err, resp) {
                if (err) {
                    reject(err);
                } else {
                    resolve(resp);
                }
            });
        });
    } catch (e) {
        return null;
    }
}

async function bfs() {
    let cur = 0;
    resultUrlsArray.push(seedUrl);
    while (cur < resultUrlsArray.length) {
        try {
            const tempUrls = await getUrlLinks(resultUrlsArray[cur++]);
            await getFilteredUrls(tempUrls);
        } catch (e) {}
    }
}

async function getUrlLinks(url) {
    try {
        const response = await getResponse(url);
        if (response.request.responseContent.statusCode != 200) return null;
        const dom = parser.parseFromString(response.body);
        const aList = dom.getElementsByTagName('a');
        let urlList = aList.map(el => {
            const url = el.getAttribute('href')
            if(url == null || url.indexOf('#') == 0 || url == 'javascript:;') {
                return null;
            } else if (url?.indexOf('http') == 0){
                return url;
            }
            const protocol = response.request.req.protocol;
            const hostUrl = response.request.originalHost;
            if (url.indexOf('/') == 0) {
                return protocol + "//" + hostUrl + url;
            } else {
                return protocol + "//" + hostUrl + "/" + url;
            }
        });
        return urlList.filter(url=>url!=undefined);
    } catch (e) {
        return null;
    }
}

async function getFilteredUrls(urls) {
    for (let i = 0; i < urls?.length; i++) {
        try {
            const newUrl = removeLastSlash(urls[i]);
            if (skipUrlsArray.has(newUrl)) {
                console.log("skip url");
                continue;
            }
            skipUrlsArray.add(newUrl);
            console.log("newUrl:", newUrl);
            if (resultUrlsArray.includes(newUrl) == false) {
                const response = await getResponse(urls[i]);
                if (seedOriginHost == response.request.originalHost) {
                    console.log("url push(urls[i]) : ", urls[i]);
                    resultUrlsArray.push(urls[i]);
                }
            }
        } catch (e) {
            return null;
        }
    }
}

function removeLastSlash(url) {
    if (url == '/') {
        return url.slice(0, -1);
    } else {
        return url;
    }
}

 

📌마치며

대형 사이트 같은 URL이 많은 사이트는 시간이 오래 걸릴 것이다. 네이버 뉴스 페이지를 기어 다녀 봤을 때 약 1시간 30분 정도 돌려도 끝나질 않아 도중에 정지 했다.

테스트를 위해서는 간단한 사이트를 만들어 테스트하는 것을 추천한다. 최종적으로 테스트할 때를 제외하곤 개인 페이지(http://146.56.138.212:3000/)에서 테스트를 자주 했다. (페이지가 열려있다면 사용해도 좋다.)

크롤링을 하게 되면 크롤링하는 곳에 서버 부담이 갈 수도 있기에 피해가 가지 않는 곳을 선정해서 테스트해야 한다.

다음 글에서는 이렇게 돌아다니는 것으로 끝나는게 아니라 HTML에서 단어들을 추출해서 어떠한 단어들이 포함 되어 있는지 분석해보자.

반응형