1
2
3
4
5
6
7
8const ALGOLIA_APP_ID = 'CSEKHVMS53';
9const ALGOLIA_SEARCH_KEY = '4bd8f6215d0cc52b26430765769e65a0';
10const ALGOLIA_URL = `https://${ALGOLIA_APP_ID.toLowerCase()}-dsn.algolia.net/1/indexes/*/queries`;
11const DETAIL_URL = (orgSlug, jobSlug) =>
12 `https://api.welcometothejungle.com/api/v1/organizations/${encodeURIComponent(orgSlug)}/jobs/${encodeURIComponent(jobSlug)}`;
13
14const SITE = 'https://www.welcometothejungle.com';
15const HEADERS = {
16 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36',
17 referer: `${SITE}/`,
18 origin: SITE,
19 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8',
20};
21
22export const ALGOLIA_HARD_LIMIT = 1000;
23
24
25export async function fetchWithRetry(url, init = {}, { retries = 4, baseDelayMs = 500, fetchImpl = fetch } = {}) {
26 let lastErr;
27 for (let attempt = 0; attempt <= retries; attempt++) {
28 try {
29 const res = await fetchImpl(url, { ...init, headers: { ...HEADERS, ...(init.headers || {}) } });
30 if (res.status === 404) return { status: 404, json: null };
31 if (res.status === 429 || res.status >= 500) throw new Error(`HTTP ${res.status}`);
32 if (!res.ok) throw new Error(`HTTP ${res.status}`);
33 return { status: res.status, json: await res.json() };
34 } catch (err) {
35 lastErr = err;
36 if (attempt === retries) break;
37 await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
38 }
39 }
40 throw lastErr;
41}
42
43
44export function buildFilters(input = {}) {
45 const and = [];
46 const or = (field, values) => {
47 if (!values?.length) return;
48 and.push('(' + values.map((v) => `${field}:"${String(v).replace(/"/g, '')}"`).join(' OR ') + ')');
49 };
50 or('offices.country_code', (input.countryCodes || []).map((c) => c.toUpperCase()));
51 or('offices.city', input.cities);
52 or('contract_type', input.contractTypes);
53 or('remote', input.remote);
54 or('language', (input.languages || []).map((l) => l.toLowerCase()));
55
56 const numeric = [];
57 if (Number.isFinite(input.minExperienceYears)) numeric.push(`experience_level_minimum>=${input.minExperienceYears}`);
58 if (Number.isFinite(input.maxExperienceYears)) numeric.push(`experience_level_minimum<=${input.maxExperienceYears}`);
59 if (Number.isFinite(input.publishedWithinDays) && input.publishedWithinDays > 0) {
60 const since = Math.floor(Date.now() / 1000) - input.publishedWithinDays * 86400;
61 numeric.push(`published_at_timestamp>=${since}`);
62 }
63 return { filters: and.join(' AND '), numericFilters: numeric };
64}
65
66
67export function parseSearchUrl(url) {
68 const u = new URL(url);
69 const p = u.searchParams;
70 const out = {};
71 if (p.get('query')) out.query = p.get('query');
72 const list = (key) => p.getAll(key).concat(p.getAll(`${key}[]`)).flatMap((v) => v.split(',')).filter(Boolean);
73 const countries = list('refinementList[offices.country_code]');
74 if (countries.length) out.countryCodes = countries;
75 const contract = list('refinementList[contract_type]');
76 if (contract.length) out.contractTypes = contract;
77 const remote = list('refinementList[remote]');
78 if (remote.length) out.remote = remote;
79 const m = u.pathname.match(/^\/(fr|en|es|cs|sk)\//);
80 if (m) out.language = m[1];
81 return out;
82}
83
84
85export async function searchPage({ query = '', page = 0, hitsPerPage = 100, sortBy = 'newest', filters, numericFilters, fetchImpl }) {
86 const indexName = sortBy === 'newest' ? 'wttj_jobs_production_fr_published_at_desc' : 'wttj_jobs_production_fr';
87 const params = new URLSearchParams({ query, page: String(page), hitsPerPage: String(hitsPerPage) });
88 if (filters) params.set('filters', filters);
89 if (numericFilters?.length) params.set('numericFilters', JSON.stringify(numericFilters));
90 params.set('attributesToHighlight', '[]');
91 const body = JSON.stringify({ requests: [{ indexName, params: params.toString() }] });
92 const { json } = await fetchWithRetry(
93 ALGOLIA_URL,
94 {
95 method: 'POST',
96 body,
97 headers: {
98 'content-type': 'application/json',
99 'x-algolia-application-id': ALGOLIA_APP_ID,
100 'x-algolia-api-key': ALGOLIA_SEARCH_KEY,
101 },
102 },
103 { fetchImpl },
104 );
105 const result = json?.results?.[0];
106 if (!result || result.message) throw new Error(`Algolia error: ${result?.message || 'empty response'}`);
107 return result;
108}
109
110
111export async function fetchJobDetail(orgSlug, jobSlug, { fetchImpl } = {}) {
112 const { status, json } = await fetchWithRetry(DETAIL_URL(orgSlug, jobSlug), {}, { fetchImpl });
113 if (status === 404 || !json) return null;
114 return json.job || json;
115}
116
117const stripHtml = (s) =>
118 typeof s === 'string'
119 ? s.replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|li|h\d|div)>/gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ')
120 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
121 .replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
122 : null;
123
124
125export function normalizeJob(hit, detail = null, lang = 'fr') {
126 const org = hit.organization || {};
127 const office = (hit.offices || [])[0] || {};
128 const url = `${SITE}/${lang}/companies/${org.slug}/jobs/${hit.slug}`;
129 const base = {
130 id: hit.objectID,
131 reference: hit.reference || null,
132 name: hit.name,
133 url,
134 companyName: org.name || null,
135 companySlug: org.slug || null,
136 companyUrl: org.slug ? `${SITE}/${lang}/companies/${org.slug}` : null,
137 companyLogo: org.logo?.url || null,
138 companySummary: org.summary || null,
139 companyEmployees: org.nb_employees ?? null,
140 companyCreationYear: org.creation_year ?? null,
141 companyLabels: org.labels || [],
142 sectors: (hit.sectors || []).map((s) => (typeof s === 'string' ? s : s?.name)).filter(Boolean),
143 city: office.city || null,
144 countryCode: office.country_code || null,
145 address: office.address || null,
146 latitude: office.latitude ?? hit._geoloc?.lat ?? null,
147 longitude: office.longitude ?? hit._geoloc?.lng ?? null,
148 allOffices: (hit.offices || []).map((o) => ({ city: o.city, countryCode: o.country_code })),
149 contractType: hit.contract_type || null,
150 contractDurationMinMonths: hit.contract_duration_minimum ?? null,
151 contractDurationMaxMonths: hit.contract_duration_maximum ?? null,
152 remote: hit.remote || null,
153 experienceYearsMin: hit.experience_level_minimum ?? null,
154 educationLevel: hit.education_level || null,
155 salaryMin: hit.salary_minimum ?? null,
156 salaryMax: hit.salary_maximum ?? null,
157 salaryCurrency: hit.salary_currency || null,
158 salaryPeriod: hit.salary_period || null,
159 salaryYearlyMin: hit.salary_yearly_minimum ?? null,
160 language: hit.language || null,
161 profession: hit.new_profession?.pivot_name || null,
162 professionCategory: hit.new_profession?.category_name || null,
163 professionSubCategory: hit.new_profession?.sub_category_name || null,
164 candidateProfileSummary: stripHtml(hit.profile),
165 benefits: hit.benefits || [],
166 keyMissions: hit.key_missions || null,
167 summary: stripHtml(hit.summary),
168 publishedAt: hit.published_at || null,
169 scrapedAt: new Date().toISOString(),
170 };
171 if (!detail) return base;
172 const dOffice = detail.office || (detail.offices || [])[0] || {};
173 return {
174 ...base,
175 address: dOffice.address || base.address,
176 latitude: dOffice.latitude ?? base.latitude,
177 longitude: dOffice.longitude ?? base.longitude,
178 description: stripHtml(detail.description),
179 descriptionHtml: detail.description || null,
180 candidateProfile: stripHtml(detail.profile),
181 recruitmentProcess: stripHtml(detail.recruitment_process),
182 companyDescription: stripHtml(detail.company_description),
183 skills: (detail.skills || []).map((s) => s?.name?.[lang] || s?.name?.en || s?.name || s).filter(Boolean),
184 tools: (detail.tools || []).map((t) => t?.name || t).filter(Boolean),
185 applyUrl: detail.apply_url || detail.external_apply_url || `${url}/apply`,
186 ats: detail.ats || null,
187 startDate: detail.start_date || null,
188 status: detail.status || null,
189 updatedAt: detail.updated_at || null,
190 };
191}