crawling with nodejs

베트남의 모든 은행들과 그 지점정보들의 목록이 필요했다.

다음 목록은 내가 스크립트를 짜면서 가져다 쓴 라이브러리와 그 용도.

  1. cheerio : 서버에서 HTML document를 파싱하는 데에 프론트엔드 개발자인 내가 편한 server-side jQuery를 사용.
  2. lodash : 그냥 스크립트 짤 때 짜기 귀찮은 함수들 어지간히 다 있어서 걍 임포트. 적당히 trim, uniqWith, isEqual 정도를 사용.
  3. sprintf-js : 대부분의 프로그래머가 익숙한 C 스타일의 printf 를 자바스크립트로 포팅해놓은 것. for-loop 내부에서 url 포매팅에 사용.
  4. sync-request : get 보내는 데에 사용. 비동기 + 프로미스를 사용하려고 했는데, 간단한 스크립트 짜는 건데.. + 직전 리퀘스트가 다음 리퀘스트에 영향을 주는 식으로 짜서 그냥 속편하게 동기로
  5. sha1 : 동기라서 엄청 느림. 그래서 html response를 url을 기준으로 해싱해서 텍스트파일로 저장하는 데에 사용.

 


 

RESTful api + SPA 였으면 jQuery도 없이 JSON으로 포매팅된 데이터를 바로 얻을 수 있었겠지만, 그런 상황이 아니었음. 이 링크에서 베트남 은행을 긁어오는데 bank > province > branch 의 순서로 계층이 있었고,  더 세부적으로 district도 있던데 원하는 데이터가 아니라서 제외. 일단 1차원적인 bank, province, branch를 가진 테이블 row들의 배열을 크롤링한 뒤에, 이 배열을 적당히 계층화할 생각을 함.

거기에 이 사이트의 특징이라면, 항목에 기본값 0을 넣고 get req를 보내면 모든 항목이 뜬다는 것. 그래서 복잡한 4중 루프가 아니라 그나마 익숙한 2중 루프를 돌 수 있게 바꿨다. 그래서, 바꿔줄 url 파라미터는 다음으로 정했다.

> http://vayvontieudung.com.vn/index.php
branch_bank=0&branch_province=%d
&district=0&com=search&ctr=search&act=searchDiemGiaoDich&p=%d

오직 두 부분만 sprintf-js를 사용할 예정. provincepagination param이 바로 그것이다.

 


 

그럼 이제 본격적으로 크롤링을 시작해보자. URL for-loop에 필요한 province는 select > option 에서 발견할 수 있다.

var getList = function(selector) {
  var result = [];
  var list = $(selector).contents();
  // console.log(list.length)
  for (var i = 0; i < list.length; i++) {
    var option = list[i];
    result.push({
      viewValue: __.trim(option.data),
      modelValue: Number.parseInt(option.parent.attribs.value)
    });
  }
  return result;
};
var provinceList = __.uniqWith(getList('#branch_province option'), __.isEqual);

위 코드의 provinceList 에는 다음과 같은 값이 들어간다.

[{"viewValue":"Hà Nội","modelValue":4},{"viewValue":"TP.HCM","modelValue":15},{"viewValue":"An Giang","modelValue":31},{"viewValue":"Bắc Ninh","modelValue":32},{"viewValue":"Bạc Liêu","modelValue":33},{"viewValue":"Bình Dương","modelValue":34},{"viewValue":"Bình Phước","modelValue":35},{"viewValue":"Bình Thuận","modelValue":36},{"viewValue":"Bình Định","modelValue":104},{"viewValue":"Bắc Cạn","modelValue":109},{"viewValue":"Bắc Giang","modelValue":110},{"viewValue":"Bến tre","modelValue":111},{"viewValue":"Cần Thơ","modelValue":37},{"viewValue":"Cà Mau","modelValue":102},{"viewValue":"Cao Bằng","modelValue":112},{"viewValue":"Đà Nẵng","modelValue":38},{"viewValue":"Đồng Nai","modelValue":39},{"viewValue":"Đồng Tháp","modelValue":40},{"viewValue":"Đăk Lăk","modelValue":105},{"viewValue":"Đăk Nông","modelValue":113},{"viewValue":"Điện Biên","modelValue":114},{"viewValue":"Gia Lai","modelValue":55},{"viewValue":"Hưng Yên","modelValue":41},{"viewValue":"Hải Phòng","modelValue":43},{"viewValue":"Hải Dương","modelValue":95},{"viewValue":"Hà Nam","modelValue":97},{"viewValue":"Hà Giang","modelValue":115},{"viewValue":"Hà Tĩnh","modelValue":117},{"viewValue":"Hậu Giang","modelValue":120},{"viewValue":"Hòa Bình","modelValue":121},{"viewValue":"Kiên Giang","modelValue":42},{"viewValue":"Khánh Hòa","modelValue":56},{"viewValue":"Kon Tum","modelValue":123},{"viewValue":"Long An","modelValue":44},{"viewValue":"Lạng Sơn","modelValue":103},{"viewValue":"Lâm Đồng","modelValue":107},{"viewValue":"Lào cai","modelValue":118},{"viewValue":"Lai Châu","modelValue":124},{"viewValue":"Nghệ An","modelValue":98},{"viewValue":"Ninh Bình","modelValue":99},{"viewValue":"Ninh Thuận","modelValue":106},{"viewValue":"Nam Định","modelValue":119},{"viewValue":"Phú Thọ","modelValue":96},{"viewValue":"Phú Yên","modelValue":126},{"viewValue":"Quảng Nam","modelValue":46},{"viewValue":"Quảng Ninh","modelValue":47},{"viewValue":"Quảng Ngãi","modelValue":108},{"viewValue":"Quảng Bình","modelValue":127},{"viewValue":"Quảng Trị","modelValue":128},{"viewValue":"Sơn La","modelValue":48},{"viewValue":"Sóc Trăng","modelValue":129},{"viewValue":"Tây Ninh","modelValue":49},{"viewValue":"Thái Nguyên","modelValue":50},{"viewValue":"Thừa Thiên Huế","modelValue":51},{"viewValue":"Tiền Giang","modelValue":54},{"viewValue":"Thái Bình","modelValue":100},{"viewValue":"Thanh Hóa","modelValue":101},{"viewValue":"Trà Vinh","modelValue":130},{"viewValue":"Tuyên Quang","modelValue":131},{"viewValue":"Vĩnh Phúc","modelValue":52},{"viewValue":"Vĩnh Long","modelValue":53},{"viewValue":"Vũng Tàu","modelValue":57},{"viewValue":"Yên Bái","modelValue":132}];

 


 

HTTP 리퀘스트와 캐싱을 담당하는 Fetcher 클래스도 만들었다.

var fileExists = function (filePath) {
  try {
    return fs.statSync(filePath).isFile();
  } catch (err) {
    return false;
  }
}

function Fetcher() {
}
Fetcher.prototype.getFilePathFromUrl = function(url) {
  return './cached/' + sha1(url);
}
Fetcher.prototype.fetch = function(url) {
  var filePath = this.getFilePathFromUrl(url);
  if (fileExists(filePath)) {
    p('hit');
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  }
  else {
    p('miss');
    var res = request('get', url);
    fs.writeFileSync(filePath, JSON.stringify(res), 'utf-8');
    return res;
  }
}

파일이 있는지 (캐싱이 되었는지) 확인해서 반복적인 호출을 줄였다. 사람일이라는게 한 번에 잘하기가 쉽지 않더라고ㅋㅋㅋㅋ 꼭 캐싱하자! 굳이 sha1를 쓴 이유는 / 와 같은 유닉스 파일시스템에서 쓸 수 없는 문자를 제거하기 귀찮아서


 

var result = [];

var i, j, provinceNumber = provinceList.length, fetcher = new Fetcher();

for (i = 0; i < provinceNumber; ++i) {
  for (j = 1; ; j++) {
    var url = 'http://vayvontieudung.com.vn/index.php?branch_bank=0&branch_province=%d&district=0&com=search&ctr=search&act=searchDiemGiaoDich&p=%d';
    url = sprintf(url, provinceList[i].modelValue, j);
    console.log('get ', url);
    
    var res = fetcher.fetch(url);
    var list = cheerio('body > div.wrapper > div.content > div.wrapper_left > div.sh_box.search_point_map > div.search_list > div.search_result_table > table > tr', res.body.toString());
    if (list.length === 1) {
      j = 0;
      break;
    }
    for (var a = 1; a < list.length; a++) {
      var tr = list[a];
      var branch = __.trim(cheerio(tr.children[1]).text());
      var bank = __.trim(cheerio(tr.children[3]).text());
      var location = __.trim(cheerio(tr.children[5]).text());
      var province = provinceList[i].viewValue;

      result.push({
        branch,
        bank,
        location,
        province,
      });
    }
  }
}

– 리스트에 아무것도 없으면 (list.length === 1) 다음 province 루프로 진행하는 코드가 있어서! 그래서! sync-request를 사용하게 된 것.

  • jQuery selector는 크롬 개발자도구에서 얻어온 것을 살짝 변형했는데, 그 이유는 터미널에서 curl로 긁어온 값에는 분명히 tbody가 없는데 크롬에서 찍어보면 있더라고. 똑똑하신 갓 크롬님이 tbody 엘리먼트로 한 번 감싸주신 듯 함.

 


 

이후에는 fs.writeFileSync('result.txt', JSON.stringify(result), 'utf-8'); 로 결과값을 저장했고 그 결과의 일부는 다음과 같다.

[{“branch”:”PGD Gò Cát”,”bank”:”ACB – Ngân Hàng TMCP Á Châu”,”location”:”973, Tân Kỳ Tân Quý, P.Bình Hưng Hòa A, Q.Bình Tân”,”province”:”TP.HCM”},{“branch”:”PGD Lê Văn Khương”,”bank”:”ACB – Ngân Hàng TMCP Á Châu”,”location”:”201, Lê Văn Khương, P. Hiệp Thành, Q.12″,”province”:”TP.HCM”},{“branch”:”PGD Tân Chánh Hiệp”,”bank”:”ACB – Ngân Hàng TMCP Á Châu”,”location”:”248-250-252-254, Tô Ký, P. Tân Chánh Hiệp, Q.12″,”province”:”TP.HCM”},…]

 


 

그리고 이 선형적인 table row들의 배열을 계층을 가진 것으로 바꾸는 코드는 다른 파일에 새롭게 짜서 result.txtformatted-result.txt를 생성했고 그 결과의 일부는 다음과 같다.

[{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Gò Cát”,”branchLocation”:”973, Tân Kỳ Tân Quý, P.Bình Hưng Hòa A, Q.Bình Tân”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Lê Văn Khương”,”branchLocation”:”201, Lê Văn Khương, P. Hiệp Thành, Q.12″}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Tân Chánh Hiệp”,”branchLocation”:”248-250-252-254, Tô Ký, P. Tân Chánh Hiệp, Q.12″}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Kiến Thiết”,”branchLocation”:”43, Lê Văn Việt, P. Hiệp Phú, Q. 9″}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Gò Mây”,”branchLocation”:”745, Lê Trọng Tấn, P.Bình Hưng Hòa, Q.Bình Tân”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”CN Củ Chi”,”branchLocation”:”863, KP. 5, Quốc Lộ 22, TT. Củ Chi, H. Củ Chi, Tp.HCM”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Linh Xuân”,”branchLocation”:”26, Quốc lộ 1K, KP3, P. Linh Xuân, Q. Thủ Đức”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Nguyễn Ảnh Thủ”,”branchLocation”:”10 B/A, Nguyễn Ảnh Thủ, P. Trung Mỹ Tây, Q.12″}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Tô Ký”,”branchLocation”:”B87-B88, Tô Ký, P. Thới Tam Thôn, Q.Hóc Môn”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Hóc Môn”,”branchLocation”:”5/4, Lý Thường Kiệt, KP.2, TT Hóc Môn. Huyện Hóc Môn”}]}]},{“bankName”:”ACB – Ngân Hàng TMCP Á Châu”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”PGD Bình Chánh”,”branchLocation”:”A11/11, Ấp 1, Xã Bình Chánh, H. Bình Chánh”}]}]},{“bankName”:”ABBank – Ngân Hàng TMCP An Bình”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”QTK Nguyễn Cư Trinh”,”branchLocation”:”118 Nguyễn Cư Trinh, P. Nguyễn Cư Trinh, Quận 1, Tp. Hồ Chí Minh”}]}]},{“bankName”:”ABBank – Ngân Hàng TMCP An Bình”,”provinceList”:[{“provinceName”:”TP.HCM”,”branchList”:[{“branchName”:”QTK Bình Chánh”,”branchLocation”:”PGD Bình Chánh: A13/46 Quốc lộ 1A, Xã Bình Chánh, H.Bình Chánh, Tp.Hồ Chí Minh”}]}]}, …]

 


 

예전엔 무려 Java로 크롤링 외주를 했던 적이 있었는데, 그것에 비하면 정말 쉽고 간편하다고 느꼈다. 외주 스펙을 충족하기 위해 8개 스레드로 CPU usage 100%를 찍으면서 5분주기로 24시간 돌리는 코드가 없어서 그랬겠지만 (…) 한 번 크롤링하고 땡인 경우라서 더 쉽게 느껴졌는지도? 아무튼 간단하게 크롤링하기에는 스크립트 언어가 짱짱맨이시당. 요샌 내 주력언어가 자바에서 자바스크립트로 넘어간 느낌이라서 더 그렇겠거니!!

Leave a Reply