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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Base class for every error raised by this library. Rescuing this catches
|
|
6
|
+
# anything the gem raises on purpose.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when the client is constructed with missing or nonsensical
|
|
10
|
+
# credentials or options.
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a {Posting} is missing fields the interface requires, caught
|
|
14
|
+
# locally before a request is made.
|
|
15
|
+
class ValidationError < Error
|
|
16
|
+
# @return [Array<String>] every problem found, not just the first
|
|
17
|
+
attr_reader :errors
|
|
18
|
+
|
|
19
|
+
def initialize(errors)
|
|
20
|
+
@errors = Array(errors)
|
|
21
|
+
super(@errors.join("; "))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised when a request never produced a usable HTTP response: DNS failure,
|
|
26
|
+
# connection refused, TLS problems, timeouts.
|
|
27
|
+
class ConnectionError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when a request exceeded the configured timeout.
|
|
30
|
+
class TimeoutError < ConnectionError; end
|
|
31
|
+
|
|
32
|
+
# Raised when a response arrived but could not be parsed as the XML or JSON
|
|
33
|
+
# the endpoint promised.
|
|
34
|
+
class ParseError < Error; end
|
|
35
|
+
|
|
36
|
+
# Raised when Craigslist returned a response we understand as a failure.
|
|
37
|
+
#
|
|
38
|
+
# Carries the HTTP status and raw body so callers can inspect what actually
|
|
39
|
+
# came back, plus any structured errors extracted from a JSON envelope.
|
|
40
|
+
class ResponseError < Error
|
|
41
|
+
# @return [Integer, nil] HTTP status code
|
|
42
|
+
attr_reader :status
|
|
43
|
+
|
|
44
|
+
# @return [String, nil] raw response body
|
|
45
|
+
attr_reader :body
|
|
46
|
+
|
|
47
|
+
# @return [Array<Hash>] structured +{code:, message:}+ entries, when present
|
|
48
|
+
attr_reader :api_errors
|
|
49
|
+
|
|
50
|
+
def initialize(message = nil, status: nil, body: nil, api_errors: [])
|
|
51
|
+
@status = status
|
|
52
|
+
@body = body
|
|
53
|
+
@api_errors = api_errors
|
|
54
|
+
super(message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Raised for malformed requests. The RSS interface uses HTTP 415 for RSS it
|
|
59
|
+
# cannot parse; the JSON API uses 400.
|
|
60
|
+
class RequestError < ResponseError; end
|
|
61
|
+
|
|
62
|
+
# Raised when credentials were rejected, or the account is not authorized
|
|
63
|
+
# for bulk posting. Covers HTTP 401 and 403, and OAuth token failures.
|
|
64
|
+
class AuthenticationError < ResponseError; end
|
|
65
|
+
|
|
66
|
+
# Raised on HTTP 404.
|
|
67
|
+
class NotFoundError < ResponseError; end
|
|
68
|
+
|
|
69
|
+
# Raised on HTTP 429.
|
|
70
|
+
class RateLimitError < ResponseError; end
|
|
71
|
+
|
|
72
|
+
# Raised on any 5xx.
|
|
73
|
+
class ServerError < ResponseError; end
|
|
74
|
+
|
|
75
|
+
# Raised when a JSON response arrived with HTTP 200 but carried a non-empty
|
|
76
|
+
# +errors+ array in its envelope.
|
|
77
|
+
#
|
|
78
|
+
# This is separate from {ResponseError} subclasses keyed to status codes
|
|
79
|
+
# because the Bulkpost API reports application-level failures in-band.
|
|
80
|
+
class APIError < ResponseError; end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# An image attached to a bulk posting.
|
|
8
|
+
#
|
|
9
|
+
# The RSS interface takes images inline as base64 JPEG data, up to 24 per
|
|
10
|
+
# posting. Position is zero-based and the image at position 0 is the one
|
|
11
|
+
# featured on search pages.
|
|
12
|
+
class Image
|
|
13
|
+
# Craigslist rejects postings carrying more than this many images.
|
|
14
|
+
MAX_PER_POSTING = 24
|
|
15
|
+
|
|
16
|
+
# Highest valid zero-based position.
|
|
17
|
+
MAX_POSITION = MAX_PER_POSTING - 1
|
|
18
|
+
|
|
19
|
+
# @return [String] base64-encoded image data
|
|
20
|
+
attr_reader :data
|
|
21
|
+
|
|
22
|
+
# @return [Integer, nil] zero-based position within the posting
|
|
23
|
+
attr_reader :position
|
|
24
|
+
|
|
25
|
+
# @param data [String] already base64-encoded data
|
|
26
|
+
# @param position [Integer, nil]
|
|
27
|
+
def initialize(data, position: nil)
|
|
28
|
+
@data = data
|
|
29
|
+
@position = position
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Coerces a caller-supplied image into an {Image}.
|
|
35
|
+
#
|
|
36
|
+
# Accepts an existing {Image}, an IO-like object, or a path as a String
|
|
37
|
+
# or Pathname. Raw base64 has to go through {from_base64} — guessing
|
|
38
|
+
# whether a String is a path or a payload would be worse than asking.
|
|
39
|
+
#
|
|
40
|
+
# @param source [Image, IO, Pathname, String]
|
|
41
|
+
# @param position [Integer, nil]
|
|
42
|
+
# @return [Image]
|
|
43
|
+
# @raise [ValidationError] if the source type is not supported
|
|
44
|
+
def wrap(source, position: nil)
|
|
45
|
+
case source
|
|
46
|
+
when Image
|
|
47
|
+
position.nil? ? source : new(source.data, position: position)
|
|
48
|
+
when Pathname
|
|
49
|
+
from_file(source, position: position)
|
|
50
|
+
when String
|
|
51
|
+
from_file(source, position: position)
|
|
52
|
+
else
|
|
53
|
+
if source.respond_to?(:read)
|
|
54
|
+
from_io(source, position: position)
|
|
55
|
+
else
|
|
56
|
+
raise ValidationError, ["cannot build an image from #{source.class}"]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param path [String, Pathname]
|
|
62
|
+
# @return [Image]
|
|
63
|
+
def from_file(path, position: nil)
|
|
64
|
+
from_data(File.binread(path.to_s), position: position)
|
|
65
|
+
rescue SystemCallError => e
|
|
66
|
+
raise ValidationError, ["could not read image #{path}: #{e.message}"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param io [IO]
|
|
70
|
+
# @return [Image]
|
|
71
|
+
def from_io(io, position: nil)
|
|
72
|
+
io.binmode if io.respond_to?(:binmode)
|
|
73
|
+
from_data(io.read, position: position)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param binary [String] raw (unencoded) image bytes
|
|
77
|
+
# @return [Image]
|
|
78
|
+
def from_data(binary, position: nil)
|
|
79
|
+
# pack("m") produces RFC 2045 base64 with line breaks, matching the
|
|
80
|
+
# form Craigslist's own documentation shows. Using Array#pack rather
|
|
81
|
+
# than the base64 gem keeps this dependency-free on Ruby 3.4+.
|
|
82
|
+
new([binary].pack("m"), position: position)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param encoded [String] data that is already base64
|
|
86
|
+
# @return [Image]
|
|
87
|
+
def from_base64(encoded, position: nil)
|
|
88
|
+
new(encoded, position: position)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Image] a copy pinned to +index+ when no position was set
|
|
93
|
+
def with_default_position(index)
|
|
94
|
+
position.nil? ? self.class.new(data, position: index) : self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inspect
|
|
98
|
+
"#<#{self.class.name} position=#{position.inspect} bytes=#{data.bytesize}>"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Metadata about an image attached to a live posting.
|
|
6
|
+
class ImageInfo
|
|
7
|
+
# @return [String] craigslist's image id, e.g. "4:00101_b1ztTgNtBAU"
|
|
8
|
+
attr_reader :id
|
|
9
|
+
|
|
10
|
+
# @return [String, nil]
|
|
11
|
+
attr_reader :filename
|
|
12
|
+
|
|
13
|
+
# @return [String, nil] e.g. "JPEG", "GIF", "WEBP"
|
|
14
|
+
attr_reader :format
|
|
15
|
+
|
|
16
|
+
# @return [Integer, nil]
|
|
17
|
+
attr_reader :width
|
|
18
|
+
|
|
19
|
+
# @return [Integer, nil]
|
|
20
|
+
attr_reader :height
|
|
21
|
+
|
|
22
|
+
# @return [Integer, nil] zero-based position within the posting
|
|
23
|
+
attr_reader :position
|
|
24
|
+
|
|
25
|
+
def initialize(id:, filename: nil, format: nil, width: nil, height: nil, position: nil)
|
|
26
|
+
@id = id
|
|
27
|
+
@filename = filename
|
|
28
|
+
@format = format
|
|
29
|
+
@width = width
|
|
30
|
+
@height = height
|
|
31
|
+
@position = position
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param hash [Hash] raw payload
|
|
36
|
+
# @return [ImageInfo]
|
|
37
|
+
def self.from(hash)
|
|
38
|
+
new(
|
|
39
|
+
id: hash["id"],
|
|
40
|
+
filename: hash["filename"],
|
|
41
|
+
format: hash["format"],
|
|
42
|
+
width: hash["width"],
|
|
43
|
+
height: hash["height"],
|
|
44
|
+
position: hash["position"]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect
|
|
49
|
+
"#<#{self.class.name} id=#{id.inspect} position=#{position.inspect} #{width}x#{height} #{format}>"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# Request plumbing for the JSON Bulkpost API.
|
|
8
|
+
#
|
|
9
|
+
# Owns three concerns the resource classes should not repeat: attaching a
|
|
10
|
+
# bearer token (refreshing once on a 401), mapping HTTP status onto this
|
|
11
|
+
# library's error classes, and unwrapping the response envelope.
|
|
12
|
+
class JsonTransport
|
|
13
|
+
# Writes to the Bulkpost API are form-encoded, not JSON, even though the
|
|
14
|
+
# responses are JSON.
|
|
15
|
+
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
16
|
+
|
|
17
|
+
# @return [Array<Hash>] account notices seen on the most recent response
|
|
18
|
+
attr_reader :account_messages
|
|
19
|
+
|
|
20
|
+
def initialize(config:, connection:, token_provider:)
|
|
21
|
+
@config = config
|
|
22
|
+
@connection = connection
|
|
23
|
+
@token_provider = token_provider
|
|
24
|
+
@account_messages = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Envelope]
|
|
28
|
+
def get(path, params: {})
|
|
29
|
+
run(:get, path, params: params)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Envelope]
|
|
33
|
+
def post(path, form: nil)
|
|
34
|
+
run(:post, path, form: form)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Envelope]
|
|
38
|
+
def put(path, form: nil)
|
|
39
|
+
run(:put, path, form: form)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Envelope]
|
|
43
|
+
def delete(path)
|
|
44
|
+
run(:delete, path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Uploads a file as +multipart/form-data+.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String]
|
|
50
|
+
# @param part [Faraday::Multipart::FilePart]
|
|
51
|
+
# @param fields [Hash] additional form fields
|
|
52
|
+
# @return [Envelope]
|
|
53
|
+
def upload(path, part:, fields: {})
|
|
54
|
+
run(:put, path, body: fields.merge("file" => part), content_type: nil)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def run(method, path, params: {}, form: nil, body: nil, content_type: FORM_CONTENT_TYPE, retried: false)
|
|
60
|
+
response = Connection.perform do
|
|
61
|
+
@connection.public_send(method, path) do |req|
|
|
62
|
+
req.headers["Authorization"] = @token_provider.token.to_header
|
|
63
|
+
req.headers["Accept"] = "application/json"
|
|
64
|
+
req.params.update(stringify(params)) unless params.empty?
|
|
65
|
+
|
|
66
|
+
if form && !form.empty?
|
|
67
|
+
req.headers["Content-Type"] = content_type if content_type
|
|
68
|
+
req.body = URI.encode_www_form(form)
|
|
69
|
+
elsif body
|
|
70
|
+
req.body = body
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A 401 can precede the recorded expiry if the token was revoked, so
|
|
76
|
+
# give it exactly one refresh-and-retry before surfacing the failure.
|
|
77
|
+
if response.status == 401 && !retried
|
|
78
|
+
@token_provider.invalidate!
|
|
79
|
+
return run(method, path,
|
|
80
|
+
params: params, form: form, body: body,
|
|
81
|
+
content_type: content_type, retried: true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
handle(response)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle(response)
|
|
88
|
+
envelope = parse_envelope(response)
|
|
89
|
+
@account_messages = envelope&.account_messages || []
|
|
90
|
+
|
|
91
|
+
raise_for_status(response, envelope) unless response.success?
|
|
92
|
+
|
|
93
|
+
# Only reachable on a 2xx, so an unparseable body here is a genuine
|
|
94
|
+
# surprise rather than an error page.
|
|
95
|
+
if envelope.nil?
|
|
96
|
+
raise ParseError, "expected JSON from the Bulkpost API, got #{response.body.to_s[0, 200].inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# HTTP 200 with a populated errors array is a real failure mode here.
|
|
100
|
+
if envelope.error?
|
|
101
|
+
raise APIError.new(
|
|
102
|
+
envelope.error_message,
|
|
103
|
+
status: response.status,
|
|
104
|
+
body: response.body,
|
|
105
|
+
api_errors: envelope.errors
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
envelope
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns nil only when a non-empty body could not be parsed. An empty
|
|
113
|
+
# body is treated as an empty envelope, since some writes reply with no
|
|
114
|
+
# payload at all.
|
|
115
|
+
def parse_envelope(response)
|
|
116
|
+
body = response.body.to_s
|
|
117
|
+
return Envelope.new if body.strip.empty?
|
|
118
|
+
|
|
119
|
+
Envelope.parse(body)
|
|
120
|
+
rescue ParseError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def raise_for_status(response, envelope)
|
|
125
|
+
message = envelope&.error? ? envelope.error_message : "HTTP #{response.status}"
|
|
126
|
+
|
|
127
|
+
raise error_class(response.status).new(
|
|
128
|
+
message,
|
|
129
|
+
status: response.status,
|
|
130
|
+
body: response.body,
|
|
131
|
+
api_errors: envelope&.errors || []
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def error_class(status)
|
|
136
|
+
case status
|
|
137
|
+
when 400 then RequestError
|
|
138
|
+
when 401, 403 then AuthenticationError
|
|
139
|
+
when 404 then NotFoundError
|
|
140
|
+
when 429 then RateLimitError
|
|
141
|
+
when 500..599 then ServerError
|
|
142
|
+
else ResponseError
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def stringify(params)
|
|
147
|
+
params.each_with_object({}) do |(key, value), memo|
|
|
148
|
+
memo[key.to_s] = value unless value.nil?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# A monetary amount as the Bulkpost API reports it: an integer in minor
|
|
6
|
+
# units plus the exponent needed to scale it.
|
|
7
|
+
#
|
|
8
|
+
# +{amount: 100000, currency: "USD", exponent: 2}+ is $1,000.00.
|
|
9
|
+
#
|
|
10
|
+
# Conversion goes through +Rational+ rather than +Float+ so the scaling is
|
|
11
|
+
# exact, and deliberately avoids +BigDecimal+, which stopped being a default
|
|
12
|
+
# gem in Ruby 3.4 and would add a dependency.
|
|
13
|
+
class Money
|
|
14
|
+
# @return [Integer] value in minor units
|
|
15
|
+
attr_reader :amount
|
|
16
|
+
|
|
17
|
+
# @return [String] ISO 4217 currency code
|
|
18
|
+
attr_reader :currency
|
|
19
|
+
|
|
20
|
+
# @return [Integer] power of ten separating minor units from major
|
|
21
|
+
attr_reader :exponent
|
|
22
|
+
|
|
23
|
+
def initialize(amount:, currency: "USD", exponent: 2)
|
|
24
|
+
@amount = amount.to_i
|
|
25
|
+
@currency = currency.to_s
|
|
26
|
+
@exponent = exponent.to_i
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param hash [Hash, nil] raw +{"amount", "currency", "exponent"}+ payload
|
|
31
|
+
# @return [Money, nil]
|
|
32
|
+
def self.from(hash)
|
|
33
|
+
return nil if hash.nil?
|
|
34
|
+
|
|
35
|
+
new(
|
|
36
|
+
amount: hash["amount"],
|
|
37
|
+
currency: hash.fetch("currency", "USD"),
|
|
38
|
+
exponent: hash.fetch("exponent", 2)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Rational] exact value in major units
|
|
43
|
+
def to_r
|
|
44
|
+
Rational(amount, 10**exponent)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Float] value in major units, with the usual float caveats
|
|
48
|
+
def to_f
|
|
49
|
+
to_r.to_f
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [String] e.g. "1000.00 USD"
|
|
53
|
+
def to_s
|
|
54
|
+
"#{format("%.#{exponent}f", to_r)} #{currency}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param other [Object]
|
|
58
|
+
# @return [Boolean] true when amount, currency and exponent all match
|
|
59
|
+
def ==(other)
|
|
60
|
+
other.is_a?(Money) &&
|
|
61
|
+
amount == other.amount &&
|
|
62
|
+
currency == other.currency &&
|
|
63
|
+
exponent == other.exponent
|
|
64
|
+
end
|
|
65
|
+
alias_method :eql?, :==
|
|
66
|
+
|
|
67
|
+
# @return [Integer] value-based hash, so Money can key a Hash
|
|
68
|
+
def hash
|
|
69
|
+
[amount, currency, exponent].hash
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<#{self.class.name} #{self}>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# A posting to be submitted through the bulk RSS interface.
|
|
6
|
+
#
|
|
7
|
+
# Validated on construction, so a malformed posting fails locally instead of
|
|
8
|
+
# consuming a round trip. Everything is frozen afterwards.
|
|
9
|
+
#
|
|
10
|
+
# The interface exposes two generations of optional fields. This models the
|
|
11
|
+
# newer flat attribute groups (+cl:housing_basics+, +cl:job_basics+,
|
|
12
|
+
# +cl:auto_basics+, +cl:forsale+, +cl:generic+, +cl:housing_terms+,
|
|
13
|
+
# +cl:housing_pets+), whose keys are already snake_case and pass through
|
|
14
|
+
# unchanged. The legacy camelCase groups (+cl:housingInfo+, +cl:jobInfo+)
|
|
15
|
+
# are not modelled; +cl:brokerInfo+ is, because housing postings still need
|
|
16
|
+
# it and it has no modern equivalent.
|
|
17
|
+
#
|
|
18
|
+
# @example A minimal for-sale posting
|
|
19
|
+
# Craigslist::API::Posting.new(
|
|
20
|
+
# key: "listing-1",
|
|
21
|
+
# title: "1998 Toyota Hilux",
|
|
22
|
+
# description: "Runs great.",
|
|
23
|
+
# category: "ctd",
|
|
24
|
+
# area: "sfo",
|
|
25
|
+
# price: 4500,
|
|
26
|
+
# reply_email: "sales@example.com",
|
|
27
|
+
# location: {postal: "94110"}
|
|
28
|
+
# )
|
|
29
|
+
class Posting
|
|
30
|
+
# How the reply address is displayed on the posting.
|
|
31
|
+
PRIVACY = {
|
|
32
|
+
none: "A", # show no email address
|
|
33
|
+
anonymous: "C", # use an anonymous craigslist relay address
|
|
34
|
+
public: "P" # show the address as given
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Ruby-side names for +cl:mapLocation+ attributes, which are camelCase on
|
|
38
|
+
# the wire.
|
|
39
|
+
LOCATION_ATTRIBUTES = {
|
|
40
|
+
postal: "postal",
|
|
41
|
+
city: "city",
|
|
42
|
+
state: "state",
|
|
43
|
+
cross_street1: "crossStreet1",
|
|
44
|
+
cross_street2: "crossStreet2",
|
|
45
|
+
latitude: "latitude",
|
|
46
|
+
longitude: "longitude"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Ruby-side names for +cl:brokerInfo+ attributes.
|
|
50
|
+
BROKER_ATTRIBUTES = {
|
|
51
|
+
company_name: "companyName",
|
|
52
|
+
fee_disclosure: "feeDisclosure"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
attr_reader :key, :title, :description, :category, :area, :subarea,
|
|
56
|
+
:neighborhood, :price, :po_number, :reply_email, :reply_privacy,
|
|
57
|
+
:other_contact_info, :location, :images, :broker, :generic,
|
|
58
|
+
:housing_basics, :housing_pets, :housing_terms, :job_basics,
|
|
59
|
+
:auto_basics, :forsale
|
|
60
|
+
|
|
61
|
+
# @param key [String] identifier unique within the submission document.
|
|
62
|
+
# Echoed back on the matching result, and how you correlate a response
|
|
63
|
+
# to what you sent.
|
|
64
|
+
# @param title [String]
|
|
65
|
+
# @param description [String] the posting body
|
|
66
|
+
# @param category [String] category abbreviation, e.g. "ctd"
|
|
67
|
+
# @param area [String] area abbreviation, e.g. "sfo"
|
|
68
|
+
# @param subarea [String, nil] required in areas that have subareas
|
|
69
|
+
# @param neighborhood [String, nil]
|
|
70
|
+
# @param price [Integer, nil]
|
|
71
|
+
# @param po_number [String, nil] your own tracking reference
|
|
72
|
+
# @param reply_email [String, nil]
|
|
73
|
+
# @param reply_privacy [Symbol, String] one of +:none+, +:anonymous+,
|
|
74
|
+
# +:public+, or a raw "A"/"C"/"P"
|
|
75
|
+
# @param other_contact_info [String, nil] free-text alternate contact
|
|
76
|
+
# @param location [Hash] see {LOCATION_ATTRIBUTES}. Either +:postal+ or
|
|
77
|
+
# both +:latitude+ and +:longitude+ is required.
|
|
78
|
+
# @param images [Array<Image, IO, String, Pathname>] see {Image.wrap}
|
|
79
|
+
# @param broker [Hash] see {BROKER_ATTRIBUTES}
|
|
80
|
+
# @param generic [Hash] +cl:generic+ attributes
|
|
81
|
+
# @param housing_basics [Hash] +cl:housing_basics+ attributes
|
|
82
|
+
# @param housing_pets [Hash] +cl:housing_pets+ attributes
|
|
83
|
+
# @param housing_terms [Hash] +cl:housing_terms+ attributes
|
|
84
|
+
# @param job_basics [Hash] +cl:job_basics+ attributes
|
|
85
|
+
# @param auto_basics [Hash] +cl:auto_basics+ attributes
|
|
86
|
+
# @param forsale [Hash] +cl:forsale+ attributes
|
|
87
|
+
# @raise [ValidationError] if anything required is missing
|
|
88
|
+
def initialize(
|
|
89
|
+
key:,
|
|
90
|
+
title:,
|
|
91
|
+
description:,
|
|
92
|
+
category:,
|
|
93
|
+
area:,
|
|
94
|
+
subarea: nil,
|
|
95
|
+
neighborhood: nil,
|
|
96
|
+
price: nil,
|
|
97
|
+
po_number: nil,
|
|
98
|
+
reply_email: nil,
|
|
99
|
+
reply_privacy: :anonymous,
|
|
100
|
+
other_contact_info: nil,
|
|
101
|
+
location: {},
|
|
102
|
+
images: [],
|
|
103
|
+
broker: {},
|
|
104
|
+
generic: {},
|
|
105
|
+
housing_basics: {},
|
|
106
|
+
housing_pets: {},
|
|
107
|
+
housing_terms: {},
|
|
108
|
+
job_basics: {},
|
|
109
|
+
auto_basics: {},
|
|
110
|
+
forsale: {}
|
|
111
|
+
)
|
|
112
|
+
@key = key.to_s
|
|
113
|
+
@title = title.to_s
|
|
114
|
+
@description = description.to_s
|
|
115
|
+
@category = category.to_s
|
|
116
|
+
@area = area.to_s
|
|
117
|
+
@subarea = subarea&.to_s
|
|
118
|
+
@neighborhood = neighborhood&.to_s
|
|
119
|
+
@price = price
|
|
120
|
+
@po_number = po_number&.to_s
|
|
121
|
+
@reply_email = reply_email&.to_s
|
|
122
|
+
@reply_privacy = normalize_privacy(reply_privacy)
|
|
123
|
+
@other_contact_info = other_contact_info&.to_s
|
|
124
|
+
@location = compact(location).freeze
|
|
125
|
+
@images = build_images(images).freeze
|
|
126
|
+
@broker = compact(broker).freeze
|
|
127
|
+
@generic = compact(generic).freeze
|
|
128
|
+
@housing_basics = compact(housing_basics).freeze
|
|
129
|
+
@housing_pets = compact(housing_pets).freeze
|
|
130
|
+
@housing_terms = compact(housing_terms).freeze
|
|
131
|
+
@job_basics = compact(job_basics).freeze
|
|
132
|
+
@auto_basics = compact(auto_basics).freeze
|
|
133
|
+
@forsale = compact(forsale).freeze
|
|
134
|
+
|
|
135
|
+
validate!
|
|
136
|
+
freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Array<String>] problems with this posting, empty when valid
|
|
140
|
+
def errors
|
|
141
|
+
problems = []
|
|
142
|
+
problems << "key is required" if key.empty?
|
|
143
|
+
problems << "title is required" if title.empty?
|
|
144
|
+
problems << "description is required" if description.empty?
|
|
145
|
+
problems << "category is required" if category.empty?
|
|
146
|
+
problems << "area is required" if area.empty?
|
|
147
|
+
problems.concat(location_errors)
|
|
148
|
+
problems.concat(image_errors)
|
|
149
|
+
problems
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Boolean]
|
|
153
|
+
def valid?
|
|
154
|
+
errors.empty?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def inspect
|
|
158
|
+
"#<#{self.class.name} key=#{key.inspect} category=#{category.inspect} " \
|
|
159
|
+
"area=#{area.inspect} images=#{images.size}>"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def validate!
|
|
165
|
+
found = errors
|
|
166
|
+
raise ValidationError, found unless found.empty?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def location_errors
|
|
170
|
+
has_postal = !location[:postal].to_s.empty?
|
|
171
|
+
has_coords = !location[:latitude].nil? && !location[:longitude].nil?
|
|
172
|
+
return [] if has_postal || has_coords
|
|
173
|
+
|
|
174
|
+
["location requires :postal, or both :latitude and :longitude"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def image_errors
|
|
178
|
+
problems = []
|
|
179
|
+
|
|
180
|
+
if images.size > Image::MAX_PER_POSTING
|
|
181
|
+
problems << "a posting accepts at most #{Image::MAX_PER_POSTING} images, got #{images.size}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
positions = images.map(&:position).compact
|
|
185
|
+
out_of_range = positions.reject { |p| p.between?(0, Image::MAX_POSITION) }
|
|
186
|
+
unless out_of_range.empty?
|
|
187
|
+
problems << "image positions must be 0..#{Image::MAX_POSITION}, got #{out_of_range.inspect}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
duplicates = positions.tally.select { |_, count| count > 1 }.keys
|
|
191
|
+
problems << "duplicate image positions: #{duplicates.inspect}" unless duplicates.empty?
|
|
192
|
+
|
|
193
|
+
problems
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_images(sources)
|
|
197
|
+
Array(sources).each_with_index.map do |source, index|
|
|
198
|
+
Image.wrap(source).with_default_position(index)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_privacy(value)
|
|
203
|
+
return PRIVACY.fetch(value) if value.is_a?(Symbol) && PRIVACY.key?(value)
|
|
204
|
+
|
|
205
|
+
string = value.to_s.upcase
|
|
206
|
+
return string if PRIVACY.value?(string)
|
|
207
|
+
|
|
208
|
+
raise ValidationError, ["reply_privacy must be one of #{PRIVACY.keys.inspect} or #{PRIVACY.values.inspect}"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def compact(hash)
|
|
212
|
+
(hash || {}).reject { |_, value| value.nil? }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|