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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a1572032feb21d35545c35c0c35d800e277816d1506370dfe45c67b0c155b98
4
- data.tar.gz: 5d33aa525fce53607adaf8f5aef417e24203b165fb93e1b2ffd6af3ad7dc64ce
3
+ metadata.gz: 0d06bbc06b837c9d82502a0d9e9a61d3d2062af141f2a19e28ff0cb3e7e3fe5d
4
+ data.tar.gz: 2b2bbe5682ed207b8911f31a74e1a1a0b8bf471326509b35a3849527b39bcab7
5
5
  SHA512:
6
- metadata.gz: 4d52699fd00743a73a4508a239169b11070a1aac78f8c7f49b0a614a689d06b29fdae2495c6f38af6a85c69a0659953eb3c96091f95f398331024763288d6e06
7
- data.tar.gz: 25b6bcc92e173955671f6e441357423e51e42ad0cd1219029b7faed8ae6e395d8b08c8932fc1851361f62357ca1bf372c84b9459e77934a68e4dd8d35538754c
6
+ metadata.gz: 70c78eccc21549dd9c08fe183ec8ddf6a11060d79e913891ebca47de23f01248a07e7b73e27ea5fb45ba463ea93469d98f8be7f7fdcf5e99576600d5f2992c2a
7
+ data.tar.gz: d345ad2550cd0ea344d0310a2aa8bdf351a5a3b08b999a19395092e7669ba07b9d98d9ea18a96b7480c3e5d4e44774922db943d115c1ca066ab1592cc058ac6c
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.6.0)
5
5
  async
6
6
  base64
7
7
  concurrent-ruby
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
- def initialize(description, model = nil)
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
- @model = model || "openrouter/auto"
10
+ @credits = credits
11
+ @model = model || BandcampDiscover.configuration.model
6
12
  end
7
13
 
8
14
  def label?
9
- if defined?(OpenRouter) && !!OpenRouter.configuration.access_token
10
- response = OpenRouter::Client.new.complete(
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
- if defined?(OpenRouter) && !!OpenRouter.configuration.access_token
33
- response = OpenRouter::Client.new.complete(
34
- [
35
- { role: "system", content: "You are given a description of a record label or music platform. Analyze if they accept demo submissions from artists. Look for mentions of 'demos', 'demo submissions', 'send demos', 'submit music', 'accepting submissions', contact information for demos, or similar language. Be critical, and default to false. Only answer with true when you are certain that the label accepts demos. Answer with a JSON object {\"answer\": true|false}." },
36
- { role: "user", content: @description }
37
- ],
38
- model: [
39
- @model
40
- ],
41
- extras: {
42
- response_format: {
43
- type: "json_object"
44
- }
45
- }
46
- )
47
-
48
- JSON.parse(response["choices"][0]["message"]["content"])["answer"]&.to_s&.downcase == "true"
49
- else
50
- false
51
- end
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 '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
@@ -11,9 +15,15 @@ module BandcampDiscover
11
15
  end
12
16
 
13
17
  def scrape(force: false)
14
- yield @page if block_given?
15
- rescue Playwright::TimeoutError
16
- puts "Failed to wait for element"
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
- 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,5 +1,5 @@
1
- require_relative "./base"
2
- require_relative "./music"
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
- if force || Analyzer.new(bio_text&.inner_html).label?
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 = Scrapers::Music.new(url: "#{@url}/music", browser: @browser, max_tasks: @max_tasks).scrape
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
- super do |page|
18
- page.goto(@url)
19
- album_list = page.wait_for_selector("#music-grid")
20
- album_links = album_list.query_selector_all("li.music-grid-item > a")
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 = album_links.take(20).map do |album_link|
48
+ albums = grid.take(MAX_ALBUMS).map do |item|
25
49
  semaphore.async do
26
- url = album_link[:href].start_with?("https://") ? album_link[:href] : "#{@base_url}#{album_link[:href]}"
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
- { url: album_url, title: album_title, tags: album_tags, player_url: album_player }
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
@@ -1,3 +1,3 @@
1
1
  module BandcampDiscover
2
- VERSION = "0.4.1"
2
+ VERSION = "0.6.0"
3
3
  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
@@ -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.1
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-02-07 00:00:00.000000000 Z
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: []