openings 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Find evidence-grounded jobs, including relevant roles you may not have searched for, without accounts or API keys.",
5
5
  "author": { "name": "Openings contributors" },
6
6
  "license": "MIT",
@@ -76,7 +76,7 @@
76
76
  }
77
77
  },
78
78
  "8451": {
79
- "name": "84.51°",
79
+ "name": "84.51\u00b0",
80
80
  "ats": "greenhouse",
81
81
  "token": "8451",
82
82
  "sourceUrl": "https://job-boards.greenhouse.io/8451",
@@ -85,7 +85,7 @@
85
85
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-30-index"
86
86
  },
87
87
  "verification": {
88
- "observedCompanyName": "84.51°",
88
+ "observedCompanyName": "84.51\u00b0",
89
89
  "identityEvidence": "provider_board",
90
90
  "contentType": "application/json",
91
91
  "payloadVersion": "greenhouse-job-board:v1",
@@ -3066,25 +3066,6 @@
3066
3066
  "canonicalSourceUrl": "https://job-boards.greenhouse.io/accelschools"
3067
3067
  }
3068
3068
  },
3069
- "accenture": {
3070
- "name": "Accenture",
3071
- "ats": "workday",
3072
- "token": "accenture.wd103.myworkdayjobs.com/accenture/AccentureCareers",
3073
- "sourceUrl": "https://accenture.wd103.myworkdayjobs.com/en-US/AccentureCareers",
3074
- "discoveredFrom": {
3075
- "channel": "dataset",
3076
- "reference": "common-crawl-workday-host:accenture"
3077
- },
3078
- "verification": {
3079
- "observedCompanyName": "Accenture",
3080
- "identityEvidence": "provider_board",
3081
- "contentType": "application/json",
3082
- "payloadVersion": "workday-cxs:v1",
3083
- "jobCount": 2000,
3084
- "checkedAt": "2026-09-08T13:10:08.710Z",
3085
- "canonicalSourceUrl": "https://accenture.wd103.myworkdayjobs.com/en-US/AccentureCareers"
3086
- }
3087
- },
3088
3069
  "accenturefederalservices": {
3089
3070
  "name": "Accenture Federal Services",
3090
3071
  "ats": "greenhouse",
@@ -10521,7 +10502,7 @@
10521
10502
  }
10522
10503
  },
10523
10504
  "alten-mexico-1": {
10524
- "name": "ALTEN MÉXICO",
10505
+ "name": "ALTEN M\u00c9XICO",
10525
10506
  "ats": "workable",
10526
10507
  "token": "alten-mexico-1",
10527
10508
  "sourceUrl": "https://apply.workable.com/alten-mexico-1/",
@@ -10530,7 +10511,7 @@
10530
10511
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
10531
10512
  },
10532
10513
  "verification": {
10533
- "observedCompanyName": "ALTEN MÉXICO",
10514
+ "observedCompanyName": "ALTEN M\u00c9XICO",
10534
10515
  "identityEvidence": "provider_board",
10535
10516
  "contentType": "application/json; charset=utf-8",
10536
10517
  "payloadVersion": "workable-widget:v1",
@@ -12169,7 +12150,7 @@
12169
12150
  }
12170
12151
  },
12171
12152
  "amount": {
12172
- "name": "FIS® Amount",
12153
+ "name": "FIS\u00ae Amount\u2122",
12173
12154
  "ats": "greenhouse",
12174
12155
  "token": "amount",
12175
12156
  "sourceUrl": "https://job-boards.greenhouse.io/amount",
@@ -12178,7 +12159,7 @@
12178
12159
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-30-index"
12179
12160
  },
12180
12161
  "verification": {
12181
- "observedCompanyName": "FIS® Amount",
12162
+ "observedCompanyName": "FIS\u00ae Amount\u2122",
12182
12163
  "identityEvidence": "provider_board",
12183
12164
  "contentType": "application/json",
12184
12165
  "payloadVersion": "greenhouse-job-board:v1",
@@ -14525,7 +14506,7 @@
14525
14506
  }
14526
14507
  },
14527
14508
  "apothekary": {
14528
- "name": "Apothékary",
14509
+ "name": "Apoth\u00e9kary",
14529
14510
  "ats": "workable",
14530
14511
  "token": "apothekary",
14531
14512
  "sourceUrl": "https://apply.workable.com/apothekary/",
@@ -14534,7 +14515,7 @@
14534
14515
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
14535
14516
  },
14536
14517
  "verification": {
14537
- "observedCompanyName": "Apothékary",
14518
+ "observedCompanyName": "Apoth\u00e9kary",
14538
14519
  "identityEvidence": "provider_board",
14539
14520
  "contentType": "application/json; charset=utf-8",
14540
14521
  "payloadVersion": "workable-widget:v1",
@@ -15855,7 +15836,7 @@
15855
15836
  }
15856
15837
  },
15857
15838
  "arcoeducacao": {
15858
- "name": "Arco Educação",
15839
+ "name": "Arco Educa\u00e7\u00e3o",
15859
15840
  "ats": "greenhouse",
15860
15841
  "token": "arcoeducacao",
15861
15842
  "sourceUrl": "https://job-boards.greenhouse.io/arcoeducacao",
@@ -15864,7 +15845,7 @@
15864
15845
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-30-index"
15865
15846
  },
15866
15847
  "verification": {
15867
- "observedCompanyName": "Arco Educação",
15848
+ "observedCompanyName": "Arco Educa\u00e7\u00e3o",
15868
15849
  "identityEvidence": "provider_board",
15869
15850
  "contentType": "application/json",
15870
15851
  "payloadVersion": "greenhouse-job-board:v1",
@@ -23104,7 +23085,7 @@
23104
23085
  }
23105
23086
  },
23106
23087
  "baked-bros": {
23107
- "name": "Baked Bros",
23088
+ "name": "Baked Bros\u2122",
23108
23089
  "ats": "breezy",
23109
23090
  "token": "baked-bros",
23110
23091
  "sourceUrl": "https://baked-bros.breezy.hr",
@@ -23113,7 +23094,7 @@
23113
23094
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
23114
23095
  },
23115
23096
  "verification": {
23116
- "observedCompanyName": "Baked Bros",
23097
+ "observedCompanyName": "Baked Bros\u2122",
23117
23098
  "identityEvidence": "provider_board",
23118
23099
  "contentType": "application/json; charset=utf-8",
23119
23100
  "payloadVersion": "breezy-positions:v1",
@@ -23873,7 +23854,7 @@
23873
23854
  }
23874
23855
  },
23875
23856
  "barriere": {
23876
- "name": "Barrière",
23857
+ "name": "Barri\u00e8re",
23877
23858
  "ats": "smartrecruiters",
23878
23859
  "token": "Barriere",
23879
23860
  "sourceUrl": "https://jobs.smartrecruiters.com/Barriere",
@@ -23882,7 +23863,7 @@
23882
23863
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
23883
23864
  },
23884
23865
  "verification": {
23885
- "observedCompanyName": "Barrière",
23866
+ "observedCompanyName": "Barri\u00e8re",
23886
23867
  "identityEvidence": "provider_board",
23887
23868
  "contentType": "application/json; charset=utf-8",
23888
23869
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -34969,7 +34950,7 @@
34969
34950
  }
34970
34951
  },
34971
34952
  "cadillacf1team": {
34972
- "name": "Cadillac Formula 1® Team",
34953
+ "name": "Cadillac Formula 1\u00ae Team",
34973
34954
  "ats": "workable",
34974
34955
  "token": "cadillacf1team",
34975
34956
  "sourceUrl": "https://apply.workable.com/cadillacf1team/",
@@ -34978,7 +34959,7 @@
34978
34959
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
34979
34960
  },
34980
34961
  "verification": {
34981
- "observedCompanyName": "Cadillac Formula 1® Team",
34962
+ "observedCompanyName": "Cadillac Formula 1\u00ae Team",
34982
34963
  "identityEvidence": "provider_board",
34983
34964
  "contentType": "application/json; charset=utf-8",
34984
34965
  "payloadVersion": "workable-widget:v1",
@@ -38858,7 +38839,7 @@
38858
38839
  }
38859
38840
  },
38860
38841
  "cerfrancebroceliande": {
38861
- "name": "Cerfrance Brocéliande",
38842
+ "name": "Cerfrance Broc\u00e9liande",
38862
38843
  "ats": "smartrecruiters",
38863
38844
  "token": "CerfranceBroceliande",
38864
38845
  "sourceUrl": "https://jobs.smartrecruiters.com/CerfranceBroceliande",
@@ -38867,7 +38848,7 @@
38867
38848
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
38868
38849
  },
38869
38850
  "verification": {
38870
- "observedCompanyName": "Cerfrance Brocéliande",
38851
+ "observedCompanyName": "Cerfrance Broc\u00e9liande",
38871
38852
  "identityEvidence": "provider_board",
38872
38853
  "contentType": "application/json; charset=utf-8",
38873
38854
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -39010,7 +38991,7 @@
39010
38991
  }
39011
38992
  },
39012
38993
  "cfmontreal": {
39013
- "name": "CF Montréal",
38994
+ "name": "CF Montr\u00e9al",
39014
38995
  "ats": "smartrecruiters",
39015
38996
  "token": "CFMontreal",
39016
38997
  "sourceUrl": "https://jobs.smartrecruiters.com/CFMontreal",
@@ -39019,7 +39000,7 @@
39019
39000
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
39020
39001
  },
39021
39002
  "verification": {
39022
- "observedCompanyName": "CF Montréal",
39003
+ "observedCompanyName": "CF Montr\u00e9al",
39023
39004
  "identityEvidence": "provider_board",
39024
39005
  "contentType": "application/json; charset=utf-8",
39025
39006
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -40207,7 +40188,7 @@
40207
40188
  }
40208
40189
  },
40209
40190
  "chuva-inc": {
40210
- "name": "Galoá (Chuva Inc.)",
40191
+ "name": "Galo\u00e1 (Chuva Inc.)",
40211
40192
  "ats": "workable",
40212
40193
  "token": "chuva-inc",
40213
40194
  "sourceUrl": "https://apply.workable.com/chuva-inc/",
@@ -40216,7 +40197,7 @@
40216
40197
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
40217
40198
  },
40218
40199
  "verification": {
40219
- "observedCompanyName": "Galoá (Chuva Inc.)",
40200
+ "observedCompanyName": "Galo\u00e1 (Chuva Inc.)",
40220
40201
  "identityEvidence": "provider_board",
40221
40202
  "contentType": "application/json; charset=utf-8",
40222
40203
  "payloadVersion": "workable-widget:v1",
@@ -43926,7 +43907,7 @@
43926
43907
  }
43927
43908
  },
43928
43909
  "commerzbank-poland": {
43929
- "name": "Commerzbank AG Poland",
43910
+ "name": "Commerzbank AG \u2013 Poland",
43930
43911
  "ats": "breezy",
43931
43912
  "token": "commerzbank-poland",
43932
43913
  "sourceUrl": "https://commerzbank-poland.breezy.hr",
@@ -43935,7 +43916,7 @@
43935
43916
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
43936
43917
  },
43937
43918
  "verification": {
43938
- "observedCompanyName": "Commerzbank AG Poland",
43919
+ "observedCompanyName": "Commerzbank AG \u2013 Poland",
43939
43920
  "identityEvidence": "provider_board",
43940
43921
  "contentType": "application/json; charset=utf-8",
43941
43922
  "payloadVersion": "breezy-positions:v1",
@@ -56004,7 +55985,7 @@
56004
55985
  }
56005
55986
  },
56006
55987
  "epiktet": {
56007
- "name": "Kinougarde-Complétude",
55988
+ "name": "Kinougarde-Compl\u00e9tude",
56008
55989
  "ats": "smartrecruiters",
56009
55990
  "token": "epiktet",
56010
55991
  "sourceUrl": "https://jobs.smartrecruiters.com/epiktet",
@@ -56013,7 +55994,7 @@
56013
55994
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
56014
55995
  },
56015
55996
  "verification": {
56016
- "observedCompanyName": "Kinougarde-Complétude",
55997
+ "observedCompanyName": "Kinougarde-Compl\u00e9tude",
56017
55998
  "identityEvidence": "provider_board",
56018
55999
  "contentType": "application/json; charset=utf-8",
56019
56000
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -56517,7 +56498,7 @@
56517
56498
  }
56518
56499
  },
56519
56500
  "etablissementspublicspourlintegration": {
56520
- "name": "Etablissements publics pour l’intégration",
56501
+ "name": "Etablissements publics pour l\u2019int\u00e9gration",
56521
56502
  "ats": "smartrecruiters",
56522
56503
  "token": "EtablissementsPublicsPourLIntegration",
56523
56504
  "sourceUrl": "https://jobs.smartrecruiters.com/EtablissementsPublicsPourLIntegration",
@@ -56526,7 +56507,7 @@
56526
56507
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
56527
56508
  },
56528
56509
  "verification": {
56529
- "observedCompanyName": "Etablissements publics pour l’intégration",
56510
+ "observedCompanyName": "Etablissements publics pour l\u2019int\u00e9gration",
56530
56511
  "identityEvidence": "provider_board",
56531
56512
  "contentType": "application/json; charset=utf-8",
56532
56513
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -63241,7 +63222,7 @@
63241
63222
  }
63242
63223
  },
63243
63224
  "gek-terna": {
63244
- "name": "ΟΜΙΛΟΣ ΓΕΚ ΤΕΡΝΑ / GEK TERNA GROUP",
63225
+ "name": "\u039f\u039c\u0399\u039b\u039f\u03a3 \u0393\u0395\u039a \u03a4\u0395\u03a1\u039d\u0391 / GEK TERNA GROUP",
63245
63226
  "ats": "workable",
63246
63227
  "token": "gek-terna",
63247
63228
  "sourceUrl": "https://apply.workable.com/gek-terna/",
@@ -63250,7 +63231,7 @@
63250
63231
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
63251
63232
  },
63252
63233
  "verification": {
63253
- "observedCompanyName": "ΟΜΙΛΟΣ ΓΕΚ ΤΕΡΝΑ / GEK TERNA GROUP",
63234
+ "observedCompanyName": "\u039f\u039c\u0399\u039b\u039f\u03a3 \u0393\u0395\u039a \u03a4\u0395\u03a1\u039d\u0391 / GEK TERNA GROUP",
63254
63235
  "identityEvidence": "provider_board",
63255
63236
  "contentType": "application/json; charset=utf-8",
63256
63237
  "payloadVersion": "workable-widget:v1",
@@ -63778,7 +63759,7 @@
63778
63759
  }
63779
63760
  },
63780
63761
  "geoprobe": {
63781
- "name": "Geoprobe Systems®",
63762
+ "name": "Geoprobe Systems\u00ae",
63782
63763
  "ats": "workable",
63783
63764
  "token": "geoprobe",
63784
63765
  "sourceUrl": "https://apply.workable.com/geoprobe/",
@@ -63787,7 +63768,7 @@
63787
63768
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
63788
63769
  },
63789
63770
  "verification": {
63790
- "observedCompanyName": "Geoprobe Systems®",
63771
+ "observedCompanyName": "Geoprobe Systems\u00ae",
63791
63772
  "identityEvidence": "provider_board",
63792
63773
  "contentType": "application/json; charset=utf-8",
63793
63774
  "payloadVersion": "workable-widget:v1",
@@ -71964,7 +71945,7 @@
71964
71945
  }
71965
71946
  },
71966
71947
  "hopital-fribourgeois": {
71967
- "name": "Hôpital fribourgeois",
71948
+ "name": "H\u00f4pital fribourgeois",
71968
71949
  "ats": "breezy",
71969
71950
  "token": "hopital-fribourgeois",
71970
71951
  "sourceUrl": "https://hopital-fribourgeois.breezy.hr",
@@ -71973,7 +71954,7 @@
71973
71954
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
71974
71955
  },
71975
71956
  "verification": {
71976
- "observedCompanyName": "Hôpital fribourgeois",
71957
+ "observedCompanyName": "H\u00f4pital fribourgeois",
71977
71958
  "identityEvidence": "provider_board",
71978
71959
  "contentType": "application/json; charset=utf-8",
71979
71960
  "payloadVersion": "breezy-positions:v1",
@@ -72882,7 +72863,7 @@
72882
72863
  }
72883
72864
  },
72884
72865
  "hug": {
72885
- "name": "Hôpitaux Universitaires de Genève",
72866
+ "name": "H\u00f4pitaux Universitaires de Gen\u00e8ve",
72886
72867
  "ats": "smartrecruiters",
72887
72868
  "token": "HUG",
72888
72869
  "sourceUrl": "https://jobs.smartrecruiters.com/HUG",
@@ -72891,7 +72872,7 @@
72891
72872
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
72892
72873
  },
72893
72874
  "verification": {
72894
- "observedCompanyName": "Hôpitaux Universitaires de Genève",
72875
+ "observedCompanyName": "H\u00f4pitaux Universitaires de Gen\u00e8ve",
72895
72876
  "identityEvidence": "provider_board",
72896
72877
  "contentType": "application/json; charset=utf-8",
72897
72878
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -74718,7 +74699,7 @@
74718
74699
  }
74719
74700
  },
74720
74701
  "il-gabbiano-societa-cooperativa-sociale-onlus": {
74721
- "name": "Il Gabbiano, Società Cooperativa Sociale - ONLUS",
74702
+ "name": "Il Gabbiano, Societ\u00e0 Cooperativa Sociale - ONLUS",
74722
74703
  "ats": "breezy",
74723
74704
  "token": "il-gabbiano-societa-cooperativa-sociale-onlus",
74724
74705
  "sourceUrl": "https://il-gabbiano-societa-cooperativa-sociale-onlus.breezy.hr",
@@ -74727,7 +74708,7 @@
74727
74708
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
74728
74709
  },
74729
74710
  "verification": {
74730
- "observedCompanyName": "Il Gabbiano, Società Cooperativa Sociale - ONLUS",
74711
+ "observedCompanyName": "Il Gabbiano, Societ\u00e0 Cooperativa Sociale - ONLUS",
74731
74712
  "identityEvidence": "provider_board",
74732
74713
  "contentType": "application/json; charset=utf-8",
74733
74714
  "payloadVersion": "breezy-positions:v1",
@@ -83785,7 +83766,7 @@
83785
83766
  }
83786
83767
  },
83787
83768
  "lgihealthcaresolutionssanteinc": {
83788
- "name": "LGI Healthcare Solutions Santé Inc.",
83769
+ "name": "LGI Healthcare Solutions Sant\u00e9 Inc.",
83789
83770
  "ats": "smartrecruiters",
83790
83771
  "token": "LGIHealthcareSolutionsSanteInc",
83791
83772
  "sourceUrl": "https://jobs.smartrecruiters.com/LGIHealthcareSolutionsSanteInc",
@@ -83794,7 +83775,7 @@
83794
83775
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
83795
83776
  },
83796
83777
  "verification": {
83797
- "observedCompanyName": "LGI Healthcare Solutions Santé Inc.",
83778
+ "observedCompanyName": "LGI Healthcare Solutions Sant\u00e9 Inc.",
83798
83779
  "identityEvidence": "provider_board",
83799
83780
  "contentType": "application/json; charset=utf-8",
83800
83781
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -83937,7 +83918,7 @@
83937
83918
  }
83938
83919
  },
83939
83920
  "libraryofparliamentbibliothequeduparlement": {
83940
- "name": "Library of Parliament / Bibliothèque du Parlement",
83921
+ "name": "Library of Parliament / Biblioth\u00e8que du Parlement",
83941
83922
  "ats": "smartrecruiters",
83942
83923
  "token": "LibraryOfParliamentBibliothequeDuParlement",
83943
83924
  "sourceUrl": "https://jobs.smartrecruiters.com/LibraryOfParliamentBibliothequeDuParlement",
@@ -83946,7 +83927,7 @@
83946
83927
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
83947
83928
  },
83948
83929
  "verification": {
83949
- "observedCompanyName": "Library of Parliament / Bibliothèque du Parlement",
83930
+ "observedCompanyName": "Library of Parliament / Biblioth\u00e8que du Parlement",
83950
83931
  "identityEvidence": "provider_board",
83951
83932
  "contentType": "application/json; charset=utf-8",
83952
83933
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -86818,7 +86799,7 @@
86818
86799
  }
86819
86800
  },
86820
86801
  "mcdonaldsoesterreich": {
86821
- "name": "McDonald's Österreich",
86802
+ "name": "McDonald's \u00d6sterreich",
86822
86803
  "ats": "smartrecruiters",
86823
86804
  "token": "McDonaldsOesterreich",
86824
86805
  "sourceUrl": "https://jobs.smartrecruiters.com/McDonaldsOesterreich",
@@ -86827,7 +86808,7 @@
86827
86808
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
86828
86809
  },
86829
86810
  "verification": {
86830
- "observedCompanyName": "McDonald's Österreich",
86811
+ "observedCompanyName": "McDonald's \u00d6sterreich",
86831
86812
  "identityEvidence": "provider_board",
86832
86813
  "contentType": "application/json; charset=utf-8",
86833
86814
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -87383,7 +87364,7 @@
87383
87364
  }
87384
87365
  },
87385
87366
  "methode": {
87386
- "name": "Méthode Srl",
87367
+ "name": "M\u00e9thode Srl",
87387
87368
  "ats": "breezy",
87388
87369
  "token": "methode",
87389
87370
  "sourceUrl": "https://methode.breezy.hr",
@@ -87392,7 +87373,7 @@
87392
87373
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
87393
87374
  },
87394
87375
  "verification": {
87395
- "observedCompanyName": "Méthode Srl",
87376
+ "observedCompanyName": "M\u00e9thode Srl",
87396
87377
  "identityEvidence": "provider_board",
87397
87378
  "contentType": "application/json; charset=utf-8",
87398
87379
  "payloadVersion": "breezy-positions:v1",
@@ -89950,7 +89931,7 @@
89950
89931
  }
89951
89932
  },
89952
89933
  "natureo": {
89953
- "name": "naturéO",
89934
+ "name": "natur\u00e9O",
89954
89935
  "ats": "smartrecruiters",
89955
89936
  "token": "natureO",
89956
89937
  "sourceUrl": "https://jobs.smartrecruiters.com/natureO",
@@ -89959,7 +89940,7 @@
89959
89940
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
89960
89941
  },
89961
89942
  "verification": {
89962
- "observedCompanyName": "naturéO",
89943
+ "observedCompanyName": "natur\u00e9O",
89963
89944
  "identityEvidence": "provider_board",
89964
89945
  "contentType": "application/json; charset=utf-8",
89965
89946
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -91650,7 +91631,7 @@
91650
91631
  }
91651
91632
  },
91652
91633
  "officeofthecommonwealthsattorneyforarlingtoncountyandthecityoffallschurch": {
91653
- "name": "Office of the Commonwealth’s Attorney for Arlington County and the City of Falls Church",
91634
+ "name": "Office of the Commonwealth\u2019s Attorney for Arlington County and the City of Falls Church",
91654
91635
  "ats": "smartrecruiters",
91655
91636
  "token": "OfficeOfTheCommonwealthsAttorneyForArlingtonCountyAndTheCityOfFallsChurch",
91656
91637
  "sourceUrl": "https://jobs.smartrecruiters.com/OfficeOfTheCommonwealthsAttorneyForArlingtonCountyAndTheCityOfFallsChurch",
@@ -91659,7 +91640,7 @@
91659
91640
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
91660
91641
  },
91661
91642
  "verification": {
91662
- "observedCompanyName": "Office of the Commonwealth’s Attorney for Arlington County and the City of Falls Church",
91643
+ "observedCompanyName": "Office of the Commonwealth\u2019s Attorney for Arlington County and the City of Falls Church",
91663
91644
  "identityEvidence": "provider_board",
91664
91645
  "contentType": "application/json; charset=utf-8",
91665
91646
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -94986,7 +94967,7 @@
94986
94967
  }
94987
94968
  },
94988
94969
  "reitmanscanadalteltd": {
94989
- "name": "Reitmans (Canada) Ltée/Ltd",
94970
+ "name": "Reitmans (Canada) Lt\u00e9e/Ltd",
94990
94971
  "ats": "smartrecruiters",
94991
94972
  "token": "ReitmansCanadaLteLtd",
94992
94973
  "sourceUrl": "https://jobs.smartrecruiters.com/ReitmansCanadaLteLtd",
@@ -94995,7 +94976,7 @@
94995
94976
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
94996
94977
  },
94997
94978
  "verification": {
94998
- "observedCompanyName": "Reitmans (Canada) Ltée/Ltd",
94979
+ "observedCompanyName": "Reitmans (Canada) Lt\u00e9e/Ltd",
94999
94980
  "identityEvidence": "provider_board",
95000
94981
  "contentType": "application/json; charset=utf-8",
95001
94982
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -95005,7 +94986,7 @@
95005
94986
  }
95006
94987
  },
95007
94988
  "relaischateaux": {
95008
- "name": "Relais & Châteaux",
94989
+ "name": "Relais & Ch\u00e2teaux",
95009
94990
  "ats": "smartrecruiters",
95010
94991
  "token": "RelaisChateaux",
95011
94992
  "sourceUrl": "https://jobs.smartrecruiters.com/RelaisChateaux",
@@ -95014,7 +94995,7 @@
95014
94995
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
95015
94996
  },
95016
94997
  "verification": {
95017
- "observedCompanyName": "Relais & Châteaux",
94998
+ "observedCompanyName": "Relais & Ch\u00e2teaux",
95018
94999
  "identityEvidence": "provider_board",
95019
95000
  "contentType": "application/json; charset=utf-8",
95020
95001
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -99138,7 +99119,7 @@
99138
99119
  }
99139
99120
  },
99140
99121
  "staubligroup": {
99141
- "name": "Stäubli",
99122
+ "name": "St\u00e4ubli",
99142
99123
  "ats": "smartrecruiters",
99143
99124
  "token": "StaubliGroup",
99144
99125
  "sourceUrl": "https://jobs.smartrecruiters.com/StaubliGroup",
@@ -99147,7 +99128,7 @@
99147
99128
  "reference": "https://index.commoncrawl.org/CC-MAIN-2026-34-index"
99148
99129
  },
99149
99130
  "verification": {
99150
- "observedCompanyName": "Stäubli",
99131
+ "observedCompanyName": "St\u00e4ubli",
99151
99132
  "identityEvidence": "provider_board",
99152
99133
  "contentType": "application/json; charset=utf-8",
99153
99134
  "payloadVersion": "smartrecruiters-postings:v1",
@@ -108103,5 +108084,120 @@
108103
108084
  "checkedAt": "2026-09-08T09:37:45.626Z",
108104
108085
  "canonicalSourceUrl": "https://zypp.keka.com/careers"
108105
108086
  }
108087
+ },
108088
+ "accenture-india": {
108089
+ "name": "Accenture",
108090
+ "ats": "accenture",
108091
+ "token": "in-en",
108092
+ "companyDomain": "accenture.com",
108093
+ "sourceUrl": "https://www.accenture.com/in-en/careers/jobsearch",
108094
+ "cohorts": [
108095
+ "IN"
108096
+ ],
108097
+ "discoveredFrom": {
108098
+ "channel": "career_page",
108099
+ "reference": "https://www.accenture.com/in-en/careers/jobsearch"
108100
+ },
108101
+ "verification": {
108102
+ "checkedAt": "2026-09-11T05:39:41.016030+00:00",
108103
+ "canonicalSourceUrl": "https://www.accenture.com/in-en/careers/jobsearch",
108104
+ "observedCompanyName": "Accenture",
108105
+ "identityEvidence": "company_site",
108106
+ "contentType": "application/json",
108107
+ "payloadVersion": "accenture-findjobs:v1",
108108
+ "jobCount": 20000
108109
+ }
108110
+ },
108111
+ "infosys": {
108112
+ "name": "Infosys",
108113
+ "ats": "infosys",
108114
+ "token": "1",
108115
+ "companyDomain": "infosys.com",
108116
+ "sourceUrl": "https://career.infosys.com/joblist",
108117
+ "cohorts": [
108118
+ "IN"
108119
+ ],
108120
+ "discoveredFrom": {
108121
+ "channel": "career_page",
108122
+ "reference": "https://career.infosys.com/joblist"
108123
+ },
108124
+ "verification": {
108125
+ "checkedAt": "2026-09-11T05:39:41.016030+00:00",
108126
+ "canonicalSourceUrl": "https://career.infosys.com/joblist",
108127
+ "observedCompanyName": "Infosys",
108128
+ "identityEvidence": "company_site",
108129
+ "contentType": "application/json",
108130
+ "payloadVersion": "infosys-careersearch:v1",
108131
+ "jobCount": 1652
108132
+ }
108133
+ },
108134
+ "infosys-bpm": {
108135
+ "name": "Infosys BPM",
108136
+ "ats": "infosys",
108137
+ "token": "41",
108138
+ "companyDomain": "infosysbpm.com",
108139
+ "sourceUrl": "https://career.infosys.com/joblist",
108140
+ "cohorts": [
108141
+ "IN"
108142
+ ],
108143
+ "discoveredFrom": {
108144
+ "channel": "career_page",
108145
+ "reference": "https://career.infosys.com/joblist"
108146
+ },
108147
+ "verification": {
108148
+ "checkedAt": "2026-09-11T05:39:41.016030+00:00",
108149
+ "canonicalSourceUrl": "https://career.infosys.com/joblist",
108150
+ "observedCompanyName": "Infosys BPM",
108151
+ "identityEvidence": "company_site",
108152
+ "contentType": "application/json",
108153
+ "payloadVersion": "infosys-careersearch:v1",
108154
+ "jobCount": 113
108155
+ }
108156
+ },
108157
+ "capgemini-india": {
108158
+ "name": "Capgemini",
108159
+ "ats": "capgemini",
108160
+ "token": "in-en",
108161
+ "companyDomain": "capgemini.com",
108162
+ "sourceUrl": "https://www.capgemini.com/in-en/careers/join-capgemini/job-search/",
108163
+ "cohorts": [
108164
+ "IN"
108165
+ ],
108166
+ "discoveredFrom": {
108167
+ "channel": "career_page",
108168
+ "reference": "https://www.capgemini.com/in-en/careers/join-capgemini/job-search/"
108169
+ },
108170
+ "verification": {
108171
+ "checkedAt": "2026-09-11T05:39:41.016030+00:00",
108172
+ "canonicalSourceUrl": "https://www.capgemini.com/in-en/careers/join-capgemini/job-search/",
108173
+ "observedCompanyName": "Capgemini",
108174
+ "identityEvidence": "company_site",
108175
+ "contentType": "application/json",
108176
+ "payloadVersion": "capgemini-jobstream:v1",
108177
+ "jobCount": 922
108178
+ }
108179
+ },
108180
+ "amazon-india": {
108181
+ "name": "Amazon",
108182
+ "ats": "amazon",
108183
+ "token": "IND",
108184
+ "companyDomain": "amazon.jobs",
108185
+ "sourceUrl": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND",
108186
+ "cohorts": [
108187
+ "IN"
108188
+ ],
108189
+ "discoveredFrom": {
108190
+ "channel": "career_page",
108191
+ "reference": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND"
108192
+ },
108193
+ "verification": {
108194
+ "checkedAt": "2026-09-11T06:43:09.522600+00:00",
108195
+ "canonicalSourceUrl": "https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=IND",
108196
+ "observedCompanyName": "Amazon",
108197
+ "identityEvidence": "company_site",
108198
+ "contentType": "application/json",
108199
+ "payloadVersion": "amazon-jobs-search:v1",
108200
+ "jobCount": 2368
108201
+ }
108106
108202
  }
108107
- }
108203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
@@ -0,0 +1,103 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicJson } from "./atomic-file.ts";
3
+
4
+ /**
5
+ * A rolling 30-day map of who is hiring in a country, built from Adzuna within its search allowance. Each run
6
+ * samples pages spread evenly across the last day's postings (newest first), so bursts from one employer do not
7
+ * crowd out the rest, and merges them into a state file keyed by Adzuna's posting id. Jobs are never ingested;
8
+ * the map only says which employers are hiring, how much, and whether our catalog covers them.
9
+ */
10
+ export interface MarketEmployer { name: string; postings: number; firstSeen: string; lastSeen: string; cities: Record<string, number>; categories: Record<string, number> }
11
+ export interface MarketState { country: string; updatedAt: string; runs: number; hitsUsed: number; seen: Record<string, { employer: string; created: string }>; employers: Record<string, MarketEmployer> }
12
+ export interface MarketCoverage { employers: number; postings: number; coveredEmployers: number; coveredPostings: number; uncovered: Array<{ name: string; postings: number; cities: string[] }> }
13
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
14
+
15
+ export async function sampleAdzunaMarket(appId: string, appKey: string, statePath: string, options: { country?: string; maxHits?: number; fetcher?: Fetch; sleep?: (ms: number) => Promise<void>; now?: () => Date; delayMs?: number } = {}): Promise<{ state: MarketState; hits: number; dayTotal: number; newPostings: number; newEmployers: number }> {
16
+ const fetcher = options.fetcher ?? globalThis.fetch;
17
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
18
+ const now = options.now ?? (() => new Date());
19
+ const country = (options.country ?? "in").toLowerCase();
20
+ const state: MarketState = await readFile(statePath, "utf8").then((text) => JSON.parse(text) as MarketState).catch(() => ({ country, updatedAt: "", runs: 0, hitsUsed: 0, seen: {}, employers: {} }));
21
+ const maxHits = Math.max(1, options.maxHits ?? 75);
22
+ const search = async (page: number) => {
23
+ const url = new URL(`https://api.adzuna.com/v1/api/jobs/${country}/search/${page}`);
24
+ for (const [key, value] of Object.entries({ app_id: appId, app_key: appKey, results_per_page: "50", "content-type": "application/json", max_days_old: "1", sort_by: "date" })) url.searchParams.set(key, value);
25
+ const response = await fetcher(url.href, { signal: AbortSignal.timeout(30_000) });
26
+ if (response.status === 429) throw new Error("Adzuna allowance reached");
27
+ if (!response.ok) throw new Error(`Adzuna HTTP ${response.status}`);
28
+ return await response.json() as { count?: number; results?: Array<Record<string, unknown>> };
29
+ };
30
+ let hits = 0; let newPostings = 0; let newEmployers = 0;
31
+ const absorb = (results: Array<Record<string, unknown>>) => {
32
+ for (const job of results) {
33
+ const id = String(job.id ?? ""); if (!id || state.seen[id]) continue;
34
+ const name = isRecord(job.company) ? String(job.company.display_name ?? "").trim() : "";
35
+ if (name.length < 2) continue;
36
+ const key = name.toLowerCase();
37
+ const created = typeof job.created === "string" ? job.created : now().toISOString();
38
+ state.seen[id] = { employer: key, created }; newPostings += 1;
39
+ const employer = state.employers[key] ?? (newEmployers += 1, { name, postings: 0, firstSeen: created, lastSeen: created, cities: {}, categories: {} });
40
+ employer.postings += 1;
41
+ if (created > employer.lastSeen) employer.lastSeen = created;
42
+ const area = isRecord(job.location) && Array.isArray(job.location.area) ? String(job.location.area[job.location.area.length - 1] ?? "") : "";
43
+ if (area) employer.cities[area] = (employer.cities[area] ?? 0) + 1;
44
+ const category = isRecord(job.category) ? String(job.category.label ?? "") : "";
45
+ if (category) employer.categories[category] = (employer.categories[category] ?? 0) + 1;
46
+ state.employers[key] = employer;
47
+ }
48
+ };
49
+ const first = await search(1); hits += 1; absorb(first.results ?? []);
50
+ const dayTotal = first.count ?? 0;
51
+ const pages = Math.max(1, Math.ceil(dayTotal / 50));
52
+ // Spread the remaining allowance evenly across the day's pages.
53
+ const picks = new Set<number>();
54
+ for (let index = 1; index < Math.min(maxHits, pages); index += 1) picks.add(1 + Math.round((index * (pages - 1)) / Math.max(1, Math.min(maxHits, pages) - 1)));
55
+ picks.delete(1);
56
+ for (const page of [...picks].sort((left, right) => left - right)) {
57
+ if (hits >= maxHits) break;
58
+ await sleep(options.delayMs ?? 2_600); // Adzuna allows 25 searches a minute
59
+ try { absorb((await search(page)).results ?? []); hits += 1; }
60
+ catch (error) { hits += 1; if (String(error).includes("allowance")) break; }
61
+ }
62
+ // Keep a rolling 30 days: postings older than that drop out of both the dedupe set and the employer counts.
63
+ const cutoff = new Date(now().getTime() - 30 * 86_400_000).toISOString();
64
+ for (const [id, entry] of Object.entries(state.seen)) {
65
+ if (entry.created >= cutoff) continue;
66
+ delete state.seen[id];
67
+ const employer = state.employers[entry.employer];
68
+ if (employer && --employer.postings <= 0) delete state.employers[entry.employer];
69
+ }
70
+ state.updatedAt = now().toISOString(); state.runs += 1; state.hitsUsed += hits;
71
+ await atomicJson(statePath, state);
72
+ return { state, hits, dayTotal, newPostings, newEmployers };
73
+ }
74
+
75
+ const STOP = new Set(["the", "inc", "llc", "ltd", "limited", "pvt", "private", "corp", "corporation", "company", "co", "group", "india", "technologies", "technology", "solutions", "services", "global", "international", "plc", "llp", "and", "of", "com"]);
76
+ function keysFor(name: string): string[] {
77
+ const words = name.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter(Boolean);
78
+ const core = words.filter((word) => !STOP.has(word));
79
+ const base = core.length ? core : words;
80
+ const full = base.join("");
81
+ // A short whole name (JLL, MSD, ABB) is still a name; only a lone first word needs four letters to avoid false matches.
82
+ return [...new Set([full, words.join(""), ...(ALIASES[full] ?? []), ...(base[0] && base[0].length >= 4 ? [base[0]] : [])].filter((key) => key.length >= 2))];
83
+ }
84
+ /** Employers our catalog knows under a provider tenant that looks nothing like the name Adzuna shows. */
85
+ const ALIASES: Record<string, string[]> = { pricewaterhousecoopers: ["pwc"], deutschebank: ["db"], standardchartered: ["standard"], spglobal: ["spgi"], jonesianglasalle: ["jll"], merck: ["msd"], johnsonandjohnson: ["jj"], wellsfargo: ["wf"], northropgrumman: ["ngc"] };
86
+
87
+ /** Which of the market's employers the catalog covers, matched on slugs, names, provider tenants, and company domains. */
88
+ export function marketCoverage(state: MarketState, catalog: Record<string, { name: string; token: string; companyDomain?: string }>, limit = 50): MarketCoverage {
89
+ const known = new Set<string>();
90
+ const squash = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
91
+ for (const [slug, entry] of Object.entries(catalog)) for (const key of [slug, entry.name, entry.token.split("/")[0]!.split(".")[0]!, (entry.companyDomain ?? "").split(".")[0]!]) { const value = squash(key); if (value.length >= 3) known.add(value); }
92
+ const employers = Object.values(state.employers);
93
+ const covered = employers.filter((employer) => keysFor(employer.name).some((key) => known.has(key)));
94
+ const coveredSet = new Set(covered);
95
+ return {
96
+ employers: employers.length, postings: employers.reduce((sum, employer) => sum + employer.postings, 0),
97
+ coveredEmployers: covered.length, coveredPostings: covered.reduce((sum, employer) => sum + employer.postings, 0),
98
+ uncovered: employers.filter((employer) => !coveredSet.has(employer)).sort((left, right) => right.postings - left.postings).slice(0, limit)
99
+ .map((employer) => ({ name: employer.name, postings: employer.postings, cities: Object.entries(employer.cities).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([city]) => city) })),
100
+ };
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
package/src/catalog.ts CHANGED
@@ -320,8 +320,8 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
320
320
 
321
321
  /** JSON fetch with the catalog's retry and backoff, shaped for the table-driven providers. */
322
322
  function jsonGetter(fetcher: Fetch, companyName: string, signal?: AbortSignal, observer?: FetchJobsObserver): JsonGet {
323
- return async (url, format = "json") => {
324
- const response = await fetchWithRetry(fetcher, url, signal ? { signal } : undefined, companyName, observer);
323
+ return async (url, format = "json", init) => {
324
+ const response = await fetchWithRetry(fetcher, url, { ...(init ?? {}), ...(signal ? { signal } : {}) }, companyName, observer);
325
325
  if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new Error(`${companyName} job board returned HTTP ${response.status}`); }
326
326
  return format === "text" ? response.text() : response.json();
327
327
  };
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ import { kekaTenantCandidates } from "./keka-tenants.ts";
23
23
  import { collectJoobleSignals } from "./jooble-signals.ts";
24
24
  import { collectAdzunaSignals } from "./adzuna-signals.ts";
25
25
  import { resolveEmployers } from "./employer-resolver.ts";
26
+ import { marketCoverage, sampleAdzunaMarket } from "./adzuna-market.ts";
26
27
  import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recruitee-round.ts";
27
28
 
28
29
  const HELP = `Openings — search public company job boards
@@ -46,6 +47,7 @@ Usage:
46
47
  openings sources keka-tenants HOSTS.txt [--output FILE] [--registry FILE] [--concurrency N]
47
48
  openings sources jooble-signals [--location PLACE] [--max-pages N] [--output FILE] (key from JOOBLE_API_KEY)
48
49
  openings sources adzuna-signals [--country in] [--max-hits N] [--max-days-old N] [--output FILE] (ADZUNA_APP_ID and ADZUNA_APP_KEY)
50
+ openings sources adzuna-market [--country in] [--max-hits 75] [--state FILE] [--catalog FILE] (daily sample of who is hiring; ADZUNA_APP_ID and ADZUNA_APP_KEY)
49
51
  openings sources resolve-employers SIGNALS.json [--catalog FILE] [--registry FILE] [--min-jobs N] [--limit N] [--concurrency N] [--report FILE]
50
52
  openings sources prepare-recruitee-round IDENTITIES.json [--catalog FILE] [--artifacts DIR]
51
53
  openings sources merge-attempted-round-leads ISOLATED_REGISTRY [--registry FILE]
@@ -137,6 +139,18 @@ export async function run(args: string[]): Promise<number> {
137
139
  console.log(JSON.stringify({ ...summary, topSites: sites.slice(0, 15) }, null, 2));
138
140
  return 0;
139
141
  }
142
+ if (rest[0] === "adzuna-market") {
143
+ const args = rest.slice(1);
144
+ const appId = process.env.ADZUNA_APP_ID; const appKey = process.env.ADZUNA_APP_KEY;
145
+ if (!appId || !appKey) return fail("sources adzuna-market requires ADZUNA_APP_ID and ADZUNA_APP_KEY in the environment");
146
+ const value = (flag: string) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
147
+ const statePath = value("--state") ?? ".openings/adzuna-market.json";
148
+ const run = await sampleAdzunaMarket(appId, appKey, statePath, { country: value("--country"), maxHits: value("--max-hits") ? Number(value("--max-hits")) : undefined });
149
+ const catalog = JSON.parse(await readFile(value("--catalog") ?? "data/companies.json", "utf8"));
150
+ const coverage = marketCoverage(run.state, catalog, 40);
151
+ console.log(JSON.stringify({ run: { hits: run.hits, postingsInLastDay: run.dayTotal, newPostings: run.newPostings, newEmployers: run.newEmployers, runs: run.state.runs, hitsUsed: run.state.hitsUsed }, market: { employers: coverage.employers, postings: coverage.postings, coveredEmployers: coverage.coveredEmployers, coveredPostings: coverage.coveredPostings, coveredShare: coverage.postings ? Math.round((100 * coverage.coveredPostings) / coverage.postings) : 0 }, uncovered: coverage.uncovered }, null, 2));
152
+ return 0;
153
+ }
140
154
  if (rest[0] === "resolve-employers") {
141
155
  const args = rest.slice(1);
142
156
  const signalsPath = args[0];
@@ -39,7 +39,7 @@ export interface CommonCrawlDiscoveryReport extends ReportMeta {
39
39
 
40
40
  const providerPatterns: Record<Ats, string[]> = {
41
41
  greenhouse: ["job-boards.greenhouse.io/*", "boards.greenhouse.io/*"], lever: ["jobs.lever.co/*"], ashby: ["jobs.ashbyhq.com/*"], workday: ["*.myworkdayjobs.com/*"], recruitee: ["*.recruitee.com/*"],
42
- ...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy" | "freshteam" | "keka" | "zohorecruit", string[]>,
42
+ ...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy" | "freshteam" | "keka" | "zohorecruit" | "accenture" | "infosys" | "capgemini" | "amazon", string[]>,
43
43
  jobposting: [], // company sites are found by probing seeds, never by URL pattern
44
44
  };
45
45
  const patterns = Object.values(providerPatterns).flat();
package/src/portals.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { classifyJob } from "./locations.ts";
2
+ import { asRecords, isRecord, plainText, str, type JsonGet, type ProviderSpec } from "./providers.ts";
3
+ import type { Company, Job } from "./types.ts";
4
+
5
+ /**
6
+ * Employer portals: large Indian employers whose own careers site loads its listing from a public JSON endpoint
7
+ * that needs no login and that robots.txt does not disallow. Same technique as the ATS adapters, one employer or
8
+ * platform per entry. Only public job fields are read; anything else in a payload is never stored.
9
+ */
10
+ type Rec = Record<string, unknown>;
11
+ const DAY = 86_400_000;
12
+ const job = (company: Company, id: string, fields: { title: string; location: string; url: string; updatedAt?: string; description: string; remote?: boolean }): Job => classifyJob({
13
+ id: `${company.ats}:${company.slug}:${id}`, company: company.name, title: fields.title, location: fields.location || "Unspecified",
14
+ remote: fields.remote === true, workMode: fields.remote ? "remote" : "unknown",
15
+ eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
16
+ url: fields.url, ...(fields.updatedAt ? { updatedAt: fields.updatedAt } : {}), description: fields.description,
17
+ });
18
+ const titleCase = (value: string) => value.trim().toLowerCase().replace(/\s+/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
19
+ const iso = (time: number) => new Date(time).toISOString();
20
+
21
+ /** "Posted within last 24 hours", "Posted 3 days ago", "Posted 1 month ago", "Posted more than 1 month ago". */
22
+ export function accenturePostedAt(label: string, now = Date.now()): string | undefined {
23
+ const text = label.toLowerCase();
24
+ if (/24 hours|today/.test(text)) return iso(now);
25
+ const days = /(\d+)\s*days?/.exec(text); if (days) return iso(now - Number(days[1]) * DAY);
26
+ const weeks = /(\d+)\s*weeks?/.exec(text); if (weeks) return iso(now - Number(weeks[1]) * 7 * DAY);
27
+ if (/more than 1 month/.test(text)) return iso(now - 45 * DAY);
28
+ if (/1 month/.test(text)) return iso(now - 30 * DAY);
29
+ return undefined;
30
+ }
31
+
32
+ /** JobPosting description from an employer's job page, for the full text on demand. */
33
+ async function jsonLdDescription(url: string, get: JsonGet): Promise<string> {
34
+ const html = String(await get(url, "text"));
35
+ for (const match of html.matchAll(/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
36
+ try {
37
+ const value = JSON.parse(match[1]!.trim()) as unknown;
38
+ for (const node of Array.isArray(value) ? value : [value]) if (isRecord(node) && String(node["@type"]).toLowerCase() === "jobposting" && typeof node.description === "string") return plainText(node.description);
39
+ } catch { /* not JSON */ }
40
+ }
41
+ return "";
42
+ }
43
+
44
+ const ACCENTURE_SITES: Record<string, string> = { "in-en": "India", "us-en": "United States" };
45
+ const ACCENTURE_FIELDS = ["requisitionId", "title", "location", "feedCity", "country", "jobDetailUrl", "postedDateText", "staticExtractiveSummary", "mustHaveSkills", "goodToHaveSkills", "yearsOfExperience", "qualificationShort", "remoteType"];
46
+ const accenture: ProviderSpec = {
47
+ ats: "accenture", label: "Accenture careers", hosts: ["accenture.com"], crawlPatterns: [],
48
+ resolve(url) { const site = /^\/(in-en|us-en)\/careers/i.exec(url.pathname)?.[1]?.toLowerCase(); return /(^|\.)accenture\.com$/i.test(url.hostname) && site ? site : null; },
49
+ canonicalUrl: (token) => `https://www.accenture.com/${token}/careers/jobsearch`,
50
+ endpoint: () => "https://www.accenture.com/api/accenture/elastic/findjobs",
51
+ jobsFromBody: (body) => (isRecord(body) ? asRecords(body.data) : null),
52
+ providerName: () => "Accenture",
53
+ payloadVersion: () => "accenture-findjobs:v1",
54
+ /** Newest first, 100 a page; stops once a page is older than 30 days, which keeps the crawl to the roles people act on. */
55
+ async fetchAll(token, get) {
56
+ const records: Rec[] = [];
57
+ for (let start = 0; start < 40_000; start += 100) {
58
+ const form = new FormData();
59
+ for (const [key, value] of Object.entries({ startIndex: String(start), maxResultSize: "100", jobKeyword: "", jobCountry: ACCENTURE_SITES[token] ?? "India", jobLanguage: "en", countrySite: token, sortBy: "1", searchType: "vectorSearch", enableQueryBoost: "true", minScore: "0.6", totalHits: "true", jobFilters: "[]" })) form.append(key, value);
60
+ const page = accenture.jobsFromBody(await get(accenture.endpoint(token), "json", { method: "POST", body: form })) ?? [];
61
+ // Rows carry full descriptions and internal fields (about 25 KB each); keep only the public fields normalize reads.
62
+ records.push(...page.map((row) => Object.fromEntries(ACCENTURE_FIELDS.map((key) => [key, row[key]]))));
63
+ const last = str(page[page.length - 1]?.postedDateText).toLowerCase();
64
+ if (page.length < 100 || /month/.test(last)) break;
65
+ }
66
+ return records;
67
+ },
68
+ normalize(company, record) {
69
+ const places = Array.isArray(record.location) ? record.location.map(str).filter(Boolean) : [];
70
+ const country = str(record.country) || ACCENTURE_SITES[company.token] || "India";
71
+ const location = (places.length ? places : [str(record.feedCity)]).filter(Boolean).map((place) => `${place}, ${country}`).join("; ") || country;
72
+ const skills = [str(record.mustHaveSkills) && `Must have skills: ${str(record.mustHaveSkills)}`, str(record.goodToHaveSkills) && `Good to have skills: ${str(record.goodToHaveSkills)}`].filter(Boolean).join("\n");
73
+ const description = [plainText(str(record.staticExtractiveSummary)), skills, str(record.yearsOfExperience), str(record.qualificationShort) && `Educational qualification: ${str(record.qualificationShort)}`].filter(Boolean).join("\n\n").slice(0, 2_000);
74
+ return job(company, str(record.requisitionId), {
75
+ title: str(record.title), location, url: str(record.jobDetailUrl).replace("{0}", company.token),
76
+ updatedAt: accenturePostedAt(str(record.postedDateText)), description, remote: /remote/i.test(str(record.remoteType)),
77
+ });
78
+ },
79
+ detail: (_company, current, get) => jsonLdDescription(current.url, get),
80
+ };
81
+
82
+ const infosys: ProviderSpec = {
83
+ ats: "infosys", label: "Infosys careers", hosts: ["infosys.com", "infosysapps.com", "infosysbpm.com"], crawlPatterns: [],
84
+ resolve(url) { return /intapgateway\.infosysapps\.com$/i.test(url.hostname) ? url.searchParams.get("sourceId") : null; },
85
+ canonicalUrl: () => "https://career.infosys.com/joblist",
86
+ endpoint: (token) => `https://intapgateway.infosysapps.com/careersci/search/intapjbsrch/getCareerSearchJobs?sourceId=${encodeURIComponent(token)}&searchText=ALL`,
87
+ jobsFromBody: (body) => asRecords(body),
88
+ providerName: (jobs) => str(jobs[0]?.company),
89
+ payloadVersion: () => "infosys-careersearch:v1",
90
+ normalize(company, record) {
91
+ const created = Date.parse(`${str(record.createdOn)}+05:30`); // the gateway returns India time without a zone
92
+ const description = ["rolesResponsibilities", "technicalRequirement", "additionalResponsibility", "preferredSkills", "educationalRequirement"].map((key) => plainText(str(record[key]))).filter(Boolean).join("\n\n").slice(0, 6_000);
93
+ return job(company, str(record.postingId), {
94
+ title: str(record.postingTitle), location: `${titleCase(str(record.location))}, ${str(record.country) || "India"}`,
95
+ url: `https://career.infosys.com/jobdesc?jobReferenceCode=${encodeURIComponent(str(record.referenceCode))}&sourceId=${encodeURIComponent(str(record.sourceId))}`,
96
+ ...(Number.isFinite(created) ? { updatedAt: iso(created) } : {}), description,
97
+ });
98
+ },
99
+ };
100
+
101
+ const capgemini: ProviderSpec = {
102
+ ats: "capgemini", label: "Capgemini careers", hosts: ["capgemini.com"], crawlPatterns: [],
103
+ resolve(url) { const site = /^\/(in-en|us-en)\//i.exec(url.pathname)?.[1]?.toLowerCase(); return /(^|\.)capgemini\.com$/i.test(url.hostname) && site ? site : null; },
104
+ canonicalUrl: (token) => `https://www.capgemini.com/${token}/careers/join-capgemini/job-search/`,
105
+ endpoint: (token) => `https://cg-jobstream-api.azurewebsites.net/api/job-search?page=1&size=1000&country_code=${encodeURIComponent(token)}`,
106
+ jobsFromBody: (body) => (isRecord(body) ? asRecords(body.data) : null),
107
+ providerName: () => "Capgemini",
108
+ payloadVersion: () => "capgemini-jobstream:v1",
109
+ // Capgemini's feed carries a last-updated time, not a posting date; it is the best date the employer publishes.
110
+ normalize(company, record) {
111
+ const ref = str(record.ref); const source = str(record.source).toLowerCase();
112
+ return job(company, str(record.id), {
113
+ title: str(record.title), location: `${str(record.location)}, ${str(record.country_name) || "India"}`,
114
+ url: `https://www.capgemini.com/${company.token}/jobs/${encodeURIComponent(ref)}+${encodeURIComponent(source)}`,
115
+ updatedAt: str(record.updated_at) || undefined, description: plainText(str(record.description_stripped) || str(record.description)).slice(0, 6_000),
116
+ });
117
+ },
118
+ };
119
+
120
+ /** Amazon's public job search (amazon.jobs/en/search.json; robots.txt disallows only /internal). Token is the ISO-3 country code. */
121
+ const amazon: ProviderSpec = {
122
+ ats: "amazon", label: "Amazon jobs", hosts: ["amazon.jobs"], crawlPatterns: [],
123
+ resolve(url) { return /(^|\.)amazon\.jobs$/i.test(url.hostname) ? (url.searchParams.get("normalized_country_code[]") ?? null) : null; },
124
+ canonicalUrl: (token) => `https://www.amazon.jobs/en/search?normalized_country_code%5B%5D=${encodeURIComponent(token)}`,
125
+ endpoint: (token) => `https://www.amazon.jobs/en/search.json?normalized_country_code%5B%5D=${encodeURIComponent(token)}&result_limit=100&sort=recent&offset=0`,
126
+ jobsFromBody: (body) => (isRecord(body) ? asRecords(body.jobs) : null),
127
+ providerName: () => "Amazon",
128
+ payloadVersion: () => "amazon-jobs-search:v1",
129
+ async fetchAll(token, get) {
130
+ const records: Rec[] = [];
131
+ for (let offset = 0; offset < 10_000; offset += 100) {
132
+ const reply = await get(`https://www.amazon.jobs/en/search.json?normalized_country_code%5B%5D=${encodeURIComponent(token)}&result_limit=100&sort=recent&offset=${offset}`);
133
+ const page = amazon.jobsFromBody(reply) ?? [];
134
+ records.push(...page.map((row) => Object.fromEntries(AMAZON_FIELDS.map((key) => [key, row[key]]))));
135
+ const total = isRecord(reply) ? Number(reply.hits) : 0;
136
+ if (page.length < 100 || records.length >= total) break;
137
+ }
138
+ return records;
139
+ },
140
+ normalize(company, record) {
141
+ const posted = Date.parse(`${str(record.posted_date)} 00:00:00 UTC`);
142
+ const description = [plainText(str(record.description_short)), str(record.basic_qualifications) && `Basic qualifications:\n${plainText(str(record.basic_qualifications))}`, str(record.preferred_qualifications) && `Preferred qualifications:\n${plainText(str(record.preferred_qualifications))}`].filter(Boolean).join("\n\n").slice(0, 3_000);
143
+ const country = str(record.country_code) === "IND" ? "India" : str(record.country_code) === "USA" ? "United States" : str(record.country_code);
144
+ return job(company, str(record.id_icims) || str(record.id), {
145
+ title: str(record.title), location: [str(record.city), str(record.state), country].filter(Boolean).join(", "),
146
+ url: `https://www.amazon.jobs${str(record.job_path)}`, ...(Number.isFinite(posted) ? { updatedAt: iso(posted) } : {}), description,
147
+ });
148
+ },
149
+ };
150
+ const AMAZON_FIELDS = ["id", "id_icims", "title", "city", "state", "country_code", "posted_date", "job_path", "description_short", "basic_qualifications", "preferred_qualifications"];
151
+
152
+ export const PORTALS: ProviderSpec[] = [accenture, infosys, capgemini, amazon];
package/src/providers.ts CHANGED
@@ -9,7 +9,9 @@ import type { Ats, Company, Job } from "./types.ts";
9
9
 
10
10
  type Rec = Record<string, unknown>;
11
11
  /** Fetches a URL and returns its parsed JSON body (or the raw text when asked); the caller supplies retry and pacing. */
12
- export type JsonGet = (url: string, format?: "json" | "text") => Promise<unknown>;
12
+ /** POST bodies are for employer portals whose own search page posts its query; everything else is a plain GET. */
13
+ export interface GetInit { method?: "GET" | "POST"; body?: string | FormData; headers?: Record<string, string> }
14
+ export type JsonGet = (url: string, format?: "json" | "text", init?: GetInit) => Promise<unknown>;
13
15
 
14
16
  export interface ProviderSpec {
15
17
  ats: Ats;
@@ -261,7 +263,8 @@ const zohorecruit: ProviderSpec = {
261
263
  function tag(xml: string, name: string): string { return new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "i").exec(xml)?.[1]?.trim() ?? ""; }
262
264
  function cdata(value: string): string { return value.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, "$1").trim(); }
263
265
 
264
- export const PROVIDERS: ReadonlyArray<ProviderSpec> = [smartrecruiters, workable, breezy, freshteam, keka, zohorecruit];
266
+ import { PORTALS } from "./portals.ts";
267
+ export const PROVIDERS: ReadonlyArray<ProviderSpec> = [smartrecruiters, workable, breezy, freshteam, keka, zohorecruit, ...PORTALS];
265
268
 
266
269
  export function providerSpec(ats: string): ProviderSpec | undefined {
267
270
  return PROVIDERS.find((spec) => spec.ats === ats);
@@ -303,6 +306,6 @@ function majority(values: string[]): string {
303
306
  for (const value of values) if (value) counts.set(value, (counts.get(value) ?? 0) + 1);
304
307
  return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
305
308
  }
306
- function str(value: unknown): string { return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; }
307
- function asRecords(value: unknown): Rec[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
308
- function isRecord(value: unknown): value is Rec { return typeof value === "object" && value !== null && !Array.isArray(value); }
309
+ export function str(value: unknown): string { return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; }
310
+ export function asRecords(value: unknown): Rec[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
311
+ export function isRecord(value: unknown): value is Rec { return typeof value === "object" && value !== null && !Array.isArray(value); }
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** ATS boards plus "jobposting": a company's own career site read only through its schema.org JobPosting markup (token = careers URL). */
2
- export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam", "keka", "zohorecruit", "jobposting"] as const;
2
+ export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam", "keka", "zohorecruit", "jobposting", "accenture", "infosys", "capgemini", "amazon"] as const;
3
3
  export type Ats = (typeof ALL_PROVIDERS)[number];
4
4
 
5
5
  export interface DomainEvidence {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.28";
1
+ export const VERSION = "0.1.30";