File size: 28,319 Bytes
6fb7573
 
 
93aa512
 
 
 
 
 
 
 
 
 
6fb7573
93aa512
 
 
 
 
 
 
 
 
 
 
0d08056
 
 
 
 
 
 
 
76a6493
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0eafe5
 
 
 
 
 
 
 
6fb7573
 
 
 
 
 
18ed4d9
6fb7573
 
175af2a
 
 
 
 
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1056d7
 
 
 
 
 
 
6fb7573
 
 
 
a0eafe5
 
88800df
 
 
 
 
 
 
 
 
a0eafe5
 
 
88800df
 
 
a0eafe5
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0eafe5
 
b57921d
18ed4d9
 
6fb7573
 
 
 
 
 
175af2a
 
6fb7573
 
 
175af2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18ed4d9
 
 
0d08056
 
 
 
 
 
18ed4d9
 
0d08056
18ed4d9
 
 
6fb7573
18ed4d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68b2300
 
 
 
18ed4d9
68b2300
 
d42c579
 
 
 
68b2300
18ed4d9
d42c579
 
 
a5d1486
18ed4d9
 
68b2300
d42c579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68b2300
18ed4d9
 
68b2300
 
 
 
 
c3a9e33
 
 
 
 
 
 
 
 
 
 
 
 
 
68b2300
18ed4d9
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b57921d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
93aa512
 
 
 
 
 
 
 
 
6fb7573
93aa512
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1056d7
 
 
 
 
 
6fb7573
 
 
a1056d7
 
 
 
 
6fb7573
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
// Pure normalization functions β€” run ONCE at scrape/ingestion time,
// so the read path (jobs/courses pages) serves already-clean rows from the DB.

/**
 * Job-board marks come from Google's favicon service.
 *
 * These used to be logo.clearbit.com URLs. That host no longer resolves at all
 * (ERR_NAME_NOT_RESOLVED), so every card on the jobs page fired a failed image
 * request β€” dozens of console errors per page load and a broken mark on each
 * row. The favicon service is plain, always-available, and needs no key.
 */
const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;

export const SOURCE_LOGOS: Record<string, string> = {
  'adzuna':        favicon('adzuna.com'),
  'rozee.pk':      favicon('rozee.pk'),
  'jsearch':       favicon('indeed.com'),
  'indeed':        favicon('indeed.com'),
  'linkedin':      favicon('linkedin.com'),
  'glassdoor':     favicon('glassdoor.com'),
  'bayt':          favicon('bayt.com'),
  'naukri':        favicon('naukri.com'),
  'monster':       favicon('monster.com'),
  'ziprecruiter':  favicon('ziprecruiter.com'),
  'jooble':        favicon('jooble.org'),
  // Free whole-board remote sources + ATS (direct-employer) boards.
  'himalayas':     favicon('himalayas.app'),
  'remotive':      favicon('remotive.com'),
  'jobicy':        favicon('jobicy.com'),
  'arbeitnow':     favicon('arbeitnow.com'),
  'remoteok':      favicon('remoteok.com'),
  'greenhouse':    favicon('greenhouse.io'),
  'lever':         favicon('lever.co'),
  'workingnomads': favicon('workingnomads.com'),
  'weworkremotely': favicon('weworkremotely.com'),
};

export const PLACEHOLDER_LOGO = 'https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=';

export const PLATFORM_LOGOS: Record<string, string> = {
  'edx':      'https://www.edx.org/favicon.ico',
  'youtube':  'https://www.youtube.com/favicon.ico',
  'coursera': 'https://www.coursera.org/favicon.ico',
  'udemy':    'https://www.udemy.com/favicon.ico',
};

export const PLATFORM_THUMBNAILS: Record<string, string> = {
  'edx':      'https://www.edx.org/images/logos/edx-logo-elm.svg',
  'youtube':  'https://www.youtube.com/img/desktop/yt_1200.png',
  'coursera': 'https://d3njjcbhbojbot.cloudfront.net/api/utilities/v1/imageproxy/https://coursera.s3.amazonaws.com/media/coursera-logo-square.png',
  'udemy':    'https://www.udemy.com/staticx/udemy/images/v7/logo-udemy.svg',
};

const COURSE_PLACEHOLDER_LOGO = 'https://ui-avatars.com/api/?background=6366f1&color=fff&name=';

export interface NormalizedJob {
  title: string;
  company: string;
  companyLogo: string;
  location: string;
  country: string;
  salaryMin: string;
  salaryMax: string;
  /**
   * Numeric mirrors of the salary strings, or null when the board didn't say.
   * The strings stay authoritative for display ("Not specified", currency prose
   * from some boards); these exist so salary filtering can happen in SQL instead
   * of pulling every row into Node to `parseFloat` it.
   */
  salaryMinValue: number | null;
  salaryMaxValue: number | null;
  jobType: string;
  experienceLevel: string;
  category: string;
  source: string;
  sourceLogo: string;
  description: string;
  geoRestriction: string;
  applyUrl: string;
  scrapedAt: string;
  /**
   * Board-reported posting date and offer expiry, ISO strings or null when the
   * board didn't say. `expiresAt` is an explicit death signal we honour without
   * fetching anything (Phase 0 liveness); currently only JSearch provides it.
   */
  postedAt: string | null;
  expiresAt: string | null;
}

export interface NormalizedCourse {
  title: string;
  instructor: string;
  platform: string;
  platformLogo: string;
  category: string;
  level: string;
  duration: string;
  price: string;
  rating: string;
  language: string;
  description: string;
  courseUrl: string;
  thumbnail: string;
  scrapedAt: string;
  source: string;
  // Structured stats (YouTube playlists; 0/'' when the platform doesn't provide them)
  totalVideos: number;
  totalDurationMinutes: number;
  avgVideoMinutes: number;
  viewCount: number;
  likeCount: number;
  lastUpdatedAt: string;
}

// ─── Jobs ─────────────────────────────────────────────────────────────────────

/**
 * Pull a number out of whatever the board reported. Boards send "85000",
 * "Β£45,000", "Not specified", or nothing at all.
 *
 * Only currency symbols, separators and whitespace are stripped, and what
 * remains must be a number END TO END. Stripping every non-digit instead looks
 * tempting but silently fabricates figures: "$30 - $130/hour" collapses to
 * 30130 and "USD 50k" to 50, both of which then satisfy salary filters they
 * have no business matching. A salary we cannot read is unknown, and unknown
 * has to mean null β€” a wrong number is worse than no number, because the filter
 * trusts it.
 */
function parseSalaryValue(raw: unknown): number | null {
  if (raw === null || raw === undefined) return null;
  const cleaned = String(raw).replace(/[\s,$£€]/g, '');
  if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
  const value = parseFloat(cleaned);
  return Number.isFinite(value) ? value : null;
}

export function normalizeJob(raw: any, source: string): NormalizedJob | null {
  const applyUrl = raw.apply_url;
  if (!applyUrl || !String(applyUrl).startsWith('http')) return null;

  const company = raw.company || 'Not specified';
  const resolvedSource = (raw.source === 'jsearch' || source === 'jsearch')
    ? extractJsearchSource(applyUrl)
    : (raw.source || source);

  return {
    title:           raw.title || 'Not specified',
    company,
    companyLogo:     getCompanyLogo(company),
    location:        raw.location || raw.city || 'Not specified',
    country:         normalizeCountry(raw.country, raw.location),
    salaryMin:       String(raw.salary_min ?? raw.salary ?? 'Not specified'),
    salaryMax:       String(raw.salary_max ?? 'Not specified'),
    salaryMinValue:  parseSalaryValue(raw.salary_min ?? raw.salary),
    salaryMaxValue:  parseSalaryValue(raw.salary_max),
    jobType:         resolveJobType(raw),
    experienceLevel: resolveExperienceLevel(raw, resolvedSource),
    geoRestriction:  classifyGeoRestriction(raw),
    category:        normalizeCategory(raw.category, raw.title),
    source:          resolvedSource,
    sourceLogo:      SOURCE_LOGOS[resolvedSource] || PLACEHOLDER_LOGO + resolvedSource,
    description:     cleanDescription(raw.description),
    applyUrl:        String(applyUrl),
    scrapedAt:       raw.scraped_at || new Date().toISOString().split('T')[0],
    postedAt:        parseIsoOrNull(raw.posted_at),
    expiresAt:       parseIsoOrNull(raw.expires_at),
  };
}

/**
 * Parse a board-supplied date into an ISO string, or null if absent/garbage.
 * Accepts ISO datetimes ("2026-07-25T12:00:00Z"), unix seconds, and plain
 * dates. A value we can't trust becomes null rather than a fabricated date β€”
 * `expiresAt` gates whether a job is shown, so a wrong date would hide (or keep)
 * the wrong jobs.
 */
function parseIsoOrNull(raw: unknown): string | null {
  if (raw === null || raw === undefined || raw === '') return null;
  if (typeof raw === 'number' || /^\d{9,13}$/.test(String(raw))) {
    const n = Number(raw);
    // Heuristic: 10-digit = seconds, 13-digit = ms.
    const ms = String(raw).length <= 10 ? n * 1000 : n;
    const d = new Date(ms);
    return Number.isNaN(d.getTime()) ? null : d.toISOString();
  }
  const d = new Date(String(raw));
  return Number.isNaN(d.getTime()) ? null : d.toISOString();
}

function cleanDescription(raw: any): string {
  if (!raw || typeof raw !== 'string') return '';
  return raw
    .replace(/<[^>]+>/g, ' ')       // strip any HTML tags
    .replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/[ \t]+/g, ' ')
    .trim()
    .slice(0, 5000);
}

function extractJsearchSource(applyUrl: string): string {
  if (!applyUrl) return 'jsearch';
  const url = applyUrl.toLowerCase();
  if (url.includes('linkedin.com'))  return 'linkedin';
  if (url.includes('glassdoor.com')) return 'glassdoor';
  if (url.includes('indeed.com'))    return 'indeed';
  if (url.includes('bayt.com'))      return 'bayt';
  if (url.includes('rozee.pk'))      return 'rozee.pk';
  if (url.includes('naukri.com'))    return 'naukri';
  if (url.includes('monster.com'))   return 'monster';
  if (url.includes('ziprecruiter'))  return 'ziprecruiter';
  if (url.includes('jooble'))        return 'jooble';
  if (url.includes('jobgether'))     return 'jobgether';
  if (url.includes('jobrapido'))     return 'jobrapido';
  if (url.includes('expertini'))     return 'expertini';
  return 'jsearch';
}

function normalizeCountry(country: string, location: string): string {
  if (country && country.length > 2) return country;
  const codeMap: Record<string, string> = {
    'PK': 'Pakistan',       'IN': 'India',         'US': 'United States',
    'GB': 'United Kingdom', 'AU': 'Australia',     'CA': 'Canada',
    'DE': 'Germany',        'SG': 'Singapore',     'AE': 'United Arab Emirates',
  };
  const code = (country || '').toUpperCase();
  if (codeMap[code]) return codeMap[code];
  return inferCountry(location);
}

function inferCountry(location: string): string {
  if (!location) return 'Not specified';
  const l = location.toLowerCase();
  if (l.includes('pakistan') || l.includes('lahore') || l.includes('karachi') ||
      l.includes('islamabad') || l.includes('rawalpindi') || l.includes('faisalabad')) return 'Pakistan';
  if (l.includes('united states') || l.includes(', us') || l.includes(', ny') ||
      l.includes(', ca') || l.includes(', tx')) return 'United States';
  if (l.includes('united kingdom') || l.includes(', uk') || l.includes('london')) return 'United Kingdom';
  if (l.includes('australia') || l.includes('sydney') || l.includes('melbourne')) return 'Australia';
  if (l.includes('canada') || l.includes('toronto') || l.includes('vancouver')) return 'Canada';
  if (l.includes('india') || l.includes('mumbai') || l.includes('bangalore')) return 'India';
  if (l.includes('germany') || l.includes('berlin') || l.includes('munich')) return 'Germany';
  return 'Not specified';
}

// Level inference for EVERY source: declared value first, then title keywords,
// then years-of-experience mentions in the description. Adzuna never declares a
// level, so without this all its jobs were unfilterable "Not specified".
// Sources whose declared experience level is reliable enough to trust directly.
// himalayas/jobicy expose a real seniority field (mapped to our vocabulary in the
// scraper); jsearch et al. slap "Entry level" on everything, so theirs is ignored
// in favour of title/description inference below.
const TRUSTED_LEVEL_SOURCES = new Set(['adzuna', 'rozee.pk', 'himalayas', 'jobicy']);

function resolveExperienceLevel(raw: any, source: string): string {
  const declared = raw.experience_level;
  if (!TRUSTED_LEVEL_SOURCES.has(source)) {
    // jsearch et al. often lie ("Entry level" on everything) β€” ignore declared
  } else if (declared && declared !== 'Not specified') {
    return declared;
  }

  const t = String(raw.title || '').toLowerCase();
  if (t.match(/\bjunior\b|\bentry\b|graduate|\bintern(ship)?\b|trainee/)) return 'Entry Level';
  if (t.match(/\bsenior\b|\bsr\.?\b|lead|principal|staff/))               return 'Senior';
  if (t.match(/\bmanager\b|director|vp |head of/))                        return 'Management';
  if (t.match(/\bmid\b|intermediate|associate/))                          return 'Mid Level';

  // Fall back to "N years experience" statements in the description
  const desc = String(raw.description || '').slice(0, 2000).toLowerCase();
  const yearsMatch = desc.match(/(\d{1,2})\s*(?:-\s*\d{1,2})?\s*\+?\s*(?:years?|yrs?)(?:\s+of)?\s+(?:\w+\s+){0,3}experience/);
  if (yearsMatch) {
    const years = parseInt(yearsMatch[1], 10);
    if (years <= 1) return 'Entry Level';
    if (years <= 4) return 'Mid Level';
    return 'Senior';
  }
  if (desc.match(/\bentry[- ]level\b|no experience (necessary|required)|fresh graduate/)) return 'Entry Level';

  return 'Not specified';
}

// Remote jobs often quietly require living in a specific country or holding
// citizenship/work authorization β€” classify that so users can filter out jobs
// they can't actually take. Priority: citizenship signals > explicit in-country
// wording > explicit worldwide wording > structural inference (a remote job
// advertised for one specific country is normally restricted to it).
function classifyGeoRestriction(raw: any): string {
  const location = String(raw.location || raw.city || '').toLowerCase();
  const desc = String(raw.description || '').slice(0, 4000).toLowerCase();
  // The title carries the restriction surprisingly often ("Senior DevOps
  // Engineer (US REMOTE)") and used to be ignored entirely.
  const title = String(raw.title || '').toLowerCase();
  const text = `${title} ${location} ${desc}`;
  const isRemote = resolveJobType(raw) === 'Remote';

  // Punctuation-tolerant: postings write "Visa Sponsorship: Not Available" as
  // often as "no visa sponsorship", and the colon form used to slip through and
  // get labelled 'Anywhere'.
  if (/\b(us|u\.s\.|uk|eu|canadian|australian)? ?citizen(ship)?\b.{0,40}(required|only|must)|security clearance|green card|must (be (legally )?authori[sz]ed|have (the )?(legal )?right) to work|authori[sz]ed to work\b|eligible to work in|\busc\b[^.]{0,20}\bgc\b|work authori[sz]ation required|work permit required|(no|without) (visa )?sponsorship|sponsorship\W{0,4}(is\W{0,3})?not\W{0,4}(available|offered|provided|supported)|unable to (provide|offer) sponsorship|no c2c/.test(text)) {
    return 'Citizenship/visa required';
  }

  // US payroll classifications. A role offered on W2 or 1099 is administered
  // under US tax law and in practice needs US work authorization, even when the
  // posting never mentions visas β€” this was the single biggest source of
  // wrongly-'Anywhere' jobs.
  if (/\bw-?2\b|\b1099\b|\bc2c\b|corp[- ]to[- ]corp/.test(text)) {
    return 'Citizenship/visa required';
  }

  // "Remote (United States)" / "Remote - US" / "(US REMOTE)" bind a remote role
  // to one country without the "must reside in" phrasing the next test needs.
  if (/remote\s*[\(\[\-–—:,]\s*(the )?(usa?|u\.s\.|united states|uk|united kingdom|canada|australia|india|pakistan|germany|netherlands|eu|europe)\b|\b(usa?|uk|eu)[\s\-]remote\b|remote[\s\-](usa?|uk|eu)\b/.test(text)) {
    return 'Country-restricted';
  }

  // "Remote (EST/CST only)" β€” a US-timezone requirement is a residency
  // requirement in practice, however the posting phrases it.
  // Either an explicit "only", or a timezone qualifying the word remote β€”
  // "Remote (EST/CST)" is a residency requirement without ever saying "only".
  if (/\b(est|cst|pst|mst|edt|cdt|pdt)\b[^.]{0,30}\bonly\b|\bonly\b[^.]{0,30}\b(est|cst|pst|mst)\b|must (work|be available).{0,30}\b(est|cst|pst|mst)\b|remote\s*[\(\[\-–—:,][^)\].]{0,25}\b(est|cst|pst|mst|edt|cdt|pdt)\b/.test(text)) {
    return 'Country-restricted';
  }

  if (/remote.{0,25}\b(in|within|from|across)\b.{0,35}\b(usa?|united states|uk|united kingdom|europe|eu|canada|australia|india|pakistan|germany|netherlands)\b|only (candidates|applicants) (in|based in|located in|residing in|from)|must (reside|be located|be based|live|currently reside) in|within the (us|uk|eu|usa)|candidates? must be (in|from|based)|residents? of (the )?(us|uk|eu|usa)|\b(us|uk|eu)[- ]based (candidates|applicants|only)|local candidates only/.test(text)) {
    return 'Country-restricted';
  }

  if (/work from anywhere|anywhere in the world|(worldwide|globally) (remote|distributed|team)|remote (worldwide|globally)|fully distributed|remote[- ]first|100% remote.{0,40}(anywhere|global)|remote \((global|worldwide|anywhere)\)|open to (all|any) (locations?|countries|regions)|open to candidates (from )?(anywhere|worldwide|globally|all countries)|international (applicants|candidates).{0,20}welcome|location:?\s*(anywhere|global|worldwide|flexible)|work from any (location|country)|(emea|apac|latam|americas).{0,15}(and|,|\/).{0,15}(emea|apac|latam|americas)/.test(text)) {
    return 'Anywhere';
  }

  // A remote posting that states no citizenship, work-authorization or
  // residency requirement is treated as open, whatever market it was found in.
  //
  // This used to downgrade any remote job with a country attached to
  // 'Country-restricted (likely)'. But the country on a scraped job records
  // which market the scrape searched, not a restriction β€” a country-scoped
  // scrape stamps one on every single job, so 'Anywhere' became unreachable and
  // the "Work from anywhere" filter could never match anything.
  //
  // "US job + work from anywhere" is a real and common combination: the role is
  // hired for the US market but done from abroad with no authorization needed.
  // The checks above already catch the postings that do state a requirement, so
  // reaching this point means nothing was asked for.
  if (isRemote) return 'Anywhere';

  return 'Not specified';
}

function normalizeCategory(raw: string, title: string): string {
  const JUNK_CATEGORIES = [
    'linkedin', 'indeed', 'glassdoor', 'bayt', 'rozee', 'workable',
    'careers', 'jobs', 'smartrecruiters', 'jobgether', 'bebee', 'startup',
    'expertini', 'jobrapido', 'yulys', 'jaabz', 'manatal', 'spglobal',
    's&p', 'cbre', 'pepsico', 'acca', 'wizbii', 'zones', 'terra',
    'unjobnet', 'jobleads', 'crossover', 'kaispe', 'getpakjob', 'afridi',
    'webnet', 'connectx', 'sperton', 'estm', 'oraclecloud', 'ace money',
    'jazzkr', 'jazzhr', 'leading digital',
  ];

  const CATEGORY_MAP: Record<string, string> = {
    'software engineer': 'Technology',           'senior software engineer': 'Technology',
    'lead software engineer': 'Technology',      'web developer': 'Technology',
    'typescript developer': 'Technology',        'devops engineer': 'Technology',
    'cloud engineer': 'Technology',              'mobile developer': 'Technology',
    'frontend developer': 'Technology',          'backend developer': 'Technology',
    'full stack developer': 'Technology',        'database administrator': 'Technology',
    'blockchain developer': 'Technology',        'data scientist': 'Data & AI',
    'machine learning engineer': 'Data & AI',    'ai engineer': 'Data & AI',
    'ai/ml engineer': 'Data & AI',               'data analyst': 'Data & AI',
    'cybersecurity analyst': 'Cybersecurity',    'fintech software engineer': 'Finance & Accounting',
    'senior fintech developer': 'Finance & Accounting', 'finance tech lead': 'Finance & Accounting',
    'finance software engineer': 'Finance & Accounting', 'ai engineer finance': 'Finance & Accounting',
    'finance manager': 'Finance & Accounting',   'accountant': 'Finance & Accounting',
    'investment analyst': 'Finance & Accounting','financial analyst': 'Finance & Accounting',
    'business analyst': 'Product & Project Management', 'product manager': 'Product & Project Management',
    'project manager': 'Product & Project Management',  'supply chain manager': 'Operations',
    'operations manager': 'Operations',          'business development': 'Sales & Business Development',
    'sales manager': 'Sales & Business Development',    'account manager': 'Sales & Business Development',
    'digital marketing': 'Marketing',            'content marketing': 'Marketing',
    'seo specialist': 'Marketing',               'social media manager': 'Marketing',
    'marketing manager': 'Marketing',            'graphic designer': 'Design & Creative',
    'ui ux designer': 'Design & Creative',       'product designer': 'Design & Creative',
    'human resources manager': 'Human Resources','recruiter': 'Human Resources',
    'talent acquisition': 'Human Resources',     'customer service': 'Customer Service',
    'technical support': 'Customer Service',
  };

  if (!raw || raw === 'Not specified') return inferJobCategoryFromTitle(title);

  const rawLower = raw.toLowerCase().trim();
  if (CATEGORY_MAP[rawLower]) return CATEGORY_MAP[rawLower];

  const isJunk = JUNK_CATEGORIES.some(j => rawLower.includes(j));
  if (isJunk) return inferJobCategoryFromTitle(title);

  const looksLikeCompany =
    /\.(com|pk|org|net|io)$/i.test(raw) ||
    (/^[A-Z][a-z]+ [A-Z][a-z]+/.test(raw) &&
      raw.split(' ').length <= 3 &&
      !raw.toLowerCase().match(/manager|engineer|analyst|developer|designer|specialist|officer/));

  if (looksLikeCompany) return inferJobCategoryFromTitle(title);
  return raw;
}

function inferJobCategoryFromTitle(title: string): string {
  if (!title) return 'Not specified';
  const t = title.toLowerCase();
  if (t.match(/software|developer|engineer|frontend|backend|fullstack|full stack|devops|cloud|mobile|flutter|react|angular|vue|node|python|java|\.net/)) return 'Technology';
  if (t.match(/data\s*(scientist|analyst|engineer)|machine learning|ai |artificial|nlp|deep learning/)) return 'Data & AI';
  if (t.match(/cyber|security|infosec|penetration/)) return 'Cybersecurity';
  if (t.match(/product manager|project manager|scrum|agile|program manager/)) return 'Product & Project Management';
  if (t.match(/marketing|seo|content|social media|brand|growth/)) return 'Marketing';
  if (t.match(/sales|account manager|business development|bd /)) return 'Sales & Business Development';
  if (t.match(/finance|financial|accountant|accounting|audit|tax|investment|analyst/)) return 'Finance & Accounting';
  if (t.match(/fintech|fin tech/)) return 'Finance & Accounting';
  if (t.match(/hr |human resource|recruiter|talent|people ops/)) return 'Human Resources';
  if (t.match(/design|ui|ux|graphic|creative|figma|illustrat/)) return 'Design & Creative';
  if (t.match(/customer|support|service|helpdesk|success/)) return 'Customer Service';
  if (t.match(/operations|supply chain|logistics|procurement/)) return 'Operations';
  return 'General';
}

// Sources often misreport work mode (JSearch's is_remote flag misses jobs whose
// location or description plainly says "Remote"), so infer from text when the
// declared type carries no work-mode signal.
function resolveJobType(raw: any): string {
  const declared = normalizeJobType(raw.job_type);
  if (declared === 'Remote' || declared === 'Hybrid' || declared === 'Onsite') return declared;

  const location = String(raw.location || raw.city || '').toLowerCase();
  const title = String(raw.title || '').toLowerCase();
  const desc = String(raw.description || '').slice(0, 1500).toLowerCase();

  if (location.includes('remote') || title.includes('remote') ||
      /\b(fully remote|100% remote|work from home|remote position|remote job|location:\s*remote|job type:\s*remote)\b/.test(desc)) {
    return 'Remote';
  }
  if (location.includes('hybrid') || title.includes('hybrid') || /\bhybrid\b/.test(desc)) {
    return 'Hybrid';
  }
  return declared;
}

function normalizeJobType(raw: string): string {
  if (!raw || raw === 'Not specified') return 'Not specified';
  const val = raw.toLowerCase().trim();
  if (val.includes('full'))      return 'Full Time';
  if (val.includes('part'))      return 'Part Time';
  if (val.includes('remote'))    return 'Remote';
  if (val.includes('hybrid'))    return 'Hybrid';
  if (val.includes('onsite') || val.includes('on-site') || val.includes('on site')) return 'Onsite';
  if (val.includes('contract'))  return 'Contract';
  if (val.includes('intern'))    return 'Internship';
  if (val.includes('permanent')) return 'Full Time';
  return raw;
}

/**
 * Company marks are generated initials, not fetched logos.
 *
 * This used to guess a domain from the company name and ask clearbit for its
 * logo. Two problems: that host is dead, and the guess was wrong far more often
 * than it was right β€” "Hire Feed" is not hirefeed.com. Initials on a tinted
 * square always render, never 404, and never mislabel a company with someone
 * else's brand.
 */
function getCompanyLogo(company: string): string {
  const name = !company || company === 'Not specified' ? '?' : company;
  return PLACEHOLDER_LOGO + encodeURIComponent(name);
}

// ─── Courses ──────────────────────────────────────────────────────────────────

export function normalizeCourse(raw: any): NormalizedCourse | null {
  const courseUrl = raw.course_url;
  if (!courseUrl || !String(courseUrl).startsWith('http')) return null;

  const source = (raw.source || 'unknown').toLowerCase();
  const platform = raw.platform || inferPlatform(source);

  return {
    title:        raw.title       || 'Not specified',
    instructor:   raw.instructor  || 'Not specified',
    platform,
    platformLogo: PLATFORM_LOGOS[source] || COURSE_PLACEHOLDER_LOGO + platform,
    category:     normalizeCourseCategory(raw.category, raw.title),
    level:        normalizeLevel(raw.level),
    duration:     raw.duration    || 'Not specified',
    price:        raw.price       || 'Not specified',
    rating:       String(raw.rating ?? 'Not specified'),
    language:     raw.language    || 'English',
    description:  raw.description || 'Not specified',
    courseUrl:    String(courseUrl),
    thumbnail:    resolveThumbnail(raw.thumbnail, source),
    scrapedAt:    raw.scraped_at  || new Date().toISOString().split('T')[0],
    source,
    totalVideos:          toInt(raw.total_videos),
    totalDurationMinutes: toInt(raw.total_duration_minutes),
    avgVideoMinutes:      toInt(raw.avg_video_minutes),
    viewCount:            toInt(raw.view_count),
    likeCount:            toInt(raw.like_count),
    lastUpdatedAt:        typeof raw.last_updated_at === 'string' ? raw.last_updated_at : '',
  };
}

function toInt(v: any): number {
  const n = Number(v);
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}

function resolveThumbnail(thumbnail: string, source: string): string {
  if (thumbnail && thumbnail.startsWith('http')) return thumbnail;
  return PLATFORM_THUMBNAILS[source] || COURSE_PLACEHOLDER_LOGO + source;
}

function inferPlatform(source: string): string {
  const map: Record<string, string> = {
    'edx': 'edX', 'youtube': 'YouTube', 'coursera': 'Coursera', 'udemy': 'Udemy',
  };
  return map[source] || source;
}

function normalizeLevel(raw: string): string {
  if (!raw || raw === 'Not specified') return 'Not specified';
  const val = raw.toLowerCase();
  if (val.includes('beginner') || val.includes('basic') || val.includes('introduct')) return 'Beginner';
  if (val.includes('intermediate') || val.includes('mid'))  return 'Intermediate';
  if (val.includes('advanced') || val.includes('expert'))   return 'Advanced';
  if (val.includes('all'))                                   return 'All Levels';
  return raw;
}

function normalizeCourseCategory(raw: string, title: string): string {
  if (!raw || raw === 'Not specified' || raw === 'General') {
    return inferCourseCategoryFromTitle(title);
  }
  return raw;
}

function inferCourseCategoryFromTitle(title: string): string {
  if (!title) return 'General';
  const t = title.toLowerCase();
  if (t.match(/typescript|javascript|react|node|python|java|\.net|programming|software|developer|web dev|backend|frontend|fullstack|devops/)) return 'Technology';
  if (t.match(/machine learning|ai |artificial|deep learning|nlp|data science|neural/)) return 'Data & AI';
  if (t.match(/fintech|fin tech|financial technology|blockchain|crypto|defi/)) return 'Finance & Technology';
  if (t.match(/finance|investment|accounting|trading|banking|economics/)) return 'Finance';
  if (t.match(/cyber|security|ethical hacking|penetration/)) return 'Cybersecurity';
  if (t.match(/product|project management|agile|scrum/)) return 'Product Management';
  if (t.match(/marketing|seo|growth|branding/)) return 'Marketing';
  if (t.match(/design|ui|ux|figma|graphic/)) return 'Design';
  if (t.match(/sql|database|postgresql|mongodb/)) return 'Database';
  if (t.match(/cloud|aws|azure|gcp|kubernetes|docker/)) return 'Cloud & DevOps';
  return 'General';
}