혼자 고민해보기_ 개발/TIL (Today I Learned)

20230829(화)_ 최종프로젝트 진행

nuri-story 2023. 8. 29. 23:20

금일 달성 항목

1)  puppeteer로 채용공고 데이터 크롤링 하기


문제 해결 과정 1 - 채용공고 데이터 크롤링, 상세페이지 정보 가져오기

[문제]

채용공고 크롤링은 성공하였으나, 각각의 채용공고를 작성한 회사의 정보를 가져오기 위해서는 상세페이지에 진입해서 가져와야하는 상황이었습니다. 

 

[시도 및 해결]

1. 튜터님에게 방향성을 여쭈어보았습니다.

첫번째 방법은, Puppeteer를 통해 리스트url을 통해 채용공고 데이터를 가져오고 각각의 채용공고를 선택해서 진입하여 상세페이지 정보를 가져오는 것입니다.

두번째 방법은, 상세페이지에 진입하면 가지고 있는 회사의 ID를 (data-info)를 가져와서 매칭시키는 방법이었습니다.

추가로 데이터를 Pasing할때 너무 빠른 속도로 가져오게되면 블락을 당할 수 있으니

 

2. 채용리스트를 가져오는 nest.js코드는 그대로 두고 간단하게 테스트하기 위해서 

axios와 cheerio로 간단하게 구현하여 해당 데이터를 가져왔습니다.

const axios = require("axios");
const cheerio = require("cheerio");
const faker = require("faker");

// 상세페이지 테스트

const getHTML = async (keyword) => {
  try {
    const html = (
      await axios.get(
        ` https://www.jobkorea.co.kr/Recruit/GI_Read/${encodeURI(
          keyword
        )}?rPageCode=AM&logpath=21`
      )
    ).data;

    return html;
  } catch (e) {
    console.log(e);
  }
};

const parsing = async (page) => {
  const $ = cheerio.load(page);
  const jobs = [];

  const $jobList = $(".artReadCoInfo");
  $jobList.each((idx, node) => {
    // 이메일
    const email = faker.internet.email(); // 랜덤한 이메일

    // 비밀번호
    const password = faker.internet.password(); // 랜덤한 패스워드 생성

    // 회사명
    const title = $(node).find(".tbRow > .coInfo > h4.hd_4").text().trim();

    const introduction = faker.lorem.paragraph();

    const business = $(node)
      .find(".tbList dt:contains('산업')")
      .next("dd")
      .text()
      .trim();

    const employees = $(node)
      .find(".tbList dt:contains('사원수')")
      .next("dd")
      .text()
      .trim();

    const image = "http:" + $(node).find(".coLogo img").attr("src");

    console.log(image);

    jobs.push({
      email, //랜덤 생성
      password, //랜덤 생성
      title, //회사명
      introduction, //산업
      business, // 뭐하는 곳인지
      employees, // 사원 수
      image, // 회사 이미지
    });
  });

  const $companyLocation = $(".artReadWork");
  $companyLocation.each((idx, node) => {
    const address = $(node).find(".address > strong.girIcn").text().trim();

    jobs.push({
      address,
    });
  });

  const $companyWebsite = $(".artReadJobSum");
  $companyWebsite.each((idx, node) => {
    const websiteLabel = $(node)
      .find(".tbList dt")
      .filter(function () {
        return $(this).text().trim() === "홈페이지";
      });
    const website = websiteLabel
      .next()
      .find("a.devCoHomepageLink")
      .attr("href");

    jobs.push({
      website,
    });
  });

  return jobs;
};

const getJob = async (keyword) => {
  const html = await getHTML(keyword);
  const jobs = await parsing(html);

  // console.log(jobs);
  return jobs;
};

// // 페이지 번호를 계속 바꿔주면서 추가해주는 것
// const getFullJob = async () => {
//   let jobs = [];
//   let i = 42725747;
//   while (i <= 42725748) {
//     const job = await getJob(
//       `https://www.jobkorea.co.kr/Recruit/GI_Read/${i}?rPageCode=AM&logpath=21`
//     );
//     jobs = jobs.concat(job);
//     i++;
//   }

//   console.log(jobs.length);
// };

// getFullJob();
getJob(42714430);

여기서 문제는 화면안에 필요한 데이터들이 흩어져있어  저런식으로 가져온 데이터들을 company라는 것에 하나로 묶어야하는 상황이었습니다. (웹사이트, 주소는 다른 html 영역에 있었음)

 const $companyLocation = $(".artReadWork");
  $companyLocation.each((idx, node) => {
    const address = $(node).find(".address > strong.girIcn").text().trim();

    jobs.push({
      address,
    });
  });

  const $companyWebsite = $(".artReadJobSum");
  $companyWebsite.each((idx, node) => {
    const websiteLabel = $(node)
      .find(".tbList dt")
      .filter(function () {
        return $(this).text().trim() === "홈페이지";
      });
    const website = websiteLabel
      .next()
      .find("a.devCoHomepageLink")
      .attr("href");

    jobs.push({
      website,
    });
  });

 

그래서 동료분의 도움으로두개의 영역을 분할해서 다시 파싱하는 방법으로 진행하였습니다.

companyParsing(page: string, companyId: string) {
    const $ = cheerio.load(page);
    const companies: {
      companyId: number;
      password?: string; //랜덤 생성
      title?: string; //회사명
      introduction?: string; //산업
      business?: string; // 뭐하는 곳인지
      employees?: string; // 사원 수
      image?: string;
      website: string;
      address: string;
    }[] = [];
    let company;
    const $companyList = $('.artReadCoInfo');
    $companyList.each((idx, node) => {
      // 이메일faker.internet.email()
      // const email = 'abcdefg@naver.com'; // 랜덤한 이메일
      const email = faker.internet.email(); // 랜덤한 이메일

      // 비밀번호
      const password = faker.internet.password(); // 랜덤한 패스워드 생성

      // 회사명
      const title =
        $(node).find('.tbRow > .coInfo > h4.hd_4').text().trim() || '회사 이름';

      // 소개
      const introduction = faker.lorem.paragraph();

      // 산업
      const business =
        $(node).find(".tbList dt:contains('산업')").next('dd').text().trim() ||
        '기타 산업';

      // 사원수
      const employees =
        $(node)
          .find(".tbList dt:contains('사원수')")
          .next('dd')
          .text()
          .trim() || '0';

      // 기업 이미지
      const image =
        'http:' + $(node).find('.coLogo img').attr('src') ||
        'https://img.freepik.com/free-photo/central-business-district-in-singapore_335224-638.jpg';

      company = {
        companyId: Number(companyId),
        email, //랜덤 생성
        password, //랜덤 생성
        title, //회사명
        introduction, //산업
        business, // 뭐하는 곳인지
        employees, // 사원 수
        image, // 회사 이미지
      };
      companies.push(company);
    });

    const $companyLocation = $('.artReadWork');
    $companyLocation.each((idx, node) => {
      const address =
        $(node).find('.address > strong.girIcn').text().trim() || '주소';

      companies[idx].address = address; //주소
    });

    const $companyWebsite = $('.artReadJobSum');
    $companyWebsite.each((idx, node) => {
      const websiteLabel = $(node)
        .find('.tbList dt')
        .filter(function () {
          return $(this).text().trim() === '홈페이지';
        });
      const website =
        websiteLabel.next().find('a.devCoHomepageLink').attr('href') ||
        '웹 사이트';

      companies[idx].website = website; //주소
    });
    console.log(company);

    // console.log('채용 공고 크롤링 부분:', jobs);
    // console.log('회사 크롤링 부분:', companies);
    return companies;
  }

  jobParsing(page) {
    const $ = cheerio.load(page);
    const jobs = [];
    // 채용공고 크롤링
    const $jobList = $('.devloopArea');

    $jobList.filter((idx, node) => {
      // 회사 아이디
      const dataInfo = $(node).attr('data-info'); // data-info 속성 값 가져오기
      const companyId = dataInfo.split('|')[0].trim(); // '|'로 분리된 값 중 첫 번째 값을 가져옵니다

      // 채용공고 타이틀
      const title = $(node)
        .find(' .titBx > strong > a.normalLog:eq(0)')
        .text()
        .trim();
      if (title === '') {
        return false;
      }

      // 경력
      const career = $(node)
        .find('.titBx > .etc:eq(0) > .cell:eq(0)')
        .text()
        .trim();

      // 급여 관련 // 공백은 '면접 후 결정'으로 변경
      const salary =
        $(node)
          .find('.titBx > .etc:eq(0) > .cell:eq(4)')
          .text()
          .trim()
          .replace(
            /\s*주임급\s*|\s*사원급 외\s*|\s*사원급\s*|\s*주임~대리급\s*/g,
            '',
          ) || '면접 후 결정';

      // 학력
      const education = $(node)
        .find('.titBx > .etc:eq(0) > .cell:eq(1)')
        .text()
        .trim();

      // 정규직 관련
      const workType = $(node)
        .find('.titBx > .etc:eq(0) > .cell:eq(3)')
        .text()
        .trim()
        .replace(/\s*외\s*/g, ''); // 여백과 "외" 문자열을 제거
      // 지역
      const workArea = $(node)
        .find('.titBx > .etc:eq(0) > .cell:eq(2)')
        .text()
        .trim();

      // 기타 내용
      const content = $(node).find('.titBx > .dsc:eq(0)').text().trim();

      // 채용 마감일
      const dueDate =
        $(node).find('.odd > .date:eq(0)').text().trim() || '상시 채용';

      const jobposting = {
        companyId,
        title,
        career,
        salary,
        education,
        workType,
        workArea,
        content,
        dueDate,
      };

      jobs.push(jobposting);
    });
    console.log(jobs);
    return jobs;
  }

 

그 외에도 테스트를하는 과정에서 다양한 오류가 떴습니다.

companyId를 찾을 수 없다.

company.email 를 찾을 수 없다.

timeouterror: navigation timeout of 30000 ms exceeded = 30초를 초과했다.

 

error: Error: Field 'email' doesn't have a default value
[Nest] 58551  - 08/29/2023, 11:22:15 PM   ERROR [ExceptionsHandler] Field 'email' doesn't have a default value
QueryFailedError: Field 'email' doesn't have a default value
    at Query.onResult (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/src/driver/mysql/MysqlQueryRunner.ts:222:33)
    at Query.execute (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/node_modules/mysql2/lib/commands/command.js:36:14)
    at PoolConnection.handlePacket (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/node_modules/mysql2/lib/connection.js:478:34)
    at PacketParser.onPacket (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/node_modules/mysql2/lib/connection.js:97:12)
    at PacketParser.executeStart (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/node_modules/mysql2/lib/packet_parser.js:75:16)
    at Socket.<anonymous> (/Users/hyerim/Desktop/sparta/12. jobposting-project/jobposting-project/node_modules/mysql2/lib/connection.js:104:25)
    at Socket.emit (node:events:512:28)
    at addChunk (node:internal/streams/readable:343:12)
    at readableAddChunk (node:internal/streams/readable:316:9)
    at Socket.Readable.push (node:internal/streams/readable:253:10)


아직 테스트 과정이라 제대로 파싱이 되지 않고 있지만, 성공하게 되면 다시 til을 올리겠습니다.

 

 

[알게된 점]

타입과 객체의 구조 등으로 불러오는 과정이 아직도 너무 미숙해서 실수가 많은 것 같습니다.

그리고 코드가 돌아가는 구조에 대해서도 아직도 이해가 부족하다고 느꼈고... 공부를 많이 해야할 것 같습니다.