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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +405 -0
  5. data/lib/craigslist/api/access_token.rb +68 -0
  6. data/lib/craigslist/api/area.rb +98 -0
  7. data/lib/craigslist/api/bulk_transport.rb +94 -0
  8. data/lib/craigslist/api/category.rb +47 -0
  9. data/lib/craigslist/api/client.rb +207 -0
  10. data/lib/craigslist/api/configuration.rb +130 -0
  11. data/lib/craigslist/api/connection.rb +54 -0
  12. data/lib/craigslist/api/credit_summary.rb +39 -0
  13. data/lib/craigslist/api/envelope.rb +65 -0
  14. data/lib/craigslist/api/errors.rb +82 -0
  15. data/lib/craigslist/api/image.rb +102 -0
  16. data/lib/craigslist/api/image_info.rb +53 -0
  17. data/lib/craigslist/api/json_transport.rb +153 -0
  18. data/lib/craigslist/api/money.rb +77 -0
  19. data/lib/craigslist/api/posting.rb +216 -0
  20. data/lib/craigslist/api/posting_block.rb +44 -0
  21. data/lib/craigslist/api/posting_handle.rb +142 -0
  22. data/lib/craigslist/api/posting_stats.rb +85 -0
  23. data/lib/craigslist/api/reference.rb +84 -0
  24. data/lib/craigslist/api/resources/account.rb +73 -0
  25. data/lib/craigslist/api/resources/base.rb +45 -0
  26. data/lib/craigslist/api/resources/billing.rb +47 -0
  27. data/lib/craigslist/api/resources/images.rb +104 -0
  28. data/lib/craigslist/api/resources/postings.rb +95 -0
  29. data/lib/craigslist/api/response_parser.rb +91 -0
  30. data/lib/craigslist/api/result.rb +123 -0
  31. data/lib/craigslist/api/result_set.rb +96 -0
  32. data/lib/craigslist/api/serializer.rb +177 -0
  33. data/lib/craigslist/api/token_provider.rb +80 -0
  34. data/lib/craigslist/api/version.rb +8 -0
  35. data/lib/craigslist/api/zip_location.rb +80 -0
  36. data/lib/craigslist/api.rb +63 -0
  37. 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