Files
santifer--career-ops/providers/ibm.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

125 lines
4.7 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// IBM careers provider — POSTs to IBM's careers search API (Elasticsearch-style;
// the same endpoint the ibm.com/careers/search UI calls). One endpoint serves
// every locale, so results are language-agnostic (lang: "zz").
//
// Configure via a `job_boards` (or `tracked_companies`) entry with `provider: ibm`
// and an optional `ibm:` block of facet filters:
//
// - name: IBM Germany — SWE & Data
// provider: ibm
// ibm:
// country: Germany # field_keyword_05
// categories: ["Software Engineering", "Data & Analytics"] # field_keyword_08
// enabled: true
//
// Omit `country` / `categories` to widen the search. Paginates via `from` until
// the result set is exhausted or MAX_RECORDS is reached.
const API_URL = 'https://www-api.ibm.com/search/api/v2';
const PAGE_SIZE = 30;
const MAX_RECORDS = 600; // safety cap on pagination
/**
* Builds the Elasticsearch post_filter from the entry's `ibm:` config.
* @param {{ country?: string, categories?: string[] }} cfg
*/
export function buildPostFilter(cfg) {
const must = [];
// Sanitize operator config: keep only non-empty trimmed strings so a stray/
// mistyped entry can't inject empty or non-string filter terms.
const categories = Array.isArray(cfg.categories)
? cfg.categories.filter(c => typeof c === 'string' && c.trim()).map(c => c.trim())
: [];
if (categories.length) {
must.push({ bool: { should: categories.map(c => ({ term: { field_keyword_08: c } })) } });
}
const country = typeof cfg.country === 'string' ? cfg.country.trim() : '';
if (country) must.push({ term: { field_keyword_05: country } });
return { bool: { must } };
}
/**
* Normalizes one page of the IBM careers API response into job entries.
* Throws if the response doesn't carry the expected `hits.hits[]` shape, so a
* silent endpoint change surfaces as a hard error instead of empty results.
* @param {any} json - A single API response page.
* @returns {Array<{title: string, url: string, company: string, location: string}>}
*/
export function parseIbmResponse(json) {
const hits = json && json.hits && Array.isArray(json.hits.hits) ? json.hits.hits : null;
if (!hits) {
throw new Error(`ibm: unexpected API response — expected hits.hits[], got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
}
const out = [];
for (const h of hits) {
const s = (h && h._source) || {};
if (typeof s.title !== 'string' || s.title.trim() === '') continue;
if (typeof s.url !== 'string' || !/^https?:\/\//i.test(s.url.trim())) continue;
const loc = typeof s.field_keyword_19 === 'string' ? s.field_keyword_19.trim() : '';
const mode = typeof s.field_keyword_17 === 'string' ? s.field_keyword_17.trim() : '';
out.push({
title: s.title.trim(),
url: s.url.trim(),
company: 'IBM',
location: [loc, mode].filter(Boolean).join(' · '),
});
}
return out;
}
/** @type {Provider} */
export default {
id: 'ibm',
/**
* Fetches and normalizes postings from IBM's careers search API.
* @param {{ name?: string, ibm?: { country?: string, categories?: string[] } }} entry
* @param {{ fetchJson: (url: string, opts?: object) => Promise<any> }} ctx
* @returns {Promise<Array<{title: string, url: string, company: string, location: string}>>}
*/
async fetch(entry, ctx) {
const postFilter = buildPostFilter(entry.ibm || {});
/** @type {Array<{title: string, url: string, company: string, location: string}>} */
const out = [];
for (let from = 0; from < MAX_RECORDS; from += PAGE_SIZE) {
const body = {
appId: 'careers',
scopes: ['careers2'],
query: { bool: { must: [] } },
post_filter: postFilter,
size: PAGE_SIZE,
from,
sort: [{ _score: 'desc' }, { pageviews: 'desc' }],
lang: 'zz',
localeSelector: {},
sm: { query: '', lang: 'zz' },
_source: ['_id', 'title', 'url', 'description', 'language',
'field_keyword_05', 'field_keyword_08', 'field_keyword_17',
'field_keyword_18', 'field_keyword_19'],
};
// redirect:'error' prevents SSRF via server-side redirects
const json = await ctx.fetchJson(API_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
redirect: 'error',
});
out.push(...parseIbmResponse(json));
// Stop on the first short page. IBM's `total` is unreliable (often
// capped at the page size), so don't trust it for the loop bound —
// MAX_RECORDS is the hard ceiling.
if (json.hits.hits.length < PAGE_SIZE) break;
}
return out;
},
};