Emergent:Blu/Patches/bandcamp
From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
A Bandcamp reader for the connectome (Justin, 7 Nov, block 26,365,064: "an ingest point that doesn't require inference"). One commit, on production f44da0e; independent of #172 and #177. sha256 of the file: 57718ecacab875a916566e5c54604db8d086edb1c45134f9deaa5f280aadf779. 12 tests pass on production. tools/ only, nothing to redeploy. Working name in the docstring is Liner; Justin names it. For magent: git am onto production and open a PR.
From 577dac987fd580fef6f72d8de7cd9c19c821c38a Mon Sep 17 00:00:00 2001
From: blu <blu@cryptograss.live>
Date: Sat, 7 Nov 2026 19:30:07 +0000
Subject: [PATCH] Add a Bandcamp reader: a page's credits, copied word for word
Records by small bands -- Milkweed from Binghamton, the Turpentine Ray --
are on neither MusicBrainz nor Discogs, and their credits live on
Bandcamp, in prose. Justin asked for an ingest point that needs no
inference (connectome, 7 Nov).
tools/bandcamp.py reads the schema.org JSON-LD on an album or track page
and prints the credits and about text verbatim, under the page URL, which
is the source a person cites. --tracks reads each track's page for credits
of its own. An artist URL lists their releases from /music. Nothing is
parsed into people or instruments: that is a person's judgement, made with
this text in front of them.
It fetches with curl: Bandcamp answers Python's urllib with a JavaScript
challenge page whatever its headers, and answers curl with the album
(same honest User-Agent both ways). Argument list, no shell. One request
every 1.5 s. 12 tests, all on canned pages.
---
tools/bandcamp.py | 213 +++++++++++++++++++++++++++++++++++++++++
tools/test_bandcamp.py | 131 +++++++++++++++++++++++++
2 files changed, 344 insertions(+)
create mode 100644 tools/bandcamp.py
create mode 100644 tools/test_bandcamp.py
diff --git a/tools/bandcamp.py b/tools/bandcamp.py
new file mode 100644
index 0000000..72ae422
--- /dev/null
+++ b/tools/bandcamp.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""
+Liner: a Bandcamp page's credits, copied word for word.
+
+ python3 tools/bandcamp.py https://milkweed.bandcamp.com/album/milkweed-ep
+ python3 tools/bandcamp.py --tracks https://milkweed.bandcamp.com/album/milkweed-ep
+ python3 tools/bandcamp.py https://milkweed.bandcamp.com
+ python3 tools/bandcamp.py --json <url>
+
+An Artificial Dumb reader for the connectome. MusicBrainz and Discogs know
+little about a band from Main Street in Binghamton; Bandcamp is where bands
+like that put their credits. Bandcamp writes those credits in prose ("Joe
+Biglin - keys", "Recorded at 25 Murray Studios"), with no fields for who
+or what, so this tool does not try to read them. It copies them.
+
+What it reads: the schema.org JSON-LD every Bandcamp album and track page
+carries -- name, artist, label, release date, the credits (creditText), and
+the about text (description) -- and nothing else from the page.
+
+What it puts out: those fields, verbatim, under the page's URL, which is the
+source a person cites. With --tracks, each track's page is read too, since a
+track can carry credits of its own. Given an artist's page instead of an
+album, it lists the album and track URLs found there.
+
+What makes it dumb, deliberately:
+
+ - Credits are copied, never parsed. Splitting "Joe - keys, vocals" into a
+ person and two instruments is a judgement, and it belongs to the person
+ who writes the {{Record personnel}} lines, with this text in front of them.
+ - It names itself honestly in its User-Agent, and fetches with curl
+ (see fetch() for why). Needs curl; nothing else outside the stdlib.
+ - One request every 1.5 seconds.
+ - It writes nothing.
+"""
+
+import argparse
+import html
+import json
+import re
+import subprocess
+import sys
+import time
+from urllib.parse import urljoin, urlparse
+
+USER_AGENT = "pickipedia-liner/0.1 (https://pickipedia.xyz; Artificial Dumb for the connectome)"
+INTERVAL = 1.5
+_last = [0.0]
+
+LD_JSON = re.compile(r'<script type="application/ld\+json">\s*(.*?)\s*</script>', re.S)
+LINKS = re.compile(r'href="(/(?:album|track)/[A-Za-z0-9_-]+)"')
+
+
+class NotAnAlbum(Exception):
+ pass
+
+
+def fetch(url):
+ """(final URL after redirects, page). Fetched with curl, not urllib.
+
+ Bandcamp answers Python's own HTTPS client with a JavaScript challenge
+ page, whatever its headers say, and answers curl with the album (tested
+ 7 Nov 2026, same User-Agent both ways). So curl does the fetching: same
+ honest User-Agent, no pretending to be a browser. Arguments are a list;
+ no shell is involved.
+ """
+ wait = INTERVAL - (time.monotonic() - _last[0])
+ if wait > 0:
+ time.sleep(wait)
+ try:
+ out = subprocess.run(
+ ["curl", "-sSL", "--max-time", "30", "-A", USER_AGENT,
+ "-w", "\n%{url_effective}", "--", url],
+ capture_output=True, text=True, check=True).stdout
+ finally:
+ _last[0] = time.monotonic()
+ page, _, final = out.rpartition("\n")
+ return final, page
+
+
+def is_bandcamp(url):
+ host = urlparse(url).hostname or ""
+ return host == "bandcamp.com" or host.endswith(".bandcamp.com")
+
+
+def ld_json(page):
+ """The page's schema.org object, or None. A challenge page has none."""
+ m = LD_JSON.search(page)
+ return json.loads(m.group(1)) if m else None
+
+
+def text(value):
+ """Bandcamp's prose as written: entities decoded, Windows line ends made
+ plain, nothing else touched."""
+ if not value:
+ return ""
+ return html.unescape(value).replace("\r\n", "\n").strip()
+
+
+def date(value):
+ """'18 Jul 2013 00:00:00 GMT' -> '18 Jul 2013'. The time is always midnight."""
+ return re.sub(r"\s+00:00:00 GMT$", "", value or "")
+
+
+def read_page(url, page):
+ """One album or track page -> its credits and the facts around them."""
+ d = ld_json(page)
+ if not d or d.get("@type") not in ("MusicAlbum", "MusicRecording"):
+ raise NotAnAlbum(f"{url}: no album or track data on the page "
+ "(an artist page, a challenge page, or not Bandcamp)")
+ tracks = []
+ for entry in (d.get("track") or {}).get("itemListElement", []):
+ item = entry.get("item", {})
+ tracks.append({"position": entry.get("position"), "name": text(item.get("name")),
+ "url": item.get("@id") or item.get("mainEntityOfPage", "")})
+ return {
+ "url": url,
+ "kind": "album" if d["@type"] == "MusicAlbum" else "track",
+ "title": text(d.get("name")),
+ "artist": text((d.get("byArtist") or {}).get("name")),
+ "label": text((d.get("publisher") or {}).get("name")),
+ "album": text((d.get("inAlbum") or {}).get("name")),
+ "released": date(d.get("datePublished")),
+ "credits": text(d.get("creditText")),
+ "about": text(d.get("description")),
+ "tracks": tracks,
+ }
+
+
+def discography(url, page):
+ """Album and track URLs on an artist's page, in page order, once each."""
+ seen, found = set(), []
+ for path in LINKS.findall(page):
+ full = urljoin(url, path)
+ if full not in seen:
+ seen.add(full)
+ found.append(full)
+ return found
+
+
+def read(url, tracks=False, get=fetch):
+ """Read url. Returns ("album", record) or ("artist", [urls]).
+
+ An artist's URL (no /album/ or /track/ in it) is read as the artist's
+ /music page. Their front page often redirects to one featured album,
+ which would hide the rest.
+ """
+ path = urlparse(url).path
+ if "/album/" not in path and "/track/" not in path:
+ final, page = get(urljoin(url, "/music"))
+ found = discography(final, page)
+ if not found and ("/album/" in final or "/track/" in final):
+ found = [final] # one release: /music redirects straight to it
+ return "artist", found
+ final, page = get(url)
+ record = read_page(final, page)
+ if tracks:
+ for t in record["tracks"]:
+ if t["url"]:
+ turl, tpage = get(t["url"])
+ tr = read_page(turl, tpage)
+ t["credits"], t["about"] = tr["credits"], tr["about"]
+ return "album", record
+
+
+def render(record):
+ r = record
+ lines = [f'{r["artist"]}: {r["title"]} ({r["kind"]}{", on " + r["album"] if r["album"] else ""})',
+ f' Source: {r["url"]}']
+ if r["released"]:
+ lines.append(f' Released: {r["released"]}')
+ if r["label"] and r["label"] != r["artist"]:
+ lines.append(f' Label: {r["label"]}')
+ lines += ["", " Credits, as Bandcamp has them:"]
+ lines += [f" {ln}" for ln in r["credits"].splitlines()] if r["credits"] else [" (none)"]
+ if r["about"]:
+ lines += ["", " About, as Bandcamp has it:"]
+ lines += [f" {ln}" for ln in r["about"].splitlines()]
+ with_credits = [t for t in r["tracks"] if t.get("credits")]
+ if with_credits:
+ lines += ["", " Track credits:"]
+ for t in with_credits:
+ lines.append(f' {t["position"]}. {t["name"]} {t["url"]}')
+ lines += [f" {ln}" for ln in t["credits"].splitlines()]
+ elif r["tracks"] and any("credits" in t for t in r["tracks"]):
+ lines += ["", " No track carries credits of its own."]
+ lines += ["", " Nothing here is parsed. Who played what is for a person to read off the text above."]
+ return "\n".join(lines)
+
+
+def main(argv):
+ ap = argparse.ArgumentParser(description="Liner: a Bandcamp page's credits, word for word.")
+ ap.add_argument("url", help="a Bandcamp album, track or artist URL")
+ ap.add_argument("--tracks", action="store_true", help="read each track's page for its own credits")
+ ap.add_argument("--json", action="store_true")
+ args = ap.parse_args(argv[1:])
+ if not is_bandcamp(args.url):
+ ap.error("not a bandcamp.com URL")
+ try:
+ kind, result = read(args.url, tracks=args.tracks)
+ except NotAnAlbum as e:
+ print(e, file=sys.stderr)
+ return 1
+ if args.json:
+ print(json.dumps(result, indent=2, ensure_ascii=False))
+ elif kind == "artist":
+ print("\n".join(result) if result else "No albums or tracks found on that page.")
+ else:
+ print(render(result))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/test_bandcamp.py b/tools/test_bandcamp.py
new file mode 100644
index 0000000..5e1192a
--- /dev/null
+++ b/tools/test_bandcamp.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""
+Tests for the Bandcamp reader. What these guard: credits come out exactly
+as written, never split into people; a challenge page is an error, not an
+empty album; an artist's URL lists their releases. Pages are canned; no
+test touches the network.
+"""
+
+import json
+
+import pytest
+
+import bandcamp as bc
+
+
+def page(obj):
+ return ('<html><head><script type="application/ld+json">\n'
+ + json.dumps(obj) + "\n</script></head><body></body></html>")
+
+
+ALBUM = {
+ "@type": "MusicAlbum",
+ "name": "Workin's Too Hard",
+ "byArtist": {"name": "Rayna Gellert"},
+ "publisher": {"name": "Rayna Gellert"},
+ "datePublished": "20 Jan 2017 00:00:00 GMT",
+ "creditText": "Musicians: Rayna Gellert, Kieran Kane, Kai Welch, Jamie Dick\r\nJoe - keys & vocals",
+ "description": "Cut live in a room.",
+ "track": {"itemListElement": [
+ {"position": 1, "item": {"name": "Starch", "@id": "https://x.bandcamp.com/track/starch"}},
+ {"position": 2, "item": {"name": "Iron", "@id": "https://x.bandcamp.com/track/iron"}},
+ ]},
+}
+TRACK = {"@type": "MusicRecording", "name": "Starch", "byArtist": {"name": "Rayna Gellert"},
+ "inAlbum": {"name": "Workin's Too Hard"}, "creditText": "Fiddle: Rayna Gellert"}
+CHALLENGE = "<html><head><title>Client Challenge</title></head><body>enable JavaScript</body></html>"
+
+
+def fake_get(pages):
+ calls = []
+
+ def get(url):
+ calls.append(url)
+ final, body = pages[url]
+ return final, body
+ get.calls = calls
+ return get
+
+
+def test_credits_are_copied_not_parsed():
+ rec = bc.read_page("https://x.bandcamp.com/album/w", page(ALBUM))
+ assert rec["credits"] == "Musicians: Rayna Gellert, Kieran Kane, Kai Welch, Jamie Dick\nJoe - keys & vocals"
+ assert rec["title"] == "Workin's Too Hard"
+ assert rec["released"] == "20 Jan 2017"
+ assert rec["kind"] == "album"
+ assert [t["name"] for t in rec["tracks"]] == ["Starch", "Iron"]
+ # No field anywhere claims a person or an instrument.
+ assert not {"people", "personnel", "instruments"} & set(rec)
+
+
+def test_challenge_page_is_an_error_not_an_empty_album():
+ with pytest.raises(bc.NotAnAlbum):
+ bc.read_page("https://x.bandcamp.com/album/w", CHALLENGE)
+
+
+def test_tracks_flag_reads_each_track_page():
+ get = fake_get({
+ "https://x.bandcamp.com/album/w": ("https://x.bandcamp.com/album/w", page(ALBUM)),
+ "https://x.bandcamp.com/track/starch": ("https://x.bandcamp.com/track/starch", page(TRACK)),
+ "https://x.bandcamp.com/track/iron": ("https://x.bandcamp.com/track/iron",
+ page({**TRACK, "name": "Iron", "creditText": None})),
+ })
+ kind, rec = bc.read("https://x.bandcamp.com/album/w", tracks=True, get=get)
+ assert kind == "album"
+ assert rec["tracks"][0]["credits"] == "Fiddle: Rayna Gellert"
+ assert rec["tracks"][1]["credits"] == ""
+ text = bc.render(rec)
+ assert " 1. Starch https://x.bandcamp.com/track/starch\n Fiddle: Rayna Gellert" in text
+ assert "2. Iron" not in text
+
+
+def test_without_tracks_flag_only_the_album_is_fetched():
+ get = fake_get({"https://x.bandcamp.com/album/w": ("https://x.bandcamp.com/album/w", page(ALBUM))})
+ bc.read("https://x.bandcamp.com/album/w", get=get)
+ assert get.calls == ["https://x.bandcamp.com/album/w"]
+
+
+def test_artist_url_lists_the_discography_from_music():
+ music = '<a href="/album/one">1</a><a href="/track/single">s</a><a href="/album/one">again</a>'
+ get = fake_get({"https://x.bandcamp.com/music": ("https://x.bandcamp.com/music", music)})
+ kind, found = bc.read("https://x.bandcamp.com", get=get)
+ assert kind == "artist"
+ assert found == ["https://x.bandcamp.com/album/one", "https://x.bandcamp.com/track/single"]
+
+
+def test_artist_with_one_release_redirects_to_it():
+ get = fake_get({"https://x.bandcamp.com/music": ("https://x.bandcamp.com/album/only", page(ALBUM))})
+ assert bc.read("https://x.bandcamp.com/", get=get) == ("artist", ["https://x.bandcamp.com/album/only"])
+
+
+def test_render_names_the_source_and_says_nothing_is_parsed():
+ rec = bc.read_page("https://x.bandcamp.com/album/w", page({**ALBUM, "creditText": None}))
+ text = bc.render(rec)
+ assert " Source: https://x.bandcamp.com/album/w" in text
+ assert " Credits, as Bandcamp has them:\n (none)" in text
+ assert "Nothing here is parsed" in text
+ assert "Label:" not in text # self-released: label is the artist
+
+
+@pytest.mark.parametrize("url,ok", [
+ ("https://milkweed.bandcamp.com/album/milkweed-ep", True),
+ ("https://bandcamp.com/search?q=x", True),
+ ("https://evilbandcamp.com/album/x", False),
+ ("https://bandcamp.com.example.org/album/x", False),
+])
+def test_is_bandcamp(url, ok):
+ assert bc.is_bandcamp(url) is ok
+
+
+def test_fetch_uses_curl_with_an_argument_list(monkeypatch):
+ seen = {}
+
+ def run(cmd, **kw):
+ seen["cmd"] = cmd
+ return bc.subprocess.CompletedProcess(cmd, 0, stdout="<html>ok</html>\nhttps://x.bandcamp.com/album/w")
+ monkeypatch.setattr(bc.subprocess, "run", run)
+ monkeypatch.setattr(bc, "INTERVAL", 0)
+ final, body = bc.fetch("https://x.bandcamp.com/album/w")
+ assert final == "https://x.bandcamp.com/album/w" and body == "<html>ok</html>"
+ assert seen["cmd"][0] == "curl" and seen["cmd"][-2:] == ["--", "https://x.bandcamp.com/album/w"]
+ assert bc.USER_AGENT in seen["cmd"]
--
2.53.0