Puppeteer 는 웹자동화, 스크래핑 목적으로 사용되는 Chrome 헤드리스 브라우저 기반의 Node.js 라이브러리입니다. Bun 에서도 작동된다고 해서 사용해보려 합니다.
0. 개요
참고자료
1. Express 프로젝트
Bun 프로젝트 생성
1
2
3
4
5
6
7
|
mkdir puppeteer-api
cd puppeteer-api
bun init
bun run index.ts
# Hello Bun!
|
- Typescript
- cors, json 플러그인
- query, path 파라미터 읽기
- get, post 메소드
1
2
|
bun add express cors
bun add -D @types/express @types/cors
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// index.ts
import express, { Request, Response } from 'express';
import cors from 'cors';
const app = express();
app.use(express.json());
app.use(cors());
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
});
app.get('/query', async (req, res) => {
const { searchString }: { searchString?: string } = req.query;
res.json({ type: 'query', searchString });
});
app.get('/param/:id', async (req, res) => {
const { id } = req.params;
res.json({ type: 'param', id });
});
(() => {
const port = process.env.PORT || 8000;
try {
app.listen(port, () => {
console.log(`Listening on port ${port}...`);
});
} catch (err) {
console.error(err);
process.exit(1);
}
})();
|
- zod 스키마 정의
- 스키마 검사 함수 정의
- app 메소드에 스키마 검사 함수를 RequestHandler 로 등록
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import express, { Request, Response, NextFunction } from 'express';
import { z, AnyZodObject } from 'zod';
/* ... */
const LoginSchema = z.object({
// In this example we will only validate the request body.
body: z.object({
// email should be valid and non-empty
email: z.string().email(),
// password should be at least 6 characters
password: z.string().min(6),
}),
});
const validate =
(schema: AnyZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
const formatted = result.error.format();
return res.status(400).json(formatted.body);
}
next();
};
app.post('/login', validate(LoginSchema), (req, res) => {
return res.json({ ...req.body });
});
|
Express.js 는 오래된 레거시 코드이다. (버전 5-beta 는 2022년 초에 멈춤)
-
Koa v2.14.2 : 관리되지 않음(사망)
- Express.js 의 최신 버전 (비동기 지원)
-
Feathers.js v4.5.18
- REST-API 에 중점을 두고 있고, 멋진 개념과 사용하기 쉽다는 장점이 있음
- 서비스와 별개로 Server 기능을 위해 socketio, koa 등을 결합시켜야 한다.
- Nestjs 의 대체용으로 쓸 수 있다.
- 웹프레임워크 3대장 : Fastify, Restify, Hapi
- REST-API 구축을 위한 다양한 기능을 제공
-
Hono v3.7.2 : 블로그 저자가 찬양하는 프레임워크
- 웹표준을 준수하여 Bun, Deno, AWS, Vercel 등 거의 모든 플랫폼 위에서 작동됨
- 더욱이 속도도 빠름
-
Elysia.js v0.7 : Bun 에 특화된 프레임워크
Headless Chrome 브라우저 및 puppeteer 설치
1
2
3
4
|
bunx @puppeteer/browsers install chrome@stable
# => download to './chrome/mac_arm-117.0.5938.92/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
bun add puppeteer
|
- headless 브라우저 및 페이지 생성
-
https://naver.com
이동 및 화면 사이즈 설정
- 검색창 선택(focus) 후 검색어 입력(type)
- 검색버튼 클릭
- (선택사항) 1초 딜레이 :
page.waitForTimeout
는 없어졌음
- 장소검색 섹션 선택
- 이어서, 섹션 내부의 모든 장소 아이템 선택 :
$$(selector)
- 장소 아이템의 title 선택 후 evaluate
-
Promise.all
로 값 배열 변환 후 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
// puppeteer-demo.ts
import puppeteer, { TimeoutError } from 'puppeteer';
import { sleep } from 'bun';
(async () => {
// Launch the browser and open a new blank page
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox'], // (도커) root 실행 아니더라도 필요하다
executablePath:
'./chrome/mac_arm-117.0.5938.92/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(10000); // 10 sec
try {
console.log('1. navigate the page to a URL');
await page.goto('https://naver.com/', {
waitUntil: 'load',
timeout: 6000, // 6 sec
});
// Set screen size
await page.setViewport({ width: 1280, height: 1024 });
console.log('2. focus search_input_box');
const searchInputSelector = '.search_input_box > input';
await page.waitForSelector(searchInputSelector);
await page.focus(searchInputSelector);
console.log('3. Type into search box');
await page.type(searchInputSelector, '제주도 맛집');
// Wait and click search Button
const searchButtonSelector = '.btn_search';
await page.waitForSelector(searchButtonSelector);
await page.click(searchButtonSelector);
// deprecated: await page.waitForTimeout(1000)
// await new Promise((r) => setTimeout(r, 1000));
await sleep(1000); // with Bun
console.log('4. Wait and click on first result');
const searchResultSelector = '.place-app-root .api_subject_bx ul';
const placesSection = await page.waitForSelector(searchResultSelector);
const placeHandles = await placesSection?.$$('li');
const titles = placeHandles?.map(async (handle) => {
const titleSelector = await handle.$('.place_bluelink');
return await titleSelector?.evaluate((el) => el.textContent);
});
const values = await Promise.all(titles ?? []);
console.log(`==> ${values.join('|')} (size=${values.length})`);
}
catch (e) {
if (e instanceof TimeoutError) {
console.log('** TimeoutError:', e);
} else {
console.log('** Other Errors:', e);
}
}
finally {
await page.close();
await browser.close();
}
})();
|
Bun 런타임으로 ts 파일 직접 실행 (편하다!)
1
2
3
4
5
6
|
$ bun run puppeteer-demo.ts
1. Navigate the page to a URL
2. focus search_input_box
3. Type into search box
4. Wait and click on first result
==> 연남물갈비 서귀포점|제주곰집|우진해장국|제주광해 애월|런던베이글뮤지엄 제주점|올래국수|이춘옥원조고등어쌈밥 제주애월본점|아베베베이커리 (size=8)
|
- Mac OS (Apple M1) 에서는 Chrome 브라우저를 설치해도 안된다.
- 오류
E: Unable to locate package google-chrome-stable
-
Ubuntu 서버의 Docker 에서는 잘 작동한다.
- 테스트용 Headless 브라우저가 단독으로는 실행이 안된다.
- google-chrome-stable 설치가 되어 있어야 실행된다.
- root 권한으로 Chrome 브라우저를 실행하려면
--no-sandbox
옵션 필요
- 옵션
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
도커 oven/bun
이미지에 이미 USER bun
이 있다. 이용하자!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
FROM oven/bun as base
# npm/yarn 등으로 패키지 설치시, 테스트용 브라우저 다운로드를 건너뛰기
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg \
&& sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-freefont-ttf libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# USER 'bun' already exists
RUN chown -hR bun:bun /home/bun
# FROM base as build
USER bun
WORKDIR /home/bun/app
COPY --chown=bun:bun . /home/bun/app
RUN bun install
EXPOSE 8080
ENV PORT 8080
CMD ["bun", "./src/index.ts"]
|
chrome
디렉토리에 복사된 테스트용 Headless 브라우저는 복사해서 사용한다.
.dockerignore
Dockerfile
.gitignore
/node_modules
*.md
bun.lockb
도커 빌드 및 실행 (image size 1.2GB)
1
2
3
4
5
6
7
8
9
10
|
docker build -t bun-puppeteer .
docker run -it -P --rm bun-puppeteer bash
docker run -d -p 8080:8080 -v `pwd`/data/:/home/bun/data \
--rm --name bun-puppeteer-app bun-puppeteer
docker exec -it bun-puppeteer-app bash
docker stop bun-puppeteer-app
|
3. Elysia + Puppeteer
Elysia 프로젝트 생성
1
2
3
4
|
bun create elysia puppeteer-api
cd puppeteer-api
bun dev
|
라우팅 및 파라미터 처리 (Context)
-
Context 는 라우터 Handler 가 처리할 입력 구조체이다.
- request, body, query, params, store, set 등을 속성으로 보유
- query 파라미터와 path 파라미터 모두 context 로 접근할 수 있다.
app.get('/id/:id', (context) => context.params.id)
Handler 의 출력은 표준 Response 부터 text, json, html 등 다양하게 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
app
.get('/', () => 'Hello Elysia');
app
.get('/path/:id', ({ params: { id } }) => {
console.log(`path params: id=${id}`);
return new Response(
JSON.stringify({
type: 'path',
params: [id],
}),
{
headers: {
'Content-Type': 'application/json',
},
}
);
})
.get('/query', ({ query: { id } }) => {
console.log(`query params: id=${id}`);
return {
type: 'query',
params: [id],
};
});
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
import { Elysia, NotFoundError } from 'elysia';
const app = new Elysia()
.onError(({ code, error, set }) => {
if (code === 'NOT_FOUND') {
set.status = 404;
return 'Not Found :(';
}
return new Response(error.toString());
})
.onStart(() => {
console.log('💫 start!');
})
.onStop(() => {
console.log('💤 stop!');
});
app
.post('/', () => {
throw new NotFoundError();
});
(() => {
const port = process.env.PORT || 8000;
try {
app.listen(
{
port,
hostname: '0.0.0.0',
},
({ hostname, port }) => {
console.log(`🦊 Elysia is running at ${hostname}:${port}`);
}
);
} catch (err) {
console.error(err);
process.exit(1);
}
process.on('SIGINT', () => {
console.log('\n\nReceived SIGINT');
app.stop();
});
})();
|
- 시작시 puppeteer 또는 DB 접속을 처리하고
- 종료시 자원 해제, 접속 해제 등을 처리한다.
1
2
3
4
5
6
7
8
|
$ bun run src/index.ts
💫 start!
🦊 Elysia is running at 0.0.0.0:8000
query params: id=999
^C
Received SIGINT
💤 stop!
|
브라우저 상에서 보이는 html 출력이 text 응답과 html 응답이 조금 다르다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import { html } from '@elysiajs/html';
const app = new Elysia()
.use(html())
.get(
'/html',
() => `
<html lang="en">
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello World v1</h1>
</body>
</html> `
)
.get('/html-plugin', ({ html }) =>
html(`
<html lang="en">
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello World v2</h1>
</body>
</html> `)
);
|
앞의 puppeteer 예제를 두 부분으로 분리하여 elysia context 에 연결한다.
-
scraper.ts
- launchBrowser :
async (): Promise<Browser>
- searchPlaces :
async (browser: Browser, q: string): string[]
-
index.ts
-
decorate
를 이용해 context 에 속성으로 등록하여 handler 에서 사용
좀 더 nice 하게 하려면 Dependency Injection 를 참고할 것.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import { launchBrowser, searchPlaces } from '$lib/scraper';
import { Browser } from 'puppeteer';
const app = new Elysia()
.decorate('browser', await launchBrowser())
.onStart(async ({ browser }) => {
if (browser && browser instanceof Browser) {
console.log('Browser version :', await browser.version());
}
})
.onStop(async ({ browser }) => {
if (browser && browser instanceof Browser) {
await browser.close();
console.log('Browser is closed!');
}
});
app.get('/search', async ({ browser, query: { q } }) => {
console.log(`query params: q="${q}"`);
if ((q?.trim()?.length || 0) === 0) {
throw new Error('invalid empty query');
}
return {
query: q,
results: await searchPlaces(browser, q as string),
};
});
|
실행
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
$ bun run src/index.ts
🦊 Elysia is running at 0.0.0.0:8000
Browser version : Chrome/117.0.5938.92
query params: q="경포대 일식"
^C
Received SIGINT
Browser is closed!
$ curl -X GET "http://localhost:8000/search?q=경포대%20일식"
{
"query":"경포대 일식",
"results":[
"마카","메시56","고성장미경양식","루이식당","시나미83","봉창이해물샤브칼국수"
]
}%
|
9. Review
- 삼천포로 빠진거 같다.
- 요즘 게으름을 피우고 있다.