nice 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 453be594423ebb52c2d2f79176d99ccf45978a186866c05b95865570753b2af4
4
- data.tar.gz: 94dd016e6281da05dcc3ec203069c6618d47930a9b021357c1eac76e547daf3b
3
+ metadata.gz: 9a7c4f589ce84353843f51bead144c5baa739032ac3de0efe7fa223510a97f97
4
+ data.tar.gz: cc875cb0407b2489e00342d6f78e78cb72d5cdbbaa0d7c2bb4d244a29b5537df
5
5
  SHA512:
6
- metadata.gz: b2099b60716dd2bb8e2b053e5e33bd7f633259b7ba2b26ff4bd2fe14024d4a31211fbe6d94980b6a5e99cc5f75e91fa416f917de0aad96f7f0ad87245265c13e
7
- data.tar.gz: 32c4dd74d029529ef5439787bfb8233ab34b22439de997bd53fdb1867e4cd9761d041525eecd23128fe17663461610de2c218e74dc9c475c10c7bf08acaf2984
6
+ metadata.gz: 88001fdd0e6d14fbc07526e1b81c769bbb1d3c54bd05c1881c6e8acfe35361c8420a279f8b1feecb2e383ca10371a0caef455b229b20024126e95bc625a91ee7
7
+ data.tar.gz: 0df0cd12ad4c61f79d3c62f8b79dfc7c7086d48cdc893eb3ed398ffd54d4cb87f0226e56261a9bf56a61f94fb13c397ee12dedfd60d6a0f4715da50d398c83a5
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.1.0"
3
+ }
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,63 @@
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
+
15
+ ## [1.0.1](https://github.com/nicolasleger/nice/compare/v1.0.0...v1.0.1) (2026-02-16)
16
+
17
+
18
+ ### Features
19
+
20
+ * add Nice data.gouv ([#5](https://github.com/nicolasleger/nice/issues/5)) ([a660143](https://github.com/nicolasleger/nice/commit/a660143c78ddc0dd808e00c61eec47ab64f87b74))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * CI publish action ([386bbe3](https://github.com/nicolasleger/nice/commit/386bbe3ee07465198a9d790677d1cf93bae11c24))
26
+ * CI release-please version ([b6f3ebf](https://github.com/nicolasleger/nice/commit/b6f3ebfe9acb87f35c7e43fabf1a0475ed73fa4a))
27
+ * CI Ruby version ([67ee027](https://github.com/nicolasleger/nice/commit/67ee027e55ae5f281d7841bb209fbe3d7ddfb14f))
28
+ * main test environment ([e93f5c4](https://github.com/nicolasleger/nice/commit/e93f5c46c2a2e9758893901eb085e3401abc2319))
29
+ * release please config ([fe9b956](https://github.com/nicolasleger/nice/commit/fe9b9569605c84acae79b5dde178baf45f33ba85))
30
+ * release please config ([1ebc7eb](https://github.com/nicolasleger/nice/commit/1ebc7eb580ad6252c7be705023bff7ab4a1056db))
31
+ * version file ([cf0c1f0](https://github.com/nicolasleger/nice/commit/cf0c1f0c9d583af31326b36d728e822fcc286d99))
32
+
33
+
34
+ ### Miscellaneous Chores
35
+
36
+ * release 1.0.0 ([65055e7](https://github.com/nicolasleger/nice/commit/65055e78e6b03cbd4666986e07d1b7ef71b1b48d))
37
+ * release 1.0.1 ([dbe4386](https://github.com/nicolasleger/nice/commit/dbe4386348d050b7270956ac48863a5bcea9656a))
38
+
39
+ ## [1.0.0](https://github.com/nicolasleger/nice/compare/v1.0.0...v1.0.0) (2026-02-16)
40
+
41
+
42
+ ### Features
43
+
44
+ * add Nice data.gouv ([#5](https://github.com/nicolasleger/nice/issues/5)) ([a660143](https://github.com/nicolasleger/nice/commit/a660143c78ddc0dd808e00c61eec47ab64f87b74))
45
+
46
+
47
+ ### Bug Fixes
48
+
49
+ * CI publish action ([386bbe3](https://github.com/nicolasleger/nice/commit/386bbe3ee07465198a9d790677d1cf93bae11c24))
50
+ * CI release-please version ([b6f3ebf](https://github.com/nicolasleger/nice/commit/b6f3ebfe9acb87f35c7e43fabf1a0475ed73fa4a))
51
+ * CI Ruby version ([67ee027](https://github.com/nicolasleger/nice/commit/67ee027e55ae5f281d7841bb209fbe3d7ddfb14f))
52
+ * main test environment ([e93f5c4](https://github.com/nicolasleger/nice/commit/e93f5c46c2a2e9758893901eb085e3401abc2319))
53
+ * release please config ([1ebc7eb](https://github.com/nicolasleger/nice/commit/1ebc7eb580ad6252c7be705023bff7ab4a1056db))
54
+ * version file ([cf0c1f0](https://github.com/nicolasleger/nice/commit/cf0c1f0c9d583af31326b36d728e822fcc286d99))
55
+
56
+
57
+ ### Miscellaneous Chores
58
+
59
+ * release 1.0.0 ([65055e7](https://github.com/nicolasleger/nice/commit/65055e78e6b03cbd4666986e07d1b7ef71b1b48d))
60
+
3
61
  ## 1.0.0 (2026-02-12)
4
62
 
5
63
 
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', branch: 'feat/add-ckan-v3-support'
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
- branch: feat/add-ckan-v3-support
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 (0.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.8.8)
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.18.1)
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 (7.0.2)
65
+ public_suffix (5.1.1)
57
66
  racc (1.8.1)
58
67
  rainbow (3.1.1)
59
68
  rake (13.3.1)
@@ -74,7 +83,7 @@ GEM
74
83
  diff-lcs (>= 1.2.0, < 2.0)
75
84
  rspec-support (~> 3.13.0)
76
85
  rspec-support (3.13.7)
77
- rubocop (1.84.1)
86
+ rubocop (1.84.2)
78
87
  json (~> 2.3)
79
88
  language_server-protocol (~> 3.17.0.2)
80
89
  lint_roller (~> 1.1.0)
@@ -101,6 +110,7 @@ GEM
101
110
  PLATFORMS
102
111
  arm64-darwin-25
103
112
  ruby
113
+ x86_64-linux
104
114
 
105
115
  DEPENDENCIES
106
116
  ckan!
@@ -114,7 +124,7 @@ DEPENDENCIES
114
124
  webmock (~> 3.0)
115
125
 
116
126
  CHECKSUMS
117
- addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
127
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
118
128
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
119
129
  bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
120
130
  byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
@@ -126,19 +136,23 @@ CHECKSUMS
126
136
  hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
127
137
  httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38
128
138
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
129
- json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
139
+ json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
130
140
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
131
141
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
132
142
  method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
133
143
  mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
144
+ mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
134
145
  multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a
135
- nice (0.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
136
150
  parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
137
151
  parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
138
152
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
139
153
  pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
140
154
  pry-byebug (3.12.0) sha256=594e094ae8a8390a7ad4c7b36ae36e13304ed02664c67417d108dc5f7213d1b7
141
- public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
155
+ public_suffix (5.1.1) sha256=250ec74630d735194c797491c85e3c6a141d7b5d9bd0b66a3fa6268cf67066ed
142
156
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
143
157
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
144
158
  rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
@@ -150,7 +164,7 @@ CHECKSUMS
150
164
  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
151
165
  rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c
152
166
  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
153
- rubocop (1.84.1) sha256=14cc626f355141f5a2ef53c10a68d66b13bb30639b26370a76559096cc6bcc1a
167
+ rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
154
168
  rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
155
169
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
156
170
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
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
@@ -0,0 +1,115 @@
1
+ require 'httparty'
2
+
3
+ require_relative 'organization'
4
+ require_relative 'resource'
5
+ require_relative 'dataset'
6
+
7
+ module Nice
8
+ module DataGouv
9
+ class Client
10
+ include HTTParty
11
+ base_uri 'https://www.data.gouv.fr/api/1'
12
+ follow_redirects true
13
+ default_timeout 30
14
+
15
+ attr_reader :api_key
16
+
17
+ # Manually curated list of organization slugs for the Nice Côte d'Azur region, sorted alphabetically.
18
+ ORGANIZATION_SLUGS = %w[
19
+ association-mediterraneenne-de-secourisme-du-06-1
20
+ banque-populaire-mediterranee
21
+ communaute-dagglomeration-cannes-lerins-1
22
+ communaute-urbaine-nice-cote-dazur
23
+ commune-de-cagnes-sur-mer
24
+ commune-de-fontan
25
+ compagnie-autobus-de-monaco
26
+ compagnie-des-autobus-de-monaco
27
+ conseil-departemental-du-var
28
+ ddtm-alpes-maritimes
29
+ departement-des-alpes-maritimes
30
+ eau-dazur
31
+ ecole-nationale-superieure-d-art-villa-arson-de-nice
32
+ groupement-dassociations-de-defense-de-lenvironnement-et-des-sites-de-la-cote-dazur
33
+ innovevents-reseau-national-dagences-evenementielles
34
+ mairie-de-cannes
35
+ mairie-de-grasse
36
+ mairie-de-la-gaude
37
+ metropole-toulon-provence-mediterranee
38
+ nice-cote-dazur
39
+ office-de-tourisme-metropolitain-nice-cote-dazur
40
+ parc-national-de-port-cros
41
+ prise-de-nice-1
42
+ regie-ligne-dazur
43
+ service-departemental-dincendie-et-de-secours-des-alpes-maritimes
44
+ sictiam
45
+ ville-dantibes
46
+ ville-de-frejus
47
+ ville-de-nice
48
+ ]
49
+
50
+ def initialize(api_key: nil)
51
+ @api_key = api_key || ENV['DATA_GOUV_API_KEY']
52
+ end
53
+
54
+ # Get organization by ID or slug
55
+ def organization(id_or_slug)
56
+ validate_id_or_slug!(id_or_slug)
57
+ response = self.class.get("/organizations/#{id_or_slug}", headers: headers)
58
+ handle_response(response) do |data|
59
+ Organization.new(data)
60
+ end
61
+ end
62
+
63
+ # List organization datasets
64
+ def organization_datasets(org_id_or_slug, page: 1, page_size: 20)
65
+ validate_id_or_slug!(org_id_or_slug)
66
+ response = self.class.get(
67
+ "/organizations/#{org_id_or_slug}/datasets",
68
+ query: { page: page, page_size: page_size },
69
+ headers: headers
70
+ )
71
+ handle_response(response) do |data|
72
+ (data['data'] || []).map { |d| Dataset.new(d) }
73
+ end
74
+ end
75
+
76
+ # Get dataset by ID or slug
77
+ def dataset(id_or_slug)
78
+ validate_id_or_slug!(id_or_slug)
79
+ response = self.class.get("/datasets/#{id_or_slug}/", headers: headers)
80
+ handle_response(response) do |data|
81
+ Dataset.new(data)
82
+ end
83
+ end
84
+
85
+ private
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
+
93
+ def headers
94
+ headers = { 'Accept' => 'application/json' }
95
+ headers['X-API-KEY'] = @api_key if @api_key
96
+ headers
97
+ end
98
+
99
+ def handle_response(response)
100
+ case response.code
101
+ when 200
102
+ yield response.parsed_response
103
+ when 404
104
+ raise Nice::APIError, "Resource not found"
105
+ when 401, 403
106
+ raise Nice::APIError, "Authentication failed or access denied"
107
+ else
108
+ raise Nice::APIError, "API request failed with status #{response.code}: #{response.body}"
109
+ end
110
+ rescue JSON::ParserError => e
111
+ raise Nice::APIError, "Failed to parse API response: #{e.message}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,42 @@
1
+ require_relative 'organization'
2
+ require_relative 'resource'
3
+
4
+ module Nice
5
+ module DataGouv
6
+ class Dataset
7
+ attr_reader :id, :name, :title, :notes, :organization, :resources, :tags, :created_at, :metadata_modified
8
+
9
+ def initialize(attributes = {})
10
+ @id = attributes['id']
11
+ @name = attributes['name']
12
+ @title = attributes['title']
13
+ @notes = attributes['notes']
14
+ @created_at = attributes['metadata_created']
15
+ @metadata_modified = attributes['metadata_modified']
16
+ @tags = (attributes['tags'] || []).map { |t| t.is_a?(Hash) ? t['name'] : t }
17
+
18
+ @organization = if attributes['organization']
19
+ Organization.new(attributes['organization'])
20
+ end
21
+
22
+ @resources = (attributes['resources'] || []).map do |resource_data|
23
+ Resource.new(resource_data)
24
+ end
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ 'id' => @id,
30
+ 'name' => @name,
31
+ 'title' => @title,
32
+ 'notes' => @notes,
33
+ 'organization' => @organization&.to_h,
34
+ 'resources' => @resources.map(&:to_h),
35
+ 'tags' => @tags,
36
+ 'created_at' => @created_at,
37
+ 'metadata_modified' => @metadata_modified
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ module Nice
2
+ module DataGouv
3
+ class Organization
4
+ attr_reader :id, :name, :slug, :title, :description, :image_url, :created_at
5
+
6
+ def initialize(attributes = {})
7
+ @id = attributes['id']
8
+ @name = attributes['name']
9
+ @slug = attributes['slug']
10
+ @title = attributes['title'] || attributes['display_name'] || attributes['name']
11
+ @description = attributes['description']
12
+ @image_url = attributes['logo'] || attributes['image_url'] || attributes['image_display_url']
13
+ @created_at = attributes['created_at'] || attributes['created']
14
+ end
15
+
16
+ def to_h
17
+ {
18
+ 'id' => @id,
19
+ 'name' => @name,
20
+ 'slug' => @slug,
21
+ 'title' => @title,
22
+ 'description' => @description,
23
+ 'image_url' => @image_url,
24
+ 'created_at' => @created_at
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ module Nice
2
+ module DataGouv
3
+ class Resource
4
+ attr_reader :id, :name, :description, :url, :format, :size, :created_at, :last_modified
5
+
6
+ def initialize(attributes = {})
7
+ @id = attributes['id']
8
+ @name = attributes['name']
9
+ @description = attributes['description']
10
+ @url = attributes['url']
11
+ @format = attributes['format']
12
+ @size = attributes['size']
13
+ @created_at = attributes['created']
14
+ @last_modified = attributes['last_modified']
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ 'id' => @id,
20
+ 'name' => @name,
21
+ 'description' => @description,
22
+ 'url' => @url,
23
+ 'format' => @format,
24
+ 'size' => @size,
25
+ 'created_at' => @created_at,
26
+ 'last_modified' => @last_modified
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Nice
2
- VERSION = "0.1.0"
4
+ VERSION = '1.1.0'
3
5
  end
data/lib/nice.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  require 'ckan'
2
+
2
3
  require_relative 'nice/client'
3
4
  require_relative 'nice/version'
4
5
 
6
+ require_relative 'nice/data_gouv/client'
7
+ require_relative 'nice/recruitment/client'
8
+
5
9
  module Nice
6
10
  class Error < StandardError; end
7
11
  class ConfigurationError < Error; end
@@ -25,9 +29,5 @@ module Nice
25
29
  @api_key = ENV['NICE_CKAN_API_KEY']
26
30
  end
27
31
 
28
- def validate!
29
- raise ConfigurationError, "CKAN URL is required" if ckan_url.nil? || ckan_url.empty?
30
- raise ConfigurationError, "API key is required" if api_key.nil? || api_key.empty?
31
- end
32
32
  end
33
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"
@@ -0,0 +1,10 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "package-name": "nice",
5
+ "release-type": "ruby",
6
+ "version-file": "lib/nice/version.rb",
7
+ "extra-files": ["Gemfile.lock"]
8
+ }
9
+ }
10
+ }
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: 0.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: '0'
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: '0'
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
@@ -157,17 +171,27 @@ executables: []
157
171
  extensions: []
158
172
  extra_rdoc_files: []
159
173
  files:
174
+ - ".release-please-manifest.json"
160
175
  - ".rspec"
176
+ - ".talismanrc"
161
177
  - CHANGELOG.md
162
178
  - Gemfile
163
179
  - Gemfile.lock
164
180
  - LICENSE
165
181
  - README.md
166
182
  - Rakefile
183
+ - examples/usage.rb
167
184
  - lib/nice.rb
168
185
  - lib/nice/client.rb
186
+ - lib/nice/data_gouv/client.rb
187
+ - lib/nice/data_gouv/dataset.rb
188
+ - lib/nice/data_gouv/organization.rb
189
+ - lib/nice/data_gouv/resource.rb
190
+ - lib/nice/recruitment/client.rb
191
+ - lib/nice/recruitment/job_offer.rb
169
192
  - lib/nice/version.rb
170
193
  - nice.gemspec
194
+ - release-please-config.json
171
195
  homepage: https://github.com/nicolasleger/nice
172
196
  licenses:
173
197
  - MIT
@@ -189,7 +213,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
189
213
  - !ruby/object:Gem::Version
190
214
  version: '0'
191
215
  requirements: []
192
- rubygems_version: 4.0.3
216
+ rubygems_version: 4.0.16
193
217
  specification_version: 4
194
218
  summary: OpenData NCA - Métropole Nice Côte d'Azur
195
219
  test_files: []