forked from DIYgod/RSSHub
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add HiringCafe route (DIYgod#17858)
* 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
1 parent
5ffc73e
commit 0c2bd40
Showing
3 changed files
with
178 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |