bandcamp-discover 0.7.1 → 0.8.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: 0f788095223220ca96084269592bcfacc5ffcd11bdc4ccc27c43312f9a4f019e
4
- data.tar.gz: d8428295e7b665d2ae448f34f4a84b74bd1290357499d6266e6859fe9b60b7fd
3
+ metadata.gz: 4591eadf76413cf74687ca5c4233890ff1c2e96d641dd8d966c8a1336935e00d
4
+ data.tar.gz: cbf6cb74fca34d127a78469106af4d89597d04f1a812cd1f748ef96e0ae186b0
5
5
  SHA512:
6
- metadata.gz: 97041bbe3141a01e73712b5fa8f1a175ce437ad8ec6f9c38a09447c21e828af10390a248a508d63048447c982b2ac27fcef24bd1f82f285421537aaeb0acbd5a
7
- data.tar.gz: cd751e7ed3dd432b66e3988c89277cf6b91363193dd3a174f5803a2d34e620ad0ec0a2ad347b4c8636c19d263fedde9beb114d9a7594f7182922bc91bca35fb2
6
+ metadata.gz: 9eba8a9fbb1ebf9c31195bb2c4cfda3d00601d66dc4773c9590bc3e2368585e0522734b4c6f3540e27e28b13a9314a770535d893070edfcd5444ed99b8dbb49d
7
+ data.tar.gz: 6d5a34e8edd6c23cd1db88803e8c392911f96eb1a20eea31fe2fb31ed4258c423dc2e7ff14e243255a431e7f529eff3e20f2870bfe58deef6847a4f6c5f7c074
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- bandcamp-discover (0.7.1)
4
+ bandcamp-discover (0.8.0)
5
5
  async
6
6
  base64
7
7
  concurrent-ruby
data/README.rdoc CHANGED
@@ -21,6 +21,10 @@ bio for the model and returned as +artists+ in the scrape result; each
21
21
  entry in +albums+ carries its own credit as +artist+ (nil when the release
22
22
  is credited to the page owner).
23
23
 
24
+ Pages are read at DOMContentLoaded and images, media and fonts are never
25
+ fetched: everything the scrapers use is in the server-rendered HTML, and the
26
+ /music grid is completed from the JSON Bandcamp ships for its own script.
27
+
24
28
  Without a token, +label?+ falls back to matching /label|platform|records/i
25
29
  and +accepts_demos?+ is always false.
26
30
 
@@ -3,6 +3,10 @@ require_relative "configuration"
3
3
 
4
4
  module BandcampDiscover
5
5
  class Analyzer
6
+ # The model replied, but not with the object asked for. Carries the reply so
7
+ # a failed job says what was said instead of where JSON.parse gave up.
8
+ class NoAnswer < StandardError; end
9
+
6
10
  # A per-call model still wins over the configured one so existing callers
7
11
  # keep their behaviour.
8
12
  def initialize(description, model = nil, credits: [])
@@ -17,10 +21,14 @@ module BandcampDiscover
17
21
  @description.to_s.match?(/label|platform|records/i)
18
22
  end
19
23
 
24
+ # The prompt says to default to false; a reply that never reached an answer
25
+ # is that default, not a reason to lose the whole scrape.
20
26
  def accepts_demos?
21
27
  return false unless llm?
22
28
 
23
29
  ask(BandcampDiscover.configuration.demos_prompt)
30
+ rescue NoAnswer
31
+ false
24
32
  end
25
33
 
26
34
  private
@@ -50,6 +58,8 @@ module BandcampDiscover
50
58
  # object inside a ```json fence, and some prepend a sentence.
51
59
  def answer(content)
52
60
  JSON.parse(content[/\{.*\}/m] || content)
61
+ rescue JSON::ParserError
62
+ raise NoAnswer, "model replied without a JSON object: #{content.to_s.strip[0, 200].inspect}"
53
63
  end
54
64
 
55
65
  # The bio alone cannot tell one person releasing under aliases from a
@@ -1,12 +1,12 @@
1
- require_relative "./base"
2
- require_relative "./music"
1
+ require_relative "base"
2
+ require_relative "music"
3
3
 
4
4
  module BandcampDiscover
5
5
  module Scrapers
6
6
  class Album < Base
7
7
  def scrape(force: false)
8
8
  super do |page|
9
- page.goto(@url)
9
+ visit(@url)
10
10
 
11
11
  title = page.query_selector("meta[name=title]")&.[](:content)
12
12
 
@@ -7,10 +7,16 @@ module BandcampDiscover
7
7
  class ScrapeError < StandardError; end
8
8
 
9
9
  class Base
10
+ # Nothing read from a page is an image, a player or a font, and the
11
+ # album pages are twenty per label; not fetching them is most of the
12
+ # time and bandwidth a scrape used to cost.
13
+ BLOCKED_RESOURCES = %w[image media font].freeze
14
+
10
15
  def initialize(url:, browser:, max_tasks: 2)
11
16
  @url = url
12
17
  @browser = browser
13
18
  @page = browser.new_page
19
+ @page.route("**/*", ->(route, request) { BLOCKED_RESOURCES.include?(request.resource_type) ? route.abort : route.continue })
14
20
  @max_tasks = max_tasks
15
21
  end
16
22
 
@@ -20,6 +26,13 @@ module BandcampDiscover
20
26
 
21
27
  private
22
28
 
29
+ # Everything read is in the server-rendered HTML, so the DOM is enough;
30
+ # waiting for "load" waited on every tracker and player asset, and one
31
+ # slow one failed the whole label.
32
+ def visit(url)
33
+ @page.goto(url, waitUntil: "domcontentloaded")
34
+ end
35
+
23
36
  def guarded
24
37
  yield
25
38
  rescue Playwright::TimeoutError => e
@@ -8,7 +8,7 @@ module BandcampDiscover
8
8
  def scrape(force: false)
9
9
  super do |page|
10
10
  puts "starting to scrape #{@url}"
11
- page.goto(@url)
11
+ visit(@url)
12
12
  bio_container = page.wait_for_selector("#bio-container")
13
13
  bio_text = bio_container.query_selector("#bio-text")
14
14
 
@@ -2,6 +2,7 @@ require_relative "base"
2
2
  require_relative "album"
3
3
  require_relative "../roster"
4
4
  require "async"
5
+ require "json"
5
6
  require "async/semaphore"
6
7
 
7
8
  module BandcampDiscover
@@ -23,18 +24,33 @@ module BandcampDiscover
23
24
  # The grid alone tells a label from an artist (see Roster), and it is one
24
25
  # page load against the twenty behind albums, so callers can look at it
25
26
  # before paying for the rest.
27
+ #
28
+ # Bandcamp renders the first sixteen items as HTML and ships the rest as
29
+ # JSON in data-client-items for its own script to render after load. Read
30
+ # both and no script has to run: the grid is complete at DOMContentLoaded.
26
31
  def grid
27
32
  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&.strip
35
- }
33
+ visit(@url)
34
+ grid = @page.wait_for_selector("#music-grid")
35
+
36
+ rendered = grid.query_selector_all("li.music-grid-item").map do |item|
37
+ {url: item.query_selector("a")[:href], credit: item.query_selector(".artist-override")&.inner_text}
36
38
  end
39
+
40
+ merge_grid(rendered, grid.get_attribute("data-client-items"))
41
+ end
42
+ end
43
+
44
+ # A credit is present in the JSON only when the release is not the
45
+ # owner's; the same convention as the rendered override.
46
+ def merge_grid(rendered, client_items_json)
47
+ client = JSON.parse(client_items_json.to_s.empty? ? "[]" : client_items_json).map do |item|
48
+ {url: item["page_url"], credit: item["artist"]}
37
49
  end
50
+
51
+ (rendered + client)
52
+ .map { |item| {url: absolute(item[:url]), credit: credit_or_nil(item[:credit])} }
53
+ .uniq { |item| item[:url] }
38
54
  end
39
55
 
40
56
  def roster(grid, band_name:)
@@ -79,6 +95,11 @@ module BandcampDiscover
79
95
  def absolute(href)
80
96
  href.start_with?("https://") ? href : "#{@base_url}#{href}"
81
97
  end
98
+
99
+ def credit_or_nil(credit)
100
+ text = credit.to_s.strip
101
+ text.empty? ? nil : text
102
+ end
82
103
  end
83
104
  end
84
105
  end
@@ -1,3 +1,3 @@
1
1
  module BandcampDiscover
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.0"
3
3
  end
@@ -112,6 +112,19 @@ class AnalyzerTest < Minitest::Test
112
112
  assert Analyzer.new("bio").label?
113
113
  end
114
114
 
115
+ def test_label_raises_a_named_error_carrying_a_reply_without_an_object
116
+ OpenRouter::Client.raw = "I don't see enough information to decide."
117
+
118
+ error = assert_raises(BandcampDiscover::Analyzer::NoAnswer) { Analyzer.new("bio").label? }
119
+ assert_includes error.message, "I don't see enough information"
120
+ end
121
+
122
+ def test_demos_defaults_to_false_on_a_reply_without_an_object
123
+ OpenRouter::Client.raw = "I don't see any mention of demos here."
124
+
125
+ refute Analyzer.new("bio").accepts_demos?
126
+ end
127
+
115
128
  def test_parses_the_answer
116
129
  OpenRouter::Client.answer = false
117
130
 
data/test/music_test.rb CHANGED
@@ -6,8 +6,12 @@ require "bandcamp-discover/scrapers/music"
6
6
  class MusicTest < Minitest::Test
7
7
  Music = BandcampDiscover::Scrapers::Music
8
8
 
9
+ FakePage = Struct.new(:routes) do
10
+ def route(pattern, handler) = (self.routes ||= []) << pattern
11
+ end
12
+
9
13
  FakeBrowser = Struct.new(:pages) do
10
- def new_page = nil
14
+ def new_page = FakePage.new
11
15
  end
12
16
 
13
17
  FakeAlbum = Struct.new(:url) do
@@ -43,4 +47,39 @@ class MusicTest < Minitest::Test
43
47
 
44
48
  assert_equal [{url: nil, title: nil, tags: nil, player_url: nil, artist: "Helen"}], albums
45
49
  end
50
+
51
+ # Bandcamp renders sixteen items and ships the rest as JSON for its own
52
+ # script; reading both makes the grid complete without running it.
53
+ def test_grid_merges_rendered_items_with_the_client_items_json
54
+ rendered = [
55
+ {url: "/album/overflowing", credit: " Early Moon "},
56
+ {url: "/album/own-release", credit: nil}
57
+ ]
58
+ json = '[{"art_id":1,"artist":"David Cordero","band_id":3141249859,"id":2,"page_url":"/album/and-stillness-came","title":"And Stillness Came","type":"album"},' \
59
+ '{"art_id":3,"band_id":3141249859,"id":4,"page_url":"/track/pay","title":"Pay","type":"track"},' \
60
+ '{"art_id":5,"artist":"","band_id":3141249859,"id":6,"page_url":"/album/overflowing","title":"dup","type":"album"}]'
61
+
62
+ grid = @music.merge_grid(rendered, json)
63
+
64
+ assert_equal [
65
+ {url: "https://kranky.bandcamp.com/album/overflowing", credit: "Early Moon"},
66
+ {url: "https://kranky.bandcamp.com/album/own-release", credit: nil},
67
+ {url: "https://kranky.bandcamp.com/album/and-stillness-came", credit: "David Cordero"},
68
+ {url: "https://kranky.bandcamp.com/track/pay", credit: nil}
69
+ ], grid
70
+ end
71
+
72
+ def test_grid_without_client_items_is_the_rendered_items
73
+ rendered = [{url: "https://radiohead.bandcamp.com/album/kid-a-mnesia", credit: nil}]
74
+
75
+ assert_equal rendered, @music.merge_grid(rendered, nil)
76
+ assert_equal rendered, @music.merge_grid(rendered, "")
77
+ end
78
+
79
+ def test_pages_block_images_media_and_fonts
80
+ page = @music.instance_variable_get(:@page)
81
+
82
+ assert_equal ["**/*"], page.routes
83
+ assert_equal %w[image media font], BandcampDiscover::Scrapers::Base::BLOCKED_RESOURCES
84
+ end
46
85
  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.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian RUbisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-03 00:00:00.000000000 Z
10
+ date: 2026-09-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake