reverse-engine 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of reverse-engine might be problematic. Click here for more details.

package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import { execFileSync } from 'child_process';
7
7
  import { dirname, join, resolve } from 'path';
8
8
  import { createRequire } from 'module';
9
9
  import { analyze } from '../analyzer/index.js';
10
+ import { crawl } from '../crawler/index.js';
10
11
  import { generateReport } from '../docgen/index.js';
11
12
  import { generateTests } from '../testgen/index.js';
12
13
  // ─── 기본값 ───
@@ -135,22 +136,96 @@ program
135
136
  console.log(chalk.green('✓'), `완료! (${files.length}개 파일)`);
136
137
  files.forEach(p => console.log(` → ${chalk.cyan(p)}`));
137
138
  });
139
+ // ─── crawl ───
140
+ program
141
+ .command('crawl')
142
+ .argument('[url]', '크롤링 대상 URL')
143
+ .option('--max-depth <n>', '최대 탐색 깊이', '5')
144
+ .option('--max-pages <n>', '최대 페이지 수', '100')
145
+ .option('--no-screenshot', '스크린샷 비활성화')
146
+ .option('--no-headless', '브라우저 표시 (디버깅용)')
147
+ .option('--auth-cookie <cookie>', '인증 쿠키 (name=value;name2=value2)')
148
+ .option('--auth-bearer <token>', 'Bearer 토큰')
149
+ .option('--login-url <url>', '로그인 페이지 URL')
150
+ .option('--login-id <id>', '로그인 ID 필드값')
151
+ .option('--login-pw <pw>', '로그인 PW 필드값')
152
+ .option('--wait <ms>', '페이지 로드 후 대기시간(ms)', '1500')
153
+ .option('-o, --output <dir>', '출력 디렉토리')
154
+ .description('실행 중인 서비스를 브라우저로 크롤링하여 화면/API 수집')
155
+ .action(async (url, opts) => {
156
+ if (!url) {
157
+ console.log(chalk.red('✗'), 'URL을 입력하세요: reverse-engine crawl http://localhost:3000');
158
+ process.exit(1);
159
+ }
160
+ const outputDir = opts.output || DEFAULT_OUTPUT;
161
+ console.log(chalk.green('▶'), '크롤링 시작:', chalk.cyan(url));
162
+ console.log(` 최대 깊이: ${opts.maxDepth} | 최대 페이지: ${opts.maxPages}`);
163
+ // 인증 옵션 구성
164
+ const auth = {};
165
+ if (opts.authCookie)
166
+ auth.cookie = opts.authCookie;
167
+ if (opts.authBearer)
168
+ auth.bearer = opts.authBearer;
169
+ if (opts.loginUrl) {
170
+ auth.loginUrl = opts.loginUrl;
171
+ auth.credentials = {};
172
+ if (opts.loginId)
173
+ auth.credentials.email = opts.loginId;
174
+ if (opts.loginPw)
175
+ auth.credentials.password = opts.loginPw;
176
+ }
177
+ const result = await crawl({
178
+ url,
179
+ maxDepth: parseInt(opts.maxDepth),
180
+ maxPages: parseInt(opts.maxPages),
181
+ screenshot: opts.screenshot !== false,
182
+ headless: opts.headless !== false,
183
+ outputDir,
184
+ waitTime: parseInt(opts.wait),
185
+ auth: Object.keys(auth).length > 0 ? auth : undefined,
186
+ });
187
+ console.log(chalk.green('✓'), `크롤링 완료!`);
188
+ console.log(` 페이지: ${result.pages.length}개`);
189
+ console.log(` API 호출: ${result.pages.reduce((n, p) => n + p.apiCalls.length, 0)}개`);
190
+ await mkdir(outputDir, { recursive: true });
191
+ const crawlPath = join(outputDir, 'crawl-result.json');
192
+ await writeFile(crawlPath, JSON.stringify(result, null, 2));
193
+ console.log(` → ${chalk.cyan(crawlPath)}`);
194
+ });
138
195
  // ─── full ───
139
196
  program
140
197
  .command('full')
141
198
  .argument('[path]', '소스코드 경로 (생략하면 현재 디렉토리)')
199
+ .option('--url <url>', '실행 중인 서비스 URL (크롤링 추가)')
142
200
  .option('--framework <name>', '프레임워크', 'auto')
201
+ .option('--no-headless', '크롤링 시 브라우저 표시')
202
+ .option('--auth-cookie <cookie>', '크롤링 인증 쿠키')
143
203
  .option('-o, --output <dir>', '출력 디렉토리 (기본: <프로젝트>/.reverse-engine)')
144
- .description('전체 파이프라인 (analyze → report → test)')
204
+ .description('전체 파이프라인 (crawl → analyze → report → test)')
145
205
  .action(async (path, opts) => {
146
206
  const sourcePath = path ? resolve(path) : detectProjectRoot();
147
207
  const outputDir = resolveOutput(opts.output, sourcePath);
148
208
  console.log(chalk.green('\n◆'), 'ReversEngine', nativeBin ? chalk.dim('⚡') : '', '\n');
209
+ if (opts.url)
210
+ console.log(` URL: ${chalk.cyan(opts.url)}`);
149
211
  console.log(` 소스: ${chalk.cyan(sourcePath)}`);
150
212
  console.log(` 출력: ${chalk.cyan(outputDir)}\n`);
151
213
  await mkdir(outputDir, { recursive: true });
152
214
  const analysisPath = join(outputDir, 'analysis.json');
153
- // Step 1: 분석
215
+ // Step 0: 크롤링 (URL이 있는 경우)
216
+ if (opts.url) {
217
+ console.log(chalk.gray('━'.repeat(50)));
218
+ console.log(chalk.green('▶'), '크롤링:', chalk.cyan(opts.url));
219
+ const crawlResult = await crawl({
220
+ url: opts.url,
221
+ outputDir,
222
+ headless: opts.headless !== false,
223
+ auth: opts.authCookie ? { cookie: opts.authCookie } : undefined,
224
+ });
225
+ console.log(chalk.green('✓'), `크롤링: 페이지 ${crawlResult.pages.length} | API ${crawlResult.pages.reduce((n, p) => n + p.apiCalls.length, 0)}`);
226
+ await writeFile(join(outputDir, 'crawl-result.json'), JSON.stringify(crawlResult, null, 2));
227
+ }
228
+ // Step 1: 코드 분석
154
229
  console.log(chalk.gray('━'.repeat(50)));
155
230
  if (nativeBin) {
156
231
  runNative(['analyze', sourcePath, '--framework', opts.framework || 'auto']);
@@ -0,0 +1,20 @@
1
+ import type { CrawlResult } from '../types.js';
2
+ export interface CrawlOptions {
3
+ url: string;
4
+ maxDepth?: number;
5
+ maxPages?: number;
6
+ screenshot?: boolean;
7
+ outputDir?: string;
8
+ auth?: AuthOptions;
9
+ headless?: boolean;
10
+ waitTime?: number;
11
+ ignorePatterns?: string[];
12
+ }
13
+ export interface AuthOptions {
14
+ cookie?: string;
15
+ bearer?: string;
16
+ loginUrl?: string;
17
+ credentials?: Record<string, string>;
18
+ submitSelector?: string;
19
+ }
20
+ export declare function crawl(options: CrawlOptions): Promise<CrawlResult>;
@@ -0,0 +1,201 @@
1
+ import { chromium } from 'playwright';
2
+ import { mkdir } from 'fs/promises';
3
+ import { join } from 'path';
4
+ export async function crawl(options) {
5
+ const { url, maxDepth = 5, maxPages = 100, screenshot = true, outputDir = '.reverse-engine', headless = true, waitTime = 1500, ignorePatterns = ['/logout', '/signout', '/auth/logout'], } = options;
6
+ const screenshotDir = join(outputDir, 'screenshots');
7
+ if (screenshot)
8
+ await mkdir(screenshotDir, { recursive: true });
9
+ const browser = await chromium.launch({ headless });
10
+ const context = await browser.newContext({
11
+ viewport: { width: 1920, height: 1080 },
12
+ locale: 'ko-KR',
13
+ });
14
+ // 인증 설정
15
+ if (options.auth) {
16
+ await setupAuth(context, url, options.auth);
17
+ }
18
+ const result = {
19
+ targetUrl: url,
20
+ pages: [],
21
+ timestamp: new Date().toISOString(),
22
+ };
23
+ const visited = new Set();
24
+ const queue = [{ url, depth: 0 }];
25
+ const baseHost = new URL(url).hostname;
26
+ while (queue.length > 0 && result.pages.length < maxPages) {
27
+ const current = queue.shift();
28
+ const normalizedUrl = normalizeUrl(current.url);
29
+ if (visited.has(normalizedUrl) || current.depth > maxDepth)
30
+ continue;
31
+ if (ignorePatterns.some(p => normalizedUrl.includes(p)))
32
+ continue;
33
+ visited.add(normalizedUrl);
34
+ const page = await context.newPage();
35
+ try {
36
+ const pageInfo = await scanPage(page, current.url, waitTime);
37
+ // 스크린샷
38
+ if (screenshot) {
39
+ const safeName = normalizedUrl.replace(/[^a-zA-Z0-9가-힣]/g, '_').slice(0, 80);
40
+ const ssPath = join(screenshotDir, `${safeName}.png`);
41
+ await page.screenshot({ path: ssPath, fullPage: true });
42
+ pageInfo.screenshotPath = ssPath;
43
+ }
44
+ // API 호출 수집 (이미 scanPage에서 네트워크 인터셉트)
45
+ result.pages.push(pageInfo);
46
+ // 새 URL 큐에 추가
47
+ for (const link of pageInfo.elements.links) {
48
+ try {
49
+ const linkUrl = new URL(link.href, current.url);
50
+ if (linkUrl.hostname === baseHost && !visited.has(normalizeUrl(linkUrl.href))) {
51
+ queue.push({ url: linkUrl.href, depth: current.depth + 1 });
52
+ }
53
+ }
54
+ catch { /* invalid URL */ }
55
+ }
56
+ // 버튼 클릭으로 발견되는 URL도 추가
57
+ for (const nav of pageInfo.navigatesTo) {
58
+ try {
59
+ const navUrl = new URL(nav, current.url);
60
+ if (navUrl.hostname === baseHost && !visited.has(normalizeUrl(navUrl.href))) {
61
+ queue.push({ url: navUrl.href, depth: current.depth + 1 });
62
+ }
63
+ }
64
+ catch { /* invalid URL */ }
65
+ }
66
+ }
67
+ catch (err) {
68
+ // 페이지 로드 실패 → 건너뜀
69
+ }
70
+ finally {
71
+ await page.close();
72
+ }
73
+ }
74
+ await browser.close();
75
+ return result;
76
+ }
77
+ /** 개별 페이지 스캔: DOM 요소 + 네트워크 인터셉트 */
78
+ async function scanPage(page, url, waitTime) {
79
+ const apiCalls = [];
80
+ // 네트워크 인터셉트 — API 호출 캡처
81
+ page.on('response', async (response) => {
82
+ const request = response.request();
83
+ const resUrl = request.url();
84
+ const resourceType = request.resourceType();
85
+ if ((resourceType === 'xhr' || resourceType === 'fetch') && !isStaticResource(resUrl)) {
86
+ apiCalls.push({
87
+ method: request.method(),
88
+ url: resUrl,
89
+ responseStatus: response.status(),
90
+ triggeredBy: null,
91
+ });
92
+ }
93
+ });
94
+ // 페이지 로드
95
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
96
+ await page.waitForTimeout(waitTime);
97
+ const title = await page.title();
98
+ // DOM 스캔
99
+ const elements = await page.evaluate(() => {
100
+ // 링크
101
+ const links = Array.from(document.querySelectorAll('a[href]')).map((el, i) => {
102
+ const a = el;
103
+ return {
104
+ text: a.textContent?.trim().slice(0, 100) || '',
105
+ href: a.href,
106
+ selector: a.id ? `#${a.id}` : `a:nth-of-type(${i + 1})`,
107
+ };
108
+ });
109
+ // 버튼
110
+ const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]')).map((el, i) => {
111
+ const btn = el;
112
+ return {
113
+ text: btn.textContent?.trim().slice(0, 100) || btn.value || '',
114
+ selector: btn.id ? `#${btn.id}` : `button:nth-of-type(${i + 1})`,
115
+ navigatesTo: null,
116
+ };
117
+ });
118
+ // 폼
119
+ const forms = Array.from(document.querySelectorAll('form')).map((el) => {
120
+ const form = el;
121
+ const fields = Array.from(form.querySelectorAll('input, select, textarea')).map((f) => {
122
+ const field = f;
123
+ return {
124
+ name: field.name || field.id || '',
125
+ fieldType: field.type || field.tagName.toLowerCase(),
126
+ required: field.required,
127
+ };
128
+ });
129
+ return {
130
+ id: form.id || null,
131
+ action: form.action || null,
132
+ method: form.method?.toUpperCase() || 'GET',
133
+ fields,
134
+ };
135
+ });
136
+ return { links, buttons, forms };
137
+ });
138
+ // 네비게이션 대상 URL 수집
139
+ const navigatesTo = [
140
+ ...new Set(elements.links.map(l => l.href).filter(Boolean)),
141
+ ];
142
+ // 로그인 페이지 감지
143
+ const authRequired = await page.evaluate(() => {
144
+ const html = document.body?.innerHTML?.toLowerCase() || '';
145
+ return html.includes('login') || html.includes('sign in') || html.includes('로그인');
146
+ });
147
+ return {
148
+ url,
149
+ title,
150
+ screenshotPath: null,
151
+ elements,
152
+ apiCalls,
153
+ navigatesTo,
154
+ authRequired,
155
+ };
156
+ }
157
+ /** 인증 설정 */
158
+ async function setupAuth(context, baseUrl, auth) {
159
+ const host = new URL(baseUrl).hostname;
160
+ if (auth.cookie) {
161
+ const cookies = auth.cookie.split(';').map(c => {
162
+ const [name, ...rest] = c.trim().split('=');
163
+ return { name, value: rest.join('='), domain: host, path: '/' };
164
+ });
165
+ await context.addCookies(cookies);
166
+ }
167
+ if (auth.bearer) {
168
+ await context.setExtraHTTPHeaders({
169
+ Authorization: `Bearer ${auth.bearer}`,
170
+ });
171
+ }
172
+ if (auth.loginUrl && auth.credentials) {
173
+ const page = await context.newPage();
174
+ await page.goto(auth.loginUrl, { waitUntil: 'networkidle' });
175
+ for (const [field, value] of Object.entries(auth.credentials)) {
176
+ await page.fill(`[name="${field}"], #${field}, input[type="${field}"]`, value).catch(() => { });
177
+ }
178
+ const submitSelector = auth.submitSelector || 'button[type="submit"], button:has-text("로그인"), button:has-text("Login")';
179
+ await page.click(submitSelector).catch(() => { });
180
+ await page.waitForNavigation({ waitUntil: 'networkidle' }).catch(() => { });
181
+ await page.close();
182
+ }
183
+ }
184
+ function normalizeUrl(url) {
185
+ try {
186
+ const u = new URL(url);
187
+ u.hash = '';
188
+ u.search = '';
189
+ let path = u.pathname;
190
+ if (path.endsWith('/') && path.length > 1)
191
+ path = path.slice(0, -1);
192
+ u.pathname = path;
193
+ return u.href;
194
+ }
195
+ catch {
196
+ return url;
197
+ }
198
+ }
199
+ function isStaticResource(url) {
200
+ return /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|ico|map)(\?|$)/i.test(url);
201
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { analyze } from './analyzer/index.js';
2
+ export { crawl } from './crawler/index.js';
2
3
  export { generateReport } from './docgen/index.js';
3
4
  export { generateTests } from './testgen/index.js';
4
5
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { analyze } from './analyzer/index.js';
2
+ export { crawl } from './crawler/index.js';
2
3
  export { generateReport } from './docgen/index.js';
3
4
  export { generateTests } from './testgen/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reverse-engine",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "웹 서비스 역분석 자동화 도구 - 소스코드 분석, 문서 생성, 테스트 자동화",
5
5
  "keywords": [
6
6
  "reverse-engineering",