init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,124 @@
|
||||
// @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;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user