Skip to content

Commit

Permalink
feat: add HiringCafe route (DIYgod#17858)
Browse files Browse the repository at this point in the history
* feat(hiringcafe): add namespace.ts

Signed-off-by: mintyfrankie <[email protected]>

* feat(hiringcafe): add jobs endpoint

Signed-off-by: mintyfrankie <[email protected]>

* fix(hiringcafe): fix ESLint warning

Signed-off-by: mintyfrankie <[email protected]>

* fix: apply suggestions from code review

Co-authored-by: Tony <[email protected]>

* fix: accept suggestions from code review

Signed-off-by: mintyfrankie <[email protected]>

* refactor: modularize art template and sub-functions

Signed-off-by: mintyfrankie <[email protected]>

* feat(hiringcafe): add API interfaces

Signed-off-by: mintyfrankie <[email protected]>

* fix: resolve __dirname error

Signed-off-by: mintyfrankie <[email protected]>

* refactor: change API payload and interfaces to match upstream changes

Signed-off-by: mintyfrankie <[email protected]>

* refactor: add type safety and error handling

Signed-off-by: mintyfrankie <[email protected]>

* Apply suggestions from code review

Co-authored-by: Tony <[email protected]>

* fix: resolve ESLint error

Signed-off-by: mintyfrankie <[email protected]>

* fix: use hiring.cafe

---------

Signed-off-by: mintyfrankie <[email protected]>
  • Loading branch information
mintyfrankie authored Dec 18, 2024
1 parent 5ffc73e commit 0c2bd40
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 0 deletions.
150 changes: 150 additions & 0 deletions lib/routes/hiring.cafe/jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import ofetch from '@/utils/ofetch';
import path from 'node:path';
import { art } from '@/utils/render';
import { Context } from 'hono';
import { getCurrentPath } from '@/utils/helpers';
import { Route } from '@/types';

const __dirname = getCurrentPath(import.meta.url);

const CONFIG = {
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
} as const;

const API = {
BASE_URL: 'https://hiring.cafe/api/search-jobs',
HEADERS: {
'Content-Type': 'application/json',
},
} as const;

interface GeoLocation {
readonly lat: number;
readonly lon: number;
}

interface JobInformation {
readonly title: string;
readonly description: string;
}

interface ProcessedJobData {
readonly company_name: string;
readonly is_compensation_transparent: boolean;
readonly yearly_min_compensation?: number;
readonly yearly_max_compensation?: number;
readonly workplace_type?: string;
readonly requirements_summary?: string;
readonly job_category: string;
readonly role_activities: readonly string[];
readonly formatted_workplace_location?: string;
}

interface JobResult {
readonly id: string;
readonly apply_url: string;
readonly job_information: JobInformation;
readonly v5_processed_job_data: ProcessedJobData;
readonly _geoloc: readonly GeoLocation[];
readonly estimated_publish_date: string;
}

interface ApiResponse {
readonly results: readonly JobResult[];
readonly total: number;
}

interface SearchParams {
readonly keywords: string;
readonly page?: number;
readonly size?: number;
}

const validateSearchParams = ({ keywords, page = 0, size = CONFIG.DEFAULT_PAGE_SIZE }: SearchParams): SearchParams => ({
keywords: keywords.trim(),
page: Math.max(0, Math.floor(Number(page))),
size: Math.min(Math.max(1, Math.floor(Number(size))), CONFIG.MAX_PAGE_SIZE),
});

const fetchJobs = async (searchParams: SearchParams): Promise<ApiResponse> => {
const payload = {
size: searchParams.size,
page: searchParams.page,
searchState: {
searchQuery: searchParams.keywords,
},
};

return await ofetch<ApiResponse>(API.BASE_URL, {
method: 'POST',
body: payload,
headers: API.HEADERS,
});
};

const renderJobDescription = (jobInfo: JobInformation, processedData: ProcessedJobData): string =>
art(path.join(__dirname, 'templates/jobs.art'), {
company_name: processedData.company_name,
location: processedData.formatted_workplace_location ?? 'Remote/Unspecified',
is_compensation_transparent: Boolean(processedData.is_compensation_transparent && processedData.yearly_min_compensation && processedData.yearly_max_compensation),
yearly_min_compensation_formatted: processedData.yearly_min_compensation?.toLocaleString() ?? '',
yearly_max_compensation_formatted: processedData.yearly_max_compensation?.toLocaleString() ?? '',
workplace_type: processedData.workplace_type ?? 'Not specified',
requirements_summary: processedData.requirements_summary ?? 'No requirements specified',
job_description: jobInfo.description ?? '',
});

const transformJobItem = (item: JobResult) => {
const { job_information: jobInfo, v5_processed_job_data: processedData, estimated_publish_date, apply_url, id } = item;

return {
title: `${jobInfo.title} - ${processedData.company_name}`,
description: renderJobDescription(jobInfo, processedData),
link: apply_url,
pubDate: new Date(estimated_publish_date).toUTCString(),
category: [processedData.job_category, ...processedData.role_activities, processedData.workplace_type].filter((x): x is string => !!x),
author: processedData.company_name,
guid: id,
};
};

async function handler(ctx: Context) {
const searchParams = validateSearchParams({
keywords: ctx.req.param('keywords'),
});

const response = await fetchJobs(searchParams);
const items = response.results.map((item) => transformJobItem(item));

return {
title: `HiringCafe Jobs: ${searchParams.keywords}`,
description: `Job search results for "${searchParams.keywords}" on HiringCafe`,
link: `https://hiring.cafe/jobs?q=${encodeURIComponent(searchParams.keywords)}`,
item: items,
total: response.total,
};
}

export const route: Route = {
path: '/jobs/:keywords',
categories: ['other'],
example: '/hiring.cafe/jobs/sustainability',
parameters: { keywords: 'Keywords to search for' },
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
source: ['hiring.cafe'],
},
],
name: 'Jobs',
maintainers: ['mintyfrankie'],
handler,
};
10 changes: 10 additions & 0 deletions lib/routes/hiring.cafe/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Namespace } from '@/types';

export const namespace: Namespace = {
name: 'HiringCafe',
url: 'hiring.cafe',
description: 'HiringCafe is a platform for job seekers to find job opportunities and for employers to post job listings.',
zh: {
name: 'HiringCafe',
},
};
18 changes: 18 additions & 0 deletions lib/routes/hiring.cafe/templates/jobs.art
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<p><strong>Company:</strong> {{ company_name }}</p>
<p><strong>Location:</strong> {{ location }}</p>

{{if is_compensation_transparent}}
<p><strong>Compensation:</strong> ${{ yearly_min_compensation_formatted }} - ${{ yearly_max_compensation_formatted }} per year</p>
{{/if}}

<p><strong>Workplace Type:</strong> {{ workplace_type }}</p>
<p><strong>Requirements:</strong> {{ requirements_summary }}</p>

<div class="job-description">
{{@ job_description }}
</div>

{{if has_company_info}}
<h2>About {{ company_name }}</h2>
{{@ company_info_description }}
{{/if}}

0 comments on commit 0c2bd40

Please sign in to comment.