1import { request, mapLimit } from './http.js';
2
3export const SITES = {
4 AU: { host: 'https://www.seek.com.au', siteKey: 'AU-Main', locale: 'en-AU', zone: 'anz-1', where: 'All Australia', currency: 'AUD' },
5 NZ: { host: 'https://www.seek.co.nz', siteKey: 'NZ-Main', locale: 'en-NZ', zone: 'anz-2', where: 'All New Zealand', currency: 'NZD' },
6};
7
8export const CLASSIFICATIONS = {
9 'Accounting': 1200,
10 'Administration & Office Support': 6251,
11 'Advertising, Arts & Media': 6304,
12 'Banking & Financial Services': 1203,
13 'Call Centre & Customer Service': 1204,
14 'CEO & General Management': 7019,
15 'Community Services & Development': 6163,
16 'Construction': 1206,
17 'Consulting & Strategy': 6076,
18 'Design & Architecture': 6263,
19 'Education & Training': 6123,
20 'Engineering': 1209,
21 'Farming, Animals & Conservation': 6205,
22 'Government & Defence': 1210,
23 'Healthcare & Medical': 1211,
24 'Hospitality & Tourism': 1212,
25 'Human Resources & Recruitment': 6317,
26 'Information & Communication Technology': 6281,
27 'Insurance & Superannuation': 1214,
28 'Legal': 1216,
29 'Manufacturing, Transport & Logistics': 6092,
30 'Marketing & Communications': 6008,
31 'Mining, Resources & Energy': 6058,
32 'Real Estate & Property': 1220,
33 'Retail & Consumer Products': 6043,
34 'Sales': 6362,
35 'Science & Technology': 1223,
36 'Self Employment': 6261,
37 'Sport & Recreation': 6246,
38 'Trades & Services': 1225,
39};
40
41export const WORK_TYPES = { 'full-time': 242, 'part-time': 243, 'contract': 244, 'casual': 245 };
42export const WORK_ARRANGEMENTS = { 'on-site': 1, 'hybrid': 2, 'remote': 3 };
43
44export const REGIONS = {
45 AU: ['New South Wales NSW', 'Victoria VIC', 'Queensland QLD', 'Western Australia WA', 'South Australia SA', 'Tasmania TAS', 'ACT', 'Northern Territory NT'],
46 NZ: ['Auckland', 'Wellington', 'Canterbury', 'Waikato', 'Bay of Plenty', 'Otago', 'Manawatu, Taranaki & Whanganui', "Hawkes Bay", 'Northland', 'Nelson, Tasman & Marlborough', 'Southland', 'Gisborne', 'West Coast'],
47};
48
49
50const SALARY_BUCKETS = ['0-50000', '50000-70000', '70000-90000', '90000-110000', '110000-130000', '130000-160000', '160000-200000', '200000-'];
51
52export const PAGE_SIZE = 100;
53export const MAX_PAGES = 5;
54
55export async function searchPage(site, params, page) {
56 const s = SITES[site];
57 const qs = new URLSearchParams({ siteKey: s.siteKey, locale: s.locale, pageSize: String(PAGE_SIZE), page: String(page) });
58 for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
59 if (!qs.has('where')) qs.set('where', s.where);
60 const d = await request(`${s.host}/api/jobsearch/v5/search?${qs}`);
61 return { total: d.totalCount ?? 0, jobs: d.data ?? [] };
62}
63
64
65
66
67
68export async function planQueries(site, baseParams, log) {
69 const dims = [];
70 if (!baseParams.classification) dims.push({ key: 'classification', values: Object.values(CLASSIFICATIONS) });
71 const whereIsBroad = !baseParams.where || baseParams.where === SITES[site].where;
72 if (whereIsBroad) dims.push({ key: 'where', values: REGIONS[site] });
73 if (!baseParams.worktype) dims.push({ key: 'worktype', values: Object.values(WORK_TYPES) });
74 if (!baseParams.workarrangement) dims.push({ key: 'workarrangement', values: Object.values(WORK_ARRANGEMENTS) });
75 if (!baseParams.salaryrange) dims.push({ key: 'salaryrange', values: SALARY_BUCKETS, extra: { salarytype: 'annual' } });
76
77 const out = [];
78 async function visit(params, dimIdx, first) {
79 const res = first ?? (await searchPage(site, params, 1));
80 if (res.total === 0) return;
81 if (res.total <= PAGE_SIZE * MAX_PAGES || dimIdx >= dims.length) {
82 if (res.total > PAGE_SIZE * MAX_PAGES) log.warning(`A search slice has ${res.total} jobs; only the first ${PAGE_SIZE * MAX_PAGES} can be retrieved: ${JSON.stringify(params)}`);
83 out.push({ params, total: res.total, first: res });
84 return;
85 }
86 const dim = dims[dimIdx];
87 await mapLimit(dim.values, 4, (v) => visit({ ...params, ...(dim.extra || {}), [dim.key]: v }, dimIdx + 1));
88 }
89 const first = await searchPage(site, baseParams, 1);
90 await visit(baseParams, 0, first);
91 return { queries: out, totalReported: first.total };
92}
93
94const DETAILS_QUERY = `query jobDetails($jobId: ID!, $locale: Locale, $zone: Zone!) {
95 jobDetails(id: $jobId) {
96 job {
97 id title content(platform: WEB) isExpired
98 salary { label currencyLabel(zone: $zone) }
99 advertiser { id name(locale: $locale) }
100 location { label(locale: $locale, type: LONG) }
101 workTypes { label(locale: $locale) }
102 listedAt { dateTimeUtc }
103 expiresAt { dateTimeUtc }
104 }
105 companyProfile(zone: $zone) {
106 name
107 overview { website { url } size { description } industry }
108 reviewsSummary { overallRating { value } }
109 }
110 }
111}`;
112
113export async function jobDetails(site, jobId) {
114 const s = SITES[site];
115 const d = await request(`${s.host}/graphql`, {
116 method: 'POST',
117 headers: { 'seek-request-brand': 'seek', 'seek-request-country': site },
118 body: { operationName: 'jobDetails', variables: { jobId: String(jobId), zone: s.zone, locale: s.locale }, query: DETAILS_QUERY },
119 });
120 if (d.errors?.length && !d.data?.jobDetails) throw new Error(d.errors[0].message);
121 return d.data?.jobDetails ?? null;
122}
123
124
125export function parseSalary(label) {
126 if (!label) return { min: null, max: null, period: null };
127 const nums = [...label.replace(/,/g, '').matchAll(/\$?\s*(\d+(?:\.\d+)?)\s*(k)?/gi)]
128 .map((m) => Number(m[1]) * (m[2] ? 1000 : 1))
129 .filter((n) => n >= 10);
130 const l = label.toLowerCase();
131 let period = null;
132 if (/(per|\/|p\.?)\s*(hour|hr|h\b)|hourly|p\.h/.test(l)) period = 'hour';
133 else if (/(per|\/|p\.?)\s*day|daily|p\.d/.test(l)) period = 'day';
134 else if (/(per|\/)\s*week|weekly/.test(l)) period = 'week';
135 else if (/(per|\/)\s*month|monthly/.test(l)) period = 'month';
136 else if (/(per|\/|p\.?)\s*(year|annum|yr)|annual|p\.a|package|\bk\b/.test(l) || nums.some((n) => n >= 20000)) period = 'year';
137 return { min: nums[0] ?? null, max: nums[1] ?? nums[0] ?? null, period };
138}
139
140
141export function paramsFromUrl(url) {
142 const u = new URL(url);
143 const site = u.hostname.endsWith('.co.nz') ? 'NZ' : 'AU';
144 const params = Object.fromEntries(u.searchParams.entries());
145 const path = decodeURIComponent(u.pathname).replace(/^\/|\/$/g, '');
146 for (const seg of path.split('/')) {
147 let m;
148 if ((m = seg.match(/^in-(.+)$/))) params.where = m[1].replace(/-/g, ' ');
149 else if ((m = seg.match(/^jobs-in-(.+)$/))) {
150 const slug = m[1];
151 const found = Object.entries(CLASSIFICATIONS).find(([name]) => slugify(name) === slug);
152 if (found) params.classification = found[1];
153 } else if ((m = seg.match(/^(.+)-jobs$/))) params.keywords = m[1].replace(/-/g, ' ');
154 }
155 return { site, params };
156}
157
158export const slugify = (s) => s.toLowerCase().replace(/&/g, '').replace(/,/g, '').trim().replace(/\s+/g, '-');