Phone Finder
Pricing
from $3.12 / 1,000 api requests
Go to Apify Store
Phone Finder
Tomba’s Phone Finder lets you retrieve direct phone numbers associated with professionals or businesses using just a few key details
Phone Finder
Pricing
from $3.12 / 1,000 api requests
Tomba’s Phone Finder lets you retrieve direct phone numbers associated with professionals or businesses using just a few key details
1[settings]2idiomatic_version_file_enable_tools = []3
4[tools."github:apify/apify-cli"]5version = "latest"6bin = "apify"7
8[tools."github:apify/apify-cli".platforms]9linux-x64 = { asset_pattern = "apify-*-linux-x64" }10linux-arm64 = { asset_pattern = "apify-*-linux-arm64" }11macos-x64 = { asset_pattern = "apify-*-darwin-x64" }12macos-arm64 = { asset_pattern = "apify-*-darwin-arm64" }13windows-x64 = { asset_pattern = "apify-*-windows-x64.exe" }14windows-arm64 = { asset_pattern = "apify-*-windows-arm64.exe" }.prettierignore{ "printWidth": 120, "singleQuote": true, "tabWidth": 4}1# Development2
3Notes for maintainers of the Tomba Phone Finder Actor. The README is the end-user page shown on Apify Store.4
5## Requirements6
7- Node.js 20+8- [Apify CLI](https://docs.apify.com/cli) for deployment9
10## Scripts11
12```bash13npm install14npm run build # compile TypeScript to dist/15npm run lint # ESLint (src and test)16npm run format # Prettier17npm test # unit + end-to-end tests (node:test)18npm start # run locally with tsx19```20
21## Credentials22
23The Actor uses our Tomba account. Users never enter an API key: credentials come from environment variables, never from the input:24
25| Variable | Description |26| -------------------- | -------------------------------------------------- |27| `TOMBA_API_KEY` | Tomba API key (`ta_…`) |28| `TOMBA_API_SECRET` | Tomba secret (`ts_…`) |29| `TOMBA_API_ENDPOINT` | Optional API base URL; only used by the test suite |3031`.actor/actor.json` maps the variables to Apify secrets:32
33```bash34apify secrets add tombaApiKey ta_xxxxxxxxxxxxxxxxxxxx35apify secrets add tombaApiSecret ts_xxxxxxxxxxxxxxxxxxxx36apify push37```38
39Run locally:40
41```bash42TOMBA_API_KEY=ta_… TOMBA_API_SECRET=ts_… npm start43```44
45There is no client-side rate limit: requests run in parallel (`maxConcurrency`, 1–50) and 429/5xx responses are retried with exponential backoff, honoring `Retry-After`.46
47## Deploy48
49- **From Git**: on the [Actor creation page](https://console.apify.com/actors/new), click **Link Git Repository**50- **From your machine**: `apify login`, then `apify push`51
52## Pricing (pay per event)53
54In **Apify Console → Publication → Monetization**, choose **Pay per event** and add a single event:55
56| Event | Price | Charged when |57| --------------- | -------- | -------------------------------------------------- |58| `tomba-request` | $0.00312 | Once per credit of a billable response (see below) |5960Phone Finder costs credits (Tomba: "5 search credits, or 1 when the request includes domain"), charged as `tomba-request` events with `count`:61
62- 1 credit when the search includes a `domain` (alone or with `email`/`linkedin`)63- 5 credits (`PHONE_CREDITS`, $0.0156) for an `email` or `linkedin` search without a `domain`64
65`searchCredits()` in `src/main.ts` computes this and passes it to `callTomba()` as the charge count; the item's `chargedCredits` is `res.chargedCount`. The `full` option does not change the price. The former `phone-finder-request` event is no longer used and can be removed from the pricing configuration.66
67`isBillable()` in `src/tomba.ts` mirrors Tomba's billing:68
69| Tomba outcome | Charged |70| ------------------------------------------------------------ | ------- |71| JSON with non-empty `data`, including `valid: false` numbers | Yes |72| Error status (4xx, 5xx, including 422 and 429) | No |73| Success with empty or null `data` (no number on record) | No |74| Success with an `errors` object | No |75| Non-JSON body (reported as 502) | No |76| Cache hit | No |7778## Architecture79
80- `src/tomba.ts`: shared helper, identical in every Tomba Actor. It handles credentials, caching (per-Actor `tomba-cache-<actorId>` key-value store; falls back to an in-run cache if it can't be opened), retries with exponential backoff, pay-per-event charging, budget reservation, the concurrency pool and resume state.81- `src/main.ts`: input normalization (emails lowercased, domains cleaned, LinkedIn URLs trimmed, empty searches skipped, duplicates removed), credit calculation and output mapping. The Tomba `data` object is spread into the row, followed by `source`, `chargedCredits`, `charged`, `cached` and `error`. `maxResults` counts valid numbers only.82- The SDK call is `Phone.finder({ email, domain, linkedin, full, webhook_url })` → `GET /phone-finder`. The global `full` and `webhookUrl` inputs are added to every search, but only when set (`full` true, `webhookUrl` non-blank); they are part of the cache key.83- The `tomba` SDK v1.1.1 resolves every call to `{ data, rateLimit }`, where `data` is the response body. Its `.d.ts` types still declare the old return type, so always go through `callTomba()`.84
85## Tests86
87- `test/tomba.test.ts`: unit tests for the shared helper (identical in every Actor)88- `test/main.test.ts`: end-to-end tests that run `src/main.ts` against a local mock Tomba API89- `test/helpers.ts`: mock server and Actor runner (identical in every Actor)90
91Locally, the Apify SDK prices every event at $1 when `ACTOR_TEST_PAY_PER_EVENT=true`, so the tests use `maxTotalChargeUsd` as an event count.92
93## Standby mode (real-time API)94
95`.actor/actor.json` sets `usesStandbyMode: true` and `webServerSchema: ./web_server_schema.json` (OpenAPI 3).96
97- `src/standby.ts` (shared, identical in every Actor): `runActor()` runs a batch job, or, when `APIFY_META_ORIGIN=STANDBY`, starts an HTTP server on `Actor.config.get('containerPort')`.98 - `GET /` with the `x-apify-container-server-readiness-probe` header, or with no query: readiness / usage.99 - `GET /?…`: input built by the Actor's `fromQuery()`.100 - `POST /`: the same JSON input as a batch run.101 - Responses: `200 { items }`, `400` invalid input, `402` max charge limit reached, `404`, `405`.102- Every Actor's `run(input, ctx)` is shared by both modes: `ctx.push()` writes to the dataset in batch runs and to the HTTP response in Standby; `ctx.isDone()`/`ctx.markDone()` persist resume state only in batch runs.103- `fromQuery()` builds a single search from the `email`, `domain` and `linkedin` parameters, plus `full`, `webhookUrl` and `maxResults`. Several searches need `POST /` with `searches`.104- Caching and pay-per-event charging work the same in both modes.105
106Try it locally:107
108```bash109APIFY_META_ORIGIN=STANDBY ACTOR_WEB_SERVER_PORT=8080 TOMBA_API_KEY=ta_… TOMBA_API_SECRET=ts_… npm start110curl "localhost:8080/?email=jane.doe@stripe.com&domain=stripe.com"111```112
113## Key-value store schema114
115`.actor/key_value_store_schema.json` documents the default key-value store records (`INPUT`, `TOMBA_STATE`). The cross-run cache lives in the separate named store `tomba-cache-<actorId>`, one per Actor: under limited permissions an Actor can only open named storages it created itself, so the Tomba Actors must not share one store. If the store can't be opened, the run logs a warning and caches for this run only.116
117## Memory118
119`defaultMemoryMbytes` is 256: the Actor only makes HTTP calls, so more memory just costs more.{ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true }, "include": ["./src/**/*", "./test/**/*"]}# configurations.idea.vscode.zed
# crawlee and apify storage foldersapify_storagecrawlee_storagestorage
# installed filesnode_modules
# git folder.git
# dist folderdisttest# This file tells Git which files shouldn't be added to source control
.idea.vscode.zedstorageapify_storagecrawlee_storagenode_modulesdisttsconfig.tsbuildinfo
# Added by Apify CLI.venv1import prettier from 'eslint-config-prettier';2
3import apify from '@apify/eslint-config/ts.js';4import globals from 'globals';5import tsEslint from 'typescript-eslint';6
7// eslint-disable-next-line import/no-default-export8export default [9 { ignores: ['**/dist', 'eslint.config.mjs'] },10 ...apify,11 prettier,12 {13 languageOptions: {14 parser: tsEslint.parser,15 parserOptions: {16 project: 'tsconfig.eslint.json',17 },18 globals: {19 ...globals.node,20 ...globals.jest,21 },22 },23 plugins: {24 '@typescript-eslint': tsEslint.plugin,25 },26 rules: {27 'no-console': 0,28 },29 },30 {31 // node:test's describe/it return promises by design.32 files: ['test/**/*.ts'],33 rules: {34 '@typescript-eslint/no-floating-promises': 0,35 },36 },37];root = true
[*]indent_style = spaceindent_size = 4charset = utf-8trim_trailing_whitespace = trueinsert_final_newline = trueend_of_line = lf{ "extends": "@apify/tsconfig", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "dist", "noUnusedLocals": false, "skipLibCheck": true, "lib": ["DOM"] }, "include": ["./src/**/*"]}{ "name": "phone-finder", "version": "1.0.0", "type": "module", "description": "Find phone numbers and related information using the Tomba API.", "engines": { "node": ">=18.0.0" }, "dependencies": { "apify": "^3.7.2", "tomba": "^1.1.1" }, "devDependencies": { "@apify/eslint-config": "^1.0.0", "@apify/tsconfig": "^0.1.1", "@types/node": "^22.15.32", "ajv": "^8.20.0", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "globals": "^16.2.0", "prettier": "^3.5.3", "standard-version": "^9.5.0", "tsx": "^4.20.3", "typescript": "^5.8.3", "typescript-eslint": "^8.34.1" }, "scripts": { "start": "npm run start:dev", "start:prod": "node dist/main.js", "start:dev": "tsx src/main.ts", "build": "tsc", "lint": "eslint src test", "lint:fix": "eslint src test --fix", "format": "prettier --write .", "format:check": "prettier --check .", "release": "standard-version && git push --follow-tags origin main", "test": "node --import tsx --test test/*.test.ts" }, "author": "It's not you it's me", "license": "ISC"}# Specify the base Docker image. You can read more about# the available images at https://docs.apify.com/sdk/js/docs/guides/docker-images# You can also use any other image from Docker Hub.FROM apify/actor-node:22 AS builder
# Check preinstalled packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install all dependencies. Don't audit to speed up the installation.RUN npm install --include=dev --audit=false
# Next, copy the source files using the user set# in the base image.COPY . ./
# Install all dependencies and build the project.# Don't audit to speed up the installation.RUN npm run build
# Create final imageFROM apify/actor-node:22
# Check preinstalled packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install NPM packages, skip optional and development dependencies to# keep the image small. Avoid logging too much and print the dependency# tree for debuggingRUN npm --quiet set progress=false \ && npm install --omit=dev --omit=optional \ && echo "Installed NPM packages:" \ && (npm list --omit=dev --all || true) \ && echo "Node.js version:" \ && node --version \ && echo "NPM version:" \ && npm --version \ && rm -r ~/.npm
# Copy built JS files from builder imageCOPY /usr/src/app/dist ./dist
# Next, copy the remaining files and directories with the source code.# Since we do this after NPM install, quick build will be really fast# for most source file changes.COPY . ./
# Run the image.CMD npm run start:prod --silent1// Shared test harness for the Tomba Apify Actors.2// Keep this file identical across all Actors.3import { spawn } from 'node:child_process';4import { readFileSync } from 'node:fs';5import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';6import type { IncomingMessage, Server, ServerResponse } from 'node:http';7import { createServer, request as httpRequest } from 'node:http';8import type { AddressInfo } from 'node:net';9import { tmpdir } from 'node:os';10import { join } from 'node:path';11
12import { Ajv } from 'ajv';13
14export interface MockRequest {15 method: string;16 path: string;17 query: Record<string, string>;18 headers: IncomingMessage['headers'];19 body: unknown;20}21
22export interface MockResponse {23 status?: number;24 body?: unknown;25 /** Raw (non-JSON) body. */26 raw?: string;27 headers?: Record<string, string>;28}29
30export type MockHandler = (req: MockRequest) => MockResponse | Promise<MockResponse>;31
32export interface MockServer {33 url: string;34 requests: MockRequest[];35 close: () => Promise<void>;36}37
38/** Start a local HTTP server that impersonates the Tomba API. */39export async function startMockTomba(handler: MockHandler): Promise<MockServer> {40 const requests: MockRequest[] = [];41 const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {42 const chunks: Buffer[] = [];43 req.on('data', (chunk: Buffer) => chunks.push(chunk));44 req.on('end', async () => {45 const url = new URL(req.url ?? '/', 'http://localhost');46 const text = Buffer.concat(chunks).toString();47 let body: unknown = text;48 try {49 body = text ? JSON.parse(text) : undefined;50 } catch {51 // keep raw text52 }53 const request: MockRequest = {54 method: req.method ?? 'GET',55 path: url.pathname,56 query: Object.fromEntries(url.searchParams),57 headers: req.headers,58 body,59 };60 requests.push(request);61 try {62 const out = await handler(request);63 const status = out.status ?? 200;64 if (out.raw !== undefined) {65 res.writeHead(status, { 'content-type': 'text/html', ...out.headers });66 res.end(out.raw);67 } else {68 res.writeHead(status, { 'content-type': 'application/json', ...out.headers });69 res.end(JSON.stringify(out.body ?? {}));70 }71 } catch (err) {72 res.writeHead(500, { 'content-type': 'application/json' });73 res.end(JSON.stringify({ errors: { message: (err as Error).message } }));74 }75 });76 });77 await new Promise<void>((resolve) => {78 server.listen(0, '127.0.0.1', resolve);79 });80 const { port } = server.address() as AddressInfo;81 return {82 url: `http://127.0.0.1:${port}`,83 requests,84 close: async () =>85 new Promise<void>((resolve) => {86 server.close(() => resolve());87 }),88 };89}90
91export interface RunOptions {92 input: Record<string, unknown>;93 /** Mock server URL; omit to run without an endpoint override. */94 endpoint?: string;95 /** Reuse a storage directory (to test cache and resume). */96 storageDir?: string;97 /** Keep the default storages from the previous run in `storageDir`. */98 keepStorage?: boolean;99 /** Max total charge in USD (locally every event costs $1). */100 maxTotalChargeUsd?: number;101 /** Set to false to run without Tomba credentials. */102 withCredentials?: boolean;103 timeoutMs?: number;104}105
106export interface ChargeLogEntry {107 eventName: string;108 chargedCount?: number;109 [key: string]: unknown;110}111
112export interface RunResult {113 code: number | null;114 output: string;115 items: Record<string, unknown>[];116 charges: ChargeLogEntry[];117 storageDir: string;118 /** Number of charged events per event name. */119 chargeCounts: Record<string, number>;120}121
122/** Run the Actor (`src/main.ts`) in a child process against local storage. */123export async function runActor(options: RunOptions): Promise<RunResult> {124 const storageDir = options.storageDir ?? (await mkdtemp(join(tmpdir(), 'tomba-actor-test-')));125 const inputDir = join(storageDir, 'key_value_stores', 'default');126 await mkdir(inputDir, { recursive: true });127 await writeFile(join(inputDir, 'INPUT.json'), JSON.stringify(options.input));128
129 const env: NodeJS.ProcessEnv = {130 PATH: process.env.PATH,131 HOME: process.env.HOME,132 CRAWLEE_STORAGE_DIR: storageDir,133 CRAWLEE_PURGE_ON_START: options.keepStorage ? '0' : '1',134 APIFY_LOG_LEVEL: 'INFO',135 ACTOR_TEST_PAY_PER_EVENT: 'true',136 ACTOR_USE_CHARGING_LOG_DATASET: 'true',137 };138 if (options.withCredentials !== false) {139 env.TOMBA_API_KEY = 'ta_test_key';140 env.TOMBA_API_SECRET = 'ts_test_secret';141 }142 if (options.endpoint) env.TOMBA_API_ENDPOINT = options.endpoint;143 if (options.maxTotalChargeUsd !== undefined) env.ACTOR_MAX_TOTAL_CHARGE_USD = String(options.maxTotalChargeUsd);144
145 const { code, output } = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {146 const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts'], { env, cwd: process.cwd() });147 let out = '';148 child.stdout.on('data', (d: Buffer) => {149 out += d.toString();150 });151 child.stderr.on('data', (d: Buffer) => {152 out += d.toString();153 });154 const timer = setTimeout(() => {155 child.kill('SIGKILL');156 reject(new Error(`Actor run timed out\n${out}`));157 }, options.timeoutMs ?? 60_000);158 child.on('close', (c) => {159 clearTimeout(timer);160 resolve({ code: c, output: out });161 });162 });163
164 const items = await readDataset(storageDir, 'default');165 assertMatchesDatasetSchema(items);166 const charges = (await readDataset(storageDir, 'charging_log')) as ChargeLogEntry[];167 const chargeCounts: Record<string, number> = {};168 for (const c of charges) chargeCounts[c.eventName] = (chargeCounts[c.eventName] ?? 0) + (c.chargedCount ?? 1);169
170 return { code, output, items, charges, storageDir, chargeCounts };171}172
173export async function readDataset(storageDir: string, name: string): Promise<Record<string, unknown>[]> {174 const dir = join(storageDir, 'datasets', name);175 let files: string[];176 try {177 files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();178 } catch {179 return [];180 }181 return Promise.all(files.map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));182}183
184export async function removeStorage(storageDir: string): Promise<void> {185 await rm(storageDir, { recursive: true, force: true });186}187
188/** Total number of charged events across all event names. */189export function totalCharges(result: RunResult): number {190 return Object.values(result.chargeCounts).reduce((a, b) => a + b, 0);191}192
193export interface StandbyActor {194 /** Base URL of the Actor's HTTP server. */195 url: string;196 storageDir: string;197 /** GET or POST a request to the Actor and parse the JSON response. */198 call: (199 path: string,200 init?: { method?: string; body?: unknown; headers?: Record<string, string> },201 ) => Promise<{ status: number; body: Record<string, unknown> }>;202 /** Stop the Actor and return its log output and charges. */203 stop: () => Promise<{ output: string; chargeCounts: Record<string, number> }>;204}205
206async function freePort(): Promise<number> {207 const server = createServer();208 await new Promise<void>((resolve) => {209 server.listen(0, '127.0.0.1', resolve);210 });211 const { port } = server.address() as AddressInfo;212 await new Promise<void>((resolve) => {213 server.close(() => resolve());214 });215 return port;216}217
218async function httpCall(219 url: string,220 init: { method?: string; body?: unknown; headers?: Record<string, string> } = {},221): Promise<{ status: number; body: Record<string, unknown> }> {222 let payload: string | undefined;223 if (typeof init.body === 'string') payload = init.body;224 else if (init.body !== undefined) payload = JSON.stringify(init.body);225 return new Promise((resolve, reject) => {226 const req = httpRequest(227 url,228 {229 method: init.method ?? (payload ? 'POST' : 'GET'),230 headers: { 'content-type': 'application/json', ...init.headers },231 },232 (res) => {233 const chunks: Buffer[] = [];234 res.on('data', (c: Buffer) => chunks.push(c));235 res.on('end', () => {236 const text = Buffer.concat(chunks).toString();237 let body: Record<string, unknown> = {};238 try {239 body = JSON.parse(text);240 } catch {241 body = { raw: text };242 }243 resolve({ status: res.statusCode ?? 0, body });244 });245 },246 );247 req.on('error', reject);248 if (payload) req.write(payload);249 req.end();250 });251}252
253/** Start the Actor in Standby mode (real-time HTTP API) and wait until it is ready. */254export async function startStandbyActor(255 options: { endpoint?: string; maxTotalChargeUsd?: number; withCredentials?: boolean } = {},256): Promise<StandbyActor> {257 const storageDir = await mkdtemp(join(tmpdir(), 'tomba-standby-test-'));258 const port = await freePort();259 const env: NodeJS.ProcessEnv = {260 PATH: process.env.PATH,261 HOME: process.env.HOME,262 CRAWLEE_STORAGE_DIR: storageDir,263 APIFY_LOG_LEVEL: 'INFO',264 APIFY_META_ORIGIN: 'STANDBY',265 ACTOR_WEB_SERVER_PORT: String(port),266 ACTOR_TEST_PAY_PER_EVENT: 'true',267 ACTOR_USE_CHARGING_LOG_DATASET: 'true',268 };269 if (options.withCredentials !== false) {270 env.TOMBA_API_KEY = 'ta_test_key';271 env.TOMBA_API_SECRET = 'ts_test_secret';272 }273 if (options.endpoint) env.TOMBA_API_ENDPOINT = options.endpoint;274 if (options.maxTotalChargeUsd !== undefined) env.ACTOR_MAX_TOTAL_CHARGE_USD = String(options.maxTotalChargeUsd);275
276 const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts'], { env, cwd: process.cwd() });277 let output = '';278 child.stdout.on('data', (d: Buffer) => {279 output += d.toString();280 });281 child.stderr.on('data', (d: Buffer) => {282 output += d.toString();283 });284 const exited = new Promise<void>((resolve) => {285 child.on('close', () => resolve());286 });287
288 const url = `http://127.0.0.1:${port}`;289 const deadline = Date.now() + 30_000;290 for (;;) {291 if (child.exitCode !== null) throw new Error(`Standby Actor exited early\n${output}`);292 try {293 const res = await httpCall(`${url}/`, { headers: { 'x-apify-container-server-readiness-probe': '1' } });294 if (res.status === 200) break;295 } catch {296 // not listening yet297 }298 if (Date.now() > deadline) {299 child.kill('SIGKILL');300 throw new Error(`Standby Actor did not become ready\n${output}`);301 }302 await new Promise((r) => {303 setTimeout(r, 100);304 });305 }306
307 return {308 url,309 storageDir,310 call: async (path, init) => httpCall(`${url}${path}`, init),311 stop: async () => {312 child.kill('SIGTERM');313 await exited;314 const charges = (await readDataset(storageDir, 'charging_log')) as ChargeLogEntry[];315 const chargeCounts: Record<string, number> = {};316 for (const c of charges)317 chargeCounts[c.eventName] = (chargeCounts[c.eventName] ?? 0) + (c.chargedCount ?? 1);318 await removeStorage(storageDir);319 return { output, chargeCounts };320 },321 };322}323
324let datasetValidator: ReturnType<Ajv['compile']> | undefined;325
326/**327 * Apify validates every pushed item against `.actor/dataset_schema.json` and fails the run on a mismatch,328 * while local runs don't. Check the items the same way so schema mismatches fail the tests.329 */330export function assertMatchesDatasetSchema(items: Record<string, unknown>[]): void {331 if (!datasetValidator) {332 const { fields } = JSON.parse(readFileSync(join(process.cwd(), '.actor/dataset_schema.json'), 'utf8'));333 delete fields.$schema;334 datasetValidator = new Ajv({ allErrors: true, strict: false }).compile(fields);335 }336 for (const [index, item] of items.entries()) {337 if (!datasetValidator(item)) {338 const errors = (datasetValidator.errors ?? [])339 .map((e) => `${e.instancePath || '/'} ${e.message}`)340 .join('; ');341 throw new Error(342 `Dataset item ${index} doesn't match .actor/dataset_schema.json: ${errors}\n${JSON.stringify(item)}`,343 );344 }345 }346}347
348/**349 * Validate an input against `.actor/input_schema.json` (Apify validates the input before a run or API call350 * starts). Returns the error messages; empty when the input is accepted.351 */352export function inputSchemaErrors(input: Record<string, unknown>): string[] {353 const schema = JSON.parse(readFileSync(join(process.cwd(), '.actor/input_schema.json'), 'utf8'));354 const validate = new Ajv({ allErrors: true, strict: false }).compile({355 type: 'object',356 properties: schema.properties,357 required: schema.required ?? [],358 });359 return validate(input) ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`);360}1// Unit tests for src/tomba.ts. Keep this file identical across all Actors.2import assert from 'node:assert/strict';3import { mkdtemp } from 'node:fs/promises';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { after, before, describe, it } from 'node:test';7
8process.env.CRAWLEE_STORAGE_DIR = await mkdtemp(join(tmpdir(), 'tomba-unit-'));9process.env.ACTOR_TEST_PAY_PER_EVENT = 'true';10process.env.TOMBA_API_KEY = 'ta_unit';11process.env.TOMBA_API_SECRET = 'ts_unit';12
13const { Actor } = await import('apify');14const { TombaException } = await import('tomba');15const T = await import('../src/tomba.js');16
17const ok = (body: unknown) => async () => ({ data: body, rateLimit: {} });18const httpError = (status: number, body: unknown = {}, headers: Record<string, string> = {}) =>19 new TombaException('error', { status, headers }, body);20const charged = (event = T.EVENT_REQUEST) => Actor.getChargingManager().getChargedEventCount(event);21
22let seq = 0;23const params = () => ({ n: ++seq });24
25before(async () => {26 await Actor.init();27 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });28});29
30after(async () => {31 await Actor.exit({ exit: false });32});33
34describe('isBillable', () => {35 const cases: [string, unknown, boolean][] = [36 ['data array', { data: [{ a: 1 }] }, true],37 ['data object', { data: { email: 'a@b.co' } }, true],38 ['negative answer (object with empty list)', { data: { organization: {}, emails: [] } }, true],39 ['negative answer (undeliverable)', { data: { email: { status: 'invalid' } } }, true],40 ['empty array', { data: [] }, false],41 ['empty object', { data: {} }, false],42 ['object with only null values', { data: { person: null, company: null } }, false],43 ['negative answer (email null, other fields set)', { data: { email: null, first_name: 'John' } }, true],44 ['null data', { data: null }, false],45 ['missing data', { meta: {} }, false],46 ['errors object', { data: { a: 1 }, errors: { message: 'x' } }, false],47 ['string body', '<html>', false],48 ['null body', null, false],49 ['array body', [1, 2], false],50 ];51 for (const [name, body, expected] of cases) {52 it(name, () => assert.equal(T.isBillable(body), expected));53 }54});55
56describe('normalizers', () => {57 it('normalizeDomain strips protocol, www, path and case', () => {58 assert.equal(T.normalizeDomain(' https://www.Stripe.com/about?x=1 '), 'stripe.com');59 assert.equal(T.normalizeDomain('http://tomba.io'), 'tomba.io');60 assert.equal(T.normalizeDomain('sub.example.org'), 'sub.example.org');61 });62
63 it('normalizeEmail trims and lowercases', () => {64 assert.equal(T.normalizeEmail(' John@Stripe.COM '), 'john@stripe.com');65 });66
67 it('unique keeps first occurrence and drops empty keys', () => {68 assert.deepEqual(T.unique(['a', 'b', 'a', '', 'c']), ['a', 'b', 'c']);69 assert.deepEqual(70 T.unique([{ id: 1 }, { id: 2 }, { id: 1 }], (x) => String(x.id)),71 [{ id: 1 }, { id: 2 }],72 );73 });74});75
76describe('phone data', () => {77 it('counts phone numbers in phone_data', () => {78 assert.equal(T.phoneDataCount({ phone_data: [{ number: '+1' }, { number: '+2' }] }), 2);79 assert.equal(T.phoneDataCount({ phone_data: [] }), 0);80 assert.equal(T.phoneDataCount({ phone_number: true }), 0);81 assert.equal(T.phoneDataCount(null), 0);82 assert.equal(T.phoneDataCount('x'), 0);83 });84
85 it('detects records with phone data', () => {86 assert.equal(T.hasPhoneData({ phone_data: [{ number: '+1' }] }), true);87 assert.equal(T.hasPhoneData({ phone_data: [] }), false);88 assert.equal(T.hasPhoneData({}), false);89 });90
91 it('prices phone data at 5 credits', () => {92 assert.equal(T.PHONE_CREDITS, 5);93 });94});95
96describe('cacheStoreName', () => {97 it("is unique per Actor so limited-permission runs never open another Actor's store", () => {98 const previous = process.env.ACTOR_ID;99 try {100 process.env.ACTOR_ID = 'AbC123xyz';101 assert.equal(T.cacheStoreName(), 'tomba-cache-AbC123xyz');102 delete process.env.ACTOR_ID;103 assert.equal(T.cacheStoreName(), 'tomba-cache');104 } finally {105 if (previous === undefined) delete process.env.ACTOR_ID;106 else process.env.ACTOR_ID = previous;107 }108 });109});110
111describe('setupTomba without cache permissions', () => {112 it('keeps working with an in-run cache when the cache store cannot be opened', async () => {113 const original = Actor.openKeyValueStore.bind(Actor);114 Actor.openKeyValueStore = async () => {115 throw new Error('Insufficient permissions for the key-value store.');116 };117 try {118 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });119 const p = { cachePermissionTest: true };120 let calls = 0;121 const fn = async () => {122 calls++;123 return { data: { data: [1] }, rateLimit: {} };124 };125 const first = await T.callTomba('perm', p, fn);126 const second = await T.callTomba('perm', p, fn);127 assert.equal(first.charged, true);128 assert.equal(second.cached, true);129 assert.equal(calls, 1);130 } finally {131 Actor.openKeyValueStore = original;132 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });133 }134 });135});136
137describe('runPool', () => {138 it('processes every item with bounded concurrency', async () => {139 let active = 0;140 let peak = 0;141 const seen: number[] = [];142 await T.runPool(143 Array.from({ length: 20 }, (_, i) => i),144 async (item) => {145 active++;146 peak = Math.max(peak, active);147 await new Promise((r) => {148 setTimeout(r, 5);149 });150 seen.push(item);151 active--;152 },153 3,154 );155 assert.equal(seen.length, 20);156 assert.deepEqual(157 [...seen].sort((a, b) => a - b),158 Array.from({ length: 20 }, (_, i) => i),159 );160 assert.ok(peak <= 3, `peak concurrency ${peak}`);161 });162
163 it('handles an empty list', async () => {164 await T.runPool([], async () => assert.fail('should not run'));165 });166});167
168describe('callTomba', () => {169 it('charges a response with data and unwraps the SDK { data, rateLimit } shape', async () => {170 const start = charged();171 const res = await T.callTomba('test', params(), ok({ data: [{ name: 'React' }] }));172 assert.equal(res.status, 200);173 assert.equal(res.charged, true);174 assert.equal(res.cached, false);175 assert.deepEqual(res.data, [{ name: 'React' }]);176 assert.equal(charged(), start + 1);177 });178
179 it('accepts an SDK that returns the body directly', async () => {180 const res = await T.callTomba('test', params(), async () => ({ data: { x: 1 } }));181 assert.equal(res.charged, true);182 assert.deepEqual(res.data, { x: 1 });183 });184
185 it('charges a negative answer', async () => {186 const res = await T.callTomba('test', params(), ok({ data: { organization: {}, emails: [] } }));187 assert.equal(res.charged, true);188 });189
190 it('serves a repeated request from cache without charging', async () => {191 const p = params();192 let calls = 0;193 const fn = async () => {194 calls++;195 return { data: { data: { v: 1 } }, rateLimit: {} };196 };197 await T.callTomba('test', p, fn);198 const start = charged();199 const res = await T.callTomba('test', { ...p }, fn);200 assert.equal(res.cached, true);201 assert.equal(res.charged, false);202 assert.deepEqual(res.data, { v: 1 });203 assert.equal(calls, 1);204 assert.equal(charged(), start);205 });206
207 it('uses a different cache entry for different params or endpoints', async () => {208 const p = params();209 await T.callTomba('a', p, ok({ data: [1] }));210 const other = await T.callTomba('b', p, ok({ data: [2] }));211 assert.equal(other.cached, false);212 assert.deepEqual(other.data, [2]);213 });214
215 it('does not charge or cache empty data', async () => {216 const p = params();217 const start = charged();218 const res = await T.callTomba('test', p, ok({ data: [] }));219 assert.equal(res.charged, false);220 assert.equal(res.status, 200);221 const again = await T.callTomba('test', p, ok({ data: [] }));222 assert.equal(again.cached, false);223 assert.equal(charged(), start);224 });225
226 it('does not charge a success with an errors object', async () => {227 const res = await T.callTomba('test', params(), ok({ data: { a: 1 }, errors: { message: 'Bad thing' } }));228 assert.equal(res.charged, false);229 assert.equal(res.error, 'Bad thing');230 });231
232 it('reports a non-JSON body as 502 without charging', async () => {233 const res = await T.callTomba('test', params(), ok('<html>oops</html>'));234 assert.equal(res.status, 502);235 assert.equal(res.charged, false);236 });237
238 it('does not retry or charge a 422', async () => {239 let calls = 0;240 const res = await T.callTomba('test', params(), async () => {241 calls++;242 throw httpError(422, { errors: { message: 'Invalid domain' } });243 });244 assert.equal(calls, 1);245 assert.equal(res.status, 422);246 assert.equal(res.charged, false);247 assert.match(res.error ?? '', /Invalid domain/);248 });249
250 it('does not retry SDK input validation errors', async () => {251 let calls = 0;252 const res = await T.callTomba('test', params(), async () => {253 calls++;254 throw new TombaException('Domain is required.');255 });256 assert.equal(calls, 1);257 assert.equal(res.status, 400);258 assert.equal(res.charged, false);259 });260
261 it('retries a 429 honoring Retry-After, then charges the success', async () => {262 let calls = 0;263 const res = await T.callTomba('test', params(), async () => {264 calls++;265 if (calls === 1) throw httpError(429, {}, { 'retry-after': '1' });266 return { data: { data: { ok: true } }, rateLimit: {} };267 });268 assert.equal(calls, 2);269 assert.equal(res.charged, true);270 });271
272 it('retries network errors', async () => {273 let calls = 0;274 const res = await T.callTomba('test', params(), async () => {275 calls++;276 if (calls === 1) throw new TombaException('connect ECONNREFUSED 127.0.0.1:9');277 return { data: { data: [1] }, rateLimit: {} };278 });279 assert.equal(calls, 2);280 assert.equal(res.charged, true);281 });282
283 it('gives up after maxRetries on 5xx without charging', async () => {284 let calls = 0;285 const res = await T.callTomba('test', params(), async () => {286 calls++;287 throw httpError(503);288 });289 assert.equal(calls, 2); // 1 attempt + maxRetries (1)290 assert.equal(res.status, 503);291 assert.equal(res.charged, false);292 });293
294 it('charges the given event name', async () => {295 const start = charged(T.EVENT_PHONE_FINDER);296 await T.callTomba('phone', params(), ok({ data: { phone: '+1' } }), T.EVENT_PHONE_FINDER);297 assert.equal(charged(T.EVENT_PHONE_FINDER), start + 1);298 });299
300 it('charges a fixed count of events', async () => {301 const start = charged();302 const res = await T.callTomba('test', params(), ok({ data: [1] }), T.EVENT_REQUEST, 3);303 assert.equal(res.chargedCount, 3);304 assert.equal(charged(), start + 3);305 });306
307 it('charges a count computed from the response body', async () => {308 const start = charged();309 const res = await T.callTomba(310 'test',311 params(),312 ok({ data: { items: [1, 2, 3, 4] } }),313 T.EVENT_REQUEST,314 (body) => (body.data as { items: unknown[] }).items.length,315 );316 assert.equal(res.chargedCount, 4);317 assert.equal(charged(), start + 4);318 });319
320 it('does not charge a count for a non-billable response', async () => {321 const start = charged();322 const res = await T.callTomba('test', params(), ok({ data: [] }), T.EVENT_REQUEST, 5);323 assert.equal(res.charged, false);324 assert.equal(charged(), start);325 });326
327 it('tracks stats', () => {328 assert.ok(T.stats.requests > 0);329 assert.ok(T.stats.charged > 0);330 assert.ok(T.stats.cached > 0);331 assert.ok(T.stats.retries > 0);332 assert.ok(T.stats.failed > 0);333 });334});1// End-to-end tests: run the Actor against a mock Tomba API.2import assert from 'node:assert/strict';3import { after, afterEach, describe, it } from 'node:test';4
5import type { MockHandler, MockServer } from './helpers.js';6import { removeStorage, runActor, startMockTomba, startStandbyActor, totalCharges } from './helpers.js';7
8const PHONE = {9 valid: true,10 local_format: '(415) 555-0132',11 intl_format: '+1 415-555-0132',12 e164_format: '+14155550132',13 rfc3966_format: 'tel:+1-415-555-0132',14 country_code: 'US',15 line_type: 'MOBILE',16 carrier: 'AT&T',17 region: { name: 'California', code: 'CA' },18 timezones: ['America/Los_Angeles'],19};20
21const LINKEDIN = 'https://www.linkedin.com/in/janedoe';22
23/** Default Tomba behaviour for GET /phone-finder. */24const tomba: MockHandler = (req) => {25 assert.equal(req.method, 'GET');26 assert.equal(req.path, '/phone-finder');27 const { email, domain, linkedin } = req.query;28 if (email === 'nobody@stripe.com') return { body: { data: null } };29 if (email === 'empty@stripe.com') return { body: { data: {} } };30 if (email === 'landline@stripe.com') return { body: { data: { email, domain: 'stripe.com', valid: false } } };31 if (email === 'bad-email') return { status: 422, body: { errors: { message: 'Invalid email address' } } };32 if (email === 'html@stripe.com') return { raw: '<html>Bad gateway</html>' };33 if (domain === 'nophone.com') return { body: { data: null } };34 if (email) return { body: { data: { email, domain: email.split('@')[1], ...PHONE } } };35 if (linkedin) return { body: { data: { email: 'jane.doe@stripe.com', domain: 'stripe.com', linkedin, ...PHONE } } };36 return { body: { data: { domain, ...PHONE, line_type: 'FIXED_LINE' } } };37};38
39const servers: MockServer[] = [];40const dirs: string[] = [];41
42async function mock(handler: MockHandler = tomba): Promise<MockServer> {43 const server = await startMockTomba(handler);44 servers.push(server);45 return server;46}47
48async function run(...args: Parameters<typeof runActor>) {49 const result = await runActor(...args);50 dirs.push(result.storageDir);51 return result;52}53
54afterEach(async () => {55 await Promise.all(servers.splice(0).map(async (s) => s.close()));56});57
58after(async () => {59 await Promise.all(dirs.map(removeStorage));60});61
62describe('phone-finder', () => {63 it('returns the phone number for an email search and charges 5 credits', async () => {64 const server = await mock();65 const result = await run({ input: { searches: [{ email: 'jane.doe@stripe.com' }] }, endpoint: server.url });66
67 assert.equal(result.code, 0, result.output);68 assert.equal(result.items.length, 1);69 assert.deepEqual(result.items[0], {70 email: 'jane.doe@stripe.com',71 domain: 'stripe.com',72 linkedin: null,73 ...PHONE,74 source: { search_type: 'email', search_value: 'jane.doe@stripe.com' },75 chargedCredits: 5,76 charged: true,77 cached: false,78 });79 assert.deepEqual(server.requests[0].query, { email: 'jane.doe@stripe.com' });80 assert.deepEqual(result.chargeCounts, { 'tomba-request': 5 });81 });82
83 it('charges LinkedIn searches 5 credits', async () => {84 const server = await mock();85 const result = await run({ input: { searches: [{ linkedin: LINKEDIN }] }, endpoint: server.url });86
87 assert.equal(result.code, 0, result.output);88 assert.deepEqual(server.requests[0].query, { linkedin: LINKEDIN });89 const item = result.items[0];90 assert.equal(item.linkedin, LINKEDIN);91 assert.equal(item.intl_format, '+1 415-555-0132');92 assert.deepEqual(item.source, { search_type: 'linkedin', search_value: LINKEDIN });93 assert.equal(item.charged, true);94 assert.equal(item.chargedCredits, 5);95 assert.deepEqual(result.chargeCounts, { 'tomba-request': 5 });96 });97
98 it('charges domain searches 1 credit', async () => {99 const server = await mock();100 const result = await run({ input: { searches: [{ domain: 'stripe.com' }] }, endpoint: server.url });101
102 assert.equal(result.code, 0, result.output);103 assert.deepEqual(server.requests[0].query, { domain: 'stripe.com' });104 const item = result.items[0];105 assert.equal(item.email, null);106 assert.equal(item.domain, 'stripe.com');107 assert.equal(item.line_type, 'FIXED_LINE');108 assert.deepEqual(item.source, { search_type: 'domain', search_value: 'stripe.com' });109 assert.equal(item.charged, true);110 assert.equal(item.chargedCredits, 1);111 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });112 });113
114 it('charges 1 credit for an email search that includes a domain', async () => {115 const server = await mock();116 const result = await run({117 input: { searches: [{ email: 'john@shopify.com', domain: 'shopify.com' }] },118 endpoint: server.url,119 });120
121 assert.equal(result.code, 0, result.output);122 assert.deepEqual(server.requests[0].query, { email: 'john@shopify.com', domain: 'shopify.com' });123 assert.equal(result.items[0].chargedCredits, 1);124 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });125 });126
127 it('charges a LinkedIn search that includes a domain 1 credit', async () => {128 const server = await mock();129 const result = await run({130 input: { searches: [{ linkedin: LINKEDIN, domain: 'stripe.com' }] },131 endpoint: server.url,132 });133 assert.equal(result.items[0].chargedCredits, 1);134 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });135 });136
137 it('charges every search type with the right credits in a mixed run', async () => {138 const server = await mock();139 const result = await run({140 input: {141 searches: [142 { email: 'jane.doe@stripe.com' },143 { linkedin: LINKEDIN },144 { domain: 'stripe.com' },145 { domain: 'shopify.com' },146 // Any search that includes a domain costs 1 credit.147 { email: 'john@shopify.com', domain: 'shopify.com' },148 ],149 },150 endpoint: server.url,151 });152
153 assert.equal(result.code, 0, result.output);154 assert.equal(result.items.length, 5);155 assert.deepEqual(result.items.map((i) => i.chargedCredits).sort(), [1, 1, 1, 5, 5]);156 // 5 (email) + 5 (LinkedIn) + 1 + 1 (domains) + 1 (email with domain) = 13 credits.157 assert.deepEqual(result.chargeCounts, { 'tomba-request': 13 });158 });159
160 it('does not charge a search with no number on record', async () => {161 const server = await mock();162 const result = await run({163 input: {164 searches: [{ email: 'nobody@stripe.com' }, { email: 'empty@stripe.com' }, { domain: 'nophone.com' }],165 },166 endpoint: server.url,167 });168
169 assert.equal(result.code, 0, result.output);170 assert.equal(result.items.length, 3);171 for (const item of result.items) {172 assert.equal(item.charged, false);173 assert.equal(item.cached, false);174 assert.equal(item.valid, null);175 assert.equal(item.error, 'No phone number found');176 }177 const nobody = result.items.find((i) => i.email === 'nobody@stripe.com');178 assert.deepEqual(nobody, {179 email: 'nobody@stripe.com',180 domain: null,181 linkedin: null,182 valid: null,183 source: { search_type: 'email', search_value: 'nobody@stripe.com' },184 chargedCredits: 0,185 charged: false,186 cached: false,187 error: 'No phone number found',188 });189 assert.equal(totalCharges(result), 0);190 });191
192 it('reports an invalid number returned by Tomba as an answer', async () => {193 const server = await mock();194 const result = await run({ input: { searches: [{ email: 'landline@stripe.com' }] }, endpoint: server.url });195
196 assert.equal(result.items[0].valid, false);197 assert.equal(result.items[0].error, 'No valid phone number found');198 // A non-empty answer is billable, like any other Tomba negative answer.199 assert.equal(result.items[0].charged, true);200 assert.deepEqual(result.chargeCounts, { 'tomba-request': 5 });201 });202
203 it('sends the built-in credentials to Tomba', async () => {204 const server = await mock();205 await run({ input: { searches: [{ email: 'jane.doe@stripe.com' }] }, endpoint: server.url });206 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');207 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');208 });209
210 it('normalizes and deduplicates searches and skips empty ones', async () => {211 const server = await mock();212 const result = await run({213 input: {214 searches: [215 { email: ' Jane.Doe@Stripe.COM ' },216 { email: 'jane.doe@stripe.com' },217 { domain: 'https://www.Stripe.com/contact' },218 { domain: 'stripe.com' },219 { linkedin: ` ${LINKEDIN} ` },220 { linkedin: LINKEDIN },221 {},222 { email: ' ', domain: '' },223 ],224 },225 endpoint: server.url,226 });227
228 assert.equal(result.code, 0, result.output);229 assert.deepEqual(230 server.requests.map((r) => r.query),231 [{ email: 'jane.doe@stripe.com' }, { domain: 'stripe.com' }, { linkedin: LINKEDIN }],232 );233 assert.equal(result.items.length, 3);234 });235
236 it('does not charge Tomba error statuses and does not retry them', async () => {237 const server = await mock();238 const result = await run({ input: { searches: [{ email: 'bad-email' }] }, endpoint: server.url });239 assert.equal(result.code, 0, result.output);240 assert.equal(server.requests.length, 1);241 assert.equal(result.items[0].charged, false);242 assert.match(String(result.items[0].error), /422: Invalid email address/);243 assert.equal(totalCharges(result), 0);244 });245
246 it('does not charge a non-JSON body', async () => {247 const server = await mock();248 const result = await run({ input: { searches: [{ email: 'html@stripe.com' }] }, endpoint: server.url });249 assert.equal(result.items[0].charged, false);250 assert.match(String(result.items[0].error), /Invalid response/);251 assert.equal(totalCharges(result), 0);252 });253
254 it('retries 429 and 5xx responses, then charges the success once', async () => {255 let calls = 0;256 const server = await mock(async (req) => {257 calls++;258 if (calls === 1)259 return {260 status: 429,261 body: { errors: { message: 'Too many requests' } },262 headers: { 'retry-after': '1' },263 };264 if (calls === 2) return { status: 502, body: {} };265 return tomba(req);266 });267 const result = await run({268 input: { searches: [{ email: 'jane.doe@stripe.com' }], maxRetries: 3 },269 endpoint: server.url,270 });271 assert.equal(server.requests.length, 3);272 assert.equal(result.items.length, 1);273 assert.equal(result.items[0].charged, true);274 assert.deepEqual(result.chargeCounts, { 'tomba-request': 5 });275 });276
277 it('serves repeated runs from the cache for free', async () => {278 const server = await mock();279 const input = { searches: [{ email: 'jane.doe@stripe.com' }, { domain: 'stripe.com' }] };280 const first = await run({ input, endpoint: server.url });281 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });282
283 assert.deepEqual(first.chargeCounts, { 'tomba-request': 6 });284 assert.equal(server.requests.length, 2);285 assert.equal(second.items.length, 2);286 assert.ok(second.items.every((i) => i.cached === true && i.charged === false && i.chargedCredits === 0));287 assert.equal(second.items.find((i) => i.email)?.intl_format, '+1 415-555-0132');288 assert.equal(totalCharges(second), 0);289 });290
291 it('calls Tomba again when the cache is disabled', async () => {292 const server = await mock();293 const input = { searches: [{ email: 'jane.doe@stripe.com' }], useCache: false };294 const first = await run({ input, endpoint: server.url });295 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });296 assert.equal(server.requests.length, 2);297 assert.equal(second.items[0].cached, false);298 assert.deepEqual(second.chargeCounts, { 'tomba-request': 5 });299 });300
301 it('stops at the max charge limit and resumes without reprocessing', async () => {302 const server = await mock();303 const emails = ['a@acme.com', 'b@acme.com', 'c@acme.com', 'd@acme.com', 'e@acme.com'];304 const input = { searches: emails.map((email) => ({ email })), maxConcurrency: 1, useCache: false };305
306 // Locally every event costs $1 and an email search is 5 events, so a $10 budget allows two searches.307 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 10 });308 assert.equal(first.code, 0, first.output);309 assert.deepEqual(first.chargeCounts, { 'tomba-request': 10 });310 assert.equal(server.requests.length, 2);311 assert.equal(first.items.length, 2);312
313 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });314 assert.equal(second.code, 0, second.output);315 assert.deepEqual(316 server.requests.map((r) => r.query.email),317 emails,318 );319 assert.equal(second.items.length, 5);320 });321
322 it('respects maxResults', async () => {323 const server = await mock();324 const result = await run({325 input: {326 searches: [{ email: 'a@acme.com' }, { email: 'b@acme.com' }, { email: 'c@acme.com' }],327 maxResults: 1,328 maxConcurrency: 1,329 },330 endpoint: server.url,331 });332 assert.equal(result.items.length, 1);333 assert.equal(server.requests.length, 1);334 assert.equal(totalCharges(result), 5);335 });336
337 it('sends full and webhook_url on every search only when they are set', async () => {338 const server = await mock();339 const searches = [{ email: 'jane.doe@stripe.com' }, { domain: 'stripe.com' }];340 await run({ input: { searches, full: false, webhookUrl: ' ', maxConcurrency: 1 }, endpoint: server.url });341 await run({342 input: { searches, full: true, webhookUrl: 'https://hooks.example.com/tomba', maxConcurrency: 1 },343 endpoint: server.url,344 });345
346 assert.deepEqual(347 server.requests.map((r) => r.query),348 [349 { email: 'jane.doe@stripe.com' },350 { domain: 'stripe.com' },351 { email: 'jane.doe@stripe.com', full: 'true', webhook_url: 'https://hooks.example.com/tomba' },352 { domain: 'stripe.com', full: 'true', webhook_url: 'https://hooks.example.com/tomba' },353 ],354 );355 });356
357 it('keeps the same credits when full is set', async () => {358 const server = await mock();359 const result = await run({360 input: { searches: [{ email: 'jane.doe@stripe.com' }, { domain: 'stripe.com' }], full: true },361 endpoint: server.url,362 });363 assert.deepEqual(result.chargeCounts, { 'tomba-request': 6 });364 });365
366 it('runs requests in parallel', async () => {367 let active = 0;368 let peak = 0;369 const server = await mock(async (req) => {370 active++;371 peak = Math.max(peak, active);372 await new Promise((r) => {373 setTimeout(r, 100);374 });375 active--;376 return tomba(req);377 });378 const searches = Array.from({ length: 8 }, (_, i) => ({ email: `person${i}@acme.com` }));379 await run({ input: { searches, maxConcurrency: 4 }, endpoint: server.url });380 assert.equal(server.requests.length, 8);381 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);382 });383
384 it('fails without Tomba credentials and never calls the API', async () => {385 const server = await mock();386 const result = await run({387 input: { searches: [{ email: 'jane.doe@stripe.com' }] },388 endpoint: server.url,389 withCredentials: false,390 });391 assert.notEqual(result.code, 0);392 assert.match(result.output, /misconfigured/);393 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);394 assert.equal(server.requests.length, 0);395 });396
397 it('fails on empty input', async () => {398 const server = await mock();399 const result = await run({ input: { searches: [] }, endpoint: server.url });400 assert.notEqual(result.code, 0);401 assert.equal(server.requests.length, 0);402 });403
404 it('fails when searches is missing', async () => {405 const server = await mock();406 const result = await run({ input: {}, endpoint: server.url });407 assert.notEqual(result.code, 0);408 assert.equal(server.requests.length, 0);409 });410});411
412describe('phone-finder standby (real-time API)', () => {413 it('answers the readiness probe and a bare GET with usage info', async () => {414 const server = await mock();415 const actor = await startStandbyActor({ endpoint: server.url });416 try {417 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });418 assert.equal(probe.status, 200);419 const usage = await actor.call('/');420 assert.equal(usage.status, 200);421 assert.match(String(usage.body.usage), /GET/);422 assert.equal(server.requests.length, 0);423 } finally {424 await actor.stop();425 }426 });427
428 it('runs one search from GET query parameters and charges 1 credit with a domain', async () => {429 const server = await mock();430 const actor = await startStandbyActor({ endpoint: server.url });431 let stopped;432 try {433 const res = await actor.call('/?email=Jane.Doe@Stripe.com&domain=https://www.stripe.com/&full=true');434 assert.equal(res.status, 200);435 const items = res.body.items as Record<string, unknown>[];436 assert.equal(items.length, 1);437 assert.equal(items[0].e164_format, PHONE.e164_format);438 assert.equal(items[0].chargedCredits, 1);439 assert.deepEqual(server.requests[0].query, {440 email: 'jane.doe@stripe.com',441 domain: 'stripe.com',442 full: 'true',443 });444 } finally {445 stopped = await actor.stop();446 }447 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });448 });449
450 it('charges 5 credits for a GET search by email or LinkedIn only', async () => {451 const server = await mock();452 const actor = await startStandbyActor({ endpoint: server.url });453 let stopped;454 try {455 const res = await actor.call(`/?linkedin=${encodeURIComponent(LINKEDIN)}`);456 assert.equal(res.status, 200);457 const items = res.body.items as Record<string, unknown>[];458 assert.equal(items[0].chargedCredits, 5);459 assert.deepEqual(items[0].source, { search_type: 'linkedin', search_value: LINKEDIN });460 } finally {461 stopped = await actor.stop();462 }463 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 5 });464 });465
466 it('accepts a POST with the same JSON input as a normal run', async () => {467 const server = await mock();468 const actor = await startStandbyActor({ endpoint: server.url });469 let stopped;470 try {471 const res = await actor.call('/', {472 body: { searches: [{ domain: 'stripe.com' }, { email: 'nobody@stripe.com' }] },473 });474 assert.equal(res.status, 200);475 const items = res.body.items as Record<string, unknown>[];476 assert.equal(items.length, 2);477 assert.equal(items.find((i) => i.email === 'nobody@stripe.com')?.error, 'No phone number found');478 } finally {479 stopped = await actor.stop();480 }481 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });482 });483
484 it('serves repeated requests from the cache for free', async () => {485 const server = await mock();486 const actor = await startStandbyActor({ endpoint: server.url });487 let stopped;488 try {489 await actor.call('/?domain=stripe.com');490 const second = await actor.call('/?domain=stripe.com');491 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));492 assert.equal(server.requests.length, 1);493 } finally {494 stopped = await actor.stop();495 }496 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });497 });498
499 it('keeps serving after a request hits maxResults', async () => {500 const server = await mock();501 const actor = await startStandbyActor({ endpoint: server.url });502 try {503 const first = await actor.call('/?domain=a.com&maxResults=1');504 assert.equal((first.body.items as unknown[]).length, 1);505 const second = await actor.call('/?domain=b.com&maxResults=1');506 assert.equal(second.status, 200);507 assert.equal((second.body.items as unknown[]).length, 1);508 const third = await actor.call('/', { body: { searches: [{ domain: 'c.com' }, { domain: 'd.com' }] } });509 assert.equal((third.body.items as unknown[]).length, 2);510 assert.equal(server.requests.length, 4);511 } finally {512 await actor.stop();513 }514 });515
516 it('rejects invalid input with 400 and unknown paths with 404', async () => {517 const server = await mock();518 const actor = await startStandbyActor({ endpoint: server.url });519 try {520 assert.equal((await actor.call('/', { body: {} })).status, 400);521 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);522 assert.equal((await actor.call('/?full=true')).status, 400);523 assert.equal((await actor.call('/?domain=a.com&maxResults=abc')).status, 400);524 assert.equal((await actor.call('/?domain=a.com&full=maybe')).status, 400);525 assert.equal((await actor.call('/nope')).status, 404);526 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);527 assert.equal(server.requests.length, 0);528 } finally {529 await actor.stop();530 }531 });532
533 it('returns 402 once the max charge limit is reached', async () => {534 const server = await mock();535 // An email search costs 5 events: a $5 budget allows exactly one.536 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 5 });537 let stopped;538 try {539 const first = await actor.call('/?email=a@acme.com');540 assert.equal(first.status, 200);541 assert.equal((first.body.items as Record<string, unknown>[])[0].chargedCredits, 5);542 const second = await actor.call('/?email=b@acme.com');543 assert.equal(second.status, 402);544 assert.equal(server.requests.length, 1);545 } finally {546 stopped = await actor.stop();547 }548 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 5 });549 });550});1// Shared runner for the Tomba Apify Actors: batch runs and Standby (real-time HTTP API) mode.2// Keep this file identical across all Actors.3import type { IncomingMessage, ServerResponse } from 'node:http';4import { createServer } from 'node:http';5
6import { Actor, log } from 'apify';7
8import type { RunOptions } from './tomba.js';9import { isStopped, logSummary, setupTomba, useRunState } from './tomba.js';10
11/** Invalid input: a failed batch run, or `400 Bad Request` in Standby mode. */12export class InputError extends Error {}13
14export type Item = Record<string, unknown>;15
16export interface RunContext {17 /** Save result items (dataset in batch runs, HTTP response in Standby mode). */18 push: (items: Item | Item[]) => Promise<void>;19 /** True when this input was already processed (batch runs resume after a migration or restart). */20 isDone: (key: string) => boolean;21 /** Record a processed input so a resumed run skips it. */22 markDone: (key: string) => void;23 standby: boolean;24}25
26/** Processes one Actor input. Throws InputError for invalid input. */27export type ActorRun<I> = (input: I, ctx: RunContext) => Promise<void>;28
29export interface ActorDefinition<I> {30 /** Name used in logs. */31 title: string;32 run: ActorRun<I>;33 /** Builds the Actor input from `GET /?…` query parameters in Standby mode. */34 fromQuery: (query: URLSearchParams) => I;35 /** Number of inputs, for the batch summary log. */36 count?: (input: I) => number;37}38
39const MAX_BODY_BYTES = 1_000_000;40
41export function isStandby(): boolean {42 return Actor.config.get('metaOrigin') === 'STANDBY';43}44
45/** Entry point: runs the Actor as a batch job, or as an HTTP server in Standby mode. */46export async function runActor<I extends RunOptions>(definition: ActorDefinition<I>): Promise<void> {47 await Actor.init();48
49 if (isStandby()) {50 await setupTomba();51 await startStandbyServer(definition);52 return;53 }54
55 const input = ((await Actor.getInput<I>()) ?? {}) as I;56 await setupTomba(input);57 const state = await useRunState();58 const startedAt = Date.now();59 try {60 await definition.run(input, {61 push: async (items) => Actor.pushData(items),62 isDone: (key) => Boolean(state.done[key]),63 markDone: (key) => {64 state.done[key] = true;65 },66 standby: false,67 });68 } catch (err) {69 if (err instanceof InputError) {70 await Actor.fail(err.message);71 return;72 }73 throw err;74 }75 logSummary(definition.title, definition.count?.(input) ?? 0, startedAt);76 await Actor.exit();77}78
79async function startStandbyServer<I>(definition: ActorDefinition<I>): Promise<void> {80 const server = createServer((req, res) => {81 handleRequest(definition, req, res).catch((err: Error) => {82 log.exception(err, 'Standby request failed');83 send(res, 500, { error: 'Internal error' });84 });85 });86 const port = Actor.config.get('containerPort');87 await new Promise<void>((resolve) => {88 server.listen(port, resolve);89 });90 log.info(`${definition.title} is ready for real-time requests`, { port });91}92
93async function handleRequest<I>(definition: ActorDefinition<I>, req: IncomingMessage, res: ServerResponse) {94 if (req.headers['x-apify-container-server-readiness-probe']) {95 send(res, 200, { status: 'ready' });96 return;97 }98
99 const url = new URL(req.url ?? '/', 'http://localhost');100 if (url.pathname !== '/') {101 send(res, 404, { error: 'Not found. Use GET / with query parameters or POST / with a JSON input.' });102 return;103 }104
105 let input: I;106 try {107 if (req.method === 'GET') {108 if ([...url.searchParams.keys()].length === 0) {109 send(res, 200, {110 status: 'ready',111 usage: 'GET /?<parameters> or POST / with the same JSON input as a normal run.',112 });113 return;114 }115 input = definition.fromQuery(url.searchParams);116 } else if (req.method === 'POST') {117 input = (await readJson(req)) as I;118 } else {119 send(res, 405, { error: 'Method not allowed. Use GET or POST.' });120 return;121 }122 } catch (err) {123 send(res, 400, { error: (err as Error).message });124 return;125 }126
127 if (isStopped()) {128 send(res, 402, { error: 'Max charge limit reached for this Actor run.' });129 return;130 }131
132 const items: Item[] = [];133 try {134 await definition.run(input, {135 push: async (pushed) => {136 items.push(...(Array.isArray(pushed) ? pushed : [pushed]));137 },138 isDone: () => false,139 markDone: () => undefined,140 standby: true,141 });142 } catch (err) {143 if (err instanceof InputError) {144 send(res, 400, { error: err.message });145 return;146 }147 throw err;148 }149 send(res, 200, { items });150}151
152async function readJson(req: IncomingMessage): Promise<unknown> {153 const chunks: Buffer[] = [];154 let size = 0;155 for await (const chunk of req) {156 size += (chunk as Buffer).length;157 if (size > MAX_BODY_BYTES) throw new InputError('Request body is too large (max 1 MB).');158 chunks.push(chunk as Buffer);159 }160 const text = Buffer.concat(chunks).toString('utf8').trim();161 if (!text) return {};162 try {163 const body = JSON.parse(text);164 if (typeof body !== 'object' || body === null || Array.isArray(body)) {165 throw new InputError('Request body must be a JSON object.');166 }167 return body;168 } catch (err) {169 if (err instanceof InputError) throw err;170 throw new InputError('Request body must be valid JSON.');171 }172}173
174function send(res: ServerResponse, status: number, body: unknown): void {175 if (res.headersSent) return;176 res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });177 res.end(JSON.stringify(body));178}179
180// --- Query string helpers for `fromQuery` ---------------------------------------------------181
182/** Values of a list parameter: repeated (`?domain=a&domain=b`) and/or comma-separated (`?domains=a,b`). */183export function queryList(query: URLSearchParams, ...names: string[]): string[] {184 return names185 .flatMap((name) => query.getAll(name))186 .flatMap((value) => value.split(','))187 .map((value) => value.trim())188 .filter(Boolean);189}190
191export function queryString(query: URLSearchParams, name: string): string | undefined {192 const value = query.get(name)?.trim();193 return value || undefined;194}195
196export function queryBool(query: URLSearchParams, name: string): boolean | undefined {197 const value = query.get(name)?.trim().toLowerCase();198 if (value === undefined || value === '') return undefined;199 if (['1', 'true', 'yes'].includes(value)) return true;200 if (['0', 'false', 'no'].includes(value)) return false;201 throw new InputError(`Query parameter "${name}" must be true or false.`);202}203
204export function queryInt(query: URLSearchParams, name: string): number | undefined {205 const value = query.get(name)?.trim();206 if (value === undefined || value === '') return undefined;207 const number = Number(value);208 if (!Number.isInteger(number)) throw new InputError(`Query parameter "${name}" must be an integer.`);209 return number;210}1// Shared Tomba helper for the Tomba Apify Actors.2// Keep this file identical across all Actors.3import { createHash } from 'node:crypto';4
5import type { KeyValueStore } from 'apify';6import { Actor, log } from 'apify';7import { TombaClient, TombaException } from 'tomba';8
9/** Pay-per-event event names (configured in Apify Console → Monetization). */10export const EVENT_REQUEST = 'tomba-request';11export const EVENT_PHONE_FINDER = 'phone-finder-request';12
13/**14 * Tomba credits added per phone number (or per address with phone data, depending on the endpoint)15 * returned when `enrich_mobile=true`. Every credit is charged as one `tomba-request` event.16 */17export const PHONE_CREDITS = 5;18
19/** Number of phone numbers in a record's `phone_data` array (0 when missing or empty). */20export function phoneDataCount(record: unknown): number {21 if (!isObject(record)) return 0;22 return Array.isArray(record.phone_data) ? record.phone_data.length : 0;23}24
25/** True when a record has at least one phone number in `phone_data`. */26export function hasPhoneData(record: unknown): boolean {27 return phoneDataCount(record) > 0;28}29
30export interface RunOptions {31 maxConcurrency?: number;32 maxRetries?: number;33 useCache?: boolean;34 cacheTtlHours?: number;35}36
37export interface TombaCallResult {38 /** Raw JSON body returned by Tomba (`{ data, meta, ... }`). */39 body?: Record<string, unknown>;40 /** Shortcut to `body.data`. */41 data?: unknown;42 /** HTTP status (0 for network errors, 502 for a non-JSON body). */43 status: number;44 charged: boolean;45 /** Number of events charged for this call (0 when not charged). */46 chargedCount?: number;47 cached: boolean;48 /** True when the call was not made because the user's max charge limit was reached. */49 skipped?: boolean;50 error?: string;51}52
53export const stats = {54 requests: 0,55 charged: 0,56 cached: 0,57 failed: 0,58 retries: 0,59};60
61const CACHE_STORE_PREFIX = 'tomba-cache';62const BASE_DELAY_MS = 1000;63const MAX_DELAY_MS = 30000;64const NETWORK_ERROR = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|timeout|network/i;65
66let client: TombaClient | undefined;67let options: Required<RunOptions> = { maxConcurrency: 10, maxRetries: 3, useCache: true, cacheTtlHours: 24 };68let cacheStore: KeyValueStore | undefined;69const memoryCache = new Map<string, Record<string, unknown>>();70const inFlight = new Map<string, number>();71let stopped = false;72
73/** Initialise the Tomba client from the Actor's environment variables and apply the run options. */74export async function setupTomba(runOptions: RunOptions = {}): Promise<TombaClient> {75 const key = process.env.TOMBA_API_KEY;76 const secret = process.env.TOMBA_API_SECRET;77 if (!key || !secret) {78 await Actor.fail('Actor is misconfigured: Tomba credentials are missing. Please contact the Actor developer.');79 throw new Error('unreachable');80 }81
82 options = {83 maxConcurrency: clamp(runOptions.maxConcurrency ?? 10, 1, 50),84 maxRetries: clamp(runOptions.maxRetries ?? 3, 0, 10),85 useCache: runOptions.useCache ?? true,86 cacheTtlHours: Math.max(0, runOptions.cacheTtlHours ?? 24),87 };88
89 client = new TombaClient();90 // Verification and enrichment can take over a minute, so keep the SDK's 120 s timeout.91 client.setKey(key).setSecret(secret).setTimeout(120_000);92 // Only used by the test suite to point the client at a local mock server.93 if (process.env.TOMBA_API_ENDPOINT) client.setEndpoint(process.env.TOMBA_API_ENDPOINT);94
95 if (options.useCache && options.cacheTtlHours > 0) {96 try {97 cacheStore = await Actor.openKeyValueStore(cacheStoreName());98 } catch (err) {99 // E.g. "Insufficient permissions" under limited permissions: never fail the run because of the cache.100 log.warning('Cross-run cache is unavailable; results are cached for this run only.', {101 error: (err as Error).message,102 });103 cacheStore = undefined;104 }105 }106
107 return client;108}109
110/**111 * Name of the cross-run cache store. It is per Actor: with limited permissions an Actor can only open112 * named storages it created itself, so the Tomba Actors must not share one store.113 */114export function cacheStoreName(): string {115 const actorId = Actor.getEnv().actorId ?? process.env.ACTOR_ID;116 return actorId ? `${CACHE_STORE_PREFIX}-${actorId}` : CACHE_STORE_PREFIX;117}118
119/** The Tomba client created by setupTomba(). */120export function getClient(): TombaClient {121 if (!client) throw new Error('setupTomba() must be called first');122 return client;123}124
125export function getOptions(): Required<RunOptions> {126 return options;127}128
129/** True once the user's max-charge budget is exhausted; workers should stop picking up new items. */130export function isStopped(): boolean {131 return stopped;132}133
134export function stop(): void {135 stopped = true;136}137
138/**139 * Tomba billing rules: charge only when Tomba returns a JSON body without an `errors` object140 * and with a non-empty `data` (a negative answer such as an undeliverable verification141 * or a domain search with no emails is still a non-empty `data` object).142 */143export function isBillable(body: unknown): boolean {144 if (!isObject(body)) return false;145 if (body.errors !== undefined && body.errors !== null) return false;146 const { data } = body;147 if (data === null || data === undefined) return false;148 if (Array.isArray(data)) return data.length > 0;149 // An object whose values are all null (e.g. `{ person: null, company: null }`) is an empty result.150 if (isObject(data)) return Object.values(data).some((v) => v !== null && v !== undefined);151 return data !== '' && data !== false;152}153
154/**155 * Perform one Tomba API call with caching, retry and pay-per-event charging.156 *157 * @param endpoint short endpoint name used for the cache key (e.g. 'technology')158 * @param params request parameters used for the cache key159 * @param fn the SDK call160 * @param eventName pay-per-event name to charge when the response is billable161 * @param count events to charge for a billable response: a number, or a function of the response body162 */163export async function callTomba(164 endpoint: string,165 params: Record<string, unknown>,166 fn: (tomba: TombaClient) => Promise<unknown>,167 eventName: string = EVENT_REQUEST,168 count: number | ((body: Record<string, unknown>) => number) = 1,169): Promise<TombaCallResult> {170 if (!client) throw new Error('setupTomba() must be called first');171
172 const cacheKey = hashKey(endpoint, params);173 const cachedBody = await readCache(cacheKey);174 if (cachedBody) {175 stats.cached++;176 return { body: cachedBody, data: cachedBody.data, status: 200, charged: false, cached: true };177 }178
179 // Reserve budget before calling Tomba so concurrent workers never exceed the user's max charge.180 // A fixed count is known up front, so reserve all of it; a body-dependent count reserves at least 1.181 const reserved = typeof count === 'number' ? Math.max(1, Math.ceil(count)) : 1;182 if (!reserve(eventName, reserved)) {183 stop();184 return { status: 0, charged: false, cached: false, skipped: true, error: 'Max charge limit reached' };185 }186
187 try {188 for (let attempt = 0; ; attempt++) {189 try {190 stats.requests++;191 const body = unwrap(await fn(client));192
193 if (!isObject(body)) {194 stats.failed++;195 return { status: 502, charged: false, cached: false, error: 'Invalid response from Tomba' };196 }197
198 if (!isBillable(body)) {199 const error = errorMessage(body) ?? undefined;200 return { body, data: body.data, status: 200, charged: false, cached: false, error };201 }202
203 const units = Math.max(1, Math.ceil(typeof count === 'function' ? count(body) : count));204 const chargedCount = await charge(eventName, units);205 const charged = chargedCount > 0;206 await writeCache(cacheKey, body);207 return { body, data: body.data, status: 200, charged, chargedCount, cached: false };208 } catch (err) {209 const { status, message, retryAfter } = parseError(err);210 const retryable = status === 0 || status === 429 || status >= 500;211 if (retryable && attempt < options.maxRetries) {212 stats.retries++;213 const delay = retryAfter ?? Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);214 const jitter = Math.random() * 250;215 log.warning(`Tomba ${endpoint} returned ${status || 'network error'}, retrying in ${delay}ms`, {216 attempt: attempt + 1,217 });218 await sleep(delay + jitter);219 continue;220 }221 stats.failed++;222 return { status, charged: false, cached: false, error: message };223 }224 }225 } finally {226 release(eventName, reserved);227 }228}229
230/**231 * Run `worker` over `items` with bounded concurrency. Stops picking new items once the charge limit232 * is reached (`isStopped()`) or `shouldStop()` returns true (e.g. maxResults reached for this run).233 */234export async function runPool<T>(235 items: T[],236 worker: (item: T, index: number) => Promise<void>,237 concurrency = options.maxConcurrency,238 shouldStop: () => boolean = () => false,239): Promise<void> {240 let next = 0;241 const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {242 while (!stopped && !shouldStop() && next < items.length) {243 const index = next++;244 await worker(items[index], index);245 }246 });247 await Promise.all(runners);248}249
250/** Persisted run state used to resume after a migration, restart or resurrection. */251export async function useRunState(): Promise<{ done: Record<string, true> }> {252 return Actor.useState<{ done: Record<string, true> }>('TOMBA_STATE', { done: {} });253}254
255export function logSummary(title: string, total: number, startedAt: number): void {256 const seconds = Math.round((Date.now() - startedAt) / 1000);257 log.info(`${title} finished`, {258 inputs: total,259 tombaRequests: stats.requests,260 charged: stats.charged,261 cacheHits: stats.cached,262 failed: stats.failed,263 retries: stats.retries,264 seconds,265 stoppedByChargeLimit: stopped,266 });267}268
269export function normalizeDomain(value: string): string {270 return value271 .trim()272 .toLowerCase()273 .replace(/^https?:\/\//, '')274 .replace(/^www\./, '')275 .replace(/\/.*$/, '');276}277
278export function normalizeEmail(value: string): string {279 return value.trim().toLowerCase();280}281
282export function unique<T>(items: T[], key: (item: T) => string = String): T[] {283 const seen = new Set<string>();284 return items.filter((item) => {285 const k = key(item);286 if (!k || seen.has(k)) return false;287 seen.add(k);288 return true;289 });290}291
292// ---------------------------------------------------------------------------293
294async function charge(eventName: string, count: number): Promise<number> {295 const result = await Actor.charge({ eventName, count });296 if (result.chargedCount > 0) stats.charged += result.chargedCount;297 if (result.eventChargeLimitReached) {298 log.info('Max charge limit reached, finishing current items and stopping.');299 stop();300 }301 return result.chargedCount;302}303
304function reserve(eventName: string, units: number): boolean {305 if (stopped) return false;306 const manager = Actor.getChargingManager();307 const remaining = manager.calculateMaxEventChargeCountWithinLimit(eventName);308 const current = inFlight.get(eventName) ?? 0;309 if (remaining - current < units) return false;310 inFlight.set(eventName, current + units);311 return true;312}313
314function release(eventName: string, units: number): void {315 inFlight.set(eventName, Math.max(0, (inFlight.get(eventName) ?? units) - units));316}317
318async function readCache(key: string): Promise<Record<string, unknown> | undefined> {319 if (!options.useCache) return undefined;320 const hit = memoryCache.get(key);321 if (hit) return hit;322 if (!cacheStore) return undefined;323 const entry = await cacheStore.getValue<{ savedAt: number; body: Record<string, unknown> }>(key);324 if (!entry) return undefined;325 if (Date.now() - entry.savedAt > options.cacheTtlHours * 3600_000) return undefined;326 memoryCache.set(key, entry.body);327 return entry.body;328}329
330async function writeCache(key: string, body: Record<string, unknown>): Promise<void> {331 if (!options.useCache) return;332 memoryCache.set(key, body);333 if (cacheStore) {334 try {335 await cacheStore.setValue(key, { savedAt: Date.now(), body });336 } catch (err) {337 log.debug('Failed to write cache entry', { error: (err as Error).message });338 }339 }340}341
342function hashKey(endpoint: string, params: Record<string, unknown>): string {343 const sorted = Object.keys(params)344 .sort()345 .map((k) => [k, params[k]]);346 return `${endpoint}-${createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 40)}`;347}348
349/** tomba >= 1.1.0 resolves to `{ data: body, rateLimit }`; older versions resolve to the body itself. */350function unwrap(response: unknown): unknown {351 if (isObject(response) && 'rateLimit' in response && 'data' in response) return response.data;352 return response;353}354
355function parseError(err: unknown): { status: number; message: string; retryAfter?: number } {356 if (err instanceof TombaException) {357 const response = err.code as { status?: number; headers?: Record<string, string> } | undefined;358 // Without an HTTP response it is either a network error (retryable, status 0)359 // or the SDK's own input validation (not retryable, treated as 400).360 const networkError = NETWORK_ERROR.test(err.message ?? '');361 let status = networkError ? 0 : 400;362 if (typeof response?.status === 'number') status = response.status;363 const retryAfterHeader = Number(response?.headers?.['retry-after']);364 const retryAfter =365 Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader * 1000 : undefined;366 const message = errorMessage(err.response) ?? err.message ?? 'Tomba request failed';367 return { status, message: status ? `${status}: ${message}` : message, retryAfter };368 }369 return { status: 0, message: err instanceof Error ? err.message : String(err) };370}371
372function errorMessage(body: unknown): string | undefined {373 if (!isObject(body)) return typeof body === 'string' && body.length < 300 ? body : undefined;374 const errors = body.errors as Record<string, unknown> | undefined;375 if (isObject(errors) && typeof errors.message === 'string') return errors.message;376 return undefined;377}378
379function isObject(value: unknown): value is Record<string, unknown> {380 return typeof value === 'object' && value !== null && !Array.isArray(value);381}382
383function clamp(value: number, min: number, max: number): number {384 return Math.min(max, Math.max(min, Math.floor(value)));385}386
387async function sleep(ms: number): Promise<void> {388 return new Promise((resolve) => {389 setTimeout(resolve, ms);390 });391}1import { log } from 'apify';2import { Phone } from 'tomba';3
4import { InputError, queryBool, queryInt, queryString, runActor } from './standby.js';5import type { RunOptions } from './tomba.js';6import {7 callTomba,8 EVENT_REQUEST,9 getClient,10 normalizeDomain,11 normalizeEmail,12 PHONE_CREDITS,13 runPool,14 unique,15} from './tomba.js';16
17interface SearchQuery {18 email?: string;19 domain?: string;20 linkedin?: string;21}22
23interface PhoneFinderInput extends RunOptions {24 searches?: SearchQuery[];25 maxResults?: number;26 full?: boolean;27 webhookUrl?: string;28}29
30interface PhoneFinderParams extends SearchQuery {31 full?: boolean;32 webhook_url?: string;33}34
35const SOURCE_FIELDS = ['email', 'domain', 'linkedin'] as const;36
37/** Normalize a search query, keeping only non-empty fields. */38function normalizeSearch(search: SearchQuery): SearchQuery {39 const result: SearchQuery = {};40 const email = typeof search?.email === 'string' ? normalizeEmail(search.email) : '';41 const domain = typeof search?.domain === 'string' ? normalizeDomain(search.domain) : '';42 const linkedin = typeof search?.linkedin === 'string' ? search.linkedin.trim() : '';43 if (email) result.email = email;44 if (domain) result.domain = domain;45 if (linkedin) result.linkedin = linkedin;46 return result;47}48
49function searchKey(search: SearchQuery): string {50 if (!search.email && !search.domain && !search.linkedin) return '';51 return JSON.stringify([search.email ?? '', search.domain ?? '', search.linkedin ?? '']);52}53
54function searchSource(search: SearchQuery): { search_type: string; search_value: string } {55 if (search.email) return { search_type: 'email', search_value: search.email };56 if (search.linkedin) return { search_type: 'linkedin', search_value: search.linkedin };57 return { search_type: 'domain', search_value: search.domain ?? '' };58}59
60/** Phone Finder pricing: 1 credit when the request includes a domain, otherwise 5 (email or LinkedIn only). */61function searchCredits(search: SearchQuery): number {62 return search.domain ? 1 : PHONE_CREDITS;63}64
65await runActor<PhoneFinderInput>({66 title: 'Phone Finder',67 count: (input) => input.searches?.length ?? 0,68 fromQuery: (query) => {69 // One search per GET request, built from the email, domain and linkedin parameters.70 const search: SearchQuery = {};71 for (const field of SOURCE_FIELDS) {72 const value = queryString(query, field);73 if (value) search[field] = value;74 }75 return {76 searches: Object.keys(search).length ? [search] : [],77 full: queryBool(query, 'full'),78 webhookUrl: queryString(query, 'webhookUrl'),79 maxResults: queryInt(query, 'maxResults'),80 };81 },82 run: async (input, { push, isDone, markDone, standby }) => {83 if (!input.searches?.length) {84 throw new InputError('Input must contain at least one search query in "searches".');85 }86
87 const { searches: rawSearches, maxResults = 50, full = false, webhookUrl } = input;88 const webhook = typeof webhookUrl === 'string' && webhookUrl.trim() ? webhookUrl.trim() : undefined;89 const phone = new Phone(getClient());90
91 /** Request parameters; the global options are only sent when set. */92 const requestParams = (search: SearchQuery): PhoneFinderParams => {93 const params: PhoneFinderParams = { ...search };94 if (full) params.full = true;95 if (webhook) params.webhook_url = webhook;96 return params;97 };98
99 const normalized = rawSearches.map(normalizeSearch);100 const emptyCount = normalized.filter((search) => !searchKey(search)).length;101 if (emptyCount > 0) {102 log.warning(`Skipping ${emptyCount} search queries without email, domain or linkedin.`);103 }104
105 const searches = unique(normalized, searchKey);106 const pending = searches.filter((search) => !isDone(searchKey(search)));107 if (pending.length < searches.length) {108 log.info(`Resuming: ${searches.length - pending.length} search queries already processed.`);109 }110
111 let pushed = 0;112 const limitReached = () => pushed >= maxResults;113 if (!standby) {114 log.info(`Finding phone numbers for ${pending.length} search queries`, { full, webhook: Boolean(webhook) });115 }116
117 await runPool(118 pending,119 async (search) => {120 if (limitReached()) return;121 const params = requestParams(search);122 const res = await callTomba(123 'phone-finder',124 { ...params },125 async () => phone.finder({ ...params }),126 EVENT_REQUEST,127 searchCredits(search),128 );129 if (res.skipped) return;130
131 const source = searchSource(search);132 const data =133 res.data &&134 typeof res.data === 'object' &&135 !Array.isArray(res.data) &&136 Object.keys(res.data).length > 0137 ? (res.data as Record<string, unknown>)138 : undefined;139 const base = {140 email: search.email ?? null,141 domain: search.domain ?? null,142 linkedin: search.linkedin ?? null,143 };144
145 if (data) {146 const valid = data.valid === true;147 if (valid) pushed++;148 await push({149 ...base,150 ...data,151 source,152 chargedCredits: res.chargedCount ?? 0,153 charged: res.charged,154 cached: res.cached,155 ...(valid ? {} : { error: 'No valid phone number found' }),156 });157 const found = valid158 ? String(data.intl_format ?? data.local_format ?? 'phone found')159 : 'no valid phone number';160 log.info(`${source.search_value}: ${found}${res.cached ? ' (cached)' : ''}`);161 } else {162 await push({163 ...base,164 valid: null,165 source,166 chargedCredits: 0,167 charged: res.charged,168 cached: res.cached,169 error: res.error ?? 'No phone number found',170 });171 log.info(`${source.search_value}: ${res.error ?? 'no phone number found'}`);172 }173
174 markDone(searchKey(search));175 },176 undefined,177 limitReached,178 );179 },180});{ "actorSpecification": 1, "fields": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "email": { "type": ["string", "null"], "description": "Email address associated with the phone number" }, "domain": { "type": ["string", "null"], "description": "Domain associated with the phone number" }, "linkedin": { "type": ["string", "null"], "description": "LinkedIn profile associated with the phone number" }, "valid": { "type": ["boolean", "null"], "description": "Whether the phone number is valid" }, "local_format": { "type": ["string", "null"], "description": "Phone number in local format" }, "intl_format": { "type": ["string", "null"], "description": "Phone number in international format" }, "e164_format": { "type": ["string", "null"], "description": "Phone number in E.164 format" }, "rfc3966_format": { "type": ["string", "null"], "description": "Phone number in RFC3966 format" }, "country_code": { "type": ["string", "null"], "description": "Country code of the phone number" }, "line_type": { "type": ["string", "null"], "description": "Type of phone line (mobile, landline, etc.)" }, "carrier": { "type": ["string", "null"], "description": "Phone carrier/provider information" }, "timezones": { "type": ["array", "null"], "description": "Timezone information for the phone number location", "items": { "type": "string" } }, "source": { "type": ["object", "null"], "description": "Source information used to find this phone number", "properties": { "search_type": { "type": ["string", "null"], "description": "Type of search performed (email, domain, linkedin)" }, "search_value": { "type": ["string", "null"], "description": "Value used in the search query" } } }, "error": { "type": ["string", "null"], "description": "Error message if no phone number was found or the search failed" }, "chargedCredits": { "type": "integer", "description": "Credits billed for this search ($0.00312 each): 1 when the search includes a domain, otherwise 5. 0 when not charged" }, "charged": { "type": "boolean", "description": "Whether this request was charged (pay-per-event)" }, "cached": { "type": "boolean", "description": "Whether the result came from the cache (free)" } }, "required": ["valid"] }, "views": { "overview": { "title": "Overview", "transformation": { "fields": ["email", "domain", "linkedin", "intl_format", "line_type", "carrier", "valid", "source"] }, "display": { "component": "table", "properties": { "email": { "label": "Email" }, "domain": { "label": "Domain" }, "linkedin": { "label": "LinkedIn" }, "intl_format": { "label": "Phone Number" }, "line_type": { "label": "Type" }, "carrier": { "label": "Carrier" }, "valid": { "label": "Valid" }, "source": { "label": "Source" } } } }, "detailed": { "title": "Detailed View", "transformation": { "fields": [ "email", "domain", "linkedin", "valid", "local_format", "intl_format", "e164_format", "rfc3966_format", "country_code", "line_type", "carrier", "timezones", "source", "error", "chargedCredits", "charged", "cached" ] }, "display": { "component": "table" } } }}{ "openapi": "3.0.0", "info": { "title": "Tomba Phone Finder API", "version": "1.0.0", "description": "Real-time API (Apify Standby mode). Find the phone number linked to an email address, a company domain or a LinkedIn profile. One credit costs $0.00312: 5 credits ($0.0156) per search, or 1 credit ($0.00312) when the search includes a domain. Searches with no phone number on record, errors and cached results are free." }, "paths": { "/": { "get": { "summary": "Find a phone number for one search", "description": "Without query parameters, returns a short usage note (also used as the readiness probe).", "parameters": [ { "name": "email", "in": "query", "description": "Email address to look up. 5 credits, or 1 when `domain` is also given.", "required": false, "schema": { "type": "string" }, "example": "jane.doe@stripe.com" }, { "name": "domain", "in": "query", "description": "Company domain to look up, alone or with `email` / `linkedin` (1 credit). URLs are cleaned up.", "required": false, "schema": { "type": "string" }, "example": "stripe.com" }, { "name": "linkedin", "in": "query", "description": "LinkedIn profile URL to look up. 5 credits, or 1 when `domain` is also given.", "required": false, "schema": { "type": "string" }, "example": "https://www.linkedin.com/in/janedoe" }, { "name": "full", "in": "query", "description": "Ask Tomba for the full phone record it has stored (sent as `full=true`). Does not change the price.", "required": false, "schema": { "type": "boolean", "default": false } }, { "name": "webhookUrl", "in": "query", "description": "Optional URL that Tomba also sends the result to (http:// or https://).", "required": false, "schema": { "type": "string" }, "example": "https://hooks.example.com/tomba" }, { "name": "maxResults", "in": "query", "description": "Maximum number of valid phone numbers to return (default 50).", "required": false, "schema": { "type": "integer", "minimum": 1, "default": 50 } } ], "responses": { "200": { "$ref": "#/components/responses/Results" }, "400": { "$ref": "#/components/responses/Error" }, "402": { "$ref": "#/components/responses/Error" } } }, "post": { "summary": "Find phone numbers for several searches using a JSON input", "description": "Accepts the same JSON input as a normal Actor run.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "searches": { "type": "array", "description": "Array of search queries to find phone numbers. Each query can contain email, domain, or LinkedIn profile.", "example": [ { "email": "john@example.com" }, { "domain": "example.com" }, { "linkedin": "https://linkedin.com/in/johndoe" } ], "items": { "type": "object", "properties": { "email": { "type": "string", "description": "Email address to find phone number for" }, "domain": { "type": "string", "description": "Domain to search for phone numbers" }, "linkedin": { "type": "string", "description": "LinkedIn profile URL to find phone number for" } } } }, "maxResults": { "type": "integer", "description": "Maximum number of phone numbers to find. Default is 50.", "minimum": 1, "maximum": 1000, "default": 50, "example": 50 }, "full": { "type": "boolean", "description": "Ask Tomba for the full phone record it has stored for each search (sent as `full=true`). Applies to every search and does not change the price.", "default": false }, "webhookUrl": { "type": "string", "description": "Optional URL that Tomba also sends each result to (sent as `webhook_url` on every search). Must start with http:// or https://.", "pattern": "^https?://" }, "maxConcurrency": { "type": "integer", "description": "How many Tomba requests run in parallel. There is no rate limit, so raise it for large lists.", "minimum": 1, "maximum": 50, "default": 10 }, "maxRetries": { "type": "integer", "description": "How many times to retry a request after a network error, 429 or 5xx response (exponential backoff). Failed requests are never charged.", "minimum": 0, "maximum": 10, "default": 3 }, "useCache": { "type": "boolean", "description": "Reuse results from previous runs for identical requests. Cached results are free.", "default": true }, "cacheTtlHours": { "type": "integer", "description": "How long cached results stay valid. 0 disables the cross-run cache.", "minimum": 0, "maximum": 720, "default": 24 } }, "required": ["searches"], "example": { "searches": [ { "email": "jane.doe@stripe.com", "domain": "stripe.com" }, { "linkedin": "https://www.linkedin.com/in/janedoe" } ] } } } } }, "responses": { "200": { "$ref": "#/components/responses/Results" }, "400": { "$ref": "#/components/responses/Error" }, "402": { "$ref": "#/components/responses/Error" } } } } }, "components": { "responses": { "Results": { "description": "One row per search, in the same format as the dataset items.", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/PhoneResult" } } } } } } }, "Error": { "description": "Invalid input (400) or the run's max charge limit was reached (402).", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string" } } } } } } }, "schemas": { "PhoneResult": { "type": "object", "properties": { "email": { "type": "string", "nullable": true, "description": "Email address associated with the phone number" }, "domain": { "type": "string", "nullable": true, "description": "Domain associated with the phone number" }, "linkedin": { "type": "string", "nullable": true, "description": "LinkedIn profile associated with the phone number" }, "valid": { "type": "boolean", "nullable": true, "description": "Whether the phone number is valid" }, "local_format": { "type": "string", "nullable": true, "description": "Phone number in local format" }, "intl_format": { "type": "string", "nullable": true, "description": "Phone number in international format" }, "e164_format": { "type": "string", "nullable": true, "description": "Phone number in E.164 format" }, "rfc3966_format": { "type": "string", "nullable": true, "description": "Phone number in RFC3966 format" }, "country_code": { "type": "string", "nullable": true, "description": "Country code of the phone number" }, "line_type": { "type": "string", "nullable": true, "description": "Type of phone line (mobile, landline, etc.)" }, "carrier": { "type": "string", "nullable": true, "description": "Phone carrier/provider information" }, "timezones": { "type": "array", "nullable": true, "description": "Timezone information for the phone number location", "items": { "type": "string" } }, "source": { "type": "object", "nullable": true, "description": "Source information used to find this phone number", "properties": { "search_type": { "type": "string", "nullable": true, "description": "Type of search performed (email, domain, linkedin)" }, "search_value": { "type": "string", "nullable": true, "description": "Value used in the search query" } } }, "error": { "type": "string", "nullable": true, "description": "Error message if no phone number was found or the search failed" }, "chargedCredits": { "type": "integer", "description": "Credits billed for this search ($0.00312 each): 1 when the search includes a domain, otherwise 5. 0 when not charged" }, "charged": { "type": "boolean", "description": "Whether this request was charged (pay-per-event)" }, "cached": { "type": "boolean", "description": "Whether the result came from the cache (free)" } } } } }}{ "actorOutputSchemaVersion": 1, "title": "Phone Finder Output", "description": "Output from Tomba Phone Finder Actor - phone numbers discovered for domains and companies", "type": "object", "properties": { "results": { "type": "string", "title": "Phone Discovery Results", "description": "Found phone numbers with carrier and location information", "template": "{{links.apiDefaultDatasetUrl}}/items" } }}{ "actorSpecification": 1, "name": "phone-finder", "title": "Tomba Phone Finder", "description": "Find phone numbers and related information using the Tomba API.", "version": "1.0", "buildTag": "latest", "meta": { "templateId": "ts-empty" }, "dockerfile": "../Dockerfile", "input": "./input_schema.json", "output": "./output_schema.json", "storages": { "dataset": "./dataset_schema.json", "keyValueStore": "./key_value_store_schema.json" }, "environmentVariables": { "TOMBA_API_KEY": "@tombaApiKey", "TOMBA_API_SECRET": "@tombaApiSecret" }, "usesStandbyMode": true, "webServerSchema": "./web_server_schema.json", "defaultMemoryMbytes": 256}{ "actorKeyValueStoreSchemaVersion": 1, "title": "Tomba Phone Finder storage", "description": "Records the Actor keeps in its default key-value store. Results are in the dataset.", "collections": { "input": { "title": "Input", "description": "The input of this run.", "key": "INPUT", "contentTypes": ["application/json"] }, "progress": { "title": "Progress", "description": "Searches already processed, so a migrated or restarted run continues without charging again.", "key": "TOMBA_STATE", "contentTypes": ["application/json"], "jsonSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "done": { "type": "object", "description": "Processed searches (JSON [email, domain, linkedin] keys) mapped to true", "additionalProperties": { "type": "boolean" } } }, "required": ["done"] } } }}{ "title": "Tomba Phone Finder Actor Input", "description": "Input schema for the Tomba Phone Finder Actor that discovers phone numbers using the Tomba API.", "type": "object", "schemaVersion": 1, "properties": { "searches": { "title": "Search Queries", "type": "array", "description": "Array of search queries to find phone numbers. Each query can contain email, domain, or LinkedIn profile.", "editor": "json", "items": { "type": "object", "properties": { "email": { "title": "Email Address", "type": "string", "description": "Email address to find phone number for" }, "domain": { "title": "Domain", "type": "string", "description": "Domain to search for phone numbers" }, "linkedin": { "title": "LinkedIn Profile", "type": "string", "description": "LinkedIn profile URL to find phone number for" } } }, "example": [ { "email": "john@example.com" }, { "domain": "example.com" }, { "linkedin": "https://linkedin.com/in/johndoe" } ], "prefill": [ { "email": "john@stripe.com" } ] }, "maxResults": { "title": "Maximum Results", "type": "integer", "description": "Maximum number of phone numbers to find. Default is 50.", "editor": "number", "minimum": 1, "maximum": 1000, "default": 50, "example": 50 }, "full": { "title": "Full phone record", "type": "boolean", "description": "Ask Tomba for the full phone record it has stored for each search (sent as `full=true`). Applies to every search and does not change the price.", "default": false, "editor": "checkbox" }, "webhookUrl": { "title": "Webhook URL", "type": "string", "description": "Optional URL that Tomba also sends each result to (sent as `webhook_url` on every search). Must start with http:// or https://.", "pattern": "^https?://", "editor": "textfield" }, "maxConcurrency": { "title": "Max concurrency", "type": "integer", "description": "How many Tomba requests run in parallel. There is no rate limit, so raise it for large lists.", "minimum": 1, "maximum": 50, "default": 10, "editor": "number", "sectionCaption": "Run options", "sectionDescription": "Concurrency, retry and cache settings. The defaults work well for most runs." }, "maxRetries": { "title": "Max retries", "type": "integer", "description": "How many times to retry a request after a network error, 429 or 5xx response (exponential backoff). Failed requests are never charged.", "minimum": 0, "maximum": 10, "default": 3, "editor": "number" }, "useCache": { "title": "Use cache", "type": "boolean", "description": "Reuse results from previous runs for identical requests. Cached results are free.", "default": true, "editor": "checkbox" }, "cacheTtlHours": { "title": "Cache TTL (hours)", "type": "integer", "description": "How long cached results stay valid. 0 disables the cross-run cache.", "minimum": 0, "maximum": 720, "default": 24, "editor": "number", "unit": "hours" } }, "required": ["searches"]}name: Test and push latest version
on: push: branches: - main - master pull_request: workflow_dispatch:
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
jobs: test: name: Format, lint, build and test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm
- name: Install dependencies run: npm ci
- name: Check formatting run: npm run format:check
- name: Lint run: npm run lint
- name: Build run: npm run build
- name: Test run: npm test
push: name: Push Actor to Apify needs: test # Only publish from the default branch, never from pull requests. if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
# .actor/actor.json maps TOMBA_API_KEY / TOMBA_API_SECRET to the Apify CLI secrets # @tombaApiKey / @tombaApiSecret, which `apify push` reads from ~/.apify/secrets.json. - name: Provide Tomba secrets to the Apify CLI env: TOMBA_API_KEY: ${{ secrets.TOMBA_API_KEY }} TOMBA_API_SECRET: ${{ secrets.TOMBA_API_SECRET }} run: | if [ -z "$TOMBA_API_KEY" ] || [ -z "$TOMBA_API_SECRET" ]; then echo "::error::Add the TOMBA_API_KEY and TOMBA_API_SECRET repository secrets." exit 1 fi mkdir -p ~/.apify node -e 'require("fs").writeFileSync(require("os").homedir() + "/.apify/secrets.json", JSON.stringify({ tombaApiKey: process.env.TOMBA_API_KEY, tombaApiSecret: process.env.TOMBA_API_SECRET }))'
- name: Push Actor to Apify id: push uses: apify/push-actor-action@v1 with: token: ${{ secrets.APIFY_TOKEN }}
- name: Print build details run: | echo "Build ${{ steps.push.outputs.build-id }} finished with status ${{ steps.push.outputs.build-status }}"