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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Remaining prepaid posting blocks for one area/product combination.
|
|
6
|
+
class PostingBlock
|
|
7
|
+
# @return [String] area abbreviation, e.g. "htf"
|
|
8
|
+
attr_reader :area
|
|
9
|
+
|
|
10
|
+
# @return [String] e.g. "CAR", "JOB"
|
|
11
|
+
attr_reader :product_class
|
|
12
|
+
|
|
13
|
+
# @return [String] e.g. "hartford cars & trucks - by dealer block"
|
|
14
|
+
attr_reader :product_name
|
|
15
|
+
|
|
16
|
+
# @return [Integer] postings still available against this block
|
|
17
|
+
attr_reader :remaining_posts
|
|
18
|
+
|
|
19
|
+
def initialize(area:, product_class:, product_name:, remaining_posts:)
|
|
20
|
+
@area = area
|
|
21
|
+
@product_class = product_class
|
|
22
|
+
@product_name = product_name
|
|
23
|
+
@remaining_posts = remaining_posts.to_i
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param hash [Hash] raw payload
|
|
28
|
+
# @return [PostingBlock]
|
|
29
|
+
def self.from(hash)
|
|
30
|
+
new(
|
|
31
|
+
area: hash["area"],
|
|
32
|
+
product_class: hash["productClass"],
|
|
33
|
+
product_name: hash["productName"],
|
|
34
|
+
remaining_posts: hash["remainingPosts"]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect
|
|
39
|
+
"#<#{self.class.name} area=#{area.inspect} class=#{product_class.inspect} " \
|
|
40
|
+
"remaining=#{remaining_posts}>"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# A live posting, addressed by id.
|
|
6
|
+
#
|
|
7
|
+
# Returned by {Client#posting}. Holds no state of its own beyond the id —
|
|
8
|
+
# every reader hits the API — so it is safe to keep around, and it never
|
|
9
|
+
# goes stale.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# posting = client.posting("7123456780")
|
|
13
|
+
# posting.status #=> "active"
|
|
14
|
+
# posting.price = 4200
|
|
15
|
+
# posting.add_image("photo.jpg")
|
|
16
|
+
# posting.delete
|
|
17
|
+
class PostingHandle
|
|
18
|
+
# @return [String]
|
|
19
|
+
attr_reader :id
|
|
20
|
+
|
|
21
|
+
def initialize(client, id)
|
|
22
|
+
@client = client
|
|
23
|
+
@id = id.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] one of {Resources::Postings::STATUSES}
|
|
27
|
+
def status
|
|
28
|
+
client.postings.status(id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def active?
|
|
33
|
+
status == "active"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def deleted?
|
|
38
|
+
status == "deleted"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def expired?
|
|
43
|
+
status == "expired"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String]
|
|
47
|
+
def body
|
|
48
|
+
client.postings.body(id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param value [String]
|
|
52
|
+
# @return [String]
|
|
53
|
+
def update_body(value)
|
|
54
|
+
client.postings.update_body(id, value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param value [String]
|
|
58
|
+
def body=(value)
|
|
59
|
+
update_body(value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Integer, nil]
|
|
63
|
+
def price
|
|
64
|
+
client.postings.price(id)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param value [Integer]
|
|
68
|
+
# @return [Integer]
|
|
69
|
+
def update_price(value)
|
|
70
|
+
client.postings.update_price(id, value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param value [Integer]
|
|
74
|
+
def price=(value)
|
|
75
|
+
update_price(value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String, nil]
|
|
79
|
+
def remuneration
|
|
80
|
+
client.postings.remuneration(id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param value [String]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def update_remuneration(value)
|
|
86
|
+
client.postings.update_remuneration(id, value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param value [String]
|
|
90
|
+
def remuneration=(value)
|
|
91
|
+
update_remuneration(value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [true]
|
|
95
|
+
def delete
|
|
96
|
+
client.postings.delete(id)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [true]
|
|
100
|
+
def undelete
|
|
101
|
+
client.postings.undelete(id)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Array<ImageInfo>]
|
|
105
|
+
def images
|
|
106
|
+
client.images.list(id)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @see Resources::Images#upload
|
|
110
|
+
# @return [ImageInfo]
|
|
111
|
+
def add_image(source, **options)
|
|
112
|
+
client.images.upload(id, source, **options)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param image_id [String]
|
|
116
|
+
# @return [true]
|
|
117
|
+
def remove_image(image_id)
|
|
118
|
+
client.images.remove(id, image_id)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param image_ids [Array<String>]
|
|
122
|
+
# @return [true]
|
|
123
|
+
def reorder_images(image_ids)
|
|
124
|
+
client.images.reorder(id, image_ids)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @see Resources::Account#posting_stats
|
|
128
|
+
# @return [PostingStats]
|
|
129
|
+
def stats(start: nil, stop: nil)
|
|
130
|
+
client.account.posting_stats(id, start: start, stop: stop)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def inspect
|
|
134
|
+
"#<#{self.class.name} id=#{id.inspect}>"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
attr_reader :client
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Daily engagement counts for a posting.
|
|
6
|
+
#
|
|
7
|
+
# Craigslist reports each metric as +[unix_timestamp, count]+ pairs, one per
|
|
8
|
+
# UTC day, over a rolling thirty-day window ending at midnight UTC yesterday.
|
|
9
|
+
# Counts are requests, not unique viewers.
|
|
10
|
+
class PostingStats
|
|
11
|
+
# Every metric the API reports.
|
|
12
|
+
METRICS = %i[
|
|
13
|
+
impressions
|
|
14
|
+
views
|
|
15
|
+
contact
|
|
16
|
+
contact_chat
|
|
17
|
+
contact_phone
|
|
18
|
+
contact_email
|
|
19
|
+
share
|
|
20
|
+
favorite
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] absent when the stats were requested for a single
|
|
24
|
+
# posting, since the id is then already known
|
|
25
|
+
attr_reader :posting_id
|
|
26
|
+
|
|
27
|
+
# @return [Hash{Symbol => Array<Array(Time, Integer)>}]
|
|
28
|
+
attr_reader :series
|
|
29
|
+
|
|
30
|
+
def initialize(posting_id: nil, series: {})
|
|
31
|
+
@posting_id = posting_id
|
|
32
|
+
@series = series.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param hash [Hash] raw payload for one posting
|
|
37
|
+
# @return [PostingStats]
|
|
38
|
+
def self.from(hash)
|
|
39
|
+
hash ||= {}
|
|
40
|
+
|
|
41
|
+
series = METRICS.each_with_object({}) do |metric, memo|
|
|
42
|
+
points = hash[metric.to_s]
|
|
43
|
+
next if points.nil?
|
|
44
|
+
|
|
45
|
+
memo[metric] = Array(points).map do |(timestamp, count)|
|
|
46
|
+
[Time.at(timestamp.to_i).utc, count.to_i]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
new(posting_id: hash["postingId"], series: series)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Time series for one metric.
|
|
54
|
+
#
|
|
55
|
+
# @param metric [Symbol] one of {METRICS}
|
|
56
|
+
# @return [Array<Array(Time, Integer)>] empty when not reported
|
|
57
|
+
def [](metric)
|
|
58
|
+
series.fetch(metric.to_sym, [])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sum of a metric across the whole window.
|
|
62
|
+
#
|
|
63
|
+
# @param metric [Symbol] one of {METRICS}
|
|
64
|
+
# @return [Integer]
|
|
65
|
+
def total(metric)
|
|
66
|
+
self[metric].sum { |(_, count)| count }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
METRICS.each do |metric|
|
|
70
|
+
# @return [Integer] total for this metric over the window
|
|
71
|
+
define_method(:"total_#{metric}") { total(metric) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<Symbol>] metrics that carry data
|
|
75
|
+
def reported_metrics
|
|
76
|
+
series.keys
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inspect
|
|
80
|
+
"#<#{self.class.name} posting_id=#{posting_id.inspect} " \
|
|
81
|
+
"metrics=#{reported_metrics.inspect}>"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# Areas and categories, from craigslist's public reference service.
|
|
8
|
+
#
|
|
9
|
+
# These endpoints need no authentication and return bare JSON arrays rather
|
|
10
|
+
# than the Bulkpost envelope, so they bypass {JsonTransport} entirely.
|
|
11
|
+
#
|
|
12
|
+
# Both payloads are static enough to fetch once and hold — areas is around
|
|
13
|
+
# 165KB — so results are memoized per client behind a mutex.
|
|
14
|
+
class Reference
|
|
15
|
+
# Public endpoint listing every area and its subareas.
|
|
16
|
+
AREAS_PATH = "/Areas"
|
|
17
|
+
|
|
18
|
+
# Public endpoint listing every posting category.
|
|
19
|
+
CATEGORIES_PATH = "/Categories"
|
|
20
|
+
|
|
21
|
+
def initialize(connection)
|
|
22
|
+
@connection = connection
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@areas = nil
|
|
25
|
+
@categories = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array<Area>]
|
|
29
|
+
def areas
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@areas ||= get(AREAS_PATH).map { |entry| Area.from(entry) }.freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<Category>]
|
|
36
|
+
def categories
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@categories ||= get(CATEGORIES_PATH).map { |entry| Category.from(entry) }.freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param abbreviation [String] e.g. "sfo"
|
|
43
|
+
# @return [Area, nil]
|
|
44
|
+
def area(abbreviation)
|
|
45
|
+
areas.find { |a| a.abbreviation == abbreviation.to_s }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param abbreviation [String] e.g. "ctd"
|
|
49
|
+
# @return [Category, nil]
|
|
50
|
+
def category(abbreviation)
|
|
51
|
+
categories.find { |c| c.abbreviation == abbreviation.to_s }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Drops the cached payloads so the next call refetches.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def reload
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@areas = nil
|
|
60
|
+
@categories = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def get(path)
|
|
67
|
+
response = Connection.perform { @connection.get(path) }
|
|
68
|
+
|
|
69
|
+
unless response.success?
|
|
70
|
+
raise ResponseError.new(
|
|
71
|
+
"reference request failed with HTTP #{response.status}",
|
|
72
|
+
status: response.status,
|
|
73
|
+
body: response.body
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
parsed = JSON.parse(response.body.to_s)
|
|
78
|
+
parsed.is_a?(Array) ? parsed : []
|
|
79
|
+
rescue JSON::ParserError => e
|
|
80
|
+
raise ParseError, "reference service returned invalid JSON: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
module Resources
|
|
6
|
+
# Account notices and posting statistics.
|
|
7
|
+
class Account < Base
|
|
8
|
+
# Acknowledges an account message so it stops appearing on subsequent
|
|
9
|
+
# responses.
|
|
10
|
+
#
|
|
11
|
+
# Craigslist attaches these notices to every response until they are
|
|
12
|
+
# acknowledged, which is why {Client#account_messages} exists.
|
|
13
|
+
#
|
|
14
|
+
# @param message_id [String]
|
|
15
|
+
# @return [true]
|
|
16
|
+
def acknowledge(message_id)
|
|
17
|
+
transport.put(path("account", "message", message_id, "ack"))
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Engagement statistics for every posting on the account.
|
|
22
|
+
#
|
|
23
|
+
# @param start [String, Date, Time, nil] window start, "yyyy-mm-dd".
|
|
24
|
+
# Defaults server-side to 31 days ago.
|
|
25
|
+
# @param stop [String, Date, Time, nil] window end, "yyyy-mm-dd".
|
|
26
|
+
# Defaults server-side to yesterday.
|
|
27
|
+
# @return [Array<PostingStats>]
|
|
28
|
+
def stats(start: nil, stop: nil)
|
|
29
|
+
envelope = transport.get(
|
|
30
|
+
path("account", "stats", "all-postings"),
|
|
31
|
+
params: window(start, stop)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Array(envelope.data).map { |entry| PostingStats.from(entry) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Engagement statistics for one posting.
|
|
38
|
+
#
|
|
39
|
+
# @param posting_id [String, Integer]
|
|
40
|
+
# @param start [String, Date, Time, nil] see {#stats}
|
|
41
|
+
# @param stop [String, Date, Time, nil] see {#stats}
|
|
42
|
+
# @return [PostingStats]
|
|
43
|
+
def posting_stats(posting_id, start: nil, stop: nil)
|
|
44
|
+
envelope = transport.get(
|
|
45
|
+
path("account", "stats", "posting", posting_id),
|
|
46
|
+
params: window(start, stop)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# This endpoint returns a single-element array rather than an object.
|
|
50
|
+
payload = envelope.data.is_a?(Array) ? envelope.data.first : envelope.data
|
|
51
|
+
stats = PostingStats.from(payload || {})
|
|
52
|
+
|
|
53
|
+
return stats unless stats.posting_id.nil?
|
|
54
|
+
|
|
55
|
+
PostingStats.new(posting_id: posting_id.to_s, series: stats.series)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def window(start, stop)
|
|
61
|
+
{"start" => format_date(start), "stop" => format_date(stop)}.compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_date(value)
|
|
65
|
+
return nil if value.nil?
|
|
66
|
+
return value if value.is_a?(String)
|
|
67
|
+
|
|
68
|
+
value.strftime("%Y-%m-%d")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Endpoint groups for the JSON Bulkpost API, reached through {Client}.
|
|
6
|
+
module Resources
|
|
7
|
+
# Shared plumbing for the JSON API resource groups.
|
|
8
|
+
class Base
|
|
9
|
+
# Every JSON endpoint hangs off this prefix.
|
|
10
|
+
BASE_PATH = "/bulkpost/v1"
|
|
11
|
+
|
|
12
|
+
# Characters RFC 3986 leaves unreserved in a path segment.
|
|
13
|
+
RESERVED = /[^A-Za-z0-9\-._~]/
|
|
14
|
+
|
|
15
|
+
def initialize(transport)
|
|
16
|
+
@transport = transport
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :transport
|
|
22
|
+
|
|
23
|
+
# Joins path segments, percent-encoding each one.
|
|
24
|
+
#
|
|
25
|
+
# Encoding matters here: image ids look like "4:00101_b1ztTgNtBAU" and
|
|
26
|
+
# the colon would otherwise land in the path unescaped.
|
|
27
|
+
#
|
|
28
|
+
# @return [String]
|
|
29
|
+
def path(*segments)
|
|
30
|
+
[BASE_PATH, *segments.map { |segment| escape(segment) }].join("/")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def escape(segment)
|
|
34
|
+
segment.to_s.b.gsub(RESERVED) { |char| format("%%%02X", char.ord) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Most endpoints wrap a single scalar in +data+; this pulls it out.
|
|
38
|
+
def fetch(envelope, key)
|
|
39
|
+
data = envelope.data
|
|
40
|
+
data.is_a?(Hash) ? data[key] : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
module Resources
|
|
6
|
+
# Account credit, prepaid posting blocks, pricing, and invoicing.
|
|
7
|
+
class Billing < Base
|
|
8
|
+
# @return [CreditSummary]
|
|
9
|
+
def credit
|
|
10
|
+
CreditSummary.from(transport.get(path("account", "billing", "credit")).data)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Prepaid blocks still available, per area and product.
|
|
14
|
+
#
|
|
15
|
+
# @return [Array<PostingBlock>]
|
|
16
|
+
def posting_blocks
|
|
17
|
+
Array(transport.get(path("account", "billing", "posting-block-balances")).data)
|
|
18
|
+
.map { |entry| PostingBlock.from(entry) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Current cost of posting in an area and category.
|
|
22
|
+
#
|
|
23
|
+
# Prices can change without notice, so treat the result as a quote
|
|
24
|
+
# rather than something to cache.
|
|
25
|
+
#
|
|
26
|
+
# @param area [String] area abbreviation, e.g. "sfo"
|
|
27
|
+
# @param category [String] category abbreviation, e.g. "ofc"
|
|
28
|
+
# @return [Money, nil]
|
|
29
|
+
def pricing(area:, category:)
|
|
30
|
+
data = transport.get(
|
|
31
|
+
path("account", "billing", "current-pricing", "area", area, "category", category)
|
|
32
|
+
).data || {}
|
|
33
|
+
|
|
34
|
+
Money.from(data["currentPricing"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Invoices everything not yet invoiced, ahead of the normal cycle.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array<String>] ids of the invoices created
|
|
40
|
+
def create_invoice
|
|
41
|
+
data = transport.post(path("account", "billing", "make-invoice")).data || {}
|
|
42
|
+
Array(data["invoiceIDs"]).map(&:to_s)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday/multipart"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Craigslist
|
|
7
|
+
module API
|
|
8
|
+
module Resources
|
|
9
|
+
# Images on a live posting.
|
|
10
|
+
#
|
|
11
|
+
# Note the API's verb choices are inverted from the usual convention:
|
|
12
|
+
# PUT uploads a new image, POST reorders the existing ones. The method
|
|
13
|
+
# names here describe the effect rather than the verb.
|
|
14
|
+
class Images < Base
|
|
15
|
+
# Assumed when the caller does not say otherwise.
|
|
16
|
+
DEFAULT_CONTENT_TYPE = "image/jpeg"
|
|
17
|
+
|
|
18
|
+
# @param posting_id [String, Integer]
|
|
19
|
+
# @return [Array<ImageInfo>] ordered as they appear on the posting
|
|
20
|
+
def list(posting_id)
|
|
21
|
+
data = transport.get(path("postings", posting_id, "images")).data || {}
|
|
22
|
+
Array(data["imageInfo"]).map { |info| ImageInfo.from(info) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Uploads an image and attaches it to the posting.
|
|
26
|
+
#
|
|
27
|
+
# With neither position given the image is appended. Positions are
|
|
28
|
+
# zero-based.
|
|
29
|
+
#
|
|
30
|
+
# @param posting_id [String, Integer]
|
|
31
|
+
# @param source [String, Pathname, IO] the image
|
|
32
|
+
# @param filename [String, nil] defaults to the basename of a path
|
|
33
|
+
# @param content_type [String]
|
|
34
|
+
# @param insert_position [Integer, nil] insert at this position
|
|
35
|
+
# @param replace_position [Integer, nil] replace the image here
|
|
36
|
+
# @return [ImageInfo] the newly attached image
|
|
37
|
+
# @raise [ValidationError] if both positions are given
|
|
38
|
+
def upload(posting_id, source, filename: nil, content_type: DEFAULT_CONTENT_TYPE,
|
|
39
|
+
insert_position: nil, replace_position: nil)
|
|
40
|
+
if !insert_position.nil? && !replace_position.nil?
|
|
41
|
+
raise ValidationError, ["pass insert_position or replace_position, not both"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
fields = {}
|
|
45
|
+
fields["insert_position"] = insert_position.to_s unless insert_position.nil?
|
|
46
|
+
fields["replace_position"] = replace_position.to_s unless replace_position.nil?
|
|
47
|
+
|
|
48
|
+
envelope = transport.upload(
|
|
49
|
+
path("postings", posting_id, "images"),
|
|
50
|
+
part: file_part(source, filename, content_type),
|
|
51
|
+
fields: fields
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
ImageInfo.from((envelope.data || {})["imageInfo"] || {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets the order of the posting's images.
|
|
58
|
+
#
|
|
59
|
+
# @param posting_id [String, Integer]
|
|
60
|
+
# @param image_ids [Array<String>] every image id, in the desired order
|
|
61
|
+
# @return [true]
|
|
62
|
+
def reorder(posting_id, image_ids)
|
|
63
|
+
transport.post(
|
|
64
|
+
path("postings", posting_id, "images"),
|
|
65
|
+
form: {"imageIdList" => Array(image_ids).join(",")}
|
|
66
|
+
)
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Detaches an image from the posting.
|
|
71
|
+
#
|
|
72
|
+
# The image itself is not destroyed — it stays publicly retrievable and
|
|
73
|
+
# can still be referenced by other postings.
|
|
74
|
+
#
|
|
75
|
+
# @param posting_id [String, Integer]
|
|
76
|
+
# @param image_id [String]
|
|
77
|
+
# @return [true]
|
|
78
|
+
def remove(posting_id, image_id)
|
|
79
|
+
transport.delete(path("postings", posting_id, "images", image_id))
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def file_part(source, filename, content_type)
|
|
86
|
+
case source
|
|
87
|
+
when Pathname, String
|
|
88
|
+
Faraday::Multipart::FilePart.new(
|
|
89
|
+
source.to_s,
|
|
90
|
+
content_type,
|
|
91
|
+
filename || File.basename(source.to_s)
|
|
92
|
+
)
|
|
93
|
+
else
|
|
94
|
+
unless source.respond_to?(:read)
|
|
95
|
+
raise ValidationError, ["cannot upload a #{source.class}; pass a path or an IO"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Faraday::Multipart::FilePart.new(source, content_type, filename || "upload.jpg")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|