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

EndpointMétodoQué hace
/v1/clustersGETLos clústeres de la cuenta, del más reciente al más antiguo: identificador, nombre, número de dominios, fecha de creación.
/v1/clustersPOSTCrea un clúster a partir de una búsqueda (query) o de una lista (domains); name es opcional.
/v1/clusters/{id}GETEl 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/combinePOSTUn clúster nuevo a partir de otros existentes: operation and, or o diff, y clusters, una lista de identificadores.
/v1/clusters/{id}/extractGET, POSTExtrae valores con presets y regex, parte por parte: offset, limit (hasta 1.000 sitios por llamada), format json, xml o csv.
/v1/clusters/presetsGETLos patrones predefinidos, con sus expresiones.
/v1/clusters/{id}/renamePOSTNuevo name.
/v1/clusters/{id}/deletePOSTElimina 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 predefinidoQué extrae
emailDirecciones de los enlaces mailto:
phoneNúmeros de los enlaces tel:
whatsapp, telegram, skypeNúmeros de WhatsApp, nombres de usuario de Telegram y nombres de Skype, a partir de sus enlaces
facebook, instagram, twitter, linkedinEnlaces a los perfiles sociales del sitio (twitter también reconoce x.com, y linkedin, las páginas de empresa)
gtm, ga4, uaIdentificadores de contenedor de Google Tag Manager, de Google Analytics 4 y de Universal Analytics
hotjarIdentificador de sitio de Hotjar
adsenseIdentificador de editor de AdSense
bitcoinDirecciones 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ódigoSignificado
404 cluster_not_foundNo hay ningún clúster con ese identificador en esta cuenta.
409 cluster_limitLa cuenta ya guarda 100 clústeres.
400 invalid_regexUna expresión no es una expresión PCRE entre barras o barras verticales, de hasta 200 caracteres.
400 unknown_presetNo existe ese patrón predefinido; la respuesta enumera los que hay.
400 missing_source, ambiguous_sourcePara crear un clúster hace falta query o bien domains.
429 extract_quota_exceededSe 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