bandcamp-discover 0.4.1 → 0.6.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/README.rdoc +20 -0
- data/lib/bandcamp-discover/analyzer.rb +42 -42
- data/lib/bandcamp-discover/configuration.rb +40 -0
- data/lib/bandcamp-discover/roster.rb +35 -0
- data/lib/bandcamp-discover/scrapers/base.rb +14 -4
- data/lib/bandcamp-discover/scrapers/discover.rb +112 -27
- data/lib/bandcamp-discover/scrapers/label.rb +18 -4
- data/lib/bandcamp-discover/scrapers/music.rb +39 -10
- data/lib/bandcamp-discover/version.rb +1 -1
- data/test/analyzer_test.rb +106 -0
- data/test/discover_test.rb +86 -0
- data/test/roster_test.rb +52 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d06bbc06b837c9d82502a0d9e9a61d3d2062af141f2a19e28ff0cb3e7e3fe5d
|
|
4
|
+
data.tar.gz: 2b2bbe5682ed207b8911f31a74e1a1a0b8bf471326509b35a3849527b39bcab7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70c78eccc21549dd9c08fe183ec8ddf6a11060d79e913891ebca47de23f01248a07e7b73e27ea5fb45ba463ea93469d98f8be7f7fdcf5e99576600d5f2992c2a
|
|
7
|
+
data.tar.gz: d345ad2550cd0ea344d0310a2aa8bdf351a5a3b08b999a19395092e7669ba07b9d98d9ea18a96b7480c3e5d4e44774922db943d115c1ca066ab1592cc058ac6c
|
data/Gemfile.lock
CHANGED
data/README.rdoc
CHANGED
|
@@ -2,5 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
Describe your project here
|
|
4
4
|
|
|
5
|
+
== Configuration
|
|
6
|
+
|
|
7
|
+
When the +open_router+ gem is loaded and has an access token, +Analyzer+
|
|
8
|
+
asks a model whether a Bandcamp bio belongs to a label and whether it
|
|
9
|
+
accepts demos. The model and both prompts can be set by the host app:
|
|
10
|
+
|
|
11
|
+
BandcampDiscover.configure do |config|
|
|
12
|
+
config.model = "anthropic/claude-3.5-haiku" # default: "openrouter/auto"
|
|
13
|
+
config.label_prompt = "..." # must ask for {"answer": true|false}
|
|
14
|
+
config.demos_prompt = "..."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Before the bio is asked, the /music grid is read: a page whose releases carry
|
|
18
|
+
no artist credit other than the owner is an artist, not a label, and is
|
|
19
|
+
rejected without a model call. The credits that remain are appended to the
|
|
20
|
+
bio for the model and returned as +artists+ in the scrape result.
|
|
21
|
+
|
|
22
|
+
Without a token, +label?+ falls back to matching /label|platform|records/i
|
|
23
|
+
and +accepts_demos?+ is always false.
|
|
24
|
+
|
|
5
25
|
:include:bandcamp-discover.rdoc
|
|
6
26
|
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "configuration"
|
|
3
|
+
|
|
1
4
|
module BandcampDiscover
|
|
2
5
|
class Analyzer
|
|
3
|
-
|
|
6
|
+
# A per-call model still wins over the configured one so existing callers
|
|
7
|
+
# keep their behaviour.
|
|
8
|
+
def initialize(description, model = nil, credits: [])
|
|
4
9
|
@description = description
|
|
5
|
-
@
|
|
10
|
+
@credits = credits
|
|
11
|
+
@model = model || BandcampDiscover.configuration.model
|
|
6
12
|
end
|
|
7
13
|
|
|
8
14
|
def label?
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{ role: "system", content: "You are given a description that could or could not be that of a record label. Analyze and answer witha JSON object {\"answer\": true|false}. Be critical: Individuals and bands are not labels, but collectives can be labels." },
|
|
13
|
-
{ role: "user", content: @description }
|
|
14
|
-
],
|
|
15
|
-
model: [
|
|
16
|
-
@model
|
|
17
|
-
],
|
|
18
|
-
extras: {
|
|
19
|
-
response_format: {
|
|
20
|
-
type: "json_object"
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
JSON.parse(response["choices"][0]["message"]["content"])["answer"]&.to_s&.downcase == "true"
|
|
26
|
-
else
|
|
27
|
-
@description.match? /label|platform|records/i
|
|
28
|
-
end
|
|
15
|
+
return ask(BandcampDiscover.configuration.label_prompt) if llm?
|
|
16
|
+
|
|
17
|
+
@description.to_s.match?(/label|platform|records/i)
|
|
29
18
|
end
|
|
30
19
|
|
|
31
20
|
def accepts_demos?
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
21
|
+
return false unless llm?
|
|
22
|
+
|
|
23
|
+
ask(BandcampDiscover.configuration.demos_prompt)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def llm?
|
|
29
|
+
defined?(OpenRouter) && !!OpenRouter.configuration.access_token
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ask(prompt)
|
|
33
|
+
response = OpenRouter::Client.new.complete(
|
|
34
|
+
[
|
|
35
|
+
{role: "system", content: prompt},
|
|
36
|
+
{role: "user", content: message}
|
|
37
|
+
],
|
|
38
|
+
model: [@model],
|
|
39
|
+
extras: {response_format: {type: "json_object"}}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
JSON.parse(response["choices"][0]["message"]["content"])["answer"]&.to_s&.downcase == "true"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The bio alone cannot tell one person releasing under aliases from a
|
|
46
|
+
# roster; who the releases are credited to is the other half of the answer.
|
|
47
|
+
def message
|
|
48
|
+
return @description.to_s if @credits.empty?
|
|
49
|
+
|
|
50
|
+
"#{@description}\n\nReleases on this page are credited to #{@credits.size} " \
|
|
51
|
+
"#{(@credits.size == 1) ? "artist" : "artists"} other than the page owner: #{@credits.join(", ")}."
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module BandcampDiscover
|
|
2
|
+
# The prompts decide what enters a catalogue, and tuning them used to mean a
|
|
3
|
+
# gem release. Hosting apps set them once; the defaults keep every existing
|
|
4
|
+
# caller behaving as before.
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_MODEL = "openrouter/auto"
|
|
7
|
+
|
|
8
|
+
DEFAULT_LABEL_PROMPT = "You are given a description that could or could not be that of a record label. " \
|
|
9
|
+
"Analyze and answer with a JSON object {\"answer\": true|false}. " \
|
|
10
|
+
"Be critical: Individuals and bands are not labels, but collectives can be labels."
|
|
11
|
+
|
|
12
|
+
DEFAULT_DEMOS_PROMPT = "You are given a description of a record label or music platform. " \
|
|
13
|
+
"Analyze if they accept demo submissions from artists. Look for mentions of 'demos', 'demo submissions', " \
|
|
14
|
+
"'send demos', 'submit music', 'accepting submissions', contact information for demos, or similar language. " \
|
|
15
|
+
"Be critical, and default to false. Only answer with true when you are certain that the label accepts demos. " \
|
|
16
|
+
"Answer with a JSON object {\"answer\": true|false}."
|
|
17
|
+
|
|
18
|
+
attr_accessor :model, :label_prompt, :demos_prompt
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@model = DEFAULT_MODEL
|
|
22
|
+
@label_prompt = DEFAULT_LABEL_PROMPT
|
|
23
|
+
@demos_prompt = DEFAULT_DEMOS_PROMPT
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield configuration
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset_configuration!
|
|
37
|
+
@configuration = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module BandcampDiscover
|
|
2
|
+
# A label's /music grid credits each release to its artist; an artist's own
|
|
3
|
+
# page credits nothing, because the artist is the page. That is the one
|
|
4
|
+
# structural difference between the two, and it costs no API call.
|
|
5
|
+
#
|
|
6
|
+
# It only ever says "no": one person releasing under aliases looks like a
|
|
7
|
+
# roster, and a bio can say "not a label" over a grid full of credits, so a
|
|
8
|
+
# positive still has to come from the bio.
|
|
9
|
+
class Roster
|
|
10
|
+
attr_reader :band_name, :releases, :credits
|
|
11
|
+
|
|
12
|
+
def initialize(band_name:, credits:, releases: credits.size)
|
|
13
|
+
@band_name = band_name.to_s.strip
|
|
14
|
+
@releases = releases
|
|
15
|
+
@credits = credits.compact.map(&:strip).reject { |credit| credit.empty? || own?(credit) }.uniq
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A single self-released record is not evidence either way.
|
|
19
|
+
def solo?
|
|
20
|
+
credits.empty? && releases >= 2
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# "Sean Woosley" and "Woosley Band" on woosley.bandcamp.com are the owner,
|
|
26
|
+
# not a roster.
|
|
27
|
+
def own?(credit)
|
|
28
|
+
a = credit.downcase
|
|
29
|
+
b = band_name.downcase
|
|
30
|
+
return false if b.empty?
|
|
31
|
+
|
|
32
|
+
a.include?(b) || b.include?(a)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -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
|
|
@@ -11,9 +15,15 @@ module BandcampDiscover
|
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def scrape(force: false)
|
|
14
|
-
yield @page if block_given?
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
guarded { yield @page if block_given? }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def guarded
|
|
24
|
+
yield
|
|
25
|
+
rescue Playwright::TimeoutError => e
|
|
26
|
+
raise ScrapeError, "Timed out waiting for an element on #{@url}: #{e.message}"
|
|
17
27
|
end
|
|
18
28
|
end
|
|
19
29
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
require_relative "
|
|
2
|
-
require_relative "
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require_relative "music"
|
|
3
3
|
require_relative "../analyzer"
|
|
4
4
|
|
|
5
5
|
module BandcampDiscover
|
|
@@ -16,9 +16,13 @@ module BandcampDiscover
|
|
|
16
16
|
name = band_name_location_container.query_selector(".title").inner_text
|
|
17
17
|
location = band_name_location_container.query_selector(".location").inner_text
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
music = Scrapers::Music.new(url: "#{@url}/music", browser: @browser, max_tasks: @max_tasks)
|
|
20
|
+
grid = music.grid
|
|
21
|
+
roster = music.roster(grid, band_name: name)
|
|
22
|
+
|
|
23
|
+
if force || label?(roster, bio_text&.inner_html)
|
|
20
24
|
return Sync do
|
|
21
|
-
albums, music_tags =
|
|
25
|
+
albums, music_tags = music.albums(grid)
|
|
22
26
|
|
|
23
27
|
puts "done scraping #{@url}"
|
|
24
28
|
|
|
@@ -27,6 +31,7 @@ module BandcampDiscover
|
|
|
27
31
|
name: name,
|
|
28
32
|
location: location,
|
|
29
33
|
bio: bio_text.inner_text,
|
|
34
|
+
artists: roster.credits,
|
|
30
35
|
tags_with_weights: music_tags&.compact,
|
|
31
36
|
albums: albums
|
|
32
37
|
}
|
|
@@ -37,6 +42,15 @@ module BandcampDiscover
|
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The grid can rule a page out for free; only the rest costs a model call.
|
|
49
|
+
def label?(roster, bio)
|
|
50
|
+
return false if roster.solo?
|
|
51
|
+
|
|
52
|
+
Analyzer.new(bio, credits: roster.credits).label?
|
|
53
|
+
end
|
|
40
54
|
end
|
|
41
55
|
end
|
|
42
56
|
end
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
require_relative "base"
|
|
2
2
|
require_relative "album"
|
|
3
|
+
require_relative "../roster"
|
|
3
4
|
require "async"
|
|
4
5
|
require "async/semaphore"
|
|
5
6
|
|
|
6
7
|
module BandcampDiscover
|
|
7
8
|
module Scrapers
|
|
8
9
|
class Music < Base
|
|
10
|
+
MAX_ALBUMS = 20
|
|
11
|
+
|
|
9
12
|
def initialize(url:, browser:, max_tasks:)
|
|
10
13
|
super
|
|
11
14
|
|
|
@@ -14,28 +17,48 @@ module BandcampDiscover
|
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def scrape(force: false)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
albums(grid)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The grid alone tells a label from an artist (see Roster), and it is one
|
|
24
|
+
# page load against the twenty behind albums, so callers can look at it
|
|
25
|
+
# before paying for the rest.
|
|
26
|
+
def grid
|
|
27
|
+
guarded do
|
|
28
|
+
@page.goto(@url)
|
|
29
|
+
items = @page.wait_for_selector("#music-grid").query_selector_all("li.music-grid-item")
|
|
30
|
+
|
|
31
|
+
items.map do |item|
|
|
32
|
+
{
|
|
33
|
+
url: absolute(item.query_selector("a")[:href]),
|
|
34
|
+
credit: item.query_selector(".artist-override")&.inner_text
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def roster(grid, band_name:)
|
|
41
|
+
Roster.new(band_name: band_name, credits: grid.map { _1[:credit] }, releases: grid.size)
|
|
42
|
+
end
|
|
21
43
|
|
|
44
|
+
def albums(grid)
|
|
45
|
+
guarded do
|
|
22
46
|
semaphore = Async::Semaphore.new(@max_tasks)
|
|
23
47
|
|
|
24
|
-
albums =
|
|
48
|
+
albums = grid.take(MAX_ALBUMS).map do |item|
|
|
25
49
|
semaphore.async do
|
|
26
|
-
|
|
27
|
-
puts "starting to scrape #{url}"
|
|
50
|
+
puts "starting to scrape #{item[:url]}"
|
|
28
51
|
|
|
29
|
-
album = Scrapers::Album.new(url: url, browser: @browser).scrape
|
|
52
|
+
album = Scrapers::Album.new(url: item[:url], browser: @browser).scrape
|
|
30
53
|
|
|
31
|
-
puts "done scraping #{url}"
|
|
54
|
+
puts "done scraping #{item[:url]}"
|
|
32
55
|
|
|
33
56
|
album
|
|
34
57
|
end
|
|
35
58
|
end.map(&:wait)
|
|
36
59
|
|
|
37
60
|
albums.map! do |album_url, album_title, album_tags, album_player|
|
|
38
|
-
{
|
|
61
|
+
{url: album_url, title: album_title, tags: album_tags, player_url: album_player}
|
|
39
62
|
end
|
|
40
63
|
|
|
41
64
|
[albums, normalize_tally(albums.map { _1[:tags] }.flatten.tally)]
|
|
@@ -47,6 +70,12 @@ module BandcampDiscover
|
|
|
47
70
|
tally.transform_values! { |count| count / total }
|
|
48
71
|
tally.sort_by { |k, v| v }.reverse.to_h
|
|
49
72
|
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def absolute(href)
|
|
77
|
+
href.start_with?("https://") ? href : "#{@base_url}#{href}"
|
|
78
|
+
end
|
|
50
79
|
end
|
|
51
80
|
end
|
|
52
81
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "bandcamp-discover/analyzer"
|
|
3
|
+
|
|
4
|
+
# OpenRouter is the host app's dependency, not this gem's, so a stand-in that
|
|
5
|
+
# records the request is enough to prove what the Analyzer sends.
|
|
6
|
+
module OpenRouter
|
|
7
|
+
Configuration = Struct.new(:access_token)
|
|
8
|
+
|
|
9
|
+
def self.configuration
|
|
10
|
+
@configuration ||= Configuration.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Client
|
|
14
|
+
class << self
|
|
15
|
+
attr_accessor :requests, :answer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def complete(messages, model:, extras:)
|
|
19
|
+
self.class.requests << {messages: messages, model: model, extras: extras}
|
|
20
|
+
{"choices" => [{"message" => {"content" => JSON.generate("answer" => self.class.answer)}}]}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class AnalyzerTest < Minitest::Test
|
|
26
|
+
Analyzer = BandcampDiscover::Analyzer
|
|
27
|
+
|
|
28
|
+
def setup
|
|
29
|
+
BandcampDiscover.reset_configuration!
|
|
30
|
+
OpenRouter.configuration.access_token = "token"
|
|
31
|
+
OpenRouter::Client.requests = []
|
|
32
|
+
OpenRouter::Client.answer = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def teardown
|
|
36
|
+
BandcampDiscover.reset_configuration!
|
|
37
|
+
OpenRouter.configuration.access_token = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_defaults_match_the_prompts_and_model_shipped_so_far
|
|
41
|
+
Analyzer.new("We release tapes.").label?
|
|
42
|
+
|
|
43
|
+
request = OpenRouter::Client.requests.last
|
|
44
|
+
assert_equal ["openrouter/auto"], request[:model]
|
|
45
|
+
assert_includes request[:messages].first[:content], "Individuals and bands are not labels"
|
|
46
|
+
assert_equal "We release tapes.", request[:messages].last[:content]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_configured_prompt_and_model_are_sent
|
|
50
|
+
BandcampDiscover.configure do |config|
|
|
51
|
+
config.model = "anthropic/claude-3.5-haiku"
|
|
52
|
+
config.label_prompt = "Is this a label? Answer {\"answer\": true|false}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Analyzer.new("bio").label?
|
|
56
|
+
|
|
57
|
+
request = OpenRouter::Client.requests.last
|
|
58
|
+
assert_equal ["anthropic/claude-3.5-haiku"], request[:model]
|
|
59
|
+
assert_equal "Is this a label? Answer {\"answer\": true|false}.", request[:messages].first[:content]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_per_call_model_wins_over_the_configured_one
|
|
63
|
+
BandcampDiscover.configure { |config| config.model = "configured/model" }
|
|
64
|
+
|
|
65
|
+
Analyzer.new("bio", "explicit/model").label?
|
|
66
|
+
|
|
67
|
+
assert_equal ["explicit/model"], OpenRouter::Client.requests.last[:model]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_demos_prompt_is_configurable_too
|
|
71
|
+
BandcampDiscover.configure { |config| config.demos_prompt = "Demos?" }
|
|
72
|
+
|
|
73
|
+
Analyzer.new("bio").accepts_demos?
|
|
74
|
+
|
|
75
|
+
assert_equal "Demos?", OpenRouter::Client.requests.last[:messages].first[:content]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_credits_are_appended_to_the_bio
|
|
79
|
+
Analyzer.new("We run a tape label.", credits: ["Helen", "Cate Kennan"]).label?
|
|
80
|
+
|
|
81
|
+
message = OpenRouter::Client.requests.last[:messages].last[:content]
|
|
82
|
+
assert_equal "We run a tape label.\n\nReleases on this page are credited to 2 artists other than the page owner: Helen, Cate Kennan.", message
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_no_credits_sends_the_bio_alone
|
|
86
|
+
Analyzer.new(nil, credits: []).label?
|
|
87
|
+
|
|
88
|
+
assert_equal "", OpenRouter::Client.requests.last[:messages].last[:content]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_parses_the_answer
|
|
92
|
+
OpenRouter::Client.answer = false
|
|
93
|
+
|
|
94
|
+
refute Analyzer.new("bio").label?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_falls_back_to_the_regex_without_a_token
|
|
98
|
+
OpenRouter.configuration.access_token = nil
|
|
99
|
+
|
|
100
|
+
assert Analyzer.new("An independent label from Graz").label?
|
|
101
|
+
refute Analyzer.new("Solo musician").label?
|
|
102
|
+
refute Analyzer.new(nil).label?
|
|
103
|
+
refute Analyzer.new("Send demos to ...").accepts_demos?
|
|
104
|
+
assert_empty OpenRouter::Client.requests
|
|
105
|
+
end
|
|
106
|
+
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
|
data/test/roster_test.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "bandcamp-discover/roster"
|
|
3
|
+
|
|
4
|
+
# Cases are real pages from the discover.bandcamp.labels similarity audit.
|
|
5
|
+
class RosterTest < Minitest::Test
|
|
6
|
+
Roster = BandcampDiscover::Roster
|
|
7
|
+
|
|
8
|
+
def test_a_label_credits_other_artists
|
|
9
|
+
roster = Roster.new(band_name: "kranky", credits: ["Helen", "Cate Kennan", "Ana Roxanne", "Helen"])
|
|
10
|
+
|
|
11
|
+
refute roster.solo?
|
|
12
|
+
assert_equal ["Helen", "Cate Kennan", "Ana Roxanne"], roster.credits
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_an_artist_page_credits_nobody
|
|
16
|
+
roster = Roster.new(band_name: "Radiohead", credits: [nil] * 15)
|
|
17
|
+
|
|
18
|
+
assert roster.solo?
|
|
19
|
+
assert_empty roster.credits
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_credits_naming_the_owner_are_not_a_roster
|
|
23
|
+
roster = Roster.new(band_name: "Woosley", credits: ["Sean Woosley", "Woosley Band", nil, nil, nil])
|
|
24
|
+
|
|
25
|
+
assert roster.solo?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_owner_match_is_case_insensitive_and_either_direction
|
|
29
|
+
roster = Roster.new(band_name: "Soul Juice", credits: ["J Sand (Soul Juice)", "Geechie Dan", "soul juice"])
|
|
30
|
+
|
|
31
|
+
assert_equal ["Geechie Dan"], roster.credits
|
|
32
|
+
refute roster.solo?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_a_single_self_release_is_not_evidence
|
|
36
|
+
roster = Roster.new(band_name: "New Label", credits: [nil])
|
|
37
|
+
|
|
38
|
+
refute roster.solo?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_releases_can_outnumber_rendered_credits
|
|
42
|
+
roster = Roster.new(band_name: "Whoever", credits: [], releases: 12)
|
|
43
|
+
|
|
44
|
+
assert roster.solo?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_blank_band_name_keeps_every_credit
|
|
48
|
+
roster = Roster.new(band_name: nil, credits: [" A ", "", "B"])
|
|
49
|
+
|
|
50
|
+
assert_equal ["A", "B"], roster.credits
|
|
51
|
+
end
|
|
52
|
+
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.6.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-09-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rake
|
|
@@ -195,13 +195,18 @@ files:
|
|
|
195
195
|
- bin/bandcamp-discover
|
|
196
196
|
- lib/bandcamp-discover.rb
|
|
197
197
|
- lib/bandcamp-discover/analyzer.rb
|
|
198
|
+
- lib/bandcamp-discover/configuration.rb
|
|
199
|
+
- lib/bandcamp-discover/roster.rb
|
|
198
200
|
- lib/bandcamp-discover/scrapers/album.rb
|
|
199
201
|
- lib/bandcamp-discover/scrapers/base.rb
|
|
200
202
|
- lib/bandcamp-discover/scrapers/discover.rb
|
|
201
203
|
- lib/bandcamp-discover/scrapers/label.rb
|
|
202
204
|
- lib/bandcamp-discover/scrapers/music.rb
|
|
203
205
|
- lib/bandcamp-discover/version.rb
|
|
206
|
+
- test/analyzer_test.rb
|
|
204
207
|
- test/default_test.rb
|
|
208
|
+
- test/discover_test.rb
|
|
209
|
+
- test/roster_test.rb
|
|
205
210
|
- test/test_helper.rb
|
|
206
211
|
homepage: https://julianrubisch.at
|
|
207
212
|
licenses: []
|