@@ -60,6 +60,33 @@ function detectApi(company) {
6060 } ;
6161 }
6262
63+ // Workable (public markdown feed at /<slug>/jobs.md — no auth required)
64+ const workableMatch = url . match ( / a p p l y \. w o r k a b l e \. c o m \/ ( [ ^ / ? # ] + ) / ) ;
65+ if ( workableMatch ) {
66+ return {
67+ type : 'workable' ,
68+ url : `https://apply.workable.com/${ workableMatch [ 1 ] } /jobs.md` ,
69+ } ;
70+ }
71+
72+ // SmartRecruiters
73+ const smartRecruitersMatch = url . match ( / (?: c a r e e r s | j o b s ) \. s m a r t r e c r u i t e r s \. c o m \/ ( [ ^ / ? # ] + ) / ) ;
74+ if ( smartRecruitersMatch ) {
75+ return {
76+ type : 'smartrecruiters' ,
77+ url : `https://api.smartrecruiters.com/v1/companies/${ smartRecruitersMatch [ 1 ] } /postings?limit=100&offset=0&status=PUBLIC` ,
78+ } ;
79+ }
80+
81+ // Recruitee
82+ const recruiteeMatch = url . match ( / ( [ a - z 0 - 9 - ] + ) \. r e c r u i t e e \. c o m / ) ;
83+ if ( recruiteeMatch ) {
84+ return {
85+ type : 'recruitee' ,
86+ url : `https://${ recruiteeMatch [ 1 ] } .recruitee.com/api/offers/` ,
87+ } ;
88+ }
89+
6390 // Greenhouse EU boards
6491 const ghEuMatch = url . match ( / j o b - b o a r d s (?: \. e u ) ? \. g r e e n h o u s e \. i o \/ ( [ ^ / ? # ] + ) / ) ;
6592 if ( ghEuMatch && ! company . api ) {
@@ -104,7 +131,63 @@ function parseLever(json, companyName) {
104131 } ) ) ;
105132}
106133
107- const PARSERS = { greenhouse : parseGreenhouse , ashby : parseAshby , lever : parseLever } ;
134+ function parseWorkable ( text , companyName ) {
135+ // Workable exposes a public markdown table at /<slug>/jobs.md (no auth needed).
136+ // Format: | Title | Department | Location | Type | Salary | Posted | Details |
137+ // where Details has a markdown link [View](https://apply.workable.com/<slug>/jobs/view/<id>.md)
138+ if ( typeof text !== 'string' ) return [ ] ;
139+ const jobs = [ ] ;
140+ const lines = text . split ( '\n' ) ;
141+ for ( const line of lines ) {
142+ // Skip non-data lines: must be a table row with `[View](...)` link
143+ if ( ! line . startsWith ( '|' ) || ! line . includes ( '[View]' ) ) continue ;
144+ const cols = line . split ( '|' ) . map ( c => c . trim ( ) ) ;
145+ // Cols: [empty, title, dept, location, type, salary, posted, details, empty]
146+ if ( cols . length < 8 ) continue ;
147+ const title = cols [ 1 ] ;
148+ const location = cols [ 3 ] ;
149+ const urlMatch = cols [ 7 ] . match ( / \( ( [ ^ ) ] + ) \) / ) ;
150+ let url = urlMatch ? urlMatch [ 1 ] : '' ;
151+ // Strip the .md suffix to get the human-readable URL
152+ if ( url . endsWith ( '.md' ) ) url = url . slice ( 0 , - 3 ) ;
153+ if ( ! title || title === 'Title' ) continue ;
154+ jobs . push ( { title, url, location, company : companyName } ) ;
155+ }
156+ return jobs ;
157+ }
158+
159+ function parseSmartRecruiters ( json , companyName ) {
160+ return ( json ?. content || [ ] ) . map ( j => {
161+ const loc = j . location || { } ;
162+ const fullLocation = loc . fullLocation || [ loc . city , loc . region , loc . country ] . filter ( Boolean ) . join ( ', ' ) ;
163+ const remote = loc . remote ? 'Remote' : '' ;
164+ const location = [ fullLocation , remote ] . filter ( Boolean ) . join ( ', ' ) ;
165+ const slugified = ( j . name || '' ) . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' ) . replace ( / ^ - | - $ / g, '' ) ;
166+ return {
167+ title : j . name || '' ,
168+ url : j . ref ? j . ref . replace ( 'api.smartrecruiters.com/v1/companies/' , 'jobs.smartrecruiters.com/' ) : `https://jobs.smartrecruiters.com/${ companyName . toLowerCase ( ) } /${ j . id } -${ slugified } ` ,
169+ location,
170+ company : companyName ,
171+ } ;
172+ } ) ;
173+ }
174+
175+ function parseRecruitee ( json , companyName ) {
176+ return ( json ?. offers || [ ] ) . map ( j => {
177+ const city = j . city || '' ;
178+ const country = j . country || '' ;
179+ const remote = j . remote ? 'Remote' : '' ;
180+ const location = j . location || [ city , country , remote ] . filter ( Boolean ) . join ( ', ' ) ;
181+ return {
182+ title : j . title || '' ,
183+ url : j . careers_url || j . url || '' ,
184+ location,
185+ company : companyName ,
186+ } ;
187+ } ) ;
188+ }
189+
190+ const PARSERS = { greenhouse : parseGreenhouse , ashby : parseAshby , lever : parseLever , workable : parseWorkable , smartrecruiters : parseSmartRecruiters , recruitee : parseRecruitee } ;
108191
109192// ── Fetch with timeout ──────────────────────────────────────────────
110193
@@ -120,6 +203,18 @@ async function fetchJson(url) {
120203 }
121204}
122205
206+ async function fetchText ( url ) {
207+ const controller = new AbortController ( ) ;
208+ const timer = setTimeout ( ( ) => controller . abort ( ) , FETCH_TIMEOUT_MS ) ;
209+ try {
210+ const res = await fetch ( url , { signal : controller . signal } ) ;
211+ if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` ) ;
212+ return await res . text ( ) ;
213+ } finally {
214+ clearTimeout ( timer ) ;
215+ }
216+ }
217+
123218// ── Title filter ────────────────────────────────────────────────────
124219
125220function buildTitleFilter ( titleFilter ) {
@@ -319,8 +414,8 @@ async function main() {
319414 const tasks = targets . map ( company => async ( ) => {
320415 const { type, url } = company . _api ;
321416 try {
322- const json = await fetchJson ( url ) ;
323- const jobs = PARSERS [ type ] ( json , company . name ) ;
417+ const data = type === 'workable' ? await fetchText ( url ) : await fetchJson ( url ) ;
418+ const jobs = PARSERS [ type ] ( data , company . name ) ;
324419 totalFound += jobs . length ;
325420
326421 for ( const job of jobs ) {
0 commit comments