puppeteer๋ ํฌ๋กฌ์ด๋ ํ์ด์ดํญ์ค์์ ๋์์ ์กฐ์ํ๊ธฐ ์ํ ์๋ฐ์คํฌ๋ฆฝํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ด๋ค.
์ค๋์ ์ด puppeteer๋ฅผ ํตํด ์น์คํฌ๋ํ์ ํ์ฌ ์ฅ์ ์ ๋ณด๋ฅผ ์ค์๊ฐ์ผ๋ก ๊ฐ์ ธ์ค๊ณ ์ ํ๋ค.
๊ทธ๋ฆฌ๊ณ aws ๋๋ค๋ฅผ ํตํด ๋ฐฐํฌํ๋ ๊ณผ์ ์ ๋ด์ ํํ ๋ฆฌ์ผ์ ์ฐจ๊ทผํ ๋ฐ๋ผ๊ฐ๋ฉด์ ์์ ํ ์ฌํญ์ ๊ธฐ๋กํ๊ณ ์ ํ๋ค.
๊ตฌ๊ธ place api๋ฅผ ์ฌ์ฉํ๋๋ผ 30๋ง ์์ด์น ์ด์์ ๋น์ฉ์ด ๋ฐ์ํด์,
์ฐจ์ ํ์ผ๋ก ์น์คํฌ๋ํํ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์์ ์จ๋ณด๋ ๋ฐฉํฅ์ ์ ํํด ๋ดค๋ค.
๋ฌผ๋ก api์ ๋นํด์ ์๋ต ์๋ ๋ฑ์ด ๋๋ ค์ง ๋ถ๋ถ์ด ์ผ๋ ค๋์ง๋ง ๊ธ์ก์ ์ผ๋ก ์ต์ ์ธ ๊ฑฐ ๊ฐ๊ณ ,
ํ์์ ๋ค์ด๋ณด๊ธฐ๋ง ํ๋ lambda์ docker๋ฅผ ๊ฒฝํํด ๋ณผ ์ข์ ๊ฒฝํ ๊ฐ์์ ๊ณต๋ถ ์ผ์ํด ๋ณด๊ธฐ๋ก ํ๋ค.
์ค๋น๋ฌผ
- aws ๊ณ์ : lambda์ ๋ฐฐํฌ
- aws sam CLI (์ค์น ๋งํฌ) : SAM(Serverless application model)๋ก ์๋ฒ๋ฆฌ์ค ์ ํ๋ฆฌ์ผ์ด์ ์ ๋น๋
- access key ID์ secret key ID
- docker (์ค์น ๋งํฌ) : ๋ฐฐํฌ & ๋ก์ปฌ์์ ์ฝ๋ ํ ์คํธํ๊ธฐ ์ํด
๊ธฐ๋ณธ ์ธํ
npm init ์ผ๋ก ํจํค์ง๋ฅผ ์ด๊ธฐํํ๊ณ ๋ค์๊ณผ ๊ฐ์ด ํจํค์ง๋ค์ ์ค์นํด ์ค๋ค.
๋ค๋ฅธ ๋ฒ์ ์ผ๋ก ์ค์นํ์ ๊ฒฝ์ฐ ์ฝ๋๊ฐ ๋์๊ฐ์ง ์์ ์ ์์ผ๋ฏ๋ก ์๋์ ๋ฒ์ ์ด์๋ค๋ก ์ค์นํด์ค์ผ ํ๋ค.
index.js ํ์ผ์ ์์ฑํด ์ฃผ์.
๊ทธ๋ฆฌ๊ณ lambda์ ์ด๋ฏธ ์ค์น๋ chromium์ ์ฌ์ฉํ ์ ์๋๋ก ํ์ผ ์ต์๋จ์ ์๋์ ๊ฐ์ด ์์ฑํด ์ฃผ์.
lambda์ ๋ฐฐํฌํ์ ๋, chromium์ด ๋ค์ ๋ค์ด๋ก๋๋๋ ๊ฒ์ ๋ฐฉ์งํ๊ธฐ ์ํด์๋ค.
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = 1;
๋ค์์ผ๋ก๋ ์ฌ์ฉํ ํจํค์ง๋ค์ ๋ถ๋ฌ์ ์ ์ธํด ์ฃผ์.
const chromium = require("@sparticuz/chromium");
const puppeteer = require("puppeteer-core");
const cheerio = require("cheerio");
exports.handler = async (event, context, callback) => {
// !!TODO ์ฝ๋๋ฅผ ์์ฑํ๊ธฐ
}
์ธ๋ถ์์ url์ ๋ฐ์์ค๊ณ ์ถ๊ธฐ ๋๋ฌธ์ ๋ค์๊ณผ ๊ฐ์ด event.json ํ์ผ์ ์์ฑํ์.
ํ ์คํธ๋ก ์ฐ๋ฆฌ๊ฐ ํ์ฌ ๋ฐ์์ฌ ์์ ์ง์ญ ์ ๋ณด๋ ๊ฒฝ๋ณต๊ถ์ด๋ค.
{ "url": "https://www.google.com/maps/search/๊ฒฝ๋ณต๊ถ"}
https://www.google.com/maps/search/${query}์ ๊ฐ์ ํํ๋ก url์ ๋ฐ์ผ๋ฉด ๋ค์๊ณผ ๊ฐ์ด ํ์ด์ง๋ฅผ ๋ ์์ฃผ๋๋ก ํ์.
๋ง์ฝ url์ ์ ๊ณตํ์ง ์๋๋ค๋ฉด 400์ ๋ฐํํ๋๋ก ํ์.
// ...์๋ต...
// https://www.google.com/maps/search/${query}
const websiteUrl = event.url;
if (!websiteUrl) {
return { statusCode: 400, body: "Please provide an URL" };
}
const executablePath = await chromium.executablePath();
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath,
headless: chromium.headless,
ignoreHTTPSErrors: true,
});
const page = await browser.newPage();
await page.goto(websiteUrl, { timeout: 0, waitUntil: "networkidle0" });
const contents = await page.content();
// ...์๋ต...
sam cli๋ก ๋์ด๊ฐ๊ธฐ ์ ์ ๋จผ์ docker desktop์ด ์ ์ค์น๋์๋์ง ํ์ธํด ๋ณด์.
์ผ๋จ์ signin ํ์ง ์์ ์ํ๋ก ์งํํด๋ ๋๋ค. default ๋ก ์ค์ ์ ํ๊ณ ์ค์น๊ฐ ์ ๋์๋ค๋ฉด ๋ค์๊ณผ ๊ฐ์ ์ฐฝ์ด ๋จ๊ฒ ๋๋ค.
sam ๋ช ๋ น์ด๋ฅผ ์คํํด ์ฃผ๊ธฐ ์ ์ template.yml ํ์ผ์ ์์ฑํด ์ฃผ์.
๋ค์๊ณผ ๊ฐ์ด ์์ฑํด ์ฃผ์. puppeteer๋ฅผ ๋ก๋ํด์ค์ผ ํ๋ฏ๋ก ๋ฉ๋ชจ๋ฆฌ ํฌ๊ธฐ๋ 512(์ต๋)๋ก ์ค์ ํด ์ฃผ๋๋ก ํ๋ค.
Timeout ์ค์ ์ ํด์ค์ ์ฝ๋๊ฐ ๋ฌดํ์ ์คํ๋๋ ๊ฒ์ ๋ฐฉ์งํด ์ฃผ์.
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Resources:
MyFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs18.x
CodeUri: .
MemorySize: 512
Timeout: 300
vscode ํฐ๋ฏธ๋๋ก ๋ค์ด์์ ๋ณ๊ฒฝ์ฌํญ์ ๋ฐ์ํ๊ธฐ ์ํด sam build๋ฅผ ํด์ฃผ์. (sam build๋ ๋ณ๊ฒฝ์ฌํญ์ ๋ฐ์ํ๊ณ ์ ํ ๋๋ง๋ค ์คํํด์ค์ผ ํ๋ค.)
๋ง์ฝ ํฐ๋ฏธ๋์์ sam ๋ช ๋ น์ด๋ฅผ ์ ๋ ฅํ๋๋ฐ ๋ถ์ ๊ธ์จ๋ผ๋ฉด sam cli install ๊ณผ์ ์์ ๋ฌธ์ ๊ฐ ์์๊ฑฐ๋ ์ค์น๋ฅผ ํด์ฃผ์ง ์์์ ์ ์๋ค. (์ค์น ๋งํฌ)
๋ก์ปฌ๋ก ํ ์คํธ๋ฅผ ํ๊ณ ์ถ๋ค๋ฉด ๋์ปค๋ฅผ ์คํ์ํค๊ณ , ๋ค์๊ณผ ๊ฐ์ด ๋ช ๋ น์ด๋ฅผ ํฐ๋ฏธ๋์์ ์คํํ๋ฉด ๋๋ค.
sam local invoke --event ./event.json
(์ ์ ๊ธ์ ๊ตฌ๊ธ ์ง๋ ๊ฒ์ ํ์ด์ง์์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ ์ฝ๋๋ค.)
// ์๋ต
const contents = await page.content();
const $ = cheerio.load(contents);
const results = [];
const ifUndefinedReturnNull = (item) => {
if (item == null || item == "") {
return null;
} else {
return item;
}
};
const container = $(".m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde.ecceSd > div");
// Results๋ผ๊ณ ์๋์ค๊ณ ๋ฐ๋ก ๋ฆฌ์คํธ๊ฐ ์ฃผ์ด์ง๋ ๊ฒฝ์ฐ
if (
(container.html() != null && $("h1").text(),
ifUndefinedReturnNull($("h1").text()) == null)
) {
let tags = null;
let info = null;
let address = null;
let placeName = null;
let openCloseInfo = null;
let otherInfo = [];
let price = null;
container.children().each((i, el) => {
const item = $(el).find(".Nv2PK.Q2HXcd.THOPZb");
const tempPlaceName = item.find(".fontHeadlineSmall").text();
if (tempPlaceName != null) {
placeName = tempPlaceName;
}
const tempTags = item
.find(".fontBodyMedium > .W4Efsd > .AJB7ye > span")
.eq(1)
.text();
if (tempTags != null) {
tags = tempTags;
}
const tempPrice = item
.find(".fontBodyMedium > .W4Efsd > .AJB7ye > span")
.eq(2)
.find("span")
.last()
.text();
if (tempPrice != null) {
price = tempPrice;
}
const tempInfo = item
.find(".fontBodyMedium > .W4Efsd > .W4Efsd > span")
.eq(0)
.find("span")
.text();
if (tempInfo != null) {
info = tempInfo;
}
const tempAddress = item
.find(".fontBodyMedium > .W4Efsd > .W4Efsd > span")
.eq(1)
.find("span")
.last()
.text();
if (tempAddress != null) {
address = tempAddress;
}
const tempOpenCloseInfo = item
.find(".fontBodyMedium > .W4Efsd > .W4Efsd")
.last()
.find("span > span > span")
.text();
if (tempOpenCloseInfo != null) {
openCloseInfo = tempOpenCloseInfo;
}
const tempOtherInfo = item
.find(".qty3Ue > .fontBodySmall > .Ahnjwc.fontBodyMedium ")
.text();
if (tempOtherInfo != null) {
otherInfo = tempOtherInfo;
}
if (placeName != null && placeName != "") {
results.push({
placeName,
tags: ifUndefinedReturnNull(tags),
price: ifUndefinedReturnNull(price),
info: ifUndefinedReturnNull(info),
address: ifUndefinedReturnNull(address),
openCloseInfo: ifUndefinedReturnNull(openCloseInfo),
otherInfo: ifUndefinedReturnNull(otherInfo),
});
}
});
const response = {
statusCode: 200,
contents: results,
};
return response;
}
// ๋จ์ผ ๊ฒ์ ๊ฒฐ๊ณผ
else if (ifUndefinedReturnNull($(".DUwDvf.lfPIob").text().trim()) != null) {
const searchResults = $(
"div.w6VYqd > div.bJzME.tTVLSc > div.k7jAl.miFGmb.lJ3Kh > div.e07Vkf.kA9KIf > div.aIFcqe > div.m6QErb.WNBkOb.XiKgde"
);
let img = null;
let tags = null;
let info = null;
let address = null;
let placeName = null;
let openCloseInfo = null;
let otherInfo = [];
let price = null;
if (searchResults != null) {
searchResults.children().each((index, element) => {
const imgs = $(element)
.find(".ZKCDEc > .RZ66Rb.FgCUCc > button > img")
.attr("src");
if (ifUndefinedReturnNull(imgs) != null) {
img = imgs;
}
const tempPlaceName = $(element)
.find(".TIHn2 > .tAiQdd > .lMbq3e > div > h1")
.text()
.trim();
if (ifUndefinedReturnNull(tempPlaceName) != null) {
placeName = tempPlaceName;
}
const tempTag = $(element)
.find(
".TIHn2 > .tAiQdd > .lMbq3e > div.LBgpqf > div.skqShb > div.fontBodyMedium.dmRWX > div.F7nice"
)
.text()
.trim();
if (ifUndefinedReturnNull(tempTag) != null) {
tags = tempTag;
}
const tempPrice = $(element)
.find(
".TIHn2 > .tAiQdd > .lMbq3e > div.LBgpqf > div.skqShb > div.fontBodyMedium > span > span > span > span"
)
.not(".google-symbols")
.not(".fjHK4")
.text()
.trim();
if (ifUndefinedReturnNull(tempPrice) != null) {
price = tempPrice;
}
const tempInfo = $(element)
.find(
".TIHn2 > .tAiQdd > .lMbq3e > div.LBgpqf > div.skqShb > div.fontBodyMedium > span > span > .DkEaL"
)
.not(".google-symbols")
.not(".fjHK4")
.text()
.trim();
if (ifUndefinedReturnNull(tempInfo) != null) {
info = tempInfo;
}
const tempAddress = $(element)
.find(".Io6YTe.fontBodyMedium.kR99db.fdkmkc")
.eq(0)
.text()
.trim();
if (ifUndefinedReturnNull(tempAddress) != null) {
address = tempAddress;
}
const tempOpenCloseInfo = $(element).find(".ZDu9vd").text().trim();
if (ifUndefinedReturnNull(tempOpenCloseInfo) != null) {
openCloseInfo = tempOpenCloseInfo;
}
$(element)
.find(".E0DTEd")
.children("div.LTs0Rc")
.each((_, el) => {
const tempOtherInfo = $(el).attr("aria-label");
if (ifUndefinedReturnNull(tempOtherInfo) != null) {
otherInfo.push(tempOtherInfo);
}
});
});
const response = {
statusCode: 200,
contents: [
{
placeName,
tags,
price,
info,
address,
img,
openCloseInfo,
otherInfo: otherInfo.join(" · "),
},
],
};
return response;
}
} else {
// w6VYqd > bJzME tTVLSc > k7jAl miFGmb lJ3Kh PLbyfe > e07Vkf kA9KIf > aIFcqe > m6QErb WNBkOb XiKgde > m6QErb DxyBCb kA9KIf dS8AEf XiKgde ecceSd
const searchResults = $(
"div.w6VYqd > div.bJzME.tTVLSc > div.k7jAl.miFGmb.lJ3Kh.PLbyfe > div.e07Vkf.kA9KIf > div.aIFcqe > div.m6QErb.WNBkOb.XiKgde > div.m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde.ecceSd > div.m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde.ecceSd"
);
const results = [];
if (searchResults != null) {
// Iterate through each matching element
searchResults.children().each((index, element) => {
const result = $(element)
.find("div > div")
.not(".JrN27d.SuV3fd.Zjt37e.TGiyyc") // Results (์ฌ๋ฌ๊ฐ์ ๊ฒ์ ๊ฒฐ๊ณผ)
.not(".m6QErb.XiKgde.z7i0C"); // Convert the element to a Cheerio object
const img = $(result).find("img").attr("src");
const placeName = $(result).find(".fontHeadlineSmall").text();
const tags = $(result)
.find(".fontBodyMedium > span")
?.attr("aria-label");
const price = $(result)
.find(".AJB7ye > span")
.eq(2)
?.find("span")
?.eq(1)
.text()
.trim();
const info = $(result)
.find(".W4Efsd > .W4Efsd > span > span")
.eq(0)
.text()
.trim();
const address = $(result)
.find(".W4Efsd > .W4Efsd")
.eq(0)
.find("span")
.not(".google-symbols")
.last()
.text()
.trim();
const openCloseInfo = $(result)
.find(".W4Efsd > span > span > span")
.not(".doJOZc")
.text()
.trim();
const otherInfo = $(result).find(".qty3Ue").text().trim();
if (placeName != null && placeName != "") {
results.push({
placeName,
tags: ifUndefinedReturnNull(tags),
price: ifUndefinedReturnNull(price),
info: ifUndefinedReturnNull(info),
address: ifUndefinedReturnNull(address),
openCloseInfo: ifUndefinedReturnNull(openCloseInfo),
img: ifUndefinedReturnNull(img),
otherInfo: ifUndefinedReturnNull(otherInfo),
});
}
});
}
const response = {
statusCode: 200,
contents: results,
};
return response;
}
// ์๋ต
์ฑ๊ณต์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ฉด ๋ค์๊ณผ ๊ฐ์ด ํ์ธ๊ฐ๋ฅํ๋ค.
ํ์ง๋ง ์ผ์ผ์ด ์ด ๊ธด ๋ช ๋ น์ด๋ฅผ ์คํํ๊ธฐ ๊ท์ฐฎ์ผ๋ฏ๋ก
package.json์ ์์ฑํด ์ฃผ์. ์ด์ npm run test ๋ก ๋ก์ปฌ์์ ์คํ๊ฐ๋ฅํ๋ค.
// ์๋ต
{
"scripts": {
"build": "sam build",
"test": "sam local invoke --event ./event.json",
}
// ์๋ต
aws cli ์ค์น๋ฅผ ์๋ฃํ๋ค๋ฉด ์ด์ ์ค์ ์ ํด์ฃผ์.
(accessํค์ secret ํค๊ฐ ๋ ธ์ถ๋์ง ์๊ธฐ ์ํด ํด๋น ์ด๋ฏธ์ง๋ ํํ ๋ฆฌ์ผ ์์์ ์คํฌ๋ฆฐ์์ผ๋ก ๋์ฒดํ์ต๋๋ค.)
์ค๋นํด ๋ aws access key์ secret access ๋ฅผ ์ ๋ ฅํด ์ฃผ์.
region์ ์ง์ญ ์ ๋ณด(ap-northeast-2)๋ฅผ ์ ๋ ฅํด ์ฃผ๋ฉด ๋๋ค.
output format์ ๋ฐ๋ก ์ง์ ํ์ง ์์๋ ๋๋ค.
ํฐ๋ฏธ๋์์ sam deploy ๋ฅผ ํด์ฃผ์.
Deploy this changeset ๋ฌธ๊ตฌ๊ฐ ๋์ค๋ฉด 'y'๋ฅผ ์ ๋ ฅํด ์ฃผ์.
aws lambdaํ์ด์ง๋ก ๋ค์ด๊ฐ๋ฉด ๋ค์๊ณผ ๊ฐ์ด function์ด ๋ง๋ค์ด์ง๋ค.
๋ฐฐํฌ๋ lambda function์ ์คํํ๊ธฐ ์ํด์๋ ๋ ๊ฐ์ง ๋ฐฉ๋ฒ์ด ์๋๋ฐ, ๋ ํธํ ๊ฑธ๋ก ํ๋ฉด ๋๋ค.
- ํฐ๋ฏธ๋์์
- cli๋ก
ํฐ๋ฏธ๋์์ ์คํํ๊ธฐ ์ํด์๋ Funciton ARN์ ๋ณต์ฌํด ์ฃผ์.
ํฐ๋ฏธ๋๋ก ๋ค์ ๋์์์ ๋ค์์ ์ ๋ ฅํด ์ฃผ์.
sam remote invoke ARN๋ณต์ฌ๋ถ์ฌ๋๊ธฐ --event-file ./event.json
ARN์ ํญ์ ๋ณต์ฌํด ์ค๋ ๋ฒ๊ฑฐ๋ก์ด ์ผ ๋์ , package.json์ ๋ช ๋ น์ด๋ฅผ ์ถ๊ฐํด ์ฃผ์.
// ์๋ต
"build": "sam build",
"deploy": "sam deploy",
"test": "sam local invoke --event ./event.json",
// ์ถ๊ฐํด์ฃผ๊ธฐ
"prod": "sam remote invoke ARN_๋ณต์ฌ๋ถ์ฌ๋๊ธฐ --event-file ./event.json"
// ์๋ต
์ด์ npm run prod์ผ๋ก ๋ฐฐํฌ๊ฐ ๊ฐ๋ฅํ๋ค.
์ฌ๊ธฐ์๋ถํฐ๋ ํํ ๋ฆฌ์ผ ์์ ์์ด ์งํ๋๋ ๋ด์ฉ์ ๋๋ค.
๋ ๋ฒ์งธ ๋ฐฉ๋ฒ์ผ๋ก๋ sam cli์ ์ฌ์ฉํ๋ ๊ฒ์ด๋ค.
๋ค์๊ณผ ๊ฐ์ด Test tab์ ๋ค์ด๊ฐ๋ฉด
๋ค์๊ณผ ๊ฐ์ด param์ ๋ณ๊ฒฝํ์ฌ ๋ฐฐํฌ๋ lambda function์ ํ ์คํธํ ์ ์๋ค.
์ฑ๊ณต์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ฉด ๋ค์๊ณผ ๊ฐ์ ํ๋ฉด์ ํ์ธํ ์ ์๋ค.
๋ค๋ฅธ ๊ฒ์์ด 'ํ๋ผ์ฐ'์ผ๋ก ๋ณ๊ฒฝ ํ ๋ค์ test๋ฅผ ๋๋ฆฌ๋ฉด ๋ค์๊ณผ ๊ฐ์ด ์ฑ๊ณต์ ์ผ๋ก ์ ๋ณด๋ฅผ ๊ฐ์ ธ์จ๋ค.
{
"statusCode": 200,
"contents": [
{
"placeName": "ํ๋ผ์ฐ",
"tags": "4.7(729)",
"price": null,
"info": "์ฐ๋ด์ฐ๋ฆฌ",
"address": "์๊ทํฌ์",
"img": "https://lh5.googleusercontent.com/p/AF1QipOAkhVKrq3broFnCMCx4sdqm45jxANDfoC2k3bi=w426-h240-k-no",
"openCloseInfo": null,
"otherInfo": ""
}
]
}
๋ง์น๋ฉด์
์ฒซ lambda ํจ์ ๋ง๋ค์ด/๋ง๋ณด๊ธฐ!
์ฒ์์๋ vercel๋ก ๋ฐฐํฌํด์ ์ฌ์ฉํ๋ ค๊ณ ํ์ผ๋ hobby ํ๋์ด์ด์ ๊ทธ๋ฐ์ง
๊ฐ๋ฐํ๊ฒฝ์์ ์๋ง ๋ฐฐํฌ๋๊ณ ๋์๊ฐ๋ ์ฝ๋๊ฐ ๋์๊ฐ์ง๋ ๋ชปํด์ ์ข์ ํ์๋ค. ๊ธฐ๊ป puppeteer๊น์ง ์จ๊ฐ๋ฉด์ ํด๋๋๋ ใ ใ
์ฑ๊ณต์ ์ด์ด์ ํ๋ก์ ํธ์ ๋ถ์์ง๋ง ์์ฌ์ด ์ ์
์ด๋ ๊ฒ ํ๋์ ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํ๋ ๋ฐ์๋ ์๊ฐ์ด ๋ง์ด ๊ฑธ๋ฆฌ๋๋ฐ,
์ฌํ ์ผ์ ์ ์ง๊ธฐ ์ํด ์ฌ๋ฌ (์ต์ 3๊ตฐ๋ฐ)๋ฅผ ์ฐพ๋ ๋ฐ ๊ฑธ๋ฆฌ๋ ์๊ฐ์ ์ด๋ป๊ฒ ์ค์ผ ์ ์์์ง.
์๋๋ puppeteer cluster๋ฅผ ์ฌ์ฉํด๋ณด๋ ค๊ณ ํ๋ค๊ฐ ๋ฒ์ ์ด์๋๋ฌธ์ธ์ง ์คํจ..
์์ฝ์ง๋ง ์ผ๋จ ๋ง๋ค๊ณ ๊ฐ์ ํ๋ ๋ฐฉํฅ์ ์๊ฐํด ๋ณด๊ธฐ๋ก ํ๋ค.
References
https://www.youtube.com/watch?v=INlCCRdOfj4