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 +4 -4
- data/Gemfile.lock +1 -1
- data/lib/bandcamp-discover/scrapers/base.rb +7 -3
- data/lib/bandcamp-discover/scrapers/discover.rb +112 -27
- data/lib/bandcamp-discover/version.rb +1 -1
- data/test/discover_test.rb +86 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2c7501fed34fb2971b6be16d1abb01ea93b25246dc117d0ef5f45f76a884a81
|
|
4
|
+
data.tar.gz: f3b13208003d5c7473a5fab3c010119f0cafb6cd5e161f1c7a92796af97ed8fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39337db593351f00eb19f50826342512bd4e7e16fb680074d30734669486db1f56d9e6b7959664acf1ffe77d122984571eabf4c261a02abf52e778c9dbab0fcb
|
|
7
|
+
data.tar.gz: 32c9359e36d955b34fb9fc36942bbe455dc99e4a41e9032ac0ae3d6420e76bc9b8840f629c90e797669358979b2317bcd42ef55bf54e52ab07f00378ed637f45
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
require
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
page.goto(@url)
|
|
37
|
+
urls = band_urls
|
|
19
38
|
|
|
20
|
-
|
|
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
|
-
|
|
41
|
+
Sync do
|
|
42
|
+
semaphore = Async::Semaphore.new(@max_tasks, parent: barrier)
|
|
25
43
|
|
|
26
|
-
|
|
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
|
-
|
|
58
|
+
private
|
|
29
59
|
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
# One label recurs across its albums, so dedupe.
|
|
61
|
+
def band_urls
|
|
62
|
+
cursor = "*"
|
|
63
|
+
urls = []
|
|
32
64
|
|
|
33
|
-
|
|
34
|
-
|
|
65
|
+
@pages.times do
|
|
66
|
+
body = fetch_batch(cursor)
|
|
67
|
+
assert_shape!(body)
|
|
35
68
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
@@ -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
|
+
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-
|
|
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: []
|