클러스터
클러스터는 저장된 웹사이트 목록입니다. API로 검색 결과나 직접 만든 목록에서 클러스터를 만들고, 클러스터끼리 조합하고, 클러스터에 속한 모든 사이트의 페이지 소스에서 값을 추출할 수 있습니다. 연락처, 소셜 프로필, 애널리틱스와 태그 ID 등 정규 표현식으로 잡아낼 수 있는 것이라면 무엇이든 추출할 수 있습니다.
엔드포인트
| 엔드포인트 | 메서드 | 기능 |
|---|---|---|
/v1/clusters | GET | 계정의 클러스터를 최신순으로 보여 줍니다. ID, 이름, 도메인 수, 생성 시각이 담깁니다. |
/v1/clusters | POST | 검색(query)이나 목록(domains)으로 클러스터를 만듭니다. name은 선택 사항입니다. |
/v1/clusters/{id} | GET | 클러스터 정보와 도메인 한 페이지를 돌려줍니다. page, per_page(최대 10,000), 한 줄에 도메인 하나씩 받으려면 format=txt를 지정합니다. |
/v1/clusters/combine | POST | 기존 클러스터로 새 클러스터를 만듭니다. operation(and, or, diff 중 하나), clusters(ID 목록)를 지정합니다. |
/v1/clusters/{id}/extract | GET, POST | presets와 regex로 값을 부분별로 추출합니다. offset, limit(호출당 최대 1,000개 사이트), format(json, xml, csv)을 지정합니다. |
/v1/clusters/presets | GET | 기성 표현식과 그 패턴을 보여 줍니다. |
/v1/clusters/{id}/rename | POST | 새 name을 지정합니다. |
/v1/clusters/{id}/delete | POST | 클러스터를 영구히 삭제합니다. |
클러스터를 바꾸는 요청은 모두 JSON 본문을 담은 POST입니다. PUT이나 DELETE는 쓰지 않으므로,
검색을 할 수 있는 클라이언트라면 클러스터도 관리할 수 있습니다. 토큰, 속도 제한, 오류 형식은
검색과 같습니다. /v1/account 응답의 클러스터
항목에서 클러스터를 몇 개 가지고 있는지, 오늘 추출 포인트가 얼마나 남았는지 확인할 수
있습니다.
클러스터 만들기
검색으로: 쿼리 결과가 요금제의 검색당 최대 행 수까지 클러스터가 됩니다.
/v1/search와 마찬가지로 검색 1회가 차감됩니다.
curl https://api.publicwww.com/v1/clusters \
-H "Authorization: Bearer $PUBLICWWW_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "\"googletagmanager.com/gtm.js\"", "name": "GTM sites"}'
{"id": 7, "name": "GTM sites", "size": 100000, "created": "2026-10-10T20:21:30Z",
"query": "\"googletagmanager.com/gtm.js\"", "total": 2412577, "index_complete": true}
목록으로: 도메인이나 URL을 JSON 배열로, 또는 한 줄에 하나씩 보냅니다. 인덱스에 있는
사이트만 남습니다. submitted는 보낸 개수, size는 실제로 들어간
개수입니다. 목록으로 만들 때는 아무것도 차감되지 않습니다.
curl https://api.publicwww.com/v1/clusters \
-H "Authorization: Bearer $PUBLICWWW_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["example.com", "https://www.example.org/about"], "name": "Prospects"}'
클러스터당 도메인 수는 요금제에 따라 제한되며, 계정당 클러스터는 최대 100개까지 보관할 수
있습니다. 이미 100개가 있으면 새로 만들 때 409 cluster_limit 오류가 반환됩니다.
사용자를 대신해 삭제하는 일은 없으니, 더 이상 필요 없는 클러스터를 먼저 삭제하세요.
클러스터 조합하기
curl https://api.publicwww.com/v1/clusters/combine \
-H "Authorization: Bearer $PUBLICWWW_KEY" \
-H "Content-Type: application/json" \
-d '{"operation": "diff", "clusters": [7, 3], "name": "GTM, not yet contacted"}'
and는 모든 클러스터에 있는 도메인을, or는 어느 하나에라도 있는
도메인을, diff는 첫 번째 클러스터에는 있고 두 번째에는 없는 도메인을 남깁니다.
이때는 클러스터를 정확히 두 개 지정해야 합니다. 조합에는 아무것도 차감되지 않습니다.
데이터 추출
추출은 클러스터에 속한 모든 사이트의 인덱싱된 페이지를 읽어, 표현식이 캡처한 내용을 표현식마다 한 열씩 돌려줍니다. 가장 쉬운 방법은 프리셋입니다.
| 프리셋 | 추출하는 내용 |
|---|---|
email | mailto: 링크의 이메일 주소 |
phone | tel: 링크의 전화번호 |
whatsapp, telegram, skype | 각 서비스 링크에 담긴 WhatsApp 번호, Telegram 사용자 이름, Skype 이름 |
facebook, instagram, twitter, linkedin | 사이트의 소셜 프로필 링크(twitter는 x.com도, linkedin은 회사 페이지도 포함) |
gtm, ga4, ua | Google Tag Manager 컨테이너 ID, Google Analytics 4 ID, Universal Analytics ID |
hotjar | Hotjar 사이트 ID |
adsense | AdSense 퍼블리셔 ID |
bitcoin | bitcoin: 결제 링크의 주소 |
직접 작성할 수도 있습니다. 슬래시(또는 파이프)로 감싼 200자 이내의 정규 표현식이며,
i, m, s, u 플래그를 붙일 수 있습니다. 첫 번째
캡처 그룹이 값이 됩니다. snipexp:와 같은
규칙입니다. 호출당 표현식은 프리셋을 포함해 최대 10개입니다.
curl https://api.publicwww.com/v1/clusters/7/extract \
-H "Authorization: Bearer $PUBLICWWW_KEY" \
-H "Content-Type: application/json" \
-d '{"presets": ["gtm", "email"], "regex": ["/data-site-id=\"([0-9]+)\"/i"], "limit": 1000}'
{
"cluster": 7, "name": "GTM sites", "size": 100000,
"offset": 0, "scanned": 1000, "in_index": 1000, "with_matches": 941,
"next_offset": 1000,
"regex": ["/(GTM-[A-Z0-9]{4,10})\\b/", "/mailto:(...)/i", "/data-site-id=\"([0-9]+)\"/i"],
"points_used": 1834.2, "points_left": 98165,
"rows": [
{ "domain": "example.com", "values": [["GTM-AB12CD"], ["info@example.com"], []], "matches": 2 }
]
}
부분별 처리. 한 번의 호출은 offset부터 시작해 클러스터의 사이트를 최대
1,000개까지 처리합니다. offset을 next_offset 값으로 바꿔 가며, 그
값이 null로 돌아올 때까지 다시 호출하세요. 아무것도 일치하지
않은 사이트는 결과에서 빠지며, skip_empty=0을 지정하면 함께 나열됩니다.
format=csv는 사이트마다 한 행(도메인, 이어서 표현식마다 한 열)을 돌려주며, 다음
오프셋은 X-Next-Offset 헤더에 담깁니다.
포인트. 추출에는 요금제의 일일 추출 할당량이 쓰입니다. 인덱스에서 찾은 사이트마다 그
사이트에서 찾은 값의 개수만큼 포인트가 차감되며, 값이 없으면 0.1포인트가 차감됩니다.
포인트가 바닥나면 호출이 도중에 멈추고 "stopped": "extract_quota_exceeded"와 함께
그때까지의 결과를 돌려줍니다. 남은 포인트가 없는 상태에서 호출하면
429 extract_quota_exceeded 오류가 반환됩니다. 표현식은 먼저 작은
limit 값으로 시험해 보세요. 클러스터 제한을
참고하세요.
클러스터 전체를 코드로
검색으로 클러스터를 만들고, 모든 사이트에서 추출한 값을 CSV 파일에 기록합니다. 클라이언트 라이브러리에는 같은 기능이 바로 쓸 수 있는 함수와 명령줄 도구로 들어 있습니다.
Python
import csv, os, time, requests
KEY = os.environ["PUBLICWWW_KEY"]
BASE = "https://api.publicwww.com"
H = {"Authorization": "Bearer " + KEY}
def call(method, path, body=None):
while True:
r = requests.request(method, BASE + path, headers=H, json=body)
if r.status_code == 429 and r.json()["error"]["code"] == "too_many_requests":
time.sleep(int(r.headers.get("Retry-After", 30)))
continue
r.raise_for_status()
return r.json()
cluster = call("POST", "/v1/clusters", {"query": '"googletagmanager.com/gtm.js"'})
offset = 0
with open("extract.csv", "w", newline="") as f:
out = csv.writer(f)
while offset is not None:
part = call("POST", "/v1/clusters/%d/extract" % cluster["id"],
{"presets": ["gtm", "email"], "offset": offset})
for row in part["rows"]:
out.writerow([row["domain"]] + [" ".join(v) for v in row["values"]])
offset = part["next_offset"]
JavaScript (Node 18+)
const BASE = "https://api.publicwww.com";
const H = { Authorization: "Bearer " + process.env.PUBLICWWW_KEY,
"Content-Type": "application/json" };
async function call(method, path, body) {
for (;;) {
const r = await fetch(BASE + path, { method, headers: H,
body: body && JSON.stringify(body) });
const data = await r.json();
if (r.status === 429 && data.error.code === "too_many_requests") {
await new Promise(ok => setTimeout(ok, 1000 * (r.headers.get("Retry-After") || 30)));
continue;
}
if (!r.ok) throw new Error(data.error.message);
return data;
}
}
const cluster = await call("POST", "/v1/clusters", { query: '"hotjar.com"' });
for (let offset = 0; offset !== null; ) {
const part = await call("POST", `/v1/clusters/${cluster.id}/extract`,
{ presets: ["hotjar", "email"], offset });
for (const row of part.rows) console.log(row.domain, row.values.map(v => v.join(" ")).join(";"));
offset = part.next_offset;
}
PHP
<?php
function call ($method, $path, $body = null) {
$ch = curl_init ("https://api.publicwww.com" . $path);
curl_setopt_array ($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv ("PUBLICWWW_KEY"),
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode ($body),
]);
$data = json_decode (curl_exec ($ch), true);
if (isset ($data ["error"])) throw new Exception ($data ["error"]["message"]);
return $data;
}
$cluster = call ("POST", "/v1/clusters", ["query" => '"jquery.min.js"']);
$out = fopen ("extract.csv", "w");
for ($offset = 0; $offset !== null; ) {
$part = call ("POST", "/v1/clusters/" . $cluster ["id"] . "/extract",
["presets" => ["email", "phone"], "offset" => $offset]);
foreach ($part ["rows"] as $row)
fputcsv ($out, array_merge ([$row ["domain"]], array_map (fn ($v) => join (" ", $v), $row ["values"])));
$offset = $part ["next_offset"];
}
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
func call(method, path string, body, out any) error {
b, _ := json.Marshal(body)
req, _ := http.NewRequest(method, "https://api.publicwww.com"+path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+os.Getenv("PUBLICWWW_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("publicwww: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
func main() {
var cluster struct{ ID int `json:"id"` }
if err := call("POST", "/v1/clusters", map[string]any{"query": `"googletagmanager.com/gtm.js"`}, &cluster); err != nil {
panic(err)
}
for offset := 0; ; {
var part struct {
Rows []struct {
Domain string `json:"domain"`
Values [][]string `json:"values"`
} `json:"rows"`
NextOffset *int `json:"next_offset"`
}
path := fmt.Sprintf("/v1/clusters/%d/extract", cluster.ID)
if err := call("POST", path, map[string]any{"presets": []string{"gtm", "ga4"}, "offset": offset}, &part); err != nil {
panic(err)
}
for _, r := range part.Rows {
cells := []string{r.Domain}
for _, v := range r.Values {
cells = append(cells, strings.Join(v, " "))
}
fmt.Println(strings.Join(cells, ";"))
}
if part.NextOffset == nil {
break
}
offset = *part.NextOffset
}
}
Ruby
require "json"
require "net/http"
def call(path, body)
uri = URI("https://api.publicwww.com" + path)
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{ENV.fetch('PUBLICWWW_KEY')}",
"Content-Type" => "application/json")
req.body = body.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
raise data["error"]["message"] if data["error"]
data
end
cluster = call("/v1/clusters", { query: '"hotjar.com"' })
offset = 0
while offset
part = call("/v1/clusters/#{cluster['id']}/extract", { presets: %w[hotjar email], offset: offset })
part["rows"].each { |r| puts [r["domain"], *r["values"].map { |v| v.join(" ") }].join(";") }
offset = part["next_offset"]
end
오류
| 상태와 코드 | 의미 |
|---|---|
404 cluster_not_found | 이 계정에 해당 ID의 클러스터가 없습니다. |
409 cluster_limit | 계정에 이미 클러스터가 100개 있습니다. |
400 invalid_regex | 표현식이 슬래시나 파이프로 감싼 200자 이내의 PCRE가 아닙니다. |
400 unknown_preset | 그런 프리셋이 없습니다. 응답에 사용할 수 있는 프리셋이 나열됩니다. |
400 missing_source, ambiguous_source | 클러스터를 만들려면 query와 domains 중 하나만 지정해야 합니다. |
429 extract_quota_exceeded | 오늘의 추출 포인트를 모두 사용했습니다. |
전체 목록은 오류 페이지와 API 자체 설명 https://api.publicwww.com/에 있습니다. AI 어시스턴트에서는 같은 작업을 MCP 도구로 할 수 있습니다.
다음 코드 예제