[Next.js] Puppeteer로 CSR 페이지 크롤링하기

김방울·2023년 12월 31일
0

Next.js

목록 보기
5/6

Next.js 12.3.1 기준으로 작성되었습니다. (13버전 이하)

이전에 Cheerio.js 로 Velog 글 목록을 크롤링해오는 api를 포스팅 한 적 있었는데, (https://velog.io/@kimbangul/Next.js-Cheerio.js-%EB%A1%9C-Velog-%ED%81%AC%EB%A1%A4%EB%A7%81%ED%95%98%EA%B8%B0)
스켈레톤 ui도 추가된 것 같고, 포스팅을 렌더링하는 방식이 전과 변경점이 생겼는지 cheerio.js 만으로는 크롤링이 이루어지지 않아
Puppeteer + chromium 으로 백그라운드에서 크롬을 실행하여 크롤링을 시도해 보았습니다.

Vercel을 통해 배포했는데, 50MB 용량 제한이랑 executablePath 설정에서 많이 애를 먹었습니다..😥

결론적으로 서버 측에서 크롤링은 성공했는데, 함수 실행시간에 10초 제한이 걸려있어
첫 요청 시 504 gateway time-out 에러가 발생하는 문제를 확인했습니다.
플랜을 업그레이드하거나 배포 플랫폼을 변경하면 해결될 것 같은데, 그냥 velog api에 직접 접근해서 게시물을 가져오도록 변경했습니다.😂

배포환경 문제로 실적용에는 실패했지만, 헤드리스 모드로 csr 페이지를 크롤링하는 방법에 대해 공부했다는 것에 의의를 두고 포스팅을 적어보고자 합니다.

Puppeteer란?

Chrome DevTools 프로토콜을 이용하여 Chrome/Chromium을 제어할 수 있게 해 주는 Node.js 라이브러리로, 기본적으로 헤드리스(표시되는 UI 없이 백그라운드에서 브라우저를 실행) 모드에서 동작합니다.

라이브러리 설치

npm install puppeteer-core // or yarn add puppeteer-core
npm install @sparticuz/chromium-min

배포 환경에서 50MB 이상 파일을 배포하지 못하도록 제한이 걸려 있어서, puppeteer가 아닌 puppeteer-corechromium-min을 설치했습니다. 배포 환경에 제한이 없다면 npm install puppeteer 명령어만 실행해도 무방합니다. (Chromium을 포함한 모든 실행 파일이 포함되어 있음)

환경변수 설정

NEXT_PUBLIC_BLOG_URL=https://velog.io/@kimbangul/posts
NEXT_PUBLIC_CDN_LINK=/* chromium 파일을 올린 링크 입력 */
NEXT_LOCAL_CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome /* 로컬 Chrome path */ 

크롬 실행 path는 chrome://version/ 에서 확인할 수 있습니다.

cdn에 chromium 실행 파일 업로드

호스팅 환경에 50MB 업로드 제한이 걸려있기 때문에 puppeteer-core 를 설치해야 할 경우, brotli 파일을 파일서버/cdn에 업로드 해 주어야 합니다.

https://github.com/Sparticuz/chromium/releases 에서 tar파일을 다운받을 수 있습니다.

처음에 최신 버전이 아닌 이전 버전 파일을 업로드했었는데, 서버에 호스팅했을 시 fonts.tar.br 파일을 찾을 수 없다고 에러가 출력됐습니다. 해당 파일이 포함된 최신 버전 tar파일으로 교체하여 문제를 해결했습니다.

api 코드 작성

html을 파싱하는 부분은 https://velog.io/@kimbangul/Next.js-Cheerio.js-%EB%A1%9C-Velog-%ED%81%AC%EB%A1%A4%EB%A7%81%ED%95%98%EA%B8%B0 게시물과 거의 유사합니다.

// api/crawler/index.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import cheerio, {Element} from 'cheerio';
import { ContentType } from './type';
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium-min';

// background에서 브라우저를 열어서 내용을 가져오는 함수
const openBrowser  = async (url: string) => {
  chromium.setHeadlessMode = true;
  chromium.setGraphicsMode = false;

  // 크로미움으로 브라우저를 연다. 
  const browser = await puppeteer.launch(
    process.env.NODE_ENV === 'development' ?
    // 로컬 실행 환경
    {
      headless: true,
      executablePath: process.env.NEXT_LOCAL_CHROME_PATH,
    }
    :
    // 서버 실행 환경
    {
      args: [...chromium.args, '--hide-scrollbars', '--disable-web-security', "--no-sandbox", "--disable-setuid-sandbox"],
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath(`${process.env.NEXT_PUBLIC_CDN_LINK}/chromium/chromium-v119.0.2-pack.tar`
       ),
      headless: chromium.headless,
      ignoreHTTPSErrors: true
    }
  ); 
        
  // 페이지 열기
  const page = await browser.newPage();
        
  // 링크 이동
  await page.goto(url, {
    waitUntil: "networkidle2" // 500ms 동안 두 개 이상의 네트워크 연결이 없을 때 탐색이 완료되는 것으로 간주
  });

  //4. HTML 정보 가지고 온다.
  const content  : string= await page.content();
  console.log(content);
          
  //5. 페이지와 브라우저 종료
  await page.close();

  return content;
}

// openBrowser() 를 통해 불러온 html을 파싱하는 함수
// 이 부분은 https://velog.io/@kimbangul/Next.js-Cheerio.js-%EB%A1%9C-Velog-%ED%81%AC%EB%A1%A4%EB%A7%81%ED%95%98%EA%B8%B0 와 거의 동일합니다.(cheerio.load() 부분과, 태그 클래스명만 변경) 
const getHtml = async (url : string) => {
  try {  
    const $ = cheerio.load(await openBrowser(url));
    console.log($);

    let content : ContentType[] = [];
    const ARTICLE_SELECTOR= $("main section > div:nth-child(2) > div:nth-child(3) > div");

    // FUNCTION get tag
    const getTag = (tagSelector : Element) => {
      let result : string[] = []
      
      const tagList = $(tagSelector).find(".FlatPostCard_tagsWrapper__iNQR3 > a");

      tagList.map((idx,el)=>{
        const tag = $(el).text();
        result[idx] = tag;
      });
    
      return result;
    }

    ARTICLE_SELECTOR.map((idx, el) => {     
      content[idx] = {
        head: $(el).find("img").attr('src'),
        date: $(el).find(".FlatPostCard_subInfo__cT3J6 > span:first-of-type").text(),
        context: $(el).find("p").text(),
        href: $(el).find("a:first-child").attr('href'),
        headline: $(el).find("h2").text(),
        tags: getTag(el),
      };
    });
    return content;
  }
  catch(e){
    console.log(e);
  }
}

const article = getHtml(process.env.NEXT_PUBLIC_BLOG_URL || '');

export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse)  {  
  res.status(200).json(await article);
}

클라이언트에서 데이터 받아오기

// test.tsx
import axios from 'axios';
import { NextPage } from 'next';
import { useEffect, useMemo } from 'react';

const Home: NextPage = () => {
  useEffect(()=> {
    axios.get(`api/crawler`).then((res)=>{
      console.log(res);
    })
  }, []);
  return (
    <>
    <h1>test page</h1>
    </>
  );
};

export default Home;
  
  //...(생략)...

크롤링한 데이터가 잘 들어온 것을 확인할 수 있습니다.😀

참고자료

profile
코딩하는 고양이🐱 / UI Developer, Front-end Developer

0개의 댓글