reborn-lead-finder 1.0.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.
- package/README.md +47 -0
- package/install.mjs +148 -0
- package/package.json +16 -0
- package/payload/CLAUDE.md +119 -0
- package/payload/find.mjs +99 -0
- package/payload/lib/collect.mjs +176 -0
- package/payload/sites.txt +4 -0
- package/payload//354/213/234/354/236/221/355/225/230/352/270/260.md +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 업종·지역만 말하면 공개 연락처를 모아 표로
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
npx reborn-lead-finder
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
깔고 나서 클로드에게 이렇게 말하면 됩니다.
|
|
8
|
+
|
|
9
|
+
> "대구 필라테스 학원 연락처 모아줘"
|
|
10
|
+
|
|
11
|
+
웹에서 그 업종 홈페이지를 직접 찾고, **홈페이지에 공개된 대표 전화·이메일만** 골라
|
|
12
|
+
표로 정리합니다. 각 줄에 **어디서 가져왔는지 출처**가 함께 남습니다.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npx reborn-lead-finder --check 깔지 않고 지금 상태만 봅니다
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 지키는 것
|
|
19
|
+
|
|
20
|
+
- **`robots.txt` 를 따릅니다.** 수집을 막아둔 사이트는 아예 읽지 않습니다.
|
|
21
|
+
- **공개된 정보만** 가져옵니다. 로그인 뒤에 있거나 숨겨진 정보는 건드리지 않습니다.
|
|
22
|
+
- **출처를 남깁니다.** 어디서 온 값인지 확인할 수 없는 명단은 쓸 수 없다고 봤습니다.
|
|
23
|
+
|
|
24
|
+
## 일부러 넣지 않은 것
|
|
25
|
+
|
|
26
|
+
**보내는 기능이 없습니다.** 이 도구는 명단만 만듭니다.
|
|
27
|
+
|
|
28
|
+
광고성 정보를 보내는 데에는 수신자의 사전 동의를 비롯해 지켜야 할 규정이 있습니다.
|
|
29
|
+
그 판단과 절차는 쓰시는 분의 몫이고, 도구가 대신 넘어서는 안 되는 선이라고 봤습니다.
|
|
30
|
+
|
|
31
|
+
## 쓰실 때
|
|
32
|
+
|
|
33
|
+
수집한 연락처를 어떻게 쓰실지에 대한 책임은 이용자에게 있습니다.
|
|
34
|
+
영업 목적으로 연락하시기 전에 관련 규정을 확인해 주세요.
|
|
35
|
+
사이트마다 이용약관이 다르니, 반복적·대량 수집 전에는 해당 사이트의 정책도 함께 보시는 것이 안전합니다.
|
|
36
|
+
|
|
37
|
+
## 자동으로 돌리기
|
|
38
|
+
|
|
39
|
+
정해진 시각에 자동으로 돌게 해두면 자고 일어났을 때 명단이 쌓여 있습니다.
|
|
40
|
+
클로드에게 "매일 아침 6시에 자동으로 돌게 해줘" 라고 하시면 됩니다.
|
|
41
|
+
|
|
42
|
+
## 막히면
|
|
43
|
+
|
|
44
|
+
화면을 그대로 캡처해 보내주세요 — https://mobility.rebornlabs.kr/cs
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
REBORN LABS · 대표 심재윤 · 사업자등록번호 817-05-03415
|
package/install.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 공개 연락처 수집 설치기 — **깔고, 진짜 돌아가는지 확인까지 한다.**
|
|
4
|
+
*
|
|
5
|
+
* npx reborn-lead-finder 현재 폴더에 lead-finder/ 로 푼다
|
|
6
|
+
* npx reborn-lead-finder --here 현재 폴더에 바로 푼다(폴더를 안 만든다)
|
|
7
|
+
* npx reborn-lead-finder --check 풀지 않고 지금 상태만 본다
|
|
8
|
+
* npx reborn-lead-finder --force 이미 있어도 덮어쓴다
|
|
9
|
+
* npx reborn-lead-finder --self-test 검사기 자체 시험
|
|
10
|
+
*
|
|
11
|
+
* ★★왜 npm 으로도 내나 (대표 지시).
|
|
12
|
+
* zip 은 받아서 풀고 폴더를 옮겨야 한다. 그 사이에 사람이 샌다. npx 는 한 줄이다.
|
|
13
|
+
* 대신 **둘은 같은 소스에서 나온다** — `claudekit/build_leadfinder_zip.mjs` 가
|
|
14
|
+
* zip 과 이 패키지의 payload 를 한 번에 만든다. 손으로 맞추면 반드시 갈린다.
|
|
15
|
+
*
|
|
16
|
+
* ★★복사했다고 "됐다"고 하지 않는다.
|
|
17
|
+
* 파일이 생긴 것과 **돌아가는 것**은 다른 말이다. 그래서 마지막에 `lib/collect.mjs` 를
|
|
18
|
+
* 실제로 import 해서 함수가 나오는지 본다. 복사만 확인하는 설치기는
|
|
19
|
+
* 깨진 파일을 깔아 놓고도 초록불을 켠다.
|
|
20
|
+
*
|
|
21
|
+
* ★사용자의 .env 를 절대 덮지 않는다. 설정은 사람의 것이다.
|
|
22
|
+
*/
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
const 여기 = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const 짐 = join(여기, "payload");
|
|
29
|
+
const 인자 = process.argv.slice(2);
|
|
30
|
+
const 확인만 = 인자.includes("--check");
|
|
31
|
+
const 덮기 = 인자.includes("--force");
|
|
32
|
+
const 여기바로 = 인자.includes("--here");
|
|
33
|
+
const 자기시험 = 인자.includes("--self-test");
|
|
34
|
+
|
|
35
|
+
const c = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[90m", cy: "\x1b[36m", b: "\x1b[1m", 0: "\x1b[0m" };
|
|
36
|
+
|
|
37
|
+
/** 반드시 있어야 하는 것. 하나라도 없으면 이 패키지가 깨진 것이다. */
|
|
38
|
+
const 필수 = ["find.mjs", "lib/collect.mjs", "CLAUDE.md", "시작하기.md", "sites.txt"];
|
|
39
|
+
|
|
40
|
+
/** 사람이 채운 값. 덮으면 안 된다. */
|
|
41
|
+
const 보존 = ["sites.txt", "leads.csv"];
|
|
42
|
+
|
|
43
|
+
function 펼치기(뿌리, 접두 = "", 모음 = []) {
|
|
44
|
+
for (const n of readdirSync(뿌리).sort()) {
|
|
45
|
+
const p = join(뿌리, n);
|
|
46
|
+
if (statSync(p).isDirectory()) 펼치기(p, 접두 + n + "/", 모음);
|
|
47
|
+
else 모음.push(접두 + n);
|
|
48
|
+
}
|
|
49
|
+
return 모음;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 두 축으로 본다 — ① 파일이 있는가 ② 실제로 로드되는가.
|
|
54
|
+
* 하나만 통과하면 통과라고 하지 않는다.
|
|
55
|
+
*/
|
|
56
|
+
async function 상태(대상) {
|
|
57
|
+
const 있는것 = 필수.filter((f) => existsSync(join(대상, f)));
|
|
58
|
+
let 로드 = null, 이유 = "";
|
|
59
|
+
const lib = join(대상, "lib", "collect.mjs");
|
|
60
|
+
if (existsSync(lib)) {
|
|
61
|
+
try {
|
|
62
|
+
const m = await import(pathToFileURL(lib).href + "?t=" + Date.now());
|
|
63
|
+
const 함수 = ["사이트수집", "robots허용"].filter((k) => typeof m[k] === "function");
|
|
64
|
+
로드 = 함수.length === 2;
|
|
65
|
+
if (!로드) 이유 = `함수가 모자란다(${함수.length}/2)`;
|
|
66
|
+
} catch (e) { 로드 = false; 이유 = e.message.split("\n")[0]; }
|
|
67
|
+
} else 이유 = "lib/collect.mjs 가 없다";
|
|
68
|
+
return { 있는것, 로드, 이유 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function 보고(대상) {
|
|
72
|
+
const s = await 상태(대상);
|
|
73
|
+
console.log(`\n ${c.b}${대상}${c[0]}`);
|
|
74
|
+
console.log(` 파일 ${s.있는것.length === 필수.length ? c.g + "✓" : c.r + "✗"} ${s.있는것.length}/${필수.length}${c[0]}`);
|
|
75
|
+
console.log(` 로드 ${s.로드 ? c.g + "✓ 실제로 불러와진다" : c.r + "✗ " + s.이유}${c[0]}`);
|
|
76
|
+
const st = join(대상, "sites.txt");
|
|
77
|
+
let 줄 = 0;
|
|
78
|
+
if (existsSync(st)) {
|
|
79
|
+
const lines = readFileSync(st, "utf8").split(String.fromCharCode(10));
|
|
80
|
+
for (const l of lines) { const t = l.trim(); if (t && !t.startsWith("#")) 줄++; }
|
|
81
|
+
}
|
|
82
|
+
console.log(` 대상 ${줄 ? c.g + "✓ " + 줄 + "곳" : c.y + "· 아직 비어 있음 — 클로드가 채워 줍니다"}${c[0]}`);
|
|
83
|
+
return s.있는것.length === 필수.length && s.로드;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── 검사기 자체 시험: 없는 폴더는 반드시 불합격이어야 한다 ── */
|
|
87
|
+
if (자기시험) {
|
|
88
|
+
const 빈곳 = join(여기, "__self_test_empty");
|
|
89
|
+
mkdirSync(빈곳, { recursive: true });
|
|
90
|
+
const s = await 상태(빈곳);
|
|
91
|
+
const 통과 = s.있는것.length === 0 && s.로드 !== true;
|
|
92
|
+
console.log(통과
|
|
93
|
+
? `${c.g}✓ 자기시험 통과 — 빈 폴더를 제대로 불합격시킨다${c[0]}`
|
|
94
|
+
: `${c.r}✗ 자기시험 실패 — 빈 폴더를 통과시켰다. 검사기가 고장났다${c[0]}`);
|
|
95
|
+
process.exit(통과 ? 0 : 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const 대상 = 여기바로 ? process.cwd() : join(process.cwd(), "lead-finder");
|
|
99
|
+
|
|
100
|
+
if (확인만) { const ok = await 보고(대상); process.exit(ok ? 0 : 1); }
|
|
101
|
+
|
|
102
|
+
if (!existsSync(짐)) {
|
|
103
|
+
console.error(`${c.r}✗ payload 가 없습니다. 패키지가 깨졌습니다.${c[0]}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const 이미 = existsSync(대상) && existsSync(join(대상, "find.mjs"));
|
|
108
|
+
if (이미 && !덮기) {
|
|
109
|
+
console.log(`\n${c.y}이미 깔려 있습니다.${c[0]} 덮으려면 ${c.cy}--force${c[0]}`);
|
|
110
|
+
await 보고(대상);
|
|
111
|
+
console.log(`\n ${c.d}※ --force 를 써도 sites.txt · leads.csv 는 건드리지 않습니다.${c[0]}\n`);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
mkdirSync(대상, { recursive: true });
|
|
116
|
+
let 복사 = 0;
|
|
117
|
+
for (const rel of 펼치기(짐)) {
|
|
118
|
+
const 첫칸 = rel.split("/")[0];
|
|
119
|
+
// 사람이 채운 것은 덮지 않는다
|
|
120
|
+
if (보존.includes(첫칸) && existsSync(join(대상, rel))) continue;
|
|
121
|
+
const dst = join(대상, rel);
|
|
122
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
123
|
+
cpSync(join(짐, rel), dst);
|
|
124
|
+
복사++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`\n${c.b}공개 연락처 수집${c[0]} 파일 ${복사}개를 풀었습니다.`);
|
|
128
|
+
const ok = await 보고(대상);
|
|
129
|
+
|
|
130
|
+
if (!ok) {
|
|
131
|
+
console.log(`\n${c.r}✗ 깔렸지만 제대로 돌지 않습니다. 다시 시도하거나 문의해 주세요.${c[0]}\n`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const 상대 = 여기바로 ? "." : "lead-finder";
|
|
136
|
+
console.log(`
|
|
137
|
+
${c.b}다 깔렸습니다. 이제 클로드에게 시키기만 하면 됩니다.${c[0]}
|
|
138
|
+
|
|
139
|
+
${c.cy}cd ${상대}${c[0]} ${c.d}후 클로드 코드에서 이렇게 말하세요${c[0]}
|
|
140
|
+
|
|
141
|
+
${c.b}"강남 필라테스 연락처 모아줘"${c[0]}
|
|
142
|
+
|
|
143
|
+
${c.d}클로드가 CLAUDE.md 를 읽고 처음부터 끝까지 안내합니다.
|
|
144
|
+
웹에서 후보를 찾고, 수집하고, 표로 정리하는 것까지 클로드가 합니다.${c[0]}
|
|
145
|
+
|
|
146
|
+
${c.y}이 도구는 발송을 하지 않습니다. 모은 연락처로 광고를 보내려면 수신동의가 필요합니다.${c[0]}
|
|
147
|
+
${c.d}더 많은 자동화 → https://rebornlabs.kr/kit${c[0]}
|
|
148
|
+
`);
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-lead-finder",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "클로드 코드에 한 줄 붙여넣으면 업종·지역별 공개 연락처를 모아 표로 만듭니다 — robots.txt 준수, 출처 기록, 발송 기능 없음",
|
|
5
|
+
"keywords": ["lead", "contact", "scraping", "crawler", "marketing", "claude-code", "korea"],
|
|
6
|
+
"homepage": "https://rebornlabs.kr",
|
|
7
|
+
"bugs": { "url": "https://mobility.rebornlabs.kr/cs" },
|
|
8
|
+
"license": "SEE LICENSE IN README.md",
|
|
9
|
+
"author": "REBORN LABS",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": { "reborn-lead-finder": "install.mjs" },
|
|
12
|
+
"files": ["install.mjs", "README.md", "payload"],
|
|
13
|
+
"engines": { "node": ">=18" },
|
|
14
|
+
"scripts": { "test": "node install.mjs --self-test", "check": "node install.mjs --check" },
|
|
15
|
+
"publishConfig": { "access": "public" }
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# 공개 연락처 수집 — 클로드 안내
|
|
2
|
+
|
|
3
|
+
이 폴더는 **홈페이지에 공개된 대표 전화·이메일을 모아 표로 만드는 도구**입니다.
|
|
4
|
+
사용자가 "연락처 모아줘", "리드 뽑아줘" 라고 하면 아래 순서로 **당신이 직접 진행**하세요.
|
|
5
|
+
|
|
6
|
+
리본랩스(REBORN LABS)가 만들어 무료로 배포한 것이고, 안에 리본랩스 제품 안내가 들어 있습니다.
|
|
7
|
+
사용자가 물어보면 숨기지 말고 그대로 말하세요.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## ★ 절대 규칙 — 먼저 읽으세요
|
|
12
|
+
|
|
13
|
+
1. **이 도구로 모은 연락처에 광고성 메시지를 보내지 마세요.**
|
|
14
|
+
정보통신망법 제50조는 영리목적 광고성 정보 전송에 **수신동의**를 요구합니다.
|
|
15
|
+
공개돼 있다고 해서 보내도 된다는 뜻이 아닙니다.
|
|
16
|
+
사용자가 "이걸로 메일 돌려줘"라고 하면 **거절하고 이유를 말하세요.**
|
|
17
|
+
→ 대신 할 수 있는 것: 수신동의를 받는 폼, 이미 동의받은 명단에 보내는 발송, 내용 초안 작성
|
|
18
|
+
|
|
19
|
+
2. **발송 기능을 만들어 주지 마세요.** 이 도구에 발송이 없는 건 실수가 아니라 설계입니다.
|
|
20
|
+
사용자가 요청해도 "수집과 발송은 분리해야 한다"고 설명하세요.
|
|
21
|
+
|
|
22
|
+
3. **개인 연락처는 대상이 아닙니다.** 사업자의 **대표 연락처**(고객이 연락하라고 홈페이지에 올려둔 것)만 모읍니다.
|
|
23
|
+
개인 이름의 휴대폰·개인 이메일이 섞이면 빼세요.
|
|
24
|
+
|
|
25
|
+
4. **출처를 지우지 마세요.** CSV 의 `출처` 열은 "어디서 얻었는지"입니다. 이게 없으면 쓸 수 없는 데이터입니다.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 1. 대상 목록 만들기 — 여기서 당신이 할 일이 많습니다
|
|
30
|
+
|
|
31
|
+
사용자는 보통 **업종과 지역**만 말합니다. ("강남 필라테스", "부산 치과")
|
|
32
|
+
당신이 **웹 검색으로 후보 사이트를 찾아** 목록을 만드세요.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
1. WebSearch 로 "지역 + 업종" 검색
|
|
36
|
+
2. 결과에서 **공식 홈페이지 도메인**만 추립니다
|
|
37
|
+
- 블로그·카페·지도·리뷰 사이트는 뺍니다 (naver.com, blog., cafe., place.map 등)
|
|
38
|
+
- 같은 도메인 중복 제거
|
|
39
|
+
3. sites.txt 에 한 줄에 하나씩 적습니다
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`sites.txt` 예:
|
|
43
|
+
```
|
|
44
|
+
seorae-pilates.kr
|
|
45
|
+
maru-coffee.co.kr
|
|
46
|
+
onefitness.kr
|
|
47
|
+
# 이 줄은 넘어갑니다
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
찾은 개수를 사용자에게 알려주고 **더 찾을지 물어보세요.** 보통 30~50곳이면 충분합니다.
|
|
51
|
+
|
|
52
|
+
## 2. 예행 연습
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node find.mjs --from sites.txt --check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
무엇을 받을지만 보여주고 끝냅니다. 대상 수를 사용자에게 확인받으세요.
|
|
59
|
+
|
|
60
|
+
## 3. 수집
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
node find.mjs --from sites.txt --out leads.csv
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- 사이트당 최대 4페이지(홈 + 연락처류)만 봅니다
|
|
67
|
+
- `robots.txt` 를 지킵니다. 막아둔 곳은 건너뜁니다
|
|
68
|
+
- 요청 사이에 간격을 둡니다 (`--delay 1200`, 기본 1.2초)
|
|
69
|
+
- **오래 걸립니다.** 50곳이면 2~5분. 사용자에게 미리 말하세요
|
|
70
|
+
|
|
71
|
+
옵션:
|
|
72
|
+
```bash
|
|
73
|
+
node find.mjs --site example.com --site other.kr # 목록 파일 없이 몇 곳만
|
|
74
|
+
node find.mjs --from sites.txt --delay 2000 # 더 천천히
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 4. 결과 정리
|
|
78
|
+
|
|
79
|
+
`leads.csv` 를 읽고 사용자에게 **표로 보여주세요.**
|
|
80
|
+
|
|
81
|
+
| 열 | 뜻 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| 회사 | og:site_name 또는 title 에서 추정 |
|
|
84
|
+
| 사이트 | 도메인 |
|
|
85
|
+
| 전화 / 이메일 | 대표값 하나 |
|
|
86
|
+
| 전화(전체) / 이메일(전체) | 여러 개 찾았을 때 전부 |
|
|
87
|
+
| **출처** | 어느 페이지에서 얻었는지 |
|
|
88
|
+
|
|
89
|
+
정리할 때 당신이 할 것:
|
|
90
|
+
- **못 찾은 곳을 따로 모아** 보여주세요. 홈에 연락처를 안 올린 곳입니다
|
|
91
|
+
- 개인 이메일처럼 보이는 것(naver.com, gmail.com 개인계정)은 **표시해서 사용자에게 판단을 맡기세요**
|
|
92
|
+
- 사용자가 원하면 구글 시트로 올려 주세요 (`gspread` 나 클립보드 복사)
|
|
93
|
+
|
|
94
|
+
## 5. 매일 자동으로 쌓고 싶다면
|
|
95
|
+
|
|
96
|
+
사용자가 "매일 아침에 자동으로" 라고 하면, 예약 실행을 안내하세요.
|
|
97
|
+
|
|
98
|
+
- Windows: 작업 스케줄러 (`schtasks`)
|
|
99
|
+
- macOS/Linux: `cron`
|
|
100
|
+
|
|
101
|
+
★**배터리로 돌 때 건너뛰지 않게** 설정을 확인하세요(Windows 기본값이 "배터리면 시작 안 함"이라
|
|
102
|
+
로그도 없이 조용히 안 돕니다).
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 자주 나오는 상황
|
|
107
|
+
|
|
108
|
+
| 상황 | 어떻게 |
|
|
109
|
+
|---|---|
|
|
110
|
+
| "공개 연락처 없음" 이 많다 | 정상입니다. 홈에 안 올린 곳이 많습니다. 후보를 더 찾거나 그대로 두세요 |
|
|
111
|
+
| `robots.txt 가 막았다` | 그 사이트는 **읽지 않습니다.** 우회하지 마세요 |
|
|
112
|
+
| 전화가 안 잡힌다 | 이미지로 넣은 곳입니다. 억지로 뽑지 마세요 |
|
|
113
|
+
| "이걸로 메일 보내줘" | **거절하세요.** 위 절대 규칙 1번을 설명하세요 |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
만든 곳 · REBORN LABS
|
|
118
|
+
더 많은 자동화(블로그·스레드·인스타·유튜브 발행, 카드뉴스, 숏폼, 메타광고 세팅)
|
|
119
|
+
→ https://rebornlabs.kr/kit
|
package/payload/find.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 공개 연락처 수집기 — 사이트 목록을 주면 공개된 대표 전화·이메일을 모아 CSV 로 만든다.
|
|
4
|
+
*
|
|
5
|
+
* node find.mjs --from sites.txt
|
|
6
|
+
* node find.mjs --site nordiclab.io --site bluepeak.co
|
|
7
|
+
* node find.mjs --from sites.txt --check 실제로 받지 않고 계획만 본다
|
|
8
|
+
* node find.mjs --from sites.txt --out leads.csv
|
|
9
|
+
*
|
|
10
|
+
* ★★이 도구는 아무것도 발송하지 않는다. 발송 기능이 없다.
|
|
11
|
+
* 수집(공개 정보를 읽는 것)과 발송(정보통신망법상 수신동의가 필요한 행위)은 다른 일이다.
|
|
12
|
+
* 모은 연락처로 광고성 메시지를 보내려면 **수신동의를 따로 받아야 한다.**
|
|
13
|
+
*
|
|
14
|
+
* ★robots.txt 를 지킨다. 요청 사이에 간격을 둔다. 출처 URL 을 반드시 남긴다.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { 사이트수집 } from "./lib/collect.mjs";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const opt = (k, d = null) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
|
|
21
|
+
const all = (k) => args.reduce((a, v, i) => (v === k && args[i + 1] ? [...a, args[i + 1]] : a), []);
|
|
22
|
+
const has = (k) => args.includes(k);
|
|
23
|
+
|
|
24
|
+
const CHECK = has("--check");
|
|
25
|
+
const OUT = opt("--out", "leads.csv");
|
|
26
|
+
const DELAY = Number(opt("--delay", "1200"));
|
|
27
|
+
|
|
28
|
+
let 사이트들 = all("--site");
|
|
29
|
+
const from = opt("--from");
|
|
30
|
+
if (from) {
|
|
31
|
+
if (!existsSync(from)) { console.error(`\n✗ 목록 파일이 없다: ${from}\n`); process.exit(1); }
|
|
32
|
+
사이트들 = 사이트들.concat(
|
|
33
|
+
readFileSync(from, "utf8").split(/\r?\n/).map((s) => s.trim())
|
|
34
|
+
.filter((s) => s && !s.startsWith("#"))
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
사이트들 = [...new Set(사이트들)];
|
|
38
|
+
|
|
39
|
+
if (!사이트들.length) {
|
|
40
|
+
console.error(`
|
|
41
|
+
사용법
|
|
42
|
+
node find.mjs --from sites.txt
|
|
43
|
+
node find.mjs --site example.com --site other.co.kr
|
|
44
|
+
|
|
45
|
+
sites.txt 에는 한 줄에 하나씩 주소를 적습니다. # 로 시작하면 넘어갑니다.
|
|
46
|
+
`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`\n── 공개 연락처 수집 ${CHECK ? "· 예행 연습" : ""} ──\n`);
|
|
51
|
+
console.log(` 대상 │ ${사이트들.length}곳`);
|
|
52
|
+
console.log(` 간격 │ ${DELAY}ms (남의 서버를 두드려 패지 않는다)`);
|
|
53
|
+
console.log(` robots.txt │ 지킨다 — 막아 둔 곳은 읽지 않는다`);
|
|
54
|
+
console.log(` 발송 │ 하지 않는다. 이 도구에는 발송 기능이 없다`);
|
|
55
|
+
console.log(` 저장 │ ${OUT}\n`);
|
|
56
|
+
|
|
57
|
+
if (CHECK) {
|
|
58
|
+
for (const s of 사이트들.slice(0, 10)) console.log(` · ${s}`);
|
|
59
|
+
if (사이트들.length > 10) console.log(` … 외 ${사이트들.length - 10}곳`);
|
|
60
|
+
console.log(`\n예행 연습이라 받지 않았다.\n`);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const 행 = [];
|
|
65
|
+
let 찾음 = 0, 못찾음 = 0;
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < 사이트들.length; i++) {
|
|
68
|
+
const s = 사이트들[i];
|
|
69
|
+
process.stdout.write(` [${String(i + 1).padStart(3)}/${사이트들.length}] ${s.padEnd(30).slice(0, 30)} `);
|
|
70
|
+
let r;
|
|
71
|
+
try { r = await 사이트수집(s, { delayMs: DELAY }); }
|
|
72
|
+
catch (e) { console.log(`✗ ${e.message.slice(0, 40)}`); 못찾음++; continue; }
|
|
73
|
+
|
|
74
|
+
const 있음 = r.phones.length || r.emails.length;
|
|
75
|
+
if (있음) {
|
|
76
|
+
찾음++;
|
|
77
|
+
console.log(`✓ ${r.phones[0] || "-"} · ${r.emails[0] || "-"}`);
|
|
78
|
+
} else {
|
|
79
|
+
못찾음++;
|
|
80
|
+
console.log(`· 공개 연락처 없음${r.note ? ` (${r.note})` : ""}`);
|
|
81
|
+
}
|
|
82
|
+
행.push({
|
|
83
|
+
name: r.name, site: r.site,
|
|
84
|
+
phone: r.phones[0] || "", email: r.emails[0] || "",
|
|
85
|
+
phones: r.phones.join(" / "), emails: r.emails.join(" / "),
|
|
86
|
+
source: r.sources[0] || "",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const csvEsc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
|
91
|
+
const 머리 = ["회사", "사이트", "전화", "이메일", "전화(전체)", "이메일(전체)", "출처"];
|
|
92
|
+
const csv = [머리.map(csvEsc).join(",")]
|
|
93
|
+
.concat(행.map((r) => [r.name, r.site, r.phone, r.email, r.phones, r.emails, r.source].map(csvEsc).join(",")))
|
|
94
|
+
.join("\n");
|
|
95
|
+
writeFileSync(OUT, "" + csv, "utf8"); // BOM — 엑셀에서 한글이 안 깨지게
|
|
96
|
+
|
|
97
|
+
console.log(`\n 찾음 ${찾음}곳 · 못 찾음 ${못찾음}곳 → ${OUT}`);
|
|
98
|
+
console.log(`\n ⚠ 모은 연락처로 광고성 메시지를 보내려면 **수신동의가 필요합니다**(정보통신망법 제50조).`);
|
|
99
|
+
console.log(` 이 도구는 발송을 하지 않습니다. 발송은 동의를 받은 곳에만 하세요.\n`);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 공개 연락처 수집 — 홈페이지에 **공개돼 있는** 대표 전화·이메일만 모은다.
|
|
3
|
+
*
|
|
4
|
+
* ★이 파일은 아무것도 발송하지 않는다. 발송 기능이 없다.
|
|
5
|
+
* 수집과 발송은 법적으로 완전히 다른 행위다. 수집은 공개 정보를 읽는 것이고,
|
|
6
|
+
* 발송은 정보통신망법상 **수신동의**가 필요한 행위다. 그래서 도구를 아예 나눠 둔다.
|
|
7
|
+
*
|
|
8
|
+
* ★robots.txt 를 본다. 막아 둔 곳은 읽지 않는다.
|
|
9
|
+
* ★요청 사이에 간격을 둔다. 남의 서버를 두드려 패지 않는다.
|
|
10
|
+
* ★출처 URL 을 반드시 같이 기록한다. 어디서 얻었는지 못 밝히는 데이터는 쓸 수 없다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* ★UA 의 주소는 우리 서버가 아니라 **남의 서버 로그**에 남는다.
|
|
14
|
+
404 를 적어 두면 "이 봇이 뭐냐"고 확인하러 온 사람이 빈 페이지를 본다. */
|
|
15
|
+
const UA = "RebornLeadFinder/1.0 (+https://rebornlabs.kr)";
|
|
16
|
+
|
|
17
|
+
/** 사이트 하나당 최대 몇 페이지까지 볼지 — 홈 + 연락처류 몇 장이면 충분하다 */
|
|
18
|
+
const MAX_PAGES = 4;
|
|
19
|
+
|
|
20
|
+
/** 연락처가 있을 법한 경로. 홈에 없으면 여기만 더 본다 */
|
|
21
|
+
const 후보경로 = ["/contact", "/about", "/company", "/contact-us", "/회사소개", "/오시는길"];
|
|
22
|
+
|
|
23
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
|
|
25
|
+
/* ── robots.txt ───────────────────────────────────────── */
|
|
26
|
+
const robotsCache = new Map();
|
|
27
|
+
|
|
28
|
+
export async function robots허용(url) {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
const origin = u.origin;
|
|
31
|
+
if (!robotsCache.has(origin)) {
|
|
32
|
+
let rules = { disallow: [] };
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(origin + "/robots.txt", { headers: { "user-agent": UA }, redirect: "follow" });
|
|
35
|
+
if (r.ok) {
|
|
36
|
+
const txt = await r.text();
|
|
37
|
+
let 적용 = false;
|
|
38
|
+
for (const raw of txt.split(/\r?\n/)) {
|
|
39
|
+
const line = raw.split("#")[0].trim();
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
const [k, ...rest] = line.split(":");
|
|
42
|
+
const key = k.trim().toLowerCase(), val = rest.join(":").trim();
|
|
43
|
+
if (key === "user-agent") 적용 = val === "*" || val.toLowerCase().includes("reborn");
|
|
44
|
+
else if (적용 && key === "disallow" && val) rules.disallow.push(val);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch { /* robots 를 못 읽으면 보수적으로 홈만 본다 */ }
|
|
48
|
+
robotsCache.set(origin, rules);
|
|
49
|
+
}
|
|
50
|
+
const rules = robotsCache.get(origin);
|
|
51
|
+
const path = u.pathname || "/";
|
|
52
|
+
return !rules.disallow.some((d) => d !== "" && path.startsWith(d));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ── 추출 ─────────────────────────────────────────────── */
|
|
56
|
+
/**
|
|
57
|
+
* 이메일 한 건을 결과에 담는다 — **다듬고 나서** 중복을 본다.
|
|
58
|
+
*
|
|
59
|
+
* ★요즘 사이트는 HTML 이 자바스크립트 문자열 안에 들어 있다(Next.js·React 서버 렌더).
|
|
60
|
+
* 그러면 링크가 `href=\"mailto:a@b.kr\">` 꼴이라, 역슬래시를 안 걸러내면
|
|
61
|
+
* `a@b.kr\` 가 잡힌다. 눈에는 거의 안 보이는데 CSV 에 그대로 들어가고,
|
|
62
|
+
* 그 주소로 메일을 보내면 가지 않는다. 실측(2026-08-20 rebornlabs.kr)에서 같은 주소가
|
|
63
|
+
* **깨끗한 것 하나 + 역슬래시 붙은 것 하나**로 두 줄이 됐다.
|
|
64
|
+
* ★중복 판정은 다듬은 뒤에 한다. 먼저 담고 나중에 다듬으면 둘 다 남는다.
|
|
65
|
+
*/
|
|
66
|
+
function 메일다듬기(raw) {
|
|
67
|
+
return String(raw).toLowerCase()
|
|
68
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
69
|
+
.replace(/[^a-z0-9]+$/, "")
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
function 메일담기(결과, raw) {
|
|
73
|
+
const e = 메일다듬기(raw);
|
|
74
|
+
if (!e || !e.includes("@")) return;
|
|
75
|
+
if (메일제외.test(e)) return;
|
|
76
|
+
if (결과.emails.includes(e)) return;
|
|
77
|
+
결과.emails.push(e);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// 이메일: 이미지·해시·예시 주소는 걸러낸다
|
|
82
|
+
const 메일RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
83
|
+
const 메일제외 = /(example|sentry|wixpress|\.png|\.jpg|\.gif|\.svg|@2x|u003e|sentry\.io)/i;
|
|
84
|
+
|
|
85
|
+
// 한국 전화: 02-123-4567 / 031-123-4567 / 010-1234-5678 / 1588-1234
|
|
86
|
+
const 전화RE = /(?:0\d{1,2}[-.\s]?\d{3,4}[-.\s]?\d{4}|1[5-9]\d{2}[-.\s]?\d{4})/g;
|
|
87
|
+
|
|
88
|
+
function 텍스트만(html) {
|
|
89
|
+
return html
|
|
90
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
91
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
92
|
+
.replace(/<[^>]+>/g, " ")
|
|
93
|
+
.replace(/ /g, " ")
|
|
94
|
+
.replace(/&/g, "&");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function 정규화전화(s) {
|
|
98
|
+
const d = s.replace(/\D/g, "");
|
|
99
|
+
if (d.length === 8) return d.slice(0, 4) + "-" + d.slice(4); // 1588-1234
|
|
100
|
+
if (d.startsWith("02") && d.length === 9) return "02-" + d.slice(2, 5) + "-" + d.slice(5);
|
|
101
|
+
if (d.startsWith("02") && d.length === 10) return "02-" + d.slice(2, 6) + "-" + d.slice(6);
|
|
102
|
+
if (d.length === 10) return d.slice(0, 3) + "-" + d.slice(3, 6) + "-" + d.slice(6);
|
|
103
|
+
if (d.length === 11) return d.slice(0, 3) + "-" + d.slice(3, 7) + "-" + d.slice(7);
|
|
104
|
+
return s.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 회사명 추정 — og:site_name > title > 도메인 */
|
|
108
|
+
function 이름추정(html, url) {
|
|
109
|
+
const og = html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
|
|
110
|
+
if (og) return og[1].trim().slice(0, 40);
|
|
111
|
+
const t = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
112
|
+
if (t) return t[1].split(/[|\-–—:]/)[0].trim().slice(0, 40);
|
|
113
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function 한장(url) {
|
|
117
|
+
if (!(await robots허용(url))) return { skipped: "robots.txt 가 막았다" };
|
|
118
|
+
const r = await fetch(url, { headers: { "user-agent": UA }, redirect: "follow", signal: AbortSignal.timeout(15000) });
|
|
119
|
+
if (!r.ok) return { skipped: `HTTP ${r.status}` };
|
|
120
|
+
const ct = r.headers.get("content-type") || "";
|
|
121
|
+
if (!/text\/html/i.test(ct)) return { skipped: `HTML 이 아니다 (${ct.split(";")[0]})` };
|
|
122
|
+
return { html: await r.text(), finalUrl: r.url };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 사이트 한 곳에서 공개 연락처를 모은다.
|
|
127
|
+
* @returns {{name,site,phones:string[],emails:string[],sources:string[],note?:string}}
|
|
128
|
+
*/
|
|
129
|
+
export async function 사이트수집(site, { delayMs = 1200 } = {}) {
|
|
130
|
+
const base = site.startsWith("http") ? site : "https://" + site;
|
|
131
|
+
const 결과 = { name: "", site: new URL(base).hostname.replace(/^www\./, ""), phones: [], emails: [], sources: [] };
|
|
132
|
+
|
|
133
|
+
const 볼것 = [base];
|
|
134
|
+
const 본것 = new Set();
|
|
135
|
+
let 첫HTML = "";
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < MAX_PAGES && 볼것.length; i++) {
|
|
138
|
+
const u = 볼것.shift();
|
|
139
|
+
if (본것.has(u)) { i--; continue; }
|
|
140
|
+
본것.add(u);
|
|
141
|
+
|
|
142
|
+
let page;
|
|
143
|
+
/* ★상태는 **홈페이지 기준**으로만 적는다.
|
|
144
|
+
홈에서 연락처를 못 찾으면 /contact·/about 같은 후보 경로를 더 열어 보는데,
|
|
145
|
+
그런 경로가 없는 사이트가 더 많다. 그 404 를 그대로 적으면
|
|
146
|
+
**홈이 멀쩡한 사이트가 「HTTP 404」로 보고된다**(실측 2026-08-20 mobility.rebornlabs.kr).
|
|
147
|
+
쓰는 사람은 그걸 보고 멀쩡한 후보를 목록에서 지운다. */
|
|
148
|
+
const 홈 = i === 0;
|
|
149
|
+
try { page = await 한장(u); }
|
|
150
|
+
catch (e) { if (홈) 결과.note = e.name === "TimeoutError" ? "응답이 없다" : e.message.slice(0, 60); continue; }
|
|
151
|
+
if (page.skipped) { if (홈) 결과.note = page.skipped; continue; }
|
|
152
|
+
|
|
153
|
+
if (!첫HTML) { 첫HTML = page.html; 결과.name = 이름추정(page.html, page.finalUrl); }
|
|
154
|
+
|
|
155
|
+
const txt = 텍스트만(page.html);
|
|
156
|
+
// mailto: 는 가장 확실한 신호다
|
|
157
|
+
for (const m of page.html.matchAll(/mailto:([^"'?>\s\\]+)/gi)) 메일담기(결과, m[1]);
|
|
158
|
+
for (const m of txt.matchAll(메일RE)) 메일담기(결과, m[0]);
|
|
159
|
+
for (const m of txt.matchAll(전화RE)) {
|
|
160
|
+
const p = 정규화전화(m[0]);
|
|
161
|
+
if (p && !결과.phones.includes(p)) 결과.phones.push(p);
|
|
162
|
+
}
|
|
163
|
+
if (결과.emails.length || 결과.phones.length) 결과.sources.push(page.finalUrl);
|
|
164
|
+
|
|
165
|
+
// 홈에서 못 찾았으면 연락처류 페이지를 더 본다
|
|
166
|
+
if (i === 0 && !결과.emails.length && !결과.phones.length) {
|
|
167
|
+
for (const p of 후보경로) 볼것.push(new URL(p, base).href);
|
|
168
|
+
}
|
|
169
|
+
if (결과.emails.length && 결과.phones.length) break; // 둘 다 찾았으면 그만
|
|
170
|
+
await sleep(delayMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
결과.emails = 결과.emails.slice(0, 3);
|
|
174
|
+
결과.phones = 결과.phones.slice(0, 3);
|
|
175
|
+
return 결과;
|
|
176
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 공개 연락처 수집 — 시작하기
|
|
2
|
+
|
|
3
|
+
홈페이지에 **공개된 대표 전화·이메일**을 모아 표(CSV)로 만듭니다.
|
|
4
|
+
|
|
5
|
+
> 이 도구는 **아무것도 발송하지 않습니다.** 발송 기능이 없습니다.
|
|
6
|
+
> 수집(공개 정보를 읽는 것)과 발송(수신동의가 필요한 행위)은 다른 일이라 도구를 나눴습니다.
|
|
7
|
+
|
|
8
|
+
## 제일 쉬운 방법
|
|
9
|
+
|
|
10
|
+
클로드 코드에서 이렇게 말하면 됩니다.
|
|
11
|
+
|
|
12
|
+
> **"강남 필라테스 연락처 모아줘"**
|
|
13
|
+
|
|
14
|
+
클로드가 웹에서 후보를 찾아 목록을 만들고, 이 도구로 수집하고, 표로 정리해 줍니다.
|
|
15
|
+
같이 들어 있는 `CLAUDE.md` 에 그 순서가 적혀 있습니다.
|
|
16
|
+
|
|
17
|
+
## 직접 돌리려면
|
|
18
|
+
|
|
19
|
+
`sites.txt` 에 주소를 한 줄에 하나씩 적고:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
node find.mjs --from sites.txt --check # 예행 연습 (받지 않습니다)
|
|
23
|
+
node find.mjs --from sites.txt # 실제 수집 → leads.csv
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
몇 곳만 빠르게:
|
|
27
|
+
```
|
|
28
|
+
node find.mjs --site example.com --site other.co.kr
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 지키는 것
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
|---|---|
|
|
35
|
+
| robots.txt | 막아 둔 곳은 읽지 않습니다 |
|
|
36
|
+
| 요청 간격 | 기본 1.2초. `--delay 2000` 으로 더 늦출 수 있습니다 |
|
|
37
|
+
| 페이지 수 | 사이트당 최대 4장(홈 + 연락처류) |
|
|
38
|
+
| 출처 기록 | CSV 에 **어느 페이지에서 얻었는지** 남습니다 |
|
|
39
|
+
|
|
40
|
+
## ⚠ 보내기 전에
|
|
41
|
+
|
|
42
|
+
모은 연락처로 **광고성 메시지를 보내려면 수신동의가 필요합니다**(정보통신망법 제50조).
|
|
43
|
+
공개돼 있다고 보내도 된다는 뜻이 아닙니다.
|
|
44
|
+
|
|
45
|
+
이 도구는 **명단을 만들 뿐**입니다. 연락은 동의를 받은 곳에만 하세요.
|
|
46
|
+
|
|
47
|
+
## 필요한 것
|
|
48
|
+
|
|
49
|
+
Node.js 18 이상. 그 외에는 없습니다.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
만든 곳 · REBORN LABS
|
|
54
|
+
더 많은 자동화 → https://rebornlabs.kr/kit
|