Clústeres
Un clúster es una lista guardada de sitios web. Con la API puedes crear uno a partir de una búsqueda o de tu propia lista, combinar clústeres y extraer valores del código fuente de cada sitio de un clúster: contactos, perfiles en redes sociales, identificadores de analítica y de etiquetas, todo lo que pueda capturar una expresión regular.
Los endpoints
| Endpoint | Método | Qué hace |
|---|---|---|
/v1/clusters | GET | Los clústeres de la cuenta, del más reciente al más antiguo: identificador, nombre, número de dominios, fecha de creación. |
/v1/clusters | POST | Crea un clúster a partir de una búsqueda (query) o de una lista (domains); name es opcional. |
/v1/clusters/{id} | GET | El clúster y una página de sus dominios: page, per_page (hasta 10.000), format=txt para un dominio por línea. |
/v1/clusters/combine | POST | Un clúster nuevo a partir de otros existentes: operation and, or o diff, y clusters, una lista de identificadores. |
/v1/clusters/{id}/extract | GET, POST | Extrae valores con presets y regex, parte por parte: offset, limit (hasta 1.000 sitios por llamada), format json, xml o csv. |
/v1/clusters/presets | GET | Los patrones predefinidos, con sus expresiones. |
/v1/clusters/{id}/rename | POST | Nuevo name. |
/v1/clusters/{id}/delete | POST | Elimina el clúster para siempre. |
Todo lo que modifica un clúster es un POST con cuerpo JSON (no hay PUT ni DELETE),
así que cualquier cliente que pueda buscar también puede gestionar clústeres. El
token, el límite de frecuencia y el formato de los errores son los mismos que en la
búsqueda; las cifras de clústeres de la respuesta
de /v1/account muestran cuántos tienes y cuántos puntos de extracción
te quedan hoy.
Crear un clúster
A partir de una búsqueda: los resultados de la consulta, hasta el máximo de
filas de tu plan, pasan a ser el clúster. Cuesta una búsqueda, igual que
/v1/search.
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}
A partir de una lista: dominios o URL, como array JSON o uno por línea. Solo
se conservan los sitios que están en el índice: submitted indica
cuántos enviaste y size, cuántos entraron. Una lista no cuesta nada.
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"}'
El plan limita el número de dominios de un clúster, y una cuenta guarda hasta 100
clústeres. Si ya hay 100, crear otro devuelve 409 cluster_limit: no se
elimina nada en tu nombre; elimina primero los que ya no necesites.
Combinar clústeres
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 conserva los dominios que están en todos los clústeres,
or los que están en cualquiera de ellos y diff los que
están en el primer clúster pero no en el segundo (en este caso, exactamente dos
clústeres). Combinar no cuesta nada.
Extraer datos
La extracción lee las páginas indexadas de cada sitio del clúster y devuelve lo que capturan las expresiones, una columna por expresión. Lo más sencillo es usar un patrón predefinido:
| Patrón predefinido | Qué extrae |
|---|---|
email | Direcciones de los enlaces mailto: |
phone | Números de los enlaces tel: |
whatsapp, telegram, skype | Números de WhatsApp, nombres de usuario de Telegram y nombres de Skype, a partir de sus enlaces |
facebook, instagram, twitter, linkedin | Enlaces a los perfiles sociales del sitio (twitter también reconoce x.com, y linkedin, las páginas de empresa) |
gtm, ga4, ua | Identificadores de contenedor de Google Tag Manager, de Google Analytics 4 y de Universal Analytics |
hotjar | Identificador de sitio de Hotjar |
adsense | Identificador de editor de AdSense |
bitcoin | Direcciones de los enlaces de pago bitcoin: |
O escribe tu propia expresión: una expresión regular entre barras (o barras
verticales) con los modificadores opcionales i, m,
s, u, de hasta 200 caracteres. El primer grupo de captura
es el valor, la misma regla que en
snipexp:. Hasta diez
expresiones por llamada, patrones predefinidos incluidos.
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 }
]
}
Parte por parte. Una llamada recorre hasta 1.000 sitios del clúster, a
partir de offset. Vuelve a llamar con offset igual a
next_offset hasta que este llegue como null. Los sitios
sin coincidencias se omiten; con skip_empty=0 también aparecen.
format=csv da una fila por sitio (el dominio y luego una columna por
expresión), con el siguiente offset en el encabezado X-Next-Offset.
Puntos. La extracción consume la cuota diaria de extracción de tu plan: cada
sitio encontrado en el índice cuesta tantos puntos como valores se encontraron en
él, o 0,1 si no hubo ninguno. Cuando se acaban los puntos, la llamada se detiene
antes de tiempo con "stopped": "extract_quota_exceeded" y devuelve lo
que lleva; una llamada sin puntos restantes recibe
429 extract_quota_exceeded. Prueba primero una expresión con un
limit pequeño; consulta los
límites de los clústeres.
Un clúster entero, desde código
Crear un clúster a partir de una búsqueda y escribir en un archivo CSV los valores extraídos de cada sitio. Las bibliotecas cliente traen lo mismo como funciones listas para usar y como línea de comandos.
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
Errores
| Estado y código | Significado |
|---|---|
404 cluster_not_found | No hay ningún clúster con ese identificador en esta cuenta. |
409 cluster_limit | La cuenta ya guarda 100 clústeres. |
400 invalid_regex | Una expresión no es una expresión PCRE entre barras o barras verticales, de hasta 200 caracteres. |
400 unknown_preset | No existe ese patrón predefinido; la respuesta enumera los que hay. |
400 missing_source, ambiguous_source | Para crear un clúster hace falta query o bien domains. |
429 extract_quota_exceeded | Se agotaron los puntos de extracción de hoy. |
La lista completa está en la página de errores y en la descripción de la propia API, en https://api.publicwww.com/. En un asistente de IA, las mismas operaciones son herramientas MCP.
Siguiente Ejemplos de código