phase 1 and 2

This commit is contained in:
Doar Mihai
2026-04-03 00:23:01 +03:00
parent 4976742c29
commit 9dcf3c36d7
33 changed files with 2563 additions and 5 deletions
@@ -0,0 +1,108 @@
<?php
namespace App\Services\JobApi\Transformers;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\TransformationException;
use DateTime;
class AdzunaTransformer implements JobTransformer
{
public function transform(array $data): JobData
{
try {
return new JobData(
externalId: (string) $data['id'],
title: $data['title'] ?? 'Untitled',
company: $data['company']['display_name'] ?? 'Unknown Company',
description: $this->sanitizeDescription($data['description'] ?? ''),
location: $this->parseLocation($data),
salaryMin: $data['salary_min'] ?? null,
salaryMax: $data['salary_max'] ?? null,
salaryCurrency: 'USD',
jobType: $this->mapJobType($data['contract_type'] ?? null),
sourceUrl: $data['redirect_url'] ?? null,
applyUrl: $data['redirect_url'] ?? null,
postedAt: $this->parseDate($data['created'] ?? null),
expiresAt: null,
remoteAllowed: $this->isRemote($data),
experienceLevel: null,
category: $data['category']['label'] ?? null,
rawData: $data,
);
} catch (\Throwable $e) {
throw new TransformationException(
'Adzuna',
'Failed to transform Adzuna job data: ' . $e->getMessage(),
['data' => $data, 'error' => $e->getMessage()]
);
}
}
public function transformMany(array $jobs): array
{
return array_map(fn($job) => $this->transform($job), $jobs);
}
private function sanitizeDescription(string $description): string
{
$clean = strip_tags($description);
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/\s+/', ' ', $clean);
return trim($clean);
}
private function parseLocation(array $data): ?string
{
$location = $data['location']['display_name'] ?? null;
if (empty($location)) {
// Fallback to area
$area = $data['location']['area'] ?? [];
if (!empty($area)) {
return implode(', ', $area);
}
}
return $location;
}
private function isRemote(array $data): bool
{
$title = strtolower($data['title'] ?? '');
$description = strtolower($data['description'] ?? '');
$location = strtolower($data['location']['display_name'] ?? '');
return str_contains($title, 'remote') ||
str_contains($description, 'remote') ||
str_contains($location, 'remote');
}
private function parseDate(?string $date): ?DateTime
{
if (empty($date)) {
return null;
}
try {
return new DateTime($date);
} catch (\Exception $e) {
return null;
}
}
private function mapJobType(?string $type): ?string
{
if (empty($type)) {
return null;
}
return match (strtolower($type)) {
'permanent', 'full_time' => 'full-time',
'part_time' => 'part-time',
'contract' => 'contract',
'temporary' => 'temporary',
default => 'full-time',
};
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\JobApi\Transformers;
use App\Services\JobApi\DTO\JobData;
interface JobTransformer
{
/**
* Transform a single job from API response to JobData DTO
*
* @param array $data Raw job data from API
* @return JobData Standardized job data
*/
public function transform(array $data): JobData;
/**
* Transform multiple jobs from API response
*
* @param array $jobs Array of raw job data from API
* @return array<JobData> Array of standardized job data
*/
public function transformMany(array $jobs): array;
}
@@ -0,0 +1,121 @@
<?php
namespace App\Services\JobApi\Transformers;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\TransformationException;
use DateTime;
class RemotiveTransformer implements JobTransformer
{
public function transform(array $data): JobData
{
try {
return new JobData(
externalId: (string) $data['id'],
title: $data['title'] ?? 'Untitled',
company: $data['company_name'] ?? 'Unknown Company',
description: $this->sanitizeDescription($data['description'] ?? ''),
location: $this->parseLocation($data),
salaryMin: $this->parseSalary($data['salary'] ?? null, 'min'),
salaryMax: $this->parseSalary($data['salary'] ?? null, 'max'),
salaryCurrency: 'USD',
jobType: $this->mapJobType($data['job_type'] ?? null),
sourceUrl: $data['url'] ?? null,
applyUrl: $data['url'] ?? null,
postedAt: $this->parseDate($data['publication_date'] ?? null),
expiresAt: null,
remoteAllowed: true, // Remotive only has remote jobs
experienceLevel: $this->mapExperienceLevel($data['candidate_required_location'] ?? null),
category: $data['category'] ?? null,
rawData: $data,
);
} catch (\Throwable $e) {
throw new TransformationException(
'Remotive',
'Failed to transform Remotive job data: ' . $e->getMessage(),
['data' => $data, 'error' => $e->getMessage()]
);
}
}
public function transformMany(array $jobs): array
{
return array_map(fn($job) => $this->transform($job), $jobs);
}
private function sanitizeDescription(string $description): string
{
// Remove HTML tags and clean up whitespace
$clean = strip_tags($description);
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/\s+/', ' ', $clean);
return trim($clean);
}
private function parseLocation(array $data): ?string
{
$location = $data['candidate_required_location'] ?? null;
if (empty($location)) {
return 'Remote';
}
return $location;
}
private function parseSalary(?string $salary, string $type): ?float
{
if (empty($salary)) {
return null;
}
// Try to extract numbers from salary string
if (preg_match_all('/[\d,]+/', $salary, $matches)) {
$numbers = array_map(fn($n) => (float) str_replace(',', '', $n), $matches[0]);
if (count($numbers) >= 2) {
return $type === 'min' ? min($numbers) : max($numbers);
} elseif (count($numbers) === 1) {
return $numbers[0];
}
}
return null;
}
private function parseDate(?string $date): ?DateTime
{
if (empty($date)) {
return null;
}
try {
return new DateTime($date);
} catch (\Exception $e) {
return null;
}
}
private function mapJobType(?string $type): ?string
{
if (empty($type)) {
return 'full-time';
}
return match (strtolower($type)) {
'full_time', 'full-time' => 'full-time',
'part_time', 'part-time' => 'part-time',
'contract' => 'contract',
'freelance' => 'freelance',
default => 'full-time',
};
}
private function mapExperienceLevel(?string $location): ?string
{
// Remotive doesn't provide experience level directly
// This is a placeholder that could be enhanced with ML/NLP
return null;
}
}
@@ -0,0 +1,138 @@
<?php
namespace App\Services\JobApi\Transformers;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\TransformationException;
use DateTime;
class TheMuseTransformer implements JobTransformer
{
public function transform(array $data): JobData
{
try {
return new JobData(
externalId: (string) $data['id'],
title: $data['name'] ?? 'Untitled',
company: $data['company']['name'] ?? 'Unknown Company',
description: $this->sanitizeDescription($data['contents'] ?? ''),
location: $this->parseLocation($data),
salaryMin: null, // The Muse doesn't provide salary info
salaryMax: null,
salaryCurrency: 'USD',
jobType: $this->mapJobType($data['type'] ?? null),
sourceUrl: $data['refs']['landing_page'] ?? null,
applyUrl: $data['refs']['landing_page'] ?? null,
postedAt: $this->parseDate($data['publication_date'] ?? null),
expiresAt: null,
remoteAllowed: $this->isRemote($data),
experienceLevel: $this->mapExperienceLevel($data['levels'] ?? []),
category: $this->parseCategory($data['categories'] ?? []),
rawData: $data,
);
} catch (\Throwable $e) {
throw new TransformationException(
'The Muse',
'Failed to transform The Muse job data: ' . $e->getMessage(),
['data' => $data, 'error' => $e->getMessage()]
);
}
}
public function transformMany(array $jobs): array
{
return array_map(fn($job) => $this->transform($job), $jobs);
}
private function sanitizeDescription(string $description): string
{
$clean = strip_tags($description);
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/\s+/', ' ', $clean);
return trim($clean);
}
private function parseLocation(array $data): ?string
{
if (empty($data['locations'])) {
return null;
}
$locations = array_map(fn($loc) => $loc['name'] ?? '', $data['locations']);
return implode(', ', array_filter($locations));
}
private function isRemote(array $data): bool
{
if (empty($data['locations'])) {
return false;
}
foreach ($data['locations'] as $location) {
$name = strtolower($location['name'] ?? '');
if (str_contains($name, 'remote') || str_contains($name, 'flexible')) {
return true;
}
}
return false;
}
private function parseDate(?string $date): ?DateTime
{
if (empty($date)) {
return null;
}
try {
return new DateTime($date);
} catch (\Exception $e) {
return null;
}
}
private function mapJobType(?string $type): ?string
{
if (empty($type)) {
return 'full-time';
}
return match (strtolower($type)) {
'full-time', 'fulltime' => 'full-time',
'part-time', 'parttime' => 'part-time',
'contract' => 'contract',
'temporary', 'temp' => 'temporary',
'internship', 'intern' => 'internship',
'freelance' => 'freelance',
default => 'full-time',
};
}
private function mapExperienceLevel(array $levels): ?string
{
if (empty($levels)) {
return null;
}
// The Muse uses: Entry Level, Mid Level, Senior Level
$firstLevel = strtolower($levels[0]['name'] ?? '');
return match (true) {
str_contains($firstLevel, 'entry') => 'entry',
str_contains($firstLevel, 'mid') => 'mid',
str_contains($firstLevel, 'senior') => 'senior',
str_contains($firstLevel, 'executive') => 'executive',
default => null,
};
}
private function parseCategory(array $categories): ?string
{
if (empty($categories)) {
return null;
}
// Return the first category name
return $categories[0]['name'] ?? null;
}
}