Para crear un portal de empleo sin scrapear miles de webs de carrera, usa una API de Google SERP como capa de descubrimiento: consulta en Google las ofertas recientes en los grandes dominios de seguimiento de candidatos (Greenhouse, Lever, Ashby, Workday), extrae de los resultados orgánicos el título, el enlace y el fragmento, elimina duplicados por URL y guarda lo que te interese. Nunca mantienes un solo scraper específico por web. Un desarrollador en r/developersIndia scrapeó más de 2 millones de ofertas de empleo de más de 100.000 webs de carrera en un único conjunto de datos, y un comentarista señaló el coste evidente: estaba "construyendo mi propia API de búsqueda de Google (que cuesta mucho)." Ahí está la trampa. Mantener 100.000 parsers se rompe cada vez que una página de empleo cambia su marcado, y una capa SERP casera devora proxies y horas de ingeniería. Google ya rastrea cada página pública de empleo; una consulta indexada saca a la luz las ofertas nuevas el mismo día en que aparecen. Este tutorial recorre POST /api/v1/google con operadores site: dirigidos y consultas por fecha, el parseo de organic_results, la eliminación de duplicados y el almacenamiento. Scavio cobra 2 créditos (0,01 $) por una llamada SERP completa y 1 crédito (0,005 $) por una ligera, con pago por uso real, sin mínimo mensual ni depósito mínimo. Alcance honesto: el descubrimiento por SERP te da amplitud y frescura a bajo coste, pero para campos plenamente estructurados (salario, ubicación normalizada, URL de aplicación) aún parseas la página de destino o usas una API de ATS donde exista. SERP es la capa de descubrimiento, no todo el pipeline de ETL.
Requisitos previos
- Una clave de API de Scavio en scavio.dev (el plan gratuito incluye 50 créditos de un solo uso)
- Python 3.9+ con la librería requests, o Node.js 18+ para la versión JS
- Familiaridad básica con los operadores de Google (site:, intitle:, after:)
- Un almacén para las ofertas (SQLite, Postgres o incluso un archivo JSON para empezar)
Guia paso a paso
Paso 1: Configura tu clave de API y elige las consultas de descubrimiento
Exporta tu clave como variable de entorno y diseña consultas que apunten a los dominios de seguimiento de candidatos donde publican la mayoría de empresas. Cada consulta combina un portal con un rol y, opcionalmente, una ubicación. Lanza varias consultas concretas en lugar de una amplia; la cobertura SERP por consulta es finita, así que segmentar por portal y rol saca más ofertas distintas.
export SCAVIO_API_KEY="your_key_here"
# Discovery queries: board + role (+ optional location)
QUERIES = [
'site:boards.greenhouse.io OR site:jobs.lever.co "backend engineer" remote',
'site:jobs.ashbyhq.com "product designer"',
'site:boards.greenhouse.io "data engineer" New York',
]Paso 2: Llama a POST /api/v1/google y lee organic_results
Envía cada consulta al endpoint de Google con light_request=false para obtener el SERP completo. La respuesta incluye organic_results; cada fila trae position, title, link y snippet. El link es la oferta en vivo en Greenhouse o Lever, y el snippet suele contener el rol y la ubicación. Usa autenticación Bearer en la cabecera Authorization.
import os, requests
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
def search(query):
r = requests.post("https://api.scavio.dev/api/v1/google", headers=H,
json={"query": query, "light_request": False})
r.raise_for_status()
return r.json().get("organic_results", [])
for row in search('site:jobs.lever.co "backend engineer" remote'):
print(row["position"], row["title"], row["link"])Paso 3: Elimina duplicados por URL y normaliza en registros de oferta
La misma oferta aparece en varias consultas, así que elimina duplicados por URL antes de guardar nada. Quita las cadenas de consulta y los parámetros de seguimiento para que dos URLs del mismo empleo se fusionen en una. Convierte cada resultado orgánico en un registro plano que controlas tú, conservando la consulta de origen para auditar la cobertura más adelante.
from urllib.parse import urlsplit, urlunsplit
def canonical(url):
p = urlsplit(url)
return urlunsplit((p.scheme, p.netloc, p.path.rstrip("/"), "", ""))
def to_listing(row, source_query):
return {
"url": canonical(row["link"]),
"title": row["title"],
"snippet": row.get("snippet", ""),
"source_query": source_query,
}
seen = set()
listings = []
for q in QUERIES:
for row in search(q):
rec = to_listing(row, q)
if rec["url"] not in seen:
seen.add(rec["url"])
listings.append(rec)Paso 4: Mantenlo fresco y guárdalo en tu almacén
Añade una consulta por fecha para traer solo ofertas recientes y luego haz upsert por URL de forma programada (un cron diario basta para la mayoría de portales). Google indexa las páginas de empleo nuevas en horas o un día, así que un barrido diario mantiene el portal al día sin volver a scrapear nada. Aquí persistimos en SQLite; cambia a Postgres para producción.
import sqlite3
db = sqlite3.connect("jobs.db")
db.execute("""CREATE TABLE IF NOT EXISTS listings (
url TEXT PRIMARY KEY, title TEXT, snippet TEXT, source_query TEXT,
first_seen TEXT DEFAULT CURRENT_TIMESTAMP)""")
# Fresh-only discovery query (last 7 days)
fresh = 'site:boards.greenhouse.io "software engineer" after:2026-06-13'
listings += [to_listing(r, fresh) for r in search(fresh)
if canonical(r["link"]) not in seen]
for rec in listings:
db.execute("""INSERT OR IGNORE INTO listings (url, title, snippet, source_query)
VALUES (:url, :title, :snippet, :source_query)""", rec)
db.commit()
print(f"stored {len(listings)} listings")Ejemplo en Python
import os, requests, sqlite3
from urllib.parse import urlsplit, urlunsplit
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
QUERIES = [
'site:boards.greenhouse.io OR site:jobs.lever.co "backend engineer" remote',
'site:jobs.ashbyhq.com "product designer"',
'site:boards.greenhouse.io "data engineer" New York',
]
def search(query):
r = requests.post("https://api.scavio.dev/api/v1/google", headers=H,
json={"query": query, "light_request": False})
r.raise_for_status()
return r.json().get("organic_results", [])
def canonical(url):
p = urlsplit(url)
return urlunsplit((p.scheme, p.netloc, p.path.rstrip("/"), "", ""))
db = sqlite3.connect("jobs.db")
db.execute("""CREATE TABLE IF NOT EXISTS listings (
url TEXT PRIMARY KEY, title TEXT, snippet TEXT, source_query TEXT,
first_seen TEXT DEFAULT CURRENT_TIMESTAMP)""")
seen, stored = set(), 0
for q in QUERIES:
for row in search(q):
url = canonical(row["link"])
if url in seen:
continue
seen.add(url)
db.execute("INSERT OR IGNORE INTO listings (url, title, snippet, source_query) VALUES (?, ?, ?, ?)",
(url, row["title"], row.get("snippet", ""), q))
stored += 1
db.commit()
print(f"stored {stored} unique listings")Ejemplo en JavaScript
import Database from "better-sqlite3";
const H = {
Authorization: `Bearer ${process.env.SCAVIO_API_KEY}`,
"Content-Type": "application/json",
};
const QUERIES = [
'site:boards.greenhouse.io OR site:jobs.lever.co "backend engineer" remote',
'site:jobs.ashbyhq.com "product designer"',
'site:boards.greenhouse.io "data engineer" New York',
];
async function search(query) {
const r = await fetch("https://api.scavio.dev/api/v1/google", {
method: "POST",
headers: H,
body: JSON.stringify({ query, light_request: false }),
});
const data = await r.json();
return data.organic_results ?? [];
}
function canonical(url) {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname.replace(/\/$/, "")}`;
}
const db = new Database("jobs.db");
db.exec(`CREATE TABLE IF NOT EXISTS listings (
url TEXT PRIMARY KEY, title TEXT, snippet TEXT, source_query TEXT)`);
const insert = db.prepare("INSERT OR IGNORE INTO listings (url, title, snippet, source_query) VALUES (?, ?, ?, ?)");
const seen = new Set();
let stored = 0;
for (const q of QUERIES) {
for (const row of await search(q)) {
const url = canonical(row.link);
if (seen.has(url)) continue;
seen.add(url);
insert.run(url, row.title, row.snippet ?? "", q);
stored++;
}
}
console.log(`stored ${stored} unique listings`);Salida esperada
1 Senior Backend Engineer - Acme - jobs.lever.co https://jobs.lever.co/acme/8f2c...
2 Backend Engineer (Remote) - Globex https://jobs.lever.co/globex/1a9d...
3 Staff Backend Engineer - Initech https://jobs.lever.co/initech/4b7e...
stored 27 unique listings