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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
module Resources
|
|
6
|
+
# Reading and editing postings that already exist.
|
|
7
|
+
#
|
|
8
|
+
# Postings are created through the RSS interface ({Client#post}); this
|
|
9
|
+
# covers everything afterwards.
|
|
10
|
+
class Postings < Base
|
|
11
|
+
# Statuses a posting can report.
|
|
12
|
+
STATUSES = %w[active deleted expired pending removed].freeze
|
|
13
|
+
|
|
14
|
+
# @param posting_id [String, Integer]
|
|
15
|
+
# @return [String] one of {STATUSES}
|
|
16
|
+
def status(posting_id)
|
|
17
|
+
fetch(transport.get(path("postings", posting_id, "status")), "status")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param posting_id [String, Integer]
|
|
21
|
+
# @return [String] the posting body
|
|
22
|
+
def body(posting_id)
|
|
23
|
+
fetch(transport.get(path("postings", posting_id, "body")), "body")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Replaces the posting body.
|
|
27
|
+
#
|
|
28
|
+
# Postings in "ctd" (cars & trucks by dealer) must keep the VIN they
|
|
29
|
+
# were created with; the API rejects a body that changes it.
|
|
30
|
+
#
|
|
31
|
+
# @param posting_id [String, Integer]
|
|
32
|
+
# @param body [String]
|
|
33
|
+
# @return [String] the updated body as the API echoes it back
|
|
34
|
+
def update_body(posting_id, body)
|
|
35
|
+
envelope = transport.put(path("postings", posting_id, "body"), form: {"body" => body})
|
|
36
|
+
fetch(envelope, "body")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param posting_id [String, Integer]
|
|
40
|
+
# @return [Integer, nil] for categories that carry a price
|
|
41
|
+
def price(posting_id)
|
|
42
|
+
fetch(transport.get(path("postings", posting_id, "price")), "price")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param posting_id [String, Integer]
|
|
46
|
+
# @param price [Integer] in the currency of the posting's location
|
|
47
|
+
# @return [Integer] the updated price
|
|
48
|
+
def update_price(posting_id, price)
|
|
49
|
+
envelope = transport.put(path("postings", posting_id, "price"), form: {"price" => price.to_i})
|
|
50
|
+
fetch(envelope, "price")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param posting_id [String, Integer]
|
|
54
|
+
# @return [String, nil] for jobs and gigs postings
|
|
55
|
+
def remuneration(posting_id)
|
|
56
|
+
fetch(transport.get(path("postings", posting_id, "remuneration")), "remuneration")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param posting_id [String, Integer]
|
|
60
|
+
# @param remuneration [String] free text, e.g. "$19.95/hr plus tips"
|
|
61
|
+
# @return [String] the updated value
|
|
62
|
+
def update_remuneration(posting_id, remuneration)
|
|
63
|
+
envelope = transport.put(
|
|
64
|
+
path("postings", posting_id, "remuneration"),
|
|
65
|
+
form: {"remuneration" => remuneration}
|
|
66
|
+
)
|
|
67
|
+
fetch(envelope, "remuneration")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param posting_id [String, Integer]
|
|
71
|
+
# @return [true]
|
|
72
|
+
def delete(posting_id)
|
|
73
|
+
transport.delete(path("postings", posting_id))
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param posting_id [String, Integer]
|
|
78
|
+
# @return [true]
|
|
79
|
+
def undelete(posting_id)
|
|
80
|
+
transport.put(path("postings", posting_id, "undelete"))
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Maps a US ZIP code onto the craigslist area that covers it, and the
|
|
85
|
+
# subarea within it when there is one.
|
|
86
|
+
#
|
|
87
|
+
# @param zip [String]
|
|
88
|
+
# @return [ZipLocation]
|
|
89
|
+
def area_for_zip(zip)
|
|
90
|
+
ZipLocation.from(transport.get(path("posting", "zip", zip, "area")).data)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# Turns the RSS document returned by validate/post into a {ResultSet}.
|
|
8
|
+
#
|
|
9
|
+
# Element lookups use the literal prefixed names as they appear on the wire
|
|
10
|
+
# rather than XPath namespace resolution, which REXML handles awkwardly when
|
|
11
|
+
# a default namespace is in play.
|
|
12
|
+
class ResponseParser
|
|
13
|
+
# Prefix craigslist puts on the channel description in post mode.
|
|
14
|
+
UPLOAD_ID_PREFIX = "upload-id:"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# @param xml [String] raw response body
|
|
18
|
+
# @return [ResultSet]
|
|
19
|
+
# @raise [ParseError] if the body is not well-formed XML
|
|
20
|
+
def parse(xml)
|
|
21
|
+
document = build(xml)
|
|
22
|
+
root = document.root
|
|
23
|
+
raise ParseError, "response contained no root element" if root.nil?
|
|
24
|
+
|
|
25
|
+
new(root, xml.to_s).result_set
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build(xml)
|
|
31
|
+
REXML::Document.new(xml.to_s)
|
|
32
|
+
rescue REXML::ParseException => e
|
|
33
|
+
raise ParseError, "could not parse the bulk posting response: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(root, raw = nil)
|
|
38
|
+
@root = root
|
|
39
|
+
@raw = raw
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [ResultSet]
|
|
43
|
+
def result_set
|
|
44
|
+
ResultSet.new(results: results, upload_id: upload_id, raw: raw)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :root, :raw
|
|
50
|
+
|
|
51
|
+
def results
|
|
52
|
+
root.get_elements("item").map { |item| build_result(item) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_result(item)
|
|
56
|
+
Result.new(
|
|
57
|
+
key: item.attributes["rdf:about"].to_s,
|
|
58
|
+
status: value_of(item, "cl:postedStatus"),
|
|
59
|
+
explanation: value_of(item, "cl:postedExplanation"),
|
|
60
|
+
posting_id: value_of(item, "cl:postingID"),
|
|
61
|
+
manage_url: value_of(item, "cl:postingManageURL"),
|
|
62
|
+
view_url: value_of(item, "cl:postingViewURL"),
|
|
63
|
+
preview_html: value_of(item, "cl:previewHTML"),
|
|
64
|
+
warnings: item.get_elements("cl:warning").filter_map { |w| text_of(w) }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def upload_id
|
|
69
|
+
description = root.get_elements("channel/description").first
|
|
70
|
+
text = text_of(description)
|
|
71
|
+
return nil if text.nil? || !text.start_with?(UPLOAD_ID_PREFIX)
|
|
72
|
+
|
|
73
|
+
text.delete_prefix(UPLOAD_ID_PREFIX)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def value_of(parent, name)
|
|
77
|
+
text_of(parent.get_elements(name).first)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reads every text child, CDATA included, and strips surrounding
|
|
81
|
+
# whitespace — craigslist pretty-prints URLs onto their own lines, which
|
|
82
|
+
# leaves newlines inside the element.
|
|
83
|
+
def text_of(element)
|
|
84
|
+
return nil if element.nil?
|
|
85
|
+
|
|
86
|
+
text = element.texts.map(&:value).join.strip
|
|
87
|
+
text.empty? ? nil : text
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# The outcome of a single posting within a bulk submission.
|
|
6
|
+
#
|
|
7
|
+
# Bulk submissions succeed and fail per item, and the HTTP status says
|
|
8
|
+
# nothing about it — a 200 can carry a document in which every posting
|
|
9
|
+
# failed. So submissions return results rather than raising: a batch with a
|
|
10
|
+
# few rejections is ordinary, not exceptional.
|
|
11
|
+
class Result
|
|
12
|
+
# Returned by validate when the posting looks acceptable.
|
|
13
|
+
VALID = "VALID"
|
|
14
|
+
|
|
15
|
+
# Validation failed. {#explanation} says why.
|
|
16
|
+
NOT_VALID = "NOT_VALID"
|
|
17
|
+
|
|
18
|
+
# The posting was accepted.
|
|
19
|
+
POSTED = "POSTED"
|
|
20
|
+
|
|
21
|
+
# Unexpected error at post time.
|
|
22
|
+
FAILED = "FAILED"
|
|
23
|
+
|
|
24
|
+
# The account has no remaining blocks for this area/category and is not
|
|
25
|
+
# invoiced. More must be purchased before the posting is processed.
|
|
26
|
+
INSUFFICIENT_BLOCKS = "INSUFFICIENT_BLOCKS"
|
|
27
|
+
|
|
28
|
+
# The invoiced account hit its credit limit.
|
|
29
|
+
CREDIT_LIMIT_REACHED = "CREDIT_LIMIT_REACHED"
|
|
30
|
+
|
|
31
|
+
# Billing the card named by +cl:purchaseWithCreditCardID+ failed.
|
|
32
|
+
CREDIT_CARD_ERROR = "CREDIT_CARD_ERROR"
|
|
33
|
+
|
|
34
|
+
# @return [String] the key you supplied for this posting
|
|
35
|
+
attr_reader :key
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] one of the status constants above
|
|
38
|
+
attr_reader :status
|
|
39
|
+
|
|
40
|
+
# @return [String, nil] human-readable detail, populated on failure
|
|
41
|
+
attr_reader :explanation
|
|
42
|
+
|
|
43
|
+
# @return [String, nil] craigslist's id for the new posting, on success
|
|
44
|
+
attr_reader :posting_id
|
|
45
|
+
|
|
46
|
+
# @return [String, nil] URL for editing or deleting the posting
|
|
47
|
+
attr_reader :manage_url
|
|
48
|
+
|
|
49
|
+
# @return [String, nil] public URL of the posting
|
|
50
|
+
attr_reader :view_url
|
|
51
|
+
|
|
52
|
+
# @return [String, nil] HTML preview of the posting as submitted
|
|
53
|
+
attr_reader :preview_html
|
|
54
|
+
|
|
55
|
+
# @return [Array<String>] non-fatal warnings, typically XML parsing
|
|
56
|
+
# complaints. Worth logging even when the posting succeeded.
|
|
57
|
+
attr_reader :warnings
|
|
58
|
+
|
|
59
|
+
def initialize(key:, status: nil, explanation: nil, posting_id: nil,
|
|
60
|
+
manage_url: nil, view_url: nil, preview_html: nil, warnings: [])
|
|
61
|
+
@key = key
|
|
62
|
+
@status = status
|
|
63
|
+
@explanation = explanation
|
|
64
|
+
@posting_id = posting_id
|
|
65
|
+
@manage_url = manage_url
|
|
66
|
+
@view_url = view_url
|
|
67
|
+
@preview_html = preview_html
|
|
68
|
+
@warnings = Array(warnings).freeze
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] validated successfully (validate mode only)
|
|
73
|
+
def valid?
|
|
74
|
+
status == VALID
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] accepted for posting (post mode only)
|
|
78
|
+
def posted?
|
|
79
|
+
status == POSTED
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] either valid or posted
|
|
83
|
+
def success?
|
|
84
|
+
valid? || posted?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def failure?
|
|
89
|
+
!success?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Boolean] rejected during validation
|
|
93
|
+
def not_valid?
|
|
94
|
+
status == NOT_VALID
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Boolean] the account is out of posting blocks
|
|
98
|
+
def insufficient_blocks?
|
|
99
|
+
status == INSUFFICIENT_BLOCKS
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Boolean] the invoiced account hit its credit limit
|
|
103
|
+
def credit_limit_reached?
|
|
104
|
+
status == CREDIT_LIMIT_REACHED
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] the credit card could not be billed
|
|
108
|
+
def credit_card_error?
|
|
109
|
+
status == CREDIT_CARD_ERROR
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Boolean] whether any non-fatal warnings came back
|
|
113
|
+
def warnings?
|
|
114
|
+
!warnings.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def inspect
|
|
118
|
+
"#<#{self.class.name} key=#{key.inspect} status=#{status.inspect} " \
|
|
119
|
+
"posting_id=#{posting_id.inspect}>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Every {Result} from one bulk submission, plus the upload id Craigslist
|
|
6
|
+
# assigns to the batch.
|
|
7
|
+
#
|
|
8
|
+
# Enumerable, so it behaves like the array of results it wraps while still
|
|
9
|
+
# answering the questions you actually have after a submission.
|
|
10
|
+
class ResultSet
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
# @return [Array<Result>]
|
|
14
|
+
attr_reader :results
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] craigslist's identifier for this submission,
|
|
17
|
+
# taken from the channel description. Present in post mode.
|
|
18
|
+
attr_reader :upload_id
|
|
19
|
+
|
|
20
|
+
# The unparsed response body.
|
|
21
|
+
#
|
|
22
|
+
# Kept because a NOT_VALID with no explanation is otherwise a dead end,
|
|
23
|
+
# and reaching the raw XML should not require rebuilding the request by
|
|
24
|
+
# hand.
|
|
25
|
+
#
|
|
26
|
+
# @return [String, nil]
|
|
27
|
+
attr_reader :raw
|
|
28
|
+
|
|
29
|
+
def initialize(results:, upload_id: nil, raw: nil)
|
|
30
|
+
@results = Array(results).freeze
|
|
31
|
+
@upload_id = upload_id
|
|
32
|
+
@raw = raw
|
|
33
|
+
@index = @results.each_with_object({}) { |r, memo| memo[r.key] = r }.freeze
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @yieldparam result [Result]
|
|
38
|
+
# @return [Enumerator, ResultSet]
|
|
39
|
+
def each(&block)
|
|
40
|
+
results.each(&block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param key [String] the key supplied when building the posting
|
|
44
|
+
# @return [Result, nil]
|
|
45
|
+
def [](key)
|
|
46
|
+
@index[key.to_s]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Result>]
|
|
50
|
+
def successful
|
|
51
|
+
select(&:success?)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Array<Result>]
|
|
55
|
+
def failed
|
|
56
|
+
select(&:failure?)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Array<Result>] results carrying non-fatal warnings, which are
|
|
60
|
+
# easy to miss because they can accompany a success
|
|
61
|
+
def warned
|
|
62
|
+
select(&:warnings?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether every posting succeeded
|
|
66
|
+
def all_successful?
|
|
67
|
+
failed.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def any_failures?
|
|
72
|
+
!failed.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Array<String>] ids of postings that were created
|
|
76
|
+
def posting_ids
|
|
77
|
+
select(&:posted?).map(&:posting_id).compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Integer] number of results in the batch
|
|
81
|
+
def size
|
|
82
|
+
results.size
|
|
83
|
+
end
|
|
84
|
+
alias_method :length, :size
|
|
85
|
+
|
|
86
|
+
def empty?
|
|
87
|
+
results.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inspect
|
|
91
|
+
"#<#{self.class.name} size=#{size} successful=#{successful.size} " \
|
|
92
|
+
"failed=#{failed.size} upload_id=#{upload_id.inspect}>"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# Renders {Posting} objects into the RDF/RSS document the bulk interface
|
|
8
|
+
# expects.
|
|
9
|
+
#
|
|
10
|
+
# REXML lives here and nowhere else. Bulk submissions inline base64 images,
|
|
11
|
+
# so a large batch is a large document; if building a DOM ever becomes a
|
|
12
|
+
# memory problem this class can be reimplemented as a streaming writer
|
|
13
|
+
# without changing anything public.
|
|
14
|
+
class Serializer
|
|
15
|
+
# Default namespace for the RSS 1.0 elements.
|
|
16
|
+
RSS_NAMESPACE = "http://purl.org/rss/1.0/"
|
|
17
|
+
|
|
18
|
+
# RDF namespace, used for the item manifest.
|
|
19
|
+
RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
20
|
+
|
|
21
|
+
# Craigslist's own namespace, carrying every cl:* element.
|
|
22
|
+
CL_NAMESPACE = "http://www.craigslist.org/about/cl-bulk-ns/1.0"
|
|
23
|
+
|
|
24
|
+
# @param config [Configuration] supplies the +cl:auth+ credentials
|
|
25
|
+
def initialize(config)
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param postings [Array<Posting>]
|
|
30
|
+
# @param io [IO, String, nil] destination; a new String is used if omitted
|
|
31
|
+
# @return [String, IO] whatever was written to
|
|
32
|
+
def serialize(postings, io: nil)
|
|
33
|
+
output = io || +""
|
|
34
|
+
build_document(Array(postings)).write(output)
|
|
35
|
+
output
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :config
|
|
41
|
+
|
|
42
|
+
def build_document(postings)
|
|
43
|
+
doc = REXML::Document.new
|
|
44
|
+
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
45
|
+
|
|
46
|
+
root = doc.add_element("rdf:RDF",
|
|
47
|
+
"xmlns" => RSS_NAMESPACE,
|
|
48
|
+
"xmlns:rdf" => RDF_NAMESPACE,
|
|
49
|
+
"xmlns:cl" => CL_NAMESPACE)
|
|
50
|
+
|
|
51
|
+
append_channel(root, postings)
|
|
52
|
+
postings.each { |posting| append_item(root, posting) }
|
|
53
|
+
|
|
54
|
+
doc
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def append_channel(root, postings)
|
|
58
|
+
channel = root.add_element("channel")
|
|
59
|
+
|
|
60
|
+
items = channel.add_element("items")
|
|
61
|
+
postings.each do |posting|
|
|
62
|
+
items.add_element("rdf:li", "rdf:resource" => posting.key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
channel.add_element("cl:auth",
|
|
66
|
+
"username" => config.email,
|
|
67
|
+
"password" => config.password,
|
|
68
|
+
"accountID" => config.account_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_item(root, posting)
|
|
72
|
+
item = root.add_element("item", "rdf:about" => posting.key)
|
|
73
|
+
|
|
74
|
+
text_element(item, "cl:category", posting.category)
|
|
75
|
+
text_element(item, "cl:area", posting.area)
|
|
76
|
+
text_element(item, "cl:subarea", posting.subarea)
|
|
77
|
+
text_element(item, "cl:neighborhood", posting.neighborhood)
|
|
78
|
+
text_element(item, "cl:price", posting.price)
|
|
79
|
+
|
|
80
|
+
attribute_element(item, "cl:housing_basics", posting.housing_basics)
|
|
81
|
+
attribute_element(item, "cl:housing_pets", posting.housing_pets)
|
|
82
|
+
attribute_element(item, "cl:housing_terms", posting.housing_terms)
|
|
83
|
+
attribute_element(item, "cl:job_basics", posting.job_basics)
|
|
84
|
+
attribute_element(item, "cl:auto_basics", posting.auto_basics)
|
|
85
|
+
attribute_element(item, "cl:forsale", posting.forsale)
|
|
86
|
+
attribute_element(item, "cl:generic", posting.generic)
|
|
87
|
+
|
|
88
|
+
attribute_element(item, "cl:mapLocation",
|
|
89
|
+
rename(posting.location, Posting::LOCATION_ATTRIBUTES))
|
|
90
|
+
append_reply_email(item, posting)
|
|
91
|
+
attribute_element(item, "cl:brokerInfo",
|
|
92
|
+
rename(posting.broker, Posting::BROKER_ATTRIBUTES))
|
|
93
|
+
|
|
94
|
+
text_element(item, "title", posting.title)
|
|
95
|
+
cdata_element(item, "description", posting.description)
|
|
96
|
+
text_element(item, "cl:PONumber", posting.po_number)
|
|
97
|
+
|
|
98
|
+
posting.images.each { |image| append_image(item, image) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def append_reply_email(item, posting)
|
|
102
|
+
return if posting.reply_email.nil? || posting.reply_email.empty?
|
|
103
|
+
|
|
104
|
+
attributes = {"privacy" => posting.reply_privacy}
|
|
105
|
+
unless posting.other_contact_info.nil? || posting.other_contact_info.empty?
|
|
106
|
+
attributes["otherContactInfo"] = posting.other_contact_info
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
item.add_element("cl:replyEmail", attributes).add_text(posting.reply_email)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def append_image(item, image)
|
|
113
|
+
attributes = image.position.nil? ? {} : {"position" => image.position.to_s}
|
|
114
|
+
element = item.add_element("cl:image", attributes)
|
|
115
|
+
|
|
116
|
+
# Written raw: base64 is alphanumeric plus "+/=", so it needs no
|
|
117
|
+
# escaping, and skipping the escape pass matters on megabyte payloads.
|
|
118
|
+
element.add(REXML::Text.new(image.data, true, nil, true))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def text_element(parent, name, value)
|
|
122
|
+
return if value.nil? || value.to_s.empty?
|
|
123
|
+
|
|
124
|
+
parent.add_element(name).add_text(value.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Emits text as CDATA, split around any literal "]]>".
|
|
128
|
+
#
|
|
129
|
+
# A "]]>" in a posting body would otherwise close the section early and
|
|
130
|
+
# corrupt the whole document. REXML does not guard against this and
|
|
131
|
+
# posting bodies are arbitrary user text, so split the run across
|
|
132
|
+
# adjacent sections: "a]]>b" becomes CDATA("a]]") + CDATA(">b"), which
|
|
133
|
+
# concatenates back to the original on the far end.
|
|
134
|
+
def cdata_element(parent, name, value)
|
|
135
|
+
element = parent.add_element(name)
|
|
136
|
+
chunks = value.to_s.split("]]>", -1)
|
|
137
|
+
final = chunks.size - 1
|
|
138
|
+
|
|
139
|
+
chunks.each_with_index do |chunk, index|
|
|
140
|
+
content = +""
|
|
141
|
+
content << ">" unless index.zero?
|
|
142
|
+
content << chunk
|
|
143
|
+
content << "]]" unless index == final
|
|
144
|
+
|
|
145
|
+
element.add(REXML::CData.new(content))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
element
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def attribute_element(parent, name, attributes)
|
|
152
|
+
return if attributes.nil? || attributes.empty?
|
|
153
|
+
|
|
154
|
+
parent.add_element(name, stringify(attributes))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def rename(hash, mapping)
|
|
158
|
+
hash.each_with_object({}) do |(key, value), memo|
|
|
159
|
+
memo[mapping.fetch(key.to_sym, key.to_s)] = value
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The interface expresses booleans as 0/1, so accept Ruby booleans and
|
|
164
|
+
# translate rather than making callers remember.
|
|
165
|
+
def stringify(hash)
|
|
166
|
+
hash.each_with_object({}) do |(key, value), memo|
|
|
167
|
+
memo[key.to_s] =
|
|
168
|
+
case value
|
|
169
|
+
when true then "1"
|
|
170
|
+
when false then "0"
|
|
171
|
+
else value.to_s
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Craigslist
|
|
7
|
+
module API
|
|
8
|
+
# Acquires and caches the OAuth2 access token used by the JSON API.
|
|
9
|
+
#
|
|
10
|
+
# Tokens live an hour. This holds one per client instance, guarded by a
|
|
11
|
+
# mutex so concurrent callers on a shared client cannot stampede the token
|
|
12
|
+
# endpoint or read a half-replaced token. Deliberately an instance, not a
|
|
13
|
+
# class-level cache: one process may talk to several accounts.
|
|
14
|
+
class TokenProvider
|
|
15
|
+
# OAuth2 token endpoint.
|
|
16
|
+
TOKEN_PATH = "/bulkpost/oauth/access-token"
|
|
17
|
+
|
|
18
|
+
# @param config [Configuration]
|
|
19
|
+
# @param connection [Faraday::Connection] pointed at the bapi host
|
|
20
|
+
def initialize(config:, connection:)
|
|
21
|
+
@config = config
|
|
22
|
+
@connection = connection
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@token = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns a live token, fetching or refreshing if needed.
|
|
28
|
+
#
|
|
29
|
+
# @return [AccessToken]
|
|
30
|
+
def token
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@token = fetch if @token.nil? || @token.expired?
|
|
33
|
+
@token
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Drops the cached token so the next call re-authenticates.
|
|
38
|
+
#
|
|
39
|
+
# Called after a 401, which can happen before the recorded expiry if the
|
|
40
|
+
# token was revoked server-side.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
def invalidate!
|
|
44
|
+
@mutex.synchronize { @token = nil }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def fetch
|
|
50
|
+
response = Connection.perform do
|
|
51
|
+
@connection.post(TOKEN_PATH) do |req|
|
|
52
|
+
req.headers["Authorization"] = @config.basic_authorization
|
|
53
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
54
|
+
req.headers["Accept"] = "application/json"
|
|
55
|
+
req.body = URI.encode_www_form(
|
|
56
|
+
grant_type: "client_credentials",
|
|
57
|
+
scope: @config.scopes.join(" ")
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
unless response.success?
|
|
63
|
+
raise AuthenticationError.new(
|
|
64
|
+
"token request failed with HTTP #{response.status}",
|
|
65
|
+
status: response.status,
|
|
66
|
+
body: response.body
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
AccessToken.from_payload(parse(response))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse(response)
|
|
74
|
+
JSON.parse(response.body.to_s)
|
|
75
|
+
rescue JSON::ParserError => e
|
|
76
|
+
raise ParseError, "token endpoint returned invalid JSON: #{e.message}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|