11import axios from 'axios'
22
3- import { isEmail , replaceDoubleQuotes } from '@crowd/common'
3+ import { replaceDoubleQuotes } from '@crowd/common'
44import { Logger , LoggerBase } from '@crowd/logging'
55import {
66 IMemberEnrichmentCache ,
@@ -25,8 +25,8 @@ import {
2525import { normalizeAttributes , normalizeSocialIdentity } from '../../utils/common'
2626
2727import {
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