클라우드 브라우저로 웹 페이지 찍기
chrome 가상 기기를 띄워 주소로 이동하고, 탭을 읽고, 화면 한 장을 PNG 로 저장해요.
무엇을 하나
화면 API 는 명령이 아니에요 — 명령 기록에 남지 않고, max_age_ms 안에 찍은 화면이 있으면 기기에 다시 묻지 않아요. 여러 화면이 같은 브라우저를 봐도 스크린샷은 한 번이에요.
같은 방법을 반복하면 화면 동기화가 돼요. 데스크톱(prototype-web)의 Chrome 창이 이렇게 동작해요.
가상 기기 만들기와 명령은 크레딧을 써요. 계정 잔액이 0 이하면 402 INSUFFICIENT_CREDITS 예요.
부르는 API
프롬프트
Claude Code 같은 AI 코딩 에이전트에 그대로 붙여 넣으세요. 아래 코드가 이 프롬프트로 만든 결과예요.
endue OS 공개 REST API 로 "클라우드 Chrome 으로 웹 페이지 스크린샷" 을 찍는 Node.js 스크립트를 만들어 줘.
의존성 없이 Node 22 의 fetch 만, 파일 하나(chrome-screenshot.mjs). 사용법: node chrome-screenshot.mjs <url> <out.png>
API 규칙:
- 기본 주소 ENDUE_OS_URL (없으면 http://127.0.0.1:5173), 경로 /api/v1/...
- Authorization: Bearer <토큰>. 토큰은 ENDUE_OS_TOKEN, 없으면 POST {ENDUE_OS_URL}/idp/token?sub=<이름>&ttl=3600 의 access_token.
- 성공은 { data }, 오류는 { error: { code, details } }. 402 INSUFFICIENT_CREDITS 면 크레딧 부족(details.balance)이라고 알려 줘.
할 일:
1. POST /stations {name} → POST /stations/{id}/devices {type: "virtual", kind: "chrome", name} → data.device.
2. GET /devices/{device_id} 를 2초마다 — compute_status "running" 이고 online 이 true 일 때까지.
3. POST /devices/{device_id}/commands {command: "navigate", input: {url}} 로 이동한다 (201 이면 data.status 가 결과).
4. {command: "tab_list", input: {}} 의 output.tabs 에서 active 탭의 title·url 을 출력한다.
5. GET /devices/{device_id}/screen?max_age_ms=500 — 본문이 이미지 바이트다. 파일로 저장하고
content-type 과 X-Frame-At 헤더를 출력한다.
6. finally: POST /devices/{id}/stop, DELETE /devices/{id}, DELETE /stations/{id} (409 면 몇 번 재시도).결과 코드 chrome-screenshot.mjs
#!/usr/bin/env node
// 클라우드 브라우저로 웹 페이지 찍기 — endue OS 샘플.
// chrome 가상 기기를 띄우고 → 주소로 이동하고 → 탭을 읽고 → 화면 한 장을 PNG 로 저장한다.
//
// node chrome-screenshot.mjs https://example.com shot.png
// ENDUE_OS_TOKEN=… ENDUE_OS_URL=… node … 운영 — endue 계정의 토큰
import { writeFile } from 'node:fs/promises';
const [url = 'https://example.com', out = 'shot.png'] = process.argv.slice(2);
const BASE = (process.env.ENDUE_OS_URL ?? 'http://127.0.0.1:5173').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const token =
process.env.ENDUE_OS_TOKEN ??
(await (await fetch(`${BASE}/idp/token?sub=${process.env.ENDUE_OS_SUB ?? `sample-${Date.now().toString(36)}`}&ttl=3600`, { method: 'POST' })).json()).access_token;
async function api(method, path, body, { raw = false } = {}) {
const res = await fetch(`${API}${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
if (raw) return res;
const json = res.status === 204 ? null : await res.json();
if (res.status === 402) throw new Error(`${method} ${path} → 402 ${json?.error?.code}: 크레딧이 부족해요 (잔액 ${json?.error?.details?.balance ?? '?'}). 충전한 뒤 다시 돌려 주세요.`);
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${json?.error?.code}`);
return { status: res.status, data: json?.data };
}
const station = (await api('POST', '/stations', { name: 'web-shots' })).data;
let device;
try {
device = (await api('POST', `/stations/${station.station_id}/devices`, { type: 'virtual', kind: 'chrome', name: 'browser' })).data.device;
while (!(device.compute_status === 'running' && device.online)) {
if (device.compute_status === 'failed') throw new Error(device.status_reason);
await sleep(2000);
device = (await api('GET', `/devices/${device.device_id}`)).data;
}
const rid = device.device_id;
console.log(`chrome running — ${rid}`);
const nav = (await api('POST', `/devices/${rid}/commands`, { command: 'navigate', input: { url } })).data;
if (nav.status !== 'succeeded') throw new Error(`navigate: ${nav.status} ${nav.error}`);
const tabs = (await api('POST', `/devices/${rid}/commands`, { command: 'tab_list', input: {} })).data.output?.tabs ?? [];
const active = tabs.find((t) => t.active);
console.log(`활성 탭: ${active?.title ?? '?'} — ${active?.url}`);
await sleep(1000); // 페이지가 그려질 틈
// 화면 API — 명령 기록에 남지 않고, max_age_ms 안의 화면은 다시 찍지 않는다.
const res = await api('GET', `/devices/${rid}/screen?max_age_ms=500`, undefined, { raw: true });
if (res.status !== 200) throw new Error(`screen → ${res.status} ${await res.text()}`);
const bytes = Buffer.from(await res.arrayBuffer());
await writeFile(out, bytes);
console.log(`${out} 저장 — ${bytes.length} bytes, ${res.headers.get('content-type')}, 찍은 시각 ${res.headers.get('x-frame-at')}`);
} finally {
if (device) {
await api('POST', `/devices/${device.device_id}/stop`).catch(() => {});
await api('DELETE', `/devices/${device.device_id}`).catch(() => {});
}
for (let i = 0; i < 15 && !(await api('DELETE', `/stations/${station.station_id}`).then(() => true, () => false)); i++) await sleep(2000);
}돌려 보기
# endue-os 제품 레포에서 로컬 스택을 띄운 뒤 local/up.sh node site/samples/chrome-screenshot.mjs # 운영 (예정) — endue 계정의 토큰으로 ENDUE_OS_URL=https://api.endueos.com ENDUE_OS_TOKEN=… node chrome-screenshot.mjs