bandcamp-discover 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a1572032feb21d35545c35c0c35d800e277816d1506370dfe45c67b0c155b98
4
- data.tar.gz: 5d33aa525fce53607adaf8f5aef417e24203b165fb93e1b2ffd6af3ad7dc64ce
3
+ metadata.gz: c2c7501fed34fb2971b6be16d1abb01ea93b25246dc117d0ef5f45f76a884a81
4
+ data.tar.gz: f3b13208003d5c7473a5fab3c010119f0cafb6cd5e161f1c7a92796af97ed8fa
5
5
  SHA512:
6
- metadata.gz: 4d52699fd00743a73a4508a239169b11070a1aac78f8c7f49b0a614a689d06b29fdae2495c6f38af6a85c69a0659953eb3c96091f95f398331024763288d6e06
7
- data.tar.gz: 25b6bcc92e173955671f6e441357423e51e42ad0cd1219029b7faed8ae6e395d8b08c8932fc1851361f62357ca1bf372c84b9459e77934a68e4dd8d35538754c
6
+ metadata.gz: 39337db593351f00eb19f50826342512bd4e7e16fb680074d30734669486db1f56d9e6b7959664acf1ffe77d122984571eabf4c261a02abf52e778c9dbab0fcb
7
+ data.tar.gz: 32c9359e36d955b34fb9fc36942bbe455dc99e4a41e9032ac0ae3d6420e76bc9b8840f629c90e797669358979b2317bcd42ef55bf54e52ab07f00378ed637f45
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- bandcamp-discover (0.1.7)
4
+ bandcamp-discover (0.5.0)
5
5
  async
6
6
  base64
7
7
  concurrent-ruby
@@ -1,7 +1,11 @@
1
- require 'playwright'
1
+ require "playwright"
2
2
 
3
3
  module BandcampDiscover
4
4
  module Scrapers
5
+ # Previously rescued into a puts + nil return, which made a total scraping
6
+ # outage read as "no results" and go unnoticed.
7
+ class ScrapeError < StandardError; end
8
+
5
9
  class Base
6
10
  def initialize(url:, browser:, max_tasks: 2)
7
11
  @url = url
@@ -12,8 +16,8 @@ module BandcampDiscover
12
16
 
13
17
  def scrape(force: false)
14
18
  yield @page if block_given?
15
- rescue Playwright::TimeoutError
16
- puts "Failed to wait for element"
19
+ rescue Playwright::TimeoutError => e
20
+ raise ScrapeError, "Timed out waiting for an element on #{@url}: #{e.message}"
17
21
  end
18
22
  end
19
23
  end
@@ -1,5 +1,6 @@
1
- require_relative "base"
2
1
  require_relative "label"
2
+ require "net/http"
3
+ require "json"
3
4
  require "uri"
4
5
  require "async"
5
6
  require "async/semaphore"
@@ -8,43 +9,127 @@ require "concurrent"
8
9
 
9
10
  module BandcampDiscover
10
11
  module Scrapers
11
- class Discover < Base
12
- def initialize(genre:, browser:, max_tasks: Concurrent.processor_count)
13
- super(url: "https://bandcamp.com/discover/#{genre}?s=rand", browser: browser, max_tasks: max_tasks)
12
+ # Not Playwright: the /discover/<genre> grid only exists after the Vue app
13
+ # hydrates, and Fastly serves that page a JS challenge to datacenter IPs that
14
+ # headless Chrome never clears. The XHR behind it is unchallenged.
15
+ #
16
+ # It is also private and unversioned, hence assert_shape!.
17
+ class Discover
18
+ API_URI = URI("https://bandcamp.com/api/discover/1/discover_web")
19
+ PAGE_SIZE = 60
20
+ OPEN_TIMEOUT = 10
21
+ READ_TIMEOUT = 30
22
+
23
+ # "a" is albums, "s" is subscriptions.
24
+ RESULT_TYPES = %w[a s].freeze
25
+
26
+ class ResponseError < StandardError; end
27
+
28
+ # browser: is unused, kept so existing callers do not break.
29
+ def initialize(genre:, browser: nil, max_tasks: Concurrent.processor_count, pages: 1)
30
+ @genre = genre
31
+ @browser = browser
32
+ @max_tasks = max_tasks
33
+ @pages = pages
14
34
  end
15
35
 
16
36
  def scrape(force: false)
17
- super do |page|
18
- page.goto(@url)
37
+ urls = band_urls
19
38
 
20
- records_list = page.wait_for_selector("ul.items")
21
- records = records_list.query_selector_all("li")
22
- links = records.map { _1.query_selector("a")[:href] }
39
+ barrier = Async::Barrier.new
23
40
 
24
- # click "View more results"
41
+ Sync do
42
+ semaphore = Async::Semaphore.new(@max_tasks, parent: barrier)
25
43
 
26
- uris = links.map { ::URI.parse(_1) }
44
+ urls.map do |url|
45
+ semaphore.async do
46
+ if block_given?
47
+ yield url
48
+ else
49
+ Scrapers::Label.new(url: url, browser: @browser).scrape
50
+ end
51
+ end
52
+ end.map(&:wait).compact
53
+ ensure
54
+ barrier.stop
55
+ end
56
+ end
27
57
 
28
- barrier = Async::Barrier.new
58
+ private
29
59
 
30
- Sync do
31
- semaphore = Async::Semaphore.new(@max_tasks, parent: barrier)
60
+ # One label recurs across its albums, so dedupe.
61
+ def band_urls
62
+ cursor = "*"
63
+ urls = []
32
64
 
33
- uris.map do |uri|
34
- url = "#{uri.scheme}://#{uri.host}"
65
+ @pages.times do
66
+ body = fetch_batch(cursor)
67
+ assert_shape!(body)
35
68
 
36
- semaphore.async do
37
- if block_given?
38
- yield url
39
- else
40
- Scrapers::Label.new(url: url, browser: @browser).scrape
41
- end
42
- end
43
- end.map(&:wait).compact
44
- ensure
45
- barrier.stop
46
- end
69
+ results = body["results"]
70
+ break if results.empty?
71
+
72
+ urls.concat(results.map { root_url(_1["band_url"]) }.compact)
73
+
74
+ cursor = body["cursor"]
75
+ break if cursor.nil? || cursor.empty?
47
76
  end
77
+
78
+ urls.uniq
79
+ end
80
+
81
+ def fetch_batch(cursor)
82
+ request = Net::HTTP::Post.new(API_URI)
83
+ request["Content-Type"] = "application/json"
84
+ request.body = JSON.generate(
85
+ category_id: 0,
86
+ tag_norm_names: [@genre],
87
+ geoname_id: 0,
88
+ slice: "rand",
89
+ time_facet_id: nil,
90
+ cursor: cursor,
91
+ size: PAGE_SIZE,
92
+ include_result_types: RESULT_TYPES,
93
+ followed_bands: false
94
+ )
95
+
96
+ response = Net::HTTP.start(
97
+ API_URI.hostname,
98
+ API_URI.port,
99
+ use_ssl: true,
100
+ open_timeout: OPEN_TIMEOUT,
101
+ read_timeout: READ_TIMEOUT
102
+ ) { _1.request(request) }
103
+
104
+ unless response.is_a?(Net::HTTPSuccess)
105
+ raise ResponseError, "discover_web returned #{response.code} for genre #{@genre.inspect}"
106
+ end
107
+
108
+ JSON.parse(response.body)
109
+ rescue JSON::ParserError => e
110
+ raise ResponseError, "discover_web returned unparseable JSON for genre #{@genre.inspect}: #{e.message}"
111
+ end
112
+
113
+ def assert_shape!(body)
114
+ unless body.is_a?(Hash) && body["results"].is_a?(Array)
115
+ raise ResponseError, "discover_web response has no results array (keys: #{body.is_a?(Hash) ? body.keys.inspect : body.class})"
116
+ end
117
+
118
+ return if body["results"].empty? || body["results"].any? { _1.is_a?(Hash) && _1.key?("band_url") }
119
+
120
+ raise ResponseError, "discover_web results carry no band_url (keys: #{body["results"].first.keys.inspect})"
121
+ end
122
+
123
+ # Strips the ?from=discover_page band_url carries.
124
+ def root_url(band_url)
125
+ return nil if band_url.nil? || band_url.empty?
126
+
127
+ uri = URI.parse(band_url)
128
+ return nil if uri.scheme.nil? || uri.host.nil?
129
+
130
+ "#{uri.scheme}://#{uri.host}"
131
+ rescue URI::InvalidURIError
132
+ nil
48
133
  end
49
134
  end
50
135
  end
@@ -1,3 +1,3 @@
1
1
  module BandcampDiscover
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -0,0 +1,86 @@
1
+ require_relative "test_helper"
2
+ require "bandcamp-discover/scrapers/discover"
3
+
4
+ class DiscoverTest < Minitest::Test
5
+ Discover = BandcampDiscover::Scrapers::Discover
6
+
7
+ # Stub only the network, so the rest of the class runs for real.
8
+ def build(genre: "ambient", pages: 1, batches: [])
9
+ scraper = Discover.new(genre: genre, max_tasks: 2, pages: pages)
10
+ queue = batches.dup
11
+ scraper.define_singleton_method(:fetch_batch) { |_cursor| queue.shift }
12
+ scraper
13
+ end
14
+
15
+ def batch(band_urls, cursor: nil)
16
+ {
17
+ "results" => band_urls.map { {"band_url" => _1, "item_type" => "a"} },
18
+ "cursor" => cursor
19
+ }
20
+ end
21
+
22
+ def collect(scraper)
23
+ [].tap { |out| scraper.scrape { |url| out << url } }
24
+ end
25
+
26
+ def test_yields_scheme_and_host_only
27
+ scraper = build(batches: [batch(["https://polarseasrecordings.bandcamp.com?from=discover_page"])])
28
+
29
+ assert_equal ["https://polarseasrecordings.bandcamp.com"], collect(scraper)
30
+ end
31
+
32
+ def test_dedupes_labels_appearing_under_several_albums
33
+ scraper = build(batches: [batch([
34
+ "https://a.bandcamp.com?from=discover_page",
35
+ "https://a.bandcamp.com?from=discover_page",
36
+ "https://b.bandcamp.com?from=discover_page"
37
+ ])])
38
+
39
+ assert_equal ["https://a.bandcamp.com", "https://b.bandcamp.com"], collect(scraper).sort
40
+ end
41
+
42
+ def test_follows_cursor_across_pages
43
+ scraper = build(pages: 2, batches: [
44
+ batch(["https://a.bandcamp.com"], cursor: "next"),
45
+ batch(["https://b.bandcamp.com"], cursor: nil)
46
+ ])
47
+
48
+ assert_equal ["https://a.bandcamp.com", "https://b.bandcamp.com"], collect(scraper).sort
49
+ end
50
+
51
+ def test_stops_early_when_cursor_is_exhausted
52
+ scraper = build(pages: 3, batches: [
53
+ batch(["https://a.bandcamp.com"], cursor: nil),
54
+ batch(["https://never-reached.bandcamp.com"])
55
+ ])
56
+
57
+ assert_equal ["https://a.bandcamp.com"], collect(scraper)
58
+ end
59
+
60
+ def test_skips_unusable_band_urls
61
+ scraper = build(batches: [batch([nil, "", "not a url", "/relative", "https://ok.bandcamp.com"])])
62
+
63
+ assert_equal ["https://ok.bandcamp.com"], collect(scraper)
64
+ end
65
+
66
+ # A silent zero-result discovery is how the last breakage hid; these must raise.
67
+ def test_raises_when_results_array_is_missing
68
+ scraper = build(batches: [{"cursor" => "*"}])
69
+
70
+ error = assert_raises(Discover::ResponseError) { collect(scraper) }
71
+ assert_match(/no results array/, error.message)
72
+ end
73
+
74
+ def test_raises_when_results_no_longer_carry_band_url
75
+ scraper = build(batches: [{"results" => [{"item_id" => 1, "band_name" => "x"}], "cursor" => nil}])
76
+
77
+ error = assert_raises(Discover::ResponseError) { collect(scraper) }
78
+ assert_match(/no band_url/, error.message)
79
+ end
80
+
81
+ def test_empty_results_is_not_an_error
82
+ scraper = build(batches: [{"results" => [], "cursor" => nil}])
83
+
84
+ assert_empty collect(scraper)
85
+ end
86
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bandcamp-discover
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian RUbisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-07 00:00:00.000000000 Z
10
+ date: 2026-08-31 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -202,6 +202,7 @@ files:
202
202
  - lib/bandcamp-discover/scrapers/music.rb
203
203
  - lib/bandcamp-discover/version.rb
204
204
  - test/default_test.rb
205
+ - test/discover_test.rb
205
206
  - test/test_helper.rb
206
207
  homepage: https://julianrubisch.at
207
208
  licenses: []