245 lines
6.8 KiB
Go
245 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
BaseURL = "https://hiring.cafe"
|
|
SearchURL = BaseURL + "/api/search-jobs"
|
|
UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
PageSize = 20
|
|
)
|
|
|
|
// Client handles API requests to hiring.cafe
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new API client
|
|
func NewClient() *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// SearchRequest represents the search request payload (based on RSSHub implementation)
|
|
type SearchRequest struct {
|
|
Size int `json:"size"`
|
|
Page int `json:"page"`
|
|
SearchState SearchState `json:"searchState"`
|
|
}
|
|
|
|
// SearchState contains search parameters
|
|
type SearchState struct {
|
|
SearchQuery string `json:"searchQuery"`
|
|
SortBy string `json:"sortBy"` // date, default, compensation_desc, experience_asc
|
|
}
|
|
|
|
// SearchResponse represents the API response
|
|
type SearchResponse struct {
|
|
Results []JobResult `json:"results"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// JobResult represents a job result from the API
|
|
type JobResult struct {
|
|
ID string `json:"id"`
|
|
ApplyURL string `json:"apply_url"`
|
|
JobInformation JobInformation `json:"job_information"`
|
|
ProcessedJobData ProcessedJobData `json:"v5_processed_job_data"`
|
|
}
|
|
|
|
// JobInformation contains basic job info
|
|
type JobInformation struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// ProcessedJobData contains processed job details
|
|
type ProcessedJobData struct {
|
|
CompanyName string `json:"company_name"`
|
|
IsCompensationTransparent bool `json:"is_compensation_transparent"`
|
|
YearlyMinCompensation *float64 `json:"yearly_min_compensation"`
|
|
YearlyMaxCompensation *float64 `json:"yearly_max_compensation"`
|
|
WorkplaceType string `json:"workplace_type"`
|
|
RequirementsSummary string `json:"requirements_summary"`
|
|
JobCategory string `json:"job_category"`
|
|
RoleActivities []string `json:"role_activities"`
|
|
FormattedWorkplaceLocation string `json:"formatted_workplace_location"`
|
|
EstimatedPublishDateMillis string `json:"estimated_publish_date_millis"`
|
|
}
|
|
|
|
// Job is a simplified job structure for external use
|
|
type Job struct {
|
|
ID string
|
|
ApplyURL string
|
|
Title string
|
|
Description string
|
|
Company string
|
|
CompanyURL string
|
|
Location string
|
|
Remote bool
|
|
Salary string
|
|
PostedAt time.Time
|
|
DescriptionClean string
|
|
}
|
|
|
|
// SearchFilters contains user-configurable search filters
|
|
type SearchFilters struct {
|
|
Query string
|
|
Country string
|
|
Remote bool
|
|
Hybrid bool
|
|
Onsite bool
|
|
FullTime bool
|
|
PartTime bool
|
|
Contract bool
|
|
Internship bool
|
|
EntryLevel bool
|
|
MidLevel bool
|
|
SeniorLevel bool
|
|
DateRangeDays int
|
|
MaxPages int
|
|
}
|
|
|
|
// DefaultFilters returns default search filters
|
|
func DefaultFilters() SearchFilters {
|
|
return SearchFilters{
|
|
Query: "",
|
|
Country: "United States",
|
|
Remote: true,
|
|
Hybrid: true,
|
|
Onsite: true,
|
|
FullTime: true,
|
|
PartTime: false,
|
|
Contract: false,
|
|
Internship: false,
|
|
EntryLevel: true,
|
|
MidLevel: true,
|
|
SeniorLevel: false,
|
|
DateRangeDays: 30,
|
|
MaxPages: 10,
|
|
}
|
|
}
|
|
|
|
// Search performs a job search with the given filters
|
|
func (c *Client) Search(filters SearchFilters) ([]Job, error) {
|
|
var allJobs []Job
|
|
page := 1
|
|
|
|
for {
|
|
jobs, totalCount, err := c.searchPage(filters, page)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to search page %d: %w", page, err)
|
|
}
|
|
|
|
allJobs = append(allJobs, jobs...)
|
|
|
|
// Check if we've fetched all jobs or reached max pages
|
|
if len(allJobs) >= totalCount || page >= filters.MaxPages || len(jobs) == 0 {
|
|
break
|
|
}
|
|
|
|
page++
|
|
// Rate limiting - be nice to the API
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
return allJobs, nil
|
|
}
|
|
|
|
// searchPage performs a single page search using the correct API format
|
|
func (c *Client) searchPage(filters SearchFilters, page int) ([]Job, int, error) {
|
|
reqBody := SearchRequest{
|
|
Size: PageSize,
|
|
Page: page - 1, // API uses 0-based indexing
|
|
SearchState: SearchState{
|
|
SearchQuery: filters.Query,
|
|
SortBy: "date",
|
|
},
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
log.Printf("Sending request to %s with body: %s", SearchURL, string(jsonBody))
|
|
|
|
req, err := http.NewRequest("POST", SearchURL, bytes.NewBuffer(jsonBody))
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", UserAgent)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Origin", BaseURL)
|
|
req.Header.Set("Referer", BaseURL+"/")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, 0, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var searchResp SearchResponse
|
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
return nil, 0, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
}
|
|
|
|
// Convert JobResult to Job
|
|
var jobs []Job
|
|
for _, result := range searchResp.Results {
|
|
job := Job{
|
|
ID: result.ID,
|
|
ApplyURL: result.ApplyURL,
|
|
Title: result.JobInformation.Title,
|
|
Description: result.JobInformation.Description,
|
|
Company: result.ProcessedJobData.CompanyName,
|
|
Location: result.ProcessedJobData.FormattedWorkplaceLocation,
|
|
Remote: result.ProcessedJobData.WorkplaceType == "remote",
|
|
DescriptionClean: result.JobInformation.Description,
|
|
}
|
|
|
|
// Parse salary if available
|
|
if result.ProcessedJobData.IsCompensationTransparent &&
|
|
result.ProcessedJobData.YearlyMinCompensation != nil &&
|
|
result.ProcessedJobData.YearlyMaxCompensation != nil {
|
|
job.Salary = fmt.Sprintf("$%.0f - $%.0f",
|
|
*result.ProcessedJobData.YearlyMinCompensation,
|
|
*result.ProcessedJobData.YearlyMaxCompensation)
|
|
}
|
|
|
|
// Parse posted date
|
|
if result.ProcessedJobData.EstimatedPublishDateMillis != "" {
|
|
// Try to parse as milliseconds timestamp
|
|
var millis int64
|
|
if _, err := fmt.Sscanf(result.ProcessedJobData.EstimatedPublishDateMillis, "%d", &millis); err == nil {
|
|
job.PostedAt = time.UnixMilli(millis)
|
|
}
|
|
}
|
|
|
|
jobs = append(jobs, job)
|
|
}
|
|
|
|
return jobs, searchResp.Total, nil
|
|
}
|