From d87479b0d9fef73947876328e37228186ec6972c Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Sun, 30 Jun 2024 00:31:07 -0700 Subject: [PATCH] feat: add hunter.io client --- legacy/.eslintrc.json | 1 + legacy/bin/scratch.ts | 18 +- legacy/readme.md | 2 + legacy/src/services/hunter-client.ts | 322 +++++++++++++++++++++++++++ legacy/src/services/index.ts | 1 + legacy/src/utils.ts | 35 ++- 6 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 legacy/src/services/hunter-client.ts diff --git a/legacy/.eslintrc.json b/legacy/.eslintrc.json index 6de124a4..b4178273 100644 --- a/legacy/.eslintrc.json +++ b/legacy/.eslintrc.json @@ -3,6 +3,7 @@ "extends": ["@fisch0920/eslint-config/node"], "rules": { "unicorn/no-static-only-class": "off", + "unicorn/no-array-reduce": "off", "@typescript-eslint/naming-convention": "off" } } diff --git a/legacy/bin/scratch.ts b/legacy/bin/scratch.ts index 11f7435b..0d7b58d4 100644 --- a/legacy/bin/scratch.ts +++ b/legacy/bin/scratch.ts @@ -19,7 +19,8 @@ import restoreCursor from 'restore-cursor' // import { MidjourneyClient } from '../src/index.js' // import { BingClient } from '../src/index.js' // import { TavilyClient } from '../src/index.js' -import { SocialDataClient } from '../src/index.js' +// import { SocialDataClient } from '../src/index.js' +import { HunterClient } from '../src/index.js' /** * Scratch pad for testing. @@ -120,8 +121,19 @@ async function main() { // }) // console.log(JSON.stringify(res, null, 2)) - const socialData = new SocialDataClient() - const res = await socialData.getUserByUsername('transitive_bs') + // const socialData = new SocialDataClient() + // const res = await socialData.getUserByUsername('transitive_bs') + // console.log(JSON.stringify(res, null, 2)) + + const hunter = new HunterClient() + // const res = await hunter.emailVerifier({ + // email: 'travis@transitivebullsh.it' + // }) + const res = await hunter.emailFinder({ + domain: 'aomni.com', + first_name: 'David', + last_name: 'Zhang' + }) console.log(JSON.stringify(res, null, 2)) } diff --git a/legacy/readme.md b/legacy/readme.md index 7d1bb77b..4d95559a 100644 --- a/legacy/readme.md +++ b/legacy/readme.md @@ -138,6 +138,7 @@ Depending on the AI SDK and tool you want to use, you'll also need to install th | [E2B](https://e2b.dev) | `e2b` | Hosted Python code intrepreter sandbox which is really useful for data analysis, flexible code execution, and advanced reasoning on-the-fly. | | [Exa](https://docs.exa.ai) | `ExaClient` | Web search tailored for LLMs. | | [Firecrawl](https://www.firecrawl.dev) | `FirecrawlClient` | Website scraping and sanitization. | +| [Hunter](https://hunter.io) | `HunterClient` | Email finder, verifier, and enrichment. | | [Midjourney](https://www.imagineapi.dev) | `MidjourneyClient` | Unofficial Midjourney client for generative images. | | [Novu](https://novu.co) | `NovuClient` | Sending notifications (email, SMS, in-app, push, etc). | | [People Data Labs](https://www.peopledatalabs.com) | `PeopleDataLabsClient` | People & company data (WIP). | @@ -204,6 +205,7 @@ See the [examples](./examples) directory for examples of how to use each of thes - replicate - huggingface - [skyvern](https://github.com/Skyvern-AI/skyvern) + - pull from [clay](https://www.clay.com/integrations) - pull from [langchain](https://github.com/langchain-ai/langchainjs/tree/main/langchain) - provide a converter for langchain `DynamicStructuredTool` - pull from [nango](https://docs.nango.dev/integrations/overview) diff --git a/legacy/src/services/hunter-client.ts b/legacy/src/services/hunter-client.ts new file mode 100644 index 00000000..1bee435f --- /dev/null +++ b/legacy/src/services/hunter-client.ts @@ -0,0 +1,322 @@ +import defaultKy, { type KyInstance } from 'ky' +import { z } from 'zod' + +import { aiFunction, AIFunctionsProvider } from '../fns.js' +import { + assert, + getEnv, + pruneNullOrUndefinedDeep, + sanitizeSearchParams +} from '../utils.js' + +export namespace hunter { + export const API_BASE_URL = 'https://api.hunter.io' + + export const DepartmentSchema = z.enum([ + 'executive', + 'it', + 'finance', + 'management', + 'sales', + 'legal', + 'support', + 'hr', + 'marketing', + 'communication', + 'education', + 'design', + 'health', + 'operations' + ]) + export type Department = z.infer + + export const SenioritySchema = z.enum(['junior', 'senior', 'executive']) + export type Seniority = z.infer + + export const PersonFieldSchema = z.enum([ + 'full_name', + 'position', + 'phone_number' + ]) + export type PersonField = z.infer + + export const DomainSearchOptionsSchema = z.object({ + domain: z.string().optional().describe('domain to search for'), + company: z.string().optional().describe('company name to search for'), + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + type: z.enum(['personal', 'generic']).optional(), + seniority: z.union([SenioritySchema, z.array(SenioritySchema)]).optional(), + department: z + .union([DepartmentSchema, z.array(DepartmentSchema)]) + .optional(), + required_field: z + .union([PersonFieldSchema, z.array(PersonFieldSchema)]) + .optional() + }) + export type DomainSearchOptions = z.infer + + export const EmailFinderOptionsSchema = z.object({ + domain: z.string().optional().describe('domain to search for'), + company: z.string().optional().describe('company name to search for'), + first_name: z.string().describe("person's first name"), + last_name: z.string().describe("person's last name"), + max_duration: z.number().int().positive().min(3).max(20).optional() + }) + export type EmailFinderOptions = z.infer + + export const EmailVerifierOptionsSchema = z.object({ + email: z.string().describe('email address to verify') + }) + export type EmailVerifierOptions = z.infer + + export interface DomainSearchResponse { + data: DomainSearchData + meta: { + results: number + limit: number + offset: number + params: { + domain?: string + company?: string + type?: string + seniority?: string + department?: string + } + } + } + + export interface DomainSearchData { + domain: string + disposable: boolean + webmail?: boolean + accept_all?: boolean + pattern?: string + organization?: string + description?: string + industry?: string + twitter?: string + facebook?: string + linkedin?: string + instagram?: string + youtube?: string + technologies?: string[] + country?: string + state?: string + city?: string + postal_code?: string + street?: string + headcount?: string + company_type?: string + emails?: Email[] + linked_domains?: string[] + } + + export interface Email { + value: string + type: string + confidence: number + first_name?: string + last_name?: string + position?: string + seniority?: string + department?: string + linkedin?: string + twitter?: string + phone_number?: string + verification?: Verification + sources?: Source[] + } + + export interface Source { + domain: string + uri: string + extracted_on: string + last_seen_on: string + still_on_page?: boolean + } + + export interface Verification { + date: string + status: string + } + + export interface EmailFinderResponse { + data: EmailFinderData + meta: { + params: { + first_name?: string + last_name?: string + full_name?: string + domain?: string + company?: string + max_duration?: string + } + } + } + + export interface EmailFinderData { + first_name: string + last_name: string + email: string + score: number + domain: string + accept_all: boolean + position?: string + twitter?: any + linkedin_url?: any + phone_number?: any + company?: string + sources?: Source[] + verification?: Verification + } + + export interface EmailVerifierResponse { + data: EmailVerifierData + meta: { + params: { + email: string + } + } + } + + export interface EmailVerifierData { + status: + | 'valid' + | 'invalid' + | 'accept_all' + | 'webmail' + | 'disposable' + | 'unknown' + result: 'deliverable' | 'undeliverable' | 'risky' + score: number + email: string + regexp: boolean + gibberish: boolean + disposable: boolean + webmail: boolean + mx_records: boolean + smtp_server: boolean + smtp_check: boolean + accept_all: boolean + block: boolean + sources?: Source[] + _deprecation_notice?: string + } +} + +/** + * Lightweight wrapper around Hunter.io email finder, verifier, and enrichment + * APIs. + * + * @see https://hunter.io/api-documentation + */ +export class HunterClient extends AIFunctionsProvider { + protected readonly ky: KyInstance + protected readonly apiKey: string + protected readonly apiBaseUrl: string + + constructor({ + apiKey = getEnv('HUNTER_API_KEY'), + apiBaseUrl = hunter.API_BASE_URL, + ky = defaultKy + }: { + apiKey?: string + apiBaseUrl?: string + ky?: KyInstance + } = {}) { + assert( + apiKey, + 'HunterClient missing required "apiKey" (defaults to "HUNTER_API_KEY")' + ) + + super() + + this.apiKey = apiKey + this.apiBaseUrl = apiBaseUrl + + this.ky = ky.extend({ + prefixUrl: this.apiBaseUrl + }) + } + + @aiFunction({ + name: 'hunter_domain_search', + description: + 'Gets all the email addresses associated with a given company or domain.', + inputSchema: hunter.DomainSearchOptionsSchema.pick({ + domain: true, + company: true + }) + }) + async domainSearch(domainOrOpts: string | hunter.DomainSearchOptions) { + const opts = + typeof domainOrOpts === 'string' ? { domain: domainOrOpts } : domainOrOpts + if (!opts.domain && !opts.company) { + throw new Error('Either "domain" or "company" is required') + } + + const res = await this.ky + .get('v2/domain-search', { + searchParams: sanitizeSearchParams( + { + ...opts, + api_key: this.apiKey + }, + { csv: true } + ) + }) + .json() + + return pruneNullOrUndefinedDeep(res) + } + + @aiFunction({ + name: 'hunter_email_finder', + description: + 'Finds the most likely email address from a domain name, a first name and a last name.', + inputSchema: hunter.EmailFinderOptionsSchema.pick({ + domain: true, + company: true, + first_name: true, + last_name: true + }) + }) + async emailFinder(opts: hunter.EmailFinderOptions) { + if (!opts.domain && !opts.company) { + throw new Error('Either "domain" or "company" is required') + } + + const res = await this.ky + .get('v2/email-finder', { + searchParams: sanitizeSearchParams({ + ...opts, + api_key: this.apiKey + }) + }) + .json() + + return pruneNullOrUndefinedDeep(res) + } + + @aiFunction({ + name: 'hunter_email_verifier', + description: 'Verifies the deliverability of an email address.', + inputSchema: hunter.EmailVerifierOptionsSchema + }) + async emailVerifier(emailOrOpts: string | hunter.EmailVerifierOptions) { + const opts = + typeof emailOrOpts === 'string' ? { email: emailOrOpts } : emailOrOpts + + const res = await this.ky + .get('v2/email-verifier', { + searchParams: sanitizeSearchParams({ + ...opts, + api_key: this.apiKey + }) + }) + .json() + + return pruneNullOrUndefinedDeep(res) + } +} diff --git a/legacy/src/services/index.ts b/legacy/src/services/index.ts index 22c82b5e..b233386f 100644 --- a/legacy/src/services/index.ts +++ b/legacy/src/services/index.ts @@ -4,6 +4,7 @@ export * from './dexa-client.js' export * from './diffbot-client.js' export * from './exa-client.js' export * from './firecrawl-client.js' +export * from './hunter-client.js' export * from './midjourney-client.js' export * from './novu-client.js' export * from './people-data-labs-client.js' diff --git a/legacy/src/utils.ts b/legacy/src/utils.ts index c159498a..fbd10f5c 100644 --- a/legacy/src/utils.ts +++ b/legacy/src/utils.ts @@ -134,21 +134,34 @@ export function sanitizeSearchParams( string, string | number | boolean | string[] | number[] | boolean[] | undefined > - | object + | object, + { csv = false }: { csv?: boolean } = {} ): URLSearchParams { - return new URLSearchParams( - Object.entries(searchParams).flatMap(([key, value]) => { - if (key === undefined || value === undefined) { - return [] - } + const entries = Object.entries(searchParams).flatMap(([key, value]) => { + if (key === undefined || value === undefined) { + return [] + } - if (Array.isArray(value)) { - return value.map((v) => [key, String(v)]) - } + if (Array.isArray(value)) { + return value.map((v) => [key, String(v)]) + } - return [[key, String(value)]] - }) as [string, string][] + return [[key, String(value)]] + }) as [string, string][] + + if (!csv) { + return new URLSearchParams(entries) + } + + const csvEntries = entries.reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: acc[key] ? `${acc[key]},${value}` : value + }), + {} as any ) + + return new URLSearchParams(csvEntries) } /**