mcpField
E
email-enrichment

email-enrichment

github.com/crustdataマーケティング・グロース11618 回インストール2 週間前に更新
リポジトリ

email-enrichment について

Email Enrichmentは、メールアドレス一覧から連絡先プロフィールを作成し、人の一覧から連絡先情報を探すスキルです。メール分類、企業・人物の照合、検証、段階的な代替検索を組み合わせます。

主な機能

  1. 01メール入力の分類と重複除去
  2. 02メールドメインから企業を特定
  3. 03勤務先・教育・個人メールの情報補完
  4. 04勤務先と氏名の照合確認
  5. 05段階的な代替検索

活用シーン

  1. 01メール一覧から連絡先プロフィールを作成
  2. 02人物一覧の業務連絡先を検索
  3. 03未照合の個人メールの所有者を特定

MCP 対応

このスキルは `execute({ code })` スクリプトと `callTool` を通じてCrustdataの検索を実行し、`company_identify`、`person_enrich`、`person_search`、`web_search_live`などを利用します。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/crustdata/skills --skill email-enrichment

インストール後、Claude Code で/email-enrichmentを入力して読み込まれたか確認します。

同梱ファイルevals/evals.jsonREADME.md

詳細

メールのエンリッチメント

1つのスキルで2つの方向に対応します。

  1. メールアドレスから人物を特定 - メールアドレスのリストを、名前、役職、会社、プロフィール URL などの詳細な連絡先プロフィールに変換します。網羅性と精度を高めるために最適化された、6段階のウォーターフォール方式を使用します。
  2. 人物からメールアドレスを特定 - 人物のリストから、仕事用メールアドレス、個人用メールアドレス、電話番号を見つけます。個人の連絡先情報を使ったエンリッチメントに加え、技術職の人向けにコミット履歴をフォールバックとして使用します。

概要

このアプローチでは、厳密なウォーターフォール方式で6つのフェーズを実行します。各フェーズでは、それ以前のフェーズで見つからなかったメールアドレスを検出します。フェーズはコスト(無料から始め、順に低コスト)と信頼性(精度の高いものから順に)の両方を考慮して並べられています。

Crustdata の処理はすべて execute({ code }) スクリプト内で実行します。短いプレーンな JavaScript スクリプトを作成してください。I/O は const r = await callTool("<tool>", params) のみとし、その後 r.ok に基づいて分岐します。get_schema から取得した TypeScript 型付きツールサーフェスに基づいて記述しますが、スクリプト本文には型注釈を一切入れないでください。: Type、as、interface を含めると解析エラーが発生し、実行全体が失敗します。以下の各フェーズのスクリプトを参照してください。

フェーズCrustdata ツール(callTool 経由)対象コスト
1company_identify仕事用 + 学校用メール無料
2person_enrich(business_emails)+ 検証後処理仕事用 + 学校用約1~2 cr/プロフィール
2(個人用)batch_person_identify(非同期)個人用1 cr/一致
3person_search(名前+会社)未検出の仕事用/学校用約0.03 cr/結果
4person_search(名前のみ)残りの個人用約0.03 cr/結果
5web_search_live + person_enrich残りすべて1 cr/クエリ + エンリッチメント
6スコアリングゲートフェーズ3~5の候補すべて該当なし

カバー率:

カテゴリ人物の一致率会社の一致率
仕事用メール95%以上95%以上
学校用メール95%以上95%以上
個人用メール95%以上該当なし
全体95%以上95%以上

フェーズ0:入力の解析とメールアドレスの分類

入力を読み込む

CSV ファイル、スプレッドシート(.xlsx/.csv)、またはインラインリストを受け付けます。すべてのメールアドレスを抽出し、重複を削除します。

各メールアドレスを次の3カテゴリに分類する

個人用メールのドメイン(このリストと照合します):

gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com, icloud.com, me.com,
live.com, protonmail.com, proton.me, msn.com, ymail.com, comcast.net, att.net,
verizon.net, mac.com, fastmail.com, hey.com, pm.me, zoho.com, gmx.com,
googlemail.com

学校用メールのドメイン(次の TLD パターンと照合します):

.edu, .ac.uk, .ac.jp, .ac.kr, .ac.in, .ac.nz, .ac.za

仕事用メール:それ以外すべて。

メールアドレスのプレフィックスから名前を抽出する

ローカル部(@ より前)を、ドット、アンダースコア、ハイフンで分割します。数字だけで構成される部分を削除し、残った各部分の先頭を大文字にします。2文字以上の部分のみを残します。

import re

def extract_name_parts(email):
    local = email.split("@")[0]
    parts = re.split(r'[._\-]', local)
    parts = [p for p in parts if not p.isdigit()]
    parts = [p.capitalize() for p in parts if len(p) >= 2]
    return parts

# Examples:
# "[email protected]"   -> ["Daniel", "Lee"]
# "[email protected]"      -> ["John", "Smith"]
# "[email protected]"      -> ["Jsmith"]
# "[email protected]"  -> ["Rodriguez"]

フェーズ1:会社の特定(無料)

個人用ではないメールドメインごとに、そのドメインの背後にある会社を特定します。このフェーズは無料なので、必ず最初に実行してください。

スクリプト

company_identify は domains 配列を受け取ります。そのため、まずドメインの重複を削除します(仕事用メールが1,000件あっても、重複を除くとドメインが200件だけの場合があります)。そして、すべてのドメインを1回の呼び出しで解決します。

// model query: identify the company behind each work/edu email domain
const domains = [...new Set(workEduEmails.map(e => e.split("@")[1].toLowerCase()))];
const r = await callTool("company_identify", { domains });
if (!r.ok) return { error: r.message };

// build domain_map: domain -> { name, primary_domain, professional_network_url }
const domainMap = {};
for (const rec of r.data) {
  const info = rec.matches?.[0]?.company_data?.basic_info;
  if (info) domainMap[rec.matched_on.toLowerCase()] = {
    name: info.name,
    primary_domain: info.primary_domain,
    professional_network_url: info.professional_network_url,
  };
}
return { domainMap };
返される内容

{ matched_on, match_type, matches: [{ confidence_score, company_data }] } の配列です。会社情報は matches[0].company_data.basic_info(name、primary_domain、website、professional_network_url、description、year_founded など)から取得します。matched_on は渡したドメインなので、結果を入力に直接対応付けられます。

実行方法

結果を domain_map に保存します。形式は domain -> { name, ... } です。この domainMap は返され、後続のフェーズ(inputs 経由)に渡されてフェーズ3の検証に使用されます。ドメインごとに、この無料呼び出しを再実行しないでください。

期待される結果
  • 仕事用メールのドメインの95%以上が特定されます
  • 学校用ドメインはほぼ100%特定されます(大学はよく知られているためです)

フェーズ2:仕事用メールを使った人物のエンリッチメント(仕事用/学校用)

人物エンリッチメントを使って、仕事用/学校用メールアドレスごとに直接検索します。フェーズ2はメールの種類に応じて分岐します。

  • 仕事用/学校用メール -> business_emails 識別子と検証後処理を使用します(以下の分岐A)
  • 個人用メール -> 分岐Bを参照してください。非同期の batch_person_identify 経路を使用し、名前ベースのフェーズをフォールバックとして使います
ブランチ A:仕事用・教育機関用メールアドレス(business_emails)
スクリプト

person_enrich は business_emails 配列(1回の呼び出しにつき25件以下)を受け取るため、仕事用・教育機関用メールアドレスはまとめて処理します。検証後に必要となる名前と雇用先を取得するには、fields: ["basic_profile", "experience"] を渡します。各呼び出しは入力メールアドレスごとに1件のレコードを返します。matched_on を使って結果を対応付けます。

// model query: enrich work/edu emails to find the person behind each
const r = await callTool("person_enrich", {
  business_emails: workEduEmailBatch,          // ≤25 emails
  fields: ["basic_profile", "experience"],     // name + current/past employers
});
if (!r.ok) return { error: r.message };

// r.data is an ARRAY of { matched_on, match_type, matches: [{ confidence_score, person_data }] }
const byEmail = {};
for (const rec of r.data) {
  const pd = rec.matches?.[0]?.person_data;
  if (pd) byEmail[rec.matched_on.toLowerCase()] = pd;   // post-verify before accepting
}
return { byEmail };

仕事用・教育機関用メールアドレスが25件を超える場合は、chunk(emails, 25) で分割してから、await parallelMap(batches, b => callTool("person_enrich", { business_emails: b, fields: ["basic_profile", "experience"] })) を実行します。

重要な詳細
  • business_emails にはメールアドレス文字列の配列を指定します(1回の呼び出しにつき25件以下)。
  • 呼び出しごとに識別子の種類は1つだけ指定します。professional_network_profile_urls と business_emails を同じ呼び出しにまとめることはできません。
  • 名前は「business_emails」ですが、教育機関のメールアドレスにも使えます(特に教員・職員の場合)。
  • 次の情報を含む person_data が返されます:basic_profile.name、basic_profile.headline、social_handles.professional_network_identifier.profile_url、および experience.employment_details.current[] / .past[](雇用先)。
  • 支払い前にクレジット消費なしで基本プロフィールを確認するには、preview: true を設定します。

一致する結果(matches[0].person_data に名前がある場合)が返された場合、受け入れる前に必ず事後検証に合格させる必要があります(以下の「事後検証」セクションを参照)。

ブランチ B:個人用メールアドレス(個人用メールアドレスの逆引き)

非同期のみ: 個人用メールアドレス(gmail/yahoo/outlook)の照合には、非同期の batch_person_identify を使います。個人用メールアドレスに対応するツールはこれだけです。同期ツールは対応していません。 person_enrich が識別子として受け付けるのは professional_network_profile_urls または business_emails のみです。 person_enrich_live が受け付けるのはプロフィール URL のみで、person_search には emails フィルター列がありません。

方法: batch_person_identify({ emails: [...] })(1ジョブにつき300件以下)を送信し、 completed になるまで batch_job_get(batch_id) をポーリングしてから、 batch_results の行を読み取ります。matches[0].person_data には、ブランチ A と同じ ID/基本プロフィール/プロフィール URL の形式の情報が含まれます。 一致した識別子1件につき1クレジットです。一致しなかった識別子は無料です。照合されなかったメールアドレスについては、次の手順に進みます。

  1. フェーズ 4 — メールアドレスのプレフィックスから抽出した氏名全体で person_search を実行します(プレフィックスが明確な first.last パターンの場合に有効です)。その後、取得したプロフィール URL で person_enrich を実行します。
  2. フェーズ 5 — web_search_live({ query: "who is EMAIL", sources: ["ai"] }) で所有者の名前を特定し、その後 person_search → person_enrich を実行します。
検証後の確認(Phase 2 Branch A のすべての結果に必須 — work/edu のみ)

person enrich API は誤った候補を返すことがあります。正しい会社に勤めているもののメールの所有者ではない人物や、すでにその会社を退職した人物が返される場合があります。すべての結果で、以下の確認を行う必要があります。確認には、上記の enrich スクリプトが返す person_data を使用します。雇用先は experience.employment_details.current[] / .past[] にあり、各エントリには会社名の name、title、および company_website_domain が含まれます。

確認 1:雇用先ドメインの検証。 メールドメインが、その人物の現在または過去の雇用先のウェブサイトドメインに含まれている必要があります。たとえば、[email protected] の場合、少なくとも1つの雇用先のドメイン一覧に sapphireventures.com が含まれている必要があります。現在または過去の雇用先のいずれにもそのドメインが含まれていない場合は、候補を REJECT します。

def verify_employer_domain(person_data, email_domain):
    domain_base = email_domain.lower().split('.')[0]
    emp_details = person_data.get("experience", {}).get("employment_details", {})
    employers = emp_details.get("current", []) + emp_details.get("past", [])
    for emp in employers:
        domains = emp.get("company_website_domain") or []
        if isinstance(domains, str):
            domains = [domains]
        for d in domains:
            if email_domain.lower() in d.lower() or d.lower() in email_domain.lower():
                return True, emp.get("name", "")
        if len(domain_base) > 3 and domain_base in emp.get("name", "").lower().replace(" ", ""):
            return True, emp.get("name", "")
    return False, None

確認 2:名前の先頭部分の一致。 メールのプレフィックスが、返された人物の名前と妥当に一致している必要があります。たとえば、[email protected] は「Santosh Sankar」ではなく、「Ted Alling」のように「alling」を含む名前と一致する必要があります。プロフィール名のいずれかの部分がメールのプレフィックスと同じ文字で始まるか、名のイニシャル+姓のパターンが一致するかを確認します。

def name_matches_prefix(profile_name, email_prefix):
    pn_parts = profile_name.lower().split()
    prefix = email_prefix.lower()
    for part in pn_parts:
        if prefix.startswith(part[:3]) or part.startswith(prefix[:3]):
            return True
        # First-initial + lastname pattern: "talling" = "t" + "alling"
        if len(prefix) > 2:
            for i in range(1, min(3, len(prefix))):
                if prefix[i:] in part and len(prefix[i:]) > 2:
                    return True
    return False

確認 3:名前が一致しない場合の AI による修正。 雇用先のドメインは一致するものの、名前が一致しない場合(会社は正しいが人物が違う場合)は、deep research モード(sources: ["ai"])で web_search_live を使用し、実際にそのメールを所有している人物を特定します。

// model query: who actually owns [email protected]
const r = await callTool("web_search_live", {
  query: "who is [email protected]",
  sources: ["ai"],
});
const aiText = r.ok ? r.data.results?.map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };

AI の応答は通常、「Lamp Post Group のパートナーである Ted Alling のメールアドレスです」のような内容です。正しい名前を抽出し、修正した名前と会社名を使って person DB を検索します(Phase 3 person_search)。

このステップにより、テストでは、これを行わなければ失われていた正しい一致を7件回収できました。

期待される結果
  • Work/edu(Branch A): work+edu メールの約58%がすべての検証を通過します。約5%は雇用先ドメインの確認で却下されます(人物がまったく違います)。約1%は AI によって修正されます(会社は正しいが人物が違う場合に、AI が正しい名前を特定します)。
  • Personal(Branch B): 非同期の batch_person_identify で直接照合します。一致しなかったメールは、下流の名前ベースの Phase 4/5 で解決し、精度は Phase 6 で判定します。

Phase 3:名前+会社名による人物検索

Phase 2 で見つからなかった work/edu メールについて、名前+会社名で検索します。メールのプレフィックスから名前を推定し、Phase 1 で特定した会社名と組み合わせます。

使用するタイミング

次の条件をすべて満たすメールにのみ使用します。

  1. Phase 2 で一致する結果がなかった
  2. メールが work または edu である(personal ではない)
  3. Phase 1 でドメインを特定済みである(会社名がわかっている)
  4. メールのプレフィックスから名前の一部を少なくとも1つ抽出できる
スクリプト

フィルターキーは常に field です。人物名のフィルターには basic_profile.name を、現在の雇用先名のフィルターには experience.employment_details.current.company_name を使用します。limit はページサイズの上限を設定します。

// model query: find FirstName at CompanyName
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "CompanyName" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],   // name + profile_url
});
if (!r.ok) return { error: r.message };
// r.data.profiles[]: read basic_profile.name and
// social_handles.professional_network_identifier.profile_url
return { profiles: (r.data.profiles ?? []).map((p) => ({
  name: p.basic_profile?.name,
  profile_url: p.social_handles?.professional_network_identifier?.profile_url,
})) };
検証(必須)

返されたプロフィール名に、メールのプレフィックスから抽出した名が含まれている必要があります。

def verify_name_match(email_name_parts, profile_name):
    if not email_name_parts or not profile_name:
        return False
    return email_name_parts[0].lower() in profile_name.lower()
実行方法

見つからなかった work/edu メールごとに名前を抽出し、domain_map から会社名を検索します。

// model query: find Kyle at Backswing Ventures (email [email protected])
// Name parts: ["Kyle"]; company from Phase 1 domainMap: "Backswing Ventures"
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Kyle" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "Backswing Ventures" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

返されたプロフィールをそれぞれ確認します。basic_profile.name に「kyle」が含まれていますか?含まれていれば、一致と判断します。

期待される結果
  • 名前+会社名の複合検索により、Phase 2 で見つからなかったメールを見つけます。
  • 名前の形式が明確なメール(john.smith@、daniel_lee@)で最も効果を発揮します。

候補者の検証(重要 -- 省略しないでください)

検証しないと、名前や部分文字列の一致によって誤検出が生じます。たとえば、「wraecca」で検索すると、「racca」が部分文字列として含まれるため、「Alessandro Racca」が一致する場合があります。

すべての候補者について、以下のすべてを検証してください。

ステップ 1 -- 名前の検証(すべてのメールタイプで必須):

  • メールアドレスのプレフィックスから名前の各部分を抽出します
  • 名前の部分が 2 つ以上ある場合:名と姓の両方がプロフィール名に含まれている必要があります
  • 名前の部分が 1 つの場合:その部分がプロフィール名に含まれ、かつ 3 文字以上である必要があります

ステップ 2 -- 組織の検証(仕事用および教育機関のメールで必須):

  • domain_map(フェーズ 1)から会社/教育機関を調べます
  • 組織名から重要な単語を抽出します(「the」、「inc」、「llc」、「of」などの一般的な単語は除外)
  • 重要な組織名の単語が少なくとも 1 つ、プロフィールデータのどこかに含まれている必要があります(勤務先、学歴、見出しを確認してください)
  • 組織名の単語が一致しない場合は、名前が一致していても結果を拒否します

ステップ 3 -- 個人用メール(名前のみを確認):

  • 個人用メール(gmail、yahoo など)には、照合対象となる組織がありません
  • ステップ 1 の名前の一致のみを判定基準とします
  • つまり、個人用メールの一致は精度が低くなります

検索プロフィールの名前は basic_profile.name に格納されています。検証の前にフラット化してください。

def verify_candidate_match(email, name_parts, profile, domain_map):
    profile_name = profile.get("basic_profile", {}).get("name", "").lower()

    # Name verification
    if len(name_parts) >= 2:
        if not (name_parts[0].lower() in profile_name and name_parts[-1].lower() in profile_name):
            return False
    elif len(name_parts) == 1 and len(name_parts[0]) > 2:
        if name_parts[0].lower() not in profile_name:
            return False
    else:
        return False

    # Org verification for work/edu
    domain = email.split("@")[1]
    company_info = domain_map.get(domain)
    if company_info:
        org_name = company_info.get("name", "")
        skip = {"the", "inc", "llc", "ltd", "co", "corp", "of", "and", "for", "university", "college"}
        org_words = [w.lower() for w in org_name.split() if w.lower() not in skip and len(w) > 2]
        profile_str = str(profile).lower()
        if org_words and not any(w in profile_str for w in org_words):
            return False

    return True
期待される結果
  • 仕事用/教育機関のメール:名前と会社の両方が一致することを確認します。フェーズ 1 で会社が見つからない場合は、自動的に拒否します。
  • 個人用メール:名前の一致のみ(精度は低くなりますが、検証しないよりは適切です)

フェーズ 4:個人用メールの名前による人物検索

メールアドレスのプレフィックスから妥当なフルネームを抽出できる、個人用メールの最終手段です。

使用するタイミング

次の条件をすべて満たすメールにのみ使用します。

  1. これまでのすべてのフェーズで一致する結果がなかった
  2. メールが個人用(gmail、yahoo など)である
  3. メールアドレスのプレフィックスから名前の部分を 2 つ以上抽出できる
スクリプト
// model query: find FirstName LastName by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName LastName" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
if (!r.ok) return { error: r.message };
return { profiles: r.data.profiles ?? [] };
受け入れ基準
  • 検索結果は 3 件以下である必要があります(曖昧さが小さいこと)
  • 4 件以上の結果が返された場合は、候補が多すぎるためスキップします
  • 返された名前が、抽出した名前の部分と妥当に一致している必要があります
実行方法
// model query: find Joanne Bradford (email [email protected]) by name
// Name parts: ["Joanne", "Bradford"]
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Joanne Bradford" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

結果が 1~3 件返された場合は、最初の結果を採用します。0 件または 4 件以上の場合は、一致なしとして扱います。

期待される結果
  • 明確な姓.名のパターンを持つ、残りの個人用メールを検出します
  • 結果を 3 件までに制限することで、一般的な名前の別人と誤って一致するのを防ぎます
  • 必ず検証してください:メールアドレスのプレフィックスにある名前の部分が両方とも、返されたプロフィール名に含まれている必要があります。最初の結果をそのまま採用しないでください。

フェーズ 5:残りの不一致メールすべてに対するウェブ検索フォールバック

これまでのすべてのフェーズで見つからなかったメールに対する最終的なフォールバックです。ウェブ検索で人物のプロフィール URL を見つけ、その URL を使って情報を拡充します。これにより、独自ドメイン(例:carolewainaina.com)、個人ブランドのドメイン、および Crustdata のデータベースにインデックスされていないメールを検出できます。

使用するタイミング

カテゴリ(仕事用、教育機関、個人用)を問わず、フェーズ 1~5 の後も一致しないメールすべてに使用します。

ステップ 1:ウェブ検索でプロフィール URL を探す
// model query: find a professional-network profile URL for EMAIL
const r = await callTool("web_search_live", {
  query: "EMAIL linkedin",
  sources: ["web"],
});
// r.data.results[]: { source, title, url, snippet, ... }
const hit = r.ok && (r.data.results ?? []).find((x) => /linkedin\.com\/in\//.test(x.url || ""));
return { profile_url: hit?.url ?? null };

結果に linkedin.com/in/ を含む URL があるか確認してください。見つかった場合は、ステップ 3 に進みます。

ステップ 2:名前の詳細調査モード(ステップ 1 でプロフィール URL が見つからなかった場合)
// model query: who owns EMAIL
const r = await callTool("web_search_live", {
  query: "who is EMAIL",
  sources: ["ai"],
});
const aiText = r.ok ? (r.data.results ?? []).map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };

AI の応答には、「Carole Wamuyu Wainaina のものです」や「Company X の John Smith に関連付けられています」のような記述が含まれることがよくあります。人物の名前を抽出し、人物 DB を検索します。

// model query: find <Extracted Name> by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Extracted Name" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
// take social_handles.professional_network_identifier.profile_url off the best match
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

person_search がプロフィール URL(social_handles.professional_network_identifier.profile_url)を含む結果を返した場合は、ステップ 3 に進みます。

ステップ 3:プロフィール URL から情報を拡充する
// model query: enrich the resolved profile URL to get the person's details
const r = await callTool("person_enrich", {
  professional_network_profile_urls: ["PROFILE_URL_FROM_STEP_1_OR_2"],
  fields: ["basic_profile", "experience"],
});
// r.data[0].matches[0].person_data: basic_profile.name, basic_profile.headline, employers
return r.ok ? { person: r.data?.[0]?.matches?.[0]?.person_data } : { error: r.message };
期待される結果
  • 独自ドメイン/個人用ドメイン(carolewainaina.com、first-last.com)を検出します
  • メールではインデックスされていなくても、ウェブ検索で見つかる人物を検出します
  • 詳細調査モードは、「このメールの所有者は誰か」という問い合わせの解決に特に効果的です

フェーズ 6:スコアリングゲート(フェーズ 3~5 のすべての候補に適用)

フェーズ 3、4、5 で得られた候補の一致は、採用される前にすべてこのスコアリングゲートを通過する必要があります。フェーズ 2 の結果は対象外です。ブランチ A には雇用主ドメインと名前による独自の検証があり、ブランチ B の一致はメールアドレス自体をキーとしています(API が識別子に直接一致しました)。

必須条件(両方を満たす必要があります)
  1. name_sim > 0.8 -- メールアドレスのプレフィックスから抽出した名前と候補プロフィール名の類似度が 0.8 を超えている必要があります。これにより、会社が完全に一致していても、名前の一致度が低い場合に補えないようにします。
  2. combined_score > 0.7 -- 名前の類似度、会社の一致、その他のシグナルを含む総合スコアが 0.7 を超えている必要があります。

フェーズ 6 では、name_sim > 0.8 と combined_score > 0.7 の両方が必要です。これにより、会社が完全に一致していても、名前の一致度が低い場合に補えないようにします。たとえば、会社は正しく一致していても、名前がメールアドレスのプレフィックスと似ていない場合、その会社に所属する人物は拒否されます。

候補が条件を満たさない場合

候補がスコアリングゲートの条件を満たさない場合、その候補は拒否され、メールアドレスはウォーターフォールの次のフェーズに進みます。どのフェーズでもゲートを通過する候補が見つからなかった場合、メールアドレスは UNMATCHED としてマークされます。


スケーリングと並行処理

レート制限は execute 内でホストによって処理されるため、手動で RPM テーブルを管理したり、待機したり、バックオフを設定したりする必要はありません。大量のリストを処理する場合は、1 つのスクリプト内で作業を並列化してスケールさせます。

  • 一括エンリッチ。 person_enrich と company_identify は配列を受け取ります。1 回の呼び出しにつき、最大 25 件のメールアドレス/ドメインを渡します。chunk(emails, 25) を使い、次に await parallelMap(batches, b => callTool("person_enrich", { business_emails: b, fields: [...] })) を実行します。
  • 独立した呼び出しを並列化するには await parallelMap(items, fn) を使用します。for … await は使用しないでください。依存するステップ(検索 → エンリッチ)は順次実行し、並列化するのは各ステージ内に限ります。
  • 必要最小限のデータだけを返すようにします。return した内容だけがモデルに渡されます。
最適化:フェーズ 1 でドメインを重複排除する

1,000 件の仕事用メールアドレスに対して、ドメインが重複しないのは 200 件だけという場合もあります。domains 配列を受け取る company_identify を呼び出す前に、必ずドメインを重複排除します(重複のないドメインをすべてまとめて 1 回で解決します)。


進捗の保存と再開

大量のリストの場合は、エンリッチを中断後に再開できるように、各フェーズの終了後に進捗を JSON ファイルに保存します。

進捗ファイルの形式
{
  "phase_completed": 3,
  "domain_map": {
    "acme.com": {"name": "Acme Corp"},
    "stanford.edu": {"name": "Stanford University"}
  },
  "results": {
    "[email protected]": {
      "name": "John Smith",
      "headline": "VP Engineering at Acme",
      "company": "Acme Corp",
      "method": "person_enrich"
    }
  },
  "unmatched_emails": ["[email protected]"]
}
再開ロジック

開始時に、進捗ファイルが存在するかどうかを確認します。存在する場合は、完了済みのフェーズをスキップし、中断した箇所から続行します。


出力

CSV 出力

次の列を含む CSV を生成します。

列説明
email元のメールアドレス
categorywork、edu、または personal
person_name人物のフルネーム
person_headline役職/ヘッドライン
company_name会社または機関の名前
profile_urlプロフィールの URL
method一致が見つかったフェーズ:person_enrich、name+company、name_search、web_search
集計統計

最後に集計を出力します。

=== Email Enrichment Results ===
Total emails: {N}
  Work:     {W}  | Person: {P1} ({P1%})  | Company: {C1} ({C1%})
  Edu:      {E}  | Person: {P2} ({P2%})  | Company: {C2} ({C2%})
  Personal: {R}  | Person: {P3} ({P3%})  | Company: {C3} ({C3%})
  Overall:  {N}  | Person: {PT} ({PT%})  | Company: {CT} ({CT%})

Breakdown by method:
  Phase 2 (person_enrich):    {count}
  Phase 2B (batch_identify):  {count}
  Phase 3 (name+company):     {count}
  Phase 4 (name_search):      {count}
  Phase 5 (web_search):       {count}

判定フローチャート

For each email:
|
+-- Classify: work / edu / personal
|
+-- Phase 1: Is it work or edu?
|   +-- Yes -> Extract domain -> company_identify({ domains: [...] })
|   |   +-- Found company? -> Store in domain_map
|   |   +-- Not found? -> Continue (no company info for this domain)
|   +-- No (personal) -> Continue to Phase 2 Branch B
|
+-- Phase 2: Branch by email type
|   |
|   +-- Work/edu (Branch A):
|   |   +-- person_enrich({ business_emails: [email], fields: ["basic_profile","experience"] })
|   |   +-- Found person? -> Post-verify (employer domain + name prefix + AI correction)
|   |   |   +-- Verified? -> DONE (method=person_enrich)
|   |   |   +-- Failed verification? -> Continue to Phase 3
|   |   +-- Not found? -> Continue to Phase 3
|   |
|   +-- Personal (Branch B): batch_person_identify({ emails: [...] }) -> poll batch_job_get -> batch_results
|       +-- Matched? -> DONE (method=batch_identify)
|       +-- Not matched? -> Continue to Phase 4 (name search) then Phase 5 (web search)
|
+-- Phase 3: Is it work/edu AND have company name AND name parts?
|   +-- Yes -> person_search(filters: basic_profile.name + experience.employment_details.current.company_name)
|   |   +-- Found + name verified? -> Phase 6 scoring gate -> DONE (method=name+company)
|   |   +-- Not found? -> Continue to Phase 4
|   +-- No -> Skip to Phase 4
|
+-- Phase 4: Is it personal AND has 2+ name parts?
|   +-- Yes -> person_search(filters: basic_profile.name="FirstName LastName")
|   |   +-- 1-3 results returned + name verified? -> Phase 6 scoring gate -> DONE (method=name_search)
|   |   +-- 0 or 4+ results? -> Continue to Phase 5
|   +-- No -> Continue to Phase 5
|
+-- Phase 5: Still unmatched? (any email type)
|   +-- web_search_live({ query: "EMAIL linkedin", sources: ["web"] })
|   |   +-- Found linkedin.com/in/ URL? -> person_enrich({ professional_network_profile_urls: [URL] }) -> Phase 6 scoring gate -> DONE
|   +-- No URL found? -> web_search_live({ query: "who is EMAIL", sources: ["ai"] })
|   |   +-- Extracted person name? -> person_search(name) -> get profile URL -> person_enrich -> Phase 6 scoring gate -> DONE
|   +-- Nothing found? -> UNMATCHED
|
+-- Phase 6: Scoring gate (applied to all candidates from Phases 3-5)
    +-- Requires BOTH: name_sim > 0.8 AND combined_score > 0.7
    +-- Pass? -> Accept match
    +-- Fail? -> Reject, continue to next phase or mark UNMATCHED

主な学び

  1. 検証は必須です。 1,476 件のメールをテストしたところ、厳密な検証によって、未検証のアプローチでは返されていた偽陽性を 149 件除外できました。必ず検証してください。

  2. フェーズ 2 の事後検証で、誤った一致の約 5%を検出できます。 person enrich API は、会社は正しいものの人物が違う結果(例:別の従業員)を返すことがあります。雇用主のドメインと名前の先頭部分を確認すれば検出できます。

  3. 詳細調査モードでの AI による修正は有効です。 person enrich が会社は正しいものの人物が違う結果を返した場合、sources: ["ai"] とクエリ「who is EMAIL」を指定した web_search_live によって、実際の人物を正しく特定できます。テストでは 7 件の一致を取り戻せました。

  4. 検証なしで名前や部分一致の候補を使うと、偽陽性率が非常に高くなります。 テストでは、却下が 1,540 件だったのに対し、受け入れは 37 件でした。名前の部分一致では、誤った一致が多数発生します。名前と会社の厳密な検証が不可欠です。

  5. 個人メールからの逆引きは非同期のみです。 person_enrich には個人メールの識別子がなく、person_search にも emails フィルターの field がありません。直接使える方法は batch_person_identify のみです(ジョブを送信し、batch_job_get でポーリングして、batch_results を読み取ります)。一致しないメールは、名前ベースのフェーズ 4/5 にフォールバックします。

  6. 仕事用/教育機関のメールでは、会社が特定できなければ一致なしです。 フェーズ 1 でドメインの会社を特定できなかった場合、そのドメインのメールに対する名前検索結果を受け入れないでください。照合対象がありません。

  7. フェーズ 4 では、結果数だけでなく名前も検証する必要があります。 結果が 3 件以下だからという理由だけで最初の結果を受け入れると、「Bert Zacharin」が「Zacharie Bere」に一致するような偽陽性が発生します。メールアドレスに含まれる名前の両方の部分が、プロフィール名に含まれている必要があります。

  8. 教育機関のメールでも person_enrich は利用できます。 識別子の名前は business_emails ですが、大学の教職員にも一致します。

  9. person_enrich は 1 回の呼び出しにつき 1 種類の識別子しか受け付けません。professional_network_profile_urls または business_emails のどちらか一方を渡し、両方は渡さないでください。どちらも配列(≤25)です。料金を支払う前に、preview: true を使ってクレジット不要の基本プロフィール確認を行ってください。

  10. person_search の結果は r.data.profiles に含まれます(また、すべてのデータはまず r.data の下にあります)。呼び出しに失敗してもスクリプトは中断されません。必ず r.ok(または unwrap)に基づいて分岐してください。失敗を確認しないと、「結果なし」のように見えてしまいます。

  11. (.) は正規表現の選択ではなく、大文字小文字を区別しないリテラルの部分文字列です。 "a|b" のようなパイプ区切り値は人物検索で何にも一致しません。N 個の値には any_of(field, [...])/in_ を使ってください。フィルターは field に適用し、レスポンスは Returns: に記載されたパスから読み取ってください(例:フィルターに使った experience.… フィールドではなく、social_handles.professional_network_identifier.profile_url)。

  12. フェーズ 6 では name_sim > 0.8 と combined_score > 0.7 の両方が必須です。 これにより、会社の完全一致で名前の不一致を補えてしまうことを防ぎます。name_sim の必須条件がないと、「Bert Zacharin」が「Zacharie Bere」に一致するような偽陽性がすり抜ける可能性があります。



人物からメールアドレスへのエンリッチメント

入力が人物(名前、プロフィール URL、またはその両方)のリストで、メールアドレスを見つけることが目的の場合は、代わりにこちらのフローを使用してください。


ステップ 1:プロフィール URL を特定する

入力にプロフィール URL がすでに含まれている場合は、このステップをスキップしてください。

名前と会社名のみが指定されている場合は、まずプロフィール URL を特定してください。

// model query: resolve Person Name at Company Name to a profile URL
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Person Name" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "Company Name" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
if (!r.ok) return { error: r.message };
// profile URL lives at social_handles.professional_network_identifier.profile_url
return { profile_url: r.data.profiles?.[0]?.social_handles?.professional_network_identifier?.profile_url };

必要なプロフィール URL は social_handles.professional_network_identifier.profile_url にあります。

フォールバック: person DB で見つからない場合は、ウェブ検索を試してください。

// model query: find Person Name's profile URL on the web
const r = await callTool("web_search_live", {
  query: "Person Name Company site:linkedin.com/in/",
  sources: ["web"],
});
const hit = r.ok && (r.data.results ?? []).find((x) => /linkedin\.com\/in\//.test(x.url || ""));
return { profile_url: hit?.url ?? null };

最上位の結果からプロフィール URL を抽出してください。

よくある落とし穴
  • よくある名前: 必ず会社名または役職の情報を含めてください。「Michael Ma」だけではなく、「Michael Ma Liquid 2 Ventures」のように検索します。
  • 名前のバリエーション: 正式名と一般的な名前の両方を試してください。「William Drevno」と「Will Drevno」、「Robert」と「Bob」などです。
  • 最近の役職変更: 旧会社と新会社がわかっている場合は、両方の会社名で検索してください。

ステップ 2:ビジネスメールを取得する

professional_network_profile_urls は配列で、1 回の呼び出しにつき最大 25 件です。ビジネスメールを取得するには、contact フィールドグループをリクエストしてください。デフォルトでは返されません。

// model query: get business emails for these profile URLs
const r = await callTool("person_enrich", {
  professional_network_profile_urls: profileUrlBatch,   // ≤25 URLs
  fields: ["basic_profile", "contact"],                 // name + business_emails
});
if (!r.ok) return { error: r.message };
// r.data is an ARRAY of { matched_on, matches: [{ person_data }] }; map back via matched_on
return { results: r.data.map((rec) => ({
  profile_url: rec.matched_on,
  name: rec.matches?.[0]?.person_data?.basic_profile?.name,
  business_emails: (rec.matches?.[0]?.person_data?.contact?.business_emails ?? []).map((b) => b.email),
})) };
重要な詳細
  • professional_network_profile_urls は、1 回の呼び出しにつき最大 25 件のプロフィール URL を受け付けます(配列)。
  • ビジネスメールは contact フィールドグループ(contact.business_emails[].email)で返されます。fields で contact をリクエストしてください。
  • 1 回の呼び出しにつき、識別子の種類は 1 つだけ渡してください。ここでは professional_network_profile_urls を使用します。
  • レスポンスは配列です。各レコードの matched_on フィールドを使って、結果を入力 URL に対応付けてください。
大きなリストの処理

プロフィールが 25 件以上ある場合は、chunk(profileUrls, 25) で分割してから、次のように並列処理します:await parallelMap(batches, b => callTool("person_enrich", { professional_network_profile_urls: b, fields: ["basic_profile", "contact"] }))。トークン制限を超えないよう、return するのは投影した { profile_url, name, business_emails } のみとしてください。


ステップ 3:個人用メールアドレスと電話番号を取得する

contact フィールドグループをリクエストすると、business_emails、personal_emails、および phone_numbers が返されます。個人の連絡先情報を取得するには、このグループをリクエストしてください。料金を支払う前に、preview: true(クレジット消費 0)を使って、プロフィールに個人の連絡先情報があるか確認してください。preview: true はアカウントごとに利用可否が異なります。400 エラー「Preview feature is not available for your account」が返された場合、そのアカウントではプレビューを利用できません。その場合はプレビューを省略し、直接データを取得してください(基本クレジットを消費します)。

// model query: get personal emails and phone numbers for these profiles
const r = await callTool("person_enrich", {
  professional_network_profile_urls: profileUrlBatch,   // ≤25 URLs
  fields: ["basic_profile", "contact"],                 // personal_emails + phone_numbers
});
if (!r.ok) return { error: r.message };
return { results: r.data.map((rec) => {
  const c = rec.matches?.[0]?.person_data?.contact ?? {};
  return {
    profile_url: rec.matched_on,
    personal_emails: (c.personal_emails ?? []).map((e) => e.email ?? e),
    phone_numbers: c.phone_numbers ?? [],
  };
}) };
クレジットの使用量

person_enrich の料金は、追加項目ごとに加算されます。基本 1、ビジネスメール +1、個人用メールアドレス +2、電話番号 +2、開発者プラットフォーム +1 で、上限は 7 です。preview: true はクレジットを 0 消費します。したがって、ビジネスメールを含むプロフィールは 2 クレジット、個人用メールアドレスと電話番号を含むプロフィールは 5 クレジットです。アウトリーチにはビジネスメールをデフォルトで使用し、タスクで必要な場合にのみ個人用メールアドレスや電話番号をリクエストしてください。

アクセス

個人の連絡先情報の取得にはアクセス制限があります。すべてのアカウントで有効になっているわけではありません。personal_emails / phone_numbers が空で返された場合、この機能を有効にする必要がある可能性があります(まずプレビューで手軽に確認できます)。

ビジネスメールとの組み合わせ

contact グループはビジネスメール、個人用メールアドレス、電話番号を 1 回の呼び出しでまとめて返すため、すべての情報を一度に取得できます。

// model query: get business + personal emails + phone for one profile
const r = await callTool("person_enrich", {
  professional_network_profile_urls: ["https://linkedin.com/in/person1"],
  fields: ["basic_profile", "contact"],
});
return r.ok ? { contact: r.data?.[0]?.matches?.[0]?.person_data?.contact } : { error: r.message };

ステップ 4:個人用メールアドレスを取得するためのコミット履歴の代替手段(技術職の人向け)

技術職の人について、個人の連絡先情報を取得できない場合や、結果が空の場合は、公開されているコミット履歴を代替手段として使用してください。これはエンジニア、開発者、技術系の創業者にのみ有効です。

GitHub ユーザー名を特定する

まず人物レコードを確認してください。social_handles には、プロフェッショナルネットワークの識別子に加えて、dev_platform_identifier(その人物の開発者ハンドル)が含まれます。また、person_enrich の fields に dev_platform_profiles(開発者向けアドオン、+1 クレジット)を追加すると、プロフィールと同じ呼び出しで、リポジトリ、組織のメンバーシップ、all_languages、場合によっては公開メールアドレスを含む開発者プロフィール全体が返されます。これらのグループを指定せずに人物を特定した場合は、単独の dev_platform_enrich を使用してください。crustdata_person_id または profile_url のどちらか1 つだけを指定します。以前のフェーズの各マッチ行には person_data.crustdata_person_id として crustdata_person_id が含まれているため、すでに特定済みの人物にはこれを使用してください。profile_url は GitHub URL(https://github.com/<username>)である必要があります。ここで LinkedIn URL を指定すると、400 エラーになります。このツールは、profile_url、name、bio、company_text、場合によっては公開メールアドレスを含む dev_platform_profiles[] を返します。

// model query: find this person's developer profile (and any public email)
const r = await callTool("dev_platform_enrich", {
  crustdata_person_id: PERSON_ID_FROM_EARLIER_PHASE, // person_data.crustdata_person_id
});
if (!r.ok) return { error: r.message };
// r.data.dev_platform_profiles[]: { profile_url, name, bio, company_text, email, ... }
return { dev_profiles: r.data.dev_platform_profiles ?? [] };

または、ウェブで検索してください。

// model query: find Person Name's GitHub profile
const r = await callTool("web_search_live", {
  query: "Person Name Company site:github.com",
  sources: ["web"],
});
return r.ok ? { results: r.data.results } : { error: r.message };
GitHub プロフィールを確認する

次の項目のうち、少なくとも 2 つが一致することを確認してください:自己紹介に会社名や役職が記載されている、プロフィール名が一致している、リポジトリのトピックが専門分野と一致している。dev_platform_enrich は、照合に使える name、bio、company_text を返します。dev_platform_profiles[].email にすでに値があり、noreply アドレスでない場合は、そのまま使用して、以下のコミット履歴の確認を省略できます。

コミットからメールアドレスを抽出する

dev_platform_profiles[].repos のエントリには、対象を選ぶために必要な full_name、is_fork、github_created_at がすでに含まれています。is_fork が false のリポジトリのうち、最も古いものを選んでください。追加の呼び出しは不要です。エンリッチメントでリポジトリが返されなかった場合に限り、web_enrich_live で一覧を取得してください(urls パラメーターは ARRAY です)。

// model query: list the user's oldest repos to find a non-fork repo
const r = await callTool("web_enrich_live", {
  urls: ["https://api.github.com/users/USERNAME/repos?sort=created&direction=asc&per_page=5"],
});
// r.data is [{ success, url, title, content }]; parse content[0] for repo names
return r.ok ? { content: r.data?.[0]?.content } : { error: r.message };

次に、コミットのメールアドレスを取得します。

// model query: read the commit patch to extract the author email
const r = await callTool("web_enrich_live", {
  urls: ["https://github.com/OWNER/REPO/commit/SHA.patch"],
});
return r.ok ? { content: r.data?.[0]?.content } : { error: r.message };

パッチ内の From: ヘッダーからメールアドレスを抽出してください。[email protected] および *@users.noreply.github.com のアドレスは除外してください。


人物からメールアドレスへの期待される結果

  • ビジネスメールアドレス:既知の企業に所属するプロフェッショナルの 95% 以上
  • 個人メールアドレス:個人の連絡先情報を有効にしたエンリッチメント API 経由で 95% 以上
  • 電話番号:個人の連絡先情報を有効にしたエンリッチメント API 経由で 95% 以上

Crustdata ツールリファレンス

すべてのツールは、execute({ code }) スクリプト内で callTool("<tool>", params) を介して呼び出します。

ツール用途主なパラメーター
company_identifyドメインから企業を特定(無料)domains: [...](配列)、names、任意の exact_match
person_enrichメールアドレスから人物を特定、またはプロフィール URL からメールアドレスを取得business_emails: [...] または professional_network_profile_urls: [...] のいずれか一方;fields: [...](グループ:basic_profile、contact、experience など);preview: true(0 クレジット)
person_searchフィルターで人物を検索filters({ op, conditions: [{ field, type, value }] })、limit、cursor、fields
web_search_liveウェブ検索(プロフィールの検索、詳細調査、「〜は誰か」の検索)query、sources(例:["web"]、["ai"])
web_enrich_liveページ内容を取得(コミットパッチ)urls: [...](配列)
dev_platform_enrich技術職の人物向け開発者プロフィール検索(Dev Platform)profile_url(GitHub URL)または crustdata_person_id

MCP サーバー:install.crustdata.com/mcp(Code Mode — メタツール list_tools、get_schema、execute)。


ツールの依存関係

このスキルを使用するには、install.crustdata.com/mcp に接続された Crustdata Code Mode MCP サーバーが必要です。このサーバーは、メタツール list_tools、get_schema、execute を公開します。データツールは、execute スクリプト内で callTool(...) を介して呼び出します。このスキルで使用するデータツール:

  • company_identify
  • person_enrich(リアルタイムのプロフィール URL エンリッチメントには person_enrich_live も使用)
  • person_search
  • web_search_live
  • web_enrich_live
  • dev_platform_enrich

コメント