craigslist-api 0.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 +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +405 -0
- data/lib/craigslist/api/access_token.rb +68 -0
- data/lib/craigslist/api/area.rb +98 -0
- data/lib/craigslist/api/bulk_transport.rb +94 -0
- data/lib/craigslist/api/category.rb +47 -0
- data/lib/craigslist/api/client.rb +207 -0
- data/lib/craigslist/api/configuration.rb +130 -0
- data/lib/craigslist/api/connection.rb +54 -0
- data/lib/craigslist/api/credit_summary.rb +39 -0
- data/lib/craigslist/api/envelope.rb +65 -0
- data/lib/craigslist/api/errors.rb +82 -0
- data/lib/craigslist/api/image.rb +102 -0
- data/lib/craigslist/api/image_info.rb +53 -0
- data/lib/craigslist/api/json_transport.rb +153 -0
- data/lib/craigslist/api/money.rb +77 -0
- data/lib/craigslist/api/posting.rb +216 -0
- data/lib/craigslist/api/posting_block.rb +44 -0
- data/lib/craigslist/api/posting_handle.rb +142 -0
- data/lib/craigslist/api/posting_stats.rb +85 -0
- data/lib/craigslist/api/reference.rb +84 -0
- data/lib/craigslist/api/resources/account.rb +73 -0
- data/lib/craigslist/api/resources/base.rb +45 -0
- data/lib/craigslist/api/resources/billing.rb +47 -0
- data/lib/craigslist/api/resources/images.rb +104 -0
- data/lib/craigslist/api/resources/postings.rb +95 -0
- data/lib/craigslist/api/response_parser.rb +91 -0
- data/lib/craigslist/api/result.rb +123 -0
- data/lib/craigslist/api/result_set.rb +96 -0
- data/lib/craigslist/api/serializer.rb +177 -0
- data/lib/craigslist/api/token_provider.rb +80 -0
- data/lib/craigslist/api/version.rb +8 -0
- data/lib/craigslist/api/zip_location.rb +80 -0
- data/lib/craigslist/api.rb +63 -0
- metadata +127 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f9a564a5e0b54cd65ef0712f045d2fc7aee5cf9c321e7c5cfa6d00112bb91352
|
|
4
|
+
data.tar.gz: 7c456966c241b46bff3d55d3175c39b823e149bfd39d4d7522b7673a302190ae
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f39a965ba62a0c0c1b0854b4e67f56d4c61ba03265ec3544d29d9f8b3672cf2b7cfec9fce6a8a5407002d0707e7e22a099a9973d0d66aed1fefdb1ac4285f629
|
|
7
|
+
data.tar.gz: 797c855211e745fc1aa3830af823707fbaae3f60752bf2dfb00d4b7ac6034d9e401d6d05d4ba6a0d74b41aac2d326d661a0cd8663ff8ec55619882f46517fcf9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
While the version is below 1.0.0 the public API may change between minor
|
|
9
|
+
versions. The gem wraps an API that cannot be exercised without access granted
|
|
10
|
+
by Craigslist, so real-world use may surface corrections.
|
|
11
|
+
|
|
12
|
+
## [Unreleased]
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-08-17
|
|
15
|
+
|
|
16
|
+
Initial release.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `Craigslist::API::Client`, a single client covering both halves of the bulk
|
|
21
|
+
posting platform: the RSS interface used to create postings and the JSON
|
|
22
|
+
Bulkpost API used to manage them afterwards.
|
|
23
|
+
- Posting creation via `#validate` and `#post`, returning a `ResultSet` of
|
|
24
|
+
per-posting outcomes rather than raising, since a bulk submission succeeds
|
|
25
|
+
and fails per item.
|
|
26
|
+
- `Posting` and `Image` models with local validation, covering the newer flat
|
|
27
|
+
attribute groups (`housing_basics`, `job_basics`, `auto_basics`, `forsale`,
|
|
28
|
+
`generic`, `housing_terms`, `housing_pets`) and `brokerInfo`.
|
|
29
|
+
- `PostingHandle` for live postings, via `client.posting(id)`: status, body,
|
|
30
|
+
price, remuneration, delete/undelete, images, and statistics.
|
|
31
|
+
- Resource groups for postings, images, billing, and account.
|
|
32
|
+
- Areas and categories from the public reference service, memoized per client.
|
|
33
|
+
- OAuth2 client credentials flow with automatic token caching and refresh,
|
|
34
|
+
including a single retry on a 401.
|
|
35
|
+
- Value objects for `Money`, `CreditSummary`, `PostingBlock`, `PostingStats`,
|
|
36
|
+
`ImageInfo`, `Area`, and `Category`.
|
|
37
|
+
- An error hierarchy rooted at `Craigslist::API::Error`, including `APIError`
|
|
38
|
+
for the case where an HTTP 200 carries a populated `errors` array.
|
|
39
|
+
- `ZipLocation`, returned by `#area_for_zip`. Carries the subarea, which a
|
|
40
|
+
flat return value would discard -- and a missing subarea is the most common
|
|
41
|
+
cause of a `NOT_VALID` posting. `#to_h` splats straight into `Posting.new`.
|
|
42
|
+
- `ResultSet#raw`, the unparsed response body, so an unexplained rejection can
|
|
43
|
+
be inspected without rebuilding the request by hand.
|
|
44
|
+
|
|
45
|
+
### Notes
|
|
46
|
+
|
|
47
|
+
Verified against a live bulk posting account: postings created and removed in
|
|
48
|
+
`pit`/`ctd`, plus management, billing, image and error paths. Two corrections
|
|
49
|
+
came out of that run, both folded in above -- the zip lookup shape, and the
|
|
50
|
+
missing subarea in the smoke-test example.
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://github.com/biggeektx/craigslist-api-gem/compare/v0.1.0...HEAD
|
|
53
|
+
[0.1.0]: https://github.com/biggeektx/craigslist-api-gem/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Chewning
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# craigslist-api
|
|
2
|
+
|
|
3
|
+
[](https://github.com/biggeektx/craigslist-api-gem/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A Ruby client for Craigslist's bulk posting platform.
|
|
6
|
+
|
|
7
|
+
Craigslist splits this platform across two services that look nothing alike:
|
|
8
|
+
|
|
9
|
+
| | Creating postings | Managing them afterwards |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Service | `post.craigslist.org/bulk-rss` | `bapi.craigslist.org/bulkpost/v1` |
|
|
12
|
+
| Format | RDF/RSS XML | JSON (writes are form-encoded) |
|
|
13
|
+
| Auth | credentials inside the XML body | OAuth2 bearer token |
|
|
14
|
+
|
|
15
|
+
One set of credentials covers both, and neither can do the other's job — you
|
|
16
|
+
cannot create a posting over the JSON API, and you cannot edit one over RSS.
|
|
17
|
+
This gem hides that seam behind a single client.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
client = Craigslist::API.new(email: ..., password: ..., account_id: ...)
|
|
21
|
+
results = client.post(postings) # RSS under the hood
|
|
22
|
+
client.posting(results.posting_ids.first).price = 4200 # JSON under the hood
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Before you start
|
|
26
|
+
|
|
27
|
+
**Bulk posting access is not self-serve.** Craigslist grants it case by case to
|
|
28
|
+
high-volume posters (hundreds of postings per month), and only for paid US
|
|
29
|
+
categories: jobs (offered), apartment rentals in NYC, and for-sale-by-dealer.
|
|
30
|
+
Contact them at 415-399-5200 x8283.
|
|
31
|
+
|
|
32
|
+
Without a granted account, every call in this README will return HTTP 403.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "craigslist-api"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then `bundle install`. Requires Ruby 3.3 or newer.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require "craigslist/api"
|
|
46
|
+
|
|
47
|
+
client = Craigslist::API.new(
|
|
48
|
+
email: "you@example.com",
|
|
49
|
+
password: ENV.fetch("CRAIGSLIST_PASSWORD"),
|
|
50
|
+
account_id: 1234
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
posting = Craigslist::API::Posting.new(
|
|
54
|
+
key: "listing-1", # your own identifier for this posting
|
|
55
|
+
title: "1998 Toyota Hilux",
|
|
56
|
+
description: "Runs great. Highway miles.",
|
|
57
|
+
category: "ctd", # cars & trucks - by dealer
|
|
58
|
+
area: "sfo",
|
|
59
|
+
price: 4500,
|
|
60
|
+
reply_email: "sales@example.com",
|
|
61
|
+
location: {postal: "94110"}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Dry run first. This sends the identical document that #post would.
|
|
65
|
+
check = client.validate(posting)
|
|
66
|
+
abort check.failed.map(&:explanation).join("\n") if check.any_failures?
|
|
67
|
+
|
|
68
|
+
results = client.post(posting)
|
|
69
|
+
results.posting_ids #=> ["7123456780"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Creating postings
|
|
73
|
+
|
|
74
|
+
### Results, not exceptions
|
|
75
|
+
|
|
76
|
+
A bulk submission succeeds and fails *per posting*, and the HTTP status tells
|
|
77
|
+
you nothing about it — a 200 can carry a document in which every posting
|
|
78
|
+
failed. So `#post` and `#validate` return a `ResultSet` rather than raising.
|
|
79
|
+
A batch with a few rejections is ordinary, not exceptional.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
results = client.post(postings)
|
|
83
|
+
|
|
84
|
+
results.all_successful? #=> false
|
|
85
|
+
results.successful #=> [Result, ...]
|
|
86
|
+
results.failed #=> [Result, ...]
|
|
87
|
+
results.posting_ids #=> ids of postings that were created
|
|
88
|
+
results.upload_id #=> craigslist's id for the whole batch
|
|
89
|
+
results["listing-1"] #=> the Result for the key you submitted
|
|
90
|
+
|
|
91
|
+
results.failed.each do |result|
|
|
92
|
+
warn "#{result.key}: #{result.status} — #{result.explanation}"
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Transport and authentication failures *do* raise. See [Errors](#errors).
|
|
97
|
+
|
|
98
|
+
When a rejection is opaque, the unparsed response is always on hand:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
puts results.raw # the RSS document exactly as craigslist sent it
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each `Result` answers the status it came back with:
|
|
105
|
+
|
|
106
|
+
| Predicate | Status | Meaning |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `valid?` | `VALID` | passed validation (validate mode only) |
|
|
109
|
+
| `posted?` | `POSTED` | accepted (post mode only) |
|
|
110
|
+
| `not_valid?` | `NOT_VALID` | rejected; see `explanation` |
|
|
111
|
+
| `insufficient_blocks?` | `INSUFFICIENT_BLOCKS` | out of prepaid blocks for this area/category |
|
|
112
|
+
| `credit_limit_reached?` | `CREDIT_LIMIT_REACHED` | invoiced account hit its limit |
|
|
113
|
+
| `credit_card_error?` | `CREDIT_CARD_ERROR` | card could not be billed |
|
|
114
|
+
| — | `FAILED` | unexpected error at post time |
|
|
115
|
+
|
|
116
|
+
`success?` covers `VALID` and `POSTED`; `failure?` is everything else.
|
|
117
|
+
|
|
118
|
+
### Don't skip the warnings
|
|
119
|
+
|
|
120
|
+
Craigslist returns non-fatal warnings — usually XML it had to paper over —
|
|
121
|
+
**alongside successes**, which makes them easy to miss entirely:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
results.warned.each do |result|
|
|
125
|
+
warn "#{result.key} posted with warnings: #{result.warnings.join(", ")}"
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Posting fields
|
|
130
|
+
|
|
131
|
+
Only `key`, `title`, `description`, `category`, `area` and a location are
|
|
132
|
+
required. A location means either `:postal`, or both `:latitude` and
|
|
133
|
+
`:longitude`.
|
|
134
|
+
|
|
135
|
+
> **Subarea is required in any area that has subareas**, and omitting it is the
|
|
136
|
+
> most common reason a posting comes back `NOT_VALID`. `sfo` has six; `pit` has
|
|
137
|
+
> none. Let the ZIP lookup tell you rather than guessing:
|
|
138
|
+
>
|
|
139
|
+
> ```ruby
|
|
140
|
+
> place = client.area_for_zip("94110") #=> area "sfo", subarea "sfc"
|
|
141
|
+
> Craigslist::API::Posting.new(**place.to_h, key: ..., title: ...)
|
|
142
|
+
> ```
|
|
143
|
+
>
|
|
144
|
+
> Or check directly with `client.reference.area("sfo").subareas?`.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Craigslist::API::Posting.new(
|
|
148
|
+
key: "listing-2",
|
|
149
|
+
title: "1BR in Chelsea",
|
|
150
|
+
description: "Sunny, quiet block.",
|
|
151
|
+
category: "apa",
|
|
152
|
+
area: "nyc",
|
|
153
|
+
subarea: "mnh", # required where an area has subareas
|
|
154
|
+
neighborhood: "Chelsea",
|
|
155
|
+
price: 4875,
|
|
156
|
+
po_number: "PO-094122", # your own tracking reference
|
|
157
|
+
reply_email: "leasing@example.com",
|
|
158
|
+
reply_privacy: :anonymous, # :none | :anonymous | :public
|
|
159
|
+
other_contact_info: "212.555.1212",
|
|
160
|
+
|
|
161
|
+
location: {
|
|
162
|
+
postal: "10011", city: "New York", state: "NY",
|
|
163
|
+
cross_street1: "23rd Street", cross_street2: "9th Avenue",
|
|
164
|
+
latitude: 40.746492, longitude: -74.001326
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
housing_basics: {bedrooms: 1, bathrooms: 1, surface_area: 850,
|
|
168
|
+
housing_type: "apartment", laundry: "w/d in unit",
|
|
169
|
+
is_furnished: false},
|
|
170
|
+
housing_terms: {rent_period: "monthly", broker_fee: true},
|
|
171
|
+
housing_pets: {pets_cat: true, pets_dog: false},
|
|
172
|
+
broker: {company_name: "Sample & Associates", fee_disclosure: "One month"},
|
|
173
|
+
|
|
174
|
+
images: ["photos/living-room.jpg", "photos/kitchen.jpg"]
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Booleans are written as the `0`/`1` the interface expects — pass `true`/`false`
|
|
179
|
+
and the gem translates.
|
|
180
|
+
|
|
181
|
+
The attribute groups map straight onto the interface's newer flat field sets:
|
|
182
|
+
`generic`, `housing_basics`, `housing_pets`, `housing_terms`, `job_basics`,
|
|
183
|
+
`auto_basics`, `forsale`, plus `broker` for `cl:brokerInfo`. Their keys pass
|
|
184
|
+
through unchanged, so anything Craigslist documents for a group works without
|
|
185
|
+
this gem needing to know about it.
|
|
186
|
+
|
|
187
|
+
Postings are validated on construction and raise `ValidationError` listing
|
|
188
|
+
*every* problem, not just the first:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
Craigslist::API::Posting.new(key: "x", title: "", description: "b",
|
|
192
|
+
category: "", area: "sfo", location: {})
|
|
193
|
+
# => Craigslist::API::ValidationError:
|
|
194
|
+
# title is required; category is required;
|
|
195
|
+
# location requires :postal, or both :latitude and :longitude
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Images
|
|
199
|
+
|
|
200
|
+
Up to 24 per posting, sent inline as base64. Position is zero-based and
|
|
201
|
+
position 0 is the image featured on search pages.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
images: [
|
|
205
|
+
"photos/front.jpg", # path
|
|
206
|
+
Pathname("photos/side.jpg"), # Pathname
|
|
207
|
+
File.open("photos/rear.jpg", "rb"), # any IO
|
|
208
|
+
Craigslist::API::Image.from_base64(encoded, position: 5)
|
|
209
|
+
]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Positions are assigned in order when you don't set them. Raw base64 must go
|
|
213
|
+
through `Image.from_base64` — guessing whether a String is a path or a payload
|
|
214
|
+
would be worse than asking.
|
|
215
|
+
|
|
216
|
+
> Bulk submissions with many images get large fast: 24 images at 200KB each is
|
|
217
|
+
> roughly 6MB of base64 *per posting*. Submit in modest batches.
|
|
218
|
+
|
|
219
|
+
## Managing live postings
|
|
220
|
+
|
|
221
|
+
`client.posting(id)` returns a handle. It caches nothing, so it can never go
|
|
222
|
+
stale:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
posting = client.posting("7123456780")
|
|
226
|
+
|
|
227
|
+
posting.status #=> "active" | "deleted" | "expired" | "pending" | "removed"
|
|
228
|
+
posting.active? #=> true
|
|
229
|
+
|
|
230
|
+
posting.body #=> "The posting body"
|
|
231
|
+
posting.body = "New text"
|
|
232
|
+
posting.price = 4200
|
|
233
|
+
posting.remuneration = "$19.95/hr plus tips" # jobs and gigs
|
|
234
|
+
|
|
235
|
+
posting.images #=> [ImageInfo, ...]
|
|
236
|
+
posting.add_image("new.jpg")
|
|
237
|
+
posting.add_image("hero.jpg", insert_position: 0)
|
|
238
|
+
posting.reorder_images(%w[4:00202_x 4:00101_y])
|
|
239
|
+
posting.remove_image("4:00101_y")
|
|
240
|
+
|
|
241
|
+
posting.stats.total_views #=> 1_284
|
|
242
|
+
|
|
243
|
+
posting.delete
|
|
244
|
+
posting.undelete
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Two API quirks worth knowing: postings in `ctd` (cars & trucks by dealer) must
|
|
248
|
+
keep the VIN they were created with, and removing an image only *detaches* it —
|
|
249
|
+
the image itself stays publicly retrievable.
|
|
250
|
+
|
|
251
|
+
The same operations are available grouped by resource if you prefer:
|
|
252
|
+
`client.postings`, `client.images`, `client.billing`, `client.account`.
|
|
253
|
+
|
|
254
|
+
## Billing, stats, and reference data
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
credit = client.credit
|
|
258
|
+
credit.remaining.to_s #=> "3.00 USD"
|
|
259
|
+
credit.remaining.to_f #=> 3.0
|
|
260
|
+
|
|
261
|
+
client.posting_blocks.each do |block|
|
|
262
|
+
puts "#{block.area} #{block.product_class}: #{block.remaining_posts} left"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
client.pricing(area: "sfo", category: "ofc").to_s #=> "1000.00 USD"
|
|
266
|
+
client.billing.create_invoice #=> ["1234321"]
|
|
267
|
+
|
|
268
|
+
place = client.area_for_zip("02134")
|
|
269
|
+
place.area #=> "bos"
|
|
270
|
+
place.subarea #=> "gbs"
|
|
271
|
+
place.to_h #=> {area: "bos", subarea: "gbs"}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Money keeps Craigslist's minor-unit representation and converts through
|
|
275
|
+
`Rational`, so scaling is exact.
|
|
276
|
+
|
|
277
|
+
Statistics cover a rolling 30-day window ending midnight UTC yesterday, and
|
|
278
|
+
count requests rather than unique viewers:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
stats = client.stats(start: "2026-01-01", stop: "2026-01-31")
|
|
282
|
+
|
|
283
|
+
stats.each do |posting|
|
|
284
|
+
puts "#{posting.posting_id}: #{posting.total_views} views, #{posting.total_contact} contacts"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
stats.first[:impressions] #=> [[2026-01-01 00:00:00 UTC, 57], ...]
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Metrics: `impressions`, `views`, `contact`, `contact_chat`, `contact_phone`,
|
|
291
|
+
`contact_email`, `share`, `favorite`.
|
|
292
|
+
|
|
293
|
+
Areas and categories come from Craigslist's public reference service (no auth
|
|
294
|
+
needed) and are memoized per client:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
client.reference.area("sfo").subareas.map(&:abbreviation) #=> ["sfc", "sby", ...]
|
|
298
|
+
client.reference.area("sfo").subareas? #=> true
|
|
299
|
+
client.reference.category("ctd").description #=> "cars & trucks - by dealer"
|
|
300
|
+
client.reference.category("prk").unsupported? #=> true
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Account messages
|
|
304
|
+
|
|
305
|
+
Craigslist attaches notices to every JSON response and repeats them until
|
|
306
|
+
acknowledged. Surface them somewhere a human will look:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
client.account_messages.each do |message|
|
|
310
|
+
logger.info("craigslist: #{message["message"]}")
|
|
311
|
+
client.account.acknowledge(message["messageId"])
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Errors
|
|
316
|
+
|
|
317
|
+
Everything raised derives from `Craigslist::API::Error`.
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
Error
|
|
321
|
+
├── ConfigurationError missing or blank credentials
|
|
322
|
+
├── ValidationError caught locally, before any request; carries #errors
|
|
323
|
+
├── ConnectionError DNS, refused, TLS
|
|
324
|
+
│ └── TimeoutError
|
|
325
|
+
├── ParseError response was not the XML/JSON it promised
|
|
326
|
+
└── ResponseError carries #status, #body, #api_errors
|
|
327
|
+
├── RequestError 400, or 415 for unparseable RSS
|
|
328
|
+
├── AuthenticationError 401/403, or a rejected token
|
|
329
|
+
├── NotFoundError 404
|
|
330
|
+
├── RateLimitError 429
|
|
331
|
+
├── ServerError 5xx
|
|
332
|
+
└── APIError HTTP 200 with a populated errors array
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`APIError` exists because the JSON API reports application-level failures
|
|
336
|
+
in-band. A 200 is not proof of success:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
begin
|
|
340
|
+
client.posting("7123456780").body = "updated"
|
|
341
|
+
rescue Craigslist::API::APIError => e
|
|
342
|
+
e.api_errors #=> [{"code" => 0, "message" => "posting 7123456780 not found"}]
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Configuration
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
Craigslist::API.new(
|
|
350
|
+
email: "you@example.com",
|
|
351
|
+
password: ENV.fetch("CRAIGSLIST_PASSWORD"),
|
|
352
|
+
account_id: 1234,
|
|
353
|
+
|
|
354
|
+
timeout: 120, # read timeout; generous, submissions can be large
|
|
355
|
+
open_timeout: 15,
|
|
356
|
+
logger: Rails.logger, # logs request lines via Faraday
|
|
357
|
+
adapter: :net_http, # any Faraday adapter
|
|
358
|
+
scopes: %w[bulkpost.posting bulkpost.account.billing],
|
|
359
|
+
user_agent: "my-app/1.0"
|
|
360
|
+
)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Scopes are hierarchical — `bulkpost.posting` grants `bulkpost.posting.delete`
|
|
364
|
+
and the rest beneath it. The default requests all four top-level scopes.
|
|
365
|
+
|
|
366
|
+
### Threads and multiple accounts
|
|
367
|
+
|
|
368
|
+
Configuration is frozen and nothing mutates at request time, so a client is
|
|
369
|
+
safe to share across threads. The OAuth token is cached per client behind a
|
|
370
|
+
mutex and refreshed automatically, including once on a 401 in case it was
|
|
371
|
+
revoked early.
|
|
372
|
+
|
|
373
|
+
There is no global to configure. Talking to several accounts means building
|
|
374
|
+
several clients, which is deliberate — ambient credentials are exactly the
|
|
375
|
+
thing that breaks when one process serves more than one account.
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
CLIENTS = accounts.to_h { |a| [a.id, Craigslist::API.new(**a.credentials)] }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Passwords and tokens are redacted from `inspect` output, so a stray `inspect`
|
|
382
|
+
in a log line or exception backtrace will not leak them.
|
|
383
|
+
|
|
384
|
+
## Development
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
bin/setup # install dependencies
|
|
388
|
+
bundle exec rake # specs + linter
|
|
389
|
+
bundle exec rspec
|
|
390
|
+
bundle exec standardrb --fix
|
|
391
|
+
bin/console # IRB with the gem loaded
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The suite is fully offline — WebMock blocks net connections outright. Response
|
|
395
|
+
parsing is tested against fixtures transcribed from Craigslist's own published
|
|
396
|
+
samples.
|
|
397
|
+
|
|
398
|
+
## Contributing
|
|
399
|
+
|
|
400
|
+
Bug reports and pull requests are welcome at
|
|
401
|
+
<https://github.com/biggeektx/craigslist-api-gem>.
|
|
402
|
+
|
|
403
|
+
## License
|
|
404
|
+
|
|
405
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# An OAuth2 bearer token with an expiry.
|
|
6
|
+
#
|
|
7
|
+
# Immutable. {TokenProvider} discards and re-fetches rather than mutating.
|
|
8
|
+
class AccessToken
|
|
9
|
+
# Treat a token as expired this many seconds early, so a token does not
|
|
10
|
+
# die in flight between the expiry check and the server receiving it.
|
|
11
|
+
LEEWAY = 60
|
|
12
|
+
|
|
13
|
+
# @return [String] the raw token value
|
|
14
|
+
attr_reader :value
|
|
15
|
+
|
|
16
|
+
# @return [Time] the moment the token stops being valid
|
|
17
|
+
attr_reader :expires_at
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] scopes the token was granted
|
|
20
|
+
attr_reader :scopes
|
|
21
|
+
|
|
22
|
+
# @return [String] usually "Bearer"
|
|
23
|
+
attr_reader :token_type
|
|
24
|
+
|
|
25
|
+
def initialize(value:, expires_in:, scopes: [], token_type: "Bearer", now: Time.now)
|
|
26
|
+
@value = value
|
|
27
|
+
@expires_at = now + expires_in.to_i
|
|
28
|
+
@scopes = Array(scopes).flat_map { |s| s.to_s.split(/\s+/) }.freeze
|
|
29
|
+
@token_type = token_type || "Bearer"
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Builds a token from the token endpoint's JSON payload.
|
|
34
|
+
#
|
|
35
|
+
# @param payload [Hash]
|
|
36
|
+
# @return [AccessToken]
|
|
37
|
+
# @raise [AuthenticationError] if the payload carries no token
|
|
38
|
+
def self.from_payload(payload, now: Time.now)
|
|
39
|
+
value = payload["access_token"]
|
|
40
|
+
raise AuthenticationError, "token endpoint returned no access_token" if value.nil? || value.empty?
|
|
41
|
+
|
|
42
|
+
new(
|
|
43
|
+
value: value,
|
|
44
|
+
expires_in: payload.fetch("expires_in", 3600),
|
|
45
|
+
scopes: payload["scopes"] || payload["scope"] || [],
|
|
46
|
+
token_type: payload["token_type"],
|
|
47
|
+
now: now
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether the token is expired, or close enough to it
|
|
52
|
+
def expired?(now: Time.now)
|
|
53
|
+
now >= (expires_at - LEEWAY)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String] value for the +Authorization+ header
|
|
57
|
+
def to_header
|
|
58
|
+
"#{token_type} #{value}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Keeps the token value out of logs and exception output.
|
|
62
|
+
def inspect
|
|
63
|
+
"#<#{self.class.name} token=[FILTERED] expires_at=#{expires_at.iso8601} scopes=#{scopes.inspect}>"
|
|
64
|
+
end
|
|
65
|
+
alias_method :to_s, :inspect
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# A craigslist area (a city or region), as published by the public
|
|
6
|
+
# reference service.
|
|
7
|
+
class Area
|
|
8
|
+
# A subdivision of an {Area}. Postings in areas that have subareas are
|
|
9
|
+
# expected to name one.
|
|
10
|
+
class SubArea
|
|
11
|
+
attr_reader :abbreviation, :description, :short_description, :id
|
|
12
|
+
|
|
13
|
+
def initialize(abbreviation:, description: nil, short_description: nil, id: nil)
|
|
14
|
+
@abbreviation = abbreviation
|
|
15
|
+
@description = description
|
|
16
|
+
@short_description = short_description
|
|
17
|
+
@id = id
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param hash [Hash] raw payload
|
|
22
|
+
# @return [SubArea]
|
|
23
|
+
def self.from(hash)
|
|
24
|
+
new(
|
|
25
|
+
abbreviation: hash["Abbreviation"],
|
|
26
|
+
description: hash["Description"],
|
|
27
|
+
short_description: hash["ShortDescription"],
|
|
28
|
+
id: hash["SubAreaID"]
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
abbreviation.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inspect
|
|
37
|
+
"#<#{self.class.name} #{abbreviation.inspect} #{description.inspect}>"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :abbreviation, :description, :short_description, :hostname,
|
|
42
|
+
:country, :region, :latitude, :longitude, :id, :subareas
|
|
43
|
+
|
|
44
|
+
def initialize(abbreviation:, description: nil, short_description: nil,
|
|
45
|
+
hostname: nil, country: nil, region: nil, latitude: nil,
|
|
46
|
+
longitude: nil, id: nil, subareas: [])
|
|
47
|
+
@abbreviation = abbreviation
|
|
48
|
+
@description = description
|
|
49
|
+
@short_description = short_description
|
|
50
|
+
@hostname = hostname
|
|
51
|
+
@country = country
|
|
52
|
+
@region = region
|
|
53
|
+
@latitude = latitude
|
|
54
|
+
@longitude = longitude
|
|
55
|
+
@id = id
|
|
56
|
+
@subareas = subareas.freeze
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param hash [Hash] raw payload
|
|
61
|
+
# @return [Area]
|
|
62
|
+
def self.from(hash)
|
|
63
|
+
new(
|
|
64
|
+
abbreviation: hash["Abbreviation"],
|
|
65
|
+
description: hash["Description"],
|
|
66
|
+
short_description: hash["ShortDescription"],
|
|
67
|
+
hostname: hash["Hostname"],
|
|
68
|
+
country: hash["Country"],
|
|
69
|
+
region: hash["Region"],
|
|
70
|
+
latitude: hash["Latitude"],
|
|
71
|
+
longitude: hash["Longitude"],
|
|
72
|
+
id: hash["AreaID"],
|
|
73
|
+
subareas: Array(hash["SubAreas"]).map { |sub| SubArea.from(sub) }
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] whether a subarea must be supplied when posting here
|
|
78
|
+
def subareas?
|
|
79
|
+
!subareas.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param abbreviation [String]
|
|
83
|
+
# @return [SubArea, nil]
|
|
84
|
+
def subarea(abbreviation)
|
|
85
|
+
subareas.find { |sub| sub.abbreviation == abbreviation.to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_s
|
|
89
|
+
abbreviation.to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def inspect
|
|
93
|
+
"#<#{self.class.name} #{abbreviation.inspect} #{description.inspect} " \
|
|
94
|
+
"subareas=#{subareas.size}>"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|