Skip to content

Commit 5105a20

Browse files
committed
feat: migrate crustdata to live person enrich API (CM-1354)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 1a92550 commit 5105a20

2 files changed

Lines changed: 145 additions & 116 deletions

File tree

services/apps/members_enrichment_worker/src/sources/crustdata/service.ts

Lines changed: 85 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios from 'axios'
22

3-
import { isEmail, replaceDoubleQuotes } from '@crowd/common'
3+
import { replaceDoubleQuotes } from '@crowd/common'
44
import { Logger, LoggerBase } from '@crowd/logging'
55
import {
66
IMemberEnrichmentCache,
@@ -25,8 +25,8 @@ import {
2525
import { normalizeAttributes, normalizeSocialIdentity } from '../../utils/common'
2626

2727
import {
28-
IMemberEnrichmentCrustdataAPIErrorResponse,
29-
IMemberEnrichmentCrustdataAPIResponse,
28+
IMemberEnrichmentCrustdataEnrichResponse,
29+
IMemberEnrichmentCrustdataPersonData,
3030
IMemberEnrichmentCrustdataRemainingCredits,
3131
IMemberEnrichmentDataCrustdata,
3232
} from './types'
@@ -51,38 +51,41 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
5151

5252
public attributeSettings: IMemberEnrichmentAttributeSettings = {
5353
[MemberAttributeName.AVATAR_URL]: {
54-
fields: ['profile_picture_url'],
54+
// Fallback order: stable permalink, then CDN url.
55+
fields: [
56+
'professional_network.profile_picture_permalink',
57+
'basic_profile.profile_picture_permalink',
58+
'professional_network.profile_picture_url',
59+
],
5560
},
5661
[MemberAttributeName.JOB_TITLE]: {
57-
fields: ['title'],
62+
fields: ['basic_profile.current_title'],
5863
},
5964
[MemberAttributeName.BIO]: {
60-
fields: ['summary', 'headline'],
65+
fields: ['basic_profile.summary', 'basic_profile.headline'],
6166
},
6267
[MemberAttributeName.SKILLS]: {
63-
fields: ['skills'],
64-
// Note: Crustdata API docs specify skills as string, but API returns string[]
65-
// So we're handling both cases in the transformer.
66-
transform: (skills: string | string[]) => {
68+
fields: ['skills.professional_network_skills'],
69+
transform: (skills: string[]) => {
6770
if (!skills) {
6871
return []
6972
}
7073

71-
const arr = Array.isArray(skills) ? skills : skills.split(',')
72-
73-
return arr
74+
return skills
7475
.map((s) => s.trim())
7576
.filter(Boolean)
7677
.sort()
7778
},
7879
},
7980
[MemberAttributeName.LANGUAGES]: {
80-
fields: ['languages'],
81-
transform: (languages: string[]) => languages.sort(),
81+
fields: ['basic_profile.languages'],
82+
transform: (languages: string[]) => (languages || []).sort(),
8283
},
8384
[MemberAttributeName.SCHOOLS]: {
84-
fields: ['all_schools'],
85-
transform: (schools: string[]) => schools.sort(),
85+
// Same school can appear multiple times (different degrees).
86+
fields: ['education.schools'],
87+
transform: (schools: Array<{ school?: string }>) =>
88+
[...new Set((schools || []).map((s) => s.school?.trim()).filter(Boolean))].sort(),
8689
},
8790
}
8891

@@ -134,16 +137,17 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
134137
try {
135138
const config = {
136139
method: 'get',
137-
url: `${process.env['CROWD_ENRICHMENT_CRUSTDATA_URL']}/user/credits`,
140+
url: `${process.env['CROWD_ENRICHMENT_CRUSTDATA_URL']}/account/credits`,
138141
headers: {
139-
Authorization: `Token ${process.env['CROWD_ENRICHMENT_CRUSTDATA_API_KEY']}`,
142+
Authorization: `Bearer ${process.env['CROWD_ENRICHMENT_CRUSTDATA_API_KEY']}`,
143+
'x-api-version': '2025-11-01',
140144
},
141145
}
142146

143147
const response: IMemberEnrichmentCrustdataRemainingCredits = (await axios(config)).data
144148

145-
// realtime linkedin enrichment costs 5 credits
146-
return response.credits > 5
149+
// Live enrich costs 7 credits per profile.
150+
return response.account.credits > 7
147151
} catch (error) {
148152
this.log.error('Error while checking Crustdata account usage', error)
149153
throw error
@@ -174,40 +178,47 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
174178

175179
private async getDataUsingLinkedinHandle(
176180
handle: string,
177-
): Promise<IMemberEnrichmentDataCrustdata | null> {
181+
): Promise<IMemberEnrichmentCrustdataPersonData | null> {
178182
const config = {
179-
method: 'get',
180-
url: `${process.env['CROWD_ENRICHMENT_CRUSTDATA_URL']}/screener/person/enrich`,
181-
params: {
182-
linkedin_profile_url: `https://linkedin.com/in/${encodeURIComponent(handle)}`,
183-
enrich_realtime: true,
184-
},
183+
method: 'post',
184+
url: `${process.env['CROWD_ENRICHMENT_CRUSTDATA_URL']}/person/professional_network/enrich/live`,
185185
headers: {
186-
Authorization: `Token ${process.env['CROWD_ENRICHMENT_CRUSTDATA_API_KEY']}`,
186+
Authorization: `Bearer ${process.env['CROWD_ENRICHMENT_CRUSTDATA_API_KEY']}`,
187+
'x-api-version': '2025-11-01',
188+
'content-type': 'application/json',
189+
},
190+
data: {
191+
professional_network_profile_urls: [`https://www.linkedin.com/in/${handle}`],
192+
// Default response is only basic_profile + social_handles.
193+
fields: [
194+
'basic_profile',
195+
'social_handles',
196+
'professional_network',
197+
'experience',
198+
'education',
199+
'skills',
200+
],
187201
},
188202
validateStatus: function (status) {
189-
return (status >= 200 && status < 300) || status === 404 || status === 422
203+
return (status >= 200 && status < 300) || status === 404
190204
},
191205
}
192206

193-
const response = await axios(config)
207+
const response = await axios<IMemberEnrichmentCrustdataEnrichResponse>(config)
194208

195-
if (response.status === 404 || response.status === 422) {
209+
if (response.status === 404) {
196210
this.log.debug({ source: this.source, handle }, 'No data found for linkedin handle!')
197211
return null
198212
}
199213

200-
if (response.data.length === 0 || this.isErrorResponse(response.data[0])) {
214+
// No match returns 200 with empty matches[].
215+
const match = response.data?.[0]?.matches?.[0]
216+
if (!match?.person_data) {
217+
this.log.debug({ source: this.source, handle }, 'No data found for linkedin handle!')
201218
return null
202219
}
203220

204-
return response.data[0]
205-
}
206-
207-
private isErrorResponse(
208-
response: IMemberEnrichmentCrustdataAPIResponse,
209-
): response is IMemberEnrichmentCrustdataAPIErrorResponse {
210-
return (response as IMemberEnrichmentCrustdataAPIErrorResponse).error !== undefined
221+
return match.person_data
211222
}
212223

213224
private async findDistinctScrapableLinkedinIdentities(
@@ -288,8 +299,8 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
288299
normalized = normalizeAttributes(data, normalized, this.attributeSettings, this.platform)
289300
normalized = this.normalizeEmployment(data, normalized)
290301

291-
if (data.num_of_connections) {
292-
normalized.reach[this.platform] = data.num_of_connections
302+
if (data.professional_network?.connections) {
303+
normalized.reach[this.platform] = data.professional_network.connections
293304
}
294305

295306
return normalized
@@ -307,52 +318,51 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
307318
normalized.attributes = {}
308319
}
309320

310-
if (data.name) {
311-
normalized.displayName = data.name
312-
}
313-
314-
if (data.email) {
315-
let emails: string[]
316-
317-
if (Array.isArray(data.email)) {
318-
emails = data.email
319-
} else {
320-
emails = data.email.split(',').filter(isEmail)
321-
}
322-
323-
for (const email of emails) {
324-
normalized.identities.push({
325-
type: MemberIdentityType.EMAIL,
326-
platform: this.platform,
327-
value: email.trim(),
328-
verified: false,
329-
source: 'enrichment',
330-
})
331-
}
321+
if (data.basic_profile?.name) {
322+
normalized.displayName = data.basic_profile.name
332323
}
333324

334-
if (data.twitter_handle) {
325+
// Crustdata social_handles use generic identifiers:
326+
// professional_network = LinkedIn, dev_platform = GitHub.
327+
const twitterHandle = data.social_handles?.twitter_identifier?.slug
328+
if (twitterHandle) {
335329
normalized = normalizeSocialIdentity(
336330
{
337-
handle: data.twitter_handle,
331+
handle: twitterHandle,
338332
platform: PlatformType.TWITTER,
339333
},
340334
MemberIdentityType.USERNAME,
341335
normalized,
342336
)
343337
}
344338

345-
if (data.linkedin_flagship_url) {
339+
const linkedinUrl = data.social_handles?.professional_network_identifier?.profile_url
340+
if (linkedinUrl) {
346341
normalized = normalizeSocialIdentity(
347342
{
348-
handle: data.linkedin_flagship_url.split('/').pop(),
343+
handle: linkedinUrl.split('/').filter(Boolean).pop(),
349344
platform: PlatformType.LINKEDIN,
350345
},
351346
MemberIdentityType.USERNAME,
352347
normalized,
353348
)
354349
}
355350

351+
const githubUrl = data.social_handles?.dev_platform_identifier?.profile_url
352+
if (githubUrl) {
353+
const handle = githubUrl.split('/').filter(Boolean).pop()
354+
if (handle) {
355+
normalized = normalizeSocialIdentity(
356+
{
357+
handle,
358+
platform: PlatformType.GITHUB,
359+
},
360+
MemberIdentityType.USERNAME,
361+
normalized,
362+
)
363+
}
364+
}
365+
356366
return normalized
357367
}
358368

@@ -364,31 +374,30 @@ export default class EnrichmentServiceCrustdata extends LoggerBase implements IE
364374
normalized.memberOrganizations = []
365375
}
366376

367-
const employmentInformation = (data.past_employers || []).concat(data.current_employers || [])
377+
const employmentInformation = (data.experience?.employment_details?.past || []).concat(
378+
data.experience?.employment_details?.current || [],
379+
)
368380
if (employmentInformation.length > 0) {
369381
for (const workExperience of employmentInformation) {
370382
const identities = []
371383

372-
if (workExperience.employer_linkedin_id) {
384+
if (workExperience.professional_network_id) {
373385
identities.push({
374386
platform: PlatformType.LINKEDIN,
375-
value: `company:${workExperience.employer_linkedin_id}`,
387+
value: `company:${workExperience.professional_network_id}`,
376388
type: OrganizationIdentityType.USERNAME,
377389
verified: true,
378390
source: 'enrichment',
379391
})
380392
}
381393

382394
normalized.memberOrganizations.push({
383-
name: replaceDoubleQuotes(workExperience.employer_name),
395+
name: replaceDoubleQuotes(workExperience.name),
384396
source: OrganizationSource.ENRICHMENT_CRUSTDATA,
385397
identities,
386-
title: replaceDoubleQuotes(workExperience.employee_title),
398+
title: replaceDoubleQuotes(workExperience.title),
387399
startDate: workExperience?.start_date ?? null,
388400
endDate: workExperience?.end_date ?? null,
389-
organizationDescription: replaceDoubleQuotes(
390-
workExperience.employer_linkedin_description,
391-
),
392401
})
393402
}
394403
}

0 commit comments

Comments
 (0)