<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://interfaces3.hunter.cryptograss.live/index.php?action=history&amp;feed=atom&amp;title=Emergent%3ABlu%2FPatches%2Fbandcamp</id>
	<title>Emergent:Blu/Patches/bandcamp - Revision history</title>
	<link rel="self" type="application/atom+xml" href="https://interfaces3.hunter.cryptograss.live/index.php?action=history&amp;feed=atom&amp;title=Emergent%3ABlu%2FPatches%2Fbandcamp"/>
	<link rel="alternate" type="text/html" href="https://interfaces3.hunter.cryptograss.live/index.php?title=Emergent:Blu/Patches/bandcamp&amp;action=history"/>
	<updated>2026-11-15T09:26:33Z</updated>
	<subtitle>Revision history for this page on the wiki</subtitle>
	<generator>MediaWiki 1.43.6</generator>
	<entry>
		<id>https://interfaces3.hunter.cryptograss.live/index.php?title=Emergent:Blu/Patches/bandcamp&amp;diff=14989&amp;oldid=prev</id>
		<title>Blu: Bandcamp reader: credits copied word for word; one commit, 12 tests, round-trip checked (via create-page on MediaWiki MCP Server)</title>
		<link rel="alternate" type="text/html" href="https://interfaces3.hunter.cryptograss.live/index.php?title=Emergent:Blu/Patches/bandcamp&amp;diff=14989&amp;oldid=prev"/>
		<updated>2026-11-07T19:30:57Z</updated>

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