nice 1.0.1 → 1.1.0
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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.talismanrc +47 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +23 -10
- data/README.md +38 -0
- data/examples/usage.rb +78 -0
- data/lib/nice/data_gouv/client.rb +10 -0
- data/lib/nice/recruitment/client.rb +363 -0
- data/lib/nice/recruitment/job_offer.rb +82 -0
- data/lib/nice/version.rb +1 -1
- data/lib/nice.rb +1 -4
- data/nice.gemspec +4 -2
- data/release-please-config.json +1 -0
- metadata +24 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a7c4f589ce84353843f51bead144c5baa739032ac3de0efe7fa223510a97f97
|
|
4
|
+
data.tar.gz: cc875cb0407b2489e00342d6f78e78cb72d5cdbbaa0d7c2bb4d244a29b5537df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88001fdd0e6d14fbc07526e1b81c769bbb1d3c54bd05c1881c6e8acfe35361c8420a279f8b1feecb2e383ca10371a0caef455b229b20024126e95bc625a91ee7
|
|
7
|
+
data.tar.gz: 0df0cd12ad4c61f79d3c62f8b79dfc7c7086d48cdc893eb3ed398ffd54d4cb87f0226e56261a9bf56a61f94fb13c397ee12dedfd60d6a0f4715da50d398c83a5
|
data/.talismanrc
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
fileignoreconfig:
|
|
2
|
+
- filename: Gemfile
|
|
3
|
+
checksum: 353d470f3ce5ab7497984d7f64877eb0287f1b4475ce5c1c3ab1cf24b0cf0c2a
|
|
4
|
+
- filename: examples/usage.rb
|
|
5
|
+
checksum: a76ef74606f089575e525b4eb152e4d55f6a089fbaccf7cf602ce976639492b9
|
|
6
|
+
- filename: Gemfile.lock
|
|
7
|
+
checksum: 8019973a6e588d2330e19364ed8c0205a761586e51fd6da46bf50fd42a3dbeb2
|
|
8
|
+
- filename: README.md
|
|
9
|
+
checksum: 2f48f043b33ba99067ca5fcd248bf3506b0a90870955e84bdf426de6ffeda470
|
|
10
|
+
- filename: lib/nice/recruitment/client.rb
|
|
11
|
+
checksum: 9db5cd0f085195b53152021dffa7eb7bebe8212e9be570e6568ae2c16b54be42
|
|
12
|
+
- filename: lib/nice/recruitment/job_offer.rb
|
|
13
|
+
checksum: 20f1786a4207b314cd713e2b5d98bda02e63d291799a15f5c85f6d4d5e954037
|
|
14
|
+
- filename: spec/nice/recruitment/client_spec.rb
|
|
15
|
+
checksum: 54892d592185bdfce321d3684c90f63207b48f94655c221e2995487c09b51128
|
|
16
|
+
- filename: spec/nice/client_spec.rb
|
|
17
|
+
checksum: ""
|
|
18
|
+
ignore_detectors: [filecontent]
|
|
19
|
+
- filename: spec/spec_helper.rb
|
|
20
|
+
checksum: ""
|
|
21
|
+
ignore_detectors: [filecontent]
|
|
22
|
+
- filename: spec/cassettes/**/*
|
|
23
|
+
checksum: ""
|
|
24
|
+
ignore_detectors: [filecontent]
|
|
25
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_job/returns_a_fully_populated_JobOffer.yml
|
|
26
|
+
checksum: ec15c95a53f15a54e4e133afa3b1ac9ba1359f5222db8831ecff30baca5b6606
|
|
27
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_job/raises_Nice_APIError_for_an_unknown_id.yml
|
|
28
|
+
checksum: e9b954b26a5891c94295c7f8ad5547654ee267bbc229eaf95dda633c2e034bd8
|
|
29
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_jobs/returns_an_array_of_JobOffer_objects_from_the_listing.yml
|
|
30
|
+
checksum: 475e281374ac762f281c7ad4cd92d1d00a403a9ac5311415bc2460600f73d43e
|
|
31
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_jobs/returns_a_filtered_subset_for_a_keyword_search.yml
|
|
32
|
+
checksum: 9bb958134b18d41c0edc517239ab49e7ea91904e7c6e047fde255a6503734194
|
|
33
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_jobs/filters_by_category_label.yml
|
|
34
|
+
checksum: c1ecff9c2f1dc9a524347b5b06fb3dbc2624b76cc91a1771d60947c81ff2b9a6
|
|
35
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_jobs/raises_ArgumentError_for_an_unknown_filter_value.yml
|
|
36
|
+
checksum: 33dd21225ea24ad00c443de10f2afa35eb881014b3376221a43882c6515b67e9
|
|
37
|
+
- filename: spec/cassettes/Nice_Recruitment_Client/_search_options/returns_the_filter_vocabularies_parsed_from_the_listing_page.yml
|
|
38
|
+
checksum: 2247a959e229631142d7a326f737bbf2b03af578a3ab8aa67deca2c1ba0592f1
|
|
39
|
+
|
|
40
|
+
scopeconfig:
|
|
41
|
+
- scope: ruby
|
|
42
|
+
ignore_detectors: []
|
|
43
|
+
|
|
44
|
+
allowed_patterns:
|
|
45
|
+
- NICE_CKAN_API_KEY
|
|
46
|
+
- test-api-key
|
|
47
|
+
- <NICE_CKAN_API_KEY>
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0](https://github.com/nicolasleger/nice/compare/v1.0.1...v1.1.0) (2026-07-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add recruitment job offers client ([#12](https://github.com/nicolasleger/nice/issues/12)) ([92fd2de](https://github.com/nicolasleger/nice/commit/92fd2dedacdf5d42c9cfe997b387e27c06b4bdbc))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* security remediation ([#8](https://github.com/nicolasleger/nice/issues/8)) ([daf837c](https://github.com/nicolasleger/nice/commit/daf837c9d212d3383cd217edc1a53f09cf2ff91a))
|
|
14
|
+
|
|
3
15
|
## [1.0.1](https://github.com/nicolasleger/nice/compare/v1.0.0...v1.0.1) (2026-02-16)
|
|
4
16
|
|
|
5
17
|
|
data/Gemfile
CHANGED
|
@@ -2,5 +2,5 @@ source 'https://rubygems.org'
|
|
|
2
2
|
|
|
3
3
|
gemspec
|
|
4
4
|
|
|
5
|
-
# Use nicolasleger updated CKAN fork for v3 API support
|
|
6
|
-
gem 'ckan', github: 'nicolasleger/ckan',
|
|
5
|
+
# Use nicolasleger updated CKAN fork for v3 API support — pinned to reviewed SHA
|
|
6
|
+
gem 'ckan', github: 'nicolasleger/ckan', ref: '0f89d5f23b62b83c98b5371d9243e36c2d42839e'
|
data/Gemfile.lock
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
GIT
|
|
2
2
|
remote: https://github.com/nicolasleger/ckan.git
|
|
3
3
|
revision: 0f89d5f23b62b83c98b5371d9243e36c2d42839e
|
|
4
|
-
|
|
4
|
+
ref: 0f89d5f23b62b83c98b5371d9243e36c2d42839e
|
|
5
5
|
specs:
|
|
6
6
|
ckan (0.0.3)
|
|
7
7
|
|
|
8
8
|
PATH
|
|
9
9
|
remote: .
|
|
10
10
|
specs:
|
|
11
|
-
nice (1.0
|
|
12
|
-
ckan
|
|
11
|
+
nice (1.1.0)
|
|
12
|
+
ckan (~> 0.0.3)
|
|
13
13
|
httparty (~> 0.21)
|
|
14
14
|
json (~> 2.6)
|
|
15
|
+
nokogiri (~> 1.15)
|
|
15
16
|
|
|
16
17
|
GEM
|
|
17
18
|
remote: https://rubygems.org/
|
|
18
19
|
specs:
|
|
19
|
-
addressable (2.
|
|
20
|
+
addressable (2.9.0)
|
|
20
21
|
public_suffix (>= 2.0.2, < 8.0)
|
|
21
22
|
ast (2.4.3)
|
|
22
23
|
bigdecimal (4.0.1)
|
|
@@ -34,13 +35,21 @@ GEM
|
|
|
34
35
|
mini_mime (>= 1.0.0)
|
|
35
36
|
multi_xml (>= 0.5.2)
|
|
36
37
|
io-console (0.8.2)
|
|
37
|
-
json (2.
|
|
38
|
+
json (2.19.9)
|
|
38
39
|
language_server-protocol (3.17.0.5)
|
|
39
40
|
lint_roller (1.1.0)
|
|
40
41
|
method_source (1.1.0)
|
|
41
42
|
mini_mime (1.1.5)
|
|
43
|
+
mini_portile2 (2.8.9)
|
|
42
44
|
multi_xml (0.8.1)
|
|
43
45
|
bigdecimal (>= 3.1, < 5)
|
|
46
|
+
nokogiri (1.19.4)
|
|
47
|
+
mini_portile2 (~> 2.8.2)
|
|
48
|
+
racc (~> 1.4)
|
|
49
|
+
nokogiri (1.19.4-arm64-darwin)
|
|
50
|
+
racc (~> 1.4)
|
|
51
|
+
nokogiri (1.19.4-x86_64-linux-gnu)
|
|
52
|
+
racc (~> 1.4)
|
|
44
53
|
parallel (1.27.0)
|
|
45
54
|
parser (3.3.10.1)
|
|
46
55
|
ast (~> 2.4.1)
|
|
@@ -53,7 +62,7 @@ GEM
|
|
|
53
62
|
pry-byebug (3.12.0)
|
|
54
63
|
byebug (~> 13.0)
|
|
55
64
|
pry (>= 0.13, < 0.17)
|
|
56
|
-
public_suffix (
|
|
65
|
+
public_suffix (5.1.1)
|
|
57
66
|
racc (1.8.1)
|
|
58
67
|
rainbow (3.1.1)
|
|
59
68
|
rake (13.3.1)
|
|
@@ -115,7 +124,7 @@ DEPENDENCIES
|
|
|
115
124
|
webmock (~> 3.0)
|
|
116
125
|
|
|
117
126
|
CHECKSUMS
|
|
118
|
-
addressable (2.
|
|
127
|
+
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
|
119
128
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
120
129
|
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
|
121
130
|
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
|
|
@@ -127,19 +136,23 @@ CHECKSUMS
|
|
|
127
136
|
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
|
|
128
137
|
httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38
|
|
129
138
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
130
|
-
json (2.
|
|
139
|
+
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
131
140
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
132
141
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
133
142
|
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
|
|
134
143
|
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
|
144
|
+
mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
|
|
135
145
|
multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a
|
|
136
|
-
nice (1.0
|
|
146
|
+
nice (1.1.0)
|
|
147
|
+
nokogiri (1.19.4) sha256=50c951611c92bca05c51411aef45f1cbc50f2821c4802758c5c6d34696533ab5
|
|
148
|
+
nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
|
|
149
|
+
nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
|
|
137
150
|
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
|
|
138
151
|
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
|
|
139
152
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
140
153
|
pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
|
|
141
154
|
pry-byebug (3.12.0) sha256=594e094ae8a8390a7ad4c7b36ae36e13304ed02664c67417d108dc5f7213d1b7
|
|
142
|
-
public_suffix (
|
|
155
|
+
public_suffix (5.1.1) sha256=250ec74630d735194c797491c85e3c6a141d7b5d9bd0b66a3fa6268cf67066ed
|
|
143
156
|
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
144
157
|
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
145
158
|
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
data/README.md
CHANGED
|
@@ -100,6 +100,44 @@ groups = client.list_groups
|
|
|
100
100
|
resource = client.get_resource("resource-id")
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
### Job offers (recrutement.nicecotedazur.org)
|
|
104
|
+
|
|
105
|
+
Job offers are read from the metropolitan recruitment site (Eqwa ATS). The
|
|
106
|
+
platform exposes no public API or feed, so the client parses the
|
|
107
|
+
server-rendered pages — expect occasional breakage if the site's markup
|
|
108
|
+
changes.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
recruitment = Nice::Recruitment::Client.new
|
|
112
|
+
|
|
113
|
+
# All current offers (one request; summary fields)
|
|
114
|
+
offers = recruitment.jobs
|
|
115
|
+
puts "#{offers.count} offers"
|
|
116
|
+
|
|
117
|
+
# Filtered search — pass ids or exact labels, see recruitment.search_options
|
|
118
|
+
recruitment.jobs(keywords: "informatique")
|
|
119
|
+
recruitment.jobs(categories: %w[A B], sectors: ["Technique"])
|
|
120
|
+
recruitment.jobs(domain: "INFORMATIQUE & TIC", entity: "Ville de Nice")
|
|
121
|
+
|
|
122
|
+
# Full detail for one offer
|
|
123
|
+
offer = recruitment.job(offers.first.id)
|
|
124
|
+
offer.title # => "Auxiliaire de Puériculture vacataire (H/F)"
|
|
125
|
+
offer.contract # => "Contrat vacataire"
|
|
126
|
+
offer.organization # => "VILLE DE NICE"
|
|
127
|
+
offer.deadline # => Date (or nil)
|
|
128
|
+
offer.description # plain text
|
|
129
|
+
offer.profile # plain text
|
|
130
|
+
|
|
131
|
+
# Full details for everything (one request per offer — be considerate)
|
|
132
|
+
detailed = recruitment.jobs.map { |o| recruitment.job(o.id) }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Listing offers carry summary fields (`updated_at`, `recruitment_nature`);
|
|
136
|
+
detail offers carry the full set (`contract`, `organization`, `department`,
|
|
137
|
+
`location`, `workplace`, `sector`, `domain`, `employment_framework`,
|
|
138
|
+
`introduction`, `description`, `profile`). Fields missing from a given
|
|
139
|
+
source are `nil`.
|
|
140
|
+
|
|
103
141
|
## Development
|
|
104
142
|
|
|
105
143
|
After checking out the repo, run `bundle install` to install dependencies.
|
data/examples/usage.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/nice'
|
|
4
|
+
|
|
5
|
+
# Configure via environment variables (recommended — never hardcode credentials):
|
|
6
|
+
# export NICE_CKAN_URL="https://opendata.nicecotedazur.org/data/"
|
|
7
|
+
# export NICE_CKAN_API_KEY="your-api-key-here"
|
|
8
|
+
#
|
|
9
|
+
# The library reads these automatically. You can also override them explicitly:
|
|
10
|
+
Nice.configure do |config|
|
|
11
|
+
config.ckan_url = ENV.fetch('NICE_CKAN_URL', 'https://opendata.nicecotedazur.org/data/')
|
|
12
|
+
config.api_key = ENV.fetch('NICE_CKAN_API_KEY', nil)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Create a client
|
|
16
|
+
client = Nice::Client.new
|
|
17
|
+
|
|
18
|
+
# List all packages
|
|
19
|
+
puts "Listing packages..."
|
|
20
|
+
packages = client.list_packages
|
|
21
|
+
puts "Found #{packages.count} packages"
|
|
22
|
+
|
|
23
|
+
# Search for specific datasets
|
|
24
|
+
puts "\nSearching for 'transport' datasets..."
|
|
25
|
+
results = client.search_packages("transport")
|
|
26
|
+
puts "Found #{results['count']} matches"
|
|
27
|
+
|
|
28
|
+
# Get specific package details
|
|
29
|
+
if packages.any?
|
|
30
|
+
package_id = packages.first
|
|
31
|
+
puts "\nGetting details for package: #{package_id}"
|
|
32
|
+
package = client.get_package(package_id)
|
|
33
|
+
puts "Package name: #{package['name']}"
|
|
34
|
+
puts "Package title: #{package['title']}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# List organizations
|
|
38
|
+
puts "\nListing organizations..."
|
|
39
|
+
orgs = client.list_organizations
|
|
40
|
+
puts "Found #{orgs.count} organizations"
|
|
41
|
+
|
|
42
|
+
# Alternative: pass credentials directly (still read from env to avoid hardcoding)
|
|
43
|
+
# client = Nice::Client.new(
|
|
44
|
+
# ckan_url: ENV.fetch('NICE_CKAN_URL'),
|
|
45
|
+
# api_key: ENV.fetch('NICE_CKAN_API_KEY', nil)
|
|
46
|
+
# )
|
|
47
|
+
|
|
48
|
+
# --- Job offers (recrutement.nicecotedazur.org, no credentials needed) ---
|
|
49
|
+
puts "\nListing job offers..."
|
|
50
|
+
recruitment = Nice::Recruitment::Client.new
|
|
51
|
+
offers = recruitment.jobs
|
|
52
|
+
puts "Found #{offers.count} job offers"
|
|
53
|
+
|
|
54
|
+
if offers.any?
|
|
55
|
+
offer = recruitment.job(offers.first.id)
|
|
56
|
+
puts "First offer: #{offer.title} (#{offer.contract}) — deadline: #{offer.deadline || 'n/a'}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Discover the available filter vocabularies (domains, functions, contracts,
|
|
60
|
+
# sectors, categories, entities) before building a filtered search.
|
|
61
|
+
puts "\nAvailable job filters..."
|
|
62
|
+
options = recruitment.search_options
|
|
63
|
+
puts "Domains: #{options[:domains].values.take(3).join(', ')}..."
|
|
64
|
+
puts "Contracts: #{options[:contracts].values.join(', ')}"
|
|
65
|
+
|
|
66
|
+
# Search job offers with filters. Each filter accepts an id or an exact label
|
|
67
|
+
# (case-insensitive); the multi-valued ones (contracts, sectors, categories)
|
|
68
|
+
# also take arrays. See #search_options above for valid values.
|
|
69
|
+
puts "\nSearching job offers with filters..."
|
|
70
|
+
filtered = recruitment.jobs(
|
|
71
|
+
keywords: 'informatique',
|
|
72
|
+
contracts: ['Titulaire / Lauréat de concours'],
|
|
73
|
+
entity: 'Ville de Nice'
|
|
74
|
+
)
|
|
75
|
+
puts "Found #{filtered.count} matching offers"
|
|
76
|
+
filtered.first(5).each do |job|
|
|
77
|
+
puts "- #{job.title} (#{job.recruitment_nature}) — #{job.category}"
|
|
78
|
+
end
|
|
@@ -10,6 +10,7 @@ module Nice
|
|
|
10
10
|
include HTTParty
|
|
11
11
|
base_uri 'https://www.data.gouv.fr/api/1'
|
|
12
12
|
follow_redirects true
|
|
13
|
+
default_timeout 30
|
|
13
14
|
|
|
14
15
|
attr_reader :api_key
|
|
15
16
|
|
|
@@ -52,6 +53,7 @@ module Nice
|
|
|
52
53
|
|
|
53
54
|
# Get organization by ID or slug
|
|
54
55
|
def organization(id_or_slug)
|
|
56
|
+
validate_id_or_slug!(id_or_slug)
|
|
55
57
|
response = self.class.get("/organizations/#{id_or_slug}", headers: headers)
|
|
56
58
|
handle_response(response) do |data|
|
|
57
59
|
Organization.new(data)
|
|
@@ -60,6 +62,7 @@ module Nice
|
|
|
60
62
|
|
|
61
63
|
# List organization datasets
|
|
62
64
|
def organization_datasets(org_id_or_slug, page: 1, page_size: 20)
|
|
65
|
+
validate_id_or_slug!(org_id_or_slug)
|
|
63
66
|
response = self.class.get(
|
|
64
67
|
"/organizations/#{org_id_or_slug}/datasets",
|
|
65
68
|
query: { page: page, page_size: page_size },
|
|
@@ -72,6 +75,7 @@ module Nice
|
|
|
72
75
|
|
|
73
76
|
# Get dataset by ID or slug
|
|
74
77
|
def dataset(id_or_slug)
|
|
78
|
+
validate_id_or_slug!(id_or_slug)
|
|
75
79
|
response = self.class.get("/datasets/#{id_or_slug}/", headers: headers)
|
|
76
80
|
handle_response(response) do |data|
|
|
77
81
|
Dataset.new(data)
|
|
@@ -80,6 +84,12 @@ module Nice
|
|
|
80
84
|
|
|
81
85
|
private
|
|
82
86
|
|
|
87
|
+
VALID_ID_OR_SLUG = /\A[\w\-]+\z/
|
|
88
|
+
|
|
89
|
+
def validate_id_or_slug!(value)
|
|
90
|
+
raise ArgumentError, "Invalid ID or slug: #{value.inspect}" unless value.to_s.match?(VALID_ID_OR_SLUG)
|
|
91
|
+
end
|
|
92
|
+
|
|
83
93
|
def headers
|
|
84
94
|
headers = { 'Accept' => 'application/json' }
|
|
85
95
|
headers['X-API-KEY'] = @api_key if @api_key
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
require 'httparty'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
require_relative 'job_offer'
|
|
6
|
+
|
|
7
|
+
module Nice
|
|
8
|
+
module Recruitment
|
|
9
|
+
# Client for recrutement.nicecotedazur.org (Eqwa ATS).
|
|
10
|
+
#
|
|
11
|
+
# The platform exposes no public API or feed, so offers are read from the
|
|
12
|
+
# server-rendered pages: the listing page carries every current offer in a
|
|
13
|
+
# single response, and detail pages carry the full field set. Filtered
|
|
14
|
+
# search replays the site's own form flow (CSRF-protected POST followed by
|
|
15
|
+
# a session-scoped redirect).
|
|
16
|
+
class Client
|
|
17
|
+
include HTTParty
|
|
18
|
+
base_uri 'https://recrutement.nicecotedazur.org'
|
|
19
|
+
follow_redirects true
|
|
20
|
+
default_timeout 30
|
|
21
|
+
|
|
22
|
+
LISTING_PATH = '/front-jobs.html'.freeze
|
|
23
|
+
DETAIL_PATH = '/front-jobs-detail.html'.freeze
|
|
24
|
+
|
|
25
|
+
# The CSRF token must be posted back in a form field named after the
|
|
26
|
+
# cookie itself (the site's csrfprotector.js overrides the field name
|
|
27
|
+
# with the value of #csrfp_hidden_data_token). A wrong or missing token
|
|
28
|
+
# makes the server silently ignore the filters.
|
|
29
|
+
CSRF_COOKIE = '__Secure-CSRFP-Token'.freeze
|
|
30
|
+
|
|
31
|
+
# id_identite values wired to the entity tiles by front-jobs.js.
|
|
32
|
+
ENTITIES = {
|
|
33
|
+
1 => "Métropole Nice Côte d'Azur",
|
|
34
|
+
2 => 'CCAS de Nice',
|
|
35
|
+
3 => 'Ville de Nice'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Normalized French <dt> labels on the detail page => JobOffer attributes.
|
|
39
|
+
DETAIL_LABELS = {
|
|
40
|
+
'référence' => 'reference',
|
|
41
|
+
'contrat' => 'contract',
|
|
42
|
+
'collectivité' => 'organization',
|
|
43
|
+
'direction' => 'department',
|
|
44
|
+
'localisation' => 'location',
|
|
45
|
+
'lieu de travail' => 'workplace',
|
|
46
|
+
'filière' => 'sector',
|
|
47
|
+
'domaine' => 'domain',
|
|
48
|
+
'catégorie' => 'category',
|
|
49
|
+
"cadre d'emploi" => 'employment_framework',
|
|
50
|
+
"cadre d'emplois" => 'employment_framework',
|
|
51
|
+
'date limite' => 'deadline'
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# List current job offers. Without filters this is a single stateless
|
|
55
|
+
# GET returning every offer. Filters accept ids or exact labels (see
|
|
56
|
+
# #search_options): keywords (String), domain, function (requires
|
|
57
|
+
# domain), entity, and the multi-valued contracts, sectors, categories.
|
|
58
|
+
def jobs(keywords: nil, domain: nil, function: nil, contracts: [], sectors: [], categories: [], entity: nil)
|
|
59
|
+
filters = {
|
|
60
|
+
'keywords' => keywords,
|
|
61
|
+
'domain' => domain,
|
|
62
|
+
'function' => function,
|
|
63
|
+
'contracts' => Array(contracts),
|
|
64
|
+
'sectors' => Array(sectors),
|
|
65
|
+
'categories' => Array(categories),
|
|
66
|
+
'entity' => entity
|
|
67
|
+
}
|
|
68
|
+
return all_jobs if filters.values.all? { |v| v.nil? || (v.is_a?(Array) && v.empty?) }
|
|
69
|
+
|
|
70
|
+
filtered_jobs(filters)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get a fully populated offer from its detail page.
|
|
74
|
+
def job(id)
|
|
75
|
+
validate_id!(id)
|
|
76
|
+
response = self.class.get(DETAIL_PATH, query: { id_job: id, id_origin: 0 }, headers: headers)
|
|
77
|
+
handle_response(response) { |html| parse_detail(html, id.to_i) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Available filter vocabularies, parsed from the listing page:
|
|
81
|
+
# { domains: {id => label}, functions: {domain_id => {id => label}},
|
|
82
|
+
# contracts:, sectors:, categories:, entities: }.
|
|
83
|
+
def search_options
|
|
84
|
+
response = self.class.get(LISTING_PATH, headers: headers)
|
|
85
|
+
handle_response(response) { |html| parse_search_options(html) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
VALID_ID = /\A\d+\z/.freeze
|
|
91
|
+
|
|
92
|
+
def validate_id!(id)
|
|
93
|
+
raise ArgumentError, "Invalid job id: #{id.inspect}" unless id.to_s.match?(VALID_ID)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def headers
|
|
97
|
+
{ 'Accept' => 'text/html' }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def all_jobs
|
|
101
|
+
response = self.class.get(LISTING_PATH, headers: headers)
|
|
102
|
+
handle_response(response) { |html| parse_listing(html) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The site's search flow: GET the listing (session + CSRF cookies, filter
|
|
106
|
+
# vocabularies), POST the form with the token echoed as a field, then GET
|
|
107
|
+
# the listing again with the same session to read the filtered table.
|
|
108
|
+
def filtered_jobs(filters)
|
|
109
|
+
first = self.class.get(LISTING_PATH, headers: headers)
|
|
110
|
+
listing_html = handle_response(first) { |html| html }
|
|
111
|
+
cookies = session_cookies(first)
|
|
112
|
+
token = cookies[CSRF_COOKIE]
|
|
113
|
+
raise Nice::APIError, 'CSRF token cookie missing from listing response' unless token
|
|
114
|
+
|
|
115
|
+
form = build_filter_form(filters, parse_search_options(listing_html), token)
|
|
116
|
+
post = self.class.post(
|
|
117
|
+
LISTING_PATH,
|
|
118
|
+
body: form,
|
|
119
|
+
headers: headers.merge('Cookie' => cookie_header(cookies)),
|
|
120
|
+
follow_redirects: false
|
|
121
|
+
)
|
|
122
|
+
# A validated POST stores the filter in the session and redirects; a
|
|
123
|
+
# rejected one (stripped params) renders the full list directly with 200.
|
|
124
|
+
raise Nice::APIError, "Filtered search rejected (expected redirect, got #{post.code})" unless post.code == 302
|
|
125
|
+
|
|
126
|
+
cookies.merge!(session_cookies(post))
|
|
127
|
+
response = self.class.get(LISTING_PATH, headers: headers.merge('Cookie' => cookie_header(cookies)))
|
|
128
|
+
handle_response(response) { |html| parse_listing(html) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_filter_form(filters, options, token)
|
|
132
|
+
form = {
|
|
133
|
+
'filter' => 1,
|
|
134
|
+
'id_identite' => filters['entity'] ? resolve_option(filters['entity'], options[:entities], 'entity') : 0,
|
|
135
|
+
'id_domaine' => filters['domain'] ? resolve_option(filters['domain'], options[:domains], 'domain') : 0,
|
|
136
|
+
'mots_cles' => filters['keywords'].to_s
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if filters['function']
|
|
140
|
+
raise ArgumentError, 'The function filter requires a domain' unless filters['domain']
|
|
141
|
+
|
|
142
|
+
functions = options[:functions][form['id_domaine']] || {}
|
|
143
|
+
form['id_fonction'] = resolve_option(filters['function'], functions, 'function')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if filters['contracts'].any?
|
|
147
|
+
form['ids_contrat'] = filters['contracts'].map do |v|
|
|
148
|
+
resolve_option(v, options[:contracts], 'contract')
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
if filters['sectors'].any?
|
|
152
|
+
form['ids_fp_filiere'] = filters['sectors'].map do |v|
|
|
153
|
+
resolve_option(v, options[:sectors], 'sector')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
if filters['categories'].any?
|
|
157
|
+
form['ids_fp_categorie'] = filters['categories'].map do |v|
|
|
158
|
+
resolve_option(v, options[:categories], 'category')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
form[CSRF_COOKIE] = token
|
|
163
|
+
form
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Accepts an id (Integer or numeric String) present in the map, or an
|
|
167
|
+
# exact label (case-insensitive). Raises rather than letting a typo fall
|
|
168
|
+
# through to an unfiltered search.
|
|
169
|
+
def resolve_option(value, options_map, kind)
|
|
170
|
+
if value.is_a?(Integer) || value.to_s.match?(VALID_ID)
|
|
171
|
+
id = value.to_i
|
|
172
|
+
return id if options_map.key?(id)
|
|
173
|
+
else
|
|
174
|
+
match = options_map.find { |_, label| label.casecmp?(value.to_s.strip) }
|
|
175
|
+
return match.first if match
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raise ArgumentError, "Unknown #{kind}: #{value.inspect} (see #search_options for valid values)"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def session_cookies(response)
|
|
182
|
+
fields = response.headers.get_fields('set-cookie') || []
|
|
183
|
+
fields.each_with_object({}) do |raw, acc|
|
|
184
|
+
pair = raw.split(';').first
|
|
185
|
+
next unless pair&.include?('=')
|
|
186
|
+
|
|
187
|
+
name, value = pair.split('=', 2)
|
|
188
|
+
acc[name.strip] = value
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cookie_header(cookies)
|
|
193
|
+
cookies.map { |name, value| "#{name}=#{value}" }.join('; ')
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def handle_response(response)
|
|
197
|
+
case response.code
|
|
198
|
+
when 200
|
|
199
|
+
yield ensure_utf8(response.body.to_s)
|
|
200
|
+
when 404
|
|
201
|
+
raise Nice::APIError, 'Resource not found'
|
|
202
|
+
when 401, 403
|
|
203
|
+
raise Nice::APIError, 'Authentication failed or access denied'
|
|
204
|
+
else
|
|
205
|
+
raise Nice::APIError, "Request failed with status #{response.code}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ensure_utf8(html)
|
|
210
|
+
return html if html.encoding == Encoding::UTF_8 && html.valid_encoding?
|
|
211
|
+
|
|
212
|
+
html.dup.force_encoding(Encoding::UTF_8)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Listing table columns: 0 = update date (hidden), 1 = logo, 2 = title
|
|
216
|
+
# cell (link + optional "Date limite"/"réf" labels), 3 = contract type,
|
|
217
|
+
# 4 = category.
|
|
218
|
+
def parse_listing(html)
|
|
219
|
+
doc = Nokogiri::HTML(html)
|
|
220
|
+
doc.css('table.with-datatable tbody tr').filter_map do |row|
|
|
221
|
+
link = row.at_css('a[href*="front-jobs-detail"]')
|
|
222
|
+
next unless link
|
|
223
|
+
|
|
224
|
+
id = link['href'].to_s[/id_job=(\d+)/, 1]
|
|
225
|
+
next unless id
|
|
226
|
+
|
|
227
|
+
cells = row.css('td')
|
|
228
|
+
labels = row.css('span.label').filter_map { |span| clean_text(span.text) }
|
|
229
|
+
|
|
230
|
+
JobOffer.new(
|
|
231
|
+
'id' => id.to_i,
|
|
232
|
+
'url' => job_url(id),
|
|
233
|
+
'title' => clean_text(link['title']) || clean_text(link.text),
|
|
234
|
+
'updated_at' => clean_text(cells[0]&.text),
|
|
235
|
+
'recruitment_nature' => clean_text(cells[3]&.text),
|
|
236
|
+
'category' => clean_text(cells[4]&.text),
|
|
237
|
+
'deadline' => label_value(labels, /date limite/i),
|
|
238
|
+
'reference' => label_value(labels, /\Ar[ée]f/i)
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Text after the first colon of the matching "label : value" span.
|
|
244
|
+
def label_value(labels, pattern)
|
|
245
|
+
labels.find { |text| text.match?(pattern) }&.sub(/\A[^:]*:\s*/, '')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def job_url(id)
|
|
249
|
+
"#{self.class.base_uri}#{DETAIL_PATH}?id_job=#{id}&id_origin=0"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def parse_detail(html, id)
|
|
253
|
+
doc = Nokogiri::HTML(html)
|
|
254
|
+
dl = doc.at_css('dl.dl-horizontal')
|
|
255
|
+
# Unknown ids are redirected to the listing (HTTP 200 once followed),
|
|
256
|
+
# so "not found" means the detail structure is absent.
|
|
257
|
+
raise Nice::APIError, "Job offer #{id} not found" unless dl && doc.at_css('h1')
|
|
258
|
+
|
|
259
|
+
attributes = {
|
|
260
|
+
'id' => id,
|
|
261
|
+
'url' => job_url(id),
|
|
262
|
+
'title' => clean_text(doc.at_css('h1').text),
|
|
263
|
+
'introduction' => block_text(doc.at_css('div.job-detail-commentaire')),
|
|
264
|
+
'description' => section_text(doc, /contexte du recrutement/i),
|
|
265
|
+
'profile' => section_text(doc, /profil recherch/i)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
dl.css('dt').each do |dt|
|
|
269
|
+
key = DETAIL_LABELS[normalize_label(dt.text)]
|
|
270
|
+
next unless key
|
|
271
|
+
|
|
272
|
+
value = clean_text(dt.at_xpath('following-sibling::dd[1]')&.text)
|
|
273
|
+
attributes[key] = value if value && !attributes[key]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The dt.visible-xs deadline dd is often empty; the deadline also
|
|
277
|
+
# appears as plain text elsewhere on the page.
|
|
278
|
+
attributes['deadline'] ||= doc.text[/date limite\s*:?\s*(\d{1,2}-\d{1,2}-\d{4})/i, 1]
|
|
279
|
+
|
|
280
|
+
JobOffer.new(attributes)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def normalize_label(text)
|
|
284
|
+
clean_text(text)&.downcase&.tr('’', "'")&.chomp(':')&.strip
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Content between the matching <h2> and the next <h2> in the description.
|
|
288
|
+
def section_text(doc, heading_pattern)
|
|
289
|
+
heading = doc.css('div.job-detail-desc h2').find { |h2| h2.text.match?(heading_pattern) }
|
|
290
|
+
return nil unless heading
|
|
291
|
+
|
|
292
|
+
nodes = []
|
|
293
|
+
node = heading.next
|
|
294
|
+
while node && !(node.element? && node.name == 'h2')
|
|
295
|
+
nodes << node
|
|
296
|
+
node = node.next
|
|
297
|
+
end
|
|
298
|
+
block_text(Nokogiri::HTML.fragment(nodes.map(&:to_html).join))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def parse_search_options(html)
|
|
302
|
+
doc = Nokogiri::HTML(html)
|
|
303
|
+
{
|
|
304
|
+
domains: select_options(doc, 'id_domaine'),
|
|
305
|
+
functions: functions_by_domain(html),
|
|
306
|
+
contracts: select_options(doc, 'ids_contrat'),
|
|
307
|
+
sectors: select_options(doc, 'ids_fp_filiere'),
|
|
308
|
+
categories: select_options(doc, 'ids_fp_categorie'),
|
|
309
|
+
entities: ENTITIES
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def select_options(doc, select_id)
|
|
314
|
+
doc.css("select##{select_id} option").each_with_object({}) do |option, acc|
|
|
315
|
+
value = option['value'].to_s
|
|
316
|
+
next if value.empty? || value == '0'
|
|
317
|
+
|
|
318
|
+
label = clean_text(option.text)
|
|
319
|
+
acc[value.to_i] = label if label
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The domain => functions map is embedded as JSON in the listing page
|
|
324
|
+
# (window.FrontJobsData.mapFunctionParentChildren).
|
|
325
|
+
def functions_by_domain(html)
|
|
326
|
+
json = html[/mapFunctionParentChildren\s*:\s*(\{.*\})\s*,\s*$/, 1]
|
|
327
|
+
return {} unless json
|
|
328
|
+
|
|
329
|
+
JSON.parse(json).each_with_object({}) do |(domain_id, functions), acc|
|
|
330
|
+
acc[domain_id.to_i] = functions.each_with_object({}) do |function, map|
|
|
331
|
+
map[function['id'].to_i] = function['name']
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
rescue JSON::ParserError
|
|
335
|
+
{}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Plain text preserving block structure: <br> and block-element
|
|
339
|
+
# boundaries become newlines.
|
|
340
|
+
def block_text(node)
|
|
341
|
+
return nil unless node
|
|
342
|
+
|
|
343
|
+
node = node.dup
|
|
344
|
+
node.css('br').each { |br| br.replace("\n") }
|
|
345
|
+
node.css('p, div, li, ul, ol, h2, h3, h4, tr').each { |el| el.add_next_sibling("\n") }
|
|
346
|
+
text = node.text
|
|
347
|
+
.tr("\u00A0", ' ')
|
|
348
|
+
.gsub(/[ \t]+/, ' ')
|
|
349
|
+
.gsub(/ ?\n ?/, "\n")
|
|
350
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
351
|
+
.strip
|
|
352
|
+
text.empty? ? nil : text
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def clean_text(text)
|
|
356
|
+
return nil if text.nil?
|
|
357
|
+
|
|
358
|
+
cleaned = text.tr("\u00A0", ' ').gsub(/\s+/, ' ').strip
|
|
359
|
+
cleaned.empty? ? nil : cleaned
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
|
|
3
|
+
module Nice
|
|
4
|
+
module Recruitment
|
|
5
|
+
# A job offer from recrutement.nicecotedazur.org.
|
|
6
|
+
#
|
|
7
|
+
# Offers built from the listing page carry summary fields only
|
|
8
|
+
# (updated_at, recruitment_nature); offers built from a detail page carry
|
|
9
|
+
# the full set (contract, organization, description, ...). Fields absent
|
|
10
|
+
# from the source page are nil.
|
|
11
|
+
class JobOffer
|
|
12
|
+
attr_reader :id, :title, :url, :reference, :category, :deadline,
|
|
13
|
+
:updated_at, :recruitment_nature, :contract, :organization,
|
|
14
|
+
:department, :location, :workplace, :sector, :domain,
|
|
15
|
+
:employment_framework, :introduction, :description, :profile
|
|
16
|
+
|
|
17
|
+
def initialize(attributes = {})
|
|
18
|
+
@id = attributes['id']
|
|
19
|
+
@title = attributes['title']
|
|
20
|
+
@url = attributes['url']
|
|
21
|
+
@reference = attributes['reference']
|
|
22
|
+
@category = attributes['category']
|
|
23
|
+
@deadline = parse_date(attributes['deadline'])
|
|
24
|
+
@updated_at = parse_date(attributes['updated_at'])
|
|
25
|
+
@recruitment_nature = attributes['recruitment_nature']
|
|
26
|
+
@contract = attributes['contract']
|
|
27
|
+
@organization = attributes['organization']
|
|
28
|
+
@department = attributes['department']
|
|
29
|
+
@location = attributes['location']
|
|
30
|
+
@workplace = attributes['workplace']
|
|
31
|
+
@sector = attributes['sector']
|
|
32
|
+
@domain = attributes['domain']
|
|
33
|
+
@employment_framework = attributes['employment_framework']
|
|
34
|
+
@introduction = attributes['introduction']
|
|
35
|
+
@description = attributes['description']
|
|
36
|
+
@profile = attributes['profile']
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
'id' => @id,
|
|
42
|
+
'title' => @title,
|
|
43
|
+
'url' => @url,
|
|
44
|
+
'reference' => @reference,
|
|
45
|
+
'category' => @category,
|
|
46
|
+
'deadline' => @deadline,
|
|
47
|
+
'updated_at' => @updated_at,
|
|
48
|
+
'recruitment_nature' => @recruitment_nature,
|
|
49
|
+
'contract' => @contract,
|
|
50
|
+
'organization' => @organization,
|
|
51
|
+
'department' => @department,
|
|
52
|
+
'location' => @location,
|
|
53
|
+
'workplace' => @workplace,
|
|
54
|
+
'sector' => @sector,
|
|
55
|
+
'domain' => @domain,
|
|
56
|
+
'employment_framework' => @employment_framework,
|
|
57
|
+
'introduction' => @introduction,
|
|
58
|
+
'description' => @description,
|
|
59
|
+
'profile' => @profile
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Accepts a Date (pass-through), "YYYY-MM-DD" (listing update date) or
|
|
66
|
+
# "DD-MM-YYYY" (deadline). Returns nil for blank or unparseable values.
|
|
67
|
+
def parse_date(value)
|
|
68
|
+
return value if value.is_a?(Date)
|
|
69
|
+
|
|
70
|
+
text = value.to_s.strip
|
|
71
|
+
case text
|
|
72
|
+
when /\A\d{4}-\d{2}-\d{2}\z/
|
|
73
|
+
Date.strptime(text, '%Y-%m-%d')
|
|
74
|
+
when /\A\d{1,2}-\d{1,2}-\d{4}\z/
|
|
75
|
+
Date.strptime(text, '%d-%m-%Y')
|
|
76
|
+
end
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/nice/version.rb
CHANGED
data/lib/nice.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative 'nice/client'
|
|
|
4
4
|
require_relative 'nice/version'
|
|
5
5
|
|
|
6
6
|
require_relative 'nice/data_gouv/client'
|
|
7
|
+
require_relative 'nice/recruitment/client'
|
|
7
8
|
|
|
8
9
|
module Nice
|
|
9
10
|
class Error < StandardError; end
|
|
@@ -28,9 +29,5 @@ module Nice
|
|
|
28
29
|
@api_key = ENV['NICE_CKAN_API_KEY']
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def validate!
|
|
32
|
-
raise ConfigurationError, "CKAN URL is required" if ckan_url.nil? || ckan_url.empty?
|
|
33
|
-
raise ConfigurationError, "API key is required" if api_key.nil? || api_key.empty?
|
|
34
|
-
end
|
|
35
32
|
end
|
|
36
33
|
end
|
data/nice.gemspec
CHANGED
|
@@ -25,12 +25,14 @@ Gem::Specification.new do |spec|
|
|
|
25
25
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
26
26
|
spec.require_paths = ["lib"]
|
|
27
27
|
|
|
28
|
-
# CKAN dependency
|
|
29
|
-
spec.add_dependency "ckan"
|
|
28
|
+
# CKAN dependency — pin to known-good version until upstream gem supports CKAN v3
|
|
29
|
+
spec.add_dependency "ckan", "~> 0.0.3"
|
|
30
30
|
|
|
31
31
|
# Additional useful dependencies
|
|
32
32
|
spec.add_dependency "httparty", "~> 0.21"
|
|
33
33
|
spec.add_dependency "json", "~> 2.6"
|
|
34
|
+
# HTML parsing for the recruitment site (no public API/feed available)
|
|
35
|
+
spec.add_dependency "nokogiri", "~> 1.15"
|
|
34
36
|
|
|
35
37
|
# Development dependencies
|
|
36
38
|
spec.add_development_dependency "rake", "~> 13.0"
|
data/release-please-config.json
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nice
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nicolas Leger
|
|
@@ -13,16 +13,16 @@ dependencies:
|
|
|
13
13
|
name: ckan
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 0.0.3
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: 0.0.3
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: httparty
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '2.6'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: nokogiri
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.15'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.15'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: rake
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -159,18 +173,22 @@ extra_rdoc_files: []
|
|
|
159
173
|
files:
|
|
160
174
|
- ".release-please-manifest.json"
|
|
161
175
|
- ".rspec"
|
|
176
|
+
- ".talismanrc"
|
|
162
177
|
- CHANGELOG.md
|
|
163
178
|
- Gemfile
|
|
164
179
|
- Gemfile.lock
|
|
165
180
|
- LICENSE
|
|
166
181
|
- README.md
|
|
167
182
|
- Rakefile
|
|
183
|
+
- examples/usage.rb
|
|
168
184
|
- lib/nice.rb
|
|
169
185
|
- lib/nice/client.rb
|
|
170
186
|
- lib/nice/data_gouv/client.rb
|
|
171
187
|
- lib/nice/data_gouv/dataset.rb
|
|
172
188
|
- lib/nice/data_gouv/organization.rb
|
|
173
189
|
- lib/nice/data_gouv/resource.rb
|
|
190
|
+
- lib/nice/recruitment/client.rb
|
|
191
|
+
- lib/nice/recruitment/job_offer.rb
|
|
174
192
|
- lib/nice/version.rb
|
|
175
193
|
- nice.gemspec
|
|
176
194
|
- release-please-config.json
|
|
@@ -195,7 +213,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
195
213
|
- !ruby/object:Gem::Version
|
|
196
214
|
version: '0'
|
|
197
215
|
requirements: []
|
|
198
|
-
rubygems_version: 4.0.
|
|
216
|
+
rubygems_version: 4.0.16
|
|
199
217
|
specification_version: 4
|
|
200
218
|
summary: OpenData NCA - Métropole Nice Côte d'Azur
|
|
201
219
|
test_files: []
|