Spaces:
Sleeping
Sleeping
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(/&/g, '&').replace(/ /g, ' ')
.replace(/</g, '<').replace(/>/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';
}
|