Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 33 additions & 36 deletions src/qodev_apollo_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,21 +279,27 @@ async def find_contact_by_linkedin_url(
create_if_missing: bool = False,
contact_stage_id: str | None = None,
) -> str | None:
"""Find contact using 3-tier fallback strategy.
"""Find an existing contact by LinkedIn URL (2-tier lookup).

Strategy:
1. Search by LinkedIn URL (exact match)
2. Fallback to name search (if unique match)
3. People database search for auto-creation (if enabled)
1. Search existing contacts by LinkedIn URL (exact match).
2. Fall back to a name search among existing contacts (unique match whose
normalized URL equals the target).

Auto-creation from Apollo's people database (the former Step 3) is no
longer possible: ``/mixed_people/api_search`` returns teaser data only
(no ``linkedin_url``, an obfuscated last name, no email), so a URL match
can never succeed and there isn't enough data to create a usable contact.

Args:
linkedin_url: LinkedIn profile URL
person_name: Person's full name (for fallback search)
create_if_missing: Auto-create from people database if not found
contact_stage_id: Stage ID to assign when creating
person_name: Person's full name (for the fallback search)
create_if_missing: Deprecated no-op — retained for backwards
compatibility. Logs a warning when set; never creates a contact.
contact_stage_id: Deprecated no-op (was only used for auto-creation).

Returns:
Contact ID if found/created, None otherwise
Contact ID if found, None otherwise.
"""
normalized_url = normalize_linkedin_url(linkedin_url)

Expand Down Expand Up @@ -322,34 +328,18 @@ async def find_contact_by_linkedin_url(
# Ambiguous - multiple contacts with same name and URL
return None

# Step 3: People database search for auto-creation
# Auto-creation from the people database is no longer possible — Apollo's
# /mixed_people/api_search returns teaser data (no linkedin_url, obfuscated
# last name, no email), so a URL match can't be made nor a usable contact
# created. Warn instead of silently doing nothing.
if create_if_missing and person_name:
people_result = await self._post(
"/mixed_people/search",
{
"q_keywords": person_name,
"per_page": 10,
},
logger.warning(
"find_contact_by_linkedin_url: create_if_missing is no longer supported — "
"Apollo's people search returns teaser data without linkedin_url, so no "
"contact was created for %r. Use enrichment/reveal + create_contact instead.",
person_name,
)

people = people_result.get("people", [])
for person in people:
person_url = person.get("linkedin_url", "")
if person_url and normalize_linkedin_url(person_url) == normalized_url:
# Create contact from people database
create_data = {
"first_name": person.get("first_name", ""),
"last_name": person.get("last_name", ""),
"linkedin_url": person.get("linkedin_url"),
"title": person.get("title"),
"person_id": person.get("id"),
}
if contact_stage_id:
create_data["contact_stage_id"] = contact_stage_id

created = await self.create_contact(**create_data)
return created.id

return None

# ========================================================================
Expand Down Expand Up @@ -673,13 +663,20 @@ async def enrich_person(self, email: str) -> dict:
async def search_people(self, **filters) -> dict:
"""Search people in Apollo's global database.

Uses ``/mixed_people/api_search``; the older ``/mixed_people/search`` is
deprecated for API callers (returns 422). Note that this endpoint returns
**teaser data only** — ``first_name``, ``last_name_obfuscated``, ``title``
and ``organization``, but not full name / email / linkedin_url (those
require a separate enrichment/reveal step and consume credits).

Args:
**filters: Search filters (q_keywords, person_titles, person_locations, etc.)
**filters: Search filters (q_keywords, person_titles, person_seniorities,
person_locations, q_organization_domains_list, etc.).

Returns:
Search results dictionary
Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int).
"""
return await self._post("/mixed_people/search", filters)
return await self._post("/mixed_people/api_search", filters)

# ========================================================================
# NOTES
Expand Down
90 changes: 23 additions & 67 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1289,17 +1289,17 @@ async def test_enrich_person(client: ApolloClient):


async def test_search_people(client: ApolloClient):
"""Test POST /mixed_people/search."""
"""Test POST /mixed_people/api_search (the old /mixed_people/search is deprecated)."""
client._client.request.return_value = _make_response(
{"people": [{"id": "p1", "name": "Alice"}]}
{"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1}
)

result = await client.search_people(q_keywords="Alice")

assert result == {"people": [{"id": "p1", "name": "Alice"}]}
assert result == {"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1}

call_args = client._client.request.call_args
assert call_args[0] == ("POST", "/mixed_people/search")
assert call_args[0] == ("POST", "/mixed_people/api_search")
payload = call_args[1]["json"]
assert payload["q_keywords"] == "Alice"

Expand Down Expand Up @@ -1453,82 +1453,38 @@ async def test_find_by_linkedin_url_step2_ambiguous(client: ApolloClient):
assert result is None


async def test_find_by_linkedin_url_step3_create(client: ApolloClient):
"""Test people DB match → creates contact."""
async def test_find_by_linkedin_url_create_if_missing_is_noop(client: ApolloClient, caplog):
"""create_if_missing no longer auto-creates: Apollo's api_search returns teaser
data (no linkedin_url), so the former Step 3 is gone. It warns and returns None,
and never POSTs to /contacts."""
step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
# Step 2: Name search — no URL match
step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
# Step 3: People DB search
step3_response = _make_response(
{
"people": [
{
"id": "person_1",
"first_name": "Alice",
"last_name": "Smith",
"linkedin_url": "https://www.linkedin.com/in/alice",
"title": "CTO",
}
]
}
)
# Step 3: Create contact
step4_response = _make_response(
{"contact": {"id": "c_new", "first_name": "Alice", "last_name": "Smith"}}
)
client._client.request.side_effect = [
step1_response,
step2_response,
step3_response,
step4_response,
]

result = await client.find_contact_by_linkedin_url(
"https://www.linkedin.com/in/alice",
person_name="Alice Smith",
create_if_missing=True,
contact_stage_id="stage_1",
)
client._client.request.side_effect = [step1_response, step2_response]

assert result == "c_new"
with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
result = await client.find_contact_by_linkedin_url(
"https://www.linkedin.com/in/alice",
person_name="Alice Smith",
create_if_missing=True,
contact_stage_id="stage_1",
)

# Verify create_contact was called with correct data
create_call = client._client.request.call_args_list[3]
assert create_call[0] == ("POST", "/contacts")
payload = create_call[1]["json"]
assert payload["first_name"] == "Alice"
assert payload["last_name"] == "Smith"
assert payload["person_id"] == "person_1"
assert payload["contact_stage_id"] == "stage_1"
assert result is None
# Only the two lookup searches ran — no people-search POST, no contact creation.
assert client._client.request.call_count == 2
assert all(call[0][1] != "/contacts" for call in client._client.request.call_args_list)
assert any("create_if_missing is no longer supported" in r.message for r in caplog.records)


async def test_find_by_linkedin_url_not_found(client: ApolloClient):
"""Test all three steps fail → None."""
"""Both existing-contact lookups fail → None."""
step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
# Step 3: People DB — no URL match
step3_response = _make_response(
{
"people": [
{
"id": "person_1",
"first_name": "Bob",
"last_name": "Jones",
"linkedin_url": "https://www.linkedin.com/in/bob",
}
]
}
)
client._client.request.side_effect = [
step1_response,
step2_response,
step3_response,
]
client._client.request.side_effect = [step1_response, step2_response]

result = await client.find_contact_by_linkedin_url(
"https://www.linkedin.com/in/alice",
person_name="Alice Smith",
create_if_missing=True,
)

assert result is None
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading